From cdae3586cf010da196ed0b8aaf1002c357137836 Mon Sep 17 00:00:00 2001 From: Zeke Zhang <958414905@qq.com> Date: Sat, 27 Sep 2025 00:27:13 +0800 Subject: [PATCH] feat(code-editor): add HTML and JavaScript completion support --- .../__tests__/javascriptHtmlTemplate.test.ts | 54 ++++++ .../components/code-editor/htmlCompletion.ts | 155 ++++++++++++++++++ .../src/flow/components/code-editor/index.tsx | 25 +-- .../code-editor/javascriptCompletion.ts | 32 ++++ .../code-editor/javascriptHtmlTemplate.ts | 8 +- 5 files changed, 250 insertions(+), 24 deletions(-) create mode 100644 packages/core/client/src/flow/components/code-editor/htmlCompletion.ts create mode 100644 packages/core/client/src/flow/components/code-editor/javascriptCompletion.ts diff --git a/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts b/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts index 62bf039aa41..81e811c735a 100644 --- a/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts +++ b/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts @@ -8,9 +8,12 @@ */ import { syntaxTree } from '@codemirror/language'; +import { CompletionContext } from '@codemirror/autocomplete'; import { EditorState } from '@codemirror/state'; import { describe, expect, it } from 'vitest'; import { javascriptWithHtmlTemplates } from '../javascriptHtmlTemplate'; +import { createJavascriptCompletion } from '../javascriptCompletion'; +import { createHtmlCompletion } from '../htmlCompletion'; describe('javascriptWithHtmlTemplates', () => { it('mounts html parser for template literal segments', () => { @@ -38,4 +41,55 @@ describe('javascriptWithHtmlTemplates', () => { expect(node.name).toBe('VariableName'); }); + + it('defers to html completions inside template literals', async () => { + const support = javascriptWithHtmlTemplates(); + const state = EditorState.create({ + doc: 'const template = `<`;', + extensions: [support], + }); + const doc = state.doc.toString(); + const pos = doc.indexOf('<') + 1; + const context = new CompletionContext(state, pos, true); + + const jsCompletion = createJavascriptCompletion()(context); + expect(jsCompletion).toBeNull(); + + const htmlResult = await createHtmlCompletion()(context); + expect(htmlResult).not.toBeNull(); + expect(htmlResult?.options?.some((option) => option.label.includes('div'))).toBe(true); + }); + + it('keeps html completions working after interpolations', async () => { + const support = javascriptWithHtmlTemplates(); + const state = EditorState.create({ + doc: 'const template = `
${value}<<`;', + extensions: [support], + }); + + const doc = state.doc.toString(); + const pos = doc.lastIndexOf('<'); + const context = new CompletionContext(state, pos + 1, true); + + const jsCompletion = createJavascriptCompletion()(context); + expect(jsCompletion).toBeNull(); + + const htmlResult = await createHtmlCompletion()(context); + expect(htmlResult).not.toBeNull(); + expect(htmlResult?.options?.some((option) => option.label.includes('div'))).toBe(true); + }); + + it('keeps javascript completions available outside template literals', () => { + const state = EditorState.create({ + doc: 'const value = windo', + extensions: [javascriptWithHtmlTemplates()], + }); + + const pos = state.doc.length; + const context = new CompletionContext(state, pos, true); + + const result = createJavascriptCompletion()(context); + expect(result).not.toBeNull(); + expect(result?.options.length ?? 0).toBeGreaterThan(0); + }); }); diff --git a/packages/core/client/src/flow/components/code-editor/htmlCompletion.ts b/packages/core/client/src/flow/components/code-editor/htmlCompletion.ts new file mode 100644 index 00000000000..598fb7d88e3 --- /dev/null +++ b/packages/core/client/src/flow/components/code-editor/htmlCompletion.ts @@ -0,0 +1,155 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { CompletionContext, type CompletionSource, type CompletionResult } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { htmlCompletionSource, htmlLanguage } from '@codemirror/lang-html'; +import { syntaxTree } from '@codemirror/language'; +import type { SyntaxNode } from '@lezer/common'; +import { getTemplateHtmlRanges, TemplateHtmlRange } from './javascriptHtmlTemplate'; + +interface TemplateHtmlSegment { + htmlFrom: number; + htmlTo: number; + docFrom: number; +} + +interface HtmlTemplateContext { + htmlDoc: string; + htmlPos: number; + segments: TemplateHtmlSegment[]; +} + +const buildSegments = ( + node: SyntaxNode, + ranges: TemplateHtmlRange[], + context: CompletionContext, +): HtmlTemplateContext | null => { + const state = context.state; + const doc = state.doc; + const segments: TemplateHtmlSegment[] = []; + let htmlDoc = ''; + let htmlOffset = 0; + + for (const range of ranges) { + const text = doc.sliceString(range.from, range.to); + const htmlFrom = htmlOffset; + const htmlTo = htmlFrom + text.length; + segments.push({ htmlFrom, htmlTo, docFrom: range.from }); + htmlDoc += text; + htmlOffset = htmlTo; + } + + if (!segments.length) { + return null; + } + + const segment = segments.find( + ({ docFrom, htmlFrom, htmlTo }) => context.pos >= docFrom && context.pos <= docFrom + (htmlTo - htmlFrom), + ); + + if (!segment) { + return null; + } + + const htmlPos = segment.htmlFrom + (context.pos - segment.docFrom); + + return { + htmlDoc, + htmlPos, + segments, + }; +}; + +const mapHtmlToDoc = (segments: TemplateHtmlSegment[], htmlPos: number): number | null => { + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index]; + if (htmlPos >= segment.htmlFrom && htmlPos <= segment.htmlTo) { + return segment.docFrom + (htmlPos - segment.htmlFrom); + } + } + + return null; +}; + +const findTemplateStringNode = (node: SyntaxNode | null): SyntaxNode | null => { + for (let current: SyntaxNode | null = node; current; current = current.parent) { + if (current.type.name === 'TemplateString') { + return current; + } + } + + return null; +}; + +const resolveHtmlTemplateContext = (context: CompletionContext): HtmlTemplateContext | null => { + const tree = syntaxTree(context.state); + const node = tree.resolveInner(context.pos, -1); + const template = findTemplateStringNode(node); + + if (!template) { + return null; + } + + if (node.type.name === 'Interpolation') { + return null; + } + + const ranges = getTemplateHtmlRanges(template.node); + if (!ranges.length) { + return null; + } + + return buildSegments(template.node, ranges, context); +}; + +const createHtmlCompletionResult = ( + base: CompletionResult, + segments: TemplateHtmlSegment[], +): CompletionResult | null => { + const from = mapHtmlToDoc(segments, base.from); + const to = mapHtmlToDoc(segments, base.to); + + if (from == null || to == null) { + return null; + } + + return { + ...base, + from, + to, + }; +}; + +export const isHtmlTemplateContext = (context: CompletionContext): boolean => + resolveHtmlTemplateContext(context) != null; + +export const createHtmlCompletion = (): CompletionSource => { + const source = htmlCompletionSource; + + return (context) => { + const templateContext = resolveHtmlTemplateContext(context); + if (!templateContext) { + return null; + } + + const htmlState = EditorState.create({ + doc: templateContext.htmlDoc, + extensions: [htmlLanguage], + }); + const htmlContext = new CompletionContext(htmlState, templateContext.htmlPos, context.explicit); + const result = source(htmlContext); + + if (!result) { + return null; + } + + return createHtmlCompletionResult(result, templateContext.segments); + }; +}; diff --git a/packages/core/client/src/flow/components/code-editor/index.tsx b/packages/core/client/src/flow/components/code-editor/index.tsx index f1b64459af3..20ebec165ef 100644 --- a/packages/core/client/src/flow/components/code-editor/index.tsx +++ b/packages/core/client/src/flow/components/code-editor/index.tsx @@ -10,13 +10,14 @@ import React, { useEffect, useRef } from 'react'; // CodeMirror imports -import { autocompletion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { autocompletion } from '@codemirror/autocomplete'; import { lintGutter } from '@codemirror/lint'; import { EditorState } from '@codemirror/state'; import { oneDark } from '@codemirror/theme-one-dark'; import { basicSetup, EditorView } from 'codemirror'; -import completions from './completions'; import { javascriptWithHtmlTemplates } from './javascriptHtmlTemplate'; +import { createHtmlCompletion } from './htmlCompletion'; +import { createJavascriptCompletion } from './javascriptCompletion'; import { createJavaScriptLinter } from './linter'; import { InjectableRendingEventTrigger, InjectableRendingEventTriggerProps } from '../decorator'; import { Flex } from 'antd'; @@ -26,23 +27,6 @@ export interface EditorRef { read(): string; } -// 自定义自动补全函数 -const createCustomCompletion = () => { - return (context: CompletionContext): CompletionResult | null => { - const word = context.matchBefore(/[a-zA-Z_][\w.]*/); - if (!word || (word.from === word.to && !context.explicit)) return null; - - const from = word.from; - const to = word.to; - - return { - from, - to, - options: completions, - }; - }; -}; - interface CodeEditorProps { value?: string; onChange?: (value: string) => void; @@ -85,7 +69,7 @@ const InnerCodeEditor: React.FC = ({ basicSetup, javascriptWithHtmlTemplates(), autocompletion({ - override: [createCustomCompletion()], + override: [createHtmlCompletion(), createJavascriptCompletion()], closeOnBlur: false, activateOnTyping: true, }), @@ -197,6 +181,7 @@ const InnerCodeEditor: React.FC = ({ view.destroy(); viewRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [theme, height, minHeight, placeholder, readonly, enableLinter]); // Update editor content when value prop changes diff --git a/packages/core/client/src/flow/components/code-editor/javascriptCompletion.ts b/packages/core/client/src/flow/components/code-editor/javascriptCompletion.ts new file mode 100644 index 00000000000..afdf2c6f2b6 --- /dev/null +++ b/packages/core/client/src/flow/components/code-editor/javascriptCompletion.ts @@ -0,0 +1,32 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import completions from './completions'; +import { isHtmlTemplateContext } from './htmlCompletion'; + +export const javascriptCompletionSource = (context: CompletionContext): CompletionResult | null => { + if (isHtmlTemplateContext(context)) { + return null; + } + + const word = context.matchBefore(/[a-zA-Z_][\w.]*/); + if (!word || (word.from === word.to && !context.explicit)) return null; + + const from = word.from; + const to = word.to; + + return { + from, + to, + options: completions, + }; +}; + +export const createJavascriptCompletion = () => javascriptCompletionSource; diff --git a/packages/core/client/src/flow/components/code-editor/javascriptHtmlTemplate.ts b/packages/core/client/src/flow/components/code-editor/javascriptHtmlTemplate.ts index 16e09d50794..5c50b20d90b 100644 --- a/packages/core/client/src/flow/components/code-editor/javascriptHtmlTemplate.ts +++ b/packages/core/client/src/flow/components/code-editor/javascriptHtmlTemplate.ts @@ -13,13 +13,13 @@ import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; import { parseMixed } from '@lezer/common'; import type { SyntaxNode } from '@lezer/common'; -interface OverlayRange { +export interface TemplateHtmlRange { from: number; to: number; } -function buildTemplateHtmlRanges(node: SyntaxNode): OverlayRange[] { - const ranges: OverlayRange[] = []; +export function getTemplateHtmlRanges(node: SyntaxNode): TemplateHtmlRange[] { + const ranges: TemplateHtmlRange[] = []; const open = node.from + 1; const close = node.to - 1; let cursor = open; @@ -50,7 +50,7 @@ export function javascriptWithHtmlTemplates(): LanguageSupport { return null; } - const ranges = buildTemplateHtmlRanges(node.node); + const ranges = getTemplateHtmlRanges(node.node); if (!ranges.length) { return null; }