@plannotator/ui 0.29.1 → 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 discard, and host card footers).
84
- * Selection and scrolling still work. Default false — today's behavior. */
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={readOnly ? undefined : renderCardFooter?.(entry.annotation)}
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;
@@ -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) removeAnnRecord(id);
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) return;
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 (!enabledRef.current && type !== `${PREFIX}mark-click` && type !== `${PREFIX}resize`) {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.29.1",
3
+ "version": "0.30.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",