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;
}