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,83 @@
import { describe, expect, it } from 'vitest';
import {
createBusinessCompletions,
normalizeCodeEngine,
resolveBusinessCompletionContext,
shouldAutoAppendCallParens,
shouldSkipBusinessCompletion
} from './codeCompletion';
describe('codeCompletion utils', () => {
it('should normalize engine aliases', () => {
expect(normalizeCodeEngine('py')).toBe('python');
expect(normalizeCodeEngine('python')).toBe('python');
expect(normalizeCodeEngine('js')).toBe('javascript');
expect(normalizeCodeEngine('javascript')).toBe('javascript');
expect(normalizeCodeEngine('')).toBe('javascript');
});
it('should create business completion list with stable priority and dedupe', () => {
const completions = createBusinessCompletions('python', [
{ name: 'input.text', resolved: true },
{ name: 'input.text', resolved: false },
{ name: 'question', resolved: false }
]);
expect(completions[0]).toMatchObject({
label: '_result',
type: 'variable'
});
expect(completions.filter((item) => item.label === 'input.text').length).toBe(1);
expect(completions.find((item) => item.label === 'input.text')).toMatchObject({
apply: '{{input.text}}',
detail: '参数模板'
});
expect(completions.find((item) => item.label === 'question')).toMatchObject({
apply: '{{question}}',
detail: '参数模板(未映射)'
});
expect(completions.some((item) => item.type === 'snippet' && item.label === 'if-else')).toBe(true);
});
it('should detect blocked nodes for comment/string/property contexts', () => {
expect(shouldSkipBusinessCompletion('Comment')).toBe(true);
expect(shouldSkipBusinessCompletion('LineComment')).toBe(true);
expect(shouldSkipBusinessCompletion('String')).toBe(true);
expect(shouldSkipBusinessCompletion('TemplateString')).toBe(true);
expect(shouldSkipBusinessCompletion('PropertyName')).toBe(true);
expect(shouldSkipBusinessCompletion('VariableName')).toBe(false);
});
it('should resolve template context for parameter token completion', () => {
const source = "value = {{input.use";
const pos = source.length;
const context = resolveBusinessCompletionContext(source, pos);
expect(context).toBeTruthy();
expect(context?.from).toBe(8);
expect(context?.validFor).toBeInstanceOf(RegExp);
expect(context?.validFor.source).toBe('^[\\w.]*$');
});
it('should resolve word context and skip property access context', () => {
const source = '_result.value';
expect(resolveBusinessCompletionContext(source, source.length)).toBeNull();
const wordSource = 'ret';
const context = resolveBusinessCompletionContext(wordSource, wordSource.length);
expect(context).toBeTruthy();
expect(context?.from).toBe(0);
expect(context?.validFor).toBeInstanceOf(RegExp);
expect(context?.validFor.source).toBe('^[\\w$]*$');
});
it('should auto append call parens for function/method completions only', () => {
expect(shouldAutoAppendCallParens({ label: 'print', type: 'function' }, '')).toBe(true);
expect(shouldAutoAppendCallParens({ label: 'run', type: 'method' }, '')).toBe(true);
expect(shouldAutoAppendCallParens({ label: 'count', type: 'variable' }, '')).toBe(false);
expect(shouldAutoAppendCallParens({ label: 'print', type: 'function' }, '(')).toBe(false);
expect(shouldAutoAppendCallParens({ label: 'call', type: 'function', apply: 'call()' }, '')).toBe(false);
});
});

View File

@@ -0,0 +1,217 @@
import type { Completion, CompletionResult, CompletionSource } from '@codemirror/autocomplete';
import { syntaxTree } from '@codemirror/language';
import type { ParameterCandidate } from './paramToken';
export type CodeEngine = 'javascript' | 'python';
export interface BusinessCompletionConfig {
engine: CodeEngine;
paramCandidates: ParameterCandidate[];
}
interface CompletionContextHint {
from: number;
validFor: RegExp;
}
const BLOCKED_NODE_NAMES = new Set([
'String',
'TemplateString',
'FormatString',
'Comment',
'LineComment',
'BlockComment',
'PropertyName',
'PrivatePropertyName'
]);
const JS_SNIPPETS: Array<{ label: string; insert: string; detail: string }> = [
{
label: 'if-else',
detail: '条件分支',
insert: "if (condition) {\n _result.value = value;\n} else {\n _result.value = null;\n}"
},
{
label: 'for-of',
detail: '遍历数组',
insert: "for (const item of items) {\n // TODO\n}\n_result.done = true;"
},
{
label: 'result-object',
detail: '返回对象',
insert: "_result.message = 'ok';\n_result.data = data;"
}
];
const PYTHON_SNIPPETS: Array<{ label: string; insert: string; detail: string }> = [
{
label: 'if-else',
detail: '条件分支',
insert: "if condition:\n _result['value'] = value\nelse:\n _result['value'] = None"
},
{
label: 'for-loop',
detail: '遍历数组',
insert: "for item in items:\n # TODO\n pass\n_result['done'] = True"
},
{
label: 'result-object',
detail: '返回对象',
insert: "_result['message'] = 'ok'\n_result['data'] = data"
}
];
export function normalizeCodeEngine(rawEngine?: string): CodeEngine {
const normalized = (rawEngine || 'js').trim().toLowerCase();
if (normalized === 'python' || normalized === 'py') {
return 'python';
}
return 'javascript';
}
export function shouldSkipBusinessCompletion(nodeName: string): boolean {
if (!nodeName) {
return false;
}
if (BLOCKED_NODE_NAMES.has(nodeName)) {
return true;
}
if (nodeName.endsWith('Comment')) {
return true;
}
return nodeName.includes('String');
}
export function createBusinessCompletions(
targetEngine: CodeEngine,
candidates: ParameterCandidate[]
): Completion[] {
const resultCompletion: Completion = {
label: '_result',
type: 'variable',
detail: '代码节点输出对象',
boost: 900
};
const parameterCompletions: Completion[] = candidates.map((candidate) => ({
label: candidate.name,
type: 'variable',
detail: candidate.resolved ? '参数模板' : '参数模板(未映射)',
apply: `{{${candidate.name}}}`,
boost: 700
}));
const snippetCompletions: Completion[] = (targetEngine === 'python' ? PYTHON_SNIPPETS : JS_SNIPPETS).map(
(snippet) => ({
label: snippet.label,
type: 'snippet',
detail: snippet.detail,
apply: snippet.insert,
boost: 500
})
);
return dedupeCompletions([resultCompletion, ...parameterCompletions, ...snippetCompletions]);
}
function dedupeCompletions(items: Completion[]): Completion[] {
const seen = new Set<string>();
const deduped: Completion[] = [];
for (const item of items) {
const key = `${item.label}::${item.type || ''}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(item);
}
return deduped;
}
function resolveTemplateContext(source: string, pos: number): CompletionContextHint | null {
const start = Math.max(0, pos - 300);
const prefix = source.slice(start, pos);
const match = /\{\{\s*([\w.]*)$/.exec(prefix);
if (!match) {
return null;
}
return {
from: pos - match[0].length,
validFor: /^[\w.]*$/
};
}
function resolveWordContext(source: string, pos: number): CompletionContextHint | null {
let from = pos;
while (from > 0 && /[\w$]/.test(source[from - 1])) {
from -= 1;
}
if (from === pos) {
return null;
}
if (from > 0 && source[from - 1] === '.') {
return null;
}
return {
from,
validFor: /^[\w$]*$/
};
}
export function resolveBusinessCompletionContext(source: string, pos: number): CompletionContextHint | null {
return resolveTemplateContext(source, pos) || resolveWordContext(source, pos);
}
export function createBusinessCompletionSource(config: BusinessCompletionConfig): CompletionSource {
return (context) => {
const node = syntaxTree(context.state).resolveInner(context.pos, -1);
if (shouldSkipBusinessCompletion(node.name)) {
return null;
}
const docText = context.state.doc.toString();
const resolved = resolveBusinessCompletionContext(docText, context.pos);
if (!resolved && !context.explicit) {
return null;
}
const result: CompletionResult = {
from: resolved?.from ?? context.pos,
options: createBusinessCompletions(config.engine, config.paramCandidates)
};
if (resolved?.validFor) {
result.validFor = resolved.validFor;
}
return result;
};
}
export function shouldAutoAppendCallParens(completion: Completion | null | undefined, nextChar: string): boolean {
if (!completion) {
return false;
}
const completionType = (completion.type || '').toLowerCase();
if (completionType !== 'function' && completionType !== 'method') {
return false;
}
if (nextChar === '(') {
return false;
}
if (typeof completion.apply === 'string' && completion.apply.includes('(')) {
return false;
}
if (typeof completion.apply === 'function') {
return false;
}
return true;
}