@plannotator/ui 0.39.0 → 0.41.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 +140 -10
- package/README.md +9 -5
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +17 -630
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +67 -3
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +121 -9
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +133 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +469 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +6 -4
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +5 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +36 -7
- package/utils/terminalToolsAnnouncement.ts +76 -0
- package/components/mermaidSvg.ts +0 -33
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { Pause, Play } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One-time announcement for Plannotator's two terminal clients, Plannotator TUI
|
|
7
|
+
* and Herdr Annotate. Video first: the real demo footage fills the top of the
|
|
8
|
+
* panel edge to edge, and the text below it is one headline and one sentence.
|
|
9
|
+
* Same shell as the other first-run announcements (portal, z-[100], hand-rolled
|
|
10
|
+
* Escape + Tab wrap + focus restore), with one difference: it asks the user to
|
|
11
|
+
* decide nothing, so Escape, the backdrop and the single "Got it" button all do
|
|
12
|
+
* the same thing.
|
|
13
|
+
*
|
|
14
|
+
* LAST in each app's first-run dialog chain. The Apps gate rendering through
|
|
15
|
+
* terminalToolsAnnouncementCanShow so the chain dialogs never stack; see that
|
|
16
|
+
* function for why last rather than first.
|
|
17
|
+
*
|
|
18
|
+
* The footage is hosted on plannotator.ai like GuideIntroDialog's hero image
|
|
19
|
+
* (the two demos together are ~23MB, far too much to inline the way the Edit
|
|
20
|
+
* Mode recording is), so the dialog has to look right without it: the poster
|
|
21
|
+
* stays up while the video buffers, and if the media cannot load at all the
|
|
22
|
+
* frame keeps its place and offers the X post instead.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const TUI_REPO = 'https://github.com/plannotator/plannotator-tui';
|
|
26
|
+
const HERDR_REPO = 'https://github.com/plannotator/herdr-annotate';
|
|
27
|
+
|
|
28
|
+
/** Where the marketing deploy publishes `apps/marketing/public/assets/`. */
|
|
29
|
+
const MEDIA_BASE_URL = 'https://plannotator.ai/assets';
|
|
30
|
+
|
|
31
|
+
export interface TerminalToolsDemo {
|
|
32
|
+
readonly id: 'full' | 'lite';
|
|
33
|
+
/** Segment label. Herdr Annotate's own naming for its two install targets. */
|
|
34
|
+
readonly label: string;
|
|
35
|
+
readonly mp4: string;
|
|
36
|
+
readonly webm: string;
|
|
37
|
+
readonly poster: string;
|
|
38
|
+
/** The X post the footage was cut from. */
|
|
39
|
+
readonly watchUrl: string;
|
|
40
|
+
readonly description: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 1280x806 and 1280x808: one aspect ratio, so switching never reflows the panel. */
|
|
44
|
+
const DEMO_ASPECT = 1280 / 806;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Herdr Annotate's own colors, from its repo badges (assets/install-full.svg
|
|
48
|
+
* and siblings): badge purple, lavender text, periwinkle accent. They belong to
|
|
49
|
+
* another product, so they live as local variables on this dialog and never
|
|
50
|
+
* enter theme.css. The badge purple is the one that still reads as a purple
|
|
51
|
+
* block over the dark footage; the darker hero ground (#10101f-#2c2536) would
|
|
52
|
+
* vanish into the frame.
|
|
53
|
+
*/
|
|
54
|
+
const HERDR_BRAND_VARS = {
|
|
55
|
+
'--announce-brand': '#312b52',
|
|
56
|
+
'--announce-brand-text': '#c9c6f1',
|
|
57
|
+
'--announce-brand-accent': '#B9C0FF',
|
|
58
|
+
} as React.CSSProperties;
|
|
59
|
+
|
|
60
|
+
export const TERMINAL_TOOLS_DEMOS: readonly TerminalToolsDemo[] = [
|
|
61
|
+
{
|
|
62
|
+
id: 'full',
|
|
63
|
+
label: 'Full',
|
|
64
|
+
mp4: `${MEDIA_BASE_URL}/tui-herdr-full-demo.mp4`,
|
|
65
|
+
webm: `${MEDIA_BASE_URL}/tui-herdr-full-demo.webm`,
|
|
66
|
+
poster: `${MEDIA_BASE_URL}/tui-herdr-full-poster.jpg`,
|
|
67
|
+
watchUrl: 'https://x.com/plannotator/status/2093419561077154287',
|
|
68
|
+
description:
|
|
69
|
+
'Herdr Annotate reviewing a Markdown file with Plannotator TUI: a file tree, a selected block with its comment, and the feedback sent to the agent.',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'lite',
|
|
73
|
+
label: 'Lite',
|
|
74
|
+
mp4: `${MEDIA_BASE_URL}/tui-herdr-lite-demo.mp4`,
|
|
75
|
+
webm: `${MEDIA_BASE_URL}/tui-herdr-lite-demo.webm`,
|
|
76
|
+
poster: `${MEDIA_BASE_URL}/tui-herdr-lite-poster.jpg`,
|
|
77
|
+
watchUrl: 'https://x.com/plannotator/status/2092757422322627008',
|
|
78
|
+
description:
|
|
79
|
+
'Herdr Annotate Lite: terminal text selected in an agent session, a comment written in a popover, and the notes sent back to the agent.',
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
interface TerminalToolsAnnouncementDialogProps {
|
|
84
|
+
readonly isOpen: boolean;
|
|
85
|
+
/** Marks the announcement seen and closes it. Also wired to Escape and the backdrop. */
|
|
86
|
+
readonly onDismiss: () => void;
|
|
87
|
+
/**
|
|
88
|
+
* Test seam. Defaults to the media query; forcing it lets a test assert the
|
|
89
|
+
* reduced-motion branch without a real `matchMedia`.
|
|
90
|
+
*/
|
|
91
|
+
readonly reducedMotion?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function prefersReducedMotion(): boolean {
|
|
95
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
|
96
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function GitHubMark({ className }: { readonly className?: string }) {
|
|
100
|
+
return (
|
|
101
|
+
<svg viewBox="0 0 16 16" className={className} aria-hidden="true" focusable="false">
|
|
102
|
+
<path
|
|
103
|
+
fill="currentColor"
|
|
104
|
+
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z"
|
|
105
|
+
/>
|
|
106
|
+
</svg>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function XMark({ className }: { readonly className?: string }) {
|
|
111
|
+
return (
|
|
112
|
+
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" focusable="false">
|
|
113
|
+
<path
|
|
114
|
+
fill="currentColor"
|
|
115
|
+
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117Z"
|
|
116
|
+
/>
|
|
117
|
+
</svg>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function OutboundAction({
|
|
122
|
+
href,
|
|
123
|
+
children,
|
|
124
|
+
icon,
|
|
125
|
+
}: {
|
|
126
|
+
readonly href: string;
|
|
127
|
+
readonly children: React.ReactNode;
|
|
128
|
+
readonly icon: React.ReactNode;
|
|
129
|
+
}) {
|
|
130
|
+
return (
|
|
131
|
+
<a
|
|
132
|
+
href={href}
|
|
133
|
+
target="_blank"
|
|
134
|
+
rel="noopener noreferrer"
|
|
135
|
+
className="inline-flex min-h-9 items-center gap-2 rounded-lg border border-border bg-surface-0/40 px-3 text-sm font-medium text-foreground outline-none transition-colors motion-reduce:transition-none hover:bg-surface-1/70 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
|
|
136
|
+
>
|
|
137
|
+
{icon}
|
|
138
|
+
{children}
|
|
139
|
+
</a>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface DemoPlayerProps {
|
|
144
|
+
readonly demo: TerminalToolsDemo;
|
|
145
|
+
readonly reducedMotion: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The footage. Muted, looping, inline; autoplays unless the reader asked for
|
|
150
|
+
* reduced motion, in which case the poster waits behind a play button. One
|
|
151
|
+
* toggle serves both: large and centered while paused, tucked into a corner
|
|
152
|
+
* while playing, so a paused frame is never mistaken for a broken one.
|
|
153
|
+
*/
|
|
154
|
+
function DemoPlayer({ demo, reducedMotion }: DemoPlayerProps) {
|
|
155
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
156
|
+
// Optimistic: autoplay is expected, so the corner control is the initial
|
|
157
|
+
// shape and the centered Play only appears once the browser has proven it
|
|
158
|
+
// will not start (canplaythrough with the element still paused).
|
|
159
|
+
const [playing, setPlaying] = useState(!reducedMotion);
|
|
160
|
+
const [failed, setFailed] = useState(false);
|
|
161
|
+
|
|
162
|
+
const toggle = useCallback(() => {
|
|
163
|
+
const video = videoRef.current;
|
|
164
|
+
if (!video) return;
|
|
165
|
+
if (video.paused) {
|
|
166
|
+
// Autoplay policies can reject play(); the button simply stays put.
|
|
167
|
+
void video.play().catch(() => {});
|
|
168
|
+
} else {
|
|
169
|
+
video.pause();
|
|
170
|
+
}
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div
|
|
175
|
+
className="group relative w-full overflow-hidden bg-muted"
|
|
176
|
+
style={{ aspectRatio: String(DEMO_ASPECT) }}
|
|
177
|
+
>
|
|
178
|
+
{/* A corner block on the frame, flush to the panel's top-left edge (the
|
|
179
|
+
panel's own radius clips its outer corner) the way a "new" corner tag
|
|
180
|
+
sits on a product card. The posters keep only a sidebar label and a
|
|
181
|
+
tab marker under this corner, so nothing that matters is covered.
|
|
182
|
+
Roughly 200x48 over the desktop video, 118x32 at phone width. */}
|
|
183
|
+
<span
|
|
184
|
+
aria-hidden="true"
|
|
185
|
+
data-terminal-tools-tag
|
|
186
|
+
data-shimmer={reducedMotion ? 'off' : 'on'}
|
|
187
|
+
className={`terminal-tools-announcement-tag absolute left-0 top-0 z-10 inline-flex h-8 select-none items-center rounded-br-xl pl-3.5 pr-4 text-[11px] font-semibold uppercase leading-none tracking-[0.16em] sm:h-12 sm:pl-6 sm:pr-7 sm:text-[15px] sm:tracking-[0.18em]${
|
|
188
|
+
reducedMotion ? '' : ' terminal-tools-announcement-tag--sheen'
|
|
189
|
+
}`}
|
|
190
|
+
>
|
|
191
|
+
New · Watch:
|
|
192
|
+
</span>
|
|
193
|
+
<video
|
|
194
|
+
ref={videoRef}
|
|
195
|
+
data-terminal-tools-demo={demo.id}
|
|
196
|
+
poster={demo.poster}
|
|
197
|
+
muted
|
|
198
|
+
playsInline
|
|
199
|
+
loop
|
|
200
|
+
autoPlay={!reducedMotion}
|
|
201
|
+
preload="auto"
|
|
202
|
+
aria-label={demo.description}
|
|
203
|
+
onPlay={() => setPlaying(true)}
|
|
204
|
+
onPause={() => setPlaying(false)}
|
|
205
|
+
onCanPlayThrough={(event) => setPlaying(!event.currentTarget.paused)}
|
|
206
|
+
onClick={toggle}
|
|
207
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
208
|
+
>
|
|
209
|
+
<source src={demo.mp4} type="video/mp4" />
|
|
210
|
+
{/* The last source is the one whose error means nothing could load. */}
|
|
211
|
+
<source src={demo.webm} type="video/webm" onError={() => setFailed(true)} />
|
|
212
|
+
</video>
|
|
213
|
+
|
|
214
|
+
{failed ? (
|
|
215
|
+
<div className="absolute inset-0 grid place-items-center bg-background/70 backdrop-blur-sm">
|
|
216
|
+
<a
|
|
217
|
+
href={demo.watchUrl}
|
|
218
|
+
target="_blank"
|
|
219
|
+
rel="noopener noreferrer"
|
|
220
|
+
className="inline-flex min-h-10 items-center gap-2 rounded-lg border border-border bg-card px-4 text-sm font-medium text-foreground outline-none hover:bg-surface-1 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
221
|
+
>
|
|
222
|
+
<XMark className="size-3.5" />
|
|
223
|
+
Watch on X
|
|
224
|
+
</a>
|
|
225
|
+
</div>
|
|
226
|
+
) : (
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
onClick={toggle}
|
|
230
|
+
aria-label={playing ? 'Pause demo' : 'Play demo'}
|
|
231
|
+
data-terminal-tools-playback={playing ? 'pause' : 'play'}
|
|
232
|
+
className={
|
|
233
|
+
playing
|
|
234
|
+
? 'absolute bottom-3 left-3 grid size-9 place-items-center rounded-full border border-border/60 bg-background/70 text-foreground opacity-0 outline-none backdrop-blur-sm transition-opacity motion-reduce:transition-none group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary'
|
|
235
|
+
: 'absolute left-1/2 top-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-border/60 bg-background/80 text-foreground shadow-lg outline-none backdrop-blur-sm transition-transform motion-reduce:transition-none hover:scale-105 focus-visible:ring-2 focus-visible:ring-primary'
|
|
236
|
+
}
|
|
237
|
+
>
|
|
238
|
+
{playing ? (
|
|
239
|
+
<Pause className="size-4" aria-hidden="true" />
|
|
240
|
+
) : (
|
|
241
|
+
<Play className="ml-0.5 size-6 fill-current" aria-hidden="true" />
|
|
242
|
+
)}
|
|
243
|
+
</button>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
interface DemoSwitchProps {
|
|
250
|
+
readonly active: TerminalToolsDemo['id'];
|
|
251
|
+
readonly onChange: (id: TerminalToolsDemo['id']) => void;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function DemoSwitch({ active, onChange }: DemoSwitchProps) {
|
|
255
|
+
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
256
|
+
|
|
257
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
258
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
const index = TERMINAL_TOOLS_DEMOS.findIndex((demo) => demo.id === active);
|
|
261
|
+
const step = event.key === 'ArrowRight' ? 1 : -1;
|
|
262
|
+
const next = TERMINAL_TOOLS_DEMOS[
|
|
263
|
+
(index + step + TERMINAL_TOOLS_DEMOS.length) % TERMINAL_TOOLS_DEMOS.length
|
|
264
|
+
];
|
|
265
|
+
onChange(next.id);
|
|
266
|
+
tabRefs.current[next.id]?.focus();
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<div
|
|
271
|
+
role="tablist"
|
|
272
|
+
aria-label="Demo"
|
|
273
|
+
className="inline-flex shrink-0 rounded-lg border border-border bg-surface-0/60 p-0.5"
|
|
274
|
+
>
|
|
275
|
+
{TERMINAL_TOOLS_DEMOS.map((demo) => {
|
|
276
|
+
const selected = demo.id === active;
|
|
277
|
+
return (
|
|
278
|
+
<button
|
|
279
|
+
key={demo.id}
|
|
280
|
+
ref={(node) => {
|
|
281
|
+
tabRefs.current[demo.id] = node;
|
|
282
|
+
}}
|
|
283
|
+
type="button"
|
|
284
|
+
role="tab"
|
|
285
|
+
aria-selected={selected}
|
|
286
|
+
aria-controls="terminal-tools-announcement-demo"
|
|
287
|
+
tabIndex={selected ? 0 : -1}
|
|
288
|
+
onClick={() => onChange(demo.id)}
|
|
289
|
+
onKeyDown={handleKeyDown}
|
|
290
|
+
className={`min-h-8 rounded-md px-3 text-xs font-medium outline-none transition-colors motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-primary ${
|
|
291
|
+
selected
|
|
292
|
+
? 'bg-card text-foreground shadow-sm'
|
|
293
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
294
|
+
}`}
|
|
295
|
+
>
|
|
296
|
+
{demo.label}
|
|
297
|
+
</button>
|
|
298
|
+
);
|
|
299
|
+
})}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function TerminalToolsAnnouncementDialog({
|
|
305
|
+
isOpen,
|
|
306
|
+
onDismiss,
|
|
307
|
+
reducedMotion,
|
|
308
|
+
}: TerminalToolsAnnouncementDialogProps) {
|
|
309
|
+
const dismissRef = useRef<HTMLButtonElement>(null);
|
|
310
|
+
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
311
|
+
const onDismissRef = useRef(onDismiss);
|
|
312
|
+
const [activeDemoId, setActiveDemoId] = useState<TerminalToolsDemo['id']>('full');
|
|
313
|
+
const [motionPreference] = useState(() => reducedMotion ?? prefersReducedMotion());
|
|
314
|
+
const reduced = reducedMotion ?? motionPreference;
|
|
315
|
+
const activeDemo =
|
|
316
|
+
TERMINAL_TOOLS_DEMOS.find((demo) => demo.id === activeDemoId) ?? TERMINAL_TOOLS_DEMOS[0];
|
|
317
|
+
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
onDismissRef.current = onDismiss;
|
|
320
|
+
}, [onDismiss]);
|
|
321
|
+
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
if (!isOpen) return;
|
|
324
|
+
|
|
325
|
+
previousFocusRef.current = document.activeElement instanceof HTMLElement
|
|
326
|
+
? document.activeElement
|
|
327
|
+
: null;
|
|
328
|
+
dismissRef.current?.focus();
|
|
329
|
+
|
|
330
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
331
|
+
if (event.key === 'Escape') {
|
|
332
|
+
event.preventDefault();
|
|
333
|
+
event.stopPropagation();
|
|
334
|
+
onDismissRef.current();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// Both apps submit their decision on Mod+Enter from a window-level
|
|
338
|
+
// handler. This listener is registered on the capture phase, so
|
|
339
|
+
// swallowing the chord here is what stops a keystroke aimed at the
|
|
340
|
+
// announcement from approving a plan or posting a review behind it.
|
|
341
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
342
|
+
event.preventDefault();
|
|
343
|
+
event.stopPropagation();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (event.key !== 'Tab') return;
|
|
347
|
+
|
|
348
|
+
const dialog = document.querySelector<HTMLElement>('[data-terminal-tools-announcement-dialog]');
|
|
349
|
+
const focusable = Array.from(
|
|
350
|
+
dialog?.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')
|
|
351
|
+
?? [],
|
|
352
|
+
).filter((element) => element.getAttribute('tabindex') !== '-1');
|
|
353
|
+
if (focusable.length === 0) return;
|
|
354
|
+
|
|
355
|
+
const first = focusable[0];
|
|
356
|
+
const last = focusable[focusable.length - 1];
|
|
357
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
358
|
+
event.preventDefault();
|
|
359
|
+
last.focus();
|
|
360
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
first.focus();
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
367
|
+
return () => {
|
|
368
|
+
document.removeEventListener('keydown', handleKeyDown, true);
|
|
369
|
+
previousFocusRef.current?.focus();
|
|
370
|
+
previousFocusRef.current = null;
|
|
371
|
+
};
|
|
372
|
+
}, [isOpen]);
|
|
373
|
+
|
|
374
|
+
if (!isOpen) return null;
|
|
375
|
+
|
|
376
|
+
return createPortal(
|
|
377
|
+
<div
|
|
378
|
+
className="terminal-tools-announcement-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-background/90 p-4 backdrop-blur-sm"
|
|
379
|
+
// Dismissing from the backdrop is safe here because the dialog collects
|
|
380
|
+
// no decision: there is nothing to lose by closing it the impatient way.
|
|
381
|
+
onMouseDown={(event) => {
|
|
382
|
+
if (event.target === event.currentTarget) onDismissRef.current();
|
|
383
|
+
}}
|
|
384
|
+
>
|
|
385
|
+
<div
|
|
386
|
+
data-terminal-tools-announcement-dialog
|
|
387
|
+
role="dialog"
|
|
388
|
+
aria-modal="true"
|
|
389
|
+
aria-labelledby="terminal-tools-announcement-title"
|
|
390
|
+
aria-describedby="terminal-tools-announcement-description"
|
|
391
|
+
// Width follows the viewport height as well as its width: the footage
|
|
392
|
+
// keeps its aspect ratio, so on a short window the panel narrows until
|
|
393
|
+
// video plus footer fit instead of scrolling the video out of view.
|
|
394
|
+
// The 12rem is the footer's height, with the wrap at narrow widths.
|
|
395
|
+
style={{
|
|
396
|
+
...HERDR_BRAND_VARS,
|
|
397
|
+
width: `min(1120px, 100%, calc((100dvh - 2rem - 12rem) * ${DEMO_ASPECT}))`,
|
|
398
|
+
}}
|
|
399
|
+
className="terminal-tools-announcement-dialog flex max-h-[calc(100dvh-2rem)] min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
|
|
400
|
+
>
|
|
401
|
+
<div
|
|
402
|
+
id="terminal-tools-announcement-demo"
|
|
403
|
+
role="tabpanel"
|
|
404
|
+
aria-label={`${activeDemo.label} demo`}
|
|
405
|
+
className="shrink-0 border-b border-border"
|
|
406
|
+
>
|
|
407
|
+
{/* Keyed so a switch resets playback and load-failure state with the footage. */}
|
|
408
|
+
<DemoPlayer key={activeDemo.id} demo={activeDemo} reducedMotion={reduced} />
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
<div className="min-h-0 overflow-y-auto px-5 py-5 sm:px-7 sm:py-6">
|
|
412
|
+
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3">
|
|
413
|
+
<div className="min-w-0">
|
|
414
|
+
<h2
|
|
415
|
+
id="terminal-tools-announcement-title"
|
|
416
|
+
className="text-balance text-xl font-semibold tracking-tight sm:text-2xl"
|
|
417
|
+
>
|
|
418
|
+
Plannotator TUI and Herdr Annotate
|
|
419
|
+
</h2>
|
|
420
|
+
<p
|
|
421
|
+
id="terminal-tools-announcement-description"
|
|
422
|
+
className="mt-1 text-pretty text-sm leading-relaxed text-muted-foreground"
|
|
423
|
+
>
|
|
424
|
+
Annotate Markdown and terminal text, then send the notes to your agent.
|
|
425
|
+
</p>
|
|
426
|
+
</div>
|
|
427
|
+
<DemoSwitch active={activeDemo.id} onChange={setActiveDemoId} />
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
<div className="mt-5 flex flex-wrap items-center gap-2">
|
|
431
|
+
<OutboundAction href={TUI_REPO} icon={<GitHubMark className="size-4" />}>
|
|
432
|
+
Star plannotator-tui
|
|
433
|
+
</OutboundAction>
|
|
434
|
+
<OutboundAction href={HERDR_REPO} icon={<GitHubMark className="size-4" />}>
|
|
435
|
+
Star herdr-annotate
|
|
436
|
+
</OutboundAction>
|
|
437
|
+
<OutboundAction href={activeDemo.watchUrl} icon={<XMark className="size-3.5" />}>
|
|
438
|
+
Watch on X
|
|
439
|
+
</OutboundAction>
|
|
440
|
+
<button
|
|
441
|
+
ref={dismissRef}
|
|
442
|
+
type="button"
|
|
443
|
+
onClick={onDismiss}
|
|
444
|
+
className="ml-auto min-h-9 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground outline-none transition-opacity motion-reduce:transition-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
|
|
445
|
+
>
|
|
446
|
+
Got it
|
|
447
|
+
</button>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
</div>,
|
|
452
|
+
document.body,
|
|
453
|
+
);
|
|
454
|
+
}
|
package/components/Viewer.tsx
CHANGED
|
@@ -40,6 +40,7 @@ import { CommentPopover, type CommentAskAIHandler } from './CommentPopover';
|
|
|
40
40
|
import { TaterSpriteSitting } from './TaterSpriteSitting';
|
|
41
41
|
import { AttachmentsButton } from './AttachmentsButton';
|
|
42
42
|
import { MessagesIcon } from './icons/MessagesIcon';
|
|
43
|
+
import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
|
|
43
44
|
import { GraphvizBlock } from './GraphvizBlock';
|
|
44
45
|
import { MermaidBlock } from './MermaidBlock';
|
|
45
46
|
import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
|
|
@@ -48,7 +49,7 @@ import { type QuickLabel } from '../utils/quickLabels';
|
|
|
48
49
|
import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
|
|
49
50
|
import { PinpointOverlay } from './PinpointOverlay';
|
|
50
51
|
import { usePinpoint } from '../hooks/usePinpoint';
|
|
51
|
-
import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
|
|
52
|
+
import { useAnnotationHighlighter, type AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
|
|
52
53
|
import { useVimSelection } from '../hooks/useVimSelection';
|
|
53
54
|
import {
|
|
54
55
|
getScrollViewportIntersectionRoot,
|
|
@@ -167,6 +168,9 @@ export interface ViewerProps {
|
|
|
167
168
|
vimHudKeyPanelEnabled?: boolean;
|
|
168
169
|
/** Persist a user request to hide the bottom-right key panel. */
|
|
169
170
|
onVimHudKeyPanelChange?: (enabled: boolean) => void;
|
|
171
|
+
/** Fires once per highlight-restore pass with what it tried and what it could
|
|
172
|
+
* not anchor, so a host can mark the leftovers in its annotation panel. */
|
|
173
|
+
onRestoreReport?: (report: AnnotationRestoreReport) => void;
|
|
170
174
|
}
|
|
171
175
|
|
|
172
176
|
export interface ViewerHandle {
|
|
@@ -364,6 +368,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
364
368
|
imageBaseDir,
|
|
365
369
|
codePathBaseDir,
|
|
366
370
|
disableCodePathValidation,
|
|
371
|
+
onRestoreReport,
|
|
367
372
|
copyLabel,
|
|
368
373
|
actionsLabelMode = 'full',
|
|
369
374
|
archiveInfo,
|
|
@@ -463,6 +468,15 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
463
468
|
const lastAutoScrolledHashRef = useRef<string | null>(null);
|
|
464
469
|
const [isStuck, setIsStuck] = useState(false);
|
|
465
470
|
|
|
471
|
+
// Reported only when the text-search rescue could not re-anchor either, so
|
|
472
|
+
// the annotation is listed in the panel with no highlight in the document.
|
|
473
|
+
const handleRestoreMismatch = useCallback((annotation: Annotation, restoredText: string) => {
|
|
474
|
+
console.warn(
|
|
475
|
+
`Annotation ${annotation.id} could not be re-anchored: stored positions resolved onto ` +
|
|
476
|
+
`"${restoredText.slice(0, 50)}" and its text "${annotation.originalText.slice(0, 50)}" is no longer in the document.`,
|
|
477
|
+
);
|
|
478
|
+
}, []);
|
|
479
|
+
|
|
466
480
|
// Shared annotation infrastructure via hook
|
|
467
481
|
const {
|
|
468
482
|
toolbarState,
|
|
@@ -489,6 +503,17 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
489
503
|
selectedAnnotationId,
|
|
490
504
|
mode,
|
|
491
505
|
enabled: !readOnly,
|
|
506
|
+
// Markdown documents drift between the moment a draft/share is written and
|
|
507
|
+
// the moment it is restored (a plan revision, a re-rendered block, a
|
|
508
|
+
// renderer change that adds or drops elements — #1509 hoisted an alert's
|
|
509
|
+
// bold first line onto the icon row, which renumbers every later
|
|
510
|
+
// `parentIndex`). web-highlighter's stored metas are positional, so a
|
|
511
|
+
// drifted anchor resolves onto the WRONG text and paints silently. Verify
|
|
512
|
+
// the painted text against the annotation's own quote so a bad resolve is
|
|
513
|
+
// dropped and the text-search rescue runs instead of a wrong highlight.
|
|
514
|
+
verifyRestoredContent: true,
|
|
515
|
+
onRestoreMismatch: handleRestoreMismatch,
|
|
516
|
+
onRestoreReport,
|
|
492
517
|
});
|
|
493
518
|
|
|
494
519
|
// Refs for code block annotation path
|
|
@@ -1018,8 +1043,28 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1018
1043
|
</>
|
|
1019
1044
|
);
|
|
1020
1045
|
|
|
1046
|
+
// The document's diagram blocks, in order: a diagram comment that names
|
|
1047
|
+
// none of them (an external POST, a deleted fence) is resolved by anchor
|
|
1048
|
+
// against each, first resolver wins (see diagram/anchorClaims).
|
|
1049
|
+
const diagramBlockKey = blocks
|
|
1050
|
+
.filter((b) => b.type === 'code' && (isMermaidLanguage(b.language) || isGraphvizLanguage(b.language)))
|
|
1051
|
+
.map((b) => b.id)
|
|
1052
|
+
.join('\n');
|
|
1053
|
+
const diagramClaims = useMemo(
|
|
1054
|
+
() => new DiagramAnchorClaims(diagramBlockKey === '' ? [] : diagramBlockKey.split('\n')),
|
|
1055
|
+
[diagramBlockKey],
|
|
1056
|
+
);
|
|
1057
|
+
// With no diagram in the document nobody can resolve a diagram comment:
|
|
1058
|
+
// it is unanchored, and the highlighter (which skips it) will not say so.
|
|
1059
|
+
useEffect(() => {
|
|
1060
|
+
if (diagramBlockKey !== '' || onRestoreReport === undefined) return;
|
|
1061
|
+
const ids = annotations.filter((ann) => ann.diagramAnchor !== undefined).map((ann) => ann.id);
|
|
1062
|
+
if (ids.length > 0) onRestoreReport({ attempted: ids, unanchored: ids });
|
|
1063
|
+
}, [annotations, diagramBlockKey, onRestoreReport]);
|
|
1064
|
+
|
|
1021
1065
|
return (
|
|
1022
1066
|
<CodePathValidationContext.Provider value={codePathValidation}>
|
|
1067
|
+
<DiagramAnchorClaimsContext.Provider value={diagramClaims}>
|
|
1023
1068
|
<div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
|
|
1024
1069
|
{taterMode && <TaterSpriteSitting />}
|
|
1025
1070
|
<article
|
|
@@ -1123,9 +1168,27 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1123
1168
|
);
|
|
1124
1169
|
})()
|
|
1125
1170
|
) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
|
|
1126
|
-
<MermaidBlock
|
|
1171
|
+
<MermaidBlock
|
|
1172
|
+
key={group.block.id}
|
|
1173
|
+
block={group.block}
|
|
1174
|
+
annotations={annotations}
|
|
1175
|
+
selectedAnnotationId={selectedAnnotationId}
|
|
1176
|
+
onSelectAnnotation={onSelectAnnotation}
|
|
1177
|
+
onAddAnnotation={readOnly ? undefined : onAddAnnotation}
|
|
1178
|
+
readOnly={readOnly}
|
|
1179
|
+
onRestoreReport={onRestoreReport}
|
|
1180
|
+
/>
|
|
1127
1181
|
) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
|
|
1128
|
-
<GraphvizBlock
|
|
1182
|
+
<GraphvizBlock
|
|
1183
|
+
key={group.block.id}
|
|
1184
|
+
block={group.block}
|
|
1185
|
+
annotations={annotations}
|
|
1186
|
+
selectedAnnotationId={selectedAnnotationId}
|
|
1187
|
+
onSelectAnnotation={onSelectAnnotation}
|
|
1188
|
+
onAddAnnotation={readOnly ? undefined : onAddAnnotation}
|
|
1189
|
+
readOnly={readOnly}
|
|
1190
|
+
onRestoreReport={onRestoreReport}
|
|
1191
|
+
/>
|
|
1129
1192
|
) : group.block.type === 'table' ? (
|
|
1130
1193
|
<TableBlock
|
|
1131
1194
|
key={group.block.id}
|
|
@@ -1420,6 +1483,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1420
1483
|
document.body
|
|
1421
1484
|
)}
|
|
1422
1485
|
</div>
|
|
1486
|
+
</DiagramAnchorClaimsContext.Provider>
|
|
1423
1487
|
</CodePathValidationContext.Provider>
|
|
1424
1488
|
);
|
|
1425
1489
|
});
|
|
@@ -91,8 +91,13 @@ export const AlertBlock: React.FC<AlertBlockProps> = ({
|
|
|
91
91
|
<>
|
|
92
92
|
{/* The type word stays part of the accessible name. A visually
|
|
93
93
|
hidden span is read by every engine; an aria-label on this
|
|
94
|
-
generic div is prohibited by ARIA and dropped by WebKit.
|
|
95
|
-
|
|
94
|
+
generic div is prohibited by ARIA and dropped by WebKit.
|
|
95
|
+
`annotation-exclude` keeps it out of the annotation text
|
|
96
|
+
stream: without it a drag that starts on the icon quotes
|
|
97
|
+
the invisible word ("Warning:Named icon title"), which is
|
|
98
|
+
what the panel shows, what the agent is handed, and a quote
|
|
99
|
+
no share-link restore can find again. */}
|
|
100
|
+
<span className="sr-only annotation-exclude">{TITLE[kind]}: </span>
|
|
96
101
|
<InlineMarkdown text={titleLine.title} {...proseProps} />
|
|
97
102
|
</>
|
|
98
103
|
)
|