mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
91f57ad7a2
* feat(ui): highlight external SSE annotations inline on plan text External annotations posted via /api/external-annotations previously appeared in the sidebar only. They now highlight the matching `originalText` span in the rendered plan, giving tools (linters, agents) an optional way to attach feedback to specific phrases — while `GLOBAL_COMMENT` (or any annotation without `originalText`) still degrades to sidebar-only. Implementation is a new focused hook that drives the Viewer's existing imperative `applySharedAnnotations` / `removeHighlight` API, reusing the same DOM text-search path that share-URL restoration uses. No protocol change, no schema change to `transformPlanInput`, and App.tsx gains only a single hook call. The hook tracks applied ids with a type+originalText fingerprint so SSE updates correctly remove+reapply, clears its bookkeeping only on plan markdown change (where blocks re-render), and early-returns (preserving state) while diff view or a linked doc overlay is active so SSE removals arriving under those conditions still reconcile when the hook re-enables. For provenance purposes, this commit was AI assisted. * fix(ui): repaint external SSE highlights after share import, clean dead code Addresses three review findings on the external annotation highlight hook: - Share-import wipe: `importFromShareUrl` merges annotations without changing `markdown`, so our `planKey` stayed stable. The share-apply effect in App.tsx calls `clearAllHighlights()` to reset the DOM before applying imported annotations — which also wiped live external SSE highlights. The hook believed they were still painted and never re-drove them, leaving sidebar entries with no visible highlight until the next SSE event. Fix: hook now exposes a `reset()` that clears its applied-set and re-runs the main effect via a counter; App.tsx calls it right after `clearAllHighlights()` in the share-apply path. - Removed a dead `nextIds.has(a.id)` guard inside the apply timer callback. `toAdd` is computed as a subset of `eligible`, and `nextIds` was built from `eligible.map(.id)`, so the guard was vacuously always true. - Removed the stable `viewerRef` from the main effect's dep array; React ref objects have stable identity for the component's lifetime so it was noise. Added a comment noting the intentional omission. For provenance purposes, this commit was AI assisted. * docs(ui): note reset() in useExternalAnnotationHighlights header comment For provenance purposes, this commit was AI assisted.
106 lines
4.1 KiB
TypeScript
106 lines
4.1 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import type { Annotation } from '../types';
|
|
import { AnnotationType } from '../types';
|
|
import type { ViewerHandle } from '../components/Viewer';
|
|
|
|
/**
|
|
* Bridges SSE-delivered external annotations into the Viewer's imperative
|
|
* highlight API so tools can POST annotations with `originalText` and have
|
|
* them highlight real spans of the rendered plan.
|
|
*
|
|
* The Viewer's `applySharedAnnotations` already searches the DOM for
|
|
* `originalText` and dedupes against already-applied marks, so this hook
|
|
* just needs to drive it when the external list changes.
|
|
*
|
|
* - Annotations without `originalText` (or `GLOBAL_COMMENT`) stay sidebar-only.
|
|
* - Annotations with `diffContext` are skipped (diff view owns those).
|
|
* - On plan markdown change the applied set is cleared so re-rendered blocks
|
|
* get re-highlighted.
|
|
* - Callers can invoke the returned `reset()` to force a full re-apply — used
|
|
* by the share-import path in App.tsx after it calls `clearAllHighlights()`,
|
|
* which would otherwise leave our bookkeeping stale against a wiped DOM.
|
|
* - Disabled state no-ops WITHOUT clearing the applied set. This preserves the
|
|
* bookkeeping while the Viewer DOM is hidden (diff view / linked doc) so that
|
|
* any SSE removals that arrive while hidden are correctly reconciled when the
|
|
* hook re-enables.
|
|
*/
|
|
export function useExternalAnnotationHighlights(params: {
|
|
viewerRef: React.RefObject<ViewerHandle | null>;
|
|
externalAnnotations: Annotation[];
|
|
enabled: boolean;
|
|
/** Bump to force a full re-apply (e.g. plan markdown changed and blocks re-rendered). */
|
|
planKey: string;
|
|
}): { reset: () => void } {
|
|
const { viewerRef, externalAnnotations, enabled, planKey } = params;
|
|
|
|
// Tracks annotation IDs currently materialized as DOM highlights, along
|
|
// with a fingerprint so updates trigger remove+reapply.
|
|
const appliedRef = useRef<Map<string, string>>(new Map());
|
|
|
|
// Bumped to force the main effect to treat every current external as a
|
|
// fresh application target — used by `reset()` below.
|
|
const [resetCount, setResetCount] = useState(0);
|
|
|
|
// Clear tracking when plan content changes — the Viewer re-parses blocks
|
|
// and wipes marks, so our bookkeeping is stale.
|
|
useEffect(() => {
|
|
appliedRef.current.clear();
|
|
}, [planKey]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
const viewer = viewerRef.current;
|
|
if (!viewer) return;
|
|
|
|
const eligible = externalAnnotations.filter(
|
|
a => a.type !== AnnotationType.GLOBAL_COMMENT && !a.diffContext && a.originalText,
|
|
);
|
|
const applied = appliedRef.current;
|
|
|
|
// Removals: previously applied but no longer present, or fingerprint changed.
|
|
const toRemove: string[] = [];
|
|
for (const [id, fp] of applied) {
|
|
const match = eligible.find(a => a.id === id);
|
|
if (!match || fingerprint(match) !== fp) {
|
|
toRemove.push(id);
|
|
}
|
|
}
|
|
toRemove.forEach(id => {
|
|
viewer.removeHighlight(id);
|
|
applied.delete(id);
|
|
});
|
|
|
|
// Additions: eligible but not yet applied (includes re-adds from updates).
|
|
const toAdd = eligible.filter(a => !applied.has(a.id));
|
|
if (toAdd.length === 0) return;
|
|
|
|
// Paint delay matches the existing draft/share restore pattern —
|
|
// ensures blocks are mounted before we walk the DOM.
|
|
const timer = setTimeout(() => {
|
|
const v = viewerRef.current;
|
|
if (!v) return;
|
|
v.applySharedAnnotations(toAdd);
|
|
toAdd.forEach(a => applied.set(a.id, fingerprint(a)));
|
|
}, 100);
|
|
|
|
return () => clearTimeout(timer);
|
|
// viewerRef is a stable ref object and intentionally omitted from deps.
|
|
}, [externalAnnotations, enabled, planKey, resetCount]);
|
|
|
|
// Forget everything we've tracked and force a full re-apply on the next
|
|
// effect run. Callers invoke this after an external action has wiped the
|
|
// Viewer DOM out from under us (e.g. `clearAllHighlights()` during share
|
|
// import) so live externals get repainted.
|
|
const reset = useCallback(() => {
|
|
appliedRef.current.clear();
|
|
setResetCount(c => c + 1);
|
|
}, []);
|
|
|
|
return { reset };
|
|
}
|
|
|
|
function fingerprint(a: Annotation): string {
|
|
return `${a.type}\u0000${a.originalText}`;
|
|
}
|