@plannotator/ui 0.30.0 → 0.32.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/README.md +46 -1
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +158 -36
- package/components/AnnotationToolbar.tsx +50 -30
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +238 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/GraphvizBlock.tsx +86 -7
- package/components/HtmlSurfaceControls.tsx +170 -0
- package/components/InlineMarkdown.tsx +25 -4
- package/components/KeyboardShortcuts.tsx +9 -0
- package/components/Landing.tsx +1 -1
- package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
- package/components/MarkdownEditor/embedPicker.ts +349 -0
- package/components/MarkdownEditor.tsx +12 -0
- package/components/MermaidBlock.tsx +60 -26
- package/components/ModeToggle.tsx +2 -1
- package/components/PermissionModeSetup.tsx +24 -5
- package/components/PinpointOverlay.tsx +11 -6
- package/components/PlanHeaderMenu.tsx +140 -1
- package/components/SearchableSelect.tsx +2 -0
- package/components/Settings.tsx +175 -10
- package/components/SkillReferenceMenu.tsx +9 -0
- package/components/StickyHeaderLane.tsx +9 -2
- package/components/TableOfContents.tsx +9 -4
- package/components/TextShimmer.tsx +8 -5
- package/components/ThemeProvider.tsx +43 -1
- package/components/ThemeTab.tsx +52 -1
- package/components/Tooltip.tsx +3 -1
- package/components/Viewer.tsx +19 -5
- package/components/VimTargetReticle.tsx +12 -4
- package/components/ai/DocumentAIChatPanel.tsx +1 -0
- package/components/blocks/MathBlock.tsx +26 -14
- package/components/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +320 -41
- package/components/html-viewer/bridge-script.ts +360 -72
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/hostThreads.ts +37 -0
- package/components/html-viewer/index.ts +9 -0
- package/components/html-viewer/unanchored.ts +47 -0
- package/components/html-viewer/useHtmlAnnotation.ts +240 -61
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
- package/components/sidebar/FileBrowser.tsx +17 -5
- package/components/sidebar/SidebarContainer.tsx +124 -28
- package/components/ui/button.tsx +10 -8
- package/components/ui/dialog.tsx +35 -25
- package/config/index.ts +6 -1
- package/config/reviewView.ts +42 -9
- package/config/settings.ts +141 -0
- package/configure.ts +32 -0
- package/hooks/useAIProviderConfig.ts +8 -7
- package/hooks/useActiveSection.ts +6 -4
- package/hooks/useAgentJobs.ts +3 -0
- package/hooks/useAnnotationHighlighter.ts +20 -0
- package/hooks/useHtmlRefresh.ts +149 -0
- package/hooks/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useMathRenderer.ts +30 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useSharing.ts +31 -5
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +5 -2
- package/shortcuts/index.ts +3 -0
- package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
- package/styles.css +1 -1
- package/theme.css +229 -0
- package/types.ts +35 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/generateIdentity.ts +64 -14
- package/utils/htmlChrome.ts +20 -16
- package/utils/identity-tater.ts +36 -0
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/math-eager.ts +25 -0
- package/utils/math.ts +146 -0
- package/utils/mermaid-eager.ts +28 -0
- package/utils/mermaid.ts +132 -0
- package/utils/parser.ts +75 -2
- package/utils/quickLabels.ts +13 -0
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -0
- package/webmcp/activity.ts +46 -0
- package/webmcp/changes.ts +227 -0
- package/webmcp/index.ts +72 -0
- package/webmcp/modelContext.ts +103 -0
- package/webmcp/nudges.ts +174 -0
- package/webmcp/policy.ts +50 -0
- package/webmcp/preference.ts +50 -0
- package/webmcp/schema.ts +81 -0
- package/webmcp/toolset.ts +337 -0
- package/webmcp/useToolset.ts +74 -0
- package/components/PlanAIAnnouncementDialog.tsx +0 -187
- package/components/VimModeAnnouncementDialog.tsx +0 -557
- package/utils/planAIAnnouncement.ts +0 -17
- package/utils/vimModeAnnouncement.ts +0 -23
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Reusable confirmation dialog component
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import React, { useEffect } from 'react';
|
|
5
|
+
import React, { useEffect, useId, useRef } from 'react';
|
|
6
|
+
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
|
|
6
7
|
|
|
7
8
|
export interface ConfirmDialogProps {
|
|
8
9
|
isOpen: boolean;
|
|
@@ -31,6 +32,10 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
31
32
|
showCancel = false,
|
|
32
33
|
wide = false,
|
|
33
34
|
}) => {
|
|
35
|
+
const descriptionId = useId();
|
|
36
|
+
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
|
37
|
+
const confirmButtonRef = useRef<HTMLButtonElement>(null);
|
|
38
|
+
|
|
34
39
|
useEffect(() => {
|
|
35
40
|
if (!isOpen) return;
|
|
36
41
|
const handleKey = (event: KeyboardEvent) => {
|
|
@@ -40,11 +45,6 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
40
45
|
if (onConfirm) onConfirm();
|
|
41
46
|
else onClose();
|
|
42
47
|
}
|
|
43
|
-
if (event.key === 'Escape') {
|
|
44
|
-
event.preventDefault();
|
|
45
|
-
event.stopPropagation();
|
|
46
|
-
onClose();
|
|
47
|
-
}
|
|
48
48
|
};
|
|
49
49
|
window.addEventListener('keydown', handleKey);
|
|
50
50
|
return () => window.removeEventListener('keydown', handleKey);
|
|
@@ -58,8 +58,8 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
const buttonColors = {
|
|
61
|
-
info: 'bg-primary text-primary-foreground hover:opacity-90',
|
|
62
|
-
warning: 'bg-warning text-warning-foreground hover:opacity-90',
|
|
61
|
+
info: 'bg-primary text-primary-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-90',
|
|
62
|
+
warning: 'bg-warning text-warning-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-90',
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
const icons = {
|
|
@@ -76,40 +76,54 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
76
76
|
};
|
|
77
77
|
|
|
78
78
|
return (
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
<Dialog
|
|
80
|
+
open={isOpen}
|
|
81
|
+
disablePointerDismissal
|
|
82
|
+
onOpenChange={(open) => {
|
|
83
|
+
if (!open) onClose();
|
|
84
|
+
}}
|
|
82
85
|
>
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
aria-
|
|
86
|
-
|
|
86
|
+
<DialogContent
|
|
87
|
+
hideClose
|
|
88
|
+
aria-describedby={descriptionId}
|
|
89
|
+
initialFocus={() => (showCancel ? cancelButtonRef.current : confirmButtonRef.current)}
|
|
90
|
+
backdropClassName="bg-background/80 backdrop-blur-sm"
|
|
91
|
+
className={`bg-card text-foreground rounded-xl shadow-2xl p-6 transition-none ${wide ? 'max-w-md' : 'max-w-sm'}`}
|
|
92
|
+
data-plannotator-confirm-dialog="true"
|
|
87
93
|
>
|
|
88
94
|
<div className="flex items-center gap-3 mb-4">
|
|
89
|
-
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${iconColors[variant]}`}>
|
|
95
|
+
<div className={`w-10 h-10 shrink-0 rounded-full flex items-center justify-center ${iconColors[variant]}`}>
|
|
90
96
|
{icons[variant]}
|
|
91
97
|
</div>
|
|
92
|
-
<
|
|
98
|
+
<DialogTitle className="font-semibold tracking-normal">{title}</DialogTitle>
|
|
93
99
|
</div>
|
|
94
|
-
<div
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{subMessage && (
|
|
98
|
-
<div className="text-xs text-muted-foreground mb-6">
|
|
99
|
-
{subMessage}
|
|
100
|
+
<div id={descriptionId}>
|
|
101
|
+
<div className="text-sm text-muted-foreground mb-2">
|
|
102
|
+
{message}
|
|
100
103
|
</div>
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
{subMessage && (
|
|
105
|
+
<div className="text-xs text-muted-foreground mb-6">
|
|
106
|
+
{subMessage}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
{!subMessage && <div className="mb-4" />}
|
|
110
|
+
</div>
|
|
103
111
|
<div className="flex justify-end gap-2">
|
|
104
112
|
{showCancel && (
|
|
105
113
|
<button
|
|
114
|
+
ref={cancelButtonRef}
|
|
115
|
+
type="button"
|
|
116
|
+
data-pn-touch-target="true"
|
|
106
117
|
onClick={onClose}
|
|
107
|
-
className="px-4 py-2 rounded-md text-sm font-medium bg-muted text-muted-foreground hover:bg-muted/80 transition-opacity"
|
|
118
|
+
className="px-4 py-2 rounded-md text-sm font-medium bg-muted text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted/80 transition-opacity"
|
|
108
119
|
>
|
|
109
120
|
{cancelText}
|
|
110
121
|
</button>
|
|
111
122
|
)}
|
|
112
123
|
<button
|
|
124
|
+
ref={confirmButtonRef}
|
|
125
|
+
type="button"
|
|
126
|
+
data-pn-touch-target="true"
|
|
113
127
|
onClick={() => {
|
|
114
128
|
if (onConfirm) {
|
|
115
129
|
onConfirm();
|
|
@@ -122,7 +136,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|
|
122
136
|
{confirmText}
|
|
123
137
|
</button>
|
|
124
138
|
</div>
|
|
125
|
-
</
|
|
126
|
-
</
|
|
139
|
+
</DialogContent>
|
|
140
|
+
</Dialog>
|
|
127
141
|
);
|
|
128
142
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
|
-
import {
|
|
3
|
+
import type { Viz } from '@viz-js/viz';
|
|
4
4
|
import type { Block } from '../types';
|
|
5
5
|
|
|
6
6
|
interface ViewBox {
|
|
@@ -14,13 +14,52 @@ const ZOOM_STEP = 0.25;
|
|
|
14
14
|
const MIN_ZOOM = 0.25;
|
|
15
15
|
const MAX_ZOOM = 8;
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The Graphviz engine (about 1.2 MB of Emscripten JS) is imported inside the
|
|
19
|
+
* render effect, not statically, so a host that bundles by route only fetches
|
|
20
|
+
* it when a dot fence is on the page. WASM instantiation was already deferred
|
|
21
|
+
* to first render; in Plannotator's single-file builds the import is inlined
|
|
22
|
+
* and resolves in a microtask ahead of a render that was already asynchronous.
|
|
23
|
+
*/
|
|
24
|
+
const loadVizInstance = (): Promise<Viz> => import('@viz-js/viz').then((m) => m.instance());
|
|
25
|
+
|
|
26
|
+
let vizLoader = loadVizInstance;
|
|
27
|
+
let vizInstancePromise: Promise<Viz> | null = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Delay before the one automatic re-attempt after a failed engine import.
|
|
31
|
+
* Only chunking hosts can fail here (a single-file build never fetches).
|
|
32
|
+
*/
|
|
33
|
+
let runtimeRetryDelayMs = 750;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Memoized engine. A rejected load is dropped from the memo so the next call
|
|
37
|
+
* (the automatic re-attempt, a later mount, or the Retry button) issues a
|
|
38
|
+
* fresh import() instead of replaying the cached rejection.
|
|
39
|
+
*/
|
|
40
|
+
function getVizInstance(): Promise<Viz> {
|
|
41
|
+
if (!vizInstancePromise) {
|
|
42
|
+
const attempt = vizLoader().catch((err: unknown) => {
|
|
43
|
+
if (vizInstancePromise === attempt) vizInstancePromise = null;
|
|
44
|
+
throw err;
|
|
45
|
+
});
|
|
46
|
+
vizInstancePromise = attempt;
|
|
47
|
+
}
|
|
21
48
|
return vizInstancePromise;
|
|
22
49
|
}
|
|
23
50
|
|
|
51
|
+
/** Test hook: stand in for the engine import and shorten the retry delay. */
|
|
52
|
+
export function __setVizLoaderForTests(
|
|
53
|
+
loader: (() => Promise<Viz>) | undefined,
|
|
54
|
+
options?: { retryDelayMs?: number },
|
|
55
|
+
): void {
|
|
56
|
+
vizLoader = loader ?? loadVizInstance;
|
|
57
|
+
vizInstancePromise = null;
|
|
58
|
+
runtimeRetryDelayMs = options?.retryDelayMs ?? 750;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
62
|
+
|
|
24
63
|
function parseViewBox(svgEl: SVGSVGElement): ViewBox | null {
|
|
25
64
|
const raw = svgEl.getAttribute('viewBox');
|
|
26
65
|
if (!raw) return null;
|
|
@@ -107,6 +146,11 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
107
146
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
108
147
|
const [svg, setSvg] = useState('');
|
|
109
148
|
const [error, setError] = useState<string | null>(null);
|
|
149
|
+
// True when the failure was the engine import itself (a chunking host's
|
|
150
|
+
// fetch), which is the only failure a Retry can change; a dot syntax error
|
|
151
|
+
// keeps the panel exactly as it always was.
|
|
152
|
+
const [runtimeUnavailable, setRuntimeUnavailable] = useState(false);
|
|
153
|
+
const [retryToken, setRetryToken] = useState(0);
|
|
110
154
|
const [showSource, setShowSource] = useState(false);
|
|
111
155
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
112
156
|
|
|
@@ -158,8 +202,28 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
158
202
|
let cancelled = false;
|
|
159
203
|
|
|
160
204
|
const renderDiagram = async () => {
|
|
205
|
+
let viz: Viz;
|
|
206
|
+
try {
|
|
207
|
+
try {
|
|
208
|
+
viz = await getVizInstance();
|
|
209
|
+
} catch {
|
|
210
|
+
// Transient chunk failure on a chunking host: one automatic
|
|
211
|
+
// re-attempt with a fresh import() after a short delay. In a
|
|
212
|
+
// single-file build the first await never rejects, so this branch
|
|
213
|
+
// is unreachable there and the success path is unchanged.
|
|
214
|
+
await wait(runtimeRetryDelayMs);
|
|
215
|
+
if (cancelled) return;
|
|
216
|
+
viz = await getVizInstance();
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (!cancelled) {
|
|
220
|
+
setError(err instanceof Error ? err.message : 'Failed to render diagram');
|
|
221
|
+
setRuntimeUnavailable(true);
|
|
222
|
+
setSvg('');
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
161
226
|
try {
|
|
162
|
-
const viz = await getVizInstance();
|
|
163
227
|
const renderedSvg = await viz.renderString(block.content, { format: 'svg' });
|
|
164
228
|
const cleaned = renderedSvg
|
|
165
229
|
.replace(/ width="[^"]*"/, ' width="100%"')
|
|
@@ -177,10 +241,12 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
177
241
|
naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned);
|
|
178
242
|
setSvg(cleaned);
|
|
179
243
|
setError(null);
|
|
244
|
+
setRuntimeUnavailable(false);
|
|
180
245
|
}
|
|
181
246
|
} catch (err) {
|
|
182
247
|
if (!cancelled) {
|
|
183
248
|
setError(err instanceof Error ? err.message : 'Failed to render diagram');
|
|
249
|
+
setRuntimeUnavailable(false);
|
|
184
250
|
setSvg('');
|
|
185
251
|
}
|
|
186
252
|
}
|
|
@@ -191,7 +257,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
191
257
|
return () => {
|
|
192
258
|
cancelled = true;
|
|
193
259
|
};
|
|
194
|
-
}, [block.content]);
|
|
260
|
+
}, [block.content, retryToken]);
|
|
195
261
|
|
|
196
262
|
useEffect(() => {
|
|
197
263
|
zoomLevelRef.current = 1;
|
|
@@ -362,6 +428,19 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
362
428
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
363
429
|
</svg>
|
|
364
430
|
<span className="text-xs text-destructive font-medium">Graphviz Error</span>
|
|
431
|
+
{runtimeUnavailable && (
|
|
432
|
+
<button
|
|
433
|
+
type="button"
|
|
434
|
+
onClick={() => {
|
|
435
|
+
setError(null);
|
|
436
|
+
setRetryToken((token) => token + 1);
|
|
437
|
+
}}
|
|
438
|
+
className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
|
|
439
|
+
title="Retry loading the diagram renderer"
|
|
440
|
+
>
|
|
441
|
+
Retry
|
|
442
|
+
</button>
|
|
443
|
+
)}
|
|
365
444
|
</div>
|
|
366
445
|
<pre className="p-3 text-xs text-destructive/80 overflow-x-auto">{error}</pre>
|
|
367
446
|
<pre className="p-3 text-xs text-muted-foreground bg-muted/30 border-t border-border/30 overflow-x-auto">
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header controls for a raw-HTML or live-app annotation surface: the eye
|
|
3
|
+
* (show/hide the floating tools over the page), the optional refresh, and
|
|
4
|
+
* the pen (Annotate/Interact toggle). Presentation only; every state lives
|
|
5
|
+
* in the host. Each control renders only when its handler is passed, so a
|
|
6
|
+
* read-only document can show the eye without a pen.
|
|
7
|
+
*
|
|
8
|
+
* The markup, data attributes (`data-html-tools-toggle`, `data-html-refresh`,
|
|
9
|
+
* `data-html-annotate-toggle`), aria state and the pixel-stable pen border
|
|
10
|
+
* are the exact ones Plannotator's header shipped with; hosts get the same
|
|
11
|
+
* control, and `labels` overrides the strings without touching the DOM.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** String overrides. Every key optional; defaults are Plannotator's strings. */
|
|
15
|
+
export interface HtmlSurfaceControlLabels {
|
|
16
|
+
/** Pen title while Annotate is armed. */
|
|
17
|
+
annotateTitle?: string;
|
|
18
|
+
/** Pen title while in Interact mode. */
|
|
19
|
+
interactTitle?: string;
|
|
20
|
+
/** Pen aria-label while armed. Default: none (the title carries the name). */
|
|
21
|
+
annotateLabel?: string;
|
|
22
|
+
/** Pen aria-label while in Interact mode. Default: none. */
|
|
23
|
+
interactLabel?: string;
|
|
24
|
+
/** Eye title and screen-reader text while the tools are visible. */
|
|
25
|
+
hideTools?: string;
|
|
26
|
+
/** Eye title and screen-reader text while the tools are hidden. */
|
|
27
|
+
showTools?: string;
|
|
28
|
+
/** Refresh visible text while idle. */
|
|
29
|
+
refresh?: string;
|
|
30
|
+
/** Refresh visible text while a refresh is in flight. */
|
|
31
|
+
refreshing?: string;
|
|
32
|
+
/** Refresh title and aria-label while idle. */
|
|
33
|
+
refreshTitle?: string;
|
|
34
|
+
/** Refresh title and aria-label while a refresh is in flight. */
|
|
35
|
+
refreshingTitle?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_HTML_SURFACE_CONTROL_LABELS: Required<
|
|
39
|
+
Omit<HtmlSurfaceControlLabels, 'annotateLabel' | 'interactLabel'>
|
|
40
|
+
> = {
|
|
41
|
+
annotateTitle: 'Annotate mode: click an element or select text to comment. Esc to interact',
|
|
42
|
+
interactTitle: 'Interact mode: clicks reach the page (text selection still comments). Click to annotate',
|
|
43
|
+
hideTools: 'Hide tools',
|
|
44
|
+
showTools: 'Show tools',
|
|
45
|
+
refresh: 'Refresh',
|
|
46
|
+
refreshing: 'Refreshing',
|
|
47
|
+
refreshTitle: 'Refresh document',
|
|
48
|
+
refreshingTitle: 'Refreshing document',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface HtmlSurfaceControlsProps {
|
|
52
|
+
/** Whether Annotate is armed (pen pressed). */
|
|
53
|
+
armed: boolean;
|
|
54
|
+
/** Flip Annotate/Interact. The pen renders only when provided. */
|
|
55
|
+
onToggleArmed?: () => void;
|
|
56
|
+
/** Whether the floating tools over the page are hidden (eye-off). */
|
|
57
|
+
toolsHidden?: boolean;
|
|
58
|
+
/** Flip the tools. The eye (and the refresh beside it) render only when provided. */
|
|
59
|
+
onToggleTools?: () => void;
|
|
60
|
+
/** Whether a refresh is offered for this document. */
|
|
61
|
+
canRefresh?: boolean;
|
|
62
|
+
onRefresh?: () => void;
|
|
63
|
+
isRefreshing?: boolean;
|
|
64
|
+
/** Compact touch shells put these actions in a menu instead: render nothing. */
|
|
65
|
+
compact?: boolean;
|
|
66
|
+
labels?: HtmlSurfaceControlLabels;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function HtmlSurfaceControls({
|
|
70
|
+
armed,
|
|
71
|
+
onToggleArmed,
|
|
72
|
+
toolsHidden = false,
|
|
73
|
+
onToggleTools,
|
|
74
|
+
canRefresh = false,
|
|
75
|
+
onRefresh,
|
|
76
|
+
isRefreshing = false,
|
|
77
|
+
compact = false,
|
|
78
|
+
labels,
|
|
79
|
+
}: HtmlSurfaceControlsProps) {
|
|
80
|
+
if (compact) return null;
|
|
81
|
+
const text = { ...DEFAULT_HTML_SURFACE_CONTROL_LABELS, ...labels };
|
|
82
|
+
const penLabel = armed ? labels?.annotateLabel : labels?.interactLabel;
|
|
83
|
+
return (
|
|
84
|
+
<>
|
|
85
|
+
{/* Show/hide tools: removes ALL floating chrome (sidebar tongue tabs +
|
|
86
|
+
the comment/attachments cluster) from the DOM, leaving nothing over
|
|
87
|
+
the page. Sits left of the pen; this button is the only way back,
|
|
88
|
+
so it never hides itself. Eye = tools visible, eye-off = hidden. */}
|
|
89
|
+
{onToggleTools && (
|
|
90
|
+
<div className="ml-1 flex items-center gap-0.5">
|
|
91
|
+
{canRefresh && onRefresh && (
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
data-html-refresh
|
|
95
|
+
// aria-disabled rather than disabled: a disabled control drops
|
|
96
|
+
// keyboard focus to body when activated. useHtmlRefresh already
|
|
97
|
+
// dedups in-flight requests, so an extra click is harmless.
|
|
98
|
+
onClick={isRefreshing ? undefined : onRefresh}
|
|
99
|
+
aria-disabled={isRefreshing}
|
|
100
|
+
className="flex items-center gap-1.5 rounded px-1.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground aria-disabled:cursor-wait aria-disabled:opacity-70"
|
|
101
|
+
title={isRefreshing ? text.refreshingTitle : text.refreshTitle}
|
|
102
|
+
aria-label={isRefreshing ? text.refreshingTitle : text.refreshTitle}
|
|
103
|
+
>
|
|
104
|
+
<svg
|
|
105
|
+
aria-hidden="true"
|
|
106
|
+
viewBox="0 0 24 24"
|
|
107
|
+
fill="none"
|
|
108
|
+
stroke="currentColor"
|
|
109
|
+
strokeWidth="2"
|
|
110
|
+
strokeLinecap="round"
|
|
111
|
+
strokeLinejoin="round"
|
|
112
|
+
className={`h-3.5 w-3.5 ${isRefreshing ? 'animate-spin motion-reduce:animate-none' : ''}`}
|
|
113
|
+
>
|
|
114
|
+
<path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5" />
|
|
115
|
+
<path d="M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5" />
|
|
116
|
+
</svg>
|
|
117
|
+
<span className="hidden sm:inline">{isRefreshing ? text.refreshing : text.refresh}</span>
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
data-html-tools-toggle
|
|
123
|
+
onClick={onToggleTools}
|
|
124
|
+
aria-pressed={toolsHidden}
|
|
125
|
+
className="cursor-pointer rounded-md border border-transparent p-1.5 text-xs font-medium text-muted-foreground transition-all hover:bg-muted hover:text-foreground"
|
|
126
|
+
title={toolsHidden ? text.showTools : text.hideTools}
|
|
127
|
+
>
|
|
128
|
+
{toolsHidden ? (
|
|
129
|
+
<svg aria-hidden="true" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
130
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
|
131
|
+
</svg>
|
|
132
|
+
) : (
|
|
133
|
+
<svg aria-hidden="true" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
134
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
|
135
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
|
136
|
+
</svg>
|
|
137
|
+
)}
|
|
138
|
+
<span className="sr-only">{toolsHidden ? text.showTools : text.hideTools}</span>
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{/* Interact/Annotate toggle. A PEN icon (deliberately not a speech
|
|
144
|
+
bubble: an annotations-panel bubble beside it must stay
|
|
145
|
+
distinguishable at a glance, and so must AI sparkles). Always the
|
|
146
|
+
same icon: armed shows the accent color plus a visible border;
|
|
147
|
+
unarmed is muted with a TRANSPARENT border of the same width, so
|
|
148
|
+
the button's box is pixel-identical in both states. */}
|
|
149
|
+
{onToggleArmed && (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
data-html-annotate-toggle
|
|
153
|
+
onClick={onToggleArmed}
|
|
154
|
+
aria-pressed={armed}
|
|
155
|
+
className={`p-1.5 rounded-md border text-xs font-medium transition-all cursor-pointer ${
|
|
156
|
+
armed
|
|
157
|
+
? 'border-primary/60 bg-primary/15 text-primary'
|
|
158
|
+
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
159
|
+
}`}
|
|
160
|
+
title={armed ? text.annotateTitle : text.interactTitle}
|
|
161
|
+
{...(penLabel !== undefined ? { 'aria-label': penLabel } : {})}
|
|
162
|
+
>
|
|
163
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
164
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.862 4.487zm0 0L19.5 7.125" />
|
|
165
|
+
</svg>
|
|
166
|
+
</button>
|
|
167
|
+
)}
|
|
168
|
+
</>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -4,11 +4,13 @@ import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePa
|
|
|
4
4
|
import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
|
|
5
5
|
import { useFenceTheme } from "../hooks/useFenceTheme";
|
|
6
6
|
import { transformPlainText } from "../utils/inlineTransforms";
|
|
7
|
+
import { hasLinkedDocExtension } from "../utils/markdownExtensions";
|
|
7
8
|
import { getImageSrc } from "./ImageThumbnail";
|
|
8
9
|
import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext";
|
|
9
10
|
import type { ValidationEntry } from "../hooks/useValidatedCodePaths";
|
|
10
11
|
import { CodeFilePicker } from "./CodeFilePicker";
|
|
11
|
-
import { normalizeMathTex, renderMathToHtml } from "
|
|
12
|
+
import { normalizeMathTex, renderMathToHtml } from "../utils/math";
|
|
13
|
+
import { useMathRenderer } from "../hooks/useMathRenderer";
|
|
12
14
|
|
|
13
15
|
export interface DocPreviewResult {
|
|
14
16
|
contents?: string;
|
|
@@ -41,6 +43,9 @@ export const resetDocPreviewFetcher = (): void => {
|
|
|
41
43
|
docPreviewFetcher = defaultDocPreviewFetcher;
|
|
42
44
|
};
|
|
43
45
|
|
|
46
|
+
/** Read the active fetcher at call time (so a late override is honored). */
|
|
47
|
+
export const getDocPreviewFetcher = (): DocPreviewFetcher => docPreviewFetcher;
|
|
48
|
+
|
|
44
49
|
/**
|
|
45
50
|
* Decide how a candidate code-file path should render based on validation state:
|
|
46
51
|
* - 'link' → clickable, opens directly via onOpenCodeFile(resolvedOrInput)
|
|
@@ -308,7 +313,23 @@ const CodeFileIcon = () => (
|
|
|
308
313
|
|
|
309
314
|
const InlineMath: React.FC<{ tex: string }> = ({ tex }) => {
|
|
310
315
|
const normalizedTex = normalizeMathTex(tex);
|
|
311
|
-
const
|
|
316
|
+
const renderer = useMathRenderer();
|
|
317
|
+
const html = useMemo(() => renderMathToHtml(normalizedTex, false, renderer), [normalizedTex, renderer]);
|
|
318
|
+
|
|
319
|
+
// Same wrapper in both branches (see MathBlock): the placeholder carries the
|
|
320
|
+
// attributes annotation restore keys on, with the TeX as a text child.
|
|
321
|
+
if (html === null) {
|
|
322
|
+
return (
|
|
323
|
+
<span
|
|
324
|
+
className="math-inline math-annotatable text-foreground"
|
|
325
|
+
data-math-tex={normalizedTex}
|
|
326
|
+
data-math-display="false"
|
|
327
|
+
aria-label={normalizedTex}
|
|
328
|
+
>
|
|
329
|
+
{normalizedTex}
|
|
330
|
+
</span>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
312
333
|
|
|
313
334
|
return (
|
|
314
335
|
<span
|
|
@@ -877,7 +898,7 @@ export const InlineMarkdown: React.FC<{
|
|
|
877
898
|
// targets are opaque doc ids, not paths. null → today's rendering.
|
|
878
899
|
const resolution = resolveLinkedDoc?.(target) ?? null;
|
|
879
900
|
const display = resolution?.label || storedLabel || target;
|
|
880
|
-
const targetPath =
|
|
901
|
+
const targetPath = hasLinkedDocExtension(target)
|
|
881
902
|
? target
|
|
882
903
|
: `${target}.md`;
|
|
883
904
|
|
|
@@ -1009,7 +1030,7 @@ export const InlineMarkdown: React.FC<{
|
|
|
1009
1030
|
// Fragment is stripped before handing to onOpenLinkedDoc (overlay has
|
|
1010
1031
|
// no anchor-scroll support today).
|
|
1011
1032
|
const isLocalDoc =
|
|
1012
|
-
|
|
1033
|
+
hasLinkedDocExtension(linkUrl, { allowFragment: true }) &&
|
|
1013
1034
|
!linkUrl.startsWith("http://") &&
|
|
1014
1035
|
!linkUrl.startsWith("https://");
|
|
1015
1036
|
const isCodeFile = !isLocalDoc && isCodeFilePath(linkUrl);
|
|
@@ -84,9 +84,17 @@ const inputMethodShortcuts: ShortcutSection = {
|
|
|
84
84
|
],
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
+
const documentViewShortcuts: ShortcutSection = {
|
|
88
|
+
title: 'View',
|
|
89
|
+
shortcuts: [
|
|
90
|
+
{ keys: [modKey, '.'], desc: 'Toggle focus mode', hint: 'Collapses the Contents sidebar and the right-hand panel together; press again to restore whatever was open before. Markdown documents only; HTML pages keep their own layout.' },
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
|
|
87
94
|
const annotationShortcuts: ShortcutSection = {
|
|
88
95
|
title: 'Annotations',
|
|
89
96
|
shortcuts: [
|
|
97
|
+
{ keys: [shiftKey, '1-4'], desc: 'Switch annotation mode', hint: '1 Markup, 2 Comment, 3 Redline, 4 Label — matches the toolstrip order' },
|
|
90
98
|
{ keys: ['a-z'], desc: 'Start typing comment', hint: 'When the annotation toolbar is open, any letter key opens the comment editor with that character' },
|
|
91
99
|
{ keys: [altKey, '1-0'], desc: 'Apply quick label', hint: 'Instantly applies the Nth preset label (0 = 10th). When the label picker is open, bare digits also work.' },
|
|
92
100
|
{ keys: [modKey, enter], desc: 'Submit comment' },
|
|
@@ -108,6 +116,7 @@ const imageAnnotatorShortcuts: ShortcutSection = {
|
|
|
108
116
|
};
|
|
109
117
|
|
|
110
118
|
const sharedPlanEditorShortcuts: ShortcutSection[] = [
|
|
119
|
+
documentViewShortcuts,
|
|
111
120
|
inputMethodShortcuts,
|
|
112
121
|
annotationShortcuts,
|
|
113
122
|
imageAnnotatorShortcuts,
|
package/components/Landing.tsx
CHANGED
|
@@ -50,7 +50,7 @@ export const Landing: React.FC<LandingProps> = ({ onEnter, shareBaseUrl }) => {
|
|
|
50
50
|
<div className="max-w-2xl">
|
|
51
51
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium mb-6">
|
|
52
52
|
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
|
53
|
-
For Claude Code & OpenCode
|
|
53
|
+
For Claude Code & OpenCode
|
|
54
54
|
</div>
|
|
55
55
|
|
|
56
56
|
<div className="flex items-center gap-6 mb-4">
|