@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
|
@@ -60,13 +60,41 @@ export interface TocItem {
|
|
|
60
60
|
annotationCount: number;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The headings a Contents list shows, in document order: levels 1 to
|
|
65
|
+
* `maxLevel` (3 by default, plan review's list). The one place that picks
|
|
66
|
+
* them: `TableOfContents`, plan review's "has entries" check and the contents
|
|
67
|
+
* rail (`MarkdownContentsRail`, levels 1 to 4) all read it.
|
|
68
|
+
*/
|
|
69
|
+
export function tocHeadings(blocks: readonly Block[], maxLevel = 3): Block[] {
|
|
70
|
+
return blocks
|
|
71
|
+
.filter(b => b.type === 'heading' && (b.level ?? 0) <= maxLevel)
|
|
72
|
+
.sort((a, b) => a.order - b.order);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The contents rail lists headings of levels 1 to this (`MarkdownContentsRail`). */
|
|
76
|
+
export const CONTENTS_RAIL_MAX_LEVEL = 4;
|
|
77
|
+
/** The contents rail shows for a document with at least this many of them. */
|
|
78
|
+
export const CONTENTS_RAIL_MIN_HEADINGS = 4;
|
|
79
|
+
/** The contents rail shows in a scroll element at least this wide (and with a 68 px gutter). */
|
|
80
|
+
export const CONTENTS_RAIL_MIN_PANE_WIDTH = 480;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether a document gets the contents rail: at least
|
|
84
|
+
* `CONTENTS_RAIL_MIN_HEADINGS` headings of levels 1 to
|
|
85
|
+
* `CONTENTS_RAIL_MAX_LEVEL`. Kept out of the rail's own module, so a host can
|
|
86
|
+
* decide without loading the (lazy) rail.
|
|
87
|
+
*/
|
|
88
|
+
export function hasContentsRailHeadings(blocks: readonly Block[]): boolean {
|
|
89
|
+
return tocHeadings(blocks, CONTENTS_RAIL_MAX_LEVEL).length >= CONTENTS_RAIL_MIN_HEADINGS;
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
export function buildTocHierarchy(
|
|
64
93
|
blocks: Block[],
|
|
65
|
-
annotationCounts: Map<string, number
|
|
94
|
+
annotationCounts: Map<string, number>,
|
|
95
|
+
maxLevel = 3
|
|
66
96
|
): TocItem[] {
|
|
67
|
-
const headings = blocks
|
|
68
|
-
.filter(b => b.type === 'heading' && (b.level ?? 0) <= 3)
|
|
69
|
-
.sort((a, b) => a.order - b.order);
|
|
97
|
+
const headings = tocHeadings(blocks, maxLevel);
|
|
70
98
|
|
|
71
99
|
const root: TocItem[] = [];
|
|
72
100
|
const stack: TocItem[] = [];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Moved from the Workspaces app's `block-parse.ts` with its contents rail
|
|
2
|
+
// (MarkdownContentsRail, approved 2026-10-03). Port: none; the module-level
|
|
3
|
+
// cache is the same.
|
|
4
|
+
|
|
5
|
+
import type { Block } from '../types';
|
|
6
|
+
import { parseMarkdownToBlocks } from './parser';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ONE parse per document body, shared across consumers: a host's Viewer and
|
|
10
|
+
* its contents rail render from the same body string, and each would
|
|
11
|
+
* otherwise run its own full `parseMarkdownToBlocks`. This module-level cache
|
|
12
|
+
* keys on the content string (value equality: the call sites derive `content`
|
|
13
|
+
* independently via `extractFrontmatter`), so whoever parses first feeds
|
|
14
|
+
* everyone.
|
|
15
|
+
*
|
|
16
|
+
* Sharing one blocks array is safe: the consumers treat the parser output as
|
|
17
|
+
* immutable.
|
|
18
|
+
*
|
|
19
|
+
* Capacity 4 with hit-refresh recency: a page holds at most a couple of live
|
|
20
|
+
* bodies at once; the cap only exists so navigating across documents cannot
|
|
21
|
+
* grow it without bound.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_ENTRIES = 4;
|
|
24
|
+
const cache = new Map<string, Block[]>();
|
|
25
|
+
|
|
26
|
+
/** Test probe: counts real parses (cache misses). */
|
|
27
|
+
export const blockParseProbe = { parses: 0 };
|
|
28
|
+
|
|
29
|
+
export function parseBlocksShared(content: string): Block[] {
|
|
30
|
+
const hit = cache.get(content);
|
|
31
|
+
if (hit !== undefined) {
|
|
32
|
+
// Refresh recency (Map preserves insertion order; oldest is evicted).
|
|
33
|
+
cache.delete(content);
|
|
34
|
+
cache.set(content, hit);
|
|
35
|
+
return hit;
|
|
36
|
+
}
|
|
37
|
+
blockParseProbe.parses += 1;
|
|
38
|
+
const blocks = parseMarkdownToBlocks(content);
|
|
39
|
+
cache.set(content, blocks);
|
|
40
|
+
if (cache.size > MAX_ENTRIES) {
|
|
41
|
+
const oldest = cache.keys().next().value;
|
|
42
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
43
|
+
}
|
|
44
|
+
return blocks;
|
|
45
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contents rail's scroll-spy: which heading is being read, from the
|
|
3
|
+
* headings' cached tops and the scroll position, on every scroll frame.
|
|
4
|
+
*
|
|
5
|
+
* Moved as it was from the Workspaces app's
|
|
6
|
+
* `apps/web/src/plannotator/MarkdownContentsRail.tsx` (approved 2026-10-03):
|
|
7
|
+
* `topInsideViewport`, `currentBinding` (the binary search) and the rule that
|
|
8
|
+
* the last heading is the current one at the document's end. One change: the
|
|
9
|
+
* activation line is 80 px below the top (it was 52), plan review's
|
|
10
|
+
* `TableOfContents` scroll offset, so a pick lands where plan review's does.
|
|
11
|
+
*
|
|
12
|
+
* Plan review's own list keeps `hooks/useActiveSection` (an
|
|
13
|
+
* IntersectionObserver band). One mechanism per surface: the rail, and a
|
|
14
|
+
* Contents list on the rail's surface (`DocumentContents`), use this one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** How far below the scroll element's top a heading becomes the current one. */
|
|
18
|
+
export const CONTENTS_ACTIVATION_OFFSET = 80;
|
|
19
|
+
|
|
20
|
+
/** A heading drawn in the scroll element, with its top inside the scrolled content. */
|
|
21
|
+
export interface HeadingBinding {
|
|
22
|
+
/** The heading's index in the caller's own list. */
|
|
23
|
+
readonly itemIndex: number;
|
|
24
|
+
readonly node: HTMLElement;
|
|
25
|
+
readonly top: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function topInsideViewport(node: HTMLElement, viewport: HTMLElement): number {
|
|
29
|
+
const viewportRect = viewport.getBoundingClientRect();
|
|
30
|
+
return node.getBoundingClientRect().top - viewportRect.top + viewport.scrollTop;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Bind each listed heading id to its drawn heading (`[data-block-type="heading"]`
|
|
35
|
+
* with that `data-block-id`), sorted by top. Only the caller's own ids are
|
|
36
|
+
* bound, so a heading of an embedded document (its ids are namespaced) is never
|
|
37
|
+
* read; when an id is drawn twice, the first one in document order is.
|
|
38
|
+
*/
|
|
39
|
+
export function bindHeadings(
|
|
40
|
+
viewport: HTMLElement,
|
|
41
|
+
blockIds: readonly string[],
|
|
42
|
+
): HeadingBinding[] {
|
|
43
|
+
const nodesByBlockId = new Map<string, HTMLElement>();
|
|
44
|
+
for (const node of viewport.querySelectorAll<HTMLElement>('[data-block-type="heading"]')) {
|
|
45
|
+
const id = node.dataset.blockId ?? '';
|
|
46
|
+
if (!nodesByBlockId.has(id)) nodesByBlockId.set(id, node);
|
|
47
|
+
}
|
|
48
|
+
const bindings = blockIds.flatMap<HeadingBinding>((blockId, itemIndex) => {
|
|
49
|
+
const node = nodesByBlockId.get(blockId);
|
|
50
|
+
return node === undefined ? [] : [{ itemIndex, node, top: topInsideViewport(node, viewport) }];
|
|
51
|
+
});
|
|
52
|
+
bindings.sort((left, right) => left.top - right.top);
|
|
53
|
+
return bindings;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The last binding whose top is at or above `activationTop`, else the first. */
|
|
57
|
+
export function currentBinding(
|
|
58
|
+
bindings: readonly HeadingBinding[],
|
|
59
|
+
activationTop: number,
|
|
60
|
+
): HeadingBinding {
|
|
61
|
+
let low = 0;
|
|
62
|
+
let high = bindings.length - 1;
|
|
63
|
+
let result = bindings[0]!;
|
|
64
|
+
while (low <= high) {
|
|
65
|
+
const middle = (low + high) >> 1;
|
|
66
|
+
const candidate = bindings[middle]!;
|
|
67
|
+
if (candidate.top <= activationTop) {
|
|
68
|
+
result = candidate;
|
|
69
|
+
low = middle + 1;
|
|
70
|
+
} else {
|
|
71
|
+
high = middle - 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The heading being read: the last one at or above the activation line, or
|
|
79
|
+
* the last heading once the document is scrolled to its end. `null` with no
|
|
80
|
+
* bindings.
|
|
81
|
+
*/
|
|
82
|
+
export function activeHeadingBinding(
|
|
83
|
+
bindings: readonly HeadingBinding[],
|
|
84
|
+
viewport: HTMLElement,
|
|
85
|
+
): HeadingBinding | null {
|
|
86
|
+
if (bindings.length === 0) return null;
|
|
87
|
+
const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
|
|
88
|
+
const atDocumentEnd = maxScrollTop > 0 && viewport.scrollTop >= maxScrollTop - 1;
|
|
89
|
+
return atDocumentEnd
|
|
90
|
+
? bindings.at(-1)!
|
|
91
|
+
: currentBinding(bindings, viewport.scrollTop + CONTENTS_ACTIVATION_OFFSET);
|
|
92
|
+
}
|