123 lines
4.2 KiB
JavaScript
123 lines
4.2 KiB
JavaScript
/**
|
|
* AnnotationLayer — per-page SVG overlays for text highlight annotations.
|
|
*
|
|
* Each page wrapper gets one <svg class="annot-layer"> 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);
|
|
}
|
|
}
|