@plannotator/ui 0.28.0 → 0.29.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 +6 -0
- package/components/AISettingsTab.tsx +5 -4
- package/components/ActionMenu.tsx +5 -1
- package/components/AgentsTab.tsx +10 -23
- package/components/AnnotationPanel.tsx +9 -5
- package/components/AnnotationToolbar.tsx +5 -13
- package/components/AnnotationToolstrip.tsx +2 -2
- package/components/ApproveDropdown.tsx +1 -1
- package/components/BlockRenderer.tsx +1 -1
- package/components/CodeFilePopout.tsx +5 -4
- package/components/CommentPopover.tsx +391 -65
- package/components/DocBadges.tsx +29 -11
- package/components/ExportModal.tsx +16 -8
- package/components/GraphvizBlock.tsx +1 -1
- package/components/InlineMarkdown.tsx +30 -13
- package/components/KeyboardShortcuts.tsx +37 -2
- package/components/Landing.tsx +7 -7
- package/components/MenuVersionSection.tsx +4 -4
- package/components/MermaidBlock.tsx +1 -1
- package/components/ModeToggle.tsx +7 -6
- package/components/OpenInAppButton.tsx +2 -5
- package/components/PinpointOverlay.tsx +9 -7
- package/components/PlanHeaderMenu.tsx +8 -8
- package/components/PopoutDialog.tsx +6 -1
- package/components/ResizeHandle.tsx +1 -0
- package/components/Settings.tsx +172 -12
- package/components/SkillReferenceMenu.tsx +260 -0
- package/components/StickyHeaderLane.tsx +7 -0
- package/components/ThemeProvider.tsx +131 -32
- package/components/ThemeTab.tsx +123 -77
- package/components/ToolbarButtons.tsx +29 -8
- package/components/Viewer.tsx +396 -130
- package/components/VimKeyHud.tsx +695 -0
- package/components/VimModeAnnouncementDialog.tsx +557 -0
- package/components/VimModeOverlay.tsx +235 -0
- package/components/VimTargetReticle.tsx +284 -0
- package/components/ai/DocumentAIChatPanel.tsx +1 -1
- package/components/blocks/CodeBlock.tsx +18 -18
- package/components/blocks/TablePopout.tsx +7 -8
- package/components/blocks/TableToolbar.tsx +7 -8
- package/components/goal-setup/GoalSetupSurface.tsx +16 -3
- package/components/html-viewer/HtmlViewer.tsx +450 -47
- package/components/html-viewer/annotationNumbering.ts +37 -0
- package/components/html-viewer/bridge-script.ts +4051 -298
- package/components/html-viewer/composerYield.ts +51 -0
- package/components/html-viewer/srcdoc.ts +18 -3
- package/components/html-viewer/useHtmlAnnotation.ts +457 -32
- package/components/icons/themeIcons.tsx +1 -1
- package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
- package/components/plan-diff/PlanDiffBadge.tsx +22 -1
- package/components/settings/HooksTab.tsx +12 -8
- package/components/sidebar/FileBrowser.tsx +4 -1
- package/components/themeModes.tsx +28 -0
- package/config/configStore.ts +76 -1
- package/config/settings.ts +152 -0
- package/configure.ts +9 -0
- package/globals.d.ts +7 -1
- package/hooks/useAIChat.ts +5 -2
- package/hooks/useAIProviderActivation.ts +47 -0
- package/hooks/useAIProviderConfig.ts +5 -1
- package/hooks/useAgentSettings.ts +64 -23
- package/hooks/useAgents.ts +4 -4
- package/hooks/useAnnotationHighlighter.ts +100 -3
- package/hooks/useArchive.ts +2 -1
- package/hooks/useFenceTheme.ts +17 -0
- package/hooks/useLinkedDoc.ts +68 -1
- package/hooks/usePinpoint.ts +76 -75
- package/hooks/usePlanDiff.ts +73 -2
- package/hooks/useSkillReferenceAutocomplete.ts +239 -0
- package/hooks/useUpdateCheck.ts +1 -2
- package/hooks/useVimDocumentFocus.ts +116 -0
- package/hooks/useVimSelection.ts +1063 -0
- package/package.json +4 -4
- package/print.css +14 -13
- package/shortcuts/core.ts +38 -13
- package/shortcuts/index.ts +10 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
- package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
- package/shortcuts/runtime.ts +111 -12
- package/styles.css +1 -1
- package/theme.css +504 -0
- package/themes/colorblind.css +89 -0
- package/themes/plannotator.css +2 -2
- package/types.ts +93 -10
- package/utils/agentSwitch.ts +33 -7
- package/utils/blockTargeting.ts +462 -178
- package/utils/clipboard.ts +110 -0
- package/utils/codeBlockMark.ts +50 -0
- package/utils/codeHighlight.ts +293 -0
- package/utils/codexModels.ts +79 -0
- package/utils/domSelection.ts +84 -0
- package/utils/htmlChrome.ts +73 -0
- package/utils/inputMethod.ts +79 -6
- package/utils/parser.ts +517 -21
- package/utils/preferenceTtl.ts +15 -0
- package/utils/sharing.ts +0 -1
- package/utils/skillCatalog.ts +269 -0
- package/utils/skillReferences.ts +475 -0
- package/utils/syntaxTheme.ts +83 -0
- package/utils/themeRegistry.ts +154 -0
- package/utils/vimHud.ts +263 -0
- package/utils/vimModeAnnouncement.ts +23 -0
- package/utils/vimNavigation.ts +417 -0
- package/utils/vimReticle.ts +88 -0
- package/utils/vimScroll.ts +162 -0
|
@@ -8,14 +8,36 @@ import {
|
|
|
8
8
|
useState,
|
|
9
9
|
} from "react";
|
|
10
10
|
import { createPortal } from "react-dom";
|
|
11
|
+
import { useVimDocumentFocus } from "../../hooks/useVimDocumentFocus";
|
|
12
|
+
import {
|
|
13
|
+
isVimSelectionActionId,
|
|
14
|
+
type VimSelectionHudContext,
|
|
15
|
+
} from "../../shortcuts";
|
|
11
16
|
import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../../types";
|
|
12
17
|
import { AnnotationType } from "../../types";
|
|
18
|
+
import { copyTextPreservingFocus } from "../../utils/clipboard";
|
|
13
19
|
import { getIdentity } from "../../utils/identity";
|
|
20
|
+
import {
|
|
21
|
+
createVimHudCommand,
|
|
22
|
+
getVimHudPhase,
|
|
23
|
+
type VimHudCommand,
|
|
24
|
+
} from "../../utils/vimHud";
|
|
14
25
|
import { AnnotationToolbar } from "../AnnotationToolbar";
|
|
15
26
|
import { AttachmentsButton } from "../AttachmentsButton";
|
|
16
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
CommentPopover,
|
|
29
|
+
type CommentAskAIHandler,
|
|
30
|
+
type CommentTargetChip,
|
|
31
|
+
} from "../CommentPopover";
|
|
17
32
|
import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker";
|
|
33
|
+
import { VimKeyHud } from "../VimKeyHud";
|
|
18
34
|
import type { ViewerHandle } from "../Viewer";
|
|
35
|
+
import {
|
|
36
|
+
computeComposerYield,
|
|
37
|
+
distanceToRect,
|
|
38
|
+
type ComposerYieldState,
|
|
39
|
+
} from "./composerYield";
|
|
40
|
+
import { buildSyncNumbering } from "./annotationNumbering";
|
|
19
41
|
import { useHtmlAnnotation } from "./useHtmlAnnotation";
|
|
20
42
|
import {
|
|
21
43
|
THEME_TOKENS,
|
|
@@ -41,6 +63,79 @@ function isLightTheme(): boolean {
|
|
|
41
63
|
return document.documentElement.classList.contains("light");
|
|
42
64
|
}
|
|
43
65
|
|
|
66
|
+
function isBridgeReadyMessage(value: unknown): boolean {
|
|
67
|
+
return typeof value === "object"
|
|
68
|
+
&& value !== null
|
|
69
|
+
&& "type" in value
|
|
70
|
+
&& value.type === `${PREFIX}ready`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
74
|
+
return typeof value === "object" && value !== null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseVimSelectionHudContext(
|
|
78
|
+
value: unknown,
|
|
79
|
+
): VimSelectionHudContext | null {
|
|
80
|
+
return value === "inactive"
|
|
81
|
+
|| value === "block"
|
|
82
|
+
|| value === "inline"
|
|
83
|
+
|| value === "text"
|
|
84
|
+
|| value === "visual"
|
|
85
|
+
|| value === "visual-block"
|
|
86
|
+
|| value === "action"
|
|
87
|
+
? value
|
|
88
|
+
: null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface VimBridgeCommand {
|
|
92
|
+
readonly actionId: Parameters<typeof createVimHudCommand>[1];
|
|
93
|
+
readonly key: string;
|
|
94
|
+
readonly context: VimSelectionHudContext;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseVimBridgeCommand(value: unknown): VimBridgeCommand | null {
|
|
98
|
+
if (
|
|
99
|
+
!isRecord(value)
|
|
100
|
+
|| value.type !== `${PREFIX}vim-command`
|
|
101
|
+
|| !isVimSelectionActionId(value.actionId)
|
|
102
|
+
|| typeof value.key !== "string"
|
|
103
|
+
) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const context = parseVimSelectionHudContext(value.context);
|
|
107
|
+
return context
|
|
108
|
+
? { actionId: value.actionId, key: value.key, context }
|
|
109
|
+
: null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseVimBridgeState(value: unknown): VimSelectionHudContext | null {
|
|
113
|
+
return isRecord(value) && value.type === `${PREFIX}vim-state`
|
|
114
|
+
? parseVimSelectionHudContext(value.phase)
|
|
115
|
+
: null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseVimBridgeHelp(value: unknown): boolean | null {
|
|
119
|
+
return isRecord(value)
|
|
120
|
+
&& value.type === `${PREFIX}vim-help`
|
|
121
|
+
&& typeof value.open === "boolean"
|
|
122
|
+
? value.open
|
|
123
|
+
: null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024;
|
|
127
|
+
|
|
128
|
+
function parseVimBridgeCopy(value: unknown): string | null {
|
|
129
|
+
return isRecord(value)
|
|
130
|
+
&& value.type === `${PREFIX}vim-copy`
|
|
131
|
+
&& typeof value.text === "string"
|
|
132
|
+
&& value.text.length > 0
|
|
133
|
+
&& value.text.length <= MAX_VIM_COPY_TEXT_LENGTH
|
|
134
|
+
? value.text
|
|
135
|
+
: null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Inputs for the sandboxed raw-HTML viewer and its parent-side annotation UI. */
|
|
44
139
|
export interface HtmlViewerProps {
|
|
45
140
|
rawHtml: string;
|
|
46
141
|
annotations: Annotation[];
|
|
@@ -50,6 +145,14 @@ export interface HtmlViewerProps {
|
|
|
50
145
|
mode: EditorMode;
|
|
51
146
|
/** Input method: 'drag' = text selection, 'pinpoint' = click an element. */
|
|
52
147
|
inputMethod: InputMethod;
|
|
148
|
+
/** Opt-in Vim-style keyboard selection. Default false for compatibility. */
|
|
149
|
+
vimModeEnabled?: boolean;
|
|
150
|
+
/** Replace the iframe-local compact badge with the shared live key HUD. */
|
|
151
|
+
vimHudEnabled?: boolean;
|
|
152
|
+
/** Show the parent key panel without affecting the iframe HUD reticle. */
|
|
153
|
+
vimHudKeyPanelEnabled?: boolean;
|
|
154
|
+
/** Persist a user request to hide the parent key panel. */
|
|
155
|
+
onVimHudKeyPanelChange?: (enabled: boolean) => void;
|
|
53
156
|
globalAttachments?: ImageAttachment[];
|
|
54
157
|
onAddGlobalAttachment?: (image: ImageAttachment) => void;
|
|
55
158
|
onRemoveGlobalAttachment?: (path: string) => void;
|
|
@@ -67,8 +170,16 @@ export interface HtmlViewerProps {
|
|
|
67
170
|
/** Toggle the diff-highlighted view on/off. */
|
|
68
171
|
onToggleDiff?: () => void;
|
|
69
172
|
onAskAI?: CommentAskAIHandler;
|
|
173
|
+
/** Disable every annotation mutation entry point while preserving reading and navigation. */
|
|
174
|
+
readOnly?: boolean;
|
|
175
|
+
/** Accessible iframe title. */
|
|
176
|
+
title?: string;
|
|
70
177
|
}
|
|
71
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Render arbitrary HTML in a sandbox and adapt its validated bridge messages
|
|
181
|
+
* to the same annotation controls used by the Markdown viewer.
|
|
182
|
+
*/
|
|
72
183
|
export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
73
184
|
(
|
|
74
185
|
{
|
|
@@ -79,6 +190,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
79
190
|
selectedAnnotationId,
|
|
80
191
|
mode,
|
|
81
192
|
inputMethod,
|
|
193
|
+
vimModeEnabled = false,
|
|
194
|
+
vimHudEnabled = false,
|
|
195
|
+
vimHudKeyPanelEnabled = true,
|
|
196
|
+
onVimHudKeyPanelChange,
|
|
82
197
|
globalAttachments = [],
|
|
83
198
|
onAddGlobalAttachment,
|
|
84
199
|
onRemoveGlobalAttachment,
|
|
@@ -89,13 +204,24 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
89
204
|
diffActive,
|
|
90
205
|
onToggleDiff,
|
|
91
206
|
onAskAI,
|
|
207
|
+
readOnly = false,
|
|
208
|
+
title = "HTML Plan Viewer",
|
|
92
209
|
},
|
|
93
210
|
ref,
|
|
94
211
|
) => {
|
|
95
212
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
96
213
|
const globalCommentButtonRef = useRef<HTMLButtonElement>(null);
|
|
97
214
|
const [iframeHeight, setIframeHeight] = useState(600);
|
|
98
|
-
|
|
215
|
+
// Increment on every bridge-ready event so srcdoc navigations re-send
|
|
216
|
+
// state even though the iframe element and its WindowProxy are reused.
|
|
217
|
+
const [iframeReadyVersion, setIframeReadyVersion] = useState(0);
|
|
218
|
+
const [iframeFocused, setIframeFocused] = useState(false);
|
|
219
|
+
const [vimBridgePhase, setVimBridgePhase] =
|
|
220
|
+
useState<VimSelectionHudContext>("inactive");
|
|
221
|
+
const [vimHudCommand, setVimHudCommand] = useState<VimHudCommand | null>(null);
|
|
222
|
+
const [vimHelpOpen, setVimHelpOpen] = useState(false);
|
|
223
|
+
const vimHudSequenceRef = useRef(0);
|
|
224
|
+
const vimHudActive = !readOnly && vimModeEnabled && vimHudEnabled;
|
|
99
225
|
const [globalCommentPopover, setGlobalCommentPopover] = useState<{
|
|
100
226
|
anchorEl: HTMLElement;
|
|
101
227
|
contextText: string;
|
|
@@ -119,45 +245,269 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
119
245
|
setIframeHeight(height);
|
|
120
246
|
}, []);
|
|
121
247
|
|
|
248
|
+
// Composer yield while shift-selecting (multi-target drafts): fade the
|
|
249
|
+
// composer as the pointer approaches, click-through when over it. Pointer
|
|
250
|
+
// positions arrive from parent mousemoves AND from the bridge (the iframe
|
|
251
|
+
// consumes moves over the page, so the bridge relays them).
|
|
252
|
+
const [composerYield, setComposerYield] = useState<ComposerYieldState>("none");
|
|
253
|
+
const composerYieldRef = useRef(composerYield);
|
|
254
|
+
composerYieldRef.current = composerYield;
|
|
255
|
+
const shiftHeldRef = useRef(false);
|
|
256
|
+
|
|
257
|
+
const handleYieldPointer = useCallback((clientX: number, clientY: number) => {
|
|
258
|
+
if (!shiftHeldRef.current) return;
|
|
259
|
+
const popover = document.querySelector("[data-comment-popover]");
|
|
260
|
+
if (!popover) return;
|
|
261
|
+
const rect = popover.getBoundingClientRect();
|
|
262
|
+
const next = computeComposerYield(
|
|
263
|
+
composerYieldRef.current,
|
|
264
|
+
distanceToRect(clientX, clientY, rect),
|
|
265
|
+
);
|
|
266
|
+
if (next !== composerYieldRef.current) setComposerYield(next);
|
|
267
|
+
}, []);
|
|
268
|
+
|
|
269
|
+
const handleBridgePointer = useCallback(
|
|
270
|
+
(x: number, y: number, shift: boolean) => {
|
|
271
|
+
// The bridge is the only observer of Shift while the pointer lives
|
|
272
|
+
// inside the sandbox (parent keydowns don't fire there, and window
|
|
273
|
+
// blur clears our local flag when focus enters the iframe) — so the
|
|
274
|
+
// relayed shift state arms/disarms the yield directly.
|
|
275
|
+
shiftHeldRef.current = shift;
|
|
276
|
+
if (!shift) {
|
|
277
|
+
setComposerYield("none");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const iframeRect = iframeRef.current?.getBoundingClientRect();
|
|
281
|
+
if (!iframeRect) return;
|
|
282
|
+
handleYieldPointer(iframeRect.left + x, iframeRect.top + y);
|
|
283
|
+
},
|
|
284
|
+
[handleYieldPointer],
|
|
285
|
+
);
|
|
286
|
+
|
|
122
287
|
const hook = useHtmlAnnotation({
|
|
123
288
|
iframeRef,
|
|
289
|
+
enabled: !readOnly,
|
|
124
290
|
annotations,
|
|
125
291
|
onAddAnnotation,
|
|
126
292
|
onSelectAnnotation,
|
|
127
293
|
selectedAnnotationId,
|
|
128
294
|
mode,
|
|
129
295
|
onResize: handleResize,
|
|
296
|
+
onBridgePointer: handleBridgePointer,
|
|
130
297
|
});
|
|
131
298
|
|
|
299
|
+
const multiSelectActive = !readOnly && !!hook.commentPopover && hook.draftTargets.length > 0;
|
|
300
|
+
|
|
301
|
+
// Track Shift while a multi-select draft composer is open; releasing it
|
|
302
|
+
// (or losing window focus) always restores the composer.
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
if (!multiSelectActive) {
|
|
305
|
+
shiftHeldRef.current = false;
|
|
306
|
+
setComposerYield("none");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const down = (e: KeyboardEvent) => {
|
|
310
|
+
if (e.key === "Shift") shiftHeldRef.current = true;
|
|
311
|
+
};
|
|
312
|
+
const release = () => {
|
|
313
|
+
shiftHeldRef.current = false;
|
|
314
|
+
setComposerYield("none");
|
|
315
|
+
};
|
|
316
|
+
const up = (e: KeyboardEvent) => {
|
|
317
|
+
if (e.key === "Shift") release();
|
|
318
|
+
};
|
|
319
|
+
const move = (e: MouseEvent) => {
|
|
320
|
+
// Parent-side pointer (over app chrome or the composer itself).
|
|
321
|
+
if (e.shiftKey) shiftHeldRef.current = true;
|
|
322
|
+
handleYieldPointer(e.clientX, e.clientY);
|
|
323
|
+
};
|
|
324
|
+
window.addEventListener("keydown", down);
|
|
325
|
+
window.addEventListener("keyup", up);
|
|
326
|
+
window.addEventListener("blur", release);
|
|
327
|
+
window.addEventListener("mousemove", move);
|
|
328
|
+
return () => {
|
|
329
|
+
window.removeEventListener("keydown", down);
|
|
330
|
+
window.removeEventListener("keyup", up);
|
|
331
|
+
window.removeEventListener("blur", release);
|
|
332
|
+
window.removeEventListener("mousemove", move);
|
|
333
|
+
};
|
|
334
|
+
}, [multiSelectActive, handleYieldPointer]);
|
|
335
|
+
|
|
336
|
+
// Chip data for the composer: semantic label + short excerpt per target.
|
|
337
|
+
const targetChips = useMemo<CommentTargetChip[] | undefined>(() => {
|
|
338
|
+
if (!hook.draftTargets.length) return undefined;
|
|
339
|
+
return hook.draftTargets.map((t) => ({
|
|
340
|
+
key: t.key,
|
|
341
|
+
label: t.label,
|
|
342
|
+
excerpt: t.text.replace(/\s+/g, " ").trim().slice(0, 80),
|
|
343
|
+
}));
|
|
344
|
+
}, [hook.draftTargets]);
|
|
345
|
+
|
|
132
346
|
useEffect(() => {
|
|
133
|
-
function handler(e: MessageEvent) {
|
|
134
|
-
if (e.
|
|
135
|
-
|
|
347
|
+
function handler(e: MessageEvent<unknown>) {
|
|
348
|
+
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
349
|
+
if (isBridgeReadyMessage(e.data)) {
|
|
350
|
+
setIframeReadyVersion((version) => version + 1);
|
|
351
|
+
setVimBridgePhase("inactive");
|
|
352
|
+
setVimHudCommand(null);
|
|
353
|
+
setVimHelpOpen(false);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const vimCopy = parseVimBridgeCopy(e.data);
|
|
357
|
+
if (vimCopy !== null) {
|
|
358
|
+
const iframe = iframeRef.current;
|
|
359
|
+
if (
|
|
360
|
+
!readOnly
|
|
361
|
+
&& vimModeEnabled
|
|
362
|
+
&& iframe
|
|
363
|
+
&& document.activeElement === iframe
|
|
364
|
+
) {
|
|
365
|
+
copyTextPreservingFocus(vimCopy, iframe);
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (!vimHudActive) return;
|
|
370
|
+
const vimHelp = parseVimBridgeHelp(e.data);
|
|
371
|
+
if (vimHelp !== null) {
|
|
372
|
+
setVimHelpOpen(vimHelp);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const vimState = parseVimBridgeState(e.data);
|
|
376
|
+
if (vimState) {
|
|
377
|
+
setVimBridgePhase(vimState);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const vimCommand = parseVimBridgeCommand(e.data);
|
|
381
|
+
if (vimCommand) {
|
|
382
|
+
vimHudSequenceRef.current += 1;
|
|
383
|
+
setVimHudCommand(createVimHudCommand(
|
|
384
|
+
vimHudSequenceRef.current,
|
|
385
|
+
vimCommand.actionId,
|
|
386
|
+
vimCommand.key,
|
|
387
|
+
vimCommand.context,
|
|
388
|
+
));
|
|
136
389
|
}
|
|
137
390
|
}
|
|
138
391
|
window.addEventListener("message", handler);
|
|
139
392
|
return () => window.removeEventListener("message", handler);
|
|
393
|
+
}, [readOnly, vimHudActive, vimModeEnabled]);
|
|
394
|
+
|
|
395
|
+
useEffect(() => {
|
|
396
|
+
if (vimHudActive) return;
|
|
397
|
+
setVimBridgePhase("inactive");
|
|
398
|
+
setVimHudCommand(null);
|
|
399
|
+
setVimHelpOpen(false);
|
|
400
|
+
}, [vimHudActive]);
|
|
401
|
+
|
|
402
|
+
const handleVimHelpOpenChange = useCallback((open: boolean) => {
|
|
403
|
+
setVimHelpOpen(open);
|
|
404
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
405
|
+
{ type: `${PREFIX}set-vim-help`, open },
|
|
406
|
+
"*",
|
|
407
|
+
);
|
|
140
408
|
}, []);
|
|
141
409
|
|
|
410
|
+
const handleVimHudFocusLeave = useCallback(() => {
|
|
411
|
+
if (iframeRef.current === document.activeElement) return;
|
|
412
|
+
setIframeFocused(false);
|
|
413
|
+
}, []);
|
|
414
|
+
|
|
415
|
+
const focusVimDocument = useCallback((): boolean => {
|
|
416
|
+
const iframe = iframeRef.current;
|
|
417
|
+
if (readOnly || !vimModeEnabled || !iframe) return false;
|
|
418
|
+
if (document.activeElement === iframe) return false;
|
|
419
|
+
iframe.focus({ preventScroll: true });
|
|
420
|
+
if (document.activeElement !== iframe) return false;
|
|
421
|
+
iframe.contentWindow?.postMessage(
|
|
422
|
+
{ type: `${PREFIX}focus-vim` },
|
|
423
|
+
"*",
|
|
424
|
+
);
|
|
425
|
+
return true;
|
|
426
|
+
}, [readOnly, vimModeEnabled]);
|
|
427
|
+
|
|
428
|
+
useVimDocumentFocus({
|
|
429
|
+
enabled: !readOnly && vimModeEnabled,
|
|
430
|
+
blocked: !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker,
|
|
431
|
+
focusDocument: focusVimDocument,
|
|
432
|
+
});
|
|
433
|
+
|
|
142
434
|
useEffect(() => {
|
|
143
|
-
if (
|
|
435
|
+
if (iframeReadyVersion === 0) return;
|
|
144
436
|
if (annotations.length > 0) {
|
|
145
437
|
hook.applyAnnotations(annotations);
|
|
146
438
|
}
|
|
147
|
-
}, [
|
|
439
|
+
}, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
440
|
+
|
|
441
|
+
// Placed-marker numbering is parent-authoritative and matches the
|
|
442
|
+
// numbers exportAnnotations writes into the submitted feedback: the full
|
|
443
|
+
// list INCLUDING globals is numbered by ARRAY position (the export's
|
|
444
|
+
// effective order — its sort keys tie for raw-HTML annotations), and
|
|
445
|
+
// globals then ship no entry (no page location) — see buildSyncNumbering
|
|
446
|
+
// for the contract. Renumbers on delete; the bridge's own registration
|
|
447
|
+
// order is only a pre-sync fallback.
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
if (iframeReadyVersion === 0) return;
|
|
450
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
451
|
+
{ type: `${PREFIX}sync-annotations`, annotations: buildSyncNumbering(annotations) },
|
|
452
|
+
"*",
|
|
453
|
+
);
|
|
454
|
+
}, [iframeReadyVersion, annotations]);
|
|
148
455
|
|
|
149
456
|
// Tell the bridge the current input method (drag vs pinpoint). Re-posts on
|
|
150
457
|
// ready (fresh iframe) and whenever the user switches it in the toolstrip.
|
|
151
458
|
useEffect(() => {
|
|
152
|
-
if (
|
|
459
|
+
if (iframeReadyVersion === 0) return;
|
|
153
460
|
iframeRef.current?.contentWindow?.postMessage(
|
|
154
461
|
{ type: `${PREFIX}set-input-method`, method: inputMethod },
|
|
155
462
|
"*",
|
|
156
463
|
);
|
|
157
|
-
}, [
|
|
464
|
+
}, [iframeReadyVersion, inputMethod]);
|
|
465
|
+
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
if (iframeReadyVersion === 0) return;
|
|
468
|
+
const iframe = iframeRef.current;
|
|
469
|
+
iframe?.contentWindow?.postMessage(
|
|
470
|
+
{
|
|
471
|
+
type: `${PREFIX}set-vim-mode`,
|
|
472
|
+
enabled: !readOnly && vimModeEnabled,
|
|
473
|
+
hudEnabled: vimHudEnabled,
|
|
474
|
+
mode,
|
|
475
|
+
},
|
|
476
|
+
"*",
|
|
477
|
+
);
|
|
478
|
+
if (!readOnly && vimModeEnabled && iframe === document.activeElement) {
|
|
479
|
+
// The initial parent focus can land before the sandbox bridge is ready.
|
|
480
|
+
// Reassert it after configuration so raw HTML enters BLOCK immediately,
|
|
481
|
+
// matching the Markdown surface instead of waiting for the first key.
|
|
482
|
+
iframe.contentWindow?.postMessage(
|
|
483
|
+
{ type: `${PREFIX}focus-vim` },
|
|
484
|
+
"*",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}, [iframeReadyVersion, mode, readOnly, vimHudEnabled, vimModeEnabled]);
|
|
488
|
+
|
|
489
|
+
const vimOverlayWasOpenRef = useRef(false);
|
|
490
|
+
useEffect(() => {
|
|
491
|
+
const overlayOpen = !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker;
|
|
492
|
+
const wasOpen = vimOverlayWasOpenRef.current;
|
|
493
|
+
vimOverlayWasOpenRef.current = overlayOpen;
|
|
494
|
+
if (
|
|
495
|
+
!readOnly
|
|
496
|
+
&& vimModeEnabled
|
|
497
|
+
&& wasOpen
|
|
498
|
+
&& !overlayOpen
|
|
499
|
+
&& (document.activeElement === document.body || document.activeElement === null)
|
|
500
|
+
) {
|
|
501
|
+
iframeRef.current?.focus({ preventScroll: true });
|
|
502
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
503
|
+
{ type: `${PREFIX}focus-vim` },
|
|
504
|
+
"*",
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}, [hook.commentPopover, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
|
|
158
508
|
|
|
159
509
|
useEffect(() => {
|
|
160
|
-
if (
|
|
510
|
+
if (iframeReadyVersion === 0) return;
|
|
161
511
|
function sendTheme() {
|
|
162
512
|
iframeRef.current?.contentWindow?.postMessage(
|
|
163
513
|
{
|
|
@@ -176,7 +526,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
176
526
|
attributeFilter: ["class", "style"],
|
|
177
527
|
});
|
|
178
528
|
return () => observer.disconnect();
|
|
179
|
-
}, [
|
|
529
|
+
}, [iframeReadyVersion, hostTheme]);
|
|
180
530
|
|
|
181
531
|
useImperativeHandle(ref, () => ({
|
|
182
532
|
removeHighlight: hook.removeHighlight,
|
|
@@ -186,6 +536,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
186
536
|
|
|
187
537
|
const handleGlobalCommentSubmit = useCallback(
|
|
188
538
|
(text: string, images?: ImageAttachment[]) => {
|
|
539
|
+
if (readOnly) return;
|
|
189
540
|
onAddAnnotation({
|
|
190
541
|
id: `global-${Date.now()}`,
|
|
191
542
|
blockId: "",
|
|
@@ -200,9 +551,15 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
200
551
|
});
|
|
201
552
|
setGlobalCommentPopover(null);
|
|
202
553
|
},
|
|
203
|
-
[onAddAnnotation],
|
|
554
|
+
[onAddAnnotation, readOnly],
|
|
204
555
|
);
|
|
205
556
|
|
|
557
|
+
useEffect(() => {
|
|
558
|
+
if (readOnly) setGlobalCommentPopover(null);
|
|
559
|
+
}, [readOnly]);
|
|
560
|
+
|
|
561
|
+
const hasActionButtons = !readOnly || Boolean(diffAvailable && onToggleDiff);
|
|
562
|
+
|
|
206
563
|
// Document-level controls (attachments + global comment). Shared between the
|
|
207
564
|
// normal layout (bar above the card) and full-viewport (floating overlay), so
|
|
208
565
|
// edge-to-edge HTML keeps these affordances rather than dropping them.
|
|
@@ -220,7 +577,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
220
577
|
<span>{diffActive ? "Hide changes" : "Show changes"}</span>
|
|
221
578
|
</button>
|
|
222
579
|
)}
|
|
223
|
-
{onAddGlobalAttachment && onRemoveGlobalAttachment && (
|
|
580
|
+
{!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
|
|
224
581
|
<AttachmentsButton
|
|
225
582
|
images={globalAttachments}
|
|
226
583
|
onAdd={onAddGlobalAttachment}
|
|
@@ -228,22 +585,23 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
228
585
|
variant="toolbar"
|
|
229
586
|
/>
|
|
230
587
|
)}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
anchorEl
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
588
|
+
{!readOnly && (
|
|
589
|
+
<button
|
|
590
|
+
ref={globalCommentButtonRef}
|
|
591
|
+
onClick={() => {
|
|
592
|
+
const anchorEl = globalCommentButtonRef.current;
|
|
593
|
+
if (!anchorEl) return;
|
|
594
|
+
setGlobalCommentPopover({ anchorEl, contextText: "" });
|
|
595
|
+
}}
|
|
596
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors cursor-pointer"
|
|
597
|
+
title="Add global comment"
|
|
598
|
+
>
|
|
599
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
600
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
|
601
|
+
</svg>
|
|
602
|
+
<span>Comment</span>
|
|
603
|
+
</button>
|
|
604
|
+
)}
|
|
247
605
|
</>
|
|
248
606
|
);
|
|
249
607
|
|
|
@@ -254,7 +612,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
254
612
|
style={fullViewport ? undefined : { maxWidth: maxWidth ?? undefined }}
|
|
255
613
|
>
|
|
256
614
|
{/* Action bar — above the iframe in normal mode (outside overflow:hidden). */}
|
|
257
|
-
{!fullViewport && (
|
|
615
|
+
{!fullViewport && hasActionButtons && (
|
|
258
616
|
<div data-print-hide className="flex justify-end gap-1 md:gap-2 mb-2">
|
|
259
617
|
{actionButtons}
|
|
260
618
|
</div>
|
|
@@ -267,7 +625,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
267
625
|
{/* Full-viewport mode has no card chrome, so float the same controls
|
|
268
626
|
over the top-right of the iframe (with a backdrop so they read over
|
|
269
627
|
any HTML). The selection toolbar is portaled separately. */}
|
|
270
|
-
{fullViewport && !hideControls && (
|
|
628
|
+
{fullViewport && !hideControls && hasActionButtons && (
|
|
271
629
|
<div
|
|
272
630
|
data-print-hide
|
|
273
631
|
className="absolute top-3 right-3 z-10 flex items-center gap-1 md:gap-2 rounded-lg border border-border/50 bg-background/80 px-1.5 py-1 shadow-md backdrop-blur-sm"
|
|
@@ -276,23 +634,60 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
276
634
|
</div>
|
|
277
635
|
)}
|
|
278
636
|
<iframe
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
637
|
+
ref={iframeRef}
|
|
638
|
+
srcDoc={srcdoc}
|
|
639
|
+
sandbox="allow-scripts"
|
|
640
|
+
style={{
|
|
641
|
+
width: "100%",
|
|
642
|
+
height: fullViewport ? "100%" : `${iframeHeight}px`,
|
|
643
|
+
border: "none",
|
|
644
|
+
display: "block",
|
|
645
|
+
colorScheme: "auto",
|
|
646
|
+
outline: !readOnly && vimModeEnabled ? "none" : undefined,
|
|
647
|
+
}}
|
|
648
|
+
title={title}
|
|
649
|
+
onFocus={() => setIframeFocused(true)}
|
|
650
|
+
onBlur={(event) => {
|
|
651
|
+
if (
|
|
652
|
+
event.relatedTarget instanceof Element
|
|
653
|
+
&& event.relatedTarget.closest('[data-vim-key-hud]')
|
|
654
|
+
) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
setIframeFocused(false);
|
|
658
|
+
}}
|
|
659
|
+
/>
|
|
291
660
|
</article>
|
|
292
661
|
</div>
|
|
293
662
|
|
|
663
|
+
{vimHudActive
|
|
664
|
+
&& (vimHelpOpen || (
|
|
665
|
+
vimHudKeyPanelEnabled
|
|
666
|
+
&& vimBridgePhase !== "inactive"
|
|
667
|
+
))
|
|
668
|
+
&& (iframeFocused || vimBridgePhase === "action" || vimHelpOpen)
|
|
669
|
+
&& createPortal(
|
|
670
|
+
<VimKeyHud
|
|
671
|
+
command={vimHudCommand}
|
|
672
|
+
phase={getVimHudPhase(vimBridgePhase, vimHudCommand?.actionId)}
|
|
673
|
+
inputMethod={inputMethod}
|
|
674
|
+
expanded={vimHelpOpen}
|
|
675
|
+
onExpandedChange={handleVimHelpOpenChange}
|
|
676
|
+
onHide={
|
|
677
|
+
onVimHudKeyPanelChange
|
|
678
|
+
? () => {
|
|
679
|
+
handleVimHelpOpenChange(false);
|
|
680
|
+
onVimHudKeyPanelChange(false);
|
|
681
|
+
}
|
|
682
|
+
: undefined
|
|
683
|
+
}
|
|
684
|
+
onFocusLeave={handleVimHudFocusLeave}
|
|
685
|
+
/>,
|
|
686
|
+
document.body,
|
|
687
|
+
)}
|
|
688
|
+
|
|
294
689
|
{/* Toolbar portal */}
|
|
295
|
-
{hook.toolbarState &&
|
|
690
|
+
{!readOnly && hook.toolbarState &&
|
|
296
691
|
createPortal(
|
|
297
692
|
<AnnotationToolbar
|
|
298
693
|
positionMode="center-above"
|
|
@@ -307,7 +702,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
307
702
|
)}
|
|
308
703
|
|
|
309
704
|
{/* Comment popover portal */}
|
|
310
|
-
{hook.commentPopover &&
|
|
705
|
+
{!readOnly && hook.commentPopover &&
|
|
311
706
|
createPortal(
|
|
312
707
|
<CommentPopover
|
|
313
708
|
anchorEl={hook.commentPopover.anchorEl}
|
|
@@ -316,18 +711,25 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
316
711
|
isGlobal={false}
|
|
317
712
|
onSubmit={hook.handleCommentSubmit}
|
|
318
713
|
onClose={hook.handleCommentClose}
|
|
714
|
+
skillReferences
|
|
319
715
|
onAskAI={onAskAI}
|
|
320
716
|
askAIContext={{
|
|
321
717
|
kind: "selection",
|
|
322
718
|
label: "Selected HTML",
|
|
323
719
|
text: hook.commentPopover.selectedText ?? hook.commentPopover.contextText,
|
|
324
720
|
}}
|
|
721
|
+
targetChips={targetChips}
|
|
722
|
+
onRemoveTargetChip={targetChips ? hook.removeDraftTarget : undefined}
|
|
723
|
+
onHoverTargetChip={targetChips ? hook.flashDraftTarget : undefined}
|
|
724
|
+
refocusToken={targetChips ? hook.composerFocusToken : undefined}
|
|
725
|
+
captureStrayKeys={multiSelectActive}
|
|
726
|
+
yieldState={multiSelectActive ? composerYield : undefined}
|
|
325
727
|
/>,
|
|
326
728
|
document.body,
|
|
327
729
|
)}
|
|
328
730
|
|
|
329
731
|
{/* Quick label picker portal */}
|
|
330
|
-
{hook.quickLabelPicker &&
|
|
732
|
+
{!readOnly && hook.quickLabelPicker &&
|
|
331
733
|
createPortal(
|
|
332
734
|
<FloatingQuickLabelPicker
|
|
333
735
|
anchorEl={hook.quickLabelPicker.anchorEl}
|
|
@@ -339,7 +741,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
339
741
|
)}
|
|
340
742
|
|
|
341
743
|
{/* Global comment popover portal */}
|
|
342
|
-
{globalCommentPopover &&
|
|
744
|
+
{!readOnly && globalCommentPopover &&
|
|
343
745
|
createPortal(
|
|
344
746
|
<CommentPopover
|
|
345
747
|
anchorEl={globalCommentPopover.anchorEl}
|
|
@@ -347,6 +749,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
347
749
|
isGlobal={true}
|
|
348
750
|
onSubmit={handleGlobalCommentSubmit}
|
|
349
751
|
onClose={() => setGlobalCommentPopover(null)}
|
|
752
|
+
skillReferences
|
|
350
753
|
onAskAI={onAskAI}
|
|
351
754
|
askAIContext={{ kind: "general", label: "Document" }}
|
|
352
755
|
/>,
|