feat(code-editor): add HTML and JavaScript completion support

This commit is contained in:
Zeke Zhang
2025-09-27 02:43:49 +08:00
parent 7fba933094
commit cdae3586cf
5 changed files with 250 additions and 24 deletions
@@ -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 = `<div>${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);
});
});
@@ -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);
};
};
@@ -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<CodeEditorProps> = ({
basicSetup,
javascriptWithHtmlTemplates(),
autocompletion({
override: [createCustomCompletion()],
override: [createHtmlCompletion(), createJavascriptCompletion()],
closeOnBlur: false,
activateOnTyping: true,
}),
@@ -197,6 +181,7 @@ const InnerCodeEditor: React.FC<CodeEditorProps> = ({
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
@@ -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;
@@ -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;
}