070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
669 lines
26 KiB
JavaScript
669 lines
26 KiB
JavaScript
// Open Design web clipper service worker.
|
|
//
|
|
// Zero-config: there is no pairing and no token. The daemon is loopback-bound,
|
|
// and a web page cannot forge this extension's chrome-extension:// origin, so
|
|
// the daemon auto-trusts our requests to /api/library/*. All we need is the
|
|
// daemon URL (default below; overridable in the popup). host_permissions let
|
|
// the service worker reach the loopback daemon without CORS friction.
|
|
//
|
|
// The popup and the on-page toolbar both message this worker rather than
|
|
// talking to the daemon directly, so all daemon traffic lives in one place.
|
|
|
|
try {
|
|
if (typeof importScripts === 'function') importScripts('i18n.js');
|
|
} catch {
|
|
// The extension still works with English fallback if the helper cannot load.
|
|
}
|
|
|
|
const DEFAULT_DAEMON_URL = 'http://127.0.0.1:7456';
|
|
const I18N = globalThis.OD_CLIPPER_I18N;
|
|
const currentLocale = () => (I18N?.currentLocale ? I18N.currentLocale() : 'en');
|
|
const t = (key, vars) => (I18N?.t ? I18N.t(key, vars, currentLocale()) : key);
|
|
|
|
async function getDaemonUrl() {
|
|
const { daemonUrl } = await chrome.storage.local.get(['daemonUrl']);
|
|
return daemonUrl || DEFAULT_DAEMON_URL;
|
|
}
|
|
|
|
// Is Open Design running and reachable? We probe the narrow clipper route the
|
|
// daemon auto-trusts for extension origins and treat any 2xx as connected.
|
|
async function probe() {
|
|
const daemonUrl = await getDaemonUrl();
|
|
try {
|
|
const resp = await fetch(`${daemonUrl}/api/library/clipper-probe`, { method: 'GET' });
|
|
return resp.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function ingest(body) {
|
|
const daemonUrl = await getDaemonUrl();
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`${daemonUrl}/api/library/ingest`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch {
|
|
// Network-level failure → the daemon almost certainly isn't running.
|
|
throw new Error('not running');
|
|
}
|
|
if (!resp.ok) {
|
|
// 413 means the capture is bigger than the daemon will accept — surface a
|
|
// concise, actionable message instead of the server's full HTML error page.
|
|
if (resp.status === 413) {
|
|
throw new Error(t('errorCaptureTooLarge'));
|
|
}
|
|
const raw = await resp.text().catch(() => '');
|
|
// Strip any HTML (Express error pages) and collapse whitespace so the popup
|
|
// never shows a wall of markup.
|
|
const snippet = raw.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
throw new Error(`ingest ${resp.status}${snippet ? `: ${snippet}` : ''}`);
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
async function activeTab() {
|
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
if (!tab) throw new Error('no active tab');
|
|
return tab;
|
|
}
|
|
|
|
// Best-effort message to a tab's content script. Resolves regardless of whether
|
|
// a receiver exists (e.g. chrome:// pages have no content script).
|
|
function sendToTab(tabId, message) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
chrome.tabs.sendMessage(tabId, message, (res) => {
|
|
void chrome.runtime.lastError; // swallow "no receiving end"
|
|
resolve(res);
|
|
});
|
|
} catch {
|
|
resolve(undefined);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function captureScreenshot() {
|
|
const tab = await activeTab();
|
|
// Pull our own on-page bar out of frame so it never lands in the screenshot.
|
|
await sendToTab(tab.id, { type: 'odClipper:hideForCapture' });
|
|
let dataUrl;
|
|
try {
|
|
dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
|
|
} finally {
|
|
await sendToTab(tab.id, { type: 'odClipper:restoreAfterCapture' });
|
|
}
|
|
return ingest({
|
|
dataUrl,
|
|
kind: 'image',
|
|
sourceUrl: tab.url,
|
|
sourceTitle: tab.title,
|
|
tags: ['screenshot'],
|
|
});
|
|
}
|
|
|
|
// Runs in the page context (serialized by executeScript) — keep self-contained.
|
|
function collectImages() {
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const el of document.images) {
|
|
const src = el.currentSrc || el.src;
|
|
if (!src || seen.has(src)) continue;
|
|
if (!/^https?:/i.test(src)) continue;
|
|
if ((el.naturalWidth || 0) < 64 || (el.naturalHeight || 0) < 64) continue;
|
|
seen.add(src);
|
|
out.push({ src, alt: el.alt || '' });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function grabImages() {
|
|
const tab = await activeTab();
|
|
const [first] = await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
func: collectImages,
|
|
});
|
|
const images = Array.isArray(first?.result) ? first.result.slice(0, 30) : [];
|
|
let count = 0;
|
|
for (const img of images) {
|
|
try {
|
|
await ingest({ url: img.src, kind: 'image', sourceUrl: tab.url, sourceTitle: img.alt || tab.title });
|
|
count += 1;
|
|
} catch {
|
|
// skip individual failures (hotlink-protected / oversized)
|
|
}
|
|
}
|
|
return { count, total: images.length };
|
|
}
|
|
|
|
// --- page capture (high-fidelity HTML + Figma IR) --------------------------
|
|
//
|
|
// capture.js runs in the page and returns the snapshot with cross-origin
|
|
// resource URLs left intact; only the service worker can fetch those without
|
|
// CORS, so it does the fetch-and-inline pass here. One fetch per resource feeds
|
|
// both the HTML string and the Figma IR's image fills.
|
|
|
|
// Page captures inline cross-origin resources as base64 data URIs into both the
|
|
// HTML and the Figma IR before one JSON POST to the daemon (128MB ingest limit).
|
|
// To make a capture ALWAYS fit — even an image-heavy news front page — we:
|
|
// 1. compress large raster images (downscale + WebP) before inlining,
|
|
// 2. inline smallest-first within a fixed budget, leaving the largest as live
|
|
// URLs (the HTML skeleton + styles are preserved either way),
|
|
// 3. drop the secondary Figma IR if the combined body would still be too big,
|
|
// so the HTML page itself can never fail to save.
|
|
const MAX_RESOURCE_BYTES = 16 * 1024 * 1024; // per-resource fetch ceiling
|
|
const MAX_TOTAL_INLINE_BYTES = 40 * 1024 * 1024; // total inlined data-URI budget
|
|
const SAFE_BODY_BYTES = 100 * 1024 * 1024; // drop the Figma IR past this body size
|
|
|
|
// Raster images above the threshold are re-encoded smaller; vectors (SVG),
|
|
// animations (GIF), small icons, and non-images are left untouched.
|
|
const COMPRESSIBLE_MIMES = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/bmp']);
|
|
const IMAGE_COMPRESS_OVER_BYTES = 150 * 1024;
|
|
const MAX_IMAGE_DIM = 2000;
|
|
const COMPRESS_MIME = 'image/webp';
|
|
const COMPRESS_QUALITY = 0.8;
|
|
|
|
// Service workers have no FileReader/createObjectURL — base64 the bytes by hand.
|
|
function bytesToDataUri(bytes, mime) {
|
|
let bin = '';
|
|
const chunk = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
|
}
|
|
return `data:${mime || 'application/octet-stream'};base64,${btoa(bin)}`;
|
|
}
|
|
|
|
// Re-encode a large raster image to a smaller, downscaled WebP so an image-heavy
|
|
// page's inlined payload stays small enough to always ingest. Service workers
|
|
// expose createImageBitmap + OffscreenCanvas, and because we fetched the bytes
|
|
// ourselves (not read them off a cross-origin <img>) the canvas isn't tainted,
|
|
// so convertToBlob works. Returns a data URI, or null to fall back to the
|
|
// original (decode failed, or re-encoding wasn't actually smaller).
|
|
async function compressImageToDataUri(buf, originalBase64Len) {
|
|
let bitmap;
|
|
try {
|
|
bitmap = await createImageBitmap(new Blob([buf]), { imageOrientation: 'from-image' });
|
|
} catch {
|
|
return null;
|
|
}
|
|
try {
|
|
const scale = Math.min(1, MAX_IMAGE_DIM / Math.max(bitmap.width, bitmap.height));
|
|
const w = Math.max(1, Math.round(bitmap.width * scale));
|
|
const h = Math.max(1, Math.round(bitmap.height * scale));
|
|
const canvas = new OffscreenCanvas(w, h);
|
|
canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h);
|
|
const blob = await canvas.convertToBlob({ type: COMPRESS_MIME, quality: COMPRESS_QUALITY });
|
|
const dataUri = bytesToDataUri(new Uint8Array(await blob.arrayBuffer()), COMPRESS_MIME);
|
|
return dataUri.length < originalBase64Len ? dataUri : null;
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
bitmap.close();
|
|
}
|
|
}
|
|
|
|
async function fetchAsDataUri(url) {
|
|
const resp = await fetch(url, { redirect: 'follow' });
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
const declared = Number(resp.headers.get('content-length') || '0');
|
|
if (declared && declared > MAX_RESOURCE_BYTES) throw new Error('too large');
|
|
const buf = await resp.arrayBuffer();
|
|
if (buf.byteLength > MAX_RESOURCE_BYTES) throw new Error('too large');
|
|
const mime = (resp.headers.get('content-type') || 'application/octet-stream').split(';')[0].trim();
|
|
if (COMPRESSIBLE_MIMES.has(mime) && buf.byteLength > IMAGE_COMPRESS_OVER_BYTES) {
|
|
const approxBase64Len = Math.ceil(buf.byteLength / 3) * 4;
|
|
const compressed = await compressImageToDataUri(buf, approxBase64Len);
|
|
if (compressed) return compressed;
|
|
}
|
|
return bytesToDataUri(new Uint8Array(buf), mime);
|
|
}
|
|
|
|
// Run an async fn over items with a bounded number in flight at once. Decoding
|
|
// and re-encoding images is memory-hungry, so a 300-image page must not fan all
|
|
// of them out at once or the service worker can OOM mid-capture.
|
|
async function mapWithConcurrency(items, limit, fn) {
|
|
const results = new Array(items.length);
|
|
let next = 0;
|
|
const worker = async () => {
|
|
while (next < items.length) {
|
|
const i = next;
|
|
next += 1;
|
|
results[i] = await fn(items[i], i);
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
return results;
|
|
}
|
|
|
|
async function buildResourceMap(urls, includeImages) {
|
|
const map = new Map();
|
|
let skipped = 0;
|
|
if (!includeImages || !Array.isArray(urls) || !urls.length) return { map, skipped };
|
|
// Fetch (and compress) every candidate, then inline SMALLEST-FIRST until the
|
|
// budget is spent — so we keep the most images and the ones we drop (left as
|
|
// live URLs) are the largest. This bounds the inlined total by construction,
|
|
// so the page's images can never push the body over the ingest limit.
|
|
const candidates = await mapWithConcurrency(urls, 6, async (url) => {
|
|
try {
|
|
const dataUri = await fetchAsDataUri(url);
|
|
return { url, dataUri, size: dataUri.length };
|
|
} catch {
|
|
return null; // hotlink-protected / oversized / offline — leave the URL
|
|
}
|
|
});
|
|
let used = 0;
|
|
for (const c of candidates.filter(Boolean).sort((a, b) => a.size - b.size)) {
|
|
if (used + c.size > MAX_TOTAL_INLINE_BYTES) {
|
|
skipped += 1; // budget spent — this (larger) resource stays a live URL
|
|
continue;
|
|
}
|
|
used += c.size;
|
|
map.set(c.url, c.dataUri);
|
|
}
|
|
return { map, skipped };
|
|
}
|
|
|
|
function inlineHtml(html, map) {
|
|
let out = html;
|
|
for (const [url, data] of map) out = out.split(url).join(data);
|
|
return out;
|
|
}
|
|
|
|
function inlineFigmaIr(ir, map) {
|
|
const walk = (node) => {
|
|
if (!node) return;
|
|
if (Array.isArray(node.fills)) {
|
|
node.fills = node.fills
|
|
.map((f) => {
|
|
if (f && f.type === 'IMAGE' && f.url) {
|
|
const data = map.get(f.url);
|
|
return data ? { type: 'IMAGE', scaleMode: f.scaleMode || 'FILL', dataUri: data } : null;
|
|
}
|
|
return f;
|
|
})
|
|
.filter(Boolean);
|
|
if (!node.fills.length) delete node.fills;
|
|
}
|
|
if (Array.isArray(node.children)) node.children.forEach(walk);
|
|
};
|
|
if (ir && ir.root) walk(ir.root);
|
|
return ir;
|
|
}
|
|
|
|
function slugify(title) {
|
|
const slug = String(title || '')
|
|
.slice(0, 60)
|
|
.replace(/[^a-z0-9._-]+/gi, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
return slug || 'capture';
|
|
}
|
|
|
|
// Run capture.js in the active tab and inline its cross-origin resources.
|
|
async function capturePage(opts) {
|
|
const includeImages = !opts || opts.includeImages !== false;
|
|
const tab = await activeTab();
|
|
// No hideForCapture here: this is a DOM/IR snapshot, not a pixel screenshot,
|
|
// and capture.js already strips our own on-page UI by id ([id^="od-clipper-"])
|
|
// from both the HTML clone and the Figma IR. So the bar stays put and keeps
|
|
// showing its in-progress strip through the whole (often slow) snapshot —
|
|
// hiding it here is what used to make the toolbar vanish mid-capture.
|
|
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['capture.js'] });
|
|
const [out] = await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
func: (o) => window.__odCapture(o),
|
|
args: [{ includeImages }],
|
|
});
|
|
const cap = out && out.result;
|
|
if (!cap || !cap.html) throw new Error('capture failed');
|
|
const { map, skipped } = await buildResourceMap(cap.resources, includeImages);
|
|
return {
|
|
html: inlineHtml(cap.html, map),
|
|
figmaIr: cap.figmaIr ? inlineFigmaIr(cap.figmaIr, map) : null,
|
|
figmaNodeCount: cap.figmaNodeCount || 0,
|
|
truncated: Boolean(cap.figmaTruncated),
|
|
partialImages: skipped, // resources left as live URLs to fit the size budget
|
|
title: cap.title || tab.title,
|
|
url: cap.url || tab.url,
|
|
};
|
|
}
|
|
|
|
async function capturePageToLibrary(opts) {
|
|
const cap = await capturePage(opts);
|
|
let figmaCapture = cap.figmaIr ? JSON.stringify(cap.figmaIr) : undefined;
|
|
// The HTML page is the primary artifact. The Figma IR carries the same inlined
|
|
// images a second time, so if pairing the two would push the body past the
|
|
// safe ingest size, drop the IR — the page itself then always saves.
|
|
let figmaDropped = false;
|
|
if (figmaCapture && cap.html.length + figmaCapture.length > SAFE_BODY_BYTES) {
|
|
figmaCapture = undefined;
|
|
figmaDropped = true;
|
|
}
|
|
const r = await ingest({
|
|
text: cap.html,
|
|
kind: 'html',
|
|
mime: 'text/html',
|
|
sourceUrl: cap.url,
|
|
sourceTitle: cap.title,
|
|
tags: ['page-capture'],
|
|
figmaCapture,
|
|
figmaNodeCount: figmaCapture ? cap.figmaNodeCount : 0,
|
|
});
|
|
return {
|
|
deduped: Boolean(r.deduped),
|
|
hasFigma: Boolean(figmaCapture),
|
|
truncated: cap.truncated,
|
|
partialImages: cap.partialImages || 0,
|
|
figmaDropped,
|
|
};
|
|
}
|
|
|
|
async function downloadFigma(opts) {
|
|
const cap = await capturePage(opts);
|
|
if (!cap.figmaIr) throw new Error('no figma capture produced');
|
|
const json = JSON.stringify(cap.figmaIr, null, 2);
|
|
const dataUrl = `data:application/json;charset=utf-8,${encodeURIComponent(json)}`;
|
|
await chrome.downloads.download({
|
|
url: dataUrl,
|
|
filename: `${slugify(cap.title)}.od-figma.json`,
|
|
saveAs: false,
|
|
});
|
|
return { truncated: cap.truncated, partialImages: cap.partialImages || 0 };
|
|
}
|
|
|
|
// --- design-system capture -------------------------------------------------
|
|
//
|
|
// brand-capture.js does not snapshot the page. It extracts brand/product
|
|
// signals and fills a stable HTML template, then the worker inlines the
|
|
// discovered logos/images/fonts exactly like the full-page capture path.
|
|
|
|
async function captureDesignSystem(opts) {
|
|
const includeImages = !opts || opts.includeImages !== false;
|
|
const tab = await activeTab();
|
|
const locale = currentLocale();
|
|
// No hideForCapture: brand-capture.js reads the page through the light DOM
|
|
// (our toolbar lives in a shadow root it can't see) and skips any
|
|
// [id^="od-clipper-"] node, so the bar stays visible with its progress strip
|
|
// through the whole extraction instead of disappearing for several seconds.
|
|
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['i18n.js', 'brand-capture.js'] });
|
|
const [out] = await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
func: (captureLocale) => window.__odBrandCapture({ locale: captureLocale }),
|
|
args: [locale],
|
|
});
|
|
const cap = out && out.result;
|
|
if (!cap || !cap.html) throw new Error(t('errorDesignSystemCaptureFailed'));
|
|
const { map, skipped } = await buildResourceMap(cap.resources, includeImages);
|
|
return {
|
|
html: inlineHtml(cap.html, map),
|
|
partialImages: skipped,
|
|
title: cap.title || t('brandFileTitle', { title: tab.title || t('brandFallbackTitle') }),
|
|
url: cap.url || tab.url,
|
|
summary: cap.summary || {},
|
|
};
|
|
}
|
|
|
|
async function captureDesignSystemToLibrary(opts) {
|
|
const cap = await captureDesignSystem(opts);
|
|
const r = await ingest({
|
|
text: cap.html,
|
|
kind: 'design-system',
|
|
mime: 'text/html',
|
|
sourceUrl: cap.url,
|
|
sourceTitle: cap.title,
|
|
tags: ['design-system', 'brand-capture'],
|
|
metadata: {
|
|
designSystemCapture: {
|
|
version: 1,
|
|
capturedAt: Date.now(),
|
|
...cap.summary,
|
|
},
|
|
},
|
|
});
|
|
return { deduped: Boolean(r.deduped), partialImages: cap.partialImages || 0 };
|
|
}
|
|
|
|
// --- element + selected-image capture --------------------------------------
|
|
|
|
// Blob → base64 data URL (service workers have no FileReader).
|
|
async function blobToDataUrl(blob) {
|
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
let bin = '';
|
|
const chunk = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
|
}
|
|
return `data:${blob.type || 'image/png'};base64,${btoa(bin)}`;
|
|
}
|
|
|
|
// Crop a captured-tab PNG (data URL) to a viewport rect given in CSS pixels.
|
|
// The captured image is the visible viewport scaled by the device/zoom factor,
|
|
// so we derive the scale from the real image width vs the reported viewport
|
|
// width (robust against retina + page zoom, which a raw devicePixelRatio is
|
|
// not) and fall back to dpr when the viewport width is unknown.
|
|
async function cropToRect(tabDataUrl, rect, viewportWidth, dpr) {
|
|
const blob = await (await fetch(tabDataUrl)).blob();
|
|
const bmp = await createImageBitmap(blob);
|
|
const scale = viewportWidth && bmp.width ? bmp.width / viewportWidth : dpr || 1;
|
|
const sx = Math.max(0, Math.round((rect.x || 0) * scale));
|
|
const sy = Math.max(0, Math.round((rect.y || 0) * scale));
|
|
const sw = Math.max(1, Math.min(Math.round((rect.width || 0) * scale), bmp.width - sx));
|
|
const sh = Math.max(1, Math.min(Math.round((rect.height || 0) * scale), bmp.height - sy));
|
|
const canvas = new OffscreenCanvas(sw, sh);
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(bmp, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
bmp.close();
|
|
const out = await canvas.convertToBlob({ type: 'image/png' });
|
|
return blobToDataUrl(out);
|
|
}
|
|
|
|
// Capture the picked element as ONE self-contained HTML asset. The content
|
|
// script marks the element with `data-od-clip-target` on the live DOM, then
|
|
// capture.js prunes the page to just that element (keeping the page CSS so its
|
|
// cascade resolves) and returns scoped HTML + the resources to inline. No
|
|
// screenshot, no separate `.element.html` markup sidecar — a single HTML file
|
|
// that opens, shares, and distributes as the element, styled as it was on the
|
|
// page. The worker inlines the element's own images exactly like a page capture.
|
|
async function captureElementHtml(payload) {
|
|
const includeImages = !payload || payload.includeImages !== false;
|
|
const tab = await activeTab();
|
|
// A DOM snapshot, not a pixel screenshot: capture.js strips our own on-page UI
|
|
// by id, so the bar never needs to leave the frame and keeps its progress strip.
|
|
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['capture.js'] });
|
|
const [out] = await chrome.scripting.executeScript({
|
|
target: { tabId: tab.id },
|
|
func: (o) => window.__odCaptureElement(o),
|
|
args: [{ includeImages, marker: 'data-od-clip-target' }],
|
|
});
|
|
const cap = out && out.result;
|
|
if (!cap || !cap.html) throw new Error('element capture failed');
|
|
const { map } = await buildResourceMap(cap.resources, includeImages);
|
|
const html = inlineHtml(cap.html, map);
|
|
const meta = payload.meta || {};
|
|
const r = await ingest({
|
|
text: html,
|
|
kind: 'html',
|
|
mime: 'text/html',
|
|
sourceUrl: payload.sourceUrl || tab.url,
|
|
sourceTitle: payload.sourceTitle || tab.title,
|
|
tags: ['element', meta.tag].filter(Boolean),
|
|
// The HTML asset IS the markup now, so there is no sidecar to advertise —
|
|
// the lightweight element summary still rides along for the Library badge.
|
|
metadata: { element: { ...meta, hasHtml: false } },
|
|
});
|
|
return { deduped: Boolean(r.deduped) };
|
|
}
|
|
|
|
// Screenshot a user-dragged region (cropped from the visible tab). A flat pixel
|
|
// asset — unlike element capture, which saves the picked node as live HTML.
|
|
async function captureRegion(payload) {
|
|
const tab = await activeTab();
|
|
// Hide the bar for the screenshot only if it would land inside the cropped
|
|
// region; otherwise it stays put with its progress strip.
|
|
const hideBar = Boolean(payload.hideBar);
|
|
if (hideBar) await sendToTab(tab.id, { type: 'odClipper:hideForCapture' });
|
|
let tabDataUrl;
|
|
try {
|
|
tabDataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
|
|
} finally {
|
|
if (hideBar) await sendToTab(tab.id, { type: 'odClipper:restoreAfterCapture' });
|
|
}
|
|
const cropped = await cropToRect(tabDataUrl, payload.rect || {}, payload.viewportWidth, payload.dpr);
|
|
const r = await ingest({
|
|
dataUrl: cropped,
|
|
kind: 'image',
|
|
sourceUrl: payload.sourceUrl || tab.url,
|
|
sourceTitle: payload.sourceTitle || tab.title,
|
|
tags: ['region', 'screenshot'],
|
|
});
|
|
return { deduped: Boolean(r.deduped) };
|
|
}
|
|
|
|
// Ingest a user-chosen subset of page images (from the on-page picker).
|
|
async function ingestImages(payload) {
|
|
const tab = await activeTab().catch(() => null);
|
|
const images = Array.isArray(payload.images) ? payload.images.slice(0, 100) : [];
|
|
let count = 0;
|
|
for (const img of images) {
|
|
if (!img || !img.src) continue;
|
|
try {
|
|
await ingest({
|
|
url: img.src,
|
|
kind: 'image',
|
|
sourceUrl: payload.sourceUrl || (tab && tab.url),
|
|
sourceTitle: img.alt || payload.sourceTitle || (tab && tab.title),
|
|
});
|
|
count += 1;
|
|
} catch {
|
|
// skip individual failures (hotlink-protected / oversized)
|
|
}
|
|
}
|
|
return { count, total: images.length };
|
|
}
|
|
|
|
// Capture messages that should show a progress indicator. The on-page toolbar
|
|
// shows a rich step strip, but it dies if the page reloads mid-capture (hostile
|
|
// paywall sites do this). A per-tab badge on the EXTENSION ICON survives the
|
|
// reload — it's the one progress surface a page navigation can't take down.
|
|
const CAPTURE_TYPES = new Set([
|
|
'captureScreenshot', 'capturePageToLibrary', 'downloadFigma',
|
|
'captureDesignSystemToLibrary', 'captureElementHtml', 'captureRegion',
|
|
'ingestImages', 'grabImages',
|
|
]);
|
|
|
|
async function setCaptureBadge(tabId, on) {
|
|
if (tabId == null) return;
|
|
try {
|
|
await chrome.action.setBadgeBackgroundColor({ color: '#7c3aed' });
|
|
await chrome.action.setBadgeText({ tabId, text: on ? '•••' : '' });
|
|
} catch {
|
|
// chrome.action may be unavailable in some embeds; the in-page strip still shows
|
|
}
|
|
}
|
|
|
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
(async () => {
|
|
const isCapture = Boolean(msg && CAPTURE_TYPES.has(msg.type));
|
|
let badgeTabId = null;
|
|
if (isCapture) {
|
|
badgeTabId = (_sender && _sender.tab && _sender.tab.id) ||
|
|
(await activeTab().catch(() => null))?.id || null;
|
|
setCaptureBadge(badgeTabId, true);
|
|
}
|
|
try {
|
|
switch (msg && msg.type) {
|
|
case 'getStatus': {
|
|
const [daemonUrl, connected] = await Promise.all([getDaemonUrl(), probe()]);
|
|
sendResponse({ ok: true, connected, daemonUrl });
|
|
break;
|
|
}
|
|
case 'setDaemonUrl': {
|
|
await chrome.storage.local.set({ daemonUrl: msg.url || DEFAULT_DAEMON_URL });
|
|
const connected = await probe();
|
|
sendResponse({ ok: true, connected });
|
|
break;
|
|
}
|
|
case 'captureScreenshot': {
|
|
const r = await captureScreenshot();
|
|
sendResponse({ ok: true, deduped: Boolean(r.deduped) });
|
|
break;
|
|
}
|
|
case 'grabImages': {
|
|
const r = await grabImages();
|
|
sendResponse({ ok: true, count: r.count, total: r.total });
|
|
break;
|
|
}
|
|
case 'capturePageToLibrary': {
|
|
const r = await capturePageToLibrary(msg.opts);
|
|
sendResponse({
|
|
ok: true,
|
|
deduped: r.deduped,
|
|
hasFigma: r.hasFigma,
|
|
truncated: r.truncated,
|
|
partialImages: r.partialImages,
|
|
figmaDropped: r.figmaDropped,
|
|
});
|
|
break;
|
|
}
|
|
case 'downloadFigma': {
|
|
const r = await downloadFigma(msg.opts);
|
|
sendResponse({ ok: true, truncated: r.truncated, partialImages: r.partialImages });
|
|
break;
|
|
}
|
|
case 'captureDesignSystemToLibrary': {
|
|
const r = await captureDesignSystemToLibrary(msg.opts);
|
|
sendResponse({ ok: true, deduped: r.deduped, partialImages: r.partialImages });
|
|
break;
|
|
}
|
|
case 'captureElementHtml': {
|
|
const r = await captureElementHtml(msg);
|
|
sendResponse({ ok: true, deduped: r.deduped });
|
|
break;
|
|
}
|
|
case 'captureRegion': {
|
|
const r = await captureRegion(msg);
|
|
sendResponse({ ok: true, deduped: r.deduped });
|
|
break;
|
|
}
|
|
case 'ingestImages': {
|
|
const r = await ingestImages(msg);
|
|
sendResponse({ ok: true, count: r.count, total: r.total });
|
|
break;
|
|
}
|
|
default:
|
|
sendResponse({ ok: false, error: 'unknown message' });
|
|
}
|
|
} catch (err) {
|
|
sendResponse({ ok: false, error: (err && err.message) || String(err) });
|
|
} finally {
|
|
if (isCapture) setCaptureBadge(badgeTabId, false);
|
|
}
|
|
})();
|
|
return true; // keep the message channel open for the async response
|
|
});
|
|
|
|
// Right-click any image → save straight to the library.
|
|
chrome.runtime.onInstalled.addListener(() => {
|
|
chrome.contextMenus.create({
|
|
id: 'od-save-image',
|
|
title: t('saveImageToLibrary'),
|
|
contexts: ['image'],
|
|
});
|
|
});
|
|
|
|
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
if (info.menuItemId !== 'od-save-image' || !info.srcUrl) return;
|
|
try {
|
|
await ingest({
|
|
url: info.srcUrl,
|
|
kind: 'image',
|
|
sourceUrl: tab && tab.url,
|
|
sourceTitle: tab && tab.title,
|
|
});
|
|
} catch {
|
|
// best-effort; the popup surfaces detailed errors
|
|
}
|
|
});
|