@jackuait/blok 0.10.10 → 0.10.12
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-5Ez9qE7T.mjs → blok-Cb7w54t6.mjs} +1519 -1448
- package/dist/chunks/{constants-BYv7qYbw.mjs → constants-C0aZXxoO.mjs} +1 -1
- package/dist/chunks/{tools-CqBnZUYU.mjs → tools-vS7102lG.mjs} +102 -35
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +2 -2
- package/dist/tools.mjs +2 -2
- package/package.json +1 -1
- package/src/components/modules/api/blocks.ts +9 -4
- package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +29 -6
- package/src/components/modules/blockManager/blockManager.ts +12 -2
- package/src/components/modules/blockManager/operations.ts +189 -9
- package/src/components/modules/caret.ts +57 -0
- package/src/components/modules/drag/operations/DragOperations.ts +10 -3
- package/src/components/utils/data-model-transform.ts +93 -42
- package/src/styles/main.css +5 -0
- package/src/tools/callout/index.ts +39 -4
- package/src/tools/list/data-normalizer.ts +12 -3
- package/src/tools/table/index.ts +59 -7
- package/src/tools/table/table-cell-blocks.ts +90 -3
- package/types/api/blocks.d.ts +2 -1
|
@@ -67,10 +67,26 @@ export class CalloutTool implements BlockTool {
|
|
|
67
67
|
private _emojiPicker: EmojiPicker | null = null;
|
|
68
68
|
private _colorPicker: ColorPickerHandle | null = null;
|
|
69
69
|
private blockId?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Text captured from a source block during conversion (paragraph -> callout).
|
|
72
|
+
* Callout stores its rich content inside child blocks rather than in `data`,
|
|
73
|
+
* so the first-time `rendered()` hook seeds a child paragraph with this
|
|
74
|
+
* text — preserving the original content across the conversion.
|
|
75
|
+
*/
|
|
76
|
+
private _pendingChildText: string | null = null;
|
|
70
77
|
|
|
71
78
|
constructor({ data, api, readOnly, block }: BlockToolConstructorOptions<CalloutData, CalloutConfig>) {
|
|
72
79
|
this.api = api;
|
|
73
80
|
this.readOnly = readOnly;
|
|
81
|
+
|
|
82
|
+
const importedText = typeof (data as Record<string, unknown>).__importedText === 'string'
|
|
83
|
+
? (data as Record<string, unknown>).__importedText as string
|
|
84
|
+
: null;
|
|
85
|
+
|
|
86
|
+
if (importedText !== null && importedText.length > 0) {
|
|
87
|
+
this._pendingChildText = importedText;
|
|
88
|
+
}
|
|
89
|
+
|
|
74
90
|
this._data = this.normalizeData(data);
|
|
75
91
|
|
|
76
92
|
if (block) {
|
|
@@ -156,13 +172,23 @@ export class CalloutTool implements BlockTool {
|
|
|
156
172
|
const blockIndex = this.api.blocks.getBlockIndex(this.blockId);
|
|
157
173
|
|
|
158
174
|
if (blockIndex !== undefined) {
|
|
159
|
-
|
|
175
|
+
// If conversion handed us source text to preserve, seed the first
|
|
176
|
+
// child paragraph with it (single-shot — cleared immediately).
|
|
177
|
+
const seedText = this._pendingChildText;
|
|
178
|
+
|
|
179
|
+
this._pendingChildText = null;
|
|
180
|
+
|
|
181
|
+
const childData = seedText !== null && seedText.length > 0
|
|
182
|
+
? { text: seedText }
|
|
183
|
+
: undefined;
|
|
184
|
+
|
|
185
|
+
const newBlock = this.api.blocks.insertInsideParent(this.blockId, blockIndex + 1, childData);
|
|
160
186
|
|
|
161
187
|
// Manually append the new child's holder — insertInsideParent places it in the
|
|
162
188
|
// flat block list but doesn't know about our childContainer DOM.
|
|
163
189
|
this._dom.childContainer.appendChild(newBlock.holder);
|
|
164
190
|
|
|
165
|
-
this.api.caret.setToBlock(newBlock.id, 'start');
|
|
191
|
+
this.api.caret.setToBlock(newBlock.id, seedText !== null ? 'end' : 'start');
|
|
166
192
|
}
|
|
167
193
|
}
|
|
168
194
|
}
|
|
@@ -392,11 +418,20 @@ export class CalloutTool implements BlockTool {
|
|
|
392
418
|
|
|
393
419
|
public static get conversionConfig(): ConversionConfig<CalloutData> {
|
|
394
420
|
return {
|
|
395
|
-
|
|
421
|
+
/**
|
|
422
|
+
* Callout stores its text inside child blocks, not in its own `data`.
|
|
423
|
+
* On import we capture the source block's text through a transient
|
|
424
|
+
* `__importedText` field that the callout constructor reads and the
|
|
425
|
+
* `rendered()` hook uses to seed the first child paragraph with the
|
|
426
|
+
* original content — preserving the text across paragraph -> callout
|
|
427
|
+
* conversion.
|
|
428
|
+
*/
|
|
429
|
+
import: (stringToImport: string): CalloutData => ({
|
|
396
430
|
emoji: DEFAULT_EMOJI,
|
|
397
431
|
textColor: null,
|
|
398
432
|
backgroundColor: null,
|
|
399
|
-
|
|
433
|
+
__importedText: stringToImport,
|
|
434
|
+
} as CalloutData),
|
|
400
435
|
};
|
|
401
436
|
}
|
|
402
437
|
|
|
@@ -10,7 +10,7 @@ import type { ListItemConfig, ListItemData, ListItemStyle } from './types';
|
|
|
10
10
|
* Type for legacy list item format (used for type guard)
|
|
11
11
|
*/
|
|
12
12
|
type LegacyListItemFormat = {
|
|
13
|
-
items: Array<{ content
|
|
13
|
+
items: Array<string | { content?: string; text?: string; checked?: boolean | string }>;
|
|
14
14
|
style?: ListItemStyle;
|
|
15
15
|
start?: number;
|
|
16
16
|
};
|
|
@@ -117,8 +117,17 @@ export const normalizeListItemData = (
|
|
|
117
117
|
// This provides backward compatibility when legacy data is passed directly to the tool
|
|
118
118
|
if (isLegacyFormat(data)) {
|
|
119
119
|
const firstItem = data.items[0];
|
|
120
|
-
|
|
121
|
-
const
|
|
120
|
+
// handle string items and old {text,checked} shape
|
|
121
|
+
const extractLegacy = (item: typeof firstItem): { text: string; checked: boolean | string | undefined } => {
|
|
122
|
+
if (typeof item === 'string') {
|
|
123
|
+
return { text: item, checked: false };
|
|
124
|
+
}
|
|
125
|
+
if (item !== null && typeof item === 'object') {
|
|
126
|
+
return { text: item.content ?? item.text ?? '', checked: item.checked ?? false };
|
|
127
|
+
}
|
|
128
|
+
return { text: '', checked: false };
|
|
129
|
+
};
|
|
130
|
+
const { text, checked } = extractLegacy(firstItem);
|
|
122
131
|
|
|
123
132
|
return {
|
|
124
133
|
text,
|
package/src/tools/table/index.ts
CHANGED
|
@@ -675,9 +675,10 @@ export class Table implements BlockTool {
|
|
|
675
675
|
// (blocks deleted but matrix not updated); persisting them causes DOM
|
|
676
676
|
// node stealing and data loss on subsequent renders.
|
|
677
677
|
const tableId = this.blockId ?? '';
|
|
678
|
+
const gridEl = this.gridElement;
|
|
678
679
|
|
|
679
|
-
data.content = data.content.map(row =>
|
|
680
|
-
row.map(cell => {
|
|
680
|
+
data.content = data.content.map((row, rowIndex) =>
|
|
681
|
+
row.map((cell, colIndex) => {
|
|
681
682
|
if (!isCellWithBlocks(cell)) {
|
|
682
683
|
return cell;
|
|
683
684
|
}
|
|
@@ -688,6 +689,35 @@ export class Table implements BlockTool {
|
|
|
688
689
|
return block != null && (block.parentId ?? '') === tableId;
|
|
689
690
|
});
|
|
690
691
|
|
|
692
|
+
// Recover from a stale model snapshot: if the model says this cell is
|
|
693
|
+
// empty but the live DOM still has child blocks parented to the table
|
|
694
|
+
// here, harvest their ids from the DOM. Without this guard, a
|
|
695
|
+
// mid-render snapshot can persist empty cells to Yjs and become an
|
|
696
|
+
// attractor state that later undo presses revert to — see
|
|
697
|
+
// table-undo-redo-orphans regression.
|
|
698
|
+
if (filtered.length === 0 && gridEl) {
|
|
699
|
+
const cellEl = gridEl.querySelector<HTMLElement>(
|
|
700
|
+
`[${CELL_ROW_ATTR}="${rowIndex}"][${CELL_COL_ATTR}="${colIndex}"]`
|
|
701
|
+
);
|
|
702
|
+
const container = cellEl?.querySelector<HTMLElement>(`[${CELL_BLOCKS_ATTR}]`);
|
|
703
|
+
const harvested = container
|
|
704
|
+
? Array.from(container.querySelectorAll<HTMLElement>('[data-blok-id]'))
|
|
705
|
+
.map(el => el.getAttribute('data-blok-id') ?? '')
|
|
706
|
+
.filter(id => {
|
|
707
|
+
if (!id) {
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
const block = this.api.blocks.getById?.(id);
|
|
711
|
+
|
|
712
|
+
return block != null && (block.parentId ?? '') === tableId;
|
|
713
|
+
})
|
|
714
|
+
: [];
|
|
715
|
+
|
|
716
|
+
if (harvested.length > 0) {
|
|
717
|
+
return { ...cell, blocks: harvested };
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
691
721
|
return { ...cell, blocks: filtered };
|
|
692
722
|
})
|
|
693
723
|
);
|
|
@@ -772,6 +802,8 @@ export class Table implements BlockTool {
|
|
|
772
802
|
return;
|
|
773
803
|
}
|
|
774
804
|
|
|
805
|
+
const isSyncReplay = this.api.blocks.isSyncingFromYjs;
|
|
806
|
+
|
|
775
807
|
this.runStructuralOp(() => {
|
|
776
808
|
const setDataContent = this.cellBlocks?.initializeCells(this.initialContent ?? []) ?? this.initialContent ?? [];
|
|
777
809
|
|
|
@@ -782,9 +814,14 @@ export class Table implements BlockTool {
|
|
|
782
814
|
return;
|
|
783
815
|
}
|
|
784
816
|
|
|
785
|
-
// When
|
|
786
|
-
// but
|
|
787
|
-
// with empty
|
|
817
|
+
// When an undo replay reverts content to empty, the DOM grid has its
|
|
818
|
+
// default dimensions but the model has zero rows. Reflect the grid
|
|
819
|
+
// shape in the model with empty cells so subsequent operations have
|
|
820
|
+
// valid bounds. Do NOT call populateNewCells here — fabricating new
|
|
821
|
+
// paragraph blocks during a Yjs replay creates orphans that survive
|
|
822
|
+
// the next undo cycle (regression: table-undo-redo-orphans).
|
|
823
|
+
// If Yjs actually contains child blocks for those cells they will
|
|
824
|
+
// arrive via separate block-add events.
|
|
788
825
|
if (this.api.blocks.isSyncingFromYjs && setDataContent.length === 0 && gridEl) {
|
|
789
826
|
const emptyGridContent = Array.from(gridEl.querySelectorAll(`[${ROW_ATTR}]`), (row) => {
|
|
790
827
|
const cellCount = row.querySelectorAll(`[${CELL_ATTR}]`).length;
|
|
@@ -796,8 +833,6 @@ export class Table implements BlockTool {
|
|
|
796
833
|
...this.model.snapshot(),
|
|
797
834
|
content: emptyGridContent,
|
|
798
835
|
});
|
|
799
|
-
|
|
800
|
-
populateNewCells(gridEl, this.cellBlocks);
|
|
801
836
|
} else {
|
|
802
837
|
this.model.replaceAll({
|
|
803
838
|
...this.model.snapshot(),
|
|
@@ -825,6 +860,23 @@ export class Table implements BlockTool {
|
|
|
825
860
|
const snapSet = this.model.snapshot();
|
|
826
861
|
applyCellColors(gridEl, snapSet.content);
|
|
827
862
|
applyCellPlacements(gridEl, snapSet.content);
|
|
863
|
+
|
|
864
|
+
if (isSyncReplay) {
|
|
865
|
+
// Catch blocks already restored by sibling Yjs ops in this same replay
|
|
866
|
+
// batch — they may be sitting at the top level waiting to be reattached
|
|
867
|
+
// to their original cell. Without this, multi-cell undo restoration
|
|
868
|
+
// leaves cell content as orphan top-level blocks.
|
|
869
|
+
this.cellBlocks?.reclaimReferencedBlocks();
|
|
870
|
+
// Yjs sometimes restores sibling blocks AFTER this sync transaction
|
|
871
|
+
// commits. Schedule a second pass on the next microtask so blocks that
|
|
872
|
+
// arrive late are still attached to their cells.
|
|
873
|
+
void Promise.resolve().then(() => {
|
|
874
|
+
if (currentGeneration !== this.setDataGeneration) {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
this.cellBlocks?.reclaimReferencedBlocks();
|
|
878
|
+
});
|
|
879
|
+
}
|
|
828
880
|
}
|
|
829
881
|
|
|
830
882
|
public onPaste(event: HTMLPasteEvent): void {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { API } from '../../../types';
|
|
2
2
|
import { DATA_ATTR } from '../../components/constants/data-attributes';
|
|
3
3
|
|
|
4
|
-
import { CELL_ATTR, ROW_ATTR, CELL_COL_ATTR } from './table-core';
|
|
4
|
+
import { CELL_ATTR, ROW_ATTR, CELL_ROW_ATTR, CELL_COL_ATTR } from './table-core';
|
|
5
5
|
import type { TableModel } from './table-model';
|
|
6
6
|
import type { LegacyCellContent, CellContent } from './types';
|
|
7
7
|
import { isCellWithBlocks } from './types';
|
|
@@ -353,7 +353,9 @@ export class TableCellBlocks {
|
|
|
353
353
|
* - Cells that already have block references get those blocks mounted.
|
|
354
354
|
* - If referenced blocks are missing from BlockManager, a fallback paragraph is created.
|
|
355
355
|
*/
|
|
356
|
-
public initializeCells(
|
|
356
|
+
public initializeCells(
|
|
357
|
+
content: LegacyCellContent[][]
|
|
358
|
+
): CellContent[][] {
|
|
357
359
|
const rowElements = this.gridElement.querySelectorAll(`[${ROW_ATTR}]`);
|
|
358
360
|
const normalizedContent: CellContent[][] = [];
|
|
359
361
|
|
|
@@ -437,6 +439,63 @@ export class TableCellBlocks {
|
|
|
437
439
|
return normalizedContent;
|
|
438
440
|
}
|
|
439
441
|
|
|
442
|
+
/**
|
|
443
|
+
* After a setData/render rebuild, reclaim any blocks the model references
|
|
444
|
+
* whose holders are not yet mounted in their model cell. This catches blocks
|
|
445
|
+
* that were restored via separate Yjs ops in a different transaction order
|
|
446
|
+
* — without it the restored block would float at the top level as an orphan
|
|
447
|
+
* (regression: table-undo-redo-orphans, multi-cell undo restoration).
|
|
448
|
+
*/
|
|
449
|
+
public reclaimReferencedBlocks(): void {
|
|
450
|
+
const snapshot = this.model.snapshot();
|
|
451
|
+
|
|
452
|
+
snapshot.content.forEach((row, rowIndex) => {
|
|
453
|
+
row.forEach((cellContent, colIndex) => {
|
|
454
|
+
if (!isCellWithBlocks(cellContent) || cellContent.blocks.length === 0) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const cell = this.gridElement.querySelector<HTMLElement>(
|
|
459
|
+
`[${CELL_ROW_ATTR}="${rowIndex}"][${CELL_COL_ATTR}="${colIndex}"]`
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
if (!cell) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const container = cell.querySelector<HTMLElement>(`[${CELL_BLOCKS_ATTR}]`);
|
|
467
|
+
|
|
468
|
+
if (!container) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const blockId of cellContent.blocks) {
|
|
473
|
+
const getIndex = this.api.blocks.getBlockIndex;
|
|
474
|
+
const getByIndex = this.api.blocks.getBlockByIndex;
|
|
475
|
+
|
|
476
|
+
if (typeof getIndex !== 'function' || typeof getByIndex !== 'function') {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const index = getIndex(blockId);
|
|
481
|
+
|
|
482
|
+
if (index === undefined) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const block = getByIndex(index);
|
|
486
|
+
|
|
487
|
+
if (!block) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (container.contains(block.holder)) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
this.claimBlockForCell(cell, blockId);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
440
499
|
/**
|
|
441
500
|
* Remove placeholder attributes from contenteditable elements inside a cell container.
|
|
442
501
|
* Blocks in table cells should feel like plain table fields, not standalone paragraphs.
|
|
@@ -635,7 +694,14 @@ export class TableCellBlocks {
|
|
|
635
694
|
* When a block is removed, ensure no cell is left empty.
|
|
636
695
|
*/
|
|
637
696
|
private handleBlockMutation = (data: unknown): void => {
|
|
638
|
-
|
|
697
|
+
// While a structural op (setData / paste / row-col change) is rebuilding
|
|
698
|
+
// the table, defer events so they don't operate on stale DOM. EXCEPT for
|
|
699
|
+
// Yjs replay: an undo/redo that restores a previously-owned cell block
|
|
700
|
+
// fires block-added DURING the table's own setData, and discarding it
|
|
701
|
+
// would leave the restored block as a top-level orphan
|
|
702
|
+
// (regression: table-undo-redo-orphans). Process those immediately —
|
|
703
|
+
// recordedCellPos lookup below will route the block back to its cell.
|
|
704
|
+
if (this.isStructuralOpActive() && !this.api.blocks.isSyncingFromYjs) {
|
|
639
705
|
this.deferredEvents.push(data);
|
|
640
706
|
|
|
641
707
|
return;
|
|
@@ -674,6 +740,27 @@ export class TableCellBlocks {
|
|
|
674
740
|
return;
|
|
675
741
|
}
|
|
676
742
|
|
|
743
|
+
// Yjs undo replay: a block this table previously owned is being restored.
|
|
744
|
+
// The model's contentGrid still references its id from a prior render but
|
|
745
|
+
// the DOM is empty (we deliberately did not fabricate a replacement, see
|
|
746
|
+
// table-undo-redo-orphans regression). Reattach it to the recorded cell
|
|
747
|
+
// before falling through to adjacency-based heuristics, otherwise the
|
|
748
|
+
// restored block lands as a top-level orphan.
|
|
749
|
+
const recordedCellPos = this.model.findCellForBlock(detail.target.id);
|
|
750
|
+
|
|
751
|
+
if (recordedCellPos) {
|
|
752
|
+
const cellEl = this.gridElement.querySelector<HTMLElement>(
|
|
753
|
+
`[${CELL_ROW_ATTR}="${recordedCellPos.row}"][${CELL_COL_ATTR}="${recordedCellPos.col}"]`
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
if (cellEl) {
|
|
757
|
+
this.claimBlockForCell(cellEl, detail.target.id);
|
|
758
|
+
this.cellsPendingCheck.delete(cellEl);
|
|
759
|
+
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
677
764
|
const blockIndex = detail.index;
|
|
678
765
|
|
|
679
766
|
if (blockIndex === undefined) {
|
package/types/api/blocks.d.ts
CHANGED
|
@@ -189,9 +189,10 @@ export interface Blocks {
|
|
|
189
189
|
*
|
|
190
190
|
* @param parentId - id of the parent block
|
|
191
191
|
* @param insertIndex - flat block index where the new block should appear
|
|
192
|
+
* @param childData - optional data override for the child block (default: empty paragraph)
|
|
192
193
|
* @returns BlockAPI for the newly created child block
|
|
193
194
|
*/
|
|
194
|
-
insertInsideParent(parentId: string, insertIndex: number): BlockAPI;
|
|
195
|
+
insertInsideParent(parentId: string, insertIndex: number, childData?: BlockToolData): BlockAPI;
|
|
195
196
|
|
|
196
197
|
/**
|
|
197
198
|
* Execute a function within a transaction.
|