/** * AnnotationLayer — per-page SVG overlays for text highlight annotations. * * Each page wrapper gets one absolutely positioned * over the canvas. Highlight quads (in PDF coordinate space, origin * bottom-left) are converted to CSS pixel coordinates using the simple * transform: cx = x * scale, cy = (vpHeight - y) * scale * which is correct for upright (non-rotated) pages. * * Annotations from the model arrive as snake_case JSON matching the Rust * field names (annotation_type.type = "textmarkup", etc.). */ export class AnnotationLayer { /** * @param {HTMLElement[]} wrappers - PageManager.pageWrappers (0-indexed array) * @param {Array<{width:number,height:number}>} viewports - scale=1 page dims * @param {Function} onAnnotationClick - (annotationId: string) => void */ constructor(wrappers, viewports, onAnnotationClick) { this._wrappers = wrappers; this._viewports = viewports; this._onAnnotationClick = onAnnotationClick; this._annotations = []; this._scale = 1; this._selectedId = null; } /** * Replace the full annotation list and re-render every page overlay. * * @param {object[]} annotations - Annotation objects from the backend * @param {number} scale - Current CSS display scale */ setAnnotations(annotations, scale) { this._annotations = annotations || []; this._scale = scale; for (let i = 0; i < this._wrappers.length; i++) { this._renderPage(i + 1); } } /** Called by viewer when zoom debounce fires; re-renders all overlays. */ onScaleChange(scale) { this._scale = scale; for (let i = 0; i < this._wrappers.length; i++) { this._renderPage(i + 1); } } /** * Mark one annotation as visually selected (adds .selected CSS class). * Pass null to clear the selection. * * @param {string|null} id */ selectAnnotation(id) { if (this._selectedId) { document.querySelectorAll(`.annot-layer [data-id="${CSS.escape(this._selectedId)}"]`) .forEach(el => el.classList.remove("selected")); } this._selectedId = id; if (id) { document.querySelectorAll(`.annot-layer [data-id="${CSS.escape(id)}"]`) .forEach(el => el.classList.add("selected")); } } // ── Private ──────────────────────────────────────────────────────────────── _renderPage(pageNum) { const i = pageNum - 1; const wrap = this._wrappers[i]; if (!wrap) return; // Remove any existing overlay for this page wrap.querySelector(".annot-layer")?.remove(); const vp0 = this._viewports[i]; const scale = this._scale; const cssW = vp0.width * scale; const cssH = vp0.height * scale; // Collect highlights for this page (page field is 0-indexed in the model) const highlights = this._annotations.filter(a => a.page === i && a.annotation_type?.type === "textmarkup" && a.annotation_type?.markup_type === "highlight" ); if (highlights.length === 0) return; const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.classList.add("annot-layer"); svg.setAttribute("viewBox", `0 0 ${cssW} ${cssH}`); svg.style.width = cssW + "px"; svg.style.height = cssH + "px"; for (const ann of highlights) { for (const quad of ann.annotation_type.quads ?? []) { const pts = (quad.points ?? []).map(pt => { const cx = pt.x * scale; const cy = (vp0.height - pt.y) * scale; return `${cx},${cy}`; }).join(" "); const polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); polygon.setAttribute("points", pts); polygon.classList.add("highlight"); polygon.dataset.id = ann.id; if (this._selectedId === ann.id) polygon.classList.add("selected"); polygon.addEventListener("click", ev => { ev.stopPropagation(); this._onAnnotationClick?.(ann.id); }); svg.appendChild(polygon); } } wrap.appendChild(svg); } }