diff --git a/packages/core/client/package.json b/packages/core/client/package.json
index e59310b6a94..6da6b48bec4 100644
--- a/packages/core/client/package.json
+++ b/packages/core/client/package.json
@@ -30,6 +30,7 @@
"@nocobase/evaluators": "1.9.0-alpha.16",
"@nocobase/sdk": "1.9.0-alpha.16",
"@nocobase/utils": "1.9.0-alpha.16",
+ "@codemirror/lang-html": "^6.4.10",
"ahooks": "^3.7.2",
"antd": "5.24.2",
"antd-style": "3.7.1",
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
new file mode 100644
index 00000000000..62bf039aa41
--- /dev/null
+++ b/packages/core/client/src/flow/components/code-editor/__tests__/javascriptHtmlTemplate.test.ts
@@ -0,0 +1,41 @@
+/**
+ * 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 { syntaxTree } from '@codemirror/language';
+import { EditorState } from '@codemirror/state';
+import { describe, expect, it } from 'vitest';
+import { javascriptWithHtmlTemplates } from '../javascriptHtmlTemplate';
+
+describe('javascriptWithHtmlTemplates', () => {
+ it('mounts html parser for template literal segments', () => {
+ const support = javascriptWithHtmlTemplates();
+ const state = EditorState.create({
+ doc: 'const template = `
${value}
`;',
+ extensions: [support],
+ });
+
+ const htmlPos = state.doc.toString().indexOf('div class');
+ const node = syntaxTree(state).resolveInner(htmlPos, 1);
+
+ expect(node.name).toBe('TagName');
+ });
+
+ it('preserves interpolation nodes from javascript parser', () => {
+ const support = javascriptWithHtmlTemplates();
+ const state = EditorState.create({
+ doc: 'const template = `${value}
`;',
+ extensions: [support],
+ });
+
+ const variablePos = state.doc.toString().indexOf('value');
+ const node = syntaxTree(state).resolveInner(variablePos, 1);
+
+ expect(node.name).toBe('VariableName');
+ });
+});
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 dee5e8108a9..f1b64459af3 100644
--- a/packages/core/client/src/flow/components/code-editor/index.tsx
+++ b/packages/core/client/src/flow/components/code-editor/index.tsx
@@ -11,12 +11,12 @@ import React, { useEffect, useRef } from 'react';
// CodeMirror imports
import { autocompletion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
-import { javascript } from '@codemirror/lang-javascript';
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 { createJavaScriptLinter } from './linter';
import { InjectableRendingEventTrigger, InjectableRendingEventTriggerProps } from '../decorator';
import { Flex } from 'antd';
@@ -83,7 +83,7 @@ const InnerCodeEditor: React.FC = ({
const extensions = [
basicSetup,
- javascript(),
+ javascriptWithHtmlTemplates(),
autocompletion({
override: [createCustomCompletion()],
closeOnBlur: false,
diff --git a/packages/core/client/src/flow/components/code-editor/javascriptHtmlTemplate.ts b/packages/core/client/src/flow/components/code-editor/javascriptHtmlTemplate.ts
new file mode 100644
index 00000000000..16e09d50794
--- /dev/null
+++ b/packages/core/client/src/flow/components/code-editor/javascriptHtmlTemplate.ts
@@ -0,0 +1,66 @@
+/**
+ * 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 { html, htmlLanguage } from '@codemirror/lang-html';
+import { LanguageSupport } from '@codemirror/language';
+import { javascript, javascriptLanguage } from '@codemirror/lang-javascript';
+import { parseMixed } from '@lezer/common';
+import type { SyntaxNode } from '@lezer/common';
+
+interface OverlayRange {
+ from: number;
+ to: number;
+}
+
+function buildTemplateHtmlRanges(node: SyntaxNode): OverlayRange[] {
+ const ranges: OverlayRange[] = [];
+ const open = node.from + 1;
+ const close = node.to - 1;
+ let cursor = open;
+
+ for (let child = node.firstChild; child; child = child.nextSibling) {
+ if (child.type.name === 'Interpolation') {
+ if (child.from > cursor) {
+ ranges.push({ from: cursor, to: child.from });
+ }
+ cursor = Math.max(cursor, child.to);
+ }
+ }
+
+ if (cursor < close) {
+ ranges.push({ from: cursor, to: close });
+ }
+
+ return ranges.filter((range) => range.to > range.from);
+}
+
+export function javascriptWithHtmlTemplates(): LanguageSupport {
+ const baseJavascript = javascript();
+ const htmlSupport = html();
+
+ const mixedLanguage = javascriptLanguage.configure({
+ wrap: parseMixed((node) => {
+ if (node.type.name !== 'TemplateString') {
+ return null;
+ }
+
+ const ranges = buildTemplateHtmlRanges(node.node);
+ if (!ranges.length) {
+ return null;
+ }
+
+ return {
+ parser: htmlLanguage.parser,
+ overlay: ranges,
+ };
+ }),
+ });
+
+ return new LanguageSupport(mixedLanguage, [baseJavascript.support, htmlSupport.support]);
+}