@beyondwork/docx-react-component 1.0.3 → 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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/bwllaming/React-OOXML-Office/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bwllaming/React-OOXML-Office/actions/workflows/ci.yml)
4
4
 
5
- `docx-react-component` is now configured as an npm-ready package for the shipped product in this repository: `WordReviewEditor`, a fidelity-first React editor for legal-review-safe `docx` workflows.
5
+ `@beyondwork/docx-react-component` is now configured as an npm-ready package for the shipped product in this repository: `WordReviewEditor`, a fidelity-first React editor for legal-review-safe `docx` workflows.
6
6
 
7
7
  The broader repository is still evolving toward a layered `react-ooxml-office` platform, but the source reality is unchanged:
8
8
 
@@ -13,7 +13,7 @@ The broader repository is still evolving toward a layered `react-ooxml-office` p
13
13
  ## Install
14
14
 
15
15
  ```bash
16
- pnpm add docx-react-component react react-dom \
16
+ pnpm add @beyondwork/docx-react-component react react-dom tailwindcss \
17
17
  prosemirror-commands prosemirror-keymap prosemirror-model \
18
18
  prosemirror-state prosemirror-tables prosemirror-transform prosemirror-view
19
19
  ```
@@ -23,6 +23,7 @@ Current packaging truth:
23
23
  - the package is ESM-only
24
24
  - exports point at shipped TypeScript source entry points
25
25
  - consumers need a bundler or runtime that can resolve `.ts` and `.tsx` ESM imports
26
+ - consumers should import `@beyondwork/docx-react-component/ui-tailwind/theme/editor-theme.css` once and provide a Tailwind v4 CSS pipeline for it
26
27
  - package and source identifiers remain docx-first until a deliberate rename lands
27
28
 
28
29
  ## Shipped Product
@@ -30,7 +31,7 @@ Current packaging truth:
30
31
  The primary shipped surface is:
31
32
 
32
33
  ```tsx
33
- import { WordReviewEditor } from "docx-react-component";
34
+ import { WordReviewEditor } from "@beyondwork/docx-react-component";
34
35
 
35
36
  <WordReviewEditor />
36
37
  ```
@@ -43,9 +44,10 @@ import { WordReviewEditor } from "docx-react-component";
43
44
 
44
45
  The current public ESM exports are:
45
46
 
46
- - `docx-react-component` -> `WordReviewEditor`
47
- - `docx-react-component/public-types` -> public TypeScript contracts
48
- - `docx-react-component/ui-tailwind` -> current Tailwind UI primitives
47
+ - `@beyondwork/docx-react-component` -> `WordReviewEditor`
48
+ - `@beyondwork/docx-react-component/public-types` -> public TypeScript contracts
49
+ - `@beyondwork/docx-react-component/ui-tailwind` -> current Tailwind UI primitives
50
+ - `@beyondwork/docx-react-component/ui-tailwind/theme/editor-theme.css` -> shipped theme variables and Tailwind theme import
49
51
 
50
52
  ## Product Contract
51
53
 
@@ -84,6 +86,7 @@ Start here:
84
86
  Current shipped docx contracts:
85
87
 
86
88
  - `docs/reference/public-api.md`
89
+ This doc separates the shipped Wave 21 surface from the future Waves 25 through 27 ref expansion.
87
90
  - `docs/reference/ooxml-compliance.md`
88
91
  - `docs/reference/word-review-editor-frontend-architecture.md`
89
92
  - `docs/reference/word-review-editor-ux-guide.md`
@@ -99,9 +102,10 @@ Shared platform and planned xlsx docs:
99
102
 
100
103
  ## Packaging And Release
101
104
 
102
- - `.github/workflows/publish.yml` publishes on `v*` tags
105
+ - `.github/workflows/publish.yml` publishes on `v*` tags after verifying the tag matches `package.json`
103
106
  - `pnpm pack --dry-run` is the baseline package proof for this wave
104
107
  - npm provenance is enabled in `publishConfig`
108
+ - the published package currently ships source ESM entry points plus TypeScript source-backed `types` exports
105
109
  - the Microsoft Open XML SDK remains CI/internal-service only, never part of the shipped browser runtime
106
110
 
107
111
  ## Contribution Rules
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.3",
4
+ "version": "1.0.4",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "files": [
@@ -29,6 +29,7 @@
29
29
  "./io/docx-session": "./src/io/docx-session.ts",
30
30
  "./runtime/document-runtime": "./src/runtime/document-runtime.ts",
31
31
  "./api/public-types": "./src/api/public-types.ts",
32
+ "./ui-tailwind/theme/editor-theme.css": "./src/ui-tailwind/theme/editor-theme.css",
32
33
  "./package.json": "./package.json"
33
34
  },
34
35
  "types": "./src/index.ts",
@@ -65,7 +66,8 @@
65
66
  "prosemirror-transform": "^1.11.0",
66
67
  "prosemirror-view": "^1.41.7",
67
68
  "react": "^19.2.0",
68
- "react-dom": "^19.2.0"
69
+ "react-dom": "^19.2.0",
70
+ "tailwindcss": "^4.2.2"
69
71
  },
70
72
  "devDependencies": {
71
73
  "@chllming/wave-orchestration": "^0.9.8",
@@ -1,5 +1,3 @@
1
- import { TextEncoder } from "node:util";
2
-
3
1
  import type { OpcRelationship } from "../io/ooxml/part-manifest.ts";
4
2
  import { serializeMainDocument } from "../io/export/serialize-main-document.ts";
5
3
  import {
@@ -9,8 +9,13 @@ import {
9
9
  } from "../state/editor-state.ts";
10
10
  import {
11
11
  areAnchorsEqual,
12
+ createDetachedAnchor,
12
13
  createEmptyMapping,
14
+ createNodeAnchor,
15
+ createRangeAnchor,
16
+ DEFAULT_BOUNDARY_ASSOC,
13
17
  mapAnchor,
18
+ type BoundaryAssoc,
14
19
  type EditorAnchorProjection,
15
20
  type MappingStep,
16
21
  type TransactionMapping,
@@ -655,20 +660,123 @@ function createTransaction(
655
660
  }
656
661
 
657
662
  function normalizeSelection(selection: SelectionSnapshot): SelectionSnapshot {
658
- if (selection.activeRange.kind === "range") {
663
+ const activeRange = normalizeSelectionAnchor(
664
+ selection.activeRange,
665
+ selection.anchor,
666
+ selection.head,
667
+ );
668
+
669
+ if (activeRange.kind === "range") {
659
670
  return {
660
- ...selection,
661
- anchor: selection.activeRange.range.from,
662
- head: selection.activeRange.range.to,
663
- isCollapsed: selection.activeRange.range.from === selection.activeRange.range.to,
671
+ anchor: activeRange.range.from,
672
+ head: activeRange.range.to,
673
+ isCollapsed: activeRange.range.from === activeRange.range.to,
674
+ activeRange,
664
675
  };
665
676
  }
666
677
 
667
- if (selection.activeRange.kind === "node") {
668
- return createSelectionSnapshot(selection.activeRange.at, selection.activeRange.at);
678
+ if (activeRange.kind === "node") {
679
+ return createSelectionSnapshot(activeRange.at, activeRange.at);
680
+ }
681
+
682
+ return {
683
+ anchor: activeRange.lastKnownRange.from,
684
+ head: activeRange.lastKnownRange.to,
685
+ isCollapsed: activeRange.lastKnownRange.from === activeRange.lastKnownRange.to,
686
+ activeRange,
687
+ };
688
+ }
689
+
690
+ function normalizeSelectionAnchor(
691
+ value: SelectionSnapshot["activeRange"],
692
+ anchor: number,
693
+ head: number,
694
+ ): EditorAnchorProjection {
695
+ if (!value || typeof value !== "object") {
696
+ return createRangeAnchor(anchor, head, DEFAULT_BOUNDARY_ASSOC);
697
+ }
698
+
699
+ const record = value as unknown as Record<string, unknown>;
700
+ switch (record.kind) {
701
+ case "range": {
702
+ const assoc = normalizeBoundaryAssoc(record.assoc);
703
+ const internalRange = record.range;
704
+ if (
705
+ internalRange &&
706
+ typeof internalRange === "object" &&
707
+ typeof (internalRange as { from?: unknown }).from === "number" &&
708
+ typeof (internalRange as { to?: unknown }).to === "number"
709
+ ) {
710
+ return createRangeAnchor(
711
+ (internalRange as { from: number }).from,
712
+ (internalRange as { to: number }).to,
713
+ assoc,
714
+ );
715
+ }
716
+
717
+ if (typeof record.from === "number" && typeof record.to === "number") {
718
+ return createRangeAnchor(record.from, record.to, assoc);
719
+ }
720
+
721
+ return createRangeAnchor(anchor, head, assoc);
722
+ }
723
+ case "node":
724
+ return createNodeAnchor(
725
+ typeof record.at === "number" ? record.at : anchor,
726
+ normalizeAssoc(record.assoc),
727
+ );
728
+ case "detached": {
729
+ const lastKnownRange = record.lastKnownRange;
730
+ if (
731
+ lastKnownRange &&
732
+ typeof lastKnownRange === "object" &&
733
+ typeof (lastKnownRange as { from?: unknown }).from === "number" &&
734
+ typeof (lastKnownRange as { to?: unknown }).to === "number"
735
+ ) {
736
+ return createDetachedAnchor(
737
+ {
738
+ from: (lastKnownRange as { from: number }).from,
739
+ to: (lastKnownRange as { to: number }).to,
740
+ },
741
+ normalizeDetachedReason(record.reason),
742
+ );
743
+ }
744
+
745
+ return createDetachedAnchor({ from: anchor, to: head }, normalizeDetachedReason(record.reason));
746
+ }
747
+ default:
748
+ return createRangeAnchor(anchor, head, DEFAULT_BOUNDARY_ASSOC);
749
+ }
750
+ }
751
+
752
+ function normalizeBoundaryAssoc(value: unknown): BoundaryAssoc {
753
+ if (value && typeof value === "object") {
754
+ const record = value as unknown as Record<string, unknown>;
755
+ return {
756
+ start: normalizeAssoc(record.start),
757
+ end: normalizeAssoc(record.end),
758
+ };
759
+ }
760
+
761
+ return DEFAULT_BOUNDARY_ASSOC;
762
+ }
763
+
764
+ function normalizeAssoc(value: unknown): -1 | 1 {
765
+ return value === -1 ? -1 : 1;
766
+ }
767
+
768
+ function normalizeDetachedReason(
769
+ value: unknown,
770
+ ): "deleted" | "invalidatedByStructureChange" | "importAmbiguity" {
771
+ if (
772
+ value === "deleted" ||
773
+ value === "invalidatedByStructureChange" ||
774
+ value === "importAmbiguity"
775
+ ) {
776
+ return value;
669
777
  }
670
778
 
671
- return selection;
779
+ return "importAmbiguity";
672
780
  }
673
781
 
674
782
  function applyTextCommand(
@@ -10,6 +10,8 @@ import {
10
10
  type StoryUnit,
11
11
  type TextStory,
12
12
  } from "../schema/text-schema.ts";
13
+ import { createEditorSurfaceSnapshot } from "../../runtime/surface-projection.ts";
14
+ import type { DocumentRootNode, ParagraphNode, TableNode } from "../../model/canonical-document.ts";
13
15
 
14
16
  export type TextInsertion =
15
17
  | {
@@ -69,6 +71,27 @@ export function applyTextTransaction(
69
71
  options: {
70
72
  timestamp: string;
71
73
  },
74
+ ): TextTransactionResult {
75
+ const tableScopedResult = tryApplyTableParagraphTransaction(
76
+ document,
77
+ selection,
78
+ intent,
79
+ options,
80
+ );
81
+ if (tableScopedResult) {
82
+ return tableScopedResult;
83
+ }
84
+
85
+ return applyLinearTextTransaction(document, selection, intent, options);
86
+ }
87
+
88
+ function applyLinearTextTransaction(
89
+ document: CanonicalDocumentEnvelope,
90
+ selection: SelectionSnapshot,
91
+ intent: TextTransactionIntent,
92
+ options: {
93
+ timestamp: string;
94
+ },
72
95
  ): TextTransactionResult {
73
96
  const story = parseTextStory(document.content);
74
97
  const normalizedRange = resolveRange(selection, story.size, intent);
@@ -118,6 +141,209 @@ export function applyTextTransaction(
118
141
  };
119
142
  }
120
143
 
144
+ function tryApplyTableParagraphTransaction(
145
+ document: CanonicalDocumentEnvelope,
146
+ selection: SelectionSnapshot,
147
+ intent: TextTransactionIntent,
148
+ options: {
149
+ timestamp: string;
150
+ },
151
+ ): TextTransactionResult | null {
152
+ const scope = resolveTableParagraphScope(document, selection);
153
+ if (!scope) {
154
+ return null;
155
+ }
156
+ if (scope === "unsupported") {
157
+ throw new TextTransactionError(
158
+ "unsupported_content",
159
+ "Text transactions inside table structures are only supported when the selection stays within one paragraph-backed table cell.",
160
+ );
161
+ }
162
+
163
+ const localDocument: CanonicalDocumentEnvelope = {
164
+ ...document,
165
+ content: {
166
+ type: "doc",
167
+ children: [scope.paragraph],
168
+ },
169
+ };
170
+ const localSelection = createSelectionSnapshot(
171
+ selection.anchor - scope.paragraphStart,
172
+ selection.head - scope.paragraphStart,
173
+ );
174
+ const localResult = applyLinearTextTransaction(localDocument, localSelection, intent, options);
175
+ const nextParagraphBlocks = (localResult.document.content as DocumentRootNode).children;
176
+ const nextRoot: DocumentRootNode = {
177
+ ...scope.root,
178
+ children: scope.root.children.map((child, blockIndex) => {
179
+ if (blockIndex !== scope.tableBlockIndex) {
180
+ return child;
181
+ }
182
+
183
+ const table = child as TableNode;
184
+ return {
185
+ ...table,
186
+ rows: table.rows.map((row, rowIndex) => {
187
+ if (rowIndex !== scope.rowIndex) {
188
+ return row;
189
+ }
190
+
191
+ return {
192
+ ...row,
193
+ cells: row.cells.map((cell, cellIndex) => {
194
+ if (cellIndex !== scope.cellIndex) {
195
+ return cell;
196
+ }
197
+
198
+ return {
199
+ ...cell,
200
+ children: [
201
+ ...cell.children.slice(0, scope.childIndex),
202
+ ...nextParagraphBlocks,
203
+ ...cell.children.slice(scope.childIndex + 1),
204
+ ],
205
+ };
206
+ }),
207
+ };
208
+ }),
209
+ };
210
+ }),
211
+ };
212
+
213
+ return {
214
+ document: {
215
+ ...document,
216
+ updatedAt: options.timestamp,
217
+ content: nextRoot,
218
+ },
219
+ selection: createSelectionSnapshot(
220
+ localResult.selection.anchor + scope.paragraphStart,
221
+ localResult.selection.head + scope.paragraphStart,
222
+ ),
223
+ mapping: {
224
+ ...localResult.mapping,
225
+ steps: localResult.mapping.steps.map((step) => ({
226
+ ...step,
227
+ from: step.from + scope.paragraphStart,
228
+ to: step.to + scope.paragraphStart,
229
+ })),
230
+ },
231
+ storyText: localResult.storyText,
232
+ };
233
+ }
234
+
235
+ function resolveTableParagraphScope(
236
+ document: CanonicalDocumentEnvelope,
237
+ selection: SelectionSnapshot,
238
+ ):
239
+ | {
240
+ root: DocumentRootNode;
241
+ tableBlockIndex: number;
242
+ rowIndex: number;
243
+ cellIndex: number;
244
+ childIndex: number;
245
+ paragraph: ParagraphNode;
246
+ paragraphStart: number;
247
+ }
248
+ | "unsupported"
249
+ | null {
250
+ const root = document.content as DocumentRootNode;
251
+ if (!root || root.type !== "doc" || !Array.isArray(root.children)) {
252
+ return null;
253
+ }
254
+
255
+ const surface = createEditorSurfaceSnapshot(document, selection);
256
+ const selectionFrom = Math.min(selection.anchor, selection.head);
257
+ const selectionTo = Math.max(selection.anchor, selection.head);
258
+
259
+ for (let blockIndex = 0; blockIndex < root.children.length; blockIndex += 1) {
260
+ const block = root.children[blockIndex];
261
+ const surfaceBlock = surface.blocks[blockIndex];
262
+ if (block?.type !== "table" || surfaceBlock?.kind !== "table") {
263
+ continue;
264
+ }
265
+
266
+ const insideTable = selectionFallsWithinSurfaceRange(
267
+ selectionFrom,
268
+ selectionTo,
269
+ surfaceBlock.from,
270
+ surfaceBlock.to,
271
+ );
272
+ if (!insideTable) {
273
+ continue;
274
+ }
275
+
276
+ for (let rowIndex = 0; rowIndex < block.rows.length; rowIndex += 1) {
277
+ const row = block.rows[rowIndex];
278
+ const surfaceRow = surfaceBlock.rows[rowIndex];
279
+ if (!row || !surfaceRow) {
280
+ continue;
281
+ }
282
+
283
+ for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) {
284
+ const cell = row.cells[cellIndex];
285
+ const surfaceCell = surfaceRow.cells[cellIndex];
286
+ if (!cell || !surfaceCell) {
287
+ continue;
288
+ }
289
+
290
+ for (
291
+ let childIndex = 0;
292
+ childIndex < Math.min(cell.children.length, surfaceCell.content.length);
293
+ childIndex += 1
294
+ ) {
295
+ const child = cell.children[childIndex];
296
+ const surfaceChild = surfaceCell.content[childIndex];
297
+ if (child?.type !== "paragraph" || surfaceChild?.kind !== "paragraph") {
298
+ continue;
299
+ }
300
+
301
+ const insideParagraph = selectionFallsWithinSurfaceRange(
302
+ selectionFrom,
303
+ selectionTo,
304
+ surfaceChild.from,
305
+ surfaceChild.to,
306
+ );
307
+ if (!insideParagraph) {
308
+ continue;
309
+ }
310
+
311
+ return {
312
+ root,
313
+ tableBlockIndex: blockIndex,
314
+ rowIndex,
315
+ cellIndex,
316
+ childIndex,
317
+ paragraph: child,
318
+ paragraphStart: surfaceChild.from,
319
+ };
320
+ }
321
+ }
322
+ }
323
+
324
+ return "unsupported";
325
+ }
326
+
327
+ return null;
328
+ }
329
+
330
+ function selectionFallsWithinSurfaceRange(
331
+ selectionFrom: number,
332
+ selectionTo: number,
333
+ rangeFrom: number,
334
+ rangeTo: number,
335
+ ): boolean {
336
+ if (rangeFrom === rangeTo) {
337
+ return selectionFrom === rangeFrom && selectionTo === rangeTo;
338
+ }
339
+
340
+ return (
341
+ selectionFrom >= rangeFrom &&
342
+ selectionTo <= rangeTo &&
343
+ selectionFrom < rangeTo
344
+ );
345
+ }
346
+
121
347
  function resolveRange(
122
348
  selection: SelectionSnapshot,
123
349
  storySize: number,
@@ -302,5 +302,5 @@ function convertCachedFormulaValue(
302
302
  // ---------------------------------------------------------------------------
303
303
 
304
304
  function decodePartBytes(bytes: Uint8Array): string {
305
- return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("utf8");
305
+ return new TextDecoder("utf-8").decode(bytes);
306
306
  }
@@ -1733,7 +1733,7 @@ function decodeUtf8(bytes: Uint8Array | undefined): string {
1733
1733
  return "";
1734
1734
  }
1735
1735
 
1736
- return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("utf8");
1736
+ return new TextDecoder("utf-8").decode(bytes);
1737
1737
  }
1738
1738
 
1739
1739
  function toUint8Array(bytes: Uint8Array | ArrayBuffer): Uint8Array {
@@ -6,6 +6,7 @@ import type {
6
6
  ParsedTableBorders,
7
7
  ParsedTableCell,
8
8
  ParsedTableCellBorders,
9
+ ParsedTableLook,
9
10
  ParsedTableRow,
10
11
  ParsedTableWidth,
11
12
  } from "../ooxml/parse-tables.ts";
@@ -36,6 +37,9 @@ function serializeCell(cell: ParsedTableCell): string {
36
37
 
37
38
  function buildTablePropertiesXml(table: ParsedTable): string {
38
39
  const children: string[] = [];
40
+ if (table.styleId) {
41
+ children.push(`<w:tblStyle w:val="${table.styleId}"/>`);
42
+ }
39
43
  if (table.width) {
40
44
  children.push(serializeWidth("tblW", table.width));
41
45
  }
@@ -50,6 +54,10 @@ function buildTablePropertiesXml(table: ParsedTable): string {
50
54
  const marginsXml = serializeTableCellMargins(table.cellMargins);
51
55
  if (marginsXml) children.push(`<w:tblCellMar>${marginsXml}</w:tblCellMar>`);
52
56
  }
57
+ if (table.tblLook) {
58
+ const tblLookXml = serializeTableLook(table.tblLook);
59
+ if (tblLookXml) children.push(tblLookXml);
60
+ }
53
61
  return children.length > 0 ? `<w:tblPr>${children.join("")}</w:tblPr>` : "";
54
62
  }
55
63
 
@@ -128,6 +136,25 @@ function serializeCellBorders(borders: ParsedTableCellBorders): string {
128
136
  .join("");
129
137
  }
130
138
 
139
+ function serializeTableLook(tblLook: ParsedTableLook): string {
140
+ const attrs: string[] = [];
141
+ if (tblLook.val) attrs.push(`w:val="${tblLook.val}"`);
142
+ for (const [key, attr] of [
143
+ ["firstRow", "w:firstRow"],
144
+ ["lastRow", "w:lastRow"],
145
+ ["firstColumn", "w:firstColumn"],
146
+ ["lastColumn", "w:lastColumn"],
147
+ ["noHBand", "w:noHBand"],
148
+ ["noVBand", "w:noVBand"],
149
+ ] as const) {
150
+ const value = tblLook[key];
151
+ if (value !== undefined) {
152
+ attrs.push(`${attr}="${value ? "1" : "0"}"`);
153
+ }
154
+ }
155
+ return attrs.length > 0 ? `<w:tblLook ${attrs.join(" ")}/>` : "";
156
+ }
157
+
131
158
  function serializeCellShading(shading: ParsedCellShading): string {
132
159
  const attrs: string[] = [];
133
160
  if (shading.val) attrs.push(`w:val="${shading.val}"`);
@@ -59,12 +59,23 @@ export interface ParsedCellMargins {
59
59
  right?: number;
60
60
  }
61
61
 
62
+ export interface ParsedTableLook {
63
+ val?: string;
64
+ firstRow?: boolean;
65
+ lastRow?: boolean;
66
+ firstColumn?: boolean;
67
+ lastColumn?: boolean;
68
+ noHBand?: boolean;
69
+ noVBand?: boolean;
70
+ }
71
+
62
72
  export interface ParsedTableDocument {
63
73
  tables: ParsedTable[];
64
74
  }
65
75
 
66
76
  export interface ParsedTable {
67
77
  type: "table";
78
+ styleId?: string;
68
79
  propertiesXml?: string;
69
80
  gridColumns: number[];
70
81
  rows: ParsedTableRow[];
@@ -73,6 +84,7 @@ export interface ParsedTable {
73
84
  alignment?: "left" | "center" | "right";
74
85
  borders?: ParsedTableBorders;
75
86
  cellMargins?: ParsedCellMargins;
87
+ tblLook?: ParsedTableLook;
76
88
  }
77
89
 
78
90
  export interface ParsedTableRow {
@@ -115,13 +127,16 @@ function parseTable(node: XmlElementNode, sourceXml: string): ParsedTable {
115
127
  .filter((child): child is XmlElementNode => child.type === "element" && localName(child.name) === "tr")
116
128
  .map((rowNode) => parseRow(rowNode, sourceXml));
117
129
 
130
+ const styleId = propertiesNode ? readTableStyleId(propertiesNode) : undefined;
118
131
  const width = propertiesNode ? readTableWidth(propertiesNode) : undefined;
119
132
  const alignment = propertiesNode ? readTableAlignment(propertiesNode) : undefined;
120
133
  const borders = propertiesNode ? readTableBorders(propertiesNode) : undefined;
121
134
  const cellMargins = propertiesNode ? readTableCellMargins(propertiesNode) : undefined;
135
+ const tblLook = propertiesNode ? readTableLook(propertiesNode) : undefined;
122
136
 
123
137
  return {
124
138
  type: "table",
139
+ ...(styleId ? { styleId } : {}),
125
140
  ...(propertiesNode ? { propertiesXml: sourceXml.slice(propertiesNode.start, propertiesNode.end) } : {}),
126
141
  gridColumns: gridNode ? readGridColumns(gridNode) : [],
127
142
  rows,
@@ -130,6 +145,7 @@ function parseTable(node: XmlElementNode, sourceXml: string): ParsedTable {
130
145
  ...(alignment ? { alignment } : {}),
131
146
  ...(borders ? { borders } : {}),
132
147
  ...(cellMargins ? { cellMargins } : {}),
148
+ ...(tblLook ? { tblLook } : {}),
133
149
  };
134
150
  }
135
151
 
@@ -493,6 +509,40 @@ function readTableAlignment(propertiesNode: XmlElementNode): "left" | "center" |
493
509
  return undefined;
494
510
  }
495
511
 
512
+ function readTableStyleId(propertiesNode: XmlElementNode): string | undefined {
513
+ const styleNode = findFirstChild(propertiesNode, "tblStyle");
514
+ if (!styleNode) return undefined;
515
+ return styleNode.attributes["w:val"] ?? styleNode.attributes.val;
516
+ }
517
+
518
+ function readTableLook(propertiesNode: XmlElementNode): ParsedTableLook | undefined {
519
+ const tblLookNode = findFirstChild(propertiesNode, "tblLook");
520
+ if (!tblLookNode) return undefined;
521
+
522
+ const tableLook: ParsedTableLook = {};
523
+ const val = tblLookNode.attributes["w:val"] ?? tblLookNode.attributes.val;
524
+ if (val) {
525
+ tableLook.val = val;
526
+ }
527
+
528
+ for (const [attribute, key] of [
529
+ ["w:firstRow", "firstRow"],
530
+ ["w:lastRow", "lastRow"],
531
+ ["w:firstColumn", "firstColumn"],
532
+ ["w:lastColumn", "lastColumn"],
533
+ ["w:noHBand", "noHBand"],
534
+ ["w:noVBand", "noVBand"],
535
+ ] as const) {
536
+ const fallback = attribute.replace("w:", "");
537
+ const raw = tblLookNode.attributes[attribute] ?? tblLookNode.attributes[fallback];
538
+ if (raw !== undefined) {
539
+ tableLook[key] = raw !== "0" && raw !== "false" && raw !== "off";
540
+ }
541
+ }
542
+
543
+ return Object.keys(tableLook).length > 0 ? tableLook : undefined;
544
+ }
545
+
496
546
  function readTableBorders(propertiesNode: XmlElementNode): ParsedTableBorders | undefined {
497
547
  const bordersNode = findFirstChild(propertiesNode, "tblBorders");
498
548
  if (!bordersNode) return undefined;
@@ -1,4 +1,4 @@
1
- import { inflateRawSync } from "fflate";
1
+ import { inflateSync } from "fflate";
2
2
 
3
3
  import {
4
4
  CONTENT_TYPES_PATH,
@@ -184,7 +184,7 @@ function readZipEntry(archive: Uint8Array, entry: ZipCentralDirectoryEntry): Uin
184
184
  case "store":
185
185
  return new Uint8Array(compressed);
186
186
  case "deflate":
187
- return inflateRawSync(compressed);
187
+ return inflateSync(compressed);
188
188
  default:
189
189
  throw new Error(`Unsupported ZIP compression for ${entry.path}.`);
190
190
  }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { Command as PMCommand, EditorState } from "prosemirror-state";
13
13
  import {
14
+ TableMap,
14
15
  addColumnAfter as pmAddColumnAfter,
15
16
  addColumnBefore as pmAddColumnBefore,
16
17
  addRowAfter as pmAddRowAfter,
@@ -48,8 +49,14 @@ function withTableGuard(
48
49
  command: PMCommand,
49
50
  ): PMCommand {
50
51
  return (state, dispatch, view) => {
51
- if (!canRun(state)) return false;
52
- return command(state, dispatch, view);
52
+ try {
53
+ if (!canRun(state)) {
54
+ return false;
55
+ }
56
+ return command(state, dispatch, view);
57
+ } catch {
58
+ return false;
59
+ }
53
60
  };
54
61
  }
55
62
 
@@ -72,7 +79,7 @@ export const addColumnAfter: PMCommand = (state, dispatch, view) =>
72
79
 
73
80
  export const deleteColumn: PMCommand = withTableGuard((state) => {
74
81
  const table = tableAtSelection(state);
75
- return table !== null && table.childCount > 0 && table.firstChild !== null && table.firstChild.childCount > 1;
82
+ return table !== null && TableMap.get(table).width > 1;
76
83
  }, pmDeleteColumn);
77
84
 
78
85
  export {
@@ -11,6 +11,14 @@
11
11
 
12
12
  import type { NodeSpec } from "prosemirror-model";
13
13
 
14
+ type TableCellAttrs = {
15
+ colspan?: number | null;
16
+ rowspan?: number | null;
17
+ colwidth?: number[] | null;
18
+ gridSpan?: number | null;
19
+ verticalMerge?: "restart" | "continue" | null;
20
+ };
21
+
14
22
  function resolveRenderedColspan(attrs: {
15
23
  colspan?: number | null;
16
24
  gridSpan?: number | null;
@@ -24,6 +32,93 @@ function resolveRenderedColspan(attrs: {
24
32
  return 1;
25
33
  }
26
34
 
35
+ function resolveRenderedRowspan(attrs: { rowspan?: number | null }): number {
36
+ return typeof attrs.rowspan === "number" && attrs.rowspan > 1 ? attrs.rowspan : 1;
37
+ }
38
+
39
+ function parseColwidthAttr(dom: HTMLElement, colspan: number): number[] | null {
40
+ const widthAttr = dom.getAttribute("data-colwidth");
41
+ if (!widthAttr || !/^\d+(,\d+)*$/.test(widthAttr)) {
42
+ return null;
43
+ }
44
+
45
+ const widths = widthAttr.split(",").map((value) => Number.parseInt(value, 10));
46
+ return widths.length === colspan ? widths : null;
47
+ }
48
+
49
+ function getCellAttrs(dom: HTMLElement): TableCellAttrs {
50
+ const colspan = Number(dom.getAttribute("colspan") || 1);
51
+ const rowspan = Number(dom.getAttribute("rowspan") || 1);
52
+ const gridSpanAttr = dom.getAttribute("data-grid-span");
53
+ const verticalMergeAttr = dom.getAttribute("data-vertical-merge");
54
+ const gridSpan = gridSpanAttr ? Number.parseInt(gridSpanAttr, 10) : colspan;
55
+
56
+ return {
57
+ colspan,
58
+ rowspan,
59
+ colwidth: parseColwidthAttr(dom, colspan),
60
+ gridSpan: Number.isFinite(gridSpan) && gridSpan > 0 ? gridSpan : colspan,
61
+ verticalMerge:
62
+ verticalMergeAttr === "restart" || verticalMergeAttr === "continue"
63
+ ? verticalMergeAttr
64
+ : null,
65
+ };
66
+ }
67
+
68
+ function setCellDomAttrs(nodeAttrs: TableCellAttrs, className: string): Record<string, string> {
69
+ const attrs: Record<string, string> = { class: className };
70
+ const colspan = resolveRenderedColspan(nodeAttrs);
71
+ const rowspan = resolveRenderedRowspan(nodeAttrs);
72
+
73
+ if (colspan > 1) {
74
+ attrs.colspan = String(colspan);
75
+ }
76
+ if (rowspan > 1) {
77
+ attrs.rowspan = String(rowspan);
78
+ }
79
+ if (nodeAttrs.colwidth && nodeAttrs.colwidth.length > 0) {
80
+ attrs["data-colwidth"] = nodeAttrs.colwidth.join(",");
81
+ }
82
+ if (typeof nodeAttrs.gridSpan === "number" && nodeAttrs.gridSpan > 1) {
83
+ attrs["data-grid-span"] = String(nodeAttrs.gridSpan);
84
+ }
85
+ if (nodeAttrs.verticalMerge) {
86
+ attrs["data-vertical-merge"] = nodeAttrs.verticalMerge;
87
+ }
88
+
89
+ return attrs;
90
+ }
91
+
92
+ function validateColwidth(value: unknown): void {
93
+ if (value === null) {
94
+ return;
95
+ }
96
+ if (!Array.isArray(value)) {
97
+ throw new TypeError("colwidth must be null or an array");
98
+ }
99
+ for (const item of value) {
100
+ if (typeof item !== "number") {
101
+ throw new TypeError("colwidth must be null or an array of numbers");
102
+ }
103
+ }
104
+ }
105
+
106
+ function validateVerticalMerge(value: unknown): void {
107
+ if (value === null || value === "restart" || value === "continue") {
108
+ return;
109
+ }
110
+ throw new TypeError("verticalMerge must be null, 'restart', or 'continue'");
111
+ }
112
+
113
+ const tableCellSpecAttrs = {
114
+ propertiesXml: { default: null },
115
+ gridSpan: { default: 1, validate: "number" },
116
+ verticalMerge: { default: null, validate: validateVerticalMerge },
117
+ colspan: { default: 1, validate: "number" },
118
+ rowspan: { default: 1, validate: "number" },
119
+ colwidth: { default: null, validate: validateColwidth },
120
+ } as const;
121
+
27
122
  export const tableNodeSpec: NodeSpec = {
28
123
  content: "table_row+",
29
124
  tableRole: "table",
@@ -56,35 +151,24 @@ export const tableCellNodeSpec: NodeSpec = {
56
151
  content: "paragraph+",
57
152
  tableRole: "cell",
58
153
  isolating: true,
59
- attrs: {
60
- propertiesXml: { default: null },
61
- gridSpan: { default: 1 },
62
- verticalMerge: { default: null },
63
- colspan: { default: 1 },
64
- rowspan: { default: 1 },
65
- colwidth: { default: null },
66
- },
154
+ attrs: tableCellSpecAttrs,
67
155
  parseDOM: [
68
156
  {
69
157
  tag: "td",
70
- getAttrs(dom: HTMLElement) {
71
- const colspan = dom.getAttribute("colspan");
72
- const rowspan = dom.getAttribute("rowspan");
73
- return {
74
- colspan: colspan ? Number.parseInt(colspan, 10) : 1,
75
- rowspan: rowspan ? Number.parseInt(rowspan, 10) : 1,
76
- };
158
+ getAttrs(dom) {
159
+ if (!(dom instanceof HTMLElement)) {
160
+ return false;
161
+ }
162
+ return getCellAttrs(dom);
77
163
  },
78
164
  },
79
165
  ],
80
166
  toDOM(node) {
81
- const attrs: Record<string, string> = {
82
- class: "border border-primary/20 p-2 align-top",
83
- };
84
- const colspan = resolveRenderedColspan(node.attrs as { colspan?: number; gridSpan?: number });
85
- if (colspan > 1) attrs.colspan = String(colspan);
86
- if (node.attrs.rowspan > 1) attrs.rowspan = String(node.attrs.rowspan);
87
- return ["td", attrs, 0];
167
+ return [
168
+ "td",
169
+ setCellDomAttrs(node.attrs as TableCellAttrs, "border border-primary/20 p-2 align-top"),
170
+ 0,
171
+ ];
88
172
  },
89
173
  };
90
174
 
@@ -92,35 +176,27 @@ export const tableHeaderCellNodeSpec: NodeSpec = {
92
176
  content: "paragraph+",
93
177
  tableRole: "header_cell",
94
178
  isolating: true,
95
- attrs: {
96
- propertiesXml: { default: null },
97
- gridSpan: { default: 1 },
98
- verticalMerge: { default: null },
99
- colspan: { default: 1 },
100
- rowspan: { default: 1 },
101
- colwidth: { default: null },
102
- },
179
+ attrs: tableCellSpecAttrs,
103
180
  parseDOM: [
104
181
  {
105
182
  tag: "th",
106
- getAttrs(dom: HTMLElement) {
107
- const colspan = dom.getAttribute("colspan");
108
- const rowspan = dom.getAttribute("rowspan");
109
- return {
110
- colspan: colspan ? Number.parseInt(colspan, 10) : 1,
111
- rowspan: rowspan ? Number.parseInt(rowspan, 10) : 1,
112
- };
183
+ getAttrs(dom) {
184
+ if (!(dom instanceof HTMLElement)) {
185
+ return false;
186
+ }
187
+ return getCellAttrs(dom);
113
188
  },
114
189
  },
115
190
  ],
116
191
  toDOM(node) {
117
- const attrs: Record<string, string> = {
118
- class: "border border-primary/20 p-2 align-top font-semibold bg-surface-raised",
119
- };
120
- const colspan = resolveRenderedColspan(node.attrs as { colspan?: number; gridSpan?: number });
121
- if (colspan > 1) attrs.colspan = String(colspan);
122
- if (node.attrs.rowspan > 1) attrs.rowspan = String(node.attrs.rowspan);
123
- return ["th", attrs, 0];
192
+ return [
193
+ "th",
194
+ setCellDomAttrs(
195
+ node.attrs as TableCellAttrs,
196
+ "border border-primary/20 p-2 align-top font-semibold bg-surface-raised",
197
+ ),
198
+ 0,
199
+ ];
124
200
  },
125
201
  };
126
202
 
@@ -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; }