@plannotator/ui 0.39.0 → 0.40.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 +99 -10
- package/README.md +3 -3
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +39 -5
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +25 -1
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +96 -9
- package/components/html-viewer/bridge-script.ts +96 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +464 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +3 -3
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -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 +21 -6
- package/utils/terminalToolsAnnouncement.ts +76 -0
|
@@ -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
|
@@ -48,7 +48,7 @@ import { type QuickLabel } from '../utils/quickLabels';
|
|
|
48
48
|
import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
|
|
49
49
|
import { PinpointOverlay } from './PinpointOverlay';
|
|
50
50
|
import { usePinpoint } from '../hooks/usePinpoint';
|
|
51
|
-
import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
|
|
51
|
+
import { useAnnotationHighlighter, type AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
|
|
52
52
|
import { useVimSelection } from '../hooks/useVimSelection';
|
|
53
53
|
import {
|
|
54
54
|
getScrollViewportIntersectionRoot,
|
|
@@ -167,6 +167,9 @@ export interface ViewerProps {
|
|
|
167
167
|
vimHudKeyPanelEnabled?: boolean;
|
|
168
168
|
/** Persist a user request to hide the bottom-right key panel. */
|
|
169
169
|
onVimHudKeyPanelChange?: (enabled: boolean) => void;
|
|
170
|
+
/** Fires once per highlight-restore pass with what it tried and what it could
|
|
171
|
+
* not anchor, so a host can mark the leftovers in its annotation panel. */
|
|
172
|
+
onRestoreReport?: (report: AnnotationRestoreReport) => void;
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
export interface ViewerHandle {
|
|
@@ -364,6 +367,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
364
367
|
imageBaseDir,
|
|
365
368
|
codePathBaseDir,
|
|
366
369
|
disableCodePathValidation,
|
|
370
|
+
onRestoreReport,
|
|
367
371
|
copyLabel,
|
|
368
372
|
actionsLabelMode = 'full',
|
|
369
373
|
archiveInfo,
|
|
@@ -463,6 +467,15 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
463
467
|
const lastAutoScrolledHashRef = useRef<string | null>(null);
|
|
464
468
|
const [isStuck, setIsStuck] = useState(false);
|
|
465
469
|
|
|
470
|
+
// Reported only when the text-search rescue could not re-anchor either, so
|
|
471
|
+
// the annotation is listed in the panel with no highlight in the document.
|
|
472
|
+
const handleRestoreMismatch = useCallback((annotation: Annotation, restoredText: string) => {
|
|
473
|
+
console.warn(
|
|
474
|
+
`Annotation ${annotation.id} could not be re-anchored: stored positions resolved onto ` +
|
|
475
|
+
`"${restoredText.slice(0, 50)}" and its text "${annotation.originalText.slice(0, 50)}" is no longer in the document.`,
|
|
476
|
+
);
|
|
477
|
+
}, []);
|
|
478
|
+
|
|
466
479
|
// Shared annotation infrastructure via hook
|
|
467
480
|
const {
|
|
468
481
|
toolbarState,
|
|
@@ -489,6 +502,17 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
489
502
|
selectedAnnotationId,
|
|
490
503
|
mode,
|
|
491
504
|
enabled: !readOnly,
|
|
505
|
+
// Markdown documents drift between the moment a draft/share is written and
|
|
506
|
+
// the moment it is restored (a plan revision, a re-rendered block, a
|
|
507
|
+
// renderer change that adds or drops elements — #1509 hoisted an alert's
|
|
508
|
+
// bold first line onto the icon row, which renumbers every later
|
|
509
|
+
// `parentIndex`). web-highlighter's stored metas are positional, so a
|
|
510
|
+
// drifted anchor resolves onto the WRONG text and paints silently. Verify
|
|
511
|
+
// the painted text against the annotation's own quote so a bad resolve is
|
|
512
|
+
// dropped and the text-search rescue runs instead of a wrong highlight.
|
|
513
|
+
verifyRestoredContent: true,
|
|
514
|
+
onRestoreMismatch: handleRestoreMismatch,
|
|
515
|
+
onRestoreReport,
|
|
492
516
|
});
|
|
493
517
|
|
|
494
518
|
// Refs for code block annotation path
|
|
@@ -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
|
)
|
|
@@ -190,6 +190,18 @@ export interface HtmlViewerProps {
|
|
|
190
190
|
currentPageUrl?: string;
|
|
191
191
|
/** Live-mode page navigation reports (ready pageUrl + page-change). */
|
|
192
192
|
onPageChange?: (pageUrl: string) => void;
|
|
193
|
+
/** A link the framed srcdoc document swallowed rather than navigating to.
|
|
194
|
+
* A srcdoc document's base URL is the PARENT page's, so an unhandled
|
|
195
|
+
* `<a href="other.html">` would load the host app inside the frame; the
|
|
196
|
+
* bridge suppresses every such navigation and relays the RAW href here
|
|
197
|
+
* (bounded and screened at the trust boundary) for the host to resolve —
|
|
198
|
+
* see `resolveHtmlLinkIntent`. In-page `#fragment` links are scrolled by
|
|
199
|
+
* the bridge and never reported. Never fires in live (`src`) sessions,
|
|
200
|
+
* which navigate the proxied app for real. */
|
|
201
|
+
onOpenLink?: (href: string) => void;
|
|
202
|
+
/** Fragment to scroll to once this document's bridge is ready, for a
|
|
203
|
+
* linked document opened from a `#`-carrying link. Bare id, no `#`. */
|
|
204
|
+
initialFragment?: string;
|
|
193
205
|
annotations: Annotation[];
|
|
194
206
|
onAddAnnotation: (ann: Annotation) => void;
|
|
195
207
|
onSelectAnnotation: (id: string | null) => void;
|
|
@@ -209,6 +221,9 @@ export interface HtmlViewerProps {
|
|
|
209
221
|
onAnnotateModeExit?: () => void;
|
|
210
222
|
/** Mod+Shift+A pressed while focus lived inside the iframe. */
|
|
211
223
|
onAnnotateModeToggle?: () => void;
|
|
224
|
+
/** Mod+Shift+X pressed while focus lived inside the iframe: show/hide the
|
|
225
|
+
* host's floating tools over the page. The host owns that state. */
|
|
226
|
+
onToolsToggle?: () => void;
|
|
212
227
|
/** Opt-in Vim-style keyboard selection. Default false for compatibility. */
|
|
213
228
|
vimModeEnabled?: boolean;
|
|
214
229
|
/** Replace the iframe-local compact badge with the shared live key HUD. */
|
|
@@ -308,6 +323,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
308
323
|
liveSession,
|
|
309
324
|
currentPageUrl,
|
|
310
325
|
onPageChange,
|
|
326
|
+
onOpenLink,
|
|
327
|
+
initialFragment,
|
|
311
328
|
annotations,
|
|
312
329
|
onAddAnnotation,
|
|
313
330
|
onSelectAnnotation,
|
|
@@ -317,6 +334,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
317
334
|
annotateModeActive = true,
|
|
318
335
|
onAnnotateModeExit,
|
|
319
336
|
onAnnotateModeToggle,
|
|
337
|
+
onToolsToggle,
|
|
320
338
|
vimModeEnabled = false,
|
|
321
339
|
vimHudEnabled = false,
|
|
322
340
|
vimHudKeyPanelEnabled = true,
|
|
@@ -372,6 +390,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
372
390
|
onAnnotateModeExitRef.current = onAnnotateModeExit;
|
|
373
391
|
const onAnnotateModeToggleRef = useRef(onAnnotateModeToggle);
|
|
374
392
|
onAnnotateModeToggleRef.current = onAnnotateModeToggle;
|
|
393
|
+
const onToolsToggleRef = useRef(onToolsToggle);
|
|
394
|
+
onToolsToggleRef.current = onToolsToggle;
|
|
375
395
|
|
|
376
396
|
/** Single choke point for direct-to-bridge posts: live sessions get the
|
|
377
397
|
* token + concrete targetOrigin, srcdoc keeps "*" and no token. */
|
|
@@ -569,6 +589,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
569
589
|
onResize: handleResize,
|
|
570
590
|
live: liveSession,
|
|
571
591
|
onPageChange,
|
|
592
|
+
onLinkClick: onOpenLink,
|
|
572
593
|
onBridgePointer: handleBridgePointer,
|
|
573
594
|
onUnanchoredChange: handleBridgeUnanchored,
|
|
574
595
|
maxAdditionalTargets,
|
|
@@ -697,6 +718,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
697
718
|
onAnnotateModeToggleRef.current?.();
|
|
698
719
|
return;
|
|
699
720
|
}
|
|
721
|
+
if (isRecord(e.data) && e.data.type === `${PREFIX}tools-toggle`) {
|
|
722
|
+
onToolsToggleRef.current?.();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
700
725
|
const vimCopy = parseVimBridgeCopy(e.data);
|
|
701
726
|
if (vimCopy !== null) {
|
|
702
727
|
const iframe = iframeRef.current;
|
|
@@ -798,6 +823,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
798
823
|
postToBridge({ type: `${PREFIX}report-unanchored` });
|
|
799
824
|
}, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
800
825
|
|
|
826
|
+
// A linked document opened from a `#`-carrying link: the srcdoc document
|
|
827
|
+
// has no URL, so the fragment is replayed once its bridge is ready.
|
|
828
|
+
useEffect(() => {
|
|
829
|
+
if (iframeReadyVersion === 0 || !initialFragment) return;
|
|
830
|
+
postToBridge({ type: `${PREFIX}scroll-to-fragment`, fragment: initialFragment });
|
|
831
|
+
}, [iframeReadyVersion, initialFragment, postToBridge]);
|
|
832
|
+
|
|
801
833
|
// Live page navigation with a ready iframe: explicitly clear the previous
|
|
802
834
|
// page's marks, then re-apply the filtered set. Relying on dead anchors to
|
|
803
835
|
// hide pins would risk cross-page text-search false matches and waste
|