@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.
- package/README.md +8 -2
- package/package.json +24 -34
- package/src/api/README.md +5 -1
- package/src/api/public-types.ts +710 -4
- package/src/api/session-state.ts +60 -0
- package/src/core/commands/formatting-commands.ts +2 -1
- package/src/core/commands/image-commands.ts +147 -0
- package/src/core/commands/index.ts +19 -3
- 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 +357 -0
- package/src/core/selection/mapping.ts +41 -0
- package/src/core/state/editor-state.ts +4 -1
- package/src/index.ts +51 -0
- package/src/io/docx-session.ts +623 -56
- package/src/io/export/serialize-comments.ts +104 -34
- package/src/io/export/serialize-footnotes.ts +198 -1
- package/src/io/export/serialize-headers-footers.ts +203 -10
- package/src/io/export/serialize-main-document.ts +285 -8
- package/src/io/export/serialize-numbering.ts +28 -7
- package/src/io/export/split-review-boundaries.ts +181 -19
- package/src/io/normalize/normalize-text.ts +144 -32
- package/src/io/ooxml/highlight-colors.ts +39 -0
- package/src/io/ooxml/numbering-sentinels.ts +44 -0
- package/src/io/ooxml/parse-comments.ts +85 -19
- package/src/io/ooxml/parse-fields.ts +396 -0
- package/src/io/ooxml/parse-footnotes.ts +452 -22
- package/src/io/ooxml/parse-headers-footers.ts +657 -29
- package/src/io/ooxml/parse-inline-media.ts +30 -0
- package/src/io/ooxml/parse-main-document.ts +807 -20
- package/src/io/ooxml/parse-numbering.ts +7 -0
- package/src/io/ooxml/parse-revisions.ts +317 -38
- 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/legal/bookmarks.ts +44 -0
- package/src/legal/cross-references.ts +59 -1
- package/src/model/canonical-document.ts +250 -4
- package/src/model/cds-1.0.0.ts +13 -0
- package/src/model/snapshot.ts +87 -2
- package/src/review/store/revision-store.ts +6 -0
- package/src/review/store/revision-types.ts +1 -0
- package/src/runtime/document-layout.ts +332 -0
- package/src/runtime/document-navigation.ts +603 -0
- package/src/runtime/document-runtime.ts +1754 -78
- 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 +9 -0
- package/src/runtime/session-capabilities.ts +35 -3
- package/src/runtime/story-context.ts +164 -0
- package/src/runtime/story-targeting.ts +162 -0
- package/src/runtime/surface-projection.ts +324 -36
- package/src/runtime/table-schema.ts +89 -7
- package/src/runtime/view-state.ts +477 -0
- package/src/runtime/workflow-markup.ts +349 -0
- package/src/ui/WordReviewEditor.tsx +2469 -1344
- package/src/ui/browser-export.ts +52 -0
- package/src/ui/editor-command-bag.ts +120 -0
- package/src/ui/editor-runtime-boundary.ts +1422 -0
- package/src/ui/editor-shell-view.tsx +134 -0
- package/src/ui/editor-surface-controller.tsx +51 -0
- package/src/ui/headless/preserve-editor-selection.ts +5 -0
- package/src/ui/headless/revision-decoration-model.ts +4 -4
- 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/runtime-snapshot-selectors.ts +197 -0
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
- package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
- package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
- package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
- package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
- package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
- package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
- package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
- 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/page-chrome-model.ts +27 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
- package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
- package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
- package/src/ui-tailwind/theme/editor-theme.css +127 -0
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
- package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
- package/src/validation/compatibility-engine.ts +119 -24
- package/src/validation/compatibility-report.ts +1 -0
- package/src/validation/diagnostics.ts +1 -0
- 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
|
+
}
|