@plannotator/ui 0.29.0 → 0.30.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.
|
@@ -80,8 +80,11 @@ interface PanelProps {
|
|
|
80
80
|
* resolve UI). The panel stays presentation-only; clicks inside the slot
|
|
81
81
|
* do not select the card. Default: nothing rendered. */
|
|
82
82
|
renderCardFooter?: (annotation: Annotation) => React.ReactNode;
|
|
83
|
-
/** Hide every mutation affordance (delete/edit, direct-edit
|
|
84
|
-
*
|
|
83
|
+
/** Hide every built-in mutation affordance (delete/edit, direct-edit
|
|
84
|
+
* discard). The host footer slot still renders: its contents are
|
|
85
|
+
* host-owned and may be read affordances (replies, links), so the host
|
|
86
|
+
* gates what belongs in it. Selection and scrolling still work.
|
|
87
|
+
* Default false — today's behavior. */
|
|
85
88
|
readOnly?: boolean;
|
|
86
89
|
}
|
|
87
90
|
|
|
@@ -207,7 +210,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
207
210
|
onDelete={() => onDelete(entry.annotation.id)}
|
|
208
211
|
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
|
|
209
212
|
readOnly={readOnly}
|
|
210
|
-
footer={
|
|
213
|
+
footer={renderCardFooter?.(entry.annotation)}
|
|
211
214
|
/>
|
|
212
215
|
) : (
|
|
213
216
|
<CodeAnnotationCard
|
|
@@ -172,6 +172,11 @@ export interface HtmlViewerProps {
|
|
|
172
172
|
onAskAI?: CommentAskAIHandler;
|
|
173
173
|
/** Disable every annotation mutation entry point while preserving reading and navigation. */
|
|
174
174
|
readOnly?: boolean;
|
|
175
|
+
/** Reports the full set of annotation ids with no live representation on
|
|
176
|
+
* the page (fail-closed anchors hide markers rather than guess). Called
|
|
177
|
+
* with the complete current set whenever it changes, including back to
|
|
178
|
+
* empty on recovery. Fires in readOnly mode too. */
|
|
179
|
+
onUnanchoredChange?: (ids: string[]) => void;
|
|
175
180
|
/** Accessible iframe title. */
|
|
176
181
|
title?: string;
|
|
177
182
|
}
|
|
@@ -205,6 +210,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
205
210
|
onToggleDiff,
|
|
206
211
|
onAskAI,
|
|
207
212
|
readOnly = false,
|
|
213
|
+
onUnanchoredChange,
|
|
208
214
|
title = "HTML Plan Viewer",
|
|
209
215
|
},
|
|
210
216
|
ref,
|
|
@@ -294,6 +300,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
294
300
|
mode,
|
|
295
301
|
onResize: handleResize,
|
|
296
302
|
onBridgePointer: handleBridgePointer,
|
|
303
|
+
onUnanchoredChange,
|
|
297
304
|
});
|
|
298
305
|
|
|
299
306
|
const multiSelectActive = !readOnly && !!hook.commentPopover && hook.draftTargets.length > 0;
|
|
@@ -475,7 +482,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
475
482
|
},
|
|
476
483
|
"*",
|
|
477
484
|
);
|
|
478
|
-
if (!readOnly && vimModeEnabled && iframe === document.activeElement) {
|
|
485
|
+
if (!readOnly && vimModeEnabled && iframe && iframe === document.activeElement) {
|
|
479
486
|
// The initial parent focus can land before the sandbox bridge is ready.
|
|
480
487
|
// Reassert it after configuration so raw HTML enters BLOCK immediately,
|
|
481
488
|
// matching the Markdown surface instead of waiting for the first key.
|
|
@@ -499,6 +499,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
499
499
|
annRecords = [];
|
|
500
500
|
annNumbers = null; // stale synced numbers must not leak onto future records
|
|
501
501
|
focusedAnnotationId = null;
|
|
502
|
+
restoreFailedIds.clear();
|
|
502
503
|
renderAnnotationOverlay();
|
|
503
504
|
}
|
|
504
505
|
|
|
@@ -1327,9 +1328,43 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1327
1328
|
for (var i = annRecords.length - 1; i >= 0; i--) {
|
|
1328
1329
|
if (annRecords[i].id === id) annRecords.splice(i, 1);
|
|
1329
1330
|
}
|
|
1331
|
+
restoreFailedIds['delete'](id);
|
|
1330
1332
|
if (focusedAnnotationId === id) focusedAnnotationId = null;
|
|
1331
1333
|
}
|
|
1332
1334
|
|
|
1335
|
+
// Fail-closed transparency (host ask): markers for dead targets are
|
|
1336
|
+
// omitted, never guessed — this names WHICH annotations currently have no
|
|
1337
|
+
// live representation on the page (every target dead, or the restore never
|
|
1338
|
+
// resolved anything) so the host can tell the user instead of letting them
|
|
1339
|
+
// silently vanish. Emitted only when the set changes, and only from
|
|
1340
|
+
// complete overlay passes — budget-starved passes reschedule themselves
|
|
1341
|
+
// and would flap the set. restoreFailedIds carries total restore failures,
|
|
1342
|
+
// whose records are removed and therefore invisible to the per-pass scan.
|
|
1343
|
+
var lastUnanchoredKey = '[]';
|
|
1344
|
+
var restoreFailedIds = new Set();
|
|
1345
|
+
function emitUnanchored(deadRecordIds) {
|
|
1346
|
+
var seen = new Set();
|
|
1347
|
+
var combined = [];
|
|
1348
|
+
for (var deadIndex = 0; deadIndex < deadRecordIds.length; deadIndex++) {
|
|
1349
|
+
if (!seen.has(deadRecordIds[deadIndex])) {
|
|
1350
|
+
seen.add(deadRecordIds[deadIndex]);
|
|
1351
|
+
combined.push(deadRecordIds[deadIndex]);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
restoreFailedIds.forEach(function(failedId) {
|
|
1355
|
+
if (!seen.has(failedId)) {
|
|
1356
|
+
seen.add(failedId);
|
|
1357
|
+
combined.push(failedId);
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
combined.sort();
|
|
1361
|
+
if (combined.length > 512) combined = combined.slice(0, 512);
|
|
1362
|
+
var key = JSON.stringify(combined);
|
|
1363
|
+
if (key === lastUnanchoredKey) return;
|
|
1364
|
+
lastUnanchoredKey = key;
|
|
1365
|
+
parent.postMessage({ type: PREFIX + 'unanchored', ids: combined }, '*');
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1333
1368
|
function validNormalizedPoint(p) {
|
|
1334
1369
|
if (!p || typeof p.x !== 'number' || typeof p.y !== 'number') return null;
|
|
1335
1370
|
if (!isFinite(p.x) || !isFinite(p.y)) return null;
|
|
@@ -1467,7 +1502,12 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1467
1502
|
}
|
|
1468
1503
|
}
|
|
1469
1504
|
}
|
|
1470
|
-
if (!record.targets.length)
|
|
1505
|
+
if (!record.targets.length) {
|
|
1506
|
+
// The record is removed (nothing to retry), so the per-pass dead scan
|
|
1507
|
+
// cannot see this id: track it separately for the unanchored report.
|
|
1508
|
+
removeAnnRecord(id);
|
|
1509
|
+
restoreFailedIds.add(id);
|
|
1510
|
+
}
|
|
1471
1511
|
// rAF-coalesced render (B3): restoring N annotations posts N
|
|
1472
1512
|
// find-and-mark messages, and a synchronous render here made a batch
|
|
1473
1513
|
// restore O(N^2) full overlay passes. The searches above stay
|
|
@@ -2094,10 +2134,17 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2094
2134
|
|
|
2095
2135
|
function renderAnnotationOverlay() {
|
|
2096
2136
|
var hasDraftRange = !!(pendingSelection && pendingRange);
|
|
2097
|
-
if (!annRecords.length && !hasDraftRange && !overlayHostEl)
|
|
2137
|
+
if (!annRecords.length && !hasDraftRange && !overlayHostEl) {
|
|
2138
|
+
// Nothing to project, but total restore failures must still report:
|
|
2139
|
+
// a session whose only annotations failed to restore never builds the
|
|
2140
|
+
// overlay host, and silence here would hide exactly that case.
|
|
2141
|
+
emitUnanchored([]);
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2098
2144
|
ensureOverlayHost();
|
|
2099
2145
|
beginDeadSearchPass();
|
|
2100
2146
|
queuedHighlights.length = 0;
|
|
2147
|
+
var unanchoredThisPass = [];
|
|
2101
2148
|
var markers = [];
|
|
2102
2149
|
// Fallback numbering by first-seen registration order — used only until
|
|
2103
2150
|
// the parent's ordered sync arrives. Numbering NEVER derives from target
|
|
@@ -2111,6 +2158,20 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2111
2158
|
for (var recordIndex = 0; recordIndex < annRecords.length; recordIndex++) {
|
|
2112
2159
|
var record = annRecords[recordIndex];
|
|
2113
2160
|
refreshRecordTargets(record);
|
|
2161
|
+
// Live means connected/findable — clipped, offscreen, or style-hidden
|
|
2162
|
+
// targets are still anchored (their content exists; it just isn't
|
|
2163
|
+
// currently visible) and must not report as unanchored.
|
|
2164
|
+
var recordAnchored = false;
|
|
2165
|
+
for (var liveIndex = 0; liveIndex < record.targets.length; liveIndex++) {
|
|
2166
|
+
var liveTarget = record.targets[liveIndex];
|
|
2167
|
+
if (liveTarget.kind === 'element'
|
|
2168
|
+
? (liveTarget.element && liveTarget.element.isConnected)
|
|
2169
|
+
: rangeAlive(liveTarget.range)) {
|
|
2170
|
+
recordAnchored = true;
|
|
2171
|
+
break;
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
if (!recordAnchored) unanchoredThisPass.push(record.id);
|
|
2114
2175
|
var number = annNumbers && annNumbers.has(record.id)
|
|
2115
2176
|
? annNumbers.get(record.id)
|
|
2116
2177
|
: fallbackNumbers.get(record.id);
|
|
@@ -2219,6 +2280,9 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2219
2280
|
// record.
|
|
2220
2281
|
flushQueuedHighlights();
|
|
2221
2282
|
placeMarkers(markers);
|
|
2283
|
+
// Budget-starved passes reschedule below and may still revive targets,
|
|
2284
|
+
// so only a complete pass may update the unanchored report.
|
|
2285
|
+
if (!deadSearchSkipped) emitUnanchored(unanchoredThisPass);
|
|
2222
2286
|
// Eligible dead-target searches skipped for budget get a follow-up pass;
|
|
2223
2287
|
// the loop terminates once every eligible target has been attempted at
|
|
2224
2288
|
// the current generation (its failedGeneration then blocks it).
|
|
@@ -65,6 +65,7 @@ type BridgeMessage =
|
|
|
65
65
|
| { type: `${typeof PREFIX}selection-rect`; rect: BridgeRect }
|
|
66
66
|
| { type: `${typeof PREFIX}keytype`; key: string }
|
|
67
67
|
| { type: `${typeof PREFIX}mark-click`; id: string }
|
|
68
|
+
| { type: `${typeof PREFIX}unanchored`; ids: string[] }
|
|
68
69
|
| { type: `${typeof PREFIX}resize`; height: number };
|
|
69
70
|
|
|
70
71
|
/** Dependencies and callbacks for the sandboxed HTML annotation bridge. */
|
|
@@ -84,6 +85,13 @@ export interface UseHtmlAnnotationOptions {
|
|
|
84
85
|
* held while the pointer lives in the sandbox). Drives the composer-yield
|
|
85
86
|
* fade in the host component. */
|
|
86
87
|
onBridgePointer?: (x: number, y: number, shift: boolean) => void;
|
|
88
|
+
/** Reports the full set of annotation ids that currently have NO live
|
|
89
|
+
* representation on the page — every target dead, or the restore never
|
|
90
|
+
* resolved (fail-closed anchors hide markers rather than guess). Called
|
|
91
|
+
* with the complete current set whenever it changes, including back to
|
|
92
|
+
* empty on recovery. Delivered in readOnly mode too: view-only surfaces
|
|
93
|
+
* are exactly where silently missing markers would go unnoticed. */
|
|
94
|
+
onUnanchoredChange?: (ids: string[]) => void;
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
function postToIframe(iframe: HTMLIFrameElement | null, msg: Record<string, unknown>) {
|
|
@@ -258,6 +266,18 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
|
|
|
258
266
|
return typeof value.id === "string" && value.id.length <= 256
|
|
259
267
|
? { type: value.type, id: value.id }
|
|
260
268
|
: null;
|
|
269
|
+
case `${PREFIX}unanchored`: {
|
|
270
|
+
// Bounded like the bridge's own emission (512 ids, 256 chars each); any
|
|
271
|
+
// out-of-contract entry rejects the whole report — the real bridge
|
|
272
|
+
// never sends one, so a violation means a forged message.
|
|
273
|
+
if (!Array.isArray(value.ids) || value.ids.length > 512) return null;
|
|
274
|
+
const unanchoredIds: string[] = [];
|
|
275
|
+
for (const entry of value.ids) {
|
|
276
|
+
if (typeof entry !== "string" || entry.length > 256) return null;
|
|
277
|
+
unanchoredIds.push(entry);
|
|
278
|
+
}
|
|
279
|
+
return { type: value.type, ids: unanchoredIds };
|
|
280
|
+
}
|
|
261
281
|
case `${PREFIX}resize`:
|
|
262
282
|
return typeof value.height === "number" && Number.isFinite(value.height)
|
|
263
283
|
? { type: value.type, height: value.height }
|
|
@@ -282,6 +302,7 @@ export function useHtmlAnnotation({
|
|
|
282
302
|
mode,
|
|
283
303
|
onResize,
|
|
284
304
|
onBridgePointer,
|
|
305
|
+
onUnanchoredChange,
|
|
285
306
|
}: UseHtmlAnnotationOptions): Omit<
|
|
286
307
|
UseAnnotationHighlighterReturn,
|
|
287
308
|
"highlighterRef" | "highlightRange" | "highlightMathElement"
|
|
@@ -328,6 +349,8 @@ export function useHtmlAnnotation({
|
|
|
328
349
|
onAddRef.current = onAddAnnotation;
|
|
329
350
|
const onSelectRef = useRef(onSelectAnnotation);
|
|
330
351
|
onSelectRef.current = onSelectAnnotation;
|
|
352
|
+
const onUnanchoredChangeRef = useRef(onUnanchoredChange);
|
|
353
|
+
onUnanchoredChangeRef.current = onUnanchoredChange;
|
|
331
354
|
|
|
332
355
|
const anchorRef = useRef<HTMLDivElement | null>(null);
|
|
333
356
|
|
|
@@ -411,7 +434,12 @@ export function useHtmlAnnotation({
|
|
|
411
434
|
|
|
412
435
|
const type = message.type;
|
|
413
436
|
|
|
414
|
-
if (
|
|
437
|
+
if (
|
|
438
|
+
!enabledRef.current
|
|
439
|
+
&& type !== `${PREFIX}mark-click`
|
|
440
|
+
&& type !== `${PREFIX}unanchored`
|
|
441
|
+
&& type !== `${PREFIX}resize`
|
|
442
|
+
) {
|
|
415
443
|
return;
|
|
416
444
|
}
|
|
417
445
|
|
|
@@ -564,6 +592,10 @@ export function useHtmlAnnotation({
|
|
|
564
592
|
onSelectRef.current?.(message.id);
|
|
565
593
|
}
|
|
566
594
|
|
|
595
|
+
if (type === `${PREFIX}unanchored`) {
|
|
596
|
+
onUnanchoredChangeRef.current?.(message.ids);
|
|
597
|
+
}
|
|
598
|
+
|
|
567
599
|
if (type === `${PREFIX}resize`) {
|
|
568
600
|
onResize?.(message.height);
|
|
569
601
|
}
|