feat: replace codemirror and image viewr, opt for simplier approach
This commit is contained in:
102
frontend/src/components/editor/CodeEditor.tsx
Normal file
102
frontend/src/components/editor/CodeEditor.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Shared code editor with Nunjucks support by default.
|
||||
* Uses react-simple-code-editor + Prism + prism-react-renderer; highlights base language + {{ }}, {% %}, {# #}.
|
||||
* Wherever this editor is used, it provides the same features (syntax highlighting + Nunjucks).
|
||||
* The parent (node) provides the expected base language for highlighting.
|
||||
*/
|
||||
import React, { useCallback, useRef } from 'react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { highlight, type HighlightLanguage } from '@/lib/syntaxHighlight'
|
||||
|
||||
export type CodeEditorProps = {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
/** Base language for syntax highlighting (Nunjucks is always applied on top). */
|
||||
language: HighlightLanguage
|
||||
/** Stable id for the underlying textarea (for insert-at-cursor). */
|
||||
textareaId?: string
|
||||
readOnly?: boolean
|
||||
placeholder?: string
|
||||
padding?: number
|
||||
tabSize?: number
|
||||
insertSpaces?: boolean
|
||||
ignoreTabKey?: boolean
|
||||
style?: React.CSSProperties
|
||||
className?: string
|
||||
textareaClassName?: string
|
||||
preClassName?: string
|
||||
/** Minimum height so the container doesn't collapse before resize. */
|
||||
minHeight?: number
|
||||
}
|
||||
|
||||
const defaultStyle: React.CSSProperties = {
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
overflow: 'auto',
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
onValueChange,
|
||||
language,
|
||||
textareaId,
|
||||
readOnly = false,
|
||||
padding = 8,
|
||||
tabSize = 2,
|
||||
insertSpaces = true,
|
||||
ignoreTabKey = false,
|
||||
style,
|
||||
className,
|
||||
textareaClassName,
|
||||
preClassName,
|
||||
minHeight = 120,
|
||||
}: CodeEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const highlightCode = useCallback(
|
||||
(code: string) => highlight(code, language),
|
||||
[language]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (readOnly) {
|
||||
onValueChange(newValue)
|
||||
return
|
||||
}
|
||||
const ta =
|
||||
(textareaId ? document.getElementById(textareaId) : containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
|
||||
const start = ta?.selectionStart ?? newValue.length
|
||||
const end = ta?.selectionEnd ?? newValue.length
|
||||
onValueChange(newValue)
|
||||
requestAnimationFrame(() => {
|
||||
const el = (textareaId
|
||||
? document.getElementById(textareaId)
|
||||
: containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
|
||||
if (el) el.setSelectionRange(start, end)
|
||||
})
|
||||
},
|
||||
[onValueChange, readOnly, textareaId]
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="code-editor" style={{ minHeight: 0 }}>
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
highlight={highlightCode}
|
||||
tabSize={tabSize}
|
||||
insertSpaces={insertSpaces}
|
||||
ignoreTabKey={ignoreTabKey}
|
||||
padding={padding}
|
||||
readOnly={readOnly}
|
||||
textareaId={textareaId}
|
||||
style={{ ...defaultStyle, minHeight, ...style }}
|
||||
className={className}
|
||||
textareaClassName={textareaClassName}
|
||||
preClassName={preClassName}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -63,9 +63,7 @@ function BorderLoadingIndicator({
|
||||
container.firstElementChild instanceof HTMLElement
|
||||
? container.firstElementChild
|
||||
: container
|
||||
const cw = (target as HTMLElement).offsetWidth
|
||||
const ch = (target as HTMLElement).offsetHeight
|
||||
if (cw > 0 && ch > 0) setMeasured({ w: cw, h: ch })
|
||||
// Use ResizeObserver only to avoid forced synchronous layout (offsetWidth/offsetHeight).
|
||||
const unObserve = observeResize(target, ({ width: w, height: h }) => {
|
||||
if (w > 0 && h > 0) setMeasured({ w, h })
|
||||
})
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import React, { useCallback, useContext, useMemo, useRef } from 'react'
|
||||
import { autocompletion } from '@codemirror/autocomplete'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import React, { useCallback, useContext, useId, useMemo } from 'react'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
getConnectedNodesByType,
|
||||
useAbstractNode,
|
||||
} from '@/lib/graph/abstractNode'
|
||||
import { useCodeMirrorInsert, type CodeMirrorEditorRef } from '@/hooks/useCodeMirrorInsert'
|
||||
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
|
||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||
import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete'
|
||||
import { plantumlLanguage } from '@/lib/plantumlLanguage'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
|
||||
import {
|
||||
getConfigTypes,
|
||||
getConfigContent,
|
||||
@@ -59,14 +54,13 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const configTypeId = getConfigTypeId(data ?? {})
|
||||
const configType = getConfigType(configTypeId)
|
||||
const content = getConfigContent(data ?? {})
|
||||
const { theme } = useTheme()
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<CodeMirrorEditorRef | null>(null)
|
||||
const editorId = useId()
|
||||
const onChange = useCallback(
|
||||
(val: string) => updateData({ content: val, configType: configTypeId }),
|
||||
[updateData, configTypeId]
|
||||
)
|
||||
const insertAt = useCodeMirrorInsert(editorRef, content, onChange)
|
||||
const insertAt = useSimpleEditorInsert(editorId, content, onChange)
|
||||
|
||||
const connectedConfigNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'config'),
|
||||
@@ -146,21 +140,8 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
[connectedConfigNodes],
|
||||
)
|
||||
const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes])
|
||||
const extensions = useMemo(() => {
|
||||
const lang =
|
||||
configTypeId === 'wireframe'
|
||||
? javascript()
|
||||
: configType.language === 'plantuml'
|
||||
? plantumlLanguage.extension
|
||||
: markdown()
|
||||
return [
|
||||
lang,
|
||||
autocompletion({
|
||||
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds, dataIds)],
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
]
|
||||
}, [configTypeId, configType.language, variableIds, functionIds, configTitles, dataIds])
|
||||
const highlightLang: HighlightLanguage =
|
||||
configTypeId === 'wireframe' ? 'javascript' : configType.language === 'plantuml' ? 'plantuml' : 'markdown'
|
||||
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
||||
|
||||
const insertBlocksContent = useMemo(() => {
|
||||
@@ -326,16 +307,17 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input" style={{ minHeight: editorHeight }}>
|
||||
<CodeEditor
|
||||
textareaId={editorId}
|
||||
value={content}
|
||||
height={`${editorHeight}px`}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
className="text-sm [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
||||
onValueChange={onChange}
|
||||
language={highlightLang}
|
||||
minHeight={editorHeight}
|
||||
style={{ fontSize: 14 }}
|
||||
textareaClassName="text-sm outline-none border-0 resize-none nodrag nopan"
|
||||
preClassName="text-sm nodrag nopan"
|
||||
className="min-h-0 w-full"
|
||||
/>
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import React, { useCallback, useContext, useMemo, useRef } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import React, { useCallback, useContext, useId, useMemo } from 'react'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
getConnectedNodesByType,
|
||||
useAbstractNode,
|
||||
} from '@/lib/graph/abstractNode'
|
||||
import { useCodeMirrorInsert, type CodeMirrorEditorRef } from '@/hooks/useCodeMirrorInsert'
|
||||
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
|
||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
@@ -30,19 +28,20 @@ export type FunctionNodeData = { body?: string }
|
||||
|
||||
type Props = AbstractNodeProps<FunctionNodeData>
|
||||
|
||||
const LANGUAGE: HighlightLanguage = 'javascript'
|
||||
|
||||
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowUIContext = useContext(FlowUIContext)
|
||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
|
||||
const bodyValue = data?.body ?? ''
|
||||
const { theme } = useTheme()
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<CodeMirrorEditorRef | null>(null)
|
||||
const editorId = useId()
|
||||
const onChange = useCallback(
|
||||
(val: string) => updateData({ body: val }),
|
||||
[updateData]
|
||||
)
|
||||
const insertAt = useCodeMirrorInsert(editorRef, bodyValue, onChange)
|
||||
const insertAt = useSimpleEditorInsert(editorId, bodyValue, onChange)
|
||||
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
|
||||
@@ -68,7 +67,6 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const extensions = useMemo(() => [javascript()], [])
|
||||
const [editorHeight, editorContainerRef] = useResizeHeight(120)
|
||||
const dimensions =
|
||||
width != null && height != null && width > 0 && height > 0
|
||||
@@ -120,16 +118,17 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input" style={{ minHeight: editorHeight }}>
|
||||
<CodeEditor
|
||||
textareaId={editorId}
|
||||
value={bodyValue}
|
||||
height={`${editorHeight}px`}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
||||
onValueChange={onChange}
|
||||
language="javascript"
|
||||
minHeight={editorHeight}
|
||||
style={{ fontSize: 12 }}
|
||||
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
|
||||
preClassName="text-xs nodrag nopan"
|
||||
className="min-h-0 w-full"
|
||||
/>
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React from 'react'
|
||||
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'
|
||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
|
||||
export type ImageOutputViewProps = {
|
||||
@@ -13,8 +11,6 @@ export type ImageOutputViewProps = {
|
||||
|
||||
export function ImageOutputView({
|
||||
state,
|
||||
selected,
|
||||
viewportFocused,
|
||||
onViewportFocus,
|
||||
onViewportBlur,
|
||||
}: ImageOutputViewProps) {
|
||||
@@ -25,59 +21,10 @@ export function ImageOutputView({
|
||||
onFocus={onViewportFocus}
|
||||
onBlur={onViewportBlur}
|
||||
>
|
||||
<TransformWrapper
|
||||
initialScale={1}
|
||||
initialPositionX={0}
|
||||
initialPositionY={0}
|
||||
minScale={0.2}
|
||||
maxScale={4}
|
||||
centerOnInit={false}
|
||||
panning={{ disabled: !selected && !viewportFocused }}
|
||||
wheel={{ disabled: !selected && !viewportFocused }}
|
||||
doubleClick={{ disabled: !selected && !viewportFocused }}
|
||||
>
|
||||
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||
<>
|
||||
<div className="react-flow__controls absolute bottom-2 left-2 z-10 nodrag nopan">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomIn()}
|
||||
className="react-flow__controls-button"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomOut()}
|
||||
className="react-flow__controls-button"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetTransform()}
|
||||
className="react-flow__controls-button"
|
||||
title="Reset view (fit all)"
|
||||
>
|
||||
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute inset-0 nodrag nopan overflow-hidden [&_.react-transform-component]:!w-full [&_.react-transform-component]:!h-full [&_.react-transform-wrapper]:!w-full [&_.react-transform-wrapper]:!h-full">
|
||||
<TransformComponent
|
||||
wrapperClass="!w-full !h-full"
|
||||
contentClass="nodrag nopan !w-full !h-full !block !min-h-0"
|
||||
>
|
||||
<div
|
||||
className="rendering-diagram absolute inset-0 w-full h-full min-w-0 min-h-0 nodrag nopan"
|
||||
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
<div
|
||||
className="absolute inset-0 nodrag nopan overflow-auto"
|
||||
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { plantumlLanguage } from '@/lib/plantumlLanguage'
|
||||
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -15,21 +13,19 @@ export type RawOutputViewProps = {
|
||||
theme: 'light' | 'dark'
|
||||
}
|
||||
|
||||
export function RawOutputView({ state, height, containerRef, theme }: RawOutputViewProps) {
|
||||
const extensions = useMemo(() => {
|
||||
const lang =
|
||||
state.rawLanguage === 'wireframe'
|
||||
? javascript()
|
||||
: state.rawLanguage === 'plantuml'
|
||||
? plantumlLanguage.extension
|
||||
: markdown()
|
||||
return [lang]
|
||||
}, [state.rawLanguage])
|
||||
function languageForRaw(rawLanguage: string): HighlightLanguage {
|
||||
if (rawLanguage === 'wireframe') return 'javascript'
|
||||
if (rawLanguage === 'plantuml') return 'plantuml'
|
||||
return 'markdown'
|
||||
}
|
||||
|
||||
export function RawOutputView({ state, height, containerRef }: RawOutputViewProps) {
|
||||
const language = useMemo(() => languageForRaw(state.rawLanguage), [state.rawLanguage])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input"
|
||||
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -50,15 +46,16 @@ export function RawOutputView({ state, height, containerRef, theme }: RawOutputV
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<CodeMirror
|
||||
<CodeEditor
|
||||
value={state.rawDisplayContent}
|
||||
height={`${height}px`}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
onValueChange={() => {}}
|
||||
language={language}
|
||||
readOnly
|
||||
editable={false}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0 [&_.cm-scroller]:min-h-0"
|
||||
minHeight={height}
|
||||
style={{ fontSize: 12 }}
|
||||
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
|
||||
preClassName="text-xs nodrag nopan min-h-0"
|
||||
className="min-h-0 w-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user