From 2edecf9ec4140a9bc8eb02fc9753467b0db9a78e Mon Sep 17 00:00:00 2001 From: Admin <18832026982@163.com> Date: Mon, 21 Sep 2026 17:57:00 +0800 Subject: [PATCH 1/2] feat(history): group undo/redo for table cell and structure edits Keep CM6 typing joins, isolate table actions, and split pending cell commits so Ctrl+Z matches one complete table action. Co-authored-by: Cursor --- README.md | 2 +- README.zh.md | 2 +- docs/ROADMAP.md | 2 +- docs/ROADMAP.zh.md | 2 +- packages/core/src/live-preview-table.ts | 111 +++++++++- packages/plugin-history/package.json | 1 + packages/plugin-history/src/index.ts | 18 +- .../test/plugin-history.test.ts | 199 +++++++++++++++++- pnpm-lock.yaml | 3 + 9 files changed, 322 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e12a11ae..a06ec12d 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ A real Electron app with file IO, live preview, and every plugin enabled — the | `@floatboat/nexus-react` | React binding — `useEditor` hook and `` component | | `@floatboat/nexus-vue` | Vue 3 binding — `useEditor` composable | | `@floatboat/nexus-preset-gfm` | GitHub Flavored Markdown preset (tables, strikethrough, task lists) | -| `@floatboat/nexus-plugin-history` | Undo/redo with `Ctrl+Z` / `Ctrl+Shift+Z` | +| `@floatboat/nexus-plugin-history` | Undo/redo with `Ctrl+Z` / `Ctrl+Shift+Z`; table actions grouped per complete edit | | `@floatboat/nexus-plugin-search` | Search and replace helpers | | `@floatboat/nexus-plugin-slash` | Slash command detection, ranking, and a vanilla-DOM floating menu UI | | `@floatboat/nexus-plugin-toolbar` | Toolbar primitives for formatting commands | diff --git a/README.zh.md b/README.zh.md index 9f749dfc..7f729108 100644 --- a/README.zh.md +++ b/README.zh.md @@ -173,7 +173,7 @@ pnpm dev:electron-demo | `@floatboat/nexus-react` | React 绑定 —— `useEditor` Hook 与 `` 组件 | | `@floatboat/nexus-vue` | Vue 3 绑定 —— `useEditor` 组合式函数 | | `@floatboat/nexus-preset-gfm` | GitHub Flavored Markdown 预设(表格、删除线、任务列表) | -| `@floatboat/nexus-plugin-history` | 撤销/重做,支持 `Ctrl+Z` / `Ctrl+Shift+Z` | +| `@floatboat/nexus-plugin-history` | 撤销/重做,支持 `Ctrl+Z` / `Ctrl+Shift+Z`;表格操作按完整动作分组 | | `@floatboat/nexus-plugin-search` | 搜索替换辅助函数 | | `@floatboat/nexus-plugin-slash` | 斜杠命令检测、排序与 vanilla DOM 浮层菜单 UI | | `@floatboat/nexus-plugin-toolbar` | 工具栏基础组件与格式化命令 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7c04d0e6..3c7599ef 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -36,7 +36,7 @@ This document maps every planned feature to **package ownership / priority / sta | 5 | `getSelectedText()` API | `core` | P0 | done | No | Plus atomic `replaceRange()` (single undo entry) — see `openspec/changes/add-selection-api` | | 6 | Multi-cursor / multi-selection | `core` | P1 | done | Yes | `openspec/changes/add-core-multi-cursor` — opt-in `multiCursor` config; live-preview reveal + table checks verified by regression tests | | 7 | AST enhancement / Markdown extensions | `core` + `preset-gfm` | P2 | planned | Yes | Affects serialization and every AST-dependent plugin | -| 8 | Undo / redo grouping | `plugin-history` | P1 | planned | No | Coordinate with table's `tableEditingCount`; consolidate competing impls before merge | +| 8 | Undo / redo grouping | `plugin-history` | P1 | done | No | Table cell / structure commits use `input.table.*` + `isolateHistory`; typing still uses CM6 default joins; coordinates with `tableEditingCount` | ## 4. Plugin System diff --git a/docs/ROADMAP.zh.md b/docs/ROADMAP.zh.md index 11d0f65b..41b56e61 100644 --- a/docs/ROADMAP.zh.md +++ b/docs/ROADMAP.zh.md @@ -36,7 +36,7 @@ | 5 | `getSelectedText()` API | `core` | P0 | done | 否 | 另含原子 `replaceRange()`(单条 undo)—— 见 `openspec/changes/add-selection-api` | | 6 | 多光标 / 多选支持 | `core` | P1 | done | 是 | `openspec/changes/add-core-multi-cursor` — opt-in `multiCursor` 配置;live-preview 揭示与表格检查已有回归测试覆盖 | | 7 | AST 增强 / Markdown 扩展 | `core` + `preset-gfm` | P2 | planned | 是 | 影响序列化与所有依赖 AST 的插件 | -| 8 | undo / redo 分组 | `plugin-history` | P1 | planned | 否 | 注意与表格交互的 `tableEditingCount` 协同;合并前需收敛多个竞品实现 | +| 8 | undo / redo 分组 | `plugin-history` | P1 | done | 否 | 表格 cell / 结构操作通过 `input.table.*` + `isolateHistory` 各成一组;打字仍走 CM6 默认 join;协同 `tableEditingCount` | ## 4. 插件系统 diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index 29d9f7c4..e8c6233e 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -1,3 +1,4 @@ +import { isolateHistory } from "@codemirror/commands"; import { EditorView, WidgetType, runScopeHandlers } from "@codemirror/view"; import type { Table } from "mdast"; @@ -6,6 +7,10 @@ import { setInputTargetProvider } from "./input-target-provider"; const tableEditingCounts = new WeakMap(); +/** Kept in sync with plugin-history so each table action is its own undo group. */ +const TABLE_CELL_USER_EVENT = "input.table.cell"; +const TABLE_STRUCTURE_USER_EVENT = "input.table.structure"; + export function isTableEditing(view: EditorView | null | undefined): boolean { return !!view && (tableEditingCounts.get(view) ?? 0) > 0; } @@ -106,6 +111,27 @@ function describeActiveCell(): string { const SEPARATOR_RE = /^\|?\s*[-:]+\s*(\|\s*[-:]+\s*)*\|?\s*$/; +interface TableCellCommit { + lineIdx: number; + col: number; + text: string; +} + +function cellsFromTableLine(line: string): string[] { + return line.split("|").filter((_, i, a) => i > 0 && i < a.length - 1).map((cell) => cell.trim()); +} + +function applyTableCellCommit(source: string, commit: TableCellCommit): string { + const lines = source.split("\n"); + const line = lines[commit.lineIdx]; + if (line === undefined || SEPARATOR_RE.test(line)) return source; + const cells = cellsFromTableLine(line); + if (commit.col < 0 || commit.col >= cells.length) return source; + cells[commit.col] = commit.text; + lines[commit.lineIdx] = "| " + cells.join(" | ") + " |"; + return lines.join("\n"); +} + // Session-scoped store of user-customised column widths. Keyed by the // table's header line (e.g. `| 头像 | 用户名 | 主页 |`) so widths survive // the widget being rebuilt across edits as long as the header doesn't @@ -599,7 +625,12 @@ export class EditableTableWidget extends WidgetType { if (tableEnd > v.state.doc.length || v.state.doc.sliceString(this.tableFrom, tableEnd) !== this.source) { return; } - v.dispatch({ changes: { from: this.tableFrom, to: this.tableFrom + this.source.length, insert: newSource } }); + v.dispatch({ + changes: { from: this.tableFrom, to: this.tableFrom + this.source.length, insert: newSource }, + userEvent: TABLE_STRUCTURE_USER_EVENT, + annotations: isolateHistory.of("full"), + }); + this.source = newSource; } private deleteColumn(colIdx: number): void { @@ -634,7 +665,12 @@ export class EditableTableWidget extends WidgetType { const nr = "\n| " + Array(cc).fill(" ").join(" | ") + " |"; const v = this.viewRef.current; if (!v) return; - v.dispatch({ changes: { from: this.tableFrom + this.source.length, insert: nr } }); + v.dispatch({ + changes: { from: this.tableFrom + this.source.length, insert: nr }, + userEvent: TABLE_STRUCTURE_USER_EVENT, + annotations: isolateHistory.of("full"), + }); + this.source += nr; } private moveColumn(from: number, to: number, source = this.source): void { @@ -677,6 +713,7 @@ export class EditableTableWidget extends WidgetType { const dataLineIndices: number[] = []; for (let i = 0; i < sourceLines.length; i++) if (!SEPARATOR_RE.test(sourceLines[i])) dataLineIndices.push(i); const dirtyRows = new Map(); + const pendingCellCommits: TableCellCommit[] = []; // State let selectedCol = -1; @@ -869,6 +906,7 @@ export class EditableTableWidget extends WidgetType { function clearDirtyRows(): void { dirtyRows.clear(); + pendingCellCommits.length = 0; updateSessionRegistration(); } @@ -889,6 +927,51 @@ export class EditableTableWidget extends WidgetType { return { source: snapshot.source, changed: snapshot.changed, valid: true }; } + function adoptCommittedSource(next: string): void { + self.source = next; + const nextLines = next.split("\n"); + sourceLines.length = 0; + sourceLines.push(...nextLines); + } + + function dispatchTableSource(v: EditorView, next: string, userEvent: string, selection?: { anchor: number; head: number }): void { + v.dispatch({ + changes: { + from: self.tableFrom, + to: self.tableFrom + self.source.length, + insert: next + }, + userEvent, + annotations: isolateHistory.of("full"), + ...(selection ? { selection } : {}), + }); + adoptCommittedSource(next); + } + + function flushIsolatedCellCommits(v: EditorView): boolean { + const held = editingLocks.focus; + if (!held) acquireEditingLock("focus"); + let current = self.source; + let changed = false; + const firstChangedLineIdx = pendingCellCommits[0]?.lineIdx ?? 0; + const firstChangedRow = dirtyRows.get(firstChangedLineIdx) ?? null; + if (firstChangedRow) restoreRowScrollPosition(firstChangedLineIdx, firstChangedRow); + for (const commit of pendingCellCommits) { + if (!currentDocumentContainsOriginalTable(v)) break; + const next = applyTableCellCommit(current, commit); + if (next === current) continue; + dispatchTableSource(v, next, TABLE_CELL_USER_EVENT); + current = next; + changed = true; + } + pendingCellCommits.length = 0; + dirtyRows.clear(); + updateSessionRegistration(); + if (!held) releaseEditingLock("focus"); + if (changed) v.requestMeasure(); + return changed; + } + function syncDirtyRowsToDocument(): boolean { const v = self.viewRef.current; if (!v || dirtyRows.size === 0) return false; @@ -897,6 +980,10 @@ export class EditableTableWidget extends WidgetType { return false; } + if (pendingCellCommits.length > 1) { + return flushIsolatedCellCommits(v); + } + const snapshot = dirtySourceSnapshot(); if (!snapshot.changed) { clearDirtyRows(); @@ -906,15 +993,10 @@ export class EditableTableWidget extends WidgetType { const anchorLineIdx = snapshot.firstChangedLineIdx ?? 0; const anchor = lineStartOffset(sourceLines, anchorLineIdx, self.tableFrom); if (snapshot.firstChangedRow) restoreRowScrollPosition(anchorLineIdx, snapshot.firstChangedRow); - clearDirtyRows(); - v.dispatch({ - changes: { - from: self.tableFrom, - to: self.tableFrom + self.source.length, - insert: snapshot.source - }, - selection: { anchor, head: anchor } - }); + dispatchTableSource(v, snapshot.source, TABLE_CELL_USER_EVENT, { anchor, head: anchor }); + pendingCellCommits.length = 0; + dirtyRows.clear(); + updateSessionRegistration(); v.requestMeasure(); return true; } @@ -2098,6 +2180,13 @@ export class EditableTableWidget extends WidgetType { // before swapping raw DOM back to rich rendering so the final IME // candidate (or last ordinary keystroke) cannot be overwritten. rememberCellSourceEdit(); + if (sourceLineIdx !== undefined) { + pendingCellCommits.push({ + lineIdx: sourceLineIdx, + col: cellCol, + text: td.dataset.source ?? "", + }); + } releaseEditingLock("focus"); td.contentEditable = "false"; // Restore default text-flow + width rules — we set them on diff --git a/packages/plugin-history/package.json b/packages/plugin-history/package.json index 5f6b2b88..7454ea52 100644 --- a/packages/plugin-history/package.json +++ b/packages/plugin-history/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@codemirror/commands": "^6.8.1", + "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.36.1", "@floatboat/nexus-core": "workspace:*" }, diff --git a/packages/plugin-history/src/index.ts b/packages/plugin-history/src/index.ts index 4f65b373..292a61e2 100644 --- a/packages/plugin-history/src/index.ts +++ b/packages/plugin-history/src/index.ts @@ -1,11 +1,25 @@ -import { history, historyKeymap } from "@codemirror/commands"; +import { history, historyKeymap, isolateHistory } from "@codemirror/commands"; +import { EditorState, Transaction } from "@codemirror/state"; import { keymap } from "@codemirror/view"; import type { NexusPlugin } from "@floatboat/nexus-core"; +/** Matches table widget commits. Not a host API. */ +const TABLE_CELL_USER_EVENT = "input.table.cell"; +const TABLE_STRUCTURE_USER_EVENT = "input.table.structure"; + +function isolateTableHistory() { + return EditorState.transactionExtender.of((tr) => { + if (!tr.docChanged) return null; + const event = tr.annotation(Transaction.userEvent); + if (event !== TABLE_CELL_USER_EVENT && event !== TABLE_STRUCTURE_USER_EVENT) return null; + return { annotations: isolateHistory.of("full") }; + }); +} + export function createHistoryPlugin(): NexusPlugin { return { name: "plugin-history", - cmExtensions: [history(), keymap.of(historyKeymap)] + cmExtensions: [history(), isolateTableHistory(), keymap.of(historyKeymap)] }; } diff --git a/packages/plugin-history/test/plugin-history.test.ts b/packages/plugin-history/test/plugin-history.test.ts index 11d8d0d0..0d416d27 100644 --- a/packages/plugin-history/test/plugin-history.test.ts +++ b/packages/plugin-history/test/plugin-history.test.ts @@ -1,7 +1,58 @@ +import { EditorView, ViewPlugin } from "@codemirror/view"; import { createEditor } from "@floatboat/nexus-core"; -import { describe, expect, it } from "vitest"; +import { createGfmPreset } from "@floatboat/nexus-preset-gfm"; +import { describe, expect, it, vi } from "vitest"; + import { createHistoryPlugin } from "../src/index"; +function captureView(): { plugin: ViewPlugin; getView: () => EditorView } { + let view: EditorView | null = null; + const plugin = ViewPlugin.fromClass( + class { + constructor(current: EditorView) { + view = current; + } + } + ); + return { + plugin, + getView: () => { + if (!view) throw new Error("EditorView was not captured"); + return view; + } + }; +} + +function activateTableCell(cell: HTMLElement, clientX = 80, clientY = 40): void { + document.getSelection()?.removeAllRanges(); + cell.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + button: 0, + clientX, + clientY + })); + document.dispatchEvent(new MouseEvent("mouseup", { + bubbles: true, + button: 0, + clientX, + clientY + })); +} + +async function blurCellOutsideTable(cell: HTMLElement): Promise { + const activeElementSpy = vi.spyOn(document, "activeElement", "get").mockReturnValue(document.body); + cell.dispatchEvent(new Event("blur")); + await Promise.resolve(); + activeElementSpy.mockRestore(); +} + +function editCell(cell: HTMLElement, text: string): void { + activateTableCell(cell); + cell.textContent = text; + cell.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text })); +} + describe("@floatboat/nexus-plugin-history", () => { it("undoes the most recent document change through codemirror key handling", () => { const container = document.createElement("div"); @@ -61,4 +112,150 @@ describe("@floatboat/nexus-plugin-history", () => { expect(editor.getDocument()).toBe("next"); editor.destroy(); }); + + it("keeps CodeMirror typing joins: consecutive input.type is one undo", () => { + const container = document.createElement("div"); + const captured = captureView(); + const editor = createEditor({ + container, + initialValue: "ab", + plugins: [createHistoryPlugin(), { name: "capture-view", cmExtensions: [captured.plugin] }] + }); + const view = captured.getView(); + + view.dispatch({ changes: { from: 2, insert: "c" }, userEvent: "input.type" }); + view.dispatch({ changes: { from: 3, insert: "d" }, userEvent: "input.type" }); + expect(editor.getDocument()).toBe("abcd"); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("ab"); + expect(editor.undo()).toBe(false); + + editor.destroy(); + }); + + it("does not join a table cell commit with following typed input", () => { + const container = document.createElement("div"); + const captured = captureView(); + const editor = createEditor({ + container, + initialValue: "ab", + plugins: [createHistoryPlugin(), { name: "capture-view", cmExtensions: [captured.plugin] }] + }); + const view = captured.getView(); + + view.dispatch({ changes: { from: 2, insert: "!" }, userEvent: "input.table.cell" }); + view.dispatch({ changes: { from: 3, insert: "x" }, userEvent: "input.type" }); + expect(editor.getDocument()).toBe("ab!x"); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("ab!"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("ab"); + + editor.destroy(); + }); + + it("treats each isolated table cell commit as its own undo group", () => { + const container = document.createElement("div"); + const captured = captureView(); + const editor = createEditor({ + container, + initialValue: "ab", + plugins: [createHistoryPlugin(), { name: "capture-view", cmExtensions: [captured.plugin] }] + }); + const view = captured.getView(); + + view.dispatch({ changes: { from: 2, insert: "1" }, userEvent: "input.table.cell" }); + view.dispatch({ changes: { from: 3, insert: "2" }, userEvent: "input.table.cell" }); + expect(editor.getDocument()).toBe("ab12"); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("ab1"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("ab"); + + editor.destroy(); + }); + + it("commits one table cell blur as a single undo and restores on redo", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| 1 | 2 |", + livePreview: true, + plugins: [createHistoryPlugin(), createGfmPreset()] + }); + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + if (!cell) throw new Error("Expected table cell"); + + editCell(cell, "edited"); + await blurCellOutsideTable(cell); + expect(editor.getDocument()).toContain("| edited | 2 |"); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toContain("| 1 | 2 |"); + expect(editor.redo()).toBe(true); + expect(editor.getDocument()).toContain("| edited | 2 |"); + + editor.destroy(); + container.remove(); + }); + + it("keeps adjacent table cells as separate undo groups after blur", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| 1 | 2 |", + livePreview: true, + plugins: [createHistoryPlugin(), createGfmPreset()] + }); + const cells = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell"); + const first = cells?.[0]; + const second = cells?.[1]; + if (!first || !second) throw new Error("Expected table cells"); + + editCell(first, "one"); + const activeSpy = vi.spyOn(document, "activeElement", "get").mockReturnValue(second); + first.dispatchEvent(new Event("blur")); + await Promise.resolve(); + activeSpy.mockRestore(); + + editCell(second, "two"); + await blurCellOutsideTable(second); + + expect(editor.getDocument()).toContain("| one | two |"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toContain("| one | 2 |"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toContain("| 1 | 2 |"); + + editor.destroy(); + container.remove(); + }); + + it("treats adding a table column as one undo group", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const original = "| A | B |\n| --- | --- |\n| 1 | 2 |"; + const editor = createEditor({ + container, + initialValue: original, + livePreview: true, + plugins: [createHistoryPlugin(), createGfmPreset()] + }); + const addColumn = container.querySelector('button[title="Add column"]'); + if (!addColumn) throw new Error("Expected add-column button"); + + addColumn.click(); + expect(editor.getDocument()).not.toBe(original); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe(original); + expect(editor.undo()).toBe(false); + + editor.destroy(); + container.remove(); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0382fb79..26c5b1ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: '@codemirror/commands': specifier: ^6.8.1 version: 6.10.3 + '@codemirror/state': + specifier: ^6.6.0 + version: 6.6.0 '@codemirror/view': specifier: ^6.36.1 version: 6.41.0 From ad0a364f17205e18fffffc6fea635f8159ea0dce Mon Sep 17 00:00:00 2001 From: Admin <18832026982@163.com> Date: Mon, 21 Sep 2026 22:38:19 +0800 Subject: [PATCH 2/2] feat(history): exclude silent loads and flush table cells on undo Silent setDocument was still recorded as an edit, so Ctrl+Z after opening a file reverted the previous buffer. Undo also skipped in-progress table cells that had not blurred yet. Co-authored-by: Cursor --- README.md | 2 +- README.zh.md | 2 +- docs/ROADMAP.md | 2 +- docs/ROADMAP.zh.md | 2 +- packages/core/src/editor.ts | 19 ++++++- packages/core/src/live-preview-table.ts | 16 +++--- packages/core/src/types.ts | 22 +++++--- packages/core/test/editor.test.ts | 34 +++++++++++ packages/plugin-history/src/index.ts | 6 ++ .../test/plugin-history.test.ts | 56 +++++++++++++++++++ 10 files changed, 136 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index a06ec12d..fc5435ab 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ A real Electron app with file IO, live preview, and every plugin enabled — the | `@floatboat/nexus-react` | React binding — `useEditor` hook and `` component | | `@floatboat/nexus-vue` | Vue 3 binding — `useEditor` composable | | `@floatboat/nexus-preset-gfm` | GitHub Flavored Markdown preset (tables, strikethrough, task lists) | -| `@floatboat/nexus-plugin-history` | Undo/redo with `Ctrl+Z` / `Ctrl+Shift+Z`; table actions grouped per complete edit | +| `@floatboat/nexus-plugin-history` | Undo/redo with `Ctrl+Z` / `Ctrl+Shift+Z`; table actions grouped per complete edit; silent loads stay out of history | | `@floatboat/nexus-plugin-search` | Search and replace helpers | | `@floatboat/nexus-plugin-slash` | Slash command detection, ranking, and a vanilla-DOM floating menu UI | | `@floatboat/nexus-plugin-toolbar` | Toolbar primitives for formatting commands | diff --git a/README.zh.md b/README.zh.md index 7f729108..382e5d64 100644 --- a/README.zh.md +++ b/README.zh.md @@ -173,7 +173,7 @@ pnpm dev:electron-demo | `@floatboat/nexus-react` | React 绑定 —— `useEditor` Hook 与 `` 组件 | | `@floatboat/nexus-vue` | Vue 3 绑定 —— `useEditor` 组合式函数 | | `@floatboat/nexus-preset-gfm` | GitHub Flavored Markdown 预设(表格、删除线、任务列表) | -| `@floatboat/nexus-plugin-history` | 撤销/重做,支持 `Ctrl+Z` / `Ctrl+Shift+Z`;表格操作按完整动作分组 | +| `@floatboat/nexus-plugin-history` | 撤销/重做,支持 `Ctrl+Z` / `Ctrl+Shift+Z`;表格操作按完整动作分组;silent 加载不进历史 | | `@floatboat/nexus-plugin-search` | 搜索替换辅助函数 | | `@floatboat/nexus-plugin-slash` | 斜杠命令检测、排序与 vanilla DOM 浮层菜单 UI | | `@floatboat/nexus-plugin-toolbar` | 工具栏基础组件与格式化命令 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3c7599ef..38735a0b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -36,7 +36,7 @@ This document maps every planned feature to **package ownership / priority / sta | 5 | `getSelectedText()` API | `core` | P0 | done | No | Plus atomic `replaceRange()` (single undo entry) — see `openspec/changes/add-selection-api` | | 6 | Multi-cursor / multi-selection | `core` | P1 | done | Yes | `openspec/changes/add-core-multi-cursor` — opt-in `multiCursor` config; live-preview reveal + table checks verified by regression tests | | 7 | AST enhancement / Markdown extensions | `core` + `preset-gfm` | P2 | planned | Yes | Affects serialization and every AST-dependent plugin | -| 8 | Undo / redo grouping | `plugin-history` | P1 | done | No | Table cell / structure commits use `input.table.*` + `isolateHistory`; typing still uses CM6 default joins; coordinates with `tableEditingCount` | +| 8 | Undo / redo grouping | `plugin-history` | P1 | in-progress | No | Table cell / structure commits use `input.table.*` + `isolateHistory`; typing still uses CM6 default joins; silent `setDocument` stays out of history; undo/redo flush pending table cells; coordinates with `tableEditingCount` | ## 4. Plugin System diff --git a/docs/ROADMAP.zh.md b/docs/ROADMAP.zh.md index 41b56e61..0b1fdaca 100644 --- a/docs/ROADMAP.zh.md +++ b/docs/ROADMAP.zh.md @@ -36,7 +36,7 @@ | 5 | `getSelectedText()` API | `core` | P0 | done | 否 | 另含原子 `replaceRange()`(单条 undo)—— 见 `openspec/changes/add-selection-api` | | 6 | 多光标 / 多选支持 | `core` | P1 | done | 是 | `openspec/changes/add-core-multi-cursor` — opt-in `multiCursor` 配置;live-preview 揭示与表格检查已有回归测试覆盖 | | 7 | AST 增强 / Markdown 扩展 | `core` + `preset-gfm` | P2 | planned | 是 | 影响序列化与所有依赖 AST 的插件 | -| 8 | undo / redo 分组 | `plugin-history` | P1 | done | 否 | 表格 cell / 结构操作通过 `input.table.*` + `isolateHistory` 各成一组;打字仍走 CM6 默认 join;协同 `tableEditingCount` | +| 8 | undo / redo 分组 | `plugin-history` | P1 | in-progress | 否 | 表格 cell / 结构操作通过 `input.table.*` + `isolateHistory` 各成一组;打字仍走 CM6 默认 join;silent `setDocument` 不进历史;undo/redo 前 flush 未提交表格;协同 `tableEditingCount` | ## 4. 插件系统 diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 4d356ea2..b652f0b9 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -1,4 +1,4 @@ -import { Annotation, EditorSelection, EditorState } from "@codemirror/state"; +import { Annotation, EditorSelection, EditorState, Transaction } from "@codemirror/state"; // Annotation attached to dispatches that load content programmatically (e.g. // setDocument from file open) so updateListener can skip the user-edit path — @@ -455,7 +455,13 @@ export function createEditor(config: EditorConfig): EditorAPI { to: view.state.doc.length, insert: next }, - annotations: silent ? silentDocChange.of(true) : undefined, + // Silent loads (file open from the host) must not enter the undo + // history — pressing Ctrl+Z right after opening a document should never + // revert it to the previously open one. addToHistory=false keeps prior + // history events mapped through the load instead of recording it. + annotations: silent + ? [silentDocChange.of(true), Transaction.addToHistory.of(false)] + : undefined, ...(selection ? { selection } : {}), }; @@ -885,7 +891,9 @@ export function createEditor(config: EditorConfig): EditorAPI { ? { anchor: selection.anchor, head: selection.head ?? selection.anchor } : undefined, scrollIntoView: true, - annotations: silent ? silentDocChange.of(true) : undefined, + annotations: silent + ? [silentDocChange.of(true), Transaction.addToHistory.of(false)] + : undefined, }); if (silent) { const next = view.state.doc.toString(); @@ -901,10 +909,15 @@ export function createEditor(config: EditorConfig): EditorAPI { }, undo() { if (destroyed) return false; + // Table cells keep DOM edits local until blur. Commit that pending + // text before history so undo/redo see the in-progress cell instead + // of skipping it or replaying a stale redo branch over it. + if (flushPendingTableEdits(view, true)) flushScheduledChangeNow(); return cmUndo(view); }, redo() { if (destroyed) return false; + if (flushPendingTableEdits(view, true)) flushScheduledChangeNow(); return cmRedo(view); }, focus() { diff --git a/packages/core/src/live-preview-table.ts b/packages/core/src/live-preview-table.ts index e8c6233e..f6f08082 100644 --- a/packages/core/src/live-preview-table.ts +++ b/packages/core/src/live-preview-table.ts @@ -625,8 +625,11 @@ export class EditableTableWidget extends WidgetType { if (tableEnd > v.state.doc.length || v.state.doc.sliceString(this.tableFrom, tableEnd) !== this.source) { return; } + // Structural ops carry userEvent so plugin-history can isolate them, plus + // isolateHistory here so consecutive row/column ops never join via + // newGroupDelay even if the plugin extender is absent. v.dispatch({ - changes: { from: this.tableFrom, to: this.tableFrom + this.source.length, insert: newSource }, + changes: { from: this.tableFrom, to: tableEnd, insert: newSource }, userEvent: TABLE_STRUCTURE_USER_EVENT, annotations: isolateHistory.of("full"), }); @@ -663,14 +666,9 @@ export class EditableTableWidget extends WidgetType { private addRow(): void { const cc = (this.node.children?.[0] as any)?.children?.length ?? 2; const nr = "\n| " + Array(cc).fill(" ").join(" | ") + " |"; - const v = this.viewRef.current; - if (!v) return; - v.dispatch({ - changes: { from: this.tableFrom + this.source.length, insert: nr }, - userEvent: TABLE_STRUCTURE_USER_EVENT, - annotations: isolateHistory.of("full"), - }); - this.source += nr; + // Route through the shared dispatch so row inserts get the same source + // staleness guard and undo-group isolation as other structural ops. + this.dispatch(this.source + nr); } private moveColumn(from: number, to: number, source = this.source): void { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b40ebffd..2de223fe 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -347,8 +347,9 @@ export type CoreEditorTransactionDispatchResult = export interface SetDocumentOptions { /** - * When true, skip the onChange pipeline. Use when loading a file from disk - * to avoid treating the load as a user edit. + * When true, skip the onChange pipeline and do not record the replacement + * in undo history. Use when loading a file from disk so Ctrl+Z after open + * does not revert to the previously open document. */ silent?: boolean; /** @@ -390,9 +391,11 @@ export interface EditorAPI { /** * Replace the document content. * - * @param opts.silent When true, skip the onChange pipeline. Use when - * loading a file from disk — avoids treating a file-open as a user - * edit (no redundant mdast parse / link-index rebuild). + * @param opts.silent When true, skip the onChange pipeline and exclude + * the load from undo history. Use when loading a file from disk — + * avoids treating a file-open as a user edit (no redundant mdast parse + * / link-index rebuild, and Ctrl+Z will not revert the freshly opened + * buffer). * * 组合输入(IME)进行中时,整文档替换会打断输入法、丢失正在合成的文字 * 并把视口重置到顶部。此时本次替换会延迟到 compositionend 再应用,只保留 @@ -413,10 +416,11 @@ export interface EditorAPI { * - `selection` is optional. When omitted CM6 maps the existing selection * through the change using its default position mapping — callers that * don't care where the cursor lands after the edit can skip this. - * - `silent` mirrors `setDocument`: skips `onChange` / the `change` event. - * The AST is still resynced inline so `getAst()` stays consistent for - * immediate callers. Intended for non-user edits only (file-open, seeding). - * Plugin code should leave `silent` unset. + * - `silent` mirrors `setDocument`: skips `onChange` / the `change` event + * and excludes the patch from undo history. The AST is still resynced + * inline so `getAst()` stays consistent for immediate callers. Intended + * for non-user edits only (file-open, seeding). Plugin code should leave + * `silent` unset. * - Positions (`from`, `to`, and `selection` offsets) are in pre-edit doc * coordinates — the same coordinate space as `getSelection()` returns. * - Bounds: callers are responsible for valid offsets. CM6 throws diff --git a/packages/core/test/editor.test.ts b/packages/core/test/editor.test.ts index 85505eba..3def2612 100644 --- a/packages/core/test/editor.test.ts +++ b/packages/core/test/editor.test.ts @@ -608,6 +608,40 @@ describe("createEditor", () => { editor.destroy(); }); + it("setDocument silent: host document loads stay out of undo history", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "start", plugins: [createHistoryPlugin()] }); + + editor.setDocument("loaded from disk", { silent: true }); + expect(editor.getDocument()).toBe("loaded from disk"); + // Undo right after opening a file must not revert the fresh document + // back to the previously open one. + expect(editor.undo()).toBe(false); + expect(editor.getDocument()).toBe("loaded from disk"); + editor.destroy(); + }); + + it("setDocument non-silent: programmatic edits remain undoable", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "start", plugins: [createHistoryPlugin()] }); + + editor.setDocument("next"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("start"); + editor.destroy(); + }); + + it("replaceRange silent: host patches stay out of undo history", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello world", plugins: [createHistoryPlugin()] }); + + editor.replaceRange(6, 11, "earth", undefined, { silent: true }); + expect(editor.getDocument()).toBe("hello earth"); + expect(editor.undo()).toBe(false); + expect(editor.getDocument()).toBe("hello earth"); + editor.destroy(); + }); + // ── HTML export ── it("exports markdown to semantic HTML", () => { diff --git a/packages/plugin-history/src/index.ts b/packages/plugin-history/src/index.ts index 292a61e2..a2d1accf 100644 --- a/packages/plugin-history/src/index.ts +++ b/packages/plugin-history/src/index.ts @@ -8,6 +8,12 @@ import type { NexusPlugin } from "@floatboat/nexus-core"; const TABLE_CELL_USER_EVENT = "input.table.cell"; const TABLE_STRUCTURE_USER_EVENT = "input.table.structure"; +/** + * Table widgets tag cell/structure commits with dedicated userEvents. + * isolateHistory("full") so they never join adjacent typing or each other. + * Silent document loads stay out of history in core (`addToHistory: false`), + * not here — that is a file-open concern, not a grouping option. + */ function isolateTableHistory() { return EditorState.transactionExtender.of((tr) => { if (!tr.docChanged) return null; diff --git a/packages/plugin-history/test/plugin-history.test.ts b/packages/plugin-history/test/plugin-history.test.ts index 0d416d27..885618d2 100644 --- a/packages/plugin-history/test/plugin-history.test.ts +++ b/packages/plugin-history/test/plugin-history.test.ts @@ -258,4 +258,60 @@ describe("@floatboat/nexus-plugin-history", () => { editor.destroy(); container.remove(); }); + + it("treats consecutive add-row clicks as separate undo groups", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const initial = "| A |\n| --- |\n| 1 |"; + const editor = createEditor({ + container, + initialValue: initial, + livePreview: true, + plugins: [createHistoryPlugin(), createGfmPreset()] + }); + const clickAddRow = () => { + const button = container.querySelector('button[title="Add row"]'); + if (!button) throw new Error("Expected add-row button"); + button.click(); + }; + + clickAddRow(); + const afterFirst = editor.getDocument(); + expect(afterFirst).not.toBe(initial); + + clickAddRow(); + const afterSecond = editor.getDocument(); + expect(afterSecond).not.toBe(afterFirst); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe(afterFirst); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe(initial); + + editor.destroy(); + container.remove(); + }); + + it("commits a dirty table cell before undo so the in-progress edit is reverted", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ + container, + initialValue: "| A | B |\n| --- | --- |\n| 1 | 2 |", + livePreview: true, + plugins: [createHistoryPlugin(), createGfmPreset()] + }); + const cell = container.querySelectorAll("tr")[2]?.querySelectorAll(".nexus-cell")[0]; + if (!cell) throw new Error("Expected table cell"); + + editCell(cell, "edited"); + expect(editor.getDocument()).toContain("| 1 | 2 |"); + + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toContain("| 1 | 2 |"); + expect(editor.getDocument()).not.toContain("edited"); + + editor.destroy(); + container.remove(); + }); });