@beyondwork/docx-react-component 1.0.17 → 1.0.19

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.
Files changed (74) hide show
  1. package/README.md +8 -2
  2. package/package.json +32 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +374 -4
  5. package/src/api/session-state.ts +58 -0
  6. package/src/core/commands/formatting-commands.ts +1 -0
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +5 -1
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +329 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +1 -1
  16. package/src/index.ts +30 -0
  17. package/src/io/docx-session.ts +260 -39
  18. package/src/io/export/serialize-main-document.ts +202 -5
  19. package/src/io/export/serialize-numbering.ts +28 -7
  20. package/src/io/normalize/normalize-text.ts +63 -25
  21. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  22. package/src/io/ooxml/parse-footnotes.ts +212 -20
  23. package/src/io/ooxml/parse-headers-footers.ts +229 -25
  24. package/src/io/ooxml/parse-inline-media.ts +16 -0
  25. package/src/io/ooxml/parse-main-document.ts +411 -6
  26. package/src/io/ooxml/parse-numbering.ts +7 -0
  27. package/src/io/ooxml/parse-settings.ts +184 -0
  28. package/src/io/ooxml/parse-shapes.ts +25 -0
  29. package/src/io/ooxml/parse-styles.ts +463 -0
  30. package/src/io/ooxml/parse-theme.ts +32 -0
  31. package/src/model/canonical-document.ts +133 -3
  32. package/src/model/cds-1.0.0.ts +13 -0
  33. package/src/model/snapshot.ts +2 -1
  34. package/src/runtime/document-layout.ts +332 -0
  35. package/src/runtime/document-navigation.ts +564 -0
  36. package/src/runtime/document-runtime.ts +265 -35
  37. package/src/runtime/document-search.ts +145 -0
  38. package/src/runtime/numbering-prefix.ts +47 -26
  39. package/src/runtime/page-layout-estimation.ts +212 -0
  40. package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
  41. package/src/runtime/session-capabilities.ts +2 -0
  42. package/src/runtime/story-context.ts +164 -0
  43. package/src/runtime/story-targeting.ts +162 -0
  44. package/src/runtime/surface-projection.ts +239 -12
  45. package/src/runtime/table-schema.ts +87 -5
  46. package/src/runtime/view-state.ts +459 -0
  47. package/src/ui/WordReviewEditor.tsx +1902 -312
  48. package/src/ui/browser-export.ts +52 -0
  49. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  50. package/src/ui/headless/selection-helpers.ts +20 -0
  51. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  52. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  53. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  54. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
  57. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  58. package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
  59. package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
  60. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
  61. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  62. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  63. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
  64. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  65. package/src/ui-tailwind/index.ts +2 -1
  66. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
  68. package/src/ui-tailwind/theme/editor-theme.css +123 -0
  69. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  70. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
  71. package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
  72. package/src/validation/compatibility-engine.ts +92 -20
  73. package/src/validation/diagnostics.ts +1 -0
  74. package/src/validation/docx-comment-proof.ts +487 -0
@@ -17,6 +17,7 @@ import {
17
17
  export interface ImageCommandContext {
18
18
  timestamp: string;
19
19
  altText?: string;
20
+ display?: "inline" | "floating";
20
21
  }
21
22
 
22
23
  export interface InsertImageResult {
@@ -158,6 +159,7 @@ function insertImageIntoParagraph(
158
159
  filename: `${mediaId.replace("media:", "")}.${ext}`,
159
160
  packagePartName,
160
161
  ...(context.altText ? { altText: context.altText } : {}),
162
+ ...(context.display ? { display: context.display } : {}),
161
163
  };
162
164
 
163
165
  const existingItems =
@@ -205,6 +207,151 @@ function insertImageIntoParagraph(
205
207
  };
206
208
  }
207
209
 
210
+ // ---------------------------------------------------------------------------
211
+ // Bounded geometry commands — export-safe, command-backed
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /** EMU-based image dimensions. 1 inch = 914400 EMU. */
215
+ export interface ImageDimensions {
216
+ widthEmu: number;
217
+ heightEmu: number;
218
+ }
219
+
220
+ /** Bounded resize constraints. */
221
+ const MIN_DIMENSION_EMU = 91440; // ~0.1 inch
222
+ const MAX_DIMENSION_EMU = 27432000; // ~30 inches — Word practical max
223
+
224
+ function clampDimension(value: number): number {
225
+ return Math.max(MIN_DIMENSION_EMU, Math.min(MAX_DIMENSION_EMU, Math.round(value)));
226
+ }
227
+
228
+ export interface ResizeImageResult {
229
+ document: CanonicalDocumentEnvelope;
230
+ dimensions: ImageDimensions;
231
+ }
232
+
233
+ /**
234
+ * Resize an image in the media catalog by media ID.
235
+ *
236
+ * Bounded: dimensions are clamped to [0.1in, 30in] and stored on the
237
+ * MediaItem so they survive export round-trip. The ImageNode in the story
238
+ * is not mutated — placement geometry lives on the media item and the
239
+ * preserved `placementXml` is updated if present.
240
+ */
241
+ export function resizeImage(
242
+ document: CanonicalDocumentEnvelope,
243
+ mediaId: string,
244
+ dimensions: ImageDimensions,
245
+ timestamp: string = new Date().toISOString(),
246
+ ): ResizeImageResult {
247
+ const media = document.media as { items: Record<string, MediaItem> } | undefined;
248
+ if (!media?.items[mediaId]) {
249
+ throw new TextTransactionError(
250
+ "invalid_selection",
251
+ `Media item ${mediaId} does not exist in the catalog.`,
252
+ );
253
+ }
254
+
255
+ const clamped: ImageDimensions = {
256
+ widthEmu: clampDimension(dimensions.widthEmu),
257
+ heightEmu: clampDimension(dimensions.heightEmu),
258
+ };
259
+
260
+ const updatedItem: MediaItem = {
261
+ ...media.items[mediaId],
262
+ widthEmu: clamped.widthEmu,
263
+ heightEmu: clamped.heightEmu,
264
+ };
265
+
266
+ return {
267
+ document: {
268
+ ...document,
269
+ updatedAt: timestamp,
270
+ media: {
271
+ items: {
272
+ ...media.items,
273
+ [mediaId]: updatedItem,
274
+ },
275
+ },
276
+ },
277
+ dimensions: clamped,
278
+ };
279
+ }
280
+
281
+ export interface RepositionImageResult {
282
+ document: CanonicalDocumentEnvelope;
283
+ }
284
+
285
+ /**
286
+ * Reposition a floating image by updating its floating axis offsets.
287
+ *
288
+ * Only applies to images with `display: "floating"`. The offset values are
289
+ * EMU-based and stored on the canonical ImageNode's `floating` property so
290
+ * they survive export. Inline images cannot be repositioned — callers must
291
+ * first change the display mode through a separate command.
292
+ */
293
+ export function repositionFloatingImage(
294
+ document: CanonicalDocumentEnvelope,
295
+ mediaId: string,
296
+ offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number },
297
+ timestamp: string = new Date().toISOString(),
298
+ ): RepositionImageResult {
299
+ const blocks = document.content.children;
300
+ let found = false;
301
+ const nextBlocks = blocks.map((block) => {
302
+ if (block.type !== "paragraph") return block;
303
+ const nextChildren = block.children.map((child) => {
304
+ if (child.type !== "image" || child.mediaId !== mediaId) return child;
305
+ if (child.display !== "floating") {
306
+ throw new TextTransactionError(
307
+ "unsupported_content",
308
+ `Image ${mediaId} is not floating — reposition requires display: "floating".`,
309
+ );
310
+ }
311
+ found = true;
312
+ const prevFloating = child.floating ?? {};
313
+ return {
314
+ ...child,
315
+ floating: {
316
+ ...prevFloating,
317
+ ...(offsets.horizontalOffsetEmu !== undefined
318
+ ? {
319
+ horizontalPosition: {
320
+ ...prevFloating.horizontalPosition,
321
+ offset: offsets.horizontalOffsetEmu,
322
+ },
323
+ }
324
+ : {}),
325
+ ...(offsets.verticalOffsetEmu !== undefined
326
+ ? {
327
+ verticalPosition: {
328
+ ...prevFloating.verticalPosition,
329
+ offset: offsets.verticalOffsetEmu,
330
+ },
331
+ }
332
+ : {}),
333
+ },
334
+ };
335
+ });
336
+ return { ...block, children: nextChildren };
337
+ });
338
+
339
+ if (!found) {
340
+ throw new TextTransactionError(
341
+ "invalid_selection",
342
+ `No floating image with mediaId ${mediaId} found in document content.`,
343
+ );
344
+ }
345
+
346
+ return {
347
+ document: {
348
+ ...document,
349
+ updatedAt: timestamp,
350
+ content: { ...document.content, children: nextBlocks },
351
+ },
352
+ };
353
+ }
354
+
208
355
  function mimeTypeToExtension(mimeType: string): string {
209
356
  switch (mimeType.toLowerCase()) {
210
357
  case "image/png":
@@ -541,7 +541,11 @@ export function executeEditorCommand(
541
541
  }
542
542
  const editedEntries = [...threadToEdit.entries];
543
543
  editedEntries[0] = { ...editedEntries[0], body: command.body };
544
- const editedThread = { ...threadToEdit, entries: editedEntries };
544
+ const editedThread = {
545
+ ...threadToEdit,
546
+ body: command.body,
547
+ entries: editedEntries,
548
+ };
545
549
  const editedComments = {
546
550
  ...state.document.review.comments,
547
551
  [command.commentId]: editedThread,
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AbstractNumberingDefinition,
3
+ BlockNode,
3
4
  CanonicalDocument as CanonicalDocumentEnvelope,
4
5
  NumberingCatalog,
5
6
  NumberingInstance,
@@ -16,9 +17,6 @@ export interface ListCommandResult {
16
17
  createdNumberingInstanceId?: string;
17
18
  }
18
19
 
19
- type OpaqueBlockLike = { type: "opaque_block"; [key: string]: unknown };
20
- type BlockLike = ParagraphNode | OpaqueBlockLike;
21
-
22
20
  export function toggleNumberedList(
23
21
  document: CanonicalDocumentEnvelope,
24
22
  paragraphIndexes: readonly number[],
@@ -51,6 +49,98 @@ export function outdentListItems(
51
49
  return adjustListLevels(document, paragraphIndexes, -1, context);
52
50
  }
53
51
 
52
+ /**
53
+ * Word-like Enter behavior on a list paragraph:
54
+ * - If the paragraph has content, split normally (caller handles the split).
55
+ * Returns `action: "split"` so the caller can proceed with a standard paragraph split.
56
+ * - If the paragraph is empty AND at level > 0, outdent one level → `action: "outdented"`.
57
+ * - If the paragraph is empty AND at level 0, remove numbering → `action: "removed"`.
58
+ */
59
+ export function splitListParagraph(
60
+ document: CanonicalDocumentEnvelope,
61
+ paragraphIndex: number,
62
+ paragraphIsEmpty: boolean,
63
+ context: ListCommandContext,
64
+ ): ListCommandResult & { action: "split" | "outdented" | "removed" } {
65
+ if (!paragraphIsEmpty) {
66
+ return {
67
+ document,
68
+ affectedParagraphIndexes: [],
69
+ action: "split",
70
+ };
71
+ }
72
+
73
+ const working = cloneEnvelope(document, context.timestamp);
74
+ const paragraphs = captureEditableParagraphs(working);
75
+ const target = paragraphs[paragraphIndex];
76
+
77
+ if (!target?.numbering) {
78
+ return {
79
+ document: working,
80
+ affectedParagraphIndexes: [],
81
+ action: "split",
82
+ };
83
+ }
84
+
85
+ if (target.numbering.level > 0) {
86
+ target.numbering = {
87
+ numberingInstanceId: target.numbering.numberingInstanceId,
88
+ level: clampLevel(target.numbering.level - 1),
89
+ };
90
+ return {
91
+ document: working,
92
+ affectedParagraphIndexes: [paragraphIndex],
93
+ action: "outdented",
94
+ };
95
+ }
96
+
97
+ delete target.numbering;
98
+ return {
99
+ document: working,
100
+ affectedParagraphIndexes: [paragraphIndex],
101
+ action: "removed",
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Word-like Backspace at the start of a list paragraph:
107
+ * - If at level > 0, outdent one level.
108
+ * - If at level 0, remove numbering entirely.
109
+ * Returns the modified document and whether numbering was changed.
110
+ */
111
+ export function backspaceAtListStart(
112
+ document: CanonicalDocumentEnvelope,
113
+ paragraphIndex: number,
114
+ context: ListCommandContext,
115
+ ): ListCommandResult & { handled: boolean } {
116
+ const working = cloneEnvelope(document, context.timestamp);
117
+ const paragraphs = captureEditableParagraphs(working);
118
+ const target = paragraphs[paragraphIndex];
119
+
120
+ if (!target?.numbering) {
121
+ return {
122
+ document: working,
123
+ affectedParagraphIndexes: [],
124
+ handled: false,
125
+ };
126
+ }
127
+
128
+ if (target.numbering.level > 0) {
129
+ target.numbering = {
130
+ numberingInstanceId: target.numbering.numberingInstanceId,
131
+ level: clampLevel(target.numbering.level - 1),
132
+ };
133
+ } else {
134
+ delete target.numbering;
135
+ }
136
+
137
+ return {
138
+ document: working,
139
+ affectedParagraphIndexes: [paragraphIndex],
140
+ handled: true,
141
+ };
142
+ }
143
+
54
144
  export function restartNumbering(
55
145
  document: CanonicalDocumentEnvelope,
56
146
  paragraphIndex: number,
@@ -58,10 +148,10 @@ export function restartNumbering(
58
148
  startAt = 1,
59
149
  ): ListCommandResult {
60
150
  const working = cloneEnvelope(document, context.timestamp);
61
- const blocks = captureBlocks(working);
62
- const target = blocks[paragraphIndex];
151
+ const paragraphs = captureEditableParagraphs(working);
152
+ const target = paragraphs[paragraphIndex];
63
153
 
64
- if (!isParagraphNode(target) || !target.numbering) {
154
+ if (!target?.numbering) {
65
155
  return {
66
156
  document: working,
67
157
  affectedParagraphIndexes: [],
@@ -88,18 +178,18 @@ export function restartNumbering(
88
178
  };
89
179
 
90
180
  const affectedParagraphIndexes: number[] = [];
91
- for (let index = paragraphIndex; index < blocks.length; index += 1) {
92
- const block = blocks[index];
93
- if (!isParagraphNode(block)) {
181
+ for (let index = paragraphIndex; index < paragraphs.length; index += 1) {
182
+ const paragraph = paragraphs[index];
183
+ if (!paragraph?.numbering) {
94
184
  break;
95
185
  }
96
- if (block.numbering?.numberingInstanceId !== existingInstance.numberingInstanceId) {
186
+ if (paragraph.numbering.numberingInstanceId !== existingInstance.numberingInstanceId) {
97
187
  break;
98
188
  }
99
189
 
100
- block.numbering = {
190
+ paragraph.numbering = {
101
191
  numberingInstanceId,
102
- level: block.numbering.level,
192
+ level: paragraph.numbering.level,
103
193
  };
104
194
  affectedParagraphIndexes.push(index);
105
195
  }
@@ -113,6 +203,70 @@ export function restartNumbering(
113
203
  };
114
204
  }
115
205
 
206
+ export function continueNumbering(
207
+ document: CanonicalDocumentEnvelope,
208
+ paragraphIndex: number,
209
+ context: ListCommandContext,
210
+ ): ListCommandResult {
211
+ const working = cloneEnvelope(document, context.timestamp);
212
+ const paragraphs = captureEditableParagraphs(working);
213
+ const target = paragraphs[paragraphIndex];
214
+
215
+ if (!target?.numbering) {
216
+ return {
217
+ document: working,
218
+ affectedParagraphIndexes: [],
219
+ };
220
+ }
221
+
222
+ const catalog = ensureNumberingCatalog(working.numbering);
223
+ const currentInstanceId = target.numbering.numberingInstanceId;
224
+ const currentKind = getListKind(catalog, currentInstanceId);
225
+ if (!currentKind) {
226
+ return {
227
+ document: working,
228
+ affectedParagraphIndexes: [],
229
+ };
230
+ }
231
+
232
+ const compatibleInstanceId = findPreviousCompatibleInstance(
233
+ paragraphs,
234
+ catalog,
235
+ paragraphIndex,
236
+ currentKind,
237
+ );
238
+ if (!compatibleInstanceId || compatibleInstanceId === currentInstanceId) {
239
+ return {
240
+ document: working,
241
+ affectedParagraphIndexes: [],
242
+ };
243
+ }
244
+
245
+ const affectedParagraphIndexes: number[] = [];
246
+ for (let index = paragraphIndex; index < paragraphs.length; index += 1) {
247
+ const paragraph = paragraphs[index];
248
+ if (!paragraph?.numbering) {
249
+ break;
250
+ }
251
+ if (paragraph.numbering.numberingInstanceId !== currentInstanceId) {
252
+ break;
253
+ }
254
+ paragraph.numbering = {
255
+ numberingInstanceId: compatibleInstanceId,
256
+ level: paragraph.numbering.level,
257
+ };
258
+ affectedParagraphIndexes.push(index);
259
+ }
260
+
261
+ working.numbering = catalog;
262
+
263
+ return {
264
+ document: working,
265
+ affectedParagraphIndexes,
266
+ createdNumberingInstanceId: compatibleInstanceId,
267
+ };
268
+ }
269
+
116
270
  function toggleListKind(
117
271
  document: CanonicalDocumentEnvelope,
118
272
  paragraphIndexes: readonly number[],
@@ -120,8 +274,8 @@ function toggleListKind(
120
274
  context: ListCommandContext,
121
275
  ): ListCommandResult {
122
276
  const working = cloneEnvelope(document, context.timestamp);
123
- const blocks = captureBlocks(working);
124
- const normalizedIndexes = normalizeParagraphIndexes(blocks, paragraphIndexes);
277
+ const paragraphs = captureEditableParagraphs(working);
278
+ const normalizedIndexes = normalizeParagraphIndexes(paragraphs, paragraphIndexes);
125
279
 
126
280
  if (normalizedIndexes.length === 0) {
127
281
  return {
@@ -132,7 +286,7 @@ function toggleListKind(
132
286
 
133
287
  const catalog = ensureNumberingCatalog(working.numbering);
134
288
  const allAlreadyKind = normalizedIndexes.every((index) => {
135
- const paragraph = blocks[index] as ParagraphNode;
289
+ const paragraph = paragraphs[index] as ParagraphNode;
136
290
  return paragraph.numbering
137
291
  ? getListKind(catalog, paragraph.numbering.numberingInstanceId) === kind
138
292
  : false;
@@ -140,7 +294,7 @@ function toggleListKind(
140
294
 
141
295
  if (allAlreadyKind) {
142
296
  for (const index of normalizedIndexes) {
143
- delete (blocks[index] as ParagraphNode).numbering;
297
+ delete (paragraphs[index] as ParagraphNode).numbering;
144
298
  }
145
299
  working.numbering = catalog;
146
300
  return {
@@ -150,11 +304,11 @@ function toggleListKind(
150
304
  }
151
305
 
152
306
  const numberingInstanceId =
153
- findAdjacentCompatibleInstance(blocks, catalog, normalizedIndexes[0]!, kind) ??
307
+ findAdjacentCompatibleInstance(paragraphs, catalog, normalizedIndexes[0]!, kind) ??
154
308
  ensureDefaultInstance(catalog, kind);
155
309
 
156
310
  for (const index of normalizedIndexes) {
157
- const paragraph = blocks[index] as ParagraphNode;
311
+ const paragraph = paragraphs[index] as ParagraphNode;
158
312
  paragraph.numbering = {
159
313
  numberingInstanceId,
160
314
  level: clampLevel(paragraph.numbering?.level ?? 0),
@@ -177,13 +331,13 @@ function adjustListLevels(
177
331
  context: ListCommandContext,
178
332
  ): ListCommandResult {
179
333
  const working = cloneEnvelope(document, context.timestamp);
180
- const blocks = captureBlocks(working);
181
- const affectedParagraphIndexes = normalizeParagraphIndexes(blocks, paragraphIndexes).filter(
182
- (index) => isParagraphNode(blocks[index]) && Boolean((blocks[index] as ParagraphNode).numbering),
334
+ const paragraphs = captureEditableParagraphs(working);
335
+ const affectedParagraphIndexes = normalizeParagraphIndexes(paragraphs, paragraphIndexes).filter(
336
+ (index) => Boolean(paragraphs[index]?.numbering),
183
337
  );
184
338
 
185
339
  for (const index of affectedParagraphIndexes) {
186
- const paragraph = blocks[index] as ParagraphNode;
340
+ const paragraph = paragraphs[index] as ParagraphNode;
187
341
  if (!paragraph.numbering) {
188
342
  continue;
189
343
  }
@@ -226,13 +380,13 @@ function ensureDefaultInstance(
226
380
  }
227
381
 
228
382
  function findAdjacentCompatibleInstance(
229
- blocks: BlockLike[],
383
+ paragraphs: readonly ParagraphNode[],
230
384
  catalog: NumberingCatalog,
231
385
  fromIndex: number,
232
386
  kind: "numbered" | "bulleted",
233
387
  ): string | undefined {
234
- const previous = blocks[fromIndex - 1];
235
- if (isParagraphNode(previous) && previous.numbering) {
388
+ const previous = paragraphs[fromIndex - 1];
389
+ if (previous?.numbering) {
236
390
  const previousKind = getListKind(catalog, previous.numbering.numberingInstanceId);
237
391
  if (previousKind === kind) {
238
392
  return previous.numbering.numberingInstanceId;
@@ -242,6 +396,24 @@ function findAdjacentCompatibleInstance(
242
396
  return undefined;
243
397
  }
244
398
 
399
+ function findPreviousCompatibleInstance(
400
+ paragraphs: readonly ParagraphNode[],
401
+ catalog: NumberingCatalog,
402
+ fromIndex: number,
403
+ kind: "numbered" | "bulleted",
404
+ ): string | undefined {
405
+ for (let index = fromIndex - 1; index >= 0; index -= 1) {
406
+ const paragraph = paragraphs[index];
407
+ if (!paragraph?.numbering) {
408
+ continue;
409
+ }
410
+ if (getListKind(catalog, paragraph.numbering.numberingInstanceId) === kind) {
411
+ return paragraph.numbering.numberingInstanceId;
412
+ }
413
+ }
414
+ return undefined;
415
+ }
416
+
245
417
  function getListKind(
246
418
  catalog: NumberingCatalog,
247
419
  numberingInstanceId: string,
@@ -333,9 +505,11 @@ function cloneEnvelope(
333
505
  };
334
506
  }
335
507
 
336
- function captureBlocks(document: CanonicalDocumentEnvelope): BlockLike[] {
508
+ function captureEditableParagraphs(document: CanonicalDocumentEnvelope): ParagraphNode[] {
337
509
  if (isDocumentRoot(document.content)) {
338
- return document.content.children as BlockLike[];
510
+ const paragraphs: ParagraphNode[] = [];
511
+ collectEditableParagraphs(document.content.children as BlockNode[], paragraphs);
512
+ return paragraphs;
339
513
  }
340
514
 
341
515
  const fallback = {
@@ -343,16 +517,42 @@ function captureBlocks(document: CanonicalDocumentEnvelope): BlockLike[] {
343
517
  children: [{ type: "paragraph" as const, children: [] }],
344
518
  };
345
519
  document.content = fallback;
346
- return fallback.children as BlockLike[];
520
+ return fallback.children as ParagraphNode[];
521
+ }
522
+
523
+ function collectEditableParagraphs(
524
+ blocks: readonly BlockNode[],
525
+ output: ParagraphNode[],
526
+ ): void {
527
+ for (const block of blocks) {
528
+ switch (block.type) {
529
+ case "paragraph":
530
+ output.push(block);
531
+ break;
532
+ case "table":
533
+ for (const row of block.rows) {
534
+ for (const cell of row.cells) {
535
+ collectEditableParagraphs(cell.children, output);
536
+ }
537
+ }
538
+ break;
539
+ case "sdt":
540
+ collectEditableParagraphs(block.children, output);
541
+ break;
542
+ case "custom_xml":
543
+ break;
544
+ default:
545
+ break;
546
+ }
547
+ }
347
548
  }
348
549
 
349
550
  function normalizeParagraphIndexes(
350
- blocks: BlockLike[],
551
+ paragraphs: readonly ParagraphNode[],
351
552
  paragraphIndexes: readonly number[],
352
553
  ): number[] {
353
554
  return [...new Set(paragraphIndexes)]
354
- .filter((index) => Number.isInteger(index) && index >= 0 && index < blocks.length)
355
- .filter((index) => isParagraphNode(blocks[index]))
555
+ .filter((index) => Number.isInteger(index) && index >= 0 && index < paragraphs.length)
356
556
  .sort((left, right) => left - right);
357
557
  }
358
558
 
@@ -363,8 +563,3 @@ function clampLevel(level: number): number {
363
563
  function isDocumentRoot(value: unknown): value is { type: "doc"; children: unknown[] } {
364
564
  return Boolean(value) && typeof value === "object" && (value as { type?: string }).type === "doc";
365
565
  }
366
-
367
- function isParagraphNode(value: unknown): value is ParagraphNode {
368
- return Boolean(value) && typeof value === "object" && (value as { type?: string }).type === "paragraph";
369
- }
370
-