Skip to content

Live-DOM highlighter

entity-viz-kit/highlighter paints host-supplied entity annotations onto an existing page, keeps them attached to the right text while the page changes, and reports activations as bounded data. It performs no extraction, entity recognition, retrieval, storage, telemetry or network access. The host decides which text is annotated and what an activation does.

Read ADR 0005 before relying on it for large pages. It began in 0.5.0 as a correct first draft with measured performance gaps. Since 0.7.0, re-anchoring after an edit is batched: about 0.2 s instead of about 14 s on the 5,000-annotation stress page. Native hit testing (about 80 ms) and coarse repair units remain recorded, unmet targets.

import { attachHighlighter } from 'entity-viz-kit/highlighter';
import { documentId, entityId, mentionId } from 'entity-viz-kit/core';
const highlighter = await attachHighlighter(articleElement, {
documentId: documentId('page:dispatch'),
documentVersion: 'v1',
annotations, // LiveAnnotation[]: mentionId, entityIds, anchor, label?, kind?
activationIntent: 'query', // or 'inspect'
onActivate: event => { /* event.candidates: every mention at the point, never a guess */ },
onIndexChange: info => { /* info.revision / info.state: 'current' | 'pending' | 'failed' */ }
});
highlighter.updateAnnotations(next); // re-resolves anchors; invalid input throws ValidationError
highlighter.setQueryEntityIds([entityId('org:northstar')]); // repaint only, no re-indexing
highlighter.setSelectedMention(mentionId('m:1'));
highlighter.detach(); // idempotent; removes only this instance's resources

Anchors are the core TextAnchor contract against canonical text version 1 of the root you attach to. Compute them from highlighter.getIndex().source.text, or from createDomTextMap(sameRoot), which yields identical text. Annotation objects are validated as untrusted data: unknown fields, duplicate mention IDs, empty entity lists and non-string labels are rejected. Callbacks are host configuration and are never read from annotation data.

getMentions() returns candidates in document order. Each candidate has a status (exact / re-anchored / ambiguous / unavailable), a painted flag and insideInteractive (inside a host link or control).

  • One attach root is one coordinate space and one documentId. Attach to the content container whose direct children are the page’s blocks. Those children are the repair units (see performance below).
  • Excluded from the canonical text: script, style, noscript, template, form controls, iframes/objects, contenteditable regions, [hidden], [aria-hidden="true"], [data-evk-private], [data-evk-exclude] and the host’s excludeSelector. An excluded subtree is a word boundary; it never joins text across omitted content. If the root itself is inside an excluded subtree, nothing is indexed.
  • The root’s ancestors, across shadow hosts, are observed for the same policy attributes. Hiding or privatizing any ancestor excludes the whole root at the next repair.
  • CSS-only hiding such as display:none from a stylesheet is not inspected. Use the attributes above for private content.
  • Open shadow roots and frames are never entered from an outer root. To annotate them, attach a separate instance to the open ShadowRoot or to a same-origin frame’s body, with its own document ID. inspectBoundaries() counts open shadow roots, accessible frames and inaccessible frames so the host can report unsupported regions. Closed shadow roots and cross-origin frames stay inaccessible.

When the page supports CSS Custom Highlights, the highlighter installs one owned <style data-evk-owned> into head, or into the shadow root for a shadow attach, and checks that the stylesheet actually applied. Highlight names are generated per instance (evk-hl-<n>-<random>-entity|query|evidence|selected). The global highlight registry is never cleared.

A strict style-src policy blocks that stylesheet. The highlighter does not work around the policy. It either:

  • falls back to an owned, pointer-events:none, aria-hidden SVG overlay (renderMode: 'auto', the default), positioned through CSSOM properties that such policies permit, or
  • throws a ValidationError when renderMode: 'native' was required.

To keep native rendering under CSP, pass styleNonce or an installStyle(cssText, instanceId) hook that installs the rules through your own policy-approved mechanism. capabilities.renderer and capabilities.styleInstalled report what happened. The reference sidebar at /highlighter.html runs behind the dev server’s style-src 'self' and therefore shows the overlay.

Clicks are handled in the bubble phase on the root. They are ignored when the host already called preventDefault(), when the click was a secondary or modified click, a drag, a long press (over 650 ms), a text selection, or when the target is inside a link, control, label, summary or contenteditable. A mention inside a link therefore never hijacks navigation. Candidates report insideInteractive: true so a companion UI can offer the entity action next to it.

Candidates are found through highlightsFromPoint where available, then the caret position (confirmed against live range rectangles, so clicks in a blank margin do not snap to a mention), then cached range geometry (also confirmed live, and rebuilt when stale). Overlapping mentions return every candidate. The reference sidebar shows an explicit chooser; it never picks silently.

Keyboard and assistive-technology route: getMentions(), scrollToMention(id) and activateMention(id). The last emits the same activation event with point: null. Painted ranges have no interactive semantics of their own. See the non-React navigator at /highlighter-standalone.html and the React sidebar at /highlighter.html. Attaching never moves focus.

A MutationObserver on the root marks the owning repair unit stale synchronously. Painted ranges over stale text are withdrawn immediately. After mutationDelayMs (default 40), one serialized repair re-walks only stale units and reuses the others, including their Text node references. After content changes the source version becomes <documentVersion>#live-<n>, so anchors re-resolve by quote and context under the core rules: text that changed or became ambiguous is left unpainted rather than guessed. Call setDocumentVersion(v) when the host knows the DOM now matches version v. Changes the host made just before the call are folded in first, so they do not later turn v into v#live-1. If a repair is pending, the new version applies once that repair commits. refresh() runs a full rebuild on the same serialized queue.

If a repair fails (for example, the page grows past maxCharacters), the index state becomes failed, nothing is painted, onIndexChange reports the failure, and the next mutation or refresh() retries. EVK-owned nodes never trigger repair, so painting cannot loop.

Geometry is invalidated on scroll (capture, so nested scroll containers count), resize, root resize, font loading and visibility changes. In overlay mode the root’s box is also compared every 250 ms while the page is visible, which catches layout shifts outside the root such as an inserted banner. The overlay repaints once per frame and only draws rectangles inside the viewport. Mutations started during the first chunked build are observed from the start and repaired afterwards.

The shared browser scenario builds the 250,000-character / 5,000-annotation fixture (250 paragraphs) and records the timings in build/reports/highlighter-*/stress-*.json. The assertions cover correctness only. The main measured gaps in 0.5.0:

  • Re-anchoring after any live edit is O(annotations × quote occurrences). In the stress fixture every anchor quote occurs 5,000 times, and one paragraph edit took about 14 s of main-thread work. The index repair itself rebuilt one unit.
  • Repair granularity is a direct child of the root. A change inside one very large child re-walks that whole child synchronously. Attaching to <body> with a single app wrapper degenerates to full rescans.
  • Native highlightsFromPoint over 5,000 ranges took about 80 ms per hit in the stress fixture. Overlay geometry is rebuilt on each scroll frame.

These are candidates for later tuning, not hidden limits. Chunked re-resolution, finer repair units and candidate caching are the obvious next steps.

EVK recognizes its own nodes by identity (a module-level WeakSet), not by the data-evk-owned attribute, so page markup cannot hide content by copying that marker. Owned nodes contribute no text and no boundary, so a style inserted into a shadow root does not change its canonical text. Two separately bundled EVK copies do not recognize each other’s nodes. Every listener goes through one AbortController. Observers, timers, the animation frame, the style element, the overlay and this instance’s highlight names are removed on detach(). Multiple instances on the same root and other highlight libraries keep their own registry entries. The browser suites check detach with a DevTools listener inventory, 20 attach/detach cycles and a foreign CSS.highlights entry.