xfy a47e1e6027 refactor(build): workspace 根迁移至 libs/,Makefile 整合 lint/fix
将 pnpm workspace 根从项目根移到 libs/,项目根保持纯 Rust,JS 工作区自治:
- package.json/pnpm-workspace.yaml/pnpm-lock.yaml/biome.json 迁入 libs/
- pnpm-workspace.yaml glob 从 'libs/*' 改为 '*'(已在 libs/ 内)
- biome.json 关闭 vcs.useIgnoreFile(libs/ 无独立 .gitignore,
  files.includes 已显式排除 node_modules/dist)
- Makefile 所有 pnpm/biome 调用加 'cd libs &&' 前缀

Makefile 同步整合 lint/fix(替代独立 clippy/fix target):
- make lint = biome check (libs) + cargo clippy
- make fix  = biome format --write (libs) + cargo fix
- 删除 8 个 build-*-incremental/build-* target,改为 build-libs 聚合 +
  build-<name> 用 pnpm --filter 单库便利 target

修复 biome 格式化回归:tiptap-editor/src/index.ts:83 的 sourceTextarea!.value
被 biome 误转为可选链 ?.,导致 TS2345(string | undefined 不匹配 string),
已还原为非空断言。
2026-07-02 18:02:52 +08:00

306 lines
11 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 { Editor } from '@tiptap/core';
import { FileHandler } from '@tiptap/extension-file-handler';
import { TaskItem, TaskList } from '@tiptap/extension-list';
import { TableKit } from '@tiptap/extension-table';
import { Markdown } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import { SlashCommand } from './slash-command';
import { TaskInputRule } from './task-input-rule';
import {
UPLOAD_COORDINATOR_STORAGE_KEY,
UploadCoordinator,
type UploadEvent,
} from './upload-coordinator';
import { UploadImage } from './upload-image';
import './style.css';
/**
* 编辑器选项。用 class 而非 interface使其编译后保留运行时构造函数——
* Rust 侧 wasm-bindgen 的 `#[wasm_bindgen(constructor)]` 会生成 `new EditorOptions()`
* 需要一个全局可访问的构造函数interface 会被 TS 擦除)。
* 字段全部可选Rust 通过 setterset_placeholder/set_on_update/...)逐个赋值。
*
* 不用 export避免 IIFE 的 named export 与 default 冲突),而是挂到 globalThis
* 让 wasm-bindgen glue 里的裸标识符 `EditorOptions` 能解析到。
*/
class EditorOptions {
content?: string;
placeholder?: string;
onUpdate?: (markdown: string) => void;
onFocus?: () => void;
onBlur?: () => void;
editable?: boolean;
// 图片上传回调
onImageUpload?: (file: File) => Promise<string>;
// 编辑器实例创建完成(含 coordinator后同步触发一次替代 window.__tiptap_ready 轮询
onReady?: () => void;
// 上传状态事件error/success/removed + counts替代 window.__tiptap_uploads 轮询
onUploadEvent?: (event: UploadEvent) => void;
}
// wasm-bindgen 生成的 glue 用裸标识符 `new EditorOptions()` 从全局解析,
// IIFE 的 name 只能挂一个全局TiptapEditor这里手动把 EditorOptions 也挂到 window 上。
(window as unknown as Record<string, unknown>).EditorOptions = EditorOptions;
class TiptapEditorInstance {
private editor: Editor | null = null;
private container: HTMLElement;
private options: EditorOptions;
private isSourceMode = false;
private sourceTextarea: HTMLTextAreaElement | null = null;
private toggleButton: HTMLButtonElement | null = null;
private coordinator: UploadCoordinator | null = null;
constructor(container: HTMLElement, options: EditorOptions = new EditorOptions()) {
this.container = container;
this.options = options;
this.init();
}
private init() {
const el = document.createElement('div');
el.className = 'tiptap-editor';
this.container.appendChild(el);
// 源码模式切换按钮:悬浮于编辑器右上角
this.toggleButton = document.createElement('button');
this.toggleButton.className = 'tiptap-toggle-btn';
this.toggleButton.type = 'button';
this.toggleButton.title = '切换 Markdown 源码';
this.toggleButton.textContent = '</>';
this.toggleButton.addEventListener('click', () => this.toggleSource());
el.appendChild(this.toggleButton);
// 源码模式 textarea初始隐藏与 ProseMirror 共用同一区域
this.sourceTextarea = document.createElement('textarea');
this.sourceTextarea.className = 'tiptap-source-textarea';
this.sourceTextarea.hidden = true;
this.sourceTextarea.placeholder = '在此输入 Markdown 源码...';
this.sourceTextarea.spellcheck = false;
this.sourceTextarea.addEventListener('input', () => {
// 源码模式下通过 onUpdate 回调同步内容(替代旧版 window.__tiptap_content 缓存)
if (this.options.onUpdate) {
this.options.onUpdate(this.sourceTextarea!.value);
}
});
el.appendChild(this.sourceTextarea);
this.editor = new Editor({
element: el,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
link: {
openOnClick: false,
autolink: true,
linkOnPaste: true,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
},
}),
Markdown,
TableKit,
UploadImage,
TaskList,
TaskItem.configure({ nested: true }),
// 让手动输入 - [ ] / - [x] 直接创建任务列表(priority 1000 抢在 BulletList 前)
TaskInputRule,
// 把宿主注入的图片上传回调透传给斜杠命令扩展,使 /上传图片 命令可用。
// 注意:闭包延迟读取 this.coordinator它在 editor 创建后才实例化)。
SlashCommand.configure({
onImageUpload: this.options.onImageUpload,
onInsertUploading: this.options.onImageUpload
? (file) => this.coordinator?.insertUploading(file)
: undefined,
}),
FileHandler.configure({
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
onPaste: (_editor, files) => {
if (this.coordinator) {
files.forEach((file) => {
this.coordinator?.insertUploading(file);
});
}
},
onDrop: (_editor, files, pos) => {
if (this.coordinator) {
files.forEach((file) => {
this.coordinator?.insertUploading(file, pos);
});
}
},
}),
],
content: this.options.content || '',
editable: this.options.editable !== false,
autofocus: false,
onUpdate: ({ editor }) => {
if (this.options.onUpdate) {
this.options.onUpdate(editor.getMarkdown());
}
},
onFocus: () => {
if (this.options.onFocus) {
this.options.onFocus();
}
},
onBlur: () => {
if (this.options.onBlur) {
this.options.onBlur();
}
},
});
// 创建上传协调器,挂到 editor.storage 供 NodeView 按实例读取(支持多编辑器实例)。
// onUploadEvent 透传给 coordinator.emit未提供时空操作兜底。
if (this.options.onImageUpload) {
this.coordinator = new UploadCoordinator(
this.editor,
this.options.onImageUpload,
this.options.onUploadEvent ?? (() => {}),
);
// editor.storage 是开放式索引签名;自定义 key 需绕过严格检查Tiptap 官方扩展 storage 模式)。
(this.editor.storage as unknown as Record<string, unknown>)[UPLOAD_COORDINATOR_STORAGE_KEY] =
this.coordinator;
}
// 通知宿主编辑器已就绪(替代 window.__tiptap_ready 轮询)
this.options.onReady?.();
}
getMarkdown(): string {
// 源码模式下直接返回 textarea 内容,确保提交逻辑无需感知视图模式
if (this.isSourceMode && this.sourceTextarea) {
return this.sourceTextarea.value;
}
return this.editor?.getMarkdown() || '';
}
/**
* 在富文本模式与 Markdown 源码模式之间切换。
* 切换时同步内容:富文本 → 源码用 getMarkdown 导出;源码 → 富文本用 setMarkdown 回填。
*/
toggleSource(): void {
if (!this.editor || !this.sourceTextarea || !this.toggleButton) return;
// ProseMirror 的实际 DOM 节点(.ProseMirror用于切换显隐。
const proseMirrorDom = this.editor.view.dom;
if (!this.isSourceMode) {
// 富文本 → 源码:导出当前 Markdown 到 textarea
this.sourceTextarea.value = this.editor.getMarkdown();
// 必须在 display:'none' 之前读取滚动比例——隐藏后 scrollTop 会被浏览器归零
const pmRatio = this.getScrollRatio(proseMirrorDom);
proseMirrorDom.style.display = 'none';
this.sourceTextarea.hidden = false;
this.applyScrollRatio(this.sourceTextarea, pmRatio);
// focus() 会触发浏览器把光标所在行滚动进可视区域,光标默认在内容末尾,
// 会把 textarea 拉到底部覆盖上面设好的滚动位置。因此先记录、focus 后再恢复。
const scrollTopBeforeFocus = this.sourceTextarea.scrollTop;
this.sourceTextarea.focus();
this.sourceTextarea.scrollTop = scrollTopBeforeFocus;
this.toggleButton.textContent = '✎';
this.toggleButton.title = '切换富文本';
this.isSourceMode = true;
} else {
// 源码 → 富文本:把 textarea 内容回填到编辑器
const md = this.sourceTextarea.value;
// 先记录源码视图的滚动比例setContent 会重建文档,必须在替换前拿到比例)
const sourceRatio = this.getScrollRatio(this.sourceTextarea);
this.setMarkdown(md);
this.sourceTextarea.hidden = true;
proseMirrorDom.style.display = '';
// 等待 DOM 布局更新后,按比例同步富文本视图滚动位置
requestAnimationFrame(() => {
this.applyScrollRatio(proseMirrorDom, sourceRatio);
});
this.toggleButton.textContent = '</>';
this.toggleButton.title = '切换 Markdown 源码';
this.isSourceMode = false;
// 注意:不调用 editor.commands.focus(),它会强制滚动到光标位置(默认文档末尾),破坏比例同步
}
}
/**
* 读取可滚动容器的滚动比例0~1
* 比例 = scrollTop / 可滚动总距离,避免两种模式内容高度不同导致像素无法直接对应。
*/
private getScrollRatio(el: HTMLElement): number {
const max = el.scrollHeight - el.clientHeight;
if (max <= 0) return 0;
return el.scrollTop / max;
}
/**
* 按比例设置目标容器的滚动位置。
*/
private applyScrollRatio(el: HTMLElement, ratio: number): void {
const max = el.scrollHeight - el.clientHeight;
if (max <= 0) return;
el.scrollTop = max * ratio;
}
setMarkdown(content: string): void {
this.editor?.commands.setContent(content, { emitUpdate: false, contentType: 'markdown' });
}
getHTML(): string {
return this.editor?.getHTML() || '';
}
focus(): void {
this.editor?.commands.focus();
}
blur(): void {
this.editor?.commands.blur();
}
isEmpty(): boolean {
return this.editor?.isEmpty ?? true;
}
/** Rust 侧"×关闭"提示时调用(通过 eval。返回是否成功删除。 */
removeUploadByUploadId(uploadId: string): boolean {
return this.coordinator?.removeUpload(uploadId) ?? false;
}
destroy(): void {
this.editor?.destroy();
this.editor = null;
// coordinator 通过 editor.storage 访问,随 editor 实例一同回收,无需显式清除引用。
this.coordinator = null;
// 清理源码模式相关引用(容器 innerHTML 已清空DOM 会随之移除)
this.sourceTextarea = null;
this.toggleButton = null;
this.isSourceMode = false;
this.container.innerHTML = '';
}
}
const TiptapEditor = {
_instances: new Map<string, TiptapEditorInstance>(),
create(
containerId: string,
options: EditorOptions = new EditorOptions(),
): TiptapEditorInstance | null {
const container = document.getElementById(containerId);
if (!container) {
console.error(`[TiptapEditor] Container not found: #${containerId}`);
return null;
}
const existing = this._instances.get(containerId);
if (existing) {
existing.destroy();
}
const instance = new TiptapEditorInstance(container, options);
this._instances.set(containerId, instance);
return instance;
},
};
export default TiptapEditor;