261 lines
8.3 KiB
TypeScript
261 lines
8.3 KiB
TypeScript
import {
|
||
autocompletion,
|
||
closeBrackets,
|
||
closeBracketsKeymap,
|
||
completionKeymap,
|
||
} from '@codemirror/autocomplete';
|
||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||
import { javascript } from '@codemirror/lang-javascript';
|
||
import { python } from '@codemirror/lang-python';
|
||
import { PostgreSQL, sql } from '@codemirror/lang-sql';
|
||
import {
|
||
bracketMatching,
|
||
codeFolding,
|
||
defaultHighlightStyle,
|
||
foldGutter,
|
||
foldKeymap,
|
||
indentOnInput,
|
||
syntaxHighlighting,
|
||
} from '@codemirror/language';
|
||
import { lintKeymap } from '@codemirror/lint';
|
||
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
|
||
import { Compartment, EditorState, type Extension, Prec } from '@codemirror/state';
|
||
import {
|
||
crosshairCursor,
|
||
drawSelection,
|
||
dropCursor,
|
||
EditorView,
|
||
highlightActiveLine,
|
||
highlightActiveLineGutter,
|
||
highlightSpecialChars,
|
||
keymap,
|
||
lineNumbers,
|
||
rectangularSelection,
|
||
} from '@codemirror/view';
|
||
import { vim } from '@replit/codemirror-vim';
|
||
import { type ThemeName, themeExtension } from './themes';
|
||
|
||
/**
|
||
* 本地版 basicSetup:与 `codemirror` 包同名的便利数组成员完全一致,唯一差别是
|
||
* 折叠图标换成几何三角形字符。
|
||
*
|
||
* 原因:`codemirror` 的 basicSetup 调 `foldGutter()` 用默认 `openText: "⌄"`
|
||
* (U+2304 DOWNWARDS HARPOON WITH BARB… 实为「下花括号」),这是个基线/字形表现
|
||
* 不稳定的符号字符——在多数 mono 字体里墨迹落在字符框底部,导致折叠三角在行框里
|
||
* 明显偏低。改成 `▾` (U+25BE BLACK DOWN-POINTING SMALL TRIANGLE) / `▸`
|
||
* (U+25B8 BLACK RIGHT-POINTING SMALL TRIANGLE) 两个「几何形状」块字符,字形稳定
|
||
* 居中,配合 themes.ts 的 flex 居中后,三角精准落在每行行框中央。
|
||
*
|
||
* 按 CodeMirror 官方建议:basicSetup 「does not allow customization」,需要定制时
|
||
* 「copy this package's source ... and adjust as desired」,此处即采用此法。
|
||
*/
|
||
const basicSetup: Extension[] = [
|
||
lineNumbers(),
|
||
highlightActiveLineGutter(),
|
||
highlightSpecialChars(),
|
||
history(),
|
||
foldGutter({
|
||
openText: '▾',
|
||
closedText: '▸',
|
||
}),
|
||
drawSelection(),
|
||
dropCursor(),
|
||
EditorState.allowMultipleSelections.of(true),
|
||
indentOnInput(),
|
||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||
bracketMatching(),
|
||
closeBrackets(),
|
||
autocompletion(),
|
||
rectangularSelection(),
|
||
crosshairCursor(),
|
||
highlightActiveLine(),
|
||
highlightSelectionMatches(),
|
||
keymap.of([
|
||
...closeBracketsKeymap,
|
||
...defaultKeymap,
|
||
...searchKeymap,
|
||
...historyKeymap,
|
||
...foldKeymap,
|
||
...completionKeymap,
|
||
...lintKeymap,
|
||
]),
|
||
codeFolding(),
|
||
];
|
||
|
||
/** SQL 补全用 schema 数据(由 Rust 侧从实时库拉取注入)。 */
|
||
export interface SqlSchema {
|
||
tables: { name: string; columns: string[] }[];
|
||
}
|
||
|
||
/**
|
||
* 传给 CodeMirrorEditor.create 的配置。
|
||
* 必须是 class(非 interface),以便 TS 擦除后存活,
|
||
* wasm 侧能用 `new EditorOptions()` 构造,并通过 setter 填充字段。
|
||
*/
|
||
export class EditorOptions {
|
||
language?: string;
|
||
theme?: ThemeName;
|
||
vim?: boolean;
|
||
schema?: SqlSchema;
|
||
value?: string;
|
||
onChange?: (value: string) => void;
|
||
onReady?: () => void;
|
||
/** Ctrl/Cmd + Enter 快捷键回调(SQL 控制台触发执行)。 */
|
||
onRunShortcut?: () => void;
|
||
}
|
||
|
||
/**
|
||
* 把语言标识映射成 CodeMirror 语言 Extension。
|
||
* - `python` → @codemirror/lang-python
|
||
* - `node` / `javascript` / `js` → @codemirror/lang-javascript
|
||
* - `sql` / 缺省 → @codemirror/lang-sql(PostgreSQL 方言 + schema 补全)
|
||
*
|
||
* SQL 分支使用 schema 补全;非 SQL 语言忽略 schema(补全仅对 SQL 有意义)。
|
||
*/
|
||
function buildLanguageExtension(lang: string, schema: SqlSchema): Extension {
|
||
const normalized = (lang ?? '').toLowerCase();
|
||
if (normalized === 'python') {
|
||
return python();
|
||
}
|
||
if (normalized === 'node' || normalized === 'javascript' || normalized === 'js') {
|
||
return javascript();
|
||
}
|
||
// sql / 缺省:保留原有 PostgreSQL + schema 补全行为
|
||
return sql({
|
||
dialect: PostgreSQL,
|
||
schema: schemaToCompletions(schema),
|
||
upperCaseKeywords: true,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* CodeMirror 实例封装。
|
||
* create() 时用四个 Compartment 注入 theme/language/schema/vim,
|
||
* 支持后续热切换(reconfigure)而不重建实例——保留 Vim 状态、光标、撤销栈。
|
||
*/
|
||
export class CodeMirrorInstance {
|
||
private view: EditorView;
|
||
private themeCompartment = new Compartment();
|
||
private languageCompartment = new Compartment();
|
||
private vimCompartment = new Compartment();
|
||
private language: string;
|
||
private schema: SqlSchema;
|
||
|
||
constructor(container: HTMLElement, options: EditorOptions) {
|
||
const theme: ThemeName = options.theme ?? 'light';
|
||
const schema = options.schema ?? { tables: [] };
|
||
this.schema = schema;
|
||
this.language = options.language ?? 'sql';
|
||
|
||
this.view = new EditorView({
|
||
state: EditorState.create({
|
||
doc: options.value ?? '',
|
||
extensions: [
|
||
basicSetup,
|
||
// Ctrl/Cmd + Enter 运行快捷键:用 Prec.highest 包裹,保证在 vim 的
|
||
// ViewPlugin 按键拦截之前命中(vim 自己也用 Prec.highest,跟随此惯例)。
|
||
...(options.onRunShortcut
|
||
? [
|
||
Prec.highest(
|
||
keymap.of([
|
||
{
|
||
key: 'Mod-Enter',
|
||
preventDefault: true,
|
||
run: () => {
|
||
options.onRunShortcut?.();
|
||
return true;
|
||
},
|
||
},
|
||
]),
|
||
),
|
||
]
|
||
: []),
|
||
// vim 必须在 keymap 最前(@replit/codemirror-vim 仓库要求)
|
||
this.vimCompartment.of(options.vim ? [vim()] : []),
|
||
this.themeCompartment.of(themeExtension(theme)),
|
||
this.languageCompartment.of(buildLanguageExtension(this.language, schema)),
|
||
EditorView.updateListener.of((v) => {
|
||
if (v.docChanged) {
|
||
options.onChange?.(this.view.state.doc.toString());
|
||
}
|
||
}),
|
||
],
|
||
}),
|
||
parent: container,
|
||
});
|
||
|
||
options.onReady?.();
|
||
}
|
||
|
||
getValue(): string {
|
||
return this.view.state.doc.toString();
|
||
}
|
||
|
||
setValue(s: string): void {
|
||
this.view.dispatch({
|
||
changes: { from: 0, to: this.view.state.doc.length, insert: s },
|
||
});
|
||
}
|
||
|
||
/** 热切换主题,不重建实例(Compartment.reconfigure)。 */
|
||
setTheme(theme: ThemeName): void {
|
||
this.view.dispatch({
|
||
effects: this.themeCompartment.reconfigure(themeExtension(theme)),
|
||
});
|
||
}
|
||
|
||
/** 热切换 Vim 模式,不重建实例(Compartment.reconfigure)。 */
|
||
setVim(v: boolean): void {
|
||
this.view.dispatch({
|
||
effects: this.vimCompartment.reconfigure(v ? [vim()] : []),
|
||
});
|
||
}
|
||
|
||
/** 热切换语言(python/node/javascript/sql),不重建实例(Compartment.reconfigure)。 */
|
||
setLanguage(lang: string): void {
|
||
this.language = lang;
|
||
this.view.dispatch({
|
||
effects: this.languageCompartment.reconfigure(buildLanguageExtension(lang, this.schema)),
|
||
});
|
||
}
|
||
|
||
/** 更新 SQL 补全 schema(Compartment.reconfigure)。
|
||
* 当前语言为 SQL 时立即生效;非 SQL 语言会缓存 schema,切回 SQL 时生效。 */
|
||
setSchema(schema: SqlSchema): void {
|
||
this.schema = schema;
|
||
// 仅当当前是 SQL 语言时才重配 extension(其它语言不消费 schema)。
|
||
if ((this.language ?? '').toLowerCase() === 'sql' || !this.language) {
|
||
this.view.dispatch({
|
||
effects: this.languageCompartment.reconfigure(buildLanguageExtension('sql', schema)),
|
||
});
|
||
}
|
||
}
|
||
|
||
focus(): void {
|
||
this.view.focus();
|
||
}
|
||
|
||
destroy(): void {
|
||
this.view.destroy();
|
||
}
|
||
}
|
||
|
||
/** 把 SqlSchema 转成 @codemirror/lang-sql 期望的补全结构。 */
|
||
function schemaToCompletions(schema: SqlSchema) {
|
||
return schema.tables.map((t) => ({
|
||
label: t.name,
|
||
type: 'table',
|
||
detail: 'table',
|
||
columns: t.columns.map((c) => ({ label: c, type: 'column' })),
|
||
}));
|
||
}
|
||
|
||
// 暴露 EditorOptions 到 window,供 wasm-bindgen 用 new EditorOptions()。
|
||
// IIFE 的 name 只能挂一个全局(CodeMirrorEditor),故手动 hoist EditorOptions。
|
||
declare global {
|
||
interface Window {
|
||
EditorOptions: typeof EditorOptions;
|
||
}
|
||
}
|
||
window.EditorOptions = EditorOptions;
|