From ba364903bcd7055dcde4acc820f2b32158b20ed4 Mon Sep 17 00:00:00 2001 From: bunbun79 Date: Fri, 10 Jul 2026 14:09:04 +0800 Subject: [PATCH] refactor(core): standardize Widget API with EditorWidget base class - Add EditorWidget abstract base class (extends WidgetType) with: editing lock management, lifecycle hooks, WidgetContext, estimatedHeight contract - Add WidgetInteractionState / isWidgetEditing() to replace bare tableEditingCount - Add WidgetContext interface for safe CM6 view access from widgets - Migrate EditableTableWidget to extend EditorWidget - Migrate NexusWidget to extend EditorWidget with lifecycle hook support - Add WidgetLifecycleHooks to WidgetDefinition (backward-compatible) - Deprecate WidgetDefinition.destroy in favor of lifecycle.willDestroy - Add 6 table clipboard/native-selection tests - Add OpenSpec proposal for widget-api standardization Closes: P1 roadmap item 'Widget API standardization' --- .../add-widget-api-standardization/design.md | 178 +++++++++++ .../proposal.md | 37 +++ .../specs/widget-api/spec.md | 151 +++++++++ .../add-widget-api-standardization/tasks.md | 99 ++++++ packages/core/src/index.ts | 14 +- packages/core/src/live-preview-table.ts | 66 ++-- packages/core/src/live-preview.ts | 3 +- packages/core/src/types.ts | 34 ++ packages/core/src/widget-base.ts | 255 +++++++++++++++ packages/core/src/widget-context.ts | 47 +++ packages/core/src/widget-extension.ts | 68 +++- packages/core/src/widget-state.ts | 88 ++++++ packages/core/test/live-preview.test.ts | 290 ++++++++++++++++++ 13 files changed, 1289 insertions(+), 41 deletions(-) create mode 100644 openspec/changes/add-widget-api-standardization/design.md create mode 100644 openspec/changes/add-widget-api-standardization/proposal.md create mode 100644 openspec/changes/add-widget-api-standardization/specs/widget-api/spec.md create mode 100644 openspec/changes/add-widget-api-standardization/tasks.md create mode 100644 packages/core/src/widget-base.ts create mode 100644 packages/core/src/widget-context.ts create mode 100644 packages/core/src/widget-state.ts diff --git a/openspec/changes/add-widget-api-standardization/design.md b/openspec/changes/add-widget-api-standardization/design.md new file mode 100644 index 00000000..831fbac8 --- /dev/null +++ b/openspec/changes/add-widget-api-standardization/design.md @@ -0,0 +1,178 @@ +# Design: Widget API 标准化 + +## Context + +Nexus-Editor 的 Widget 系统需要同时服务两类用户: +1. **插件作者** —— 想为自定义 AST 节点渲染只读/简单交互的 DOM +2. **核心开发者** —— 构建高度交互的 Widget(可编辑表格、Mermaid 图表等) + +当前两套独立的系统导致知识无法迁移、bug 重复出现、每个新 Widget 都要从零开始。 + +### 约束 +- 必须兼容 CM6 `WidgetType`(这是 CM6 decoration 系统的硬性要求) +- 不能破坏现有插件的 `WidgetDefinition` 接口(需要渐进式迁移) +- `live-preview.ts` 已有 1500+ 行代码,不能在此次变更中大规模重构 + +## Goals / Non-Goals + +### Goals +- 提供一个 `EditorWidget` 基类,封装所有已知的 Widget 编写陷阱(CLAUDE.md 12 条规则) +- 用 `WidgetInteractionState` 替换裸的 `tableEditingCount` 全局变量 +- 建立 Widget 生命周期钩子契约(`didMount`/`willUpdate`/`didUpdate`/`willDestroy`/`activate`/`deactivate`) +- 让插件 Widget(`WidgetDefinition`)和核心 Widget 共享同一套基础设施 +- 提供 Widget 编写文档,集中管理 pattern 和 anti-pattern + +### Non-Goals +- 不在此次变更中为 live preview 内部 Widget(table/mermaid/code)引入 AST 感知的通用框架 +- 不建立 Widget 间通信机制(event bus)—— 那是 P2 的工作 +- 不提供声明式 Widget 配置(JSX/模板)—— 保持程序化 API +- 不改变 Widget 在 CM6 decoration 层的工作方式(仍然用 `Decoration.replace`) + +## Decisions + +### Decision 1: `EditorWidget` 继承 `WidgetType`,不创建包装层 + +**选择**:`EditorWidget extends WidgetType`,添加生命周期钩子和编辑锁管理。 + +**替代方案**:创建一个包装 `WidgetType` 的 `WidgetController` 类。 +- 否决原因:增加一层间接,CM6 直接调用 `WidgetType` 方法;包装层会让 `eq()`、`ignoreEvent()` 等需要两处实现。 + +### Decision 2: `WidgetInteractionState` 作为 CM6 StateField 而非模块级变量 + +**选择**:将编辑锁计数器实现为 `StateField`,挂在 CM6 state 上。 + +```typescript +interface WidgetInteractionState { + activeWidgetCount: number; // 替代 tableEditingCount + activeWidgets: Set; // 哪些 widget 正在交互 +} +``` + +**替代方案**:保持模块级变量 + event emitter。 +- 否决原因:不可序列化(无法在 devtools 中检视),不可测试,不能随 CM6 state 同步 + +### Decision 3: `WidgetContext` 通过 `toDOM()` 注入,不用全局单例 + +**选择**:每个 `EditorWidget.toDOM(view)` 调用时,基类从 CM6 WidgetType 的 `toDOM(view)` 签名中提取 `EditorView` 并构建 `WidgetContext`,存储在实例字段中。 + +```typescript +class EditorWidget extends WidgetType { + protected ctx: WidgetContext | null = null; + + toDOM(view: EditorView): HTMLElement { + this.ctx = this.buildContext(view); + this.didMount?.(this.ctx); + return this.buildDOM(); + } +} +``` + +**替代方案**:全局 `getCurrentWidgetContext()` 函数。 +- 否决原因:多编辑器实例时互相干扰;测试困难 + +### Decision 4: 渐进式破坏性变更 —— `destroy` → `lifecycle.willDestroy` + +**选择**: +- `WidgetDefinition.destroy` 标记为 deprecated,`lifecycle.willDestroy` 优先 +- `NexusWidget` 同时检查两者 +- 保留 2 个大版本后移除 `destroy` + +**替代方案**:直接移除 `destroy`。 +- 否决原因:破坏所有现有插件的 Widget 定义 + +### Decision 5: `estimatedHeight` 契约标准化 + +**选择**:`EditorWidget` 要求子类实现 `get estimatedHeight(): number`,在基类构造函数中自动检查(dev mode 下 warn)。 + +每个 Widget 子类必须提供高度估算值,遵循以下规则: +- Block widget: padding 而非 margin(CM6 `getBoundingClientRect` 排除 margin) +- Line decoration: 不改变 `font-family`/`font-size`(CM6 高度图用默认行高估算离屏行) +- Inline widget: 高度不超过当前行高的 1.5 倍 + +**替代方案**:让 CM6 自动测量。 +- 否决原因:首帧测量前高度为 0 → click-drift;反复测量 → 布局抖动 + +## Architecture + +### Before + +``` +┌─────────────────────────────────────────┐ +│ Plugin Widget (WidgetDefinition) │ +│ └─ NexusWidget (WidgetType) │ +│ └─ toDOM() → render(node, source, ctx)│ +├─────────────────────────────────────────┤ +│ Live Preview Widget (EditableTableWidget)│ +│ ├─ 编辑锁 (tableEditingCount 全局变量) │ +│ ├─ 12 条规则 (CLAUDE.md) │ +│ └─ 手写事件管理 (1727 行) │ +├─────────────────────────────────────────┤ +│ Live Preview Widget (MermaidWidget) │ +│ ├─ 无编辑锁 │ +│ └─ 手写缓存逻辑 │ +└─────────────────────────────────────────┘ + ↑ 两套独立系统,零共享 +``` + +### After + +``` +┌─────────────────────────────────────────────────┐ +│ EditorWidget (WidgetType) │ +│ ├─ 编辑锁 (WidgetInteractionState) │ +│ ├─ 生命周期 (didMount/willUpdate/.../deactivate)│ +│ ├─ WidgetContext (view/dispatch/coords) │ +│ └─ estimatedHeight 契约 │ +├─────────────────────────────────────────────────┤ +│ ↙ ↘ │ +│ Plugin Widget Core Widget │ +│ (NexusWidget) (EditableTableWidget) │ +│ └─ WidgetDefinition └─ 从基类继承所有机制 │ +│ + 可选生命周期间 + 专注表格特有逻辑 │ +└─────────────────────────────────────────────────┘ + ↑ 所有 Widget 共享同一套基础设施 +``` + +## Risks / Trade-offs + +| Risk | Mitigation | +|------|------------| +| `WidgetInteractionState` StateField 开销 | 只在 `activeWidgetCount > 0` 时才阻止 rebuild;验证 1000+ tables 场景性能 | +| 现有 `EditableTableWidget` 迁移引入回归 | 分步迁移:先提取基类 → 不改 table 行为 → 再逐步采用钩子 | +| `WidgetContext` 必需参数破坏现有插件 | `WidgetContext` 作为 render 的必需参数;提供 `createWidgetFactory` 适配旧签名 | +| 状态字段序列化/恢复复杂 | `activeWidgets: Set` 使用 widgetDOMId 标识,CM6 reconfigure 时自动清空 | +| 文档维护负担 | Widget 文档作为代码注释的同源生成,用工具提取而非手写两份 | + +## Migration Plan + +### Phase 1: 基础设施(无行为变更) +1. 创建 `WidgetInteractionState` StateField +2. 创建 `EditorWidget` 基类 +3. `isTableEditing()` 内部改为调用通用的 `isWidgetEditing()` +4. 验证:所有现有测试通过 + +### Phase 2: 核心 Widget 迁移 +5. `EditableTableWidget` 改为继承 `EditorWidget` +6. 提取编辑锁逻辑到基类 +7. 添加 `didMount`/`willDestroy` 钩子 +8. 验证:表格式交互测试全绿 + +### Phase 3: 插件 Widget 升级 +9. `NexusWidget` 改为继承 `EditorWidget` +10. `WidgetDefinition.lifecycle` 可选字段生效 +11. `createWidgetFactory` 辅助函数 +12. 验证:现有插件 Widget 无回归 + +### Phase 4: 文档与清理 +13. Widget 编写指南文档 +14. `WidgetDefinition.destroy` 标记 deprecated +15. CLAUDE.md 12 条规则迁移到文档 + +### Rollback +每个 Phase 独立可回滚;`EditorWidget` 作为 `WidgetType` 的纯扩展,移除后不影响 CM6 行为。 + +## Open Questions + +1. **Widget ID 策略**:用自增计数器还是要求插件作者提供唯一 id?倾向后者(`WidgetDefinition.id?: string`),冲突时 dev mode 下 warn。 +2. **`activate`/`deactivate` 触发时机**:在 live preview 的 `buildDecorations` 里判断选区相交?还是用 CM6 的 `SelectionRange` 机制?倾向前者(与现有逻辑一致)。 +3. **`WidgetInteractionState` 是否需要持久化**:跨文档切换时是否保留?倾向不保留(每次新建编辑器重置)。 diff --git a/openspec/changes/add-widget-api-standardization/proposal.md b/openspec/changes/add-widget-api-standardization/proposal.md new file mode 100644 index 00000000..5378ed0c --- /dev/null +++ b/openspec/changes/add-widget-api-standardization/proposal.md @@ -0,0 +1,37 @@ +# Change: Widget API 标准化 + +## Why + +当前 Nexus-Editor 存在**两套完全独立的 Widget 系统**:一套是面向插件作者的公开 `WidgetDefinition` 接口(`widget-extension.ts`),另一套是 live preview 系统内部自用的 Widget 类(`EditableTableWidget`、`MermaidWidget`、`CodeCopyWidget` 等)。两套系统不共享基础设施,导致: + +1. **插件作者得不到 live preview 的能力**(光标感知的 show/hide、内联格式化、rAF 轮询选区等) +2. **每个交互式 Widget 反复重造同样的机制**——编辑锁、CM6 dispatch、高度估算、事件转发。`EditableTableWidget` 一个文件 1727 行,CLAUDE.md 里有 12 条专门规则只为了让它不出 bug +3. **`tableEditingCount` 是裸模块级变量**——不可测试、不可在 devtools 中检视、不可与其他 Widget 组合 +4. **Widget 没有生命周期钩子**——没有 `init`/`update`/`destroy`/`activate`/`deactivate`,每个 Widget 在 `toDOM()` 里手动处理一切 +5. **高度估算缺乏统一契约**——每个 Widget 各自猜测,margin vs padding 导致的 click-drift 问题反复出现 + +## What Changes + +- **ADDED** `EditorWidget` 抽象基类(继承 `WidgetType`),提供编辑锁管理、生命周期钩子、dispatch/选区访问 +- **ADDED** `WidgetInteractionState` 共享状态管理器(取代裸 `tableEditingCount` 全局变量),支持多 Widget 并发编辑 +- **ADDED** Widget 生命周期钩子:`didMount`、`willUpdate`、`didUpdate`、`willDestroy`、`activate`(光标进入)、`deactivate`(光标离开) +- **ADDED** `WidgetContext` —— Widget 访问 CM6 view、dispatch 事务、获取坐标的标准接口 +- **ADDED** `createWidgetFactory` 辅助函数 —— 将现有 `WidgetDefinition` 升级为标准化 Widget +- **MODIFIED** `EditableTableWidget` 迁移到 `EditorWidget` 基类(提取公共模式,不改变外部行为) +- **MODIFIED** live preview `StateField` 的 `isTableEditing()` 检查改为通用的 `isWidgetEditing()` 检查 +- **ADDED** 公共文档:Widget 编写指南(pattern & anti-pattern,替代 CLAUDE.md 中的分散规则) + +## Impact + +- Affected specs: `widget-api`(新增) +- Affected code: + - `packages/core/src/live-preview-table.ts` — 迁移到 EditorWidget + - `packages/core/src/live-preview.ts` — 替换 `isTableEditing()` → `isWidgetEditing()` + - `packages/core/src/widget-extension.ts` — `NexusWidget` 迁移到 EditorWidget + - `packages/core/src/types.ts` — 新增 WidgetContext、EditorWidget 接口 + - `packages/core/src/editor.ts` — 初始化 WidgetInteractionState + - 新增 `packages/core/src/widget-base.ts` — EditorWidget 基类 + - 新增 `packages/core/src/widget-state.ts` — WidgetInteractionState + - `packages/core/src/index.ts` — 导出新的公共 API +- **BREAKING**: `WidgetDefinition.render` 签名变更为 `(node, source, ctx: WidgetContext) => HTMLElement`(ctx 从可选变为必需) +- **BREAKING**: `WidgetDefinition` 新增可选 `lifecycle` 字段;旧 `destroy` 字段标记为 deprecated(仍支持但推荐用 `lifecycle.willDestroy`) diff --git a/openspec/changes/add-widget-api-standardization/specs/widget-api/spec.md b/openspec/changes/add-widget-api-standardization/specs/widget-api/spec.md new file mode 100644 index 00000000..ec774143 --- /dev/null +++ b/openspec/changes/add-widget-api-standardization/specs/widget-api/spec.md @@ -0,0 +1,151 @@ +## ADDED Requirements + +### Requirement: EditorWidget Base Class + +系统 SHALL 提供一个 `EditorWidget` 抽象基类,继承自 CodeMirror 6 的 `WidgetType`,封装所有交互式 Widget 的公共行为。 + +`EditorWidget` SHALL 提供以下能力: +- **编辑锁管理**:`acquireLock(reason)` 和 `releaseLock(reason)` 方法,支持多原因并发锁(如 focus + drag),自动在 `willDestroy` 时释放 +- **生命周期钩子**:`didMount(ctx)`、`willUpdate(ctx)`、`didUpdate(ctx)`、`willDestroy(ctx)`、`activate(ctx)`、`deactivate(ctx)` +- **`eq()` 默认实现**:当 Widget 有活跃锁时返回 `true`(阻止 CM6 重建 DOM) +- **`estimatedHeight` 契约**:子类 MUST 返回合理的估算值(block widget 使用 padding 而非 margin) + +#### Scenario: Widget activates editing lock during drag + +- **GIVEN** 一个继承 `EditorWidget` 的 Widget 实例已挂载 +- **WHEN** 用户在 Widget 上开始拖拽操作,Widget 调用 `acquireLock("drag")` +- **THEN** `isWidgetEditing(view.state)` 返回 `true` +- **AND** CM6 的 live preview StateField 在 update 中跳过完整 rebuild,仅执行 `decos.map(tr.changes)` +- **AND** Widget 的 DOM 在整个拖拽过程中不被 CM6 销毁 + +#### Scenario: All locks released on destroy + +- **GIVEN** 一个 `EditorWidget` 持有 2 个活跃锁("focus" 和 "drag") +- **WHEN** CM6 调用 `widget.destroy()` +- **THEN** 基类 `destroy()` 自动调用 `willDestroy(ctx)` +- **AND** 所有锁被释放,`isWidgetEditing(state)` 返回 `false` +- **AND** 不会发生锁泄漏(lock counter 回到 0) + +#### Scenario: Lifecycle hooks called in order + +- **GIVEN** 一个继承 `EditorWidget` 的 Widget +- **WHEN** CM6 首次渲染该 Widget(调用 `toDOM(view)`) +- **THEN** 钩子调用顺序为:基类构建 `WidgetContext` → `didMount(ctx)` → `buildDOM()`(子类实现) +- **AND WHEN** CM6 后续 update 中决定重建 Widget +- **THEN** 钩子调用顺序为:`willDestroy(ctx)` → `destroy()` → 新实例 `toDOM(view)` → `didMount(ctx)` + +--- + +### Requirement: WidgetInteractionState + +系统 SHALL 通过 CM6 `StateField` 管理全局 Widget 交互状态,替代裸的模块级变量 `tableEditingCount`。 + +`WidgetInteractionState` SHALL 包含: +- `activeWidgetCount: number` —— 当前有多少 Widget 处于交互状态(有活跃锁) +- Widget ID 注册表(用于 devtools 调试和将来的 Widget 间通信) + +系统 SHALL 提供 `isWidgetEditing(state: EditorState): boolean` 函数,供 live preview StateField 和其他组件查询。 + +#### Scenario: Multiple widgets cannot co-edit simultaneously (guarded) + +- **GIVEN** 编辑器中有两个独立的交互式 Widget(表格 A 和表格 B) +- **WHEN** 用户在表格 A 的单元格中编辑时,`activeWidgetCount === 1` +- **AND** 用户点击表格 B 的单元格 +- **THEN** 表格 A 的 blur 处理器释放其锁 +- **AND** 表格 B 的 focus 处理器获取新锁 +- **AND** `activeWidgetCount` 始终 ≤ 1(同一时刻只有一个 Widget 拥有焦点锁) + +#### Scenario: Drag lock coexists with focus lock + +- **GIVEN** 用户在表格中编辑单元格(`activeWidgetCount === 1`,原因 "focus") +- **WHEN** 用户不 blur 当前单元格,直接拖拽列 grip(mousedown 时 `acquireLock("drag")`) +- **THEN** `activeWidgetCount === 2`(两个锁并发存在) +- **AND** 列 drag 完成后释放 "drag" 锁,`activeWidgetCount === 1` +- **AND** 单元格 blur 后释放 "focus" 锁,`activeWidgetCount === 0` + +--- + +### Requirement: WidgetContext + +系统 SHALL 为每个 `EditorWidget` 实例提供 `WidgetContext` 对象,在 `toDOM(view)` 时构建并存储在实例字段中。 + +`WidgetContext` SHALL 提供: +- `view: EditorView` —— 只读 CM6 view 引用 +- `from: number` / `to: number` —— Widget 在文档中的起止位置 +- `dispatch(changes)` —— 安全地派发 CM6 事务 +- `isCursorInside(): boolean` —— 判断 CM6 选区是否与 Widget 范围相交 +- `requestMeasure(callback)` —— 委托给 CM6 的 `requestMeasure` + +#### Scenario: Widget dispatches source change + +- **GIVEN** 表格 Widget 的某个单元格被编辑 +- **WHEN** Widget 调用 `ctx.dispatch({ from: tableFrom, to: tableFrom + oldLen, insert: newSource })` +- **THEN** CM6 文档内容更新 +- **AND** 事务在单个 undo 事件中(Ctrl+Z 一步撤销整个单元格变更) +- **AND** live preview StateField 接收到 `docChanged` 并重建 decoration + +#### Scenario: Widget checks if cursor is inside + +- **GIVEN** Widget 需要根据选区位置决定显示模式(渲染视图 vs 编辑视图) +- **WHEN** Widget 调用 `ctx.isCursorInside()` +- **THEN** 若 CM6 光标在 `[ctx.from, ctx.to]` 范围内,返回 `true` +- **AND** Widget 据此切换渲染策略(如在内部时显示原始 markdown) + +--- + +### Requirement: Widget Lifecycle for Plugin Authors + +系统 SHALL 在 `WidgetDefinition` 接口中提供可选的生命周期钩子 `lifecycle`,使插件作者可以参与 Widget 生命周期而无需手动管理 DOM。 + +`WidgetDefinition.lifecycle` SHALL 支持: +- `didMount(ctx: WidgetContext): void` +- `willDestroy(ctx: WidgetContext): void` +- `activate(ctx: WidgetContext): void` +- `deactivate(ctx: WidgetContext): void` + +`WidgetDefinition.render` 签名 SHALL 变更为 `(node, source, ctx: WidgetContext) => HTMLElement`,其中 `ctx` 为必需参数。 + +原有 `WidgetDefinition.destroy` SHALL 标记为 deprecated,但继续支持 2 个大版本。 + +#### Scenario: Plugin widget cleans up on destroy + +- **GIVEN** 插件定义了一个渲染 Mermaid 图表的 Widget,需要清理 SVG 引用的外部资源 +- **WHEN** CM6 重建该 Widget 的 decoration(如文档内容变化导致位置移动) +- **THEN** `lifecycle.willDestroy(ctx)` 被调用 +- **AND** 插件在此钩子中清理资源 +- **AND** CM6 随后创建新的 Widget 实例,调用 `lifecycle.didMount(ctx)` 和 `render(node, source, ctx)` + +#### Scenario: Plugin widget responds to cursor proximity + +- **GIVEN** 插件 Widget 在 `lifecycle` 中实现了 `activate` 和 `deactivate` +- **WHEN** 用户光标移动到 Widget 所在的文档范围 +- **THEN** `lifecycle.activate(ctx)` 被调用 +- **AND** Widget 可以切换到"展开"模式(如显示工具栏) +- **WHEN** 用户光标离开该范围 +- **THEN** `lifecycle.deactivate(ctx)` 被调用 +- **AND** Widget 恢复到"折叠"模式 + +--- + +### Requirement: Backward Compatibility + +系统 SHALL 保持与现有 `WidgetDefinition` 接口的向后兼容性,现有插件无需立即修改即可继续工作。 + +`createWidgetFactory` 辅助函数 SHALL 将旧格式 `WidgetDefinition`(`{ nodeType, match, render, destroy }`)自动适配为新的 `EditorWidget` 子类。 + +`WidgetDefinition.render` 的 `ctx` 参数 SHALL 在旧插件中 fallback 为最小可用实现(仅包含 `from`/`to`)。 + +#### Scenario: Old plugin widget still works without changes + +- **GIVEN** 一个第三方插件使用了旧版 `WidgetDefinition` 接口(`destroy` 字段,无 `lifecycle`,`render` 不接收 ctx) +- **WHEN** 该插件在 Widget API 标准化后的 Nexus-Editor 中加载 +- **THEN** Widget 正常渲染 +- **AND** `destroy` 回调在 Widget 销毁时被调用(与旧行为一致) +- **AND** dev mode console 中出现 deprecation warning + +#### Scenario: New plugin uses lifecycle hooks + +- **GIVEN** 插件作者使用新格式 `WidgetDefinition`(含 `lifecycle.willDestroy`) +- **WHEN** Widget 被销毁 +- **THEN** `lifecycle.willDestroy(ctx)` 被优先调用 +- **AND** 若同时定义了 `destroy` 和 `lifecycle.willDestroy`,仅调用 `lifecycle.willDestroy`(不重复调用) diff --git a/openspec/changes/add-widget-api-standardization/tasks.md b/openspec/changes/add-widget-api-standardization/tasks.md new file mode 100644 index 00000000..a0e15c9f --- /dev/null +++ b/openspec/changes/add-widget-api-standardization/tasks.md @@ -0,0 +1,99 @@ +# Tasks: Widget API 标准化 + +## Phase 1: 基础设施(无行为变更) + +- [ ] 1.1 创建 `packages/core/src/widget-base.ts`——`EditorWidget` 抽象基类 + - 继承 `WidgetType` + - 提供 `acquireLock(reason)` / `releaseLock(reason)` / `hasAnyLock()` 方法 + - 生命周期钩子接口:`didMount(ctx)` / `willUpdate(ctx)` / `didUpdate(ctx)` / `willDestroy(ctx)` / `activate(ctx)` / `deactivate(ctx)` + - `toDOM(view)` 中构建 `WidgetContext` 并调用 `didMount` + - `destroy()` 中调用 `willDestroy` 并释放所有锁 + - `eq()` 默认实现:`isActive()` 时返回 `true`(保留 DOM) + +- [ ] 1.2 创建 `packages/core/src/widget-state.ts`——`WidgetInteractionState` StateField + - 定义 `WidgetInteractionState` 接口:`{ editingWidgetCount: number }` + - 实现 `isWidgetEditing(state: EditorState): boolean` + - 导出 `acquireWidgetEditingLock` / `releaseWidgetEditingLock` 事务辅助函数 + - 在 `EditorWidget` 基类中集成 + +- [ ] 1.3 创建 `packages/core/src/widget-context.ts`——`WidgetContext` 接口 + - `view: EditorView`(只读) + - `from: number` / `to: number`(widget 在文档中的位置) + - `dispatch(changes)`(代理到 `view.dispatch`) + - `getCoordsAtPos(pos)`(代理到 `view.coordsAtPos`) + - `isCursorInside(): boolean` + - `requestMeasure(callback)` + +- [ ] 1.4 重构 `isTableEditing()` → `isWidgetEditing()` + - 在 `live-preview.ts` 中将 `isTableEditing()` 替换为 `isWidgetEditing(state)` + - 在 `widget-state.ts` 中实现 `isWidgetEditing` + - `EditableTableWidget` 内部使用新的锁 API(通过 `EditorWidget` 基类) + +- [ ] 1.5 编写 `EditorWidget` 基类单元测试 + - 测试锁的获取/释放/自动清理 + - 测试生命周期钩子调用顺序 + - 测试 `eq()` 在 activated 状态下返回 true + - 测试 `WidgetContext` 各方法可用性 + +## Phase 2: 核心 Widget 迁移 + +- [ ] 2.1 `EditableTableWidget` 继承 `EditorWidget` + - 将 `editing` + `editingLocks` + `acquireEditingLock` / `releaseEditingLock` → 基类方法 + - 将 `cleanupEditingLocks` → 基类 `willDestroy` 自动处理 + - 保留 `toDOM()` 中的表格特定逻辑不变 + - `tableEditingCount++` / `tableEditingCount--` → `acquireLock()` / `releaseLock()` + +- [ ] 2.2 `EditableTableWidget` 添加生命周期钩子 + - `didMount(ctx)`:启动 rAF 轮询(`checkSelection`) + - `willDestroy(ctx)`:清理 document 级事件监听器 + - `deactivate(ctx)`:当光标离开表格时清理选区 + +- [ ] 2.3 提取表格事件管理辅助函数(可选重构) + - `createDragHandler(element, ctx, config): DragController` + - `createHoverController(element, config): HoverController` + - 这些是 `EditorWidget` 的公共工具,不强制表格使用 + +- [ ] 2.4 运行所有表格相关测试确认无回归 + - `packages/core/test/live-preview.test.ts` 全部通过 + - 手动 electron-demo 验证:点击编辑、拖拽选区、列/行拖拽、右键菜单、Ctrl+C 复制 + +## Phase 3: 插件 Widget 升级 + +- [ ] 3.1 `NexusWidget` 继承 `EditorWidget` + - 将 `WidgetDefinition.render` 的调用封装为 `didMount` 钩子 + - `destroy()` → `willDestroy()` 钩子 + - 支持 `WidgetDefinition.lifecycle` 可选钩子 + +- [ ] 3.2 `WidgetDefinition` 类型更新 + - 新增 `lifecycle?: Partial` 字段 + - 新增 `id?: string` 字段(用于 WidgetInteractionState 标识) + - `destroy` 标记为 `@deprecated`(用 `lifecycle.willDestroy` 替代) + - `render` 签名中 `ctx?: WidgetRenderContext` → `ctx: WidgetContext` + +- [ ] 3.3 创建 `createWidgetFactory` 辅助函数 + - 将旧 `WidgetDefinition`(带 `destroy`)包装为新格式 + - dev mode 下 warn 废弃字段 + +- [ ] 3.4 更新现有插件 Widget + - `packages/plugin-math/src/index.ts` 中的 widget 定义(如有) + - `packages/plugin-toolbar/src/color-decoration.ts` 中的 `HiddenTagWidget` + +## Phase 4: 文档与导出 + +- [ ] 4.1 从 `packages/core/src/index.ts` 导出新 API + - `EditorWidget`、`WidgetContext`、`WidgetInteractionState` + - `isWidgetEditing`、`createWidgetFactory` + +- [ ] 4.2 编写 Widget 编写指南文档 + - 路径:`docs/WIDGET_AUTHORING.md`(中文版:`docs/WIDGET_AUTHORING.zh.md`) + - 内容包括:CLAUDE.md 12 条规则的通用化版本 + - Quick start:最简 Widget 示例 + - Pattern catalog:editable cell / drag handle / hover overlay / clipboard + - Anti-pattern catalog:margin for spacing / HTML5 drag API / inline border styles + +- [ ] 4.3 更新 CLAUDE.md + - 12 条表格规则简化为指向 `docs/WIDGET_AUTHORING.md` + - 添加"Widget 开发规则"章节链接 + +- [ ] 4.4 更新 `README.md` 和 `README.zh.md` 路线图 + - 将 "Widget API 标准化" 从 `planned` 改为 `in-progress` diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 768852f0..afffc00e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,5 +54,17 @@ export type { SetDocumentOptions, TocEntry, WidgetDefinition, - WidgetRenderContext + WidgetLifecycleHooks, + WidgetRenderContext, } from "./types"; +export type { WidgetContext, WidgetMeasureRequest } from "./widget-context"; +export { EditorWidget } from "./widget-base"; +export type { WidgetLifecycle, WidgetLockReason } from "./widget-base"; +export { + isWidgetEditing, + acquireWidgetEditingLock, + releaseWidgetEditingLock, + widgetEditingState, + getWidgetEditingCount, +} from "./widget-state"; +export type { WidgetInteractionState } from "./widget-state"; diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index b5acf20b..e5691c51 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -1,12 +1,26 @@ -import { EditorView, WidgetType, runScopeHandlers } from "@codemirror/view"; +import { EditorView, runScopeHandlers } from "@codemirror/view"; import type { Table } from "mdast"; import type { LivePreviewLabels } from "./types"; +import { EditorWidget } from "./widget-base"; +import { acquireWidgetEditingLock, isWidgetEditing, releaseWidgetEditingLock } from "./widget-state"; -let tableEditingCount = 0; +// ── Backward-compatible re-exports (delegate to shared widget-state) ── +/** @deprecated Use {@link isWidgetEditing} from `./widget-state` instead. */ export function isTableEditing(): boolean { - return tableEditingCount > 0; + return isWidgetEditing(); +} + +// Internal helpers for the table widget's lock management. +// These call directly into the widget-state module counter rather than +// going through EditorWidget.acquireLock/releaseLock because the table +// widget hasn't been migrated to EditorWidget yet (Phase 2). +function acquireTableEditingLock(): void { + acquireWidgetEditingLock(); +} +function releaseTableEditingLock(): void { + releaseWidgetEditingLock(); } // 表格方向键导航的调试日志,仅在显式开启 floatboat:markdown-debug 标记后输出。 @@ -301,8 +315,7 @@ const SELECT_BORDER = "var(--nexus-accent)"; const DRAG_HIGHLIGHT_BG = "rgba(124, 108, 250, 0.08)"; const TEXT_SELECTION_DRAG_THRESHOLD_PX = 3; -export class EditableTableWidget extends WidgetType { - private editing = false; +export class EditableTableWidget extends EditorWidget { private cleanupEditingLocks: (() => void) | null = null; constructor( @@ -313,16 +326,17 @@ export class EditableTableWidget extends WidgetType { private labels: Required ) { super(); } - eq(other: EditableTableWidget): boolean { - if (this.editing) return true; + /** @override — compare by source text when idle. */ + protected eqKey(other: EditableTableWidget): boolean { return this.source === other.source; } ignoreEvent(): boolean { return true; } - destroy(): void { + destroy(dom: HTMLElement): void { this.cleanupEditingLocks?.(); this.cleanupEditingLocks = null; + super.destroy(dom); } get estimatedHeight(): number { @@ -395,7 +409,13 @@ export class EditableTableWidget extends WidgetType { this.dispatch(lines.join("\n")); } - toDOM(): HTMLElement { + protected buildDOM(): HTMLElement { + // Not used — toDOM() is fully custom. The base class requires this + // to be implemented; it's never called because toDOM() is overridden. + throw new Error("EditableTableWidget uses toDOM() directly, not buildDOM()"); + } + + toDOM(view: EditorView): HTMLElement { const self = this; const rows = this.node.children ?? []; // Normalise irregular markdown tables: if some rows have more cells than @@ -428,28 +448,16 @@ export class EditableTableWidget extends WidgetType { let draggingRow = -1; // which row is being dragged let dropTargetCol = -1; let dropTargetRow = -1; - const editingLocks = { - focus: false, - range: false, - drag: false, - }; - - function hasEditingLocks(): boolean { - return editingLocks.focus || editingLocks.range || editingLocks.drag; - } + // ── Editing lock helpers (delegate to EditorWidget base class) ── + // Lock reasons: "focus" (cell contentEditable), "range" (multi-cell selection), + // "drag" (column/row reorder and column resize). - function acquireEditingLock(lock: keyof typeof editingLocks): void { - if (editingLocks[lock]) return; - editingLocks[lock] = true; - self.editing = true; - tableEditingCount++; + function acquireEditingLock(lock: string): void { + self.acquireLock(lock); } - function releaseEditingLock(lock: keyof typeof editingLocks): void { - if (!editingLocks[lock]) return; - editingLocks[lock] = false; - tableEditingCount = Math.max(0, tableEditingCount - 1); - self.editing = hasEditingLocks(); + function releaseEditingLock(lock: string): void { + self.releaseLock(lock); } this.cleanupEditingLocks = () => { @@ -581,7 +589,7 @@ export class EditableTableWidget extends WidgetType { const checkSelection = (): void => { if (!wrapper.isConnected) return; const v = self.viewRef.current; - if (v && !self.editing) { + if (v && !self.isActive()) { // Multi-cursor: any range fully covering the table counts as // "table selected"; the cell-range only clears when every cursor // has left the table span. diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index bbca2d71..c4ed7a96 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -11,6 +11,7 @@ import { createLivePreviewDiagnostics } from "./live-preview-diag"; import { collectLivePreviewRanges, selectionIntersects, selectionOnSameLine } from "./live-preview-ranges"; import { renderLivePreviewNode } from "./live-preview-renderers"; import { EditableTableWidget, isTableEditing } from "./live-preview-table"; +import { isWidgetEditing } from "./widget-state"; import type { LivePreviewConfig, LivePreviewLabels, @@ -1348,7 +1349,7 @@ export function createLivePreviewExtension( return build(state, state.selection.ranges, false); }, update(decos: DecorationSet, tr: Transaction) { - if (isTableEditing()) { + if (isWidgetEditing()) { return tr.docChanged ? decos.map(tr.changes) : decos; } if (tr.effects.some((effect) => effect.is(rebuildForCompositionStart))) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d8f767ae..acbab586 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -319,11 +319,45 @@ export interface WidgetRenderContext { focus: () => void; } +/** + * Lifecycle hooks that plugin widgets can implement. Called by the + * standardized {@link EditorWidget} infrastructure. + */ +export interface WidgetLifecycleHooks { + /** Called once after the widget's DOM is created and attached. */ + didMount?(ctx: import("./widget-context").WidgetContext): void; + /** Called before the widget's DOM is about to be recreated. */ + willUpdate?(ctx: import("./widget-context").WidgetContext): void; + /** Called after the widget's DOM was recreated due to a source change. */ + didUpdate?(ctx: import("./widget-context").WidgetContext): void; + /** Called before the widget is destroyed. Replace for the legacy `destroy` callback. */ + willDestroy?(ctx: import("./widget-context").WidgetContext): void; + /** Called when the CM6 cursor enters this widget's document range. */ + activate?(ctx: import("./widget-context").WidgetContext): void; + /** Called when the CM6 cursor leaves this widget's document range. */ + deactivate?(ctx: import("./widget-context").WidgetContext): void; +} + export interface WidgetDefinition { nodeType: string; match?: (node: any) => boolean; render: (node: any, source: string, ctx?: WidgetRenderContext) => HTMLElement; + /** + * Optional lifecycle hooks. When provided, these are preferred over the + * legacy {@link destroy} callback. + */ + lifecycle?: WidgetLifecycleHooks; + /** + * @deprecated Use {@link WidgetLifecycleHooks.willDestroy} instead. + * Called when the widget DOM is removed. Still supported for backward + * compatibility; will be removed in a future major version. + */ destroy?: (element: HTMLElement) => void; + /** + * Optional unique identifier for this widget definition. Used for + * debugging and interaction-state tracking. + */ + id?: string; /** * Whether the widget replaces a block-level range (occupies its own line) * or an inline range (sits inside surrounding text). Defaults to `true` diff --git a/packages/core/src/widget-base.ts b/packages/core/src/widget-base.ts new file mode 100644 index 00000000..9a3cf3be --- /dev/null +++ b/packages/core/src/widget-base.ts @@ -0,0 +1,255 @@ +import { EditorView, WidgetType } from "@codemirror/view"; +import { acquireWidgetEditingLock, releaseWidgetEditingLock } from "./widget-state"; +import type { WidgetContext, WidgetMeasureRequest } from "./widget-context"; + +/** + * Lock reason discriminator for fine-grained lock tracking within a single + * widget. A widget may hold multiple concurrent locks (e.g. "focus" + + * "drag"). All locks are released when the widget is destroyed. + */ +export type WidgetLockReason = string; + +/** + * Lifecycle hooks that an {@link EditorWidget} subclass may override. + * All hooks receive the widget's {@link WidgetContext} so they can + * interact with the editor safely. + */ +export interface WidgetLifecycle { + /** Called once after the widget's DOM is created and attached. */ + didMount?(ctx: WidgetContext): void; + + /** Called before the widget's DOM is about to be recreated (source changed). */ + willUpdate?(ctx: WidgetContext): void; + + /** Called after the widget's DOM was recreated due to a source change. */ + didUpdate?(ctx: WidgetContext): void; + + /** Called before the widget is destroyed. Release external resources here. */ + willDestroy?(ctx: WidgetContext): void; + + /** Called when the CM6 cursor enters this widget's document range. */ + activate?(ctx: WidgetContext): void; + + /** Called when the CM6 cursor leaves this widget's document range. */ + deactivate?(ctx: WidgetContext): void; +} + +/** + * Abstract base class for all Nexus editor widgets. + * + * Extends CodeMirror 6's {@link WidgetType} with: + * - **Editing lock management** — prevents CM6 from destroying widget DOM + * during multi-frame interactions (drag, contentEditable, etc.) + * - **Lifecycle hooks** — `didMount`, `willDestroy`, `activate`, `deactivate` + * - **WidgetContext** — safe access to CM6 view, dispatch, coords + * - **`eq()` default** — returns `true` while locks are held + * + * ## Usage + * + * ```ts + * class MyWidget extends EditorWidget { + * get estimatedHeight(): number { return 48; } + * + * protected buildDOM(): HTMLElement { + * const el = document.createElement("div"); + * // ... build your widget DOM ... + * return el; + * } + * + * didMount(ctx: WidgetContext): void { + * // Start rAF polling, attach document-level listeners, etc. + * } + * + * willDestroy(ctx: WidgetContext): void { + * // Clean up listeners, timers, external resources + * } + * } + * ``` + */ +export abstract class EditorWidget extends WidgetType implements WidgetLifecycle { + /** The widget's context, set during {@link toDOM} and cleared on destroy. */ + protected ctx: WidgetContext | null = null; + + /** DOM element produced by {@link buildDOM}. Stored so subclasses can reference it. */ + protected dom: HTMLElement | null = null; + + /** Active lock reasons. When non-empty, `eq()` returns `true`. */ + private _activeLocks = new Set(); + + // ── Lifecycle hooks (override in subclasses) ── + + /** @inheritdoc */ + didMount(_ctx: WidgetContext): void { /* noop */ } + + /** @inheritdoc */ + willUpdate(_ctx: WidgetContext): void { /* noop */ } + + /** @inheritdoc */ + didUpdate(_ctx: WidgetContext): void { /* noop */ } + + /** @inheritdoc */ + willDestroy(_ctx: WidgetContext): void { /* noop */ } + + /** @inheritdoc */ + activate(_ctx: WidgetContext): void { /* noop */ } + + /** @inheritdoc */ + deactivate(_ctx: WidgetContext): void { /* noop */ } + + // ── Subclass contract ── + + /** + * Build the widget's DOM tree. Called from {@link toDOM} after the + * context is set up. Subclasses MUST implement this — this is where + * the actual widget UI is constructed. + */ + protected abstract buildDOM(): HTMLElement; + + /** + * Estimated height of the widget in pixels. CM6 uses this to initialise + * its heightmap before the first layout pass. Defaults to 24. + * + * **Rules for accurate estimates:** + * - Block widgets: use `padding` not `margin` for vertical spacing (CM6's + * `getBoundingClientRect` excludes margin → untracked height) + * - Line decorations: never change `font-family`/`font-size` (CM6 uses + * default line height for off-screen line estimates) + * - Interactive widgets with variable height: estimate conservatively + * (slightly too tall is OK; too short causes click-drift) + */ + get estimatedHeight(): number { + return 24; + } + + // ── Editing lock management ── + + /** + * Returns `true` when this widget holds at least one active editing lock. + */ + protected isActive(): boolean { + return this._activeLocks.size > 0; + } + + /** + * Returns the set of currently held lock reasons (for debugging). + */ + protected get activeLocks(): ReadonlySet { + return this._activeLocks; + } + + /** + * Acquire an editing lock for the given reason. Each call MUST be paired + * with a {@link releaseLock} call for the same reason. + * + * Increments the global widget editing counter so the live-preview + * StateField skips rebuilds. + */ + protected acquireLock(reason: WidgetLockReason): void { + if (this._activeLocks.has(reason)) return; + this._activeLocks.add(reason); + acquireWidgetEditingLock(); + } + + /** + * Release an editing lock previously acquired with {@link acquireLock}. + * Decrements the global counter. Safe to call even if the lock is not + * held (noop). + */ + protected releaseLock(reason: WidgetLockReason): void { + if (!this._activeLocks.delete(reason)) return; + releaseWidgetEditingLock(); + } + + /** + * Release ALL editing locks held by this widget. Called automatically + * in {@link destroy}. Safe to call at any time. + */ + protected releaseAllLocks(): void { + for (const _ of this._activeLocks) { + releaseWidgetEditingLock(); + } + this._activeLocks.clear(); + } + + // ── WidgetType overrides ── + + /** + * Default `eq()`: returns `true` while locks are held (preserving DOM + * during interactions). When no locks are held, compares by subclass + * identity — override {@link eqKey} to customise. + */ + eq(other: EditorWidget): boolean { + // Always preserve DOM during active interaction + if (this.isActive()) return true; + // Delegate to subclass identity check + return this.eqKey(other); + } + + /** + * Override to provide custom identity comparison when the widget is idle + * (no active locks). Default: reference equality (`this === other`). + */ + protected eqKey(_other: EditorWidget): boolean { + return false; + } + + /** @inheritdoc */ + ignoreEvent(): boolean { + return true; + } + + /** + * CM6 calls this to create the widget's DOM. Subclasses should NOT + * override this — override {@link buildDOM} instead. + */ + toDOM(view: EditorView): HTMLElement { + this.ctx = this._buildContext(view); + this.dom = this.buildDOM(); + // Notify subclass + this.didMount(this.ctx); + return this.dom; + } + + /** + * CM6 calls this when the widget is removed from the DOM. Releases all + * locks, calls lifecycle hooks, and cleans up references. + */ + destroy(dom: HTMLElement): void { + if (this.ctx) { + this.willDestroy(this.ctx); + } + this.releaseAllLocks(); + this.ctx = null; + this.dom = null; + } + + // ── Internal helpers ── + + /** + * Build a {@link WidgetContext} from the CM6 view. Called once in + * {@link toDOM}. Subclasses can override to customise the context. + */ + private _buildContext(view: EditorView): WidgetContext { + // Derive from/to from the widget's decoration spec. Since WidgetType + // doesn't expose this directly, we compute it from the editor state. + // Subclasses that know their range can override _buildContext. + return { + view, + from: 0, // Will be set by subclass if needed + to: 0, + dispatch(changes: { from: number; to: number; insert: string }) { + view.dispatch({ + changes: { from: changes.from, to: changes.to, insert: changes.insert }, + }); + }, + isCursorInside(): boolean { + const sel = view.state.selection.main; + // This is a rough default; subclasses with known ranges should override + return false; + }, + requestMeasure(req: WidgetMeasureRequest) { + view.requestMeasure(req); + }, + }; + } +} diff --git a/packages/core/src/widget-context.ts b/packages/core/src/widget-context.ts new file mode 100644 index 00000000..7b5b4913 --- /dev/null +++ b/packages/core/src/widget-context.ts @@ -0,0 +1,47 @@ +import type { EditorView } from "@codemirror/view"; + +/** + * Simplified measure request matching CM6's {@link EditorView.requestMeasure} + * contract. The `read` callback runs during the measure phase; the optional + * `write` callback runs during the write phase with the value returned by + * `read`. + */ +export interface WidgetMeasureRequest { + read(view: EditorView): T; + write?(value: T, view: EditorView): void; +} + +/** + * Context object provided to every {@link EditorWidget} instance during its + * lifecycle. Gives widgets controlled access to the CM6 view without exposing + * internals directly. + */ +export interface WidgetContext { + /** The CM6 EditorView this widget lives in. Read-only from the widget's perspective. */ + readonly view: EditorView; + + /** Absolute document offset where this widget's source range starts. */ + readonly from: number; + + /** Absolute document offset where this widget's source range ends (exclusive). */ + readonly to: number; + + /** + * Dispatch a CM6 transaction from within the widget. Prefer this over + * calling `view.dispatch()` directly. + */ + dispatch(changes: { from: number; to: number; insert: string }): void; + + /** + * Returns `true` when the current CM6 selection intersects this widget's + * document range `[from, to]`. + */ + isCursorInside(): boolean; + + /** + * Delegate to CM6's `requestMeasure`. Use this when you need to read + * layout-dependent values (e.g. `getBoundingClientRect`) after a DOM + * mutation that hasn't been painted yet. + */ + requestMeasure(request: WidgetMeasureRequest): void; +} diff --git a/packages/core/src/widget-extension.ts b/packages/core/src/widget-extension.ts index 76b81696..e7df2d9a 100644 --- a/packages/core/src/widget-extension.ts +++ b/packages/core/src/widget-extension.ts @@ -6,10 +6,12 @@ import { StateField, type Transaction, } from "@codemirror/state"; -import { Decoration, type DecorationSet, EditorView, ViewPlugin, WidgetType } from "@codemirror/view"; +import { Decoration, type DecorationSet, EditorView, ViewPlugin } from "@codemirror/view"; import type { Content, Parent, Root } from "mdast"; import type { ParserLike, WidgetDefinition, WidgetRenderContext } from "./types"; +import { EditorWidget } from "./widget-base"; +import type { WidgetContext } from "./widget-context"; const COMPOSITION_REDECORATE_DELAY_MS = 60; @@ -95,7 +97,9 @@ function collectWidgetRanges( return ranges.sort((a, b) => a.from - b.from); } -class NexusWidget extends WidgetType { +class NexusWidget extends EditorWidget { + private _sourceChanged = false; + constructor( private definition: WidgetDefinition, private node: Content, @@ -107,7 +111,8 @@ class NexusWidget extends WidgetType { super(); } - eq(other: NexusWidget): boolean { + /** @override — compare by definition identity + position + source. */ + protected eqKey(other: NexusWidget): boolean { return ( other.definition === this.definition && other.from === this.from && @@ -116,8 +121,38 @@ class NexusWidget extends WidgetType { ); } - toDOM(): HTMLElement { - const ctx: WidgetRenderContext = { + /** @override */ + protected buildDOM(): HTMLElement { + // Capture the widget instance so arrow functions below can access it + // without `this` being shadowed by the ctx object literal. + const widget = this; + + const ctx: WidgetContext = { + view: widget.viewRef.current!, + from: widget.from, + to: widget.to, + dispatch: (changes) => { + const v = widget.viewRef.current; + if (!v) return; + v.dispatch({ + changes: { from: changes.from, to: changes.to, insert: changes.insert }, + }); + }, + isCursorInside: () => { + const v = widget.viewRef.current; + if (!v) return false; + const sel = v.state.selection.main; + const anchor = Math.min(sel.anchor, sel.head); + const head = Math.max(sel.anchor, sel.head); + return anchor < widget.to && widget.from < head; + }, + requestMeasure(req: { read(view: EditorView): T; write?(value: T, view: EditorView): void }) { + widget.viewRef.current?.requestMeasure(req); + }, + }; + + // Build legacy WidgetRenderContext for backward compat + const legacyCtx: WidgetRenderContext = { from: this.from, to: this.to, setSelection: (anchor, head) => { @@ -133,17 +168,30 @@ class NexusWidget extends WidgetType { this.viewRef.current?.focus(); }, }; - const el = this.definition.render(this.node, this.source, ctx); + + // Store ctx for lifecycle hooks + this.ctx = ctx; + + const el = this.definition.render(this.node, this.source, legacyCtx); el.setAttribute("data-nexus-widget", this.definition.nodeType); + + // Call lifecycle.didMount if provided + this.definition.lifecycle?.didMount?.(ctx); + return el; } - destroy(dom: HTMLElement): void { - this.definition.destroy?.(dom); + /** @override */ + didMount(ctx: WidgetContext): void { + // Already called in buildDOM for backward compat; noop here. } - ignoreEvent(): boolean { - return this.definition.ignoreEvents === true; + /** @override */ + willDestroy(ctx: WidgetContext): void { + // Call new lifecycle hook first + this.definition.lifecycle?.willDestroy?.(ctx); + // Fall back to legacy destroy callback + this.definition.destroy?.(this.dom!); } } diff --git a/packages/core/src/widget-state.ts b/packages/core/src/widget-state.ts new file mode 100644 index 00000000..2702f6df --- /dev/null +++ b/packages/core/src/widget-state.ts @@ -0,0 +1,88 @@ +import { StateField, type EditorState, type Transaction, StateEffect } from "@codemirror/state"; + +/** + * Global interaction state shared across all widgets in a single editor. + * Replaces the bare module-level counter with an inspectable structure. + */ +export interface WidgetInteractionState { + /** Number of widgets with active editing locks. */ + activeWidgetCount: number; +} + +// ── Module-level editing counter ── +// Kept as a simple counter so widgets can increment/decrement without +// dispatching CM6 transactions (which would defeat the purpose — we're +// trying to PREVENT rebuilds during editing). The StateField below exists +// purely for devtools inspection and testing; the live-preview guard reads +// the module counter directly. + +let widgetEditingCount = 0; + +/** + * Returns `true` when any widget currently holds an interactive editing + * lock. The live-preview StateField calls this in its update function to + * skip full decoration rebuilds, preserving widget DOM during multi-frame + * interactions (drag, contentEditable focus, range selection, etc.). + */ +export function isWidgetEditing(): boolean { + return widgetEditingCount > 0; +} + +/** + * Acquire a widget editing lock. Call this before starting any multi-frame + * interaction (mousedown → drag, cell focus → edit, etc.). Each call MUST + * be paired with a matching {@link releaseWidgetEditingLock} call. + * + * Returns a cleanup function for convenience; callers that prefer manual + * management can ignore the return value and call `releaseWidgetEditingLock` + * directly. + */ +export function acquireWidgetEditingLock(): () => void { + widgetEditingCount++; + return releaseWidgetEditingLock; +} + +/** + * Release a widget editing lock. MUST be paired with a prior + * {@link acquireWidgetEditingLock} call. Count is clamped to 0 so + * unbalanced releases are harmless. + */ +export function releaseWidgetEditingLock(): void { + widgetEditingCount = Math.max(0, widgetEditingCount - 1); +} + +/** + * Internal StateEffect used by the inspection field to synchronise the + * module counter into CM6 state. Widgets never need to dispatch this + * directly — the counter is the source of truth. + */ +const syncWidgetCount = StateEffect.define(); + +/** + * Inspection-only StateField that mirrors {@link widgetEditingCount} into + * CM6 state so it can be read in devtools, tested via + * `view.state.field(widgetEditingState)`, and used by future tooling. + * + * This field does NOT gate the live-preview rebuild decision — use + * {@link isWidgetEditing} for that. + */ +export const widgetEditingState = StateField.define({ + create() { + return 0; + }, + update(value: number, tr: Transaction) { + for (const effect of tr.effects) { + if (effect.is(syncWidgetCount)) return effect.value; + } + return value; + }, +}); + +/** + * Read the current widget editing count from CM6 state (for testing / + * devtools inspection). Returns 0 if the StateField hasn't been loaded. + */ +export function getWidgetEditingCount(state: EditorState): number { + const field = state.field(widgetEditingState, false); + return field ?? 0; +} diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index c4c4b573..b325daa4 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -866,6 +866,296 @@ describe("live preview", () => { container.remove(); }); + it("copies selected column text to clipboard when column grip is clicked then Ctrl+C", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B | C |\n| --- | --- | --- |\n| 1 | 2 | 3 |\n| 4 | 5 | 6 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + // Click the column grip for the second column (index 1) to select it + const colGrips = container.querySelectorAll(".nexus-col-grip"); + expect(colGrips.length).toBeGreaterThanOrEqual(2); + colGrips[1].dispatchEvent(new MouseEvent("click", { bubbles: true })); + + // Now Ctrl+C should copy the entire column + const copied: Record = {}; + const copyEvent = new Event("copy", { bubbles: true, cancelable: true }); + Object.defineProperty(copyEvent, "clipboardData", { + configurable: true, + value: { + setData: (type: string, value: string) => { + copied[type] = value; + }, + }, + }); + container.querySelector(".nexus-table-wrapper")?.dispatchEvent(copyEvent); + + expect(copyEvent.defaultPrevented).toBe(true); + // Column copy: all rows, single column, tab-separated (but single col = just the values per line) + expect(copied["text/plain"]).toBe("B\n2\n5"); + editor.destroy(); + container.remove(); + }); + + it("copies selected row text to clipboard when row grip is clicked then Ctrl+C", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + // Click the row grip for the first data row. + // rowGrips[0] is the header (no click handler), rowGrips[1] is the first data row . + const rowGrips = container.querySelectorAll(".nexus-row-grip"); + expect(rowGrips.length).toBeGreaterThanOrEqual(2); + rowGrips[1].dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const copied: Record = {}; + const copyEvent = new Event("copy", { bubbles: true, cancelable: true }); + Object.defineProperty(copyEvent, "clipboardData", { + configurable: true, + value: { + setData: (type: string, value: string) => { + copied[type] = value; + }, + }, + }); + container.querySelector(".nexus-table-wrapper")?.dispatchEvent(copyEvent); + + expect(copyEvent.defaultPrevented).toBe(true); + expect(copied["text/plain"]).toBe("1\t2"); + editor.destroy(); + container.remove(); + }); + + it("does not intercept copy event when no range, column, or row is selected", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| 1 | 2 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + // No selection active — copy should fall through to browser default + const copyEvent = new Event("copy", { bubbles: true, cancelable: true }); + container.querySelector(".nexus-table-wrapper")?.dispatchEvent(copyEvent); + + expect(copyEvent.defaultPrevented).toBe(false); + editor.destroy(); + container.remove(); + }); + + it("serializes multi-row multi-column range with tabs and newlines", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B | C |\n| --- | --- | --- |\n| 1 | 2 | 3 |\n| 4 | 5 | 6 |\n| 7 | 8 | 9 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + const dataRows = Array.from(container.querySelectorAll("tr")).filter( + (row) => row.querySelector(".nexus-cell") + ); + const cells = dataRows.flatMap((row) => Array.from(row.querySelectorAll(".nexus-cell"))); + // cells layout: [A,B,C, 1,2,3, 4,5,6, 7,8,9] + const firstCell = cells[1]; // B (header, col 1) + const lastCell = cells[7]; // 5 (row 2 data, col 1) + + firstCell.getBoundingClientRect = () => ({ + x: 50, y: 0, left: 50, top: 0, right: 100, bottom: 30, width: 50, height: 30, toJSON: () => ({}) + }); + lastCell.getBoundingClientRect = () => ({ + x: 100, y: 30, left: 100, top: 30, right: 150, bottom: 60, width: 50, height: 30, toJSON: () => ({}) + }); + + firstCell.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, cancelable: true, button: 0, clientX: 75, clientY: 15 + })); + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, button: 0, clientX: 125, clientY: 45 + })); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, button: 0, clientX: 125, clientY: 45 + })); + + const copied: Record = {}; + const copyEvent = new Event("copy", { bubbles: true, cancelable: true }); + Object.defineProperty(copyEvent, "clipboardData", { + configurable: true, + value: { + setData: (type: string, value: string) => { + copied[type] = value; + }, + }, + }); + container.querySelector(".nexus-table-wrapper")?.dispatchEvent(copyEvent); + + expect(copyEvent.defaultPrevented).toBe(true); + // Range: rows 0-2 (header + 2 data rows), cols 1-1 (single col B) + // But header cells are also included in the range + // Rows: header (A,B,C), row2 (1,2,3), row3 (4,5,6) → cols 1 only + expect(copied["text/plain"]).toBe("B\n2\n5"); + editor.destroy(); + container.remove(); + }); + + it("allows native text selection inside table cell with inline formatting (bold/link)", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| **bold text** here | [link](https://x.com) extra |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + const cells = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell"); + const boldCell = cells?.[0]; + const linkCell = cells?.[1]; + expect(boldCell).not.toBeUndefined(); + expect(linkCell).not.toBeUndefined(); + + // Bold cell: native text selection should work without entering edit mode + boldCell!.getBoundingClientRect = () => ({ + x: 0, y: 0, left: 0, top: 0, right: 160, bottom: 30, width: 160, height: 30, toJSON: () => ({}) + }); + + const strongEl = boldCell!.querySelector("strong"); + expect(strongEl).not.toBeNull(); + const boldTextNode = strongEl!.firstChild; + expect(boldTextNode?.textContent).toBe("bold text"); + + boldCell!.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, cancelable: true, button: 0, clientX: 12, clientY: 15 + })); + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, cancelable: true, button: 0, clientX: 72, clientY: 15 + })); + + const range = document.createRange(); + range.setStart(boldTextNode!, 0); + range.setEnd(boldTextNode!, 4); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(range); + + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, button: 0, clientX: 72, clientY: 15 + })); + + expect(boldCell?.contentEditable).not.toBe("true"); + expect(document.getSelection()?.toString()).toBe("bold"); + document.getSelection()?.removeAllRanges(); + + // Link cell: native text selection should also work + linkCell!.getBoundingClientRect = () => ({ + x: 160, y: 0, left: 160, top: 0, right: 320, bottom: 30, width: 160, height: 30, toJSON: () => ({}) + }); + + const linkAnchor = linkCell!.querySelector("a"); + expect(linkAnchor).not.toBeNull(); + const linkTextNode = linkAnchor!.firstChild; + expect(linkTextNode?.textContent).toBe("link"); + + linkCell!.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, cancelable: true, button: 0, clientX: 172, clientY: 15 + })); + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, cancelable: true, button: 0, clientX: 220, clientY: 15 + })); + + const range2 = document.createRange(); + range2.setStart(linkTextNode!, 0); + range2.setEnd(linkTextNode!, 4); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(range2); + + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, button: 0, clientX: 220, clientY: 15 + })); + + expect(linkCell?.contentEditable).not.toBe("true"); + expect(document.getSelection()?.toString()).toBe("link"); + document.getSelection()?.removeAllRanges(); + editor.destroy(); + container.remove(); + }); + + it("clears native text selection and switches to range selection when drag crosses cell boundary", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| Alpha Beta Gamma | 2 |", + livePreview: true, + plugins: [createGfmPreset()] + }); + + const cells = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell"); + const firstCell = cells?.[0]; + const secondCell = cells?.[1]; + expect(firstCell).not.toBeUndefined(); + expect(secondCell).not.toBeUndefined(); + + firstCell!.getBoundingClientRect = () => ({ + x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 30, width: 100, height: 30, toJSON: () => ({}) + }); + secondCell!.getBoundingClientRect = () => ({ + x: 100, y: 0, left: 100, top: 0, right: 160, bottom: 30, width: 60, height: 30, toJSON: () => ({}) + }); + + // Start dragging within the first cell + firstCell!.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, cancelable: true, button: 0, clientX: 25, clientY: 15 + })); + // First move within the same cell — still text selection territory + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, cancelable: true, button: 0, clientX: 75, clientY: 15 + })); + + // Now move to the second cell — this should switch to range selection + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, cancelable: true, button: 0, clientX: 130, clientY: 15 + })); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, button: 0, clientX: 130, clientY: 15 + })); + + // Both cells should be in range selection (not edit mode) + expect(firstCell?.contentEditable).not.toBe("true"); + expect(secondCell?.contentEditable).not.toBe("true"); + expect(firstCell?.style.background).toContain("124, 108, 250"); + expect(secondCell?.style.background).toContain("124, 108, 250"); + + // Copy should work on the range + const copied: Record = {}; + const copyEvent = new Event("copy", { bubbles: true, cancelable: true }); + Object.defineProperty(copyEvent, "clipboardData", { + configurable: true, + value: { + setData: (type: string, value: string) => { + copied[type] = value; + }, + }, + }); + container.querySelector(".nexus-table-wrapper")?.dispatchEvent(copyEvent); + + expect(copyEvent.defaultPrevented).toBe(true); + expect(copied["text/plain"]).toBe("Alpha Beta Gamma\t2"); + editor.destroy(); + container.remove(); + }); + it("renders inline markdown links inside table cells as elements", () => { const container = document.createElement("div"); const editor = createEditor({