// OpenDesign web clipper on-page UI.
//
// Three on-page surfaces, all isolated in their own Shadow DOM so page CSS
// can't bleed in (and the extension's own nodes are excluded from captures):
//
// 1. A floating launcher toolbar (page / design system / figma / shot / images / element),
// led by the OpenDesign brand mark. HIDDEN by default — turned on from
// the popup; the preference is remembered. A grip handle on its leading
// edge drags it anywhere on the page, and the resting spot is remembered.
// 2. A DevTools-style element picker: hover to highlight, click to capture
// that element as a cropped screenshot + its outerHTML + metadata.
// 3. An image multi-select overlay: pick exactly which page images to save,
// instead of grabbing them all.
//
// A shared toast (its own host, independent of the toolbar's visibility) is the
// single feedback channel. Captures route through the service worker.
(function () {
if (window.__odClipperInjected) return;
const I18N = globalThis.OD_CLIPPER_I18N;
const locale = I18N?.currentLocale ? I18N.currentLocale() : 'en';
const t = (key, vars) => (I18N?.t ? I18N.t(key, vars, locale) : key);
const esc = (value) => String(value ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
// Trusted-Types-safe HTML insertion. Pages that send
// `require-trusted-types-for 'script'` in their CSP (The Economist and many
// publishers do) make a plain `el.innerHTML = string` THROW — even here in the
// content script's isolated world, because we share the host page's DOM and
// its CSP governs every DOM sink. That throw used to abort this whole script
// right after the injection guard was set, leaving the page flagged but with
// no message listener: the popup then reported "OpenDesign hasn't attached to
// this page yet" and neither a reload nor the popup's "Refresh page" could
// recover it. DOMParser is NOT a Trusted Types sink, so parse the markup in a
// detached document and move the nodes in. `
`);
document.documentElement.appendChild(toastHost);
const toastEl = toastShadow.getElementById('t');
let toastTimer = null;
// A loading toast (opts.loading) shows a spinner and STAYS until the next
// toast() call replaces it with the success/failure result — so every
// operation keeps a visible "working…" state through to its outcome.
function toast(text, opts) {
const loading = Boolean(opts && opts.loading);
toastHost.style.display = '';
toastEl.textContent = text;
toastEl.classList.add('show');
toastEl.classList.toggle('loading', loading);
if (toastTimer) clearTimeout(toastTimer);
if (!loading) {
toastTimer = setTimeout(() => {
toastEl.classList.remove('show');
toastHost.style.display = 'none';
}, 2800);
}
}
// --- launcher toolbar ----------------------------------------------------
const host = document.createElement('div');
host.id = 'od-clipper-root';
// Ships anchored TOP-CENTER with a gap from the edge (Figma-style) so the bar
// reads as a deliberate, prominent surface rather than a corner afterthought.
// `left:50%` + `translateX(-50%)` keeps it centered through window resizes; the
// first drag swaps to explicit left/top px and clears the transform (see
// applyPosition).
host.style.cssText = 'position:fixed;z-index:2147483646;top:24px;left:50%;transform:translateX(-50%);display:none;background:transparent;margin:0;padding:0;border:0;';
const shadow = host.attachShadow({ mode: 'open' });
const ICON = {
page: '',
system: '',
figma: '',
shot: '',
region: '',
imgs: '',
element: '',
close: '',
};
// `data-tip` drives the custom on-hover tooltip below; `aria-label` keeps the
// control named for assistive tech. We deliberately drop the native `title`
// so there's no slow, duplicate OS tooltip stacked on ours.
const btn = (act, title) =>
``;
setHTML(shadow, `
`);
document.documentElement.appendChild(host);
let toolbarVisible = false;
let imageHoverEnabled = true; // default-on per-image hover capture badge
let hiddenForCapture = false;
let savedPos = null; // {left, top} in px, set once the user drags the bar
function applyVisibility() {
const show = toolbarVisible && !hiddenForCapture;
host.style.display = show ? '' : 'none';
// A position saved while the window was a different size (or while hidden,
// when the bar had no measurable box) can land off-screen — re-clamp the
// moment it becomes visible so it's always reachable.
if (show && savedPos) applyPosition(clampToViewport(savedPos.left, savedPos.top));
}
function setVisible(visible, persist) {
toolbarVisible = Boolean(visible);
applyVisibility();
if (persist) {
try {
chrome.storage.local.set({ toolbarVisible });
} catch {
// storage may be unavailable in some embeds; visibility still applies
}
}
}
try {
chrome.storage.local.get(['toolbarVisible', 'imageHoverCapture', 'toolbarPos']).then(
(s) => {
if (s && typeof s.imageHoverCapture === 'boolean') imageHoverEnabled = s.imageHoverCapture;
const p = s && s.toolbarPos;
if (p && typeof p.left === 'number' && typeof p.top === 'number') {
savedPos = p;
applyPosition(p); // applyVisibility re-clamps to the live viewport on show
}
setVisible(Boolean(s && s.toolbarVisible), false);
},
() => {},
);
} catch {
// ignore — stays hidden, hover capture stays on
}
// Delegated per-image hover affordance: one pair of document listeners, gated
// by `imageHoverEnabled` inside the handler so toggling needs no re-binding.
document.addEventListener('mouseover', onDocOver, true);
document.addEventListener('scroll', onDocScroll, true);
// Toolbar actions sent straight to the worker (no on-page UI of their own).
const WORKER_ACTIONS = {
shot: 'captureScreenshot',
page: 'capturePageToLibrary',
system: 'captureDesignSystemToLibrary',
figma: 'downloadFigma',
};
shadow.querySelectorAll('button[data-act]').forEach((btn) => {
btn.addEventListener('click', async () => {
const act = btn.dataset.act;
if (act === 'close') return setVisible(false, true);
if (act === 'imgs') return void pickImages();
if (act === 'element') return void startElementPick();
if (act === 'region') return void startRegionPick();
const type = WORKER_ACTIONS[act];
if (!type) return;
// The progress strip stays on the bar for the whole operation. page /
// system / figma are DOM/IR snapshots that exclude our UI by id, so the
// bar never leaves the screen; only `shot` (a pixel screenshot) is briefly
// pulled out for the capture frame, then comes right back with the strip.
// The result toast is the outcome.
startBusy(act);
let res;
try {
res = await chrome.runtime.sendMessage({ type });
} catch {
stopBusy();
toast(t('extensionErrorReload'));
return;
}
stopBusy();
if (!res || !res.ok) {
toast(res && res.error === 'not running'
? t('openDesignStartApp')
: t('failed', { error: (res && res.error) || t('unknown') }));
return;
}
if (type === 'capturePageToLibrary') {
if (res.deduped) toast(t('pageAlreadyInLibrary'));
else {
// Reduced-fidelity fallback for very large pages: note when some
// images were left as live links so the save isn't misread as full.
const base = res.hasFigma ? t('savedPageFigmaShort') : t('savedPageShort');
toast(res.partialImages ? `${base} (${t('someImagesLeftLinks')})` : base);
}
} else if (type === 'captureDesignSystemToLibrary') {
toast(res.deduped ? t('designSystemAlreadyInLibrary', { suffix: '' }) : t('savedDesignSystemShort'));
} else if (type === 'downloadFigma') toast(t('figmaDownloaded'));
else toast(res.deduped ? t('alreadyInLibrary') : t('savedScreenshot'));
});
});
// --- drag-to-move --------------------------------------------------------
// The bar ships anchored bottom-right (right/bottom). The grip handle lets the
// user reposition it: on the first drag we flip to left/top anchoring, follow
// the pointer (clamped inside the viewport), and persist the resting spot so
// it survives reloads and navigations. A dedicated handle — rather than a
// drag-anywhere bar — keeps every action button a plain click with no
// move-vs-click threshold to get wrong.
const grip = shadow.querySelector('.grip');
const bar = shadow.querySelector('.bar');
const busyTitleEl = shadow.querySelector('.busy-title');
const busySubEl = shadow.querySelector('.busy-sub');
let dragging = false;
// Geometry is captured ONCE per drag (pointerdown) so pointermove never forces
// a synchronous layout read; motion is a compositor-only transform flushed in
// one rAF per frame, and the resting spot is committed to left/top on release.
let pointerStartX = 0, pointerStartY = 0;
let dragOriginLeft = 0, dragOriginTop = 0, dragMaxLeft = 0, dragMaxTop = 0;
let dragDX = 0, dragDY = 0, dragRaf = 0;
// --- busy / progress state ----------------------------------------------
// A capture is a single round-trip to the worker, so we can't read true
// progress — but a silent spinner breeds wait-anxiety. Instead each operation
// ships a short, localized SCRIPT of steps with rough timings: the label
// advances on a timer ("Reading styles…" → "Building asset…" → "Saving…"),
// the sub-line shows the step counter and a rough ETA, and once the wait
// crosses the expected budget the sub-line switches to a reassuring "still
// working, hang tight" message (busy-slow). The grip + close stay live the
// whole time so the bar can be moved or dismissed mid-capture.
let busyTimers = [];
const BUSY_PLANS = {
page: { eta: 8, slowAt: 14000, steps: [
{ key: 'busyPageSnapshot', at: 0 },
{ key: 'busyPageInline', at: 1800 },
{ key: 'busyPageSaving', at: 6000 },
] },
system: { eta: 12, slowAt: 20000, steps: [
{ key: 'busySystemReading', at: 0 },
{ key: 'busySystemExtract', at: 2500 },
{ key: 'busySystemBuilding', at: 9000 },
{ key: 'busySystemSaving', at: 15000 },
] },
figma: { eta: 10, slowAt: 16000, steps: [
{ key: 'busyFigmaReading', at: 0 },
{ key: 'busyFigmaBuilding', at: 2200 },
{ key: 'busyFigmaPreparing', at: 8000 },
] },
shot: { eta: 4, slowAt: 8000, steps: [
{ key: 'busyShotCapturing', at: 0 },
{ key: 'busyShotSaving', at: 1600 },
] },
region: { eta: 4, slowAt: 8000, steps: [
{ key: 'busyRegionCapturing', at: 0 },
{ key: 'busyRegionSaving', at: 1600 },
] },
element: { eta: 5, slowAt: 9000, steps: [
{ key: 'busyElementCapturing', at: 0 },
{ key: 'busyElementSaving', at: 1600 },
] },
imgs: { eta: 6, slowAt: 11000, steps: [
{ key: 'busyImagesDownloading', at: 0 },
{ key: 'busyImagesSaving', at: 3000 },
] },
};
function clearBusyTimers() {
busyTimers.forEach((id) => clearTimeout(id));
busyTimers = [];
}
// The busy strip widens the bar; if the user had dragged it near the right
// edge, re-clamp so it can't grow off-screen (and tuck back on stop). The
// widening is transient, so we preserve the user's chosen resting spot —
// applyPosition mutates savedPos, so snapshot and restore it around the call.
function reclampIfMoved() {
if (!savedPos || host.style.display === 'none') return;
const keep = savedPos;
applyPosition(clampToViewport(savedPos.left, savedPos.top));
savedPos = keep;
}
function paintBusyStep(plan, idx, vars) {
busyTitleEl.textContent = t(plan.steps[idx].key, vars);
busySubEl.textContent =
t('busyStepOf', { step: idx + 1, total: plan.steps.length }) +
' · ' + t('busyAbout', { sec: plan.eta });
}
function startBusy(opKey, vars) {
const plan = BUSY_PLANS[opKey] || BUSY_PLANS.shot;
clearBusyTimers();
bar.classList.remove('busy-slow');
paintBusyStep(plan, 0, vars);
bar.classList.add('busy');
reclampIfMoved();
// Popup-launched captures can run while the on-page bar is hidden, where the
// in-bar strip is invisible — mirror the first step as a loading toast so
// progress is never lost. The result toast replaces it on completion.
if (!toolbarVisible) toast(t(plan.steps[0].key, vars), { loading: true });
for (let i = 1; i < plan.steps.length; i++) {
busyTimers.push(setTimeout(() => paintBusyStep(plan, i, vars), plan.steps[i].at));
}
busyTimers.push(setTimeout(() => {
bar.classList.add('busy-slow');
busySubEl.textContent = t('busyTakingLonger');
}, plan.slowAt));
}
function stopBusy() {
clearBusyTimers();
bar.classList.remove('busy-slow', 'busy');
reclampIfMoved();
}
// Does the bar's on-screen box intersect a capture rect (viewport coords)?
// Cropped captures (element/region) only need the bar pulled out of frame when
// it would actually land in the crop; otherwise it stays put with its spinner
// and never blinks. Both rects are viewport-relative (getBoundingClientRect).
function barOverlapsRect(r) {
if (!toolbarVisible || hiddenForCapture) return false;
const b = host.getBoundingClientRect();
if (!b.width || !b.height) return false;
return b.left < r.x + r.width && b.right > r.x && b.top < r.y + r.height && b.bottom > r.y;
}
function clampToViewport(left, top) {
const rect = host.getBoundingClientRect();
const maxLeft = Math.max(4, window.innerWidth - rect.width - 4);
const maxTop = Math.max(4, window.innerHeight - rect.height - 4);
return {
left: Math.min(Math.max(4, left), maxLeft),
top: Math.min(Math.max(4, top), maxTop),
};
}
function applyPosition(pos) {
host.style.right = 'auto';
host.style.bottom = 'auto';
// Drop the top-center centering transform so left/top are honored literally.
host.style.transform = 'none';
host.style.left = `${pos.left}px`;
host.style.top = `${pos.top}px`;
savedPos = pos;
}
// One transform write per frame: pointermove only records the (already
// clamped) delta and asks for a frame; the rAF callback is the sole place that
// touches the DOM, so a burst of move events collapses into a single paint.
function flushDrag() {
dragRaf = 0;
host.style.transform = `translate3d(${dragDX}px, ${dragDY}px, 0)`;
}
grip.addEventListener('pointerdown', (e) => {
e.preventDefault();
// The one and only layout read of the drag: cache the bar's box and the
// viewport clamp bounds so every subsequent move is pure arithmetic.
const rect = host.getBoundingClientRect();
dragOriginLeft = rect.left;
dragOriginTop = rect.top;
dragMaxLeft = Math.max(4, window.innerWidth - rect.width - 4);
dragMaxTop = Math.max(4, window.innerHeight - rect.height - 4);
pointerStartX = e.clientX;
pointerStartY = e.clientY;
dragDX = 0;
dragDY = 0;
// Anchor to explicit left/top and promote the bar to its own layer; from
// here the drag is a pure translate, so no layout runs per move.
host.style.right = 'auto';
host.style.bottom = 'auto';
host.style.left = `${dragOriginLeft}px`;
host.style.top = `${dragOriginTop}px`;
host.style.transform = 'translate3d(0,0,0)';
dragging = true;
bar.classList.add('dragging');
try { grip.setPointerCapture(e.pointerId); } catch { /* capture is best-effort */ }
});
grip.addEventListener('pointermove', (e) => {
if (!dragging) return;
const left = Math.min(Math.max(4, dragOriginLeft + (e.clientX - pointerStartX)), dragMaxLeft);
const top = Math.min(Math.max(4, dragOriginTop + (e.clientY - pointerStartY)), dragMaxTop);
dragDX = left - dragOriginLeft;
dragDY = top - dragOriginTop;
if (!dragRaf) dragRaf = requestAnimationFrame(flushDrag);
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
bar.classList.remove('dragging');
if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = 0; }
try { grip.releasePointerCapture(e.pointerId); } catch { /* may already be released */ }
// Bake the compositor transform back into a real left/top resting spot.
applyPosition(clampToViewport(dragOriginLeft + dragDX, dragOriginTop + dragDY));
if (savedPos) {
try {
chrome.storage.local.set({ toolbarPos: savedPos });
} catch {
// storage may be unavailable in some embeds; the move still applies
}
}
}
grip.addEventListener('pointerup', endDrag);
grip.addEventListener('pointercancel', endDrag);
// Keep a moved bar on-screen after the window shrinks.
window.addEventListener('resize', () => {
if (!savedPos || host.style.display === 'none') return;
applyPosition(clampToViewport(savedPos.left, savedPos.top));
});
// --- element picker (Figma-style: hover highlight + Capture button) ------
// A full-viewport BLOCKING surface (pointer-events:auto) sits above the page
// and swallows every mouse event, so the page underneath never navigates or
// reacts while picking — the previous pointer-events:none design let clicks
// fall through, so links/cards activated instead of being captured. We
// hit-test the element under the cursor with elementsFromPoint (skipping our
// own UI) and draw a highlight box that glides to it, with a label and a
// floating "Capture" pill. Clicking the element OR the pill captures it;
// Esc / the ✕ cancels. Wheel is forwarded to the page so off-screen elements
// stay reachable.
let pickActive = false;
let pickHost = null;
let pickShadow = null;
let pickSurface = null;
let boxEl = null;
let labelEl = null;
let currentTarget = null;
let lastPointerX = 0;
let lastPointerY = 0;
function buildPicker() {
if (pickHost) return;
pickHost = document.createElement('div');
pickHost.id = 'od-clipper-picker';
// `background:transparent` defends the full-viewport host against page CSS
// (e.g. `div { background: … }`) bleeding a tint across the whole screen.
// The host itself stays hit-testable (no pointer-events:none) so the inner
// `.surface` can swallow the page's clicks.
pickHost.style.cssText = 'position:fixed;inset:0;z-index:2147483646;background:transparent;margin:0;padding:0;border:0;';
pickShadow = pickHost.attachShadow({ mode: 'open' });
setHTML(pickShadow, `
`);
boxEl = pickShadow.getElementById('box');
labelEl = pickShadow.getElementById('label');
document.documentElement.appendChild(pickHost);
// The surface owns the picking interaction. Listeners live on it (not the
// page) so the page never sees these events; removing the host on teardown
// takes the listeners with it.
pickSurface = pickShadow.getElementById('surface');
pickSurface.addEventListener('mousemove', onMove);
pickSurface.addEventListener('mousedown', (e) => e.preventDefault()); // no caret/selection flash
pickSurface.addEventListener('click', onClick);
pickSurface.addEventListener('wheel', onWheel, { passive: false });
pickShadow.getElementById('chip').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
void commitCapture(currentTarget);
});
const cancel = pickShadow.querySelector('[data-act="cancel"]');
if (cancel) cancel.addEventListener('click', () => endElementPick());
}
// Our blocking surface is the topmost element at the cursor, so a plain
// elementFromPoint would always return our own UI. Walk the full hit stack and
// return the first element that isn't ours (our shadow hosts surface as the
// light-DOM host id `od-clipper-*`).
function isOwnClipperNode(el) {
if (el === pickHost) return true;
if (typeof el.id === 'string' && el.id.startsWith('od-clipper-')) return true;
return Boolean(el.closest && el.closest('[id^="od-clipper-"]'));
}
function targetAt(x, y) {
const stack = typeof document.elementsFromPoint === 'function'
? document.elementsFromPoint(x, y)
: [document.elementFromPoint(x, y)];
for (const el of stack) {
if (!el || isOwnClipperNode(el)) continue; // skip our own surface / overlays
return el;
}
return null;
}
function positionBox(el) {
const r = el.getBoundingClientRect();
boxEl.classList.add('show');
boxEl.style.left = `${r.left}px`;
boxEl.style.top = `${r.top}px`;
boxEl.style.width = `${Math.max(0, r.width)}px`;
boxEl.style.height = `${Math.max(0, r.height)}px`;
labelEl.textContent = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''} · ${Math.round(r.width)}×${Math.round(r.height)}`;
}
// Re-aim the highlight at the last known cursor position. Used on hover and
// after a scroll shifts what's under the (stationary) cursor.
function refreshTarget() {
const el = targetAt(lastPointerX, lastPointerY);
if (!el) return; // over our own UI (chip / hintbar) or nothing → keep the lock
currentTarget = el;
if (boxEl) positionBox(el);
}
function onMove(e) {
lastPointerX = e.clientX;
lastPointerY = e.clientY;
refreshTarget();
}
function onClick(e) {
// The surface already blocks the page; just resolve the target and capture.
e.preventDefault();
e.stopPropagation();
const el = targetAt(e.clientX, e.clientY) || currentTarget;
if (el) void commitCapture(el);
}
function onWheel(e) {
// The surface eats native scroll, so forward it to the page; off-screen
// elements stay reachable. onPickScroll re-aims the highlight afterwards.
e.preventDefault();
window.scrollBy(e.deltaX, e.deltaY);
}
function onPickScroll() {
refreshTarget();
}
function onKey(e) {
if (e.key === 'Escape') {
e.preventDefault();
endElementPick();
toast(t('elementPickCancelled'));
}
}
function cssPath(el) {
if (el.id) return `${el.tagName.toLowerCase()}#${el.id}`;
let sel = el.tagName.toLowerCase();
const cls = Array.from(el.classList)
.filter((c) => !c.startsWith('od-clipper'))
.slice(0, 2);
if (cls.length) sel += `.${cls.join('.')}`;
const parent = el.parentElement;
if (parent) {
const sibs = Array.from(parent.children).filter((c) => c.tagName === el.tagName);
if (sibs.length > 1) sel += `:nth-of-type(${sibs.indexOf(el) + 1})`;
}
return sel;
}
function describeElement(el, rect) {
const meta = {
tag: el.tagName.toLowerCase(),
classes: Array.from(el.classList).filter((c) => !c.startsWith('od-clipper')).slice(0, 10),
selector: cssPath(el),
width: Math.round(rect.width),
height: Math.round(rect.height),
hasHtml: true,
};
if (el.id) meta.id = el.id;
const text = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 200);
if (text) meta.text = text;
return meta;
}
// Capture the picked element as ONE self-contained HTML asset (styles + its
// own images inlined) — no screenshot, no separate markup, so the saved card
// is a single HTML file that opens and shares as the element. We mark the live
// element so capture.js (run by the worker) can find and prune the page to it,
// and ALWAYS clear the marker afterwards so we never leave a stray attribute
// on the page. The bar keeps its progress strip through the save (capture.js
// strips our own UI, so nothing needs to leave the frame).
const ELEMENT_MARKER = 'data-od-clip-target';
async function commitCapture(el) {
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) {
toast(t('elementNoVisibleSize'));
return;
}
const meta = describeElement(el, rect);
el.setAttribute(ELEMENT_MARKER, '');
endElementPick();
startBusy('element');
let res;
try {
res = await chrome.runtime.sendMessage({
type: 'captureElementHtml',
meta,
sourceUrl: location.href,
sourceTitle: document.title,
});
} catch (err) {
console.warn('[OpenDesign] element capture failed', err);
stopBusy();
toast(t('extensionErrorReload'));
return;
} finally {
el.removeAttribute(ELEMENT_MARKER);
}
stopBusy();
if (!res || !res.ok) {
toast(res && res.error === 'not running'
? t('openDesignStartApp')
: t('failed', { error: (res && res.error) || t('unknown') }));
return;
}
toast(res.deduped ? t('elementAlreadyInLibrary') : t('elementSaved'));
}
function startElementPick() {
if (pickActive) return;
endRegionPick(); // on-page picking surfaces are mutually exclusive
pickActive = true;
currentTarget = null;
buildPicker(); // wires mouse/wheel listeners on the surface
// Esc to cancel; capture-phase scroll so the highlight tracks the page as it
// moves under a stationary cursor.
document.addEventListener('keydown', onKey, true);
document.addEventListener('scroll', onPickScroll, true);
}
function endElementPick() {
pickActive = false;
currentTarget = null;
document.removeEventListener('keydown', onKey, true);
document.removeEventListener('scroll', onPickScroll, true);
if (pickHost) {
pickHost.remove(); // also drops the surface's mouse/wheel listeners
pickHost = null;
pickShadow = null;
pickSurface = null;
boxEl = null;
labelEl = null;
}
}
// --- image multi-select overlay ------------------------------------------
// Collect every picture-worthy image on the page, keeping a reference to the
// DOM element each one came from so the picker can scroll-to / highlight the
// source ("where is this from?"). Two sources, since a picker that only walks
// `document.images` silently misses everything painted as a CSS background
// (hero banners, section art, avatars): (which already resolves
// /srcset via currentSrc) and computed background-image url()s.
const MAX_PICKER_IMAGES = 300;
function collectImagesInPage() {
const out = [];
const seen = new Set();
const add = (rawSrc, alt, w, h, el) => {
if (out.length >= MAX_PICKER_IMAGES) return;
let src = rawSrc;
try {
src = new URL(rawSrc, location.href).href;
} catch {
return;
}
if (!src || seen.has(src) || !/^https?:/i.test(src)) return;
seen.add(src);
out.push({ src, alt: alt || '', w: w || 0, h: h || 0, el });
};
for (const el of document.images) {
const src = el.currentSrc || el.src;
if (!src) continue;
// Prefer decoded natural size; fall back to the laid-out box so lazy
// images that haven't decoded yet still pass the size gate and show.
let w = el.naturalWidth || 0;
let h = el.naturalHeight || 0;
if (!w || !h) {
const r = el.getBoundingClientRect();
w = Math.round(r.width);
h = Math.round(r.height);
}
if (Math.max(w, h) < 32) continue;
add(src, el.alt, w, h, el);
}
// Background images. getComputedStyle is the costly call, so gate on the
// (cheaper) box size first and stop once we've hit the cap.
const all = document.body ? document.body.getElementsByTagName('*') : [];
for (let i = 0; i < all.length && out.length < MAX_PICKER_IMAGES; i += 1) {
const el = all[i];
if (el.id && el.id.startsWith('od-clipper-')) continue;
const r = el.getBoundingClientRect();
if (Math.max(r.width, r.height) < 64) continue; // skip sprite/icon chrome
const bg = getComputedStyle(el).backgroundImage;
if (!bg || bg === 'none' || bg.indexOf('url(') === -1) continue;
const m = /url\(\s*['"]?([^'")]+)['"]?\s*\)/i.exec(bg);
if (m) add(m[1], el.getAttribute('aria-label') || '', Math.round(r.width), Math.round(r.height), el);
}
return out;
}
let imagePickerHost = null;
let locateCleanup = null; // tears down an in-progress "find on page" highlight
function clearLocate() {
if (locateCleanup) {
locateCleanup();
locateCleanup = null;
}
}
function closeImagePicker() {
clearLocate();
if (imagePickerHost) {
imagePickerHost.remove();
imagePickerHost = null;
}
}
// Draw a page's already-decoded into a small canvas for use as the grid
// thumbnail. The browser holds the source bitmap once (it's on the page);
// this keeps only a ~116px copy instead of a second full-res decode per cell —
// the difference between ~24K and ~2M pixels for a 1920px hero image. Drawing
// a cross-origin image taints the canvas, but we only ever *display* it (never
// read pixels back), so that's fine.
function drawThumbCanvas(srcEl, w, h) {
try {
const maxSide = 116 * Math.min(2, window.devicePixelRatio || 1);
const scale = Math.min(1, maxSide / Math.max(w, h));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(w * scale));
canvas.height = Math.max(1, Math.round(h * scale));
canvas.getContext('2d').drawImage(srcEl, 0, 0, canvas.width, canvas.height);
return canvas;
} catch {
return null; // tainted-source draw failure → caller falls back to
}
}
// Build a cell's thumbnail node. Two cheap paths, no extra full-res decode:
// • The image is already a decoded on the page → draw it once into a
// tiny canvas (instant, no network, ~116px of memory).
// • Otherwise (CSS background, not-yet-decoded ) → a real with
// native loading="lazy", so off-screen cells defer their fetch and the
// browser cache usually serves it (the page already loaded it).
// Returning the node directly (rather than mutating a cell via an observer)
// keeps rendering synchronous and reliable — nothing can get stuck pending.
function makeThumb(item) {
const el = item.el;
if (el && el.tagName === 'IMG' && el.complete && el.naturalWidth > 0) {
const canvas = drawThumbCanvas(el, el.naturalWidth, el.naturalHeight);
if (canvas) {
canvas.className = 'thumb';
return canvas;
}
}
const img = document.createElement('img');
img.className = 'thumb shim'; // shimmer until the bytes arrive
img.decoding = 'async';
img.loading = 'lazy';
img.referrerPolicy = 'no-referrer';
img.addEventListener('load', () => img.classList.remove('shim'), { once: true });
img.addEventListener('error', () => img.classList.remove('shim'), { once: true });
img.src = item.src;
return img;
}
// "Find on page": dim the picker, smooth-scroll the source element into view,
// and track it with a pulsing spotlight ring through the scroll (rAF re-reads
// the rect each frame so it stays glued to the element). Restores the picker
// after a beat. Lets the user see exactly where a thumbnail came from.
function locateImage(el) {
if (!el || !el.isConnected) {
toast('That image’s source is no longer on the page');
return;
}
clearLocate();
if (imagePickerHost) {
imagePickerHost.style.transition = 'opacity 140ms cubic-bezier(0.23,1,0.32,1)';
imagePickerHost.style.opacity = '0';
imagePickerHost.style.pointerEvents = 'none';
}
const host = document.createElement('div');
host.id = 'od-clipper-locate';
host.style.cssText =
'position:fixed;inset:0;z-index:2147483645;pointer-events:none;margin:0;padding:0;border:0;background:transparent;';
const sh = host.attachShadow({ mode: 'open' });
setHTML(
sh,
``,
);
document.documentElement.appendChild(host);
const ring = sh.getElementById('ring');
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
let raf = 0;
const sync = () => {
const r = el.getBoundingClientRect();
ring.style.left = `${r.left}px`;
ring.style.top = `${r.top}px`;
ring.style.width = `${r.width}px`;
ring.style.height = `${r.height}px`;
raf = requestAnimationFrame(sync);
};
sync();
const timer = setTimeout(() => clearLocate(), 1800);
locateCleanup = () => {
cancelAnimationFrame(raf);
clearTimeout(timer);
host.remove();
if (imagePickerHost) {
imagePickerHost.style.opacity = '1';
imagePickerHost.style.pointerEvents = '';
}
};
}
function pickImages() {
closeImagePicker();
const images = collectImagesInPage();
if (!images.length) {
toast(t('noImagesFound'));
return;
}
imagePickerHost = document.createElement('div');
imagePickerHost.id = 'od-clipper-imagepicker';
imagePickerHost.style.cssText = 'position:fixed;inset:0;z-index:2147483646;background:transparent;margin:0;padding:0;border:0;';
const sh = imagePickerHost.attachShadow({ mode: 'open' });
setHTML(sh, `
`);
document.documentElement.appendChild(regionHost);
regionRectEl = regionShadow.getElementById('sel');
const sizeEl = regionShadow.getElementById('size');
const surface = regionShadow.getElementById('surface');
const place = (a, b) => {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
const w = Math.abs(a.x - b.x);
const h = Math.abs(a.y - b.y);
regionRectEl.style.display = 'block';
regionRectEl.style.left = `${x}px`;
regionRectEl.style.top = `${y}px`;
regionRectEl.style.width = `${w}px`;
regionRectEl.style.height = `${h}px`;
sizeEl.textContent = `${Math.round(w)} × ${Math.round(h)}`;
return { x, y, w, h };
};
surface.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
regionStart = { x: e.clientX, y: e.clientY };
place(regionStart, regionStart);
});
surface.addEventListener('mousemove', (e) => {
if (regionStart) place(regionStart, { x: e.clientX, y: e.clientY });
});
surface.addEventListener('mouseup', (e) => {
if (!regionStart) return;
const r = place(regionStart, { x: e.clientX, y: e.clientY });
regionStart = null;
if (r.w < 6 || r.h < 6) {
toast(t('regionTooSmall'));
return;
}
void commitRegionCapture(r);
});
document.addEventListener('keydown', onRegionKey, true);
}
function onRegionKey(e) {
if (e.key === 'Escape') {
e.preventDefault();
endRegionPick();
toast(t('regionCancelled'));
}
}
function endRegionPick() {
regionActive = false;
regionStart = null;
document.removeEventListener('keydown', onRegionKey, true);
if (regionHost) {
regionHost.remove();
regionHost = null;
regionShadow = null;
regionRectEl = null;
}
}
async function commitRegionCapture(r) {
const payloadRect = { x: r.x, y: r.y, width: r.w, height: r.h };
endRegionPick();
startBusy('region');
const hideBar = barOverlapsRect(payloadRect);
// Two frames so our torn-down picker surface is off the compositor before
// the worker screenshots.
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
let res;
try {
res = await chrome.runtime.sendMessage({
type: 'captureRegion',
rect: payloadRect,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
dpr: window.devicePixelRatio || 1,
sourceUrl: location.href,
sourceTitle: document.title,
hideBar,
});
} catch {
stopBusy();
toast(t('extensionErrorReload'));
return;
}
stopBusy();
if (!res || !res.ok) {
toast(res && res.error === 'not running'
? t('openDesignStartApp')
: t('failed', { error: (res && res.error) || t('unknown') }));
return;
}
toast(res.deduped ? t('regionAlreadyInLibrary') : t('regionSaved'));
}
// --- per-image hover capture badge ---------------------------------------
// A single delegated badge that follows the cursor over images, so any image
// can be clipped in one click. One document-level listener (not per-image)
// keeps it cheap on image-heavy pages. On by default; toggled from the popup.
const IMG_HOVER_MIN = 48; // px — ignore icons/sprites smaller than this
let imgBadgeHost = null;
let imgBadgeBtn = null;
let hoveredImg = null;
let badgeHideTimer = null;
function buildImageBadge() {
if (imgBadgeHost) return;
imgBadgeHost = document.createElement('div');
imgBadgeHost.id = 'od-clipper-imghover';
imgBadgeHost.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483645;display:none;background:transparent;margin:0;padding:0;border:0;pointer-events:none;';
const sh = imgBadgeHost.attachShadow({ mode: 'open' });
setHTML(sh, `
${esc(t('saveImageToOpenDesign'))}
`);
document.documentElement.appendChild(imgBadgeHost);
imgBadgeBtn = sh.getElementById('b');
// The pointer crosses a 1-frame gap between leaving the image and reaching
// the badge; keep it alive while the cursor is on the button.
imgBadgeBtn.addEventListener('mouseenter', () => {
if (badgeHideTimer) clearTimeout(badgeHideTimer);
});
imgBadgeBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const img = hoveredImg;
const src = img && (img.currentSrc || img.src);
if (!src) return;
hideImageBadge();
// The badge vanishes on click, so the toast is the only feedback — keep it
// a spinner that holds until the result replaces it.
toast(t('savingImage'), { loading: true });
let res;
try {
res = await chrome.runtime.sendMessage({
type: 'ingestImages',
images: [{ src, alt: img.alt || '' }],
sourceUrl: location.href,
sourceTitle: document.title,
});
} catch {
toast(t('extensionErrorReload'));
return;
}
if (!res || !res.ok) {
toast(res && res.error === 'not running'
? t('openDesignStartApp')
: t('failed', { error: (res && res.error) || t('unknown') }));
return;
}
toast(res.count ? t('imageSaved') : t('imageSaveFailed'));
});
}
function positionImageBadge(img) {
const r = img.getBoundingClientRect();
if (r.width < IMG_HOVER_MIN || r.height < IMG_HOVER_MIN) {
hideImageBadge();
return;
}
buildImageBadge();
hoveredImg = img;
if (badgeHideTimer) clearTimeout(badgeHideTimer);
imgBadgeHost.style.display = '';
// Inset into the image's top-right corner; clamp into the viewport.
const top = Math.max(4, Math.round(r.top + 6));
const left = Math.min(window.innerWidth - 30, Math.round(r.right - 32));
imgBadgeHost.style.transform = `translate(${left}px, ${top}px)`;
}
function hideImageBadge() {
hoveredImg = null;
if (imgBadgeHost) imgBadgeHost.style.display = 'none';
}
function scheduleHideBadge() {
if (badgeHideTimer) clearTimeout(badgeHideTimer);
badgeHideTimer = setTimeout(hideImageBadge, 140);
}
function setImageHover(enabled, persist) {
imageHoverEnabled = Boolean(enabled);
if (!imageHoverEnabled) hideImageBadge();
if (persist) {
try {
chrome.storage.local.set({ imageHoverCapture: imageHoverEnabled });
} catch {
// storage unavailable — runtime state still applies
}
}
}
function onDocOver(e) {
if (!imageHoverEnabled || hiddenForCapture) return;
const t = e.target;
// Retargeting collapses shadow children to their host, so hovering our own
// badge surfaces here as the `od-clipper-imghover` host → keep it alive.
if (!t || (typeof t.id === 'string' && t.id.startsWith('od-clipper-'))) return;
if (t.tagName === 'IMG') {
positionImageBadge(t);
} else {
scheduleHideBadge();
}
}
function onDocScroll() {
// Badge positions are viewport-fixed; a scroll invalidates them. Hide and
// let the next hover re-place the badge.
if (hoveredImg) hideImageBadge();
}
// --- messages from the popup / service worker ----------------------------
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
switch (msg && msg.type) {
case 'odClipper:setToolbar':
setVisible(msg.mode === 'toggle' ? !toolbarVisible : msg.visible, true);
sendResponse({ ok: true, visible: toolbarVisible });
return false;
case 'odClipper:getToolbar':
sendResponse({ ok: true, visible: toolbarVisible });
return false;
case 'odClipper:pickElement':
startElementPick();
sendResponse({ ok: true });
return false;
case 'odClipper:pickImages':
pickImages();
sendResponse({ ok: true });
return false;
case 'odClipper:pickRegion':
startRegionPick();
sendResponse({ ok: true });
return false;
case 'odClipper:setImageHover':
setImageHover(Boolean(msg.enabled), true);
sendResponse({ ok: true, enabled: imageHoverEnabled });
return false;
case 'odClipper:getImageHover':
sendResponse({ ok: true, enabled: imageHoverEnabled });
return false;
case 'odClipper:ping':
// Liveness probe — lets the popup distinguish "no content script" from a
// genuinely broken page and decide whether a reload is warranted.
sendResponse({ ok: true, alive: true });
return false;
case 'odClipper:hideForCapture': {
// Pull every piece of our on-page UI out of frame for the screenshot:
// the bar, the cursor-following image badge, and any visible toast.
hiddenForCapture = true;
applyVisibility();
hideImageBadge();
toastHost.style.display = 'none';
requestAnimationFrame(() => requestAnimationFrame(() => sendResponse({ ok: true })));
return true; // async response
}
case 'odClipper:restoreAfterCapture':
hiddenForCapture = false;
applyVisibility();
sendResponse({ ok: true });
return false;
default:
return false;
}
});
// Flag the page as attached only now that the listener above is live. If any
// earlier step had thrown (e.g. a Trusted Types violation on a hostile host
// page), the guard stays unset so a reload or the popup's "Refresh page"
// re-injection can retry — instead of short-circuiting on a half-initialized
// page forever. The whole body above is synchronous, so a concurrent
// re-injection still sees the guard set before it can run past the top check.
window.__odClipperInjected = true;
})();