@beyondwork/docx-react-component 1.0.58 → 1.0.59
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/README.md +2 -2
- package/package.json +2 -1
- package/src/api/awareness-identity-types.ts +4 -2
- package/src/api/comment-negotiation-types.ts +4 -1
- package/src/api/external-custody-types.ts +16 -0
- package/src/api/internal/build-ref-projections.ts +108 -0
- package/src/api/package-version.ts +1 -1
- package/src/api/participants-types.ts +11 -1
- package/src/api/public-types.ts +978 -10
- package/src/api/scope-metadata-resolver-types.ts +6 -0
- package/src/compare/diff-engine.ts +3 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/index.ts +225 -16
- package/src/core/commands/legacy-form-field-commands.ts +181 -0
- package/src/core/commands/table-structure-commands.ts +149 -31
- package/src/core/selection/mapping.ts +20 -0
- package/src/core/state/editor-state.ts +2 -1
- package/src/index.ts +28 -0
- package/src/io/docx-session.ts +22 -3
- package/src/io/export/export-session.ts +11 -7
- package/src/io/export/ooxml-namespaces.ts +47 -0
- package/src/io/export/reattach-preserved-parts.ts +4 -16
- package/src/io/export/serialize-comments.ts +3 -131
- package/src/io/export/serialize-ffdata.ts +89 -0
- package/src/io/export/serialize-headers-footers.ts +5 -0
- package/src/io/export/serialize-main-document.ts +224 -34
- package/src/io/export/serialize-numbering.ts +22 -2
- package/src/io/export/serialize-revisions.ts +99 -0
- package/src/io/export/serialize-tables.ts +9 -0
- package/src/io/export/split-review-boundaries.ts +1 -0
- package/src/io/export/table-properties-xml.ts +14 -0
- package/src/io/load-scheduler.ts +70 -28
- package/src/io/normalize/normalize-text.ts +13 -0
- package/src/io/ooxml/_mini-xml.ts +198 -0
- package/src/io/ooxml/canonicalize-payload.ts +1 -4
- package/src/io/ooxml/chart/chart-style-table.ts +4 -3
- package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
- package/src/io/ooxml/chart/parse-series.ts +2 -1
- package/src/io/ooxml/chart/resolve-color.ts +2 -2
- package/src/io/ooxml/chart/types.ts +6 -434
- package/src/io/ooxml/comment-presentation-payload.ts +6 -5
- package/src/io/ooxml/highlight-colors.ts +8 -5
- package/src/io/ooxml/parse-anchor.ts +68 -53
- package/src/io/ooxml/parse-comments.ts +14 -142
- package/src/io/ooxml/parse-complex-content.ts +3 -106
- package/src/io/ooxml/parse-drawing.ts +100 -195
- package/src/io/ooxml/parse-ffdata.ts +93 -0
- package/src/io/ooxml/parse-fields.ts +7 -146
- package/src/io/ooxml/parse-fill.ts +88 -8
- package/src/io/ooxml/parse-font-table.ts +5 -105
- package/src/io/ooxml/parse-footnotes.ts +28 -152
- package/src/io/ooxml/parse-headers-footers.ts +106 -212
- package/src/io/ooxml/parse-inline-media.ts +3 -200
- package/src/io/ooxml/parse-main-document.ts +180 -217
- package/src/io/ooxml/parse-numbering.ts +154 -335
- package/src/io/ooxml/parse-object.ts +147 -0
- package/src/io/ooxml/parse-ole-relationship.ts +82 -0
- package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
- package/src/io/ooxml/parse-picture-sdt.ts +85 -0
- package/src/io/ooxml/parse-picture.ts +72 -42
- package/src/io/ooxml/parse-revisions.ts +285 -51
- package/src/io/ooxml/parse-settings.ts +6 -99
- package/src/io/ooxml/parse-shapes.ts +25 -140
- package/src/io/ooxml/parse-styles.ts +3 -218
- package/src/io/ooxml/parse-tables.ts +76 -256
- package/src/io/ooxml/parse-theme.ts +1 -4
- package/src/io/ooxml/property-grab-bag.ts +5 -47
- package/src/io/ooxml/xml-element-serialize.ts +32 -0
- package/src/io/ooxml/xml-parser.ts +183 -0
- package/src/legal/bookmarks.ts +1 -1
- package/src/legal/cross-references.ts +1 -1
- package/src/legal/defined-terms.ts +1 -1
- package/src/legal/{_document-root.ts → document-root.ts} +8 -0
- package/src/legal/signature-blocks.ts +1 -1
- package/src/model/canonical-document.ts +159 -6
- package/src/model/chart-types.ts +439 -0
- package/src/model/snapshot.ts +3 -1
- package/src/review/store/comment-remapping.ts +24 -11
- package/src/review/store/revision-actions.ts +482 -2
- package/src/review/store/revision-store.ts +15 -0
- package/src/review/store/revision-types.ts +76 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
- package/src/runtime/collab/runtime-collab-sync.ts +33 -0
- package/src/runtime/diagnostics/build-diagnostic.ts +151 -0
- package/src/runtime/diagnostics/code-metadata-table.ts +221 -0
- package/src/runtime/document-runtime.ts +476 -34
- package/src/runtime/document-search.ts +115 -0
- package/src/runtime/edit-ops/index.ts +18 -2
- package/src/runtime/footnote-resolver.ts +130 -0
- package/src/runtime/layout/layout-engine-instance.ts +31 -4
- package/src/runtime/layout/layout-engine-version.ts +37 -1
- package/src/runtime/layout/page-graph.ts +14 -1
- package/src/runtime/layout/resolved-formatting-state.ts +21 -0
- package/src/runtime/numbering-prefix.ts +17 -0
- package/src/runtime/query-scopes.ts +5 -8
- package/src/runtime/resolved-numbering-geometry.ts +37 -6
- package/src/runtime/revision-runtime.ts +27 -1
- package/src/runtime/selection/post-edit-validator.ts +60 -6
- package/src/runtime/structure-ops/index.ts +20 -4
- package/src/runtime/surface-projection.ts +290 -21
- package/src/runtime/table-schema.ts +6 -0
- package/src/runtime/theme-color-resolver.ts +2 -2
- package/src/runtime/units.ts +9 -0
- package/src/runtime/workflow-rail-segments.ts +4 -0
- package/src/ui/WordReviewEditor.tsx +187 -43
- package/src/ui/editor-runtime-boundary.ts +10 -0
- package/src/ui/editor-shell-view.tsx +4 -1
- package/src/ui/headless/chrome-registry.ts +53 -0
- package/src/ui/headless/selection-tool-resolver.ts +11 -1
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
- package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
- package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +0 -9
- package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
- package/src/ui-tailwind/editor-surface/pm-schema.ts +87 -25
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -0
- package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
- package/src/ui-tailwind/index.ts +9 -0
- package/src/ui-tailwind/page-chrome-model.ts +77 -5
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
- package/src/ui-tailwind/theme/tokens.ts +14 -0
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +29 -87
- package/src/validation/diagnostics.ts +1 -0
|
@@ -2,12 +2,21 @@ import { createEmptyMapping, type TransactionMapping } from "../../core/selectio
|
|
|
2
2
|
import { parseTextStory } from "../../core/schema/text-schema.ts";
|
|
3
3
|
import { createSelectionSnapshot, type CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
|
|
4
4
|
import { applyTextTransaction } from "../../core/state/text-transaction.ts";
|
|
5
|
+
import {
|
|
6
|
+
removeCellFromRow,
|
|
7
|
+
removeTableRowPure,
|
|
8
|
+
} from "../../core/commands/table-structure-commands.ts";
|
|
9
|
+
import type { BlockNode, TableNode } from "../../model/canonical-document.ts";
|
|
5
10
|
import {
|
|
6
11
|
remapRevisionStore,
|
|
7
12
|
setRevisionStatus,
|
|
8
13
|
type RevisionStore,
|
|
9
14
|
} from "./revision-store.ts";
|
|
10
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
getRevisionActionability,
|
|
17
|
+
type RevisionRecord,
|
|
18
|
+
type TableRevisionCoordinates,
|
|
19
|
+
} from "./revision-types.ts";
|
|
11
20
|
|
|
12
21
|
export type RevisionActionIntent = "accept" | "reject";
|
|
13
22
|
|
|
@@ -18,7 +27,8 @@ export type RevisionActionSkipReason =
|
|
|
18
27
|
| "preserve-only"
|
|
19
28
|
| "structural-range"
|
|
20
29
|
| "protected-range"
|
|
21
|
-
| "invalid-range"
|
|
30
|
+
| "invalid-range"
|
|
31
|
+
| "requires-runtime-mutation";
|
|
22
32
|
|
|
23
33
|
export interface ApplyRevisionActionOptions {
|
|
24
34
|
document: CanonicalDocumentEnvelope;
|
|
@@ -81,6 +91,32 @@ export function applyRevisionAction(
|
|
|
81
91
|
);
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
if (
|
|
95
|
+
revision.metadata.originalRevisionType === "cellIns" ||
|
|
96
|
+
revision.metadata.originalRevisionType === "cellDel"
|
|
97
|
+
) {
|
|
98
|
+
return applyStructuralTableAction(options, revision);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (revision.metadata.originalRevisionType === "cellMerge") {
|
|
102
|
+
return applyCellMergeAction(options, revision);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
revision.metadata.originalRevisionType === "row-ins" ||
|
|
107
|
+
revision.metadata.originalRevisionType === "row-del"
|
|
108
|
+
) {
|
|
109
|
+
return applyRowStructuralAction(options, revision);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
revision.kind === "move" &&
|
|
114
|
+
typeof revision.metadata.moveData?.linkedRevisionId === "string" &&
|
|
115
|
+
revision.metadata.moveData.linkedRevisionId.length > 0
|
|
116
|
+
) {
|
|
117
|
+
return applyPairedMoveAction(options, revision);
|
|
118
|
+
}
|
|
119
|
+
|
|
84
120
|
if (getRevisionActionability(revision) !== "actionable") {
|
|
85
121
|
return skippedResult(
|
|
86
122
|
options,
|
|
@@ -251,6 +287,450 @@ export function applyRevisionAction(
|
|
|
251
287
|
};
|
|
252
288
|
}
|
|
253
289
|
|
|
290
|
+
function applyPairedMoveAction(
|
|
291
|
+
options: ApplyRevisionActionOptions,
|
|
292
|
+
revision: RevisionRecord,
|
|
293
|
+
): ApplyRevisionActionResult {
|
|
294
|
+
// Paired moves flip status atomically; the runtime loop already added
|
|
295
|
+
// the partner to the target list via `expandLinkedMovePartners`. Content
|
|
296
|
+
// removal on the exported document is handled by the serializer based on
|
|
297
|
+
// (direction, status) — see `serializeMoveRevisionMarkup`. This keeps the
|
|
298
|
+
// live canonical model unchanged (same pattern as property-change accept).
|
|
299
|
+
const resultingStatus = toResultingStatus(options.intent);
|
|
300
|
+
return {
|
|
301
|
+
document: options.document,
|
|
302
|
+
store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
|
|
303
|
+
mapping: createEmptyMapping(),
|
|
304
|
+
outcome: {
|
|
305
|
+
kind: "applied",
|
|
306
|
+
revisionId: revision.revisionId,
|
|
307
|
+
intent: options.intent,
|
|
308
|
+
resultingStatus,
|
|
309
|
+
contentChanged: false,
|
|
310
|
+
},
|
|
311
|
+
detachedRevisionIds: [],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function applyRowStructuralAction(
|
|
316
|
+
options: ApplyRevisionActionOptions,
|
|
317
|
+
revision: RevisionRecord,
|
|
318
|
+
): ApplyRevisionActionResult {
|
|
319
|
+
const originalType = revision.metadata.originalRevisionType;
|
|
320
|
+
const coords = revision.metadata.tableRevisionCoordinates;
|
|
321
|
+
const shouldRemoveRow =
|
|
322
|
+
(originalType === "row-ins" && options.intent === "reject") ||
|
|
323
|
+
(originalType === "row-del" && options.intent === "accept");
|
|
324
|
+
|
|
325
|
+
if (shouldRemoveRow) {
|
|
326
|
+
if (!coords) {
|
|
327
|
+
return skippedResult(
|
|
328
|
+
options,
|
|
329
|
+
"structural-range",
|
|
330
|
+
"Row-structural revision is missing tableRevisionCoordinates; cannot resolve target row.",
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const resolved = resolveCanonicalTableRow(options.document, coords);
|
|
334
|
+
if (!resolved) {
|
|
335
|
+
return skippedResult(
|
|
336
|
+
options,
|
|
337
|
+
"invalid-range",
|
|
338
|
+
`Row-structural revision coordinates (tableOrdinal=${coords.tableOrdinal}, rowIndex=${coords.rowIndex}) do not resolve to a table row in the current document.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const nextTable = removeTableRowPure(resolved.table, coords.rowIndex);
|
|
342
|
+
const nextDocument = resolved.buildNextDocument(nextTable);
|
|
343
|
+
const resultingStatus = toResultingStatus(options.intent);
|
|
344
|
+
return {
|
|
345
|
+
document: nextDocument,
|
|
346
|
+
store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
|
|
347
|
+
mapping: createEmptyMapping(),
|
|
348
|
+
outcome: {
|
|
349
|
+
kind: "applied",
|
|
350
|
+
revisionId: revision.revisionId,
|
|
351
|
+
intent: options.intent,
|
|
352
|
+
resultingStatus,
|
|
353
|
+
contentChanged: true,
|
|
354
|
+
},
|
|
355
|
+
detachedRevisionIds: [],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Accept(row-ins) / Reject(row-del) = status flip only. Serializer drops
|
|
360
|
+
// the marker from trPr based on revision.status via
|
|
361
|
+
// `serializeStructuralRowMarkup`.
|
|
362
|
+
const resultingStatus = toResultingStatus(options.intent);
|
|
363
|
+
return {
|
|
364
|
+
document: options.document,
|
|
365
|
+
store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
|
|
366
|
+
mapping: createEmptyMapping(),
|
|
367
|
+
outcome: {
|
|
368
|
+
kind: "applied",
|
|
369
|
+
revisionId: revision.revisionId,
|
|
370
|
+
intent: options.intent,
|
|
371
|
+
resultingStatus,
|
|
372
|
+
contentChanged: false,
|
|
373
|
+
},
|
|
374
|
+
detachedRevisionIds: [],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function resolveCanonicalTableRow(
|
|
379
|
+
document: CanonicalDocumentEnvelope,
|
|
380
|
+
coords: TableRevisionCoordinates,
|
|
381
|
+
): {
|
|
382
|
+
blockIndex: number;
|
|
383
|
+
table: TableNode;
|
|
384
|
+
buildNextDocument: (nextTable: TableNode) => CanonicalDocumentEnvelope;
|
|
385
|
+
} | undefined {
|
|
386
|
+
const children = document.content.children;
|
|
387
|
+
|
|
388
|
+
// Phase S: body-level lookup descends into sdt / custom_xml wrappers in
|
|
389
|
+
// DFS order so that body-level tableOrdinal matches the parse-time DFS
|
|
390
|
+
// that also descends into these wrappers.
|
|
391
|
+
const bodyLookup = findBodyTableByOrdinal(children, coords.tableOrdinal);
|
|
392
|
+
|
|
393
|
+
// Phase Q: nested-table path resolution.
|
|
394
|
+
// tableOrdinalPath = [bodyOrdinal, nestedOrdinal, ...] encodes the DFS
|
|
395
|
+
// position of the containing table inside the body-level table.
|
|
396
|
+
const path = coords.tableOrdinalPath;
|
|
397
|
+
if (path && path.length >= 2) {
|
|
398
|
+
const [bodyOrdinal, ...nestedOrdinals] = path;
|
|
399
|
+
const outer = findBodyTableByOrdinal(children, bodyOrdinal);
|
|
400
|
+
if (!outer) return undefined;
|
|
401
|
+
|
|
402
|
+
// Walk the nested-ordinal chain.
|
|
403
|
+
let currentTable: TableNode = outer.table;
|
|
404
|
+
const updateFns: Array<(newInner: TableNode) => TableNode> = [];
|
|
405
|
+
|
|
406
|
+
for (const nestedOrdinal of nestedOrdinals) {
|
|
407
|
+
const found = findNestedTableByOrdinal(currentTable, nestedOrdinal);
|
|
408
|
+
if (!found) return undefined;
|
|
409
|
+
updateFns.push(found.updateOuter);
|
|
410
|
+
currentTable = found.table;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (coords.rowIndex < 0 || coords.rowIndex >= currentTable.rows.length) return undefined;
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
blockIndex: outer.pathTopIndex,
|
|
417
|
+
table: currentTable,
|
|
418
|
+
buildNextDocument: (nextTable: TableNode) => {
|
|
419
|
+
// Rebuild from innermost outward.
|
|
420
|
+
let rebuilt: TableNode = nextTable;
|
|
421
|
+
for (let i = updateFns.length - 1; i >= 0; i -= 1) {
|
|
422
|
+
rebuilt = updateFns[i]!(rebuilt);
|
|
423
|
+
}
|
|
424
|
+
const nextChildren = outer.rebuildRoot(rebuilt);
|
|
425
|
+
return { ...document, content: { ...document.content, children: nextChildren } };
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Flat body-level resolution (Phase S-aware — descends through sdt/customXml).
|
|
431
|
+
if (!bodyLookup) return undefined;
|
|
432
|
+
if (coords.rowIndex < 0 || coords.rowIndex >= bodyLookup.table.rows.length) return undefined;
|
|
433
|
+
return {
|
|
434
|
+
blockIndex: bodyLookup.pathTopIndex,
|
|
435
|
+
table: bodyLookup.table,
|
|
436
|
+
buildNextDocument: (nextTable: TableNode) => {
|
|
437
|
+
const nextChildren = bodyLookup.rebuildRoot(nextTable);
|
|
438
|
+
return { ...document, content: { ...document.content, children: nextChildren } };
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Finds the Nth table in DFS order, descending into `sdt` and `custom_xml`
|
|
445
|
+
* wrapper children. Returns the table plus a `rebuildRoot` closure that
|
|
446
|
+
* reconstructs the top-level children array with a new table substituted in
|
|
447
|
+
* place. `pathTopIndex` is the index in the top-level children array that
|
|
448
|
+
* contains (or transitively contains) the table — useful for callers that
|
|
449
|
+
* need a legacy `blockIndex` value.
|
|
450
|
+
*/
|
|
451
|
+
function findBodyTableByOrdinal(
|
|
452
|
+
rootChildren: readonly BlockNode[],
|
|
453
|
+
targetOrdinal: number,
|
|
454
|
+
): {
|
|
455
|
+
table: TableNode;
|
|
456
|
+
pathTopIndex: number;
|
|
457
|
+
rebuildRoot: (newTable: TableNode) => BlockNode[];
|
|
458
|
+
} | undefined {
|
|
459
|
+
const path: number[] = [];
|
|
460
|
+
const counter = { value: -1 };
|
|
461
|
+
const table = walkForBodyTable(rootChildren, path, counter, targetOrdinal);
|
|
462
|
+
if (!table) return undefined;
|
|
463
|
+
const capturedPath = [...path];
|
|
464
|
+
const pathTopIndex = capturedPath[0] ?? -1;
|
|
465
|
+
return {
|
|
466
|
+
table,
|
|
467
|
+
pathTopIndex,
|
|
468
|
+
rebuildRoot: (newTable: TableNode) => substituteTableAtPath(rootChildren, capturedPath, newTable),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function walkForBodyTable(
|
|
473
|
+
children: readonly BlockNode[],
|
|
474
|
+
path: number[],
|
|
475
|
+
counter: { value: number },
|
|
476
|
+
targetOrdinal: number,
|
|
477
|
+
): TableNode | undefined {
|
|
478
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
479
|
+
const child = children[i];
|
|
480
|
+
path.push(i);
|
|
481
|
+
if (child?.type === "table") {
|
|
482
|
+
counter.value += 1;
|
|
483
|
+
if (counter.value === targetOrdinal) {
|
|
484
|
+
return child;
|
|
485
|
+
}
|
|
486
|
+
} else if (child?.type === "sdt" || child?.type === "custom_xml") {
|
|
487
|
+
const found = walkForBodyTable(child.children, path, counter, targetOrdinal);
|
|
488
|
+
if (found) return found;
|
|
489
|
+
}
|
|
490
|
+
path.pop();
|
|
491
|
+
}
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function substituteTableAtPath(
|
|
496
|
+
children: readonly BlockNode[],
|
|
497
|
+
path: readonly number[],
|
|
498
|
+
newTable: TableNode,
|
|
499
|
+
): BlockNode[] {
|
|
500
|
+
if (path.length === 0) return [...children];
|
|
501
|
+
const [head, ...rest] = path;
|
|
502
|
+
return children.map((child, i) => {
|
|
503
|
+
if (i !== head) return child;
|
|
504
|
+
if (rest.length === 0) return newTable;
|
|
505
|
+
if (child?.type === "sdt") {
|
|
506
|
+
return { ...child, children: substituteTableAtPath(child.children, rest, newTable) };
|
|
507
|
+
}
|
|
508
|
+
if (child?.type === "custom_xml") {
|
|
509
|
+
return { ...child, children: substituteTableAtPath(child.children, rest, newTable) };
|
|
510
|
+
}
|
|
511
|
+
return child;
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function findNestedTableByOrdinal(
|
|
516
|
+
outerTable: TableNode,
|
|
517
|
+
nestedOrdinal: number,
|
|
518
|
+
): {
|
|
519
|
+
table: TableNode;
|
|
520
|
+
updateOuter: (newInner: TableNode) => TableNode;
|
|
521
|
+
} | undefined {
|
|
522
|
+
let seen = -1;
|
|
523
|
+
for (let ri = 0; ri < outerTable.rows.length; ri += 1) {
|
|
524
|
+
const row = outerTable.rows[ri];
|
|
525
|
+
if (!row) continue;
|
|
526
|
+
for (let ci = 0; ci < row.cells.length; ci += 1) {
|
|
527
|
+
const cell = row.cells[ci];
|
|
528
|
+
if (!cell) continue;
|
|
529
|
+
for (let bi = 0; bi < cell.children.length; bi += 1) {
|
|
530
|
+
const block = cell.children[bi];
|
|
531
|
+
if (block?.type !== "table") continue;
|
|
532
|
+
seen += 1;
|
|
533
|
+
if (seen !== nestedOrdinal) continue;
|
|
534
|
+
const capturedRi = ri;
|
|
535
|
+
const capturedCi = ci;
|
|
536
|
+
const capturedBi = bi;
|
|
537
|
+
return {
|
|
538
|
+
table: block,
|
|
539
|
+
updateOuter: (newInner: TableNode): TableNode => {
|
|
540
|
+
const newCell = {
|
|
541
|
+
...cell,
|
|
542
|
+
children: cell.children.map((ch, idx) =>
|
|
543
|
+
idx === capturedBi ? newInner : ch,
|
|
544
|
+
),
|
|
545
|
+
};
|
|
546
|
+
const newRow = {
|
|
547
|
+
...row,
|
|
548
|
+
cells: row.cells.map((c, idx) => (idx === capturedCi ? newCell : c)),
|
|
549
|
+
};
|
|
550
|
+
return {
|
|
551
|
+
...outerTable,
|
|
552
|
+
rows: outerTable.rows.map((r, idx) => (idx === capturedRi ? newRow : r)),
|
|
553
|
+
};
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Phase U — cellMerge accept/reject.
|
|
564
|
+
*
|
|
565
|
+
* LibreOffice does NOT treat `w:vMerge` as a redline topology change — it's
|
|
566
|
+
* a rendering/property annotation. The Word-generated `<w:cellMerge>`
|
|
567
|
+
* marker under `<w:tcPr>` records that a user merged cells with
|
|
568
|
+
* change-tracking on; accept/reject flips the canonical cell's
|
|
569
|
+
* `verticalMerge` property. No row structure changes, no content merges.
|
|
570
|
+
*
|
|
571
|
+
* - accept(cellMerge, direction="rest") → verticalMerge = "restart"
|
|
572
|
+
* - accept(cellMerge, direction="cont") → verticalMerge = "continue"
|
|
573
|
+
* - reject(cellMerge, *) → verticalMerge = undefined
|
|
574
|
+
*
|
|
575
|
+
* The serializer drops the `<w:cellMerge>` marker on both accept and reject
|
|
576
|
+
* via `serializeStructuralTableMarkup`.
|
|
577
|
+
*/
|
|
578
|
+
function applyCellMergeAction(
|
|
579
|
+
options: ApplyRevisionActionOptions,
|
|
580
|
+
revision: RevisionRecord,
|
|
581
|
+
): ApplyRevisionActionResult {
|
|
582
|
+
const coords = revision.metadata.tableRevisionCoordinates;
|
|
583
|
+
if (!coords || typeof coords.cellIndex !== "number") {
|
|
584
|
+
return skippedResult(
|
|
585
|
+
options,
|
|
586
|
+
"invalid-range",
|
|
587
|
+
`cellMerge ${options.intent} needs tableRevisionCoordinates.cellIndex.`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
const resolved = resolveCanonicalTableRow(options.document, coords);
|
|
591
|
+
if (!resolved) {
|
|
592
|
+
return skippedResult(
|
|
593
|
+
options,
|
|
594
|
+
"invalid-range",
|
|
595
|
+
`cellMerge ${options.intent} coordinates (tableOrdinal=${coords.tableOrdinal}, rowIndex=${coords.rowIndex}) do not resolve to a table row.`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
const row = resolved.table.rows[coords.rowIndex];
|
|
599
|
+
if (!row || coords.cellIndex >= row.cells.length) {
|
|
600
|
+
return skippedResult(
|
|
601
|
+
options,
|
|
602
|
+
"invalid-range",
|
|
603
|
+
`cellMerge ${options.intent} cellIndex=${coords.cellIndex} out of range for row with ${row?.cells.length ?? 0} cells.`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
const cell = row.cells[coords.cellIndex]!;
|
|
607
|
+
|
|
608
|
+
const direction = revision.metadata.cellMergeData?.direction ?? "rest";
|
|
609
|
+
const nextVerticalMerge: "restart" | "continue" | undefined =
|
|
610
|
+
options.intent === "accept"
|
|
611
|
+
? direction === "cont"
|
|
612
|
+
? "continue"
|
|
613
|
+
: "restart"
|
|
614
|
+
: undefined;
|
|
615
|
+
|
|
616
|
+
// Build the new cell with updated verticalMerge. We rebuild with a clean
|
|
617
|
+
// property set so "reject" genuinely clears the property (spread with
|
|
618
|
+
// `verticalMerge: undefined` doesn't drop the key under TS structural
|
|
619
|
+
// equality, so construct explicitly).
|
|
620
|
+
const { verticalMerge: _drop, ...cellRest } = cell;
|
|
621
|
+
const nextCell = nextVerticalMerge
|
|
622
|
+
? { ...cellRest, verticalMerge: nextVerticalMerge }
|
|
623
|
+
: { ...cellRest };
|
|
624
|
+
|
|
625
|
+
const nextRow = {
|
|
626
|
+
...row,
|
|
627
|
+
cells: row.cells.map((c, idx) => (idx === coords.cellIndex ? nextCell : c)),
|
|
628
|
+
};
|
|
629
|
+
const nextTable = {
|
|
630
|
+
...resolved.table,
|
|
631
|
+
rows: resolved.table.rows.map((r, idx) => (idx === coords.rowIndex ? nextRow : r)),
|
|
632
|
+
};
|
|
633
|
+
const nextDocument = resolved.buildNextDocument(nextTable);
|
|
634
|
+
|
|
635
|
+
const resultingStatus = toResultingStatus(options.intent);
|
|
636
|
+
return {
|
|
637
|
+
document: nextDocument,
|
|
638
|
+
store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
|
|
639
|
+
mapping: createEmptyMapping(),
|
|
640
|
+
outcome: {
|
|
641
|
+
kind: "applied",
|
|
642
|
+
revisionId: revision.revisionId,
|
|
643
|
+
intent: options.intent,
|
|
644
|
+
resultingStatus,
|
|
645
|
+
contentChanged: true,
|
|
646
|
+
},
|
|
647
|
+
detachedRevisionIds: [],
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function applyStructuralTableAction(
|
|
652
|
+
options: ApplyRevisionActionOptions,
|
|
653
|
+
revision: RevisionRecord,
|
|
654
|
+
): ApplyRevisionActionResult {
|
|
655
|
+
const originalType = revision.metadata.originalRevisionType;
|
|
656
|
+
|
|
657
|
+
// Determine whether this operation removes the cell from the canonical doc:
|
|
658
|
+
// - cellDel accept: deletion approved — cell is removed.
|
|
659
|
+
// - cellIns reject: insertion undone — cell is removed.
|
|
660
|
+
// The mirror cases (cellIns accept, cellDel reject) are status-flips only;
|
|
661
|
+
// the serializer drops the marker and the canonical model is unchanged.
|
|
662
|
+
const shouldRemoveCell =
|
|
663
|
+
(originalType === "cellDel" && options.intent === "accept") ||
|
|
664
|
+
(originalType === "cellIns" && options.intent === "reject");
|
|
665
|
+
|
|
666
|
+
if (shouldRemoveCell) {
|
|
667
|
+
const coords = revision.metadata.tableRevisionCoordinates;
|
|
668
|
+
if (!coords || typeof coords.cellIndex !== "number") {
|
|
669
|
+
return skippedResult(
|
|
670
|
+
options,
|
|
671
|
+
"invalid-range",
|
|
672
|
+
`${originalType} ${options.intent} needs tableRevisionCoordinates.cellIndex; record was parsed before Phase K or is malformed.`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
const resolved = resolveCanonicalTableRow(options.document, coords);
|
|
676
|
+
if (!resolved) {
|
|
677
|
+
return skippedResult(
|
|
678
|
+
options,
|
|
679
|
+
"invalid-range",
|
|
680
|
+
`${originalType} ${options.intent} coordinates (tableOrdinal=${coords.tableOrdinal}, rowIndex=${coords.rowIndex}) do not resolve to a table row in the current document.`,
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
const row = resolved.table.rows[coords.rowIndex];
|
|
684
|
+
if (!row || coords.cellIndex >= row.cells.length) {
|
|
685
|
+
return skippedResult(
|
|
686
|
+
options,
|
|
687
|
+
"invalid-range",
|
|
688
|
+
`${originalType} ${options.intent} cellIndex=${coords.cellIndex} out of range for row with ${row?.cells.length ?? 0} cells.`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Single-cell row: removing the only cell means removing the row entirely.
|
|
693
|
+
const nextTable =
|
|
694
|
+
row.cells.length <= 1
|
|
695
|
+
? removeTableRowPure(resolved.table, coords.rowIndex)
|
|
696
|
+
: removeCellFromRow(resolved.table, coords.rowIndex, coords.cellIndex);
|
|
697
|
+
|
|
698
|
+
const nextDocument = resolved.buildNextDocument(nextTable);
|
|
699
|
+
const resultingStatus = toResultingStatus(options.intent);
|
|
700
|
+
return {
|
|
701
|
+
document: nextDocument,
|
|
702
|
+
store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
|
|
703
|
+
mapping: createEmptyMapping(),
|
|
704
|
+
outcome: {
|
|
705
|
+
kind: "applied",
|
|
706
|
+
revisionId: revision.revisionId,
|
|
707
|
+
intent: options.intent,
|
|
708
|
+
resultingStatus,
|
|
709
|
+
contentChanged: true,
|
|
710
|
+
},
|
|
711
|
+
detachedRevisionIds: [],
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Status-flip only:
|
|
716
|
+
// cellIns accept = keep the inserted cell (serializer drops the marker).
|
|
717
|
+
// cellDel reject = keep the cell, undo the deletion tracking (serializer drops the marker).
|
|
718
|
+
const resultingStatus = toResultingStatus(options.intent);
|
|
719
|
+
return {
|
|
720
|
+
document: options.document,
|
|
721
|
+
store: setRevisionStatus(options.store, revision.revisionId, resultingStatus),
|
|
722
|
+
mapping: createEmptyMapping(),
|
|
723
|
+
outcome: {
|
|
724
|
+
kind: "applied",
|
|
725
|
+
revisionId: revision.revisionId,
|
|
726
|
+
intent: options.intent,
|
|
727
|
+
resultingStatus,
|
|
728
|
+
contentChanged: false,
|
|
729
|
+
},
|
|
730
|
+
detachedRevisionIds: [],
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
254
734
|
function skippedResult(
|
|
255
735
|
options: ApplyRevisionActionOptions,
|
|
256
736
|
reason: RevisionActionSkipReason,
|
|
@@ -96,6 +96,8 @@ export function createRevisionRecord(
|
|
|
96
96
|
ooxmlRevisionId: params.metadata?.ooxmlRevisionId,
|
|
97
97
|
propertyChangeData: params.metadata?.propertyChangeData,
|
|
98
98
|
moveData: params.metadata?.moveData,
|
|
99
|
+
cellMergeData: params.metadata?.cellMergeData,
|
|
100
|
+
tableRevisionCoordinates: params.metadata?.tableRevisionCoordinates,
|
|
99
101
|
},
|
|
100
102
|
});
|
|
101
103
|
}
|
|
@@ -113,6 +115,19 @@ export function upsertRevisionRecord(
|
|
|
113
115
|
};
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Set the status of a revision. Returns the store unchanged (no-op) when:
|
|
120
|
+
* - `revisionId` is not present in the store,
|
|
121
|
+
* - the revision is `detached`,
|
|
122
|
+
* - the caller asks to transition to `accepted` / `rejected` but the
|
|
123
|
+
* revision is not `actionable` (e.g. preserve-only imported revisions).
|
|
124
|
+
*
|
|
125
|
+
* This function is pure machinery; it has no emit capability. User-visible
|
|
126
|
+
* signaling for silent no-ops on `acceptChange` / `rejectChange` lives in
|
|
127
|
+
* `applyReviewCommand` in `src/core/commands/index.ts`, which emits a
|
|
128
|
+
* `review_target_not_found` transient warning via `effects.transientWarnings`
|
|
129
|
+
* when `applyRevisionRuntimeCommand` produces no applied outcome.
|
|
130
|
+
*/
|
|
116
131
|
export function setRevisionStatus(
|
|
117
132
|
store: RevisionStore,
|
|
118
133
|
revisionId: string,
|
|
@@ -32,6 +32,53 @@ export interface MoveData {
|
|
|
32
32
|
linkedRevisionId?: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Payload for `w:cellMerge` revisions. `direction` is the `w:vMerge`
|
|
37
|
+
* attribute on the marker:
|
|
38
|
+
* `"rest"` — this cell begins a vertical merge span (restart cell).
|
|
39
|
+
* `"cont"` — this cell continues a vertical merge span.
|
|
40
|
+
*
|
|
41
|
+
* Accept commits `TableCellNode.verticalMerge` to the matching state;
|
|
42
|
+
* reject clears `verticalMerge` to undefined. No row topology changes
|
|
43
|
+
* (mirrors LibreOffice's treatment — vMerge is a rendering property, not
|
|
44
|
+
* a structural mutation).
|
|
45
|
+
*/
|
|
46
|
+
export interface CellMergeData {
|
|
47
|
+
direction: "rest" | "cont";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ordinal coordinates of a structural table revision within the canonical
|
|
52
|
+
* document tree.
|
|
53
|
+
*
|
|
54
|
+
* Lane 7b row-insertion revisions (`<w:trPr><w:ins/></w:trPr>`) point at an
|
|
55
|
+
* entire row, not a text range — the runtime anchor position alone can't
|
|
56
|
+
* identify which row to remove on reject. This envelope carries the table's
|
|
57
|
+
* ordinal (the Nth `<w:tbl>` in the body) and the row's index within that
|
|
58
|
+
* table, both fixed at parse time. Accept/reject resolves these to a
|
|
59
|
+
* canonical-document `tableBlockIndex` by walking `doc.content.children[]`
|
|
60
|
+
* at action time.
|
|
61
|
+
*/
|
|
62
|
+
export interface TableRevisionCoordinates {
|
|
63
|
+
tableOrdinal: number;
|
|
64
|
+
rowIndex: number;
|
|
65
|
+
/**
|
|
66
|
+
* Cell index within `row.cells[]`. Present on cell-level structural
|
|
67
|
+
* revisions (`cellIns` / `cellDel` / `cellMerge`); `undefined` on
|
|
68
|
+
* row-level records (`row-ins` / `row-del`).
|
|
69
|
+
*/
|
|
70
|
+
cellIndex?: number;
|
|
71
|
+
/**
|
|
72
|
+
* For revisions inside nested tables (a `<w:tbl>` inside a cell), this
|
|
73
|
+
* carries the full ordinal path so `resolveCanonicalTableRow` can
|
|
74
|
+
* tree-walk the canonical document:
|
|
75
|
+
* `[bodyTableOrdinal, nestedTableIndex, ...]`
|
|
76
|
+
* Body-level revisions omit this field and use the flat `tableOrdinal`.
|
|
77
|
+
* Lane 7b Phase Q.
|
|
78
|
+
*/
|
|
79
|
+
tableOrdinalPath?: readonly number[];
|
|
80
|
+
}
|
|
81
|
+
|
|
35
82
|
export interface RevisionMetadataEnvelope {
|
|
36
83
|
source: "runtime" | "import";
|
|
37
84
|
storyTarget?: RevisionStoryTargetRecord;
|
|
@@ -56,6 +103,8 @@ export interface RevisionMetadataEnvelope {
|
|
|
56
103
|
ooxmlRevisionId?: string;
|
|
57
104
|
propertyChangeData?: PropertyChangeData;
|
|
58
105
|
moveData?: MoveData;
|
|
106
|
+
cellMergeData?: CellMergeData;
|
|
107
|
+
tableRevisionCoordinates?: TableRevisionCoordinates;
|
|
59
108
|
}
|
|
60
109
|
|
|
61
110
|
export type PropertyChangeRevision = RevisionRecord & {
|
|
@@ -140,6 +189,33 @@ export function getRevisionActionability(
|
|
|
140
189
|
| RevisionKind
|
|
141
190
|
| Pick<RevisionRecord, "kind" | "metadata">,
|
|
142
191
|
): RevisionActionability {
|
|
192
|
+
// Lane 7b promotions run BEFORE the preserveOnlyReason check on purpose:
|
|
193
|
+
// `createRevisionRecord` injects a default "Imported preserve-only revision."
|
|
194
|
+
// reason for any kind that `getRevisionActionability(kind)` returns as
|
|
195
|
+
// preserve-only (see revision-store.ts), which covers both `kind: "move"`
|
|
196
|
+
// and `kind: "formatting"`. The overrides below flip the default back to
|
|
197
|
+
// actionable for the specific shapes that Lane 7b promoted: cellIns
|
|
198
|
+
// structural-table revisions, and linked-pair move revisions. Do not add a
|
|
199
|
+
// `&& !preserveOnlyReason` guard here — that would re-lock these shapes
|
|
200
|
+
// against the injected default and regress 7b.
|
|
201
|
+
if (
|
|
202
|
+
typeof revision !== "string" &&
|
|
203
|
+
(revision.metadata.originalRevisionType === "cellIns" ||
|
|
204
|
+
revision.metadata.originalRevisionType === "cellDel" ||
|
|
205
|
+
revision.metadata.originalRevisionType === "cellMerge")
|
|
206
|
+
) {
|
|
207
|
+
return "actionable";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
typeof revision !== "string" &&
|
|
212
|
+
revision.kind === "move" &&
|
|
213
|
+
typeof revision.metadata.moveData?.linkedRevisionId === "string" &&
|
|
214
|
+
revision.metadata.moveData.linkedRevisionId.length > 0
|
|
215
|
+
) {
|
|
216
|
+
return "actionable";
|
|
217
|
+
}
|
|
218
|
+
|
|
143
219
|
if (
|
|
144
220
|
typeof revision !== "string" &&
|
|
145
221
|
typeof revision.metadata.preserveOnlyReason === "string" &&
|
|
@@ -157,6 +157,19 @@ export interface RemoteCursorTrackerHandle {
|
|
|
157
157
|
* last published. Excludes the local client.
|
|
158
158
|
*/
|
|
159
159
|
getRemoteCursors(): RemoteCursorState[];
|
|
160
|
+
/**
|
|
161
|
+
* Publishes the local peer's cursor position to awareness. If
|
|
162
|
+
* `suppressNextPublish()` was called since the last publish, this call
|
|
163
|
+
* is a no-op and the suppress flag is cleared (one-shot semantics).
|
|
164
|
+
*/
|
|
165
|
+
publishLocalCursor(state: RemoteCursorState): void;
|
|
166
|
+
/**
|
|
167
|
+
* Suppresses the next `publishLocalCursor` call. The flag is
|
|
168
|
+
* consumed by the next publish attempt regardless of whether it
|
|
169
|
+
* actually skipped — calling this twice before a publish is still
|
|
170
|
+
* one skip.
|
|
171
|
+
*/
|
|
172
|
+
suppressNextPublish(): void;
|
|
160
173
|
destroy(): void;
|
|
161
174
|
}
|
|
162
175
|
|
|
@@ -175,6 +188,7 @@ export function createRemoteCursorTracker(
|
|
|
175
188
|
): RemoteCursorTrackerHandle {
|
|
176
189
|
const { awareness, localClientId, commandAppliedBridge } = options;
|
|
177
190
|
const cache = new Map<number, RemoteCursorState>();
|
|
191
|
+
let suppressNext = false;
|
|
178
192
|
|
|
179
193
|
function setFromAwareness(clientId: number): void {
|
|
180
194
|
if (clientId === localClientId) {
|
|
@@ -251,6 +265,16 @@ export function createRemoteCursorTracker(
|
|
|
251
265
|
getRemoteCursors() {
|
|
252
266
|
return [...cache.values()];
|
|
253
267
|
},
|
|
268
|
+
publishLocalCursor(state: RemoteCursorState) {
|
|
269
|
+
if (suppressNext) {
|
|
270
|
+
suppressNext = false;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
setLocalCursorState(awareness, state);
|
|
274
|
+
},
|
|
275
|
+
suppressNextPublish() {
|
|
276
|
+
suppressNext = true;
|
|
277
|
+
},
|
|
254
278
|
destroy() {
|
|
255
279
|
awareness.off("change", onAwarenessChange);
|
|
256
280
|
unsubscribeCommandApplied?.();
|