Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions openspec/changes/add-widget-api-standardization/design.md
Original file line number Diff line number Diff line change
@@ -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<WidgetInteractionState>`,挂在 CM6 state 上。

```typescript
interface WidgetInteractionState {
activeWidgetCount: number; // 替代 tableEditingCount
activeWidgets: Set<string>; // 哪些 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<string>` 使用 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` 是否需要持久化**:跨文档切换时是否保留?倾向不保留(每次新建编辑器重置)。
37 changes: 37 additions & 0 deletions openspec/changes/add-widget-api-standardization/proposal.md
Original file line number Diff line number Diff line change
@@ -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`)
Original file line number Diff line number Diff line change
@@ -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`(不重复调用)
Loading