@plannotator/ui 0.54.0 → 0.56.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 +102 -6
- package/README.md +128 -3
- package/components/AnnotationPanel.tsx +2 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +4 -1
- package/components/CodeFilePopout.tsx +6 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/ComposerTokenTextarea.tsx +43 -2
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocBadges.tsx +47 -35
- package/components/DocumentContents.tsx +74 -0
- package/components/ImageLightbox.tsx +121 -63
- package/components/ImageZoomStage.tsx +32 -4
- package/components/InlineMarkdown.tsx +15 -150
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/Settings.tsx +2 -1
- package/components/TableOfContents.tsx +22 -7
- package/components/Viewer.tsx +51 -19
- package/components/blocks/QuestionBlock.tsx +14 -4
- package/components/chat/ChatComposer.tsx +192 -0
- package/components/chat/ChatFrame.tsx +524 -0
- package/components/chat/ChatTranscript.tsx +422 -0
- package/components/chat/chatMarkdown.ts +436 -0
- package/components/chat/chatRecipes.tsx +39 -0
- package/components/chat/chatWindowStore.ts +196 -0
- package/components/chat/index.ts +72 -0
- package/components/diagram/DiagramCanvas.tsx +7 -0
- package/components/diagram/DiagramComposer.tsx +15 -3
- package/components/diagram/DiagramViewer.tsx +1 -0
- package/components/docBadgesWidth.ts +29 -0
- package/components/html-viewer/HtmlViewer.tsx +36 -1
- package/components/imageZoom.ts +21 -0
- package/components/inbox-thread/FileName.tsx +118 -0
- package/components/inbox-thread/GuideCard.tsx +39 -0
- package/components/inbox-thread/ReaskNote.tsx +25 -0
- package/components/inbox-thread/SkillsLine.tsx +40 -0
- package/components/inbox-thread/Tip.tsx +105 -0
- package/components/inbox-thread/icons.tsx +262 -0
- package/components/inbox-thread/reask.ts +222 -0
- package/contents-rail.css +337 -0
- package/hooks/useActiveSection.ts +46 -63
- package/hooks/useAnnotationHighlighter.ts +40 -1
- package/hooks/useFileBrowser.ts +4 -3
- 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/inbox-thread.css +4542 -0
- package/package.json +13 -4
- package/styles.css +1 -1
- package/theme.css +1 -0
- package/themes/tokyo-night-moon.css +34 -0
- package/utils/aiChatFormat.ts +32 -11
- package/utils/annotationHelpers.ts +32 -4
- package/utils/blockParse.ts +45 -0
- package/utils/headingScrollSpy.ts +105 -0
- package/utils/syntaxTheme.ts +1 -0
- package/utils/themeRegistry.ts +11 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
|
|
4
|
+
import { useFenceTheme } from "../hooks/useFenceTheme";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The floating code window a code-file reference opens on hover: a few lines
|
|
8
|
+
* of one file, syntax highlighted, with line numbers. Portalled to the body
|
|
9
|
+
* and positioned under (or above) its anchor.
|
|
10
|
+
*
|
|
11
|
+
* Used by plan / annotate code-file links (`CodeFileLink` in InlineMarkdown,
|
|
12
|
+
* the exact range only) and by code review's Call Flow locations
|
|
13
|
+
* (`CallFlowTreeView`, with `contextLines` around the target).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Lines highlighted above the shown ones so a range opening inside a block comment or string tokenises as one. */
|
|
17
|
+
const HIGHLIGHT_LEAD_IN_LINES = 40;
|
|
18
|
+
/** Gap kept between the window and the viewport edge. */
|
|
19
|
+
const VIEWPORT_MARGIN = 8;
|
|
20
|
+
|
|
21
|
+
/** The highlighter language for a file path, by extension; undefined renders plain text. */
|
|
22
|
+
export function codeSnippetLanguage(filepath: string): string | undefined {
|
|
23
|
+
const ext = filepath.split('.').pop()?.toLowerCase();
|
|
24
|
+
const map: Record<string, string> = {
|
|
25
|
+
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
|
|
26
|
+
mts: 'typescript', cts: 'typescript', mjs: 'javascript', cjs: 'javascript',
|
|
27
|
+
py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
|
|
28
|
+
css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml',
|
|
29
|
+
sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown',
|
|
30
|
+
html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin',
|
|
31
|
+
lua: 'lua', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cs: 'csharp',
|
|
32
|
+
scala: 'scala', dart: 'dart', ex: 'elixir', exs: 'elixir', zig: 'zig',
|
|
33
|
+
vue: 'vue', svelte: 'svelte', astro: 'astro', php: 'php',
|
|
34
|
+
};
|
|
35
|
+
return ext ? map[ext] : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CodeSnippetPreviewProps {
|
|
39
|
+
anchorEl: HTMLElement | null;
|
|
40
|
+
/** The whole file; the preview slices the lines it shows. */
|
|
41
|
+
contents: string;
|
|
42
|
+
filepath: string;
|
|
43
|
+
/** One-based first line of the target range. */
|
|
44
|
+
line: number;
|
|
45
|
+
lineEnd?: number;
|
|
46
|
+
/** Lines shown above and below the target range, the target rows marked. 0 (default) shows the range alone. */
|
|
47
|
+
contextLines?: number;
|
|
48
|
+
/** Header label; defaults to the file's basename. */
|
|
49
|
+
title?: string;
|
|
50
|
+
/** Called when the page or a scroller scrolls: the window is placed once and would drift from its anchor. */
|
|
51
|
+
onDismiss?: () => void;
|
|
52
|
+
onMouseEnter?: () => void;
|
|
53
|
+
onMouseLeave?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const CodeSnippetPreview: React.FC<CodeSnippetPreviewProps> = ({
|
|
57
|
+
anchorEl,
|
|
58
|
+
contents,
|
|
59
|
+
filepath,
|
|
60
|
+
line,
|
|
61
|
+
lineEnd,
|
|
62
|
+
contextLines = 0,
|
|
63
|
+
title,
|
|
64
|
+
onDismiss,
|
|
65
|
+
onMouseEnter,
|
|
66
|
+
onMouseLeave,
|
|
67
|
+
}) => {
|
|
68
|
+
const allLines = contents.split('\n');
|
|
69
|
+
const targetStart = Math.max(0, line - 1);
|
|
70
|
+
const targetEnd = Math.min(allLines.length, lineEnd ?? line);
|
|
71
|
+
const start = Math.max(0, targetStart - contextLines);
|
|
72
|
+
const end = Math.min(allLines.length, targetEnd + contextLines);
|
|
73
|
+
const snippet = allLines.slice(start, end).join('\n');
|
|
74
|
+
// Highlight from a little earlier than the first shown line: a range that
|
|
75
|
+
// starts inside a block comment or template literal would otherwise colour
|
|
76
|
+
// that text as code.
|
|
77
|
+
const highlightStart = Math.max(0, start - HIGHLIGHT_LEAD_IN_LINES);
|
|
78
|
+
const highlightSource = allLines.slice(highlightStart, end).join('\n');
|
|
79
|
+
const marksTarget = contextLines > 0;
|
|
80
|
+
|
|
81
|
+
const fenceTheme = useFenceTheme();
|
|
82
|
+
// Bumped once the grammar is attached, to re-run the memo below with the
|
|
83
|
+
// highlighter warm. Until then the snippet renders as plain text — no
|
|
84
|
+
// auto-detection, and an unknown extension simply stays plain.
|
|
85
|
+
const [highlighterGeneration, setHighlighterGeneration] = useState(0);
|
|
86
|
+
const lang = codeSnippetLanguage(filepath);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!lang) return;
|
|
90
|
+
let cancelled = false;
|
|
91
|
+
void ensureHighlight(lang, fenceTheme).then((ok) => {
|
|
92
|
+
if (ok && !cancelled) setHighlighterGeneration((n) => n + 1);
|
|
93
|
+
});
|
|
94
|
+
return () => { cancelled = true; };
|
|
95
|
+
}, [lang, fenceTheme]);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!onDismiss) return;
|
|
99
|
+
const dismiss = () => onDismiss();
|
|
100
|
+
window.addEventListener('scroll', dismiss, { capture: true, passive: true });
|
|
101
|
+
return () => window.removeEventListener('scroll', dismiss, { capture: true });
|
|
102
|
+
}, [onDismiss]);
|
|
103
|
+
|
|
104
|
+
const highlightedLines = useMemo(() => {
|
|
105
|
+
// Highlight the snippet as one unit so multi-line constructs (block
|
|
106
|
+
// comments, template literals) tokenise correctly, then split back into
|
|
107
|
+
// rows: the highlighter joins lines with "\n" and never emits one inside a
|
|
108
|
+
// span, so the split is exact.
|
|
109
|
+
const html = lang ? highlightToHtml(highlightSource, lang, fenceTheme) : null;
|
|
110
|
+
if (html !== null) return html.split('\n').slice(start - highlightStart);
|
|
111
|
+
return snippet.split('\n').map(text =>
|
|
112
|
+
text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'));
|
|
113
|
+
// highlighterGeneration is the "grammar just became available" signal.
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, [snippet, highlightSource, start, highlightStart, lang, fenceTheme, highlighterGeneration]);
|
|
116
|
+
|
|
117
|
+
// Clamp against the window's rendered width (it sizes to its longest line,
|
|
118
|
+
// up to its max width) so a wide snippet never runs off the right edge.
|
|
119
|
+
const windowRef = useRef<HTMLDivElement | null>(null);
|
|
120
|
+
const [renderedWidth, setRenderedWidth] = useState<number | null>(null);
|
|
121
|
+
useLayoutEffect(() => {
|
|
122
|
+
const width = windowRef.current?.offsetWidth ?? null;
|
|
123
|
+
if (width !== renderedWidth) setRenderedWidth(width);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (!anchorEl) return null;
|
|
127
|
+
|
|
128
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
129
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
130
|
+
const showAbove = spaceBelow < 200 && rect.top > spaceBelow;
|
|
131
|
+
const top = showAbove ? undefined : rect.bottom + 4;
|
|
132
|
+
const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined;
|
|
133
|
+
const left = Math.max(VIEWPORT_MARGIN, Math.min(rect.left, window.innerWidth - (renderedWidth ?? 520) - VIEWPORT_MARGIN));
|
|
134
|
+
|
|
135
|
+
return createPortal(
|
|
136
|
+
<div
|
|
137
|
+
ref={windowRef}
|
|
138
|
+
className="code-snippet-preview-window fixed z-[9999] rounded-lg border border-border bg-card shadow-xl flex flex-col"
|
|
139
|
+
style={{ top, bottom, left, maxWidth: `min(600px, calc(100vw - ${VIEWPORT_MARGIN * 2}px))`, maxHeight: '300px' }}
|
|
140
|
+
data-code-snippet-preview={filepath}
|
|
141
|
+
onMouseEnter={onMouseEnter}
|
|
142
|
+
onMouseLeave={onMouseLeave}
|
|
143
|
+
>
|
|
144
|
+
<div className="px-3 py-1.5 border-b border-border/50 text-[10px] text-muted-foreground font-mono flex items-center justify-between gap-4 flex-shrink-0">
|
|
145
|
+
<span>{title ?? filepath.split('/').pop()}</span>
|
|
146
|
+
<span className="opacity-60">{lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}</span>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
|
|
149
|
+
<table className="border-collapse w-full">
|
|
150
|
+
<tbody>
|
|
151
|
+
{snippet.split('\n').map((_, i) => {
|
|
152
|
+
const index = start + i;
|
|
153
|
+
const target = marksTarget && index >= targetStart && index < targetEnd;
|
|
154
|
+
return (
|
|
155
|
+
<tr
|
|
156
|
+
key={index}
|
|
157
|
+
className={target ? 'bg-primary/15' : 'hover:bg-white/5'}
|
|
158
|
+
data-target-line={target ? 'true' : undefined}
|
|
159
|
+
>
|
|
160
|
+
<td className={`select-none text-right pr-3 pl-3 py-0 align-top font-mono w-8 whitespace-nowrap ${target ? 'text-primary' : 'text-muted-foreground/40'}`} style={{ userSelect: 'none' }}>{index + 1}</td>
|
|
161
|
+
<td
|
|
162
|
+
className="font-mono pr-3 py-0 whitespace-pre"
|
|
163
|
+
dangerouslySetInnerHTML={{ __html: highlightedLines[i] ?? '' }}
|
|
164
|
+
/>
|
|
165
|
+
</tr>
|
|
166
|
+
);
|
|
167
|
+
})}
|
|
168
|
+
</tbody>
|
|
169
|
+
</table>
|
|
170
|
+
</div>
|
|
171
|
+
</div>,
|
|
172
|
+
document.body,
|
|
173
|
+
);
|
|
174
|
+
};
|
|
@@ -207,6 +207,16 @@ export interface ComposerTokenTextareaProps {
|
|
|
207
207
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
208
208
|
'value' | 'onChange' | 'onKeyDown' | 'onSelect' | 'onScroll' | 'className' | 'placeholder'
|
|
209
209
|
>;
|
|
210
|
+
/** Paint on the mirror that is not in the value. Same metric rule as the token spans: it must add no width. */
|
|
211
|
+
mirrorPaint?: ComposerMirrorPaint;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface ComposerMirrorPaint {
|
|
215
|
+
/** Drawn at this offset (past the end of a token it falls inside). */
|
|
216
|
+
at?: number;
|
|
217
|
+
node?: React.ReactNode;
|
|
218
|
+
/** A range of plain text behind a mark. A token inside it keeps its own paint. */
|
|
219
|
+
wash?: { start: number; end: number; className: string };
|
|
210
220
|
}
|
|
211
221
|
|
|
212
222
|
/**
|
|
@@ -241,6 +251,7 @@ export const ComposerTokenTextarea: React.FC<ComposerTokenTextareaProps> = ({
|
|
|
241
251
|
inputClassName,
|
|
242
252
|
mirrorClassName,
|
|
243
253
|
inputProps,
|
|
254
|
+
mirrorPaint,
|
|
244
255
|
}) => {
|
|
245
256
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
246
257
|
const [composing, setComposing] = useState(false);
|
|
@@ -325,15 +336,45 @@ export const ComposerTokenTextarea: React.FC<ComposerTokenTextareaProps> = ({
|
|
|
325
336
|
}
|
|
326
337
|
|
|
327
338
|
const segments: React.ReactNode[] = [];
|
|
339
|
+
let paintDrawn = false;
|
|
340
|
+
/** One run of plain text, cut at the paint's offsets. With no paint, the plain string as ever. */
|
|
341
|
+
const pushPlain = (start: number, end: number) => {
|
|
342
|
+
if (!mirrorPaint) {
|
|
343
|
+
segments.push(value.slice(start, end));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const { at, node, wash } = mirrorPaint;
|
|
347
|
+
const cuts = [start, end, ...[at, wash?.start, wash?.end].filter((cut): cut is number => cut !== undefined && cut > start && cut < end)].sort((a, b) => a - b);
|
|
348
|
+
for (let i = 0; i + 1 < cuts.length; i++) {
|
|
349
|
+
const from = cuts[i]!;
|
|
350
|
+
const to = cuts[i + 1]!;
|
|
351
|
+
if (!paintDrawn && at !== undefined && from >= at) {
|
|
352
|
+
paintDrawn = true;
|
|
353
|
+
segments.push(<React.Fragment key="mirror-paint">{node}</React.Fragment>);
|
|
354
|
+
}
|
|
355
|
+
if (from === to) continue;
|
|
356
|
+
const text = value.slice(from, to);
|
|
357
|
+
segments.push(
|
|
358
|
+
wash && from >= wash.start && to <= wash.end ? (
|
|
359
|
+
<mark key={`mirror-wash-${from}`} className={wash.className}>
|
|
360
|
+
{text}
|
|
361
|
+
</mark>
|
|
362
|
+
) : (
|
|
363
|
+
text
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
328
368
|
let pos = 0;
|
|
329
369
|
for (const range of ranges) {
|
|
330
|
-
if (range.start > pos)
|
|
370
|
+
if (range.start > pos) pushPlain(pos, range.start);
|
|
331
371
|
segments.push(
|
|
332
372
|
renderTokenSpan(range, value.slice(range.start, range.end), mentionChips?.tokenClassName),
|
|
333
373
|
);
|
|
334
374
|
pos = range.end;
|
|
335
375
|
}
|
|
336
|
-
|
|
376
|
+
pushPlain(pos, value.length);
|
|
377
|
+
if (mirrorPaint?.at !== undefined && !paintDrawn) segments.push(<React.Fragment key="mirror-paint">{mirrorPaint.node}</React.Fragment>);
|
|
337
378
|
|
|
338
379
|
return (
|
|
339
380
|
<div className="relative">
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Moved from the Workspaces app's DeferredMarkdownContentsRail with its
|
|
2
|
+
// contents rail (MarkdownContentsRail, approved 2026-10-03). Port: the props
|
|
3
|
+
// are the rail's own (`MarkdownContentsRailProps`), passed through.
|
|
4
|
+
|
|
5
|
+
import { lazy, Suspense, useEffect, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import type { MarkdownContentsRailProps } from './MarkdownContentsRail';
|
|
8
|
+
|
|
9
|
+
const MarkdownContentsRail = lazy(() =>
|
|
10
|
+
import('./MarkdownContentsRail').then((module) => ({
|
|
11
|
+
default: module.MarkdownContentsRail,
|
|
12
|
+
})),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export function scheduleMarkdownContentsRail(callback: () => void): () => void {
|
|
16
|
+
if (typeof window.requestIdleCallback === 'function') {
|
|
17
|
+
const handle = window.requestIdleCallback(callback, { timeout: 2000 });
|
|
18
|
+
return () => window.cancelIdleCallback(handle);
|
|
19
|
+
}
|
|
20
|
+
const handle = window.setTimeout(callback, 0);
|
|
21
|
+
return () => window.clearTimeout(handle);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Load the reading aid after the document is interactive, never in the
|
|
25
|
+
* critical document-paint path. The timeout still guarantees it appears on
|
|
26
|
+
* a busy page; Safari and tests use the zero-delay fallback. */
|
|
27
|
+
export function DeferredMarkdownContentsRail(props: MarkdownContentsRailProps) {
|
|
28
|
+
const [ready, setReady] = useState(false);
|
|
29
|
+
|
|
30
|
+
useEffect(() => scheduleMarkdownContentsRail(() => setReady(true)), []);
|
|
31
|
+
|
|
32
|
+
if (!ready) return null;
|
|
33
|
+
return (
|
|
34
|
+
<Suspense fallback={null}>
|
|
35
|
+
<MarkdownContentsRail {...props} />
|
|
36
|
+
</Suspense>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -11,12 +11,13 @@ export type { DiffFileTreeFile, DiffFileTreeNode, DiffFileTreeStatus } from '../
|
|
|
11
11
|
export type { DiffFileTreeDefaultExpanded } from '../hooks/useDiffFileTreeExpansion';
|
|
12
12
|
|
|
13
13
|
/*
|
|
14
|
-
* The diff file tree's presentational parts
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
14
|
+
* The diff file tree's presentational parts: the atoms below (folder row,
|
|
15
|
+
* file-row content, change letter, +/- counts, indentation) for a host's own
|
|
16
|
+
* rows, and the `DiffFileTree` component at the bottom, the read-only,
|
|
17
|
+
* embeddable tree a host drops beside its own diff list. Plannotator's code
|
|
18
|
+
* review shares `ChangeTypeLetter` and `diffFileTreeIndent` (its All files
|
|
19
|
+
* tree) but draws its own Changes rows since its aligned sidebar (see
|
|
20
|
+
* HANDOFF.md § "Diff file tree").
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
/** Left padding of a row at `depth` (px). */
|
package/components/DocBadges.tsx
CHANGED
|
@@ -19,7 +19,9 @@ import React from 'react';
|
|
|
19
19
|
import { PlanDiffBadge } from './plan-diff/PlanDiffBadge';
|
|
20
20
|
import type { PlanDiffStats } from '../utils/planDiffEngine';
|
|
21
21
|
import { hostnameOrFallback } from '@plannotator/core/project';
|
|
22
|
+
import { basenameFromPath } from '@plannotator/core/source-save';
|
|
22
23
|
import { OpenInAppButton } from './OpenInAppButton';
|
|
24
|
+
import { HostTitle } from './HostTitle';
|
|
23
25
|
|
|
24
26
|
export interface LinkedDocBadgeInfo {
|
|
25
27
|
filepath: string;
|
|
@@ -74,8 +76,11 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
74
76
|
const isHorizontal = layout !== 'column';
|
|
75
77
|
const canOpenInApp =
|
|
76
78
|
!!openInAppPath && !/^https?:\/\//i.test(openInAppPath);
|
|
79
|
+
// Never shrinks: a file name chip beside it gives up its width first.
|
|
77
80
|
const openInButton = canOpenInApp ? (
|
|
78
|
-
<
|
|
81
|
+
<span className="flex flex-shrink-0">
|
|
82
|
+
<OpenInAppButton filePath={openInAppPath} base={null} />
|
|
83
|
+
</span>
|
|
79
84
|
) : null;
|
|
80
85
|
|
|
81
86
|
// In row layout, only PlanDiffBadge (when it has stats to show) and
|
|
@@ -100,24 +105,32 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
100
105
|
under the action buttons. The Viewer-owned header measures and wraps
|
|
101
106
|
the complete badge set, so its `header` layout keeps this context. */}
|
|
102
107
|
{repoInfo && !linkedDocInfo && !isRow && (
|
|
103
|
-
<div className="flex items-center gap-1.5">
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
108
|
+
<div className="flex min-w-0 max-w-full items-center gap-1.5">
|
|
109
|
+
{/* No fixed caps: the chips take the room the cluster has. When it runs
|
|
110
|
+
out, the branch gives way first, down to its icon and a letter or
|
|
111
|
+
two, and only then the repository: the repository never shrinks,
|
|
112
|
+
but stops short of the branch's floor (4.5em) and the gap. */}
|
|
113
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
114
|
+
<HostTitle title={repoInfo.display} cut>
|
|
115
|
+
<span
|
|
116
|
+
className={`truncate px-1.5 py-0.5 bg-muted/50 rounded ${
|
|
117
|
+
repoInfo.branch ? 'flex-shrink-0 max-w-[calc(100%_-_4.5em_-_0.375rem)]' : 'min-w-0'
|
|
118
|
+
}`}
|
|
119
|
+
>
|
|
120
|
+
{repoInfo.display}
|
|
121
|
+
</span>
|
|
122
|
+
</HostTitle>
|
|
123
|
+
{repoInfo.branch && (
|
|
124
|
+
<HostTitle title={repoInfo.branch} cut>
|
|
125
|
+
<span className="min-w-[4.5em] px-1.5 py-0.5 bg-muted/30 rounded flex items-center gap-1 overflow-hidden">
|
|
126
|
+
<svg className="w-2.5 h-2.5 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
|
|
127
|
+
<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
|
|
128
|
+
</svg>
|
|
129
|
+
<span className="truncate">{repoInfo.branch}</span>
|
|
130
|
+
</span>
|
|
131
|
+
</HostTitle>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
121
134
|
{/* Markdown/text annotate sessions have no separate source badge, so
|
|
122
135
|
keep the open-in control attached to the repository context. */}
|
|
123
136
|
{canOpenInApp && !sourceInfo && openInButton}
|
|
@@ -199,7 +212,7 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
199
212
|
{/* Linked-doc breadcrumb: only in column layout (sticky lane is hidden in linked-doc mode) */}
|
|
200
213
|
{!isRow && linkedDocInfo && (
|
|
201
214
|
linkedDocInfo.variant === 'folder-file' ? (
|
|
202
|
-
<div className="flex items-center gap-2">
|
|
215
|
+
<div className="flex min-w-0 max-w-full items-center gap-2">
|
|
203
216
|
{linkedDocInfo.onBack && (
|
|
204
217
|
<button
|
|
205
218
|
type="button"
|
|
@@ -209,19 +222,19 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
209
222
|
Close
|
|
210
223
|
</button>
|
|
211
224
|
)}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
</
|
|
225
|
+
{/* The hover text is the whole path, so it shows whether or not the name is cut. */}
|
|
226
|
+
<HostTitle title={linkedDocInfo.filepath}>
|
|
227
|
+
<span className="min-w-0 truncate rounded bg-muted/50 px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
|
228
|
+
{basenameFromPath(linkedDocInfo.filepath)}
|
|
229
|
+
</span>
|
|
230
|
+
</HostTitle>
|
|
218
231
|
{openInButton}
|
|
219
232
|
</div>
|
|
220
233
|
) : (
|
|
221
|
-
<div className="flex items-center gap-1.5">
|
|
234
|
+
<div className="flex min-w-0 max-w-full items-center gap-1.5">
|
|
222
235
|
<button
|
|
223
236
|
onClick={linkedDocInfo.onBack}
|
|
224
|
-
className="px-1.5 py-0.5 bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors flex items-center gap-1"
|
|
237
|
+
className="px-1.5 py-0.5 bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors flex flex-shrink-0 items-center gap-1"
|
|
225
238
|
>
|
|
226
239
|
<svg
|
|
227
240
|
className="w-2.5 h-2.5"
|
|
@@ -238,15 +251,14 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
238
251
|
</svg>
|
|
239
252
|
{linkedDocInfo.backLabel || 'plan'}
|
|
240
253
|
</button>
|
|
241
|
-
<span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded">
|
|
254
|
+
<span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded flex-shrink-0">
|
|
242
255
|
{linkedDocInfo.label || 'Linked File'}
|
|
243
256
|
</span>
|
|
244
|
-
<
|
|
245
|
-
className="px-1.5 py-0.5 bg-muted/50 text-muted-foreground rounded truncate
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
</span>
|
|
257
|
+
<HostTitle title={linkedDocInfo.filepath}>
|
|
258
|
+
<span className="min-w-0 px-1.5 py-0.5 bg-muted/50 text-muted-foreground rounded truncate">
|
|
259
|
+
{basenameFromPath(linkedDocInfo.filepath)}
|
|
260
|
+
</span>
|
|
261
|
+
</HostTitle>
|
|
250
262
|
{openInButton}
|
|
251
263
|
</div>
|
|
252
264
|
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useLayoutEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import type { Annotation, Block } from '../types';
|
|
3
|
+
import { CONTENTS_RAIL_MAX_LEVEL, tocHeadings } from '../utils/annotationHelpers';
|
|
4
|
+
import { useHeadingScrollSpy } from '../hooks/useHeadingScrollSpy';
|
|
5
|
+
import { TableOfContents } from './TableOfContents';
|
|
6
|
+
|
|
7
|
+
interface DocumentContentsProps {
|
|
8
|
+
blocks: Block[];
|
|
9
|
+
annotations: Annotation[];
|
|
10
|
+
/**
|
|
11
|
+
* The element the document scrolls in. The current heading is read from it
|
|
12
|
+
* and a click scrolls it. `null` until it has mounted.
|
|
13
|
+
*/
|
|
14
|
+
scrollViewport: HTMLElement | null;
|
|
15
|
+
/** Called with the heading's block id after a click (a sheet closes itself here). */
|
|
16
|
+
onNavigate?: (blockId: string) => void;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A document's Contents list for a host without plan review's sidebar, on the
|
|
22
|
+
* contents rail's surface (the Inbox's sheet, where the rail yields): the same
|
|
23
|
+
* `TableOfContents` (click to scroll, the current heading highlighted) with
|
|
24
|
+
* the rail's headings (levels 1 to 4) and the rail's scroll math
|
|
25
|
+
* (`useHeadingScrollSpy`), so the list and the rail never disagree. It draws
|
|
26
|
+
* no chrome; the host places it and titles it.
|
|
27
|
+
*
|
|
28
|
+
* The host mounts it afresh (a sheet that opens), often mid-document: the
|
|
29
|
+
* current heading is read at once. The marked entry is kept in view inside the
|
|
30
|
+
* list's own scroll box (a sheet).
|
|
31
|
+
*/
|
|
32
|
+
export function DocumentContents({ blocks, annotations, scrollViewport, onNavigate, className = '' }: DocumentContentsProps) {
|
|
33
|
+
const headingIds = useMemo(
|
|
34
|
+
() => tocHeadings(blocks, CONTENTS_RAIL_MAX_LEVEL).map((heading) => heading.id),
|
|
35
|
+
[blocks],
|
|
36
|
+
);
|
|
37
|
+
const activeId = useHeadingScrollSpy(scrollViewport, headingIds);
|
|
38
|
+
|
|
39
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
const item = wrapRef.current?.querySelector<HTMLElement>('[aria-current="location"]');
|
|
42
|
+
if (item) revealInList(item);
|
|
43
|
+
}, [activeId]);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div ref={wrapRef} style={{ display: 'contents' }}>
|
|
47
|
+
<TableOfContents
|
|
48
|
+
blocks={blocks}
|
|
49
|
+
annotations={annotations}
|
|
50
|
+
activeId={activeId}
|
|
51
|
+
onNavigate={onNavigate ?? noop}
|
|
52
|
+
scrollViewport={scrollViewport}
|
|
53
|
+
maxLevel={CONTENTS_RAIL_MAX_LEVEL}
|
|
54
|
+
className={className}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Scroll the list's own scroll box (the nearest ancestor that scrolls) just
|
|
62
|
+
* enough to show `item`, at once. Never the page or the document behind it.
|
|
63
|
+
*/
|
|
64
|
+
function revealInList(item: HTMLElement) {
|
|
65
|
+
let box = item.parentElement;
|
|
66
|
+
while (box && !/(auto|scroll)/.test(getComputedStyle(box).overflowY)) box = box.parentElement;
|
|
67
|
+
if (!box || box.scrollHeight <= box.clientHeight) return;
|
|
68
|
+
const area = box.getBoundingClientRect();
|
|
69
|
+
const rect = item.getBoundingClientRect();
|
|
70
|
+
if (rect.top < area.top) box.scrollTop -= area.top - rect.top;
|
|
71
|
+
else if (rect.bottom > area.bottom) box.scrollTop += Math.min(rect.bottom - area.bottom, rect.top - area.top);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function noop() {}
|