@beyondwork/docx-react-component 1.0.56 → 1.0.58
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 +1 -1
- package/package.json +1 -1
- package/src/api/public-types.ts +330 -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 +158 -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 +421 -3
- package/src/runtime/chart/chart-model-store.ts +73 -10
- package/src/runtime/document-runtime.ts +760 -41
- package/src/runtime/document-search.ts +61 -0
- 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/query-scopes.ts +186 -0
- package/src/runtime/resolved-numbering-geometry.ts +12 -0
- package/src/runtime/scope-resolver.ts +60 -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 +192 -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 +239 -11
- package/src/ui/editor-runtime-boundary.ts +97 -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 +24 -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-object-selection-overlay.tsx +157 -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 +214 -11
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +32 -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/ui-tailwind/tw-review-workspace.tsx +23 -0
- package/src/validation/compatibility-engine.ts +2 -0
- package/src/validation/docx-comment-proof.ts +12 -3
|
@@ -28,6 +28,7 @@ import { TwScopeCardLayer } from "./tw-scope-card-layer";
|
|
|
28
28
|
import { TwPageStackOverlayLayer } from "./tw-page-stack-overlay-layer";
|
|
29
29
|
import { TwPageStackChromeLayer, type PmPortalView } from "../page-stack/tw-page-stack-chrome-layer";
|
|
30
30
|
import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
|
|
31
|
+
import { TwObjectSelectionOverlay } from "./tw-object-selection-overlay";
|
|
31
32
|
|
|
32
33
|
export interface TwChromeOverlayProps {
|
|
33
34
|
/** Layout facet the overlay layers read from. */
|
|
@@ -93,6 +94,16 @@ export interface TwChromeOverlayProps {
|
|
|
93
94
|
/** Optional extra children (e.g., future comment balloon layer). */
|
|
94
95
|
children?: React.ReactNode;
|
|
95
96
|
|
|
97
|
+
// Object selection overlay (N6) ----------------------------------------
|
|
98
|
+
/** R.3 — grabbed image/shape id, or null. When set, the selection overlay renders. */
|
|
99
|
+
grabbedObjectId?: string | null;
|
|
100
|
+
/** Document `from` offset of the grabbed segment (for anchor-index lookup). */
|
|
101
|
+
grabbedObjectFromOffset?: number | null;
|
|
102
|
+
/** Document `to` offset of the grabbed segment. */
|
|
103
|
+
grabbedObjectToOffset?: number | null;
|
|
104
|
+
/** Called when the user clicks outside the selection box to deselect. */
|
|
105
|
+
onDeselectObject?: () => void;
|
|
106
|
+
|
|
96
107
|
// Table grip props (P6) -----------------------------------------------
|
|
97
108
|
/** Active table context — when present, column/row resize grips are shown. */
|
|
98
109
|
tableContext?: TableStructureContextSnapshot | null;
|
|
@@ -187,6 +198,10 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
187
198
|
scopeCardScopeTagEditor,
|
|
188
199
|
"data-testid": testId,
|
|
189
200
|
children,
|
|
201
|
+
grabbedObjectId,
|
|
202
|
+
grabbedObjectFromOffset,
|
|
203
|
+
grabbedObjectToOffset,
|
|
204
|
+
onDeselectObject,
|
|
190
205
|
tableContext,
|
|
191
206
|
onSetColumnWidth,
|
|
192
207
|
onSetRowHeight,
|
|
@@ -209,6 +224,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
209
224
|
facet={facet}
|
|
210
225
|
scrollRoot={pageStackScrollRoot}
|
|
211
226
|
renderFrameRevision={renderFrameRevision ?? 0}
|
|
227
|
+
visiblePageIndexRange={visiblePageIndexRange ?? null}
|
|
212
228
|
/>
|
|
213
229
|
) : null}
|
|
214
230
|
{pageStackScrollRoot !== undefined ? (
|
|
@@ -250,6 +266,14 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
250
266
|
onSetColumnWidth={onSetColumnWidth}
|
|
251
267
|
onSetRowHeight={onSetRowHeight}
|
|
252
268
|
/>
|
|
269
|
+
<TwObjectSelectionOverlay
|
|
270
|
+
grabbedObjectId={grabbedObjectId ?? null}
|
|
271
|
+
grabbedObjectFromOffset={grabbedObjectFromOffset ?? null}
|
|
272
|
+
grabbedObjectToOffset={grabbedObjectToOffset ?? null}
|
|
273
|
+
facet={facet}
|
|
274
|
+
space={space}
|
|
275
|
+
onDeselect={onDeselectObject}
|
|
276
|
+
/>
|
|
253
277
|
{children}
|
|
254
278
|
</div>
|
|
255
279
|
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { RenderBlockDecoration } from "../../runtime/render/render-frame-types";
|
|
3
|
+
import { TwCommentPreview } from "../chrome/tw-comment-preview";
|
|
4
|
+
|
|
5
|
+
const WIDE_BREAKPOINT_PX = 1024;
|
|
6
|
+
const CONNECTOR_GAP_PX = 16;
|
|
7
|
+
const BALLOON_MAX_WIDTH_PX = 240;
|
|
8
|
+
|
|
9
|
+
export interface CommentBalloonData {
|
|
10
|
+
commentId: string;
|
|
11
|
+
authorName: string;
|
|
12
|
+
authorInitials?: string;
|
|
13
|
+
authorAvatarUrl?: string;
|
|
14
|
+
timestamp?: string;
|
|
15
|
+
excerpt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TwCommentBalloonLayerProps {
|
|
19
|
+
/** Decoration rects from `frame.decorationIndex.comments`. */
|
|
20
|
+
commentDecorations: readonly RenderBlockDecoration[];
|
|
21
|
+
/** Pre-resolved comment data keyed by commentId. */
|
|
22
|
+
commentDataById: ReadonlyMap<string, CommentBalloonData>;
|
|
23
|
+
/** Viewport width — balloons only render >= WIDE_BREAKPOINT_PX. */
|
|
24
|
+
viewportWidthPx: number;
|
|
25
|
+
/** Right edge of the page frame in overlay coordinates (px). */
|
|
26
|
+
pageRightEdgePx: number;
|
|
27
|
+
onOpenThread?: (commentId: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const TwCommentBalloonLayer = React.memo(function TwCommentBalloonLayer({
|
|
31
|
+
commentDecorations,
|
|
32
|
+
commentDataById,
|
|
33
|
+
viewportWidthPx,
|
|
34
|
+
pageRightEdgePx,
|
|
35
|
+
onOpenThread,
|
|
36
|
+
}: TwCommentBalloonLayerProps) {
|
|
37
|
+
if (viewportWidthPx < WIDE_BREAKPOINT_PX) return null;
|
|
38
|
+
if (commentDecorations.length === 0) return null;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
{commentDecorations.map((dec) => {
|
|
43
|
+
const data = commentDataById.get(dec.refId);
|
|
44
|
+
if (!data) return null;
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
key={dec.refId}
|
|
48
|
+
style={{
|
|
49
|
+
position: "absolute",
|
|
50
|
+
top: dec.frame.topPx,
|
|
51
|
+
left: pageRightEdgePx + CONNECTOR_GAP_PX,
|
|
52
|
+
width: BALLOON_MAX_WIDTH_PX,
|
|
53
|
+
pointerEvents: "auto",
|
|
54
|
+
zIndex: 10,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<TwCommentPreview
|
|
58
|
+
author={{
|
|
59
|
+
name: data.authorName,
|
|
60
|
+
initials: data.authorInitials,
|
|
61
|
+
avatarUrl: data.authorAvatarUrl,
|
|
62
|
+
}}
|
|
63
|
+
timestamp={data.timestamp}
|
|
64
|
+
excerpt={data.excerpt}
|
|
65
|
+
onOpenThread={
|
|
66
|
+
onOpenThread ? () => onOpenThread(data.commentId) : undefined
|
|
67
|
+
}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Lock } from "lucide-react";
|
|
3
|
+
import type { RenderBlockDecoration } from "../../runtime/render/render-frame-types";
|
|
4
|
+
|
|
5
|
+
const OUTLINE_INSET_PX = 2;
|
|
6
|
+
const BADGE_SIZE_PX = 20;
|
|
7
|
+
|
|
8
|
+
export interface TwLockedBlockLayerProps {
|
|
9
|
+
/** Decoration rects from `frame.decorationIndex.locked`. */
|
|
10
|
+
lockedDecorations: readonly RenderBlockDecoration[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const TwLockedBlockLayer = React.memo(function TwLockedBlockLayer({
|
|
14
|
+
lockedDecorations,
|
|
15
|
+
}: TwLockedBlockLayerProps) {
|
|
16
|
+
if (lockedDecorations.length === 0) return null;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
{lockedDecorations.map((dec, idx) => {
|
|
21
|
+
const { leftPx, topPx, widthPx, heightPx } = dec.frame;
|
|
22
|
+
return (
|
|
23
|
+
<React.Fragment key={`locked-${dec.refId}-${idx}`}>
|
|
24
|
+
{/* Dashed outline around the locked block */}
|
|
25
|
+
<div
|
|
26
|
+
aria-hidden
|
|
27
|
+
style={{
|
|
28
|
+
position: "absolute",
|
|
29
|
+
top: topPx + OUTLINE_INSET_PX,
|
|
30
|
+
left: leftPx + OUTLINE_INSET_PX,
|
|
31
|
+
width: widthPx - OUTLINE_INSET_PX * 2,
|
|
32
|
+
height: heightPx - OUTLINE_INSET_PX * 2,
|
|
33
|
+
border: "1.5px dashed var(--color-border-caution, #e8a020)",
|
|
34
|
+
borderRadius: 2,
|
|
35
|
+
pointerEvents: "none",
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
{/* Lock badge in the top-right corner */}
|
|
39
|
+
<div
|
|
40
|
+
aria-label="Locked content"
|
|
41
|
+
role="img"
|
|
42
|
+
style={{
|
|
43
|
+
position: "absolute",
|
|
44
|
+
top: topPx - BADGE_SIZE_PX / 2,
|
|
45
|
+
left: leftPx + widthPx - BADGE_SIZE_PX / 2,
|
|
46
|
+
width: BADGE_SIZE_PX,
|
|
47
|
+
height: BADGE_SIZE_PX,
|
|
48
|
+
display: "flex",
|
|
49
|
+
alignItems: "center",
|
|
50
|
+
justifyContent: "center",
|
|
51
|
+
background: "var(--color-bg-surface, #fff)",
|
|
52
|
+
border: "1px solid var(--color-border-caution, #e8a020)",
|
|
53
|
+
borderRadius: "50%",
|
|
54
|
+
pointerEvents: "none",
|
|
55
|
+
zIndex: 1,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<Lock size={10} strokeWidth={2.5} />
|
|
59
|
+
</div>
|
|
60
|
+
</React.Fragment>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</>
|
|
64
|
+
);
|
|
65
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane 6d — N6 P11.1–P11.5: object selection chrome overlay.
|
|
3
|
+
*
|
|
4
|
+
* Renders a selection box with 8 resize handles and a rotate grip around the
|
|
5
|
+
* grabbed image or shape. Purely visual chrome — no resize/rotate mutations
|
|
6
|
+
* in this slice; handle interaction is deferred to the follow-up N6.b slice.
|
|
7
|
+
*
|
|
8
|
+
* Positioning: uses `RenderAnchorIndex.byRuntimeOffset(from)` + `bySelection`
|
|
9
|
+
* to compute the object rect in overlay-coordinate space, then paints over it.
|
|
10
|
+
*
|
|
11
|
+
* Dismissal: clicking outside the overlay calls `onDeselect()` which routes
|
|
12
|
+
* to `runtime.deselectObject()` in the workspace.
|
|
13
|
+
*
|
|
14
|
+
* v1 scope:
|
|
15
|
+
* - rect, ellipse, roundRect shapes and inline/floating images.
|
|
16
|
+
* - Resize handles are visual only (pointer-events-none on each handle).
|
|
17
|
+
* - Rotate grip is visual only.
|
|
18
|
+
* - Anchor drag indicator omitted until N6.b.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as React from "react";
|
|
22
|
+
import { useEffect, useRef } from "react";
|
|
23
|
+
import type { WordReviewEditorLayoutFacet } from "../../api/public-types";
|
|
24
|
+
import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
|
|
25
|
+
import { projectRectToOverlay } from "./chrome-overlay-projector";
|
|
26
|
+
|
|
27
|
+
/** The 8 corner/edge handle positions. */
|
|
28
|
+
const HANDLE_POSITIONS = [
|
|
29
|
+
"nw", "n", "ne",
|
|
30
|
+
"w", "e",
|
|
31
|
+
"sw", "s", "se",
|
|
32
|
+
] as const;
|
|
33
|
+
type HandlePosition = typeof HANDLE_POSITIONS[number];
|
|
34
|
+
|
|
35
|
+
const CURSOR_MAP: Record<HandlePosition, string> = {
|
|
36
|
+
nw: "nw-resize", n: "n-resize", ne: "ne-resize",
|
|
37
|
+
w: "w-resize", e: "e-resize",
|
|
38
|
+
sw: "sw-resize", s: "s-resize", se: "se-resize",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface TwObjectSelectionOverlayProps {
|
|
42
|
+
/** Stable id of the grabbed image/shape (mediaId or shapeId), or null. */
|
|
43
|
+
grabbedObjectId: string | null;
|
|
44
|
+
/** Document offset (`from`) of the grabbed object's inline segment. Null when no object grabbed. */
|
|
45
|
+
grabbedObjectFromOffset: number | null;
|
|
46
|
+
/** Document offset (`to`) of the grabbed object's inline segment. Null when no object grabbed. */
|
|
47
|
+
grabbedObjectToOffset: number | null;
|
|
48
|
+
/** Layout facet for render-frame + anchor-index access. */
|
|
49
|
+
facet: WordReviewEditorLayoutFacet;
|
|
50
|
+
/** Optional overlay coordinate-space override. */
|
|
51
|
+
space?: OverlayCoordinateSpace;
|
|
52
|
+
/** Called when the user clicks outside the selection box. */
|
|
53
|
+
onDeselect?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function TwObjectSelectionOverlay({
|
|
57
|
+
grabbedObjectId,
|
|
58
|
+
grabbedObjectFromOffset,
|
|
59
|
+
grabbedObjectToOffset,
|
|
60
|
+
facet,
|
|
61
|
+
space,
|
|
62
|
+
onDeselect,
|
|
63
|
+
}: TwObjectSelectionOverlayProps) {
|
|
64
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
|
|
66
|
+
// Click-outside to deselect.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!grabbedObjectId || !onDeselect) return;
|
|
69
|
+
function handlePointerDown(e: PointerEvent) {
|
|
70
|
+
if (overlayRef.current && !overlayRef.current.contains(e.target as Node)) {
|
|
71
|
+
onDeselect!();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
document.addEventListener("pointerdown", handlePointerDown, { capture: true });
|
|
75
|
+
return () => document.removeEventListener("pointerdown", handlePointerDown, { capture: true });
|
|
76
|
+
}, [grabbedObjectId, onDeselect]);
|
|
77
|
+
|
|
78
|
+
if (!grabbedObjectId || grabbedObjectFromOffset == null) return null;
|
|
79
|
+
|
|
80
|
+
const frame = typeof facet.getRenderFrame === "function" ? facet.getRenderFrame() : null;
|
|
81
|
+
if (!frame) return null;
|
|
82
|
+
|
|
83
|
+
const rawRect = grabbedObjectToOffset != null
|
|
84
|
+
? frame.anchorIndex.bySelection(grabbedObjectFromOffset, grabbedObjectToOffset)
|
|
85
|
+
: frame.anchorIndex.byRuntimeOffset(grabbedObjectFromOffset);
|
|
86
|
+
if (!rawRect) return null;
|
|
87
|
+
|
|
88
|
+
const rect = projectRectToOverlay(rawRect, space);
|
|
89
|
+
|
|
90
|
+
const boxStyle: React.CSSProperties = {
|
|
91
|
+
position: "absolute",
|
|
92
|
+
left: rect.left,
|
|
93
|
+
top: rect.top,
|
|
94
|
+
width: rect.width,
|
|
95
|
+
height: rect.height,
|
|
96
|
+
outline: "2px solid var(--color-accent-primary)",
|
|
97
|
+
boxSizing: "border-box",
|
|
98
|
+
pointerEvents: "auto",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
ref={overlayRef}
|
|
104
|
+
style={boxStyle}
|
|
105
|
+
data-object-selection=""
|
|
106
|
+
data-object-id={grabbedObjectId}
|
|
107
|
+
aria-label="Selected object"
|
|
108
|
+
>
|
|
109
|
+
{HANDLE_POSITIONS.map((pos) => (
|
|
110
|
+
<ObjectHandle key={pos} position={pos} />
|
|
111
|
+
))}
|
|
112
|
+
<RotateGrip />
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ObjectHandle({ position }: { position: HandlePosition }) {
|
|
118
|
+
const HANDLE_PX = 8;
|
|
119
|
+
const half = HANDLE_PX / 2;
|
|
120
|
+
const pos = position;
|
|
121
|
+
|
|
122
|
+
const style: React.CSSProperties = {
|
|
123
|
+
position: "absolute",
|
|
124
|
+
width: HANDLE_PX,
|
|
125
|
+
height: HANDLE_PX,
|
|
126
|
+
background: "white",
|
|
127
|
+
border: "1.5px solid var(--color-accent-primary)",
|
|
128
|
+
borderRadius: 1,
|
|
129
|
+
boxSizing: "border-box",
|
|
130
|
+
cursor: CURSOR_MAP[pos],
|
|
131
|
+
// Visual only in v1 — pointer events disabled so clicks fall through to
|
|
132
|
+
// the click-outside listener which deselectObjects.
|
|
133
|
+
pointerEvents: "none",
|
|
134
|
+
...(pos.includes("w") ? { left: -half } : pos.includes("e") ? { right: -half } : { left: "50%", transform: "translateX(-50%)" }),
|
|
135
|
+
...(pos.includes("n") ? { top: -half } : pos.includes("s") ? { bottom: -half } : { top: "50%", transform: `${pos === "w" || pos === "e" ? "translateY(-50%)" : "translateX(-50%) translateY(-50%)"}` }),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return <div style={style} data-handle={pos} aria-hidden="true" />;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function RotateGrip() {
|
|
142
|
+
const style: React.CSSProperties = {
|
|
143
|
+
position: "absolute",
|
|
144
|
+
width: 10,
|
|
145
|
+
height: 10,
|
|
146
|
+
borderRadius: "50%",
|
|
147
|
+
background: "white",
|
|
148
|
+
border: "1.5px solid var(--color-accent-primary)",
|
|
149
|
+
top: -24,
|
|
150
|
+
left: "50%",
|
|
151
|
+
transform: "translateX(-50%)",
|
|
152
|
+
cursor: "grab",
|
|
153
|
+
pointerEvents: "none",
|
|
154
|
+
boxSizing: "border-box",
|
|
155
|
+
};
|
|
156
|
+
return <div style={style} data-handle="rotate" aria-hidden="true" />;
|
|
157
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane 6d — Slice N5 (P11.6 + P11.7): page-border + column-separator overlay.
|
|
3
|
+
*
|
|
4
|
+
* Pure presentational component that paints the four page-border edges
|
|
5
|
+
* and any column-separator vertical lines for a single page. All
|
|
6
|
+
* positioning math lives in `page-border-resolver.ts`; this component
|
|
7
|
+
* does no measurement and reads no DOM — it accepts a `PageLayoutSnapshot`
|
|
8
|
+
* + page rect (in px) and emits inline-styled `<div>`s.
|
|
9
|
+
*
|
|
10
|
+
* Designed to be mounted by a per-page chrome layer (one
|
|
11
|
+
* `<TwPageBorderOverlay>` per page) so each page can use its own section's
|
|
12
|
+
* `pageBorders` and `columnDefinitions` independently.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React from "react";
|
|
16
|
+
import type { PageLayoutSnapshot } from "../../api/public-types";
|
|
17
|
+
import {
|
|
18
|
+
resolveColumnSeparatorXOffsets,
|
|
19
|
+
resolvePageBorders,
|
|
20
|
+
} from "./page-border-resolver";
|
|
21
|
+
|
|
22
|
+
export interface TwPageBorderOverlayProps {
|
|
23
|
+
/** Section layout for the page (drives `pageBorders` + `columnSeparator`). */
|
|
24
|
+
layout: PageLayoutSnapshot | undefined;
|
|
25
|
+
/** Zero-based page index within its owning section (drives display policy). */
|
|
26
|
+
pageInSection: number;
|
|
27
|
+
/** Page rect in container-relative pixels. */
|
|
28
|
+
pageWidthPx: number;
|
|
29
|
+
pageHeightPx: number;
|
|
30
|
+
/** Inner content-box insets in pixels (text margin from page edge). */
|
|
31
|
+
marginLeftPx: number;
|
|
32
|
+
marginRightPx: number;
|
|
33
|
+
marginTopPx: number;
|
|
34
|
+
marginBottomPx: number;
|
|
35
|
+
/** Optional test id for assertion targeting. */
|
|
36
|
+
"data-testid"?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Border edge geometry helpers — convert the resolved spec + page
|
|
41
|
+
* dimensions into the absolute `style` props for one positioned `<div>`.
|
|
42
|
+
*/
|
|
43
|
+
function styleForEdge(
|
|
44
|
+
edge: "top" | "right" | "bottom" | "left",
|
|
45
|
+
widthPx: number,
|
|
46
|
+
styleName: string,
|
|
47
|
+
color: string,
|
|
48
|
+
insetTop: number,
|
|
49
|
+
insetRight: number,
|
|
50
|
+
insetBottom: number,
|
|
51
|
+
insetLeft: number,
|
|
52
|
+
): React.CSSProperties {
|
|
53
|
+
switch (edge) {
|
|
54
|
+
case "top":
|
|
55
|
+
return {
|
|
56
|
+
position: "absolute",
|
|
57
|
+
top: `${insetTop}px`,
|
|
58
|
+
left: `${insetLeft}px`,
|
|
59
|
+
right: `${insetRight}px`,
|
|
60
|
+
height: `${widthPx}px`,
|
|
61
|
+
borderTop: `${widthPx}px ${styleName} ${color}`,
|
|
62
|
+
pointerEvents: "none",
|
|
63
|
+
};
|
|
64
|
+
case "bottom":
|
|
65
|
+
return {
|
|
66
|
+
position: "absolute",
|
|
67
|
+
bottom: `${insetBottom}px`,
|
|
68
|
+
left: `${insetLeft}px`,
|
|
69
|
+
right: `${insetRight}px`,
|
|
70
|
+
height: `${widthPx}px`,
|
|
71
|
+
borderBottom: `${widthPx}px ${styleName} ${color}`,
|
|
72
|
+
pointerEvents: "none",
|
|
73
|
+
};
|
|
74
|
+
case "left":
|
|
75
|
+
return {
|
|
76
|
+
position: "absolute",
|
|
77
|
+
top: `${insetTop}px`,
|
|
78
|
+
bottom: `${insetBottom}px`,
|
|
79
|
+
left: `${insetLeft}px`,
|
|
80
|
+
width: `${widthPx}px`,
|
|
81
|
+
borderLeft: `${widthPx}px ${styleName} ${color}`,
|
|
82
|
+
pointerEvents: "none",
|
|
83
|
+
};
|
|
84
|
+
case "right":
|
|
85
|
+
return {
|
|
86
|
+
position: "absolute",
|
|
87
|
+
top: `${insetTop}px`,
|
|
88
|
+
bottom: `${insetBottom}px`,
|
|
89
|
+
right: `${insetRight}px`,
|
|
90
|
+
width: `${widthPx}px`,
|
|
91
|
+
borderRight: `${widthPx}px ${styleName} ${color}`,
|
|
92
|
+
pointerEvents: "none",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function TwPageBorderOverlay(
|
|
98
|
+
props: TwPageBorderOverlayProps,
|
|
99
|
+
): React.ReactElement | null {
|
|
100
|
+
const {
|
|
101
|
+
layout,
|
|
102
|
+
pageInSection,
|
|
103
|
+
pageWidthPx,
|
|
104
|
+
pageHeightPx,
|
|
105
|
+
marginLeftPx,
|
|
106
|
+
marginRightPx,
|
|
107
|
+
marginTopPx,
|
|
108
|
+
marginBottomPx,
|
|
109
|
+
} = props;
|
|
110
|
+
|
|
111
|
+
const borders = resolvePageBorders(layout, pageInSection);
|
|
112
|
+
const columnOffsets = resolveColumnSeparatorXOffsets(layout);
|
|
113
|
+
|
|
114
|
+
// Nothing to paint at all → return null so React can skip the subtree.
|
|
115
|
+
const hasBorderEdges = !!borders?.display && (
|
|
116
|
+
borders.top !== null ||
|
|
117
|
+
borders.right !== null ||
|
|
118
|
+
borders.bottom !== null ||
|
|
119
|
+
borders.left !== null
|
|
120
|
+
);
|
|
121
|
+
if (!hasBorderEdges && columnOffsets.length === 0) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Insets — `offsetFrom: "page"` anchors to the page edge (= 0 inset);
|
|
126
|
+
// `offsetFrom: "text"` anchors to the text margin. Per-edge `space`
|
|
127
|
+
// pushes the border further inward from that anchor.
|
|
128
|
+
const usePageOffset = (borders?.offsetFrom ?? "page") === "page";
|
|
129
|
+
const baseInsetTop = usePageOffset ? 0 : marginTopPx;
|
|
130
|
+
const baseInsetRight = usePageOffset ? 0 : marginRightPx;
|
|
131
|
+
const baseInsetBottom = usePageOffset ? 0 : marginBottomPx;
|
|
132
|
+
const baseInsetLeft = usePageOffset ? 0 : marginLeftPx;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
data-page-border-overlay=""
|
|
137
|
+
data-page-in-section={pageInSection}
|
|
138
|
+
aria-hidden="true"
|
|
139
|
+
data-testid={props["data-testid"] ?? "page-border-overlay"}
|
|
140
|
+
style={{
|
|
141
|
+
position: "absolute",
|
|
142
|
+
inset: 0,
|
|
143
|
+
// zOrder: "back" sits visually behind text; default ("front") sits
|
|
144
|
+
// above. Using `pointer-events: none` on every child means content
|
|
145
|
+
// remains clickable in either case.
|
|
146
|
+
zIndex: borders?.zOrder === "back" ? -1 : 1,
|
|
147
|
+
pointerEvents: "none",
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
{hasBorderEdges && borders?.top ? (
|
|
151
|
+
<div
|
|
152
|
+
data-edge="top"
|
|
153
|
+
style={styleForEdge(
|
|
154
|
+
"top",
|
|
155
|
+
borders.top.widthPx,
|
|
156
|
+
borders.top.style,
|
|
157
|
+
borders.top.color,
|
|
158
|
+
baseInsetTop + borders.top.spacePx,
|
|
159
|
+
baseInsetRight,
|
|
160
|
+
baseInsetBottom,
|
|
161
|
+
baseInsetLeft,
|
|
162
|
+
)}
|
|
163
|
+
/>
|
|
164
|
+
) : null}
|
|
165
|
+
{hasBorderEdges && borders?.right ? (
|
|
166
|
+
<div
|
|
167
|
+
data-edge="right"
|
|
168
|
+
style={styleForEdge(
|
|
169
|
+
"right",
|
|
170
|
+
borders.right.widthPx,
|
|
171
|
+
borders.right.style,
|
|
172
|
+
borders.right.color,
|
|
173
|
+
baseInsetTop,
|
|
174
|
+
baseInsetRight + borders.right.spacePx,
|
|
175
|
+
baseInsetBottom,
|
|
176
|
+
baseInsetLeft,
|
|
177
|
+
)}
|
|
178
|
+
/>
|
|
179
|
+
) : null}
|
|
180
|
+
{hasBorderEdges && borders?.bottom ? (
|
|
181
|
+
<div
|
|
182
|
+
data-edge="bottom"
|
|
183
|
+
style={styleForEdge(
|
|
184
|
+
"bottom",
|
|
185
|
+
borders.bottom.widthPx,
|
|
186
|
+
borders.bottom.style,
|
|
187
|
+
borders.bottom.color,
|
|
188
|
+
baseInsetTop,
|
|
189
|
+
baseInsetRight,
|
|
190
|
+
baseInsetBottom + borders.bottom.spacePx,
|
|
191
|
+
baseInsetLeft,
|
|
192
|
+
)}
|
|
193
|
+
/>
|
|
194
|
+
) : null}
|
|
195
|
+
{hasBorderEdges && borders?.left ? (
|
|
196
|
+
<div
|
|
197
|
+
data-edge="left"
|
|
198
|
+
style={styleForEdge(
|
|
199
|
+
"left",
|
|
200
|
+
borders.left.widthPx,
|
|
201
|
+
borders.left.style,
|
|
202
|
+
borders.left.color,
|
|
203
|
+
baseInsetTop,
|
|
204
|
+
baseInsetRight,
|
|
205
|
+
baseInsetBottom,
|
|
206
|
+
baseInsetLeft + borders.left.spacePx,
|
|
207
|
+
)}
|
|
208
|
+
/>
|
|
209
|
+
) : null}
|
|
210
|
+
{/* Column separators — vertical hairlines centered in column gaps,
|
|
211
|
+
inset by the text margins so they paint inside the content box. */}
|
|
212
|
+
{columnOffsets.map((xPxFromContentLeft, i) => (
|
|
213
|
+
<div
|
|
214
|
+
key={`col-sep-${i}`}
|
|
215
|
+
data-column-separator=""
|
|
216
|
+
data-column-separator-index={i}
|
|
217
|
+
style={{
|
|
218
|
+
position: "absolute",
|
|
219
|
+
top: `${marginTopPx}px`,
|
|
220
|
+
bottom: `${marginBottomPx}px`,
|
|
221
|
+
left: `${marginLeftPx + xPxFromContentLeft}px`,
|
|
222
|
+
width: "1px",
|
|
223
|
+
backgroundColor: "var(--color-border-default, currentColor)",
|
|
224
|
+
opacity: 0.6,
|
|
225
|
+
pointerEvents: "none",
|
|
226
|
+
}}
|
|
227
|
+
/>
|
|
228
|
+
))}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export default TwPageBorderOverlay;
|