@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  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 +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -0,0 +1,114 @@
1
+ import React from "react";
2
+
3
+ import type {
4
+ PageLayoutSnapshot,
5
+ SectionPageNumberingPatch,
6
+ SectionBreakType,
7
+ SectionLayoutPatch,
8
+ } from "../../api/public-types";
9
+ import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
10
+
11
+ export interface TwLayoutPanelProps {
12
+ pageLayout: PageLayoutSnapshot;
13
+ readOnly: boolean;
14
+ onInsertSectionBreak?: (type: SectionBreakType) => void;
15
+ onDeleteSectionBreak?: (sectionIndex: number) => void;
16
+ onUpdateSectionLayout?: (sectionIndex: number, patch: SectionLayoutPatch) => void;
17
+ onSetSectionPageNumbering?: (
18
+ sectionIndex: number,
19
+ patch: SectionPageNumberingPatch | null,
20
+ ) => void;
21
+ }
22
+
23
+ export function TwLayoutPanel(props: TwLayoutPanelProps) {
24
+ const nextOrientation = props.pageLayout.orientation === "portrait" ? "landscape" : "portrait";
25
+ const titlePageEnabled = props.pageLayout.differentFirstPage;
26
+
27
+ return (
28
+ <div className="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-border bg-canvas px-3 py-2 shadow-sm">
29
+ <span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
30
+ Section
31
+ </span>
32
+ <ToolbarButton
33
+ ariaLabel="Insert next-page section break"
34
+ disabled={props.readOnly || !props.onInsertSectionBreak}
35
+ onClick={() => props.onInsertSectionBreak?.("nextPage")}
36
+ >
37
+ Next-page break
38
+ </ToolbarButton>
39
+ <ToolbarButton
40
+ ariaLabel={`Switch section to ${nextOrientation}`}
41
+ disabled={props.readOnly || !props.onUpdateSectionLayout}
42
+ onClick={() =>
43
+ props.onUpdateSectionLayout?.(props.pageLayout.sectionIndex, {
44
+ pageSize: {
45
+ orientation: nextOrientation,
46
+ width: props.pageLayout.pageHeight,
47
+ height: props.pageLayout.pageWidth,
48
+ },
49
+ })}
50
+ >
51
+ {nextOrientation === "landscape" ? "Landscape" : "Portrait"}
52
+ </ToolbarButton>
53
+ <ToolbarButton
54
+ ariaLabel="Delete current section break"
55
+ disabled={props.readOnly || props.pageLayout.sectionIndex === 0 || !props.onDeleteSectionBreak}
56
+ onClick={() => props.onDeleteSectionBreak?.(props.pageLayout.sectionIndex)}
57
+ >
58
+ Delete break
59
+ </ToolbarButton>
60
+ <ToolbarButton
61
+ ariaLabel="Restart page numbering at 1"
62
+ disabled={props.readOnly || !props.onSetSectionPageNumbering}
63
+ onClick={() =>
64
+ props.onSetSectionPageNumbering?.(props.pageLayout.sectionIndex, {
65
+ ...(props.pageLayout.pageNumbering ?? {}),
66
+ start: 1,
67
+ })}
68
+ >
69
+ Restart numbering
70
+ </ToolbarButton>
71
+ <ToolbarButton
72
+ ariaLabel="Use roman page numbering"
73
+ disabled={props.readOnly || !props.onSetSectionPageNumbering}
74
+ onClick={() =>
75
+ props.onSetSectionPageNumbering?.(props.pageLayout.sectionIndex, {
76
+ ...(props.pageLayout.pageNumbering ?? {}),
77
+ format: "roman",
78
+ })}
79
+ >
80
+ Roman numerals
81
+ </ToolbarButton>
82
+ <ToolbarButton
83
+ ariaLabel="Toggle different first page"
84
+ disabled={props.readOnly || !props.onUpdateSectionLayout}
85
+ onClick={() =>
86
+ props.onUpdateSectionLayout?.(props.pageLayout.sectionIndex, {
87
+ titlePage: !titlePageEnabled,
88
+ })}
89
+ >
90
+ {titlePageEnabled ? "Same first page" : "Different first page"}
91
+ </ToolbarButton>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ function ToolbarButton(props: {
97
+ ariaLabel: string;
98
+ children: React.ReactNode;
99
+ disabled: boolean;
100
+ onClick?: () => void;
101
+ }) {
102
+ return (
103
+ <button
104
+ type="button"
105
+ aria-label={props.ariaLabel}
106
+ disabled={props.disabled}
107
+ onMouseDown={preserveEditorSelectionMouseDown}
108
+ onClick={props.onClick}
109
+ className="inline-flex h-8 items-center rounded-md px-2 text-xs font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
110
+ >
111
+ {props.children}
112
+ </button>
113
+ );
114
+ }
@@ -0,0 +1,34 @@
1
+ import React from "react";
2
+
3
+ export interface ActiveObjectContext {
4
+ kind: "textbox" | "shape";
5
+ display: "inline" | "floating";
6
+ }
7
+
8
+ export interface TwObjectContextToolbarProps {
9
+ activeObject: ActiveObjectContext;
10
+ }
11
+
12
+ export function TwObjectContextToolbar(props: TwObjectContextToolbarProps) {
13
+ const label = props.activeObject.kind === "textbox" ? "Text box" : "Shape";
14
+
15
+ return (
16
+ <div
17
+ data-testid="object-context-toolbar"
18
+ className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-canvas px-3 py-2 shadow-sm"
19
+ >
20
+ <span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
21
+ Object
22
+ </span>
23
+ <span className="rounded-full bg-surface px-2 py-1 text-[10px] font-medium uppercase tracking-[0.1em] text-secondary">
24
+ {label}
25
+ </span>
26
+ <span className="rounded-full bg-surface px-2 py-1 text-[10px] font-medium uppercase tracking-[0.1em] text-secondary">
27
+ {props.activeObject.display}
28
+ </span>
29
+ <span className="text-xs text-secondary">
30
+ Object selection is active.
31
+ </span>
32
+ </div>
33
+ );
34
+ }
@@ -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
+ }