@jackuait/blok 0.10.7 → 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-oWXfRfnM.mjs → blok-DbRn9adY.mjs} +2681 -2238
- package/dist/chunks/{constants-BQ1-lyZI.mjs → constants-C9lsSOXl.mjs} +4 -3
- package/dist/chunks/{core-C942GvJO.mjs → core-B7mxBIHA.mjs} +1 -1
- package/dist/chunks/{engine-javascript-Dd6ViPCH.mjs → engine-javascript-Bmmg8uL9.mjs} +1 -1
- package/dist/chunks/{i18next-loader-CIXsptng.mjs → i18next-loader-453gJdot.mjs} +1 -1
- package/dist/chunks/{tools-MuBQQyZ-.mjs → tools-D0W3_dlA.mjs} +504 -499
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +3 -3
- 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 +20 -5
- 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 +184 -44
- package/src/components/modules/paste/index.ts +20 -0
- package/src/components/modules/renderer.ts +9 -1
- 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 +247 -35
- package/src/components/utils/hierarchy-invariant.ts +137 -0
- package/src/markdown/markdown-handler.ts +9 -2
- 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/table/table-operations.ts +9 -4
- 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
- /package/dist/chunks/{lightweight-i18n-DTYoSr_o.mjs → lightweight-i18n-DSjG0iTr.mjs} +0 -0
- /package/dist/chunks/{objectWithoutProperties-D0XxKB4n.mjs → objectWithoutProperties-Dci1-l7D.mjs} +0 -0
|
@@ -441,20 +441,24 @@ export class Toolbar extends Module<ToolbarNodes> {
|
|
|
441
441
|
|
|
442
442
|
/**
|
|
443
443
|
* Adjust toolbar button visibility based on context:
|
|
444
|
-
* - Table cell focus: settings toggler hidden (drag/settings don't apply to cells)
|
|
445
444
|
* - Callout first child: both plus button and settings toggler hidden
|
|
446
445
|
* to prevent overlap with the callout's emoji icon
|
|
446
|
+
*
|
|
447
|
+
* The callout block itself still shows BOTH buttons — the actions container
|
|
448
|
+
* sits outside the block (positioned via right:100% on the left gutter) and
|
|
449
|
+
* does not overlap the emoji which is inside the block at pl-8.
|
|
450
|
+
*
|
|
451
|
+
* Note: when the toolbar resolves to a parent table block from a focused
|
|
452
|
+
* cell, the settings toggler must STAY visible — it is wired via
|
|
453
|
+
* setupDraggable() to drag the parent table, so hiding it would leave the
|
|
454
|
+
* whole table undraggable while the user edits cell text.
|
|
447
455
|
*/
|
|
448
|
-
const focusIsInsideCell = this.isFocusInsideTableCell();
|
|
449
456
|
const isCalloutFirstChild = this.isFirstChildOfCallout(targetBlock);
|
|
450
|
-
const isCalloutBlock = targetBlock.name === 'callout';
|
|
451
457
|
|
|
452
|
-
|
|
453
|
-
// overlap with the callout emoji icon in the left padding area.
|
|
454
|
-
plusButton.style.display = (isCalloutFirstChild || isCalloutBlock) ? 'none' : '';
|
|
458
|
+
plusButton.style.display = isCalloutFirstChild ? 'none' : '';
|
|
455
459
|
|
|
456
460
|
if (settingsToggler) {
|
|
457
|
-
settingsToggler.style.display =
|
|
461
|
+
settingsToggler.style.display = isCalloutFirstChild ? 'none' : '';
|
|
458
462
|
}
|
|
459
463
|
|
|
460
464
|
/**
|
|
@@ -466,7 +470,8 @@ export class Toolbar extends Module<ToolbarNodes> {
|
|
|
466
470
|
* Skip when the target is the callout itself or its first child — their toolbar
|
|
467
471
|
* buttons render outside the callout's visual background area.
|
|
468
472
|
*/
|
|
469
|
-
const
|
|
473
|
+
const isCalloutBlock = targetBlock.name === 'callout';
|
|
474
|
+
const calloutBg = isCalloutFirstChild || isCalloutBlock
|
|
470
475
|
? null
|
|
471
476
|
: this.getCalloutBackgroundColor(targetBlock);
|
|
472
477
|
|
|
@@ -875,70 +880,48 @@ export class Toolbar extends Module<ToolbarNodes> {
|
|
|
875
880
|
return parent;
|
|
876
881
|
}
|
|
877
882
|
|
|
878
|
-
private isFocusInsideTableCell(): boolean {
|
|
879
|
-
const active = document.activeElement;
|
|
880
|
-
|
|
881
|
-
if (!active) {
|
|
882
|
-
return false;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
return active.closest('[data-blok-table-cell-blocks]') !== null;
|
|
886
|
-
}
|
|
887
|
-
|
|
888
883
|
/**
|
|
889
|
-
* Updates toolbar button visibility based on
|
|
890
|
-
*
|
|
891
|
-
*
|
|
884
|
+
* Updates toolbar button visibility based on the current hovered block.
|
|
885
|
+
* Called from the focusin listener so callout first-child state is
|
|
886
|
+
* refreshed immediately when focus moves, without waiting for the next
|
|
887
|
+
* hover/moveAndOpen cycle.
|
|
892
888
|
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
889
|
+
* INVARIANT: the ONLY reason this method may hide the settings toggler
|
|
890
|
+
* (drag handle) is `isCalloutFirstChild` — a structural property of the
|
|
891
|
+
* block, NOT where `document.activeElement` currently is. Hiding the drag
|
|
892
|
+
* handle based on focus position inside nested content (table cells,
|
|
893
|
+
* code editors, database titles, etc.) will break dragging of the
|
|
894
|
+
* containing block while the user edits its content. Do NOT add focus-
|
|
895
|
+
* based or DOM-attribute-based hide branches here. See the arch guard
|
|
896
|
+
* test in test/unit/components/modules/toolbar/index.test.ts.
|
|
895
897
|
*/
|
|
896
|
-
private
|
|
898
|
+
private updateToolbarButtonsForCalloutFirstChild(): void {
|
|
897
899
|
const { plusButton, settingsToggler } = this.nodes;
|
|
898
900
|
|
|
899
901
|
if (!plusButton) {
|
|
900
902
|
return;
|
|
901
903
|
}
|
|
902
904
|
|
|
903
|
-
const focusIsInsideCell = this.isFocusInsideTableCell();
|
|
904
905
|
const isCalloutFirstChild = this.hoveredBlock !== null && this.isFirstChildOfCallout(this.hoveredBlock);
|
|
905
|
-
const isCalloutBlock = this.hoveredBlock?.name === 'callout';
|
|
906
906
|
|
|
907
|
-
plusButton.style.display =
|
|
907
|
+
plusButton.style.display = isCalloutFirstChild ? 'none' : '';
|
|
908
908
|
|
|
909
909
|
if (settingsToggler) {
|
|
910
|
-
settingsToggler.style.display =
|
|
910
|
+
settingsToggler.style.display = isCalloutFirstChild ? 'none' : '';
|
|
911
911
|
}
|
|
912
912
|
}
|
|
913
913
|
|
|
914
914
|
/**
|
|
915
|
-
* Re-enables pointer-events on the settings toggler
|
|
916
|
-
*
|
|
915
|
+
* Re-enables pointer-events on the settings toggler after the actions
|
|
916
|
+
* container has been set to pointer-events: none for left-edge blocks.
|
|
917
917
|
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
* For all other left-edge blocks (toggle, header with arrow): simply re-enables the
|
|
924
|
-
* settings toggler so it continues to function as the drag handle.
|
|
918
|
+
* Left-edge blocks (callout, toggle, header-with-arrow) disable
|
|
919
|
+
* pointer-events on the actions container so clicks pass through to the
|
|
920
|
+
* block's own left-edge interactive element (emoji / toggle arrow). The
|
|
921
|
+
* settings toggler must remain clickable on top so it can still function
|
|
922
|
+
* as the drag handle and open the block tunes menu.
|
|
925
923
|
*/
|
|
926
|
-
private restoreSettingsTogglerForLeftEdgeBlock(
|
|
927
|
-
if (targetBlock.name === 'callout') {
|
|
928
|
-
if (this.nodes.settingsToggler) {
|
|
929
|
-
this.nodes.settingsToggler.style.pointerEvents = 'auto';
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
const calloutDragZone = targetBlock.holder.querySelector<HTMLElement>('[data-callout-drag-zone]');
|
|
933
|
-
|
|
934
|
-
if (calloutDragZone) {
|
|
935
|
-
calloutDragZone.style.pointerEvents = 'auto';
|
|
936
|
-
targetBlock.setupDraggable(calloutDragZone, this.Blok.DragManager);
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
return;
|
|
940
|
-
}
|
|
941
|
-
|
|
924
|
+
private restoreSettingsTogglerForLeftEdgeBlock(_targetBlock: Block): void {
|
|
942
925
|
if (this.nodes.settingsToggler) {
|
|
943
926
|
this.nodes.settingsToggler.style.pointerEvents = 'auto';
|
|
944
927
|
}
|
|
@@ -1264,15 +1247,13 @@ export class Toolbar extends Module<ToolbarNodes> {
|
|
|
1264
1247
|
}
|
|
1265
1248
|
|
|
1266
1249
|
/**
|
|
1267
|
-
* Listen for focus changes inside the editor
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
* This runs on every focusin event (not throttled) to ensure buttons
|
|
1272
|
-
* update immediately on click — no 300ms delay.
|
|
1250
|
+
* Listen for focus changes inside the editor so callout first-child
|
|
1251
|
+
* button state refreshes immediately on click/tab — no 300ms delay.
|
|
1252
|
+
* Drag handle visibility must NOT depend on focus position inside
|
|
1253
|
+
* nested content (see updateToolbarButtonsForCalloutFirstChild doc).
|
|
1273
1254
|
*/
|
|
1274
1255
|
this.readOnlyMutableListeners.on(this.Blok.UI.nodes.wrapper, 'focusin', () => {
|
|
1275
|
-
this.
|
|
1256
|
+
this.updateToolbarButtonsForCalloutFirstChild();
|
|
1276
1257
|
});
|
|
1277
1258
|
|
|
1278
1259
|
/**
|
|
@@ -492,6 +492,26 @@ export class KeyboardController extends Controller {
|
|
|
492
492
|
return;
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Layer 18: block undo/redo while a drag is in progress.
|
|
497
|
+
*
|
|
498
|
+
* Regression: "wrong block dropped" family. `moveUndoStack` entries capture
|
|
499
|
+
* `fromIndex`/`toIndex` at record time. Replaying them synchronously while
|
|
500
|
+
* DragController still holds a live source/target reference mutates the
|
|
501
|
+
* flat blocks array under the drag's feet — subsequent `handleDrop` then
|
|
502
|
+
* operates on stale indices and silently drops an unrelated block.
|
|
503
|
+
*
|
|
504
|
+
* The Escape handler already guards on `DragManager.isDragging` (see
|
|
505
|
+
* handleEscape above). Mirror that here: swallow the keystroke so the
|
|
506
|
+
* drag completes cleanly, then the user can undo.
|
|
507
|
+
*/
|
|
508
|
+
if (this.Blok.DragManager.isDragging) {
|
|
509
|
+
event.preventDefault();
|
|
510
|
+
event.stopPropagation();
|
|
511
|
+
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
495
515
|
// Prevent double-firing within 50ms
|
|
496
516
|
const now = Date.now();
|
|
497
517
|
|
|
@@ -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
|
/**
|