@beyondwork/docx-react-component 1.0.1 → 1.0.2

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.
Files changed (172) hide show
  1. package/README.md +44 -104
  2. package/package.json +76 -46
  3. package/src/README.md +85 -0
  4. package/src/api/README.md +22 -0
  5. package/src/api/public-types.ts +525 -0
  6. package/src/compare/diff-engine.ts +530 -0
  7. package/src/compare/export-redlines.ts +162 -0
  8. package/src/compare/snapshot.ts +37 -0
  9. package/src/component-inventory.md +99 -0
  10. package/src/core/README.md +10 -0
  11. package/src/core/commands/README.md +3 -0
  12. package/src/core/commands/formatting-commands.ts +161 -0
  13. package/src/core/commands/image-commands.ts +144 -0
  14. package/src/core/commands/index.ts +1013 -0
  15. package/src/core/commands/list-commands.ts +370 -0
  16. package/src/core/commands/review-commands.ts +108 -0
  17. package/src/core/commands/text-commands.ts +119 -0
  18. package/src/core/schema/README.md +3 -0
  19. package/src/core/schema/text-schema.ts +512 -0
  20. package/src/core/selection/README.md +3 -0
  21. package/src/core/selection/mapping.ts +238 -0
  22. package/src/core/selection/review-anchors.ts +94 -0
  23. package/src/core/state/README.md +3 -0
  24. package/src/core/state/editor-state.ts +580 -0
  25. package/src/core/state/text-transaction.ts +276 -0
  26. package/src/formats/xlsx/io/parse-shared-strings.ts +41 -0
  27. package/src/formats/xlsx/io/parse-sheet.ts +289 -0
  28. package/src/formats/xlsx/io/parse-styles.ts +57 -0
  29. package/src/formats/xlsx/io/parse-workbook.ts +75 -0
  30. package/src/formats/xlsx/io/xlsx-session.ts +306 -0
  31. package/src/formats/xlsx/model/cell.ts +189 -0
  32. package/src/formats/xlsx/model/sheet.ts +244 -0
  33. package/src/formats/xlsx/model/styles.ts +118 -0
  34. package/src/formats/xlsx/model/workbook.ts +449 -0
  35. package/src/index.ts +45 -0
  36. package/src/io/README.md +10 -0
  37. package/src/io/docx-session.ts +1763 -0
  38. package/src/io/export/README.md +3 -0
  39. package/src/io/export/export-session.ts +165 -0
  40. package/src/io/export/minimal-docx.ts +115 -0
  41. package/src/io/export/reattach-preserved-parts.ts +54 -0
  42. package/src/io/export/serialize-comments.ts +876 -0
  43. package/src/io/export/serialize-footnotes.ts +217 -0
  44. package/src/io/export/serialize-headers-footers.ts +200 -0
  45. package/src/io/export/serialize-main-document.ts +982 -0
  46. package/src/io/export/serialize-numbering.ts +97 -0
  47. package/src/io/export/serialize-revisions.ts +389 -0
  48. package/src/io/export/serialize-runtime-revisions.ts +265 -0
  49. package/src/io/export/serialize-tables.ts +147 -0
  50. package/src/io/export/split-review-boundaries.ts +194 -0
  51. package/src/io/normalize/README.md +3 -0
  52. package/src/io/normalize/normalize-text.ts +437 -0
  53. package/src/io/ooxml/README.md +3 -0
  54. package/src/io/ooxml/parse-comments.ts +779 -0
  55. package/src/io/ooxml/parse-complex-content.ts +287 -0
  56. package/src/io/ooxml/parse-fields.ts +438 -0
  57. package/src/io/ooxml/parse-footnotes.ts +403 -0
  58. package/src/io/ooxml/parse-headers-footers.ts +483 -0
  59. package/src/io/ooxml/parse-inline-media.ts +431 -0
  60. package/src/io/ooxml/parse-main-document.ts +1846 -0
  61. package/src/io/ooxml/parse-numbering.ts +425 -0
  62. package/src/io/ooxml/parse-revisions.ts +658 -0
  63. package/src/io/ooxml/parse-shapes.ts +271 -0
  64. package/src/io/ooxml/parse-tables.ts +568 -0
  65. package/src/io/ooxml/parse-theme.ts +314 -0
  66. package/src/io/ooxml/part-manifest.ts +136 -0
  67. package/src/io/ooxml/revision-boundaries.ts +351 -0
  68. package/src/io/opc/README.md +3 -0
  69. package/src/io/opc/corrupt-package.ts +166 -0
  70. package/src/io/opc/docx-package.ts +74 -0
  71. package/src/io/opc/package-reader.ts +320 -0
  72. package/src/io/opc/package-writer.ts +273 -0
  73. package/src/legal/bookmarks.ts +196 -0
  74. package/src/legal/cross-references.ts +356 -0
  75. package/src/legal/defined-terms.ts +203 -0
  76. package/src/model/README.md +3 -0
  77. package/src/model/canonical-document.ts +1911 -0
  78. package/src/model/cds-1.0.0.ts +196 -0
  79. package/src/model/snapshot.ts +393 -0
  80. package/src/preservation/README.md +3 -0
  81. package/src/preservation/markup-compatibility.ts +48 -0
  82. package/src/preservation/opaque-fragment-store.ts +89 -0
  83. package/src/preservation/opaque-region.ts +233 -0
  84. package/src/preservation/package-preservation.ts +120 -0
  85. package/src/preservation/preserved-part-manifest.ts +56 -0
  86. package/src/preservation/relationship-retention.ts +57 -0
  87. package/src/preservation/store.ts +185 -0
  88. package/src/review/README.md +16 -0
  89. package/src/review/store/README.md +3 -0
  90. package/src/review/store/comment-anchors.ts +70 -0
  91. package/src/review/store/comment-remapping.ts +154 -0
  92. package/src/review/store/comment-store.ts +331 -0
  93. package/src/review/store/comment-thread.ts +109 -0
  94. package/src/review/store/revision-actions.ts +394 -0
  95. package/src/review/store/revision-store.ts +303 -0
  96. package/src/review/store/revision-types.ts +168 -0
  97. package/src/review/store/runtime-comment-store.ts +43 -0
  98. package/src/runtime/README.md +3 -0
  99. package/src/runtime/ai-action-policy.ts +764 -0
  100. package/src/runtime/document-runtime.ts +967 -0
  101. package/src/runtime/read-only-diagnostics-runtime.ts +232 -0
  102. package/src/runtime/review-runtime.ts +44 -0
  103. package/src/runtime/revision-runtime.ts +107 -0
  104. package/src/runtime/session-capabilities.ts +138 -0
  105. package/src/runtime/surface-projection.ts +570 -0
  106. package/src/runtime/table-commands.ts +87 -0
  107. package/src/runtime/table-schema.ts +140 -0
  108. package/src/runtime/virtualized-rendering.ts +258 -0
  109. package/src/ui/README.md +30 -0
  110. package/src/ui/WordReviewEditor.tsx +1504 -0
  111. package/src/ui/comments/README.md +3 -0
  112. package/src/ui/compatibility/README.md +3 -0
  113. package/src/ui/editor-surface/README.md +3 -0
  114. package/src/ui/headless/comment-decoration-model.ts +124 -0
  115. package/src/ui/headless/revision-decoration-model.ts +128 -0
  116. package/src/ui/headless/selection-helpers.ts +34 -0
  117. package/src/ui/headless/use-editor-keyboard.ts +98 -0
  118. package/src/ui/review/README.md +3 -0
  119. package/src/ui/shared/revision-filters.ts +31 -0
  120. package/src/ui/status/README.md +3 -0
  121. package/src/ui/theme/README.md +3 -0
  122. package/src/ui/toolbar/README.md +3 -0
  123. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +48 -0
  124. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +44 -0
  125. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +58 -0
  126. package/src/ui-tailwind/chrome/use-before-unload.ts +20 -0
  127. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +139 -0
  128. package/src/ui-tailwind/editor-surface/pm-decorations.ts +98 -0
  129. package/src/ui-tailwind/editor-surface/pm-position-map.ts +123 -0
  130. package/src/ui-tailwind/editor-surface/pm-schema.ts +452 -0
  131. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +327 -0
  132. package/src/ui-tailwind/editor-surface/search-plugin.ts +157 -0
  133. package/src/ui-tailwind/editor-surface/tw-caret.tsx +12 -0
  134. package/src/ui-tailwind/editor-surface/tw-editor-surface.tsx +150 -0
  135. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +118 -0
  136. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +52 -0
  137. package/src/ui-tailwind/editor-surface/tw-paragraph-block.tsx +151 -0
  138. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +215 -0
  139. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +111 -0
  140. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +122 -0
  141. package/src/ui-tailwind/index.ts +61 -0
  142. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +276 -0
  143. package/src/ui-tailwind/review/tw-health-panel.tsx +120 -0
  144. package/src/ui-tailwind/review/tw-review-rail.tsx +120 -0
  145. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +164 -0
  146. package/src/ui-tailwind/status/tw-status-bar.tsx +58 -0
  147. package/src/ui-tailwind/theme/editor-theme.css +190 -0
  148. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +48 -0
  149. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +231 -0
  150. package/src/ui-tailwind/tw-review-workspace.tsx +140 -0
  151. package/src/validation/README.md +3 -0
  152. package/src/validation/compatibility-engine.ts +317 -0
  153. package/src/validation/compatibility-report.ts +160 -0
  154. package/src/validation/diagnostics.ts +203 -0
  155. package/src/validation/import-diagnostics.ts +128 -0
  156. package/src/validation/low-priority-word-surfaces.ts +373 -0
  157. package/dist/chunk-32W6IVQE.js +0 -7725
  158. package/dist/chunk-32W6IVQE.js.map +0 -1
  159. package/dist/index.cjs +0 -23722
  160. package/dist/index.cjs.map +0 -1
  161. package/dist/index.d.cts +0 -7
  162. package/dist/index.d.ts +0 -7
  163. package/dist/index.js +0 -16011
  164. package/dist/index.js.map +0 -1
  165. package/dist/public-types-DqCURAz8.d.cts +0 -1152
  166. package/dist/public-types-DqCURAz8.d.ts +0 -1152
  167. package/dist/tailwind.cjs +0 -8295
  168. package/dist/tailwind.cjs.map +0 -1
  169. package/dist/tailwind.d.cts +0 -323
  170. package/dist/tailwind.d.ts +0 -323
  171. package/dist/tailwind.js +0 -553
  172. package/dist/tailwind.js.map +0 -1
@@ -0,0 +1,231 @@
1
+ import React from "react";
2
+
3
+ import * as Popover from "@radix-ui/react-popover";
4
+ import * as Toggle from "@radix-ui/react-toggle";
5
+ import * as ToggleGroup from "@radix-ui/react-toggle-group";
6
+ import * as Tooltip from "@radix-ui/react-tooltip";
7
+ import {
8
+ Download,
9
+ Eye,
10
+ EyeOff,
11
+ FileText,
12
+ MessageSquare,
13
+ Monitor,
14
+ Redo2,
15
+ ShieldAlert,
16
+ ShieldCheck,
17
+ Undo2,
18
+ } from "lucide-react";
19
+
20
+ import type { CompatibilityPanelSnapshot, EditorWarning } from "../../api/public-types";
21
+ import type { SessionCapabilities } from "../../runtime/session-capabilities";
22
+ import { TwHealthPanel } from "../review/tw-health-panel";
23
+ import { TwToolbarIconButton } from "./tw-toolbar-icon-button";
24
+
25
+ export type ViewMode = "canvas" | "document";
26
+
27
+ export interface TwToolbarProps {
28
+ sourceLabel?: string;
29
+ capabilities?: SessionCapabilities;
30
+ compatibility?: CompatibilityPanelSnapshot;
31
+ warnings?: EditorWarning[];
32
+ viewMode: ViewMode;
33
+ /** Display toggle for tracked change decorations (not a runtime mutation toggle). */
34
+ showTrackedChanges: boolean;
35
+ onUndo: () => void;
36
+ onRedo: () => void;
37
+ onAddComment: () => void;
38
+ onExport: () => void;
39
+ onViewModeChange: (value: ViewMode) => void;
40
+ onShowTrackedChangesChange: (show: boolean) => void;
41
+ }
42
+
43
+ const focusRingClass =
44
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
45
+
46
+ export function TwToolbar(props: TwToolbarProps) {
47
+ const caps = props.capabilities;
48
+
49
+ return (
50
+ <header className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
51
+ {/* Left cluster: undo/redo + formatting */}
52
+ <div className="flex items-center gap-0.5">
53
+ <TwToolbarIconButton
54
+ icon={Undo2}
55
+ label="Undo"
56
+ disabled={caps ? !caps.canUndo : true}
57
+ onClick={props.onUndo}
58
+ />
59
+ <TwToolbarIconButton
60
+ icon={Redo2}
61
+ label="Redo"
62
+ disabled={caps ? !caps.canRedo : true}
63
+ onClick={props.onRedo}
64
+ />
65
+ <div className="mx-1 h-4 w-px bg-border" />
66
+
67
+ {/* Paragraph style selector and B/I/U removed — not yet supported */}
68
+ </div>
69
+
70
+ {/* Center: document title */}
71
+ <div className="flex-1 text-center min-w-0">
72
+ <span className="text-sm font-medium truncate block">
73
+ {props.sourceLabel ?? "Untitled"}
74
+ </span>
75
+ </div>
76
+
77
+ {/* Right cluster: comment, track changes, markup, view, export */}
78
+ <div className="flex items-center gap-0.5">
79
+ <TwToolbarIconButton
80
+ icon={MessageSquare}
81
+ label="Add comment"
82
+ disabled={caps ? !caps.canAddComment : true}
83
+ emphasis
84
+ onClick={props.onAddComment}
85
+ />
86
+
87
+ <Tooltip.Root>
88
+ <Tooltip.Trigger asChild>
89
+ <Toggle.Root
90
+ pressed={props.showTrackedChanges}
91
+ onPressedChange={props.onShowTrackedChangesChange}
92
+ disabled={caps ? !caps.trackChangesSupported : false}
93
+ className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-accent-soft data-[state=on]:text-accent outline-none disabled:opacity-40 ${focusRingClass}`}
94
+ >
95
+ {props.showTrackedChanges ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
96
+ </Toggle.Root>
97
+ </Tooltip.Trigger>
98
+ <Tooltip.Portal>
99
+ <Tooltip.Content
100
+ className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
101
+ sideOffset={6}
102
+ >
103
+ {props.showTrackedChanges ? "Hide tracked changes" : "Show tracked changes"}
104
+ </Tooltip.Content>
105
+ </Tooltip.Portal>
106
+ </Tooltip.Root>
107
+
108
+ <div className="mx-1 h-4 w-px bg-border" />
109
+
110
+ {/* View mode toggle group: Canvas (clean, flowing) / Document (paged, markup) */}
111
+ <ToggleGroup.Root
112
+ type="single"
113
+ value={props.viewMode}
114
+ onValueChange={(v: string) => {
115
+ if (v) props.onViewModeChange(v as ViewMode);
116
+ }}
117
+ className="flex items-center gap-0.5"
118
+ >
119
+ <Tooltip.Root>
120
+ <Tooltip.Trigger asChild>
121
+ <ToggleGroup.Item
122
+ value="canvas"
123
+ className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-accent-soft data-[state=on]:text-accent outline-none ${focusRingClass}`}
124
+ >
125
+ <Monitor className="h-3.5 w-3.5" />
126
+ </ToggleGroup.Item>
127
+ </Tooltip.Trigger>
128
+ <Tooltip.Portal>
129
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
130
+ Canvas — clean flowing text
131
+ </Tooltip.Content>
132
+ </Tooltip.Portal>
133
+ </Tooltip.Root>
134
+ <Tooltip.Root>
135
+ <Tooltip.Trigger asChild>
136
+ <ToggleGroup.Item
137
+ value="document"
138
+ className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-surface data-[state=on]:bg-accent-soft data-[state=on]:text-accent outline-none ${focusRingClass}`}
139
+ >
140
+ <FileText className="h-3.5 w-3.5" />
141
+ </ToggleGroup.Item>
142
+ </Tooltip.Trigger>
143
+ <Tooltip.Portal>
144
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
145
+ Document — paged with markup
146
+ </Tooltip.Content>
147
+ </Tooltip.Portal>
148
+ </Tooltip.Root>
149
+ </ToggleGroup.Root>
150
+
151
+ {/* Health indicator */}
152
+ {props.compatibility && props.warnings ? (
153
+ <Popover.Root>
154
+ <Tooltip.Root>
155
+ <Tooltip.Trigger asChild>
156
+ <Popover.Trigger asChild>
157
+ <button
158
+ type="button"
159
+ className={`relative inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-surface hover:text-primary outline-none ${focusRingClass} ${
160
+ (caps?.healthIssueCount ?? 0) > 0 ? "text-secondary" : "text-secondary"
161
+ }`}
162
+ >
163
+ {(caps?.healthIssueCount ?? 0) > 0
164
+ ? <ShieldAlert className="h-4 w-4" />
165
+ : <ShieldCheck className="h-4 w-4" />
166
+ }
167
+ {(caps?.healthIssueCount ?? 0) > 0 ? (
168
+ <span className="absolute -top-0.5 -right-0.5 flex h-3 min-w-[12px] items-center justify-center rounded-full bg-tertiary text-[8px] font-medium text-white">
169
+ {caps?.healthIssueCount}
170
+ </span>
171
+ ) : null}
172
+ </button>
173
+ </Popover.Trigger>
174
+ </Tooltip.Trigger>
175
+ <Tooltip.Portal>
176
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
177
+ {(caps?.healthIssueCount ?? 0) > 0
178
+ ? `Document health — ${caps?.healthIssueCount} issue${(caps?.healthIssueCount ?? 0) !== 1 ? "s" : ""}`
179
+ : "Document health — no issues"
180
+ }
181
+ </Tooltip.Content>
182
+ </Tooltip.Portal>
183
+ </Tooltip.Root>
184
+ <Popover.Portal>
185
+ <Popover.Content
186
+ className="w-[360px] max-h-[480px] overflow-y-auto rounded-lg bg-canvas shadow-lg ring-1 ring-border p-3 z-50"
187
+ sideOffset={8}
188
+ align="end"
189
+ >
190
+ <TwHealthPanel
191
+ compatibility={props.compatibility}
192
+ warnings={props.warnings}
193
+ />
194
+ </Popover.Content>
195
+ </Popover.Portal>
196
+ </Popover.Root>
197
+ ) : null}
198
+
199
+ <div className="mx-1 h-4 w-px bg-border" />
200
+
201
+ {/* Export button */}
202
+ <Tooltip.Root>
203
+ <Tooltip.Trigger asChild>
204
+ <button
205
+ type="button"
206
+ disabled={caps ? !caps.canExport : true}
207
+ className={[
208
+ "inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold transition-colors outline-none",
209
+ focusRingClass,
210
+ caps?.exportBlocked
211
+ ? "cursor-not-allowed text-danger opacity-50"
212
+ : "text-accent hover:bg-accent-soft",
213
+ ].join(" ")}
214
+ onClick={props.onExport}
215
+ >
216
+ <Download className="h-3.5 w-3.5" />
217
+ Export
218
+ </button>
219
+ </Tooltip.Trigger>
220
+ <Tooltip.Portal>
221
+ <Tooltip.Content className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50" sideOffset={6}>
222
+ {caps?.exportBlocked
223
+ ? "Export blocked by unsupported content"
224
+ : "Export document"}
225
+ </Tooltip.Content>
226
+ </Tooltip.Portal>
227
+ </Tooltip.Root>
228
+ </div>
229
+ </header>
230
+ );
231
+ }
@@ -0,0 +1,140 @@
1
+ import React, { type ReactNode, useState } from "react";
2
+
3
+ import * as Tooltip from "@radix-ui/react-tooltip";
4
+
5
+ import type {
6
+ CommentSidebarThreadSnapshot,
7
+ RuntimeRenderSnapshot,
8
+ TrackedChangeEntrySnapshot,
9
+ } from "../api/public-types";
10
+ import type { SessionCapabilities } from "../runtime/session-capabilities";
11
+ import type { MarkupDisplay } from "../ui/headless/comment-decoration-model";
12
+ import { TwAlertBanner } from "./chrome/tw-alert-banner";
13
+ import { TwSelectionToolbar } from "./chrome/tw-selection-toolbar";
14
+ import { TwReviewRail, type ReviewRailTab } from "./review/tw-review-rail";
15
+ import { TwStatusBar } from "./status/tw-status-bar";
16
+ import { TwToolbar, type ViewMode } from "./toolbar/tw-toolbar";
17
+
18
+ export interface TwReviewWorkspaceProps {
19
+ snapshot: RuntimeRenderSnapshot;
20
+ currentUserId?: string;
21
+ capabilities?: SessionCapabilities;
22
+ reviewMode?: "editing" | "review";
23
+ document: ReactNode;
24
+ viewMode: ViewMode;
25
+ activeRailTab: ReviewRailTab;
26
+ activeCommentId?: string;
27
+ activeRevisionId?: string;
28
+ showTrackedChanges: boolean;
29
+ selectionPreview?: string | null;
30
+ onViewModeChange: (value: ViewMode) => void;
31
+ onActiveRailTabChange: (value: ReviewRailTab) => void;
32
+ onShowTrackedChangesChange: (show: boolean) => void;
33
+ onUndo: () => void;
34
+ onRedo: () => void;
35
+ onAddComment: () => void;
36
+ onExport: () => void;
37
+ onOpenComment: (thread: CommentSidebarThreadSnapshot) => void;
38
+ onResolveComment: (commentId: string) => void;
39
+ onReopenComment?: (commentId: string) => void;
40
+ onAddReply?: (commentId: string, body: string) => void;
41
+ onEditBody?: (commentId: string, body: string) => void;
42
+ onOpenRevision: (revision: TrackedChangeEntrySnapshot) => void;
43
+ onAcceptRevision: (revisionId: string) => void;
44
+ onRejectRevision: (revisionId: string) => void;
45
+ onAcceptAllChanges: () => void;
46
+ onRejectAllChanges: () => void;
47
+ }
48
+
49
+ export function TwReviewWorkspace(props: TwReviewWorkspaceProps) {
50
+ const { snapshot } = props;
51
+ const caps = props.capabilities;
52
+ const markupDisplay: MarkupDisplay = props.viewMode === "document" ? "all" : "clean";
53
+ const preserveOnlyCount = caps?.preserveOnlyCount ??
54
+ snapshot.compatibility.featureEntries.filter(
55
+ (entry) => entry.featureClass === "preserve-only",
56
+ ).length;
57
+ const showReviewRail = caps?.reviewRailVisible ?? true;
58
+
59
+ return (
60
+ <Tooltip.Provider delayDuration={400}>
61
+ <div className="flex h-full flex-col bg-canvas text-primary">
62
+ <TwToolbar
63
+ sourceLabel={snapshot.sourceLabel}
64
+ capabilities={caps}
65
+ compatibility={snapshot.compatibility}
66
+ warnings={snapshot.warnings}
67
+ viewMode={props.viewMode}
68
+ showTrackedChanges={props.showTrackedChanges}
69
+ onUndo={props.onUndo}
70
+ onRedo={props.onRedo}
71
+ onAddComment={props.onAddComment}
72
+ onExport={props.onExport}
73
+ onViewModeChange={props.onViewModeChange}
74
+ onShowTrackedChangesChange={props.onShowTrackedChangesChange}
75
+ />
76
+
77
+ <TwAlertBanner snapshot={snapshot} preserveOnlyCount={preserveOnlyCount} />
78
+
79
+ <div className="flex flex-1 min-h-0">
80
+ {/* Document column */}
81
+ <div className="flex flex-1 flex-col min-w-0">
82
+ <div className={`flex-1 overflow-y-auto ${props.viewMode === "document" ? "bg-surface" : "bg-canvas"}`}>
83
+ <div
84
+ className={`mx-auto min-h-full ${
85
+ props.viewMode === "document"
86
+ ? "max-w-[780px] my-8 rounded-xl ring-1 ring-border shadow-sm bg-canvas"
87
+ : "bg-canvas"
88
+ }`}
89
+ >
90
+ {props.selectionPreview ? (
91
+ <div className="flex justify-center pt-4 px-4">
92
+ <TwSelectionToolbar
93
+ selectionPreview={props.selectionPreview}
94
+ readOnly={snapshot.readOnly}
95
+ onAddComment={props.onAddComment}
96
+ />
97
+ </div>
98
+ ) : null}
99
+ {props.document}
100
+ </div>
101
+ </div>
102
+
103
+ <TwStatusBar
104
+ isDirty={snapshot.isDirty}
105
+ isExportBlocked={snapshot.compatibility.blockExport}
106
+ preserveOnlyCount={preserveOnlyCount}
107
+ commentCount={snapshot.comments.totalCount}
108
+ changeCount={snapshot.trackedChanges.totalCount}
109
+ sessionId={snapshot.sessionId}
110
+ />
111
+ </div>
112
+
113
+ {/* Review rail — hidden in editing mode unless toggled */}
114
+ {showReviewRail ? <TwReviewRail
115
+ activeTab={props.activeRailTab}
116
+ currentUserId={props.currentUserId}
117
+ comments={snapshot.comments}
118
+ trackedChanges={snapshot.trackedChanges}
119
+ compatibility={snapshot.compatibility}
120
+ warnings={snapshot.warnings}
121
+ markupDisplay={markupDisplay}
122
+ activeCommentId={props.activeCommentId}
123
+ activeRevisionId={props.activeRevisionId}
124
+ onActiveTabChange={props.onActiveRailTabChange}
125
+ onOpenComment={props.onOpenComment}
126
+ onResolveComment={props.onResolveComment}
127
+ onReopenComment={props.onReopenComment}
128
+ onAddReply={props.onAddReply}
129
+ onEditBody={props.onEditBody}
130
+ onOpenRevision={props.onOpenRevision}
131
+ onAcceptRevision={props.onAcceptRevision}
132
+ onRejectRevision={props.onRejectRevision}
133
+ onAcceptAllChanges={props.onAcceptAllChanges}
134
+ onRejectAllChanges={props.onRejectAllChanges}
135
+ /> : null}
136
+ </div>
137
+ </div>
138
+ </Tooltip.Provider>
139
+ );
140
+ }
@@ -0,0 +1,3 @@
1
+ # Validation
2
+
3
+ Compatibility checks, round-trip validation, support classification, and Word reopen evidence helpers belong here.
@@ -0,0 +1,317 @@
1
+ import { createRangeAnchor } from "../core/selection/mapping.ts";
2
+ import type {
3
+ CanonicalDocumentEnvelope,
4
+ CompatibilityFeatureEntry,
5
+ CompatibilityReport,
6
+ EditorError,
7
+ EditorWarning,
8
+ } from "../core/state/editor-state.ts";
9
+ import type {
10
+ DocumentRootNode,
11
+ InlineNode,
12
+ ParagraphNode,
13
+ } from "../model/canonical-document.ts";
14
+ import {
15
+ describeOpaqueFragment,
16
+ listOpaqueFragments,
17
+ listPreservedPackageParts,
18
+ } from "../preservation/store.ts";
19
+
20
+ export interface BuildCompatibilityReportInput {
21
+ document: CanonicalDocumentEnvelope;
22
+ warnings?: readonly EditorWarning[];
23
+ fatalError?: EditorError;
24
+ generatedAt: string;
25
+ }
26
+
27
+ export function buildCompatibilityReport(
28
+ input: BuildCompatibilityReportInput,
29
+ ): CompatibilityReport {
30
+ const content = normalizeDocumentRoot(input.document.content);
31
+ const contentFeatures = collectContentFeatures(content);
32
+ if (hasSupportedRuntimeComments(input.document.review.comments)) {
33
+ contentFeatures.push(
34
+ supportedEntry(
35
+ "comments-single-paragraph",
36
+ "Single-paragraph review comments stay mappable through runtime selections.",
37
+ ),
38
+ );
39
+ }
40
+ const featureEntries: CompatibilityFeatureEntry[] = [
41
+ ...contentFeatures,
42
+ ...collectPreservationFeatures(input.document),
43
+ ];
44
+ const warnings = dedupeWarnings([
45
+ ...(input.warnings ?? []),
46
+ ...collectDiagnosticWarnings(input.document),
47
+ ]);
48
+ const errors = dedupeErrors([
49
+ ...collectDiagnosticErrors(input.document),
50
+ ...(input.fatalError ? [input.fatalError] : []),
51
+ ]);
52
+
53
+ return {
54
+ reportVersion: "compatibility-report/1",
55
+ generatedAt: input.generatedAt,
56
+ blockExport:
57
+ featureEntries.some((entry) => entry.featureClass === "unsupported-fatal") ||
58
+ errors.some((error) => error.isFatal),
59
+ featureEntries,
60
+ warnings,
61
+ errors,
62
+ };
63
+ }
64
+
65
+ function hasSupportedRuntimeComments(
66
+ comments: CanonicalDocumentEnvelope["review"]["comments"],
67
+ ): boolean {
68
+ return Object.values(comments).some((comment) => comment.anchor.kind !== "detached");
69
+ }
70
+
71
+ function normalizeDocumentRoot(content: unknown): DocumentRootNode {
72
+ if (content && typeof content === "object" && (content as { type?: string }).type === "doc") {
73
+ return content as DocumentRootNode;
74
+ }
75
+
76
+ if (Array.isArray(content)) {
77
+ return {
78
+ type: "doc",
79
+ children: content.filter(
80
+ (value): value is DocumentRootNode["children"][number] =>
81
+ Boolean(value) &&
82
+ typeof value === "object" &&
83
+ ((value as { type?: string }).type === "paragraph" ||
84
+ (value as { type?: string }).type === "opaque_block"),
85
+ ),
86
+ };
87
+ }
88
+
89
+ return {
90
+ type: "doc",
91
+ children: [{ type: "paragraph", children: [] }],
92
+ };
93
+ }
94
+
95
+ function collectContentFeatures(
96
+ content: DocumentRootNode,
97
+ ): CompatibilityFeatureEntry[] {
98
+ const flags = {
99
+ paragraphs: false,
100
+ runs: false,
101
+ whitespace: false,
102
+ headings: false,
103
+ lists: false,
104
+ hyperlinks: false,
105
+ images: false,
106
+ };
107
+
108
+ for (let index = 0; index < content.children.length; index += 1) {
109
+ const block = content.children[index];
110
+ if (block.type !== "paragraph") {
111
+ continue;
112
+ }
113
+
114
+ flags.paragraphs = true;
115
+ if (block.styleId?.toLowerCase().startsWith("heading")) {
116
+ flags.headings = true;
117
+ }
118
+ if (block.numbering) {
119
+ flags.lists = true;
120
+ }
121
+
122
+ measureParagraph(block, flags);
123
+ }
124
+
125
+ const entries: CompatibilityFeatureEntry[] = [];
126
+ if (flags.paragraphs) {
127
+ entries.push(supportedEntry("paragraphs", "Paragraph structure is editable and round-trippable."));
128
+ }
129
+ if (flags.runs) {
130
+ entries.push(supportedEntry("runs", "Runs and inline text are editable through runtime commands."));
131
+ }
132
+ if (flags.whitespace) {
133
+ entries.push(supportedEntry("whitespace", "Whitespace-sensitive text units stay explicit in the runtime model."));
134
+ }
135
+ if (flags.headings) {
136
+ entries.push(supportedEntry("headings", "Heading styles remain attached to paragraph structure."));
137
+ }
138
+ if (flags.lists) {
139
+ entries.push(supportedEntry("lists", "Numbering metadata stays attached to paragraph blocks."));
140
+ }
141
+ if (flags.hyperlinks) {
142
+ entries.push(supportedEntry("hyperlinks", "Hyperlink relationships are preserved and re-serialized."));
143
+ }
144
+ if (flags.images) {
145
+ entries.push(supportedEntry("inline-images", "Inline image placements stay attached to preserved media parts."));
146
+ }
147
+ return entries;
148
+ }
149
+
150
+ function measureParagraph(
151
+ paragraph: ParagraphNode,
152
+ flags: {
153
+ runs: boolean;
154
+ whitespace: boolean;
155
+ hyperlinks: boolean;
156
+ images: boolean;
157
+ },
158
+ ): number {
159
+ let size = 0;
160
+ const children = Array.isArray(paragraph.children) ? paragraph.children : [];
161
+
162
+ for (const child of children) {
163
+ size += measureInlineNode(child, flags);
164
+ }
165
+
166
+ return size;
167
+ }
168
+
169
+ function measureInlineNode(
170
+ node: InlineNode,
171
+ flags: {
172
+ runs: boolean;
173
+ whitespace: boolean;
174
+ hyperlinks: boolean;
175
+ images: boolean;
176
+ },
177
+ ): number {
178
+ switch (node.type) {
179
+ case "text":
180
+ flags.runs = flags.runs || node.text.length > 0;
181
+ flags.whitespace =
182
+ flags.whitespace ||
183
+ /^\s/u.test(node.text) ||
184
+ /\s$/u.test(node.text) ||
185
+ node.text.includes(" ");
186
+ return Array.from(node.text).length;
187
+ case "tab":
188
+ case "hard_break":
189
+ flags.runs = true;
190
+ flags.whitespace = true;
191
+ return 1;
192
+ case "hyperlink":
193
+ flags.runs = true;
194
+ flags.hyperlinks = true;
195
+ return node.children.reduce((size, child) => size + measureInlineNode(child, flags), 0);
196
+ case "image":
197
+ flags.images = true;
198
+ flags.runs = true;
199
+ return 1;
200
+ case "opaque_inline":
201
+ flags.runs = true;
202
+ return 1;
203
+ }
204
+ }
205
+
206
+ function collectPreservationFeatures(
207
+ document: CanonicalDocumentEnvelope,
208
+ ): CompatibilityFeatureEntry[] {
209
+ const entries: CompatibilityFeatureEntry[] = [];
210
+
211
+ for (const fragment of listOpaqueFragments(document.preservation as never)) {
212
+ const descriptor = describeOpaqueFragment(fragment);
213
+ entries.push({
214
+ featureEntryId: `feature:${fragment.fragmentId}`,
215
+ featureKey: descriptor.featureKey,
216
+ featureClass: "preserve-only",
217
+ message: descriptor.label,
218
+ affectedAnchor: createRangeAnchor(
219
+ fragment.lastKnownRange.from,
220
+ fragment.lastKnownRange.to,
221
+ ),
222
+ details: {
223
+ fragmentId: fragment.fragmentId,
224
+ warningId: fragment.warningId,
225
+ detail: descriptor.detail,
226
+ },
227
+ });
228
+ }
229
+
230
+ for (const packagePart of listPreservedPackageParts(document.preservation as never)) {
231
+ entries.push({
232
+ featureEntryId: `feature:package:${packagePart.packagePartName}`,
233
+ featureKey: "unknown-package-parts",
234
+ featureClass: "preserve-only",
235
+ message: `Preserved package part ${packagePart.packagePartName}.`,
236
+ details: {
237
+ packagePartName: packagePart.packagePartName,
238
+ contentType: packagePart.contentType,
239
+ relationshipIds: packagePart.relationshipIds,
240
+ },
241
+ });
242
+ }
243
+
244
+ return entries;
245
+ }
246
+
247
+ function collectDiagnosticWarnings(
248
+ document: CanonicalDocumentEnvelope,
249
+ ): EditorWarning[] {
250
+ const diagnostics = Array.isArray(document.diagnostics?.warnings)
251
+ ? document.diagnostics.warnings
252
+ : [];
253
+
254
+ return diagnostics.map((warning) => ({
255
+ warningId: warning.warningId,
256
+ code:
257
+ warning.source === "validation" || warning.source === "export"
258
+ ? "export_roundtrip_risk"
259
+ : warning.source === "preservation"
260
+ ? "unsupported_ooxml_preserved"
261
+ : "import_normalized",
262
+ severity: "warning",
263
+ message: warning.message,
264
+ source: warning.source,
265
+ }));
266
+ }
267
+
268
+ function collectDiagnosticErrors(
269
+ document: CanonicalDocumentEnvelope,
270
+ ): EditorError[] {
271
+ const diagnostics = Array.isArray(document.diagnostics?.errors)
272
+ ? document.diagnostics.errors
273
+ : [];
274
+
275
+ return diagnostics.map((error) => ({
276
+ errorId: error.diagnosticId,
277
+ code:
278
+ error.code === "load_failed"
279
+ ? "import_failed"
280
+ : error.code,
281
+ message: error.message,
282
+ isFatal: error.isFatal,
283
+ source: error.source,
284
+ }));
285
+ }
286
+
287
+ function supportedEntry(
288
+ featureKey: CompatibilityFeatureEntry["featureKey"],
289
+ message: string,
290
+ ): CompatibilityFeatureEntry {
291
+ return {
292
+ featureEntryId: `feature:${featureKey}`,
293
+ featureKey,
294
+ featureClass: "supported-roundtrip",
295
+ message,
296
+ };
297
+ }
298
+
299
+ function dedupeWarnings(warnings: EditorWarning[]): EditorWarning[] {
300
+ const byId = new Map<string, EditorWarning>();
301
+
302
+ for (const warning of warnings) {
303
+ byId.set(warning.warningId, warning);
304
+ }
305
+
306
+ return [...byId.values()];
307
+ }
308
+
309
+ function dedupeErrors(errors: EditorError[]): EditorError[] {
310
+ const byId = new Map<string, EditorError>();
311
+
312
+ for (const error of errors) {
313
+ byId.set(error.errorId, error);
314
+ }
315
+
316
+ return [...byId.values()];
317
+ }