@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.
@@ -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 (from gridSpan/verticalMerge attrs).
6
- *
7
- * Usage with EditorView:
8
- * new EditorView(mount, {
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: HTMLElement;
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/rowspan from node attrs (mapped from gridSpan/verticalMerge
70
- * in the OOXML model). Distinguishes header cells by tableRole spec attribute.
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.attrs.rowspan as number;
86
- if (colspan > 1) (cell as HTMLTableCellElement).colSpan = colspan;
87
- if (rowspan > 1) (cell as HTMLTableCellElement).rowSpan = rowspan;
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) return false;
323
+ if (this.dom.tagName !== expectedTag) {
324
+ return false;
325
+ }
101
326
 
102
327
  const colspan = resolveRenderedColspan(node);
103
- const rowspan = node.attrs.rowspan as number;
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; }