项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

370 行
13 KiB
JavaScript

// Open Design web clipper popup. Thin UI over the service worker message API.
//
// Zero-config: no pairing. The popup just probes whether Open Design is running
// and lets you capture. The daemon URL lives under "Advanced" for the rare case
// you ran the daemon on a non-default port.
const $ = (id) => document.getElementById(id);
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);
if (I18N?.translateDocument) I18N.translateDocument(document, locale);
function send(message) {
return new Promise((resolve) => {
chrome.runtime.sendMessage(message, (res) => resolve(res || { ok: false, error: t('noResponse') }));
});
}
function activeTabId() {
return new Promise((resolve) => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => resolve(tabs && tabs[0] && tabs[0].id));
});
}
function activeTab() {
return new Promise((resolve) => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => resolve((tabs && tabs[0]) || null));
});
}
// Only http(s) pages can ever host the content script. chrome://, the Web Store,
// view-source:, and the like are off-limits — a refresh wouldn't help there.
function isInjectable(url) {
return typeof url === 'string' && /^https?:\/\//i.test(url);
}
// Inject the content script into a tab that doesn't have it yet. This is the
// common state right after the extension is installed or updated: already-open
// tabs never received the auto-injected script. Re-injection is safe — content.js
// guards itself with window.__odClipperInjected. Returns true on success.
async function injectContentScript(tabId) {
try {
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] });
return true;
} catch {
return false;
}
}
// Message the active tab's content script directly (the on-page bar lives
// there). Resolves undefined when there is no receiver (e.g. a chrome:// page).
function sendToTab(tabId, message) {
return new Promise((resolve) => {
try {
chrome.tabs.sendMessage(tabId, message, (res) => {
void chrome.runtime.lastError;
resolve(res);
});
} catch {
resolve(undefined);
}
});
}
// Liveness probe against the content script. A truthy answer means a live
// listener is attached — distinguishing a dead/missing script (recoverable by
// re-inject or reload) from a genuinely restricted page (nothing can attach).
function pingTab(tabId) {
return sendToTab(tabId, { type: 'odClipper:ping' }).then((r) => Boolean(r && r.ok));
}
// Poll until the (reloaded) tab's content script answers, or time out.
async function waitForAttach(tabId, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await pingTab(tabId)) return true;
await new Promise((r) => setTimeout(r, 200));
}
return false;
}
// Message the content script, attaching it first if the tab never received it.
// A tab that was already open when the extension was installed or reloaded has
// no content script, so the first sendMessage finds no receiver. Rather than
// dead-ending on "hasn't attached yet" and forcing a manual page refresh, inject
// content.js in place (no reload, no lost page state) and retry once — so the
// on-page actions just work. Re-injection is a no-op when the script is already
// there (content.js guards with window.__odClipperInjected).
async function sendToTabEnsuringScript(tabId, url, message) {
const res = await sendToTab(tabId, message);
if (res && res.ok) return res;
if (!isInjectable(url)) return res;
if (!(await injectContentScript(tabId))) return res;
return sendToTab(tabId, message);
}
function setMsg(text, kind) {
const el = $('msg');
el.textContent = text || '';
el.dataset.kind = kind || '';
// Any fresh status replaces the previous one, so retire a stale refresh prompt.
hideRefresh();
}
// Lock + dim the capture buttons while a capture is in flight, so the spinner
// status reads as the active state and the user can't double-fire a capture.
function setBusy(on) {
document.body.classList.toggle('busy', Boolean(on));
}
// The "Refresh page" affordance and the action to retry once the page is ready.
let pendingRetry = null;
function showRefresh(retryFn) {
pendingRetry = retryFn;
const btn = $('refresh-page');
if (btn) {
btn.hidden = false;
btn.classList.remove('spinning');
}
}
function hideRefresh() {
pendingRetry = null;
const btn = $('refresh-page');
if (btn) {
btn.hidden = true;
btn.classList.remove('spinning');
}
}
function render(connected) {
const status = $('status');
status.textContent = connected ? t('statusConnected') : t('statusOffline');
status.dataset.paired = connected ? 'true' : 'false';
$('hint').hidden = connected;
// Capture stays available even when disconnected; the buttons surface a clear
// "Open Design not running" message instead of being hidden, so it never feels broken.
}
function renderToolbar(visible) {
const btn = $('toolbar-toggle');
if (!btn) return;
btn.setAttribute('aria-checked', visible ? 'true' : 'false');
}
async function refresh() {
const res = await send({ type: 'getStatus' });
if (res.ok) {
$('daemon').value = res.daemonUrl || '';
render(Boolean(res.connected));
}
try {
const { toolbarVisible, imageHoverCapture } = await chrome.storage.local.get([
'toolbarVisible',
'imageHoverCapture',
]);
renderToolbar(Boolean(toolbarVisible));
const hv = $('image-hover');
// Default-on: only an explicit `false` turns the switch off.
if (hv) hv.setAttribute('aria-checked', imageHoverCapture === false ? 'false' : 'true');
} catch {
renderToolbar(false);
}
}
function reportCapture(res, okText) {
setBusy(false);
if (res.ok) {
setMsg(okText(res), 'ok');
return;
}
if (res.error === 'not running') {
setMsg(t('openDesignNotRunning'), 'err');
void refresh();
} else {
setMsg(t('failed', { error: res.error || t('unknown') }), 'err');
}
}
$('save-daemon').addEventListener('click', async () => {
const url = $('daemon').value.trim();
const res = await send({ type: 'setDaemonUrl', url });
render(Boolean(res.connected));
setMsg(res.connected ? t('savedConnected') : t('savedNotDetected'), res.connected ? 'ok' : 'err');
});
// Capture options shared by the page / figma actions.
function captureOpts() {
const el = $('include-images');
return { includeImages: el ? el.checked : true };
}
$('page').addEventListener('click', async () => {
setBusy(true);
setMsg(t('capturingPage'), 'loading');
const res = await send({ type: 'capturePageToLibrary', opts: captureOpts() });
reportCapture(res, (r) => {
if (r.deduped) return t('pageAlreadyInLibrary');
// A very large page is captured at reduced fidelity rather than failing:
// the layout (Figma IR) may be partial and/or some images stay as live
// links. Tell the user when either happened so the result isn't surprising.
const notes = [];
if (r.truncated) notes.push(t('largePagePartialLayout'));
if (r.figmaDropped) notes.push(t('figmaLayoutSkippedPageTooLarge'));
if (r.partialImages) {
notes.push(t('imagesLeftLinks', { count: r.partialImages }));
}
const suffix = notes.length ? ` (${notes.join('; ')})` : '';
return r.hasFigma
? t('savedPageWithFigma', { suffix })
: t('savedPage', { suffix });
});
});
$('figma').addEventListener('click', async () => {
setBusy(true);
setMsg(t('buildingFigma'), 'loading');
const res = await send({ type: 'downloadFigma', opts: captureOpts() });
reportCapture(res, () => t('figmaDownloaded'));
});
$('system')?.addEventListener('click', async () => {
setBusy(true);
setMsg(t('extractingDesignSystem'), 'loading');
const res = await send({ type: 'captureDesignSystemToLibrary', opts: captureOpts() });
reportCapture(res, (r) => {
const suffix = r.partialImages
? ` (${t('resourcesLeftLinks', { count: r.partialImages })})`
: '';
return r.deduped ? t('designSystemAlreadyInLibrary', { suffix }) : t('designSystemSaved', { suffix });
});
});
$('shot').addEventListener('click', async () => {
setBusy(true);
setMsg(t('capturingScreenshot'), 'loading');
const res = await send({ type: 'captureScreenshot' });
reportCapture(res, (r) => (r.deduped ? t('alreadyInLibrary') : t('screenshotSaved')));
});
// Open an on-page surface (element picker / image picker) in the active tab,
// then close the popup so the page has focus for the interaction.
async function openOnPage(message, startedMsg, unavailableMsg) {
const tab = await activeTab();
const tabId = tab && tab.id;
if (!tabId) {
setMsg(t('openNormalPage'), 'err');
return;
}
const res = await sendToTabEnsuringScript(tabId, tab.url, message);
if (!res || !res.ok) {
// We already tried to attach the content script in place and it still didn't
// answer — fall back to a real reload, which is the last thing that can help
// on an injectable page. On a restricted page (chrome://, the Web Store, …)
// nothing can attach, so say so plainly.
if (isInjectable(tab.url)) {
setMsg(t('odNotAttached'), 'err');
showRefresh(() => openOnPage(message, startedMsg, unavailableMsg));
} else {
setMsg(unavailableMsg, 'err');
}
return;
}
setMsg(startedMsg, 'ok');
setTimeout(() => window.close(), 200);
}
$('element')?.addEventListener('click', () =>
openOnPage(
{ type: 'odClipper:pickElement' },
t('clickElement'),
t('elementPickerUnavailable'),
),
);
$('imgs')?.addEventListener('click', () =>
openOnPage(
{ type: 'odClipper:pickImages' },
t('pickImagesOnPage'),
t('imagePickerUnavailable'),
),
);
$('region')?.addEventListener('click', () =>
openOnPage(
{ type: 'odClipper:pickRegion' },
t('dragRegionOnPage'),
t('regionUnavailable'),
),
);
$('toolbar-toggle').addEventListener('click', async function toggleToolbar() {
const tab = await activeTab();
const tabId = tab && tab.id;
if (!tabId) {
setMsg(t('openNormalPageForBar'), 'err');
return;
}
const res = await sendToTabEnsuringScript(tabId, tab.url, { type: 'odClipper:setToolbar', mode: 'toggle' });
if (!res || !res.ok) {
if (isInjectable(tab.url)) {
setMsg(t('odNotAttached'), 'err');
showRefresh(toggleToolbar);
} else {
setMsg(t('onPageBarUnavailable'), 'err');
}
return;
}
renderToolbar(Boolean(res.visible));
setMsg(res.visible ? t('onPageBarShown') : t('onPageBarHidden'), 'ok');
});
// Per-image hover capture toggle. Persisted to storage so it applies to every
// tab (including ones without a content script yet) and pushed to the active
// tab's content script for an immediate effect. No on-page surface is needed,
// so this works even where the content script can't attach.
$('image-hover')?.addEventListener('click', async () => {
const btn = $('image-hover');
const next = btn.getAttribute('aria-checked') !== 'true';
btn.setAttribute('aria-checked', next ? 'true' : 'false');
try {
await chrome.storage.local.set({ imageHoverCapture: next });
} catch {
// storage unavailable — the per-tab message below still applies it live
}
const tab = await activeTab();
if (tab && tab.id) void sendToTab(tab.id, { type: 'odClipper:setImageHover', enabled: next });
setMsg(next ? t('imageHoverOn') : t('imageHoverOff'), 'ok');
});
// Recover from a tab that never received the content script. Inject it in place
// (no reload, no lost page state) and re-run the action; fall back to a real
// reload only when the page is too restricted to inject into.
$('refresh-page')?.addEventListener('click', async () => {
const btn = $('refresh-page');
if (!btn || btn.classList.contains('spinning')) return;
const retry = pendingRetry;
btn.classList.add('spinning');
const tabId = await activeTabId();
if (!tabId) {
hideRefresh();
return;
}
// First try a no-reload re-injection (keeps page state). Verify with a ping
// that a LIVE listener actually answered — a stale context can swallow the
// executeScript without re-arming a listener, so its resolve alone isn't
// proof the page is reachable.
await injectContentScript(tabId);
if (await pingTab(tabId)) {
if (typeof retry === 'function') await retry();
else setMsg(t('readyTryAgain'), 'ok');
return;
}
// Couldn't re-arm in place → genuinely reload and wait for the fresh content
// script to attach, then resume the original action.
setMsg(t('reloadingPage'));
chrome.tabs.reload(tabId);
if (await waitForAttach(tabId, 6000)) {
if (typeof retry === 'function') await retry();
else setMsg(t('readyTryAgain'), 'ok');
} else {
hideRefresh();
setMsg(t('reloadedReopen'), 'ok');
}
});
void refresh();