@plannotator/ui 0.54.0 → 0.55.0
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/HANDOFF.md +59 -5
- package/README.md +50 -2
- package/components/BlockRenderer.tsx +4 -1
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocumentContents.tsx +74 -0
- package/components/ImageLightbox.tsx +121 -63
- package/components/InlineMarkdown.tsx +15 -150
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/TableOfContents.tsx +20 -6
- package/components/Viewer.tsx +8 -2
- package/components/blocks/QuestionBlock.tsx +14 -4
- package/components/html-viewer/HtmlViewer.tsx +36 -1
- package/components/imageZoom.ts +11 -0
- package/contents-rail.css +337 -0
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSwipeStep.ts +597 -0
- package/package.json +5 -3
- package/styles.css +1 -1
- package/utils/annotationHelpers.ts +32 -4
- package/utils/blockParse.ts +45 -0
- package/utils/headingScrollSpy.ts +92 -0
|
@@ -22,6 +22,17 @@ interface TableOfContentsProps {
|
|
|
22
22
|
linkedDocFilepath?: string | null;
|
|
23
23
|
onLinkedDocBack?: () => void;
|
|
24
24
|
backLabel?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The element a click scrolls. Default: the `ScrollViewportProvider`
|
|
27
|
+
* context's viewport (plan review). A host that draws the list outside the
|
|
28
|
+
* document's scroll area (the Inbox's comments column) names it here.
|
|
29
|
+
*/
|
|
30
|
+
scrollViewport?: HTMLElement | null;
|
|
31
|
+
/**
|
|
32
|
+
* The deepest heading level listed. Default 3 (plan review's list); the
|
|
33
|
+
* contents rail's surface (`DocumentContents`) lists 1 to 4, as the rail does.
|
|
34
|
+
*/
|
|
35
|
+
maxLevel?: number;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
// The prototype's TOC is a FLAT list — heading depth is conveyed by indentation
|
|
@@ -43,7 +54,7 @@ function flattenToc(items: TocItem[]): TocItem[] {
|
|
|
43
54
|
// H1 flush + near-full strength, H2/H3 indented and dimmed to muted. Active row
|
|
44
55
|
// is a soft neutral surface tint (not a loud primary fill).
|
|
45
56
|
function itemClasses(level: number, isActive: boolean): string {
|
|
46
|
-
const indent = level <= 1 ? '' : level === 2 ? 'ml-3' : 'ml-6';
|
|
57
|
+
const indent = level <= 1 ? '' : level === 2 ? 'ml-3' : level === 3 ? 'ml-6' : 'ml-9';
|
|
47
58
|
const tone = isActive
|
|
48
59
|
? 'bg-surface-1 text-foreground'
|
|
49
60
|
: level <= 1
|
|
@@ -62,6 +73,8 @@ export function TableOfContents({
|
|
|
62
73
|
linkedDocFilepath,
|
|
63
74
|
onLinkedDocBack,
|
|
64
75
|
backLabel,
|
|
76
|
+
scrollViewport: scrollViewportProp,
|
|
77
|
+
maxLevel = 3,
|
|
65
78
|
}: TableOfContentsProps) {
|
|
66
79
|
// Annotation count per section (kept — production feature).
|
|
67
80
|
const annotationCounts = useMemo(
|
|
@@ -69,15 +82,16 @@ export function TableOfContents({
|
|
|
69
82
|
[blocks, annotations]
|
|
70
83
|
);
|
|
71
84
|
|
|
72
|
-
// Build the hierarchy (filters to heading levels ≤
|
|
73
|
-
// then flatten to a plain list.
|
|
85
|
+
// Build the hierarchy (filters to heading levels ≤ maxLevel and attaches
|
|
86
|
+
// counts), then flatten to a plain list.
|
|
74
87
|
const tocItems = useMemo(
|
|
75
|
-
() => flattenToc(buildTocHierarchy(blocks, annotationCounts)),
|
|
76
|
-
[blocks, annotationCounts]
|
|
88
|
+
() => flattenToc(buildTocHierarchy(blocks, annotationCounts, maxLevel)),
|
|
89
|
+
[blocks, annotationCounts, maxLevel]
|
|
77
90
|
);
|
|
78
91
|
|
|
79
92
|
// The real scroll element is the OverlayScrollArea viewport, not <main>.
|
|
80
|
-
const
|
|
93
|
+
const contextViewport = useScrollViewport();
|
|
94
|
+
const scrollViewport = scrollViewportProp === undefined ? contextViewport : scrollViewportProp;
|
|
81
95
|
|
|
82
96
|
// Smooth scroll-to-heading, accounting for the sticky header.
|
|
83
97
|
const handleNavigate = useCallback(
|
package/components/Viewer.tsx
CHANGED
|
@@ -320,6 +320,10 @@ export interface ViewerProps {
|
|
|
320
320
|
/** A small label over each picture tile's picture (see `QuestionBlock`
|
|
321
321
|
* `pictureBadge`); absent: none. */
|
|
322
322
|
questionPictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
|
|
323
|
+
/** A picture tile's zoom, told its question's key and the choice's index (see
|
|
324
|
+
* `QuestionBlock` `onZoomChoice`). Absent: the zoom opens the picture
|
|
325
|
+
* through `onImageClick`, as before. */
|
|
326
|
+
onQuestionPictureZoom?: (key: string, index: number, url: string, alt: string) => void;
|
|
323
327
|
/** Makes a question's "Records a decision" tag a toggle button
|
|
324
328
|
* (`aria-pressed`); a click calls this with the question's key and the
|
|
325
329
|
* next state, which the host stores and returns from
|
|
@@ -833,6 +837,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
833
837
|
questionContractLines = false,
|
|
834
838
|
questionPictureChoices = true,
|
|
835
839
|
questionPictureBadge,
|
|
840
|
+
onQuestionPictureZoom,
|
|
836
841
|
onToggleQuestionDecisionRecording,
|
|
837
842
|
onOpenQuestionDecision,
|
|
838
843
|
questionDecisionLocked,
|
|
@@ -1329,8 +1334,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1329
1334
|
const container = containerRef.current;
|
|
1330
1335
|
if (!container || !scrollViewport) return false;
|
|
1331
1336
|
|
|
1332
|
-
const target =
|
|
1333
|
-
if (!target
|
|
1337
|
+
const target = container.querySelector<HTMLElement>(`#${CSS.escape(anchor)}`);
|
|
1338
|
+
if (!target) return false;
|
|
1334
1339
|
|
|
1335
1340
|
const documentHeader = stickyActions
|
|
1336
1341
|
? container.querySelector<HTMLElement>('[data-viewer-document-header]')
|
|
@@ -1980,6 +1985,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1980
1985
|
questionContractLines={questionContractLines}
|
|
1981
1986
|
questionPictureChoices={questionPictureChoices}
|
|
1982
1987
|
questionPictureBadge={questionPictureBadge}
|
|
1988
|
+
onQuestionPictureZoom={onQuestionPictureZoom}
|
|
1983
1989
|
/>
|
|
1984
1990
|
);
|
|
1985
1991
|
// `textComments`: a question card's words are not commented on
|
|
@@ -271,6 +271,11 @@ export interface QuestionBlockProps {
|
|
|
271
271
|
* answer, never read by the parser. Default absent, so every card draws as
|
|
272
272
|
* before (the Plannotator Inbox's onboarding tour labels its pictures). */
|
|
273
273
|
pictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
|
|
274
|
+
/** The zoom button on a picture tile, told which question and which choice
|
|
275
|
+
* (its index in the question's choices) as well as the picture: a host can
|
|
276
|
+
* open a gallery of that question's choices (the Plannotator Inbox's option
|
|
277
|
+
* sets). Absent: the zoom calls `onImageClick(url, alt)` as before. */
|
|
278
|
+
onZoomChoice?: (key: string, index: number, url: string, alt: string) => void;
|
|
274
279
|
}
|
|
275
280
|
|
|
276
281
|
/** The picture tile's label (`pictureBadge`): a small dark chip over the picture's top-left corner. */
|
|
@@ -321,6 +326,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
321
326
|
onNavigateAnchor,
|
|
322
327
|
pictureChoices = true,
|
|
323
328
|
pictureBadge,
|
|
329
|
+
onZoomChoice,
|
|
324
330
|
}) => {
|
|
325
331
|
const { question, number } = indexed;
|
|
326
332
|
const resolveImage = useImageResolver();
|
|
@@ -880,7 +886,11 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
|
880
886
|
name={groupName}
|
|
881
887
|
readOnly={readOnly}
|
|
882
888
|
onPick={pick}
|
|
883
|
-
onZoom={
|
|
889
|
+
onZoom={
|
|
890
|
+
onZoomChoice
|
|
891
|
+
? (url, alt, index) => onZoomChoice(question.key, index, url, alt)
|
|
892
|
+
: onImageClick
|
|
893
|
+
}
|
|
884
894
|
onPictureError={markPictureFailed}
|
|
885
895
|
heldTitle={pictureHeldTitle}
|
|
886
896
|
renderText={inline}
|
|
@@ -1121,8 +1131,8 @@ export interface QuestionPictureTilesProps {
|
|
|
1121
1131
|
readOnly?: boolean;
|
|
1122
1132
|
/** The tile's radio or checkbox changed (the picture and the label pick through it). */
|
|
1123
1133
|
onPick: (label: string, checked: boolean) => void;
|
|
1124
|
-
/** The separate zoom button (absent: no zoom button). Never picks. */
|
|
1125
|
-
onZoom?: (url: string, alt: string) => void;
|
|
1134
|
+
/** The separate zoom button (absent: no zoom button). Never picks. `index`: the choice's place in `choices`. */
|
|
1135
|
+
onZoom?: (url: string, alt: string, index: number) => void;
|
|
1126
1136
|
onPictureError?: (url: string) => void;
|
|
1127
1137
|
/** The host's reason a picture is held (its image resolver's `unavailable` title). */
|
|
1128
1138
|
heldTitle?: (src: string) => string | null;
|
|
@@ -1256,7 +1266,7 @@ export const QuestionPictureTiles: React.FC<QuestionPictureTilesProps> = ({
|
|
|
1256
1266
|
{shown && onZoom && (
|
|
1257
1267
|
<button
|
|
1258
1268
|
type="button"
|
|
1259
|
-
onClick={() => onZoom(shown, alt)}
|
|
1269
|
+
onClick={() => onZoom(shown, alt, index)}
|
|
1260
1270
|
aria-label={`Zoom: ${alt}`}
|
|
1261
1271
|
className="annotation-exclude absolute right-1.5 top-1.5 grid h-7 w-7 place-items-center rounded-md bg-background/85 text-foreground shadow-sm ring-1 ring-border/70 before:absolute before:-inset-2 before:content-[''] hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
|
|
1262
1272
|
data-pinpoint-ignore=""
|
|
@@ -148,6 +148,20 @@ const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024;
|
|
|
148
148
|
*/
|
|
149
149
|
export const HTML_VIEWER_SANDBOX = "allow-scripts allow-modals";
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The srcdoc frame's `sandbox` attribute: the `sandbox` prop's value as given,
|
|
153
|
+
* plus `allow-popups` when `hostNavigates` needs it and the value lacks it.
|
|
154
|
+
* Anything that is not a string (`undefined`, and also `null` from a host's
|
|
155
|
+
* unset config, which a destructuring default does not catch) becomes
|
|
156
|
+
* `HTML_VIEWER_SANDBOX`, so the frame always carries a sandbox: React would
|
|
157
|
+
* drop a `null` attribute and run the page unsandboxed.
|
|
158
|
+
*/
|
|
159
|
+
function srcdocSandbox(sandbox: unknown, hostNavigates: boolean): string {
|
|
160
|
+
const value = typeof sandbox === "string" ? sandbox : HTML_VIEWER_SANDBOX;
|
|
161
|
+
if (!hostNavigates || value.split(/\s+/).includes("allow-popups")) return value;
|
|
162
|
+
return value.trim() ? `${value} allow-popups` : "allow-popups";
|
|
163
|
+
}
|
|
164
|
+
|
|
151
165
|
/** Default wait for the bridge's `ready` on the `bridgeScriptUrl` path. */
|
|
152
166
|
export const DEFAULT_BRIDGE_READY_TIMEOUT_MS = 5000;
|
|
153
167
|
|
|
@@ -373,6 +387,26 @@ export interface HtmlViewerProps {
|
|
|
373
387
|
* still pins. Absent → unchanged.
|
|
374
388
|
*/
|
|
375
389
|
hostNavigates?: boolean;
|
|
390
|
+
/**
|
|
391
|
+
* The srcdoc frame's whole `sandbox` attribute value, for a host whose
|
|
392
|
+
* documents must not open dialogs: `"allow-scripts"` keeps a shared page's
|
|
393
|
+
* `alert`, `confirm` and `prompt` from drawing over the host's origin. Used
|
|
394
|
+
* as given, except that `hostNavigates` still adds `allow-popups`, which it
|
|
395
|
+
* needs. Never add `allow-same-origin` beside `allow-scripts`: the page
|
|
396
|
+
* could then remove its own sandbox. Live-app (`src`) mode has no sandbox
|
|
397
|
+
* and ignores this. Absent, or any non-string such as `null` →
|
|
398
|
+
* `HTML_VIEWER_SANDBOX` (`"allow-scripts allow-modals"`), unchanged.
|
|
399
|
+
*
|
|
400
|
+
* `""` (or only spaces) is kept as given: the strictest frame, with no
|
|
401
|
+
* scripts, so the annotation bridge never starts and the page cannot be
|
|
402
|
+
* annotated. It never falls back to the wider default.
|
|
403
|
+
*
|
|
404
|
+
* Read when the frame loads a document: browsers apply a changed `sandbox`
|
|
405
|
+
* only on the frame's next navigation (a new `rawHtml`, for example). To
|
|
406
|
+
* apply a new value to the document already shown, remount the viewer (for
|
|
407
|
+
* example, key it on the value).
|
|
408
|
+
*/
|
|
409
|
+
sandbox?: string;
|
|
376
410
|
}
|
|
377
411
|
|
|
378
412
|
/**
|
|
@@ -429,6 +463,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
429
463
|
bridgeErrorDisplay = "banner",
|
|
430
464
|
onHostDraft,
|
|
431
465
|
hostNavigates = false,
|
|
466
|
+
sandbox = HTML_VIEWER_SANDBOX,
|
|
432
467
|
},
|
|
433
468
|
ref,
|
|
434
469
|
) => {
|
|
@@ -1295,7 +1330,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
1295
1330
|
same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
|
|
1296
1331
|
<iframe
|
|
1297
1332
|
ref={iframeRef}
|
|
1298
|
-
{...(src ? { src } : { srcDoc: srcdoc, sandbox:
|
|
1333
|
+
{...(src ? { src } : { srcDoc: srcdoc, sandbox: srcdocSandbox(sandbox, hostNavigates) })}
|
|
1299
1334
|
style={{
|
|
1300
1335
|
width: "100%",
|
|
1301
1336
|
height: fullViewport ? "100%" : `${iframeHeight}px`,
|
package/components/imageZoom.ts
CHANGED
|
@@ -147,6 +147,17 @@ export function atFit(view: ZoomViewport, limits: ZoomLimits | number): boolean
|
|
|
147
147
|
return Math.abs(view.scale - fit) < 1e-3;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Whether the part of an image on screen (`visibleBox`, its own pixels) can
|
|
152
|
+
* still pan sideways: `1` shows more of its right (the finger moving left),
|
|
153
|
+
* `-1` more of its left. A swipe that way pans first, and steps once the
|
|
154
|
+
* image is at its edge (approval "inbox-image-swipe").
|
|
155
|
+
*/
|
|
156
|
+
export function canPanToward(box: Pick<NaturalBox, 'x' | 'w'> | null, naturalWidth: number, dir: 1 | -1): boolean {
|
|
157
|
+
if (!box || !(naturalWidth > 0)) return false;
|
|
158
|
+
return dir > 0 ? box.x + box.w < naturalWidth - 1 : box.x > 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
150
161
|
/** The share of a stage that leaves `padding` CSS pixels on every side (at least a tenth). */
|
|
151
162
|
export function paddingInset(stage: ZoomSize, padding: number): ZoomInset {
|
|
152
163
|
if (!valid(stage) || padding <= 0) return FULL;
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @plannotator/ui/contents-rail.css: the document contents rail
|
|
3
|
+
* (components/MarkdownContentsRail.tsx). Its own stylesheet, NOT part of
|
|
4
|
+
* theme.css or styles.css: import it OUTSIDE any CSS cascade layer, after the
|
|
5
|
+
* host's base reset. A layered copy loses to a later unlayered reset (the
|
|
6
|
+
* marker's margin-inline-start: auto, the tick, title and label paddings and
|
|
7
|
+
* margins, the depth indents), and the ticks then sit over the prose.
|
|
8
|
+
*
|
|
9
|
+
* Moved from the Workspaces app's `ws-markdown-contents-*` rules (approved
|
|
10
|
+
* 2026-10-03). A stable marker dock sits in the document's existing right
|
|
11
|
+
* padding without participating in layout. The Contents surface opens to its
|
|
12
|
+
* LEFT and may overlay the document, never a docked comments column. Pointer
|
|
13
|
+
* events stay on the real navigation buttons; selection and scrolling remain
|
|
14
|
+
* untouched. Plain CSS, no Tailwind. Tokens are the host's (Workspaces defines
|
|
15
|
+
* them all); each has a fallback.
|
|
16
|
+
*/
|
|
17
|
+
@keyframes pn-contents-rail-enter {
|
|
18
|
+
from {
|
|
19
|
+
opacity: 0;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.pn-contents-rail {
|
|
24
|
+
--pn-contents-dock-width: 36px;
|
|
25
|
+
--pn-contents-panel-gap: 6px;
|
|
26
|
+
--pn-contents-panel-width: 256px;
|
|
27
|
+
|
|
28
|
+
position: fixed;
|
|
29
|
+
z-index: 20;
|
|
30
|
+
translate: 0 -50%;
|
|
31
|
+
width: calc(
|
|
32
|
+
var(--pn-contents-dock-width) + var(--pn-contents-panel-gap) + var(--pn-contents-panel-width)
|
|
33
|
+
);
|
|
34
|
+
pointer-events: none;
|
|
35
|
+
animation: pn-contents-rail-enter var(--motion-duration-fast, 120ms)
|
|
36
|
+
var(--ease-enter, cubic-bezier(0.22, 1, 0.36, 1)) both;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.pn-contents-rail-dock {
|
|
40
|
+
position: absolute;
|
|
41
|
+
top: 0;
|
|
42
|
+
right: 0;
|
|
43
|
+
bottom: 0;
|
|
44
|
+
width: var(--pn-contents-dock-width);
|
|
45
|
+
pointer-events: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* The floating surface (Workspaces' `floating-surface`: popover fill, no drawn
|
|
49
|
+
* border, the edge is the hairline ring in the menu shadow). */
|
|
50
|
+
.pn-contents-rail-surface {
|
|
51
|
+
position: absolute;
|
|
52
|
+
top: -40px;
|
|
53
|
+
bottom: -8px;
|
|
54
|
+
left: 0;
|
|
55
|
+
width: var(--pn-contents-panel-width);
|
|
56
|
+
border-radius: var(--geometry-floating-radius, 10px);
|
|
57
|
+
background: var(--popover);
|
|
58
|
+
color: var(--popover-foreground);
|
|
59
|
+
box-shadow: var(
|
|
60
|
+
--shadow-menu,
|
|
61
|
+
0 0 0 1px color-mix(in oklch, var(--foreground) 8%, transparent),
|
|
62
|
+
0 12px 32px -8px color-mix(in oklch, var(--foreground) 22%, transparent)
|
|
63
|
+
);
|
|
64
|
+
transform: translateX(4px) scale(0.98);
|
|
65
|
+
transform-origin: right center;
|
|
66
|
+
opacity: 0;
|
|
67
|
+
pointer-events: none;
|
|
68
|
+
transition:
|
|
69
|
+
opacity var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
|
|
70
|
+
transform var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* The section eyebrow (Workspaces' EYEBROW: 12 px, weight 500). */
|
|
74
|
+
.pn-contents-rail-title {
|
|
75
|
+
position: absolute;
|
|
76
|
+
top: -32px;
|
|
77
|
+
left: 8px;
|
|
78
|
+
display: flex;
|
|
79
|
+
width: calc(var(--pn-contents-panel-width) - 16px);
|
|
80
|
+
height: 28px;
|
|
81
|
+
align-items: center;
|
|
82
|
+
padding-inline: 4px;
|
|
83
|
+
color: var(--foreground);
|
|
84
|
+
font-size: 12px;
|
|
85
|
+
font-weight: 500;
|
|
86
|
+
line-height: 20px;
|
|
87
|
+
transform: translateX(4px);
|
|
88
|
+
opacity: 0;
|
|
89
|
+
pointer-events: none;
|
|
90
|
+
transition:
|
|
91
|
+
opacity var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
|
|
92
|
+
transform var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* A host's one control at the right of the open panel's title (panelAction). */
|
|
96
|
+
.pn-contents-rail-action {
|
|
97
|
+
position: absolute;
|
|
98
|
+
z-index: 2;
|
|
99
|
+
top: -32px;
|
|
100
|
+
right: calc(var(--pn-contents-dock-width) + var(--pn-contents-panel-gap) + 8px);
|
|
101
|
+
display: flex;
|
|
102
|
+
height: 28px;
|
|
103
|
+
align-items: center;
|
|
104
|
+
pointer-events: auto;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.pn-contents-rail[data-revealed] .pn-contents-rail-surface,
|
|
108
|
+
.pn-contents-rail[data-revealed] .pn-contents-rail-title {
|
|
109
|
+
transform: translateX(0) scale(1);
|
|
110
|
+
opacity: 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.pn-contents-rail[data-revealed] .pn-contents-rail-title {
|
|
114
|
+
transition-delay: var(--motion-duration-basic, 150ms), var(--motion-duration-basic, 150ms);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.pn-contents-rail-list {
|
|
118
|
+
position: relative;
|
|
119
|
+
z-index: 1;
|
|
120
|
+
width: 100%;
|
|
121
|
+
/* The closed rail renders at most ten sampled document spans, so it never
|
|
122
|
+
* needs an internal scroll position. Shorter maps retain their natural
|
|
123
|
+
* height and stay centered by the rail's fixed midpoint anchor. */
|
|
124
|
+
max-height: 160px;
|
|
125
|
+
overflow-x: hidden;
|
|
126
|
+
overflow-y: hidden;
|
|
127
|
+
overscroll-behavior: contain;
|
|
128
|
+
scrollbar-width: none;
|
|
129
|
+
touch-action: none;
|
|
130
|
+
pointer-events: none;
|
|
131
|
+
transition: max-height var(--motion-duration-basic, 150ms) var(--ease-exit, cubic-bezier(0.4, 0, 0.2, 1));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.pn-contents-rail[data-revealed] .pn-contents-rail-list {
|
|
135
|
+
max-height: min(62vh, 560px);
|
|
136
|
+
overflow-y: auto;
|
|
137
|
+
transition-duration: var(--motion-duration-relaxed, 300ms);
|
|
138
|
+
transition-timing-function: var(--ease-enter, cubic-bezier(0.22, 1, 0.36, 1));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.pn-contents-rail-list::-webkit-scrollbar {
|
|
142
|
+
display: none;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.pn-contents-rail-marker {
|
|
146
|
+
--pn-contents-line-width: 26px;
|
|
147
|
+
|
|
148
|
+
position: relative;
|
|
149
|
+
display: flex;
|
|
150
|
+
width: var(--pn-contents-dock-width);
|
|
151
|
+
height: 16px;
|
|
152
|
+
flex-direction: row-reverse;
|
|
153
|
+
align-items: center;
|
|
154
|
+
justify-content: flex-start;
|
|
155
|
+
overflow: hidden;
|
|
156
|
+
margin-inline-start: auto;
|
|
157
|
+
padding: 0;
|
|
158
|
+
border: 0;
|
|
159
|
+
background: transparent;
|
|
160
|
+
cursor: pointer;
|
|
161
|
+
pointer-events: auto;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.pn-contents-rail[data-revealed] .pn-contents-rail-marker {
|
|
165
|
+
width: 100%;
|
|
166
|
+
overflow: visible;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.pn-contents-rail-tick {
|
|
170
|
+
display: flex;
|
|
171
|
+
width: var(--pn-contents-dock-width);
|
|
172
|
+
flex: none;
|
|
173
|
+
align-items: center;
|
|
174
|
+
justify-content: flex-start;
|
|
175
|
+
padding-inline-start: 4px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.pn-contents-rail-line {
|
|
179
|
+
--marker-progress: 0;
|
|
180
|
+
|
|
181
|
+
display: block;
|
|
182
|
+
width: var(--pn-contents-line-width);
|
|
183
|
+
height: 2px;
|
|
184
|
+
transform: scaleX(calc(0.6154 + 0.3846 * var(--marker-progress)));
|
|
185
|
+
transform-origin: left center;
|
|
186
|
+
background: var(--muted-foreground);
|
|
187
|
+
opacity: 0.4;
|
|
188
|
+
pointer-events: none;
|
|
189
|
+
transition: none;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* The frequently scanned closed map updates immediately. The local lens is
|
|
193
|
+
* motion reserved for the explicitly opened Contents state. */
|
|
194
|
+
.pn-contents-rail[data-revealed] .pn-contents-rail-line {
|
|
195
|
+
transition:
|
|
196
|
+
transform var(--motion-duration-basic, 150ms)
|
|
197
|
+
linear(0, 0.398 10%, 0.682 20%, 0.843 30%, 0.925 40%, 0.972 50%, 1.004 60%, 1.008 70%, 1.003 80%, 1),
|
|
198
|
+
opacity var(--motion-duration-basic, 150ms) ease,
|
|
199
|
+
background-color var(--motion-duration-basic, 150ms) ease;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* H1:H2 is 12px:10px with the shorter line inset 2px toward the document, at
|
|
203
|
+
* a deliberately longer scale, then the same step for H3 and H4. */
|
|
204
|
+
.pn-contents-rail-marker[data-level="2"] {
|
|
205
|
+
--pn-contents-line-width: 22px;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.pn-contents-rail-marker[data-level="3"] {
|
|
209
|
+
--pn-contents-line-width: 18px;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.pn-contents-rail-marker[data-level="4"] {
|
|
213
|
+
--pn-contents-line-width: 14px;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.pn-contents-rail-marker[data-current] .pn-contents-rail-line {
|
|
217
|
+
background: var(--primary);
|
|
218
|
+
opacity: 0.68;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.pn-contents-rail-marker[data-highlighted] .pn-contents-rail-line,
|
|
222
|
+
.pn-contents-rail-marker:focus-visible .pn-contents-rail-line {
|
|
223
|
+
--marker-progress: 1;
|
|
224
|
+
background: var(--foreground);
|
|
225
|
+
opacity: 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.pn-contents-rail-marker:has(+ .pn-contents-rail-marker[data-highlighted]) .pn-contents-rail-line,
|
|
229
|
+
.pn-contents-rail-marker:has(+ .pn-contents-rail-marker:focus-visible) .pn-contents-rail-line,
|
|
230
|
+
.pn-contents-rail-marker[data-highlighted] + .pn-contents-rail-marker .pn-contents-rail-line,
|
|
231
|
+
.pn-contents-rail-marker:focus-visible + .pn-contents-rail-marker .pn-contents-rail-line {
|
|
232
|
+
--marker-progress: 0.7;
|
|
233
|
+
opacity: 0.78;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker[data-highlighted]) .pn-contents-rail-line,
|
|
237
|
+
.pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker:focus-visible) .pn-contents-rail-line,
|
|
238
|
+
.pn-contents-rail-marker[data-highlighted] + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line,
|
|
239
|
+
.pn-contents-rail-marker:focus-visible + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line {
|
|
240
|
+
--marker-progress: 0.4;
|
|
241
|
+
opacity: 0.58;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker[data-highlighted]) .pn-contents-rail-line,
|
|
245
|
+
.pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker:focus-visible) .pn-contents-rail-line,
|
|
246
|
+
.pn-contents-rail-marker[data-highlighted] + .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line,
|
|
247
|
+
.pn-contents-rail-marker:focus-visible + .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line {
|
|
248
|
+
--marker-progress: 0.2;
|
|
249
|
+
opacity: 0.46;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.pn-contents-rail-label {
|
|
253
|
+
display: block;
|
|
254
|
+
width: calc(var(--pn-contents-panel-width) - 16px);
|
|
255
|
+
height: 16px;
|
|
256
|
+
min-width: 0;
|
|
257
|
+
flex: none;
|
|
258
|
+
overflow: hidden;
|
|
259
|
+
margin-inline-end: calc(var(--pn-contents-panel-gap) + 8px);
|
|
260
|
+
padding-inline: 4px;
|
|
261
|
+
border-radius: var(--geometry-structure-radius, 4px);
|
|
262
|
+
background: transparent;
|
|
263
|
+
color: var(--muted-foreground);
|
|
264
|
+
font-size: inherit;
|
|
265
|
+
font-weight: 400;
|
|
266
|
+
line-height: 16px;
|
|
267
|
+
text-align: left;
|
|
268
|
+
text-overflow: ellipsis;
|
|
269
|
+
white-space: nowrap;
|
|
270
|
+
opacity: 0;
|
|
271
|
+
transform: translateX(-4px);
|
|
272
|
+
pointer-events: none;
|
|
273
|
+
transition:
|
|
274
|
+
opacity var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
|
|
275
|
+
transform var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
|
|
276
|
+
color var(--motion-duration-fast, 120ms) ease,
|
|
277
|
+
background-color var(--motion-duration-fast, 120ms) ease;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.pn-contents-rail-marker[data-depth="1"] .pn-contents-rail-label {
|
|
281
|
+
padding-inline-start: 16px;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.pn-contents-rail-marker[data-depth="2"] .pn-contents-rail-label {
|
|
285
|
+
padding-inline-start: 28px;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.pn-contents-rail-marker[data-depth="3"] .pn-contents-rail-label {
|
|
289
|
+
padding-inline-start: 40px;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.pn-contents-rail[data-revealed] .pn-contents-rail-label {
|
|
293
|
+
opacity: 1;
|
|
294
|
+
transform: translateX(0);
|
|
295
|
+
transition-delay: var(--motion-duration-basic, 150ms), var(--motion-duration-basic, 150ms), 0ms, 0ms;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.pn-contents-rail-marker[data-current] .pn-contents-rail-label {
|
|
299
|
+
color: var(--foreground);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.pn-contents-rail-marker[data-highlighted] .pn-contents-rail-label,
|
|
303
|
+
.pn-contents-rail-marker:focus-visible .pn-contents-rail-label {
|
|
304
|
+
background: var(--surface-hover, color-mix(in oklch, var(--foreground) 6%, transparent));
|
|
305
|
+
color: var(--foreground);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.pn-contents-rail:focus-within .pn-contents-rail-surface,
|
|
309
|
+
.pn-contents-rail:focus-within .pn-contents-rail-title,
|
|
310
|
+
.pn-contents-rail:focus-within .pn-contents-rail-label,
|
|
311
|
+
.pn-contents-rail:focus-within .pn-contents-rail-list {
|
|
312
|
+
transition-duration: 0ms;
|
|
313
|
+
transition-delay: 0ms;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@media (prefers-reduced-motion: reduce) {
|
|
317
|
+
.pn-contents-rail {
|
|
318
|
+
animation: none;
|
|
319
|
+
}
|
|
320
|
+
.pn-contents-rail-line {
|
|
321
|
+
transition: none;
|
|
322
|
+
}
|
|
323
|
+
.pn-contents-rail-surface,
|
|
324
|
+
.pn-contents-rail-title,
|
|
325
|
+
.pn-contents-rail-label,
|
|
326
|
+
.pn-contents-rail[data-revealed] .pn-contents-rail-surface,
|
|
327
|
+
.pn-contents-rail[data-revealed] .pn-contents-rail-title,
|
|
328
|
+
.pn-contents-rail[data-revealed] .pn-contents-rail-label {
|
|
329
|
+
transform: none;
|
|
330
|
+
transition: opacity var(--motion-duration-fast, 120ms) ease;
|
|
331
|
+
transition-delay: 0ms;
|
|
332
|
+
}
|
|
333
|
+
.pn-contents-rail-list,
|
|
334
|
+
.pn-contents-rail[data-revealed] .pn-contents-rail-list {
|
|
335
|
+
transition: none;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { activeHeadingBinding, bindHeadings, type HeadingBinding } from '../utils/headingScrollSpy';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The block id of the heading being read in `scrollViewport`, by the contents
|
|
6
|
+
* rail's own scroll math (utils/headingScrollSpy): cached heading tops, read
|
|
7
|
+
* on every scroll frame, the last heading at the document's end. For a
|
|
8
|
+
* Contents list on the rail's surface (`DocumentContents`), so the two never
|
|
9
|
+
* disagree. Plan review's list keeps `useActiveSection`.
|
|
10
|
+
*
|
|
11
|
+
* Reads at once on mount (a sheet opened mid-document marks the section being
|
|
12
|
+
* read on its first paint), and re-binds when the document's elements change
|
|
13
|
+
* (an edit can replace the headings and keep their count) or resize. Before
|
|
14
|
+
* any heading is drawn the answer stays what it was.
|
|
15
|
+
*/
|
|
16
|
+
export function useHeadingScrollSpy(
|
|
17
|
+
scrollViewport: HTMLElement | null,
|
|
18
|
+
blockIds: readonly string[],
|
|
19
|
+
): string | null {
|
|
20
|
+
const idsKey = blockIds.join('\n');
|
|
21
|
+
// Stable while the ids are the same, whatever array the caller passes.
|
|
22
|
+
const ids = useMemo(() => (idsKey === '' ? [] : idsKey.split('\n')), [idsKey]);
|
|
23
|
+
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
|
24
|
+
const bindingsRef = useRef<readonly HeadingBinding[]>([]);
|
|
25
|
+
|
|
26
|
+
const refresh = useCallback(() => {
|
|
27
|
+
if (scrollViewport === null) return;
|
|
28
|
+
const binding = activeHeadingBinding(bindingsRef.current, scrollViewport);
|
|
29
|
+
if (binding !== null) setActiveIndex(binding.itemIndex);
|
|
30
|
+
}, [scrollViewport]);
|
|
31
|
+
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
setActiveIndex(null);
|
|
34
|
+
bindingsRef.current = [];
|
|
35
|
+
if (scrollViewport === null || ids.length === 0) return;
|
|
36
|
+
const rebind = () => {
|
|
37
|
+
bindingsRef.current = bindHeadings(scrollViewport, ids);
|
|
38
|
+
refresh();
|
|
39
|
+
};
|
|
40
|
+
rebind();
|
|
41
|
+
|
|
42
|
+
let scrollFrame: number | null = null;
|
|
43
|
+
const onScroll = () => {
|
|
44
|
+
if (scrollFrame !== null) return;
|
|
45
|
+
scrollFrame = requestAnimationFrame(() => {
|
|
46
|
+
scrollFrame = null;
|
|
47
|
+
refresh();
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
let bindFrame: number | null = null;
|
|
51
|
+
const scheduleRebind = () => {
|
|
52
|
+
if (bindFrame !== null) cancelAnimationFrame(bindFrame);
|
|
53
|
+
bindFrame = requestAnimationFrame(() => {
|
|
54
|
+
bindFrame = null;
|
|
55
|
+
rebind();
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
scrollViewport.addEventListener('scroll', onScroll, { passive: true });
|
|
59
|
+
window.addEventListener('resize', scheduleRebind);
|
|
60
|
+
const resizeObserver =
|
|
61
|
+
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(scheduleRebind);
|
|
62
|
+
resizeObserver?.observe(scrollViewport);
|
|
63
|
+
const mutationObserver =
|
|
64
|
+
typeof MutationObserver === 'undefined' ? null : new MutationObserver(scheduleRebind);
|
|
65
|
+
mutationObserver?.observe(scrollViewport, { childList: true, subtree: true });
|
|
66
|
+
return () => {
|
|
67
|
+
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
|
68
|
+
if (bindFrame !== null) cancelAnimationFrame(bindFrame);
|
|
69
|
+
scrollViewport.removeEventListener('scroll', onScroll);
|
|
70
|
+
window.removeEventListener('resize', scheduleRebind);
|
|
71
|
+
resizeObserver?.disconnect();
|
|
72
|
+
mutationObserver?.disconnect();
|
|
73
|
+
};
|
|
74
|
+
}, [ids, refresh, scrollViewport]);
|
|
75
|
+
|
|
76
|
+
return activeIndex === null ? null : (ids[activeIndex] ?? null);
|
|
77
|
+
}
|