@beyondwork/docx-react-component 1.0.17 → 1.0.19
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 +8 -2
- package/package.json +32 -34
- package/src/api/README.md +5 -1
- package/src/api/public-types.ts +374 -4
- package/src/api/session-state.ts +58 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/image-commands.ts +147 -0
- package/src/core/commands/index.ts +5 -1
- package/src/core/commands/list-commands.ts +231 -36
- package/src/core/commands/paragraph-layout-commands.ts +339 -0
- package/src/core/commands/section-layout-commands.ts +680 -0
- package/src/core/commands/style-commands.ts +262 -0
- package/src/core/search/search-text.ts +329 -0
- package/src/core/selection/mapping.ts +41 -0
- package/src/core/state/editor-state.ts +1 -1
- package/src/index.ts +30 -0
- package/src/io/docx-session.ts +260 -39
- package/src/io/export/serialize-main-document.ts +202 -5
- package/src/io/export/serialize-numbering.ts +28 -7
- package/src/io/normalize/normalize-text.ts +63 -25
- package/src/io/ooxml/numbering-sentinels.ts +44 -0
- package/src/io/ooxml/parse-footnotes.ts +212 -20
- package/src/io/ooxml/parse-headers-footers.ts +229 -25
- package/src/io/ooxml/parse-inline-media.ts +16 -0
- package/src/io/ooxml/parse-main-document.ts +411 -6
- package/src/io/ooxml/parse-numbering.ts +7 -0
- package/src/io/ooxml/parse-settings.ts +184 -0
- package/src/io/ooxml/parse-shapes.ts +25 -0
- package/src/io/ooxml/parse-styles.ts +463 -0
- package/src/io/ooxml/parse-theme.ts +32 -0
- package/src/model/canonical-document.ts +133 -3
- package/src/model/cds-1.0.0.ts +13 -0
- package/src/model/snapshot.ts +2 -1
- package/src/runtime/document-layout.ts +332 -0
- package/src/runtime/document-navigation.ts +564 -0
- package/src/runtime/document-runtime.ts +265 -35
- package/src/runtime/document-search.ts +145 -0
- package/src/runtime/numbering-prefix.ts +47 -26
- package/src/runtime/page-layout-estimation.ts +212 -0
- package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
- package/src/runtime/session-capabilities.ts +2 -0
- package/src/runtime/story-context.ts +164 -0
- package/src/runtime/story-targeting.ts +162 -0
- package/src/runtime/surface-projection.ts +239 -12
- package/src/runtime/table-schema.ts +87 -5
- package/src/runtime/view-state.ts +459 -0
- package/src/ui/WordReviewEditor.tsx +1902 -312
- package/src/ui/browser-export.ts +52 -0
- package/src/ui/headless/preserve-editor-selection.ts +5 -0
- package/src/ui/headless/selection-helpers.ts +20 -0
- package/src/ui/headless/selection-toolbar-model.ts +22 -0
- package/src/ui/headless/use-editor-keyboard.ts +6 -1
- package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
- package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
- package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
- package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
- package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
- package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
- package/src/ui-tailwind/index.ts +2 -1
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
- package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
- package/src/ui-tailwind/theme/editor-theme.css +123 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
- package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
- package/src/validation/compatibility-engine.ts +92 -20
- package/src/validation/diagnostics.ts +1 -0
- package/src/validation/docx-comment-proof.ts +487 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExportDelivery, ExportResult } from "../api/public-types";
|
|
2
|
+
|
|
3
|
+
export function withExportDelivery(
|
|
4
|
+
result: ExportResult,
|
|
5
|
+
delivery: ExportDelivery,
|
|
6
|
+
): ExportResult {
|
|
7
|
+
return {
|
|
8
|
+
...result,
|
|
9
|
+
delivery,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function downloadExportResult(result: ExportResult): ExportResult {
|
|
14
|
+
const delivery = canDownloadInBrowser()
|
|
15
|
+
? triggerBrowserDownload(result)
|
|
16
|
+
: {
|
|
17
|
+
mode: "exported-bytes-only" as const,
|
|
18
|
+
};
|
|
19
|
+
return withExportDelivery(result, delivery);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function canDownloadInBrowser(): boolean {
|
|
23
|
+
return (
|
|
24
|
+
typeof window !== "undefined" &&
|
|
25
|
+
typeof document !== "undefined" &&
|
|
26
|
+
typeof URL !== "undefined" &&
|
|
27
|
+
typeof URL.createObjectURL === "function" &&
|
|
28
|
+
typeof URL.revokeObjectURL === "function" &&
|
|
29
|
+
typeof Blob !== "undefined"
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function triggerBrowserDownload(result: ExportResult): ExportDelivery {
|
|
34
|
+
const blob = new Blob([Uint8Array.from(result.bytes)], { type: result.mimeType });
|
|
35
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
36
|
+
const link = document.createElement("a");
|
|
37
|
+
link.href = objectUrl;
|
|
38
|
+
link.download = result.fileName;
|
|
39
|
+
link.rel = "noopener";
|
|
40
|
+
link.style.display = "none";
|
|
41
|
+
document.body?.appendChild(link);
|
|
42
|
+
try {
|
|
43
|
+
link.click();
|
|
44
|
+
} finally {
|
|
45
|
+
link.remove();
|
|
46
|
+
URL.revokeObjectURL(objectUrl);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
mode: "downloaded",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -19,6 +19,26 @@ export function createSelectionSnapshot(anchor: number, head = anchor): Selectio
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export function createNodeSelectionSnapshot(at: number, assoc: -1 | 1 = 1): SelectionSnapshot {
|
|
23
|
+
return {
|
|
24
|
+
anchor: at,
|
|
25
|
+
head: at,
|
|
26
|
+
isCollapsed: true,
|
|
27
|
+
activeRange: {
|
|
28
|
+
kind: "node",
|
|
29
|
+
at,
|
|
30
|
+
assoc,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isCollapsedAtBlockStart(
|
|
36
|
+
selection: SelectionSnapshot,
|
|
37
|
+
blockFrom: number,
|
|
38
|
+
): boolean {
|
|
39
|
+
return selection.isCollapsed && selection.head === blockFrom;
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
export function selectionTouchesRange(
|
|
23
43
|
selection: SelectionSnapshot,
|
|
24
44
|
from: number,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface SelectionToolbarBadge {
|
|
2
|
+
label: string;
|
|
3
|
+
tone?: "neutral" | "accent";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SelectionToolbarModel {
|
|
7
|
+
previewText: string;
|
|
8
|
+
badges: SelectionToolbarBadge[];
|
|
9
|
+
canToggleFormatting: boolean;
|
|
10
|
+
boldActive: boolean;
|
|
11
|
+
italicActive: boolean;
|
|
12
|
+
underlineActive: boolean;
|
|
13
|
+
canAddComment: boolean;
|
|
14
|
+
disabledReason?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SelectionToolbarAnchor {
|
|
18
|
+
left: number;
|
|
19
|
+
right: number;
|
|
20
|
+
top: number;
|
|
21
|
+
bottom: number;
|
|
22
|
+
}
|
|
@@ -9,6 +9,7 @@ export interface EditorKeyboardCallbacks {
|
|
|
9
9
|
onDeleteBackward?: () => void;
|
|
10
10
|
onDeleteForward?: () => void;
|
|
11
11
|
onInsertTab?: () => void;
|
|
12
|
+
onOutdentTab?: () => void;
|
|
12
13
|
onInsertHardBreak?: () => void;
|
|
13
14
|
onSplitParagraph?: () => void;
|
|
14
15
|
}
|
|
@@ -65,7 +66,11 @@ export function createEditorKeyboardHandler(
|
|
|
65
66
|
return;
|
|
66
67
|
case "Tab":
|
|
67
68
|
event.preventDefault();
|
|
68
|
-
|
|
69
|
+
if (event.shiftKey) {
|
|
70
|
+
callbacks.onOutdentTab?.();
|
|
71
|
+
} else {
|
|
72
|
+
callbacks.onInsertTab?.();
|
|
73
|
+
}
|
|
69
74
|
return;
|
|
70
75
|
case "Enter":
|
|
71
76
|
event.preventDefault();
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
EditorViewStateSnapshot,
|
|
5
|
+
PageLayoutSnapshot,
|
|
6
|
+
} from "../../api/public-types";
|
|
7
|
+
|
|
8
|
+
interface ActiveParagraphLayout {
|
|
9
|
+
leftIndent: number;
|
|
10
|
+
rightIndent: number;
|
|
11
|
+
firstLineOffset: number;
|
|
12
|
+
tabStops: Array<{ pos: number; val?: string; leader?: string }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TwPageRulerProps {
|
|
16
|
+
pageLayout: PageLayoutSnapshot;
|
|
17
|
+
viewState: EditorViewStateSnapshot;
|
|
18
|
+
paragraphLayout: ActiveParagraphLayout | null;
|
|
19
|
+
readOnly: boolean;
|
|
20
|
+
onReturnToBody: () => void;
|
|
21
|
+
onOpenHeader?: () => void;
|
|
22
|
+
onOpenFooter?: () => void;
|
|
23
|
+
onSetIndentation?: (indentation: {
|
|
24
|
+
left?: number;
|
|
25
|
+
right?: number;
|
|
26
|
+
firstLine?: number;
|
|
27
|
+
hanging?: number;
|
|
28
|
+
}) => void;
|
|
29
|
+
onSetTabStops?: (tabStops: Array<{ pos: number; val?: string; leader?: string }>) => void;
|
|
30
|
+
onRestartNumbering?: () => void;
|
|
31
|
+
onContinueNumbering?: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type DragKind = "left-indent" | "first-line";
|
|
35
|
+
|
|
36
|
+
const MIN_HANDLE_TWIPS = 0;
|
|
37
|
+
const HANDLE_OVERLAP_THRESHOLD_PERCENT = 1.4;
|
|
38
|
+
const HANDLE_OFFSET_PERCENT = 0.9;
|
|
39
|
+
const MARKER_HALF_PX = 8;
|
|
40
|
+
|
|
41
|
+
export function TwPageRuler(props: TwPageRulerProps) {
|
|
42
|
+
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
43
|
+
const dragCleanupRef = useRef<(() => void) | null>(null);
|
|
44
|
+
const [, setDragState] = useState<{
|
|
45
|
+
kind: DragKind;
|
|
46
|
+
startClientX: number;
|
|
47
|
+
startLeftIndent: number;
|
|
48
|
+
startFirstLineOffset: number;
|
|
49
|
+
} | null>(null);
|
|
50
|
+
const [previewLayout, setPreviewLayout] = useState<ActiveParagraphLayout | null>(null);
|
|
51
|
+
|
|
52
|
+
const effectiveLayout = previewLayout ?? props.paragraphLayout;
|
|
53
|
+
const activeRegion = props.viewState.activePageRegion?.region ?? "body";
|
|
54
|
+
const isBodyParagraphContext =
|
|
55
|
+
activeRegion === "body" && Boolean(props.paragraphLayout);
|
|
56
|
+
const availableHeader = props.pageLayout.headerVariants[0];
|
|
57
|
+
const availableFooter = props.pageLayout.footerVariants[0];
|
|
58
|
+
|
|
59
|
+
const usablePageWidth = Math.max(
|
|
60
|
+
1440,
|
|
61
|
+
props.pageLayout.pageWidth - props.pageLayout.marginLeft - props.pageLayout.marginRight,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
return () => {
|
|
66
|
+
dragCleanupRef.current?.();
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
function beginDrag(kind: DragKind, clientX: number): void {
|
|
71
|
+
if (!isBodyParagraphContext || !props.paragraphLayout || props.readOnly) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
dragCleanupRef.current?.();
|
|
76
|
+
const activeDrag = {
|
|
77
|
+
kind,
|
|
78
|
+
startClientX: clientX,
|
|
79
|
+
startLeftIndent: props.paragraphLayout.leftIndent,
|
|
80
|
+
startFirstLineOffset: props.paragraphLayout.firstLineOffset,
|
|
81
|
+
};
|
|
82
|
+
setDragState(activeDrag);
|
|
83
|
+
|
|
84
|
+
const handleMouseMove = (event: MouseEvent): void => {
|
|
85
|
+
const track = trackRef.current;
|
|
86
|
+
if (!track) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const rect = track.getBoundingClientRect();
|
|
90
|
+
const deltaPx = event.clientX - activeDrag.startClientX;
|
|
91
|
+
const deltaTwips = pxToTwips(deltaPx, rect.width, usablePageWidth);
|
|
92
|
+
|
|
93
|
+
if (activeDrag.kind === "left-indent") {
|
|
94
|
+
setPreviewLayout({
|
|
95
|
+
...props.paragraphLayout!,
|
|
96
|
+
leftIndent: clampTwips(activeDrag.startLeftIndent + deltaTwips),
|
|
97
|
+
firstLineOffset: activeDrag.startFirstLineOffset,
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setPreviewLayout({
|
|
103
|
+
...props.paragraphLayout!,
|
|
104
|
+
leftIndent: activeDrag.startLeftIndent,
|
|
105
|
+
firstLineOffset: activeDrag.startFirstLineOffset + deltaTwips,
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleMouseUp = (event: MouseEvent): void => {
|
|
110
|
+
const track = trackRef.current;
|
|
111
|
+
if (!track || !props.onSetIndentation) {
|
|
112
|
+
cleanupDrag();
|
|
113
|
+
setDragState(null);
|
|
114
|
+
setPreviewLayout(null);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const rect = track.getBoundingClientRect();
|
|
119
|
+
const deltaPx = event.clientX - activeDrag.startClientX;
|
|
120
|
+
const deltaTwips = pxToTwips(deltaPx, rect.width, usablePageWidth);
|
|
121
|
+
const leftIndent =
|
|
122
|
+
activeDrag.kind === "left-indent"
|
|
123
|
+
? clampTwips(activeDrag.startLeftIndent + deltaTwips)
|
|
124
|
+
: activeDrag.startLeftIndent;
|
|
125
|
+
const firstLineOffset =
|
|
126
|
+
activeDrag.kind === "first-line"
|
|
127
|
+
? activeDrag.startFirstLineOffset + deltaTwips
|
|
128
|
+
: activeDrag.startFirstLineOffset;
|
|
129
|
+
|
|
130
|
+
cleanupDrag();
|
|
131
|
+
props.onSetIndentation(
|
|
132
|
+
composeIndentation(leftIndent, effectiveLayout?.rightIndent ?? props.paragraphLayout?.rightIndent ?? 0, firstLineOffset),
|
|
133
|
+
);
|
|
134
|
+
setDragState(null);
|
|
135
|
+
setPreviewLayout(null);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const cleanupDrag = (): void => {
|
|
139
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
140
|
+
window.removeEventListener("mouseup", handleMouseUp);
|
|
141
|
+
if (dragCleanupRef.current === cleanupDrag) {
|
|
142
|
+
dragCleanupRef.current = null;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
dragCleanupRef.current = cleanupDrag;
|
|
147
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
148
|
+
window.addEventListener("mouseup", handleMouseUp);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const markerLayout = useMemo(() => {
|
|
152
|
+
if (!effectiveLayout || !isBodyParagraphContext) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
leftIndent: twipsToPercent(effectiveLayout.leftIndent, usablePageWidth),
|
|
157
|
+
firstLine: twipsToPercent(
|
|
158
|
+
Math.max(MIN_HANDLE_TWIPS, effectiveLayout.leftIndent + effectiveLayout.firstLineOffset),
|
|
159
|
+
usablePageWidth,
|
|
160
|
+
),
|
|
161
|
+
tabStops: effectiveLayout.tabStops.map((tabStop, index) => ({
|
|
162
|
+
id: `${tabStop.pos}-${index}`,
|
|
163
|
+
left: twipsToPercent(tabStop.pos, usablePageWidth),
|
|
164
|
+
})),
|
|
165
|
+
};
|
|
166
|
+
}, [effectiveLayout, isBodyParagraphContext, usablePageWidth]);
|
|
167
|
+
const handlesOverlap = markerLayout
|
|
168
|
+
? Math.abs(markerLayout.leftIndent - markerLayout.firstLine) < HANDLE_OVERLAP_THRESHOLD_PERCENT
|
|
169
|
+
: false;
|
|
170
|
+
const leftIndentHandleLeft = markerLayout
|
|
171
|
+
? offsetHandlePercent(markerLayout.leftIndent, handlesOverlap ? -HANDLE_OFFSET_PERCENT : 0)
|
|
172
|
+
: 0;
|
|
173
|
+
const firstLineHandleLeft = markerLayout
|
|
174
|
+
? offsetHandlePercent(markerLayout.firstLine, handlesOverlap ? HANDLE_OFFSET_PERCENT : 0)
|
|
175
|
+
: 0;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div
|
|
179
|
+
aria-label="Page ruler"
|
|
180
|
+
className="mb-4 rounded-2xl border border-border bg-surface/80 px-4 py-3 shadow-sm"
|
|
181
|
+
>
|
|
182
|
+
<div className="mb-3 flex flex-wrap items-center gap-2">
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
aria-label="Return to document body"
|
|
186
|
+
title="Return to document body"
|
|
187
|
+
onClick={props.onReturnToBody}
|
|
188
|
+
className={regionButtonClass(activeRegion === "body")}
|
|
189
|
+
>
|
|
190
|
+
Body
|
|
191
|
+
</button>
|
|
192
|
+
{availableHeader ? (
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
aria-label="Open header story"
|
|
196
|
+
title="Open header story"
|
|
197
|
+
onClick={props.onOpenHeader}
|
|
198
|
+
className={regionButtonClass(activeRegion === "header")}
|
|
199
|
+
>
|
|
200
|
+
Header
|
|
201
|
+
</button>
|
|
202
|
+
) : null}
|
|
203
|
+
{availableFooter ? (
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
aria-label="Open footer story"
|
|
207
|
+
title="Open footer story"
|
|
208
|
+
onClick={props.onOpenFooter}
|
|
209
|
+
className={regionButtonClass(activeRegion === "footer")}
|
|
210
|
+
>
|
|
211
|
+
Footer
|
|
212
|
+
</button>
|
|
213
|
+
) : null}
|
|
214
|
+
{props.viewState.activeListContext ? (
|
|
215
|
+
<>
|
|
216
|
+
<div className="h-4 w-px bg-border" />
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
aria-label="Continue numbering"
|
|
220
|
+
title="Continue numbering from previous list"
|
|
221
|
+
disabled={props.readOnly}
|
|
222
|
+
onClick={props.onContinueNumbering}
|
|
223
|
+
className={controlButtonClass}
|
|
224
|
+
>
|
|
225
|
+
Continue
|
|
226
|
+
</button>
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
aria-label="Restart numbering"
|
|
230
|
+
title="Restart numbering at 1"
|
|
231
|
+
disabled={props.readOnly}
|
|
232
|
+
onClick={props.onRestartNumbering}
|
|
233
|
+
className={controlButtonClass}
|
|
234
|
+
>
|
|
235
|
+
Restart
|
|
236
|
+
</button>
|
|
237
|
+
</>
|
|
238
|
+
) : null}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div
|
|
242
|
+
className="mb-2 flex items-center justify-between"
|
|
243
|
+
aria-label={`Section ${props.pageLayout.sectionIndex + 1}, ${props.pageLayout.orientation}`}
|
|
244
|
+
title={`Section ${props.pageLayout.sectionIndex + 1} · ${props.pageLayout.orientation}`}
|
|
245
|
+
>
|
|
246
|
+
<span className="sr-only">Page ruler</span>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div
|
|
250
|
+
ref={trackRef}
|
|
251
|
+
aria-label="Page ruler track"
|
|
252
|
+
className="relative h-14 overflow-hidden rounded-xl border border-border bg-canvas"
|
|
253
|
+
onClick={(event) => {
|
|
254
|
+
if (
|
|
255
|
+
props.readOnly ||
|
|
256
|
+
!isBodyParagraphContext ||
|
|
257
|
+
!props.paragraphLayout ||
|
|
258
|
+
!props.onSetTabStops
|
|
259
|
+
) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if ((event.target as HTMLElement).dataset.handle === "true") {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
266
|
+
const nextPos = clampTwips(pxToTwips(event.clientX - rect.left, rect.width, usablePageWidth));
|
|
267
|
+
props.onSetTabStops(
|
|
268
|
+
[...props.paragraphLayout.tabStops, { pos: nextPos, val: "left" }]
|
|
269
|
+
.sort((left, right) => left.pos - right.pos),
|
|
270
|
+
);
|
|
271
|
+
}}
|
|
272
|
+
>
|
|
273
|
+
<div className="absolute inset-x-4 top-2 h-px bg-border" />
|
|
274
|
+
<div className="absolute inset-x-4 top-7 h-px bg-border/70" />
|
|
275
|
+
{Array.from({ length: 8 }, (_, index) => (
|
|
276
|
+
<div
|
|
277
|
+
key={`tick-${index}`}
|
|
278
|
+
className="absolute top-1 h-3 w-px bg-border/80"
|
|
279
|
+
style={{ left: `${12 + index * 12}%` }}
|
|
280
|
+
/>
|
|
281
|
+
))}
|
|
282
|
+
|
|
283
|
+
{markerLayout ? (
|
|
284
|
+
<>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
data-handle="true"
|
|
288
|
+
aria-label="Left indent handle"
|
|
289
|
+
title={`Left indent: ${effectiveLayout?.leftIndent ?? 0} twips`}
|
|
290
|
+
disabled={props.readOnly}
|
|
291
|
+
className={`absolute top-8 h-4 w-4 -translate-x-1/2 rounded-[5px] border border-accent/40 bg-accent-soft shadow-sm transition-opacity ${
|
|
292
|
+
handlesOverlap ? "opacity-80 z-10" : ""
|
|
293
|
+
}`}
|
|
294
|
+
style={{ left: markerLeftStyle(leftIndentHandleLeft) }}
|
|
295
|
+
onMouseDown={(event) => {
|
|
296
|
+
event.preventDefault();
|
|
297
|
+
beginDrag("left-indent", event.clientX);
|
|
298
|
+
}}
|
|
299
|
+
/>
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
data-handle="true"
|
|
303
|
+
aria-label="First line indent handle"
|
|
304
|
+
title={`First line offset: ${effectiveLayout?.firstLineOffset ?? 0} twips`}
|
|
305
|
+
disabled={props.readOnly}
|
|
306
|
+
className={`absolute top-1 h-4 w-4 -translate-x-1/2 rotate-45 rounded-[4px] border border-primary/30 bg-surface-raised shadow-sm transition-opacity ${
|
|
307
|
+
handlesOverlap ? "opacity-80 z-20" : ""
|
|
308
|
+
}`}
|
|
309
|
+
style={{ left: markerLeftStyle(firstLineHandleLeft) }}
|
|
310
|
+
onMouseDown={(event) => {
|
|
311
|
+
event.preventDefault();
|
|
312
|
+
beginDrag("first-line", event.clientX);
|
|
313
|
+
}}
|
|
314
|
+
/>
|
|
315
|
+
{markerLayout.tabStops.map((tabStop) => (
|
|
316
|
+
<div
|
|
317
|
+
key={tabStop.id}
|
|
318
|
+
data-handle="true"
|
|
319
|
+
aria-label={`Tab stop at ${tabStop.left.toFixed(0)}%`}
|
|
320
|
+
title={`Tab stop`}
|
|
321
|
+
className="absolute top-5 h-4 w-4 -translate-x-1/2 rounded-sm border border-border bg-surface-raised shadow-sm"
|
|
322
|
+
style={{ left: markerLeftStyle(tabStop.left) }}
|
|
323
|
+
/>
|
|
324
|
+
))}
|
|
325
|
+
</>
|
|
326
|
+
) : null}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function composeIndentation(leftIndent: number, rightIndent: number, firstLineOffset: number) {
|
|
333
|
+
const indentation: {
|
|
334
|
+
left?: number;
|
|
335
|
+
right?: number;
|
|
336
|
+
firstLine?: number;
|
|
337
|
+
hanging?: number;
|
|
338
|
+
} = {};
|
|
339
|
+
if (leftIndent > 0) {
|
|
340
|
+
indentation.left = leftIndent;
|
|
341
|
+
}
|
|
342
|
+
if (rightIndent > 0) {
|
|
343
|
+
indentation.right = rightIndent;
|
|
344
|
+
}
|
|
345
|
+
if (firstLineOffset > 0) {
|
|
346
|
+
indentation.firstLine = Math.round(firstLineOffset);
|
|
347
|
+
} else if (firstLineOffset < 0) {
|
|
348
|
+
indentation.hanging = Math.round(Math.abs(firstLineOffset));
|
|
349
|
+
}
|
|
350
|
+
return indentation;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function regionButtonClass(active: boolean): string {
|
|
354
|
+
return `inline-flex items-center rounded-full border px-3 py-1 text-xs transition-colors ${
|
|
355
|
+
active
|
|
356
|
+
? "border-accent/30 bg-accent-soft text-accent"
|
|
357
|
+
: "border-border bg-canvas text-secondary hover:bg-surface"
|
|
358
|
+
}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const controlButtonClass =
|
|
362
|
+
"inline-flex items-center rounded-full border border-border bg-canvas px-3 py-1 text-xs text-secondary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50";
|
|
363
|
+
|
|
364
|
+
function twipsToPercent(value: number, usablePageWidth: number): number {
|
|
365
|
+
return Math.max(0, Math.min(100, (value / usablePageWidth) * 100));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function pxToTwips(px: number, width: number, usablePageWidth: number): number {
|
|
369
|
+
if (width <= 0) {
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
return Math.round((px / width) * usablePageWidth);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function clampTwips(value: number): number {
|
|
376
|
+
return Math.max(MIN_HANDLE_TWIPS, Math.round(value));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function offsetHandlePercent(value: number, offset: number): number {
|
|
380
|
+
return Math.max(0, Math.min(100, value + offset));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function markerLeftStyle(value: number): string {
|
|
384
|
+
const clamped = Math.max(0, Math.min(100, value));
|
|
385
|
+
return `clamp(${MARKER_HALF_PX}px, ${clamped}%, calc(100% - ${MARKER_HALF_PX}px))`;
|
|
386
|
+
}
|
|
@@ -1,33 +1,77 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { forwardRef } from "react";
|
|
2
|
+
import type { FocusEventHandler } from "react";
|
|
2
3
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
|
3
|
-
import { MessageSquare } from "lucide-react";
|
|
4
|
+
import { Bold, Italic, MessageSquare, Underline } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
import type { SelectionToolbarModel } from "../../ui/headless/selection-toolbar-model";
|
|
7
|
+
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
4
8
|
|
|
5
9
|
export interface TwSelectionToolbarProps {
|
|
6
|
-
|
|
7
|
-
readOnly: boolean;
|
|
8
|
-
canAddComment?: boolean;
|
|
10
|
+
model: SelectionToolbarModel;
|
|
9
11
|
disabledReason?: string;
|
|
12
|
+
onFocusCapture?: FocusEventHandler<HTMLDivElement>;
|
|
13
|
+
onBlurCapture?: FocusEventHandler<HTMLDivElement>;
|
|
14
|
+
onToggleBold?: () => void;
|
|
15
|
+
onToggleItalic?: () => void;
|
|
16
|
+
onToggleUnderline?: () => void;
|
|
10
17
|
onAddComment?: () => void;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
const focusRingClass =
|
|
14
21
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
|
|
15
22
|
|
|
16
|
-
export function TwSelectionToolbar(props
|
|
17
|
-
const
|
|
23
|
+
export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarProps>(function TwSelectionToolbar(props, ref) {
|
|
24
|
+
const { model } = props;
|
|
25
|
+
const addCommentDisabled = !model.canAddComment;
|
|
26
|
+
const formattingDisabled = !model.canToggleFormatting;
|
|
27
|
+
const contextLabel = summarizeSelectionContext(model);
|
|
18
28
|
const tooltipLabel = addCommentDisabled
|
|
19
29
|
? props.disabledReason ?? "Select text within one paragraph to add a DOCX comment"
|
|
20
30
|
: "Add comment";
|
|
31
|
+
|
|
21
32
|
return (
|
|
22
|
-
<div
|
|
33
|
+
<div
|
|
34
|
+
ref={ref}
|
|
35
|
+
data-testid="selection-toolbar"
|
|
36
|
+
className="inline-flex max-w-[min(24rem,calc(100vw-2rem))] items-center gap-1.5 rounded-xl border border-border/80 bg-canvas px-1.5 py-1.5 shadow-lg ring-1 ring-border/80"
|
|
37
|
+
role="toolbar"
|
|
38
|
+
aria-label="Selection actions"
|
|
39
|
+
onFocusCapture={props.onFocusCapture}
|
|
40
|
+
onBlurCapture={props.onBlurCapture}
|
|
41
|
+
>
|
|
42
|
+
<ToolbarActionButton
|
|
43
|
+
icon={<Bold className="h-3.5 w-3.5" />}
|
|
44
|
+
label="Bold selection"
|
|
45
|
+
pressed={model.boldActive}
|
|
46
|
+
disabled={formattingDisabled}
|
|
47
|
+
onClick={props.onToggleBold}
|
|
48
|
+
/>
|
|
49
|
+
<ToolbarActionButton
|
|
50
|
+
icon={<Italic className="h-3.5 w-3.5" />}
|
|
51
|
+
label="Italic selection"
|
|
52
|
+
pressed={model.italicActive}
|
|
53
|
+
disabled={formattingDisabled}
|
|
54
|
+
onClick={props.onToggleItalic}
|
|
55
|
+
/>
|
|
56
|
+
<ToolbarActionButton
|
|
57
|
+
icon={<Underline className="h-3.5 w-3.5" />}
|
|
58
|
+
label="Underline selection"
|
|
59
|
+
pressed={model.underlineActive}
|
|
60
|
+
disabled={formattingDisabled}
|
|
61
|
+
onClick={props.onToggleUnderline}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
<div className="mx-0.5 h-4 w-px bg-border" />
|
|
65
|
+
|
|
23
66
|
<Tooltip.Root>
|
|
24
67
|
<Tooltip.Trigger asChild>
|
|
25
68
|
<button
|
|
26
69
|
type="button"
|
|
27
|
-
aria-label="
|
|
70
|
+
aria-label="Add comment from selection"
|
|
28
71
|
disabled={addCommentDisabled}
|
|
72
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
29
73
|
onClick={props.onAddComment}
|
|
30
|
-
className={`inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors text-accent hover:bg-accent-soft disabled:
|
|
74
|
+
className={`inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors text-accent hover:bg-accent-soft disabled:cursor-not-allowed disabled:opacity-30 ${focusRingClass}`}
|
|
31
75
|
>
|
|
32
76
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
33
77
|
</button>
|
|
@@ -41,10 +85,77 @@ export function TwSelectionToolbar(props: TwSelectionToolbarProps) {
|
|
|
41
85
|
</Tooltip.Content>
|
|
42
86
|
</Tooltip.Portal>
|
|
43
87
|
</Tooltip.Root>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
88
|
+
|
|
89
|
+
{contextLabel ? (
|
|
90
|
+
<>
|
|
91
|
+
<div className="mx-0.5 h-4 w-px bg-border" />
|
|
92
|
+
<span
|
|
93
|
+
className={`min-w-0 max-w-[11rem] truncate rounded-full px-2 py-0.5 text-[10px] font-medium tracking-[0.08em] ${
|
|
94
|
+
model.badges.some((badge) => badge.tone === "accent")
|
|
95
|
+
? "bg-accent-soft text-accent"
|
|
96
|
+
: "bg-surface text-tertiary"
|
|
97
|
+
}`}
|
|
98
|
+
>
|
|
99
|
+
{contextLabel}
|
|
100
|
+
</span>
|
|
101
|
+
</>
|
|
102
|
+
) : null}
|
|
48
103
|
</div>
|
|
49
104
|
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
function summarizeSelectionContext(model: SelectionToolbarModel): string | null {
|
|
108
|
+
if (model.badges.length === 0) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const accentBadges = model.badges.filter((badge) => badge.tone === "accent");
|
|
113
|
+
const source = accentBadges.length > 0 ? accentBadges : model.badges;
|
|
114
|
+
const labels = source.slice(0, 2).map((badge) => badge.label.trim()).filter(Boolean);
|
|
115
|
+
if (labels.length === 0) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const summary = labels.join(" · ");
|
|
120
|
+
return summary.length > 30 ? `${summary.slice(0, 27)}...` : summary;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface ToolbarActionButtonProps {
|
|
124
|
+
icon: React.ReactNode;
|
|
125
|
+
label: string;
|
|
126
|
+
pressed: boolean;
|
|
127
|
+
disabled: boolean;
|
|
128
|
+
onClick?: () => void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ToolbarActionButton(props: ToolbarActionButtonProps) {
|
|
132
|
+
return (
|
|
133
|
+
<Tooltip.Root>
|
|
134
|
+
<Tooltip.Trigger asChild>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
aria-label={props.label}
|
|
138
|
+
aria-pressed={props.pressed}
|
|
139
|
+
disabled={props.disabled}
|
|
140
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
141
|
+
onClick={props.onClick}
|
|
142
|
+
className={`inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:opacity-30 ${
|
|
143
|
+
props.pressed
|
|
144
|
+
? "bg-accent-soft text-accent"
|
|
145
|
+
: "text-secondary hover:bg-surface"
|
|
146
|
+
} ${focusRingClass}`}
|
|
147
|
+
>
|
|
148
|
+
{props.icon}
|
|
149
|
+
</button>
|
|
150
|
+
</Tooltip.Trigger>
|
|
151
|
+
<Tooltip.Portal>
|
|
152
|
+
<Tooltip.Content
|
|
153
|
+
className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
|
|
154
|
+
sideOffset={6}
|
|
155
|
+
>
|
|
156
|
+
{props.label}
|
|
157
|
+
</Tooltip.Content>
|
|
158
|
+
</Tooltip.Portal>
|
|
159
|
+
</Tooltip.Root>
|
|
160
|
+
);
|
|
50
161
|
}
|