blots-editor 0.0.17 → 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",
@@ -31,6 +31,8 @@
31
31
  "settings.fileTreeRoot": "FileTree Root",
32
32
  "settings.fileTreeRoot.startup": "Startup directory",
33
33
  "settings.fileTreeRoot.custom": "Custom path",
34
+ "settings.mergeWindow": "Undo Merge Window",
35
+ "settings.mergeWindow.off": "Off",
34
36
  "settings.ok": "OK",
35
37
  "settings.cancel": "Cancel"
36
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": "普通",
@@ -31,6 +31,8 @@
31
31
  "settings.fileTreeRoot": "文件树根目录",
32
32
  "settings.fileTreeRoot.startup": "启动目录",
33
33
  "settings.fileTreeRoot.custom": "自定义路径",
34
+ "settings.mergeWindow": "撤销合并时间",
35
+ "settings.mergeWindow.off": "关闭",
34
36
  "settings.ok": "确定",
35
37
  "settings.cancel": "取消"
36
38
  }
@@ -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,33 +40,33 @@ 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);
52
- });
53
- this.defineCommand("editor.splitLine", (doc) => {
54
- new SplitLineOp().apply(doc);
55
- });
56
- this.defineCommand("editor.joinLine", (doc) => {
57
- new JoinLineOp().apply(doc);
55
+ return text ? new InsertTextOp(text) : undefined;
58
56
  });
59
- this.defineCommand("editor.indent", (doc) => {
60
- new IndentOp().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);
61
67
  });
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());
@@ -73,6 +73,9 @@ export class EditorSession {
73
73
  return { ok: false, error: "Binary file" };
74
74
  }
75
75
  this._controller.document.setText(text);
76
+ // Recorded positions refer to the previous text, so the history is
77
+ // meaningless once the document is replaced.
78
+ this._controller.history.clear();
76
79
  this._filePath = filePath;
77
80
  // Hash the NORMALIZED document text, not the raw disk bytes: the
78
81
  // document normalizes CRLF to LF, so hashing the raw text would
@@ -9,6 +9,16 @@ import { z } from "zod";
9
9
  */
10
10
  /** Wheel sensitivity in visual lines per notch, 1.0–10.0, 0.5 steps. */
11
11
  export declare const sensitivitySchema: z.ZodDefault<z.ZodNumber>;
12
+ /** Undo coalescing range in ms; `0` disables merging. */
13
+ export declare const MERGE_WINDOW_MIN = 0;
14
+ export declare const MERGE_WINDOW_MAX = 2000;
15
+ export declare const MERGE_WINDOW_STEP = 100;
16
+ export declare const MERGE_WINDOW_DEFAULT = 500;
17
+ /**
18
+ * Consecutive mergeable edits closer than this (ms) collapse into one undo
19
+ * step. `0` turns merging off, so every edit is its own step.
20
+ */
21
+ export declare const mergeWindowSchema: z.ZodDefault<z.ZodNumber>;
12
22
  /** File-tree root source: the process startup directory or a custom path. */
13
23
  export declare const fileTreeRootSchema: z.ZodDefault<z.ZodEnum<{
14
24
  startup: "startup";
@@ -27,10 +37,14 @@ export declare const settingsSchema: z.ZodObject<{
27
37
  }>>;
28
38
  customPath: z.ZodDefault<z.ZodString>;
29
39
  }, z.core.$strip>>;
40
+ history: z.ZodDefault<z.ZodObject<{
41
+ mergeWindow: z.ZodDefault<z.ZodNumber>;
42
+ }, z.core.$strip>>;
30
43
  }, z.core.$strip>;
31
44
  export type EditorSettings = z.infer<typeof settingsSchema>;
32
45
  export type WheelSensitivity = EditorSettings["wheel"];
33
46
  export type FileTreeSettings = EditorSettings["fileTree"];
47
+ export type HistorySettings = EditorSettings["history"];
34
48
  export declare const DEFAULT_SETTINGS: EditorSettings;
35
49
  /** Parse an unknown persisted value, falling back to defaults on any failure. */
36
50
  export declare function parseSettings(raw: unknown): EditorSettings;
@@ -14,6 +14,22 @@ export const sensitivitySchema = z
14
14
  .max(10)
15
15
  .step(0.5)
16
16
  .default(1);
17
+ /** Undo coalescing range in ms; `0` disables merging. */
18
+ export const MERGE_WINDOW_MIN = 0;
19
+ export const MERGE_WINDOW_MAX = 2000;
20
+ export const MERGE_WINDOW_STEP = 100;
21
+ export const MERGE_WINDOW_DEFAULT = 500;
22
+ /**
23
+ * Consecutive mergeable edits closer than this (ms) collapse into one undo
24
+ * step. `0` turns merging off, so every edit is its own step.
25
+ */
26
+ export const mergeWindowSchema = z
27
+ .number()
28
+ .int()
29
+ .min(MERGE_WINDOW_MIN)
30
+ .max(MERGE_WINDOW_MAX)
31
+ .multipleOf(MERGE_WINDOW_STEP)
32
+ .default(MERGE_WINDOW_DEFAULT);
17
33
  const DEFAULT_WHEEL = { cursor: 1, view: 3 };
18
34
  /** File-tree root source: the process startup directory or a custom path. */
19
35
  export const fileTreeRootSchema = z.enum(["startup", "custom"]).default("startup");
@@ -36,11 +52,18 @@ export const settingsSchema = z.object({
36
52
  customPath: z.string().default(""),
37
53
  })
38
54
  .default({ root: "startup", customPath: "" }),
55
+ history: z
56
+ .object({
57
+ /** Undo coalescing window in ms; see {@link mergeWindowSchema}. */
58
+ mergeWindow: mergeWindowSchema,
59
+ })
60
+ .default({ mergeWindow: MERGE_WINDOW_DEFAULT }),
39
61
  });
40
62
  export const DEFAULT_SETTINGS = {
41
63
  language: "en",
42
64
  wheel: DEFAULT_WHEEL,
43
65
  fileTree: { root: "startup", customPath: "" },
66
+ history: { mergeWindow: MERGE_WINDOW_DEFAULT },
44
67
  };
45
68
  /** Parse an unknown persisted value, falling back to defaults on any failure. */
46
69
  export function parseSettings(raw) {
@@ -18,6 +18,10 @@ export type SettingsApi = {
18
18
  setLanguage: (code: string) => void;
19
19
  /** Persist the file-tree root settings. */
20
20
  setFileTree: (fileTree: EditorSettings["fileTree"]) => void;
21
+ /** Set the undo merge window (ms) and persist it immediately. */
22
+ setMergeWindow: (ms: number) => void;
23
+ /** Update the merge window in memory only (while dragging); commit on release. */
24
+ setMergeWindowDraft: (ms: number) => void;
21
25
  };
22
26
  /**
23
27
  * React binding for the shared {@link settingsStore}. Re-renders subscribers
@@ -27,6 +27,14 @@ export function useSettings() {
27
27
  commit: () => settingsStore.commit(),
28
28
  setLanguage: (code) => settingsStore.persist({ ...settings, language: code }),
29
29
  setFileTree: (fileTree) => settingsStore.persist({ ...settings, fileTree }),
30
+ setMergeWindow: (ms) => settingsStore.persist({
31
+ ...settings,
32
+ history: { ...settings.history, mergeWindow: ms },
33
+ }),
34
+ setMergeWindowDraft: (ms) => settingsStore.update({
35
+ ...settings,
36
+ history: { ...settings.history, mergeWindow: ms },
37
+ }),
30
38
  };
31
39
  }, [settings]);
32
40
  }
@@ -152,6 +152,11 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
152
152
  useEffect(() => {
153
153
  return boundKeyboard(["ctrl+tab"], () => setToolbarOpen((open) => !open));
154
154
  }, [boundKeyboard]);
155
+ // Keep the coalescing window in step with the persisted setting; edits
156
+ // closer together than it share one undo step (0 disables merging).
157
+ useEffect(() => {
158
+ controller.history.setMergeWindow(settings.history.mergeWindow);
159
+ }, [controller, settings.history.mergeWindow]);
155
160
  useEffect(() => {
156
161
  const removeWildcard = enableWildcardPriority();
157
162
  const unbinds = [];
@@ -258,6 +263,9 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
258
263
  bind([":"], () => openCommandBar(), { mode: "normal" });
259
264
  // Ctrl+S saves in normal mode (Vim-style; insert stays untouched).
260
265
  bind(["ctrl+s"], () => session.save(), { mode: "normal" });
266
+ // Undo/redo also live in normal mode; insert stays untouched.
267
+ bind(["u"], () => controller.execute("history.undo"), { mode: "normal" });
268
+ bind(["ctrl+r"], () => controller.execute("history.redo"), { mode: "normal" });
261
269
  return () => {
262
270
  removeWildcard();
263
271
  unbinds.forEach((fn) => fn());
@@ -2,9 +2,10 @@ import { useI18n } from "@cartridge-engine/i18n";
2
2
  import { Box, Text } from "ink";
3
3
  import { back, ModalLayerElementContext, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
4
4
  import React, { useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
5
+ import { MERGE_WINDOW_MAX, MERGE_WINDOW_MIN, MERGE_WINDOW_STEP, } from "../../core/settings/schema.js";
5
6
  import { useSettings } from "../../core/settings/useSettings.js";
6
7
  import { ModalFrame } from "../utils/modal-frame.js";
7
- import { SensitivityBar, snapSensitivity } from "../utils/sensitivity-bar.js";
8
+ import { SensitivityBar, Slider, snapSensitivity, snapValue, } from "../utils/sensitivity-bar.js";
8
9
  const LANGUAGES = [
9
10
  { code: "en", labelKey: "settings.language.en" },
10
11
  { code: "zh", labelKey: "settings.language.zh" },
@@ -96,6 +97,42 @@ function SensitivityPicker({ sensitivityKey }) {
96
97
  value.toFixed(1),
97
98
  "\u00D7")));
98
99
  }
100
+ /**
101
+ * Modal slider for the undo merge window. The bar is drag/click driven;
102
+ * dragging updates in memory only, release/click persists. `0` disables
103
+ * merging, so the label reads "off" instead of the bare number.
104
+ */
105
+ function MergeWindowPicker() {
106
+ const ctx = useContext(ModalLayerElementContext);
107
+ const { t } = useI18n();
108
+ const { boundKeyboard } = useKeyboard();
109
+ const { closeModalLayer } = useScreenSystem();
110
+ const { settings, setMergeWindow, setMergeWindowDraft, commit } = useSettings();
111
+ const value = settings.history.mergeWindow;
112
+ useEffect(() => {
113
+ if (!ctx) {
114
+ return;
115
+ }
116
+ const snap = (dir) => snapValue(value, dir, MERGE_WINDOW_MIN, MERGE_WINDOW_MAX, MERGE_WINDOW_STEP);
117
+ const unbinds = [
118
+ boundKeyboard(["left"], () => setMergeWindow(snap(-1)), {
119
+ elementId: ctx.id,
120
+ }),
121
+ boundKeyboard(["right"], () => setMergeWindow(snap(1)), {
122
+ elementId: ctx.id,
123
+ }),
124
+ boundKeyboard(["escape"], () => closeModalLayer(ctx.modalLayer.layerId), { elementId: ctx.id }),
125
+ ];
126
+ return () => unbinds.forEach((fn) => fn());
127
+ }, [boundKeyboard, closeModalLayer, ctx, setMergeWindow, value]);
128
+ return (React.createElement(ModalFrame, { title: t("settings.mergeWindow"), footer: React.createElement(Text, { dimColor: true },
129
+ t("settings.back"),
130
+ " (Esc)") },
131
+ React.createElement(Slider, { value: value, min: MERGE_WINDOW_MIN, max: MERGE_WINDOW_MAX, step: MERGE_WINDOW_STEP, onChange: setMergeWindowDraft, onCommit: commit, hint: t("settings.slider.hint") }),
132
+ React.createElement(Text, null, value === MERGE_WINDOW_MIN
133
+ ? t("settings.mergeWindow.off")
134
+ : `${value} ms`)));
135
+ }
99
136
  /** Small clickable button used inside the file-tree-root picker. */
100
137
  function PickerButton({ label, active, onEnter, onLeave, onClick }) {
101
138
  const ref = useMouseRegion({ onEnter, onLeave, onClick }, { priority: 1 });
@@ -221,6 +258,13 @@ export function SettingsEntries({ onExit }) {
221
258
  element: FileTreePicker,
222
259
  });
223
260
  }, [applyElementToModalLayer, openModalLayer]);
261
+ const openMergeWindowPicker = useCallback(() => {
262
+ openModalLayer("merge-window", 50);
263
+ applyElementToModalLayer("merge-window", {
264
+ elementId: "merge-window-picker",
265
+ element: MergeWindowPicker,
266
+ });
267
+ }, [applyElementToModalLayer, openModalLayer]);
224
268
  const currentLabel = LANGUAGES.find((lang) => lang.code === currentLanguage)?.labelKey ??
225
269
  "settings.language.en";
226
270
  const entries = useMemo(() => [
@@ -250,6 +294,14 @@ export function SettingsEntries({ onExit }) {
250
294
  : t("settings.fileTreeRoot.startup"),
251
295
  open: openFileTreePicker,
252
296
  },
297
+ {
298
+ id: "merge-window",
299
+ label: t("settings.mergeWindow"),
300
+ value: settings.history.mergeWindow === MERGE_WINDOW_MIN
301
+ ? t("settings.mergeWindow.off")
302
+ : `${settings.history.mergeWindow} ms`,
303
+ open: openMergeWindowPicker,
304
+ },
253
305
  ], [
254
306
  t,
255
307
  currentLabel,
@@ -257,9 +309,11 @@ export function SettingsEntries({ onExit }) {
257
309
  settings.wheel.view,
258
310
  settings.fileTree.root,
259
311
  settings.fileTree.customPath,
312
+ settings.history.mergeWindow,
260
313
  openLanguagePicker,
261
314
  openSensitivityPicker,
262
315
  openFileTreePicker,
316
+ openMergeWindowPicker,
263
317
  ]);
264
318
  useEffect(() => {
265
319
  const unbinds = [
@@ -2,23 +2,57 @@ import React from "react";
2
2
  export declare const SENSITIVITY_MIN = 1;
3
3
  export declare const SENSITIVITY_MAX = 10;
4
4
  export declare const SENSITIVITY_STEP = 0.5;
5
- /** Bar cells: (10 - 1) / 0.5 steps, so every cell maps to exactly one step. */
6
- export declare const BAR_WIDTH: number;
7
- /** Map a 0..1 ratio to a sensitivity value in 0.5 steps. */
8
- export declare function valueFromRatio(ratio: number): number;
5
+ /** Number of steps a `min..max` range of `step` size spans. */
6
+ export declare function stepsFor(min: number, max: number, step: number): number;
7
+ /** Map a 0..1 ratio to a value snapped to `step`, clamped to `min..max`. */
8
+ export declare function valueFromRatio(ratio: number, min?: number, max?: number, step?: number): number;
9
9
  /** Clamp value + dir*step to the valid 1..10 range (0.5 steps). */
10
10
  export declare function snapSensitivity(value: number, dir: 1 | -1): number;
11
- type SensitivityBarProps = {
11
+ /** Clamp value + dir*step to an arbitrary `min..max` range. */
12
+ export declare function snapValue(value: number, dir: 1 | -1, min: number, max: number, step: number): number;
13
+ /**
14
+ * Value for a click on cell `localX` (0-based) of a bar that renders exactly
15
+ * `steps + 1` cells — one per value from `min` to `max` inclusive. The ratio
16
+ * therefore runs `localX / steps`, which is exactly one value per cell; using
17
+ * `steps` cells instead would have to skip an interior value to still reach
18
+ * `max` (e.g. 1000 ms, or 5.5×, would be unreachable by mouse).
19
+ */
20
+ export declare function valueFromBarX(localX: number, min: number, max: number, step: number): number;
21
+ /**
22
+ * Filled-cell count for `value` on the `min..max` bar, matching
23
+ * {@link valueFromBarX}: cell `i` selects `min + i*step`, so the value's own
24
+ * cell is filled and everything left of it too — one cell at `min`, the whole
25
+ * bar at `max`. Deriving the count from that integer cell index — rather than
26
+ * from the value's fraction of the range — keeps the fill aligned with the
27
+ * click mapping: a proportional `fraction * (steps + 1)` rounds unevenly and
28
+ * skips a cell at the midpoint.
29
+ */
30
+ export declare function filledCells(value: number, min: number, max: number, step: number): number;
31
+ type SliderProps = {
12
32
  value: number;
33
+ min: number;
34
+ max: number;
35
+ step: number;
13
36
  /** Called on click/drag with the new value (in-memory draft). */
14
37
  onChange: (value: number) => void;
15
38
  /** Called when the interaction ends — persist the draft. */
16
39
  onCommit: () => void;
40
+ /** Localized hint rendered under the bar. */
41
+ hint: string;
17
42
  };
18
43
  /**
19
- * Mouse-driven sensitivity slider. Clicking or dragging anywhere on the bar
20
- * sets the value proportionally (0.5 steps); the fuller the bar, the higher
21
- * the sensitivity, and the 1..10 range caps it by construction.
44
+ * Mouse-driven slider over an arbitrary `min..max`/`step` range. Clicking or
45
+ * dragging anywhere on the bar sets the value proportionally, snapped to
46
+ * `step`; the fuller the bar, the higher the value.
22
47
  */
48
+ export declare function Slider({ value, min, max, step, onChange, onCommit, hint }: SliderProps): React.JSX.Element;
49
+ type SensitivityBarProps = {
50
+ value: number;
51
+ /** Called on click/drag with the new value (in-memory draft). */
52
+ onChange: (value: number) => void;
53
+ /** Called when the interaction ends — persist the draft. */
54
+ onCommit: () => void;
55
+ };
56
+ /** Slider preset for the 1..10 wheel sensitivity range. */
23
57
  export declare function SensitivityBar({ value, onChange, onCommit }: SensitivityBarProps): React.JSX.Element;
24
58
  export {};
@@ -5,25 +5,57 @@ import React from "react";
5
5
  export const SENSITIVITY_MIN = 1;
6
6
  export const SENSITIVITY_MAX = 10;
7
7
  export const SENSITIVITY_STEP = 0.5;
8
- /** Bar cells: (10 - 1) / 0.5 steps, so every cell maps to exactly one step. */
9
- export const BAR_WIDTH = (SENSITIVITY_MAX - SENSITIVITY_MIN) / SENSITIVITY_STEP;
10
- /** Map a 0..1 ratio to a sensitivity value in 0.5 steps. */
11
- export function valueFromRatio(ratio) {
8
+ /** Number of steps a `min..max` range of `step` size spans. */
9
+ export function stepsFor(min, max, step) {
10
+ return Math.round((max - min) / step);
11
+ }
12
+ /** Map a 0..1 ratio to a value snapped to `step`, clamped to `min..max`. */
13
+ export function valueFromRatio(ratio, min = SENSITIVITY_MIN, max = SENSITIVITY_MAX, step = SENSITIVITY_STEP) {
12
14
  const r = Math.max(0, Math.min(1, ratio));
13
- // BAR_WIDTH cells span exactly the 1..10 range (18 steps of 0.5).
14
- return SENSITIVITY_MIN + Math.round(r * BAR_WIDTH) * SENSITIVITY_STEP;
15
+ return min + Math.round(r * stepsFor(min, max, step)) * step;
15
16
  }
16
17
  /** Clamp value + dir*step to the valid 1..10 range (0.5 steps). */
17
18
  export function snapSensitivity(value, dir) {
18
- return Math.min(SENSITIVITY_MAX, Math.max(SENSITIVITY_MIN, value + dir * SENSITIVITY_STEP));
19
+ return snapValue(value, dir, SENSITIVITY_MIN, SENSITIVITY_MAX, SENSITIVITY_STEP);
20
+ }
21
+ /** Clamp value + dir*step to an arbitrary `min..max` range. */
22
+ export function snapValue(value, dir, min, max, step) {
23
+ return Math.min(max, Math.max(min, value + dir * step));
19
24
  }
20
25
  /**
21
- * Mouse-driven sensitivity slider. Clicking or dragging anywhere on the bar
22
- * sets the value proportionally (0.5 steps); the fuller the bar, the higher
23
- * the sensitivity, and the 1..10 range caps it by construction.
26
+ * Value for a click on cell `localX` (0-based) of a bar that renders exactly
27
+ * `steps + 1` cells — one per value from `min` to `max` inclusive. The ratio
28
+ * therefore runs `localX / steps`, which is exactly one value per cell; using
29
+ * `steps` cells instead would have to skip an interior value to still reach
30
+ * `max` (e.g. 1000 ms, or 5.5×, would be unreachable by mouse).
24
31
  */
25
- export function SensitivityBar({ value, onChange, onCommit }) {
26
- const { t } = useI18n();
32
+ export function valueFromBarX(localX, min, max, step) {
33
+ const steps = stepsFor(min, max, step);
34
+ // A zero-width bar (single value) can only mean `min`; avoid /0.
35
+ return steps <= 0 ? min : valueFromRatio(localX / steps, min, max, step);
36
+ }
37
+ /**
38
+ * Filled-cell count for `value` on the `min..max` bar, matching
39
+ * {@link valueFromBarX}: cell `i` selects `min + i*step`, so the value's own
40
+ * cell is filled and everything left of it too — one cell at `min`, the whole
41
+ * bar at `max`. Deriving the count from that integer cell index — rather than
42
+ * from the value's fraction of the range — keeps the fill aligned with the
43
+ * click mapping: a proportional `fraction * (steps + 1)` rounds unevenly and
44
+ * skips a cell at the midpoint.
45
+ */
46
+ export function filledCells(value, min, max, step) {
47
+ const total = stepsFor(min, max, step) + 1;
48
+ const index = Math.round((value - min) / step);
49
+ return Math.min(total, Math.max(1, index + 1));
50
+ }
51
+ /**
52
+ * Mouse-driven slider over an arbitrary `min..max`/`step` range. Clicking or
53
+ * dragging anywhere on the bar sets the value proportionally, snapped to
54
+ * `step`; the fuller the bar, the higher the value.
55
+ */
56
+ export function Slider({ value, min, max, step, onChange, onCommit, hint }) {
57
+ const steps = stepsFor(min, max, step);
58
+ const valueFromX = (localX) => valueFromBarX(localX, min, max, step);
27
59
  // priority 1: the bar sits inside the draggable ModalFrame, whose region
28
60
  // overlaps it — the child control must always win the hit test, no matter
29
61
  // the registration order after frame drags.
@@ -36,14 +68,18 @@ export function SensitivityBar({ value, onChange, onCommit }) {
36
68
  onDragMove: (event, rect) => onChange(valueFromX(event.x - rect.x)),
37
69
  onDragEnd: () => onCommit(),
38
70
  }, { priority: 1 });
39
- const filled = Math.round((value - SENSITIVITY_MIN) / SENSITIVITY_STEP);
71
+ // One cell per value (min..max inclusive); the fill is derived from the
72
+ // value's own cell, so it matches what a click on that cell would select.
73
+ const total = steps + 1;
74
+ const filled = filledCells(value, min, max, step);
40
75
  return (React.createElement(Box, { flexDirection: "column", alignItems: "center", gap: 1 },
41
76
  React.createElement(Box, { ref: ref, flexDirection: "row" },
42
77
  React.createElement(Text, null, filled > 0 ? "█".repeat(filled) : ""),
43
- React.createElement(Text, { dimColor: true }, "░".repeat(BAR_WIDTH - filled))),
44
- React.createElement(Text, { dimColor: true }, t("settings.sensitivity.hint"))));
78
+ React.createElement(Text, { dimColor: true }, "░".repeat(total - filled))),
79
+ React.createElement(Text, { dimColor: true }, hint)));
45
80
  }
46
- /** Value for a click at `localX` cells inside the bar (0-based). */
47
- function valueFromX(localX) {
48
- return valueFromRatio(localX / BAR_WIDTH);
81
+ /** Slider preset for the 1..10 wheel sensitivity range. */
82
+ export function SensitivityBar({ value, onChange, onCommit }) {
83
+ const { t } = useI18n();
84
+ return (React.createElement(Slider, { value: value, min: SENSITIVITY_MIN, max: SENSITIVITY_MAX, step: SENSITIVITY_STEP, onChange: onChange, onCommit: onCommit, hint: t("settings.slider.hint") }));
49
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blots-editor",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
4
4
  "description": "A lightweight Markdown editor for the terminal, built on ink-cartridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",