@beyondwork/docx-react-component 1.0.2 → 1.0.4
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 +11 -7
- package/package.json +21 -29
- package/src/compare/export-redlines.ts +0 -2
- package/src/core/commands/index.ts +116 -8
- package/src/core/state/text-transaction.ts +226 -0
- package/src/formats/xlsx/io/xlsx-session.ts +1 -1
- package/src/io/docx-session.ts +1 -1
- package/src/io/export/serialize-tables.ts +27 -0
- package/src/io/ooxml/parse-tables.ts +50 -0
- package/src/io/opc/package-reader.ts +32 -27
- package/src/runtime/table-commands.ts +10 -3
- package/src/runtime/table-schema.ts +120 -44
- package/src/ui/WordReviewEditor.tsx +2 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +249 -16
- package/src/ui-tailwind/theme/editor-theme.css +1 -1
|
@@ -2,17 +2,36 @@
|
|
|
2
2
|
* ProseMirror NodeView implementations for table nodes.
|
|
3
3
|
*
|
|
4
4
|
* These NodeViews render table structure as proper HTML tables with
|
|
5
|
-
* colspan/rowspan support for merged cells
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* nodeViews: tableNodeViews,
|
|
10
|
-
* ...
|
|
11
|
-
* })
|
|
5
|
+
* colspan/rowspan support for merged cells. Horizontal merges come
|
|
6
|
+
* directly from `colspan`/`gridSpan`. Vertical merges can be rendered
|
|
7
|
+
* from either explicit `rowspan` attrs or OOXML `verticalMerge`
|
|
8
|
+
* chains when upstream projection has not yet materialized row spans.
|
|
12
9
|
*/
|
|
13
10
|
|
|
14
11
|
import type { Node as PMNode } from "prosemirror-model";
|
|
15
12
|
|
|
13
|
+
const TABLE_LAYOUT_SYNC_EVENT = "pm-table-layout-sync";
|
|
14
|
+
|
|
15
|
+
interface TableCellLayout {
|
|
16
|
+
cellIndex: number;
|
|
17
|
+
colSpan: number;
|
|
18
|
+
hidden: boolean;
|
|
19
|
+
rowSpan: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface OpenVerticalMerge {
|
|
23
|
+
col: number;
|
|
24
|
+
colSpan: number;
|
|
25
|
+
continuedThisRow: boolean;
|
|
26
|
+
layout: TableCellLayout;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface OpenExplicitRowSpan {
|
|
30
|
+
col: number;
|
|
31
|
+
colSpan: number;
|
|
32
|
+
remainingRows: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
16
35
|
function resolveRenderedColspan(node: PMNode): number {
|
|
17
36
|
const colspan = node.attrs.colspan as number | undefined;
|
|
18
37
|
if (typeof colspan === "number" && colspan > 1) {
|
|
@@ -27,6 +46,166 @@ function resolveRenderedColspan(node: PMNode): number {
|
|
|
27
46
|
return 1;
|
|
28
47
|
}
|
|
29
48
|
|
|
49
|
+
function resolveRenderedRowspan(node: PMNode): number {
|
|
50
|
+
const rowspan = node.attrs.rowspan as number | undefined;
|
|
51
|
+
return typeof rowspan === "number" && rowspan > 1 ? rowspan : 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readVerticalMerge(node: PMNode): "restart" | "continue" | null {
|
|
55
|
+
const value = node.attrs.verticalMerge;
|
|
56
|
+
return value === "restart" || value === "continue" ? value : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isColumnCoveredBySpan(spans: readonly OpenExplicitRowSpan[], column: number): boolean {
|
|
60
|
+
return spans.some(
|
|
61
|
+
(span) => column >= span.col && column < span.col + span.colSpan,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function findVerticalMergeOwner(
|
|
66
|
+
openVerticalMerges: readonly OpenVerticalMerge[],
|
|
67
|
+
column: number,
|
|
68
|
+
colSpan: number,
|
|
69
|
+
): OpenVerticalMerge | null {
|
|
70
|
+
const matchingByWidth = openVerticalMerges.find(
|
|
71
|
+
(merge) => merge.col >= column && merge.colSpan === colSpan,
|
|
72
|
+
);
|
|
73
|
+
if (matchingByWidth) {
|
|
74
|
+
return matchingByWidth;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return openVerticalMerges.find((merge) => merge.col >= column) ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function computeTableLayout(tableNode: PMNode): TableCellLayout[][] {
|
|
81
|
+
const rowLayouts: TableCellLayout[][] = [];
|
|
82
|
+
const openVerticalMerges: OpenVerticalMerge[] = [];
|
|
83
|
+
const openExplicitRowSpans: OpenExplicitRowSpan[] = [];
|
|
84
|
+
|
|
85
|
+
for (let rowIndex = 0; rowIndex < tableNode.childCount; rowIndex += 1) {
|
|
86
|
+
for (const merge of openVerticalMerges) {
|
|
87
|
+
merge.continuedThisRow = false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rowNode = tableNode.child(rowIndex);
|
|
91
|
+
const layoutRow: TableCellLayout[] = [];
|
|
92
|
+
let column = 0;
|
|
93
|
+
|
|
94
|
+
for (let cellIndex = 0; cellIndex < rowNode.childCount; cellIndex += 1) {
|
|
95
|
+
const cellNode = rowNode.child(cellIndex);
|
|
96
|
+
const colSpan = resolveRenderedColspan(cellNode);
|
|
97
|
+
const explicitRowSpan = resolveRenderedRowspan(cellNode);
|
|
98
|
+
const verticalMerge = readVerticalMerge(cellNode);
|
|
99
|
+
|
|
100
|
+
if (verticalMerge === "continue") {
|
|
101
|
+
const owner = findVerticalMergeOwner(openVerticalMerges, column, colSpan);
|
|
102
|
+
if (owner) {
|
|
103
|
+
owner.layout.rowSpan += 1;
|
|
104
|
+
owner.continuedThisRow = true;
|
|
105
|
+
layoutRow.push({
|
|
106
|
+
cellIndex,
|
|
107
|
+
colSpan,
|
|
108
|
+
hidden: true,
|
|
109
|
+
rowSpan: 1,
|
|
110
|
+
});
|
|
111
|
+
column = owner.col + owner.colSpan;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
while (isColumnCoveredBySpan(openExplicitRowSpans, column)) {
|
|
117
|
+
column += 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const layout: TableCellLayout = {
|
|
121
|
+
cellIndex,
|
|
122
|
+
colSpan,
|
|
123
|
+
hidden: false,
|
|
124
|
+
rowSpan: explicitRowSpan,
|
|
125
|
+
};
|
|
126
|
+
layoutRow.push(layout);
|
|
127
|
+
|
|
128
|
+
if (verticalMerge === "restart") {
|
|
129
|
+
openVerticalMerges.push({
|
|
130
|
+
col: column,
|
|
131
|
+
colSpan,
|
|
132
|
+
continuedThisRow: true,
|
|
133
|
+
layout,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (explicitRowSpan > 1) {
|
|
138
|
+
openExplicitRowSpans.push({
|
|
139
|
+
col: column,
|
|
140
|
+
colSpan,
|
|
141
|
+
remainingRows: explicitRowSpan - 1,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
column += colSpan;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (let index = openVerticalMerges.length - 1; index >= 0; index -= 1) {
|
|
149
|
+
if (!openVerticalMerges[index]?.continuedThisRow) {
|
|
150
|
+
openVerticalMerges.splice(index, 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (let index = openExplicitRowSpans.length - 1; index >= 0; index -= 1) {
|
|
155
|
+
const span = openExplicitRowSpans[index];
|
|
156
|
+
if (!span) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
span.remainingRows -= 1;
|
|
160
|
+
if (span.remainingRows <= 0) {
|
|
161
|
+
openExplicitRowSpans.splice(index, 1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
rowLayouts.push(layoutRow);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return rowLayouts;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function syncRenderedTableLayout(tableBody: HTMLTableSectionElement, tableNode: PMNode): void {
|
|
172
|
+
const rowLayouts = computeTableLayout(tableNode);
|
|
173
|
+
const rowElements = Array.from(tableBody.rows);
|
|
174
|
+
|
|
175
|
+
for (let rowIndex = 0; rowIndex < rowLayouts.length; rowIndex += 1) {
|
|
176
|
+
const rowLayout = rowLayouts[rowIndex];
|
|
177
|
+
const rowElement = rowElements[rowIndex];
|
|
178
|
+
if (!rowLayout || !rowElement) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const cellElements = Array.from(rowElement.cells);
|
|
183
|
+
for (const cellLayout of rowLayout) {
|
|
184
|
+
const element = cellElements[cellLayout.cellIndex];
|
|
185
|
+
if (!element) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
element.colSpan = cellLayout.colSpan > 1 ? cellLayout.colSpan : 1;
|
|
190
|
+
element.rowSpan = cellLayout.hidden ? 1 : cellLayout.rowSpan > 1 ? cellLayout.rowSpan : 1;
|
|
191
|
+
element.style.display = cellLayout.hidden ? "none" : "";
|
|
192
|
+
|
|
193
|
+
if (cellLayout.hidden) {
|
|
194
|
+
element.setAttribute("aria-hidden", "true");
|
|
195
|
+
element.setAttribute("data-vertical-merge-hidden", "true");
|
|
196
|
+
} else {
|
|
197
|
+
element.removeAttribute("aria-hidden");
|
|
198
|
+
element.removeAttribute("data-vertical-merge-hidden");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function requestTableLayoutSync(start: HTMLElement): void {
|
|
205
|
+
const table = start.closest("[data-pm-table-root='true']");
|
|
206
|
+
table?.dispatchEvent(new Event(TABLE_LAYOUT_SYNC_EVENT));
|
|
207
|
+
}
|
|
208
|
+
|
|
30
209
|
/**
|
|
31
210
|
* NodeView for the table node.
|
|
32
211
|
* Renders as <table><tbody>...</tbody></table>.
|
|
@@ -34,17 +213,57 @@ function resolveRenderedColspan(node: PMNode): number {
|
|
|
34
213
|
*/
|
|
35
214
|
export class TableNodeView {
|
|
36
215
|
dom: HTMLElement;
|
|
37
|
-
contentDOM:
|
|
216
|
+
contentDOM: HTMLTableSectionElement;
|
|
217
|
+
private node: PMNode;
|
|
218
|
+
private syncQueued = false;
|
|
219
|
+
private readonly onSyncRequest: EventListener;
|
|
220
|
+
|
|
221
|
+
constructor(node: PMNode) {
|
|
222
|
+
this.node = node;
|
|
38
223
|
|
|
39
|
-
constructor(_node: PMNode) {
|
|
40
224
|
const table = document.createElement("table");
|
|
41
225
|
table.className = "border-collapse w-full my-2 text-sm";
|
|
226
|
+
table.setAttribute("data-pm-table-root", "true");
|
|
42
227
|
|
|
43
228
|
const tbody = document.createElement("tbody");
|
|
44
229
|
table.appendChild(tbody);
|
|
45
230
|
|
|
46
231
|
this.dom = table;
|
|
47
232
|
this.contentDOM = tbody;
|
|
233
|
+
this.onSyncRequest = () => {
|
|
234
|
+
this.scheduleLayoutSync();
|
|
235
|
+
};
|
|
236
|
+
this.dom.addEventListener(TABLE_LAYOUT_SYNC_EVENT, this.onSyncRequest);
|
|
237
|
+
this.scheduleLayoutSync();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
update(node: PMNode): boolean {
|
|
241
|
+
if (node.type !== this.node.type) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
this.node = node;
|
|
246
|
+
this.scheduleLayoutSync();
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
destroy(): void {
|
|
251
|
+
this.dom.removeEventListener(TABLE_LAYOUT_SYNC_EVENT, this.onSyncRequest);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
ignoreMutation(record: MutationRecord): boolean {
|
|
255
|
+
return record.type === "attributes" && record.target === this.dom;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private scheduleLayoutSync(): void {
|
|
259
|
+
if (this.syncQueued) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
this.syncQueued = true;
|
|
263
|
+
queueMicrotask(() => {
|
|
264
|
+
this.syncQueued = false;
|
|
265
|
+
syncRenderedTableLayout(this.contentDOM, this.node);
|
|
266
|
+
});
|
|
48
267
|
}
|
|
49
268
|
}
|
|
50
269
|
|
|
@@ -66,8 +285,8 @@ export class TableRowNodeView {
|
|
|
66
285
|
/**
|
|
67
286
|
* NodeView for table_cell and table_header_cell nodes.
|
|
68
287
|
*
|
|
69
|
-
* Applies colspan
|
|
70
|
-
*
|
|
288
|
+
* Applies colspan immediately and defers final rowspan/vertical-merge
|
|
289
|
+
* layout to the owning table node view.
|
|
71
290
|
*/
|
|
72
291
|
export class TableCellNodeView {
|
|
73
292
|
dom: HTMLElement;
|
|
@@ -82,9 +301,13 @@ export class TableCellNodeView {
|
|
|
82
301
|
: "border border-primary/20 p-2 align-top";
|
|
83
302
|
|
|
84
303
|
const colspan = resolveRenderedColspan(node);
|
|
85
|
-
const rowspan = node
|
|
86
|
-
if (colspan > 1)
|
|
87
|
-
|
|
304
|
+
const rowspan = resolveRenderedRowspan(node);
|
|
305
|
+
if (colspan > 1) {
|
|
306
|
+
(cell as HTMLTableCellElement).colSpan = colspan;
|
|
307
|
+
}
|
|
308
|
+
if (rowspan > 1) {
|
|
309
|
+
(cell as HTMLTableCellElement).rowSpan = rowspan;
|
|
310
|
+
}
|
|
88
311
|
|
|
89
312
|
this.dom = cell;
|
|
90
313
|
this.contentDOM = cell;
|
|
@@ -97,15 +320,25 @@ export class TableCellNodeView {
|
|
|
97
320
|
update(node: PMNode): boolean {
|
|
98
321
|
const isHeader = (node.type.spec as { tableRole?: string }).tableRole === "header_cell";
|
|
99
322
|
const expectedTag = isHeader ? "TH" : "TD";
|
|
100
|
-
if (this.dom.tagName !== expectedTag)
|
|
323
|
+
if (this.dom.tagName !== expectedTag) {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
101
326
|
|
|
102
327
|
const colspan = resolveRenderedColspan(node);
|
|
103
|
-
const rowspan = node
|
|
328
|
+
const rowspan = resolveRenderedRowspan(node);
|
|
104
329
|
const cell = this.dom as HTMLTableCellElement;
|
|
105
330
|
cell.colSpan = colspan > 1 ? colspan : 1;
|
|
106
331
|
cell.rowSpan = rowspan > 1 ? rowspan : 1;
|
|
332
|
+
cell.style.display = "";
|
|
333
|
+
cell.removeAttribute("aria-hidden");
|
|
334
|
+
cell.removeAttribute("data-vertical-merge-hidden");
|
|
335
|
+
requestTableLayoutSync(this.dom);
|
|
107
336
|
return true;
|
|
108
337
|
}
|
|
338
|
+
|
|
339
|
+
ignoreMutation(record: MutationRecord): boolean {
|
|
340
|
+
return record.type === "attributes" && record.target === this.dom;
|
|
341
|
+
}
|
|
109
342
|
}
|
|
110
343
|
|
|
111
344
|
/**
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* individual variables in your own stylesheet for custom themes.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
-
* @import "@docx-react-component/ui-tailwind/theme/editor-theme.css";
|
|
9
|
+
* @import "@beyondwork/docx-react-component/ui-tailwind/theme/editor-theme.css";
|
|
10
10
|
*
|
|
11
11
|
* Custom theme:
|
|
12
12
|
* .my-brand { --color-accent: #8b5cf6; }
|