@plannotator/ui 0.53.0 → 0.53.1
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 +68 -17
- package/README.md +20 -5
- package/components/AnnotationPanel.tsx +37 -12
- package/components/AnnotationToolstrip.tsx +45 -40
- package/components/CodeFilePopout.tsx +1 -1
- package/components/CommentPopover.tsx +2 -0
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/MentionPicker.tsx +51 -0
- package/components/SkillReferenceMenu.tsx +39 -12
- package/components/Viewer.tsx +36 -6
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/html-viewer/HtmlViewer.tsx +22 -0
- package/components/html-viewer/bridge-script.asset.js +331 -11
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +372 -11
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +1 -1
- package/shortcuts/runtime.ts +10 -3
- package/utils/mentions.ts +34 -5
|
@@ -180,7 +180,7 @@ const CodeInlineAnnotation: React.FC<{
|
|
|
180
180
|
<span className="rounded bg-muted px-1.5 py-0.5 font-mono normal-case text-foreground">
|
|
181
181
|
{lineLabel(annotation.lineStart, annotation.lineEnd)}
|
|
182
182
|
</span>
|
|
183
|
-
{annotation.author && <span className="truncate normal-case">by {annotation.author}</span>}
|
|
183
|
+
{annotation.author && <span className="truncate normal-case">by <bdi>{annotation.author}</bdi></span>}
|
|
184
184
|
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
|
|
185
185
|
{onEdit && !isEditing && (
|
|
186
186
|
<button
|
|
@@ -726,6 +726,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
726
726
|
activeOptionId={activeComposerOptionId}
|
|
727
727
|
/>
|
|
728
728
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
729
|
+
{mentionSource?.renderNotice?.(mentionChipPeople)}
|
|
729
730
|
<MentionAutocompleteMenu
|
|
730
731
|
id={mentionListboxId}
|
|
731
732
|
menu={mentionAc.menu}
|
|
@@ -879,6 +880,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
879
880
|
activeOptionId={activeComposerOptionId}
|
|
880
881
|
/>
|
|
881
882
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
883
|
+
{mentionSource?.renderNotice?.(mentionChipPeople)}
|
|
882
884
|
<MentionAutocompleteMenu
|
|
883
885
|
id={mentionListboxId}
|
|
884
886
|
menu={mentionAc.menu}
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import React from 'react';
|
|
33
33
|
import { Tooltip } from './Tooltip';
|
|
34
|
+
import { modKey } from '../utils/platform';
|
|
34
35
|
import { formatShortcutBindingText, formatShortcutBindingTokens } from '../shortcuts/core';
|
|
35
36
|
import { htmlAnnotateShortcuts } from '../shortcuts/plan-review/htmlAnnotate.shortcuts';
|
|
36
37
|
|
|
@@ -135,7 +136,7 @@ export interface HtmlSurfaceControlLabels {
|
|
|
135
136
|
export const DEFAULT_HTML_SURFACE_CONTROL_LABELS: Required<
|
|
136
137
|
Omit<HtmlSurfaceControlLabels, 'annotateLabel' | 'interactLabel'>
|
|
137
138
|
> = {
|
|
138
|
-
annotateTitle:
|
|
139
|
+
annotateTitle: `Annotate mode: click an element or select text to comment; links open (${modKey}-click one to comment on it). Esc to interact`,
|
|
139
140
|
interactTitle: 'Interact mode: clicks reach the page (text selection still comments). Click to annotate',
|
|
140
141
|
hideTools: 'Hide tools',
|
|
141
142
|
showTools: 'Show tools',
|
|
@@ -34,6 +34,18 @@ export function mentionAnchorOf(element: HTMLElement | null): MentionAnchor | nu
|
|
|
34
34
|
return { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Consecutive runs of people with the same `group`, with each run's first index in the flat list. */
|
|
38
|
+
export function mentionGroups(people: readonly MentionPerson[]): { group: string; start: number; people: MentionPerson[] }[] {
|
|
39
|
+
const runs: { group: string; start: number; people: MentionPerson[] }[] = [];
|
|
40
|
+
people.forEach((person, index) => {
|
|
41
|
+
const group = person.group ?? '';
|
|
42
|
+
const last = runs[runs.length - 1];
|
|
43
|
+
if (last && last.group === group) last.people.push(person);
|
|
44
|
+
else runs.push({ group, start: index, people: [person] });
|
|
45
|
+
});
|
|
46
|
+
return runs;
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
/** The optional avatar column: an image, else initials on a tinted disc. */
|
|
38
50
|
function MentionAvatar({
|
|
39
51
|
avatar,
|
|
@@ -116,6 +128,45 @@ export function MentionPicker({
|
|
|
116
128
|
<p data-mention-empty className="px-2 py-1 text-[11px] text-muted-foreground">
|
|
117
129
|
{emptyNotice}
|
|
118
130
|
</p>
|
|
131
|
+
) : people.some((person) => person.group) ? (
|
|
132
|
+
// Grouped (a host set `group`): each run of one group is a `role="group"` labelled by its heading.
|
|
133
|
+
mentionGroups(people).map((run) => {
|
|
134
|
+
const headingId = id ? `${id}-group-${run.start}` : undefined;
|
|
135
|
+
return (
|
|
136
|
+
<div key={`${run.group}-${run.start}`} role="group" aria-labelledby={headingId} data-mention-group={run.group}>
|
|
137
|
+
<p id={headingId} data-mention-group-heading className="px-2 pb-1 pt-1.5 text-[10px] font-medium text-muted-foreground">
|
|
138
|
+
{run.group}
|
|
139
|
+
</p>
|
|
140
|
+
{run.people.map((person, offset) => {
|
|
141
|
+
const index = run.start + offset;
|
|
142
|
+
return (
|
|
143
|
+
<button
|
|
144
|
+
key={person.id}
|
|
145
|
+
type="button"
|
|
146
|
+
role="option"
|
|
147
|
+
id={id ? `${id}-option-${index}` : undefined}
|
|
148
|
+
aria-selected={active === index}
|
|
149
|
+
data-mention-option={person.id}
|
|
150
|
+
onMouseDown={(event) => {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
onPick(person);
|
|
153
|
+
}}
|
|
154
|
+
onMouseEnter={() => onHover(index)}
|
|
155
|
+
className={`flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-left text-xs transition-colors ${
|
|
156
|
+
active === index ? 'bg-muted text-foreground' : 'text-foreground/85 hover:bg-muted/60'
|
|
157
|
+
}`}
|
|
158
|
+
>
|
|
159
|
+
{person.avatar && <MentionAvatar avatar={person.avatar} label={person.label} />}
|
|
160
|
+
<span className="min-w-0 flex-1 truncate">{person.label}</span>
|
|
161
|
+
{person.detail && (
|
|
162
|
+
<span className="min-w-0 max-w-[44%] shrink-0 truncate text-[10px] text-muted-foreground">{person.detail}</span>
|
|
163
|
+
)}
|
|
164
|
+
</button>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
})
|
|
119
170
|
) : (
|
|
120
171
|
people.map((person, index) => (
|
|
121
172
|
<button
|
|
@@ -15,6 +15,16 @@ interface SkillReferenceMenuProps {
|
|
|
15
15
|
/** Explicitly activated row, or null — the menu opens with nothing active. */
|
|
16
16
|
activeIndex: number | null;
|
|
17
17
|
onSelect: (index: number) => void;
|
|
18
|
+
/**
|
|
19
|
+
* The box the menu must stay inside, in viewport coordinates, read at each
|
|
20
|
+
* measure. Default: the viewport. A host whose composer sits in a dialog
|
|
21
|
+
* with a footer passes the dialog's area above the footer.
|
|
22
|
+
*/
|
|
23
|
+
boundary?: () => { top: number; bottom: number } | null;
|
|
24
|
+
/** Which side of the anchor the menu tries first. Default: above. */
|
|
25
|
+
prefer?: 'above' | 'below';
|
|
26
|
+
/** Upper bound on the list's height, in px. Default: 256. */
|
|
27
|
+
maxListHeight?: number;
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
/** Vertical gap between the composer wrapper and the menu (mb-1.5 / mt-1.5 = 6px). */
|
|
@@ -40,24 +50,29 @@ interface MenuPlacement {
|
|
|
40
50
|
* When neither side fits the whole list, the side with more room wins and the
|
|
41
51
|
* list is clamped to it, so the menu never extends past a viewport edge.
|
|
42
52
|
*/
|
|
43
|
-
function computeMenuPlacement(
|
|
53
|
+
export function computeMenuPlacement(
|
|
44
54
|
anchorRect: Pick<DOMRect, 'top' | 'bottom'>,
|
|
45
55
|
naturalListHeight: number,
|
|
46
56
|
chromeHeight: number,
|
|
47
57
|
viewportHeight: number,
|
|
58
|
+
options: { boundaryTop?: number; prefer?: 'above' | 'below'; maxListHeight?: number } = {},
|
|
48
59
|
): MenuPlacement {
|
|
49
|
-
const
|
|
60
|
+
const boundaryTop = options.boundaryTop ?? 0;
|
|
61
|
+
const cap = options.maxListHeight ?? MAX_LIST_HEIGHT;
|
|
62
|
+
const spaceAbove = anchorRect.top - boundaryTop - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight;
|
|
50
63
|
const spaceBelow = viewportHeight - anchorRect.bottom - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight;
|
|
51
|
-
const needed = Math.min(naturalListHeight,
|
|
64
|
+
const needed = Math.min(naturalListHeight, cap);
|
|
65
|
+
const [first, second] = options.prefer === 'below' ? (['below', 'above'] as const) : (['above', 'below'] as const);
|
|
66
|
+
const space = (side: MenuPlacement['direction']) => (side === 'above' ? spaceAbove : spaceBelow);
|
|
52
67
|
const direction: MenuPlacement['direction'] =
|
|
53
|
-
needed <=
|
|
54
|
-
: needed <=
|
|
55
|
-
:
|
|
56
|
-
:
|
|
57
|
-
const available = direction
|
|
68
|
+
needed <= space(first) ? first
|
|
69
|
+
: needed <= space(second) ? second
|
|
70
|
+
: space(first) >= space(second) ? first
|
|
71
|
+
: second;
|
|
72
|
+
const available = space(direction);
|
|
58
73
|
return {
|
|
59
74
|
direction,
|
|
60
|
-
maxListHeight: Math.max(0, Math.min(
|
|
75
|
+
maxListHeight: Math.max(0, Math.min(cap, Math.round(available))),
|
|
61
76
|
};
|
|
62
77
|
}
|
|
63
78
|
|
|
@@ -85,13 +100,19 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
|
|
|
85
100
|
items,
|
|
86
101
|
activeIndex,
|
|
87
102
|
onSelect,
|
|
103
|
+
boundary,
|
|
104
|
+
prefer,
|
|
105
|
+
maxListHeight,
|
|
88
106
|
}) => {
|
|
89
107
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
90
108
|
const listRef = useRef<HTMLDivElement>(null);
|
|
91
109
|
const [placement, setPlacement] = useState<MenuPlacement>({
|
|
92
|
-
direction: 'above',
|
|
93
|
-
maxListHeight: MAX_LIST_HEIGHT,
|
|
110
|
+
direction: prefer ?? 'above',
|
|
111
|
+
maxListHeight: maxListHeight ?? MAX_LIST_HEIGHT,
|
|
94
112
|
});
|
|
113
|
+
// The optional seams, read through a ref so `measure` stays stable for its listeners.
|
|
114
|
+
const optionsRef = useRef({ boundary, prefer, maxListHeight });
|
|
115
|
+
optionsRef.current = { boundary, prefer, maxListHeight };
|
|
95
116
|
|
|
96
117
|
const measure = useCallback(() => {
|
|
97
118
|
const menuEl = menuRef.current;
|
|
@@ -106,7 +127,13 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
|
|
|
106
127
|
// jitter loop this component once shipped.
|
|
107
128
|
const chrome = Math.max(0, menuEl.offsetHeight - listEl.offsetHeight);
|
|
108
129
|
// scrollHeight reports full content height even while clamped.
|
|
109
|
-
const
|
|
130
|
+
const { boundary: readBoundary, prefer: side, maxListHeight: cap } = optionsRef.current;
|
|
131
|
+
const box = readBoundary?.() ?? null;
|
|
132
|
+
const next = computeMenuPlacement(rect, listEl.scrollHeight, chrome, box ? box.bottom : window.innerHeight, {
|
|
133
|
+
boundaryTop: box ? box.top : 0,
|
|
134
|
+
prefer: side,
|
|
135
|
+
maxListHeight: cap,
|
|
136
|
+
});
|
|
110
137
|
setPlacement((prev) =>
|
|
111
138
|
prev.direction === next.direction && prev.maxListHeight === next.maxListHeight
|
|
112
139
|
? prev
|
package/components/Viewer.tsx
CHANGED
|
@@ -326,6 +326,18 @@ export interface ViewerProps {
|
|
|
326
326
|
* blocks and no comments on its text. Absent → unchanged.
|
|
327
327
|
*/
|
|
328
328
|
answerOnly?: boolean;
|
|
329
|
+
/**
|
|
330
|
+
* Opt-in host capability, with `answerOnly`: the reader may comment on the
|
|
331
|
+
* text. A text selection opens the selection toolbar, comment-only
|
|
332
|
+
* (Comment and the one-click 👍; no Delete, no label picker), and its
|
|
333
|
+
* comment composer; `annotations` are drawn as highlights. Question cards
|
|
334
|
+
* are left out of selection (their text is not commented on), and nothing
|
|
335
|
+
* else of authoring comes back (no pinpoint, code-block toolbar, Global
|
|
336
|
+
* comment or Copy, no Vim). For a host that shows a message whose words
|
|
337
|
+
* can be commented on (the Plannotator Inbox's agent messages). Ignored
|
|
338
|
+
* without `answerOnly` and under `readOnly`. Absent → unchanged.
|
|
339
|
+
*/
|
|
340
|
+
textComments?: boolean;
|
|
329
341
|
/** Opt-in Vim-style keyboard selection. Default false for compatibility. */
|
|
330
342
|
vimModeEnabled?: boolean;
|
|
331
343
|
/** Replace the compact Vim badge with the live video-style key HUD. */
|
|
@@ -731,6 +743,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
731
743
|
allowImages = true,
|
|
732
744
|
readOnly = false,
|
|
733
745
|
answerOnly = false,
|
|
746
|
+
textComments = false,
|
|
734
747
|
vimModeEnabled = false,
|
|
735
748
|
vimHudEnabled = false,
|
|
736
749
|
vimHudKeyPanelEnabled = true,
|
|
@@ -740,6 +753,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
740
753
|
// Annotation authoring is off when read-only, and when the host only wants
|
|
741
754
|
// answers (`answerOnly`); question cards and checkboxes follow `readOnly`.
|
|
742
755
|
const authoringOff = readOnly || answerOnly;
|
|
756
|
+
// `textComments`: an answer-only document whose words still take comments
|
|
757
|
+
// (the selection toolbar, comment-only, and its composer); everything else
|
|
758
|
+
// of authoring stays off. Without it this is exactly `!authoringOff`.
|
|
759
|
+
const textCommentsOnly = answerOnly && textComments && !readOnly;
|
|
760
|
+
const selectionCommentsOn = !authoringOff || textCommentsOnly;
|
|
743
761
|
const hostComposes = onHostDraft !== undefined && !authoringOff;
|
|
744
762
|
const viewerAnnotationHeader = authoringOff ? undefined : annotationHeader;
|
|
745
763
|
const hasViewerAnnotationHeader = viewerAnnotationHeader !== undefined;
|
|
@@ -883,7 +901,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
883
901
|
onSelectAnnotation,
|
|
884
902
|
selectedAnnotationId,
|
|
885
903
|
mode,
|
|
886
|
-
enabled:
|
|
904
|
+
enabled: selectionCommentsOn,
|
|
887
905
|
// Markdown documents drift between the moment a draft/share is written and
|
|
888
906
|
// the moment it is restored (a plan revision, a re-rendered block, a
|
|
889
907
|
// renderer change that adds or drops elements — #1509 hoisted an alert's
|
|
@@ -1697,12 +1715,12 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1697
1715
|
: undefined;
|
|
1698
1716
|
if (hostCard !== undefined) {
|
|
1699
1717
|
return (
|
|
1700
|
-
<div key={group.block.id} data-block-id={group.block.id} data-block-type="directive">
|
|
1718
|
+
<div key={group.block.id} data-block-id={group.block.id} data-block-type="directive" className={textCommentsOnly ? 'annotation-exclude' : undefined}>
|
|
1701
1719
|
{hostCard}
|
|
1702
1720
|
</div>
|
|
1703
1721
|
);
|
|
1704
1722
|
}
|
|
1705
|
-
|
|
1723
|
+
const rendered = (
|
|
1706
1724
|
<BlockRenderer
|
|
1707
1725
|
imageBaseDir={imageBaseDir}
|
|
1708
1726
|
onImageClick={(src, alt) => setLightbox({ src, alt })}
|
|
@@ -1732,16 +1750,28 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1732
1750
|
renderQuestionBelowPrompt={renderQuestionBelowPrompt}
|
|
1733
1751
|
/>
|
|
1734
1752
|
);
|
|
1753
|
+
// `textComments`: a question card's words are not commented on
|
|
1754
|
+
// (its own controls already are excluded); a box-less wrapper
|
|
1755
|
+
// keeps its layout.
|
|
1756
|
+
if (question && textCommentsOnly) {
|
|
1757
|
+
return (
|
|
1758
|
+
<div key={group.block.id} className="annotation-exclude" style={{ display: 'contents' }}>
|
|
1759
|
+
{rendered}
|
|
1760
|
+
</div>
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
return rendered;
|
|
1735
1764
|
})()
|
|
1736
1765
|
)
|
|
1737
1766
|
)}
|
|
1738
1767
|
|
|
1739
1768
|
{/* Text selection toolbar */}
|
|
1740
|
-
{
|
|
1769
|
+
{selectionCommentsOn && !hostComposes && toolbarState && (
|
|
1741
1770
|
<ToolbarErrorBoundary>
|
|
1742
1771
|
<AnnotationToolbar
|
|
1743
1772
|
element={toolbarState.element}
|
|
1744
1773
|
positionMode="center-above"
|
|
1774
|
+
commentOnly={textCommentsOnly || undefined}
|
|
1745
1775
|
onAnnotate={handleAnnotate}
|
|
1746
1776
|
onClose={handleToolbarClose}
|
|
1747
1777
|
onRequestComment={handleRequestComment}
|
|
@@ -1876,7 +1906,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1876
1906
|
)}
|
|
1877
1907
|
|
|
1878
1908
|
{/* Comment popover — hook handles text selection, Viewer handles global + code block */}
|
|
1879
|
-
{
|
|
1909
|
+
{selectionCommentsOn && !hostComposes && hookCommentPopover && (
|
|
1880
1910
|
<CommentPopover
|
|
1881
1911
|
anchorEl={hookCommentPopover.anchorEl}
|
|
1882
1912
|
contextText={hookCommentPopover.contextText}
|
|
@@ -1928,7 +1958,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1928
1958
|
)}
|
|
1929
1959
|
|
|
1930
1960
|
{/* Quick Label floating picker — hook handles text selection, Viewer handles code blocks */}
|
|
1931
|
-
{
|
|
1961
|
+
{selectionCommentsOn && !hostComposes && hookQuickLabelPicker && (
|
|
1932
1962
|
<FloatingQuickLabelPicker
|
|
1933
1963
|
anchorEl={hookQuickLabelPicker.anchorEl}
|
|
1934
1964
|
cursorHint={hookQuickLabelPicker.cursorHint}
|
|
@@ -18,6 +18,9 @@ const QUESTION_CONTEXT_CSS = `
|
|
|
18
18
|
.question-context > :first-child { margin-top: 0; }
|
|
19
19
|
.question-context > :last-child { margin-bottom: 0; }
|
|
20
20
|
.question-context p, .question-context .text-sm { font-size: 13px; line-height: 1.5; }
|
|
21
|
+
/* Context lines stay lines ("Stopped: …" then "Holds up: …"), as the
|
|
22
|
+
authoring guide writes them; the parser keeps them as soft breaks. */
|
|
23
|
+
.question-context p { white-space: pre-line; }
|
|
21
24
|
.question-context p, .question-context [data-question-context-list] span { color: inherit; }
|
|
22
25
|
.question-context h1, .question-context h2, .question-context h3 { margin: 0.75rem 0 0.25rem; font-size: 14px; }
|
|
23
26
|
.question-context h3 { font-size: 13.5px; }
|
|
@@ -239,6 +239,13 @@ export interface HtmlViewerProps {
|
|
|
239
239
|
* key. Honored only while the frame holds focus, and at most once per
|
|
240
240
|
* STEP_MESSAGE_MIN_INTERVAL_MS. Absent: the message is ignored, as before. */
|
|
241
241
|
onStep?: (dir: 1 | -1) => void;
|
|
242
|
+
/** Escape pressed inside the page in Interact that the page did not use
|
|
243
|
+
* itself (no handler of the page cancelled it or stopped it, and focus was
|
|
244
|
+
* not in one of its text fields): the host may close something of its own
|
|
245
|
+
* (the Inbox leaves full screen). Never sent in Annotate, whose Escape is
|
|
246
|
+
* `onAnnotateModeExit`. Honored only while the frame holds focus, and at
|
|
247
|
+
* most once per STEP_MESSAGE_MIN_INTERVAL_MS. Absent: ignored, as before. */
|
|
248
|
+
onPageEscape?: () => void;
|
|
242
249
|
/** Opt-in Vim-style keyboard selection. Default false for compatibility. */
|
|
243
250
|
vimModeEnabled?: boolean;
|
|
244
251
|
/** Replace the iframe-local compact badge with the shared live key HUD. */
|
|
@@ -384,6 +391,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
384
391
|
onAnnotateModeToggle,
|
|
385
392
|
onToolsToggle,
|
|
386
393
|
onStep,
|
|
394
|
+
onPageEscape,
|
|
387
395
|
vimModeEnabled = false,
|
|
388
396
|
vimHudEnabled = false,
|
|
389
397
|
vimHudKeyPanelEnabled = true,
|
|
@@ -452,6 +460,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
452
460
|
const annotateModeActiveRef = useRef(annotateModeActive);
|
|
453
461
|
annotateModeActiveRef.current = annotateModeActive;
|
|
454
462
|
const lastStepAtRef = useRef(0);
|
|
463
|
+
const onPageEscapeRef = useRef(onPageEscape);
|
|
464
|
+
onPageEscapeRef.current = onPageEscape;
|
|
465
|
+
const lastPageEscapeAtRef = useRef(0);
|
|
455
466
|
|
|
456
467
|
/** Single choke point for direct-to-bridge posts: live sessions get the
|
|
457
468
|
* token + concrete targetOrigin, srcdoc keeps "*" and no token. */
|
|
@@ -809,6 +820,17 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
809
820
|
onStepRef.current?.(dir);
|
|
810
821
|
return;
|
|
811
822
|
}
|
|
823
|
+
if (isRecord(e.data) && e.data.type === `${PREFIX}page-escape`) {
|
|
824
|
+
// Like a step, the page could post this itself: honored only for a
|
|
825
|
+
// key pressed in the frame, in Interact, at most once per interval.
|
|
826
|
+
if (annotateModeActiveRef.current) return;
|
|
827
|
+
if (document.activeElement !== iframeRef.current) return;
|
|
828
|
+
const now = Date.now();
|
|
829
|
+
if (now - lastPageEscapeAtRef.current < STEP_MESSAGE_MIN_INTERVAL_MS) return;
|
|
830
|
+
lastPageEscapeAtRef.current = now;
|
|
831
|
+
onPageEscapeRef.current?.();
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
812
834
|
const vimCopy = parseVimBridgeCopy(e.data);
|
|
813
835
|
if (vimCopy !== null) {
|
|
814
836
|
const iframe = iframeRef.current;
|