@jackuait/blok 0.10.8 → 0.10.9
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/dist/blok.mjs +2 -2
- package/dist/chunks/{blok-ClCrnWuI.mjs → blok-DbRn9adY.mjs} +2454 -2057
- package/dist/chunks/{constants-BoE5frJm.mjs → constants-C9lsSOXl.mjs} +4 -3
- package/dist/chunks/{tools-HQPJLj5m.mjs → tools-D0W3_dlA.mjs} +502 -497
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +2 -2
- package/dist/tools.mjs +2 -2
- package/package.json +3 -6
- package/src/components/block/index.ts +36 -0
- package/src/components/blocks.ts +191 -5
- package/src/components/modules/api/blocks.ts +6 -4
- package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +17 -6
- package/src/components/modules/blockManager/blockManager.ts +364 -23
- package/src/components/modules/blockManager/hierarchy.ts +164 -8
- package/src/components/modules/blockManager/operations.ts +223 -26
- package/src/components/modules/blockManager/types.ts +13 -1
- package/src/components/modules/blockManager/yjs-sync.ts +48 -3
- package/src/components/modules/drag/DragController.ts +209 -8
- package/src/components/modules/drag/operations/DragOperations.ts +153 -20
- package/src/components/modules/paste/handlers/base.ts +48 -20
- package/src/components/modules/paste/handlers/blok-data-handler.ts +93 -45
- package/src/components/modules/paste/index.ts +20 -0
- package/src/components/modules/saver.ts +75 -5
- package/src/components/modules/toolbar/index.ts +41 -60
- package/src/components/modules/uiControllers/controllers/keyboard.ts +20 -0
- package/src/components/modules/yjs/block-observer.ts +87 -23
- package/src/components/modules/yjs/document-store.ts +37 -11
- package/src/components/modules/yjs/index.ts +83 -7
- package/src/components/modules/yjs/types.ts +35 -2
- package/src/components/modules/yjs/undo-history.ts +116 -5
- package/src/components/utils/data-model-transform.ts +81 -7
- package/src/components/utils/hierarchy-invariant.ts +137 -0
- package/src/styles/main.css +5 -0
- package/src/tools/callout/constants.ts +0 -1
- package/src/tools/callout/dom-builder.ts +1 -11
- package/src/tools/callout/index.ts +0 -6
- package/src/tools/header/index.ts +14 -1
- package/src/tools/toggle/constants.ts +2 -1
- package/src/tools/toggle/dom-builder.ts +7 -0
- package/src/tools/toggle/index.ts +14 -1
- package/src/tools/toggle/toggle-lifecycle.ts +24 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as Y from 'yjs';
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import {
|
|
4
|
+
LOCAL_ORIGIN_TAGS,
|
|
5
|
+
type BlockChangeEvent,
|
|
6
|
+
type BlockChangeCallback,
|
|
7
|
+
type LocalOriginTag,
|
|
8
|
+
type TransactionOrigin,
|
|
7
9
|
} from './types';
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -66,34 +68,64 @@ export class BlockObserver {
|
|
|
66
68
|
|
|
67
69
|
/**
|
|
68
70
|
* Map transaction origin to event origin.
|
|
71
|
+
*
|
|
72
|
+
* Input shapes:
|
|
73
|
+
* - `Y.UndoManager` instance → `'undo'` or `'redo'`
|
|
74
|
+
* - `LocalOriginTag` string → mapped by the exhaustive switch below
|
|
75
|
+
* - anything else → `'remote'` (treated as a peer update)
|
|
76
|
+
*
|
|
77
|
+
* IMPORTANT: the switch is exhaustive over `LOCAL_ORIGIN_TAGS`. Adding a
|
|
78
|
+
* new tag there without teaching this switch is a compile error via the
|
|
79
|
+
* `satisfies never` guard, and the enumeration test in
|
|
80
|
+
* `block-observer.test.ts` catches any runtime drift. Do not add a local
|
|
81
|
+
* origin tag that silently falls through to `'remote'` — that is the
|
|
82
|
+
* exact bug class that broke `ensureCellHasBlock` → table row deletion.
|
|
69
83
|
*/
|
|
70
84
|
public mapTransactionOrigin(origin: unknown): TransactionOrigin {
|
|
71
|
-
if (origin === 'local') {
|
|
72
|
-
return 'local';
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (origin === 'load') {
|
|
76
|
-
return 'load';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
85
|
if (this.undoManager && origin === this.undoManager) {
|
|
80
86
|
return this.undoManager.undoing ? 'undo' : 'redo';
|
|
81
87
|
}
|
|
82
88
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return 'local';
|
|
89
|
+
if (!this.isLocalOriginTag(origin)) {
|
|
90
|
+
return 'remote';
|
|
86
91
|
}
|
|
87
92
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
switch (origin) {
|
|
94
|
+
case 'local':
|
|
95
|
+
return 'local';
|
|
96
|
+
case 'load':
|
|
97
|
+
return 'load';
|
|
98
|
+
// `no-capture` is used by `DocumentStore.transactWithoutCapture` for
|
|
99
|
+
// local writes that must bypass the undo stack (auto-repair inserts,
|
|
100
|
+
// drag-move parent rewrites replayed by undo/redo, etc). They are
|
|
101
|
+
// LOCAL authoring writes — mapping them to `'remote'` would make
|
|
102
|
+
// `BlockYjsSync` call `setData(staleYjsData)` on the authoring block
|
|
103
|
+
// mid-operation and wipe any in-memory state the tool had written
|
|
104
|
+
// ahead of Yjs (e.g. Table's local model after `model.addRow()`).
|
|
105
|
+
case 'no-capture':
|
|
106
|
+
return 'local';
|
|
107
|
+
case 'move':
|
|
108
|
+
return 'local';
|
|
109
|
+
case 'move-undo':
|
|
110
|
+
return 'undo';
|
|
111
|
+
case 'move-redo':
|
|
112
|
+
return 'redo';
|
|
113
|
+
default: {
|
|
114
|
+
const _exhaustive: never = origin;
|
|
115
|
+
|
|
116
|
+
return _exhaustive;
|
|
117
|
+
}
|
|
94
118
|
}
|
|
119
|
+
}
|
|
95
120
|
|
|
96
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Type guard for known local-authored origin tags.
|
|
123
|
+
*/
|
|
124
|
+
private isLocalOriginTag(value: unknown): value is LocalOriginTag {
|
|
125
|
+
return (
|
|
126
|
+
typeof value === 'string' &&
|
|
127
|
+
(LOCAL_ORIGIN_TAGS as readonly string[]).includes(value)
|
|
128
|
+
);
|
|
97
129
|
}
|
|
98
130
|
|
|
99
131
|
/**
|
|
@@ -210,13 +242,33 @@ export class BlockObserver {
|
|
|
210
242
|
}
|
|
211
243
|
|
|
212
244
|
/**
|
|
213
|
-
* Handle map-level changes (data update
|
|
245
|
+
* Handle map-level changes (data update, tunes update, or top-level
|
|
246
|
+
* yblock key changes like `parentId` / `contentIds`).
|
|
247
|
+
*
|
|
248
|
+
* When a remote client reparents a block, the changed Y.Map is the
|
|
249
|
+
* yblock itself — not a nested `data`/`tunes` sub-map. Detect both
|
|
250
|
+
* cases so we always emit an update event for the affected block id.
|
|
214
251
|
*/
|
|
215
252
|
private handleMapEvent(ymap: Y.Map<unknown>, origin: TransactionOrigin): void {
|
|
216
253
|
if (this.yblocks === null) {
|
|
217
254
|
return;
|
|
218
255
|
}
|
|
219
256
|
|
|
257
|
+
// Direct yblock change (e.g. parentId/contentIds written on the yblock itself).
|
|
258
|
+
if (this.isTopLevelYblock(ymap)) {
|
|
259
|
+
const id: unknown = ymap.get('id');
|
|
260
|
+
|
|
261
|
+
if (typeof id === 'string') {
|
|
262
|
+
this.emitChange({
|
|
263
|
+
type: 'update',
|
|
264
|
+
blockId: id,
|
|
265
|
+
origin,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
220
272
|
const yblock = this.findParentBlock(ymap);
|
|
221
273
|
|
|
222
274
|
if (yblock === undefined) {
|
|
@@ -230,6 +282,18 @@ export class BlockObserver {
|
|
|
230
282
|
});
|
|
231
283
|
}
|
|
232
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Returns true if the given Y.Map is one of the top-level yblocks tracked
|
|
287
|
+
* in the blocks array.
|
|
288
|
+
*/
|
|
289
|
+
private isTopLevelYblock(ymap: Y.Map<unknown>): boolean {
|
|
290
|
+
if (this.yblocks === null) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return this.yblocks.toArray().includes(ymap);
|
|
295
|
+
}
|
|
296
|
+
|
|
233
297
|
/**
|
|
234
298
|
* Find the parent block Y.Map for a nested Y.Map (data or tunes).
|
|
235
299
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as Y from 'yjs';
|
|
2
2
|
|
|
3
3
|
import type { YBlockSerializer, YjsOutputBlockData } from './serializer';
|
|
4
|
-
import type {
|
|
4
|
+
import type { LocalOriginTag } from './types';
|
|
5
5
|
import { equals } from '../../utils/object';
|
|
6
6
|
|
|
7
7
|
// Re-export YjsOutputBlockData as DocumentStoreBlockData for consistency
|
|
@@ -17,9 +17,15 @@ type DocumentStoreBlockData = YjsOutputBlockData;
|
|
|
17
17
|
*/
|
|
18
18
|
export class DocumentStore {
|
|
19
19
|
/**
|
|
20
|
-
* Yjs document instance
|
|
20
|
+
* Yjs document instance.
|
|
21
|
+
*
|
|
22
|
+
* PRIVATE by design: all writes MUST route through `transact` or
|
|
23
|
+
* `transactWithoutCapture` so the origin passes the `LocalOriginTag`
|
|
24
|
+
* type barrier. Exposing the raw Y.Doc lets callers bypass the
|
|
25
|
+
* whitelist and silently reintroduce the class of bugs that
|
|
26
|
+
* `BlockObserver.mapTransactionOrigin` exists to prevent.
|
|
21
27
|
*/
|
|
22
|
-
|
|
28
|
+
private readonly ydoc: Y.Doc = new Y.Doc();
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
31
|
* Yjs array containing all blocks
|
|
@@ -97,7 +103,11 @@ export class DocumentStore {
|
|
|
97
103
|
* @param toIndex - Target index (the final position where the block should end up)
|
|
98
104
|
* @param origin - Transaction origin
|
|
99
105
|
*/
|
|
100
|
-
public moveBlock(
|
|
106
|
+
public moveBlock(
|
|
107
|
+
id: string,
|
|
108
|
+
toIndex: number,
|
|
109
|
+
origin: 'local' | 'move-undo' | 'move-redo'
|
|
110
|
+
): void {
|
|
101
111
|
const fromIndex = this.findBlockIndex(id);
|
|
102
112
|
|
|
103
113
|
if (fromIndex === -1) {
|
|
@@ -112,7 +122,7 @@ export class DocumentStore {
|
|
|
112
122
|
// Use the origin for the transaction:
|
|
113
123
|
// - 'local' for user-initiated moves (we use 'move' so Yjs UndoManager doesn't track them)
|
|
114
124
|
// - 'move-undo' / 'move-redo' for our custom undo/redo (maps to 'undo'/'redo' for DOM sync)
|
|
115
|
-
const transactionOrigin = origin === 'local' ? 'move' : origin;
|
|
125
|
+
const transactionOrigin: LocalOriginTag = origin === 'local' ? 'move' : origin;
|
|
116
126
|
|
|
117
127
|
this.transact(() => {
|
|
118
128
|
const yblock = this.yblocks.get(fromIndex);
|
|
@@ -150,12 +160,14 @@ export class DocumentStore {
|
|
|
150
160
|
* @param id - Block id
|
|
151
161
|
* @param key - Data property key
|
|
152
162
|
* @param value - New value
|
|
163
|
+
* @returns true if a Yjs write actually occurred (value changed), false if the
|
|
164
|
+
* equality guard short-circuited the write.
|
|
153
165
|
*/
|
|
154
|
-
public updateBlockData(id: string, key: string, value: unknown):
|
|
166
|
+
public updateBlockData(id: string, key: string, value: unknown): boolean {
|
|
155
167
|
const yblock = this.getBlockById(id);
|
|
156
168
|
|
|
157
169
|
if (yblock === undefined) {
|
|
158
|
-
return;
|
|
170
|
+
return false;
|
|
159
171
|
}
|
|
160
172
|
|
|
161
173
|
const ydata = yblock.get('data') as Y.Map<unknown>;
|
|
@@ -166,12 +178,14 @@ export class DocumentStore {
|
|
|
166
178
|
// (e.g., marker updates in list items during undo/redo, or table content
|
|
167
179
|
// arrays that are reference-different but structurally identical)
|
|
168
180
|
if (equals(currentValue, value)) {
|
|
169
|
-
return;
|
|
181
|
+
return false;
|
|
170
182
|
}
|
|
171
183
|
|
|
172
184
|
this.transact(() => {
|
|
173
185
|
ydata.set(key, value);
|
|
174
186
|
}, 'local');
|
|
187
|
+
|
|
188
|
+
return true;
|
|
175
189
|
}
|
|
176
190
|
|
|
177
191
|
/**
|
|
@@ -199,11 +213,21 @@ export class DocumentStore {
|
|
|
199
213
|
* @param lastEditedAt - Timestamp in milliseconds
|
|
200
214
|
* @param lastEditedBy - User ID, or null
|
|
201
215
|
*/
|
|
202
|
-
public updateBlockMetadata(id: string, lastEditedAt: number, lastEditedBy: string | null):
|
|
216
|
+
public updateBlockMetadata(id: string, lastEditedAt: number, lastEditedBy: string | null): boolean {
|
|
203
217
|
const yblock = this.getBlockById(id);
|
|
204
218
|
|
|
205
219
|
if (yblock === undefined) {
|
|
206
|
-
return;
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Defensive equality guard — if both fields already match, skip the write to avoid
|
|
224
|
+
// adding an empty/no-op entry to the Yjs undo stack.
|
|
225
|
+
const currentEditedAt = yblock.get('lastEditedAt');
|
|
226
|
+
const currentEditedBy = yblock.get('lastEditedBy');
|
|
227
|
+
const editedByMatches = lastEditedBy === null || currentEditedBy === lastEditedBy;
|
|
228
|
+
|
|
229
|
+
if (currentEditedAt === lastEditedAt && editedByMatches) {
|
|
230
|
+
return false;
|
|
207
231
|
}
|
|
208
232
|
|
|
209
233
|
this.transact(() => {
|
|
@@ -213,6 +237,8 @@ export class DocumentStore {
|
|
|
213
237
|
yblock.set('lastEditedBy', lastEditedBy);
|
|
214
238
|
}
|
|
215
239
|
}, 'local');
|
|
240
|
+
|
|
241
|
+
return true;
|
|
216
242
|
}
|
|
217
243
|
|
|
218
244
|
/**
|
|
@@ -230,7 +256,7 @@ export class DocumentStore {
|
|
|
230
256
|
* @param fn - Function containing Yjs operations to execute atomically
|
|
231
257
|
* @param origin - Transaction origin
|
|
232
258
|
*/
|
|
233
|
-
public transact(fn: () => void, origin:
|
|
259
|
+
public transact(fn: () => void, origin: LocalOriginTag): void {
|
|
234
260
|
this.ydoc.transact(fn, origin);
|
|
235
261
|
}
|
|
236
262
|
|
|
@@ -43,10 +43,23 @@ export class YjsManager extends Module {
|
|
|
43
43
|
private blockObserver: BlockObserver;
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* Flag to track if move group is active
|
|
46
|
+
* Flag to track if move group is active.
|
|
47
|
+
*
|
|
48
|
+
* Read via the `isInMoveGroup` getter by `BlockManager.setBlockParent`,
|
|
49
|
+
* which routes its Yjs writes through `transactWithoutCapture` while this
|
|
50
|
+
* flag is true so the parent change attaches to the in-flight move entry
|
|
51
|
+
* instead of landing on Y.UndoManager as a separate stack item.
|
|
47
52
|
*/
|
|
48
53
|
private isMoveGroupActive = false;
|
|
49
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Whether a drag-backed move group is currently open.
|
|
57
|
+
* See `isMoveGroupActive`.
|
|
58
|
+
*/
|
|
59
|
+
public get isInMoveGroup(): boolean {
|
|
60
|
+
return this.isMoveGroupActive;
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
/**
|
|
51
64
|
* Constructor - initializes all components
|
|
52
65
|
*/
|
|
@@ -67,6 +80,42 @@ export class YjsManager extends Module {
|
|
|
67
80
|
this.documentStore.moveBlock(blockId, toIndex, origin);
|
|
68
81
|
});
|
|
69
82
|
|
|
83
|
+
// Set up parent-restore callback — invoked by UndoHistory during
|
|
84
|
+
// move-undo/move-redo on drag-reparent entries.
|
|
85
|
+
//
|
|
86
|
+
// Writes parentId (and the two parents' contentIds) to Yjs under
|
|
87
|
+
// `transactWithoutCapture` so Y.UndoManager does not record the replay
|
|
88
|
+
// as a new stack item, then drives the in-memory BlockManager reparent
|
|
89
|
+
// directly via `reparentFromHistoryReplay`. Going direct avoids the
|
|
90
|
+
// `handleYjsUpdate` path's parentId-delete blind spot (that handler
|
|
91
|
+
// gates reconciliation on `yblock.has('parentId')` which is false after
|
|
92
|
+
// a delete, so a non-root → root undo would otherwise silently skip).
|
|
93
|
+
this.undoHistory.setParentRestoreCallback((blockId, parentId) => {
|
|
94
|
+
const yblock = this.documentStore.getBlockById(blockId);
|
|
95
|
+
|
|
96
|
+
if (yblock === undefined) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.documentStore.transactWithoutCapture(() => {
|
|
101
|
+
if (parentId !== null) {
|
|
102
|
+
yblock.set('parentId', parentId);
|
|
103
|
+
} else {
|
|
104
|
+
yblock.delete('parentId');
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const blockManager = this.Blok?.BlockManager;
|
|
109
|
+
|
|
110
|
+
if (blockManager !== undefined) {
|
|
111
|
+
const block = blockManager.getBlockById(blockId);
|
|
112
|
+
|
|
113
|
+
if (block !== undefined) {
|
|
114
|
+
blockManager.reparentFromHistoryReplay(block, parentId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
70
119
|
// Set up observation
|
|
71
120
|
this.blockObserver.observe(this.documentStore.yblocks, this.undoHistory.undoManager);
|
|
72
121
|
}
|
|
@@ -148,10 +197,10 @@ export class YjsManager extends Module {
|
|
|
148
197
|
* @param key - Data property key
|
|
149
198
|
* @param value - New value
|
|
150
199
|
*/
|
|
151
|
-
public updateBlockData(id: string, key: string, value: unknown):
|
|
200
|
+
public updateBlockData(id: string, key: string, value: unknown): boolean {
|
|
152
201
|
this.undoHistory.markCaretBeforeChange();
|
|
153
202
|
|
|
154
|
-
this.documentStore.updateBlockData(id, key, value);
|
|
203
|
+
return this.documentStore.updateBlockData(id, key, value);
|
|
155
204
|
}
|
|
156
205
|
|
|
157
206
|
/**
|
|
@@ -170,8 +219,8 @@ export class YjsManager extends Module {
|
|
|
170
219
|
* @param lastEditedAt - Timestamp in milliseconds
|
|
171
220
|
* @param lastEditedBy - User ID, or null
|
|
172
221
|
*/
|
|
173
|
-
public updateBlockMetadata(id: string, lastEditedAt: number, lastEditedBy: string | null):
|
|
174
|
-
this.documentStore.updateBlockMetadata(id, lastEditedAt, lastEditedBy);
|
|
222
|
+
public updateBlockMetadata(id: string, lastEditedAt: number, lastEditedBy: string | null): boolean {
|
|
223
|
+
return this.documentStore.updateBlockMetadata(id, lastEditedAt, lastEditedBy);
|
|
175
224
|
}
|
|
176
225
|
|
|
177
226
|
/**
|
|
@@ -259,8 +308,35 @@ export class YjsManager extends Module {
|
|
|
259
308
|
*/
|
|
260
309
|
public transactMoves(fn: () => void): void {
|
|
261
310
|
this.isMoveGroupActive = true;
|
|
262
|
-
|
|
263
|
-
|
|
311
|
+
try {
|
|
312
|
+
this.undoHistory.transactMoves(fn);
|
|
313
|
+
} finally {
|
|
314
|
+
this.isMoveGroupActive = false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Attach a parent change to the in-flight move entry so `undo`/`redo`
|
|
320
|
+
* restores the block's parent atomically with its position.
|
|
321
|
+
*
|
|
322
|
+
* Called from `BlockManager.setBlockParent` when a drag-backed move group
|
|
323
|
+
* is open (see `isInMoveGroup`). The accompanying Yjs parentId write must
|
|
324
|
+
* use `transactWithoutCapture` — otherwise Y.UndoManager records it as a
|
|
325
|
+
* separate stack item and the drag splits into a two-step undo.
|
|
326
|
+
* @param blockId - id of the block being reparented
|
|
327
|
+
* @param fromParentId - parent id before the reparent (null for root)
|
|
328
|
+
* @param toParentId - parent id after the reparent (null for root)
|
|
329
|
+
*/
|
|
330
|
+
public recordParentChangeForPendingMove(
|
|
331
|
+
blockId: string,
|
|
332
|
+
fromParentId: string | null,
|
|
333
|
+
toParentId: string | null
|
|
334
|
+
): void {
|
|
335
|
+
this.undoHistory.recordParentChangeForPendingMove(
|
|
336
|
+
blockId,
|
|
337
|
+
fromParentId,
|
|
338
|
+
toParentId
|
|
339
|
+
);
|
|
264
340
|
}
|
|
265
341
|
|
|
266
342
|
/**
|
|
@@ -10,8 +10,9 @@ export type BlockChangeEvent =
|
|
|
10
10
|
| { type: 'batch-add'; blockIds: string[]; origin: TransactionOrigin };
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Transaction origin types
|
|
14
|
-
*
|
|
13
|
+
* Transaction origin types AFTER classification by
|
|
14
|
+
* `BlockObserver.mapTransactionOrigin`. This is what downstream consumers
|
|
15
|
+
* (e.g. `BlockYjsSync`) see on a `BlockChangeEvent`.
|
|
15
16
|
*/
|
|
16
17
|
export type TransactionOrigin =
|
|
17
18
|
| 'local'
|
|
@@ -23,6 +24,30 @@ export type TransactionOrigin =
|
|
|
23
24
|
| 'move-undo'
|
|
24
25
|
| 'move-redo';
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Whitelist of raw origin tags that our own code passes to `Y.Doc.transact`.
|
|
29
|
+
*
|
|
30
|
+
* Adding a new local-authored origin tag? You MUST:
|
|
31
|
+
* 1. Add it here.
|
|
32
|
+
* 2. Handle it explicitly in `BlockObserver.mapTransactionOrigin`.
|
|
33
|
+
*
|
|
34
|
+
* The mapper's exhaustiveness check and the `block-observer.test.ts`
|
|
35
|
+
* enumeration test will otherwise fail CI — preventing a repeat of the
|
|
36
|
+
* table-row-removal bug where `'no-capture'` silently fell through to
|
|
37
|
+
* `'remote'` and made `BlockYjsSync` clobber the authoring tool's state
|
|
38
|
+
* with stale Yjs data mid-operation.
|
|
39
|
+
*/
|
|
40
|
+
export const LOCAL_ORIGIN_TAGS = [
|
|
41
|
+
'local',
|
|
42
|
+
'load',
|
|
43
|
+
'no-capture',
|
|
44
|
+
'move',
|
|
45
|
+
'move-undo',
|
|
46
|
+
'move-redo',
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
export type LocalOriginTag = (typeof LOCAL_ORIGIN_TAGS)[number];
|
|
50
|
+
|
|
26
51
|
/**
|
|
27
52
|
* Callback for block change events
|
|
28
53
|
*/
|
|
@@ -47,11 +72,19 @@ export interface CaretHistoryEntry {
|
|
|
47
72
|
|
|
48
73
|
/**
|
|
49
74
|
* Represents a single move operation within a move group.
|
|
75
|
+
*
|
|
76
|
+
* Drag-reparent flows attach `fromParentId`/`toParentId` so that undo/redo
|
|
77
|
+
* can restore the parent relationship atomically alongside the array move.
|
|
78
|
+
* Without this, a drag-reparent splits across two history stacks
|
|
79
|
+
* (`moveUndoStack` for the array move, Y.UndoManager for the parentId write)
|
|
80
|
+
* and requires two Cmd+Z presses to fully reverse.
|
|
50
81
|
*/
|
|
51
82
|
export interface SingleMoveEntry {
|
|
52
83
|
blockId: string;
|
|
53
84
|
fromIndex: number;
|
|
54
85
|
toIndex: number;
|
|
86
|
+
fromParentId?: string | null;
|
|
87
|
+
toParentId?: string | null;
|
|
55
88
|
}
|
|
56
89
|
|
|
57
90
|
/**
|
|
@@ -102,6 +102,15 @@ export class UndoHistory {
|
|
|
102
102
|
*/
|
|
103
103
|
private moveCallback: (blockId: string, toIndex: number, origin: 'local' | 'move-undo' | 'move-redo') => void;
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Callback to restore a block's parent during move-undo/move-redo.
|
|
107
|
+
*
|
|
108
|
+
* Must not record its own history entry — the call is part of replaying
|
|
109
|
+
* an existing `SingleMoveEntry`. Set by YjsManager to route through
|
|
110
|
+
* `transactWithoutCapture` + a direct in-memory reparent.
|
|
111
|
+
*/
|
|
112
|
+
private parentRestoreCallback: (blockId: string, parentId: string | null) => void;
|
|
113
|
+
|
|
105
114
|
constructor(
|
|
106
115
|
yblocks: Y.Array<Y.Map<unknown>>,
|
|
107
116
|
blok: BlokModules
|
|
@@ -120,6 +129,9 @@ export class UndoHistory {
|
|
|
120
129
|
this.moveCallback = () => {
|
|
121
130
|
// Placeholder, will be set by setMoveCallback
|
|
122
131
|
};
|
|
132
|
+
this.parentRestoreCallback = () => {
|
|
133
|
+
// Placeholder, will be set by setParentRestoreCallback
|
|
134
|
+
};
|
|
123
135
|
}
|
|
124
136
|
|
|
125
137
|
/**
|
|
@@ -131,6 +143,16 @@ export class UndoHistory {
|
|
|
131
143
|
this.moveCallback = callback;
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Set the parent-restore callback used by move-undo/move-redo to rewind
|
|
148
|
+
* drag-reparent side effects. See `parentRestoreCallback`.
|
|
149
|
+
*/
|
|
150
|
+
public setParentRestoreCallback(
|
|
151
|
+
callback: (blockId: string, parentId: string | null) => void
|
|
152
|
+
): void {
|
|
153
|
+
this.parentRestoreCallback = callback;
|
|
154
|
+
}
|
|
155
|
+
|
|
134
156
|
/**
|
|
135
157
|
* Set the Blok modules. Called when Blok modules are initialized.
|
|
136
158
|
*/
|
|
@@ -216,10 +238,14 @@ export class UndoHistory {
|
|
|
216
238
|
// Push to redo stack for potential redo
|
|
217
239
|
this.moveRedoStack.push(lastMoveGroup);
|
|
218
240
|
|
|
219
|
-
// Reverse all moves in the group, in reverse order
|
|
220
|
-
// This is crucial for multi-block moves to restore correctly
|
|
241
|
+
// Reverse all moves in the group, in reverse order.
|
|
242
|
+
// This is crucial for multi-block moves to restore correctly.
|
|
243
|
+
//
|
|
244
|
+
// Drag-reparent entries may additionally carry `fromParentId`; restore
|
|
245
|
+
// the parent BEFORE the position so the block lands in the correct
|
|
246
|
+
// flat-array slot relative to its (soon-to-be-restored) parent siblings.
|
|
221
247
|
[...lastMoveGroup].reverse().forEach((move) => {
|
|
222
|
-
this.
|
|
248
|
+
this.replayMoveUndo(move);
|
|
223
249
|
});
|
|
224
250
|
|
|
225
251
|
// Pop caret entry only after move succeeds
|
|
@@ -257,9 +283,12 @@ export class UndoHistory {
|
|
|
257
283
|
// Push back to undo stack
|
|
258
284
|
this.moveUndoStack.push(lastMoveGroup);
|
|
259
285
|
|
|
260
|
-
// Redo all moves in the group, in original order
|
|
286
|
+
// Redo all moves in the group, in original order. Drag-reparent
|
|
287
|
+
// entries restore the destination parent AFTER the position so that
|
|
288
|
+
// the flat-array splice settles first and the parent's contentIds
|
|
289
|
+
// then re-attach cleanly.
|
|
261
290
|
for (const move of lastMoveGroup) {
|
|
262
|
-
this.
|
|
291
|
+
this.replayMoveRedo(move);
|
|
263
292
|
}
|
|
264
293
|
|
|
265
294
|
// Pop caret entry only after move succeeds
|
|
@@ -307,6 +336,36 @@ export class UndoHistory {
|
|
|
307
336
|
this.restoreCaretSnapshot(snapshot);
|
|
308
337
|
}
|
|
309
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Replay a single move entry in the undo direction.
|
|
341
|
+
* Parent restore runs BEFORE the position restore so the block lands in
|
|
342
|
+
* the correct slot relative to its (soon-to-be-restored) parent siblings.
|
|
343
|
+
*/
|
|
344
|
+
private replayMoveUndo(move: SingleMoveEntry): void {
|
|
345
|
+
if (move.fromParentId !== undefined) {
|
|
346
|
+
this.parentRestoreCallback(move.blockId, move.fromParentId);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (move.fromIndex !== -1) {
|
|
350
|
+
this.moveCallback(move.blockId, move.fromIndex, 'move-undo');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Replay a single move entry in the redo direction.
|
|
356
|
+
* Position restore runs BEFORE the parent restore so the flat-array splice
|
|
357
|
+
* settles first and the destination parent's contentIds re-attach cleanly.
|
|
358
|
+
*/
|
|
359
|
+
private replayMoveRedo(move: SingleMoveEntry): void {
|
|
360
|
+
if (move.toIndex !== -1) {
|
|
361
|
+
this.moveCallback(move.blockId, move.toIndex, 'move-redo');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (move.toParentId !== undefined) {
|
|
365
|
+
this.parentRestoreCallback(move.blockId, move.toParentId);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
310
369
|
/**
|
|
311
370
|
* Execute a Yjs UndoManager operation with the isPerformingUndoRedo flag set.
|
|
312
371
|
* This prevents the stack-item-added listener from modifying caret stacks during
|
|
@@ -438,6 +497,58 @@ export class UndoHistory {
|
|
|
438
497
|
}
|
|
439
498
|
}
|
|
440
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Attach a parent change to the in-flight move entry (or create a
|
|
502
|
+
* parent-only entry if the block hasn't been moved inside the group yet).
|
|
503
|
+
*
|
|
504
|
+
* Used by drag-reparent so that `undo` restores the parent relationship
|
|
505
|
+
* atomically with the array move. The caller (`BlockManager.setBlockParent`
|
|
506
|
+
* when `YjsManager.isInMoveGroup` is true) is responsible for writing the
|
|
507
|
+
* parentId/contentIds to Yjs through `transactWithoutCapture` so the
|
|
508
|
+
* Y.UndoManager does not also record the change.
|
|
509
|
+
* @param blockId - id of the reparented block
|
|
510
|
+
* @param fromParentId - parent id before the reparent (null for root)
|
|
511
|
+
* @param toParentId - parent id after the reparent (null for root)
|
|
512
|
+
*/
|
|
513
|
+
public recordParentChangeForPendingMove(
|
|
514
|
+
blockId: string,
|
|
515
|
+
fromParentId: string | null,
|
|
516
|
+
toParentId: string | null
|
|
517
|
+
): void {
|
|
518
|
+
if (this.pendingMoveGroup === null) {
|
|
519
|
+
// Not inside a move group — nothing to attach to. Drop the hint.
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const existing = this.pendingMoveGroup.find(
|
|
524
|
+
entry => entry.blockId === blockId
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
if (existing !== undefined) {
|
|
528
|
+
// Preserve the earliest known `fromParentId` (first write wins — that's
|
|
529
|
+
// the parent BEFORE the drag started). Always update `toParentId` to
|
|
530
|
+
// the most recent write.
|
|
531
|
+
if (existing.fromParentId === undefined) {
|
|
532
|
+
existing.fromParentId = fromParentId;
|
|
533
|
+
}
|
|
534
|
+
existing.toParentId = toParentId;
|
|
535
|
+
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// No matching move entry yet (e.g. a same-index reparent within a toggle
|
|
540
|
+
// body, where DragController calls setBlockParent without a prior move).
|
|
541
|
+
// Push a parent-only entry with identical from/to indices so the undo
|
|
542
|
+
// walker still has something to unwind.
|
|
543
|
+
this.pendingMoveGroup.push({
|
|
544
|
+
blockId,
|
|
545
|
+
fromIndex: -1,
|
|
546
|
+
toIndex: -1,
|
|
547
|
+
fromParentId,
|
|
548
|
+
toParentId,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
441
552
|
/**
|
|
442
553
|
* Capture the current caret position as a snapshot.
|
|
443
554
|
* @returns CaretSnapshot or null if no block is focused
|