@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.
- package/README.md +44 -104
- package/package.json +76 -46
- package/src/README.md +85 -0
- package/src/api/README.md +22 -0
- package/src/api/public-types.ts +525 -0
- package/src/compare/diff-engine.ts +530 -0
- package/src/compare/export-redlines.ts +162 -0
- package/src/compare/snapshot.ts +37 -0
- package/src/component-inventory.md +99 -0
- package/src/core/README.md +10 -0
- package/src/core/commands/README.md +3 -0
- package/src/core/commands/formatting-commands.ts +161 -0
- package/src/core/commands/image-commands.ts +144 -0
- package/src/core/commands/index.ts +1013 -0
- package/src/core/commands/list-commands.ts +370 -0
- package/src/core/commands/review-commands.ts +108 -0
- package/src/core/commands/text-commands.ts +119 -0
- package/src/core/schema/README.md +3 -0
- package/src/core/schema/text-schema.ts +512 -0
- package/src/core/selection/README.md +3 -0
- package/src/core/selection/mapping.ts +238 -0
- package/src/core/selection/review-anchors.ts +94 -0
- package/src/core/state/README.md +3 -0
- package/src/core/state/editor-state.ts +580 -0
- package/src/core/state/text-transaction.ts +276 -0
- package/src/formats/xlsx/io/parse-shared-strings.ts +41 -0
- package/src/formats/xlsx/io/parse-sheet.ts +289 -0
- package/src/formats/xlsx/io/parse-styles.ts +57 -0
- package/src/formats/xlsx/io/parse-workbook.ts +75 -0
- package/src/formats/xlsx/io/xlsx-session.ts +306 -0
- package/src/formats/xlsx/model/cell.ts +189 -0
- package/src/formats/xlsx/model/sheet.ts +244 -0
- package/src/formats/xlsx/model/styles.ts +118 -0
- package/src/formats/xlsx/model/workbook.ts +449 -0
- package/src/index.ts +45 -0
- package/src/io/README.md +10 -0
- package/src/io/docx-session.ts +1763 -0
- package/src/io/export/README.md +3 -0
- package/src/io/export/export-session.ts +165 -0
- package/src/io/export/minimal-docx.ts +115 -0
- package/src/io/export/reattach-preserved-parts.ts +54 -0
- package/src/io/export/serialize-comments.ts +876 -0
- package/src/io/export/serialize-footnotes.ts +217 -0
- package/src/io/export/serialize-headers-footers.ts +200 -0
- package/src/io/export/serialize-main-document.ts +982 -0
- package/src/io/export/serialize-numbering.ts +97 -0
- package/src/io/export/serialize-revisions.ts +389 -0
- package/src/io/export/serialize-runtime-revisions.ts +265 -0
- package/src/io/export/serialize-tables.ts +147 -0
- package/src/io/export/split-review-boundaries.ts +194 -0
- package/src/io/normalize/README.md +3 -0
- package/src/io/normalize/normalize-text.ts +437 -0
- package/src/io/ooxml/README.md +3 -0
- package/src/io/ooxml/parse-comments.ts +779 -0
- package/src/io/ooxml/parse-complex-content.ts +287 -0
- package/src/io/ooxml/parse-fields.ts +438 -0
- package/src/io/ooxml/parse-footnotes.ts +403 -0
- package/src/io/ooxml/parse-headers-footers.ts +483 -0
- package/src/io/ooxml/parse-inline-media.ts +431 -0
- package/src/io/ooxml/parse-main-document.ts +1846 -0
- package/src/io/ooxml/parse-numbering.ts +425 -0
- package/src/io/ooxml/parse-revisions.ts +658 -0
- package/src/io/ooxml/parse-shapes.ts +271 -0
- package/src/io/ooxml/parse-tables.ts +568 -0
- package/src/io/ooxml/parse-theme.ts +314 -0
- package/src/io/ooxml/part-manifest.ts +136 -0
- package/src/io/ooxml/revision-boundaries.ts +351 -0
- package/src/io/opc/README.md +3 -0
- package/src/io/opc/corrupt-package.ts +166 -0
- package/src/io/opc/docx-package.ts +74 -0
- package/src/io/opc/package-reader.ts +320 -0
- package/src/io/opc/package-writer.ts +273 -0
- package/src/legal/bookmarks.ts +196 -0
- package/src/legal/cross-references.ts +356 -0
- package/src/legal/defined-terms.ts +203 -0
- package/src/model/README.md +3 -0
- package/src/model/canonical-document.ts +1911 -0
- package/src/model/cds-1.0.0.ts +196 -0
- package/src/model/snapshot.ts +393 -0
- package/src/preservation/README.md +3 -0
- package/src/preservation/markup-compatibility.ts +48 -0
- package/src/preservation/opaque-fragment-store.ts +89 -0
- package/src/preservation/opaque-region.ts +233 -0
- package/src/preservation/package-preservation.ts +120 -0
- package/src/preservation/preserved-part-manifest.ts +56 -0
- package/src/preservation/relationship-retention.ts +57 -0
- package/src/preservation/store.ts +185 -0
- package/src/review/README.md +16 -0
- package/src/review/store/README.md +3 -0
- package/src/review/store/comment-anchors.ts +70 -0
- package/src/review/store/comment-remapping.ts +154 -0
- package/src/review/store/comment-store.ts +331 -0
- package/src/review/store/comment-thread.ts +109 -0
- package/src/review/store/revision-actions.ts +394 -0
- package/src/review/store/revision-store.ts +303 -0
- package/src/review/store/revision-types.ts +168 -0
- package/src/review/store/runtime-comment-store.ts +43 -0
- package/src/runtime/README.md +3 -0
- package/src/runtime/ai-action-policy.ts +764 -0
- package/src/runtime/document-runtime.ts +967 -0
- package/src/runtime/read-only-diagnostics-runtime.ts +232 -0
- package/src/runtime/review-runtime.ts +44 -0
- package/src/runtime/revision-runtime.ts +107 -0
- package/src/runtime/session-capabilities.ts +138 -0
- package/src/runtime/surface-projection.ts +570 -0
- package/src/runtime/table-commands.ts +87 -0
- package/src/runtime/table-schema.ts +140 -0
- package/src/runtime/virtualized-rendering.ts +258 -0
- package/src/ui/README.md +30 -0
- package/src/ui/WordReviewEditor.tsx +1504 -0
- package/src/ui/comments/README.md +3 -0
- package/src/ui/compatibility/README.md +3 -0
- package/src/ui/editor-surface/README.md +3 -0
- package/src/ui/headless/comment-decoration-model.ts +124 -0
- package/src/ui/headless/revision-decoration-model.ts +128 -0
- package/src/ui/headless/selection-helpers.ts +34 -0
- package/src/ui/headless/use-editor-keyboard.ts +98 -0
- package/src/ui/review/README.md +3 -0
- package/src/ui/shared/revision-filters.ts +31 -0
- package/src/ui/status/README.md +3 -0
- package/src/ui/theme/README.md +3 -0
- package/src/ui/toolbar/README.md +3 -0
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +48 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +44 -0
- package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +58 -0
- package/src/ui-tailwind/chrome/use-before-unload.ts +20 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +139 -0
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +98 -0
- package/src/ui-tailwind/editor-surface/pm-position-map.ts +123 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +452 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +327 -0
- package/src/ui-tailwind/editor-surface/search-plugin.ts +157 -0
- package/src/ui-tailwind/editor-surface/tw-caret.tsx +12 -0
- package/src/ui-tailwind/editor-surface/tw-editor-surface.tsx +150 -0
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +118 -0
- package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +52 -0
- package/src/ui-tailwind/editor-surface/tw-paragraph-block.tsx +151 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +215 -0
- package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +111 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +122 -0
- package/src/ui-tailwind/index.ts +61 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +276 -0
- package/src/ui-tailwind/review/tw-health-panel.tsx +120 -0
- package/src/ui-tailwind/review/tw-review-rail.tsx +120 -0
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +164 -0
- package/src/ui-tailwind/status/tw-status-bar.tsx +58 -0
- package/src/ui-tailwind/theme/editor-theme.css +190 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +48 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +231 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +140 -0
- package/src/validation/README.md +3 -0
- package/src/validation/compatibility-engine.ts +317 -0
- package/src/validation/compatibility-report.ts +160 -0
- package/src/validation/diagnostics.ts +203 -0
- package/src/validation/import-diagnostics.ts +128 -0
- package/src/validation/low-priority-word-surfaces.ts +373 -0
- package/dist/chunk-32W6IVQE.js +0 -7725
- package/dist/chunk-32W6IVQE.js.map +0 -1
- package/dist/index.cjs +0 -23722
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -7
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -16011
- package/dist/index.js.map +0 -1
- package/dist/public-types-DqCURAz8.d.cts +0 -1152
- package/dist/public-types-DqCURAz8.d.ts +0 -1152
- package/dist/tailwind.cjs +0 -8295
- package/dist/tailwind.cjs.map +0 -1
- package/dist/tailwind.d.cts +0 -323
- package/dist/tailwind.d.ts +0 -323
- package/dist/tailwind.js +0 -553
- 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
|
+
}
|