@beyondwork/docx-react-component 1.0.37 → 1.0.39
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/package.json +41 -31
- package/src/api/public-types.ts +496 -1
- package/src/core/commands/section-layout-commands.ts +58 -0
- package/src/core/commands/table-grid.ts +431 -0
- package/src/core/commands/table-structure-commands.ts +845 -56
- package/src/core/commands/text-commands.ts +122 -2
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-main-document.ts +2 -11
- package/src/io/export/serialize-numbering.ts +43 -10
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/export/serialize-tables.ts +74 -0
- package/src/io/export/table-properties-xml.ts +139 -4
- package/src/io/normalize/normalize-text.ts +15 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-footnotes.ts +60 -0
- package/src/io/ooxml/parse-headers-footers.ts +60 -0
- package/src/io/ooxml/parse-main-document.ts +137 -0
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/parse-tables.ts +249 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +117 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-layout.ts +4 -2
- package/src/runtime/document-navigation.ts +1 -1
- package/src/runtime/document-runtime.ts +248 -18
- package/src/runtime/layout/default-page-format.ts +96 -0
- package/src/runtime/layout/index.ts +47 -0
- package/src/runtime/layout/inert-layout-facet.ts +16 -0
- package/src/runtime/layout/layout-engine-instance.ts +100 -23
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/margin-preset-catalog.ts +178 -0
- package/src/runtime/layout/page-format-catalog.ts +233 -0
- package/src/runtime/layout/page-graph.ts +55 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +484 -37
- package/src/runtime/layout/project-block-fragments.ts +225 -0
- package/src/runtime/layout/public-facet.ts +748 -16
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +249 -0
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -0
- package/src/runtime/render/block-fragment-projection.ts +35 -0
- package/src/runtime/render/decoration-resolver.ts +189 -0
- package/src/runtime/render/index.ts +57 -0
- package/src/runtime/render/pending-op-delta-reader.ts +129 -0
- package/src/runtime/render/render-frame-types.ts +317 -0
- package/src/runtime/render/render-kernel.ts +759 -0
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -0
- package/src/runtime/view-state.ts +67 -0
- package/src/runtime/workflow-markup.ts +1 -5
- package/src/runtime/workflow-rail-segments.ts +280 -0
- package/src/ui/WordReviewEditor.tsx +368 -19
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +310 -15
- package/src/ui/headless/scoped-chrome-policy.ts +49 -1
- package/src/ui/headless/selection-tool-types.ts +10 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
- package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
- package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +29 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
- package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
- package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
- package/src/ui-tailwind/theme/editor-theme.css +498 -163
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
- package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
- package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
- package/src/runtime/collab-review-sync.ts +0 -254
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logical grid topology helper for merge-aware table structural operations.
|
|
3
|
+
*
|
|
4
|
+
* A `TableNode` carries cells in row-major order, but horizontal (`gridSpan`)
|
|
5
|
+
* and vertical (`verticalMerge`) merges mean cell index and logical column
|
|
6
|
+
* index are not the same. Every structural command that has to reason about
|
|
7
|
+
* "the cell at logical column C in row R" reads through this module.
|
|
8
|
+
*
|
|
9
|
+
* The core type is `LogicalGrid`: a rectangular 2D array where each entry
|
|
10
|
+
* describes either the origin of a spanning region or a covered slot that
|
|
11
|
+
* points back to its origin.
|
|
12
|
+
*/
|
|
13
|
+
import type {
|
|
14
|
+
TableCellNode,
|
|
15
|
+
TableNode,
|
|
16
|
+
TableRowNode,
|
|
17
|
+
} from "../../model/canonical-document.ts";
|
|
18
|
+
|
|
19
|
+
export interface GridOriginSlot {
|
|
20
|
+
kind: "origin";
|
|
21
|
+
/** Row index of this origin slot in the table. */
|
|
22
|
+
rowIndex: number;
|
|
23
|
+
/** Logical column index of the left edge of the spanning region. */
|
|
24
|
+
columnIndex: number;
|
|
25
|
+
/** Index of the cell within its row's `cells` array. */
|
|
26
|
+
cellIndex: number;
|
|
27
|
+
/** Horizontal span in logical columns (== gridSpan, default 1). */
|
|
28
|
+
columnSpan: number;
|
|
29
|
+
/** Vertical span in rows, resolved from verticalMerge chains. */
|
|
30
|
+
rowSpan: number;
|
|
31
|
+
/** The cell this origin points to. */
|
|
32
|
+
cell: TableCellNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GridCoveredSlot {
|
|
36
|
+
kind: "covered";
|
|
37
|
+
/** The origin this slot is covered by. */
|
|
38
|
+
origin: GridOriginSlot;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type GridSlot = GridOriginSlot | GridCoveredSlot;
|
|
42
|
+
|
|
43
|
+
export interface LogicalGrid {
|
|
44
|
+
table: TableNode;
|
|
45
|
+
rowCount: number;
|
|
46
|
+
columnCount: number;
|
|
47
|
+
/** 2D matrix [rowIndex][logicalColumnIndex] → slot. */
|
|
48
|
+
slots: GridSlot[][];
|
|
49
|
+
/**
|
|
50
|
+
* Rows carried over via gridBefore/gridAfter spacers. Each entry lists
|
|
51
|
+
* the logical columns that were marked as "before" or "after" padding
|
|
52
|
+
* rather than cell content. Used by commands that must preserve ragged
|
|
53
|
+
* row geometry.
|
|
54
|
+
*/
|
|
55
|
+
rowPadding: Array<{ beforeColumns: number[]; afterColumns: number[] }>;
|
|
56
|
+
/** Invariant violations discovered while building the grid. */
|
|
57
|
+
warnings: GridWarning[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface GridWarning {
|
|
61
|
+
kind:
|
|
62
|
+
| "row-span-mismatch"
|
|
63
|
+
| "vmerge-continue-without-restart"
|
|
64
|
+
| "column-span-zero";
|
|
65
|
+
rowIndex: number;
|
|
66
|
+
columnIndex?: number;
|
|
67
|
+
message: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the logical grid for a canonical table.
|
|
72
|
+
*
|
|
73
|
+
* The result is tolerant: malformed inputs (e.g. a `verticalMerge: "continue"`
|
|
74
|
+
* with no matching restart above) produce `warnings` rather than throwing,
|
|
75
|
+
* so callers can still make best-effort structural decisions on imported
|
|
76
|
+
* documents. The grid is always rectangular — covered slots are filled in
|
|
77
|
+
* to match the widest effective row width.
|
|
78
|
+
*/
|
|
79
|
+
export function buildLogicalGrid(table: TableNode): LogicalGrid {
|
|
80
|
+
const rowCount = table.rows.length;
|
|
81
|
+
const columnCount = computeLogicalColumnCount(table);
|
|
82
|
+
const slots: GridSlot[][] = Array.from({ length: rowCount }, () =>
|
|
83
|
+
Array.from({ length: columnCount }) as GridSlot[],
|
|
84
|
+
);
|
|
85
|
+
const rowPadding: LogicalGrid["rowPadding"] = [];
|
|
86
|
+
const warnings: GridWarning[] = [];
|
|
87
|
+
|
|
88
|
+
// Track active vMerge chains keyed by their logical start column. Each
|
|
89
|
+
// entry holds the origin and the trailing column covered by the chain.
|
|
90
|
+
const activeVMerges = new Map<number, GridOriginSlot>();
|
|
91
|
+
|
|
92
|
+
for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
|
|
93
|
+
const row = table.rows[rowIndex]!;
|
|
94
|
+
const gridBefore = Math.max(0, row.gridBefore ?? 0);
|
|
95
|
+
const gridAfter = Math.max(0, row.gridAfter ?? 0);
|
|
96
|
+
const beforeColumns: number[] = [];
|
|
97
|
+
const afterColumns: number[] = [];
|
|
98
|
+
const endColumn = columnCount - gridAfter;
|
|
99
|
+
|
|
100
|
+
// Mark gridBefore columns as padding (they don't participate in the
|
|
101
|
+
// cell grid but we still need to know they're reserved so column
|
|
102
|
+
// counts stay accurate for subsequent vMerge math).
|
|
103
|
+
for (let padIndex = 0; padIndex < gridBefore && padIndex < columnCount; padIndex += 1) {
|
|
104
|
+
beforeColumns.push(padIndex);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let columnCursor = gridBefore;
|
|
108
|
+
|
|
109
|
+
for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) {
|
|
110
|
+
const cell = row.cells[cellIndex]!;
|
|
111
|
+
const rawSpan = cell.gridSpan ?? 1;
|
|
112
|
+
if (rawSpan <= 0) {
|
|
113
|
+
warnings.push({
|
|
114
|
+
kind: "column-span-zero",
|
|
115
|
+
rowIndex,
|
|
116
|
+
columnIndex: columnCursor,
|
|
117
|
+
message: `Cell ${cellIndex} has non-positive gridSpan; treating as 1`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const columnSpan = Math.max(1, rawSpan);
|
|
121
|
+
|
|
122
|
+
if (cell.verticalMerge === "continue") {
|
|
123
|
+
const owner = findVMergeOwner(activeVMerges, columnCursor, columnSpan);
|
|
124
|
+
if (owner) {
|
|
125
|
+
// Extend the owner's rowspan to cover this row.
|
|
126
|
+
owner.rowSpan += 1;
|
|
127
|
+
const covered: GridCoveredSlot = { kind: "covered", origin: owner };
|
|
128
|
+
for (let offset = 0; offset < columnSpan; offset += 1) {
|
|
129
|
+
const column = columnCursor + offset;
|
|
130
|
+
if (column < columnCount) {
|
|
131
|
+
slots[rowIndex]![column] = covered;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Note: a vMerge:continue cell can still break the chain later;
|
|
135
|
+
// we keep the owner entry keyed by its original column so
|
|
136
|
+
// subsequent rows can extend it.
|
|
137
|
+
columnCursor += columnSpan;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
warnings.push({
|
|
142
|
+
kind: "vmerge-continue-without-restart",
|
|
143
|
+
rowIndex,
|
|
144
|
+
columnIndex: columnCursor,
|
|
145
|
+
message: `Cell ${cellIndex} has verticalMerge=continue but no matching restart above`,
|
|
146
|
+
});
|
|
147
|
+
// Fall through and treat as a regular cell origin.
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const origin: GridOriginSlot = {
|
|
151
|
+
kind: "origin",
|
|
152
|
+
rowIndex,
|
|
153
|
+
columnIndex: columnCursor,
|
|
154
|
+
cellIndex,
|
|
155
|
+
columnSpan,
|
|
156
|
+
rowSpan: 1,
|
|
157
|
+
cell,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
for (let offset = 0; offset < columnSpan; offset += 1) {
|
|
161
|
+
const column = columnCursor + offset;
|
|
162
|
+
if (column >= columnCount) break;
|
|
163
|
+
if (offset === 0) {
|
|
164
|
+
slots[rowIndex]![column] = origin;
|
|
165
|
+
} else {
|
|
166
|
+
slots[rowIndex]![column] = { kind: "covered", origin };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (cell.verticalMerge === "restart") {
|
|
171
|
+
// Close out any prior chain starting at this column (stale).
|
|
172
|
+
activeVMerges.set(columnCursor, origin);
|
|
173
|
+
} else {
|
|
174
|
+
// Any non-merge origin breaks prior chains crossing this column.
|
|
175
|
+
clearChainsBetween(activeVMerges, columnCursor, columnCursor + columnSpan);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
columnCursor += columnSpan;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Any columns the cells did not cover fall into gridAfter padding. If
|
|
182
|
+
// the row was too short to reach endColumn, record the trailing gap so
|
|
183
|
+
// downstream code knows the row is ragged rather than broken.
|
|
184
|
+
if (columnCursor < endColumn) {
|
|
185
|
+
warnings.push({
|
|
186
|
+
kind: "row-span-mismatch",
|
|
187
|
+
rowIndex,
|
|
188
|
+
message:
|
|
189
|
+
`Row cells reached column ${columnCursor}, expected ${endColumn}` +
|
|
190
|
+
` (gridBefore=${gridBefore}, gridAfter=${gridAfter})`,
|
|
191
|
+
});
|
|
192
|
+
for (let column = columnCursor; column < endColumn; column += 1) {
|
|
193
|
+
beforeColumns.push(column);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (let padIndex = 0; padIndex < gridAfter; padIndex += 1) {
|
|
198
|
+
const column = columnCount - gridAfter + padIndex;
|
|
199
|
+
if (column >= 0 && column < columnCount) {
|
|
200
|
+
afterColumns.push(column);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
rowPadding.push({ beforeColumns, afterColumns });
|
|
205
|
+
|
|
206
|
+
// Prune chains that were not extended by a continue in this row.
|
|
207
|
+
for (const [column, owner] of activeVMerges) {
|
|
208
|
+
const slot = slots[rowIndex]![column];
|
|
209
|
+
if (!slot || slot.kind !== "covered" || slot.origin !== owner) {
|
|
210
|
+
if (owner.rowIndex !== rowIndex) {
|
|
211
|
+
// Chain did not continue; drop it so later rows don't match.
|
|
212
|
+
activeVMerges.delete(column);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
table,
|
|
220
|
+
rowCount,
|
|
221
|
+
columnCount,
|
|
222
|
+
slots,
|
|
223
|
+
rowPadding,
|
|
224
|
+
warnings,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Logical column count across all rows: the widest effective row width,
|
|
230
|
+
* honoring `gridBefore + Σ(gridSpan) + gridAfter` and `gridColumns` length.
|
|
231
|
+
*/
|
|
232
|
+
export function computeLogicalColumnCount(table: TableNode): number {
|
|
233
|
+
const fromGrid = table.gridColumns.length;
|
|
234
|
+
const fromRows = table.rows.reduce((max, row) => {
|
|
235
|
+
const rowWidth =
|
|
236
|
+
(row.gridBefore ?? 0) +
|
|
237
|
+
row.cells.reduce((sum, cell) => sum + Math.max(1, cell.gridSpan ?? 1), 0) +
|
|
238
|
+
(row.gridAfter ?? 0);
|
|
239
|
+
return rowWidth > max ? rowWidth : max;
|
|
240
|
+
}, 0);
|
|
241
|
+
return Math.max(fromGrid, fromRows);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Look up the slot at a logical position. Returns null when the row or
|
|
246
|
+
* column is out of bounds.
|
|
247
|
+
*/
|
|
248
|
+
export function slotAt(
|
|
249
|
+
grid: LogicalGrid,
|
|
250
|
+
rowIndex: number,
|
|
251
|
+
columnIndex: number,
|
|
252
|
+
): GridSlot | null {
|
|
253
|
+
if (rowIndex < 0 || rowIndex >= grid.rowCount) return null;
|
|
254
|
+
if (columnIndex < 0 || columnIndex >= grid.columnCount) return null;
|
|
255
|
+
return grid.slots[rowIndex]![columnIndex] ?? null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Returns the origin slot covering `(rowIndex, columnIndex)`. If the slot
|
|
260
|
+
* is covered (by hspan or vMerge), follows the pointer back to the origin.
|
|
261
|
+
*/
|
|
262
|
+
export function originAt(
|
|
263
|
+
grid: LogicalGrid,
|
|
264
|
+
rowIndex: number,
|
|
265
|
+
columnIndex: number,
|
|
266
|
+
): GridOriginSlot | null {
|
|
267
|
+
const slot = slotAt(grid, rowIndex, columnIndex);
|
|
268
|
+
if (!slot) return null;
|
|
269
|
+
return slot.kind === "origin" ? slot : slot.origin;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Walk the vertical-merge chain rooted at `(rowIndex, columnIndex)`.
|
|
274
|
+
* Returns the origin's row indices in order. An un-merged cell yields a
|
|
275
|
+
* single-element array. A continue cell returns the full chain from its
|
|
276
|
+
* origin through every covered row.
|
|
277
|
+
*/
|
|
278
|
+
export function collectVMergeChain(
|
|
279
|
+
grid: LogicalGrid,
|
|
280
|
+
rowIndex: number,
|
|
281
|
+
columnIndex: number,
|
|
282
|
+
): number[] {
|
|
283
|
+
const origin = originAt(grid, rowIndex, columnIndex);
|
|
284
|
+
if (!origin) return [];
|
|
285
|
+
const rows: number[] = [];
|
|
286
|
+
for (let r = origin.rowIndex; r < grid.rowCount; r += 1) {
|
|
287
|
+
const slot: GridSlot | undefined = grid.slots[r]?.[origin.columnIndex];
|
|
288
|
+
if (!slot) break;
|
|
289
|
+
const slotOrigin: GridOriginSlot = slot.kind === "origin" ? slot : slot.origin;
|
|
290
|
+
if (slotOrigin !== origin) break;
|
|
291
|
+
rows.push(r);
|
|
292
|
+
}
|
|
293
|
+
return rows;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Determine whether a rectangular selection rect contains only whole
|
|
298
|
+
* spanning regions (no torn spans). Merge-aware commands use this to
|
|
299
|
+
* decide whether a bulk op can proceed cleanly.
|
|
300
|
+
*/
|
|
301
|
+
export interface RectCoverage {
|
|
302
|
+
/** Rect is clean: every origin fully inside the rect, no partial covers. */
|
|
303
|
+
clean: boolean;
|
|
304
|
+
/** Origins that were only partially covered by the rect. */
|
|
305
|
+
straddlingOrigins: GridOriginSlot[];
|
|
306
|
+
/** Origins fully enclosed by the rect. */
|
|
307
|
+
fullyCoveredOrigins: GridOriginSlot[];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface GridRect {
|
|
311
|
+
/** Inclusive. */
|
|
312
|
+
top: number;
|
|
313
|
+
/** Inclusive. */
|
|
314
|
+
left: number;
|
|
315
|
+
/** Exclusive (one past the last row). */
|
|
316
|
+
bottom: number;
|
|
317
|
+
/** Exclusive (one past the last column). */
|
|
318
|
+
right: number;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function analyzeRect(grid: LogicalGrid, rect: GridRect): RectCoverage {
|
|
322
|
+
const seen = new Set<GridOriginSlot>();
|
|
323
|
+
const straddling = new Set<GridOriginSlot>();
|
|
324
|
+
for (let r = rect.top; r < rect.bottom; r += 1) {
|
|
325
|
+
for (let c = rect.left; c < rect.right; c += 1) {
|
|
326
|
+
const origin = originAt(grid, r, c);
|
|
327
|
+
if (!origin) continue;
|
|
328
|
+
seen.add(origin);
|
|
329
|
+
const originFitsHorizontally =
|
|
330
|
+
origin.columnIndex >= rect.left &&
|
|
331
|
+
origin.columnIndex + origin.columnSpan <= rect.right;
|
|
332
|
+
const originFitsVertically =
|
|
333
|
+
origin.rowIndex >= rect.top &&
|
|
334
|
+
origin.rowIndex + origin.rowSpan <= rect.bottom;
|
|
335
|
+
if (!originFitsHorizontally || !originFitsVertically) {
|
|
336
|
+
straddling.add(origin);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
clean: straddling.size === 0,
|
|
342
|
+
straddlingOrigins: [...straddling],
|
|
343
|
+
fullyCoveredOrigins: [...seen].filter((origin) => !straddling.has(origin)),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function findVMergeOwner(
|
|
348
|
+
activeVMerges: ReadonlyMap<number, GridOriginSlot>,
|
|
349
|
+
columnCursor: number,
|
|
350
|
+
columnSpan: number,
|
|
351
|
+
): GridOriginSlot | null {
|
|
352
|
+
const exact = activeVMerges.get(columnCursor);
|
|
353
|
+
if (exact && exact.columnSpan === columnSpan) {
|
|
354
|
+
return exact;
|
|
355
|
+
}
|
|
356
|
+
// Fall back to any owner whose origin lies inside the cursor range —
|
|
357
|
+
// Word tolerates mismatched column spans in a vMerge chain and resolves
|
|
358
|
+
// to the nearest matching origin.
|
|
359
|
+
for (const owner of activeVMerges.values()) {
|
|
360
|
+
if (
|
|
361
|
+
owner.columnIndex >= columnCursor &&
|
|
362
|
+
owner.columnIndex < columnCursor + columnSpan
|
|
363
|
+
) {
|
|
364
|
+
return owner;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return exact ?? null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function clearChainsBetween(
|
|
371
|
+
activeVMerges: Map<number, GridOriginSlot>,
|
|
372
|
+
leftColumn: number,
|
|
373
|
+
rightColumn: number,
|
|
374
|
+
): void {
|
|
375
|
+
for (const column of [...activeVMerges.keys()]) {
|
|
376
|
+
if (column >= leftColumn && column < rightColumn) {
|
|
377
|
+
activeVMerges.delete(column);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ─── Structural invariants exposed for tests and capability snapshots ─────────
|
|
383
|
+
|
|
384
|
+
export interface StructuralInvariantReport {
|
|
385
|
+
ok: boolean;
|
|
386
|
+
rowWidthMismatches: number[];
|
|
387
|
+
vMergeDangling: Array<{ rowIndex: number; columnIndex: number }>;
|
|
388
|
+
zeroColumnSpans: Array<{ rowIndex: number; columnIndex: number }>;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function evaluateInvariants(grid: LogicalGrid): StructuralInvariantReport {
|
|
392
|
+
const rowWidthMismatches: number[] = [];
|
|
393
|
+
const vMergeDangling: StructuralInvariantReport["vMergeDangling"] = [];
|
|
394
|
+
const zeroColumnSpans: StructuralInvariantReport["zeroColumnSpans"] = [];
|
|
395
|
+
for (const warning of grid.warnings) {
|
|
396
|
+
if (warning.kind === "row-span-mismatch") {
|
|
397
|
+
rowWidthMismatches.push(warning.rowIndex);
|
|
398
|
+
} else if (warning.kind === "vmerge-continue-without-restart" && warning.columnIndex !== undefined) {
|
|
399
|
+
vMergeDangling.push({ rowIndex: warning.rowIndex, columnIndex: warning.columnIndex });
|
|
400
|
+
} else if (warning.kind === "column-span-zero" && warning.columnIndex !== undefined) {
|
|
401
|
+
zeroColumnSpans.push({ rowIndex: warning.rowIndex, columnIndex: warning.columnIndex });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
ok: rowWidthMismatches.length === 0 && vMergeDangling.length === 0 && zeroColumnSpans.length === 0,
|
|
406
|
+
rowWidthMismatches,
|
|
407
|
+
vMergeDangling,
|
|
408
|
+
zeroColumnSpans,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Helper: find the cell-array index + cell reference for a logical column
|
|
414
|
+
* in a specific row. Mirrors the `findCellAtColumn` helper previously
|
|
415
|
+
* embedded in `table-structure-commands.ts` but routes through the grid.
|
|
416
|
+
*/
|
|
417
|
+
export function findCellIndexAtColumn(
|
|
418
|
+
row: TableRowNode,
|
|
419
|
+
logicalColumn: number,
|
|
420
|
+
): { cellIndex: number; cell: TableCellNode } | null {
|
|
421
|
+
let cursor = row.gridBefore ?? 0;
|
|
422
|
+
for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) {
|
|
423
|
+
const cell = row.cells[cellIndex]!;
|
|
424
|
+
const width = Math.max(1, cell.gridSpan ?? 1);
|
|
425
|
+
if (logicalColumn >= cursor && logicalColumn < cursor + width) {
|
|
426
|
+
return { cellIndex, cell };
|
|
427
|
+
}
|
|
428
|
+
cursor += width;
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|