// 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, `
${btn('page', t('toolbarCapturePage'))} ${btn('system', t('toolbarExtractDesignSystem'))} ${btn('figma', t('toolbarDownloadFigma'))} ${btn('shot', t('toolbarCaptureScreenshot'))} ${btn('region', t('toolbarCaptureRegion'))} ${btn('imgs', t('toolbarPickImages'))} ${btn('element', t('toolbarPickElement'))}
`); 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, `
${esc(t('elementPickerTitle'))}  ·  ${esc(t('elementPickerHint'))} Esc
`); 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, `
`); const grid = sh.getElementById('grid'); // Crosshair "find on page" glyph, reused per cell. const LOC_SVG = ''; // Build all cells in the detached shadow tree (no reflow until attach). // Thumbnails render synchronously: a canvas drawn from the page's decoded // (cheap, bounded memory) or a native lazy for URL-load cases. const checkboxes = []; images.forEach((img, i) => { const cell = document.createElement('div'); cell.className = 'cell'; cell.__odItem = img; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.dataset.i = String(i); checkbox.setAttribute('aria-label', img.alt || t('imageLabel', { index: i + 1 })); const thumb = makeThumb(img); const loc = document.createElement('button'); loc.type = 'button'; loc.className = 'loc'; loc.title = t('findOnPage'); setHTML(loc, LOC_SVG); const dim = document.createElement('span'); dim.className = 'dim'; dim.textContent = img.w && img.h ? `${img.w}×${img.h}` : ''; cell.append(checkbox, thumb, loc, dim); grid.appendChild(cell); checkboxes.push(checkbox); }); const countEl = sh.getElementById('count'); const saveBtn = sh.getElementById('save'); // Track the count incrementally so toggling a checkbox is O(1), not an // O(n) re-scan of the whole grid on every click. let selected = 0; function paintCount() { countEl.textContent = t('selectedCount', { selected, total: images.length }); saveBtn.textContent = t('saveNToLibrary', { count: selected }); saveBtn.disabled = selected === 0; } grid.addEventListener('change', (e) => { if (!(e.target instanceof HTMLInputElement)) return; selected += e.target.checked ? 1 : -1; paintCount(); }); // Cell click toggles selection; the locate button is carved out so "find on // page" never also (de)selects the image. grid.addEventListener('click', (e) => { const target = e.target instanceof Element ? e.target : null; if (!target) return; const locBtn = target.closest('.loc'); if (locBtn) { e.preventDefault(); e.stopPropagation(); const cell = locBtn.closest('.cell'); if (cell && cell.__odItem) locateImage(cell.__odItem.el); return; } if (target instanceof HTMLInputElement) return; // native toggle fires change const cell = target.closest('.cell'); const cb = cell && cell.querySelector('input'); if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change', { bubbles: true })); } }); sh.getElementById('ov').addEventListener('click', (e) => { const a = e.target instanceof Element ? e.target.getAttribute('data-a') : null; if (!a) { if (e.target === sh.getElementById('ov')) closeImagePicker(); return; } if (a === 'close') closeImagePicker(); else if (a === 'all') { checkboxes.forEach((c) => (c.checked = true)); selected = images.length; paintCount(); } else if (a === 'none') { checkboxes.forEach((c) => (c.checked = false)); selected = 0; paintCount(); } }); saveBtn.addEventListener('click', async () => { const chosen = checkboxes.filter((c) => c.checked).map((c) => images[Number(c.dataset.i)]); if (!chosen.length) return; saveBtn.disabled = true; saveBtn.textContent = t('saving'); closeImagePicker(); // Progress on the bar through the save, matching every other capture // action — the count rides into the busy label, so no separate toast. // ingestImages takes no screenshot, so the bar stays put — no blink. startBusy('imgs', { count: chosen.length }); let res; try { res = await chrome.runtime.sendMessage({ type: 'ingestImages', images: chosen.map((c) => ({ src: c.src, alt: c.alt })), sourceUrl: location.href, sourceTitle: document.title, }); } 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(t('savedImagesCount', { count: res.count, total: res.total })); }); document.documentElement.appendChild(imagePickerHost); } // --- region picker (drag a box → crop the visible tab) ------------------- // A full-viewport surface with a crosshair. Drag to rubber-band a rectangle; // on release the worker screenshots the visible tab and crops to the box. The // surface tears down before the capture so none of our UI lands in the frame. let regionActive = false; let regionHost = null; let regionShadow = null; let regionRectEl = null; let regionStart = null; function startRegionPick() { if (regionActive) return; endElementPick(); // on-page picking surfaces are mutually exclusive regionActive = true; regionStart = null; regionHost = document.createElement('div'); regionHost.id = 'od-clipper-region'; regionHost.style.cssText = 'position:fixed;inset:0;z-index:2147483646;background:transparent;margin:0;padding:0;border:0;'; regionShadow = regionHost.attachShadow({ mode: 'open' }); setHTML(regionShadow, `
${esc(t('dragToSelectRegion'))}  ${esc(t('dragToSelectRegionTail'))}  Esc
`); 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; })();