261 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-sqlPostgreSQL 方言 + 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 补全 schemaCompartment.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;