@beyondwork/docx-react-component 1.0.56 → 1.0.57
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/package.json +1 -1
- package/src/api/public-types.ts +157 -0
- package/src/compare/diff-engine.ts +3 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/index.ts +17 -11
- package/src/core/selection/mapping.ts +18 -1
- package/src/core/selection/review-anchors.ts +29 -18
- package/src/io/chart-preview-resolver.ts +175 -41
- package/src/io/docx-session.ts +57 -2
- package/src/io/export/serialize-main-document.ts +82 -0
- package/src/io/export/serialize-styles.ts +61 -3
- package/src/io/export/table-properties-xml.ts +19 -4
- package/src/io/normalize/normalize-text.ts +33 -0
- package/src/io/ooxml/parse-anchor.ts +182 -0
- package/src/io/ooxml/parse-drawing.ts +319 -0
- package/src/io/ooxml/parse-fields.ts +115 -2
- package/src/io/ooxml/parse-fill.ts +215 -0
- package/src/io/ooxml/parse-font-table.ts +190 -0
- package/src/io/ooxml/parse-footnotes.ts +52 -1
- package/src/io/ooxml/parse-main-document.ts +241 -1
- package/src/io/ooxml/parse-numbering.ts +96 -0
- package/src/io/ooxml/parse-picture.ts +107 -0
- package/src/io/ooxml/parse-settings.ts +34 -0
- package/src/io/ooxml/parse-shapes.ts +87 -0
- package/src/io/ooxml/parse-solid-fill.ts +11 -0
- package/src/io/ooxml/parse-styles.ts +74 -1
- package/src/io/ooxml/parse-theme.ts +60 -0
- package/src/io/paste/html-clipboard.ts +449 -0
- package/src/io/paste/word-clipboard.ts +5 -1
- package/src/legal/_document-root.ts +26 -0
- package/src/legal/bookmarks.ts +4 -3
- package/src/legal/cross-references.ts +3 -2
- package/src/legal/defined-terms.ts +2 -1
- package/src/legal/signature-blocks.ts +2 -1
- package/src/model/canonical-document.ts +415 -3
- package/src/runtime/chart/chart-model-store.ts +73 -10
- package/src/runtime/document-runtime.ts +693 -41
- package/src/runtime/edit-ops/index.ts +129 -0
- package/src/runtime/event-refresh-hints.ts +7 -0
- package/src/runtime/field-resolver.ts +341 -0
- package/src/runtime/footnote-resolver.ts +55 -0
- package/src/runtime/hyperlink-color-resolver.ts +13 -10
- package/src/runtime/object-grab/index.ts +51 -0
- package/src/runtime/paragraph-style-resolver.ts +105 -0
- package/src/runtime/resolved-numbering-geometry.ts +12 -0
- package/src/runtime/selection/cursor-ops.ts +186 -15
- package/src/runtime/selection/index.ts +17 -1
- package/src/runtime/structure-ops/index.ts +77 -0
- package/src/runtime/styles-cascade.ts +33 -0
- package/src/runtime/surface-projection.ts +186 -12
- package/src/runtime/theme-color-resolver.ts +189 -44
- package/src/runtime/units.ts +46 -0
- package/src/runtime/view-state.ts +13 -2
- package/src/ui/WordReviewEditor.tsx +168 -10
- package/src/ui/editor-runtime-boundary.ts +94 -1
- package/src/ui/editor-shell-view.tsx +1 -1
- package/src/ui/runtime-shortcut-dispatch.ts +17 -3
- package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
- package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
- package/src/ui-tailwind/chart/render/area.tsx +22 -4
- package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
- package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
- package/src/ui-tailwind/chart/render/combo.tsx +37 -4
- package/src/ui-tailwind/chart/render/line.tsx +28 -5
- package/src/ui-tailwind/chart/render/pie.tsx +36 -16
- package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
- package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
- package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
- package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
- package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
- package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
- package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
- package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
- package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
- package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
- package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
- package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
- package/src/ui-tailwind/editor-surface/pm-schema.ts +188 -11
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -2
- package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
- package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
- package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
- package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
- package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
- package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
- package/src/ui-tailwind/theme/editor-theme.css +1 -0
- package/src/ui-tailwind/theme/tokens.css +6 -0
- package/src/ui-tailwind/theme/tokens.ts +10 -0
- package/src/validation/compatibility-engine.ts +2 -0
- package/src/validation/docx-comment-proof.ts +12 -3
|
@@ -44,10 +44,15 @@ function ScatterChartImpl({ model, layout, theme }: ScatterChartProps): React.Re
|
|
|
44
44
|
const totalPoints = Math.max(0, ...model.series.map(s => s.xValues.length));
|
|
45
45
|
const visiblePoints = useProgressiveCount(totalPoints);
|
|
46
46
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
const reverseX = model.xAxis.reverse === true;
|
|
48
|
+
const reverseY = model.yAxis.reverse === true;
|
|
49
|
+
const toPoint = (x: number, y: number): Point => {
|
|
50
|
+
const xFrac = (x - xMin) / xSpan;
|
|
51
|
+
const yFrac = (y - yMin) / ySpan;
|
|
52
|
+
const px = reverseX ? plot.x + plot.w - xFrac * plot.w : plot.x + xFrac * plot.w;
|
|
53
|
+
const py = reverseY ? plot.y + yFrac * plot.h : plot.y + plot.h - yFrac * plot.h;
|
|
54
|
+
return [px, py];
|
|
55
|
+
};
|
|
51
56
|
|
|
52
57
|
const style = model.style;
|
|
53
58
|
const showLine = style === "line" || style === "lineMarker" || style === "smooth" || style === "smoothMarker";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives up to two initials from a display name in a Unicode-safe way.
|
|
3
|
+
* Splits on whitespace, takes the first character of the first two words.
|
|
4
|
+
* Falls back to the first grapheme cluster for single-word names.
|
|
5
|
+
*/
|
|
6
|
+
export function getInitials(name: string): string {
|
|
7
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
8
|
+
if (words.length === 0) return "";
|
|
9
|
+
if (words.length === 1) {
|
|
10
|
+
return (words[0].at(0) ?? "").toLocaleUpperCase();
|
|
11
|
+
}
|
|
12
|
+
const first = words[0].at(0) ?? "";
|
|
13
|
+
const second = words[1].at(0) ?? "";
|
|
14
|
+
return (first + second).toLocaleUpperCase();
|
|
15
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
|
|
3
|
+
import { getInitials } from "./avatar-initials";
|
|
4
|
+
|
|
3
5
|
export interface TwCommentPreviewProps {
|
|
4
6
|
author: {
|
|
5
7
|
name: string;
|
|
@@ -19,7 +21,7 @@ const focusRingClass =
|
|
|
19
21
|
|
|
20
22
|
export function TwCommentPreview(props: TwCommentPreviewProps): React.JSX.Element {
|
|
21
23
|
const initials =
|
|
22
|
-
props.author.initials ?? props.author.name
|
|
24
|
+
props.author.initials ?? getInitials(props.author.name);
|
|
23
25
|
|
|
24
26
|
const containerClass = [
|
|
25
27
|
"flex flex-col gap-1.5",
|
|
@@ -90,6 +90,11 @@ export function filterContextMenuEntries(
|
|
|
90
90
|
if (ctx.tableToolbarVisible) {
|
|
91
91
|
suppressedGroups.add("table");
|
|
92
92
|
}
|
|
93
|
+
if (ctx.commentCardVisible) {
|
|
94
|
+
suppressedGroups.add("comment");
|
|
95
|
+
}
|
|
96
|
+
// "clipboard" is intentionally never suppressed — Cut/Copy/Paste remain
|
|
97
|
+
// available as a fallback regardless of which floating chrome is visible.
|
|
93
98
|
|
|
94
99
|
const filtered = entries.filter((e) => {
|
|
95
100
|
if (e.kind === "separator") return true;
|
|
@@ -128,6 +133,13 @@ export interface TwContextMenuProps {
|
|
|
128
133
|
entries: ContextMenuEntry[];
|
|
129
134
|
context?: Partial<ContextMenuContext>;
|
|
130
135
|
platform?: "mac" | "win";
|
|
136
|
+
/**
|
|
137
|
+
* Accessible label for the menu container. WCAG 2.5.3 requires
|
|
138
|
+
* `role="menu"` to have an accessible name. Defaults to
|
|
139
|
+
* "Editor context menu"; hosts can override for domain context
|
|
140
|
+
* (e.g. "Review menu", "Table menu").
|
|
141
|
+
*/
|
|
142
|
+
"aria-label"?: string;
|
|
131
143
|
"data-testid"?: string;
|
|
132
144
|
}
|
|
133
145
|
|
|
@@ -143,6 +155,7 @@ export function TwContextMenu(props: TwContextMenuProps): React.JSX.Element {
|
|
|
143
155
|
entries,
|
|
144
156
|
context,
|
|
145
157
|
platform,
|
|
158
|
+
"aria-label": ariaLabel = "Editor context menu",
|
|
146
159
|
"data-testid": testId = "tw-context-menu",
|
|
147
160
|
} = props;
|
|
148
161
|
|
|
@@ -153,6 +166,7 @@ export function TwContextMenu(props: TwContextMenuProps): React.JSX.Element {
|
|
|
153
166
|
<div
|
|
154
167
|
data-testid={testId}
|
|
155
168
|
role="menu"
|
|
169
|
+
aria-label={ariaLabel}
|
|
156
170
|
className={[
|
|
157
171
|
"flex flex-col",
|
|
158
172
|
"rounded-[var(--radius-lg)]",
|
|
@@ -107,6 +107,7 @@ function useDwellDensity(tool: ActiveSelectionToolModel | null): "micro" | "full
|
|
|
107
107
|
|
|
108
108
|
export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
|
|
109
109
|
const density = useDwellDensity(props.tool);
|
|
110
|
+
const { onChromePinChange } = props;
|
|
110
111
|
|
|
111
112
|
if (!props.tool) {
|
|
112
113
|
return null;
|
|
@@ -124,9 +125,9 @@ export function TwSelectionToolHost(props: TwSelectionToolHostProps) {
|
|
|
124
125
|
|
|
125
126
|
const handlePinChange = useCallback(
|
|
126
127
|
(surface: ChromePinSurface, next: PinState | null) => {
|
|
127
|
-
|
|
128
|
+
onChromePinChange?.(surface, next);
|
|
128
129
|
},
|
|
129
|
-
[
|
|
130
|
+
[onChromePinChange],
|
|
130
131
|
);
|
|
131
132
|
|
|
132
133
|
const overlayTestId = getOverlayTestId(props.tool.kind, Boolean(props.placement));
|
|
@@ -5,6 +5,7 @@ import { Baseline, Bold, Highlighter, Italic, MessageSquare, Underline } from "l
|
|
|
5
5
|
|
|
6
6
|
import type { SelectionToolbarModel } from "../../ui/headless/selection-toolbar-model";
|
|
7
7
|
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
8
|
+
import { BRAND_TOKENS } from "../theme/tokens";
|
|
8
9
|
|
|
9
10
|
export interface TwSelectionToolbarProps {
|
|
10
11
|
model: SelectionToolbarModel;
|
|
@@ -24,13 +25,24 @@ const focusRingClass =
|
|
|
24
25
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
|
-
* Fallback
|
|
28
|
-
* The model
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* Fallback highlight color for the selection-toolbar "apply highlight"
|
|
29
|
+
* button. The model overrides via `highlightColorDefault` which R2.5
|
|
30
|
+
* plumbs through from `formattingState` so the apply button reflects
|
|
31
|
+
* the user's recent highlight pick.
|
|
32
|
+
*
|
|
33
|
+
* Drawn from the design-token system (`color.highlight.default`): user-
|
|
34
|
+
* driven content color, stable across themes (so highlights persist
|
|
35
|
+
* when the document is re-opened under a different theme).
|
|
31
36
|
*/
|
|
32
|
-
const
|
|
33
|
-
|
|
37
|
+
const DEFAULT_HIGHLIGHT_COLOR = BRAND_TOKENS.color.highlight.default;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* No static default text color — the "apply text color" button waits
|
|
41
|
+
* for the user's first color pick (via `model.textColorDefault`). Until
|
|
42
|
+
* then the button is disabled. This avoids shipping an arbitrary hex
|
|
43
|
+
* fallback and keeps the toolbar theme-neutral.
|
|
44
|
+
*/
|
|
45
|
+
const DEFAULT_TEXT_COLOR: string | null = null;
|
|
34
46
|
|
|
35
47
|
export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarProps>(function TwSelectionToolbar(props, ref) {
|
|
36
48
|
const { model } = props;
|
|
@@ -84,10 +96,17 @@ export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarP
|
|
|
84
96
|
<>
|
|
85
97
|
<ToolbarActionButton
|
|
86
98
|
icon={<Baseline className="h-3.5 w-3.5" />}
|
|
87
|
-
label={
|
|
99
|
+
label={
|
|
100
|
+
model.textColorDefault
|
|
101
|
+
? `Apply ${model.textColorDefault}`
|
|
102
|
+
: "Apply text color (pick a color first)"
|
|
103
|
+
}
|
|
88
104
|
pressed={false}
|
|
89
|
-
disabled={formattingDisabled}
|
|
90
|
-
onClick={() =>
|
|
105
|
+
disabled={formattingDisabled || !model.textColorDefault}
|
|
106
|
+
onClick={() => {
|
|
107
|
+
const color = model.textColorDefault ?? DEFAULT_TEXT_COLOR;
|
|
108
|
+
if (color) props.onSetTextColor?.(color);
|
|
109
|
+
}}
|
|
91
110
|
/>
|
|
92
111
|
<ToolbarActionButton
|
|
93
112
|
icon={<Highlighter className="h-3.5 w-3.5" />}
|
|
@@ -117,7 +136,7 @@ export const TwSelectionToolbar = forwardRef<HTMLDivElement, TwSelectionToolbarP
|
|
|
117
136
|
</Tooltip.Trigger>
|
|
118
137
|
<Tooltip.Portal>
|
|
119
138
|
<Tooltip.Content
|
|
120
|
-
className="rounded-md bg-primary px-2 py-1 text-xs text-
|
|
139
|
+
className="rounded-md bg-primary px-2 py-1 text-xs text-[var(--color-text-inverse)] shadow-md z-50"
|
|
121
140
|
sideOffset={6}
|
|
122
141
|
>
|
|
123
142
|
{tooltipLabel}
|
|
@@ -198,7 +217,7 @@ function ToolbarActionButton(props: ToolbarActionButtonProps) {
|
|
|
198
217
|
</Tooltip.Trigger>
|
|
199
218
|
<Tooltip.Portal>
|
|
200
219
|
<Tooltip.Content
|
|
201
|
-
className="rounded-md bg-primary px-2 py-1 text-xs text-
|
|
220
|
+
className="rounded-md bg-primary px-2 py-1 text-xs text-[var(--color-text-inverse)] shadow-md z-50"
|
|
202
221
|
sideOffset={6}
|
|
203
222
|
>
|
|
204
223
|
{props.label}
|
|
@@ -23,12 +23,25 @@ const MAC_SYMBOL: Record<string, string> = {
|
|
|
23
23
|
|
|
24
24
|
function detectPlatform(): "mac" | "win" {
|
|
25
25
|
if (typeof navigator === "undefined") return "win";
|
|
26
|
+
// Prefer the modern userAgentData.platform API (avoids navigator.platform deprecation warning).
|
|
27
|
+
const uaData = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData;
|
|
28
|
+
if (uaData?.platform) return /mac|iPhone|iPad/i.test(uaData.platform) ? "mac" : "win";
|
|
26
29
|
return /Mac|iPhone|iPad/.test(navigator.platform) ? "mac" : "win";
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export function TwShortcutHint(props: TwShortcutHintProps): React.JSX.Element {
|
|
30
33
|
const { keys, className } = props;
|
|
31
|
-
|
|
34
|
+
|
|
35
|
+
// SSR-safe platform detection: render with the prop value (or "win" default)
|
|
36
|
+
// on the server, then update after hydration when navigator is available.
|
|
37
|
+
const [platform, setPlatform] = React.useState<"mac" | "win">(
|
|
38
|
+
props.platform ?? "win",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
if (props.platform) return; // explicit prop wins; no client detection needed
|
|
43
|
+
setPlatform(detectPlatform());
|
|
44
|
+
}, [props.platform]);
|
|
32
45
|
|
|
33
46
|
const mapped = keys.map((k) =>
|
|
34
47
|
platform === "mac"
|
|
@@ -56,7 +69,7 @@ export function TwShortcutHint(props: TwShortcutHintProps): React.JSX.Element {
|
|
|
56
69
|
<span className={containerClass} data-testid="tw-shortcut-hint">
|
|
57
70
|
{mapped.map((label, i) => (
|
|
58
71
|
<kbd
|
|
59
|
-
key={i}
|
|
72
|
+
key={`${label}-${i}`}
|
|
60
73
|
className={chipClass}
|
|
61
74
|
data-testid="tw-shortcut-hint__chip"
|
|
62
75
|
>
|
|
@@ -89,7 +89,7 @@ export function TwSuggestionCard(props: TwSuggestionCardProps) {
|
|
|
89
89
|
</Tooltip.Trigger>
|
|
90
90
|
<Tooltip.Portal>
|
|
91
91
|
<Tooltip.Content
|
|
92
|
-
className="rounded-md bg-primary px-2 py-1 text-xs text-
|
|
92
|
+
className="rounded-md bg-primary px-2 py-1 text-xs text-[var(--color-text-inverse)] shadow-md z-50"
|
|
93
93
|
sideOffset={6}
|
|
94
94
|
>
|
|
95
95
|
{tooltipLabel}
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
TableStructureContextSnapshot,
|
|
6
6
|
} from "../../api/public-types";
|
|
7
7
|
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
8
|
+
import { BRAND_TOKENS } from "../theme/tokens";
|
|
8
9
|
|
|
9
10
|
export interface TwTableContextToolbarProps {
|
|
10
11
|
disabled: boolean;
|
|
@@ -29,13 +30,29 @@ export interface TwTableContextToolbarProps {
|
|
|
29
30
|
onSetCellVerticalAlign?: (align: "top" | "center" | "bottom") => void;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Cell fill presets — user-driven content colors, NOT design-system
|
|
35
|
+
* tokens. Cell fills are document content and must persist across
|
|
36
|
+
* themes exactly as authored, so the palette is a stable component-
|
|
37
|
+
* local constant rather than a theme-adaptive token binding.
|
|
38
|
+
*
|
|
39
|
+
* Slot 1 is pure white (opaque "overwrite" content), slot 2 is
|
|
40
|
+
* "transparent" (no fill — clear existing). The remaining four slots
|
|
41
|
+
* draw from the scope-tint palette so the starter options stay in a
|
|
42
|
+
* consistent soft-pastel family that reads well on both light and dark
|
|
43
|
+
* canvases when reopened.
|
|
44
|
+
*
|
|
45
|
+
* Hosts may extend this palette via future props; R3.c replaced the
|
|
46
|
+
* earlier arbitrary Tailwind-palette picks with values drawn from the
|
|
47
|
+
* token manifest (scope-tint family).
|
|
48
|
+
*/
|
|
49
|
+
const CELL_FILL_PRESETS = [
|
|
33
50
|
"#ffffff",
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
51
|
+
"transparent",
|
|
52
|
+
BRAND_TOKENS.color.scopeTint.inScope,
|
|
53
|
+
BRAND_TOKENS.color.scopeTint.scheduled,
|
|
54
|
+
BRAND_TOKENS.color.scopeTint.suggest,
|
|
55
|
+
BRAND_TOKENS.color.scopeTint.comment,
|
|
39
56
|
] as const;
|
|
40
57
|
|
|
41
58
|
/**
|
|
@@ -298,7 +315,7 @@ export function TwTableContextToolbar(props: TwTableContextToolbarProps) {
|
|
|
298
315
|
{tier !== "caret-in-cell" ? (
|
|
299
316
|
<ToolbarSection label="Fill">
|
|
300
317
|
<div className="flex items-center gap-1">
|
|
301
|
-
{
|
|
318
|
+
{CELL_FILL_PRESETS.map((color) => (
|
|
302
319
|
<button
|
|
303
320
|
key={color}
|
|
304
321
|
type="button"
|
|
@@ -102,11 +102,19 @@ export function TwTableGripLayer({
|
|
|
102
102
|
const rect = frame.anchorIndex.byTableRowEdge(blockId, rowIndex);
|
|
103
103
|
if (!rect) return null;
|
|
104
104
|
const pos = projectRectToOverlay(rect, space);
|
|
105
|
+
// Current row height in twips is derived from the first cell's rect.
|
|
106
|
+
// Without this, mid-drag arithmetic collapses rows to MIN_ROW_TWIPS
|
|
107
|
+
// on any upward (negative-delta) drag.
|
|
108
|
+
const cellRect = frame.anchorIndex.byTableCell(blockId, rowIndex, 0);
|
|
109
|
+
const originalTwips = cellRect
|
|
110
|
+
? Math.round(cellRect.heightPx / pxPerTwip)
|
|
111
|
+
: MIN_ROW_TWIPS;
|
|
105
112
|
return (
|
|
106
113
|
<RowResizeGrip
|
|
107
114
|
key={`row-${blockId}-${rowIndex}`}
|
|
108
115
|
pos={pos}
|
|
109
116
|
rowIndex={rowIndex}
|
|
117
|
+
originalTwips={originalTwips}
|
|
110
118
|
pxPerTwip={pxPerTwip}
|
|
111
119
|
disabled={!!disabled || !onSetRowHeight}
|
|
112
120
|
onCommit={onSetRowHeight}
|
|
@@ -143,6 +151,8 @@ function ColResizeGrip({
|
|
|
143
151
|
originalTwips: number;
|
|
144
152
|
dragStarted: boolean;
|
|
145
153
|
gripEl: HTMLElement;
|
|
154
|
+
// Captured at mousedown so re-renders can't swap out onCommit mid-drag.
|
|
155
|
+
onCommit: ColResizeGripProps["onCommit"];
|
|
146
156
|
} | null>(null);
|
|
147
157
|
const [isActive, setIsActive] = useState(false);
|
|
148
158
|
|
|
@@ -154,9 +164,10 @@ function ColResizeGrip({
|
|
|
154
164
|
originalTwips,
|
|
155
165
|
dragStarted: false,
|
|
156
166
|
gripEl: e.currentTarget,
|
|
167
|
+
onCommit,
|
|
157
168
|
};
|
|
158
169
|
},
|
|
159
|
-
[disabled, originalTwips],
|
|
170
|
+
[disabled, originalTwips, onCommit],
|
|
160
171
|
);
|
|
161
172
|
|
|
162
173
|
useEffect(() => {
|
|
@@ -185,7 +196,7 @@ function ColResizeGrip({
|
|
|
185
196
|
MIN_COLUMN_TWIPS,
|
|
186
197
|
Math.round(drag.originalTwips + deltaTwips),
|
|
187
198
|
);
|
|
188
|
-
onCommit?.(colIndex, newTwips);
|
|
199
|
+
drag.onCommit?.(colIndex, newTwips);
|
|
189
200
|
};
|
|
190
201
|
window.addEventListener("mousemove", handleMove);
|
|
191
202
|
window.addEventListener("mouseup", handleUp);
|
|
@@ -193,7 +204,7 @@ function ColResizeGrip({
|
|
|
193
204
|
window.removeEventListener("mousemove", handleMove);
|
|
194
205
|
window.removeEventListener("mouseup", handleUp);
|
|
195
206
|
};
|
|
196
|
-
}, [colIndex, pxPerTwip
|
|
207
|
+
}, [colIndex, pxPerTwip]);
|
|
197
208
|
|
|
198
209
|
return (
|
|
199
210
|
<div
|
|
@@ -226,6 +237,7 @@ function ColResizeGrip({
|
|
|
226
237
|
interface RowResizeGripProps {
|
|
227
238
|
pos: { left: string; top: string; width: string; height: string };
|
|
228
239
|
rowIndex: number;
|
|
240
|
+
originalTwips: number;
|
|
229
241
|
pxPerTwip: number;
|
|
230
242
|
disabled: boolean;
|
|
231
243
|
onCommit?: (
|
|
@@ -238,14 +250,18 @@ interface RowResizeGripProps {
|
|
|
238
250
|
function RowResizeGrip({
|
|
239
251
|
pos,
|
|
240
252
|
rowIndex,
|
|
253
|
+
originalTwips,
|
|
241
254
|
pxPerTwip,
|
|
242
255
|
disabled,
|
|
243
256
|
onCommit,
|
|
244
257
|
}: RowResizeGripProps) {
|
|
245
258
|
const dragRef = useRef<{
|
|
246
259
|
startY: number;
|
|
260
|
+
originalTwips: number;
|
|
247
261
|
dragStarted: boolean;
|
|
248
262
|
gripEl: HTMLElement;
|
|
263
|
+
// Captured at mousedown so re-renders can't swap out onCommit mid-drag.
|
|
264
|
+
onCommit: RowResizeGripProps["onCommit"];
|
|
249
265
|
} | null>(null);
|
|
250
266
|
const [isActive, setIsActive] = useState(false);
|
|
251
267
|
|
|
@@ -254,11 +270,13 @@ function RowResizeGrip({
|
|
|
254
270
|
if (disabled) return;
|
|
255
271
|
dragRef.current = {
|
|
256
272
|
startY: e.clientY,
|
|
273
|
+
originalTwips,
|
|
257
274
|
dragStarted: false,
|
|
258
275
|
gripEl: e.currentTarget,
|
|
276
|
+
onCommit,
|
|
259
277
|
};
|
|
260
278
|
},
|
|
261
|
-
[disabled],
|
|
279
|
+
[disabled, originalTwips, onCommit],
|
|
262
280
|
);
|
|
263
281
|
|
|
264
282
|
useEffect(() => {
|
|
@@ -282,13 +300,14 @@ function RowResizeGrip({
|
|
|
282
300
|
forwardNonDragClick(drag.gripEl, e);
|
|
283
301
|
return;
|
|
284
302
|
}
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const rule
|
|
291
|
-
|
|
303
|
+
const deltaTwips = (e.clientY - drag.startY) / pxPerTwip;
|
|
304
|
+
const newTwips = Math.max(
|
|
305
|
+
MIN_ROW_TWIPS,
|
|
306
|
+
Math.round(drag.originalTwips + deltaTwips),
|
|
307
|
+
);
|
|
308
|
+
const rule: "atLeast" | "auto" =
|
|
309
|
+
newTwips > MIN_ROW_TWIPS ? "atLeast" : "auto";
|
|
310
|
+
drag.onCommit?.(rowIndex, newTwips, rule);
|
|
292
311
|
};
|
|
293
312
|
window.addEventListener("mousemove", handleMove);
|
|
294
313
|
window.addEventListener("mouseup", handleUp);
|
|
@@ -296,7 +315,7 @@ function RowResizeGrip({
|
|
|
296
315
|
window.removeEventListener("mousemove", handleMove);
|
|
297
316
|
window.removeEventListener("mouseup", handleUp);
|
|
298
317
|
};
|
|
299
|
-
}, [rowIndex, pxPerTwip
|
|
318
|
+
}, [rowIndex, pxPerTwip]);
|
|
300
319
|
|
|
301
320
|
return (
|
|
302
321
|
<div
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane 6d — Slice N5: pure resolvers for page borders + column separators.
|
|
3
|
+
*
|
|
4
|
+
* Page-border data is parsed from OOXML `<w:pgBorders>` and surfaced on
|
|
5
|
+
* `PageLayoutSnapshot.pageBorders`. Units follow OOXML conventions:
|
|
6
|
+
*
|
|
7
|
+
* - `size` → border width in **eighths of a point** (`w:sz`).
|
|
8
|
+
* - `space` → spacing from page edge in **points** (`w:space`).
|
|
9
|
+
* - `value` → OOXML border-style name (`single`, `double`, …).
|
|
10
|
+
*
|
|
11
|
+
* 1 pt = 96/72 px at 96 dpi → these helpers convert into px so the
|
|
12
|
+
* overlay can render inline `style="…"` declarations.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PageLayoutSnapshot } from "../../api/public-types";
|
|
16
|
+
|
|
17
|
+
const PX_PER_PT = 96 / 72;
|
|
18
|
+
const PX_PER_EIGHTH_PT = PX_PER_PT / 8;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Convert an OOXML `w:sz` (eighths of a point) into pixels.
|
|
22
|
+
*/
|
|
23
|
+
export function borderSizeToPx(sizeEighthsPt: number | undefined): number {
|
|
24
|
+
if (!sizeEighthsPt || sizeEighthsPt <= 0) return 0;
|
|
25
|
+
return sizeEighthsPt * PX_PER_EIGHTH_PT;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Convert an OOXML `w:space` (points) into pixels.
|
|
30
|
+
*/
|
|
31
|
+
export function borderSpaceToPx(spacePt: number | undefined): number {
|
|
32
|
+
if (!spacePt || spacePt <= 0) return 0;
|
|
33
|
+
return spacePt * PX_PER_PT;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Map OOXML border-style names onto the closest CSS `border-style`.
|
|
38
|
+
* Unknown / fancy variants (`wave`, `dotDash`, `triple`, …) collapse to
|
|
39
|
+
* `solid` so the page still gets a visible edge — matching Word's
|
|
40
|
+
* fallback behavior in non-Word renderers.
|
|
41
|
+
*/
|
|
42
|
+
export function borderValueToCss(value: string | undefined): string {
|
|
43
|
+
if (!value) return "solid";
|
|
44
|
+
switch (value) {
|
|
45
|
+
case "none":
|
|
46
|
+
case "nil":
|
|
47
|
+
return "none";
|
|
48
|
+
case "single":
|
|
49
|
+
case "thick":
|
|
50
|
+
case "thinThickSmallGap":
|
|
51
|
+
case "thickThinSmallGap":
|
|
52
|
+
case "thinThickThinSmallGap":
|
|
53
|
+
case "thinThickMediumGap":
|
|
54
|
+
case "thickThinMediumGap":
|
|
55
|
+
case "thinThickThinMediumGap":
|
|
56
|
+
case "thinThickLargeGap":
|
|
57
|
+
case "thickThinLargeGap":
|
|
58
|
+
case "thinThickThinLargeGap":
|
|
59
|
+
return "solid";
|
|
60
|
+
case "double":
|
|
61
|
+
return "double";
|
|
62
|
+
case "dotted":
|
|
63
|
+
case "dottedHeavy":
|
|
64
|
+
return "dotted";
|
|
65
|
+
case "dashed":
|
|
66
|
+
case "dashedHeavy":
|
|
67
|
+
case "dashSmallGap":
|
|
68
|
+
case "dashDotStroked":
|
|
69
|
+
return "dashed";
|
|
70
|
+
default:
|
|
71
|
+
return "solid";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PageBorderDecisionInput {
|
|
76
|
+
/** 0-based index of the page within its owning section. */
|
|
77
|
+
pageInSection: number;
|
|
78
|
+
/** Total page count in the section (informational; reserved for future rules). */
|
|
79
|
+
pageCountInSection?: number;
|
|
80
|
+
/** Display policy from `pageBorders.display`; missing → `allPages`. */
|
|
81
|
+
display?: "allPages" | "firstPage" | "notFirstPage";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Apply Word's `display` policy to decide whether the border paints on a
|
|
86
|
+
* given page. `allPages` (or undefined) → always; `firstPage` → only
|
|
87
|
+
* page 0 in its section; `notFirstPage` → every page except page 0.
|
|
88
|
+
*/
|
|
89
|
+
export function shouldDisplayPageBorderOnPage(
|
|
90
|
+
input: PageBorderDecisionInput,
|
|
91
|
+
): boolean {
|
|
92
|
+
const policy = input.display ?? "allPages";
|
|
93
|
+
switch (policy) {
|
|
94
|
+
case "allPages":
|
|
95
|
+
return true;
|
|
96
|
+
case "firstPage":
|
|
97
|
+
return input.pageInSection === 0;
|
|
98
|
+
case "notFirstPage":
|
|
99
|
+
return input.pageInSection > 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ResolvedBorderEdge {
|
|
104
|
+
/** CSS `border-{edge}-style` value. */
|
|
105
|
+
style: string;
|
|
106
|
+
/** Width in pixels (already converted from `w:sz` units). */
|
|
107
|
+
widthPx: number;
|
|
108
|
+
/** Color string ready for inline style; defaults to `currentColor` when no color set. */
|
|
109
|
+
color: string;
|
|
110
|
+
/** Distance from the page (or text margin) edge, in pixels. */
|
|
111
|
+
spacePx: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a single border-spec into render-ready CSS values. Returns
|
|
116
|
+
* `null` when the spec is `none`/`nil` or has zero width — the caller
|
|
117
|
+
* should skip rendering that edge.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveBorderEdge(
|
|
120
|
+
spec: { value?: string; size?: number; space?: number; color?: string } | undefined,
|
|
121
|
+
): ResolvedBorderEdge | null {
|
|
122
|
+
if (!spec) return null;
|
|
123
|
+
const style = borderValueToCss(spec.value);
|
|
124
|
+
if (style === "none") return null;
|
|
125
|
+
const widthPx = borderSizeToPx(spec.size);
|
|
126
|
+
if (widthPx <= 0) return null;
|
|
127
|
+
return {
|
|
128
|
+
style,
|
|
129
|
+
widthPx,
|
|
130
|
+
color: spec.color && spec.color !== "auto" ? `#${spec.color}` : "currentColor",
|
|
131
|
+
spacePx: borderSpaceToPx(spec.space),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ResolvedPageBorders {
|
|
136
|
+
/** When `false`, the overlay should render nothing for this page. */
|
|
137
|
+
display: boolean;
|
|
138
|
+
/** Distance anchor — page edge or text-margin edge. */
|
|
139
|
+
offsetFrom: "page" | "text";
|
|
140
|
+
/** zOrder — `back` paints below content, `front` above (overlay default). */
|
|
141
|
+
zOrder: "front" | "back";
|
|
142
|
+
/** Per-edge resolved borders; missing keys mean "do not render this edge". */
|
|
143
|
+
top: ResolvedBorderEdge | null;
|
|
144
|
+
right: ResolvedBorderEdge | null;
|
|
145
|
+
bottom: ResolvedBorderEdge | null;
|
|
146
|
+
left: ResolvedBorderEdge | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Top-level resolver. Combines display-policy gating with per-edge
|
|
151
|
+
* conversion. Returns `null` when the section has no `pageBorders` at
|
|
152
|
+
* all so the overlay can short-circuit.
|
|
153
|
+
*/
|
|
154
|
+
export function resolvePageBorders(
|
|
155
|
+
layout: PageLayoutSnapshot | undefined,
|
|
156
|
+
pageInSection: number,
|
|
157
|
+
): ResolvedPageBorders | null {
|
|
158
|
+
const pageBorders = layout?.pageBorders;
|
|
159
|
+
if (!pageBorders) return null;
|
|
160
|
+
|
|
161
|
+
const display = shouldDisplayPageBorderOnPage({
|
|
162
|
+
pageInSection,
|
|
163
|
+
display: pageBorders.display,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
display,
|
|
168
|
+
offsetFrom: pageBorders.offsetFrom ?? "page",
|
|
169
|
+
zOrder: pageBorders.zOrder ?? "front",
|
|
170
|
+
top: resolveBorderEdge(pageBorders.top),
|
|
171
|
+
right: resolveBorderEdge(pageBorders.right),
|
|
172
|
+
bottom: resolveBorderEdge(pageBorders.bottom),
|
|
173
|
+
left: resolveBorderEdge(pageBorders.left),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Column separators (P11.7)
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Compute pixel x-offsets (relative to the content box) where vertical
|
|
183
|
+
* column-separator lines should paint. Separators sit centered in the
|
|
184
|
+
* gap between adjacent columns.
|
|
185
|
+
*
|
|
186
|
+
* Returns `[]` when separators are disabled, columns are equal-width
|
|
187
|
+
* (Word draws no separator there unless explicitly requested), or the
|
|
188
|
+
* snapshot has fewer than 2 columns.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveColumnSeparatorXOffsets(
|
|
191
|
+
layout: PageLayoutSnapshot | undefined,
|
|
192
|
+
): number[] {
|
|
193
|
+
if (!layout?.columnSeparator) return [];
|
|
194
|
+
const cols = layout.columnDefinitions ?? [];
|
|
195
|
+
if (cols.length < 2) return [];
|
|
196
|
+
|
|
197
|
+
// OOXML column widths + spaces are in twips; 1 px ≈ 15 twips at 96 dpi.
|
|
198
|
+
const TWIPS_PER_PX = 1440 / 96;
|
|
199
|
+
const offsets: number[] = [];
|
|
200
|
+
let cursorPx = 0;
|
|
201
|
+
for (let i = 0; i < cols.length - 1; i += 1) {
|
|
202
|
+
const col = cols[i]!;
|
|
203
|
+
const widthPx = col.width / TWIPS_PER_PX;
|
|
204
|
+
const spacePx = (col.space ?? 0) / TWIPS_PER_PX;
|
|
205
|
+
cursorPx += widthPx;
|
|
206
|
+
// Separator centers in the gap between column[i] and column[i+1].
|
|
207
|
+
offsets.push(cursorPx + spacePx / 2);
|
|
208
|
+
cursorPx += spacePx;
|
|
209
|
+
}
|
|
210
|
+
return offsets;
|
|
211
|
+
}
|
|
@@ -209,6 +209,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
209
209
|
facet={facet}
|
|
210
210
|
scrollRoot={pageStackScrollRoot}
|
|
211
211
|
renderFrameRevision={renderFrameRevision ?? 0}
|
|
212
|
+
visiblePageIndexRange={visiblePageIndexRange ?? null}
|
|
212
213
|
/>
|
|
213
214
|
) : null}
|
|
214
215
|
{pageStackScrollRoot !== undefined ? (
|