@plannotator/ui 0.27.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 +22 -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/MarkdownDiff.tsx +60 -0
- 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 +7 -6
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef, type RefObject } from "react";
|
|
2
|
-
import { AnnotationType, type Annotation, type EditorMode, type ImageAttachment } from "../../types";
|
|
2
|
+
import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type ImageAttachment } from "../../types";
|
|
3
3
|
import type { QuickLabel } from "../../utils/quickLabels";
|
|
4
4
|
import { getIdentity } from "../../utils/identity";
|
|
5
5
|
import type {
|
|
@@ -21,48 +21,296 @@ function nextHtmlAnnId(): string {
|
|
|
21
21
|
interface BridgeSelectionMessage {
|
|
22
22
|
type: `${typeof PREFIX}selection`;
|
|
23
23
|
text: string;
|
|
24
|
-
rect:
|
|
24
|
+
rect: BridgeRect;
|
|
25
|
+
modeOverride?: EditorMode;
|
|
26
|
+
/** Serialized element anchor (pinpoint clicks) — validated, size-capped. */
|
|
27
|
+
anchor?: HtmlElementAnchor;
|
|
28
|
+
/** True when the selection came from a pinpoint click on an element. */
|
|
29
|
+
pinpoint?: boolean;
|
|
30
|
+
/** Bridge-assigned key for the primary target (multi-select bookkeeping). */
|
|
31
|
+
targetKey?: string;
|
|
32
|
+
/** Semantic label from the pinpoint hover cascade (chips + export). */
|
|
33
|
+
targetLabel?: string;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
/** One draft target in an in-flight multi-select comment. Index 0 is primary. */
|
|
37
|
+
export interface HtmlDraftTarget {
|
|
38
|
+
key: string;
|
|
39
|
+
label?: string;
|
|
40
|
+
text: string;
|
|
41
|
+
anchor: HtmlElementAnchor | null;
|
|
30
42
|
}
|
|
31
43
|
|
|
32
|
-
interface
|
|
33
|
-
type: `${typeof PREFIX}
|
|
34
|
-
|
|
44
|
+
interface BridgeMultiTargetAddedMessage {
|
|
45
|
+
type: `${typeof PREFIX}multi-target-added`;
|
|
46
|
+
key: string;
|
|
47
|
+
label?: string;
|
|
48
|
+
text: string;
|
|
49
|
+
anchor?: HtmlElementAnchor;
|
|
35
50
|
}
|
|
36
51
|
|
|
37
|
-
|
|
52
|
+
interface BridgeRect {
|
|
53
|
+
top: number;
|
|
54
|
+
left: number;
|
|
55
|
+
width: number;
|
|
56
|
+
height: number;
|
|
57
|
+
}
|
|
38
58
|
|
|
59
|
+
type BridgeMessage =
|
|
60
|
+
| BridgeSelectionMessage
|
|
61
|
+
| BridgeMultiTargetAddedMessage
|
|
62
|
+
| { type: `${typeof PREFIX}multi-target-removed`; key: string }
|
|
63
|
+
| { type: `${typeof PREFIX}pointer`; x: number; y: number; shift: boolean }
|
|
64
|
+
| { type: `${typeof PREFIX}selection-clear` }
|
|
65
|
+
| { type: `${typeof PREFIX}selection-rect`; rect: BridgeRect }
|
|
66
|
+
| { type: `${typeof PREFIX}keytype`; key: string }
|
|
67
|
+
| { type: `${typeof PREFIX}mark-click`; id: string }
|
|
68
|
+
| { type: `${typeof PREFIX}resize`; height: number };
|
|
69
|
+
|
|
70
|
+
/** Dependencies and callbacks for the sandboxed HTML annotation bridge. */
|
|
39
71
|
export interface UseHtmlAnnotationOptions {
|
|
40
72
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
73
|
+
/** Whether selection bridge messages may open composers or create annotations. */
|
|
74
|
+
enabled?: boolean;
|
|
41
75
|
annotations: Annotation[];
|
|
42
76
|
onAddAnnotation?: (ann: Annotation) => void;
|
|
43
77
|
onSelectAnnotation?: (id: string | null) => void;
|
|
44
78
|
selectedAnnotationId: string | null;
|
|
45
79
|
mode: EditorMode;
|
|
46
80
|
onResize?: (height: number) => void;
|
|
81
|
+
/** Validated pointer positions relayed from inside the iframe while a
|
|
82
|
+
* pinpoint draft is open (iframe-local viewport coordinates), with the
|
|
83
|
+
* Shift state observed by the iframe (the parent cannot see modifiers
|
|
84
|
+
* held while the pointer lives in the sandbox). Drives the composer-yield
|
|
85
|
+
* fade in the host component. */
|
|
86
|
+
onBridgePointer?: (x: number, y: number, shift: boolean) => void;
|
|
47
87
|
}
|
|
48
88
|
|
|
49
89
|
function postToIframe(iframe: HTMLIFrameElement | null, msg: Record<string, unknown>) {
|
|
50
90
|
iframe?.contentWindow?.postMessage(msg, "*");
|
|
51
91
|
}
|
|
52
92
|
|
|
93
|
+
function parseEditorMode(value: unknown): EditorMode | undefined {
|
|
94
|
+
return value === "selection"
|
|
95
|
+
|| value === "comment"
|
|
96
|
+
|| value === "redline"
|
|
97
|
+
|| value === "quickLabel"
|
|
98
|
+
? value
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
103
|
+
return typeof value === "object" && value !== null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Size caps for the anchor DTO — the bridge script runs inside a sandboxed
|
|
107
|
+
// iframe rendering arbitrary HTML, so everything it posts is validated and
|
|
108
|
+
// bounded before it can reach React state or the annotation model.
|
|
109
|
+
const MAX_ANCHOR_SELECTOR_LENGTH = 1024;
|
|
110
|
+
const MAX_ANCHOR_TAG_LENGTH = 64;
|
|
111
|
+
const MAX_ANCHOR_TEXT_LENGTH = 400;
|
|
112
|
+
// Multi-select caps: the additional-target array is bounded at the trust
|
|
113
|
+
// boundary (a hostile page cannot grow a draft past this), and the bridge's
|
|
114
|
+
// short target keys / 40-char hover labels get generous-but-hard ceilings.
|
|
115
|
+
export const MAX_ADDITIONAL_TARGETS = 16;
|
|
116
|
+
const MAX_TARGET_KEY_LENGTH = 64;
|
|
117
|
+
const MAX_TARGET_LABEL_LENGTH = 64;
|
|
118
|
+
// Selection text is page-controlled too (a pinpoint click posts the element's
|
|
119
|
+
// entire textContent), so it gets the same treatment: truncated here — not
|
|
120
|
+
// rejected, a legitimate huge selection still annotates — before it can reach
|
|
121
|
+
// React state, drafts, exported feedback, or a share URL. Mirrors
|
|
122
|
+
// MAX_SELECTION_TEXT in bridge-script.ts; this side is the authoritative one.
|
|
123
|
+
export const MAX_SELECTION_TEXT_LENGTH = 10000;
|
|
124
|
+
|
|
125
|
+
/** Truncate to the cap without ever splitting a UTF-16 surrogate pair (a
|
|
126
|
+
* lone high surrogate becomes U+FFFD once UTF-8-encoded downstream). */
|
|
127
|
+
export function capSelectionText(text: string): string {
|
|
128
|
+
if (text.length <= MAX_SELECTION_TEXT_LENGTH) return text;
|
|
129
|
+
let cut = MAX_SELECTION_TEXT_LENGTH;
|
|
130
|
+
const last = text.charCodeAt(cut - 1);
|
|
131
|
+
if (last >= 0xd800 && last <= 0xdbff) cut -= 1;
|
|
132
|
+
return text.slice(0, cut);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validate a bridge-posted normalized marker point. Fail-closed but additive:
|
|
137
|
+
* a malformed point is DROPPED (the marker falls back to the target-rect
|
|
138
|
+
* default) without rejecting the anchor it rides on; finite values are
|
|
139
|
+
* clamped into the normalized 0..1 range.
|
|
140
|
+
*/
|
|
141
|
+
function parseAnchorPoint(value: unknown): { x: number; y: number } | undefined {
|
|
142
|
+
if (!isRecord(value)) return undefined;
|
|
143
|
+
const { x, y } = value;
|
|
144
|
+
if (
|
|
145
|
+
typeof x !== "number" || !Number.isFinite(x)
|
|
146
|
+
|| typeof y !== "number" || !Number.isFinite(y)
|
|
147
|
+
) {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
x: Math.min(1, Math.max(0, x)),
|
|
152
|
+
y: Math.min(1, Math.max(0, y)),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Validate a bridge-posted element anchor. Exported for protocol tests. */
|
|
157
|
+
export function parseHtmlElementAnchor(value: unknown): HtmlElementAnchor | null {
|
|
158
|
+
if (!isRecord(value)) return null;
|
|
159
|
+
const { selector, tagName, text } = value;
|
|
160
|
+
if (
|
|
161
|
+
typeof selector !== "string"
|
|
162
|
+
|| selector.length === 0
|
|
163
|
+
|| selector.length > MAX_ANCHOR_SELECTOR_LENGTH
|
|
164
|
+
|| typeof tagName !== "string"
|
|
165
|
+
|| tagName.length === 0
|
|
166
|
+
|| tagName.length > MAX_ANCHOR_TAG_LENGTH
|
|
167
|
+
) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const point = parseAnchorPoint(value.point);
|
|
171
|
+
if (text === undefined) return { selector, tagName, ...(point ? { point } : {}) };
|
|
172
|
+
if (typeof text !== "string" || text.length > MAX_ANCHOR_TEXT_LENGTH) return null;
|
|
173
|
+
return { selector, tagName, text, ...(point ? { point } : {}) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseTargetKey(value: unknown): string | null {
|
|
177
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_TARGET_KEY_LENGTH
|
|
178
|
+
? value
|
|
179
|
+
: null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseTargetLabel(value: unknown): string | undefined {
|
|
183
|
+
if (typeof value !== "string") return undefined;
|
|
184
|
+
// Labels derive from page-controlled attributes (aria-label etc.), so a
|
|
185
|
+
// hostile page can embed newlines that would become real markdown structure
|
|
186
|
+
// in the exported feedback — collapse ALL whitespace at the trust boundary.
|
|
187
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
188
|
+
if (!collapsed) return undefined;
|
|
189
|
+
return collapsed.length > MAX_TARGET_LABEL_LENGTH
|
|
190
|
+
? collapsed.slice(0, MAX_TARGET_LABEL_LENGTH)
|
|
191
|
+
: collapsed;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseBridgeRect(value: unknown): BridgeRect | null {
|
|
195
|
+
if (!isRecord(value)) return null;
|
|
196
|
+
const { top, left, width, height } = value;
|
|
197
|
+
return typeof top === "number" && Number.isFinite(top)
|
|
198
|
+
&& typeof left === "number" && Number.isFinite(left)
|
|
199
|
+
&& typeof width === "number" && Number.isFinite(width)
|
|
200
|
+
&& typeof height === "number" && Number.isFinite(height)
|
|
201
|
+
? { top, left, width, height }
|
|
202
|
+
: null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Validate any bridge message. Exported for protocol tests. */
|
|
206
|
+
export function parseBridgeMessage(value: unknown): BridgeMessage | null {
|
|
207
|
+
if (!isRecord(value) || typeof value.type !== "string") return null;
|
|
208
|
+
|
|
209
|
+
switch (value.type) {
|
|
210
|
+
case `${PREFIX}selection`: {
|
|
211
|
+
const rect = parseBridgeRect(value.rect);
|
|
212
|
+
if (typeof value.text !== "string" || !rect) return null;
|
|
213
|
+
return {
|
|
214
|
+
type: value.type,
|
|
215
|
+
text: capSelectionText(value.text),
|
|
216
|
+
rect,
|
|
217
|
+
modeOverride: parseEditorMode(value.modeOverride),
|
|
218
|
+
anchor: parseHtmlElementAnchor(value.anchor) ?? undefined,
|
|
219
|
+
pinpoint: value.pinpoint === true,
|
|
220
|
+
targetKey: parseTargetKey(value.targetKey) ?? undefined,
|
|
221
|
+
targetLabel: parseTargetLabel(value.targetLabel),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
case `${PREFIX}multi-target-added`: {
|
|
225
|
+
const key = parseTargetKey(value.key);
|
|
226
|
+
if (!key || typeof value.text !== "string") return null;
|
|
227
|
+
return {
|
|
228
|
+
type: value.type,
|
|
229
|
+
key,
|
|
230
|
+
label: parseTargetLabel(value.label),
|
|
231
|
+
text: capSelectionText(value.text),
|
|
232
|
+
anchor: parseHtmlElementAnchor(value.anchor) ?? undefined,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
case `${PREFIX}multi-target-removed`: {
|
|
236
|
+
const key = parseTargetKey(value.key);
|
|
237
|
+
return key ? { type: value.type, key } : null;
|
|
238
|
+
}
|
|
239
|
+
case `${PREFIX}pointer`:
|
|
240
|
+
return typeof value.x === "number" && Number.isFinite(value.x)
|
|
241
|
+
&& typeof value.y === "number" && Number.isFinite(value.y)
|
|
242
|
+
? { type: value.type, x: value.x, y: value.y, shift: value.shift === true }
|
|
243
|
+
: null;
|
|
244
|
+
case `${PREFIX}selection-clear`:
|
|
245
|
+
return { type: value.type };
|
|
246
|
+
case `${PREFIX}selection-rect`: {
|
|
247
|
+
const rect = parseBridgeRect(value.rect);
|
|
248
|
+
return rect ? { type: value.type, rect } : null;
|
|
249
|
+
}
|
|
250
|
+
case `${PREFIX}keytype`:
|
|
251
|
+
return typeof value.key === "string"
|
|
252
|
+
? { type: value.type, key: value.key }
|
|
253
|
+
: null;
|
|
254
|
+
case `${PREFIX}mark-click`:
|
|
255
|
+
// The id is page-controlled like every other bridge string: cap it like
|
|
256
|
+
// the bridge's own sync validation does (256) so a hostile page cannot
|
|
257
|
+
// ship an unbounded string into parent state via a forged mark-click.
|
|
258
|
+
return typeof value.id === "string" && value.id.length <= 256
|
|
259
|
+
? { type: value.type, id: value.id }
|
|
260
|
+
: null;
|
|
261
|
+
case `${PREFIX}resize`:
|
|
262
|
+
return typeof value.height === "number" && Number.isFinite(value.height)
|
|
263
|
+
? { type: value.type, height: value.height }
|
|
264
|
+
: null;
|
|
265
|
+
default:
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Adapt source-validated iframe messages to the existing annotation UI.
|
|
272
|
+
*
|
|
273
|
+
* Malformed bridge payloads are ignored; annotations are posted back through
|
|
274
|
+
* the iframe protocol and reported through the supplied callbacks.
|
|
275
|
+
*/
|
|
53
276
|
export function useHtmlAnnotation({
|
|
54
277
|
iframeRef,
|
|
278
|
+
enabled = true,
|
|
55
279
|
onAddAnnotation,
|
|
56
280
|
onSelectAnnotation,
|
|
57
281
|
selectedAnnotationId,
|
|
58
282
|
mode,
|
|
59
283
|
onResize,
|
|
60
|
-
|
|
284
|
+
onBridgePointer,
|
|
285
|
+
}: UseHtmlAnnotationOptions): Omit<
|
|
286
|
+
UseAnnotationHighlighterReturn,
|
|
287
|
+
"highlighterRef" | "highlightRange" | "highlightMathElement"
|
|
288
|
+
> & {
|
|
289
|
+
/** In-flight multi-select targets (index 0 = primary); empty outside pinpoint drafts. */
|
|
290
|
+
draftTargets: HtmlDraftTarget[];
|
|
291
|
+
/** Remove one draft target (chip X). Removing the last cancels the draft. */
|
|
292
|
+
removeDraftTarget: (key: string) => void;
|
|
293
|
+
/** Flash a draft target's pinned outline in the page (chip hover). */
|
|
294
|
+
flashDraftTarget: (key: string) => void;
|
|
295
|
+
/** Bumped after every target add/remove so the composer can refocus its textarea. */
|
|
296
|
+
composerFocusToken: number;
|
|
297
|
+
} {
|
|
61
298
|
const [toolbarState, setToolbarState] = useState<ToolbarState | null>(null);
|
|
62
299
|
const [commentPopover, setCommentPopover] = useState<CommentPopoverState | null>(null);
|
|
63
300
|
const [quickLabelPicker, setQuickLabelPicker] = useState<QuickLabelPickerState | null>(null);
|
|
301
|
+
const [draftTargets, setDraftTargets] = useState<HtmlDraftTarget[]>([]);
|
|
302
|
+
const [composerFocusToken, setComposerFocusToken] = useState(0);
|
|
64
303
|
|
|
65
304
|
const pendingTextRef = useRef<string>("");
|
|
305
|
+
// Element anchor for the pending pinpoint selection — committed onto the
|
|
306
|
+
// annotation so restoration can resolve the exact element again.
|
|
307
|
+
const pendingAnchorRef = useRef<HtmlElementAnchor | null>(null);
|
|
308
|
+
const draftTargetsRef = useRef<HtmlDraftTarget[]>(draftTargets);
|
|
309
|
+
draftTargetsRef.current = draftTargets;
|
|
310
|
+
const onBridgePointerRef = useRef(onBridgePointer);
|
|
311
|
+
onBridgePointerRef.current = onBridgePointer;
|
|
312
|
+
const enabledRef = useRef(enabled);
|
|
313
|
+
enabledRef.current = enabled;
|
|
66
314
|
const modeRef = useRef(mode);
|
|
67
315
|
modeRef.current = mode;
|
|
68
316
|
// Mirror toolbar visibility into a ref so the (stable) message handler can gate
|
|
@@ -83,6 +331,47 @@ export function useHtmlAnnotation({
|
|
|
83
331
|
|
|
84
332
|
const anchorRef = useRef<HTMLDivElement | null>(null);
|
|
85
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Remove one draft target. Removing the primary promotes the next remaining
|
|
336
|
+
* target (the composer's context text and pending anchor follow it);
|
|
337
|
+
* removing the final target cancels the draft. The bridge performs the same
|
|
338
|
+
* deterministic update on its side, so `remove-target` is ALWAYS posted:
|
|
339
|
+
* for chip removals it drives the bridge, and for bridge-echoed removals it
|
|
340
|
+
* is an idempotent no-op — which also resyncs the two sides if a hostile
|
|
341
|
+
* page forged the removal message the bridge never actually performed.
|
|
342
|
+
*/
|
|
343
|
+
const applyTargetRemoval = useCallback(
|
|
344
|
+
(key: string) => {
|
|
345
|
+
const targets = draftTargetsRef.current;
|
|
346
|
+
const index = targets.findIndex((t) => t.key === key);
|
|
347
|
+
if (index < 0) return;
|
|
348
|
+
postToIframe(iframeRef.current, { type: `${PREFIX}remove-target`, key });
|
|
349
|
+
const remaining = targets.filter((t) => t.key !== key);
|
|
350
|
+
if (remaining.length === 0) {
|
|
351
|
+
// Final target removed — the draft is cancelled (bridge side already
|
|
352
|
+
// tore down its pinned state via toggle-off or the remove-target post).
|
|
353
|
+
setDraftTargets([]);
|
|
354
|
+
setCommentPopover(null);
|
|
355
|
+
pendingTextRef.current = "";
|
|
356
|
+
pendingAnchorRef.current = null;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (index === 0) {
|
|
360
|
+
// Primary removed — promote the next target: the comment's quoted
|
|
361
|
+
// text and restoration anchor now belong to it.
|
|
362
|
+
const next = remaining[0]!;
|
|
363
|
+
pendingTextRef.current = next.text;
|
|
364
|
+
pendingAnchorRef.current = next.anchor;
|
|
365
|
+
setCommentPopover((prev) =>
|
|
366
|
+
prev ? { ...prev, contextText: next.text, selectedText: next.text } : prev,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
setDraftTargets(remaining);
|
|
370
|
+
setComposerFocusToken((t) => t + 1);
|
|
371
|
+
},
|
|
372
|
+
[iframeRef],
|
|
373
|
+
);
|
|
374
|
+
|
|
86
375
|
const getOrCreateAnchor = useCallback(() => {
|
|
87
376
|
if (!anchorRef.current) {
|
|
88
377
|
const div = document.createElement("div");
|
|
@@ -115,18 +404,25 @@ export function useHtmlAnnotation({
|
|
|
115
404
|
);
|
|
116
405
|
|
|
117
406
|
useEffect(() => {
|
|
118
|
-
function handler(e: MessageEvent<
|
|
119
|
-
if (
|
|
407
|
+
function handler(e: MessageEvent<unknown>) {
|
|
408
|
+
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
409
|
+
const message = parseBridgeMessage(e.data);
|
|
410
|
+
if (!message) return;
|
|
411
|
+
|
|
412
|
+
const type = message.type;
|
|
120
413
|
|
|
121
|
-
|
|
414
|
+
if (!enabledRef.current && type !== `${PREFIX}mark-click` && type !== `${PREFIX}resize`) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
122
417
|
|
|
123
418
|
if (type === `${PREFIX}selection`) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
419
|
+
pendingTextRef.current = message.text;
|
|
420
|
+
pendingAnchorRef.current = message.anchor ?? null;
|
|
421
|
+
setDraftTargets([]); // a new selection always starts a fresh draft
|
|
422
|
+
const anchor = positionAnchor(message.rect);
|
|
127
423
|
if (!anchor) return;
|
|
128
424
|
|
|
129
|
-
const currentMode = modeRef.current;
|
|
425
|
+
const currentMode = message.modeOverride ?? modeRef.current;
|
|
130
426
|
|
|
131
427
|
if (currentMode === "redline") {
|
|
132
428
|
const id = nextHtmlAnnId();
|
|
@@ -137,20 +433,46 @@ export function useHtmlAnnotation({
|
|
|
137
433
|
startOffset: 0,
|
|
138
434
|
endOffset: 0,
|
|
139
435
|
type: AnnotationType.DELETION,
|
|
140
|
-
originalText:
|
|
436
|
+
originalText: message.text,
|
|
141
437
|
author: getIdentity(),
|
|
142
438
|
createdA: Date.now(),
|
|
439
|
+
htmlAnchor: message.anchor,
|
|
143
440
|
});
|
|
144
441
|
pendingTextRef.current = "";
|
|
145
|
-
|
|
442
|
+
pendingAnchorRef.current = null;
|
|
443
|
+
} else if (
|
|
444
|
+
currentMode === "comment"
|
|
445
|
+
// Pinpoint click-to-pin: the click already chose the target, so skip
|
|
446
|
+
// the intermediate toolbar and go straight to the comment composer.
|
|
447
|
+
|| (message.pinpoint && currentMode === "selection")
|
|
448
|
+
) {
|
|
146
449
|
// Release iframe focus so the popover's textarea autofocus lands in the
|
|
147
450
|
// parent (otherwise the iframe keeps focus and swallows further keys).
|
|
148
451
|
iframeRef.current?.blur();
|
|
149
452
|
setCommentPopover({
|
|
150
453
|
anchorEl: anchor,
|
|
151
|
-
contextText:
|
|
152
|
-
selectedText:
|
|
454
|
+
contextText: message.text,
|
|
455
|
+
selectedText: message.text,
|
|
153
456
|
});
|
|
457
|
+
// Pinpoint drafts arm shift-click multi-select: the clicked element
|
|
458
|
+
// becomes the primary target of the (single) draft comment. The
|
|
459
|
+
// bridge only accepts shift-toggles once THIS explicit arm arrives,
|
|
460
|
+
// so drafts the composer does not mirror (quickLabel, redline) can
|
|
461
|
+
// never accumulate pins the saved annotation would not carry.
|
|
462
|
+
if (message.pinpoint && message.targetKey) {
|
|
463
|
+
setDraftTargets([
|
|
464
|
+
{
|
|
465
|
+
key: message.targetKey,
|
|
466
|
+
label: message.targetLabel,
|
|
467
|
+
text: message.text,
|
|
468
|
+
anchor: message.anchor ?? null,
|
|
469
|
+
},
|
|
470
|
+
]);
|
|
471
|
+
postToIframe(iframeRef.current, {
|
|
472
|
+
type: `${PREFIX}arm-multi-select`,
|
|
473
|
+
key: message.targetKey,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
154
476
|
} else if (currentMode === "quickLabel") {
|
|
155
477
|
setQuickLabelPicker({
|
|
156
478
|
anchorEl: anchor,
|
|
@@ -160,11 +482,43 @@ export function useHtmlAnnotation({
|
|
|
160
482
|
setToolbarState({
|
|
161
483
|
element: anchor,
|
|
162
484
|
source: null,
|
|
163
|
-
selectionText:
|
|
485
|
+
selectionText: message.text,
|
|
164
486
|
});
|
|
165
487
|
}
|
|
166
488
|
}
|
|
167
489
|
|
|
490
|
+
if (type === `${PREFIX}multi-target-added`) {
|
|
491
|
+
// Only meaningful while a pinpoint draft composer is open. The array
|
|
492
|
+
// cap is enforced HERE, at the trust boundary — a hostile page cannot
|
|
493
|
+
// grow the draft past MAX_ADDITIONAL_TARGETS extra targets.
|
|
494
|
+
const targets = draftTargetsRef.current;
|
|
495
|
+
if (
|
|
496
|
+
commentPopoverRef.current
|
|
497
|
+
&& targets.length > 0
|
|
498
|
+
&& targets.length < 1 + MAX_ADDITIONAL_TARGETS
|
|
499
|
+
&& !targets.some((t) => t.key === message.key)
|
|
500
|
+
) {
|
|
501
|
+
setDraftTargets([
|
|
502
|
+
...targets,
|
|
503
|
+
{
|
|
504
|
+
key: message.key,
|
|
505
|
+
label: message.label,
|
|
506
|
+
text: message.text,
|
|
507
|
+
anchor: message.anchor ?? null,
|
|
508
|
+
},
|
|
509
|
+
]);
|
|
510
|
+
setComposerFocusToken((t) => t + 1);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (type === `${PREFIX}multi-target-removed`) {
|
|
515
|
+
applyTargetRemoval(message.key);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (type === `${PREFIX}pointer`) {
|
|
519
|
+
onBridgePointerRef.current?.(message.x, message.y, message.shift);
|
|
520
|
+
}
|
|
521
|
+
|
|
168
522
|
if (type === `${PREFIX}selection-clear`) {
|
|
169
523
|
setToolbarState(null);
|
|
170
524
|
// Keep the captured text alive while a comment/quick-label is open: the user
|
|
@@ -172,6 +526,7 @@ export function useHtmlAnnotation({
|
|
|
172
526
|
// not drop the annotation on submit. It's overwritten on the next selection.
|
|
173
527
|
if (!commentPopoverRef.current && !quickLabelPickerRef.current) {
|
|
174
528
|
pendingTextRef.current = "";
|
|
529
|
+
pendingAnchorRef.current = null;
|
|
175
530
|
}
|
|
176
531
|
}
|
|
177
532
|
|
|
@@ -182,7 +537,7 @@ export function useHtmlAnnotation({
|
|
|
182
537
|
const iframe = iframeRef.current;
|
|
183
538
|
const anchor = anchorRef.current;
|
|
184
539
|
if (!iframe || !anchor) return;
|
|
185
|
-
const r =
|
|
540
|
+
const r = message.rect;
|
|
186
541
|
const iframeRect = iframe.getBoundingClientRect();
|
|
187
542
|
anchor.style.top = `${iframeRect.top + r.top}px`;
|
|
188
543
|
anchor.style.left = `${iframeRect.left + r.left + r.width / 2}px`;
|
|
@@ -194,7 +549,7 @@ export function useHtmlAnnotation({
|
|
|
194
549
|
// markdown path, where AnnotationToolbar owns this keydown). Open a comment
|
|
195
550
|
// pre-filled with the typed char.
|
|
196
551
|
if (!toolbarStateRef.current) return;
|
|
197
|
-
const key =
|
|
552
|
+
const key = message.key;
|
|
198
553
|
const text = pendingTextRef.current;
|
|
199
554
|
if (!key || !text) return;
|
|
200
555
|
const anchor = anchorRef.current ?? getOrCreateAnchor();
|
|
@@ -206,13 +561,11 @@ export function useHtmlAnnotation({
|
|
|
206
561
|
}
|
|
207
562
|
|
|
208
563
|
if (type === `${PREFIX}mark-click`) {
|
|
209
|
-
|
|
210
|
-
onSelectRef.current?.(msg.id);
|
|
564
|
+
onSelectRef.current?.(message.id);
|
|
211
565
|
}
|
|
212
566
|
|
|
213
567
|
if (type === `${PREFIX}resize`) {
|
|
214
|
-
|
|
215
|
-
onResize?.(msg.height);
|
|
568
|
+
onResize?.(message.height);
|
|
216
569
|
}
|
|
217
570
|
}
|
|
218
571
|
|
|
@@ -224,7 +577,20 @@ export function useHtmlAnnotation({
|
|
|
224
577
|
anchorRef.current = null;
|
|
225
578
|
}
|
|
226
579
|
};
|
|
227
|
-
}, [iframeRef, positionAnchor, onResize, getOrCreateAnchor]);
|
|
580
|
+
}, [iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
|
|
581
|
+
|
|
582
|
+
useEffect(() => {
|
|
583
|
+
if (enabled) return;
|
|
584
|
+
setToolbarState(null);
|
|
585
|
+
setCommentPopover(null);
|
|
586
|
+
setQuickLabelPicker(null);
|
|
587
|
+
setDraftTargets([]);
|
|
588
|
+
pendingTextRef.current = "";
|
|
589
|
+
pendingAnchorRef.current = null;
|
|
590
|
+
anchorRef.current?.remove();
|
|
591
|
+
anchorRef.current = null;
|
|
592
|
+
postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
|
|
593
|
+
}, [enabled, iframeRef]);
|
|
228
594
|
|
|
229
595
|
useEffect(() => {
|
|
230
596
|
if (selectedAnnotationId) {
|
|
@@ -242,6 +608,7 @@ export function useHtmlAnnotation({
|
|
|
242
608
|
|
|
243
609
|
const handleAnnotate = useCallback(
|
|
244
610
|
(type: AnnotationType) => {
|
|
611
|
+
if (!enabledRef.current) return;
|
|
245
612
|
const text = pendingTextRef.current;
|
|
246
613
|
if (!text || type !== AnnotationType.DELETION) return;
|
|
247
614
|
|
|
@@ -256,16 +623,19 @@ export function useHtmlAnnotation({
|
|
|
256
623
|
originalText: text,
|
|
257
624
|
author: getIdentity(),
|
|
258
625
|
createdA: Date.now(),
|
|
626
|
+
htmlAnchor: pendingAnchorRef.current ?? undefined,
|
|
259
627
|
});
|
|
260
628
|
|
|
261
629
|
setToolbarState(null);
|
|
262
630
|
pendingTextRef.current = "";
|
|
631
|
+
pendingAnchorRef.current = null;
|
|
263
632
|
},
|
|
264
633
|
[iframeRef],
|
|
265
634
|
);
|
|
266
635
|
|
|
267
636
|
const handleRequestComment = useCallback(
|
|
268
637
|
(initialChar?: string) => {
|
|
638
|
+
if (!enabledRef.current) return;
|
|
269
639
|
const text = pendingTextRef.current;
|
|
270
640
|
if (!text) return;
|
|
271
641
|
const anchor = anchorRef.current ?? getOrCreateAnchor();
|
|
@@ -277,11 +647,23 @@ export function useHtmlAnnotation({
|
|
|
277
647
|
|
|
278
648
|
const handleCommentSubmit = useCallback(
|
|
279
649
|
(comment: string, images?: ImageAttachment[]) => {
|
|
650
|
+
if (!enabledRef.current) return;
|
|
280
651
|
// Prefer the text captured when the popover opened — it can't be clobbered by
|
|
281
652
|
// a later selection change or clear while the user is composing the comment.
|
|
282
653
|
const text = commentPopoverRef.current?.selectedText || pendingTextRef.current;
|
|
283
654
|
if (!text) return;
|
|
284
655
|
|
|
656
|
+
// Multi-select: everything past the primary rides on the SAME comment.
|
|
657
|
+
const targets = draftTargetsRef.current;
|
|
658
|
+
const additionalTargets: HtmlAnnotationTarget[] | undefined =
|
|
659
|
+
targets.length > 1
|
|
660
|
+
? targets.slice(1, 1 + MAX_ADDITIONAL_TARGETS).map((t) => ({
|
|
661
|
+
label: t.label,
|
|
662
|
+
text: t.text,
|
|
663
|
+
anchor: t.anchor ?? undefined,
|
|
664
|
+
}))
|
|
665
|
+
: undefined;
|
|
666
|
+
|
|
285
667
|
const id = nextHtmlAnnId();
|
|
286
668
|
postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
287
669
|
onAddRef.current?.({
|
|
@@ -295,26 +677,51 @@ export function useHtmlAnnotation({
|
|
|
295
677
|
author: getIdentity(),
|
|
296
678
|
createdA: Date.now(),
|
|
297
679
|
images,
|
|
680
|
+
htmlAnchor: pendingAnchorRef.current ?? undefined,
|
|
681
|
+
htmlAdditionalTargets: additionalTargets,
|
|
298
682
|
});
|
|
299
683
|
|
|
300
684
|
setCommentPopover(null);
|
|
685
|
+
setDraftTargets([]);
|
|
301
686
|
pendingTextRef.current = "";
|
|
687
|
+
pendingAnchorRef.current = null;
|
|
302
688
|
},
|
|
303
689
|
[iframeRef],
|
|
304
690
|
);
|
|
305
691
|
|
|
306
692
|
const handleCommentClose = useCallback(() => {
|
|
693
|
+
postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
|
|
307
694
|
setCommentPopover(null);
|
|
695
|
+
setDraftTargets([]);
|
|
308
696
|
pendingTextRef.current = "";
|
|
309
|
-
|
|
697
|
+
pendingAnchorRef.current = null;
|
|
698
|
+
}, [iframeRef]);
|
|
699
|
+
|
|
700
|
+
const removeDraftTarget = useCallback(
|
|
701
|
+
(key: string) => {
|
|
702
|
+
if (!enabledRef.current) return;
|
|
703
|
+
applyTargetRemoval(key);
|
|
704
|
+
},
|
|
705
|
+
[applyTargetRemoval],
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
const flashDraftTarget = useCallback(
|
|
709
|
+
(key: string) => {
|
|
710
|
+
postToIframe(iframeRef.current, { type: `${PREFIX}flash-target`, key });
|
|
711
|
+
},
|
|
712
|
+
[iframeRef],
|
|
713
|
+
);
|
|
310
714
|
|
|
311
715
|
const handleToolbarClose = useCallback(() => {
|
|
716
|
+
postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
|
|
312
717
|
setToolbarState(null);
|
|
313
718
|
pendingTextRef.current = "";
|
|
314
|
-
|
|
719
|
+
pendingAnchorRef.current = null;
|
|
720
|
+
}, [iframeRef]);
|
|
315
721
|
|
|
316
722
|
const applyQuickLabel = useCallback(
|
|
317
723
|
(label: QuickLabel, clearState: () => void) => {
|
|
724
|
+
if (!enabledRef.current) return;
|
|
318
725
|
const text = pendingTextRef.current;
|
|
319
726
|
if (!text) return;
|
|
320
727
|
const id = nextHtmlAnnId();
|
|
@@ -331,9 +738,11 @@ export function useHtmlAnnotation({
|
|
|
331
738
|
quickLabelTip: label.tip,
|
|
332
739
|
author: getIdentity(),
|
|
333
740
|
createdA: Date.now(),
|
|
741
|
+
htmlAnchor: pendingAnchorRef.current ?? undefined,
|
|
334
742
|
});
|
|
335
743
|
clearState();
|
|
336
744
|
pendingTextRef.current = "";
|
|
745
|
+
pendingAnchorRef.current = null;
|
|
337
746
|
},
|
|
338
747
|
[iframeRef],
|
|
339
748
|
);
|
|
@@ -349,9 +758,11 @@ export function useHtmlAnnotation({
|
|
|
349
758
|
);
|
|
350
759
|
|
|
351
760
|
const handleQuickLabelPickerDismiss = useCallback(() => {
|
|
761
|
+
postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
|
|
352
762
|
setQuickLabelPicker(null);
|
|
353
763
|
pendingTextRef.current = "";
|
|
354
|
-
|
|
764
|
+
pendingAnchorRef.current = null;
|
|
765
|
+
}, [iframeRef]);
|
|
355
766
|
|
|
356
767
|
const removeHighlight = useCallback(
|
|
357
768
|
(id: string) => {
|
|
@@ -369,11 +780,21 @@ export function useHtmlAnnotation({
|
|
|
369
780
|
for (const ann of anns) {
|
|
370
781
|
if (ann.type === AnnotationType.GLOBAL_COMMENT) continue;
|
|
371
782
|
const annType = ann.type === AnnotationType.DELETION ? "deletion" : "comment";
|
|
783
|
+
// Multi-target annotations restore every additional target as a pin
|
|
784
|
+
// under the same id (same badge number). Anchor-only and capped.
|
|
785
|
+
const additionalAnchors = (ann.htmlAdditionalTargets ?? [])
|
|
786
|
+
.map((t) => t.anchor)
|
|
787
|
+
.filter((a): a is HtmlElementAnchor => !!a)
|
|
788
|
+
.slice(0, MAX_ADDITIONAL_TARGETS);
|
|
372
789
|
postToIframe(iframeRef.current, {
|
|
373
790
|
type: `${PREFIX}find-and-mark`,
|
|
374
791
|
id: ann.id,
|
|
375
792
|
originalText: ann.originalText,
|
|
376
793
|
annotationType: annType,
|
|
794
|
+
// Anchor-first restore: the bridge resolves the serialized element
|
|
795
|
+
// and scopes the text search to it, falling back to document-wide.
|
|
796
|
+
anchor: ann.htmlAnchor,
|
|
797
|
+
additionalAnchors: additionalAnchors.length ? additionalAnchors : undefined,
|
|
377
798
|
});
|
|
378
799
|
}
|
|
379
800
|
},
|
|
@@ -395,5 +816,9 @@ export function useHtmlAnnotation({
|
|
|
395
816
|
removeHighlight,
|
|
396
817
|
clearAllHighlights,
|
|
397
818
|
applyAnnotations,
|
|
819
|
+
draftTargets,
|
|
820
|
+
removeDraftTarget,
|
|
821
|
+
flashDraftTarget,
|
|
822
|
+
composerFocusToken,
|
|
398
823
|
};
|
|
399
824
|
}
|