@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,118 @@
1
+ import React from "react";
2
+
3
+ import type { SelectionSnapshot, SurfaceInlineSegment } from "../../api/public-types";
4
+ import type { CommentDecorationModel } from "../../ui/headless/comment-decoration-model";
5
+ import { getCommentHighlightClass, type MarkupDisplay } from "../../ui/headless/comment-decoration-model";
6
+ import { createSelectionSnapshot, selectionTouchesRange } from "../../ui/headless/selection-helpers";
7
+ import { renderTwCaret } from "./tw-caret";
8
+
9
+ export interface TwInlineTokenProps {
10
+ segment: SurfaceInlineSegment;
11
+ selection: SelectionSnapshot;
12
+ markupDisplay: MarkupDisplay;
13
+ commentDecorations?: CommentDecorationModel;
14
+ onSelectionChange?: (selection: SelectionSnapshot) => void;
15
+ }
16
+
17
+ const focusRingClass =
18
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
19
+
20
+ export function TwInlineToken(props: TwInlineTokenProps) {
21
+ const { segment, selection } = props;
22
+ const selected = selectionTouchesRange(selection, segment.from, segment.to);
23
+ const commentClass = getCommentHighlightClass(
24
+ props.commentDecorations,
25
+ segment.from,
26
+ segment.to,
27
+ props.markupDisplay,
28
+ );
29
+
30
+ const showSymbols = props.markupDisplay !== "clean";
31
+
32
+ if (segment.kind === "tab") {
33
+ return (
34
+ <button
35
+ type="button"
36
+ tabIndex={-1}
37
+ onMouseDown={(e) => {
38
+ e.preventDefault();
39
+ props.onSelectionChange?.(createSelectionSnapshot(segment.from, segment.to));
40
+ }}
41
+ className={`inline-flex items-center border-none bg-transparent cursor-text ${commentClass} ${selected ? "bg-surface-hover" : ""} ${focusRingClass} ${showSymbols ? "mx-0.5 text-tertiary/50" : "w-8"}`}
42
+ title="Tab character"
43
+ >
44
+ {renderTwCaret(selection, segment.from)}
45
+ {showSymbols ? <span className="text-xs">→</span> : <span className="w-8" />}
46
+ {renderTwCaret(selection, segment.to)}
47
+ </button>
48
+ );
49
+ }
50
+
51
+ if (segment.kind === "hard_break") {
52
+ return (
53
+ <>
54
+ <button
55
+ type="button"
56
+ tabIndex={-1}
57
+ onMouseDown={(e) => {
58
+ e.preventDefault();
59
+ props.onSelectionChange?.(createSelectionSnapshot(segment.from, segment.to));
60
+ }}
61
+ className={`inline-flex items-center border-none bg-transparent cursor-text ${commentClass} ${selected ? "bg-surface-hover" : ""} ${focusRingClass} ${showSymbols ? "mx-0.5 text-tertiary/40" : ""}`}
62
+ title="Line break"
63
+ >
64
+ {renderTwCaret(selection, segment.from)}
65
+ {showSymbols ? <span className="text-xs">↵</span> : null}
66
+ {renderTwCaret(selection, segment.to)}
67
+ </button>
68
+ <br />
69
+ </>
70
+ );
71
+ }
72
+
73
+ if (segment.kind === "image") {
74
+ const isMissing = segment.state === "missing";
75
+ return (
76
+ <button
77
+ type="button"
78
+ tabIndex={-1}
79
+ onMouseDown={(e) => {
80
+ e.preventDefault();
81
+ props.onSelectionChange?.(createSelectionSnapshot(segment.from, segment.to));
82
+ }}
83
+ className={`inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded text-xs border-none cursor-pointer ${commentClass} ${
84
+ isMissing ? "text-danger bg-delete-soft" : "text-secondary bg-surface"
85
+ } ${selected ? "ring-1 ring-accent/30" : ""} ${focusRingClass}`}
86
+ title={segment.detail ?? segment.altText ?? "Inline image"}
87
+ >
88
+ {renderTwCaret(selection, segment.from)}
89
+ <span>📷</span>
90
+ {segment.altText ?? (isMissing ? "Missing image" : "Image")}
91
+ {renderTwCaret(selection, segment.to)}
92
+ </button>
93
+ );
94
+ }
95
+
96
+ // opaque_inline
97
+ if (segment.kind === "opaque_inline") {
98
+ return (
99
+ <button
100
+ type="button"
101
+ tabIndex={-1}
102
+ onMouseDown={(e) => {
103
+ e.preventDefault();
104
+ props.onSelectionChange?.(createSelectionSnapshot(segment.from, segment.to));
105
+ }}
106
+ className={`inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded text-xs text-comment bg-warning-soft border-none cursor-pointer ${commentClass} ${selected ? "ring-1 ring-accent/30" : ""} ${focusRingClass}`}
107
+ title={segment.detail}
108
+ >
109
+ {renderTwCaret(selection, segment.from)}
110
+ <span>🔒</span>
111
+ {segment.label}
112
+ {renderTwCaret(selection, segment.to)}
113
+ </button>
114
+ );
115
+ }
116
+
117
+ return null;
118
+ }
@@ -0,0 +1,52 @@
1
+ import React from "react";
2
+
3
+ import type { SelectionSnapshot, SurfaceBlockSnapshot } from "../../api/public-types";
4
+ import { selectionTouchesRange, createSelectionSnapshot } from "../../ui/headless/selection-helpers";
5
+
6
+ export interface TwOpaqueBlockProps {
7
+ block: Extract<SurfaceBlockSnapshot, { kind: "opaque_block" }>;
8
+ selection: SelectionSnapshot;
9
+ onSelectionChange?: (selection: SelectionSnapshot) => void;
10
+ }
11
+
12
+ const focusRingClass =
13
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
14
+
15
+ export function TwOpaqueBlock(props: TwOpaqueBlockProps) {
16
+ const { block, selection } = props;
17
+ const selected = selectionTouchesRange(selection, block.from, block.to);
18
+
19
+ return (
20
+ <div className="group relative">
21
+ <div className="absolute -left-10 top-1 flex flex-col items-end gap-0 select-none w-8 text-right">
22
+ <div className="flex items-center gap-0.5">
23
+ <span className="text-[10px] text-tertiary/60 font-medium">Lock</span>
24
+ <span className="inline-block h-1 w-1 rounded-full" style={{ backgroundColor: "var(--color-comment)" }} />
25
+ </div>
26
+ </div>
27
+ <button
28
+ type="button"
29
+ tabIndex={-1}
30
+ onMouseDown={(e) => {
31
+ e.preventDefault();
32
+ props.onSelectionChange?.(createSelectionSnapshot(block.from, block.to));
33
+ }}
34
+ className={[
35
+ "w-full text-left border-l-2 border-dashed border-warning/30 pl-4 py-2 rounded-r bg-warning-soft/20",
36
+ "cursor-pointer transition-colors",
37
+ selected ? "ring-1 ring-accent/30 bg-warning-soft/40" : "hover:bg-warning-soft/30",
38
+ focusRingClass,
39
+ ].join(" ")}
40
+ >
41
+ <div className="flex items-center gap-1.5 text-xs text-tertiary mb-1">
42
+ <span>🔒</span>
43
+ <span>{block.label}</span>
44
+ <span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold text-comment bg-warning-soft">
45
+ preserve-only
46
+ </span>
47
+ </div>
48
+ <p className="text-sm text-secondary">{block.detail}</p>
49
+ </button>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,151 @@
1
+ import React from "react";
2
+
3
+ import type { SelectionSnapshot, SurfaceBlockSnapshot, SurfaceInlineSegment } from "../../api/public-types";
4
+ import type { CommentDecorationModel } from "../../ui/headless/comment-decoration-model";
5
+ import { getCommentRangeState, type MarkupDisplay } from "../../ui/headless/comment-decoration-model";
6
+ import type { RevisionDecorationModel } from "../../ui/headless/revision-decoration-model";
7
+ import { getRevisionRangeState } from "../../ui/headless/revision-decoration-model";
8
+ import { createSelectionSnapshot } from "../../ui/headless/selection-helpers";
9
+ import { renderTwCaret } from "./tw-caret";
10
+ import { TwSegmentView } from "./tw-segment-view";
11
+
12
+ export interface TwParagraphBlockProps {
13
+ block: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
14
+ selection: SelectionSnapshot;
15
+ markupDisplay: MarkupDisplay;
16
+ commentDecorations?: CommentDecorationModel;
17
+ revisionDecorations?: RevisionDecorationModel;
18
+ onSelectionChange?: (selection: SelectionSnapshot) => void;
19
+ onCommentActivated?: (commentId: string) => void;
20
+ onRevisionActivated?: (revisionId: string) => void;
21
+ }
22
+
23
+ export function TwParagraphBlock(props: TwParagraphBlockProps) {
24
+ const { block, selection, markupDisplay } = props;
25
+ const headingLevel = inferHeadingLevel(block.styleId);
26
+ const hasComment = getCommentRangeState(props.commentDecorations, block.from, block.to).hasComments;
27
+ const hasChange = getRevisionRangeState(props.revisionDecorations, block.from, block.to).hasChanges;
28
+
29
+ // Margin indicator per mode
30
+ let marginClass = "";
31
+ if (markupDisplay === "simple" && (hasComment || hasChange)) {
32
+ if (hasComment && hasChange) marginClass = "border-l-2 border-l-comment/50 pl-3 ";
33
+ else if (hasComment) marginClass = "border-l-2 border-l-comment/40 pl-3 ";
34
+ else marginClass = "border-l-2 border-l-insert/40 pl-3 ";
35
+ }
36
+
37
+ // Dot markers for gutter
38
+ const dots: string[] = [];
39
+ if (markupDisplay === "clean" && hasComment) {
40
+ dots.push("var(--color-comment)");
41
+ } else if (markupDisplay !== "simple") {
42
+ if (hasComment) dots.push("var(--color-comment)");
43
+ if (hasChange) dots.push("var(--color-insert)");
44
+ }
45
+
46
+ const segmentElements = (
47
+ <>
48
+ <CaretButton
49
+ position={block.from}
50
+ selection={selection}
51
+ onSelectionChange={props.onSelectionChange}
52
+ />
53
+ {block.segments.length > 0 ? (
54
+ block.segments.map((segment) => (
55
+ <TwSegmentView
56
+ key={segment.segmentId}
57
+ segment={segment}
58
+ selection={selection}
59
+ markupDisplay={markupDisplay}
60
+ commentDecorations={props.commentDecorations}
61
+ revisionDecorations={props.revisionDecorations}
62
+ onSelectionChange={props.onSelectionChange}
63
+ onCommentActivated={props.onCommentActivated}
64
+ onRevisionActivated={props.onRevisionActivated}
65
+ />
66
+ ))
67
+ ) : (
68
+ <span className="text-secondary italic">Empty paragraph</span>
69
+ )}
70
+ <CaretButton
71
+ position={block.to}
72
+ selection={selection}
73
+ onSelectionChange={props.onSelectionChange}
74
+ />
75
+ </>
76
+ );
77
+
78
+ return (
79
+ <div className="group relative">
80
+ <BlockHint
81
+ label={headingLevel ? `H${headingLevel}` : "P"}
82
+ dots={dots.length > 0 ? dots : undefined}
83
+ />
84
+ {headingLevel ? (
85
+ <div className={`${marginClass}transition-colors`}>
86
+ <HeadingElement level={headingLevel}>{segmentElements}</HeadingElement>
87
+ </div>
88
+ ) : (
89
+ <p className={`text-base leading-[1.75] text-primary ${marginClass}transition-colors`}>
90
+ {segmentElements}
91
+ </p>
92
+ )}
93
+ </div>
94
+ );
95
+ }
96
+
97
+ function CaretButton(props: {
98
+ position: number;
99
+ selection: SelectionSnapshot;
100
+ onSelectionChange?: (selection: SelectionSnapshot) => void;
101
+ }) {
102
+ const isActive = props.selection.isCollapsed && props.selection.anchor === props.position;
103
+ return (
104
+ <button
105
+ type="button"
106
+ tabIndex={-1}
107
+ onMouseDown={(e) => {
108
+ e.preventDefault();
109
+ props.onSelectionChange?.(createSelectionSnapshot(props.position));
110
+ }}
111
+ aria-label={`Set caret at ${props.position}`}
112
+ className={`p-0 m-0 border-none bg-transparent cursor-text inline-flex items-center ${isActive ? "min-w-[2px] h-[22px]" : "w-0 h-0 overflow-hidden"}`}
113
+ >
114
+ {renderTwCaret(props.selection, props.position)}
115
+ </button>
116
+ );
117
+ }
118
+
119
+ function BlockHint(props: { label: string; dots?: string[] }) {
120
+ return (
121
+ <div className="absolute -left-10 top-1 flex flex-col items-end gap-0 select-none w-8 text-right">
122
+ <div className="flex items-center gap-0.5">
123
+ <span className="text-[10px] text-tertiary/60 font-medium">{props.label}</span>
124
+ {props.dots?.map((color, i) => (
125
+ <span
126
+ key={i}
127
+ className="inline-block h-1 w-1 rounded-full"
128
+ style={{ backgroundColor: color }}
129
+ />
130
+ ))}
131
+ </div>
132
+ </div>
133
+ );
134
+ }
135
+
136
+ function HeadingElement(props: { level: 1 | 2 | 3; children: React.ReactNode }) {
137
+ switch (props.level) {
138
+ case 1: return <h2 className="text-2xl font-medium text-primary leading-tight">{props.children}</h2>;
139
+ case 2: return <h3 className="text-xl font-medium text-primary leading-snug">{props.children}</h3>;
140
+ case 3: return <h4 className="text-lg font-medium text-primary leading-snug">{props.children}</h4>;
141
+ }
142
+ }
143
+
144
+ function inferHeadingLevel(styleId?: string): 1 | 2 | 3 | null {
145
+ if (!styleId) return null;
146
+ const lower = styleId.toLowerCase();
147
+ if (lower === "heading1") return 1;
148
+ if (lower === "heading2") return 2;
149
+ if (lower === "heading3") return 3;
150
+ return null;
151
+ }
@@ -0,0 +1,215 @@
1
+ import React, { type FocusEventHandler, useEffect, useMemo, useRef } from "react";
2
+ import { EditorView } from "prosemirror-view";
3
+
4
+ import type {
5
+ EditorUser,
6
+ RuntimeRenderSnapshot,
7
+ SelectionSnapshot,
8
+ } from "../../api/public-types";
9
+ import {
10
+ createCommentDecorationModel,
11
+ type MarkupDisplay,
12
+ } from "../../ui/headless/comment-decoration-model";
13
+ import { createRevisionDecorationModel } from "../../ui/headless/revision-decoration-model";
14
+ import { createPMStateFromSnapshot } from "./pm-state-from-snapshot";
15
+ import {
16
+ createCommandBridgePlugins,
17
+ type CommandBridgeCallbacks,
18
+ } from "./pm-command-bridge";
19
+ import { buildDecorations } from "./pm-decorations";
20
+ import type { PositionMap } from "./pm-position-map";
21
+ import { tableNodeViews } from "./tw-table-node-view";
22
+
23
+ /**
24
+ * Same props interface as the legacy TwEditorSurface — drop-in replacement.
25
+ */
26
+ export interface TwProseMirrorSurfaceProps {
27
+ currentUser: EditorUser;
28
+ snapshot: RuntimeRenderSnapshot;
29
+ reviewMode: "editing" | "review";
30
+ markupDisplay: MarkupDisplay;
31
+ activeRevisionId?: string;
32
+ showTrackedChanges?: boolean;
33
+ onFocus: FocusEventHandler<HTMLDivElement>;
34
+ onBlur: FocusEventHandler<HTMLDivElement>;
35
+ onSelectionChange?: (selection: SelectionSnapshot) => void;
36
+ onInsertText?: (text: string) => void;
37
+ onDeleteBackward?: () => void;
38
+ onDeleteForward?: () => void;
39
+ onInsertTab?: () => void;
40
+ onInsertHardBreak?: () => void;
41
+ onSplitParagraph?: () => void;
42
+ onCommentActivated?: (commentId: string) => void;
43
+ onRevisionActivated?: (revisionId: string) => void;
44
+ }
45
+
46
+ export function TwProseMirrorSurface(props: TwProseMirrorSurfaceProps) {
47
+ const {
48
+ currentUser,
49
+ snapshot,
50
+ markupDisplay,
51
+ onFocus,
52
+ onBlur,
53
+ } = props;
54
+ const surface = snapshot.surface;
55
+
56
+ const canEdit = Boolean(
57
+ surface && snapshot.isReady && !snapshot.readOnly && !snapshot.fatalError,
58
+ );
59
+
60
+ const mountRef = useRef<HTMLDivElement>(null);
61
+ const viewRef = useRef<EditorView | null>(null);
62
+ const positionMapRef = useRef<PositionMap | null>(null);
63
+ const callbacksRef = useRef<CommandBridgeCallbacks | null>(null);
64
+
65
+ // Keep callbacks ref up to date (avoids stale closures in PM plugins)
66
+ callbacksRef.current = {
67
+ onInsertText: (text) => props.onInsertText?.(text),
68
+ onDeleteBackward: () => props.onDeleteBackward?.(),
69
+ onDeleteForward: () => props.onDeleteForward?.(),
70
+ onSplitParagraph: () => props.onSplitParagraph?.(),
71
+ onInsertHardBreak: () => props.onInsertHardBreak?.(),
72
+ onInsertTab: () => props.onInsertTab?.(),
73
+ onUndo: () => {}, // Handled by toolbar, not PM
74
+ onRedo: () => {}, // Handled by toolbar, not PM
75
+ onSelectionChange: (sel) => props.onSelectionChange?.(sel),
76
+ getPositionMap: () => positionMapRef.current,
77
+ };
78
+
79
+ // Comment/revision decoration models
80
+ const commentModel = useMemo(
81
+ () => createCommentDecorationModel(snapshot.comments),
82
+ [snapshot.comments],
83
+ );
84
+ const showTrackedChanges = props.showTrackedChanges !== false;
85
+ // Always create the revision model — needed for deletion hiding in clean mode
86
+ // even when the tracked changes display toggle is off.
87
+ const revisionModel = useMemo(
88
+ () => createRevisionDecorationModel(snapshot.trackedChanges, props.activeRevisionId),
89
+ [snapshot.trackedChanges, props.activeRevisionId],
90
+ );
91
+
92
+ // Create PM plugins (stable across renders — callbacks accessed via ref)
93
+ const plugins = useMemo(() => {
94
+ return createCommandBridgePlugins({
95
+ onInsertText: (text) => callbacksRef.current?.onInsertText(text),
96
+ onDeleteBackward: () => callbacksRef.current?.onDeleteBackward(),
97
+ onDeleteForward: () => callbacksRef.current?.onDeleteForward(),
98
+ onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
99
+ onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
100
+ onInsertTab: () => callbacksRef.current?.onInsertTab(),
101
+ onUndo: () => callbacksRef.current?.onUndo(),
102
+ onRedo: () => callbacksRef.current?.onRedo(),
103
+ onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
104
+ getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
105
+ });
106
+ }, []);
107
+
108
+ // Create or update PM view whenever surface becomes available or changes.
109
+ // The view is created lazily — if surface is null on first render (loading),
110
+ // it will be created when the runtime provides a real snapshot.
111
+ useEffect(() => {
112
+ if (!mountRef.current || !surface) return;
113
+
114
+ const { state, positionMap } = createPMStateFromSnapshot(
115
+ surface,
116
+ snapshot.selection,
117
+ plugins,
118
+ );
119
+ positionMapRef.current = positionMap;
120
+
121
+ const decorations = buildDecorations(
122
+ state.doc,
123
+ positionMap,
124
+ commentModel,
125
+ revisionModel,
126
+ markupDisplay,
127
+ showTrackedChanges,
128
+ );
129
+
130
+ if (!viewRef.current) {
131
+ // First time surface is available — create the EditorView
132
+ const view = new EditorView(mountRef.current, {
133
+ state,
134
+ nodeViews: tableNodeViews,
135
+ editable: () => canEdit,
136
+ decorations: () => decorations,
137
+ dispatchTransaction(tr) {
138
+ const newState = view.state.apply(tr);
139
+ view.updateState(newState);
140
+ },
141
+ });
142
+ viewRef.current = view;
143
+ } else {
144
+ // View exists — update state and decorations
145
+ viewRef.current.setProps({
146
+ editable: () => canEdit,
147
+ decorations: () => decorations,
148
+ });
149
+ viewRef.current.updateState(state);
150
+ }
151
+ }, [snapshot.revisionToken, surface, commentModel, revisionModel, markupDisplay, canEdit]);
152
+
153
+ // Cleanup on unmount
154
+ useEffect(() => {
155
+ return () => {
156
+ viewRef.current?.destroy();
157
+ viewRef.current = null;
158
+ };
159
+ }, []);
160
+
161
+ const fontClass =
162
+ markupDisplay === "clean"
163
+ ? "font-[family-name:var(--font-legal-sans)]"
164
+ : "font-[family-name:var(--font-legal-serif)]";
165
+
166
+ return (
167
+ <section aria-label="Document canvas" className="min-w-0">
168
+ {/* ProseMirror mount point — document content including headings is editable */}
169
+ {surface ? (
170
+ <div
171
+ ref={mountRef}
172
+ role="textbox"
173
+ aria-multiline="true"
174
+ className={`px-12 py-10 ${fontClass} text-[15px] text-primary leading-relaxed prosemirror-surface outline-none`}
175
+ onFocus={onFocus as unknown as React.FocusEventHandler<HTMLDivElement>}
176
+ onBlur={onBlur as unknown as React.FocusEventHandler<HTMLDivElement>}
177
+ onClick={(e) => {
178
+ // Activate comment or revision when clicking on decorated text
179
+ const target = e.target as HTMLElement;
180
+ const commentEl = target.closest?.("[data-comment-id]");
181
+ if (commentEl) {
182
+ const commentId = commentEl.getAttribute("data-comment-id");
183
+ if (commentId) {
184
+ props.onCommentActivated?.(commentId);
185
+ return;
186
+ }
187
+ }
188
+ const revisionEl = target.closest?.("[data-revision-id]");
189
+ if (revisionEl) {
190
+ const revisionId = revisionEl.getAttribute("data-revision-id");
191
+ if (revisionId) {
192
+ props.onRevisionActivated?.(revisionId);
193
+ }
194
+ }
195
+ }}
196
+ aria-label="Document surface"
197
+ />
198
+ ) : (
199
+ <div className="px-12 pb-10">
200
+ <p className="text-sm text-secondary leading-relaxed">
201
+ Loading the review surface...
202
+ </p>
203
+ </div>
204
+ )}
205
+
206
+ {snapshot.fatalError ? (
207
+ <div className="px-12 pb-10">
208
+ <p className="text-sm text-danger">
209
+ Fatal runtime error: {snapshot.fatalError.message}
210
+ </p>
211
+ </div>
212
+ ) : null}
213
+ </section>
214
+ );
215
+ }
@@ -0,0 +1,111 @@
1
+ import React from "react";
2
+
3
+ import type { SelectionSnapshot, SurfaceInlineSegment } from "../../api/public-types";
4
+ import type { CommentDecorationModel } from "../../ui/headless/comment-decoration-model";
5
+ import { getCommentHighlightClass, getCommentRangeState, type MarkupDisplay } from "../../ui/headless/comment-decoration-model";
6
+ import type { RevisionDecorationModel } from "../../ui/headless/revision-decoration-model";
7
+ import { getRevisionHighlightClass, shouldHideInCleanMode } from "../../ui/headless/revision-decoration-model";
8
+ import { createSelectionSnapshot, selectionTouchesRange } from "../../ui/headless/selection-helpers";
9
+ import { TwInlineToken } from "./tw-inline-token";
10
+
11
+ export interface TwSegmentViewProps {
12
+ segment: SurfaceInlineSegment;
13
+ selection: SelectionSnapshot;
14
+ markupDisplay: MarkupDisplay;
15
+ commentDecorations?: CommentDecorationModel;
16
+ revisionDecorations?: RevisionDecorationModel;
17
+ onSelectionChange?: (selection: SelectionSnapshot) => void;
18
+ onCommentActivated?: (commentId: string) => void;
19
+ onRevisionActivated?: (revisionId: string) => void;
20
+ }
21
+
22
+ export function TwSegmentView(props: TwSegmentViewProps) {
23
+ const { segment, selection, markupDisplay } = props;
24
+
25
+ // Non-text segments delegate to TwInlineToken
26
+ if (segment.kind === "tab" || segment.kind === "hard_break" || segment.kind === "image" || segment.kind === "opaque_inline") {
27
+ return (
28
+ <TwInlineToken
29
+ segment={segment}
30
+ selection={selection}
31
+ markupDisplay={markupDisplay}
32
+ commentDecorations={props.commentDecorations}
33
+ onSelectionChange={props.onSelectionChange}
34
+ />
35
+ );
36
+ }
37
+
38
+ if (segment.kind !== "text") {
39
+ return null;
40
+ }
41
+
42
+ // Text segment: render character-by-character for click-to-position editing
43
+ const characters = Array.from(segment.text);
44
+
45
+ return (
46
+ <>
47
+ {characters.map((character, index) => {
48
+ const from = segment.from + index;
49
+ const to = from + 1;
50
+ const isSelected = selectionTouchesRange(selection, from, to);
51
+
52
+ // Hide deletions in clean mode
53
+ if (shouldHideInCleanMode(props.revisionDecorations, from, to) && markupDisplay === "clean") {
54
+ return null;
55
+ }
56
+
57
+ const commentClass = getCommentHighlightClass(props.commentDecorations, from, to, markupDisplay);
58
+ const revisionClass = getRevisionHighlightClass(props.revisionDecorations, from, to, markupDisplay);
59
+
60
+ // Check if clicking this char should activate a comment or revision
61
+ const commentState = getCommentRangeState(props.commentDecorations, from, to);
62
+ const overlappingComment = commentState.overlapping[0];
63
+
64
+ // Build mark classes
65
+ let markClasses = "";
66
+ if (segment.marks) {
67
+ if (segment.marks.includes("bold")) markClasses += "font-bold ";
68
+ if (segment.marks.includes("italic")) markClasses += "italic ";
69
+ if (segment.marks.includes("underline")) markClasses += "underline ";
70
+ if (segment.marks.includes("strikethrough")) markClasses += "line-through ";
71
+ }
72
+
73
+ const hyperlinkClass = segment.hyperlinkHref
74
+ ? "text-accent underline decoration-1 underline-offset-2 "
75
+ : "";
76
+
77
+ return (
78
+ <button
79
+ key={`${segment.segmentId}-${from}`}
80
+ type="button"
81
+ tabIndex={-1}
82
+ onMouseDown={(event) => {
83
+ event.preventDefault();
84
+ props.onSelectionChange?.(createSelectionSnapshot(to));
85
+
86
+ // Activate comment/revision on click if decorated
87
+ if (overlappingComment) {
88
+ props.onCommentActivated?.(overlappingComment.commentId);
89
+ }
90
+ }}
91
+ data-comment-id={overlappingComment?.commentId}
92
+ className={[
93
+ "relative inline border-none bg-transparent cursor-text whitespace-pre p-0 m-0",
94
+ "text-[15px] text-primary leading-normal",
95
+ commentClass,
96
+ revisionClass,
97
+ markClasses,
98
+ hyperlinkClass,
99
+ isSelected ? "bg-surface-hover rounded-[3px]" : "",
100
+ ].filter(Boolean).join(" ")}
101
+ >
102
+ {character}
103
+ {selection.isCollapsed && selection.anchor === from ? (
104
+ <span aria-hidden="true" className="absolute left-0 top-[0.1em] w-0.5 h-[1em] bg-accent rounded-full animate-wre-blink pointer-events-none" />
105
+ ) : null}
106
+ </button>
107
+ );
108
+ })}
109
+ </>
110
+ );
111
+ }