@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha
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.
- package/CHANGELOG.md +108 -70
- package/LICENSE +21 -21
- package/README.md +286 -247
- package/dist/canvas/box-clipboard.js +238 -238
- package/dist/canvas/box-selection.js +80 -80
- package/dist/canvas/canvas-editor.js +502 -502
- package/dist/canvas/controller.js +717 -717
- package/dist/canvas/geometry.js +131 -131
- package/dist/canvas/gesture.d.ts +0 -1
- package/dist/canvas/gesture.js +289 -289
- package/dist/canvas/handles.js +212 -212
- package/dist/canvas/menu.js +82 -82
- package/dist/canvas/mode-controller.js +81 -81
- package/dist/canvas/nudge.js +108 -108
- package/dist/canvas/overlay.js +149 -149
- package/dist/canvas/snap.js +132 -132
- package/dist/canvas/viewport.js +112 -112
- package/dist/canvas/z-order.js +171 -171
- package/dist/commands/alignment.js +150 -150
- package/dist/commands/box.js +272 -272
- package/dist/commands/clipboard.js +93 -93
- package/dist/commands/graphemes.js +112 -112
- package/dist/commands/marks.js +153 -153
- package/dist/commands/paragraph-props.js +176 -176
- package/dist/commands/size.js +368 -368
- package/dist/commands/text.js +291 -291
- package/dist/docx.d.ts +1 -0
- package/dist/docx.js +15 -13
- package/dist/editor/editing-host.js +434 -434
- package/dist/element.js +41 -41
- package/dist/fonts/OFL-Carlito.txt +94 -0
- package/dist/fonts/calibri-metrics.d.ts +27 -0
- package/dist/fonts/calibri-metrics.js +88 -0
- package/dist/history/history.js +433 -433
- package/dist/index.js +63 -63
- package/dist/input/beforeinput.js +224 -224
- package/dist/input/clipboard.js +107 -107
- package/dist/input/composition.js +341 -341
- package/dist/input/keymap.js +54 -54
- package/dist/io/ooxml/custgeom.js +72 -72
- package/dist/io/ooxml/drawingml.js +232 -232
- package/dist/io/ooxml/edits.js +67 -67
- package/dist/io/ooxml/export-splice.js +880 -880
- package/dist/io/ooxml/export.js +365 -365
- package/dist/io/ooxml/fill.d.ts +93 -8
- package/dist/io/ooxml/fill.js +618 -562
- package/dist/io/ooxml/fit.d.ts +252 -0
- package/dist/io/ooxml/fit.js +598 -0
- package/dist/io/ooxml/import-gate.js +200 -200
- package/dist/io/ooxml/import.js +691 -691
- package/dist/io/ooxml/inflate.js +126 -126
- package/dist/io/ooxml/media.js +87 -87
- package/dist/io/ooxml/ns.d.ts +2 -0
- package/dist/io/ooxml/ns.js +26 -21
- package/dist/io/ooxml/numbering.js +290 -290
- package/dist/io/ooxml/opc-write.js +171 -171
- package/dist/io/ooxml/provenance.js +93 -93
- package/dist/io/ooxml/recipe.js +708 -708
- package/dist/io/ooxml/relationships.js +59 -59
- package/dist/io/ooxml/run-fonts.d.ts +89 -0
- package/dist/io/ooxml/run-fonts.js +378 -0
- package/dist/io/ooxml/slots.d.ts +4 -0
- package/dist/io/ooxml/slots.js +582 -581
- package/dist/io/ooxml/styles-resolve.js +257 -257
- package/dist/io/ooxml/units.js +43 -43
- package/dist/io/ooxml/value.js +120 -120
- package/dist/io/ooxml/xml-scan.js +616 -616
- package/dist/io/ooxml/zip-container.js +228 -228
- package/dist/io/ooxml/zip-reject.js +106 -106
- package/dist/measure/placement.js +83 -83
- package/dist/measure/report.js +75 -75
- package/dist/model/ids.js +70 -70
- package/dist/model/normalize.js +146 -146
- package/dist/model/position.js +283 -283
- package/dist/model/transaction.js +209 -209
- package/dist/platform/capabilities.js +25 -25
- package/dist/react/index.js +21 -21
- package/dist/selection/controller.js +315 -315
- package/dist/selection/dom-map.js +334 -334
- package/dist/state/editor-state.js +109 -109
- package/dist/styles.css +66 -66
- package/package.json +94 -93
package/dist/history/history.js
CHANGED
|
@@ -1,433 +1,433 @@
|
|
|
1
|
-
import { isDev } from "../dev.js";
|
|
2
|
-
import { marksEqual } from "../model/node.js";
|
|
3
|
-
import { positionsEqual, selectionsEqual } from "../model/position.js";
|
|
4
|
-
import { applyAll } from "../model/step-apply.js";
|
|
5
|
-
import { invertAll } from "../model/step-invert.js";
|
|
6
|
-
import { normalizeDoc } from "../model/normalize.js";
|
|
7
|
-
import { runsLength } from "../model/runs.js";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Dev-only counters, mirroring `renderStats`.
|
|
11
|
-
*
|
|
12
|
-
* `layoutCollapses` counts **the layout-collapse branch specifically** — not
|
|
13
|
-
* "coalescing happened" in general. A broad counter would keep filling from the
|
|
14
|
-
* typing-accumulate branch even if the collapse rule were deleted, so the
|
|
15
|
-
* counter itself would go vacuous and a test asserting `> 0` would still pass
|
|
16
|
-
* (CLAUDE.md's rule about vacuous guard counters).
|
|
17
|
-
*/
|
|
18
|
-
export const historyStats = {
|
|
19
|
-
layoutCollapses: 0,
|
|
20
|
-
typingAccumulations: 0,
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/** @returns {void} */
|
|
24
|
-
export function resetHistoryStats() {
|
|
25
|
-
historyStats.layoutCollapses = 0;
|
|
26
|
-
historyStats.typingAccumulations = 0;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** How long a pause breaks a run of typing into a separate undo entry. */
|
|
30
|
-
export const COALESCE_MS = 500;
|
|
31
|
-
|
|
32
|
-
/** Upper bound on entries kept. */
|
|
33
|
-
export const DEFAULT_DEPTH_LIMIT = 200;
|
|
34
|
-
|
|
35
|
-
/** Upper bound on steps merged into one entry, so a held key is still undoable. */
|
|
36
|
-
export const MAX_COALESCED_STEPS = 200;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @typedef {object} HistoryEntry
|
|
40
|
-
* @property {import("../model/step.js").Step[]} steps forward order, as applied
|
|
41
|
-
* @property {import("../model/step.js").Step[]} inverted **application order**:
|
|
42
|
-
* `invertAll` already returns the last step's inverse first, so undo applies
|
|
43
|
-
* this list front to back without reversing it
|
|
44
|
-
* @property {import("../model/position.js").Selection} selectionBefore
|
|
45
|
-
* @property {import("../model/position.js").Selection} selectionAfter
|
|
46
|
-
* @property {number} timestamp
|
|
47
|
-
* @property {import("../model/transaction.js").TransactionOrigin} origin
|
|
48
|
-
* @property {number} [gestureId] ADR-0016 §1 / ADR-0018 Karar 3 — see `Transaction.gestureId`'s own doc comment
|
|
49
|
-
*/
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Undo/redo built on step inversion — there is no snapshot stack, per
|
|
53
|
-
* ARCHITECTURE.md §4.
|
|
54
|
-
*
|
|
55
|
-
* Inverses are computed at **push time**, because `invert(step, docBefore)` needs
|
|
56
|
-
* the document as it was before the step and that document is not retained
|
|
57
|
-
* afterwards.
|
|
58
|
-
*/
|
|
59
|
-
export class HistoryEngine {
|
|
60
|
-
/**
|
|
61
|
-
* @param {{ depthLimit?: number, coalesceMs?: number }} [options]
|
|
62
|
-
*/
|
|
63
|
-
constructor(options = {}) {
|
|
64
|
-
/** @type {HistoryEntry[]} */
|
|
65
|
-
this.undoStack = [];
|
|
66
|
-
/** @type {HistoryEntry[]} */
|
|
67
|
-
this.redoStack = [];
|
|
68
|
-
this.depthLimit = options.depthLimit ?? DEFAULT_DEPTH_LIMIT;
|
|
69
|
-
this.coalesceMs = options.coalesceMs ?? COALESCE_MS;
|
|
70
|
-
/** @type {boolean} */
|
|
71
|
-
this.coalesceBarrier = false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Forces the next transaction to start a fresh undo entry. Intended for blur
|
|
76
|
-
* and for a hand-moved caret.
|
|
77
|
-
*
|
|
78
|
-
* **Not called at composition boundaries, despite what this comment used to
|
|
79
|
-
* claim** (ADR-0004, karar 6). A composition commits with `origin: "ime"`,
|
|
80
|
-
* and `canCoalesce` only ever merges `input`+`input` or `pointer`+`pointer` —
|
|
81
|
-
* so an IME entry can neither absorb the typing before it nor be absorbed by
|
|
82
|
-
* the typing after it. The origin already enforces the break; calling this as
|
|
83
|
-
* well would be unmeasured defence.
|
|
84
|
-
*
|
|
85
|
-
* Has **no callers today**. Kept because the blur and caret-move cases are
|
|
86
|
-
* real and land with the P1.9 wiring, but nothing depends on it yet.
|
|
87
|
-
* @returns {void}
|
|
88
|
-
*/
|
|
89
|
-
breakCoalescing() {
|
|
90
|
-
this.coalesceBarrier = true;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Records a transaction.
|
|
95
|
-
*
|
|
96
|
-
* Transactions that changed nothing are ignored — a no-op Ctrl+Z is worse than
|
|
97
|
-
* no entry at all. `origin: "history"` is ignored too: undo and redo must not
|
|
98
|
-
* record themselves.
|
|
99
|
-
*
|
|
100
|
-
* @param {import("../model/transaction.js").Transaction} tr
|
|
101
|
-
* @returns {void}
|
|
102
|
-
*/
|
|
103
|
-
push(tr) {
|
|
104
|
-
if (tr.origin === "history" || tr.steps.length === 0) {
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
this.redoStack.length = 0;
|
|
109
|
-
|
|
110
|
-
const previous = this.undoStack[this.undoStack.length - 1];
|
|
111
|
-
if (!this.coalesceBarrier && previous !== undefined && this.canCoalesce(previous, tr)) {
|
|
112
|
-
if (tr.steps.length > 1) {
|
|
113
|
-
// ADR-0018 Karar 3: a multi-step pointer frame (e.g. a multi-box
|
|
114
|
-
// drag) COLLAPSES exactly like the single-step case below, just
|
|
115
|
-
// replacing the previous entry's TRAILING N steps instead of only
|
|
116
|
-
// its last one — `canCoalesce` already confirmed via
|
|
117
|
-
// `sameLayoutFrame` that they pairwise match. `inverted` is left
|
|
118
|
-
// completely untouched, same reasoning as the single-step COLLAPSE
|
|
119
|
-
// comment below: it was computed against the pre-drag document on
|
|
120
|
-
// this gesture's FIRST push and stays correct no matter how many
|
|
121
|
-
// frames replace `steps` afterward.
|
|
122
|
-
previous.steps.splice(previous.steps.length - tr.steps.length, tr.steps.length, ...tr.steps);
|
|
123
|
-
if (isDev()) {
|
|
124
|
-
historyStats.layoutCollapses += 1;
|
|
125
|
-
}
|
|
126
|
-
previous.selectionAfter = tr.selectionAfter;
|
|
127
|
-
previous.timestamp = tr.timestamp;
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const last = previous.steps[previous.steps.length - 1];
|
|
132
|
-
const next = tr.steps[0];
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
last !== undefined &&
|
|
136
|
-
next !== undefined &&
|
|
137
|
-
last.type === "setLayout" &&
|
|
138
|
-
next.type === "setLayout"
|
|
139
|
-
) {
|
|
140
|
-
// COLLAPSE, not accumulate: a drag calls this once per pointer move —
|
|
141
|
-
// 200 mousemoves would otherwise mean 200 stored steps and 200 stored
|
|
142
|
-
// inverses. Replacing the last forward step with the newest one keeps
|
|
143
|
-
// `steps` correct for redo (it always reflects the drag's final
|
|
144
|
-
// geometry), while `inverted` is left completely untouched.
|
|
145
|
-
//
|
|
146
|
-
// That is safe, not accidental: the FIRST push of this drag already
|
|
147
|
-
// computed `inverted` against the document as it looked *before the
|
|
148
|
-
// drag started* (HistoryEngine.push runs `invertAll` at push time,
|
|
149
|
-
// while that pre-drag document is still in hand). Touching `inverted`
|
|
150
|
-
// here — the way the accumulate branch below does for typing — would
|
|
151
|
-
// make undo land on the second-to-last mousemove frame instead of
|
|
152
|
-
// where the drag began, which is the exact bug ADR-0007 calls out.
|
|
153
|
-
previous.steps[previous.steps.length - 1] = next;
|
|
154
|
-
if (isDev()) {
|
|
155
|
-
historyStats.layoutCollapses += 1;
|
|
156
|
-
}
|
|
157
|
-
} else {
|
|
158
|
-
previous.steps.push(...tr.steps);
|
|
159
|
-
// The newly appended steps ran last, so their inverses must run *first*.
|
|
160
|
-
previous.inverted.unshift(...invertAll(tr.docBefore, tr.steps));
|
|
161
|
-
if (isDev()) {
|
|
162
|
-
historyStats.typingAccumulations += 1;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
previous.selectionAfter = tr.selectionAfter;
|
|
167
|
-
previous.timestamp = tr.timestamp;
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
this.coalesceBarrier = false;
|
|
172
|
-
this.undoStack.push({
|
|
173
|
-
steps: tr.steps.slice(),
|
|
174
|
-
inverted: invertAll(tr.docBefore, tr.steps),
|
|
175
|
-
selectionBefore: tr.selectionBefore,
|
|
176
|
-
selectionAfter: tr.selectionAfter,
|
|
177
|
-
timestamp: tr.timestamp,
|
|
178
|
-
origin: tr.origin,
|
|
179
|
-
gestureId: tr.gestureId,
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
if (this.undoStack.length > this.depthLimit) {
|
|
183
|
-
this.undoStack.shift();
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Merge this transaction into the previous entry? Conditions 2–4 are generic;
|
|
189
|
-
* which type-specific rule (5a/5b/5c) applies depends on the origin the two
|
|
190
|
-
* transactions share — so that "merhaba" is one Ctrl+Z but "merhaba", pause,
|
|
191
|
-
* "dünya" is two, and a whole box drag is one Ctrl+Z but a drag, a pause, a
|
|
192
|
-
* second drag is two.
|
|
193
|
-
*
|
|
194
|
-
* @param {HistoryEntry} previous
|
|
195
|
-
* @param {import("../model/transaction.js").Transaction} tr
|
|
196
|
-
* @returns {boolean}
|
|
197
|
-
*/
|
|
198
|
-
canCoalesce(previous, tr) {
|
|
199
|
-
// `gestureId` matching on BOTH sides (never just one — undefined counts
|
|
200
|
-
// as "not set", not as a wildcard) means the two transactions belong to
|
|
201
|
-
// the SAME open pointer-gesture bracket (ADR-0016 §1, ADR-0018 Karar
|
|
202
|
-
// 3). `undefined === undefined` (every transaction built before a
|
|
203
|
-
// `CanvasController` exists, this file's own tests included) does NOT
|
|
204
|
-
// count as a real match — those fall through to today's exact
|
|
205
|
-
// behavior, unchanged.
|
|
206
|
-
const pointerGestureMatch =
|
|
207
|
-
previous.origin === "pointer" && tr.origin === "pointer" && previous.gestureId === tr.gestureId;
|
|
208
|
-
const realGestureMatch = pointerGestureMatch && previous.gestureId !== undefined;
|
|
209
|
-
|
|
210
|
-
// 2. Within the idle window. Compared by timestamp, never by a timer, so
|
|
211
|
-
// tests stay deterministic. BYPASSED only for a real gesture-bracket
|
|
212
|
-
// match: once a controller exists, the bracket's OWN (much larger,
|
|
213
|
-
// separately injected) idle-abandon threshold is what decides when
|
|
214
|
-
// two frames stop belonging to the same gesture — not this fixed
|
|
215
|
-
// 500ms window. This is exactly why a nudge's measured first-repeat
|
|
216
|
-
// delay (501-564ms, ADR-0016 §4) can exceed COALESCE_MS without
|
|
217
|
-
// splitting: repeats of ONE held key share a single gestureId.
|
|
218
|
-
if (!realGestureMatch && tr.timestamp - previous.timestamp >= this.coalesceMs) {
|
|
219
|
-
return false;
|
|
220
|
-
}
|
|
221
|
-
// 3. Bounded, so holding a key down (or a very long drag) still leaves
|
|
222
|
-
// usable undo granularity.
|
|
223
|
-
if (previous.steps.length + tr.steps.length > MAX_COALESCED_STEPS) {
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
// 4. The user did not move the caret, or drop the box, in between.
|
|
227
|
-
if (!selectionsEqual(previous.selectionAfter, tr.selectionBefore)) {
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// 1 + 5c + gestureId (ADR-0018 Karar 3). A box drag OR a keyboard nudge
|
|
232
|
-
// — both origin "pointer": a multi-step frame (a multi-box drag) is
|
|
233
|
-
// matched pairwise against the previous entry's own TRAILING N steps
|
|
234
|
-
// via `sameLayoutFrame`, not just its last one.
|
|
235
|
-
if (previous.origin === "pointer" && tr.origin === "pointer") {
|
|
236
|
-
if (!pointerGestureMatch) {
|
|
237
|
-
return false;
|
|
238
|
-
}
|
|
239
|
-
if (tr.steps.length > 1) {
|
|
240
|
-
return sameLayoutFrame(previous.steps, tr.steps);
|
|
241
|
-
}
|
|
242
|
-
const last = previous.steps[previous.steps.length - 1];
|
|
243
|
-
const next = tr.steps[0];
|
|
244
|
-
if (last === undefined || next === undefined) {
|
|
245
|
-
return false;
|
|
246
|
-
}
|
|
247
|
-
// A box drag: consecutive `SetLayoutStep`s from `commands/box.js`'s
|
|
248
|
-
// `moveBox`/`resizeBox`, same container, same patched keys (`{x,y}`
|
|
249
|
-
// for a move, `{cx,cy}` for a resize — never mixed, so a move drag
|
|
250
|
-
// can never collapse into a resize drag or vice versa). This is what
|
|
251
|
-
// `push()` acts on to COLLAPSE rather than accumulate (see the
|
|
252
|
-
// comment there for why that is safe).
|
|
253
|
-
if (last.type === "setLayout" && next.type === "setLayout") {
|
|
254
|
-
return last.containerId === next.containerId && sameKeySet(last.patch, next.patch);
|
|
255
|
-
}
|
|
256
|
-
return false;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const last = previous.steps[previous.steps.length - 1];
|
|
260
|
-
const next = tr.steps[0];
|
|
261
|
-
if (tr.steps.length !== 1 || last === undefined || next === undefined) {
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// 1 + 5a/5b. Only ordinary typing coalesces this way — never paste,
|
|
266
|
-
// formatting, IME, or a box drag.
|
|
267
|
-
if (previous.origin === "input" && tr.origin === "input") {
|
|
268
|
-
// 5a. Forward typing: single-character inserts, contiguous, same marks.
|
|
269
|
-
if (last.type === "insert" && next.type === "insert") {
|
|
270
|
-
return (
|
|
271
|
-
isSingleCharacterInsert(last) &&
|
|
272
|
-
isSingleCharacterInsert(next) &&
|
|
273
|
-
positionsEqual(
|
|
274
|
-
{ path: last.pos.path, offset: last.pos.offset + runsLength(last.runs) },
|
|
275
|
-
next.pos,
|
|
276
|
-
) &&
|
|
277
|
-
marksEqual(last.runs[0].marks, next.runs[0].marks)
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// 5b. A run of backspaces: each deletion ends where the previous began.
|
|
282
|
-
if (last.type === "delete" && next.type === "delete") {
|
|
283
|
-
return positionsEqual(next.to, last.from);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
return false;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return false;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** @returns {boolean} */
|
|
293
|
-
get canUndo() {
|
|
294
|
-
return this.undoStack.length > 0;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** @returns {boolean} */
|
|
298
|
-
get canRedo() {
|
|
299
|
-
return this.redoStack.length > 0;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Undoes one entry. Restores the selection too — that is most of what makes
|
|
304
|
-
* undo feel correct rather than merely accurate.
|
|
305
|
-
* @param {import("../state/editor-state.js").EditorState} state
|
|
306
|
-
* @returns {import("../model/transaction.js").Transaction | null}
|
|
307
|
-
*/
|
|
308
|
-
undo(state) {
|
|
309
|
-
const entry = this.undoStack.pop();
|
|
310
|
-
if (entry === undefined) {
|
|
311
|
-
return null;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const inverted = entry.inverted.slice();
|
|
315
|
-
const doc = normalizeDoc(applyAll(state.doc, inverted));
|
|
316
|
-
|
|
317
|
-
this.redoStack.push(entry);
|
|
318
|
-
this.coalesceBarrier = true;
|
|
319
|
-
|
|
320
|
-
return {
|
|
321
|
-
steps: inverted,
|
|
322
|
-
docBefore: state.doc,
|
|
323
|
-
doc,
|
|
324
|
-
selectionBefore: state.selection,
|
|
325
|
-
selectionAfter: entry.selectionBefore,
|
|
326
|
-
origin: "history",
|
|
327
|
-
timestamp: Date.now(),
|
|
328
|
-
storedMarks: null,
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* @param {import("../state/editor-state.js").EditorState} state
|
|
334
|
-
* @returns {import("../model/transaction.js").Transaction | null}
|
|
335
|
-
*/
|
|
336
|
-
redo(state) {
|
|
337
|
-
const entry = this.redoStack.pop();
|
|
338
|
-
if (entry === undefined) {
|
|
339
|
-
return null;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const doc = normalizeDoc(applyAll(state.doc, entry.steps));
|
|
343
|
-
this.undoStack.push(entry);
|
|
344
|
-
this.coalesceBarrier = true;
|
|
345
|
-
|
|
346
|
-
return {
|
|
347
|
-
steps: entry.steps.slice(),
|
|
348
|
-
docBefore: state.doc,
|
|
349
|
-
doc,
|
|
350
|
-
selectionBefore: state.selection,
|
|
351
|
-
selectionAfter: entry.selectionAfter,
|
|
352
|
-
origin: "history",
|
|
353
|
-
timestamp: Date.now(),
|
|
354
|
-
storedMarks: null,
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** @returns {void} */
|
|
359
|
-
clear() {
|
|
360
|
-
this.undoStack.length = 0;
|
|
361
|
-
this.redoStack.length = 0;
|
|
362
|
-
this.coalesceBarrier = false;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* @param {import("../model/step.js").InsertStep} step
|
|
368
|
-
* @returns {boolean}
|
|
369
|
-
*/
|
|
370
|
-
function isSingleCharacterInsert(step) {
|
|
371
|
-
return step.runs.length === 1 && [...step.runs[0].text].length === 1;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Whether two layout patches touch exactly the same set of keys, order
|
|
376
|
-
* ignored. Used to keep a move drag (`{x,y}`) from ever collapsing into a
|
|
377
|
-
* resize drag (`{cx,cy}`) that happens to follow it within the idle window.
|
|
378
|
-
* @param {Partial<import("../model/node.js").BoxLayout>} a
|
|
379
|
-
* @param {Partial<import("../model/node.js").BoxLayout>} b
|
|
380
|
-
* @returns {boolean}
|
|
381
|
-
*/
|
|
382
|
-
export function sameKeySet(a, b) {
|
|
383
|
-
const aKeys = Object.keys(a);
|
|
384
|
-
const bKeys = Object.keys(b);
|
|
385
|
-
if (aKeys.length !== bKeys.length) {
|
|
386
|
-
return false;
|
|
387
|
-
}
|
|
388
|
-
return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key));
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Whether `steps` (a candidate transaction's forward steps) pairwise match
|
|
393
|
-
* `previousSteps`' own TRAILING N steps, on `(containerId, sameKeySet)` —
|
|
394
|
-
* ADR-0018 Karar 3's multi-step generalization of the single-step pointer
|
|
395
|
-
* check `canCoalesce` already made for one box. Order matters: `steps[i]`
|
|
396
|
-
* is compared against `previousSteps[previousSteps.length - steps.length + i]`,
|
|
397
|
-
* never re-sorted — a caller that reorders its own entries between frames
|
|
398
|
-
* would (correctly) see this reject, since `HistoryEngine` matches frames by
|
|
399
|
-
* INDEX, not by a `containerId` lookup (a reordered frame would otherwise
|
|
400
|
-
* silently coalesce one box's step into a different box's slot).
|
|
401
|
-
*
|
|
402
|
-
* Every element on both sides must be a `SetLayoutStep` — this is a
|
|
403
|
-
* pointer-drag concept only, never mixed with typing/paste/IME steps.
|
|
404
|
-
*
|
|
405
|
-
* For `steps.length === 1` this asks EXACTLY the question `canCoalesce`'s
|
|
406
|
-
* own single-step branch already asks (same `containerId`, same key set
|
|
407
|
-
* against the previous entry's LAST step) — `canCoalesce` does not actually
|
|
408
|
-
* route through this function for that case (it keeps its original,
|
|
409
|
-
* byte-identical single-step code so HIST2/HIST3's mutations keep their
|
|
410
|
-
* exact anchor text), but the EQUIVALENCE is proven as a property in this
|
|
411
|
-
* function's own test file, not just argued.
|
|
412
|
-
*
|
|
413
|
-
* @param {import("../model/step.js").Step[]} previousSteps
|
|
414
|
-
* @param {import("../model/step.js").Step[]} steps
|
|
415
|
-
* @returns {boolean}
|
|
416
|
-
*/
|
|
417
|
-
export function sameLayoutFrame(previousSteps, steps) {
|
|
418
|
-
if (steps.length === 0 || steps.length > previousSteps.length) {
|
|
419
|
-
return false;
|
|
420
|
-
}
|
|
421
|
-
const offset = previousSteps.length - steps.length;
|
|
422
|
-
for (let i = 0; i < steps.length; i += 1) {
|
|
423
|
-
const prev = previousSteps[offset + i];
|
|
424
|
-
const next = steps[i];
|
|
425
|
-
if (prev.type !== "setLayout" || next.type !== "setLayout") {
|
|
426
|
-
return false;
|
|
427
|
-
}
|
|
428
|
-
if (prev.containerId !== next.containerId || !sameKeySet(prev.patch, next.patch)) {
|
|
429
|
-
return false;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
return true;
|
|
433
|
-
}
|
|
1
|
+
import { isDev } from "../dev.js";
|
|
2
|
+
import { marksEqual } from "../model/node.js";
|
|
3
|
+
import { positionsEqual, selectionsEqual } from "../model/position.js";
|
|
4
|
+
import { applyAll } from "../model/step-apply.js";
|
|
5
|
+
import { invertAll } from "../model/step-invert.js";
|
|
6
|
+
import { normalizeDoc } from "../model/normalize.js";
|
|
7
|
+
import { runsLength } from "../model/runs.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Dev-only counters, mirroring `renderStats`.
|
|
11
|
+
*
|
|
12
|
+
* `layoutCollapses` counts **the layout-collapse branch specifically** — not
|
|
13
|
+
* "coalescing happened" in general. A broad counter would keep filling from the
|
|
14
|
+
* typing-accumulate branch even if the collapse rule were deleted, so the
|
|
15
|
+
* counter itself would go vacuous and a test asserting `> 0` would still pass
|
|
16
|
+
* (CLAUDE.md's rule about vacuous guard counters).
|
|
17
|
+
*/
|
|
18
|
+
export const historyStats = {
|
|
19
|
+
layoutCollapses: 0,
|
|
20
|
+
typingAccumulations: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** @returns {void} */
|
|
24
|
+
export function resetHistoryStats() {
|
|
25
|
+
historyStats.layoutCollapses = 0;
|
|
26
|
+
historyStats.typingAccumulations = 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** How long a pause breaks a run of typing into a separate undo entry. */
|
|
30
|
+
export const COALESCE_MS = 500;
|
|
31
|
+
|
|
32
|
+
/** Upper bound on entries kept. */
|
|
33
|
+
export const DEFAULT_DEPTH_LIMIT = 200;
|
|
34
|
+
|
|
35
|
+
/** Upper bound on steps merged into one entry, so a held key is still undoable. */
|
|
36
|
+
export const MAX_COALESCED_STEPS = 200;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} HistoryEntry
|
|
40
|
+
* @property {import("../model/step.js").Step[]} steps forward order, as applied
|
|
41
|
+
* @property {import("../model/step.js").Step[]} inverted **application order**:
|
|
42
|
+
* `invertAll` already returns the last step's inverse first, so undo applies
|
|
43
|
+
* this list front to back without reversing it
|
|
44
|
+
* @property {import("../model/position.js").Selection} selectionBefore
|
|
45
|
+
* @property {import("../model/position.js").Selection} selectionAfter
|
|
46
|
+
* @property {number} timestamp
|
|
47
|
+
* @property {import("../model/transaction.js").TransactionOrigin} origin
|
|
48
|
+
* @property {number} [gestureId] ADR-0016 §1 / ADR-0018 Karar 3 — see `Transaction.gestureId`'s own doc comment
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Undo/redo built on step inversion — there is no snapshot stack, per
|
|
53
|
+
* ARCHITECTURE.md §4.
|
|
54
|
+
*
|
|
55
|
+
* Inverses are computed at **push time**, because `invert(step, docBefore)` needs
|
|
56
|
+
* the document as it was before the step and that document is not retained
|
|
57
|
+
* afterwards.
|
|
58
|
+
*/
|
|
59
|
+
export class HistoryEngine {
|
|
60
|
+
/**
|
|
61
|
+
* @param {{ depthLimit?: number, coalesceMs?: number }} [options]
|
|
62
|
+
*/
|
|
63
|
+
constructor(options = {}) {
|
|
64
|
+
/** @type {HistoryEntry[]} */
|
|
65
|
+
this.undoStack = [];
|
|
66
|
+
/** @type {HistoryEntry[]} */
|
|
67
|
+
this.redoStack = [];
|
|
68
|
+
this.depthLimit = options.depthLimit ?? DEFAULT_DEPTH_LIMIT;
|
|
69
|
+
this.coalesceMs = options.coalesceMs ?? COALESCE_MS;
|
|
70
|
+
/** @type {boolean} */
|
|
71
|
+
this.coalesceBarrier = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Forces the next transaction to start a fresh undo entry. Intended for blur
|
|
76
|
+
* and for a hand-moved caret.
|
|
77
|
+
*
|
|
78
|
+
* **Not called at composition boundaries, despite what this comment used to
|
|
79
|
+
* claim** (ADR-0004, karar 6). A composition commits with `origin: "ime"`,
|
|
80
|
+
* and `canCoalesce` only ever merges `input`+`input` or `pointer`+`pointer` —
|
|
81
|
+
* so an IME entry can neither absorb the typing before it nor be absorbed by
|
|
82
|
+
* the typing after it. The origin already enforces the break; calling this as
|
|
83
|
+
* well would be unmeasured defence.
|
|
84
|
+
*
|
|
85
|
+
* Has **no callers today**. Kept because the blur and caret-move cases are
|
|
86
|
+
* real and land with the P1.9 wiring, but nothing depends on it yet.
|
|
87
|
+
* @returns {void}
|
|
88
|
+
*/
|
|
89
|
+
breakCoalescing() {
|
|
90
|
+
this.coalesceBarrier = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Records a transaction.
|
|
95
|
+
*
|
|
96
|
+
* Transactions that changed nothing are ignored — a no-op Ctrl+Z is worse than
|
|
97
|
+
* no entry at all. `origin: "history"` is ignored too: undo and redo must not
|
|
98
|
+
* record themselves.
|
|
99
|
+
*
|
|
100
|
+
* @param {import("../model/transaction.js").Transaction} tr
|
|
101
|
+
* @returns {void}
|
|
102
|
+
*/
|
|
103
|
+
push(tr) {
|
|
104
|
+
if (tr.origin === "history" || tr.steps.length === 0) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.redoStack.length = 0;
|
|
109
|
+
|
|
110
|
+
const previous = this.undoStack[this.undoStack.length - 1];
|
|
111
|
+
if (!this.coalesceBarrier && previous !== undefined && this.canCoalesce(previous, tr)) {
|
|
112
|
+
if (tr.steps.length > 1) {
|
|
113
|
+
// ADR-0018 Karar 3: a multi-step pointer frame (e.g. a multi-box
|
|
114
|
+
// drag) COLLAPSES exactly like the single-step case below, just
|
|
115
|
+
// replacing the previous entry's TRAILING N steps instead of only
|
|
116
|
+
// its last one — `canCoalesce` already confirmed via
|
|
117
|
+
// `sameLayoutFrame` that they pairwise match. `inverted` is left
|
|
118
|
+
// completely untouched, same reasoning as the single-step COLLAPSE
|
|
119
|
+
// comment below: it was computed against the pre-drag document on
|
|
120
|
+
// this gesture's FIRST push and stays correct no matter how many
|
|
121
|
+
// frames replace `steps` afterward.
|
|
122
|
+
previous.steps.splice(previous.steps.length - tr.steps.length, tr.steps.length, ...tr.steps);
|
|
123
|
+
if (isDev()) {
|
|
124
|
+
historyStats.layoutCollapses += 1;
|
|
125
|
+
}
|
|
126
|
+
previous.selectionAfter = tr.selectionAfter;
|
|
127
|
+
previous.timestamp = tr.timestamp;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const last = previous.steps[previous.steps.length - 1];
|
|
132
|
+
const next = tr.steps[0];
|
|
133
|
+
|
|
134
|
+
if (
|
|
135
|
+
last !== undefined &&
|
|
136
|
+
next !== undefined &&
|
|
137
|
+
last.type === "setLayout" &&
|
|
138
|
+
next.type === "setLayout"
|
|
139
|
+
) {
|
|
140
|
+
// COLLAPSE, not accumulate: a drag calls this once per pointer move —
|
|
141
|
+
// 200 mousemoves would otherwise mean 200 stored steps and 200 stored
|
|
142
|
+
// inverses. Replacing the last forward step with the newest one keeps
|
|
143
|
+
// `steps` correct for redo (it always reflects the drag's final
|
|
144
|
+
// geometry), while `inverted` is left completely untouched.
|
|
145
|
+
//
|
|
146
|
+
// That is safe, not accidental: the FIRST push of this drag already
|
|
147
|
+
// computed `inverted` against the document as it looked *before the
|
|
148
|
+
// drag started* (HistoryEngine.push runs `invertAll` at push time,
|
|
149
|
+
// while that pre-drag document is still in hand). Touching `inverted`
|
|
150
|
+
// here — the way the accumulate branch below does for typing — would
|
|
151
|
+
// make undo land on the second-to-last mousemove frame instead of
|
|
152
|
+
// where the drag began, which is the exact bug ADR-0007 calls out.
|
|
153
|
+
previous.steps[previous.steps.length - 1] = next;
|
|
154
|
+
if (isDev()) {
|
|
155
|
+
historyStats.layoutCollapses += 1;
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
previous.steps.push(...tr.steps);
|
|
159
|
+
// The newly appended steps ran last, so their inverses must run *first*.
|
|
160
|
+
previous.inverted.unshift(...invertAll(tr.docBefore, tr.steps));
|
|
161
|
+
if (isDev()) {
|
|
162
|
+
historyStats.typingAccumulations += 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
previous.selectionAfter = tr.selectionAfter;
|
|
167
|
+
previous.timestamp = tr.timestamp;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.coalesceBarrier = false;
|
|
172
|
+
this.undoStack.push({
|
|
173
|
+
steps: tr.steps.slice(),
|
|
174
|
+
inverted: invertAll(tr.docBefore, tr.steps),
|
|
175
|
+
selectionBefore: tr.selectionBefore,
|
|
176
|
+
selectionAfter: tr.selectionAfter,
|
|
177
|
+
timestamp: tr.timestamp,
|
|
178
|
+
origin: tr.origin,
|
|
179
|
+
gestureId: tr.gestureId,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (this.undoStack.length > this.depthLimit) {
|
|
183
|
+
this.undoStack.shift();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Merge this transaction into the previous entry? Conditions 2–4 are generic;
|
|
189
|
+
* which type-specific rule (5a/5b/5c) applies depends on the origin the two
|
|
190
|
+
* transactions share — so that "merhaba" is one Ctrl+Z but "merhaba", pause,
|
|
191
|
+
* "dünya" is two, and a whole box drag is one Ctrl+Z but a drag, a pause, a
|
|
192
|
+
* second drag is two.
|
|
193
|
+
*
|
|
194
|
+
* @param {HistoryEntry} previous
|
|
195
|
+
* @param {import("../model/transaction.js").Transaction} tr
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
canCoalesce(previous, tr) {
|
|
199
|
+
// `gestureId` matching on BOTH sides (never just one — undefined counts
|
|
200
|
+
// as "not set", not as a wildcard) means the two transactions belong to
|
|
201
|
+
// the SAME open pointer-gesture bracket (ADR-0016 §1, ADR-0018 Karar
|
|
202
|
+
// 3). `undefined === undefined` (every transaction built before a
|
|
203
|
+
// `CanvasController` exists, this file's own tests included) does NOT
|
|
204
|
+
// count as a real match — those fall through to today's exact
|
|
205
|
+
// behavior, unchanged.
|
|
206
|
+
const pointerGestureMatch =
|
|
207
|
+
previous.origin === "pointer" && tr.origin === "pointer" && previous.gestureId === tr.gestureId;
|
|
208
|
+
const realGestureMatch = pointerGestureMatch && previous.gestureId !== undefined;
|
|
209
|
+
|
|
210
|
+
// 2. Within the idle window. Compared by timestamp, never by a timer, so
|
|
211
|
+
// tests stay deterministic. BYPASSED only for a real gesture-bracket
|
|
212
|
+
// match: once a controller exists, the bracket's OWN (much larger,
|
|
213
|
+
// separately injected) idle-abandon threshold is what decides when
|
|
214
|
+
// two frames stop belonging to the same gesture — not this fixed
|
|
215
|
+
// 500ms window. This is exactly why a nudge's measured first-repeat
|
|
216
|
+
// delay (501-564ms, ADR-0016 §4) can exceed COALESCE_MS without
|
|
217
|
+
// splitting: repeats of ONE held key share a single gestureId.
|
|
218
|
+
if (!realGestureMatch && tr.timestamp - previous.timestamp >= this.coalesceMs) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
// 3. Bounded, so holding a key down (or a very long drag) still leaves
|
|
222
|
+
// usable undo granularity.
|
|
223
|
+
if (previous.steps.length + tr.steps.length > MAX_COALESCED_STEPS) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
// 4. The user did not move the caret, or drop the box, in between.
|
|
227
|
+
if (!selectionsEqual(previous.selectionAfter, tr.selectionBefore)) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 1 + 5c + gestureId (ADR-0018 Karar 3). A box drag OR a keyboard nudge
|
|
232
|
+
// — both origin "pointer": a multi-step frame (a multi-box drag) is
|
|
233
|
+
// matched pairwise against the previous entry's own TRAILING N steps
|
|
234
|
+
// via `sameLayoutFrame`, not just its last one.
|
|
235
|
+
if (previous.origin === "pointer" && tr.origin === "pointer") {
|
|
236
|
+
if (!pointerGestureMatch) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
if (tr.steps.length > 1) {
|
|
240
|
+
return sameLayoutFrame(previous.steps, tr.steps);
|
|
241
|
+
}
|
|
242
|
+
const last = previous.steps[previous.steps.length - 1];
|
|
243
|
+
const next = tr.steps[0];
|
|
244
|
+
if (last === undefined || next === undefined) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
// A box drag: consecutive `SetLayoutStep`s from `commands/box.js`'s
|
|
248
|
+
// `moveBox`/`resizeBox`, same container, same patched keys (`{x,y}`
|
|
249
|
+
// for a move, `{cx,cy}` for a resize — never mixed, so a move drag
|
|
250
|
+
// can never collapse into a resize drag or vice versa). This is what
|
|
251
|
+
// `push()` acts on to COLLAPSE rather than accumulate (see the
|
|
252
|
+
// comment there for why that is safe).
|
|
253
|
+
if (last.type === "setLayout" && next.type === "setLayout") {
|
|
254
|
+
return last.containerId === next.containerId && sameKeySet(last.patch, next.patch);
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const last = previous.steps[previous.steps.length - 1];
|
|
260
|
+
const next = tr.steps[0];
|
|
261
|
+
if (tr.steps.length !== 1 || last === undefined || next === undefined) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 1 + 5a/5b. Only ordinary typing coalesces this way — never paste,
|
|
266
|
+
// formatting, IME, or a box drag.
|
|
267
|
+
if (previous.origin === "input" && tr.origin === "input") {
|
|
268
|
+
// 5a. Forward typing: single-character inserts, contiguous, same marks.
|
|
269
|
+
if (last.type === "insert" && next.type === "insert") {
|
|
270
|
+
return (
|
|
271
|
+
isSingleCharacterInsert(last) &&
|
|
272
|
+
isSingleCharacterInsert(next) &&
|
|
273
|
+
positionsEqual(
|
|
274
|
+
{ path: last.pos.path, offset: last.pos.offset + runsLength(last.runs) },
|
|
275
|
+
next.pos,
|
|
276
|
+
) &&
|
|
277
|
+
marksEqual(last.runs[0].marks, next.runs[0].marks)
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 5b. A run of backspaces: each deletion ends where the previous began.
|
|
282
|
+
if (last.type === "delete" && next.type === "delete") {
|
|
283
|
+
return positionsEqual(next.to, last.from);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @returns {boolean} */
|
|
293
|
+
get canUndo() {
|
|
294
|
+
return this.undoStack.length > 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** @returns {boolean} */
|
|
298
|
+
get canRedo() {
|
|
299
|
+
return this.redoStack.length > 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Undoes one entry. Restores the selection too — that is most of what makes
|
|
304
|
+
* undo feel correct rather than merely accurate.
|
|
305
|
+
* @param {import("../state/editor-state.js").EditorState} state
|
|
306
|
+
* @returns {import("../model/transaction.js").Transaction | null}
|
|
307
|
+
*/
|
|
308
|
+
undo(state) {
|
|
309
|
+
const entry = this.undoStack.pop();
|
|
310
|
+
if (entry === undefined) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const inverted = entry.inverted.slice();
|
|
315
|
+
const doc = normalizeDoc(applyAll(state.doc, inverted));
|
|
316
|
+
|
|
317
|
+
this.redoStack.push(entry);
|
|
318
|
+
this.coalesceBarrier = true;
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
steps: inverted,
|
|
322
|
+
docBefore: state.doc,
|
|
323
|
+
doc,
|
|
324
|
+
selectionBefore: state.selection,
|
|
325
|
+
selectionAfter: entry.selectionBefore,
|
|
326
|
+
origin: "history",
|
|
327
|
+
timestamp: Date.now(),
|
|
328
|
+
storedMarks: null,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* @param {import("../state/editor-state.js").EditorState} state
|
|
334
|
+
* @returns {import("../model/transaction.js").Transaction | null}
|
|
335
|
+
*/
|
|
336
|
+
redo(state) {
|
|
337
|
+
const entry = this.redoStack.pop();
|
|
338
|
+
if (entry === undefined) {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const doc = normalizeDoc(applyAll(state.doc, entry.steps));
|
|
343
|
+
this.undoStack.push(entry);
|
|
344
|
+
this.coalesceBarrier = true;
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
steps: entry.steps.slice(),
|
|
348
|
+
docBefore: state.doc,
|
|
349
|
+
doc,
|
|
350
|
+
selectionBefore: state.selection,
|
|
351
|
+
selectionAfter: entry.selectionAfter,
|
|
352
|
+
origin: "history",
|
|
353
|
+
timestamp: Date.now(),
|
|
354
|
+
storedMarks: null,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @returns {void} */
|
|
359
|
+
clear() {
|
|
360
|
+
this.undoStack.length = 0;
|
|
361
|
+
this.redoStack.length = 0;
|
|
362
|
+
this.coalesceBarrier = false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* @param {import("../model/step.js").InsertStep} step
|
|
368
|
+
* @returns {boolean}
|
|
369
|
+
*/
|
|
370
|
+
function isSingleCharacterInsert(step) {
|
|
371
|
+
return step.runs.length === 1 && [...step.runs[0].text].length === 1;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Whether two layout patches touch exactly the same set of keys, order
|
|
376
|
+
* ignored. Used to keep a move drag (`{x,y}`) from ever collapsing into a
|
|
377
|
+
* resize drag (`{cx,cy}`) that happens to follow it within the idle window.
|
|
378
|
+
* @param {Partial<import("../model/node.js").BoxLayout>} a
|
|
379
|
+
* @param {Partial<import("../model/node.js").BoxLayout>} b
|
|
380
|
+
* @returns {boolean}
|
|
381
|
+
*/
|
|
382
|
+
export function sameKeySet(a, b) {
|
|
383
|
+
const aKeys = Object.keys(a);
|
|
384
|
+
const bKeys = Object.keys(b);
|
|
385
|
+
if (aKeys.length !== bKeys.length) {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Whether `steps` (a candidate transaction's forward steps) pairwise match
|
|
393
|
+
* `previousSteps`' own TRAILING N steps, on `(containerId, sameKeySet)` —
|
|
394
|
+
* ADR-0018 Karar 3's multi-step generalization of the single-step pointer
|
|
395
|
+
* check `canCoalesce` already made for one box. Order matters: `steps[i]`
|
|
396
|
+
* is compared against `previousSteps[previousSteps.length - steps.length + i]`,
|
|
397
|
+
* never re-sorted — a caller that reorders its own entries between frames
|
|
398
|
+
* would (correctly) see this reject, since `HistoryEngine` matches frames by
|
|
399
|
+
* INDEX, not by a `containerId` lookup (a reordered frame would otherwise
|
|
400
|
+
* silently coalesce one box's step into a different box's slot).
|
|
401
|
+
*
|
|
402
|
+
* Every element on both sides must be a `SetLayoutStep` — this is a
|
|
403
|
+
* pointer-drag concept only, never mixed with typing/paste/IME steps.
|
|
404
|
+
*
|
|
405
|
+
* For `steps.length === 1` this asks EXACTLY the question `canCoalesce`'s
|
|
406
|
+
* own single-step branch already asks (same `containerId`, same key set
|
|
407
|
+
* against the previous entry's LAST step) — `canCoalesce` does not actually
|
|
408
|
+
* route through this function for that case (it keeps its original,
|
|
409
|
+
* byte-identical single-step code so HIST2/HIST3's mutations keep their
|
|
410
|
+
* exact anchor text), but the EQUIVALENCE is proven as a property in this
|
|
411
|
+
* function's own test file, not just argued.
|
|
412
|
+
*
|
|
413
|
+
* @param {import("../model/step.js").Step[]} previousSteps
|
|
414
|
+
* @param {import("../model/step.js").Step[]} steps
|
|
415
|
+
* @returns {boolean}
|
|
416
|
+
*/
|
|
417
|
+
export function sameLayoutFrame(previousSteps, steps) {
|
|
418
|
+
if (steps.length === 0 || steps.length > previousSteps.length) {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
const offset = previousSteps.length - steps.length;
|
|
422
|
+
for (let i = 0; i < steps.length; i += 1) {
|
|
423
|
+
const prev = previousSteps[offset + i];
|
|
424
|
+
const next = steps[i];
|
|
425
|
+
if (prev.type !== "setLayout" || next.type !== "setLayout") {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
if (prev.containerId !== next.containerId || !sameKeySet(prev.patch, next.patch)) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return true;
|
|
433
|
+
}
|