@jackuait/blok 0.10.9 → 0.10.11

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.
@@ -130,7 +130,7 @@ var a = {
130
130
  RIGHT: 2,
131
131
  BACKWARD: 3,
132
132
  FORWARD: 4
133
- }, l = () => "0.10.9", u = /* @__PURE__ */ function(e) {
133
+ }, l = () => "0.10.11", u = /* @__PURE__ */ function(e) {
134
134
  return e.VERBOSE = "VERBOSE", e.INFO = "INFO", e.WARN = "WARN", e.ERROR = "ERROR", e;
135
135
  }({}), d = (e, t, n = "log", r, i = "color: inherit") => {
136
136
  let a = typeof console > "u" ? void 0 : console;
@@ -1,4 +1,4 @@
1
- import { $ as e, $t as t, A as n, At as r, B as i, Bt as a, Ct as o, D as s, Et as c, F as l, Ft as u, G as d, Gt as f, H as p, Ht as m, I as h, It as g, J as _, Jt as v, K as y, Kt as b, L as ee, Lt as x, M as te, Mt as S, Nn as ne, Nt as re, O as ie, Ot as C, P as ae, Pt as oe, Q as se, Qt as ce, R as le, Rn as w, Rt as ue, St as de, Tt as fe, U as pe, Ut as me, V as he, W as ge, Wt as _e, X as ve, Xt as ye, Y as be, Yt as xe, Z as Se, Zt as Ce, _n as we, _t as Te, a as Ee, at as De, bt as Oe, c as ke, cn as T, ct as Ae, d as je, dr as E, dt as Me, en as Ne, et as Pe, f as Fe, ft as Ie, g as Le, gt as Re, h as ze, ht as Be, i as Ve, ir as He, it as Ue, j as We, jt as Ge, k as Ke, kt as qe, l as Je, ln as Ye, lt as Xe, mn as Ze, mt as Qe, n as $e, nt as et, o as tt, ot as nt, p as rt, pn as D, pt as it, q as at, qt as ot, r as st, rn as ct, rt as lt, s as ut, sn as dt, st as ft, t as pt, tt as mt, u as O, un as ht, ur as k, ut as gt, v as _t, vn as vt, vt as yt, wt as bt, xt, z as St, zt as Ct } from "./constants-C9lsSOXl.mjs";
1
+ import { $ as e, $t as t, A as n, At as r, B as i, Bt as a, Ct as o, D as s, Et as c, F as l, Ft as u, G as d, Gt as f, H as p, Ht as m, I as h, It as g, J as _, Jt as v, K as y, Kt as b, L as ee, Lt as x, M as te, Mt as S, Nn as ne, Nt as re, O as ie, Ot as C, P as ae, Pt as oe, Q as se, Qt as ce, R as le, Rn as w, Rt as ue, St as de, Tt as fe, U as pe, Ut as me, V as he, W as ge, Wt as _e, X as ve, Xt as ye, Y as be, Yt as xe, Z as Se, Zt as Ce, _n as we, _t as Te, a as Ee, at as De, bt as Oe, c as ke, cn as T, ct as Ae, d as je, dr as E, dt as Me, en as Ne, et as Pe, f as Fe, ft as Ie, g as Le, gt as Re, h as ze, ht as Be, i as Ve, ir as He, it as Ue, j as We, jt as Ge, k as Ke, kt as qe, l as Je, ln as Ye, lt as Xe, mn as Ze, mt as Qe, n as $e, nt as et, o as tt, ot as nt, p as rt, pn as D, pt as it, q as at, qt as ot, r as st, rn as ct, rt as lt, s as ut, sn as dt, st as ft, t as pt, tt as mt, u as O, un as ht, ur as k, ut as gt, v as _t, vn as vt, vt as yt, wt as bt, xt, z as St, zt as Ct } from "./constants-lxerM-Xa.mjs";
2
2
  import { t as A } from "./objectSpread2-CWwMYL_U.mjs";
3
3
  import { n as j } from "./tw-CqxBf-1Y.mjs";
4
4
  //#region src/components/utils/html.ts
@@ -859,11 +859,27 @@ var Qt = "outline-hidden py-[7px] mt-[2px] mb-px", $t = "outline-hidden pl-0.5 l
859
859
  depth: 0
860
860
  };
861
861
  if (En(e)) {
862
- let t = e.items[0], r = (t == null ? void 0 : t.content) || "", i = (t == null ? void 0 : t.checked) || !1;
862
+ let { text: t, checked: r } = ((e) => {
863
+ if (typeof e == "string") return {
864
+ text: e,
865
+ checked: !1
866
+ };
867
+ if (typeof e == "object" && e) {
868
+ var t, n, r;
869
+ return {
870
+ text: (t = (n = e.content) == null ? e.text : n) == null ? "" : t,
871
+ checked: (r = e.checked) == null ? !1 : r
872
+ };
873
+ }
874
+ return {
875
+ text: "",
876
+ checked: !1
877
+ };
878
+ })(e.items[0]);
863
879
  return A({
864
- text: r,
880
+ text: t,
865
881
  style: e.style || n,
866
- checked: !!i,
882
+ checked: !!r,
867
883
  depth: 0
868
884
  }, e.start !== void 0 && e.start !== 1 ? { start: e.start } : {});
869
885
  }
@@ -2283,14 +2299,14 @@ var Qt = "outline-hidden py-[7px] mt-[2px] mb-px", $t = "outline-hidden pl-0.5 l
2283
2299
  let t = this.api.blocks.getBlockIndex(i);
2284
2300
  if (t === void 0) continue;
2285
2301
  let a = this.api.blocks.getBlockByIndex(t);
2286
- if (a) {
2287
- if (a.holder.closest(`[${k.nestedBlocks}]`)) {
2288
- let t = this.api.blocks.insert(a.name, a.preservedData, {}, this.api.blocks.getBlocksCount(), !1);
2289
- e.appendChild(t.holder), this.api.blocks.setBlockParent(t.id, this.tableBlockId), n.push(t.id), r.set(i, t.id);
2290
- continue;
2291
- }
2292
- e.appendChild(a.holder), this.api.blocks.setBlockParent(i, this.tableBlockId), n.push(i);
2302
+ if (!a) continue;
2303
+ let o = a.parentId != null && a.parentId !== "" && a.parentId !== this.tableBlockId;
2304
+ if (a.holder.closest(`[${k.nestedBlocks}]`) || o) {
2305
+ let t = this.api.blocks.insert(a.name, a.preservedData, {}, this.api.blocks.getBlocksCount(), !1);
2306
+ e.appendChild(t.holder), this.api.blocks.setBlockParent(t.id, this.tableBlockId), n.push(t.id), r.set(i, t.id);
2307
+ continue;
2293
2308
  }
2309
+ e.appendChild(a.holder), this.api.blocks.setBlockParent(i, this.tableBlockId), n.push(i);
2294
2310
  }
2295
2311
  return {
2296
2312
  mountedIds: n,
@@ -2304,9 +2320,11 @@ var Qt = "outline-hidden py-[7px] mt-[2px] mb-px", $t = "outline-hidden pl-0.5 l
2304
2320
  let a = this.api.blocks.getBlockIndex(t);
2305
2321
  if (a === void 0) return;
2306
2322
  let o = this.api.blocks.getBlockByIndex(a);
2307
- if (!o || o.holder.contains(i) || o.holder.closest(`[${k.nestedBlocks}]`)) return;
2308
- let s = this.api.blocks.getBlocksCount(), c = (n = (r = Array.from({ length: s - a - 1 }, (e, t) => this.api.blocks.getBlockByIndex(a + 1 + t)).find((e) => (e == null ? void 0 : e.holder.parentElement) === i)) == null ? void 0 : r.holder) == null ? null : n;
2309
- i.insertBefore(o.holder, c), this.api.blocks.setBlockParent(t, this.tableBlockId), this.stripPlaceholders(i);
2323
+ if (!o || o.holder.contains(i)) return;
2324
+ let s = o.parentId != null && o.parentId !== "" && o.parentId !== this.tableBlockId;
2325
+ if (o.holder.closest(`[${k.nestedBlocks}]`) || s) return;
2326
+ let c = this.api.blocks.getBlocksCount(), l = (n = (r = Array.from({ length: c - a - 1 }, (e, t) => this.api.blocks.getBlockByIndex(a + 1 + t)).find((e) => (e == null ? void 0 : e.holder.parentElement) === i)) == null ? void 0 : r.holder) == null ? null : n;
2327
+ i.insertBefore(o.holder, l), this.api.blocks.setBlockParent(t, this.tableBlockId), this.stripPlaceholders(i);
2310
2328
  }
2311
2329
  findCellForNewBlock(e) {
2312
2330
  return this.findCellForAdjacentBlock(e - 1) || this.findCellForAdjacentBlock(e + 1);
@@ -3225,6 +3243,13 @@ var Ir = (e) => {
3225
3243
  setCellBlocks(e, t, n) {
3226
3244
  if (this.isInBounds(e, t) && !this.isSpannedCell(e, t)) {
3227
3245
  for (let n of this.contentGrid[e][t].blocks) this.blockCellMap.delete(n);
3246
+ for (let r of n) {
3247
+ let n = this.blockCellMap.get(r);
3248
+ if (n !== void 0 && (n.row !== e || n.col !== t)) {
3249
+ let e = this.contentGrid[n.row][n.col];
3250
+ e.blocks = e.blocks.filter((e) => e !== r);
3251
+ }
3252
+ }
3228
3253
  this.contentGrid[e][t].blocks = [...n];
3229
3254
  for (let r of n) this.blockCellMap.set(r, {
3230
3255
  row: e,
@@ -3687,7 +3712,12 @@ var Ir = (e) => {
3687
3712
  });
3688
3713
  }
3689
3714
  normalizeContent(e) {
3690
- return !e || !Array.isArray(e) ? [] : e.map((e) => (e == null ? [] : e).map((e) => this.normalizeCell(e)));
3715
+ if (!e || !Array.isArray(e)) return [];
3716
+ let t = /* @__PURE__ */ new Set();
3717
+ return e.map((e) => (e == null ? [] : e).map((e) => {
3718
+ let n = this.normalizeCell(e);
3719
+ return n.blocks = n.blocks.filter((e) => t.has(e) ? !1 : (t.add(e), !0)), n;
3720
+ }));
3691
3721
  }
3692
3722
  normalizeCell(e) {
3693
3723
  if (R(e)) {
@@ -4912,16 +4942,17 @@ var Ir = (e) => {
4912
4942
  for (let { index: e } of t) this.api.blocks.delete(e);
4913
4943
  }
4914
4944
  save(e) {
4915
- let t = this.model.snapshot();
4916
- return t.content = t.content.map((e) => e.map((e) => {
4945
+ var t;
4946
+ let n = this.model.snapshot(), r = (t = this.blockId) == null ? "" : t;
4947
+ return n.content = n.content.map((e) => e.map((e) => {
4917
4948
  if (!R(e)) return e;
4918
4949
  let t = e.blocks.filter((e) => {
4919
- var t, n;
4920
- let r = (t = (n = this.api.blocks).getById) == null ? void 0 : t.call(n, e);
4921
- return !r || r.parentId === this.blockId;
4950
+ var t, n, i;
4951
+ let a = (t = (n = this.api.blocks).getById) == null ? void 0 : t.call(n, e);
4952
+ return a != null && ((i = a.parentId) == null ? "" : i) === r;
4922
4953
  });
4923
4954
  return A(A({}, e), {}, { blocks: t });
4924
- })), t;
4955
+ })), n;
4925
4956
  }
4926
4957
  validate(e) {
4927
4958
  return e.content.length > 0;
package/dist/full.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { n as e, t } from "./chunks/blok-DbRn9adY.mjs";
2
- import { ur as n } from "./chunks/constants-C9lsSOXl.mjs";
1
+ import { n as e, t } from "./chunks/blok-CX_pZ_qq.mjs";
2
+ import { ur as n } from "./chunks/constants-lxerM-Xa.mjs";
3
3
  import { t as r } from "./chunks/objectSpread2-CWwMYL_U.mjs";
4
- import { a as i, b as a, c as o, g as s, i as c, l, n as u, o as d, s as f, t as p, v as m, y as h } from "./chunks/tools-D0W3_dlA.mjs";
4
+ import { a as i, b as a, c as o, g as s, i as c, l, n as u, o as d, s as f, t as p, v as m, y as h } from "./chunks/tools-CMNxZqJC.mjs";
5
5
  //#region src/full.ts
6
6
  var g = {
7
7
  paragraph: {
package/dist/react.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { t as e } from "./chunks/blok-DbRn9adY.mjs";
2
- import "./chunks/constants-C9lsSOXl.mjs";
1
+ import { t as e } from "./chunks/blok-CX_pZ_qq.mjs";
2
+ import "./chunks/constants-lxerM-Xa.mjs";
3
3
  import { t } from "./chunks/objectSpread2-CWwMYL_U.mjs";
4
4
  import { t as n } from "./chunks/objectWithoutProperties-Dci1-l7D.mjs";
5
5
  import { forwardRef as r, useEffect as i, useMemo as a, useRef as o, useState as s } from "react";
package/dist/tools.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { m as e } from "./chunks/constants-C9lsSOXl.mjs";
2
- import { _ as t, a as n, b as r, c as i, d as a, f as o, g as s, h as c, i as l, l as u, m as d, n as f, o as p, p as m, r as h, s as g, t as _, u as v, v as y, y as b } from "./chunks/tools-D0W3_dlA.mjs";
1
+ import { m as e } from "./chunks/constants-lxerM-Xa.mjs";
2
+ import { _ as t, a as n, b as r, c as i, d as a, f as o, g as s, h as c, i as l, l as u, m as d, n as f, o as p, p as m, r as h, s as g, t as _, u as v, v as y, y as b } from "./chunks/tools-CMNxZqJC.mjs";
3
3
  export { u as Bold, c as Callout, v as Code, e as Convert, d as Database, m as DatabaseRow, o as Divider, b as Header, h as InlineCode, i as Italic, g as Link, y as List, p as Marker, r as Paragraph, a as Quote, l as Strikethrough, t as Table, s as Toggle, n as Underline, _ as defaultBlockTools, f as defaultInlineTools };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackuait/blok",
3
- "version": "0.10.9",
3
+ "version": "0.10.11",
4
4
  "description": "Blok — headless, highly extensible rich text editor built for developers who need to implement a block-based editing experience (similar to Notion) without building it from scratch",
5
5
  "module": "dist/blok.mjs",
6
6
  "types": "./types/index.d.ts",
@@ -380,6 +380,29 @@ const expandListToHierarchical = (
380
380
  * @param bodyBlocks - legacy body blocks to expand
381
381
  * @param parentId - id of the parent block (toggle/callout) that owns them
382
382
  */
383
+ /**
384
+ * Route one emitted block into the accumulator. Root-level blocks (no parent)
385
+ * become direct children of the legacy container; descendants keep their
386
+ * existing parent refs assigned during recursive expansion.
387
+ */
388
+ const appendEmittedBlock = (
389
+ emitted: OutputBlockData,
390
+ parentId: BlockId,
391
+ childIds: BlockId[],
392
+ childBlocks: OutputBlockData[]
393
+ ): void => {
394
+ if (emitted.parent !== undefined) {
395
+ childBlocks.push(emitted);
396
+
397
+ return;
398
+ }
399
+
400
+ const childId = emitted.id ?? generateBlockId();
401
+
402
+ childIds.push(childId);
403
+ childBlocks.push({ ...emitted, id: childId, parent: parentId });
404
+ };
405
+
383
406
  const expandLegacyBodyBlocks = (
384
407
  bodyBlocks: OutputBlockData[],
385
408
  parentId: BlockId
@@ -390,18 +413,29 @@ const expandLegacyBodyBlocks = (
390
413
  for (const childBlock of bodyBlocks) {
391
414
  const expanded = expandToHierarchical([childBlock]);
392
415
 
393
- if (expanded.length === 0) {
394
- continue;
416
+ // A single legacy block may expand into N root-level siblings (e.g. a list
417
+ // with N items). Every parent-less root becomes a direct child here so
418
+ // multi-item lists inside callout/toggle bodies aren't orphaned.
419
+ for (const emitted of expanded) {
420
+ appendEmittedBlock(emitted, parentId, childIds, childBlocks);
395
421
  }
422
+ }
396
423
 
397
- // The first emitted block corresponds to the original input block.
398
- // Re-parent it to the legacy container; descendants already carry the
399
- // correct parent refs assigned during their own recursive expansion.
400
- const [first, ...rest] = expanded;
401
- const childId = first.id ?? generateBlockId();
402
-
403
- childIds.push(childId);
404
- childBlocks.push({ ...first, id: childId, parent: parentId }, ...rest);
424
+ // Invariant: every emitted block must either carry a parent ref (descendant
425
+ // assigned during its own recursive expansion) or appear in childIds (root
426
+ // we just re-parented). If this ever trips, some expansion path is leaking
427
+ // orphans exactly the regression this assertion exists to catch.
428
+ for (const block of childBlocks) {
429
+ const hasParent = block.parent !== undefined;
430
+ const hasId = block.id !== undefined;
431
+ const isDirectChild = hasId && childIds.includes(block.id as BlockId);
432
+
433
+ if (!hasParent && !isDirectChild) {
434
+ throw new Error(
435
+ `expandLegacyBodyBlocks: orphaned block emitted (type=${block.type}, id=${block.id ?? '<none>'}). ` +
436
+ `Every root-level expansion must be re-parented to ${parentId}.`
437
+ );
438
+ }
405
439
  }
406
440
 
407
441
  return { childIds, childBlocks };
@@ -646,6 +680,52 @@ const processRootListItem = (
646
680
  return listBlock;
647
681
  };
648
682
 
683
+ /**
684
+ * Recursively collapse a container's body: routes each direct child through the
685
+ * appropriate processRoot* helper based on its type so that grandchildren land in
686
+ * the correct nested legacy shape instead of being ejected to the document root.
687
+ */
688
+ function collapseBodyBlocks(
689
+ contentIds: BlockId[],
690
+ blockMap: Map<BlockId, OutputBlockData>,
691
+ processedIds: Set<BlockId>
692
+ ): OutputBlockData[] {
693
+ const result: OutputBlockData[] = [];
694
+
695
+ for (const childId of contentIds) {
696
+ if (processedIds.has(childId)) {
697
+ continue;
698
+ }
699
+ const childBlock = blockMap.get(childId);
700
+
701
+ if (childBlock === undefined) {
702
+ continue;
703
+ }
704
+
705
+ if (isFlatModelListBlock(childBlock)) {
706
+ result.push(processRootListItem(childBlock, blockMap, processedIds));
707
+ continue;
708
+ }
709
+ if (isFlatModelToggleBlock(childBlock)) {
710
+ result.push(processRootToggleItem(childBlock, blockMap, processedIds));
711
+ continue;
712
+ }
713
+ if (isToggleableHeaderBlock(childBlock)) {
714
+ result.push(processRootToggleableHeader(childBlock, blockMap, processedIds));
715
+ continue;
716
+ }
717
+ if (isFlatModelCalloutBlock(childBlock)) {
718
+ result.push(processRootCalloutItem(childBlock, blockMap, processedIds));
719
+ continue;
720
+ }
721
+
722
+ markBlockAsProcessed(childBlock.id, processedIds);
723
+ result.push(stripHierarchyFields(childBlock));
724
+ }
725
+
726
+ return result;
727
+ }
728
+
649
729
  /**
650
730
  * Process a root toggle block and convert to a legacy toggleList block
651
731
  */
@@ -662,18 +742,8 @@ const processRootToggleItem = (
662
742
  ? (data as Record<string, unknown>).isOpen as boolean
663
743
  : undefined;
664
744
 
665
- // Collect child blocks
666
- const childBlocks: OutputBlockData[] = [];
667
745
  const contentIds = block.content ?? [];
668
-
669
- for (const childId of contentIds) {
670
- const childBlock = blockMap.get(childId);
671
-
672
- if (childBlock) {
673
- markBlockAsProcessed(childId, processedIds);
674
- childBlocks.push(stripHierarchyFields(childBlock));
675
- }
676
- }
746
+ const childBlocks = collapseBodyBlocks(contentIds, blockMap, processedIds);
677
747
 
678
748
  const legacyBlock: OutputBlockData = {
679
749
  id: block.id,
@@ -732,17 +802,8 @@ const processRootToggleableHeader = (
732
802
  ? (data as Record<string, unknown>).isOpen as boolean
733
803
  : undefined;
734
804
 
735
- const childBlocks: OutputBlockData[] = [];
736
805
  const contentIds = block.content ?? [];
737
-
738
- for (const childId of contentIds) {
739
- const childBlock = blockMap.get(childId);
740
-
741
- if (childBlock) {
742
- markBlockAsProcessed(childId, processedIds);
743
- childBlocks.push(stripHierarchyFields(childBlock));
744
- }
745
- }
806
+ const childBlocks = collapseBodyBlocks(contentIds, blockMap, processedIds);
746
807
 
747
808
  const legacyBlock: OutputBlockData = {
748
809
  id: block.id,
@@ -810,18 +871,8 @@ const processRootCalloutItem = (
810
871
  const isEmojiVisible = typeof emojiValue === 'string' && emojiValue.length > 0;
811
872
  const emoji = isEmojiVisible ? emojiValue : null;
812
873
 
813
- // Collect child blocks
814
- const childBlocks: OutputBlockData[] = [];
815
874
  const contentIds = block.content ?? [];
816
-
817
- for (const childId of contentIds) {
818
- const childBlock = blockMap.get(childId);
819
-
820
- if (childBlock) {
821
- markBlockAsProcessed(childId, processedIds);
822
- childBlocks.push(stripHierarchyFields(childBlock));
823
- }
824
- }
875
+ const childBlocks = collapseBodyBlocks(contentIds, blockMap, processedIds);
825
876
 
826
877
  const legacyBlock: OutputBlockData = {
827
878
  id: block.id,
@@ -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: string; checked?: boolean | string }>;
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
- const text = firstItem?.content || '';
121
- const checked = firstItem?.checked || false;
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,
@@ -671,8 +671,11 @@ export class Table implements BlockTool {
671
671
  const data = this.model.snapshot();
672
672
 
673
673
  // Filter out block IDs that don't belong to this table.
674
- // Corrupted data may contain cross-table references; persisting them
675
- // causes DOM node stealing and data loss on subsequent renders.
674
+ // Corrupted data may contain cross-table references or phantom IDs
675
+ // (blocks deleted but matrix not updated); persisting them causes DOM
676
+ // node stealing and data loss on subsequent renders.
677
+ const tableId = this.blockId ?? '';
678
+
676
679
  data.content = data.content.map(row =>
677
680
  row.map(cell => {
678
681
  if (!isCellWithBlocks(cell)) {
@@ -682,7 +685,7 @@ export class Table implements BlockTool {
682
685
  const filtered = cell.blocks.filter(blockId => {
683
686
  const block = this.api.blocks.getById?.(blockId);
684
687
 
685
- return !block || block.parentId === this.blockId;
688
+ return block != null && (block.parentId ?? '') === tableId;
686
689
  });
687
690
 
688
691
  return { ...cell, blocks: filtered };
@@ -476,9 +476,15 @@ export class TableCellBlocks {
476
476
  }
477
477
 
478
478
  // Guard: if the block is already mounted in another nested container
479
- // (table cell, toggle, callout, header), create a duplicate with the
480
- // same tool name and data rather than stealing the DOM node.
481
- if (block.holder.closest(`[${DATA_ATTR.nestedBlocks}]`)) {
479
+ // (table cell, toggle, callout, header), OR its parentId already points
480
+ // to a different owner (race window where another table has claimed it
481
+ // via flat-list parent field but has not yet mounted its DOM), create a
482
+ // duplicate with the same tool name and data rather than stealing.
483
+ const hasDifferentOwner = block.parentId != null
484
+ && block.parentId !== ''
485
+ && block.parentId !== this.tableBlockId;
486
+
487
+ if (block.holder.closest(`[${DATA_ATTR.nestedBlocks}]`) || hasDifferentOwner) {
482
488
  const duplicate = this.api.blocks.insert(
483
489
  block.name,
484
490
  block.preservedData,
@@ -529,9 +535,15 @@ export class TableCellBlocks {
529
535
  return;
530
536
  }
531
537
 
532
- // Guard: skip blocks already mounted in another nested container.
533
- // Without this, insertBefore would steal the DOM node from the other container.
534
- if (block.holder.closest(`[${DATA_ATTR.nestedBlocks}]`)) {
538
+ // Guard: skip blocks already mounted in another nested container, or whose
539
+ // parentId already points to a different owner (race window where another
540
+ // table has claimed the block via flat-list parent field but has not yet
541
+ // mounted its DOM). Without this, insertBefore would steal the DOM node.
542
+ const hasDifferentOwner = block.parentId != null
543
+ && block.parentId !== ''
544
+ && block.parentId !== this.tableBlockId;
545
+
546
+ if (block.holder.closest(`[${DATA_ATTR.nestedBlocks}]`) || hasDifferentOwner) {
535
547
  return;
536
548
  }
537
549
 
@@ -234,6 +234,19 @@ export class TableModel {
234
234
  this.blockCellMap.delete(oldId);
235
235
  }
236
236
 
237
+ // Scrub each new ID from any other cell it previously occupied. Without this,
238
+ // reassigning a block ID from cell A to cell B would leave a dangling copy in A,
239
+ // producing the cross-cell duplicate references observed in the corrupted article.
240
+ for (const id of blockIds) {
241
+ const prior = this.blockCellMap.get(id);
242
+
243
+ if (prior !== undefined && (prior.row !== row || prior.col !== col)) {
244
+ const priorCell = this.contentGrid[prior.row][prior.col];
245
+
246
+ priorCell.blocks = priorCell.blocks.filter(existingId => existingId !== id);
247
+ }
248
+ }
249
+
237
250
  this.contentGrid[row][col].blocks = [...blockIds];
238
251
 
239
252
  // Add new entries to map
@@ -1430,14 +1443,34 @@ export class TableModel {
1430
1443
 
1431
1444
  /**
1432
1445
  * Normalize legacy content (strings) into CellContent objects.
1446
+ *
1447
+ * Also scrubs duplicate block IDs: if the same block ID appears in more than
1448
+ * one cell (as in a corrupted saved document), the first occurrence in
1449
+ * row-major order keeps it and later occurrences drop it. This preserves the
1450
+ * invariant that every block belongs to at most one cell.
1433
1451
  */
1434
1452
  private normalizeContent(content?: LegacyCellContent[][]): CellContent[][] {
1435
1453
  if (!content || !Array.isArray(content)) {
1436
1454
  return [];
1437
1455
  }
1438
1456
 
1457
+ const seen = new Set<string>();
1458
+
1439
1459
  return content.map(row =>
1440
- (row ?? []).map(c => this.normalizeCell(c))
1460
+ (row ?? []).map(c => {
1461
+ const normalized = this.normalizeCell(c);
1462
+
1463
+ normalized.blocks = normalized.blocks.filter(blockId => {
1464
+ if (seen.has(blockId)) {
1465
+ return false;
1466
+ }
1467
+ seen.add(blockId);
1468
+
1469
+ return true;
1470
+ });
1471
+
1472
+ return normalized;
1473
+ })
1441
1474
  );
1442
1475
  }
1443
1476