mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-24 16:02:20 +08:00
feat(code-editor): add HTML and JavaScript completion support
This commit is contained in:
+54
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user