@beyondwork/docx-react-component 1.0.55 → 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 +43 -32
- 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 +192 -11
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -3
- 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
|
@@ -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,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;
|
|
@@ -74,12 +74,50 @@ export interface PageOverlayRect {
|
|
|
74
74
|
export interface PageBoundaryMeasurement {
|
|
75
75
|
prevPageId: string;
|
|
76
76
|
nextPageId: string;
|
|
77
|
+
boundaryIndex?: number;
|
|
77
78
|
/** Widget top edge = bottom of the previous page. */
|
|
78
79
|
topPx: number;
|
|
79
80
|
/** Widget bottom edge = top of the next page. */
|
|
80
81
|
bottomPx: number;
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
export interface VisiblePageIndexRange {
|
|
85
|
+
start: number;
|
|
86
|
+
end: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeVisiblePageIndexRange(
|
|
90
|
+
range: VisiblePageIndexRange | null | undefined,
|
|
91
|
+
pageCount: number,
|
|
92
|
+
): VisiblePageIndexRange | null {
|
|
93
|
+
if (!range || pageCount <= 0) return null;
|
|
94
|
+
const start = Math.max(0, Math.min(range.start, pageCount));
|
|
95
|
+
const end = Math.max(start, Math.min(range.end, pageCount));
|
|
96
|
+
if (start >= end) return null;
|
|
97
|
+
return { start, end };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function collectBoundaryIndicesForVisibleRange(
|
|
101
|
+
range: VisiblePageIndexRange,
|
|
102
|
+
pageCount: number,
|
|
103
|
+
): number[] {
|
|
104
|
+
if (pageCount <= 1) return [];
|
|
105
|
+
const startBoundaryIndex = Math.max(0, range.start - 1);
|
|
106
|
+
const endBoundaryIndex = Math.min(pageCount - 2, range.end - 1);
|
|
107
|
+
if (startBoundaryIndex > endBoundaryIndex) return [];
|
|
108
|
+
const indices: number[] = [];
|
|
109
|
+
for (let index = startBoundaryIndex; index <= endBoundaryIndex; index += 1) {
|
|
110
|
+
indices.push(index);
|
|
111
|
+
}
|
|
112
|
+
return indices;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parsePageBoundaryIndex(prevPageId: string): number | undefined {
|
|
116
|
+
const match = /^page-(\d+)$/.exec(prevPageId);
|
|
117
|
+
if (!match) return undefined;
|
|
118
|
+
return Number.parseInt(match[1] ?? "", 10);
|
|
119
|
+
}
|
|
120
|
+
|
|
83
121
|
/**
|
|
84
122
|
* Pure helper: turn pre-measured page-boundary widget positions into
|
|
85
123
|
* one `PageOverlayRect` per page. No DOM access — the caller supplies
|
|
@@ -104,6 +142,8 @@ export function resolvePageOverlayRects(
|
|
|
104
142
|
pageCount: number;
|
|
105
143
|
/** Total scroll-root height in the overlay's coordinate space. */
|
|
106
144
|
scrollHeight: number;
|
|
145
|
+
/** Optional viewport-bounded page range. */
|
|
146
|
+
visiblePageIndexRange?: VisiblePageIndexRange | null;
|
|
107
147
|
}
|
|
108
148
|
// Legacy two-arg path preserved for backward compat. Walks
|
|
109
149
|
// offsetTop chain inside the scroll-root; used by harness code
|
|
@@ -158,12 +198,26 @@ export function resolvePageOverlayRects(
|
|
|
158
198
|
// emits widgets out-of-order (it doesn't today, but the cost is
|
|
159
199
|
// negligible).
|
|
160
200
|
const boundaries = [...widgets].sort((a, b) => a.topPx - b.topPx);
|
|
201
|
+
const normalizedVisiblePageIndexRange = Array.isArray(input)
|
|
202
|
+
? null
|
|
203
|
+
: normalizeVisiblePageIndexRange(input.visiblePageIndexRange, pageCount);
|
|
204
|
+
const boundaryByIndex = new Map<number, PageBoundaryMeasurement>();
|
|
205
|
+
boundaries.forEach((boundary, index) => {
|
|
206
|
+
const boundaryIndex =
|
|
207
|
+
boundary.boundaryIndex ??
|
|
208
|
+
parsePageBoundaryIndex(boundary.prevPageId) ??
|
|
209
|
+
index;
|
|
210
|
+
boundaryByIndex.set(boundaryIndex, boundary);
|
|
211
|
+
});
|
|
212
|
+
const pageStart = normalizedVisiblePageIndexRange?.start ?? 0;
|
|
213
|
+
const pageEnd = normalizedVisiblePageIndexRange?.end ?? pageCount;
|
|
161
214
|
|
|
162
215
|
const rects: PageOverlayRect[] = [];
|
|
163
|
-
for (let pageIndex =
|
|
164
|
-
const boundaryBefore =
|
|
216
|
+
for (let pageIndex = pageStart; pageIndex < pageEnd; pageIndex += 1) {
|
|
217
|
+
const boundaryBefore =
|
|
218
|
+
pageIndex === 0 ? null : (boundaryByIndex.get(pageIndex - 1) ?? null);
|
|
165
219
|
const boundaryAfter =
|
|
166
|
-
pageIndex === pageCount - 1 ? null :
|
|
220
|
+
pageIndex === pageCount - 1 ? null : (boundaryByIndex.get(pageIndex) ?? null);
|
|
167
221
|
|
|
168
222
|
let pageId: string | null = null;
|
|
169
223
|
if (boundaryBefore) pageId = boundaryBefore.nextPageId;
|
|
@@ -204,14 +258,36 @@ export function resolvePageOverlayRects(
|
|
|
204
258
|
* ancestor.
|
|
205
259
|
*/
|
|
206
260
|
export function measureWidgetsViaBoundingRect(
|
|
207
|
-
queryRoot: Pick<HTMLElement, "querySelectorAll">
|
|
261
|
+
queryRoot: Pick<HTMLElement, "querySelectorAll"> &
|
|
262
|
+
Partial<Pick<HTMLElement, "querySelector">> | null,
|
|
208
263
|
originElement: HTMLElement | null,
|
|
264
|
+
options?: {
|
|
265
|
+
pageCount?: number;
|
|
266
|
+
visiblePageIndexRange?: VisiblePageIndexRange | null;
|
|
267
|
+
},
|
|
209
268
|
): PageBoundaryMeasurement[] {
|
|
210
269
|
if (!queryRoot || !originElement) return [];
|
|
211
270
|
const originRect = originElement.getBoundingClientRect();
|
|
212
|
-
const
|
|
213
|
-
|
|
271
|
+
const normalizedVisiblePageIndexRange = normalizeVisiblePageIndexRange(
|
|
272
|
+
options?.visiblePageIndexRange,
|
|
273
|
+
options?.pageCount ?? 0,
|
|
214
274
|
);
|
|
275
|
+
const queryOne =
|
|
276
|
+
typeof queryRoot.querySelector === "function"
|
|
277
|
+
? queryRoot.querySelector.bind(queryRoot)
|
|
278
|
+
: null;
|
|
279
|
+
const widgets = normalizedVisiblePageIndexRange && queryOne && options?.pageCount
|
|
280
|
+
? collectBoundaryIndicesForVisibleRange(
|
|
281
|
+
normalizedVisiblePageIndexRange,
|
|
282
|
+
options.pageCount,
|
|
283
|
+
)
|
|
284
|
+
.map((boundaryIndex) =>
|
|
285
|
+
queryOne(`[data-page-frame-end="page-${boundaryIndex}"]`),
|
|
286
|
+
)
|
|
287
|
+
.filter((widget): widget is HTMLElement => widget !== null)
|
|
288
|
+
: Array.from(
|
|
289
|
+
queryRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
|
|
290
|
+
);
|
|
215
291
|
const out: PageBoundaryMeasurement[] = [];
|
|
216
292
|
for (const widget of widgets) {
|
|
217
293
|
const prevPageId = widget.getAttribute("data-page-frame-end");
|
|
@@ -221,6 +297,7 @@ export function measureWidgetsViaBoundingRect(
|
|
|
221
297
|
out.push({
|
|
222
298
|
prevPageId,
|
|
223
299
|
nextPageId,
|
|
300
|
+
boundaryIndex: parsePageBoundaryIndex(prevPageId),
|
|
224
301
|
topPx: rect.top - originRect.top,
|
|
225
302
|
bottomPx: rect.bottom - originRect.top,
|
|
226
303
|
});
|
|
@@ -242,11 +319,33 @@ export function measureWidgetsViaBoundingRect(
|
|
|
242
319
|
* "scroll root is often not the right origin", not about arithmetic.
|
|
243
320
|
*/
|
|
244
321
|
export function measureWidgetsViaOffsetChain(
|
|
245
|
-
scrollRoot: Pick<HTMLElement, "clientHeight" | "querySelectorAll"
|
|
322
|
+
scrollRoot: Pick<HTMLElement, "clientHeight" | "querySelectorAll"> &
|
|
323
|
+
Partial<Pick<HTMLElement, "querySelector">>,
|
|
324
|
+
options?: {
|
|
325
|
+
pageCount?: number;
|
|
326
|
+
visiblePageIndexRange?: VisiblePageIndexRange | null;
|
|
327
|
+
},
|
|
246
328
|
): PageBoundaryMeasurement[] {
|
|
247
|
-
const
|
|
248
|
-
|
|
329
|
+
const normalizedVisiblePageIndexRange = normalizeVisiblePageIndexRange(
|
|
330
|
+
options?.visiblePageIndexRange,
|
|
331
|
+
options?.pageCount ?? 0,
|
|
249
332
|
);
|
|
333
|
+
const queryOne =
|
|
334
|
+
typeof scrollRoot.querySelector === "function"
|
|
335
|
+
? scrollRoot.querySelector.bind(scrollRoot)
|
|
336
|
+
: null;
|
|
337
|
+
const widgets = normalizedVisiblePageIndexRange && queryOne && options?.pageCount
|
|
338
|
+
? collectBoundaryIndicesForVisibleRange(
|
|
339
|
+
normalizedVisiblePageIndexRange,
|
|
340
|
+
options.pageCount,
|
|
341
|
+
)
|
|
342
|
+
.map((boundaryIndex) =>
|
|
343
|
+
queryOne(`[data-page-frame-end="page-${boundaryIndex}"]`),
|
|
344
|
+
)
|
|
345
|
+
.filter((widget): widget is HTMLElement => widget !== null)
|
|
346
|
+
: Array.from(
|
|
347
|
+
scrollRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
|
|
348
|
+
);
|
|
250
349
|
const out: PageBoundaryMeasurement[] = [];
|
|
251
350
|
for (const widget of widgets) {
|
|
252
351
|
const prevPageId = widget.getAttribute("data-page-frame-end");
|
|
@@ -254,7 +353,13 @@ export function measureWidgetsViaOffsetChain(
|
|
|
254
353
|
if (!prevPageId || !nextPageId) continue;
|
|
255
354
|
const topPx = resolveOffsetTop(widget, scrollRoot);
|
|
256
355
|
const bottomPx = topPx + resolveOffsetHeight(widget);
|
|
257
|
-
out.push({
|
|
356
|
+
out.push({
|
|
357
|
+
prevPageId,
|
|
358
|
+
nextPageId,
|
|
359
|
+
boundaryIndex: parsePageBoundaryIndex(prevPageId),
|
|
360
|
+
topPx,
|
|
361
|
+
bottomPx,
|
|
362
|
+
});
|
|
258
363
|
}
|
|
259
364
|
return out;
|
|
260
365
|
}
|
|
@@ -297,6 +402,7 @@ export interface TwPageStackOverlayLayerProps {
|
|
|
297
402
|
* time this changes so the overlays stay aligned with content.
|
|
298
403
|
*/
|
|
299
404
|
renderFrameRevision: number;
|
|
405
|
+
visiblePageIndexRange?: VisiblePageIndexRange | null;
|
|
300
406
|
/** Optional test id applied to the overlay root. */
|
|
301
407
|
"data-testid"?: string;
|
|
302
408
|
}
|
|
@@ -313,6 +419,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
|
|
|
313
419
|
facet,
|
|
314
420
|
scrollRoot,
|
|
315
421
|
renderFrameRevision,
|
|
422
|
+
visiblePageIndexRange,
|
|
316
423
|
"data-testid": testId,
|
|
317
424
|
}) => {
|
|
318
425
|
const [rects, setRects] = React.useState<readonly PageOverlayRect[]>([]);
|
|
@@ -351,7 +458,10 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
|
|
|
351
458
|
const origin = overlayRootRef.current;
|
|
352
459
|
const pageCount = facet.getPageCount();
|
|
353
460
|
if (origin) {
|
|
354
|
-
const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin
|
|
461
|
+
const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
|
|
462
|
+
pageCount,
|
|
463
|
+
visiblePageIndexRange,
|
|
464
|
+
});
|
|
355
465
|
const originRect = origin.getBoundingClientRect();
|
|
356
466
|
setRects(
|
|
357
467
|
resolvePageOverlayRects({
|
|
@@ -359,12 +469,24 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
|
|
|
359
469
|
pageCount,
|
|
360
470
|
scrollHeight:
|
|
361
471
|
origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
|
|
472
|
+
visiblePageIndexRange,
|
|
362
473
|
}),
|
|
363
474
|
);
|
|
364
475
|
} else {
|
|
365
|
-
|
|
476
|
+
const widgets = measureWidgetsViaOffsetChain(scrollRoot, {
|
|
477
|
+
pageCount,
|
|
478
|
+
visiblePageIndexRange,
|
|
479
|
+
});
|
|
480
|
+
setRects(
|
|
481
|
+
resolvePageOverlayRects({
|
|
482
|
+
widgets,
|
|
483
|
+
pageCount,
|
|
484
|
+
scrollHeight: scrollRoot.clientHeight,
|
|
485
|
+
visiblePageIndexRange,
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
366
488
|
}
|
|
367
|
-
}, [facet, scrollRoot]);
|
|
489
|
+
}, [facet, scrollRoot, visiblePageIndexRange]);
|
|
368
490
|
|
|
369
491
|
const refreshRects = React.useCallback(() => {
|
|
370
492
|
if (!scrollRoot) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { RenderBlockDecoration } from "../../runtime/render/render-frame-types";
|
|
3
|
+
import { AUTHOR_PALETTE } from "../../ui/headless/revision-decoration-model";
|
|
4
|
+
|
|
5
|
+
const BAR_WIDTH_PX = 3;
|
|
6
|
+
const BAR_LEFT_OFFSET_PX = 8;
|
|
7
|
+
|
|
8
|
+
export interface TwRevisionMarginBarLayerProps {
|
|
9
|
+
/** Decoration rects from `frame.decorationIndex.revisions`. */
|
|
10
|
+
revisionDecorations: readonly RenderBlockDecoration[];
|
|
11
|
+
/**
|
|
12
|
+
* Author palette index keyed by revisionId.
|
|
13
|
+
* Build from `RevisionDecorationEntry.authorPaletteIndex` before passing.
|
|
14
|
+
*/
|
|
15
|
+
authorPaletteIndexById: ReadonlyMap<string, number>;
|
|
16
|
+
/** Left edge of the page body in overlay coordinates (px). */
|
|
17
|
+
pageBodyLeftPx: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const TwRevisionMarginBarLayer = React.memo(function TwRevisionMarginBarLayer({
|
|
21
|
+
revisionDecorations,
|
|
22
|
+
authorPaletteIndexById,
|
|
23
|
+
pageBodyLeftPx,
|
|
24
|
+
}: TwRevisionMarginBarLayerProps) {
|
|
25
|
+
if (revisionDecorations.length === 0) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
{revisionDecorations.map((dec, idx) => {
|
|
30
|
+
const paletteIdx = authorPaletteIndexById.get(dec.refId) ?? 0;
|
|
31
|
+
const color = AUTHOR_PALETTE[paletteIdx % AUTHOR_PALETTE.length];
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
key={`rev-bar-${dec.refId}-${idx}`}
|
|
35
|
+
aria-hidden
|
|
36
|
+
style={{
|
|
37
|
+
position: "absolute",
|
|
38
|
+
top: dec.frame.topPx,
|
|
39
|
+
left: pageBodyLeftPx - BAR_LEFT_OFFSET_PX - BAR_WIDTH_PX,
|
|
40
|
+
width: BAR_WIDTH_PX,
|
|
41
|
+
height: dec.frame.heightPx,
|
|
42
|
+
backgroundColor: color,
|
|
43
|
+
borderRadius: 1,
|
|
44
|
+
pointerEvents: "none",
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
})}
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
});
|
|
@@ -114,10 +114,18 @@ export const TwScopeCardLayer: React.FC<TwScopeCardLayerProps> = ({
|
|
|
114
114
|
const pinnedModel = pinnedScopeId
|
|
115
115
|
? models.find((m) => m.scopeId === pinnedScopeId) ?? null
|
|
116
116
|
: null;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
|
|
118
|
+
// R2.b — when a pinned scope disappears from the model list (e.g. the
|
|
119
|
+
// host cleared the overlay), drop the pin. Must run in an effect, not
|
|
120
|
+
// inline in render: a render-phase setState call violates React's
|
|
121
|
+
// render-purity contract and can trigger "Maximum update depth
|
|
122
|
+
// exceeded" in React 18 concurrent mode where renders are retried
|
|
123
|
+
// before commit.
|
|
124
|
+
React.useEffect(() => {
|
|
125
|
+
if (pinnedScopeId && !models.find((m) => m.scopeId === pinnedScopeId)) {
|
|
126
|
+
setPinnedScopeId(null);
|
|
127
|
+
}
|
|
128
|
+
}, [pinnedScopeId, models]);
|
|
121
129
|
|
|
122
130
|
const effectiveScopeId = pinnedModel ? pinnedScopeId : activeScopeId;
|
|
123
131
|
if (!effectiveScopeId) return null;
|