@stll/folio-core 0.54.0 → 0.54.1

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.
@@ -1486,6 +1486,13 @@ const finalParagraphPredecessor = (doc, at) => {
1486
1486
  for (let index = $emptied.index() - 1; index >= 0; index--) {
1487
1487
  const node = $emptied.parent.child(index);
1488
1488
  position -= node.nodeSize;
1489
+ if (node.type.spec["tableRole"] === "table" && node.childCount > 0) {
1490
+ let allRowsDeleted = true;
1491
+ node.forEach((row) => {
1492
+ allRowsDeleted &&= expectTableRowAttrs(row).trDel != null;
1493
+ });
1494
+ if (allRowsDeleted) continue;
1495
+ }
1489
1496
  if (node.type !== emptied.type) return null;
1490
1497
  if (isPlainDeletedPPrMark(node.attrs["pPrMark"]) && holdsOnlyDeletedContent(node)) continue;
1491
1498
  return {
@@ -1701,6 +1708,24 @@ const withRotatedAddedFinalBreaks = ({ tr, batchRevisionIds, revisionSeed, autho
1701
1708
  };
1702
1709
  collectSupersededDeletedBreaks(tr.doc, 0);
1703
1710
  for (const { preceding, inserted, deletedMark, precedingMark } of supersededDeletedBreaks) {
1711
+ const insertionRevisionId = addedBreakRevisionId(precedingMark);
1712
+ if (insertionRevisionId !== null) {
1713
+ const previous = tr.doc.nodeAt(preceding);
1714
+ const current = tr.doc.nodeAt(inserted);
1715
+ if (!previous || !current) panic("A shifted paragraph break lost its carrier");
1716
+ const previousFormatting = paragraphPropertiesOnReject(previous);
1717
+ const existing = expectParagraphAttrs(current)._propertyChanges;
1718
+ if (!hasSerializableParagraphPropertyChange(existing) && JSON.stringify(previousFormatting) !== JSON.stringify(paragraphPropertiesSnapshot(current))) tr.setNodeAttribute(inserted, "_propertyChanges", [...Array.isArray(existing) ? existing : [], {
1719
+ type: "paragraphPropertyChange",
1720
+ info: {
1721
+ id: insertionRevisionId,
1722
+ author,
1723
+ date,
1724
+ ...initials ? { initials } : {}
1725
+ },
1726
+ previousFormatting
1727
+ }]);
1728
+ }
1704
1729
  tr.setNodeAttribute(preceding, "pPrMark", precedingMark);
1705
1730
  tr.setNodeAttribute(inserted, "pPrMark", deletedMark);
1706
1731
  }
@@ -542,7 +542,7 @@ const tableCellParagraphPropertySourceBindingForTransport = (paragraph, inspecti
542
542
  };
543
543
  /** Prepare opaque continuation cells for ProseMirror and Yjs transport. */
544
544
  const transportTableCellsWithParagraphPropertySources = (cells) => {
545
- const cloned = structuredClone(cells);
545
+ const cloned = cells.map((cell) => structuredClone(cell));
546
546
  const sources = paragraphsInTableCells(cells);
547
547
  const targets = paragraphsInTableCells(cloned);
548
548
  if (sources.length !== targets.length) panic("The cloned table cells changed paragraph graph ownership.");
@@ -0,0 +1,17 @@
1
+ import { document_d_exports } from "../types/document.js";
2
+ //#region src/docx/verticalMergeProjection.d.ts
3
+ /** The reader and revision resolver share which continuation cells remain visible. */
4
+ type RowSpanInfo = {
5
+ rowSpan: number;
6
+ skip: boolean;
7
+ preserveVMergeRestart?: boolean;
8
+ continuationCells?: document_d_exports.TableCell[];
9
+ };
10
+ declare function calculateRowSpans(table: document_d_exports.Table): Map<string, RowSpanInfo>;
11
+ /**
12
+ * Whether a cell carries content a vertical-merge continuation must not hide.
13
+ * The reader keeps such a continuation as a cell of its own.
14
+ */
15
+ declare function tableCellHasMeaningfulContent(cell: document_d_exports.TableCell): boolean;
16
+ //#endregion
17
+ export { RowSpanInfo, calculateRowSpans, tableCellHasMeaningfulContent };
@@ -0,0 +1,120 @@
1
+ import { isCellMergeContinuation } from "./tableParser.js";
2
+ //#region src/docx/verticalMergeProjection.ts
3
+ function calculateRowSpans(table) {
4
+ const result = /* @__PURE__ */ new Map();
5
+ const numRows = table.rows.length;
6
+ const activeMerges = /* @__PURE__ */ new Map();
7
+ for (let rowIndex = 0; rowIndex < numRows; rowIndex++) {
8
+ const row = table.rows[rowIndex];
9
+ if (row.cells.length === 0) {
10
+ clearActiveVerticalMerges(activeMerges, result);
11
+ continue;
12
+ }
13
+ let colIndex = row.formatting?.gridBefore ?? 0;
14
+ const rowCells = row.cells.map((cell) => {
15
+ const colspan = cell.formatting?.gridSpan ?? 1;
16
+ const vMerge = cell.formatting?.vMerge;
17
+ const isMergeContinuation = isCellMergeContinuation(cell);
18
+ const startRow = isMergeContinuation ? activeMerges.get(colIndex) : void 0;
19
+ const info = {
20
+ cell,
21
+ colIndex,
22
+ colspan,
23
+ vMerge,
24
+ isMergeContinuation,
25
+ startRow,
26
+ hasMeaningfulContent: tableCellHasMeaningfulContent(cell),
27
+ shouldSkip: isMergeContinuation && startRow !== void 0
28
+ };
29
+ colIndex += colspan;
30
+ return info;
31
+ });
32
+ const rowWouldBeEmpty = rowCells.length > 0 && rowCells.every((cell) => cell.shouldSkip);
33
+ for (const cellInfo of rowCells) {
34
+ const { colIndex: cellColIndex, vMerge, isMergeContinuation, startRow, hasMeaningfulContent } = cellInfo;
35
+ const key = `${rowIndex}-${cellColIndex}`;
36
+ if (vMerge === "restart") {
37
+ closeVerticalMerge(activeMerges, result, cellColIndex);
38
+ activeMerges.set(cellColIndex, rowIndex);
39
+ result.set(key, {
40
+ rowSpan: 1,
41
+ skip: false
42
+ });
43
+ } else if (isMergeContinuation) {
44
+ if (startRow === void 0 || rowWouldBeEmpty || hasMeaningfulContent) {
45
+ result.set(key, {
46
+ rowSpan: 1,
47
+ skip: false
48
+ });
49
+ if ((rowWouldBeEmpty || hasMeaningfulContent) && startRow !== void 0) {
50
+ const restartCell = result.get(`${startRow}-${cellColIndex}`);
51
+ if (restartCell) restartCell.preserveVMergeRestart = true;
52
+ activeMerges.delete(cellColIndex);
53
+ }
54
+ continue;
55
+ }
56
+ const startKey = `${startRow}-${cellColIndex}`;
57
+ const startCell = result.get(startKey);
58
+ if (startCell) {
59
+ startCell.rowSpan++;
60
+ startCell.continuationCells ??= [];
61
+ startCell.continuationCells.push(cellInfo.cell);
62
+ }
63
+ result.set(key, {
64
+ rowSpan: 1,
65
+ skip: true
66
+ });
67
+ } else {
68
+ closeVerticalMerge(activeMerges, result, cellColIndex);
69
+ result.set(key, {
70
+ rowSpan: 1,
71
+ skip: false
72
+ });
73
+ }
74
+ }
75
+ }
76
+ clearActiveVerticalMerges(activeMerges, result);
77
+ return result;
78
+ }
79
+ /**
80
+ * End the vertical merge active in one column.
81
+ *
82
+ * A `w:vMerge="restart"` is carried through the editor by its cell's rowspan,
83
+ * which only exists once a continuation joins it. A merge that closes with a
84
+ * rowspan of 1 — a restart with no continuation, one interrupted by a plain
85
+ * cell, one whose continuation is in a row a revision removed — has nothing
86
+ * but this flag to say the cell was a merge origin, and dropping it changes
87
+ * the table's visible structure.
88
+ */
89
+ function closeVerticalMerge(activeMerges, result, colIndex) {
90
+ const startRow = activeMerges.get(colIndex);
91
+ if (startRow === void 0) return;
92
+ const restartCell = result.get(`${startRow}-${colIndex}`);
93
+ if (restartCell && restartCell.rowSpan === 1) restartCell.preserveVMergeRestart = true;
94
+ activeMerges.delete(colIndex);
95
+ }
96
+ function clearActiveVerticalMerges(activeMerges, result) {
97
+ for (const colIndex of [...activeMerges.keys()]) closeVerticalMerge(activeMerges, result, colIndex);
98
+ }
99
+ /**
100
+ * Whether a cell carries content a vertical-merge continuation must not hide.
101
+ * The reader keeps such a continuation as a cell of its own.
102
+ */
103
+ function tableCellHasMeaningfulContent(cell) {
104
+ return cell.content.some(blockHasMeaningfulContent);
105
+ }
106
+ function blockHasMeaningfulContent(block) {
107
+ if (block.type === "preservedBlock") return true;
108
+ if (block.type === "bookmarkStart" || block.type === "bookmarkEnd") return true;
109
+ if (block.type === "table") return block.rows.some((row) => row.cells.some((cell) => tableCellHasMeaningfulContent(cell)));
110
+ if (block.type === "blockSdt" || block.type === "blockCustomXml") return true;
111
+ return block.content.some(paragraphContentHasMeaningfulContent);
112
+ }
113
+ function paragraphContentHasMeaningfulContent(content) {
114
+ if (content.type === "run") return content.content.length > 0;
115
+ if (content.type === "hyperlink") return content.children.some(paragraphContentHasMeaningfulContent);
116
+ if (content.type === "insertion" || content.type === "deletion" || content.type === "moveFrom" || content.type === "moveTo") return content.content.some(paragraphContentHasMeaningfulContent);
117
+ return true;
118
+ }
119
+ //#endregion
120
+ export { calculateRowSpans, tableCellHasMeaningfulContent };
@@ -287,7 +287,12 @@ function resolveChange(from, to, mode, revisionIds) {
287
287
  const cell = tr.doc.nodeAt(mappedPos);
288
288
  if (!cell || cell.type.name !== "tableCell" && cell.type.name !== "tableHeader") continue;
289
289
  if (op.type === "merge") {
290
- const resolved = op.source === "collapsed" ? resolveCollapsedTableCellMerge(tr, mappedPos, op.mode, op.revisionSet) : resolveVisibleTableCellMerge(tr, mappedPos, op.mode);
290
+ const resolved = op.source === "collapsed" ? resolveCollapsedTableCellMerge(tr, mappedPos, op.mode, op.revisionSet) : resolveVisibleTableCellMerge({
291
+ tr,
292
+ cellPos: mappedPos,
293
+ mode: op.mode,
294
+ revisionSet: op.revisionSet
295
+ });
291
296
  if (!resolved) {
292
297
  failedTableCellMergeResolution = true;
293
298
  break;
@@ -1,3 +1,4 @@
1
+ import { isStyleSourcedParagraphNumbering } from "../../internal/paragraphFormattingSerialization.js";
1
2
  import { expectParagraphAttrs } from "../attrs/index.js";
2
3
  import { readParagraphNumberingAttr } from "../numberingAttr.js";
3
4
  import { directParagraphAlignment } from "../paragraphAlignment.js";
@@ -126,7 +127,9 @@ const paragraphPropertiesSnapshot = (node) => {
126
127
  const attrs = expectParagraphAttrs(node);
127
128
  const snapshot = {};
128
129
  for (const key of PPR_CHANGE_SCOPED_ATTR_KEYS) {
129
- if (key === "alignment" || PPR_SPACING_ATTR_KEYS.has(key)) continue;
130
+ if (key === "alignment" || PPR_SPACING_ATTR_KEYS.has(key) || key === "numPr" && isStyleSourcedParagraphNumbering(attrs.numPr, attrs.numPrFromStyle)) continue;
131
+ if (styleSuppliedParagraphValue(attrs, key)) continue;
132
+ if (key === "hangingIndent" && styleSuppliedParagraphValue(attrs, "indentFirstLine")) continue;
130
133
  if (styleSuppliedParagraphValue(attrs, key)) continue;
131
134
  if (key === "hangingIndent" && styleSuppliedParagraphValue(attrs, "indentFirstLine")) continue;
132
135
  const value = attrs[key];
@@ -5,7 +5,8 @@ import { nodePropertyRevisionSites, propertyRevisionRecords } from "../revisionC
5
5
  import { getTableCellMergeChange } from "../tableCellMergeRevision.js";
6
6
  import { isTableCellRetainedInReviewView } from "../tableCellRevisionVisibility.js";
7
7
  import { resolveAllNodePropertyChangeAttrs } from "./resolveNodePropertyChangeAttrs.js";
8
- import { acceptedMergeFoldsIntoCellAbove, createRestoredTableCell, hasMatchingCollapsedTableCellMerge, tableCellContinuationCells, tableCellContinuationFromNode, tableCellContinuationPayload } from "./tableCellMergeResolution.js";
8
+ import { createRestoredTableCell, hasMatchingCollapsedTableCellMerge, tableCellContinuationCells, tableCellContinuationFromNode, tableCellContinuationPayload } from "./tableCellMergeResolution.js";
9
+ import { resolvedTableMergeOriginAttrs, resolvedVisibleTableCellMergeAttrs, tableMergeFoldDecisions } from "./tableMergeFoldDecisions.js";
9
10
  import { panic } from "better-result";
10
11
  import { TableMap } from "prosemirror-tables";
11
12
  import { StepMap } from "prosemirror-transform";
@@ -358,6 +359,12 @@ const compactColumnWidths = (widths, removals) => {
358
359
  /** Resolve all cell edits in reverse order against one grid and mutable slots. */
359
360
  const resolvePureTableMerges = ({ table, mode, rowMetadata, tablePropertyTouched, sourceTable }) => {
360
361
  const map = TableMap.get(table);
362
+ const mergeDecisions = tableMergeFoldDecisions({
363
+ table,
364
+ mode,
365
+ revisionSet: null
366
+ });
367
+ if (mergeDecisions.invalid.size > 0) return failedTableResolution(table);
361
368
  const cellRectangle = indexedTableCells(map);
362
369
  const rows = Array.from({ length: table.childCount }, (_, index) => table.child(index));
363
370
  const rowCells = rows.map(() => /* @__PURE__ */ new Map());
@@ -383,7 +390,7 @@ const resolvePureTableMerges = ({ table, mode, rowMetadata, tablePropertyTouched
383
390
  for (let coveredRow = rectangle.top; coveredRow < rectangle.bottom; coveredRow++) rowOccupancy[coveredRow] = elementAt(rowOccupancy, coveredRow) + (rectangle.right - rectangle.left);
384
391
  const initial = elementAt(elementAt(rowMetadata, rowIndex).cells, cellIndex);
385
392
  const entry = {
386
- node,
393
+ node: mergeDecisions.origins.has(cellOffset) ? node.type.create(resolvedTableMergeOriginAttrs(node), node.content, node.marks) : node,
387
394
  position: cellOffset,
388
395
  left,
389
396
  row: rowIndex,
@@ -438,28 +445,15 @@ const resolvePureTableMerges = ({ table, mode, rowMetadata, tablePropertyTouched
438
445
  });
439
446
  }
440
447
  } else if (marker?.kind === "merge") {
441
- const joinsAbove = mode === "accept" ? isTableCellMergeRevisionContinuation(marker.verticalMerge) && acceptedMergeFoldsIntoCellAbove(entry.node) : isTableCellMergeRevisionContinuation(marker.verticalMergeOriginal);
442
- if (mode === "accept" && !joinsAbove) {
443
- entry.touched = true;
444
- entry.node = entry.node.type.create({
445
- ...entry.node.attrs,
446
- cellMarker: null
447
- }, entry.node.content, entry.node.marks);
448
- } else if (!joinsAbove) {
449
- entry.touched = true;
450
- const original = entry.node.attrs["_originalFormatting"];
451
- let formatting = original;
452
- if (typeof original === "object" && original !== null) {
453
- const copy = { ...original };
454
- delete copy["vMerge"];
455
- formatting = copy;
456
- }
457
- entry.node = entry.node.type.create({
458
- ...entry.node.attrs,
459
- cellMarker: null,
460
- _originalFormatting: formatting
461
- }, entry.node.content, entry.node.marks);
462
- } else {
448
+ const joinsAbove = mergeDecisions.folds.get(entry.position);
449
+ if (joinsAbove === void 0) return panic("Merge resolution lost its cell");
450
+ if (mode === "reject" && isTableCellMergeRevisionContinuation(marker.verticalMergeOriginal) && cellRectangle(entry.position).top === 0) return failedTableResolution(table);
451
+ if (!joinsAbove) entry.node = entry.node.type.create(resolvedVisibleTableCellMergeAttrs({
452
+ cell: entry.node,
453
+ mode,
454
+ origin: mergeDecisions.origins.has(entry.position)
455
+ }), entry.node.content, entry.node.marks);
456
+ else {
463
457
  const rectangle = cellRectangle(entry.position);
464
458
  if (rectangle.top === 0) return failedTableResolution(table);
465
459
  const abovePosition = map.map[(rectangle.top - 1) * map.width + rectangle.left];
@@ -1,5 +1,7 @@
1
+ import { resolveParagraphNumbering } from "../../docx/numberingReference.js";
1
2
  import { expectParagraphAttrs } from "../attrs/index.js";
2
3
  import { rejectedListRenderingPatch } from "../listRendering.js";
4
+ import { paragraphNumberingAttr } from "../numberingAttr.js";
3
5
  import { styleResolvedParagraphFormatting } from "../paragraphFormattingProvenance.js";
4
6
  import { resolveParagraphDefaultTextFormatting } from "../styles/paragraphStyleCascade.js";
5
7
  import { paragraphRejectAttrPatch, paragraphRejectOriginalFormatting, removeParagraphPropertyChanges, sectionRejectProperties } from "./propertyChangeScope.js";
@@ -38,11 +40,20 @@ const resolveParagraphChangeAttrs = ({ node, boundaryCovered, mode, revisionSet,
38
40
  ...previousFormattingFromStyle,
39
41
  ...resolvedFromStyle
40
42
  };
43
+ let styleNumbering = previousFormattingFromStyle?.numPr;
44
+ if (!styleResolver && sameStyle) styleNumbering = currentAttrs.numPrFromStyle ?? void 0;
45
+ const inheritedNumbering = styleNumbering == null ? null : paragraphNumberingAttr(resolveParagraphNumbering(styleNumbering));
46
+ const recordedNumbering = rejection.previousFormatting?.numPr;
47
+ const restoredNumbering = recordedNumbering ?? inheritedNumbering;
41
48
  Object.assign(nextAttrs, paragraphRejectAttrPatch(rejection.previousFormatting, inheritedFormatting), rejectedListRenderingPatch({
42
49
  current: expectParagraphAttrs(node),
43
50
  previousFormatting: rejection.previousFormatting,
44
- numbering
45
- }));
51
+ numbering,
52
+ restoredNumbering
53
+ }), {
54
+ numPr: restoredNumbering,
55
+ numPrFromStyle: recordedNumbering == null ? inheritedNumbering : null
56
+ });
46
57
  const restoredFormatting = paragraphRejectOriginalFormatting(rejection.previousFormatting, node.attrs["_originalFormatting"]);
47
58
  nextAttrs["_originalFormatting"] = restoredFormatting;
48
59
  if (styleResolver) nextAttrs["defaultTextFormatting"] = resolveParagraphDefaultTextFormatting(rejection.previousFormatting?.styleId, restoredFormatting ?? void 0, styleResolver) ?? null;
@@ -5,17 +5,16 @@ import { Transaction } from "prosemirror-state";
5
5
  //#region src/prosemirror/commands/tableCellMergeResolution.d.ts
6
6
  declare const tableCellContinuationPayload: (node: Node) => DecodedTableCellParagraphSourcePayload | null;
7
7
  declare const hasMatchingCollapsedTableCellMerge: (node: Node, revisionSet: Set<number> | null) => boolean;
8
- /**
9
- * Whether accepting a pending vertical merge folds the cell into the one above.
10
- * The saved `w:vMerge` continuation is read back as its own cell when it carries
11
- * content, so accepting such a merge keeps the cell standing; folding it would
12
- * hide text that reappears when the document is reopened.
13
- */
14
- declare const acceptedMergeFoldsIntoCellAbove: (cell: Node) => boolean;
15
- declare const resolveVisibleTableCellMerge: (tr: Transaction, cellPos: number, mode: "accept" | "reject") => boolean;
8
+ type ResolveVisibleTableCellMergeOptions = {
9
+ tr: Transaction;
10
+ cellPos: number;
11
+ mode: "accept" | "reject";
12
+ revisionSet: ReadonlySet<number> | null;
13
+ };
14
+ declare const resolveVisibleTableCellMerge: ({ tr, cellPos, mode, revisionSet }: ResolveVisibleTableCellMergeOptions) => boolean;
16
15
  declare const tableCellContinuationCells: (cell: Node, rowspan: number) => document_d_exports.TableCell[];
17
16
  declare const tableCellContinuationFromNode: (cell: Node) => document_d_exports.TableCell;
18
17
  declare const resolveCollapsedTableCellMerge: (tr: Transaction, cellPos: number, mode: "accept" | "reject", revisionSet: Set<number> | null) => boolean;
19
18
  declare const createRestoredTableCell: (origin: Node, source: document_d_exports.TableCell) => Node | null;
20
19
  //#endregion
21
- export { acceptedMergeFoldsIntoCellAbove, createRestoredTableCell, hasMatchingCollapsedTableCellMerge, resolveCollapsedTableCellMerge, resolveVisibleTableCellMerge, tableCellContinuationCells, tableCellContinuationFromNode, tableCellContinuationPayload };
20
+ export { createRestoredTableCell, hasMatchingCollapsedTableCellMerge, resolveCollapsedTableCellMerge, resolveVisibleTableCellMerge, tableCellContinuationCells, tableCellContinuationFromNode, tableCellContinuationPayload };
@@ -1,9 +1,10 @@
1
1
  import { decodeTableCellParagraphSourcePayload, restoreTableCellsWithParagraphPropertySources, transportTableCellsWithParagraphPropertySources } from "../../docx/paragraphPropertySource.js";
2
2
  import { isTableCellMergeRevisionContinuation } from "../../docx/tableParser.js";
3
3
  import { standaloneTableCellFromProseMirror } from "../conversion/fromProseDoc.js";
4
- import { standaloneTableCellToProseMirror, tableCellHasMeaningfulContent } from "../conversion/toProseDoc.js";
4
+ import { standaloneTableCellToProseMirror } from "../conversion/toProseDoc.js";
5
5
  import { getTableCellMergeChange } from "../tableCellMergeRevision.js";
6
6
  import { removeRowsWithoutCellsInRange } from "../tableGridMutation.js";
7
+ import { resolvedTableMergeOriginAttrs, resolvedVisibleTableCellMergeAttrs, tableMergeFoldDecisions } from "./tableMergeFoldDecisions.js";
7
8
  import { Result, panic } from "better-result";
8
9
  import { TableMap } from "prosemirror-tables";
9
10
  //#region src/prosemirror/commands/tableCellMergeResolution.ts
@@ -37,33 +38,35 @@ const tableCellContext = (doc, cellPos) => {
37
38
  }
38
39
  return null;
39
40
  };
40
- /**
41
- * Whether accepting a pending vertical merge folds the cell into the one above.
42
- * The saved `w:vMerge` continuation is read back as its own cell when it carries
43
- * content, so accepting such a merge keeps the cell standing; folding it would
44
- * hide text that reappears when the document is reopened.
45
- */
46
- const acceptedMergeFoldsIntoCellAbove = (cell) => !tableCellHasMeaningfulContent(standaloneTableCellFromProseMirror(cell));
47
- const resolveVisibleTableCellMerge = (tr, cellPos, mode) => {
41
+ const resolveVisibleTableCellMerge = ({ tr, cellPos, mode, revisionSet }) => {
48
42
  const cell = tr.doc.nodeAt(cellPos);
49
43
  const marker = cell?.attrs["cellMarker"];
50
44
  if (!cell || !isTableCellMergeRevisionAttr(marker)) return false;
51
- if (mode === "accept" ? isTableCellMergeRevisionContinuation(marker.verticalMerge) && acceptedMergeFoldsIntoCellAbove(cell) : isTableCellMergeRevisionContinuation(marker.verticalMergeOriginal)) return mergeTableCellWithCellAbove(tr, cellPos);
52
- if (mode === "accept") {
53
- tr.setNodeAttribute(cellPos, "cellMarker", null);
54
- return true;
55
- }
56
- const originalFormatting = cell.attrs["_originalFormatting"];
57
- let nextOriginalFormatting = originalFormatting;
58
- if (typeof originalFormatting === "object" && originalFormatting !== null) {
59
- nextOriginalFormatting = { ...originalFormatting };
60
- delete nextOriginalFormatting.vMerge;
61
- }
62
- tr.setNodeMarkup(cellPos, void 0, {
63
- ...cell.attrs,
64
- cellMarker: null,
65
- _originalFormatting: nextOriginalFormatting
45
+ const context = tableCellContext(tr.doc, cellPos);
46
+ if (!context) return false;
47
+ const rectangle = TableMap.get(context.table).findCell(context.relativeCellPos);
48
+ if (mode === "reject" && isTableCellMergeRevisionContinuation(marker.verticalMergeOriginal) && rectangle.top === 0) return false;
49
+ const decisions = tableMergeFoldDecisions({
50
+ table: context.table,
51
+ mode,
52
+ revisionSet
66
53
  });
54
+ if (decisions.invalid.has(context.relativeCellPos)) return false;
55
+ const folds = decisions.folds.get(context.relativeCellPos);
56
+ if (folds === void 0) return panic("Merge resolution lost its cell");
57
+ for (const origin of decisions.origins) {
58
+ if (origin === context.relativeCellPos) continue;
59
+ const originPos = context.tableStart + origin;
60
+ const originCell = tr.doc.nodeAt(originPos);
61
+ if (!originCell) return panic("Merge resolution lost its origin");
62
+ tr.setNodeMarkup(originPos, void 0, resolvedTableMergeOriginAttrs(originCell));
63
+ }
64
+ if (folds) return mergeTableCellWithCellAbove(tr, cellPos);
65
+ tr.setNodeMarkup(cellPos, void 0, resolvedVisibleTableCellMergeAttrs({
66
+ cell,
67
+ mode,
68
+ origin: decisions.origins.has(context.relativeCellPos)
69
+ }));
67
70
  return true;
68
71
  };
69
72
  const mergeTableCellWithCellAbove = (tr, cellPos) => {
@@ -201,4 +204,4 @@ const createRestoredTableCell = (origin, source) => {
201
204
  return Result.try(() => origin.type.schema.nodeFromJSON(restored.toJSON())).unwrapOr(null);
202
205
  };
203
206
  //#endregion
204
- export { acceptedMergeFoldsIntoCellAbove, createRestoredTableCell, hasMatchingCollapsedTableCellMerge, resolveCollapsedTableCellMerge, resolveVisibleTableCellMerge, tableCellContinuationCells, tableCellContinuationFromNode, tableCellContinuationPayload };
207
+ export { createRestoredTableCell, hasMatchingCollapsedTableCellMerge, resolveCollapsedTableCellMerge, resolveVisibleTableCellMerge, tableCellContinuationCells, tableCellContinuationFromNode, tableCellContinuationPayload };
@@ -0,0 +1,61 @@
1
+ import { Node } from "prosemirror-model";
2
+ //#region src/prosemirror/commands/tableMergeFoldDecisions.d.ts
3
+ type TableMergeFoldDecisionsOptions = {
4
+ table: Node;
5
+ mode: "accept" | "reject";
6
+ revisionSet: ReadonlySet<number> | null;
7
+ };
8
+ /** Resolve raw merge states without converting unrelated table revisions. */
9
+ declare const tableMergeFoldDecisions: ({ table, mode, revisionSet }: TableMergeFoldDecisionsOptions) => {
10
+ folds: Map<number, boolean>;
11
+ origins: Set<number>;
12
+ invalid: Set<number>;
13
+ };
14
+ /** Persist an implicit merge origin even when reader rules retain its continuation. */
15
+ declare const resolvedTableMergeOriginAttrs: (cell: Node) => {
16
+ _originalFormatting: {
17
+ vMerge: string;
18
+ width?: import("@stll/docx-core/model").TableMeasurement;
19
+ borders?: import("@stll/docx-core/model").TableCellBorders;
20
+ margins?: import("@stll/docx-core/model").CellMargins;
21
+ shading?: import("@stll/docx-core/model").ShadingProperties;
22
+ verticalAlign?: "top" | "center" | "bottom";
23
+ textDirection?: import("@stll/docx-core/model").TextDirection;
24
+ gridSpan?: number;
25
+ fitText?: boolean;
26
+ noWrap?: boolean;
27
+ hideMark?: boolean;
28
+ conditionalFormat?: import("@stll/docx-core/model").ConditionalFormatStyle;
29
+ preserved?: import("@stll/docx-core/model").PreservedMarkup;
30
+ sourceXml?: string;
31
+ };
32
+ _preserveVMergeRestart: boolean;
33
+ };
34
+ type ResolvedVisibleTableCellMergeOptions = {
35
+ cell: Node;
36
+ mode: "accept" | "reject";
37
+ origin: boolean;
38
+ };
39
+ /** A retained continuation must save the merge state the reviewer resolved. */
40
+ declare const resolvedVisibleTableCellMergeAttrs: ({ cell, mode, origin }: ResolvedVisibleTableCellMergeOptions) => {
41
+ cellMarker: null;
42
+ _originalFormatting: {
43
+ width?: import("@stll/docx-core/model").TableMeasurement;
44
+ borders?: import("@stll/docx-core/model").TableCellBorders;
45
+ margins?: import("@stll/docx-core/model").CellMargins;
46
+ shading?: import("@stll/docx-core/model").ShadingProperties;
47
+ verticalAlign?: "top" | "center" | "bottom";
48
+ textDirection?: import("@stll/docx-core/model").TextDirection;
49
+ gridSpan?: number;
50
+ vMerge?: "restart" | "continue";
51
+ fitText?: boolean;
52
+ noWrap?: boolean;
53
+ hideMark?: boolean;
54
+ conditionalFormat?: import("@stll/docx-core/model").ConditionalFormatStyle;
55
+ preserved?: import("@stll/docx-core/model").PreservedMarkup;
56
+ sourceXml?: string;
57
+ };
58
+ _preserveVMergeRestart: boolean;
59
+ };
60
+ //#endregion
61
+ export { resolvedTableMergeOriginAttrs, resolvedVisibleTableCellMergeAttrs, tableMergeFoldDecisions };
@@ -0,0 +1,79 @@
1
+ import { isCellMergeContinuation, isCellMergeStart, isTableCellMergeRevisionContinuation } from "../../docx/tableParser.js";
2
+ import { tableCellHasMeaningfulContent } from "../../docx/verticalMergeProjection.js";
3
+ import { expectTableCellAttrs } from "../attrs/index.js";
4
+ import { standaloneTableCellFromProseMirror } from "../conversion/fromProseDoc.js";
5
+ import { panic } from "better-result";
6
+ import { TableMap } from "prosemirror-tables";
7
+ //#region src/prosemirror/commands/tableMergeFoldDecisions.ts
8
+ /** Resolve raw merge states without converting unrelated table revisions. */
9
+ const tableMergeFoldDecisions = ({ table, mode, revisionSet }) => {
10
+ const decisions = /* @__PURE__ */ new Map();
11
+ const origins = /* @__PURE__ */ new Set();
12
+ const invalid = /* @__PURE__ */ new Set();
13
+ const map = TableMap.get(table);
14
+ const visited = /* @__PURE__ */ new Set();
15
+ for (const [index, position] of map.map.entries()) {
16
+ if (visited.has(position)) continue;
17
+ visited.add(position);
18
+ const cell = table.nodeAt(position);
19
+ if (cell?.attrs["cellMarker"]?.kind !== "merge") continue;
20
+ const marker = expectTableCellAttrs(cell).cellMarker;
21
+ if (marker?.kind !== "merge" || revisionSet !== null && !revisionSet.has(marker.info.revisionId)) continue;
22
+ const continues = isTableCellMergeRevisionContinuation(mode === "accept" ? marker.verticalMerge : marker.verticalMergeOriginal);
23
+ decisions.set(position, continues && (mode === "reject" || !tableCellHasMeaningfulContent(standaloneTableCellFromProseMirror(cell))));
24
+ if (!continues) continue;
25
+ if (Math.floor(index / map.width) === 0) {
26
+ invalid.add(position);
27
+ continue;
28
+ }
29
+ const abovePosition = map.map[index - map.width];
30
+ if (abovePosition === void 0) return panic("Merge resolution lost its origin");
31
+ const above = table.nodeAt(abovePosition);
32
+ if (!above) return panic("Merge resolution lost its origin");
33
+ const rectangle = map.findCell(position);
34
+ const aboveRectangle = map.findCell(abovePosition);
35
+ if (rectangle.left !== aboveRectangle.left || rectangle.right !== aboveRectangle.right || rectangle.top !== aboveRectangle.bottom) {
36
+ invalid.add(position);
37
+ continue;
38
+ }
39
+ const aboveMarker = expectTableCellAttrs(above).cellMarker;
40
+ const aboveContinues = aboveMarker?.kind === "merge" && (revisionSet === null || revisionSet.has(aboveMarker.info.revisionId)) && isTableCellMergeRevisionContinuation(mode === "accept" ? aboveMarker.verticalMerge : aboveMarker.verticalMergeOriginal);
41
+ const aboveModel = standaloneTableCellFromProseMirror(above);
42
+ if (mode === "accept" && (aboveContinues && decisions.get(abovePosition) === false || isCellMergeContinuation(aboveModel) && tableCellHasMeaningfulContent(aboveModel))) decisions.set(position, false);
43
+ if (aboveContinues || isCellMergeContinuation(aboveModel) || isCellMergeStart(aboveModel)) continue;
44
+ origins.add(abovePosition);
45
+ }
46
+ return {
47
+ folds: decisions,
48
+ origins,
49
+ invalid
50
+ };
51
+ };
52
+ /** Persist an implicit merge origin even when reader rules retain its continuation. */
53
+ const resolvedTableMergeOriginAttrs = (cell) => ({
54
+ ...cell.attrs,
55
+ _originalFormatting: {
56
+ ...expectTableCellAttrs(cell)._originalFormatting,
57
+ vMerge: "restart"
58
+ },
59
+ _preserveVMergeRestart: true
60
+ });
61
+ /** A retained continuation must save the merge state the reviewer resolved. */
62
+ const resolvedVisibleTableCellMergeAttrs = ({ cell, mode, origin }) => {
63
+ const attrs = expectTableCellAttrs(cell);
64
+ const marker = attrs.cellMarker;
65
+ if (marker?.kind !== "merge") return panic("Cell has no merge revision");
66
+ const continuation = mode === "accept" ? marker.verticalMerge : marker.verticalMergeOriginal;
67
+ const formatting = { ...attrs._originalFormatting };
68
+ if (origin) formatting.vMerge = "restart";
69
+ else if (isTableCellMergeRevisionContinuation(continuation)) formatting.vMerge = "continue";
70
+ else delete formatting.vMerge;
71
+ return {
72
+ ...cell.attrs,
73
+ cellMarker: null,
74
+ _originalFormatting: formatting,
75
+ _preserveVMergeRestart: origin
76
+ };
77
+ };
78
+ //#endregion
79
+ export { resolvedTableMergeOriginAttrs, resolvedVisibleTableCellMergeAttrs, tableMergeFoldDecisions };
@@ -23,11 +23,6 @@ type ToProseDocOptions = {
23
23
  * @param options - Conversion options including style definitions
24
24
  */
25
25
  declare function toProseDoc(document: document_d_exports.Document, options?: ToProseDocOptions): Node;
26
- /**
27
- * Whether a cell carries content a vertical-merge continuation must not hide.
28
- * The reader keeps such a continuation as a cell of its own.
29
- */
30
- declare function tableCellHasMeaningfulContent(cell: document_d_exports.TableCell): boolean;
31
26
  declare function standaloneTableCellToProseMirror(cell: document_d_exports.TableCell, nodeType: "tableCell" | "tableHeader"): Node;
32
27
  /**
33
28
  * Convert HeaderFooter content (array of Paragraph/Table blocks) to a ProseMirror document.
@@ -44,4 +39,4 @@ declare function footnoteToProseDoc(content: document_d_exports.BlockContent[],
44
39
  */
45
40
  declare function createEmptyDoc(): Node;
46
41
  //#endregion
47
- export { ToProseDocOptions, createEmptyDoc, footnoteToProseDoc, headerFooterToProseDoc, standaloneTableCellToProseMirror, tableCellHasMeaningfulContent, toProseDoc };
42
+ export { ToProseDocOptions, createEmptyDoc, footnoteToProseDoc, headerFooterToProseDoc, standaloneTableCellToProseMirror, toProseDoc };
@@ -6,6 +6,7 @@ import { isBaselineVertAlign } from "../../docx/runParser.js";
6
6
  import { replayableShapeAlternateContent } from "../../docx/shapeAlternateContent.js";
7
7
  import { resolveTableLook } from "../../docx/tableLook.js";
8
8
  import { isCellMergeContinuation } from "../../docx/tableParser.js";
9
+ import { calculateRowSpans } from "../../docx/verticalMergeProjection.js";
9
10
  import { copiedWrapPolygon } from "../../docx/wrapPolygon.js";
10
11
  import { buildPageBreakRunSourceDescendantIndex } from "../../internal/pageBreakRunSourceDescendantIndex.js";
11
12
  import { createStyleEngine } from "../../style-engine/styleEngine.js";
@@ -893,122 +894,6 @@ function resolveTextFormatting(formatting, styleResolver) {
893
894
  if (!styleResolver) return formatting;
894
895
  return mergeTextFormatting(styleResolver.resolveRunStyle(formatting.styleId), formatting);
895
896
  }
896
- function calculateRowSpans(table) {
897
- const result = /* @__PURE__ */ new Map();
898
- const numRows = table.rows.length;
899
- const activeMerges = /* @__PURE__ */ new Map();
900
- for (let rowIndex = 0; rowIndex < numRows; rowIndex++) {
901
- const row = table.rows[rowIndex];
902
- if (row.cells.length === 0) {
903
- clearActiveVerticalMerges(activeMerges, result);
904
- continue;
905
- }
906
- let colIndex = row.formatting?.gridBefore ?? 0;
907
- const rowCells = row.cells.map((cell) => {
908
- const colspan = cell.formatting?.gridSpan ?? 1;
909
- const vMerge = cell.formatting?.vMerge;
910
- const isMergeContinuation = isCellMergeContinuation(cell);
911
- const startRow = isMergeContinuation ? activeMerges.get(colIndex) : void 0;
912
- const info = {
913
- cell,
914
- colIndex,
915
- colspan,
916
- vMerge,
917
- isMergeContinuation,
918
- startRow,
919
- hasMeaningfulContent: tableCellHasMeaningfulContent(cell),
920
- shouldSkip: isMergeContinuation && startRow !== void 0
921
- };
922
- colIndex += colspan;
923
- return info;
924
- });
925
- const rowWouldBeEmpty = rowCells.length > 0 && rowCells.every((cell) => cell.shouldSkip);
926
- for (const cellInfo of rowCells) {
927
- const { colIndex: cellColIndex, vMerge, isMergeContinuation, startRow, hasMeaningfulContent } = cellInfo;
928
- const key = `${rowIndex}-${cellColIndex}`;
929
- if (vMerge === "restart") {
930
- closeVerticalMerge(activeMerges, result, cellColIndex);
931
- activeMerges.set(cellColIndex, rowIndex);
932
- result.set(key, {
933
- rowSpan: 1,
934
- skip: false
935
- });
936
- } else if (isMergeContinuation) {
937
- if (startRow === void 0 || rowWouldBeEmpty || hasMeaningfulContent) {
938
- result.set(key, {
939
- rowSpan: 1,
940
- skip: false
941
- });
942
- if ((rowWouldBeEmpty || hasMeaningfulContent) && startRow !== void 0) {
943
- const restartCell = result.get(`${startRow}-${cellColIndex}`);
944
- if (restartCell) restartCell.preserveVMergeRestart = true;
945
- activeMerges.delete(cellColIndex);
946
- }
947
- continue;
948
- }
949
- const startKey = `${startRow}-${cellColIndex}`;
950
- const startCell = result.get(startKey);
951
- if (startCell) {
952
- startCell.rowSpan++;
953
- startCell.continuationCells ??= [];
954
- startCell.continuationCells.push(cellInfo.cell);
955
- }
956
- result.set(key, {
957
- rowSpan: 1,
958
- skip: true
959
- });
960
- } else {
961
- closeVerticalMerge(activeMerges, result, cellColIndex);
962
- result.set(key, {
963
- rowSpan: 1,
964
- skip: false
965
- });
966
- }
967
- }
968
- }
969
- clearActiveVerticalMerges(activeMerges, result);
970
- return result;
971
- }
972
- /**
973
- * End the vertical merge active in one column.
974
- *
975
- * A `w:vMerge="restart"` is carried through the editor by its cell's rowspan,
976
- * which only exists once a continuation joins it. A merge that closes with a
977
- * rowspan of 1 — a restart with no continuation, one interrupted by a plain
978
- * cell, one whose continuation is in a row a revision removed — has nothing
979
- * but this flag to say the cell was a merge origin, and dropping it changes
980
- * the table's visible structure.
981
- */
982
- function closeVerticalMerge(activeMerges, result, colIndex) {
983
- const startRow = activeMerges.get(colIndex);
984
- if (startRow === void 0) return;
985
- const restartCell = result.get(`${startRow}-${colIndex}`);
986
- if (restartCell && restartCell.rowSpan === 1) restartCell.preserveVMergeRestart = true;
987
- activeMerges.delete(colIndex);
988
- }
989
- function clearActiveVerticalMerges(activeMerges, result) {
990
- for (const colIndex of [...activeMerges.keys()]) closeVerticalMerge(activeMerges, result, colIndex);
991
- }
992
- /**
993
- * Whether a cell carries content a vertical-merge continuation must not hide.
994
- * The reader keeps such a continuation as a cell of its own.
995
- */
996
- function tableCellHasMeaningfulContent(cell) {
997
- return cell.content.some(blockHasMeaningfulContent);
998
- }
999
- function blockHasMeaningfulContent(block) {
1000
- if (block.type === "preservedBlock") return true;
1001
- if (block.type === "bookmarkStart" || block.type === "bookmarkEnd") return true;
1002
- if (block.type === "table") return block.rows.some((row) => row.cells.some((cell) => tableCellHasMeaningfulContent(cell)));
1003
- if (block.type === "blockSdt" || block.type === "blockCustomXml") return true;
1004
- return block.content.some(paragraphContentHasMeaningfulContent);
1005
- }
1006
- function paragraphContentHasMeaningfulContent(content) {
1007
- if (content.type === "run") return content.content.length > 0;
1008
- if (content.type === "hyperlink") return content.children.some(paragraphContentHasMeaningfulContent);
1009
- if (content.type === "insertion" || content.type === "deletion" || content.type === "moveFrom" || content.type === "moveTo") return content.content.some(paragraphContentHasMeaningfulContent);
1010
- return true;
1011
- }
1012
897
  function convertTable(table, styleResolver, context) {
1013
898
  for (const row of table.rows) for (const cell of row.cells) {
1014
899
  if (isCellMergeContinuation(cell) && context.pageBreakRunSourceDescendants.containsPageBreakRun(cell.content)) {
@@ -2840,4 +2725,4 @@ function createEmptyDoc() {
2840
2725
  return schema.node("doc", null, [schema.node("paragraph", {}, [])]);
2841
2726
  }
2842
2727
  //#endregion
2843
- export { createEmptyDoc, footnoteToProseDoc, headerFooterToProseDoc, standaloneTableCellToProseMirror, tableCellHasMeaningfulContent, toProseDoc };
2728
+ export { createEmptyDoc, footnoteToProseDoc, headerFooterToProseDoc, standaloneTableCellToProseMirror, toProseDoc };
@@ -7,6 +7,7 @@ import { listRenderingFor, recordsListRendering } from "./listRendering.js";
7
7
  import { LIST_RENDERING_ATTR_KEYS } from "./listRenderingAttrs.js";
8
8
  import { directParagraphIndentation } from "./paragraphIndentation.js";
9
9
  import { getDocumentNumbering } from "./plugins/documentNumbering.js";
10
+ import { getDocumentStyleResolver } from "./plugins/documentStyleState.js";
10
11
  import { SUGGESTION_META, makeRevisionInfo } from "./plugins/suggestionMode.js";
11
12
  import { listAttrsFromNumbering, listLevelIndentAttrPatch } from "./styles/resolvedStyleAttrs.js";
12
13
  //#region src/prosemirror/listNumbering.ts
@@ -20,22 +21,15 @@ const PARAGRAPH_NODE = "paragraph";
20
21
  */
21
22
  const listChangeSnapshot = (node, attrs) => {
22
23
  const previousFormatting = paragraphPropertiesSnapshot(node.type.create(attrs, node.content, node.marks));
23
- previousFormatting["numPr"] = attrs["numPr"] ?? null;
24
+ previousFormatting["numPr"] ??= null;
24
25
  for (const key of LIST_RENDERING_ATTR_KEYS) previousFormatting[key] = attrs[key] ?? null;
25
26
  return previousFormatting;
26
27
  };
27
- /**
28
- * The original state a pending record describes, completed with the list
29
- * attrs it may not state. A record written by a list command states them all.
30
- * One written for another property (or read from a file) states the scope
31
- * only: its numbering is the original's, and an absent one means the original
32
- * had none, so the rendering is recomputed from it rather than copied from
33
- * the live paragraph, whose list may since have changed.
34
- */
35
- const originalListFormatting = (record, numbering) => {
28
+ const originalListFormatting = ({ record, numbering, styleResolver }) => {
36
29
  const original = { ...record };
37
30
  if (recordsListRendering(record)) return original;
38
- Object.assign(original, listRenderingFor(record.numPr, numbering));
31
+ const inherited = styleResolver?.resolveParagraphStyle(record.styleId ?? void 0).paragraphFormatting?.numPr;
32
+ Object.assign(original, listRenderingFor(record.numPr ?? inherited, numbering));
39
33
  original["numPr"] = record.numPr ?? null;
40
34
  return original;
41
35
  };
@@ -54,12 +48,16 @@ const canonicalJson = (value) => JSON.stringify(value, (_key, nested) => nested
54
48
  * the file) keeps its author, id and date: the change joins that revision
55
49
  * rather than re-attributing it, and it is never dropped.
56
50
  */
57
- const trackListChange = ({ current, next, rev, numbering }) => {
51
+ const trackListChange = ({ current, next, rev, numbering, styleResolver }) => {
58
52
  const currentAttrs = expectParagraphAttrs(current);
59
53
  const existing = currentAttrs._propertyChanges ?? [];
60
54
  const pending = existing.find(({ info }) => info.provenance !== "suggested");
61
55
  const retained = existing.filter((change) => change !== pending);
62
- const previousFormatting = pending?.previousFormatting ? originalListFormatting(pending.previousFormatting, numbering) : listChangeSnapshot(current, currentAttrs);
56
+ const previousFormatting = pending?.previousFormatting ? originalListFormatting({
57
+ record: pending.previousFormatting,
58
+ numbering,
59
+ styleResolver
60
+ }) : listChangeSnapshot(current, currentAttrs);
63
61
  const ownPending = pending !== void 0 && pending.info.author === rev.author;
64
62
  if (ownPending && canonicalJson(previousFormatting) === canonicalJson(listChangeSnapshot(current, next))) return {
65
63
  ...next,
@@ -198,12 +196,14 @@ const listItemAttrs = (attrs, { numId, ilvl }, numbering) => ({
198
196
  const applyParagraphUpdates = ({ tr, state, updates }) => {
199
197
  const rev = makeRevisionInfo(state);
200
198
  const numbering = getDocumentNumbering(state);
199
+ const styleResolver = getDocumentStyleResolver(state);
201
200
  for (const { pos, node, next } of updates) {
202
201
  const attrs = rev ? trackListChange({
203
202
  current: node,
204
203
  next,
205
204
  rev,
206
- numbering
205
+ numbering,
206
+ styleResolver
207
207
  }) : next;
208
208
  tr.setNodeMarkup(pos, void 0, attrs);
209
209
  }
@@ -1,11 +1,12 @@
1
1
  import { ParagraphAttrs, ParagraphPropertyChangeAttrs } from "./schema/nodes.js";
2
2
  import { NumberingMap } from "../docx/numberingParser.js";
3
+ import { ParagraphNumberingOverride } from "../docx/numberingReference.js";
3
4
  //#region src/prosemirror/listRendering.d.ts
4
5
  type PreviousFormatting = NonNullable<ParagraphPropertyChangeAttrs["previousFormatting"]>;
5
6
  /** Whether a `w:pPrChange` record states the list rendering it had (a list command's does). */
6
7
  declare const recordsListRendering: (record: PreviousFormatting) => boolean;
7
8
  /** Every list-rendering attr for `numPr`: its level's, or all cleared when it numbers nothing. */
8
- declare const listRenderingFor: (numPr: PreviousFormatting["numPr"], numbering: NumberingMap | null) => Record<string, unknown>;
9
+ declare const listRenderingFor: (numPr: ParagraphNumberingOverride | null | undefined, numbering: NumberingMap | null) => Record<string, unknown>;
9
10
  /**
10
11
  * The list rendering a paragraph takes when a `w:pPrChange` is rejected. A
11
12
  * reject restores the recorded numbering, but the rendering attrs sit outside
@@ -21,7 +22,8 @@ type RejectedListRenderingOptions = {
21
22
  /** The previous state the rejected record restores. */
22
23
  previousFormatting: PreviousFormatting | null | undefined;
23
24
  numbering: NumberingMap | null;
25
+ restoredNumbering: ParagraphAttrs["numPr"] | null;
24
26
  };
25
- declare const rejectedListRenderingPatch: ({ current, previousFormatting, numbering }: RejectedListRenderingOptions) => Record<string, unknown>;
27
+ declare const rejectedListRenderingPatch: ({ current, previousFormatting, numbering, restoredNumbering }: RejectedListRenderingOptions) => Record<string, unknown>;
26
28
  //#endregion
27
29
  export { RejectedListRenderingOptions, listRenderingFor, recordsListRendering, rejectedListRenderingPatch };
@@ -16,10 +16,9 @@ const listRenderingFor = (numPr, numbering) => {
16
16
  for (const key of LIST_RENDERING_ATTR_KEYS) attrs[key] = rendering[key] ?? null;
17
17
  return attrs;
18
18
  };
19
- const rejectedListRenderingPatch = ({ current, previousFormatting, numbering }) => {
20
- const record = previousFormatting ?? {};
21
- if (recordsListRendering(record)) return {};
22
- const restored = record.numPr ?? void 0;
19
+ const rejectedListRenderingPatch = ({ current, previousFormatting, numbering, restoredNumbering }) => {
20
+ if (recordsListRendering(previousFormatting ?? {})) return {};
21
+ const restored = restoredNumbering ?? void 0;
23
22
  if (sameStatedParagraphNumbering(restored, current.numPr ?? void 0)) return {};
24
23
  return listRenderingFor(restored, numbering);
25
24
  };
@@ -86,7 +86,7 @@ const carryParagraphProperties = ({ tr, position, source, styleResolver, numberi
86
86
  };
87
87
  /**
88
88
  * Whether the carried numbering is the paragraph's own or its style's: the
89
- * record holds `numPr` as the paragraph reads it, and a style's numbering
89
+ * record holds only stated numbering, and a style's numbering
90
90
  * stays the style's rather than becoming the paragraph's own `w:numPr`.
91
91
  */
92
92
  const carriedNumberingProvenance = (source, target, keep) => ({ numPrFromStyle: expectParagraphAttrs(keep?.has("numPr") ? target : source).numPrFromStyle });
@@ -112,10 +112,7 @@ const paragraphLeftAfter = ({ doc, paragraphPos, removedBreakPositions }) => {
112
112
  node = next;
113
113
  }
114
114
  };
115
- const recordedFormatting = (node) => ({
116
- ...paragraphPropertiesSnapshot(node),
117
- numPr: expectParagraphAttrs(node).numPr ?? null
118
- });
115
+ const recordedFormatting = paragraphPropertiesSnapshot;
119
116
  /**
120
117
  * A paragraph now holds the break another one had (a split, or a paste into
121
118
  * it, leaves that break on the last part) but reads with other properties:
@@ -407,11 +407,7 @@ function rotateIntoFinalParagraph(state, tr, replacedPos, attrs) {
407
407
  const replaced = tr.doc.nodeAt(replacedPos);
408
408
  const first = replaced ? tr.doc.nodeAt(replacedPos + replaced.nodeSize) : null;
409
409
  if (!replaced || !first) return replacedPos;
410
- const replacedAttrs = expectParagraphAttrs(replaced);
411
- const previousFormatting = {
412
- ...paragraphPropertiesSnapshot(replaced),
413
- numPr: replacedAttrs.numPr ?? null
414
- };
410
+ const previousFormatting = paragraphPropertiesSnapshot(replaced);
415
411
  const { _sectionProperties: sectionProperties } = replaced.attrs;
416
412
  tr.join(replacedPos + replaced.nodeSize);
417
413
  tr.setNodeMarkup(replacedPos, void 0, first.attrs);
@@ -14,6 +14,12 @@ type DirectiveRange = {
14
14
  * array path the loop iterates (`{% for row in items %}` ⇒ `items`).
15
15
  */
16
16
  expr: string;
17
+ /**
18
+ * For an `if` / `elif` whose body is one field path plus a filter chain
19
+ * (`{% if consented | checkbox | label("…") %}`), that path; `expr` keeps
20
+ * the body as authored. Unset for every other tag.
21
+ */
22
+ conditionPath?: string;
17
23
  /** Clause-slot version selector, e.g. "v3" or "latest". */
18
24
  clauseVersion?: string;
19
25
  /** Loop alias of a `for` marker (`{% for row in items %}` ⇒ `row`); unset
@@ -3,9 +3,17 @@ import { collectBlockChunks, joinChunks, offsetToDocPos } from "./pmTextScan.js"
3
3
  import { PluginKey } from "prosemirror-state";
4
4
  import { assertNever, isBlockDirectiveKind, scanMarkers } from "@stll/template-conditions";
5
5
  //#region src/prosemirror/plugins/templateDirectives.ts
6
+ /**
7
+ * The body of an `if` / `elif` tag as authored: its inner text after the
8
+ * keyword. The grammar reads a body that is one path plus a filter chain as
9
+ * that path, with the chain in `filters`; the document still holds the whole
10
+ * body, and that is what a host keys a condition by.
11
+ */
12
+ const conditionBody = ({ inner, meta }) => inner.slice(meta.kind.length).trim();
6
13
  /** The display expression for a marker (field path, clause name, key,
7
- * condition, loop array path, loop property). */
8
- const directiveExpr = (meta) => {
14
+ * condition as authored, loop array path, loop property). */
15
+ const directiveExpr = (marker) => {
16
+ const { meta } = marker;
9
17
  switch (meta.kind) {
10
18
  case "placeholder": return meta.expr;
11
19
  case "clause": return meta.name;
@@ -13,7 +21,7 @@ const directiveExpr = (meta) => {
13
21
  case "ref": return meta.key;
14
22
  case "loop": return meta.property;
15
23
  case "if":
16
- case "elif": return meta.expr;
24
+ case "elif": return conditionBody(marker);
17
25
  case "for": return meta.path;
18
26
  case "else":
19
27
  case "endif":
@@ -63,6 +71,8 @@ const computeBlockDepths = (ranges) => {
63
71
  };
64
72
  /** The loop alias a `for` marker binds, or undefined for every other kind. */
65
73
  const directiveAlias = (meta) => meta.kind === "for" ? meta.alias : void 0;
74
+ /** The path a condition's filter chain configures, or undefined without a chain. */
75
+ const directiveConditionPath = (meta) => (meta.kind === "if" || meta.kind === "elif") && meta.filters.length > 0 ? meta.expr : void 0;
66
76
  const scanDirectives = (doc) => {
67
77
  const ranges = [];
68
78
  for (const chunks of collectBlockChunks(doc)) {
@@ -73,27 +83,31 @@ const scanDirectives = (doc) => {
73
83
  if (sole && sole.raw === trimmed && isBlockDirectiveKind(sole.meta.kind)) {
74
84
  const last = chunks.at(-1);
75
85
  const alias = directiveAlias(sole.meta);
86
+ const conditionPath = directiveConditionPath(sole.meta);
76
87
  ranges.push({
77
88
  from: chunks[0]?.start ?? 0,
78
89
  to: last ? last.end ?? last.start + last.text.length : 0,
79
90
  kind: sole.meta.kind,
80
- expr: directiveExpr(sole.meta),
91
+ expr: directiveExpr(sole),
81
92
  block: true,
82
- ...alias !== void 0 ? { alias } : {}
93
+ ...alias !== void 0 ? { alias } : {},
94
+ ...conditionPath !== void 0 ? { conditionPath } : {}
83
95
  });
84
96
  continue;
85
97
  }
86
98
  for (const marker of scanMarkers(joined)) {
87
99
  const clauseVersion = marker.meta.kind === "clause" ? marker.meta.version : void 0;
88
100
  const alias = directiveAlias(marker.meta);
101
+ const conditionPath = directiveConditionPath(marker.meta);
89
102
  ranges.push({
90
103
  from: offsetToDocPos(chunks, marker.start),
91
104
  to: offsetToDocPos(chunks, marker.end, "end"),
92
105
  kind: marker.meta.kind,
93
- expr: directiveExpr(marker.meta),
106
+ expr: directiveExpr(marker),
94
107
  block: false,
95
108
  ...clauseVersion !== void 0 ? { clauseVersion } : {},
96
- ...alias !== void 0 ? { alias } : {}
109
+ ...alias !== void 0 ? { alias } : {},
110
+ ...conditionPath !== void 0 ? { conditionPath } : {}
97
111
  });
98
112
  }
99
113
  }
@@ -1,7 +1,7 @@
1
1
  import { scanDirectives } from "./templateDirectives.js";
2
2
  import { Plugin, PluginKey } from "prosemirror-state";
3
3
  import { Decoration, DecorationSet } from "prosemirror-view";
4
- import { classifyMarker, isFieldPath } from "@stll/template-conditions";
4
+ import { isFieldPath } from "@stll/template-conditions";
5
5
  //#region src/prosemirror/plugins/templatePreviewValues.ts
6
6
  /** Concatenated plain text of a preview value. */
7
7
  const templatePreviewValueText = (value) => typeof value === "string" ? value : value.runs.map((run) => run.text).join("");
@@ -64,28 +64,21 @@ function buildValueWidget(value, mode) {
64
64
  return span;
65
65
  };
66
66
  }
67
- /** Loop alias the bare-path probe binds. Any identifier does; it is discarded. */
68
- const CHAIN_PROBE_ALIAS = "__folioCondition";
69
67
  /** A field path opens with a letter or underscore, so `9x` and `-x` are not one. */
70
68
  const FIELD_PATH_HEAD_RE = /^[\p{L}_]/u;
71
69
  /**
72
70
  * The bare field path a condition's filter chain hangs off, or undefined when
73
- * the expression is not a path plus a chain.
71
+ * the tag is not a path plus a chain.
74
72
  *
75
- * A host may write the tag the way `{% for x in xs | chain %}` already carries
76
- * one — `{% if buyer_is_a_consumer | checkbox | ai("Is the buyer …") %}` — while
77
- * keying `conditions` by the bare path. Splitting on `|` would cut a quoted
78
- * argument such as `label("a | b")` in half, so the expression is classified as
79
- * that very `for` chain instead and the grammar's own argument-aware scan
80
- * reports the path. An expression that is not a path plus a chain of known
81
- * filters (`a and b`, `items|length > 0`, `ai("a|b")`) classifies as nothing,
82
- * which is what keeps a real expression on exact matching.
73
+ * A host may write the tag with the chain of the boolean it names —
74
+ * `{% if buyer_is_a_consumer | checkbox | ai("Is the buyer …") %}` — while
75
+ * keying `conditions` by the bare path. The grammar's argument-aware scan
76
+ * splits the chain (`label("a | b")` stays one argument) and reports the path
77
+ * as `conditionPath`; an expression that is not a path plus a chain of known
78
+ * filters (`a and b`, `items|length > 0`, `ai("a|b")`) has none, which is what
79
+ * keeps a real expression on exact matching.
83
80
  */
84
- const filterChainPath = (expr) => {
85
- const meta = classifyMarker(`for ${CHAIN_PROBE_ALIAS} in ${expr}`, "statement");
86
- if (meta?.kind !== "for" || !isFieldPath(meta.path) || !FIELD_PATH_HEAD_RE.test(meta.path)) return;
87
- return meta.path;
88
- };
81
+ const filterChainPath = ({ conditionPath }) => conditionPath !== void 0 && isFieldPath(conditionPath) && FIELD_PATH_HEAD_RE.test(conditionPath) ? conditionPath : void 0;
89
82
  /**
90
83
  * The verdict stored under one key. Only a key the host set itself, holding a
91
84
  * real boolean, counts: an inherited property (`constructor`, or a boolean the
@@ -102,10 +95,10 @@ const ownVerdict = (conditions, key) => {
102
95
  * by the bare path its filter chain hangs off. `undefined` means the host said
103
96
  * nothing about this block, which leaves it exactly as authored.
104
97
  */
105
- const conditionVerdict = (conditions, expr) => {
106
- const exact = ownVerdict(conditions, expr);
98
+ const conditionVerdict = (conditions, opener) => {
99
+ const exact = ownVerdict(conditions, opener.expr);
107
100
  if (exact !== void 0) return exact;
108
- const path = filterChainPath(expr);
101
+ const path = filterChainPath(opener);
109
102
  return path === void 0 ? void 0 : ownVerdict(conditions, path);
110
103
  };
111
104
  /**
@@ -157,7 +150,7 @@ function collectHiddenRanges(ranges, conditions, mode) {
157
150
  }
158
151
  if (block === void 0 || block.opener.kind !== "if") continue;
159
152
  const { opener, branches } = block;
160
- const verdict = conditionVerdict(conditions, opener.expr);
153
+ const verdict = conditionVerdict(conditions, opener);
161
154
  if (verdict === void 0) continue;
162
155
  if (!verdict) {
163
156
  paired.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.54.0",
3
+ "version": "0.54.1",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",
@@ -134,9 +134,9 @@
134
134
  "perf": "bun scripts/profile-editor.ts"
135
135
  },
136
136
  "dependencies": {
137
- "@stll/docx-core": "^0.29.0",
137
+ "@stll/docx-core": "^0.30.0",
138
138
  "@stll/docx-utils": "^0.1.0",
139
- "@stll/template-conditions": ">=0.4.0 <1.0.0",
139
+ "@stll/template-conditions": "^0.6.2",
140
140
  "better-result": "3.0.1",
141
141
  "csstype": "^3.1.3",
142
142
  "dompurify": "^3.4.13",