|
| 1 | +/** |
| 2 | + * 表达式输入框:CodeMirror 6 + JavaScript,带接口参数表达式作用域的自动补全。 |
| 3 | + * 补全项 = 内置访问器/时间/工具函数(BUILTIN_EXPR_COMPLETIONS) |
| 4 | + * + 全局参数(params.<key> / P('key')) |
| 5 | + * + 自定义函数(按名) |
| 6 | + * + 组件名(el('name')) |
| 7 | + */ |
| 8 | +import { useEffect, useRef } from 'react' |
| 9 | +import { EditorState } from '@codemirror/state' |
| 10 | +import { EditorView, keymap } from '@codemirror/view' |
| 11 | +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' |
| 12 | +import { javascript } from '@codemirror/lang-javascript' |
| 13 | +import { autocompletion, completionKeymap, closeBrackets, type CompletionContext } from '@codemirror/autocomplete' |
| 14 | +import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@codemirror/language' |
| 15 | +import { BUILTIN_EXPR_COMPLETIONS } from '@/runtime/expression' |
| 16 | + |
| 17 | +export interface ExprScopeInfo { |
| 18 | + /** 全局参数名 */ |
| 19 | + paramKeys?: string[] |
| 20 | + /** 自定义函数名 */ |
| 21 | + functionNames?: string[] |
| 22 | + /** 组件名(用于 el('name') 提示) */ |
| 23 | + elementNames?: string[] |
| 24 | +} |
| 25 | + |
| 26 | +interface Props { |
| 27 | + value: string |
| 28 | + onChange: (v: string) => void |
| 29 | + placeholder?: string |
| 30 | + scope?: ExprScopeInfo |
| 31 | + height?: number | string |
| 32 | + /** 单行模式:压缩高度、禁用换行 */ |
| 33 | + singleLine?: boolean |
| 34 | +} |
| 35 | + |
| 36 | +function buildCompletionSource(scope: ExprScopeInfo | undefined) { |
| 37 | + return (context: CompletionContext) => { |
| 38 | + const word = context.matchBefore(/[\w.'"()]*/) |
| 39 | + if (!word) return null |
| 40 | + if (word.from === word.to && !context.explicit) return null |
| 41 | + const options = [ |
| 42 | + ...BUILTIN_EXPR_COMPLETIONS.map((c) => ({ label: c.label, detail: c.detail, type: c.type })), |
| 43 | + ...(scope?.paramKeys ?? []).map((k) => ({ label: `params.${k}`, detail: '全局参数', type: 'variable' as const })), |
| 44 | + ...(scope?.functionNames ?? []).map((n) => ({ label: `${n}()`, detail: '自定义函数', type: 'function' as const })), |
| 45 | + ...(scope?.elementNames ?? []).map((n) => ({ label: `el('${n}')`, detail: '组件值', type: 'function' as const })), |
| 46 | + ] |
| 47 | + return { from: word.from, options, validFor: /^[\w.'"()]*$/ } |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +export default function ExpressionInput({ value, onChange, placeholder, scope, height = 60, singleLine = false }: Props) { |
| 52 | + const hostRef = useRef<HTMLDivElement>(null) |
| 53 | + const viewRef = useRef<EditorView | null>(null) |
| 54 | + const onChangeRef = useRef(onChange) |
| 55 | + onChangeRef.current = onChange |
| 56 | + const scopeRef = useRef(scope) |
| 57 | + scopeRef.current = scope |
| 58 | + |
| 59 | + const effectiveHeight = singleLine ? 32 : height |
| 60 | + |
| 61 | + useEffect(() => { |
| 62 | + if (!hostRef.current) return |
| 63 | + const state = EditorState.create({ |
| 64 | + doc: value, |
| 65 | + extensions: [ |
| 66 | + history(), |
| 67 | + bracketMatching(), |
| 68 | + closeBrackets(), |
| 69 | + javascript(), |
| 70 | + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), |
| 71 | + // 用函数包一层读取 scopeRef.current,保证补全始终用最新作用域 |
| 72 | + autocompletion({ override: [(ctx) => buildCompletionSource(scopeRef.current)(ctx)] }), |
| 73 | + keymap.of([...defaultKeymap, ...historyKeymap, ...completionKeymap]), |
| 74 | + singleLine ? [] : EditorView.lineWrapping, |
| 75 | + EditorView.theme({ |
| 76 | + '&': { fontSize: '12px', minHeight: typeof effectiveHeight === 'number' ? `${effectiveHeight}px` : effectiveHeight }, |
| 77 | + '.cm-scroller': { fontFamily: 'var(--font-mono, monospace)', overflow: singleLine ? 'hidden' : 'auto' }, |
| 78 | + '.cm-content': { caretColor: 'var(--accent)', minHeight: typeof effectiveHeight === 'number' ? `${effectiveHeight}px` : effectiveHeight }, |
| 79 | + '&.cm-focused': { outline: 'none' }, |
| 80 | + }), |
| 81 | + EditorView.updateListener.of((u) => { |
| 82 | + if (u.docChanged) onChangeRef.current(u.state.doc.toString()) |
| 83 | + }), |
| 84 | + ], |
| 85 | + }) |
| 86 | + const view = new EditorView({ state, parent: hostRef.current }) |
| 87 | + viewRef.current = view |
| 88 | + return () => { |
| 89 | + // Null the ref first so the [value] effect can't dispatch onto a |
| 90 | + // view that is being torn down (row deletion / remount races). |
| 91 | + viewRef.current = null |
| 92 | + try { view.destroy() } catch { /* view already detached */ } |
| 93 | + } |
| 94 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 95 | + }, [singleLine]) |
| 96 | + |
| 97 | + useEffect(() => { |
| 98 | + const view = viewRef.current |
| 99 | + if (!view) return |
| 100 | + try { |
| 101 | + const cur = view.state.doc.toString() |
| 102 | + if (cur !== value) { |
| 103 | + view.dispatch({ changes: { from: 0, to: cur.length, insert: value } }) |
| 104 | + } |
| 105 | + } catch { /* view mid-teardown; ignore stale sync */ } |
| 106 | + }, [value]) |
| 107 | + |
| 108 | + return ( |
| 109 | + <div |
| 110 | + style={{ |
| 111 | + border: '1px solid var(--border)', |
| 112 | + borderRadius: 'var(--radius-sm)', |
| 113 | + overflow: 'hidden', |
| 114 | + background: 'var(--bg-base)', |
| 115 | + position: 'relative', |
| 116 | + }} |
| 117 | + > |
| 118 | + {!value && placeholder && ( |
| 119 | + <div style={{ |
| 120 | + position: 'absolute', top: 4, left: 8, fontSize: 12, |
| 121 | + color: 'var(--text-muted)', pointerEvents: 'none', zIndex: 1, |
| 122 | + fontFamily: 'var(--font-mono, monospace)', whiteSpace: 'pre-wrap', |
| 123 | + }}> |
| 124 | + {placeholder} |
| 125 | + </div> |
| 126 | + )} |
| 127 | + <div ref={hostRef} /> |
| 128 | + </div> |
| 129 | + ) |
| 130 | +} |
0 commit comments