feat: 增强代码节点编辑器与放大阅读体验

- tinyflow-ui: 新增 CodeScriptEditor(CodeMirror 6)并支持语法高亮、上下文补全、自动括号与 _result 高亮

- tinyflow-ui: 代码节点接入引擎能力列表与节点说明提示,统一 JS/Python 编辑体验

- tinyflow-ui: 增加放大编辑模式,支持居中弹层、ESC 与点击外部关闭

- app/workflow: 对接 supportedCodeEngines 能力并透传 codeEngine provider
This commit is contained in:
2026-03-01 19:57:28 +08:00
parent beeb62c4fc
commit ac8de7dbb8
11 changed files with 1541 additions and 23 deletions

View File

@@ -0,0 +1,900 @@
<script lang="ts">
import {onMount} from 'svelte';
import {Compartment, EditorState, type Transaction} from '@codemirror/state';
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
pickedCompletion
} from '@codemirror/autocomplete';
import {defaultKeymap, history, historyKeymap, indentWithTab} from '@codemirror/commands';
import {javascript} from '@codemirror/lang-javascript';
import {defaultHighlightStyle, indentOnInput, indentUnit, syntaxHighlighting} from '@codemirror/language';
import {python} from '@codemirror/lang-python';
import {
Decoration,
type DecorationSet,
EditorView,
keymap,
placeholder as cmPlaceholder,
ViewPlugin,
type ViewUpdate
} from '@codemirror/view';
import {Button, FloatingTrigger} from '../base';
import type {Parameter} from '#types';
import {flattenParameterCandidates} from '../utils/paramToken';
import {
createBusinessCompletionSource,
type CodeEngine,
normalizeCodeEngine,
shouldAutoAppendCallParens
} from '../utils/codeCompletion';
const {
value = '',
mode = 'textarea',
engine = 'js',
parameters = [],
rows = 10,
placeholder = '',
disabled = false,
class: className = '',
style = '',
...rest
}: {
value?: string;
mode?: 'input' | 'textarea';
engine?: string;
parameters?: Parameter[];
rows?: number;
placeholder?: string;
disabled?: boolean;
class?: string;
style?: string;
[key: string]: any;
} = $props();
const TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g;
const RESULT_PATTERN = /\b_result\b/g;
const languageCompartment = new Compartment();
const indentCompartment = new Compartment();
const completionCompartment = new Compartment();
const completionSourceCompartment = new Compartment();
const decorationCompartment = new Compartment();
const editableCompartment = new Compartment();
const placeholderCompartment = new Compartment();
let containerEl = $state<HTMLDivElement | null>(null);
let editorShellEl = $state<HTMLDivElement | null>(null);
let editorView = $state<EditorView | null>(null);
let triggerObject: any;
let applyingExternalValue = $state(false);
let expanded = $state(false);
let shellOriginalParent = $state<HTMLElement | null>(null);
let shellOriginalNextSibling = $state<ChildNode | null>(null);
const isSingleLine = $derived(mode === 'input');
const normalizedEngine = $derived(normalizeCodeEngine(engine));
const minRows = $derived(isSingleLine ? 1 : Math.max(3, rows || 3));
const minHeight = $derived(`${minRows * 24}px`);
const editorMinHeight = $derived(expanded ? '100%' : minHeight);
const editorStyle = $derived(`${style};--code-editor-min-height:${editorMinHeight};`);
const paramCandidates = $derived(flattenParameterCandidates(parameters));
const hasParams = $derived(paramCandidates.length > 0);
const paramResolvedMap = $derived.by(() => {
const map = new Map<string, boolean>();
for (const candidate of paramCandidates) {
map.set(candidate.name, candidate.resolved);
}
return map;
});
function emitInput(nextValue: string) {
rest.oninput?.({
target: {
value: nextValue
}
});
}
function emitChange(nextValue: string) {
rest.onchange?.({
target: {
value: nextValue
}
});
}
function createLanguageExtension(targetEngine: CodeEngine) {
if (targetEngine === 'python') {
return python();
}
return javascript();
}
function getIndentUnitForEngine(targetEngine: CodeEngine) {
if (targetEngine === 'python') {
return ' ';
}
return ' ';
}
function createBusinessCompletionExtension(targetEngine: CodeEngine, candidates: typeof paramCandidates) {
return EditorState.languageData.of((_state, _pos, _side) => {
return [
{
autocomplete: createBusinessCompletionSource({
engine: targetEngine,
paramCandidates: candidates
})
}
];
});
}
function handlePickedCompletionParens(transactions: readonly Transaction[], currentState: EditorState) {
for (const tr of transactions) {
const picked = tr.annotation(pickedCompletion);
if (!picked) {
continue;
}
const cursor = currentState.selection.main.head;
const nextChar = currentState.sliceDoc(cursor, cursor + 1);
if (!shouldAutoAppendCallParens(picked, nextChar)) {
continue;
}
queueMicrotask(() => {
if (!editorView) {
return;
}
const head = editorView.state.selection.main.head;
const afterHead = editorView.state.sliceDoc(head, head + 1);
if (afterHead === '(') {
return;
}
editorView.dispatch({
changes: {
from: head,
to: head,
insert: '()'
},
selection: {
anchor: head + 1
}
});
});
}
}
function createHighlightDecorations(resolvedMap: Map<string, boolean>) {
const resultMark = Decoration.mark({class: 'cm-special-result'});
const validTokenMark = Decoration.mark({class: 'cm-template-token-valid'});
const unresolvedTokenMark = Decoration.mark({class: 'cm-template-token-unresolved'});
const invalidTokenMark = Decoration.mark({class: 'cm-template-token-invalid'});
function buildDecorations(source: string): DecorationSet {
const ranges: Array<{from: number; to: number; value: Decoration}> = [];
RESULT_PATTERN.lastIndex = 0;
let resultMatch: RegExpExecArray | null = RESULT_PATTERN.exec(source);
while (resultMatch) {
ranges.push({
from: resultMatch.index,
to: resultMatch.index + resultMatch[0].length,
value: resultMark
});
resultMatch = RESULT_PATTERN.exec(source);
}
TOKEN_PATTERN.lastIndex = 0;
let tokenMatch: RegExpExecArray | null = TOKEN_PATTERN.exec(source);
while (tokenMatch) {
const tokenKey = (tokenMatch[1] || '').trim();
const resolved = resolvedMap.get(tokenKey);
const decoration =
resolved === undefined ? invalidTokenMark : resolved ? validTokenMark : unresolvedTokenMark;
ranges.push({
from: tokenMatch.index,
to: tokenMatch.index + tokenMatch[0].length,
value: decoration
});
tokenMatch = TOKEN_PATTERN.exec(source);
}
return Decoration.set(ranges, true);
}
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view.state.doc.toString());
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.state.doc.toString());
}
}
},
{
decorations: (value) => value.decorations
}
);
}
function createEditorTheme() {
return EditorView.theme({
'&': {
border: '1px solid #dcdfe6',
borderRadius: '6px',
backgroundColor: '#fff',
fontSize: '13px'
},
'&.cm-focused': {
outline: 'none',
borderColor: '#4c82f7',
boxShadow: '0 0 0 1px rgba(76, 130, 247, 0.25)'
},
'&.cm-editor.cm-disabled': {
backgroundColor: '#f5f7fa',
opacity: 0.85
},
'.cm-scroller': {
minHeight: 'var(--code-editor-min-height)',
lineHeight: '1.6',
fontFamily:
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace"
},
'.cm-content': {
padding: '8px 38px 8px 10px',
caretColor: '#1f2937'
},
'.cm-gutters': {
border: 'none',
background: 'transparent'
},
'.cm-activeLine': {
background: 'rgba(76, 130, 247, 0.06)'
},
'.cm-placeholder': {
color: '#c0c4cc'
}
});
}
function insertTextAtSelection(text: string) {
if (!editorView) {
return;
}
const selection = editorView.state.selection.main;
const from = selection.from;
const to = selection.to;
editorView.dispatch({
changes: {
from,
to,
insert: text
},
selection: {
anchor: from + text.length
}
});
editorView.focus();
}
function insertParam(paramName: string) {
insertTextAtSelection(`{{${paramName}}}`);
emitChange(editorView?.state.doc.toString() || '');
triggerObject?.hide?.();
}
function toggleExpanded() {
expanded = !expanded;
}
function closeExpanded() {
expanded = false;
}
function moveExpandedShellToBody() {
if (typeof document === 'undefined' || !editorShellEl) {
return;
}
if (editorShellEl.parentElement === document.body) {
return;
}
shellOriginalParent = editorShellEl.parentElement;
shellOriginalNextSibling = editorShellEl.nextSibling;
document.body.appendChild(editorShellEl);
}
function restoreExpandedShell() {
if (!editorShellEl || !shellOriginalParent) {
return;
}
if (editorShellEl.parentElement !== document.body) {
return;
}
if (shellOriginalNextSibling && shellOriginalNextSibling.parentNode === shellOriginalParent) {
shellOriginalParent.insertBefore(editorShellEl, shellOriginalNextSibling);
} else {
shellOriginalParent.appendChild(editorShellEl);
}
}
onMount(() => {
if (!containerEl) {
return;
}
editorView = new EditorView({
parent: containerEl,
state: EditorState.create({
doc: (value || '') as string,
extensions: [
createEditorTheme(),
syntaxHighlighting(defaultHighlightStyle, {fallback: true}),
history(),
indentOnInput(),
closeBrackets(),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
handlePickedCompletionParens(update.transactions, update.state);
if (!update.docChanged) {
return;
}
const nextValue = update.state.doc.toString();
if (!applyingExternalValue) {
emitInput(nextValue);
}
}),
EditorView.contentAttributes.of({spellcheck: 'false'}),
EditorView.domEventHandlers({
focus: (event) => {
rest.onfocus?.(event);
return false;
},
blur: (event) => {
emitChange(editorView?.state.doc.toString() || '');
rest.onblur?.(event);
return false;
}
}),
keymap.of([
indentWithTab,
...defaultKeymap,
...historyKeymap,
...closeBracketsKeymap,
...completionKeymap
]),
languageCompartment.of(createLanguageExtension(normalizedEngine)),
indentCompartment.of(indentUnit.of(getIndentUnitForEngine(normalizedEngine))),
completionSourceCompartment.of(createBusinessCompletionExtension(normalizedEngine, paramCandidates)),
completionCompartment.of(
autocompletion({
activateOnTyping: true
})
),
decorationCompartment.of(createHighlightDecorations(paramResolvedMap)),
editableCompartment.of([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)]),
placeholderCompartment.of(placeholder ? cmPlaceholder(placeholder) : [])
]
})
});
return () => {
editorView?.destroy();
editorView = null;
};
});
$effect(() => {
if (!editorView) {
return;
}
editorView.dispatch({
effects: languageCompartment.reconfigure(createLanguageExtension(normalizedEngine))
});
});
$effect(() => {
if (!editorView) {
return;
}
editorView.dispatch({
effects: indentCompartment.reconfigure(indentUnit.of(getIndentUnitForEngine(normalizedEngine)))
});
});
$effect(() => {
if (!editorView) {
return;
}
editorView.dispatch({
effects: completionSourceCompartment.reconfigure(
createBusinessCompletionExtension(normalizedEngine, paramCandidates)
)
});
});
$effect(() => {
if (!editorView) {
return;
}
editorView.dispatch({
effects: decorationCompartment.reconfigure(createHighlightDecorations(paramResolvedMap))
});
});
$effect(() => {
if (!editorView) {
return;
}
editorView.dispatch({
effects: editableCompartment.reconfigure([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)])
});
const editorDom = editorView.dom;
if (disabled) {
editorDom.classList.add('cm-disabled');
} else {
editorDom.classList.remove('cm-disabled');
}
});
$effect(() => {
if (!editorView) {
return;
}
editorView.dispatch({
effects: placeholderCompartment.reconfigure(placeholder ? cmPlaceholder(placeholder) : [])
});
});
$effect(() => {
if (!editorView) {
return;
}
const nextValue = (value || '') as string;
const currentValue = editorView.state.doc.toString();
if (nextValue === currentValue) {
return;
}
applyingExternalValue = true;
editorView.dispatch({
changes: {
from: 0,
to: currentValue.length,
insert: nextValue
}
});
applyingExternalValue = false;
});
$effect(() => {
if (!expanded || typeof window === 'undefined') {
return;
}
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeExpanded();
}
};
window.addEventListener('keydown', onKeydown);
return () => {
window.removeEventListener('keydown', onKeydown);
};
});
$effect(() => {
if (typeof document === 'undefined') {
return;
}
const className = 'code-script-expanded-open';
if (expanded) {
document.body.classList.add(className);
} else {
document.body.classList.remove(className);
}
return () => {
document.body.classList.remove(className);
};
});
$effect(() => {
if (typeof document === 'undefined' || !editorShellEl) {
return;
}
if (expanded) {
moveExpandedShellToBody();
} else {
restoreExpandedShell();
}
return () => {
restoreExpandedShell();
};
});
</script>
<div class="code-script-editor-shell {expanded ? 'expanded-host' : ''}" bind:this={editorShellEl}>
{#if expanded}
<button type="button" class="code-script-overlay" aria-label="关闭放大代码编辑器" onclick={closeExpanded}></button>
{/if}
<div
class="code-script-editor nopan nodrag {className} {expanded ? 'expanded' : ''}"
style={editorStyle}
>
<div class="code-script-editor-inner" bind:this={containerEl}></div>
<div class="code-script-action">
<div class="code-script-action-buttons">
<Button
class="code-script-button {expanded ? 'expanded-cancel' : ''}"
onclick={toggleExpanded}
title={expanded ? '取消放大' : '放大编辑区域'}
aria-label={expanded ? '取消放大' : '放大编辑区域'}
>
{#if expanded}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3V9H19V6.414L15.707 9.707L14.293 8.293L17.586 5H15V3H21ZM3 21V15H5V17.586L8.293 14.293L9.707 15.707L6.414 19H9V21H3ZM9.707 8.293L8.293 9.707L5 6.414V9H3V3H9V5H6.414L9.707 8.293ZM14.293 15.707L15.707 14.293L19 17.586V15H21V21H15V19H17.586L14.293 15.707Z"></path>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M15 3H21V9H19V5H15V3ZM3 9V3H9V5H5V9H3ZM15 21V19H19V15H21V21H15ZM3 15H5V19H9V21H3V15Z"></path>
</svg>
{/if}
</Button>
<FloatingTrigger placement="bottom-end" bind:this={triggerObject}>
<Button
class="code-script-button"
disabled={!hasParams || disabled}
title={hasParams ? '插入参数模板' : '请先在输入参数中定义参数'}
>
<span>{"{x}"}</span>
</Button>
{#snippet floating()}
<div class="code-script-panel nowheel">
{#if hasParams}
{#each paramCandidates as candidate}
<button
class="code-script-item {candidate.resolved ? '' : 'unresolved'}"
title={candidate.resolved ? candidate.name : `${candidate.name}(未映射)`}
onclick={() => {
insertParam(candidate.name);
}}
>
<span>{candidate.name}</span>
{#if !candidate.resolved}
<span class="code-script-item-warn" title="该参数未映射,运行时可能为空">!</span>
{/if}
</button>
{/each}
{:else}
<div class="code-script-empty">请先在输入参数中定义参数</div>
{/if}
</div>
{/snippet}
</FloatingTrigger>
</div>
</div>
</div>
</div>
<style lang="less">
.code-script-editor-shell {
width: 100%;
position: relative;
}
.code-script-editor-shell.expanded-host {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
}
.code-script-editor {
width: 100%;
position: relative;
pointer-events: auto;
}
.code-script-overlay {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: auto;
border: none;
margin: 0;
padding: 0;
background: rgba(15, 23, 42, 0.28);
backdrop-filter: blur(1px);
}
.code-script-editor :global(.cm-editor),
.code-script-editor :global(.cm-scroller),
.code-script-editor :global(.cm-content),
.code-script-editor :global(.cm-line) {
cursor: text;
}
.code-script-editor.expanded {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 700px;
max-width: calc(100vw - 360px);
min-width: 520px;
height: min(80vh, calc(100vh - 180px));
max-height: none;
z-index: 9999;
}
.code-script-editor.expanded .code-script-editor-inner {
height: 100%;
}
.code-script-editor.expanded :global(.cm-editor) {
height: 100%;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.22);
}
.code-script-editor.expanded :global(.cm-scroller) {
height: 100%;
min-height: 0;
}
.code-script-editor-inner {
width: 100%;
}
.code-script-action {
position: absolute;
top: 6px;
right: 6px;
z-index: 5;
}
.code-script-action-buttons {
display: flex;
align-items: center;
gap: 6px;
}
:global(.code-script-button) {
height: 20px;
min-width: 20px;
border: 1px solid transparent;
padding: 0 4px;
font-size: 10px;
border-radius: 4px;
color: #666;
background: rgba(255, 255, 255, 0.9);
display: inline-flex;
align-items: center;
justify-content: center;
}
:global(.code-script-button svg) {
width: 12px;
height: 12px;
}
:global(.code-script-button.expanded-cancel) {
height: 20px;
min-width: 20px;
padding: 0 4px;
font-size: 10px;
border-color: #d3defd;
color: #1d4ed8;
background: #f5f8ff;
}
@media (max-width: 900px) {
.code-script-editor.expanded {
width: calc(100vw - 120px);
max-width: none;
min-width: 0;
height: calc(100vh - 96px);
}
}
:global(.code-script-button:hover) {
background: #f0f3ff;
color: #2563eb;
border-color: #d3defd;
}
.code-script-panel {
min-width: 220px;
max-width: 340px;
max-height: 240px;
overflow: auto;
background: #fff;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.code-script-item {
border: none;
background: #fff;
padding: 6px 8px;
text-align: left;
cursor: pointer;
border-radius: 4px;
font-size: 12px;
line-height: 1.3;
color: #333;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.code-script-item:hover {
background: #f5f7ff;
color: #2563eb;
}
.code-script-item.unresolved {
background: #fff8e6;
color: #92400e;
}
.code-script-item-warn {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #d97706;
color: #fff;
font-size: 10px;
line-height: 1;
flex-shrink: 0;
}
.code-script-empty {
padding: 8px;
font-size: 12px;
color: #999;
text-align: center;
}
.code-script-editor :global(.cm-special-result) {
color: #1d4ed8;
font-weight: 700;
}
.code-script-editor :global(.cm-template-token-valid) {
color: #1e4ed8;
background: rgba(37, 99, 235, 0.12);
border-radius: 4px;
}
.code-script-editor :global(.cm-template-token-unresolved) {
color: #92400e;
background: rgba(217, 119, 6, 0.16);
border-radius: 4px;
}
.code-script-editor :global(.cm-template-token-invalid) {
color: #b42318;
background: rgba(217, 45, 32, 0.14);
border-radius: 4px;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete) {
border: 1px solid #e6eaf2;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
padding: 6px;
min-width: 260px;
max-width: 360px;
z-index: 60;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul) {
font-family:
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace";
font-size: 13px;
max-height: 240px;
overflow: auto;
padding: 0;
margin: 0;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul > li) {
border: none;
border-radius: 8px;
padding: 8px 10px;
line-height: 1.35;
display: flex;
align-items: center;
gap: 8px;
color: #1f2937;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]) {
background: #eef4ff;
color: #1d4ed8;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover) {
background: #f6f8fc;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon) {
width: 16px;
height: 16px;
color: #6b7280;
opacity: 1;
font-size: 12px;
margin-right: 0;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] .cm-completionIcon) {
color: #2563eb;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel) {
color: inherit;
font-weight: 500;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete .cm-completionDetail) {
margin-left: auto;
color: #94a3b8;
font-style: normal;
font-size: 12px;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText) {
text-decoration: none;
color: #2563eb;
font-weight: 700;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar) {
width: 8px;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb) {
background: #d6dce8;
border-radius: 999px;
}
.code-script-editor :global(.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track) {
background: transparent;
}
:global(body.code-script-expanded-open) {
overflow: hidden;
}
</style>

View File

@@ -20,6 +20,7 @@
allowSettingOfCondition = true,
showSourceHandle = true,
showTargetHandle = true,
titleHelp = '',
onCollapse
}: {
data: NodeProps['data'],
@@ -34,6 +35,7 @@
allowSettingOfCondition?: boolean,
showSourceHandle?: boolean,
showTargetHandle?: boolean,
titleHelp?: string,
onCollapse?: (key: string) => void,
} = $props();
@@ -45,6 +47,7 @@
key: 'key',
icon,
title: data.title as string,
titleHelp,
description: data.description as string,
content: children
}];
@@ -296,4 +299,3 @@
}
}
</style>