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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Editor />` 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; 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 |
Expand Down
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ pnpm dev:electron-demo
| `@floatboat/nexus-react` | React 绑定 —— `useEditor` Hook 与 `<Editor />` 组件 |
| `@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` | 工具栏基础组件与格式化命令 |
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 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

Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | in-progress | 否 | 表格 cell / 结构操作通过 `input.table.*` + `isolateHistory` 各成一组;打字仍走 CM6 默认 join;silent `setDocument` 不进历史;undo/redo 前 flush 未提交表格;协同 `tableEditingCount` |

## 4. 插件系统

Expand Down
19 changes: 16 additions & 3 deletions packages/core/src/editor.ts
Original file line number Diff line number Diff line change
@@ -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 —
Expand Down Expand Up @@ -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 } : {}),
};

Expand Down Expand Up @@ -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();
Expand All @@ -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() {
Expand Down
113 changes: 100 additions & 13 deletions packages/core/src/live-preview-table.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isolateHistory } from "@codemirror/commands";
import { EditorView, WidgetType, runScopeHandlers } from "@codemirror/view";
import type { Table } from "mdast";

Expand All @@ -6,6 +7,10 @@ import { setInputTargetProvider } from "./input-target-provider";

const tableEditingCounts = new WeakMap<EditorView, number>();

/** 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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -599,7 +625,15 @@ 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 } });
// 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: tableEnd, insert: newSource },
userEvent: TABLE_STRUCTURE_USER_EVENT,
annotations: isolateHistory.of("full"),
});
this.source = newSource;
}

private deleteColumn(colIdx: number): void {
Expand Down Expand Up @@ -632,9 +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 } });
// 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 {
Expand Down Expand Up @@ -677,6 +711,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<number, HTMLElement>();
const pendingCellCommits: TableCellCommit[] = [];

// State
let selectedCol = -1;
Expand Down Expand Up @@ -869,6 +904,7 @@ export class EditableTableWidget extends WidgetType {

function clearDirtyRows(): void {
dirtyRows.clear();
pendingCellCommits.length = 0;
updateSessionRegistration();
}

Expand All @@ -889,6 +925,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;
Expand All @@ -897,6 +978,10 @@ export class EditableTableWidget extends WidgetType {
return false;
}

if (pendingCellCommits.length > 1) {
return flushIsolatedCellCommits(v);
}

const snapshot = dirtySourceSnapshot();
if (!snapshot.changed) {
clearDirtyRows();
Expand All @@ -906,15 +991,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;
}
Expand Down Expand Up @@ -2098,6 +2178,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
Expand Down
22 changes: 13 additions & 9 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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 再应用,只保留
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions packages/core/test/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-history/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"dependencies": {
"@codemirror/commands": "^6.8.1",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.36.1",
"@floatboat/nexus-core": "workspace:*"
},
Expand Down
24 changes: 22 additions & 2 deletions packages/plugin-history/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
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";

/**
* 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;
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)]
};
}
Loading