@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.
Files changed (74) hide show
  1. package/README.md +8 -2
  2. package/package.json +32 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +374 -4
  5. package/src/api/session-state.ts +58 -0
  6. package/src/core/commands/formatting-commands.ts +1 -0
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +5 -1
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +329 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +1 -1
  16. package/src/index.ts +30 -0
  17. package/src/io/docx-session.ts +260 -39
  18. package/src/io/export/serialize-main-document.ts +202 -5
  19. package/src/io/export/serialize-numbering.ts +28 -7
  20. package/src/io/normalize/normalize-text.ts +63 -25
  21. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  22. package/src/io/ooxml/parse-footnotes.ts +212 -20
  23. package/src/io/ooxml/parse-headers-footers.ts +229 -25
  24. package/src/io/ooxml/parse-inline-media.ts +16 -0
  25. package/src/io/ooxml/parse-main-document.ts +411 -6
  26. package/src/io/ooxml/parse-numbering.ts +7 -0
  27. package/src/io/ooxml/parse-settings.ts +184 -0
  28. package/src/io/ooxml/parse-shapes.ts +25 -0
  29. package/src/io/ooxml/parse-styles.ts +463 -0
  30. package/src/io/ooxml/parse-theme.ts +32 -0
  31. package/src/model/canonical-document.ts +133 -3
  32. package/src/model/cds-1.0.0.ts +13 -0
  33. package/src/model/snapshot.ts +2 -1
  34. package/src/runtime/document-layout.ts +332 -0
  35. package/src/runtime/document-navigation.ts +564 -0
  36. package/src/runtime/document-runtime.ts +265 -35
  37. package/src/runtime/document-search.ts +145 -0
  38. package/src/runtime/numbering-prefix.ts +47 -26
  39. package/src/runtime/page-layout-estimation.ts +212 -0
  40. package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
  41. package/src/runtime/session-capabilities.ts +2 -0
  42. package/src/runtime/story-context.ts +164 -0
  43. package/src/runtime/story-targeting.ts +162 -0
  44. package/src/runtime/surface-projection.ts +239 -12
  45. package/src/runtime/table-schema.ts +87 -5
  46. package/src/runtime/view-state.ts +459 -0
  47. package/src/ui/WordReviewEditor.tsx +1902 -312
  48. package/src/ui/browser-export.ts +52 -0
  49. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  50. package/src/ui/headless/selection-helpers.ts +20 -0
  51. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  52. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  53. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  54. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
  57. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  58. package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
  59. package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
  60. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
  61. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  62. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  63. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
  64. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  65. package/src/ui-tailwind/index.ts +2 -1
  66. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
  68. package/src/ui-tailwind/theme/editor-theme.css +123 -0
  69. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  70. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
  71. package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
  72. package/src/validation/compatibility-engine.ts +92 -20
  73. package/src/validation/diagnostics.ts +1 -0
  74. 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
+ }
@@ -0,0 +1,5 @@
1
+ import type { MouseEvent } from "react";
2
+
3
+ export function preserveEditorSelectionMouseDown(event: MouseEvent<HTMLElement>): void {
4
+ event.preventDefault();
5
+ }
@@ -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
- callbacks.onInsertTab?.();
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
- selectionPreview: string;
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: TwSelectionToolbarProps) {
17
- const addCommentDisabled = props.readOnly || props.canAddComment === false;
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 className="mb-6 inline-flex items-center gap-1 rounded-lg bg-canvas shadow-lg ring-1 ring-border p-1">
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="Comment"
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:opacity-30 disabled:cursor-not-allowed ${focusRingClass}`}
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
- <div className="h-4 w-px bg-border mx-0.5" />
45
- <span className="text-xs text-tertiary px-2 max-w-[200px] truncate">
46
- {props.selectionPreview}
47
- </span>
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
  }