@plannotator/ui 0.52.0 → 0.53.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 +317 -29
- package/README.md +35 -3
- package/components/AnnotationPanel.tsx +159 -107
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/BlockRenderer.tsx +26 -14
- package/components/CodeFilePopout.tsx +131 -68
- package/components/CommentPopover.tsx +6 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/ImageLightbox.tsx +288 -17
- package/components/ImagePeek.tsx +255 -0
- package/components/ImageResolverContext.tsx +31 -0
- package/components/InlineMarkdown.tsx +123 -15
- package/components/LocalLinkContext.tsx +49 -0
- package/components/MenuVersionSection.tsx +51 -25
- package/components/OpenInAppButton.tsx +18 -49
- package/components/PlanHeaderMenu.tsx +2 -8
- package/components/ProviderIcons.tsx +10 -4
- package/components/RenderedMarkdown.tsx +40 -4
- package/components/Settings.tsx +33 -5
- package/components/SkillReferenceMenu.tsx +28 -6
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +214 -55
- package/components/ai/AIProviderBar.tsx +11 -0
- package/components/ai/AskSessionSetupHint.tsx +12 -0
- package/components/ai/DocumentAIChatPanel.tsx +17 -5
- package/components/ai/SessionAskNotice.tsx +4 -3
- package/components/blocks/BlockquoteBlock.tsx +159 -0
- package/components/blocks/HtmlBlock.tsx +121 -9
- package/components/blocks/QuestionBlock.tsx +253 -104
- package/components/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +73 -3
- package/components/html-viewer/bridge-script.asset.js +86 -0
- package/components/html-viewer/bridge-script.ts +86 -0
- package/components/html-viewer/useHtmlAnnotation.ts +51 -3
- package/components/icons/AgentIcons.tsx +9 -7
- package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
- package/components/plan-diff/PlanDiffViewer.tsx +6 -1
- package/configure.ts +27 -1
- package/hooks/useAgentJobs.ts +8 -3
- package/hooks/useAnnotationHighlighter.ts +128 -23
- package/hooks/useExternalAnnotations.ts +6 -0
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/styles.css +1 -1
- package/types.ts +48 -0
- package/utils/agentClosed.ts +26 -0
- package/utils/agentToolSetting.ts +16 -9
- package/utils/aiProvider.ts +7 -0
- package/utils/askSessionSetupHint.ts +27 -0
- package/utils/blockTargeting.ts +14 -3
- package/utils/parser.ts +65 -51
- package/utils/pasteInsert.ts +234 -0
- package/utils/planDiffEngine.ts +272 -2
- package/utils/skillCatalog.ts +15 -2
- package/utils/skillReferences.ts +50 -13
- package/utils/storage.ts +5 -0
- package/utils/textCut.ts +41 -0
- package/utils/updateNotice.ts +120 -0
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
useCallback,
|
|
4
4
|
useEffect,
|
|
5
5
|
useImperativeHandle,
|
|
6
|
+
useLayoutEffect,
|
|
6
7
|
useMemo,
|
|
7
8
|
useRef,
|
|
8
9
|
useState,
|
|
@@ -14,7 +15,7 @@ import {
|
|
|
14
15
|
isVimSelectionActionId,
|
|
15
16
|
type VimSelectionHudContext,
|
|
16
17
|
} from "../../shortcuts";
|
|
17
|
-
import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../../types";
|
|
18
|
+
import type { Annotation, EditorMode, HostDraft, ImageAttachment, InputMethod } from "../../types";
|
|
18
19
|
import { AnnotationType } from "../../types";
|
|
19
20
|
import { copyTextPreservingFocus } from "../../utils/clipboard";
|
|
20
21
|
import { elementIdentityForAskAI } from "../../utils/parser";
|
|
@@ -181,6 +182,9 @@ function parseVimBridgeCopy(value: unknown): string | null {
|
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
/** Inputs for the sandboxed raw-HTML viewer and its parent-side annotation UI. */
|
|
185
|
+
/** The fewest milliseconds between two honored `step` messages from the page. */
|
|
186
|
+
export const STEP_MESSAGE_MIN_INTERVAL_MS = 150;
|
|
187
|
+
|
|
184
188
|
export interface HtmlViewerProps {
|
|
185
189
|
rawHtml: string;
|
|
186
190
|
/** Live proxied-app mode: render `src` (no sandbox, no srcdoc) instead of
|
|
@@ -228,6 +232,13 @@ export interface HtmlViewerProps {
|
|
|
228
232
|
/** Mod+Shift+X pressed while focus lived inside the iframe: show/hide the
|
|
229
233
|
* host's floating tools over the page. The host owns that state. */
|
|
230
234
|
onToolsToggle?: () => void;
|
|
235
|
+
/** A plain `]` (1) or `[` (-1) pressed inside the page while the surface is
|
|
236
|
+
* armed (Annotate) and the page's focus is not in a text field: the host's
|
|
237
|
+
* next / previous file (the Inbox steps through a thread's attachments).
|
|
238
|
+
* The bridge forwards nothing in Interact, and the page still gets the
|
|
239
|
+
* key. Honored only while the frame holds focus, and at most once per
|
|
240
|
+
* STEP_MESSAGE_MIN_INTERVAL_MS. Absent: the message is ignored, as before. */
|
|
241
|
+
onStep?: (dir: 1 | -1) => void;
|
|
231
242
|
/** Opt-in Vim-style keyboard selection. Default false for compatibility. */
|
|
232
243
|
vimModeEnabled?: boolean;
|
|
233
244
|
/** Replace the iframe-local compact badge with the shared live key HUD. */
|
|
@@ -325,6 +336,27 @@ export interface HtmlViewerProps {
|
|
|
325
336
|
* warning). Meaningless on the inline path, which never shows a strip.
|
|
326
337
|
*/
|
|
327
338
|
bridgeErrorDisplay?: "banner" | "none";
|
|
339
|
+
/**
|
|
340
|
+
* Opt-in host capability: the composer handed to the host (see
|
|
341
|
+
* `HostDraft`). A text selection reports `intent: 'selection'` and a pin
|
|
342
|
+
* `intent: 'compose'` with the element's label, instead of opening the
|
|
343
|
+
* toolbar or the composer; the selection clearing reports null. With it the
|
|
344
|
+
* handle's `stepPin` moves a pinned draft to the element around it or back
|
|
345
|
+
* in. The host saves the comment and draws it through `annotations` /
|
|
346
|
+
* `applySharedAnnotations`. Ignored when `readOnly`. Absent → unchanged.
|
|
347
|
+
*/
|
|
348
|
+
onHostDraft?: (draft: HostDraft | null) => void;
|
|
349
|
+
/**
|
|
350
|
+
* Opt-in host capability for a native shell whose web view decides every
|
|
351
|
+
* navigation itself (the Inbox surface in the iPhone app). A link the
|
|
352
|
+
* person taps in Interact opens as a new window (`window.open`, the sandbox
|
|
353
|
+
* gains `allow-popups`) instead of being reported through the frame
|
|
354
|
+
* protocol (`onOpenLink` does not fire for it); the host's web view hands
|
|
355
|
+
* that window to its own delegate, which decides it. A scripted click opens
|
|
356
|
+
* nothing. In-page `#` links still scroll here, and an armed pinpoint tap
|
|
357
|
+
* still pins. Absent → unchanged.
|
|
358
|
+
*/
|
|
359
|
+
hostNavigates?: boolean;
|
|
328
360
|
}
|
|
329
361
|
|
|
330
362
|
/**
|
|
@@ -351,6 +383,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
351
383
|
onAnnotateModeExit,
|
|
352
384
|
onAnnotateModeToggle,
|
|
353
385
|
onToolsToggle,
|
|
386
|
+
onStep,
|
|
354
387
|
vimModeEnabled = false,
|
|
355
388
|
vimHudEnabled = false,
|
|
356
389
|
vimHudKeyPanelEnabled = true,
|
|
@@ -377,6 +410,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
377
410
|
bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
|
|
378
411
|
onBridgeUnavailable,
|
|
379
412
|
bridgeErrorDisplay = "banner",
|
|
413
|
+
onHostDraft,
|
|
414
|
+
hostNavigates = false,
|
|
380
415
|
},
|
|
381
416
|
ref,
|
|
382
417
|
) => {
|
|
@@ -411,6 +446,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
411
446
|
onAnnotateModeToggleRef.current = onAnnotateModeToggle;
|
|
412
447
|
const onToolsToggleRef = useRef(onToolsToggle);
|
|
413
448
|
onToolsToggleRef.current = onToolsToggle;
|
|
449
|
+
const onStepRef = useRef(onStep);
|
|
450
|
+
onStepRef.current = onStep;
|
|
451
|
+
// The step message is honored only while the host has the surface armed.
|
|
452
|
+
const annotateModeActiveRef = useRef(annotateModeActive);
|
|
453
|
+
annotateModeActiveRef.current = annotateModeActive;
|
|
454
|
+
const lastStepAtRef = useRef(0);
|
|
414
455
|
|
|
415
456
|
/** Single choke point for direct-to-bridge posts: live sessions get the
|
|
416
457
|
* token + concrete targetOrigin, srcdoc keeps "*" and no token. */
|
|
@@ -613,6 +654,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
613
654
|
onUnanchoredChange: handleBridgeUnanchored,
|
|
614
655
|
maxAdditionalTargets,
|
|
615
656
|
scrollBehavior,
|
|
657
|
+
onHostDraft: readOnly ? undefined : onHostDraft,
|
|
616
658
|
});
|
|
617
659
|
createdIdsRef.current = hook.createdAnnotationIds;
|
|
618
660
|
|
|
@@ -682,7 +724,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
682
724
|
[hook.draftTargets],
|
|
683
725
|
);
|
|
684
726
|
|
|
685
|
-
|
|
727
|
+
// Registered in the commit that inserts the iframe (a layout effect), so
|
|
728
|
+
// the bridge's `ready` can never arrive before anyone listens: a passive
|
|
729
|
+
// effect can run after a fast srcdoc frame has already posted it, and a
|
|
730
|
+
// lost ready leaves the bridge unconfigured for good (seen on a Linux
|
|
731
|
+
// WebKit runner, the Inbox surface's proof).
|
|
732
|
+
useLayoutEffect(() => {
|
|
686
733
|
function handler(e: MessageEvent<unknown>) {
|
|
687
734
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
688
735
|
// Live sessions verify origin + token before reading anything.
|
|
@@ -748,6 +795,20 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
748
795
|
onToolsToggleRef.current?.();
|
|
749
796
|
return;
|
|
750
797
|
}
|
|
798
|
+
if (isRecord(e.data) && e.data.type === `${PREFIX}step`) {
|
|
799
|
+
// The page itself can post this message, so it is honored only for
|
|
800
|
+
// a key the person pressed: the frame holds focus, the surface is
|
|
801
|
+
// armed, and at most one step per STEP_MESSAGE_MIN_INTERVAL_MS (a
|
|
802
|
+
// page cannot push itself out of view by posting in a loop).
|
|
803
|
+
const dir = e.data.dir;
|
|
804
|
+
if (!annotateModeActiveRef.current || (dir !== 1 && dir !== -1)) return;
|
|
805
|
+
if (document.activeElement !== iframeRef.current) return;
|
|
806
|
+
const now = Date.now();
|
|
807
|
+
if (now - lastStepAtRef.current < STEP_MESSAGE_MIN_INTERVAL_MS) return;
|
|
808
|
+
lastStepAtRef.current = now;
|
|
809
|
+
onStepRef.current?.(dir);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
751
812
|
const vimCopy = parseVimBridgeCopy(e.data);
|
|
752
813
|
if (vimCopy !== null) {
|
|
753
814
|
const iframe = iframeRef.current;
|
|
@@ -916,6 +977,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
916
977
|
);
|
|
917
978
|
}, [iframeReadyVersion, annotateModeActive]);
|
|
918
979
|
|
|
980
|
+
// A native host decides link navigations itself (`hostNavigates`).
|
|
981
|
+
useEffect(() => {
|
|
982
|
+
if (iframeReadyVersion === 0 || !hostNavigates) return;
|
|
983
|
+
postToBridge({ type: `${PREFIX}set-host-navigates`, active: true });
|
|
984
|
+
}, [iframeReadyVersion, hostNavigates]);
|
|
985
|
+
|
|
919
986
|
// Parent-side Esc rung: with focus outside the iframe the bridge never
|
|
920
987
|
// sees the keydown. Any open composer/toolbar/picker still closes first —
|
|
921
988
|
// their state is read from this render's closure, so an Esc that closed
|
|
@@ -1012,6 +1079,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
1012
1079
|
// Shared/draft restores respect the live page filter too.
|
|
1013
1080
|
applySharedAnnotations: (anns: Annotation[]) =>
|
|
1014
1081
|
hook.applyAnnotations(forCurrentPage(anns)),
|
|
1082
|
+
stepPin: (direction: "parent" | "child") =>
|
|
1083
|
+
postToBridge({ type: `${PREFIX}step-pin`, direction }),
|
|
1084
|
+
revealPin: () => postToBridge({ type: `${PREFIX}reveal-pin` }),
|
|
1015
1085
|
}));
|
|
1016
1086
|
|
|
1017
1087
|
const handleGlobalCommentSubmit = useCallback(
|
|
@@ -1166,7 +1236,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
1166
1236
|
same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
|
|
1167
1237
|
<iframe
|
|
1168
1238
|
ref={iframeRef}
|
|
1169
|
-
{...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" })}
|
|
1239
|
+
{...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" + (hostNavigates ? " allow-popups" : "") })}
|
|
1170
1240
|
style={{
|
|
1171
1241
|
width: "100%",
|
|
1172
1242
|
height: fullViewport ? "100%" : `${iframeHeight}px`,
|
|
@@ -225,6 +225,8 @@
|
|
|
225
225
|
// marker buttons keep their own clicks. BOTH surface kinds start ARMED —
|
|
226
226
|
// Esc (or the header pen) drops to Interact.
|
|
227
227
|
var annotateModeActive = true;
|
|
228
|
+
// A native host decides link navigations itself (HtmlViewer hostNavigates).
|
|
229
|
+
var hostNavigates = false;
|
|
228
230
|
function updatePinpointCursor() {
|
|
229
231
|
if (!document.body) return;
|
|
230
232
|
if (annotateModeActive && currentInputMethod === 'pinpoint') {
|
|
@@ -530,6 +532,21 @@
|
|
|
530
532
|
flashMultiTarget(typeof e.data.key === 'string' ? e.data.key : '');
|
|
531
533
|
}
|
|
532
534
|
|
|
535
|
+
else if (type === PREFIX + 'step-pin') {
|
|
536
|
+
// A host's Parent or Child (a phone's comment sheet): move the pinned
|
|
537
|
+
// draft to the element around it, or back in. Re-pins through
|
|
538
|
+
// annotateElement, so the parent sees an ordinary pinpoint selection.
|
|
539
|
+
stepPin(e.data.direction === 'child' ? 'child' : 'parent');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
else if (type === PREFIX + 'reveal-pin') {
|
|
543
|
+
// A host whose own panel covers the page's lower part (a phone's comment
|
|
544
|
+
// sheet) shrank the frame: bring the pinned draft back into view.
|
|
545
|
+
if (pendingPinEl && pendingPinEl.isConnected) {
|
|
546
|
+
try { pendingPinEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (ex) {}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
533
550
|
else if (type === PREFIX + 'scroll-to') {
|
|
534
551
|
// Selecting an annotation scrolls its first resolved target into view
|
|
535
552
|
// and flashes the overlay focus highlight over EVERY rect of EVERY
|
|
@@ -573,6 +590,10 @@
|
|
|
573
590
|
if (vimEnabled) updateVimUi();
|
|
574
591
|
}
|
|
575
592
|
|
|
593
|
+
else if (type === PREFIX + 'set-host-navigates') {
|
|
594
|
+
hostNavigates = e.data.active === true;
|
|
595
|
+
}
|
|
596
|
+
|
|
576
597
|
else if (type === PREFIX + 'set-annotate-mode') {
|
|
577
598
|
var nextAnnotateActive = e.data.active === true;
|
|
578
599
|
if (nextAnnotateActive !== annotateModeActive) {
|
|
@@ -3524,6 +3545,41 @@
|
|
|
3524
3545
|
});
|
|
3525
3546
|
}
|
|
3526
3547
|
|
|
3548
|
+
// Parent and Child for a pinned draft. The trail holds the elements stepped
|
|
3549
|
+
// out of, so Child goes back the way Parent came; past it, Child takes the
|
|
3550
|
+
// first element child. A new pin by click starts a new trail.
|
|
3551
|
+
var pinStepTrail = [];
|
|
3552
|
+
var steppingPin = false;
|
|
3553
|
+
function stepPin(direction) {
|
|
3554
|
+
if (!pendingPinEl || !pendingPinEl.isConnected) return;
|
|
3555
|
+
var from = pendingPinEl;
|
|
3556
|
+
var next = null;
|
|
3557
|
+
if (direction === 'parent') {
|
|
3558
|
+
var up = from.parentElement;
|
|
3559
|
+
if (up && up !== document.body && up !== document.documentElement) next = up;
|
|
3560
|
+
} else {
|
|
3561
|
+
var back = pinStepTrail[pinStepTrail.length - 1];
|
|
3562
|
+
if (back && back.isConnected && from.contains(back)) next = back;
|
|
3563
|
+
else {
|
|
3564
|
+
for (var child = from.firstElementChild; child; child = child.nextElementSibling) {
|
|
3565
|
+
if (isViewerOverlayNode(child)) continue;
|
|
3566
|
+
if (/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(child.tagName)) continue;
|
|
3567
|
+
next = child;
|
|
3568
|
+
break;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
if (!next) return;
|
|
3573
|
+
var trail = pinStepTrail.slice();
|
|
3574
|
+
if (direction === 'parent') trail.push(from);
|
|
3575
|
+
else if (trail[trail.length - 1] === next) trail.pop();
|
|
3576
|
+
else trail = [];
|
|
3577
|
+
steppingPin = true;
|
|
3578
|
+
try { annotateElement(next, undefined, pendingPinViaPinpoint, null); }
|
|
3579
|
+
finally { steppingPin = false; }
|
|
3580
|
+
pinStepTrail = trail;
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3527
3583
|
// Pin an element: select its text if possible (so a <mark> can wrap it), else
|
|
3528
3584
|
// post its text + box directly so the toolbar still anchors (e.g. an SVG node,
|
|
3529
3585
|
// whose <text> doesn't select like HTML text). Either way the element stays
|
|
@@ -3531,6 +3587,7 @@
|
|
|
3531
3587
|
// rides along so the annotation can restore to this exact element later.
|
|
3532
3588
|
function annotateElement(el, modeOverride, viaPinpoint, clickPoint) {
|
|
3533
3589
|
if (!el) return false;
|
|
3590
|
+
if (!steppingPin) pinStepTrail = [];
|
|
3534
3591
|
pinpointHover = null;
|
|
3535
3592
|
hidePinpointLabel();
|
|
3536
3593
|
clearMultiTargets(); // a new primary starts a fresh draft
|
|
@@ -3666,6 +3723,16 @@
|
|
|
3666
3723
|
return;
|
|
3667
3724
|
}
|
|
3668
3725
|
e.preventDefault();
|
|
3726
|
+
// A native host decides links itself: a person's tap opens the link as a
|
|
3727
|
+
// new window, which the host's web view hands to its own delegate (the
|
|
3728
|
+
// frame never navigates; the host page's CSP would refuse it anyway).
|
|
3729
|
+
if (hostNavigates) {
|
|
3730
|
+
if (e.isTrusted && !(annotateModeActive && currentInputMethod === 'pinpoint')) {
|
|
3731
|
+
var target = (e.target && e.target.closest) ? e.target.closest('a,area') : null;
|
|
3732
|
+
try { window.open(target && target.href ? target.href : raw, '_blank', 'noopener'); } catch (ex) {}
|
|
3733
|
+
}
|
|
3734
|
+
return;
|
|
3735
|
+
}
|
|
3669
3736
|
// Armed pinpoint: the click belongs to annotation, and the capture-phase
|
|
3670
3737
|
// pinpoint handler below is about to pin this element. Navigation is
|
|
3671
3738
|
// already suppressed above, which is all this surface owes the click.
|
|
@@ -3766,6 +3833,25 @@
|
|
|
3766
3833
|
postToParent({ type: PREFIX + message });
|
|
3767
3834
|
}, true);
|
|
3768
3835
|
|
|
3836
|
+
// A plain ] or [ while armed (Annotate): the host may step to its next or
|
|
3837
|
+
// previous file (the Inbox steps through a thread's attachments). Never in
|
|
3838
|
+
// Interact (the page owns its keys), never while the page's focus is in a
|
|
3839
|
+
// text field, with a modifier, on auto-repeat or mid-composition. The page
|
|
3840
|
+
// still receives the key; a host without a step handler ignores the
|
|
3841
|
+
// message. Capture phase, like the chords above.
|
|
3842
|
+
document.addEventListener('keydown', function(e) {
|
|
3843
|
+
if (e.key !== ']' && e.key !== '[') return;
|
|
3844
|
+
if (!annotateModeActive || vimEnabled) return;
|
|
3845
|
+
if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || e.isComposing) return;
|
|
3846
|
+
var t = e.target;
|
|
3847
|
+
if (t && t.nodeType === 1) {
|
|
3848
|
+
var tag = t.tagName;
|
|
3849
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
|
3850
|
+
if (t.closest && t.closest('[contenteditable]:not([contenteditable="false"])')) return;
|
|
3851
|
+
}
|
|
3852
|
+
postToParent({ type: PREFIX + 'step', dir: e.key === ']' ? 1 : -1 });
|
|
3853
|
+
}, true);
|
|
3854
|
+
|
|
3769
3855
|
// Author opt-in: a plain click on any element tagged [data-annotate] pops the
|
|
3770
3856
|
// toolbar — no pinpoint mode. Lets an HTML doc (e.g. a flow graph) wire its own
|
|
3771
3857
|
// nodes to Plannotator's toolbar. Bubble phase so the page's own click handlers
|
|
@@ -461,6 +461,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
461
461
|
// marker buttons keep their own clicks. BOTH surface kinds start ARMED —
|
|
462
462
|
// Esc (or the header pen) drops to Interact.
|
|
463
463
|
var annotateModeActive = true;
|
|
464
|
+
// A native host decides link navigations itself (HtmlViewer hostNavigates).
|
|
465
|
+
var hostNavigates = false;
|
|
464
466
|
function updatePinpointCursor() {
|
|
465
467
|
if (!document.body) return;
|
|
466
468
|
if (annotateModeActive && currentInputMethod === 'pinpoint') {
|
|
@@ -766,6 +768,21 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
766
768
|
flashMultiTarget(typeof e.data.key === 'string' ? e.data.key : '');
|
|
767
769
|
}
|
|
768
770
|
|
|
771
|
+
else if (type === PREFIX + 'step-pin') {
|
|
772
|
+
// A host's Parent or Child (a phone's comment sheet): move the pinned
|
|
773
|
+
// draft to the element around it, or back in. Re-pins through
|
|
774
|
+
// annotateElement, so the parent sees an ordinary pinpoint selection.
|
|
775
|
+
stepPin(e.data.direction === 'child' ? 'child' : 'parent');
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
else if (type === PREFIX + 'reveal-pin') {
|
|
779
|
+
// A host whose own panel covers the page's lower part (a phone's comment
|
|
780
|
+
// sheet) shrank the frame: bring the pinned draft back into view.
|
|
781
|
+
if (pendingPinEl && pendingPinEl.isConnected) {
|
|
782
|
+
try { pendingPinEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (ex) {}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
769
786
|
else if (type === PREFIX + 'scroll-to') {
|
|
770
787
|
// Selecting an annotation scrolls its first resolved target into view
|
|
771
788
|
// and flashes the overlay focus highlight over EVERY rect of EVERY
|
|
@@ -809,6 +826,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
809
826
|
if (vimEnabled) updateVimUi();
|
|
810
827
|
}
|
|
811
828
|
|
|
829
|
+
else if (type === PREFIX + 'set-host-navigates') {
|
|
830
|
+
hostNavigates = e.data.active === true;
|
|
831
|
+
}
|
|
832
|
+
|
|
812
833
|
else if (type === PREFIX + 'set-annotate-mode') {
|
|
813
834
|
var nextAnnotateActive = e.data.active === true;
|
|
814
835
|
if (nextAnnotateActive !== annotateModeActive) {
|
|
@@ -3760,6 +3781,41 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3760
3781
|
});
|
|
3761
3782
|
}
|
|
3762
3783
|
|
|
3784
|
+
// Parent and Child for a pinned draft. The trail holds the elements stepped
|
|
3785
|
+
// out of, so Child goes back the way Parent came; past it, Child takes the
|
|
3786
|
+
// first element child. A new pin by click starts a new trail.
|
|
3787
|
+
var pinStepTrail = [];
|
|
3788
|
+
var steppingPin = false;
|
|
3789
|
+
function stepPin(direction) {
|
|
3790
|
+
if (!pendingPinEl || !pendingPinEl.isConnected) return;
|
|
3791
|
+
var from = pendingPinEl;
|
|
3792
|
+
var next = null;
|
|
3793
|
+
if (direction === 'parent') {
|
|
3794
|
+
var up = from.parentElement;
|
|
3795
|
+
if (up && up !== document.body && up !== document.documentElement) next = up;
|
|
3796
|
+
} else {
|
|
3797
|
+
var back = pinStepTrail[pinStepTrail.length - 1];
|
|
3798
|
+
if (back && back.isConnected && from.contains(back)) next = back;
|
|
3799
|
+
else {
|
|
3800
|
+
for (var child = from.firstElementChild; child; child = child.nextElementSibling) {
|
|
3801
|
+
if (isViewerOverlayNode(child)) continue;
|
|
3802
|
+
if (/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(child.tagName)) continue;
|
|
3803
|
+
next = child;
|
|
3804
|
+
break;
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
if (!next) return;
|
|
3809
|
+
var trail = pinStepTrail.slice();
|
|
3810
|
+
if (direction === 'parent') trail.push(from);
|
|
3811
|
+
else if (trail[trail.length - 1] === next) trail.pop();
|
|
3812
|
+
else trail = [];
|
|
3813
|
+
steppingPin = true;
|
|
3814
|
+
try { annotateElement(next, undefined, pendingPinViaPinpoint, null); }
|
|
3815
|
+
finally { steppingPin = false; }
|
|
3816
|
+
pinStepTrail = trail;
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3763
3819
|
// Pin an element: select its text if possible (so a <mark> can wrap it), else
|
|
3764
3820
|
// post its text + box directly so the toolbar still anchors (e.g. an SVG node,
|
|
3765
3821
|
// whose <text> doesn't select like HTML text). Either way the element stays
|
|
@@ -3767,6 +3823,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3767
3823
|
// rides along so the annotation can restore to this exact element later.
|
|
3768
3824
|
function annotateElement(el, modeOverride, viaPinpoint, clickPoint) {
|
|
3769
3825
|
if (!el) return false;
|
|
3826
|
+
if (!steppingPin) pinStepTrail = [];
|
|
3770
3827
|
pinpointHover = null;
|
|
3771
3828
|
hidePinpointLabel();
|
|
3772
3829
|
clearMultiTargets(); // a new primary starts a fresh draft
|
|
@@ -3902,6 +3959,16 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3902
3959
|
return;
|
|
3903
3960
|
}
|
|
3904
3961
|
e.preventDefault();
|
|
3962
|
+
// A native host decides links itself: a person's tap opens the link as a
|
|
3963
|
+
// new window, which the host's web view hands to its own delegate (the
|
|
3964
|
+
// frame never navigates; the host page's CSP would refuse it anyway).
|
|
3965
|
+
if (hostNavigates) {
|
|
3966
|
+
if (e.isTrusted && !(annotateModeActive && currentInputMethod === 'pinpoint')) {
|
|
3967
|
+
var target = (e.target && e.target.closest) ? e.target.closest('a,area') : null;
|
|
3968
|
+
try { window.open(target && target.href ? target.href : raw, '_blank', 'noopener'); } catch (ex) {}
|
|
3969
|
+
}
|
|
3970
|
+
return;
|
|
3971
|
+
}
|
|
3905
3972
|
// Armed pinpoint: the click belongs to annotation, and the capture-phase
|
|
3906
3973
|
// pinpoint handler below is about to pin this element. Navigation is
|
|
3907
3974
|
// already suppressed above, which is all this surface owes the click.
|
|
@@ -4002,6 +4069,25 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
4002
4069
|
postToParent({ type: PREFIX + message });
|
|
4003
4070
|
}, true);
|
|
4004
4071
|
|
|
4072
|
+
// A plain ] or [ while armed (Annotate): the host may step to its next or
|
|
4073
|
+
// previous file (the Inbox steps through a thread's attachments). Never in
|
|
4074
|
+
// Interact (the page owns its keys), never while the page's focus is in a
|
|
4075
|
+
// text field, with a modifier, on auto-repeat or mid-composition. The page
|
|
4076
|
+
// still receives the key; a host without a step handler ignores the
|
|
4077
|
+
// message. Capture phase, like the chords above.
|
|
4078
|
+
document.addEventListener('keydown', function(e) {
|
|
4079
|
+
if (e.key !== ']' && e.key !== '[') return;
|
|
4080
|
+
if (!annotateModeActive || vimEnabled) return;
|
|
4081
|
+
if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || e.isComposing) return;
|
|
4082
|
+
var t = e.target;
|
|
4083
|
+
if (t && t.nodeType === 1) {
|
|
4084
|
+
var tag = t.tagName;
|
|
4085
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
|
4086
|
+
if (t.closest && t.closest('[contenteditable]:not([contenteditable="false"])')) return;
|
|
4087
|
+
}
|
|
4088
|
+
postToParent({ type: PREFIX + 'step', dir: e.key === ']' ? 1 : -1 });
|
|
4089
|
+
}, true);
|
|
4090
|
+
|
|
4005
4091
|
// Author opt-in: a plain click on any element tagged [data-annotate] pops the
|
|
4006
4092
|
// toolbar — no pinpoint mode. Lets an HTML doc (e.g. a flow graph) wire its own
|
|
4007
4093
|
// nodes to Plannotator's toolbar. Bubble phase so the page's own click handlers
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useRef, type RefObject } from "react";
|
|
2
|
-
import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type HtmlElementContext, type ImageAttachment } from "../../types";
|
|
1
|
+
import { useState, useEffect, useLayoutEffect, useCallback, useRef, type RefObject } from "react";
|
|
2
|
+
import { AnnotationType, type Annotation, type EditorMode, type HostDraft, type HtmlAnnotationTarget, type HtmlElementAnchor, type HtmlElementContext, type ImageAttachment } from "../../types";
|
|
3
3
|
import { THUMBS_UP_LABEL, type QuickLabel } from "../../utils/quickLabels";
|
|
4
4
|
import { getIdentity } from "../../utils/identity";
|
|
5
5
|
import type {
|
|
@@ -199,6 +199,10 @@ export interface UseHtmlAnnotationOptions {
|
|
|
199
199
|
/** scrollIntoView behavior for scroll-to (selecting an annotation).
|
|
200
200
|
* Absent: smooth, as before; pass 'auto' to honor reduced motion. */
|
|
201
201
|
scrollBehavior?: 'smooth' | 'auto';
|
|
202
|
+
/** The composer handed to the host (see `HostDraft` and HtmlViewer's
|
|
203
|
+
* `onHostDraft`): a selection or a pin reports a draft instead of opening
|
|
204
|
+
* the toolbar or the composer. Absent: unchanged. */
|
|
205
|
+
onHostDraft?: (draft: HostDraft | null) => void;
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
/** Clamp a host cap into the package's bound; anything unusable is the default. */
|
|
@@ -465,6 +469,7 @@ export function useHtmlAnnotation({
|
|
|
465
469
|
onUnanchoredChange,
|
|
466
470
|
maxAdditionalTargets,
|
|
467
471
|
scrollBehavior,
|
|
472
|
+
onHostDraft,
|
|
468
473
|
}: UseHtmlAnnotationOptions): Omit<
|
|
469
474
|
UseAnnotationHighlighterReturn,
|
|
470
475
|
"highlighterRef" | "highlightRange" | "highlightMathElement"
|
|
@@ -536,6 +541,10 @@ export function useHtmlAnnotation({
|
|
|
536
541
|
onPageChangeRef.current = onPageChange;
|
|
537
542
|
const onLinkClickRef = useRef(onLinkClick);
|
|
538
543
|
onLinkClickRef.current = onLinkClick;
|
|
544
|
+
const onHostDraftRef = useRef(onHostDraft);
|
|
545
|
+
onHostDraftRef.current = onHostDraft;
|
|
546
|
+
// The id of the draft the host holds, so a stale cancel cannot drop a newer one.
|
|
547
|
+
const hostDraftIdRef = useRef<string | null>(null);
|
|
539
548
|
// The effective cap and whether the host set one: only an explicit cap
|
|
540
549
|
// rides on arm-multi-select, so an unconfigured viewer posts today's message.
|
|
541
550
|
const maxTargetsRef = useRef(resolveMaxAdditionalTargets(maxAdditionalTargets));
|
|
@@ -628,7 +637,9 @@ export function useHtmlAnnotation({
|
|
|
628
637
|
[iframeRef, getOrCreateAnchor],
|
|
629
638
|
);
|
|
630
639
|
|
|
631
|
-
|
|
640
|
+
// A layout effect, like HtmlViewer's own listener: listening from the
|
|
641
|
+
// commit that inserts the iframe, before its document can post anything.
|
|
642
|
+
useLayoutEffect(() => {
|
|
632
643
|
function handler(e: MessageEvent<unknown>) {
|
|
633
644
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
634
645
|
// Live sessions verify origin + session token BEFORE parsing. The
|
|
@@ -659,6 +670,39 @@ export function useHtmlAnnotation({
|
|
|
659
670
|
pendingAnchorRef.current = message.anchor ?? null;
|
|
660
671
|
pendingContextRef.current = message.context ?? null;
|
|
661
672
|
setDraftTargets([]); // a new selection always starts a fresh draft
|
|
673
|
+
const reportDraft = onHostDraftRef.current;
|
|
674
|
+
if (reportDraft) {
|
|
675
|
+
// The host composes: HTML is comment-only, so every selection or
|
|
676
|
+
// pin is a comment draft, and nothing of ours opens.
|
|
677
|
+
const id = nextHtmlAnnId();
|
|
678
|
+
hostDraftIdRef.current = id;
|
|
679
|
+
reportDraft({
|
|
680
|
+
annotation: {
|
|
681
|
+
id,
|
|
682
|
+
blockId: "",
|
|
683
|
+
startOffset: 0,
|
|
684
|
+
endOffset: 0,
|
|
685
|
+
type: AnnotationType.COMMENT,
|
|
686
|
+
text: "",
|
|
687
|
+
originalText: message.text,
|
|
688
|
+
author: getIdentity(),
|
|
689
|
+
createdA: Date.now(),
|
|
690
|
+
htmlAnchor: message.anchor,
|
|
691
|
+
elementContext: message.context,
|
|
692
|
+
},
|
|
693
|
+
intent: message.pinpoint ? "compose" : "selection",
|
|
694
|
+
label: message.targetLabel,
|
|
695
|
+
cancel: () => {
|
|
696
|
+
if (hostDraftIdRef.current !== id) return;
|
|
697
|
+
hostDraftIdRef.current = null;
|
|
698
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
699
|
+
pendingTextRef.current = "";
|
|
700
|
+
pendingAnchorRef.current = null;
|
|
701
|
+
pendingContextRef.current = null;
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
662
706
|
const anchor = positionAnchor(message.rect);
|
|
663
707
|
if (!anchor) return;
|
|
664
708
|
|
|
@@ -754,6 +798,10 @@ export function useHtmlAnnotation({
|
|
|
754
798
|
}
|
|
755
799
|
|
|
756
800
|
if (type === `${PREFIX}selection-clear`) {
|
|
801
|
+
if (onHostDraftRef.current && hostDraftIdRef.current) {
|
|
802
|
+
hostDraftIdRef.current = null;
|
|
803
|
+
onHostDraftRef.current(null);
|
|
804
|
+
}
|
|
757
805
|
setToolbarState(null);
|
|
758
806
|
// Keep the captured text alive while a comment/quick-label is open: the user
|
|
759
807
|
// is composing, and the selection collapsing or scrolling out of view must
|
|
@@ -19,14 +19,16 @@ export function CodexIcon({ className }: { className?: string }) {
|
|
|
19
19
|
return <img src={CODEX_DATA_URI} className={className} alt="" aria-hidden="true" draggable={false} />;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
// Pi brand mark
|
|
23
|
-
// PiIcon
|
|
24
|
-
//
|
|
22
|
+
// Pi brand mark: the pi.dev logo (https://pi.dev/logo.svg), the same paths as
|
|
23
|
+
// ProviderIcons.tsx's PiIcon and apps/marketing/public/assets/icon-pi.svg. Its
|
|
24
|
+
// own three colours in every theme; the viewBox crops pi.dev's 800-unit canvas
|
|
25
|
+
// to the mark plus an even margin so it sits level with the other marks.
|
|
25
26
|
export function PiIcon({ className }: { className?: string }) {
|
|
26
27
|
return (
|
|
27
|
-
<svg viewBox="
|
|
28
|
-
<path fill="
|
|
29
|
-
<path fill="
|
|
28
|
+
<svg viewBox="130 130 540 540" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
|
29
|
+
<path fill="#F09082" d="M165.29 165.29H517.36V400H400V282.65H165.29Z" />
|
|
30
|
+
<path fill="#4D9ABF" d="M165.29 282.65H282.65V400H400V517.36H282.65V634.72H165.29Z" />
|
|
31
|
+
<path fill="#F1BE58" d="M517.36 400H634.72V634.72H517.36Z" />
|
|
30
32
|
</svg>
|
|
31
33
|
);
|
|
32
34
|
}
|
|
@@ -34,7 +36,7 @@ export function PiIcon({ className }: { className?: string }) {
|
|
|
34
36
|
// GitHub Copilot brand mark — path from the official agent icon set
|
|
35
37
|
// (welcome.developers.workers.dev/icons/agents/copilot); the dark/light
|
|
36
38
|
// variants there differ only by fill, so one currentColor component covers
|
|
37
|
-
// both themes, like
|
|
39
|
+
// both themes, like OpenCodeIcon.
|
|
38
40
|
export function CopilotIcon({ className }: { className?: string }) {
|
|
39
41
|
return (
|
|
40
42
|
<svg viewBox="0 0 256 208" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|