feat(editor): add codemirror-editor subproject + Rust bridge

新建 libs/codemirror-editor(pnpm/Vite IIFE/@catppuccin/codemirror Catppuccin
Latte+Mocha 主题/@codemirror/lang-sql schema 补全/@replit/codemirror-vim),
输出 public/codemirror/。Rust 桥接 codemirror_bridge.rs 镜像 tiptap_bridge.rs
(Reflect::get 取对象字面量 + EditorHandle 持有闭包 + Drop→destroy)。
接入 Makefile/Dioxus.toml/.gitignore。

注意:thememirror 不含 catppuccin 主题,改用官方 @catppuccin/codemirror@1.0.3
(导出 catppuccinLatte/catppuccinMocha)。
This commit is contained in:
xfy 2026-06-29 18:18:31 +08:00
parent 2776d39ac1
commit c74e59485d
14 changed files with 1568 additions and 4 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ others/
public/style.css
public/highlight.css
public/tiptap
public/codemirror
public/lightbox
public/yggdrasil-core
public/doc

View File

@ -11,8 +11,8 @@ watch_path = ["src", "Cargo.toml"]
[web.resource]
style = ["/style.css", "/highlight.css", "/tiptap/editor.css", "/lightbox/lightbox.css", "/yggdrasil-core/yggdrasil-core.css"]
script = ["/tiptap/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]
script = ["/tiptap/editor.js", "/codemirror/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]
[web.resource.dev]
style = ["/style.css", "/highlight.css", "/tiptap/editor.css", "/lightbox/lightbox.css", "/yggdrasil-core/yggdrasil-core.css"]
script = ["/tiptap/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]
script = ["/tiptap/editor.js", "/codemirror/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]

View File

@ -1,9 +1,10 @@
.PHONY: dev build build-linux css css-watch clean build-editor build-editor-incremental build-lightbox build-lightbox-incremental build-core build-core-incremental highlight-css test doc doc-open start clippy fix
.PHONY: dev build build-linux css css-watch clean build-editor build-editor-incremental build-lightbox build-lightbox-incremental build-core build-core-incremental build-codemirror build-codemirror-incremental highlight-css test doc doc-open start clippy fix
build:
@$(MAKE) build-editor
@$(MAKE) build-lightbox
@$(MAKE) build-core
@$(MAKE) build-codemirror
@$(MAKE) highlight-css
@tailwindcss -i input.css -o public/style.css --minify
@$(MAKE) doc
@ -13,6 +14,7 @@ build-linux:
@$(MAKE) build-editor
@$(MAKE) build-lightbox
@$(MAKE) build-core
@$(MAKE) build-codemirror
@$(MAKE) highlight-css
@tailwindcss -i input.css -o public/style.css --minify
@dx build @client --release --debug-symbols=false --wasm-js-cfg false
@ -53,7 +55,16 @@ build-core:
build-core-incremental:
@cd libs/yggdrasil-core && pnpm run build
dev: build-editor-incremental build-lightbox-incremental build-core-incremental
build-codemirror:
@echo "Building CodeMirror editor..."
@cd libs/codemirror-editor && pnpm ci --include=dev && pnpm run build
@echo "CodeMirror editor built."
# dev 用的增量构建:跳过 pnpm ci假设 node_modules 已存在),仅 vite build。
build-codemirror-incremental:
@cd libs/codemirror-editor && pnpm run build
dev: build-editor-incremental build-lightbox-incremental build-core-incremental build-codemirror-incremental
@echo "Cleaning static/..."
@rm -rf static/
@echo "Building Tiptap editor (incremental)..."
@ -74,6 +85,7 @@ test:
@cd libs/tiptap-editor && pnpm test
@cd libs/lightbox && pnpm test
@cd libs/yggdrasil-core && pnpm test
@cd libs/codemirror-editor && pnpm test
clippy:
@cargo clippy --all-targets --all-features -- -D warnings

View File

@ -0,0 +1,30 @@
{
"name": "@yggdrasil/codemirror-editor",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit && vite build",
"dev": "vite",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@catppuccin/codemirror": "^1.0.3",
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.36.0",
"@replit/codemirror-vim": "^6.3.0",
"codemirror": "^6.0.1"
},
"devDependencies": {
"happy-dom": "^20.10.6",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vitest": "^4.1.9"
}
}

1041
libs/codemirror-editor/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { EditorOptions, CodeMirrorInstance } from '../editor';
describe('CodeMirrorInstance', () => {
let container: HTMLElement;
beforeEach(() => {
container = document.createElement('div');
container.id = 'test-cm';
document.body.appendChild(container);
});
it('getValue/setValue 往返', () => {
const inst = new CodeMirrorInstance(container, new EditorOptions());
inst.setValue('SELECT 1');
expect(inst.getValue()).toBe('SELECT 1');
inst.destroy();
});
it('初始 value 正确', () => {
const opts = new EditorOptions();
opts.value = 'SELECT * FROM posts';
const inst = new CodeMirrorInstance(container, opts);
expect(inst.getValue()).toBe('SELECT * FROM posts');
inst.destroy();
});
it('setTheme 不抛错(走 Compartment reconfigure', () => {
const inst = new CodeMirrorInstance(container, new EditorOptions());
expect(() => inst.setTheme('dark')).not.toThrow();
expect(() => inst.setTheme('light')).not.toThrow();
inst.destroy();
});
it('setSchema 更新 lang-sql 配置', () => {
const inst = new CodeMirrorInstance(container, new EditorOptions());
expect(() =>
inst.setSchema({ tables: [{ name: 'posts', columns: ['id', 'title'] }] }),
).not.toThrow();
inst.destroy();
});
it('vim 开关vim:true 注入false 不注入', () => {
const optsOn = new EditorOptions();
optsOn.vim = true;
const instOn = new CodeMirrorInstance(container, optsOn);
instOn.destroy();
const optsOff = new EditorOptions();
optsOff.vim = false;
const instOff = new CodeMirrorInstance(container, optsOff);
instOff.destroy();
// happy-dom 无法验证 keymap 行为,仅验证配置加载不抛错
});
it('onChange 在内容变更时触发', () => {
let captured = '';
const opts = new EditorOptions();
opts.onChange = (v) => {
captured = v;
};
const inst = new CodeMirrorInstance(container, opts);
inst.setValue('hello');
expect(captured).toBe('hello');
inst.destroy();
});
});

View File

@ -0,0 +1,127 @@
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import { sql, PostgreSQL } from '@codemirror/lang-sql';
import { vim } from '@replit/codemirror-vim';
import { themeExtension, type ThemeName } from './themes';
/** 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;
}
/**
* CodeMirror
* create() Compartment theme/schema/vim
* reconfigure Vim
*/
export class CodeMirrorInstance {
private view: EditorView;
private themeCompartment = new Compartment();
private schemaCompartment = new Compartment();
private vimCompartment = new Compartment();
constructor(container: HTMLElement, options: EditorOptions) {
const theme: ThemeName = options.theme ?? 'light';
const schema = options.schema ?? { tables: [] };
this.view = new EditorView({
state: EditorState.create({
doc: options.value ?? '',
extensions: [
basicSetup,
// vim 必须在 keymap 最前(@replit/codemirror-vim 仓库要求)
this.vimCompartment.of(options.vim ? [vim()] : []),
this.themeCompartment.of(themeExtension(theme)),
this.schemaCompartment.of(
sql({
dialect: PostgreSQL,
schema: schemaToCompletions(schema),
upperCaseKeywords: true,
}),
),
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)),
});
}
/** 更新 SQL 补全 schemaCompartment.reconfigure。 */
setSchema(schema: SqlSchema): void {
this.view.dispatch({
effects: this.schemaCompartment.reconfigure(
sql({
dialect: PostgreSQL,
schema: schemaToCompletions(schema),
upperCaseKeywords: true,
}),
),
});
}
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;

View File

@ -0,0 +1,31 @@
import { CodeMirrorInstance, EditorOptions } from './editor';
/**
* { create }
* IIFE window.CodeMirrorEditor Rust Reflect::get
* wasm-bindgen extern fn
*/
const CodeMirrorEditor = {
_instances: new Map<string, CodeMirrorInstance>(),
create(
containerId: string,
options: EditorOptions = new EditorOptions(),
): CodeMirrorInstance | null {
const container = document.getElementById(containerId);
if (!container) return null;
// 销毁同 id 的旧实例
const existing = this._instances.get(containerId);
if (existing) {
existing.destroy();
this._instances.delete(containerId);
}
const instance = new CodeMirrorInstance(container, options);
this._instances.set(containerId, instance);
return instance;
},
};
export default CodeMirrorEditor;

View File

@ -0,0 +1,11 @@
// @catppuccin/codemirror 提供现成的 Catppuccin 主题 Extension
// 与项目 themes/ 下的 Catppuccin Latte/Mocha .tmTheme 视觉一致。
import { catppuccinLatte, catppuccinMocha } from '@catppuccin/codemirror';
import type { Extension } from '@codemirror/state';
export type ThemeName = 'light' | 'dark';
/** 根据主题名返回对应的 CodeMirror 主题 Extension。 */
export function themeExtension(name: ThemeName): Extension {
return name === 'light' ? catppuccinLatte : catppuccinMocha;
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": []
},
"include": ["src"]
}

View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
// 输出直写 public/codemirror/Dioxus 直接托管,无需拷贝步骤。
outDir: resolve(__dirname, '../../public/codemirror'),
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'src/index.ts'),
// IIFE 产物挂在 window.CodeMirrorEditor 上Rust 侧用 Reflect::get 取。
name: 'CodeMirrorEditor',
fileName: () => 'editor.js',
formats: ['iife'],
},
rolldownOptions: {
output: {
// 默认导出(对象字面量 { create() })成为 window.CodeMirrorEditor。
exports: 'default',
assetFileNames: 'editor.[ext]',
},
},
cssCodeSplit: false,
minify: true,
sourcemap: true,
},
});

View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts'],
},
})

187
src/codemirror_bridge.rs Normal file
View File

@ -0,0 +1,187 @@
//! CodeMirror 编辑器的 wasm-bindgen 绑定层。
//!
//! 封装与 `window.CodeMirrorEditor`IIFE 暴露的全局对象字面量)的全部交互,
//! 严格镜像 [`crate::tiptap_bridge`] 的结构:共享纯数据类型双目标编译,
//! wasm-bindgen extern + [`EditorHandle`] 仅在 WASM 前端编译server 构建无 window
//!
//! 与 tiptap 一样,`CodeMirrorEditor` 是 IIFE 挂在 window 上的**对象字面量**
//! `{ create }`),不是函数——因此用 `js_sys::Reflect::get` 做属性访问拿到,
//! 不能用 wasm-bindgen 的 extern fn那会被编成函数调用"not a function")。
use serde::{Deserialize, Serialize};
/// SQL 补全用 schema 数据,由 `get_db_schema` server function 填充。
// 暂未被消费Task 5 的 get_db_schema 会用到),先 allow dead_code 避免 -D warnings 阻断 clippy。
#[allow(dead_code)]
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct SqlSchema {
pub tables: Vec<SqlTable>,
}
/// 单张表的补全数据:表名 + 列名列表。
#[allow(dead_code)]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SqlTable {
pub name: String,
pub columns: Vec<String>,
}
// ============================================================================
// 以下全部仅在 WASM 前端编译wasm-bindgen extern + EditorHandle + 闭包。
// 放在 #[cfg] 子模块内,避免 server 构建尝试编译引用 JS 对象的 extern。
// ============================================================================
#[cfg(target_arch = "wasm32")]
pub mod wasm {
use super::SqlSchema;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
// —— window.CodeMirrorEditor 模块对象 ——
//
// CodeMirrorEditor 是 IIFE 产物挂在 window 上的对象字面量(含 create 方法),
// 不是函数。wasm-bindgen 对 `fn get_module() -> T` 形式的 extern 会生成
// `window.CodeMirrorEditor()`(函数调用),会因 "not a function" 失败。
// 因此用 js_sys::Reflect::get 做属性访问拿到模块对象,再 unchecked_into。
#[wasm_bindgen]
extern "C" {
/// `window.CodeMirrorEditor` 模块对象的 Rust 映射IIFE 产物挂在 window 上的对象字面量)。
/// 不是函数——通过 [`get_module`] 用 Reflect::get 取属性而非 extern fn 调用拿到。
pub type CodeMirrorEditorModule;
/// 调用 `CodeMirrorEditor.create(containerId, opts)`。
/// 找不到容器返回 null被 Option 捕获);构造失败抛异常(被 catch 捕获)。
#[wasm_bindgen(method, catch)]
pub fn create(
this: &CodeMirrorEditorModule,
container_id: &str,
opts: &EditorOptions,
) -> Result<Option<EditorInstance>, JsValue>;
}
/// 读取 `window.CodeMirrorEditor`IIFE 默认导出,顶层 var 即 window 属性)。
/// 用 Reflect::get 做属性访问——extern fn 形式会被 wasm-bindgen 编成函数调用。
///
/// 用 unchecked_into 而非 dyn_intoCodeMirrorEditor 是 JS 对象字面量,
/// 不是 wasm-bindgen 注册的构造函数实例dyn_into 的 instanceof 检查必然失败。
/// unchecked_into 只做编译期类型标注,不做运行时校验
/// Reflect.get 已保证拿到的是目标对象)。
pub fn get_module() -> CodeMirrorEditorModule {
let window = web_sys::window().expect("no window");
let val = js_sys::Reflect::get(&window, &"CodeMirrorEditor".into())
.expect("window.CodeMirrorEditor missing");
val.unchecked_into::<CodeMirrorEditorModule>()
}
// —— 编辑器实例CodeMirrorInstance——
#[wasm_bindgen]
extern "C" {
/// `CodeMirrorEditor.create` 返回的编辑器实例对象,承载 CodeMirror EditorView。
pub type EditorInstance;
/// 返回当前文档全文。
#[wasm_bindgen(method, js_name = getValue)]
pub fn get_value(this: &EditorInstance) -> String;
/// 替换整个文档内容dispatch changes触发 onChange
#[wasm_bindgen(method, js_name = setValue)]
pub fn set_value(this: &EditorInstance, s: &str);
/// 热切换主题Compartment.reconfigure不重建实例
#[wasm_bindgen(method, js_name = setTheme)]
pub fn set_theme(this: &EditorInstance, theme: &str);
/// 更新 SQL 补全 schemaCompartment.reconfigure
#[wasm_bindgen(method, js_name = setSchema)]
pub fn set_schema(this: &EditorInstance, schema: &SqlSchema);
/// 让编辑器获取焦点。
#[wasm_bindgen(method)]
pub fn focus(this: &EditorInstance);
/// 销毁编辑器,释放 JS 侧资源。
#[wasm_bindgen(method)]
pub fn destroy(this: &EditorInstance);
}
// —— EditorOptions用 builder 模式setter构造 JS 对象 ——
#[wasm_bindgen]
extern "C" {
/// 传给 `CodeMirrorEditor.create` 的配置对象,对应 JS 侧的 EditorOptions。
/// 用 `new()` 创建空对象后通过 setter 链式设置字段。
pub type EditorOptions;
/// 构造一个空的 EditorOptions随后用各 setter 填充。
#[wasm_bindgen(constructor)]
pub fn new() -> EditorOptions;
/// 语言(默认 'sql')。
#[wasm_bindgen(method, setter, js_name = language)]
pub fn set_language(this: &EditorOptions, v: &str);
/// 主题:'light'Catppuccin Latte或 'dark'Catppuccin Mocha
#[wasm_bindgen(method, setter, js_name = theme)]
pub fn set_theme(this: &EditorOptions, v: &str);
/// 是否启用 Vim keymap。
#[wasm_bindgen(method, setter, js_name = vim)]
pub fn set_vim(this: &EditorOptions, v: bool);
/// SQL 补全 schema表/列数据)。
#[wasm_bindgen(method, setter, js_name = schema)]
pub fn set_schema(this: &EditorOptions, v: &SqlSchema);
/// 初始文档内容。
#[wasm_bindgen(method, setter, js_name = value)]
pub fn set_value(this: &EditorOptions, v: &str);
/// 文档变更回调(参数为最新全文)。
#[wasm_bindgen(method, setter, js_name = onChange)]
pub fn set_on_change(this: &EditorOptions, cb: &Closure<dyn FnMut(String)>);
/// 编辑器就绪回调(构造末尾同步触发一次)。
#[wasm_bindgen(method, setter, js_name = onReady)]
pub fn set_on_ready(this: &EditorOptions, cb: &Closure<dyn FnMut()>);
}
/// 编辑器实例句柄:持有 instance + 所有 ClosureDrop 时销毁实例并释放闭包。
///
/// 闭包字段 `_` 前缀表示仅用于保持生命周期——它们被注入 JS 后JS 侧持有
/// 函数引用;只要 [`EditorHandle`] 存活闭包就不会被回收。Drop 时随结构释放。
pub struct EditorHandle {
instance: EditorInstance,
_on_change: Closure<dyn FnMut(String)>,
_on_ready: Closure<dyn FnMut()>,
}
impl EditorHandle {
/// 调用方须先把各 closure set 进 EditorOptions再 create
/// 然后把返回的 instance + 同名 closure 一起传入 new。
pub fn new(
instance: EditorInstance,
on_change: Closure<dyn FnMut(String)>,
on_ready: Closure<dyn FnMut()>,
) -> Self {
Self {
instance,
_on_change: on_change,
_on_ready: on_ready,
}
}
/// 借用底层实例,供宿主调 getValue/setTheme/setSchema 等。
pub fn instance(&self) -> &EditorInstance {
&self.instance
}
}
impl Drop for EditorHandle {
fn drop(&mut self) {
// 销毁 JS 侧编辑器;随后 _on_change/_on_ready 字段按声明顺序释放,
// 释放 wasm-bindgen 函数表槽位。
let _ = self.instance.destroy();
}
}
}
#[cfg(target_arch = "wasm32")]
pub use wasm::*;

View File

@ -31,6 +31,9 @@ mod theme;
// tiptap_bridge共享类型UploadsInFlight/UploadErrorEntry两端都编译
// wasm-bindgen extern 与 EditorHandle 在内部的 #[cfg(wasm32)] 子模块里。
mod tiptap_bridge;
// codemirror_bridgeSQL 编辑器的 wasm-bindgen 绑定,结构镜像 tiptap_bridge。
// 共享类型SqlSchema/SqlTable两端都编译extern 与 EditorHandle 在 #[cfg(wasm32)] 子模块里。
mod codemirror_bridge;
mod utils;
mod webp;