FalconScan / frontend /overlay.js
Antigravity AI Agent
Optimize document loading performance, make document info circular icon-only, align header button fonts, and move selection hint slightly down
9873f3c
Raw
History Blame Contribute Delete
10.1 kB
window.FalconOverlay = (() => {
let terms = [];
let viewport;
let overlay;
let video;
let uploadedPreview;
let documentScroller;
let activePopover = null;
let currentDotIndex = 0;
function init() {
viewport = document.querySelector("#viewport");
overlay = document.querySelector("#overlay");
video = document.querySelector("#video");
uploadedPreview = document.querySelector("#uploadedPreview");
documentScroller = document.querySelector("#documentScroller");
document.addEventListener("pointerdown", (event) => {
if (activePopover && !activePopover.contains(event.target) && !event.target.closest(".term-marker")) closePopover();
});
}
function geometry(frameWidth, frameHeight) {
const documentMode = documentScroller && !documentScroller.hidden;
const media = documentMode ? uploadedPreview : video;
const mediaRect = media.getBoundingClientRect();
const viewRect = documentMode ? overlay.getBoundingClientRect() : viewport.getBoundingClientRect();
const scale = Math.min(mediaRect.width / frameWidth, mediaRect.height / frameHeight);
const drawnWidth = frameWidth * scale;
const drawnHeight = frameHeight * scale;
return {
viewRect,
scale,
offsetX: documentMode ? 0 : (viewRect.width - drawnWidth) / 2,
offsetY: documentMode ? 0 : (viewRect.height - drawnHeight) / 2,
};
}
function render(items, frameWidth, frameHeight, textRegions = []) {
terms = items;
overlay.innerHTML = "";
activePopover = null;
currentDotIndex = 0;
const layout = geometry(frameWidth, frameHeight);
renderSelectionLayer(textRegions, layout);
renderMarkers(items, layout);
renderList(items);
}
function renderMarkers(items, layout) {
if (!items.length) return;
const sorted = [...items].sort((a, b) => b.confidence - a.confidence);
renderSingleMarker(sorted, layout);
}
function renderSingleMarker(sortedItems, layout) {
// Remove existing non-user markers
overlay.querySelectorAll(".term-marker:not(.user-triggered)").forEach(m => m.remove());
if (!sortedItems.length) return;
const item = sortedItems[currentDotIndex % sortedItems.length];
const [x1, y1, x2, y2] = item.bbox;
let left = layout.offsetX + x2 * layout.scale + 7;
let top = layout.offsetY + ((y1 + y2) / 2) * layout.scale - 12;
if (left > layout.viewRect.width - 34) left = layout.offsetX + x1 * layout.scale - 31;
left = clamp(left, 7, layout.viewRect.width - 31);
top = clamp(top, 46, layout.viewRect.height - 38);
const marker = document.createElement("button");
marker.className = "term-marker";
marker.type = "button";
marker.style.left = `${left}px`;
marker.style.top = `${top}px`;
marker.setAttribute("aria-label", `Explain ${item.term}`);
marker.innerHTML = `<span></span>`;
let dragged = false;
enableDrag(marker, layout, () => { dragged = true; });
marker.onclick = (event) => {
event.stopPropagation();
if (dragged) {
dragged = false;
return;
}
openPopover(item, marker, layout);
if (sortedItems.length > 1) {
currentDotIndex = (currentDotIndex + 1) % sortedItems.length;
setTimeout(() => {
marker.remove();
renderSingleMarker(sortedItems, layout);
}, 150);
}
};
overlay.appendChild(marker);
}
function enableDrag(marker, layout, onDrag) {
let start = null;
marker.addEventListener("pointerdown", (event) => {
start = {
x: event.clientX,
y: event.clientY,
left: parseFloat(marker.style.left),
top: parseFloat(marker.style.top),
moved: false,
};
marker.setPointerCapture?.(event.pointerId);
});
marker.addEventListener("pointermove", (event) => {
if (!start) return;
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
if (Math.hypot(dx, dy) > 3) {
start.moved = true;
onDrag();
}
marker.style.left = `${clamp(start.left + dx, 5, layout.viewRect.width - 23)}px`;
marker.style.top = `${clamp(start.top + dy, 5, layout.viewRect.height - 23)}px`;
});
const finish = () => { start = null; };
marker.addEventListener("pointerup", finish);
marker.addEventListener("pointercancel", finish);
}
function triggerDotAtText(event, region, layout) {
const selection = window.getSelection();
if (selection && !selection.isCollapsed && selection.toString().trim()) return;
overlay.querySelectorAll(".term-marker").forEach(m => m.remove());
const match = terms.find((item) =>
item.bbox?.every((value, index) => Math.abs(value - region.bbox[index]) < 2)
|| String(region.text).toLowerCase().includes(String(item.term).toLowerCase())
);
const item = match || {
term: String(region.text).trim().slice(0, 58),
definition: "Select this phrase for a contextual summary and business meaning.",
confidence: Math.min(.72, Number(region.confidence || .75) * .72),
source: "ai_generated_unverified",
source_label: "Document context · needs verification",
category: "Document Context",
related_terms: [],
document_types: [],
bbox: region.bbox,
};
const overlayRect = overlay.getBoundingClientRect();
const marker = document.createElement("button");
marker.className = "term-marker user-triggered";
marker.type = "button";
marker.style.left = `${clamp(event.clientX - overlayRect.left - 9, 5, layout.viewRect.width - 23)}px`;
marker.style.top = `${clamp(event.clientY - overlayRect.top - 9, 5, layout.viewRect.height - 23)}px`;
marker.setAttribute("aria-label", `Explain ${item.term}`);
marker.innerHTML = "<span></span>";
let dragged = false;
enableDrag(marker, layout, () => { dragged = true; });
marker.onclick = (clickEvent) => {
clickEvent.stopPropagation();
if (dragged) {
dragged = false;
return;
}
openPopover(item, marker, layout);
};
overlay.appendChild(marker);
}
function openPopover(item, marker, layout) {
closePopover();
marker.classList.add("active");
const popover = document.createElement("article");
popover.className = "term-popover";
const markerLeft = parseFloat(marker.style.left);
const markerTop = parseFloat(marker.style.top);
const width = Math.min(286, layout.viewRect.width - 24);
let left = markerLeft + 34;
if (left + width > layout.viewRect.width - 8) left = markerLeft - width - 8;
left = clamp(left, 8, layout.viewRect.width - width - 8);
const top = clamp(markerTop - 18, 50, layout.viewRect.height - 170);
popover.style.left = `${left}px`;
popover.style.top = `${top}px`;
popover.style.width = `${width}px`;
const confPercent = Math.round(item.confidence * 100);
const confClass = confPercent >= 90 ? 'conf-good' : confPercent >= 70 ? 'conf-neutral' : 'conf-bad';
popover.innerHTML = `<div class="popover-heading"><span class="callout-dot"></span><span class="callout-line"></span><strong>${escapeHtml(item.term)}</strong><small class="${confClass}">${confPercent}%</small></div><p>${escapeHtml(item.definition)}</p><button type="button">Open full meaning</button>`;
popover.querySelector("button").onclick = (event) => {
event.stopPropagation();
window.FalconFeedback.open(item);
};
popover.onclick = (event) => event.stopPropagation();
overlay.appendChild(popover);
activePopover = popover;
}
function closePopover() {
if (activePopover) activePopover.remove();
activePopover = null;
overlay?.querySelectorAll(".term-marker.active").forEach((marker) => marker.classList.remove("active"));
}
function renderSelectionLayer(regions, layout) {
if (!regions.length) return;
const layer = document.createElement("div");
layer.className = "selection-layer";
regions.slice(0, 80).forEach((region) => {
if (!region.text || !region.bbox) return;
const [x1, y1, x2, y2] = region.bbox;
const span = document.createElement("span");
span.className = "selectable-region";
span.textContent = region.text;
span.style.left = `${layout.offsetX + x1 * layout.scale}px`;
span.style.top = `${layout.offsetY + y1 * layout.scale}px`;
span.style.width = `${Math.max(8, (x2 - x1) * layout.scale)}px`;
span.style.height = `${Math.max(12, (y2 - y1) * layout.scale)}px`;
span.style.fontSize = `${clamp((y2 - y1) * layout.scale * 0.72, 8, 24)}px`;
span.addEventListener("click", (event) => triggerDotAtText(event, region, layout));
layer.appendChild(span);
});
const explainSelection = () => {
requestAnimationFrame(() => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !layer.contains(selection.anchorNode)) return;
const text = selection.toString().trim();
if (text) window.FalconInsights.explain(text);
});
};
layer.addEventListener("mouseup", explainSelection);
layer.addEventListener("touchend", explainSelection);
overlay.appendChild(layer);
}
function renderList(items) {
const list = document.querySelector("#termsList");
list.innerHTML = items.length
? '<div class="instruction-card"><strong>Insights are on the document</strong><p>Tap a dot for meaning, drag it to reveal text, or select multiple words for business context.</p></div>'
: '<div class="empty-result">No readable text was detected. Try a sharper image or another page.</div>';
}
function clear() {
terms = [];
activePopover = null;
if (overlay) overlay.innerHTML = "";
}
function clamp(value, minimum, maximum) {
return Math.min(Math.max(value, minimum), Math.max(minimum, maximum));
}
function escapeHtml(value) {
return String(value).replace(/[&<>'"]/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;",
})[character]);
}
return { init, render, clear };
})();