fix: codemirror util

This commit is contained in:
2026-03-12 20:23:39 +01:00
parent e9ed508bfc
commit 35b2e9d538
4 changed files with 94 additions and 85 deletions

View File

@@ -6,9 +6,10 @@ import { markdown } from '@codemirror/lang-markdown'
import {
AbstractNodeProps,
createAbstractNodeComponent,
getConnectedNodesByType,
useAbstractNode,
type FlowNode,
} from '@/lib/graph/abstractNode'
import { useCodeMirrorInsert, type CodeMirrorEditorRef } from '@/hooks/useCodeMirrorInsert'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete'
import { plantumlLanguage } from '@/lib/plantumlLanguage'
@@ -60,30 +61,30 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
const content = getConfigContent(data ?? {})
const { theme } = useTheme()
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
const editorRef = useRef<unknown>(null)
const connectedConfigNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'config'),
[nodes, sourceIds]
)
const connectedVariableNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
[nodes, sourceIds]
)
const connectedFunctionNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
[nodes, sourceIds]
)
const connectedDataNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'data'),
[nodes, sourceIds]
)
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 || connectedDataNodes.length > 0
const editorRef = useRef<CodeMirrorEditorRef | null>(null)
const onChange = useCallback(
(val: string) => updateData({ content: val, configType: configTypeId }),
[updateData, configTypeId]
)
const insertAt = useCodeMirrorInsert(editorRef, content, onChange)
const connectedConfigNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'config'),
[nodes, sourceIds]
)
const connectedVariableNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
[nodes, sourceIds]
)
const connectedFunctionNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'function'),
[nodes, sourceIds]
)
const connectedDataNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'data'),
[nodes, sourceIds]
)
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 || connectedDataNodes.length > 0
const setConfigType = useCallback(
(newTypeId: ConfigTypeId) => {
@@ -96,36 +97,6 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
[configTypeId, data, updateData]
)
const insertAt = useCallback(
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
const ref = editorRef.current as { view: { state: { doc: { length: number; toString(): string }; selection: { main: { from: number } } }; dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void } } | null
if (ref?.view) {
const view = ref.view
const doc = view.state.doc
const len = doc.length
let from: number
if (mode === 'prepend') {
from = 0
} else if (mode === 'append') {
from = len
} else {
const main = view.state.selection.main
from = main.from
}
view.dispatch({ changes: { from, to: from, insert: insertText } })
const newVal = view.state.doc.toString()
onChange(newVal)
return
}
if (mode === 'prepend') {
onChange(insertText + content)
} else {
onChange(content + insertText)
}
},
[onChange, content]
)
const insertExtendsFromNode = useCallback(
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
insertAt(`{% extends "${sourceNode.id}" %}\n`, mode)
@@ -357,7 +328,6 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
<CodeMirror
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
ref={editorRef}
value={content}
height={`${editorHeight}px`}

View File

@@ -4,9 +4,10 @@ import { javascript } from '@codemirror/lang-javascript'
import {
AbstractNodeProps,
createAbstractNodeComponent,
getConnectedNodesByType,
useAbstractNode,
type FlowNode,
} from '@/lib/graph/abstractNode'
import { useCodeMirrorInsert, type CodeMirrorEditorRef } from '@/hooks/useCodeMirrorInsert'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import { useTheme } from '@/lib/themeContext'
import {
@@ -36,43 +37,22 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
const bodyValue = data?.body ?? ''
const { theme } = useTheme()
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
const editorRef = useRef<unknown>(null)
const connectedVariableNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
[nodes, sourceIds]
)
const connectedFunctionNodes = useMemo(
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
[nodes, sourceIds]
)
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
const editorRef = useRef<CodeMirrorEditorRef | null>(null)
const onChange = useCallback(
(val: string) => updateData({ body: val }),
[updateData]
)
const insertAt = useCodeMirrorInsert(editorRef, bodyValue, onChange)
const insertAt = useCallback(
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
const ref = editorRef.current as { view: { state: { doc: { length: number }; selection: { main: { from: number } } }; dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void } } | null
if (ref?.view) {
const view = ref.view
const doc = view.state.doc
const len = doc.length
let from: number
if (mode === 'prepend') from = 0
else if (mode === 'append') from = len
else from = view.state.selection.main.from
view.dispatch({ changes: { from, to: from, insert: insertText } })
onChange(view.state.doc.toString())
return
}
if (mode === 'prepend') onChange(insertText + bodyValue)
else onChange(bodyValue + insertText)
},
[onChange, bodyValue]
const connectedVariableNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
[nodes, sourceIds]
)
const connectedFunctionNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'function'),
[nodes, sourceIds]
)
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
const insertVariableAtCursor = useCallback(
(variableNode: any) => {
@@ -142,7 +122,6 @@ 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
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
ref={editorRef}
value={bodyValue}
height={`${editorHeight}px`}

View File

@@ -0,0 +1,51 @@
import { useCallback } from 'react'
/**
* Ref shape for @uiw/react-codemirror (view/state not in package types).
* Use with: ref={editorRef} and type editorRef as React.MutableRefObject<CodeMirrorEditorRef | null>.
*/
export type CodeMirrorEditorRef = {
view: {
state: {
doc: { length: number; toString(): string }
selection: { main: { from: number } }
}
dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void
}
}
export type InsertPosition = 'prepend' | 'append' | 'cursor'
/**
* Returns a stable insertAt(insertText, mode) that inserts text into the CodeMirror
* editor at the given position (prepend, append, or cursor), then calls onChange
* with the new content. Falls back to string concatenation when the editor ref
* is not yet mounted.
*/
export function useCodeMirrorInsert(
editorRef: React.RefObject<CodeMirrorEditorRef | null>,
currentContent: string,
onChange: (value: string) => void
): (insertText: string, mode: InsertPosition) => void {
return useCallback(
(insertText: string, mode: InsertPosition) => {
const ref = editorRef.current
if (ref?.view) {
const view = ref.view
const doc = view.state.doc
const len = doc.length
const from =
mode === 'prepend' ? 0 : mode === 'append' ? len : view.state.selection.main.from
view.dispatch({ changes: { from, to: from, insert: insertText } })
onChange(view.state.doc.toString())
return
}
if (mode === 'prepend') {
onChange(insertText + currentContent)
} else {
onChange(currentContent + insertText)
}
},
[currentContent, onChange]
)
}

View File

@@ -59,6 +59,15 @@ export type AbstractNodeContext<TData = Record<string, unknown>> = {
targetIds: string[]
}
/** Returns nodes that are connected to this node (in sourceIds) and have the given type. */
export function getConnectedNodesByType<T extends FlowNode = FlowNode>(
nodes: FlowNode[],
sourceIds: string[],
type: string
): T[] {
return nodes.filter((n) => sourceIds.includes(n.id) && n.type === type) as T[]
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------