@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 +11 -7
- package/package.json +4 -2
- 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 +2 -2
- package/src/runtime/table-commands.ts +10 -3
- package/src/runtime/table-schema.ts +120 -44
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +249 -16
- package/src/ui-tailwind/theme/editor-theme.css +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/bwllaming/React-OOXML-Office/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
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.
|
|
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",
|
|
@@ -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
|
-
|
|
663
|
+
const activeRange = normalizeSelectionAnchor(
|
|
664
|
+
selection.activeRange,
|
|
665
|
+
selection.anchor,
|
|
666
|
+
selection.head,
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
if (activeRange.kind === "range") {
|
|
659
670
|
return {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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 (
|
|
668
|
-
return createSelectionSnapshot(
|
|
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
|
|
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
|
|
305
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
306
306
|
}
|
package/src/io/docx-session.ts
CHANGED
|
@@ -1733,7 +1733,7 @@ function decodeUtf8(bytes: Uint8Array | undefined): string {
|
|
|
1733
1733
|
return "";
|
|
1734
1734
|
}
|
|
1735
1735
|
|
|
1736
|
-
return
|
|
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 {
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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 &&
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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; }
|