blots-editor 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  "settings.language.zh": "中文",
10
10
  "settings.sensitivity.cursor": "Cursor Wheel Sensitivity",
11
11
  "settings.sensitivity.view": "View Scroll Sensitivity",
12
- "settings.sensitivity.hint": "Click or drag to adjust",
12
+ "settings.slider.hint": "Click or drag to adjust",
13
13
  "settings.back": "Back",
14
14
  "editor.mode.insert": "INSERT",
15
15
  "editor.mode.normal": "NORMAL",
@@ -20,7 +20,6 @@
20
20
  "toolbar.save": "Save",
21
21
  "toolbar.settings": "Settings",
22
22
  "fileTree.scanning": "Scanning...",
23
- "fileTree.noDir": "No directory",
24
23
  "fileTree.fail": "Scan failed",
25
24
  "fileTree.unsaved": "Unsaved changes",
26
25
  "editor.setting.title": "Menu",
@@ -32,6 +31,8 @@
32
31
  "settings.fileTreeRoot": "FileTree Root",
33
32
  "settings.fileTreeRoot.startup": "Startup directory",
34
33
  "settings.fileTreeRoot.custom": "Custom path",
34
+ "settings.mergeWindow": "Undo Merge Window",
35
+ "settings.mergeWindow.off": "Off",
35
36
  "settings.ok": "OK",
36
37
  "settings.cancel": "Cancel"
37
38
  }
@@ -9,7 +9,7 @@
9
9
  "settings.language.zh": "中文",
10
10
  "settings.sensitivity.cursor": "光标滚动灵敏度",
11
11
  "settings.sensitivity.view": "视图滚动灵敏度",
12
- "settings.sensitivity.hint": "点击或拖动调整",
12
+ "settings.slider.hint": "点击或拖动调整",
13
13
  "settings.back": "返回",
14
14
  "editor.mode.insert": "插入",
15
15
  "editor.mode.normal": "普通",
@@ -20,7 +20,6 @@
20
20
  "toolbar.save": "保存",
21
21
  "toolbar.settings": "设置",
22
22
  "fileTree.scanning": "正在扫描...",
23
- "fileTree.noDir": "没有目录",
24
23
  "fileTree.fail": "扫描失败",
25
24
  "fileTree.unsaved": "未保存的更改",
26
25
  "editor.setting.title": "菜单",
@@ -32,6 +31,8 @@
32
31
  "settings.fileTreeRoot": "文件树根目录",
33
32
  "settings.fileTreeRoot.startup": "启动目录",
34
33
  "settings.fileTreeRoot.custom": "自定义路径",
34
+ "settings.mergeWindow": "撤销合并时间",
35
+ "settings.mergeWindow.off": "关闭",
35
36
  "settings.ok": "确定",
36
37
  "settings.cancel": "取消"
37
38
  }
@@ -131,9 +131,17 @@ export declare class Document {
131
131
  visualAtLogical(line: number, logical: number): number;
132
132
  moveLeft(): void;
133
133
  moveRight(): void;
134
- /** Vertical movement aligns by visual column, so wide-character lines do not drift. */
135
- moveUp(): void;
136
- moveDown(): void;
134
+ /**
135
+ * Move up by `count` visual lines (default 1), aligning by visual column so
136
+ * wide-character lines do not drift. Overshooting the top clamps to the
137
+ * first visual line.
138
+ */
139
+ moveUp(count?: number): void;
140
+ /**
141
+ * Move down by `count` visual lines (default 1), aligning by visual column.
142
+ * Overshooting the bottom clamps to the last visual line.
143
+ */
144
+ moveDown(count?: number): void;
137
145
  moveToLineStart(): void;
138
146
  moveToLineEnd(): void;
139
147
  moveToDocumentStart(): void;
@@ -325,10 +325,15 @@ export class Document {
325
325
  this.setCursor(line, target);
326
326
  }
327
327
  }
328
- /** Vertical movement aligns by visual column, so wide-character lines do not drift. */
329
- moveUp() {
328
+ /**
329
+ * Move up by `count` visual lines (default 1), aligning by visual column so
330
+ * wide-character lines do not drift. Overshooting the top clamps to the
331
+ * first visual line.
332
+ */
333
+ moveUp(count = 1) {
330
334
  const { line, logical } = this._cursor;
331
- if (this.cursorVisualLine === 0) {
335
+ const current = this.cursorVisualLine;
336
+ if (current === 0) {
332
337
  // Already on the first visual line: still move to the start of the
333
338
  // current segment (like up-to-line-start in vim).
334
339
  const segs = this.lineSegments(line);
@@ -339,17 +344,25 @@ export class Document {
339
344
  this.setCursor(line, segs[idx].start);
340
345
  return;
341
346
  }
342
- const prev = this.visualLineAt(this.cursorVisualLine - 1);
347
+ const step = Math.max(1, Math.round(count));
348
+ const prev = this.visualLineAt(Math.max(0, current - step));
343
349
  if (!prev) {
344
350
  return;
345
351
  }
346
352
  this.moveToSegment(prev);
347
353
  }
348
- moveDown() {
349
- if (this.cursorVisualLine >= this.visualLineCount - 1) {
354
+ /**
355
+ * Move down by `count` visual lines (default 1), aligning by visual column.
356
+ * Overshooting the bottom clamps to the last visual line.
357
+ */
358
+ moveDown(count = 1) {
359
+ const current = this.cursorVisualLine;
360
+ const last = this.visualLineCount - 1;
361
+ if (current >= last) {
350
362
  return;
351
363
  }
352
- const next = this.visualLineAt(this.cursorVisualLine + 1);
364
+ const step = Math.max(1, Math.round(count));
365
+ const next = this.visualLineAt(Math.min(last, current + step));
353
366
  if (!next) {
354
367
  return;
355
368
  }
@@ -0,0 +1,74 @@
1
+ import type { Document } from "./document.js";
2
+ import type { EditOperation } from "./operations.js";
3
+ import type { Position } from "./position.js";
4
+ /** One undoable edit: the operation plus the cursor before and after it ran. */
5
+ export type HistoryEntry = {
6
+ op: EditOperation;
7
+ /** Cursor before `apply`; undo leaves the cursor here. */
8
+ cursorBefore: Position;
9
+ /** Cursor after `apply`; redo leaves the cursor here, and undo starts here. */
10
+ cursorAfter: Position;
11
+ /** When the edit ran, used to group consecutive edits into one undo step. */
12
+ time: number;
13
+ };
14
+ /**
15
+ * Linear undo/redo stack over {@link EditOperation}s.
16
+ *
17
+ * Stores each operation together with the cursor positions around it. The
18
+ * cursor is part of the record — not reconstructed from the operation —
19
+ * because editing and cursor movement are independent: the user may move the
20
+ * cursor (an action that is not itself undoable) before pressing undo, so the
21
+ * position `invert` expects cannot be assumed to still hold.
22
+ *
23
+ * A burst of mergeable edits (see {@link EditOperation.merge}) that land next
24
+ * to each other within the merge window collapses into one entry, so typing a
25
+ * word undoes as a word rather than a character. Any other edit, a cursor
26
+ * move, or a pause longer than the window starts a new entry. The window is a
27
+ * user setting (see `setMergeWindow`); `0` disables merging.
28
+ */
29
+ export declare class History {
30
+ private readonly _undo;
31
+ private readonly _redo;
32
+ private readonly _limit;
33
+ private readonly _now;
34
+ private _mergeWindow;
35
+ constructor(limit?: number, now?: () => number);
36
+ /**
37
+ * Set the coalescing window in ms. Edits closer together than this share an
38
+ * undo step; `0` disables merging, so every edit stands alone.
39
+ */
40
+ setMergeWindow(ms: number): void;
41
+ canUndo(): boolean;
42
+ canRedo(): boolean;
43
+ /**
44
+ * Apply `op` and record it. Capturing the cursor here (rather than in the
45
+ * caller) keeps the entry and the operation's effects in sync by
46
+ * construction. A new edit discards the redo branch, since the document
47
+ * has diverged from the state those entries would replay onto.
48
+ *
49
+ * A no-op edit (e.g. backspace at the document start) is not recorded: its
50
+ * inverse would do nothing, so an entry for it would only make the user
51
+ * press undo twice to skip past it and would evict real history at the
52
+ * limit. Leaving the redo branch intact is correct too — the document did
53
+ * not diverge from the state those entries replay onto.
54
+ */
55
+ run(op: EditOperation, doc: Document): void;
56
+ /**
57
+ * Fold `entry` into `top` when it continues the same edit: the newer
58
+ * cursor picks up where the older left off, the operation agrees to merge,
59
+ * and the gap is within the time window. Returns false when `entry` must
60
+ * start its own step. Mutating `top.op` only happens on success, since
61
+ * {@link EditOperation.merge} is a no-op when it returns false.
62
+ */
63
+ private _merge;
64
+ /** Undo the last edit; returns false when the undo stack is empty. */
65
+ undo(doc: Document): boolean;
66
+ /** Redo the last undone edit; returns false when the redo stack is empty. */
67
+ redo(doc: Document): boolean;
68
+ /**
69
+ * Drop all history. Required after replacing the document wholesale
70
+ * (`Document.setText`), since the recorded positions then refer to a
71
+ * different text.
72
+ */
73
+ clear(): void;
74
+ }
@@ -0,0 +1,136 @@
1
+ import { MERGE_WINDOW_DEFAULT } from "../settings/schema.js";
2
+ /**
3
+ * Linear undo/redo stack over {@link EditOperation}s.
4
+ *
5
+ * Stores each operation together with the cursor positions around it. The
6
+ * cursor is part of the record — not reconstructed from the operation —
7
+ * because editing and cursor movement are independent: the user may move the
8
+ * cursor (an action that is not itself undoable) before pressing undo, so the
9
+ * position `invert` expects cannot be assumed to still hold.
10
+ *
11
+ * A burst of mergeable edits (see {@link EditOperation.merge}) that land next
12
+ * to each other within the merge window collapses into one entry, so typing a
13
+ * word undoes as a word rather than a character. Any other edit, a cursor
14
+ * move, or a pause longer than the window starts a new entry. The window is a
15
+ * user setting (see `setMergeWindow`); `0` disables merging.
16
+ */
17
+ export class History {
18
+ _undo = [];
19
+ _redo = [];
20
+ _limit;
21
+ _now;
22
+ _mergeWindow = MERGE_WINDOW_DEFAULT;
23
+ constructor(limit = 200, now = Date.now) {
24
+ this._limit = limit;
25
+ this._now = now;
26
+ }
27
+ /**
28
+ * Set the coalescing window in ms. Edits closer together than this share an
29
+ * undo step; `0` disables merging, so every edit stands alone.
30
+ */
31
+ setMergeWindow(ms) {
32
+ this._mergeWindow = ms;
33
+ }
34
+ canUndo() {
35
+ return this._undo.length > 0;
36
+ }
37
+ canRedo() {
38
+ return this._redo.length > 0;
39
+ }
40
+ /**
41
+ * Apply `op` and record it. Capturing the cursor here (rather than in the
42
+ * caller) keeps the entry and the operation's effects in sync by
43
+ * construction. A new edit discards the redo branch, since the document
44
+ * has diverged from the state those entries would replay onto.
45
+ *
46
+ * A no-op edit (e.g. backspace at the document start) is not recorded: its
47
+ * inverse would do nothing, so an entry for it would only make the user
48
+ * press undo twice to skip past it and would evict real history at the
49
+ * limit. Leaving the redo branch intact is correct too — the document did
50
+ * not diverge from the state those entries replay onto.
51
+ */
52
+ run(op, doc) {
53
+ const before = doc.cursor;
54
+ if (!op.apply(doc)) {
55
+ return;
56
+ }
57
+ const after = doc.cursor;
58
+ const entry = {
59
+ op,
60
+ cursorBefore: { line: before.line, logical: before.logical },
61
+ cursorAfter: { line: after.line, logical: after.logical },
62
+ time: this._now(),
63
+ };
64
+ const top = this._undo[this._undo.length - 1];
65
+ if (!(top && this._merge(top, entry))) {
66
+ this._undo.push(entry);
67
+ if (this._undo.length > this._limit) {
68
+ this._undo.shift();
69
+ }
70
+ }
71
+ this._redo.length = 0;
72
+ }
73
+ /**
74
+ * Fold `entry` into `top` when it continues the same edit: the newer
75
+ * cursor picks up where the older left off, the operation agrees to merge,
76
+ * and the gap is within the time window. Returns false when `entry` must
77
+ * start its own step. Mutating `top.op` only happens on success, since
78
+ * {@link EditOperation.merge} is a no-op when it returns false.
79
+ */
80
+ _merge(top, entry) {
81
+ if (entry.time - top.time >= this._mergeWindow) {
82
+ return false;
83
+ }
84
+ if (entry.cursorBefore.line !== top.cursorAfter.line ||
85
+ entry.cursorBefore.logical !== top.cursorAfter.logical) {
86
+ return false;
87
+ }
88
+ if (!top.op.merge?.(entry.op)) {
89
+ return false;
90
+ }
91
+ // The merged operation now spans both edits: its end cursor moves to
92
+ // the newer entry's, and the timestamp advances so a pause after this
93
+ // edit still ends the group.
94
+ top.cursorAfter = entry.cursorAfter;
95
+ top.time = entry.time;
96
+ return true;
97
+ }
98
+ /** Undo the last edit; returns false when the undo stack is empty. */
99
+ undo(doc) {
100
+ const entry = this._undo.pop();
101
+ if (!entry) {
102
+ return false;
103
+ }
104
+ // invert() assumes the cursor sits where apply() left it — restore
105
+ // cursorAfter first, or a since-moved cursor makes it delete the wrong
106
+ // span. cursorBefore after the invert is the position the user expects.
107
+ doc.setCursor(entry.cursorAfter.line, entry.cursorAfter.logical);
108
+ entry.op.invert(doc);
109
+ doc.setCursor(entry.cursorBefore.line, entry.cursorBefore.logical);
110
+ this._redo.push(entry);
111
+ return true;
112
+ }
113
+ /** Redo the last undone edit; returns false when the redo stack is empty. */
114
+ redo(doc) {
115
+ const entry = this._redo.pop();
116
+ if (!entry) {
117
+ return false;
118
+ }
119
+ // apply() runs against the pre-edit document, so restore cursorBefore
120
+ // first; apply() then reproduces cursorAfter on its own.
121
+ doc.setCursor(entry.cursorBefore.line, entry.cursorBefore.logical);
122
+ entry.op.apply(doc);
123
+ doc.setCursor(entry.cursorAfter.line, entry.cursorAfter.logical);
124
+ this._undo.push(entry);
125
+ return true;
126
+ }
127
+ /**
128
+ * Drop all history. Required after replacing the document wholesale
129
+ * (`Document.setText`), since the recorded positions then refer to a
130
+ * different text.
131
+ */
132
+ clear() {
133
+ this._undo.length = 0;
134
+ this._redo.length = 0;
135
+ }
136
+ }
@@ -9,33 +9,47 @@ import type { Document } from "./document.js";
9
9
  * cursor before inverting.
10
10
  */
11
11
  export type EditOperation = {
12
- apply(doc: Document): void;
12
+ /**
13
+ * Apply the edit in place. Returns false when the edit was a no-op (the
14
+ * document is unchanged), so history can skip recording a dead step.
15
+ */
16
+ apply(doc: Document): boolean;
13
17
  invert(doc: Document): void;
18
+ /**
19
+ * Absorb a following edit into this one so the two share a single undo
20
+ * step (e.g. two adjacent insertions form one typed run). Returns true
21
+ * after folding `next` in, or false when the edit kind differs and they
22
+ * must stay separate. History calls this only for edits whose cursors are
23
+ * adjacent and within its merge window, so contiguity can be assumed.
24
+ */
25
+ merge?(next: EditOperation): boolean;
14
26
  };
15
27
  /** Insert a string at the cursor; invert deletes exactly the inserted text. */
16
28
  export declare class InsertTextOp implements EditOperation {
17
- private readonly text;
29
+ private text;
18
30
  constructor(text: string);
19
- apply(doc: Document): void;
31
+ apply(doc: Document): boolean;
20
32
  invert(doc: Document): void;
33
+ /** Merge only with another insertion; anything else ends the typed run. */
34
+ merge(next: EditOperation): boolean;
21
35
  }
22
36
  /** Backspace: delete the char before the cursor, or join with the previous line at column 0. */
23
37
  export declare class DeleteBeforeOp implements EditOperation {
24
38
  private _deletedChar;
25
39
  private _joined;
26
- apply(doc: Document): void;
40
+ apply(doc: Document): boolean;
27
41
  invert(doc: Document): void;
28
42
  }
29
43
  /** Delete key: remove the char after the cursor, or join with the next line at end of line. */
30
44
  export declare class DeleteAfterOp implements EditOperation {
31
45
  private _deletedChar;
32
46
  private _joined;
33
- apply(doc: Document): void;
47
+ apply(doc: Document): boolean;
34
48
  invert(doc: Document): void;
35
49
  }
36
50
  /** Enter: split the current line at the cursor; invert rejoins the two lines. */
37
51
  export declare class SplitLineOp implements EditOperation {
38
- apply(doc: Document): void;
52
+ apply(doc: Document): boolean;
39
53
  invert(doc: Document): void;
40
54
  }
41
55
  /**
@@ -45,17 +59,18 @@ export declare class SplitLineOp implements EditOperation {
45
59
  */
46
60
  export declare class JoinLineOp implements EditOperation {
47
61
  private _joinAt;
48
- apply(doc: Document): void;
62
+ private _joined;
63
+ apply(doc: Document): boolean;
49
64
  invert(doc: Document): void;
50
65
  }
51
66
  /** Indent the current line by `indentWidth` spaces, moving the cursor along. */
52
67
  export declare class IndentOp implements EditOperation {
53
- apply(doc: Document): void;
68
+ apply(doc: Document): boolean;
54
69
  invert(doc: Document): void;
55
70
  }
56
71
  /** Outdent up to `indentWidth` leading spaces; invert re-adds exactly what was removed. */
57
72
  export declare class OutdentOp implements EditOperation {
58
73
  private _removed;
59
- apply(doc: Document): void;
74
+ apply(doc: Document): boolean;
60
75
  invert(doc: Document): void;
61
76
  }
@@ -9,6 +9,7 @@ export class InsertTextOp {
9
9
  const cur = doc.getLine(line);
10
10
  doc.setLine(line, cur.slice(0, logical) + this.text + cur.slice(logical));
11
11
  doc.setCursor(line, logical + this.text.length);
12
+ return true;
12
13
  }
13
14
  invert(doc) {
14
15
  const { line, logical } = doc.cursor;
@@ -17,6 +18,14 @@ export class InsertTextOp {
17
18
  doc.setLine(line, cur.slice(0, start) + cur.slice(logical));
18
19
  doc.setCursor(line, start);
19
20
  }
21
+ /** Merge only with another insertion; anything else ends the typed run. */
22
+ merge(next) {
23
+ if (!(next instanceof InsertTextOp)) {
24
+ return false;
25
+ }
26
+ this.text += next.text;
27
+ return true;
28
+ }
20
29
  }
21
30
  /** Backspace: delete the char before the cursor, or join with the previous line at column 0. */
22
31
  export class DeleteBeforeOp {
@@ -35,6 +44,7 @@ export class DeleteBeforeOp {
35
44
  this._joined = false;
36
45
  doc.setLine(line, cur.slice(0, logical - units) + cur.slice(logical));
37
46
  doc.setCursor(line, logical - units);
47
+ return true;
38
48
  }
39
49
  else if (line > 0) {
40
50
  const prev = doc.getLine(line - 1);
@@ -43,7 +53,9 @@ export class DeleteBeforeOp {
43
53
  doc.removeLineAt(line);
44
54
  doc.setCursor(line - 1, prev.length);
45
55
  this._joined = true;
56
+ return true;
46
57
  }
58
+ return false;
47
59
  }
48
60
  invert(doc) {
49
61
  if (!this._joined) {
@@ -78,13 +90,16 @@ export class DeleteAfterOp {
78
90
  this._deletedChar = cur.slice(logical, logical + units);
79
91
  this._joined = false;
80
92
  doc.setLine(line, cur.slice(0, logical) + cur.slice(logical + units));
93
+ return true;
81
94
  }
82
95
  else if (line < doc.lineCount - 1) {
83
96
  const next = doc.getLine(line + 1);
84
97
  doc.setLine(line, cur + next);
85
98
  doc.removeLineAt(line + 1);
86
99
  this._joined = true;
100
+ return true;
87
101
  }
102
+ return false;
88
103
  }
89
104
  invert(doc) {
90
105
  if (!this._joined) {
@@ -108,6 +123,7 @@ export class SplitLineOp {
108
123
  doc.setLine(line, cur.slice(0, logical));
109
124
  doc.insertLineAt(line + 1, cur.slice(logical));
110
125
  doc.setCursor(line + 1, 0);
126
+ return true;
111
127
  }
112
128
  invert(doc) {
113
129
  // Cursor is on the newly created line; the split point is its own start.
@@ -126,18 +142,27 @@ export class SplitLineOp {
126
142
  */
127
143
  export class JoinLineOp {
128
144
  _joinAt = 0;
145
+ _joined = false;
129
146
  apply(doc) {
147
+ this._joined = false;
130
148
  const { line } = doc.cursor;
131
149
  if (line >= doc.lineCount - 1) {
132
- return;
150
+ return false;
133
151
  }
134
152
  const cur = doc.getLine(line);
135
153
  const next = doc.getLine(line + 1);
136
154
  this._joinAt = cur.length;
155
+ this._joined = true;
137
156
  doc.setLine(line, cur + next);
138
157
  doc.removeLineAt(line + 1);
158
+ return true;
139
159
  }
140
160
  invert(doc) {
161
+ // apply() no-ops on the last line; inverting that split would corrupt
162
+ // the document, so mirror the no-op.
163
+ if (!this._joined) {
164
+ return;
165
+ }
141
166
  const { line } = doc.cursor;
142
167
  const cur = doc.getLine(line);
143
168
  doc.setLine(line, cur.slice(0, this._joinAt));
@@ -152,6 +177,7 @@ export class IndentOp {
152
177
  const spaces = " ".repeat(doc.indentWidth);
153
178
  doc.setLine(line, spaces + cur);
154
179
  doc.setCursor(line, logical + doc.indentWidth);
180
+ return true;
155
181
  }
156
182
  invert(doc) {
157
183
  const { line, logical } = doc.cursor;
@@ -172,7 +198,9 @@ export class OutdentOp {
172
198
  if (remove > 0) {
173
199
  doc.setLine(line, cur.slice(remove));
174
200
  doc.setCursor(line, Math.max(0, logical - remove));
201
+ return true;
175
202
  }
203
+ return false;
176
204
  }
177
205
  invert(doc) {
178
206
  if (this._removed === 0) {
@@ -1,24 +1,36 @@
1
1
  import { Document } from "./document/document.js";
2
+ import { History } from "./document/history.js";
3
+ import { type EditOperation } from "./document/operations.js";
2
4
  export type EditorOptions = {
3
5
  indentWidth?: number;
4
6
  };
5
7
  export type EditorCommandArgs = Record<string, unknown> | undefined;
6
- export type EditorCommandHandler = (doc: Document, args: EditorCommandArgs) => void;
8
+ /**
9
+ * A command handler. Returning an {@link EditOperation} makes the command
10
+ * undoable — `execute` routes the operation through the {@link History},
11
+ * which applies it and records the cursor positions around it. Returning
12
+ * nothing marks a command that mutates the document directly or only moves
13
+ * the cursor; neither is recorded.
14
+ */
15
+ export type EditorCommandHandler = (doc: Document, args: EditorCommandArgs) => EditOperation | void;
7
16
  type ChangeListener = () => void;
8
17
  /**
9
18
  * Coordinator between the pure core and the keymap/render layers.
10
19
  *
11
20
  * Owns the document, a command registry (so P2 vim mode only swaps key
12
- * bindings, never touching the core), and the change-notification channel
13
- * the view subscribes to. Editing commands run operations that carry their
14
- * own `invert`; the history stack (P1) will collect them.
21
+ * bindings, never touching the core), the change-notification channel the
22
+ * view subscribes to, and the undo/redo {@link History}. Editing commands
23
+ * return an atomic operation carrying its own `invert`, which `execute`
24
+ * routes through the history; movement commands return nothing.
15
25
  */
16
26
  export declare class EditorController {
17
27
  private readonly _document;
18
28
  private readonly _commands;
19
29
  private readonly _listeners;
30
+ private readonly _history;
20
31
  constructor(text: string, options?: EditorOptions);
21
32
  get document(): Document;
33
+ get history(): History;
22
34
  onChange(listener: ChangeListener): () => void;
23
35
  defineCommand(id: string, handler: EditorCommandHandler): this;
24
36
  execute(id: string, args?: EditorCommandArgs): void;
@@ -1,17 +1,20 @@
1
1
  import { Document } from "./document/document.js";
2
+ import { History } from "./document/history.js";
2
3
  import { DeleteAfterOp, DeleteBeforeOp, IndentOp, InsertTextOp, JoinLineOp, OutdentOp, SplitLineOp, } from "./document/operations.js";
3
4
  /**
4
5
  * Coordinator between the pure core and the keymap/render layers.
5
6
  *
6
7
  * Owns the document, a command registry (so P2 vim mode only swaps key
7
- * bindings, never touching the core), and the change-notification channel
8
- * the view subscribes to. Editing commands run operations that carry their
9
- * own `invert`; the history stack (P1) will collect them.
8
+ * bindings, never touching the core), the change-notification channel the
9
+ * view subscribes to, and the undo/redo {@link History}. Editing commands
10
+ * return an atomic operation carrying its own `invert`, which `execute`
11
+ * routes through the history; movement commands return nothing.
10
12
  */
11
13
  export class EditorController {
12
14
  _document;
13
15
  _commands = new Map();
14
16
  _listeners = new Set();
17
+ _history = new History();
15
18
  constructor(text, options = {}) {
16
19
  this._document = new Document(text, { indentWidth: options.indentWidth });
17
20
  this._registerBuiltins();
@@ -19,6 +22,9 @@ export class EditorController {
19
22
  get document() {
20
23
  return this._document;
21
24
  }
25
+ get history() {
26
+ return this._history;
27
+ }
22
28
  onChange(listener) {
23
29
  this._listeners.add(listener);
24
30
  return () => {
@@ -34,38 +40,44 @@ export class EditorController {
34
40
  if (!handler) {
35
41
  throw new Error(`[ink-cartridge] Unknown editor command: ${id}`);
36
42
  }
37
- handler(this._document, args);
43
+ const op = handler(this._document, args);
44
+ if (op) {
45
+ this._history.run(op, this._document);
46
+ }
38
47
  this._listeners.forEach((fn) => fn());
39
48
  }
40
49
  _registerBuiltins() {
41
- this.defineCommand("editor.insertText", (doc, args) => {
50
+ // Editing commands return their operation instead of applying it:
51
+ // `execute` hands it to the history, which applies it and records the
52
+ // cursor positions around it (see History.run).
53
+ this.defineCommand("editor.insertText", (_doc, args) => {
42
54
  const text = typeof args?.text === "string" ? args.text : "";
43
- if (text) {
44
- new InsertTextOp(text).apply(doc);
45
- }
46
- });
47
- this.defineCommand("editor.deleteBefore", (doc) => {
48
- new DeleteBeforeOp().apply(doc);
49
- });
50
- this.defineCommand("editor.deleteAfter", (doc) => {
51
- new DeleteAfterOp().apply(doc);
55
+ return text ? new InsertTextOp(text) : undefined;
52
56
  });
53
- this.defineCommand("editor.splitLine", (doc) => {
54
- new SplitLineOp().apply(doc);
57
+ this.defineCommand("editor.deleteBefore", () => new DeleteBeforeOp());
58
+ this.defineCommand("editor.deleteAfter", () => new DeleteAfterOp());
59
+ this.defineCommand("editor.splitLine", () => new SplitLineOp());
60
+ this.defineCommand("editor.joinLine", () => new JoinLineOp());
61
+ this.defineCommand("editor.indent", () => new IndentOp());
62
+ this.defineCommand("editor.outdent", () => new OutdentOp());
63
+ // Undo/redo drive the history directly; they are not edits themselves,
64
+ // so they return nothing and are never recorded.
65
+ this.defineCommand("history.undo", () => {
66
+ this._history.undo(this._document);
55
67
  });
56
- this.defineCommand("editor.joinLine", (doc) => {
57
- new JoinLineOp().apply(doc);
58
- });
59
- this.defineCommand("editor.indent", (doc) => {
60
- new IndentOp().apply(doc);
61
- });
62
- this.defineCommand("editor.outdent", (doc) => {
63
- new OutdentOp().apply(doc);
68
+ this.defineCommand("history.redo", () => {
69
+ this._history.redo(this._document);
64
70
  });
65
71
  this.defineCommand("cursor.moveLeft", (doc) => doc.moveLeft());
66
72
  this.defineCommand("cursor.moveRight", (doc) => doc.moveRight());
67
- this.defineCommand("cursor.moveUp", (doc) => doc.moveUp());
68
- this.defineCommand("cursor.moveDown", (doc) => doc.moveDown());
73
+ this.defineCommand("cursor.moveUp", (doc, args) => {
74
+ const count = typeof args?.count === "number" ? args.count : 1;
75
+ doc.moveUp(count);
76
+ });
77
+ this.defineCommand("cursor.moveDown", (doc, args) => {
78
+ const count = typeof args?.count === "number" ? args.count : 1;
79
+ doc.moveDown(count);
80
+ });
69
81
  this.defineCommand("cursor.lineStart", (doc) => doc.moveToLineStart());
70
82
  this.defineCommand("cursor.lineEnd", (doc) => doc.moveToLineEnd());
71
83
  this.defineCommand("cursor.wordForward", (doc) => doc.moveWordForward());