diff --git a/web-to-pixso/README.md b/web-to-pixso/README.md new file mode 100644 index 0000000..6d8e2b2 --- /dev/null +++ b/web-to-pixso/README.md @@ -0,0 +1,30 @@ +# Web to Pixso + +一套对标 `figma-capture-extension` 使用体验的网页采集工具,包含 Chrome 扩展和 Pixso 导入插件。 + +## 目录 + +- `manifest.json`、`popup.*`、`background.js`、`capture.js`、`runner.js`:Chrome 扩展 +- `pixso-plugin/`:Pixso 插件,用于导入扩展导出的 JSON 文件 +- `logo/`:扩展与插件图标 + +## 使用 + +1. 打开 `chrome://extensions/`,开启开发者模式。 +2. 点击“加载已解压的扩展程序”,选择本目录 `web-to-pixso`。 +3. 打开要采集的网页,点击扩展图标,按需开启“跨域图片代理模式”,点击“开始采集”。 +4. 扩展会下载一个 `web-to-pixso/*.json` 文件。 +5. 在 Pixso 中导入 `pixso-plugin/manifest.json`,运行插件并选择上一步下载的 JSON 文件。若旧版导入器要求 `plugin.json`,目录内也保留了同内容兼容文件。 + +## 数据格式 + +扩展导出的文件格式为 `pixso-design-capture`,包含页面来源、画布尺寸、DOM 节点树、图片资源、字体和诊断信息。Pixso 插件会尽量还原: + +- 文本图层 +- 图片和背景图片 +- 背景色、边框、圆角、透明度 +- DOM 层级和基础坐标 + +## 注意 + +网页到设计稿的转换无法做到 100% 语义等价,复杂 CSS、canvas、视频帧、伪元素和部分字体效果可能需要在 Pixso 中二次微调。 diff --git a/web-to-pixso/background.js b/web-to-pixso/background.js new file mode 100644 index 0000000..84c296d --- /dev/null +++ b/web-to-pixso/background.js @@ -0,0 +1,419 @@ +const CAPTURE_FILE = "capture.js"; +const RUNNER_FILE = "runner.js"; +const POPUP_PANEL_FILE = "popup-panel.js"; +const ELEMENT_PICKER_FILE = "element-picker.js"; +const SETTINGS_KEY = "webToPixsoSettings"; +const DEFAULT_SETTINGS = { + useProxy: false, + concurrency: "8", + captureMode: "mixed", + captureWidth: null +}; +const MIN_CAPTURE_WIDTH = 320; +const MAX_CAPTURE_WIDTH = 3840; + +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function normalizeSettings(value = {}) { + const concurrency = String(value.concurrency || DEFAULT_SETTINGS.concurrency); + const captureWidth = normalizeCaptureWidth(value.captureWidth, null); + return { + useProxy: Boolean(value.useProxy), + concurrency: ["4", "6", "8", "10", "12", "16", "20", "infinite"].includes(concurrency) + ? concurrency + : DEFAULT_SETTINGS.concurrency, + captureMode: value.captureMode === "editable" ? "editable" : "mixed", + captureWidth + }; +} + +function normalizeCaptureWidth(value, fallback = null) { + const number = Number.parseInt(String(value || "").replace(/\D+/g, ""), 10); + if (!Number.isFinite(number)) return fallback; + return Math.max(MIN_CAPTURE_WIDTH, Math.min(MAX_CAPTURE_WIDTH, number)); +} + +function definedWindowBounds(bounds = {}) { + return Object.fromEntries( + Object.entries(bounds).filter(([, value]) => Number.isFinite(value)) + ); +} + +function assertCaptureableTab(tab) { + if (!tab?.id || !tab.url) { + throw new Error("没有可采集的当前标签页"); + } + + if (/^(chrome|edge|about|devtools|chrome-extension):/i.test(tab.url)) { + throw new Error("浏览器内置页面不支持采集,请切换到普通网页"); + } +} + +async function getActiveTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + assertCaptureableTab(tab); + return tab; +} + +async function runCapture(tabId, options) { + await chrome.scripting.executeScript({ + target: { tabId }, + files: [CAPTURE_FILE] + }); + + await chrome.scripting.executeScript({ + target: { tabId }, + files: [RUNNER_FILE] + }); + + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId }, + func: captureOptions => window.__webToPixsoRunCapture(captureOptions), + args: [options] + }); + + if (!result) { + throw new Error("页面没有返回采集结果"); + } + + return result; +} + +async function getTabViewportWidth(tabId) { + try { + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId }, + func: () => Math.round(window.innerWidth || document.documentElement.clientWidth || 0) + }); + return normalizeCaptureWidth(result, null); + } catch { + return null; + } +} + +async function prepareCaptureViewport(tab, requestedWidth) { + const targetWidth = normalizeCaptureWidth(requestedWidth, null); + const beforeViewportWidth = await getTabViewportWidth(tab.id); + const noop = async () => {}; + if (!targetWidth || !beforeViewportWidth || Math.abs(beforeViewportWidth - targetWidth) <= 2) { + return { + restore: noop, + requestedWidth: targetWidth || beforeViewportWidth, + beforeViewportWidth, + actualViewportWidth: beforeViewportWidth, + resizedWindow: false + }; + } + + if (!tab.windowId || !chrome.windows?.get || !chrome.windows?.update) { + throw new Error("当前浏览器不支持临时调整采集视口宽度"); + } + + const originalWindow = await chrome.windows.get(tab.windowId); + const originalState = originalWindow.state || "normal"; + const originalBounds = { + left: originalWindow.left, + top: originalWindow.top, + width: originalWindow.width, + height: originalWindow.height + }; + + const restore = async () => { + try { + if (originalState !== "normal") { + await chrome.windows.update(tab.windowId, { state: "normal" }); + await delay(120); + } + const restoreBounds = definedWindowBounds(originalBounds); + if (Object.keys(restoreBounds).length) { + await chrome.windows.update(tab.windowId, restoreBounds); + } + if (originalState !== "normal") { + await delay(120); + await chrome.windows.update(tab.windowId, { state: originalState }); + } + await delay(250); + } catch { + // Restoring the user's window is best effort only. + } + }; + + try { + if (originalState !== "normal") { + await chrome.windows.update(tab.windowId, { state: "normal" }); + await delay(250); + } + + let currentViewportWidth = beforeViewportWidth; + let currentWindow = await chrome.windows.get(tab.windowId); + for (let attempt = 0; attempt < 2; attempt += 1) { + const delta = targetWidth - currentViewportWidth; + const nextWidth = Math.max(360, Math.round((currentWindow.width || targetWidth) + delta)); + await chrome.windows.update(tab.windowId, { width: nextWidth }); + await delay(650); + currentViewportWidth = await getTabViewportWidth(tab.id) || currentViewportWidth; + if (Math.abs(currentViewportWidth - targetWidth) <= 2) break; + currentWindow = await chrome.windows.get(tab.windowId); + } + + if (Math.abs(currentViewportWidth - targetWidth) > 2) { + throw new Error(`采集视口未生效:目标 ${targetWidth}px,实际 ${currentViewportWidth}px。请退出全屏或手动放宽浏览器窗口后重试。`); + } + + return { + restore, + requestedWidth: targetWidth, + beforeViewportWidth, + actualViewportWidth: currentViewportWidth, + resizedWindow: true + }; + } catch (error) { + await restore(); + throw error; + } +} + +async function captureCurrentTab(tab, settings) { + const viewport = await prepareCaptureViewport(tab, settings.captureWidth); + try { + const data = await runCapture(tab.id, { + ...settings, + captureWidth: viewport.requestedWidth + }); + data.capture = { + ...(data.capture || {}), + resizedWindow: viewport.resizedWindow, + usedTemporaryWindow: viewport.resizedWindow, + requestedWidth: viewport.requestedWidth || data.source?.actualViewportWidth || data.canvas?.width, + beforeViewportWidth: viewport.beforeViewportWidth, + actualViewportWidth: data.source?.actualViewportWidth || viewport.actualViewportWidth + }; + return data; + } finally { + await viewport.restore(); + } +} + +async function downloadCapture(data) { + const json = JSON.stringify(data, null, 2); + const encodedJson = arrayBufferToBase64(new TextEncoder().encode(json)); + const url = `data:application/json;charset=utf-8;base64,${encodedJson}`; + const title = data.source?.title || "webpage"; + const safeTitle = title + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, "-") + .slice(0, 64) || "webpage"; + const filename = `web-to-pixso/${safeTitle}-${Date.now()}.json`; + + await chrome.downloads.download({ + url, + filename, + saveAs: true + }); + + return filename; +} + +async function startCapture(options) { + const settings = normalizeSettings(options); + await chrome.storage.local.set({ [SETTINGS_KEY]: settings }); + const tab = await getActiveTab(); + const data = await captureCurrentTab(tab, settings); + const filename = await downloadCapture(data); + return { + ok: true, + filename, + actualViewportWidth: data.source?.actualViewportWidth, + requestedViewportWidth: data.source?.requestedViewportWidth, + usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow) + }; +} + +async function startCaptureFromSender(options, sender) { + const settings = normalizeSettings(options); + await chrome.storage.local.set({ [SETTINGS_KEY]: settings }); + const tab = sender?.tab || await getActiveTab(); + assertCaptureableTab(tab); + const data = await captureCurrentTab(tab, settings); + const filename = await downloadCapture(data); + return { + ok: true, + filename, + actualViewportWidth: data.source?.actualViewportWidth, + requestedViewportWidth: data.source?.requestedViewportWidth, + usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow) + }; +} + +async function captureClipboardFromSender(options, sender) { + const settings = normalizeSettings(options); + await chrome.storage.local.set({ [SETTINGS_KEY]: settings }); + const tab = sender?.tab || await getActiveTab(); + assertCaptureableTab(tab); + const data = await captureCurrentTab(tab, settings); + return { + ok: true, + json: JSON.stringify(data, null, 2), + actualViewportWidth: data.source?.actualViewportWidth, + requestedViewportWidth: data.source?.requestedViewportWidth, + usedTemporaryWindow: Boolean(data.capture?.usedTemporaryWindow) + }; +} + +async function startElementCapture(options, sender) { + const settings = { + ...normalizeSettings(options), + captureWidth: null, + selectionId: options?.selectionId, + selectionWidth: Math.max(1, Math.round(Number(options?.selectionWidth || 0))), + captureMode: "mixed" + }; + const tab = sender?.tab || await getActiveTab(); + assertCaptureableTab(tab); + const data = await runCapture(tab.id, settings); + const filename = await downloadCapture(data); + return { + ok: true, + filename, + actualViewportWidth: data.source?.actualViewportWidth, + requestedViewportWidth: data.source?.requestedViewportWidth, + selectionWidth: data.import?.defaultWidth + }; +} + +async function fetchWithTimeout(url, timeout = 10000) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + return await fetch(url, { + signal: controller.signal, + credentials: "include", + cache: "force-cache" + }); + } finally { + clearTimeout(timeoutId); + } +} + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ""; + + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + + return btoa(binary); +} + +async function proxyFetchAsset(url) { + if (!/^https?:\/\//i.test(url)) { + return { ok: false, error: "仅支持 http/https 图片代理" }; + } + + const response = await fetchWithTimeout(url, 12000); + if (!response.ok) { + return { ok: false, status: response.status, error: `HTTP ${response.status}` }; + } + + const contentType = response.headers.get("content-type") || "application/octet-stream"; + const buffer = await response.arrayBuffer(); + + return { + ok: true, + status: response.status, + contentType, + base64: arrayBufferToBase64(buffer) + }; +} + +async function captureVisibleTab(sender) { + if (!sender?.tab?.windowId || !chrome.tabs?.captureVisibleTab) { + return { ok: false, error: "当前标签页截图不可用" }; + } + + try { + const dataUrl = await chrome.tabs.captureVisibleTab(sender.tab.windowId, { + format: "png" + }); + return { ok: true, dataUrl }; + } catch (error) { + return { + ok: false, + error: error.message || String(error), + nonFatal: true + }; + } +} + +async function showInPagePanel(tab) { + assertCaptureableTab(tab); + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: [ELEMENT_PICKER_FILE, POPUP_PANEL_FILE] + }); + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => window.__webToPixsoShowPanel?.() + }); +} + +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.local.get({ [SETTINGS_KEY]: DEFAULT_SETTINGS }).then(result => { + chrome.storage.local.set({ [SETTINGS_KEY]: normalizeSettings(result[SETTINGS_KEY]) }); + }); +}); + +chrome.action.onClicked.addListener(tab => { + showInPagePanel(tab).catch(error => { + console.warn("[Web to Pixso] Failed to open panel", error); + }); +}); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.type === "PIXSO_CAPTURE_START") { + startCapture(message.options) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + if (message?.type === "PIXSO_CAPTURE_ELEMENT_START") { + startElementCapture(message.options, sender) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + if (message?.type === "PIXSO_CAPTURE_CURRENT_TAB") { + startCaptureFromSender(message.options, sender) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + if (message?.type === "PIXSO_CAPTURE_CLIPBOARD") { + captureClipboardFromSender(message.options, sender) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + if (message?.type === "PIXSO_CAPTURE_FETCH_ASSET") { + proxyFetchAsset(message.url) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + if (message?.type === "PIXSO_CAPTURE_VISIBLE_TAB") { + captureVisibleTab(sender) + .then(sendResponse) + .catch(error => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + + return false; +}); diff --git a/web-to-pixso/capture.js b/web-to-pixso/capture.js new file mode 100644 index 0000000..ad68999 --- /dev/null +++ b/web-to-pixso/capture.js @@ -0,0 +1,2018 @@ +(function () { + "use strict"; + + const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "META", "LINK", "TEMPLATE"]); + const STYLE_PROPS = [ + ["display", "display"], + ["visibility", "visibility"], + ["position", "position"], + ["zIndex", "z-index"], + ["opacity", "opacity"], + ["overflow", "overflow"], + ["overflowX", "overflow-x"], + ["overflowY", "overflow-y"], + ["backgroundColor", "background-color"], + ["backgroundImage", "background-image"], + ["backgroundPosition", "background-position"], + ["backgroundSize", "background-size"], + ["backgroundRepeat", "background-repeat"], + ["backgroundClip", "background-clip"], + ["backgroundOrigin", "background-origin"], + ["objectFit", "object-fit"], + ["objectPosition", "object-position"], + ["maskImage", "mask-image"], + ["webkitMaskImage", "-webkit-mask-image"], + ["maskPosition", "mask-position"], + ["webkitMaskPosition", "-webkit-mask-position"], + ["maskSize", "mask-size"], + ["webkitMaskSize", "-webkit-mask-size"], + ["borderTopWidth", "border-top-width"], + ["borderRightWidth", "border-right-width"], + ["borderBottomWidth", "border-bottom-width"], + ["borderLeftWidth", "border-left-width"], + ["borderTopColor", "border-top-color"], + ["borderRightColor", "border-right-color"], + ["borderBottomColor", "border-bottom-color"], + ["borderLeftColor", "border-left-color"], + ["borderTopStyle", "border-top-style"], + ["borderRightStyle", "border-right-style"], + ["borderBottomStyle", "border-bottom-style"], + ["borderLeftStyle", "border-left-style"], + ["borderTopLeftRadius", "border-top-left-radius"], + ["borderTopRightRadius", "border-top-right-radius"], + ["borderBottomRightRadius", "border-bottom-right-radius"], + ["borderBottomLeftRadius", "border-bottom-left-radius"], + ["boxShadow", "box-shadow"], + ["filter", "filter"], + ["backdropFilter", "backdrop-filter"], + ["color", "color"], + ["fontFamily", "font-family"], + ["fontSize", "font-size"], + ["fontWeight", "font-weight"], + ["fontStyle", "font-style"], + ["lineHeight", "line-height"], + ["letterSpacing", "letter-spacing"], + ["textAlign", "text-align"], + ["textTransform", "text-transform"], + ["textDecorationLine", "text-decoration-line"], + ["whiteSpace", "white-space"], + ["wordBreak", "word-break"], + ["transform", "transform"], + ["transformOrigin", "transform-origin"], + ["clipPath", "clip-path"] + ]; + const LAYOUT_PROPS = [ + ["flexDirection", "flex-direction"], + ["flexWrap", "flex-wrap"], + ["justifyContent", "justify-content"], + ["alignItems", "align-items"], + ["alignContent", "align-content"], + ["gap", "gap"], + ["rowGap", "row-gap"], + ["columnGap", "column-gap"], + ["gridTemplateColumns", "grid-template-columns"], + ["gridTemplateRows", "grid-template-rows"], + ["gridAutoFlow", "grid-auto-flow"], + ["paddingTop", "padding-top"], + ["paddingRight", "padding-right"], + ["paddingBottom", "padding-bottom"], + ["paddingLeft", "padding-left"], + ["marginTop", "margin-top"], + ["marginRight", "margin-right"], + ["marginBottom", "margin-bottom"], + ["marginLeft", "margin-left"], + ["width", "width"], + ["height", "height"], + ["minWidth", "min-width"], + ["maxWidth", "max-width"], + ["minHeight", "min-height"], + ["maxHeight", "max-height"] + ]; + + let nodeCounter = 0; + let rasterCounter = 0; + const CAPTURE_SCHEMA = "web-to-pixso-capture"; + const CAPTURE_SCHEMA_VERSION = "1.1.1"; + + function isWebToPixsoElement(element) { + if (!element?.closest) return false; + return Boolean(element.closest([ + "#__web_to_pixso_panel_root__", + "#__web_to_pixso_picker_root__", + "#__web_to_pixso_picker_box__", + "#__web_to_pixso_picker_label__" + ].join(","))); + } + + function absoluteRect(rect, style) { + const isViewportAnchored = style?.position === "fixed"; + return { + x: round(rect.left + (isViewportAnchored ? 0 : window.scrollX)), + y: round(rect.top + (isViewportAnchored ? 0 : window.scrollY)), + width: round(rect.width), + height: round(rect.height) + }; + } + + function round(value) { + return Math.round((Number(value) || 0) * 100) / 100; + } + + function isVisible(element, rect, style) { + if (!rect || rect.width <= 0 || rect.height <= 0) return false; + if (style.display === "none" || style.visibility === "hidden") return false; + if (Number(style.opacity) === 0) return false; + return true; + } + + function intersectsDocument(rect) { + const documentRect = getDocumentRect(); + return intersectRects(rect, documentRect); + } + + function intersectWithClip(rect, clipRect) { + return clipRect ? intersectRects(rect, clipRect) : intersectsDocument(rect); + } + + function intersectRects(a, b) { + if (!a) return b || null; + if (!b) return a || null; + const x1 = Math.max(a.x, b.x); + const y1 = Math.max(a.y, b.y); + const x2 = Math.min(a.x + a.width, b.x + b.width); + const y2 = Math.min(a.y + a.height, b.y + b.height); + if (x2 <= x1 || y2 <= y1) return null; + return { + x: round(x1), + y: round(y1), + width: round(x2 - x1), + height: round(y2 - y1) + }; + } + + function rectsDiffer(a, b, tolerance = 0.5) { + if (!a || !b) return false; + return Math.abs((a.x || 0) - (b.x || 0)) > tolerance || + Math.abs((a.y || 0) - (b.y || 0)) > tolerance || + Math.abs((a.width || 0) - (b.width || 0)) > tolerance || + Math.abs((a.height || 0) - (b.height || 0)) > tolerance; + } + + function clipsChildren(styles) { + const overflow = `${styles.overflow || ""} ${styles.overflowX || ""} ${styles.overflowY || ""}`; + return /(hidden|clip|scroll|auto)/i.test(overflow); + } + + function isCarouselClone(element) { + const className = String(element.className || ""); + if (/(^|\s)(slick-cloned|swiper-slide-duplicate|swiper-slide-duplicate-active|swiper-slide-duplicate-next|swiper-slide-duplicate-prev)(\s|$)/i.test(className)) { + return true; + } + if (element.getAttribute("data-swiper-slide-index") && /swiper-slide-duplicate/i.test(className)) { + return true; + } + if (element.getAttribute("aria-hidden") === "true" && /(^|\s)(slick-slide|swiper-slide)(\s|$)/i.test(className)) { + return true; + } + return false; + } + + function shouldClampToClip(element, rect, clipRect) { + if (!clipRect || !rect) return false; + const className = String(element.className || ""); + const isTrack = /(slick-track|swiper-wrapper|carousel|marquee|scroll|slider|slide-track)/i.test(className); + const isMedia = /^(IMG|PICTURE|VIDEO|CANVAS)$/i.test(element.tagName); + const isClippedMedia = isMedia && rectsDiffer(rect, clipRect); + const isVeryWide = rect.width > Math.max(window.innerWidth * 1.5, clipRect.width * 1.5); + const isVeryTall = rect.height > Math.max(window.innerHeight * 2, clipRect.height * 2); + return isTrack || isClippedMedia || isVeryWide || isVeryTall; + } + + function getChildNodes(element) { + const nodes = Array.from(element.childNodes || []); + if (element.shadowRoot) { + nodes.push(...Array.from(element.shadowRoot.childNodes || [])); + } + return nodes; + } + + function isHeaderLikeElement(element) { + if (!element) return false; + const name = `${element.tagName || ""} ${element.id || ""} ${element.className || ""} ${element.getAttribute?.("role") || ""}`; + return /^(HEADER|NAV)$/i.test(element.tagName || "") || + /header|nav|navigation|topbar|navbar|menu/i.test(String(name)); + } + + function isTopHeaderElement(element, rect, styles) { + if (!isHeaderLikeElement(element)) return false; + const tag = element?.tagName || ""; + const name = `${tag} ${element?.id || ""} ${element?.className || ""} ${element?.getAttribute?.("role") || ""}`; + const fixedOrSticky = styles?.position === "fixed" || styles?.position === "sticky"; + const nearTop = (rect?.y || 0) <= 140 || fixedOrSticky; + const headerSized = (rect?.height || 0) >= 20 && + (rect?.height || 0) <= Math.max(180, window.innerHeight * 0.18) && + (rect?.width || 0) >= window.innerWidth * 0.35; + const strongSemantic = /^(HEADER)$/i.test(tag) || /cloud-header|site-header|page-header|topbar|navbar|navigation/i.test(String(name)); + const navSemantic = /^(NAV)$/i.test(tag) || /nav|menu/i.test(String(name)); + return nearTop && headerSized && (strongSemantic || navSemantic); + } + + function inferSection(element, rect, styles, inheritedSection) { + if (inheritedSection && inheritedSection !== "document") return inheritedSection; + const tag = element?.tagName || ""; + const name = `${tag} ${element?.id || ""} ${element?.className || ""} ${element?.getAttribute?.("role") || ""}`; + if (isTopHeaderElement(element, rect, styles)) return "header"; + if (/^(FOOTER)$/i.test(tag) || /footer/i.test(String(name))) return "footer"; + if (rect?.y < Math.max(window.innerHeight * 1.4, 900) && /hero|banner|kv|carousel|swiper|slick|slider|slide|campaign|masthead/i.test(String(name))) { + return "hero"; + } + if (styles && isLikelyHeroOrCarousel(element, rect, styles)) return "hero"; + return inheritedSection || "content"; + } + + function layerMetaFor(type, options = {}) { + if (options.rasterMode === "comparison") { + return { layerGroup: "comparison", layerPriority: 0, rasterRole: "comparison" }; + } + if (options.rasterMode) { + return { layerGroup: "comparison", layerPriority: 0, rasterRole: options.rasterMode }; + } + if (type === "TEXT") { + return { layerGroup: "text", layerPriority: 200 }; + } + return { layerGroup: "editable", layerPriority: 100 }; + } + + function inferRasterSection(target = {}) { + const name = String(target.name || ""); + const mode = String(target.mode || ""); + if (/header/i.test(name)) return "header"; + if (/footer/i.test(name)) return "footer"; + if (/hero|carousel/i.test(name) || mode === "hero-background") return "hero"; + if (mode === "comparison") return "document"; + return "content"; + } + + function getDocumentRect() { + const doc = document.documentElement; + const body = document.body; + return { + x: 0, + y: 0, + width: Math.max(window.innerWidth, doc.clientWidth), + height: Math.max(doc.scrollHeight, body.scrollHeight, doc.clientHeight) + }; + } + + function getViewportRect() { + return { + x: window.scrollX, + y: window.scrollY, + width: window.innerWidth, + height: window.innerHeight + }; + } + + function extractStyles(computed) { + const styles = {}; + for (const [key, cssProp] of STYLE_PROPS) { + styles[key] = computed.getPropertyValue(cssProp); + } + return styles; + } + + function parsePixels(value, fallback = 0) { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; + } + + function countDistinctPositions(rects, key, tolerance = 6) { + const positions = []; + for (const rect of rects || []) { + const value = rect?.[key]; + if (!Number.isFinite(value)) continue; + if (!positions.some(position => Math.abs(position - value) <= tolerance)) { + positions.push(value); + } + } + return positions.length; + } + + function inferGridMetrics(childRects, gap = 0) { + const tolerance = Math.max(6, Math.min(18, Number(gap || 0) * 0.5)); + const columns = countDistinctPositions(childRects, "x", tolerance); + const rows = countDistinctPositions(childRects, "y", tolerance); + return { + columnCount: Math.max(1, columns || 1), + rowCount: Math.max(1, rows || 1) + }; + } + + function isGridLikeLayout({ isGrid, isFlex, isTable, semanticLayout, styles, childRects, inferredGrid }) { + if (isGrid) return true; + if (!childRects?.length || childRects.length < 4) return false; + const hasRowsAndColumns = inferredGrid.columnCount >= 2 && inferredGrid.rowCount >= 2; + if (!hasRowsAndColumns) return false; + const wraps = isFlex && /wrap/i.test(styles.flexWrap || ""); + const semanticGrid = semanticLayout && /grid|cards|card|product|goods|coupon|list|items|row|cols|columns|footer/i.test(String(styles.className || "")); + const visuallyGrid = inferredGrid.columnCount * inferredGrid.rowCount >= Math.max(4, childRects.length * 0.7); + return !isTable && visuallyGrid && (wraps || semanticLayout || semanticGrid); + } + + function extractLayout(computed, element, rect) { + const display = computed.getPropertyValue("display"); + const isFlex = /\b(inline-)?flex\b/i.test(display); + const isGrid = /\b(inline-)?grid\b/i.test(display); + const isTable = /\btable\b/i.test(display); + const children = Array.from(element.children || []).filter(child => { + const childRect = child.getBoundingClientRect(); + const childStyle = window.getComputedStyle(child); + return isVisible(child, childRect, childStyle); + }); + const hasLayoutChildren = children.length >= 2; + const className = String(element.className || ""); + const semanticLayout = /nav|menu|list|grid|row|column|cols|cards|items|tabs|footer|header|toolbar|form|actions|buttons/i.test(className); + + if (!isFlex && !isGrid && !isTable && !semanticLayout) return null; + if (!hasLayoutChildren && !isFlex && !isGrid) return null; + + const styles = {}; + for (const [key, cssProp] of LAYOUT_PROPS) { + styles[key] = computed.getPropertyValue(cssProp); + } + styles.className = className; + + let inferredDirection = styles.flexDirection || "row"; + if (!isFlex && hasLayoutChildren) { + const rects = children.map(child => child.getBoundingClientRect()); + const sameRowCount = rects.filter(childRect => Math.abs(childRect.top - rects[0].top) < 6).length; + inferredDirection = sameRowCount >= Math.max(2, children.length * 0.65) ? "row" : "column"; + } + + const childRects = children.map(child => { + const childStyle = window.getComputedStyle(child); + return absoluteRect(child.getBoundingClientRect(), childStyle); + }); + const inferredGrid = inferGridMetrics(childRects, parsePixels(styles.columnGap || styles.gap)); + const gridLike = isGridLikeLayout({ isGrid, isFlex, isTable, semanticLayout, styles, childRects, inferredGrid }); + const layoutType = gridLike ? "grid" : isFlex ? "flex" : isTable ? "table" : "inferred"; + + return { + type: layoutType, + display, + direction: inferredDirection, + wrap: styles.flexWrap, + justifyContent: styles.justifyContent, + alignItems: styles.alignItems, + alignContent: styles.alignContent, + gap: parsePixels(styles.gap), + rowGap: parsePixels(styles.rowGap), + columnGap: parsePixels(styles.columnGap), + padding: { + top: parsePixels(styles.paddingTop), + right: parsePixels(styles.paddingRight), + bottom: parsePixels(styles.paddingBottom), + left: parsePixels(styles.paddingLeft) + }, + margin: { + top: parsePixels(styles.marginTop), + right: parsePixels(styles.marginRight), + bottom: parsePixels(styles.marginBottom), + left: parsePixels(styles.marginLeft) + }, + grid: gridLike ? { + columns: styles.gridTemplateColumns, + rows: styles.gridTemplateRows, + autoFlow: styles.gridAutoFlow, + columnCount: inferredGrid.columnCount, + rowCount: inferredGrid.rowCount + } : null, + inferredGrid, + sizing: { + width: styles.width, + height: styles.height, + minWidth: styles.minWidth, + maxWidth: styles.maxWidth, + minHeight: styles.minHeight, + maxHeight: styles.maxHeight + }, + childCount: children.length, + childRects, + confidence: isFlex || isGrid ? 1 : gridLike ? 0.86 : semanticLayout ? 0.72 : 0.55, + rect: { + width: round(rect.width), + height: round(rect.height) + } + }; + } + + function collectAttributes(element) { + const attrs = {}; + for (const attr of Array.from(element.attributes || [])) { + if ( + attr.name === "id" || + attr.name === "class" || + attr.name === "alt" || + attr.name === "title" || + attr.name === "href" || + attr.name === "src" || + attr.name === "role" || + attr.name.startsWith("aria-") + ) { + attrs[attr.name] = attr.value; + } + } + return attrs; + } + + function extractBackgroundUrls(value) { + if (!value || value === "none") return []; + const urls = []; + const re = /url\((["']?)(.*?)\1\)/g; + let match; + while ((match = re.exec(value))) { + if (!match[2] || match[2] === "undefined" || match[2] === "null") continue; + try { + const url = new URL(match[2], document.baseURI).href; + if (!/\/undefined(?:$|[?#])/.test(url)) urls.push(url); + } catch { + urls.push(match[2]); + } + } + return urls; + } + + function extractStyleImageUrls(styles) { + return [ + ...extractBackgroundUrls(styles.backgroundImage), + ...extractBackgroundUrls(styles.maskImage), + ...extractBackgroundUrls(styles.webkitMaskImage) + ]; + } + + function parseSrcset(value) { + if (!value) return []; + return String(value) + .split(",") + .map(item => item.trim().split(/\s+/)[0]) + .filter(Boolean); + } + + function splitConcatenatedUrls(value) { + if (!value) return []; + const text = String(value).trim(); + if (!text) return []; + const matches = text.match(/https?:\/\/(?:(?!https?:\/\/).)+/gi); + if (matches && matches.length > 1) return matches; + return [text]; + } + + function canUseDescendantSources(element) { + return /^(PICTURE|VIDEO|AUDIO)$/i.test(element.tagName); + } + + function getElementImageCandidates(element) { + const candidates = []; + const add = value => { + for (const item of splitConcatenatedUrls(value)) { + const normalized = normalizeAssetUrl(item); + if (normalized) candidates.push(normalized); + } + }; + + add(element.currentSrc); + add(element.src); + add(element.poster); + add(element.getAttribute?.("src")); + add(element.getAttribute?.("data-src")); + add(element.getAttribute?.("data-original")); + add(element.getAttribute?.("data-lazy")); + add(element.getAttribute?.("data-lazy-src")); + add(element.getAttribute?.("data-bg")); + add(element.getAttribute?.("data-background")); + + for (const value of parseSrcset(element.srcset || element.getAttribute?.("srcset"))) { + add(value); + } + if (canUseDescendantSources(element)) { + for (const source of Array.from(element.querySelectorAll?.("source") || [])) { + for (const value of parseSrcset(source.srcset || source.getAttribute("srcset"))) { + add(value); + } + add(source.src); + } + } + + return Array.from(new Set(candidates)); + } + + function normalizeText(value) { + return String(value || "").replace(/\s+/g, "").trim(); + } + + function overlapRatio(a, b) { + const intersection = intersectRects(a, b); + if (!intersection) return 0; + const area = Math.max(1, Math.min( + (a.width || 1) * (a.height || 1), + (b.width || 1) * (b.height || 1) + )); + return (intersection.width * intersection.height) / area; + } + + function hasMaskImage(styles) { + return Boolean( + (styles.maskImage && styles.maskImage !== "none") || + (styles.webkitMaskImage && styles.webkitMaskImage !== "none") + ); + } + + function hasUsableBackgroundImage(styles) { + return Boolean(styles.backgroundImage && styles.backgroundImage !== "none"); + } + + function hasVisiblePaintColor(value) { + return Boolean(value && value !== "transparent" && !/rgba\([^)]*,\s*0(?:\.0+)?\)/i.test(value)); + } + + function isLightOpaquePaint(value) { + if (!value || value === "transparent") return false; + const rgba = String(value).match(/rgba?\(([^)]+)\)/i); + if (rgba) { + const parts = rgba[1].split(",").map(part => parseFloat(part.trim())); + const alpha = parts[3] == null ? 1 : parts[3]; + return alpha > 0.92 && parts[0] >= 235 && parts[1] >= 235 && parts[2] >= 235; + } + const hex = String(value).trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (!hex) return false; + const body = hex[1].length === 3 ? hex[1].split("").map(char => char + char).join("") : hex[1]; + return parseInt(body.slice(0, 2), 16) >= 235 && + parseInt(body.slice(2, 4), 16) >= 235 && + parseInt(body.slice(4, 6), 16) >= 235; + } + + function getMaxBorderWidth(styles) { + return Math.max( + parsePixels(styles.borderTopWidth), + parsePixels(styles.borderRightWidth), + parsePixels(styles.borderBottomWidth), + parsePixels(styles.borderLeftWidth) + ); + } + + function hasCornerRadius(styles) { + return Boolean( + parsePixels(styles.borderTopLeftRadius) || + parsePixels(styles.borderTopRightRadius) || + parsePixels(styles.borderBottomRightRadius) || + parsePixels(styles.borderBottomLeftRadius) + ); + } + + function classifyVisualRole(element, styles, children, currentSrc, backgroundUrls, layout) { + const tag = element?.tagName || ""; + if (/^(IMG|PICTURE|VIDEO|CANVAS|SOURCE)$/i.test(tag) || currentSrc || backgroundUrls?.length) { + return "image-node"; + } + const hasDecor = getMaxBorderWidth(styles) > 0 || + hasCornerRadius(styles) || + (styles.boxShadow && styles.boxShadow !== "none") || + (styles.filter && /drop-shadow/i.test(styles.filter)); + const isContainer = /^(HTML|BODY|DIV|SECTION|MAIN|ARTICLE|HEADER|NAV|FOOTER|ASIDE|UL|OL|LI|FORM)$/i.test(tag); + if ( + (layout || children?.length) && + isContainer && + !hasUsableBackgroundImage(styles) && + !hasDecor && + (!hasVisiblePaintColor(styles.backgroundColor) || isLightOpaquePaint(styles.backgroundColor)) + ) { + return "layout-wrapper"; + } + if ( + hasVisiblePaintColor(styles.backgroundColor) || + hasUsableBackgroundImage(styles) || + hasDecor + ) { + return "paint-node"; + } + if (layout || children?.length) return "layout-wrapper"; + return "paint-node"; + } + + function shouldSkipPseudoBackground(styles, parentRect) { + if (!styles.backgroundImage || styles.backgroundImage === "none") return false; + if (styles.backgroundRepeat && styles.backgroundRepeat !== "no-repeat") return true; + if (/auto\s+\d+px/i.test(styles.backgroundSize || "")) return true; + if (/-\d+(?:\.\d+)?px/.test(styles.backgroundPosition || "")) return true; + const parentArea = Math.max(1, (parentRect?.width || 1) * (parentRect?.height || 1)); + if (parentArea > 12000 && /(?:^|\s)(?:\d+px\s+auto|auto\s+\d+px|auto)(?:\s|$)/i.test(styles.backgroundSize || "")) return true; + return false; + } + + function hasMeaningfulElementChildren(element) { + return Array.from(element.children || []).some(child => { + const rect = child.getBoundingClientRect(); + const style = window.getComputedStyle(child); + return rect.width > 0 && + rect.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity || 1) > 0; + }); + } + + function hasWhiteTextDescendant(element) { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let textNode = walker.nextNode(); + while (textNode) { + if (textNode.textContent.trim()) { + const parent = textNode.parentElement; + if (parent) { + const color = window.getComputedStyle(parent).color; + if (/rgb\(255,\s*255,\s*255\)|rgba\(255,\s*255,\s*255,\s*(?:0\.\d+|1)\)/i.test(color)) { + return true; + } + } + } + textNode = walker.nextNode(); + } + return false; + } + + function visibleTextLength(element) { + return normalizeText(element.innerText).length; + } + + function shouldSkipTextNode(textNode, parentStyle) { + const parent = textNode.parentElement; + if (!parent) return true; + if (parent.closest("[hidden], [aria-hidden='true']")) return true; + if (parentStyle.display === "none" || parentStyle.visibility === "hidden") return true; + if (Number(parentStyle.opacity || 1) < 0.04) return true; + if (parseFloat(parentStyle.fontSize || "0") <= 0) return true; + const color = parentStyle.color || ""; + if (/rgba\([^)]*,\s*0(?:\.0+)?\)/i.test(color)) return true; + return false; + } + + function pushCaptureChild(children, childNode) { + if (!childNode) return; + if (childNode.type === "TEXT") { + const text = normalizeText(childNode.text); + if (!text) return; + const duplicate = children.some(existing => { + if (existing.type !== "TEXT") return false; + if (normalizeText(existing.text) !== text) return false; + return overlapRatio(existing.visibleRect || existing.rect, childNode.visibleRect || childNode.rect) > 0.72; + }); + if (duplicate) return; + } + children.push(childNode); + } + + function isUsefulHeaderElement(element, headerRect) { + if (!element || isWebToPixsoElement(element)) return false; + if (/^(HTML|BODY|SCRIPT|STYLE|META|LINK)$/i.test(element.tagName || "")) return false; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) return false; + const absRect = absoluteRect(rect, style); + const intersection = intersectRects(absRect, headerRect); + if (!intersection) return false; + const overlap = (intersection.width * intersection.height) / Math.max(1, absRect.width * absRect.height); + if (overlap < 0.55) return false; + if (absRect.width > headerRect.width * 0.92 && absRect.height > headerRect.height * 0.8) return false; + + const text = normalizeText(element.innerText || element.textContent || element.getAttribute?.("aria-label") || element.getAttribute?.("alt") || element.getAttribute?.("placeholder") || ""); + const name = `${element.tagName || ""} ${element.id || ""} ${element.className || ""} ${element.getAttribute?.("role") || ""}`; + const hasMedia = /^(IMG|SVG|PICTURE|CANVAS)$/i.test(element.tagName || "") || Boolean(element.querySelector?.("img, svg, picture, canvas")); + const isControl = /^(A|BUTTON|INPUT|SELECT|TEXTAREA|LABEL)$/i.test(element.tagName || "") || + /button|btn|link|logo|icon|search|input|nav|menu|item|login|register/i.test(String(name)); + return Boolean(text || hasMedia || isControl); + } + + function collectHeaderPointElements(headerRect) { + const found = new Set(); + const xSteps = Math.max(8, Math.min(28, Math.ceil(headerRect.width / 88))); + const ySteps = Math.max(2, Math.min(6, Math.ceil(headerRect.height / 24))); + for (let yIndex = 0; yIndex <= ySteps; yIndex += 1) { + const y = Math.min(window.innerHeight - 1, Math.max(0, headerRect.y + (headerRect.height * yIndex / Math.max(1, ySteps)))); + for (let xIndex = 0; xIndex <= xSteps; xIndex += 1) { + const x = Math.min(window.innerWidth - 1, Math.max(0, headerRect.x + (headerRect.width * xIndex / Math.max(1, xSteps)))); + for (const element of document.elementsFromPoint(x, y) || []) { + if (isUsefulHeaderElement(element, headerRect)) { + found.add(element); + } + } + } + } + + return Array.from(found) + .filter(element => { + return !Array.from(found).some(other => other !== element && other.contains(element) && isUsefulHeaderElement(other, headerRect)); + }) + .sort((a, b) => { + const ar = a.getBoundingClientRect(); + const br = b.getBoundingClientRect(); + if (Math.abs(ar.top - br.top) > 4) return ar.top - br.top; + return ar.left - br.left; + }); + } + + function isProbablyComplexCard(element, absRect, styles) { + if (element.tagName === "HTML" || element.tagName === "BODY") return false; + if (absRect.y < window.innerHeight * 2.2) return false; + if (absRect.width < 240 || absRect.width > Math.min(window.innerWidth * 0.75, 980)) return false; + if (absRect.height < 150 || absRect.height > 640) return false; + + const imageUrls = extractStyleImageUrls(styles); + const hasImgChild = Boolean(element.querySelector("img, picture, video")); + const hasWhiteText = hasWhiteTextDescendant(element); + const hasRoundedCard = parseFloat(styles.borderTopLeftRadius || "0") > 0 || + parseFloat(styles.borderTopRightRadius || "0") > 0 || + parseFloat(styles.borderBottomLeftRadius || "0") > 0 || + parseFloat(styles.borderBottomRightRadius || "0") > 0; + const hasCardClass = /card|case|customer|client|solution|scene|cert|safe|security|item|slide|swiper/i.test(String(element.className || "")); + const hasVisualFill = imageUrls.length > 0 || + (styles.backgroundColor && !/rgba\(0,\s*0,\s*0,\s*0\)|transparent/i.test(styles.backgroundColor)); + if (visibleTextLength(element) < 8 && !hasImgChild && !imageUrls.length && !hasCardClass) return false; + + return hasWhiteText || hasImgChild || (hasVisualFill && (hasRoundedCard || hasCardClass)); + } + + function addTargetIfNonOverlapping(targets, target, maxOverlapRatio = 0.65) { + const area = Math.max(1, target.rect.width * target.rect.height); + const overlaps = targets.some(item => { + const intersection = intersectRects(item.rect, target.rect); + if (!intersection) return false; + const intersectionArea = intersection.width * intersection.height; + return intersectionArea / area > maxOverlapRatio; + }); + if (!overlaps) targets.push(target); + } + + function clippedViewportRect(absRect, minHeight = 1) { + if (!absRect) return null; + const rect = intersectRects(absRect, { + x: 0, + y: absRect.y, + width: window.innerWidth, + height: Math.min(absRect.height, window.innerHeight) + }); + if (!rect || rect.height < minHeight || rect.width < 1) return null; + return rect; + } + + function getSelectionElement(options = {}) { + if (!options.selectionId) return null; + try { + const raw = String(options.selectionId); + const escaped = window.CSS?.escape + ? CSS.escape(raw) + : raw.replace(/["\\]/g, "\\$&"); + return document.querySelector(`[data-web-to-pixso-selection-id="${escaped}"]`); + } catch { + return null; + } + } + + function getSelectionRect(element) { + if (!element) return null; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) return null; + return absoluteRect(rect, style); + } + + function textNodeToCaptureNode(textNode, clipRect, context = {}) { + const text = textNode.textContent.replace(/\s+/g, " ").trim(); + if (!text) return null; + + const range = document.createRange(); + range.selectNodeContents(textNode); + const rect = range.getBoundingClientRect(); + range.detach(); + + const parentStyle = window.getComputedStyle(textNode.parentElement); + if (shouldSkipTextNode(textNode, parentStyle)) return null; + const absRect = absoluteRect(rect, parentStyle); + const visibleRect = intersectWithClip(absRect, clipRect); + if (!visibleRect || visibleRect.width <= 0 || visibleRect.height <= 0) return null; + + return { + id: `text-${++nodeCounter}`, + type: "TEXT", + tag: "#text", + name: text.slice(0, 40), + text, + rect: absRect, + visibleRect, + clipped: visibleRect.width !== absRect.width || visibleRect.height !== absRect.height, + visualRole: "text-node", + section: context.section || "content", + ...layerMetaFor("TEXT"), + styles: extractStyles(parentStyle), + attributes: {}, + children: [] + }; + } + + function elementLabelToTextNode(element, clipRect, context = {}) { + const text = normalizeText( + element.getAttribute?.("aria-label") || + element.getAttribute?.("alt") || + element.getAttribute?.("title") || + element.getAttribute?.("placeholder") || + element.value || + "" + ); + if (!text) return null; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + if (!isVisible(element, rect, style)) return null; + const absRect = absoluteRect(rect, style); + const visibleRect = intersectWithClip(absRect, clipRect); + if (!visibleRect) return null; + return { + id: `text-${++nodeCounter}`, + type: "TEXT", + tag: "#text", + name: text.slice(0, 40), + text, + rect: absRect, + visibleRect, + clipped: visibleRect.width !== absRect.width || visibleRect.height !== absRect.height, + visualRole: "text-node", + section: context.section || "content", + ...layerMetaFor("TEXT"), + styles: extractStyles(style), + attributes: { "data-generated-from": "element-label" }, + children: [] + }; + } + + function pseudoElementToCaptureNode(element, pseudo, parentRect, assetUrls, clipRect, context = {}) { + const computed = window.getComputedStyle(element, pseudo); + if (!computed || computed.display === "none" || computed.visibility === "hidden") return null; + + const styles = extractStyles(computed); + const rawContent = computed.getPropertyValue("content"); + const text = rawContent && rawContent !== "none" && rawContent !== "normal" + ? rawContent.replace(/^["']|["']$/g, "") + : ""; + const backgroundUrls = extractStyleImageUrls(styles); + if (text && normalizeText(element.innerText).includes(normalizeText(text))) { + return null; + } + if (!text && !backgroundUrls.length) { + return null; + } + if (!text && hasMaskImage(styles) && !hasUsableBackgroundImage(styles) && parentRect.width * parentRect.height > 4096) { + return null; + } + if (!text && backgroundUrls.length && shouldSkipPseudoBackground(styles, parentRect)) { + return null; + } + for (const url of backgroundUrls) { + assetUrls.add(url); + } + + const hasVisibleBackground = ( + styles.backgroundColor && + styles.backgroundColor !== "rgba(0, 0, 0, 0)" && + styles.backgroundColor !== "transparent" + ) || backgroundUrls.length; + + if (!text && !hasVisibleBackground) return null; + + const absRect = absoluteRect(parentRect, styles); + const visibleRect = intersectWithClip(absRect, clipRect); + if (!visibleRect) return null; + + return { + id: `pseudo-${++nodeCounter}`, + type: text ? "TEXT" : "RECTANGLE", + tag: pseudo, + name: pseudo, + text, + rect: absRect, + visibleRect, + clipped: visibleRect.width !== absRect.width || visibleRect.height !== absRect.height, + visualRole: text ? "text-node" : "paint-node", + section: context.section || "content", + ...layerMetaFor(text ? "TEXT" : "RECTANGLE"), + styles, + attributes: {}, + backgroundImages: backgroundUrls, + children: [] + }; + } + + function elementToCaptureNode(element, assetUrls, clipRect, diagnostics, context = {}) { + if (!element || SKIP_TAGS.has(element.tagName)) return null; + if (isWebToPixsoElement(element)) return null; + if (isCarouselClone(element)) { + if (diagnostics) diagnostics.skippedCarouselClones += 1; + return null; + } + + const computed = window.getComputedStyle(element); + const clientRect = element.getBoundingClientRect(); + if (!isVisible(element, clientRect, computed)) return null; + + const styles = extractStyles(computed); + const absRect = element.tagName === "BODY" || element.tagName === "HTML" + ? getDocumentRect() + : absoluteRect(clientRect, styles); + const section = element.tagName === "HTML" || element.tagName === "BODY" + ? "document" + : inferSection(element, absRect, styles, context.section); + const childContext = { section }; + const layout = extractLayout(computed, element, absRect); + const documentClip = clipRect || getDocumentRect(); + const visibleRect = intersectRects(absRect, documentClip); + if (!visibleRect) { + if (diagnostics) diagnostics.skippedOutsideClip += 1; + return null; + } + + const ownClip = clipsChildren(styles) || element.tagName === "BODY" || element.tagName === "HTML" + ? intersectRects(documentClip, absRect) || visibleRect + : documentClip; + const renderRect = shouldClampToClip(element, absRect, visibleRect) ? visibleRect : absRect; + const attrs = collectAttributes(element); + const children = []; + const imageCandidates = getElementImageCandidates(element); + const currentSrc = imageCandidates[0] || null; + const backgroundUrls = extractStyleImageUrls(styles); + + if (/^(IMG|VIDEO|SOURCE|PICTURE)$/i.test(element.tagName)) { + for (const url of imageCandidates) { + assetUrls.add(url); + } + } + + for (const url of backgroundUrls) { + assetUrls.add(url); + } + + const beforeNode = pseudoElementToCaptureNode(element, "::before", clientRect, assetUrls, ownClip, childContext); + pushCaptureChild(children, beforeNode); + + for (const child of getChildNodes(element)) { + if (child.nodeType === Node.TEXT_NODE) { + const textNode = textNodeToCaptureNode(child, ownClip, childContext); + pushCaptureChild(children, textNode); + } else if (child.nodeType === Node.ELEMENT_NODE) { + const childNode = elementToCaptureNode(child, assetUrls, ownClip, diagnostics, childContext); + pushCaptureChild(children, childNode); + } + } + + if (!children.some(child => child.type === "TEXT")) { + pushCaptureChild(children, elementLabelToTextNode(element, ownClip, childContext)); + } + + if (isHeaderLikeElement(element) && children.length === 0) { + for (const headerElement of collectHeaderPointElements(absRect)) { + if (headerElement === element || element.contains(headerElement)) continue; + const childNode = elementToCaptureNode(headerElement, assetUrls, ownClip, diagnostics, { section: "header" }); + pushCaptureChild(children, childNode); + } + } + + const afterNode = pseudoElementToCaptureNode(element, "::after", clientRect, assetUrls, ownClip, childContext); + pushCaptureChild(children, afterNode); + + const type = element.tagName === "IMG" || backgroundUrls.length ? "RECTANGLE" : "FRAME"; + return { + id: `node-${++nodeCounter}`, + type, + tag: element.tagName, + name: attrs.id || attrs.class || element.tagName.toLowerCase(), + rect: renderRect, + sourceRect: absRect, + visibleRect, + clipRect: ownClip, + clipped: renderRect.width !== absRect.width || renderRect.height !== absRect.height, + visualRole: classifyVisualRole(element, styles, children, currentSrc, backgroundUrls, layout), + section, + ...layerMetaFor(type), + styles, + layout, + attributes: attrs, + src: currentSrc, + backgroundImages: backgroundUrls, + children + }; + } + + function normalizeAssetUrl(url) { + if (!url || url === "undefined" || url === "null") return null; + try { + const absolute = new URL(url, document.baseURI).href; + if (/\/undefined(?:$|[?#])/.test(absolute)) return null; + return absolute; + } catch { + return null; + } + } + + function toDataUrl(contentType, base64) { + return `data:${contentType || "application/octet-stream"};base64,${base64}`; + } + + function blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + + async function directFetchAsset(url) { + const response = await fetch(url, { credentials: "include", cache: "force-cache" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + return { + ok: true, + mimeType: blob.type || response.headers.get("content-type") || "application/octet-stream", + data: await blobToDataUrl(blob) + }; + } + + function captureVideoPoster(url) { + const video = Array.from(document.querySelectorAll("video")).find(item => { + const source = normalizeAssetUrl(item.currentSrc || item.src || item.querySelector("source")?.src); + return source === url; + }); + if (!video || !video.videoWidth || !video.videoHeight) return null; + + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext("2d"); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + return canvasToPng(canvas); + } + + async function proxyFetchAsset(url) { + if (!chrome?.runtime?.sendMessage) { + throw new Error("扩展代理不可用"); + } + const response = await chrome.runtime.sendMessage({ + type: "PIXSO_CAPTURE_FETCH_ASSET", + url + }); + if (!response?.ok) { + throw new Error(response?.error || "代理拉取失败"); + } + return { + ok: true, + mimeType: response.contentType || "application/octet-stream", + data: toDataUrl(response.contentType, response.base64) + }; + } + + async function captureVisibleTabImage() { + if (!chrome?.runtime?.sendMessage) { + return null; + } + let response = null; + try { + response = await chrome.runtime.sendMessage({ + type: "PIXSO_CAPTURE_VISIBLE_TAB" + }); + } catch (error) { + console.warn("[Web to Pixso] Raster fallback message failed", error); + return null; + } + if (!response?.ok || !response.dataUrl) { + console.warn("[Web to Pixso] Raster fallback skipped", response?.error || "标签页截图失败"); + return null; + } + const image = new Image(); + image.decoding = "async"; + image.src = response.dataUrl; + try { + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + }); + return image; + } catch (error) { + console.warn("[Web to Pixso] Raster fallback image decode failed", error); + return null; + } + } + + async function cropViewportRegion(rect, scrollY) { + const image = await captureVisibleTabImage(); + if (!image) return null; + const ratioX = image.naturalWidth / Math.max(1, window.innerWidth); + const ratioY = image.naturalHeight / Math.max(1, window.innerHeight); + const sourceX = Math.max(0, Math.round(rect.x * ratioX)); + const sourceY = Math.max(0, Math.round((rect.y - scrollY) * ratioY)); + const sourceWidth = Math.min(image.naturalWidth - sourceX, Math.round(rect.width * ratioX)); + const sourceHeight = Math.min(image.naturalHeight - sourceY, Math.round(rect.height * ratioY)); + if (sourceWidth <= 0 || sourceHeight <= 0) return null; + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(rect.width)); + canvas.height = Math.max(1, Math.round(rect.height)); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, canvas.width, canvas.height); + return canvasToPng(canvas); + } + + function hideFixedTopOverlays() { + const hidden = []; + for (const element of Array.from(document.body?.querySelectorAll("*") || [])) { + if (isWebToPixsoElement(element)) continue; + const style = window.getComputedStyle(element); + if (style.position !== "fixed" && style.position !== "sticky") continue; + const rect = element.getBoundingClientRect(); + if (rect.width < 8 || rect.height < 8) continue; + const topBar = rect.width >= window.innerWidth * 0.25 && + rect.height >= 20 && + rect.height <= 180 && + rect.top <= 140 && + rect.bottom >= 0; + const sideFloat = rect.right >= window.innerWidth - 160 && + rect.width <= 180 && + rect.height >= 40; + const bottomFloat = rect.bottom >= window.innerHeight - 120 && + rect.width <= Math.max(720, window.innerWidth * 0.55) && + rect.height <= 180; + if (!topBar && !sideFloat && !bottomFloat) continue; + const previous = { + element, + value: element.style.getPropertyValue("visibility"), + priority: element.style.getPropertyPriority("visibility") + }; + element.style.setProperty("visibility", "hidden", "important"); + hidden.push(previous); + } + + return () => { + for (const item of hidden) { + if (item.value) { + item.element.style.setProperty("visibility", item.value, item.priority || ""); + } else { + item.element.style.removeProperty("visibility"); + } + } + }; + } + + function hideWebToPixsoOverlays() { + const hidden = []; + for (const selector of [ + "#__web_to_pixso_panel_root__", + "#__web_to_pixso_picker_root__", + "#__web_to_pixso_picker_box__", + "#__web_to_pixso_picker_label__" + ]) { + const element = document.querySelector(selector); + if (!element) continue; + hidden.push({ + element, + visibility: element.style.getPropertyValue("visibility"), + visibilityPriority: element.style.getPropertyPriority("visibility"), + display: element.style.getPropertyValue("display"), + displayPriority: element.style.getPropertyPriority("display"), + opacity: element.style.getPropertyValue("opacity"), + opacityPriority: element.style.getPropertyPriority("opacity") + }); + element.style.setProperty("visibility", "hidden", "important"); + element.style.setProperty("opacity", "0", "important"); + element.style.setProperty("display", "none", "important"); + } + return () => { + for (const item of hidden) { + if (item.visibility) { + item.element.style.setProperty("visibility", item.visibility, item.visibilityPriority || ""); + } else { + item.element.style.removeProperty("visibility"); + } + if (item.display) { + item.element.style.setProperty("display", item.display, item.displayPriority || ""); + } else { + item.element.style.removeProperty("display"); + } + if (item.opacity) { + item.element.style.setProperty("opacity", item.opacity, item.opacityPriority || ""); + } else { + item.element.style.removeProperty("opacity"); + } + } + }; + } + + function hideTextForBackgroundRaster(targetRect) { + const style = document.createElement("style"); + style.setAttribute("data-web-to-pixso-background-raster", "true"); + style.textContent = ` + html body *:not(#__web_to_pixso_panel_root__):not(#__web_to_pixso_picker_root__) { + color: transparent !important; + -webkit-text-fill-color: transparent !important; + text-shadow: none !important; + caret-color: transparent !important; + } + html body *::before, + html body *::after, + html body input::placeholder, + html body textarea::placeholder { + color: transparent !important; + -webkit-text-fill-color: transparent !important; + text-shadow: none !important; + } + `; + document.documentElement.appendChild(style); + return () => style.remove(); + } + + function hideHeroForegroundForRaster(targetRect) { + const hidden = []; + const targetArea = Math.max(1, targetRect.width * targetRect.height); + const isTransparent = value => !value || /transparent|rgba\([^)]*,\s*0(?:\.0+)?\)/i.test(value); + + for (const element of Array.from(document.body?.querySelectorAll("*") || [])) { + if (isWebToPixsoElement(element)) continue; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + const intersection = intersectRects(absRect, targetRect); + if (!intersection) continue; + + const tag = element.tagName || ""; + const areaRatio = (absRect.width * absRect.height) / targetArea; + const text = normalizeText(element.innerText || element.textContent || ""); + const name = `${tag} ${element.id || ""} ${element.className || ""} ${element.getAttribute?.("role") || ""}`; + const hasFill = !isTransparent(style.backgroundColor) || + (style.backgroundImage && style.backgroundImage !== "none") || + parseFloat(style.borderTopWidth || "0") > 0 || + parseFloat(style.borderRightWidth || "0") > 0 || + parseFloat(style.borderBottomWidth || "0") > 0 || + parseFloat(style.borderLeftWidth || "0") > 0 || + (style.boxShadow && style.boxShadow !== "none"); + const isMedia = /^(IMG|PICTURE|VIDEO|CANVAS)$/i.test(tag); + const isLargeMedia = isMedia && areaRatio > 0.08 && absRect.width > 260 && absRect.height > 140; + const isControl = /^(A|BUTTON|INPUT|SELECT|TEXTAREA|LABEL|SVG|I|EM)$/i.test(tag) || + /button|btn|link|nav|menu|tab|card|coupon|activity|hot|item|tag|badge|icon|service|float|customer|consult|sidebar/i.test(String(name)); + const isForegroundBlock = areaRatio < 0.34 && ( + isControl || + (text.length > 0 && areaRatio < 0.24) || + (hasFill && areaRatio < 0.24) + ); + + if (!isForegroundBlock || isLargeMedia) continue; + + hidden.push({ + element, + visibility: element.style.getPropertyValue("visibility"), + visibilityPriority: element.style.getPropertyPriority("visibility") + }); + element.style.setProperty("visibility", "hidden", "important"); + } + + return () => { + for (const item of hidden) { + if (item.visibility) { + item.element.style.setProperty("visibility", item.visibility, item.visibilityPriority || ""); + } else { + item.element.style.removeProperty("visibility"); + } + } + }; + } + + function waitForPaint(ms = 80) { + return new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.setTimeout(resolve, ms); + }); + }); + }); + } + + function hidePageScrollbars() { + const style = document.createElement("style"); + style.textContent = ` + html, body { + scrollbar-width: none !important; + -ms-overflow-style: none !important; + } + html::-webkit-scrollbar, + body::-webkit-scrollbar, + *::-webkit-scrollbar { + width: 0 !important; + height: 0 !important; + display: none !important; + } + `; + document.documentElement.appendChild(style); + return () => style.remove(); + } + + function isLikelyHeroOrCarousel(element, absRect, styles) { + const name = `${element.id || ""} ${element.className || ""} ${element.getAttribute?.("role") || ""}`; + const isNamed = /hero|banner|kv|carousel|swiper|slick|slider|slide|campaign|masthead/i.test(String(name)); + const isMedia = /^(PICTURE|VIDEO|CANVAS)$/i.test(element.tagName) || Boolean(element.querySelector?.("picture, video, canvas")); + const nearTop = absRect.y < Math.max(window.innerHeight * 1.4, 900); + const wide = absRect.width >= window.innerWidth * 0.65; + const tall = absRect.height >= 180; + const visual = isMedia || extractStyleImageUrls(styles).length > 0 || /gradient|url\(/i.test(styles.backgroundImage || ""); + return nearTop && wide && tall && (isNamed || visual); + } + + function isLikelyFullWidthVisualSection(element, absRect, styles) { + if (element.tagName === "HTML" || element.tagName === "BODY") return false; + const name = `${element.id || ""} ${element.className || ""}`; + const named = /banner|hero|section|panel|visual|picture|media|video|campaign|activity|recommend|promo|feature/i.test(String(name)); + const media = /^(PICTURE|VIDEO|CANVAS)$/i.test(element.tagName) || Boolean(element.querySelector?.("picture, video, canvas")); + const hasImage = extractStyleImageUrls(styles).length > 0 || Boolean(element.querySelector?.("img, picture, video, canvas")); + const wide = absRect.width >= window.innerWidth * 0.78; + const tall = absRect.height >= 220; + const saneHeight = absRect.height <= window.innerHeight * 1.05; + return wide && tall && saneHeight && (media || hasImage || named); + } + + function addRasterTarget(targets, target, overlap = 0.7) { + if (!target?.rect) return; + if (target.rect.width < 2 || target.rect.height < 2) return; + const viewportRect = { + x: 0, + y: target.rect.y, + width: window.innerWidth, + height: Math.min(target.rect.height, window.innerHeight) + }; + if (!intersectRects(target.rect, viewportRect)) return; + addTargetIfNonOverlapping(targets, target, overlap); + } + + function addRasterTargetSlices(targets, target, overlap = 0.7) { + if (!target?.rect) return; + const maxHeight = Math.max(320, Math.floor(window.innerHeight * 0.86)); + const totalHeight = Math.max(1, target.rect.height); + if (totalHeight <= maxHeight) { + addRasterTarget(targets, target, overlap); + return; + } + + let offset = 0; + let index = 1; + while (offset < totalHeight - 1) { + const remaining = totalHeight - offset; + const height = Math.min(maxHeight, remaining); + addRasterTarget(targets, { + ...target, + name: `${target.name} ${index}`, + rect: { + x: target.rect.x, + y: round(target.rect.y + offset), + width: target.rect.width, + height: round(height) + } + }, overlap); + offset += height; + index += 1; + } + } + + function countKeyword(text, pattern) { + return (String(text || "").match(pattern) || []).length; + } + + function isLikelyCommerceSection(element, absRect) { + if (!element || element.tagName === "HTML" || element.tagName === "BODY") return false; + if (absRect.y < 240) return false; + if (absRect.width < window.innerWidth * 0.62) return false; + if (absRect.height < 64 || absRect.height > getDocumentRect().height * 0.85) return false; + + const name = `${element.id || ""} ${element.className || ""}`.trim(); + if (/^app$/i.test(name)) return false; + + const text = normalizeText(element.innerText || ""); + const actionCount = countKeyword(text, /立即购买|立即开通|立即领取|免费体验|了解详情/g); + const cardCount = countKeyword(text, /云服务器|云数据库|对象存储|CDN|LSS|OCR|API|价格|配置/g); + const named = /module|product|coupon|goods|package|card|recommend|activity|campaign|promo|experience|权益|优惠|more|hot/i.test(name); + const contentNamed = /新用户|上云|产品|特惠|优惠|活动|推荐|更多产品|企业实名|价格|立即/g.test(text); + + return named && contentNamed || actionCount >= 3 || cardCount >= 4; + } + + function getRasterFallbackTargets(options = {}) { + const targets = []; + const mixedMode = options.captureMode !== "editable"; + const selectionElement = getSelectionElement(options); + const selectionRect = getSelectionRect(selectionElement); + if (selectionRect) { + addRasterTarget(targets, { + name: "Selected element raster fallback", + rect: clippedViewportRect(selectionRect, 1) || selectionRect, + mode: "visual" + }, 1); + return targets; + } + + for (const element of Array.from(document.querySelectorAll("header, nav, [role='navigation'], [class*='header' i], [class*='nav' i]"))) { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + const nearTop = absRect.y <= 120 || style.position === "fixed" || style.position === "sticky"; + const likelyHeader = rect.height >= 24 && rect.height <= 160 && rect.width >= window.innerWidth * 0.45; + if (!nearTop || !likelyHeader) continue; + addRasterTarget(targets, { + name: "Header raster fallback", + rect: absRect, + mode: "visual" + }, 0.8); + break; + } + + if (mixedMode) { + const heroCandidates = Array.from(document.querySelectorAll([ + "section", + "main > div", + "[role='banner']", + "[class*='hero' i]", + "[class*='banner' i]", + "[class*='kv' i]", + "[class*='carousel' i]", + "[class*='swiper' i]", + "[class*='slick' i]", + "[class*='slider' i]", + "[class*='slide' i]", + "[class*='campaign' i]", + "picture", + "video", + "canvas" + ].join(","))); + const heroElements = new Set(); + for (const element of heroCandidates) { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + const styles = extractStyles(style); + if (!isLikelyHeroOrCarousel(element, absRect, styles)) continue; + if (heroElements.has(element)) continue; + heroElements.add(element); + const targetRect = clippedViewportRect(absRect, 160); + if (!targetRect) continue; + addRasterTarget(targets, { + name: "Hero carousel raster fallback", + rect: targetRect, + mode: "hero-background" + }, 0.45); + if (heroElements.size >= 3) break; + } + + const commerceCandidates = Array.from(document.querySelectorAll([ + "section", + "main > div", + "[class*='module' i]", + "[class*='product' i]", + "[class*='coupon' i]", + "[class*='goods' i]", + "[class*='package' i]", + "[class*='recommend' i]", + "[class*='activity' i]", + "[class*='campaign' i]", + "[class*='promo' i]", + "[class*='more' i]", + "[class*='experience' i]" + ].join(","))); + const commerceTargets = []; + for (const element of commerceCandidates) { + if (commerceTargets.length >= 12) break; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + if (!isLikelyCommerceSection(element, absRect)) continue; + addRasterTargetSlices(commerceTargets, { + name: "Commerce section raster fallback", + mode: "background", + rect: { + x: Math.max(0, absRect.x), + y: absRect.y, + width: Math.min(window.innerWidth, absRect.width), + height: absRect.height + } + }, 0.68); + } + targets.push(...commerceTargets); + + const sectionCandidates = Array.from(document.querySelectorAll([ + "section", + "article", + "[class*='banner' i]", + "[class*='panel' i]", + "[class*='visual' i]", + "[class*='media' i]", + "[class*='feature' i]", + "[class*='activity' i]", + "[class*='promo' i]", + "[class*='recommend' i]", + "picture", + "video", + "canvas" + ].join(","))); + for (const element of sectionCandidates) { + if (targets.filter(target => target.name === "Visual section raster fallback").length >= 10) break; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + const styles = extractStyles(style); + if (!isLikelyFullWidthVisualSection(element, absRect, styles)) continue; + const targetRect = clippedViewportRect(absRect, 180); + if (!targetRect) continue; + addRasterTarget(targets, { + name: "Visual section raster fallback", + rect: targetRect, + mode: "background" + }, 0.55); + } + } + + const footer = Array.from(document.querySelectorAll("footer, [class*='footer' i]")) + .map(element => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) return null; + const absRect = absoluteRect(rect, style); + if (absRect.y < window.innerHeight * 3) return null; + if (absRect.width < window.innerWidth * 0.45 || absRect.height < 180) return null; + return { + name: "Footer raster fallback", + mode: "background", + rect: { + x: Math.max(0, absRect.x), + y: absRect.y, + width: Math.min(window.innerWidth, absRect.width), + height: Math.min(absRect.height, window.innerHeight * 0.95) + } + }; + }) + .filter(Boolean) + .sort((a, b) => b.rect.height - a.rect.height)[0]; + if (footer) addRasterTarget(targets, footer, 0.75); + + const complexCards = []; + const candidates = Array.from(document.querySelectorAll([ + "a", + "li", + "article", + "section", + "section div", + "[class*='card' i]", + "[class*='case' i]", + "[class*='customer' i]", + "[class*='client' i]", + "[class*='solution' i]", + "[class*='scene' i]", + "[class*='safe' i]", + "[class*='security' i]", + "[class*='cert' i]", + "[class*='swiper-slide' i]" + ].join(","))); + for (const element of candidates) { + if (complexCards.length >= 18) break; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + if (!isVisible(element, rect, style)) continue; + const absRect = absoluteRect(rect, style); + const styles = extractStyles(style); + if (!isProbablyComplexCard(element, absRect, styles)) continue; + addRasterTarget(complexCards, { + name: "Complex card raster fallback", + rect: absRect, + mode: "background" + }, 0.65); + } + targets.push(...complexCards); + return targets; + } + + async function collectRasterFallbacks(options = {}) { + const nodes = []; + const assets = {}; + const targets = getRasterFallbackTargets(options); + const originalX = window.scrollX; + const originalY = window.scrollY; + const restoreScrollbars = hidePageScrollbars(); + + try { + for (const target of targets) { + try { + const scrollY = Math.max(0, Math.min(target.rect.y, document.documentElement.scrollHeight - window.innerHeight)); + window.scrollTo(0, scrollY); + await new Promise(resolve => setTimeout(resolve, 620)); + const restorePluginOverlays = hideWebToPixsoOverlays(); + const restoreText = target.mode === "visual" + ? () => {} + : hideTextForBackgroundRaster(target.rect); + const restoreHeroForeground = target.mode === "hero-background" + ? hideHeroForegroundForRaster(target.rect) + : () => {}; + const restoreOverlays = target.name.includes("Header") + ? () => {} + : hideFixedTopOverlays(); + let asset = null; + try { + await waitForPaint(target.mode === "visual" ? 20 : 90); + asset = await cropViewportRegion(target.rect, window.scrollY); + } finally { + restoreOverlays(); + restoreHeroForeground(); + restoreText(); + restorePluginOverlays(); + } + if (!asset?.data) continue; + const key = `web-to-pixso-raster://${++rasterCounter}`; + assets[key] = { + type: "image", + mimeType: asset.mimeType || "image/png", + data: asset.data + }; + nodes.push({ + id: `raster-${rasterCounter}`, + type: "RECTANGLE", + tag: "RASTER_FALLBACK", + name: target.name, + rect: target.rect, + visibleRect: target.rect, + clipped: false, + visualRole: "raster-fallback", + section: inferRasterSection(target), + ...layerMetaFor("RECTANGLE", { rasterMode: target.mode || "background" }), + styles: { + display: "block", + visibility: "visible", + position: target.name.includes("Header") ? "fixed" : "absolute", + zIndex: target.name.includes("Header") ? "20" : "1", + opacity: "1", + overflow: "hidden", + backgroundColor: "rgba(0, 0, 0, 0)", + backgroundImage: `url("${key}")`, + backgroundSize: "100% 100%", + backgroundRepeat: "no-repeat", + borderTopWidth: "0px", + borderRightWidth: "0px", + borderBottomWidth: "0px", + borderLeftWidth: "0px", + boxShadow: "none", + filter: "none", + color: "rgba(0, 0, 0, 0.85)", + transform: "none" + }, + attributes: { + "data-raster-fallback": "true", + "data-raster-mode": target.mode || "background", + "data-layer-group": "comparison", + "data-section": inferRasterSection(target) + }, + rasterMode: target.mode || "background", + backgroundImages: [key], + children: [] + }); + } catch (error) { + console.warn("[Web to Pixso] Raster fallback failed", error); + } + } + } finally { + restoreScrollbars(); + window.scrollTo(originalX, originalY); + } + + await new Promise(resolve => setTimeout(resolve, 120)); + return { nodes, assets }; + } + + async function collectComparisonFallbacks(options = {}) { + const nodes = []; + const assets = {}; + const documentRect = getDocumentRect(); + const width = Math.max(1, Math.round(window.innerWidth || documentRect.width || Number(options.captureWidth || 0) || 1440)); + const totalHeight = Math.max(1, Math.round(documentRect.height || document.documentElement.scrollHeight || window.innerHeight)); + const sliceHeight = Math.max(320, Math.floor(window.innerHeight * 0.86)); + const originalX = window.scrollX; + const originalY = window.scrollY; + const restoreScrollbars = hidePageScrollbars(); + + try { + let y = 0; + let index = 1; + while (y < totalHeight - 1) { + const height = Math.min(sliceHeight, totalHeight - y); + const rect = { + x: 0, + y, + width, + height + }; + try { + const scrollY = Math.max(0, Math.min(y, document.documentElement.scrollHeight - window.innerHeight)); + window.scrollTo(0, scrollY); + await new Promise(resolve => setTimeout(resolve, 420)); + const restorePluginOverlays = hideWebToPixsoOverlays(); + const restoreFixedOverlays = options.keepFixedOverlays === true + ? () => {} + : hideFixedTopOverlays(); + let asset = null; + try { + await waitForPaint(40); + asset = await cropViewportRegion(rect, window.scrollY); + } finally { + restoreFixedOverlays(); + restorePluginOverlays(); + } + if (asset?.data) { + const key = `web-to-pixso-raster://${++rasterCounter}`; + assets[key] = { + type: "image", + mimeType: asset.mimeType || "image/png", + data: asset.data + }; + nodes.push({ + id: `raster-${rasterCounter}`, + type: "RECTANGLE", + tag: "RASTER_FALLBACK", + name: `对比底图-${width}px${totalHeight > sliceHeight ? ` ${index}` : ""}`, + rect, + visibleRect: rect, + clipped: false, + visualRole: "raster-fallback", + section: "document", + ...layerMetaFor("RECTANGLE", { rasterMode: "comparison" }), + styles: { + display: "block", + visibility: "visible", + position: "absolute", + zIndex: "0", + opacity: "1", + overflow: "hidden", + backgroundColor: "rgba(0, 0, 0, 0)", + backgroundImage: `url("${key}")`, + backgroundSize: "100% 100%", + backgroundRepeat: "no-repeat", + borderTopWidth: "0px", + borderRightWidth: "0px", + borderBottomWidth: "0px", + borderLeftWidth: "0px", + boxShadow: "none", + filter: "none", + color: "rgba(0, 0, 0, 0.85)", + transform: "none" + }, + attributes: { + "data-raster-fallback": "true", + "data-raster-mode": "comparison", + "data-layer-group": "comparison", + "data-section": "document" + }, + rasterMode: "comparison", + backgroundImages: [key], + children: [] + }); + } + } catch (error) { + console.warn("[Web to Pixso] Comparison fallback failed", error); + } + y += height; + index += 1; + } + } finally { + restoreScrollbars(); + window.scrollTo(originalX, originalY); + } + + await new Promise(resolve => setTimeout(resolve, 120)); + return { nodes, assets }; + } + + function canvasToPng(canvas) { + return new Promise(resolve => { + canvas.toBlob(blob => { + if (!blob) { + resolve(null); + return; + } + blobToDataUrl(blob).then(data => resolve({ + mimeType: "image/png", + data + })); + }, "image/png"); + }); + } + + async function normalizeAssetForPixso(asset) { + if (!asset?.data) return asset; + if (/^image\/(png|jpe?g|webp)$/i.test(asset.mimeType || "")) { + return asset; + } + + try { + const image = new Image(); + image.decoding = "async"; + image.src = asset.data; + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + }); + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, image.naturalWidth || image.width || 1); + canvas.height = Math.max(1, image.naturalHeight || image.height || 1); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + + return await canvasToPng(canvas) || asset; + } catch (error) { + console.warn("[Web to Pixso] Image normalization failed", error); + return asset; + } + } + + async function fetchAsset(url, useProxy) { + const videoPoster = await captureVideoPoster(url); + if (videoPoster) return videoPoster; + + if (useProxy) { + try { + return await proxyFetchAsset(url); + } catch (proxyError) { + console.warn("[Web to Pixso] Proxy image fetch failed, trying direct fetch", proxyError); + return directFetchAsset(url); + } + } + + try { + return await directFetchAsset(url); + } catch (directError) { + console.warn("[Web to Pixso] Direct image fetch failed, trying extension proxy", directError); + return proxyFetchAsset(url); + } + } + + async function collectAssets(urls, options) { + const assets = {}; + const queue = Array.from(new Set(Array.from(urls).map(url => normalizeAssetUrl(url)).filter(Boolean))); + const rawConcurrency = options.concurrency === "infinite" + ? queue.length || 1 + : Number(options.concurrency || 8); + const concurrency = Math.max(1, Math.min(queue.length || 1, rawConcurrency || 8)); + let cursor = 0; + + async function worker() { + while (cursor < queue.length) { + const url = queue[cursor++]; + try { + const result = await normalizeAssetForPixso(await fetchAsset(url, options.useProxy)); + assets[url] = { + type: "image", + mimeType: result.mimeType, + data: result.data + }; + } catch (error) { + assets[url] = { + type: "image", + error: error.message || String(error) + }; + } + } + } + + await Promise.all(Array.from({ length: concurrency }, worker)); + return assets; + } + + function collectFonts(root) { + const fonts = {}; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let node = walker.currentNode; + + while (node) { + const style = window.getComputedStyle(node); + const family = style.fontFamily; + if (family) { + fonts[family] = { + family, + weight: style.fontWeight, + style: style.fontStyle + }; + } + node = walker.nextNode(); + } + + return fonts; + } + + function countLayoutNodes(captureNode) { + if (!captureNode) return 0; + let count = captureNode.layout ? 1 : 0; + for (const child of captureNode.children || []) { + count += countLayoutNodes(child); + } + return count; + } + + window.__webToPixsoCapture = async function capture(options = {}) { + nodeCounter = 0; + rasterCounter = 0; + const assetUrls = new Set(); + const selectionElement = getSelectionElement(options); + const selectionRect = getSelectionRect(selectionElement); + const diagnostics = { + skippedCarouselClones: 0, + skippedOutsideClip: 0, + selectedElement: Boolean(selectionElement) + }; + let comparisonFallbacks = { nodes: [], assets: {} }; + if (!selectionRect && options.comparisonFallback !== false) { + try { + comparisonFallbacks = await collectComparisonFallbacks(options); + } catch (error) { + console.warn("[Web to Pixso] Comparison fallback collection skipped", error); + } + } + const documentRect = selectionRect || getDocumentRect(); + const root = elementToCaptureNode(selectionElement || document.documentElement, assetUrls, documentRect, diagnostics); + const viewportRect = selectionRect || getViewportRect(); + let rasterFallbacks = { nodes: [], assets: {} }; + if (options.rasterFallbacks !== false && options.captureMode !== "editable") { + try { + rasterFallbacks = await collectRasterFallbacks(options); + } catch (error) { + console.warn("[Web to Pixso] Raster fallback collection skipped", error); + } + } + if (root?.children) { + if (comparisonFallbacks.nodes.length) root.children.push(...comparisonFallbacks.nodes); + if (rasterFallbacks.nodes.length) root.children.push(...rasterFallbacks.nodes); + } + const assets = { + ...(await collectAssets(assetUrls, { + useProxy: Boolean(options.useProxy), + concurrency: options.concurrency || "8" + })), + ...comparisonFallbacks.assets, + ...rasterFallbacks.assets + }; + + return { + schema: CAPTURE_SCHEMA, + schemaVersion: CAPTURE_SCHEMA_VERSION, + format: "pixso-design-capture", + version: CAPTURE_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + source: { + title: document.title || location.hostname || "Web Capture", + url: location.href, + viewport: viewportRect, + document: documentRect, + devicePixelRatio: window.devicePixelRatio || 1, + requestedViewportWidth: Math.max(1, Math.round(Number(options.captureWidth || 0))) || window.innerWidth || viewportRect.width, + actualViewportWidth: window.innerWidth, + selection: selectionRect ? { + x: selectionRect.x, + y: selectionRect.y, + width: selectionRect.width, + height: selectionRect.height, + elementName: selectionElement?.tagName?.toLowerCase() || "element" + } : null + }, + canvas: { + x: selectionRect?.x || 0, + y: selectionRect?.y || 0, + width: viewportRect.width, + height: documentRect.height, + backgroundColor: window.getComputedStyle(document.body).backgroundColor || "#ffffff" + }, + import: { + defaultWidth: selectionRect?.width || Math.max(1, Math.round(Number(options.captureWidth || 0))) || window.innerWidth || viewportRect.width || 1440, + captureMode: options.captureMode === "editable" ? "editable" : "mixed" + }, + nodes: root, + assets, + fonts: collectFonts(document.documentElement), + diagnostics: { + nodeCount: nodeCounter, + assetCount: Object.keys(assets).length, + failedAssetCount: Object.values(assets).filter(asset => asset.error).length, + layoutNodeCount: countLayoutNodes(root), + comparisonFallbackCount: comparisonFallbacks.nodes.length, + rasterFallbackCount: rasterFallbacks.nodes.length, + captureMode: options.captureMode === "editable" ? "editable" : "mixed", + ...diagnostics + } + }; + }; +})(); diff --git a/web-to-pixso/element-picker.js b/web-to-pixso/element-picker.js new file mode 100644 index 0000000..3a47c41 --- /dev/null +++ b/web-to-pixso/element-picker.js @@ -0,0 +1,455 @@ +(function () { + "use strict"; + + const ROOT_ID = "__web_to_pixso_picker_root__"; + const BOX_ID = "__web_to_pixso_picker_box__"; + const LABEL_ID = "__web_to_pixso_picker_label__"; + const ATTR = "data-web-to-pixso-selection-id"; + const STORAGE_KEY = "__web_to_pixso_island_position__"; + + let currentElement = null; + let currentSettings = null; + let mode = "toolbar"; + let dragging = null; + + function removePicker() { + document.getElementById(ROOT_ID)?.remove(); + document.getElementById(BOX_ID)?.remove(); + document.getElementById(LABEL_ID)?.remove(); + document.removeEventListener("mousemove", onMouseMove, true); + document.removeEventListener("click", onClick, true); + document.removeEventListener("keydown", onKeyDown, true); + document.removeEventListener("pointermove", onDragMove, true); + document.removeEventListener("pointerup", onDragEnd, true); + currentElement = null; + mode = "toolbar"; + dragging = null; + } + + function elementName(element) { + if (!element) return ""; + const tag = element.tagName ? element.tagName.toLowerCase() : "element"; + const id = element.id ? `#${element.id}` : ""; + const className = String(element.className || "") + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map(item => `.${item}`) + .join(""); + return `${tag}${id}${className}`; + } + + function icon(type) { + if (type === "screen") { + return ''; + } + if (type === "select") { + return ''; + } + if (type === "copy") { + return ''; + } + return ''; + } + + function rootCss() { + return ` + #${ROOT_ID} { + color-scheme: light; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + left: 50%; + position: fixed; + top: 24px; + transform: translateX(-50%); + user-select: none; + z-index: 2147483647; + } + #${ROOT_ID} .w2p-island { + align-items: stretch; + background: rgba(36, 36, 38, 0.97); + border-radius: 22px; + box-shadow: 0 8px 24px rgba(0,0,0,.22); + color: white; + display: flex; + min-height: 56px; + overflow: hidden; + } + #${ROOT_ID} .w2p-action, + #${ROOT_ID} .w2p-close, + #${ROOT_ID} .w2p-cancel { + align-items: center; + background: transparent; + border: 0; + color: white; + cursor: pointer; + display: flex; + font: inherit; + gap: 10px; + justify-content: center; + min-height: 56px; + padding: 0 18px; + white-space: nowrap; + } + #${ROOT_ID} .w2p-action { + border-right: 1px solid rgba(255,255,255,.14); + font-size: 16px; + font-weight: 650; + } + #${ROOT_ID} .w2p-action:hover, + #${ROOT_ID} .w2p-close:hover, + #${ROOT_ID} .w2p-cancel:hover { + background: rgba(255,255,255,.1); + } + #${ROOT_ID} .w2p-action.active { + background: rgba(255,255,255,.12); + } + #${ROOT_ID} svg { + fill: none; + height: 20px; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; + width: 20px; + } + #${ROOT_ID} .w2p-close { + min-width: 56px; + padding: 0 16px; + } + #${ROOT_ID} .w2p-status { + align-items: center; + display: flex; + gap: 12px; + min-height: 56px; + padding: 0 20px; + } + #${ROOT_ID} .w2p-text { + font-size: 16px; + font-weight: 650; + white-space: nowrap; + } + #${ROOT_ID} .w2p-spinner { + animation: w2p-spin .9s linear infinite; + border: 2px solid rgba(255,255,255,.32); + border-radius: 50%; + border-top-color: #fff; + height: 20px; + width: 20px; + } + #${ROOT_ID} .w2p-cancel { + border-left: 1px solid rgba(255,255,255,.14); + font-size: 15px; + padding: 0 18px; + } + #${BOX_ID} { + background: rgba(46, 156, 255, .16); + border: 2px dashed #1593ff; + border-radius: 6px; + box-sizing: border-box; + display: none; + left: 0; + pointer-events: none; + position: fixed; + top: 0; + z-index: 2147483646; + } + #${LABEL_ID} { + background: #fff; + border-radius: 6px; + box-shadow: 0 8px 22px rgba(0,0,0,.2); + color: #333; + display: none; + font: 14px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + max-width: 280px; + overflow: hidden; + padding: 8px 10px; + pointer-events: none; + position: fixed; + text-overflow: ellipsis; + white-space: nowrap; + z-index: 2147483647; + } + @keyframes w2p-spin { to { transform: rotate(360deg); } } + `; + } + + function ensureBase() { + document.getElementById(ROOT_ID)?.remove(); + const root = document.createElement("div"); + root.id = ROOT_ID; + const style = document.createElement("style"); + style.textContent = rootCss(); + root.appendChild(style); + document.documentElement.appendChild(root); + + let box = document.getElementById(BOX_ID); + if (!box) { + box = document.createElement("div"); + box.id = BOX_ID; + document.documentElement.appendChild(box); + } + + let label = document.getElementById(LABEL_ID); + if (!label) { + label = document.createElement("div"); + label.id = LABEL_ID; + document.documentElement.appendChild(label); + } + + restorePosition(root); + root.addEventListener("pointerdown", onDragStart, true); + return root; + } + + function restorePosition(root) { + try { + const saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "null"); + if (saved && Number.isFinite(saved.left) && Number.isFinite(saved.top)) { + root.style.left = `${saved.left}px`; + root.style.top = `${saved.top}px`; + root.style.transform = "none"; + } + } catch { + // Keep the default centered position when storage is unavailable. + } + } + + function savePosition(root) { + const rect = root.getBoundingClientRect(); + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ + left: Math.round(rect.left), + top: Math.round(rect.top) + })); + } catch { + // Position persistence is only a convenience. + } + } + + function clampIsland(root, snap) { + const rect = root.getBoundingClientRect(); + let left = rect.left; + let top = rect.top; + const gap = 10; + left = Math.max(gap, Math.min(window.innerWidth - rect.width - gap, left)); + top = Math.max(gap, Math.min(window.innerHeight - rect.height - gap, top)); + if (snap) { + const distances = [ + { side: "left", value: left }, + { side: "right", value: window.innerWidth - left - rect.width }, + { side: "top", value: top }, + { side: "bottom", value: window.innerHeight - top - rect.height } + ].sort((a, b) => a.value - b.value); + if (distances[0].value < 96) { + if (distances[0].side === "left") left = gap; + if (distances[0].side === "right") left = window.innerWidth - rect.width - gap; + if (distances[0].side === "top") top = gap; + if (distances[0].side === "bottom") top = window.innerHeight - rect.height - gap; + } + } + root.style.left = `${Math.round(left)}px`; + root.style.top = `${Math.round(top)}px`; + root.style.transform = "none"; + savePosition(root); + } + + function onDragStart(event) { + const target = event.target; + if (target?.closest?.("button")) return; + const root = document.getElementById(ROOT_ID); + if (!root) return; + const rect = root.getBoundingClientRect(); + dragging = { + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top + }; + root.style.transform = "none"; + document.addEventListener("pointermove", onDragMove, true); + document.addEventListener("pointerup", onDragEnd, true); + } + + function onDragMove(event) { + if (!dragging) return; + event.preventDefault(); + const root = document.getElementById(ROOT_ID); + if (!root) return; + root.style.left = `${event.clientX - dragging.offsetX}px`; + root.style.top = `${event.clientY - dragging.offsetY}px`; + } + + function onDragEnd() { + const root = document.getElementById(ROOT_ID); + dragging = null; + document.removeEventListener("pointermove", onDragMove, true); + document.removeEventListener("pointerup", onDragEnd, true); + if (root) clampIsland(root, true); + } + + function setToolbarStatus(text, busy = true) { + const root = document.getElementById(ROOT_ID) || ensureBase(); + root.innerHTML = ` +
+
+ ${busy ? '' : ""} + +
+ +
`; + root.querySelector(".w2p-text").textContent = text; + root.querySelector(".w2p-cancel").addEventListener("click", removePicker); + root.addEventListener("pointerdown", onDragStart, true); + clampIsland(root, false); + } + + function showToolbar(settings) { + removePicker(); + currentSettings = settings || {}; + mode = "toolbar"; + const root = ensureBase(); + root.innerHTML = ` +
+ + + + +
`; + root.addEventListener("pointerdown", onDragStart, true); + root.querySelector('[data-action="close"]').addEventListener("click", removePicker); + root.querySelector('[data-action="select"]').addEventListener("click", () => startElementPicker(currentSettings)); + root.querySelector('[data-action="screen"]').addEventListener("click", () => startFullPageCapture(false)); + root.querySelector('[data-action="clipboard"]').addEventListener("click", () => startFullPageCapture(true)); + clampIsland(root, false); + } + + async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0"; + document.documentElement.appendChild(textarea); + textarea.focus(); + textarea.select(); + const ok = document.execCommand("copy"); + textarea.remove(); + if (!ok) throw new Error("浏览器拒绝写入剪贴板"); + return true; + } + } + + async function startFullPageCapture(copyToClipboard) { + setToolbarStatus(copyToClipboard ? "正在将页面捕获到剪贴板" : "正在捕获整个页面"); + try { + const response = await chrome.runtime.sendMessage({ + type: copyToClipboard ? "PIXSO_CAPTURE_CLIPBOARD" : "PIXSO_CAPTURE_CURRENT_TAB", + options: currentSettings || {} + }); + if (!response?.ok) throw new Error(response?.error || "采集失败"); + if (copyToClipboard) { + await copyText(response.json); + setToolbarStatus("已复制 JSON 到剪贴板", false); + } else { + setToolbarStatus("整页采集完成", false); + } + setTimeout(removePicker, 1100); + } catch (error) { + setToolbarStatus(error.message || String(error), false); + setTimeout(showToolbar, 2200, currentSettings); + } + } + + function hideHighlight() { + const box = document.getElementById(BOX_ID); + const label = document.getElementById(LABEL_ID); + if (box) box.style.display = "none"; + if (label) label.style.display = "none"; + } + + function updateHighlight(element) { + const box = document.getElementById(BOX_ID); + const label = document.getElementById(LABEL_ID); + if (!box || !label || !element) return; + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + + box.style.display = "block"; + box.style.left = `${Math.max(0, rect.left)}px`; + box.style.top = `${Math.max(0, rect.top)}px`; + box.style.width = `${rect.width}px`; + box.style.height = `${rect.height}px`; + + label.style.display = "block"; + label.textContent = elementName(element); + label.style.left = `${Math.max(8, rect.left)}px`; + label.style.top = `${Math.max(8, Math.min(window.innerHeight - 36, rect.bottom + 8))}px`; + } + + function isPickerNode(element) { + return Boolean(element?.closest?.(`#${ROOT_ID}, #${BOX_ID}, #${LABEL_ID}`)); + } + + function onMouseMove(event) { + if (mode !== "select") return; + const element = event.target; + if (!element || isPickerNode(element)) return; + currentElement = element; + updateHighlight(element); + } + + function onKeyDown(event) { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + removePicker(); + } + } + + async function onClick(event) { + if (mode !== "select" || !currentElement || isPickerNode(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + + const selected = currentElement; + const rect = selected.getBoundingClientRect(); + const selectionId = `w2p-${Date.now()}-${Math.random().toString(16).slice(2)}`; + selected.setAttribute(ATTR, selectionId); + setToolbarStatus("正在捕获所选元素"); + + try { + const response = await chrome.runtime.sendMessage({ + type: "PIXSO_CAPTURE_ELEMENT_START", + options: { + ...(currentSettings || {}), + captureMode: "mixed", + selectionId, + selectionWidth: Math.max(1, Math.round(rect.width)) + } + }); + if (!response?.ok) throw new Error(response?.error || "元素采集失败"); + setToolbarStatus(`元素采集完成,宽度 ${response.selectionWidth || Math.round(rect.width)}px`, false); + setTimeout(removePicker, 1100); + } catch (error) { + setToolbarStatus(error.message || String(error), false); + setTimeout(() => startElementPicker(currentSettings), 2200); + } finally { + selected.removeAttribute(ATTR); + hideHighlight(); + } + } + + function startElementPicker(settings) { + removePicker(); + currentSettings = settings || {}; + mode = "select"; + setToolbarStatus("选择要捕获的元素"); + document.addEventListener("mousemove", onMouseMove, true); + document.addEventListener("click", onClick, true); + document.addEventListener("keydown", onKeyDown, true); + } + + window.__webToPixsoStartElementPicker = startElementPicker; + window.__webToPixsoShowCaptureToolbar = showToolbar; +})(); diff --git a/web-to-pixso/logo/icon128.svg b/web-to-pixso/logo/icon128.svg new file mode 100644 index 0000000..3283807 --- /dev/null +++ b/web-to-pixso/logo/icon128.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + P + diff --git a/web-to-pixso/logo/icon16.svg b/web-to-pixso/logo/icon16.svg new file mode 100644 index 0000000..bef83a3 --- /dev/null +++ b/web-to-pixso/logo/icon16.svg @@ -0,0 +1,4 @@ + + + P + diff --git a/web-to-pixso/logo/icon32.svg b/web-to-pixso/logo/icon32.svg new file mode 100644 index 0000000..c96e9c7 --- /dev/null +++ b/web-to-pixso/logo/icon32.svg @@ -0,0 +1,4 @@ + + + P + diff --git a/web-to-pixso/logo/icon48.svg b/web-to-pixso/logo/icon48.svg new file mode 100644 index 0000000..dd73ac2 --- /dev/null +++ b/web-to-pixso/logo/icon48.svg @@ -0,0 +1,4 @@ + + + P + diff --git a/web-to-pixso/logo/plugin-logo.png b/web-to-pixso/logo/plugin-logo.png new file mode 100644 index 0000000..73f9bea Binary files /dev/null and b/web-to-pixso/logo/plugin-logo.png differ diff --git a/web-to-pixso/manifest.json b/web-to-pixso/manifest.json new file mode 100644 index 0000000..f685296 --- /dev/null +++ b/web-to-pixso/manifest.json @@ -0,0 +1,32 @@ +{ + "manifest_version": 3, + "name": "Web to Pixso", + "version": "1.1.1", + "description": "Capture a webpage and convert it into editable Pixso layers.", + "permissions": ["activeTab", "scripting", "downloads", "storage"], + "host_permissions": [""], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_title": "Web to Pixso", + "default_icon": { + "16": "logo/plugin-logo.png", + "32": "logo/plugin-logo.png", + "48": "logo/plugin-logo.png", + "128": "logo/plugin-logo.png" + } + }, + "icons": { + "16": "logo/plugin-logo.png", + "32": "logo/plugin-logo.png", + "48": "logo/plugin-logo.png", + "128": "logo/plugin-logo.png" + }, + "web_accessible_resources": [ + { + "resources": ["capture.js", "runner.js", "element-picker.js", "popup-panel.js", "logo/plugin-logo.png"], + "matches": [""] + } + ] +} diff --git a/web-to-pixso/pixso-plugin/logo/plugin-logo.png b/web-to-pixso/pixso-plugin/logo/plugin-logo.png new file mode 100644 index 0000000..73f9bea Binary files /dev/null and b/web-to-pixso/pixso-plugin/logo/plugin-logo.png differ diff --git a/web-to-pixso/pixso-plugin/main.js b/web-to-pixso/pixso-plugin/main.js new file mode 100644 index 0000000..da49c86 --- /dev/null +++ b/web-to-pixso/pixso-plugin/main.js @@ -0,0 +1,1127 @@ +const PLUGIN_WIDTH = 380; +const PLUGIN_HEIGHT = 560; +const DEFAULT_FONT = { family: "Inter", style: "Regular" }; + +function getPixso() { + return typeof pixso !== "undefined" ? pixso : figma; +} + +const app = getPixso(); + +function notify(message, type = "info") { + if (app.notification?.show) { + app.notification.show(message, type); + } else if (app.notify) { + app.notify(message); + } +} + +function rgbToPaint(color, fallback = { r: 1, g: 1, b: 1, a: 1 }) { + const parsed = parseCssColor(color) || fallback; + if (!parsed) return null; + return { + type: "SOLID", + color: { + r: clamp01(parsed.r), + g: clamp01(parsed.g), + b: clamp01(parsed.b) + }, + opacity: clamp01(parsed.a == null ? 1 : parsed.a) + }; +} + +function parseCssColor(value) { + if (!value || value === "transparent" || value === "rgba(0, 0, 0, 0)") return null; + + const rgba = String(value).match(/rgba?\(([^)]+)\)/i); + if (rgba) { + const parts = rgba[1].split(",").map(part => part.trim()); + return { + r: Number(parts[0]) / 255, + g: Number(parts[1]) / 255, + b: Number(parts[2]) / 255, + a: parts[3] == null ? 1 : Number(parts[3]) + }; + } + + const hex = String(value).trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i); + if (!hex) return null; + + let body = hex[1]; + if (body.length === 3) { + body = body.split("").map(char => char + char).join(""); + } + + return { + r: parseInt(body.slice(0, 2), 16) / 255, + g: parseInt(body.slice(2, 4), 16) / 255, + b: parseInt(body.slice(4, 6), 16) / 255, + a: body.length === 8 ? parseInt(body.slice(6, 8), 16) / 255 : 1 + }; +} + +function clamp01(value) { + return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 1)); +} + +function parsePixels(value, fallback = 0) { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function clampNumber(value, min, max, fallback = 0) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.max(min, Math.min(max, number)); +} + +function parseZIndex(value) { + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +function intersectRects(a, b) { + if (!a || !b) return null; + const x1 = Math.max(a.x || 0, b.x || 0); + const y1 = Math.max(a.y || 0, b.y || 0); + const x2 = Math.min((a.x || 0) + (a.width || 0), (b.x || 0) + (b.width || 0)); + const y2 = Math.min((a.y || 0) + (a.height || 0), (b.y || 0) + (b.height || 0)); + if (x2 <= x1 || y2 <= y1) return null; + return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; +} + +function overlapRatio(a, b) { + const intersection = intersectRects(a, b); + if (!intersection) return 0; + const area = Math.max(1, Math.min( + (a.width || 1) * (a.height || 1), + (b.width || 1) * (b.height || 1) + )); + return (intersection.width * intersection.height) / area; +} + +function clipsChildren(styles = {}) { + const overflow = `${styles.overflow || ""} ${styles.overflowX || ""} ${styles.overflowY || ""}`; + return /(hidden|clip|scroll|auto)/i.test(overflow); +} + +function getRenderRect(captureNode) { + const rect = captureNode?.rect; + const visible = captureNode?.visibleRect; + if (!rect) return null; + if (!visible) return rect; + + const rectArea = Math.max(1, (rect.width || 1) * (rect.height || 1)); + const visibleArea = Math.max(1, (visible.width || 1) * (visible.height || 1)); + const isHugeOverflow = rect.width > visible.width * 1.5 || rect.height > visible.height * 1.5 || rectArea > visibleArea * 2; + return captureNode.clipped && isHugeOverflow ? visible : rect; +} + +function rectsDiffer(a, b, tolerance = 0.5) { + if (!a || !b) return false; + return Math.abs((a.x || 0) - (b.x || 0)) > tolerance || + Math.abs((a.y || 0) - (b.y || 0)) > tolerance || + Math.abs((a.width || 0) - (b.width || 0)) > tolerance || + Math.abs((a.height || 0) - (b.height || 0)) > tolerance; +} + +function shouldUseClipWrapper(captureNode) { + if (!captureNode || captureNode.type === "TEXT") return false; + if (!captureNode.rect || !captureNode.visibleRect) return false; + if (!rectsDiffer(captureNode.rect, captureNode.visibleRect)) return false; + return Boolean(captureNode.clipped || hasImageFill(captureNode) || captureNode.backgroundImages?.length || captureNode.type === "RECTANGLE"); +} + +function paintFromBackgroundImage(value) { + if (!value || !String(value).includes("gradient")) return null; + const colors = String(value).match(/rgba?\([^)]+\)|#[0-9a-f]{3,8}/gi); + if (!colors?.length) return null; + const stops = colors.map((color, index) => { + const paint = rgbToPaint(color, null); + if (!paint) return null; + return { + position: colors.length === 1 ? 0 : index / (colors.length - 1), + color: { + ...paint.color, + a: paint.opacity == null ? 1 : paint.opacity + } + }; + }).filter(Boolean); + + if (stops.length < 2) { + return rgbToPaint(colors[0], null); + } + + return { + type: "GRADIENT_LINEAR", + gradientStops: stops, + gradientTransform: [ + [1, 0, 0], + [0, 1, 0] + ] + }; +} + +function parseBoxShadow(value, scale) { + if (!value || value === "none") return null; + const colorMatch = String(value).match(/rgba?\([^)]+\)|#[0-9a-f]{3,8}/i); + const color = rgbToPaint(colorMatch?.[0] || "rgba(0,0,0,.18)", { r: 0, g: 0, b: 0, a: 0.18 }); + const numericPart = String(value).replace(/rgba?\([^)]+\)|#[0-9a-f]{3,8}/ig, ""); + const numbers = numericPart.match(/-?\d*\.?\d+px/g)?.map(parseFloat) || []; + if (!numbers.length) return null; + + return { + type: "DROP_SHADOW", + color: { + ...color.color, + a: color.opacity == null ? 1 : color.opacity + }, + offset: { + x: (numbers[0] || 0) * scale, + y: (numbers[1] || 0) * scale + }, + radius: Math.max(0, (numbers[2] || 0) * scale), + spread: (numbers[3] || 0) * scale, + visible: true, + blendMode: "NORMAL" + }; +} + +function parseDropShadowFilter(value, scale) { + if (!value || value === "none") return null; + const match = String(value).match(/drop-shadow\(([^)]+)\)/i); + if (!match) return null; + return parseBoxShadow(match[1], scale); +} + +function setSize(node, width, height) { + const safeWidth = Math.max(1, Math.round(width || 1)); + const safeHeight = Math.max(1, Math.round(height || 1)); + if (typeof node.resize === "function") { + node.resize(safeWidth, safeHeight); + } else { + node.width = safeWidth; + node.height = safeHeight; + } +} + +function setPosition(node, rect, parentRect, scale) { + node.x = Math.round(((rect?.x || 0) - (parentRect?.x || 0)) * scale); + node.y = Math.round(((rect?.y || 0) - (parentRect?.y || 0)) * scale); + setSize(node, (rect?.width || 1) * scale, (rect?.height || 1) * scale); +} + +function applyTransform(node, captureNode) { + const transform = String(captureNode.styles?.transform || ""); + if (!transform || transform === "none" || !("rotation" in node)) return; + + const rotate = transform.match(/rotate\((-?\d*\.?\d+)deg\)/i); + if (rotate) { + node.rotation = Number(rotate[1]) || 0; + return; + } + + const matrix = transform.match(/matrix\(([^)]+)\)/i); + if (!matrix) return; + const parts = matrix[1].split(",").map(part => Number(part.trim())); + if (parts.length < 4 || parts.some(value => !Number.isFinite(value))) return; + const angle = Math.atan2(parts[1], parts[0]) * 180 / Math.PI; + const hasRotation = Math.abs(angle) > 0.1 && Math.abs(angle) < 89.9; + const hasSkew = Math.abs(parts[1]) > 0.001 || Math.abs(parts[2]) > 0.001; + if (hasRotation && hasSkew) { + node.rotation = angle; + } +} + +function mapPrimaryAxisAlign(value) { + const normalized = String(value || "").toLowerCase(); + if (normalized.includes("space-between")) return "SPACE_BETWEEN"; + if (normalized.includes("center")) return "CENTER"; + if (normalized.includes("end") || normalized.includes("right") || normalized.includes("bottom")) return "MAX"; + return "MIN"; +} + +function mapCounterAxisAlign(value) { + const normalized = String(value || "").toLowerCase(); + if (normalized.includes("center")) return "CENTER"; + if (normalized.includes("end") || normalized.includes("right") || normalized.includes("bottom")) return "MAX"; + if (normalized.includes("stretch")) return "MIN"; + return "MIN"; +} + +function trySetLayoutProperty(node, key, value) { + try { + if (key in node) { + node[key] = value; + return true; + } + } catch { + // Pixso/Figma API compatibility differs between editor versions. + } + return false; +} + +function applyAutoLayout(node, captureNode, scale) { + const layout = captureNode?.layout; + if (!layout || captureNode.type === "TEXT") return false; + if (layout.type !== "flex" && layout.type !== "inferred" && layout.type !== "grid") return false; + + const direction = String(layout.direction || "").toLowerCase(); + const isGrid = layout.type === "grid"; + const isColumn = !isGrid && direction.includes("column"); + let applied = false; + + applied = trySetLayoutProperty(node, "layoutMode", isColumn ? "VERTICAL" : "HORIZONTAL") || applied; + applied = trySetLayoutProperty(node, "primaryAxisAlignItems", mapPrimaryAxisAlign(layout.justifyContent)) || applied; + applied = trySetLayoutProperty(node, "counterAxisAlignItems", mapCounterAxisAlign(layout.alignItems)) || applied; + applied = trySetLayoutProperty(node, "primaryAxisSizingMode", "FIXED") || applied; + applied = trySetLayoutProperty(node, "counterAxisSizingMode", "FIXED") || applied; + + const itemSpacing = isColumn + ? layout.rowGap || layout.gap + : layout.columnGap || layout.gap; + applied = trySetLayoutProperty(node, "itemSpacing", Math.round(clampNumber(itemSpacing, 0, 400, 0) * scale)) || applied; + + const padding = layout.padding || {}; + applied = trySetLayoutProperty(node, "paddingTop", Math.round(clampNumber(padding.top, 0, 400, 0) * scale)) || applied; + applied = trySetLayoutProperty(node, "paddingRight", Math.round(clampNumber(padding.right, 0, 400, 0) * scale)) || applied; + applied = trySetLayoutProperty(node, "paddingBottom", Math.round(clampNumber(padding.bottom, 0, 400, 0) * scale)) || applied; + applied = trySetLayoutProperty(node, "paddingLeft", Math.round(clampNumber(padding.left, 0, 400, 0) * scale)) || applied; + + if (isGrid || String(layout.wrap || "").toLowerCase().includes("wrap")) { + applied = trySetLayoutProperty(node, "layoutWrap", "WRAP") || applied; + applied = trySetLayoutProperty(node, "counterAxisSpacing", Math.round(clampNumber(layout.rowGap || layout.gap, 0, 400, 0) * scale)) || applied; + } + + if (typeof node.setPluginData === "function") { + try { + node.setPluginData("webToPixsoLayout", JSON.stringify(layout)); + if (captureNode.section) { + node.setPluginData("webToPixsoSection", String(captureNode.section)); + } + } catch { + // Layout metadata is a bonus; failing to store it should not block import. + } + } + + return applied; +} + +function getPage() { + return app.currentPage || app.state?.currentPage; +} + +async function loadDefaultFont() { + if (typeof app.loadFontAsync !== "function") return; + try { + await app.loadFontAsync(DEFAULT_FONT); + } catch { + try { + await app.loadFontAsync({ family: "Arial", style: "Regular" }); + } catch { + // Pixso may already have a default font loaded. + } + } +} + +function hasVisualStyle(captureNode) { + if (!captureNode || captureNode.type === "TEXT") return true; + const styles = captureNode.styles || {}; + const hasImage = Boolean(hasImageFill(captureNode) || captureNode.backgroundImages?.length); + const hasFill = Boolean( + shouldRenderBackgroundFill(captureNode) && + (hasVisibleColor(styles.backgroundColor) || hasCssBackgroundImage(styles)) + ); + const borderWidth = getBorderWidth(styles); + const meaningfulRadius = hasRadius(styles) && !isLargePlainLayoutWrapper(captureNode); + return hasImage || hasFill || borderWidth > 0 || hasShadow(styles) || meaningfulRadius; +} + +function shouldSkipImportNode(captureNode) { + const styles = captureNode?.styles || {}; + if ((captureNode?.tag === "::before" || captureNode?.tag === "::after") && + !captureNode.text && + !captureNode.src && + !captureNode.backgroundImages?.length) { + return true; + } + if ((captureNode?.tag === "::before" || captureNode?.tag === "::after") && + captureNode?.backgroundImages?.length && + styles.backgroundRepeat && + styles.backgroundRepeat !== "no-repeat") { + return true; + } + if (captureNode?.tag === "PICTURE" && + captureNode.src && + captureNode.children?.some(child => child.tag === "IMG" && child.src)) { + return true; + } + return false; +} + +function isRasterFallbackNode(captureNode) { + return captureNode?.tag === "RASTER_FALLBACK" || captureNode?.attributes?.["data-raster-fallback"] === "true"; +} + +function getRasterMode(captureNode) { + return captureNode?.rasterMode || captureNode?.attributes?.["data-raster-mode"] || ""; +} + +function getLayerGroup(captureNode) { + const explicit = captureNode?.layerGroup || captureNode?.attributes?.["data-layer-group"]; + const normalized = String(explicit || "").toLowerCase(); + if (normalized === "comparison" || normalized === "fallback") return "comparison"; + if (normalized === "text") return "text"; + if (normalized === "editable" || normalized === "element") return "editable"; + if (isRasterFallbackNode(captureNode)) return "comparison"; + if (captureNode?.type === "TEXT") return "text"; + return "editable"; +} + +function getLayerPriority(captureNode) { + const explicit = Number(captureNode?.layerPriority); + if (Number.isFinite(explicit)) return explicit; + const group = getLayerGroup(captureNode); + if (group === "comparison") return 0; + if (group === "text") return 200; + return 100; +} + +function hasImageFill(captureNode) { + if (!captureNode?.src) return false; + if (isRasterFallbackNode(captureNode)) return true; + return /^(IMG|PICTURE|VIDEO|CANVAS|SOURCE)$/i.test(captureNode.tag || ""); +} + +function hasVisibleColor(value) { + return Boolean(parseCssColor(value)); +} + +function isLightOpaqueColor(value) { + const color = parseCssColor(value); + return Boolean(color && color.a > 0.92 && color.r > 0.92 && color.g > 0.92 && color.b > 0.92); +} + +function getBorderWidth(styles = {}) { + return Math.max( + parsePixels(styles.borderTopWidth), + parsePixels(styles.borderRightWidth), + parsePixels(styles.borderBottomWidth), + parsePixels(styles.borderLeftWidth) + ); +} + +function hasRadius(styles = {}) { + return Boolean( + parsePixels(styles.borderTopLeftRadius) || + parsePixels(styles.borderTopRightRadius) || + parsePixels(styles.borderBottomRightRadius) || + parsePixels(styles.borderBottomLeftRadius) + ); +} + +function hasShadow(styles = {}) { + return Boolean(styles.boxShadow && styles.boxShadow !== "none") || + Boolean(styles.filter && /drop-shadow/i.test(styles.filter)); +} + +function hasCssBackgroundImage(styles = {}) { + return Boolean(styles.backgroundImage && styles.backgroundImage !== "none"); +} + +function isContainerTag(captureNode) { + return /^(HTML|BODY|DIV|SECTION|MAIN|ARTICLE|HEADER|NAV|FOOTER|ASIDE|UL|OL|LI|FORM)$/i.test(captureNode?.tag || ""); +} + +function getNodeArea(captureNode) { + const rect = getRenderRect(captureNode) || captureNode?.rect || {}; + return Math.max(0, rect.width || 0) * Math.max(0, rect.height || 0); +} + +function isLargePlainLayoutWrapper(captureNode) { + if (!captureNode || captureNode.type === "TEXT" || isRasterFallbackNode(captureNode)) return false; + if (!captureNode.children?.length || !isContainerTag(captureNode)) return false; + + const styles = captureNode.styles || {}; + if (hasImageFill(captureNode) || captureNode.backgroundImages?.length || hasCssBackgroundImage(styles)) return false; + if (!isLightOpaqueColor(styles.backgroundColor)) return false; + + const rect = getRenderRect(captureNode) || captureNode.rect || {}; + const clipWidth = captureNode.clipRect?.width || captureNode.visibleRect?.width || rect.width || 0; + const area = getNodeArea(captureNode); + const isWideSection = clipWidth > 0 && (rect.width || 0) >= clipWidth * 0.55 && (rect.height || 0) >= 96; + const isHugeContainer = area >= 180000; + if (!isWideSection && !isHugeContainer) return false; + + const hasDecor = getBorderWidth(styles) > 0 || hasShadow(styles) || hasRadius(styles); + return !hasDecor || area >= 300000; +} + +function shouldRenderBackgroundFill(captureNode) { + if (!captureNode || isRasterFallbackNode(captureNode)) return true; + if (captureNode.visualRole === "layout-wrapper" && isLargePlainLayoutWrapper(captureNode)) return false; + return !isLargePlainLayoutWrapper(captureNode); +} + +function createNodeForCapture(captureNode) { + if (captureNode.type === "TEXT") { + return app.createText(); + } + if (captureNode.type === "RECTANGLE" || hasImageFill(captureNode) || captureNode.backgroundImages?.length) { + return app.createRectangle(); + } + return app.createFrame(); +} + +function createNodeForGroupedCapture(captureNode) { + if (captureNode.type === "TEXT") { + return app.createText(); + } + const hasChildren = Boolean(captureNode.children?.length); + const isLeafMedia = hasImageFill(captureNode) && !hasChildren; + const isLeafImageBackground = captureNode.backgroundImages?.length && !hasChildren; + if (captureNode.type === "RECTANGLE" && (isLeafMedia || isLeafImageBackground)) { + return app.createRectangle(); + } + return app.createFrame(); +} + +function dataUrlToBytes(dataUrl) { + const value = String(dataUrl || ""); + const base64 = value.includes(",") ? value.split(",")[1] : value; + if (!base64) return null; + + if (typeof app.base64Decode === "function") { + return app.base64Decode(base64); + } + + if (typeof atob !== "function") { + return decodeBase64(base64); + } + + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function decodeBase64(base64) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const clean = String(base64).replace(/[\r\n\s=]/g, ""); + const bytes = []; + let buffer = 0; + let bits = 0; + + for (let index = 0; index < clean.length; index += 1) { + const value = chars.indexOf(clean[index]); + if (value < 0) continue; + buffer = (buffer << 6) | value; + bits += 6; + if (bits >= 8) { + bits -= 8; + bytes.push((buffer >> bits) & 0xff); + } + } + + return new Uint8Array(bytes); +} + +function normalizeUrl(url) { + if (!url) return ""; + try { + const parsed = new URL(url); + parsed.hash = ""; + return parsed.href; + } catch { + return String(url).split("#")[0]; + } +} + +function resolveAsset(assets, url) { + if (!assets || !url) return null; + if (assets[url]) return assets[url]; + + const normalized = normalizeUrl(url); + for (const [assetUrl, asset] of Object.entries(assets)) { + if (normalizeUrl(assetUrl) === normalized) { + return asset; + } + } + + return null; +} + +function createImagePaint(asset, stats, scaleMode = "FILL") { + if (!asset?.data || typeof app.createImage !== "function") return null; + try { + const bytes = dataUrlToBytes(asset.data); + if (!bytes) return null; + const image = app.createImage(bytes); + const imageHash = image.hash || image.imageHash || image; + if (!imageHash || typeof imageHash !== "string") { + throw new Error("Pixso 没有返回有效的 imageHash"); + } + if (stats) stats.imagesImported += 1; + return { type: "IMAGE", scaleMode, imageHash, opacity: 1 }; + } catch (error) { + console.warn("[Web to Pixso] Image fill failed", error); + if (stats) stats.imageFailures += 1; + return null; + } +} + +function getImageCandidates(captureNode) { + const styles = captureNode.styles || {}; + return [ + hasImageFill(captureNode) ? captureNode.src : null, + ...(captureNode.backgroundImages || []), + ...[ + styles.maskImage, + styles.webkitMaskImage + ].flatMap(value => { + if (!value || value === "none") return []; + const urls = []; + const re = /url\((["']?)(.*?)\1\)/g; + let match; + while ((match = re.exec(value))) { + if (match[2]) urls.push(match[2]); + } + return urls; + }) + ].filter(Boolean); +} + +function applyVisualStyles(node, captureNode, assets, stats, scale) { + const styles = captureNode.styles || {}; + const fills = []; + const background = rgbToPaint(styles.backgroundColor, null) || paintFromBackgroundImage(styles.backgroundImage); + if (background && shouldRenderBackgroundFill(captureNode)) { + fills.push(background); + } else if (background && stats) { + stats.transparentWrappers += 1; + } + + const imageUrls = getImageCandidates(captureNode); + if (imageUrls.length && stats) stats.imageCandidates += 1; + const imageScaleMode = /contain/i.test(styles.backgroundSize || styles.objectFit || "") ? "FIT" : "FILL"; + const imagePaint = imageUrls + .map(url => createImagePaint(resolveAsset(assets, url), stats, imageScaleMode)) + .find(Boolean); + if (imagePaint) { + fills.push(imagePaint); + } + + if (fills.length) { + node.fills = fills; + } else if ("fills" in node && captureNode.type !== "TEXT") { + node.fills = []; + } + + const borderWidth = Math.max( + parsePixels(styles.borderTopWidth), + parsePixels(styles.borderRightWidth), + parsePixels(styles.borderBottomWidth), + parsePixels(styles.borderLeftWidth) + ); + + if (borderWidth > 0 && styles.borderTopStyle !== "none") { + node.strokes = [rgbToPaint(styles.borderTopColor, { r: 0, g: 0, b: 0, a: 1 })]; + node.strokeWeight = Math.max(1, borderWidth * scale); + } + + const radius = Math.max( + parsePixels(styles.borderTopLeftRadius), + parsePixels(styles.borderTopRightRadius), + parsePixels(styles.borderBottomRightRadius), + parsePixels(styles.borderBottomLeftRadius) + ); + if ("cornerRadius" in node) { + node.cornerRadius = radius * scale; + } + + if ("opacity" in node) { + node.opacity = clamp01(Number(styles.opacity || 1)); + } + + const shadow = parseBoxShadow(styles.boxShadow, scale) || parseDropShadowFilter(styles.filter, scale); + if (shadow && "effects" in node) { + node.effects = [shadow]; + } + + if ("clipsContent" in node) { + node.clipsContent = clipsChildren(styles); + } +} + +function applyTextStyles(node, captureNode, scale) { + const styles = captureNode.styles || {}; + const color = rgbToPaint(styles.color, { r: 0, g: 0, b: 0, a: 1 }); + if ("textAutoResize" in node) { + node.textAutoResize = "NONE"; + } + node.characters = captureNode.text || ""; + node.fontSize = Math.max(1, parsePixels(styles.fontSize, 14) * scale); + node.fills = [color]; + if ("fontWeight" in node) { + node.fontWeight = styles.fontWeight || "normal"; + } + + if ("textAlignHorizontal" in node) { + const align = String(styles.textAlign || "left").toUpperCase(); + node.textAlignHorizontal = align === "CENTER" || align === "RIGHT" || align === "JUSTIFY" + ? (align === "JUSTIFY" ? "JUSTIFIED" : align) + : "LEFT"; + } + + if ("lineHeight" in node) { + const lineHeight = parsePixels(styles.lineHeight); + if (lineHeight > 0) { + node.lineHeight = { value: lineHeight * scale, unit: "PIXELS" }; + } + } + + if ("letterSpacing" in node) { + const letterSpacing = parsePixels(styles.letterSpacing); + if (letterSpacing) { + node.letterSpacing = { value: letterSpacing * scale, unit: "PIXELS" }; + } + } + + const decoration = String(styles.textDecorationLine || styles.textDecoration || ""); + if ("textDecoration" in node) { + if (/line-through/i.test(decoration)) { + node.textDecoration = "STRIKETHROUGH"; + } else if (/underline/i.test(decoration)) { + node.textDecoration = "UNDERLINE"; + } + } +} + +function expandTextBounds(node, captureNode, rect, scale) { + if (!rect || typeof node.resize !== "function") return; + const fontSize = parsePixels(captureNode.styles?.fontSize, 14) * scale; + const widthPadding = Math.max(4, Math.min(14, fontSize * 0.45)); + const heightPadding = Math.max(2, Math.min(8, fontSize * 0.25)); + setSize(node, rect.width * scale + widthPadding, rect.height * scale + heightPadding); +} + +function getNodeIdentityText(captureNode) { + return [ + captureNode?.tag, + captureNode?.name, + captureNode?.attributes?.id, + captureNode?.attributes?.class, + captureNode?.attributes?.role + ].filter(Boolean).join(" "); +} + +function hasLayoutSignal(captureNode) { + const layout = captureNode?.layout; + if (!layout) return false; + if (layout.type === "flex" || layout.type === "grid") return true; + if (layout.type === "inferred" && layout.confidence >= 0.7) return true; + return false; +} + +function isHighValueLayoutContainer(captureNode) { + if (!captureNode || captureNode.type === "TEXT") return false; + if (isRasterFallbackNode(captureNode)) return false; + if (!captureNode.rect || !captureNode.children?.length) return false; + if (!hasLayoutSignal(captureNode)) return false; + if (/^(HTML|BODY)$/i.test(captureNode.tag || "")) return false; + + const rect = getRenderRect(captureNode) || captureNode.rect; + if (!rect || rect.width < 24 || rect.height < 16) return false; + + const identity = getNodeIdentityText(captureNode); + const semanticMatch = /header|nav|navigation|navbar|menu|toolbar|tabs|actions|buttons|button-group|btns|grid|cards|card-list|product|goods|footer|links|columns|list|row|form|search/i.test(identity); + const tagMatch = /^(HEADER|NAV|FOOTER|UL|OL|FORM)$/i.test(captureNode.tag || ""); + const layout = captureNode.layout || {}; + const childCount = layout.childCount || captureNode.children.length; + const isReasonableFlex = layout.type === "flex" && childCount >= 2 && childCount <= 18 && rect.height <= 480; + const isReasonableGrid = layout.type === "grid" && childCount >= 2 && childCount <= 60; + const isFooterColumns = /footer/i.test(identity) && childCount >= 2; + + return tagMatch || semanticMatch || isReasonableFlex || isReasonableGrid || isFooterColumns; +} + +function markSubtreeIds(captureNode, ids, options = {}) { + if (!captureNode) return ids; + const includeText = options.includeText === true; + if (captureNode.id && (includeText || getLayerGroup(captureNode) !== "text")) { + ids.add(captureNode.id); + } + for (const child of captureNode.children || []) { + markSubtreeIds(child, ids, options); + } + return ids; +} + +function collectLayoutRoots(captureNode, output = [], state = { order: 0 }, insideLayout = false) { + if (!captureNode) return output; + captureNode.__layoutOrder = state.order; + state.order += 1; + + const isCandidate = isHighValueLayoutContainer(captureNode); + if (isCandidate && !insideLayout) { + output.push(captureNode); + return output; + } + + for (const child of captureNode.children || []) { + collectLayoutRoots(child, output, state, insideLayout || isCandidate); + } + return output; +} + +function sortLayoutRoots(nodes) { + return nodes.sort((a, b) => { + const ar = getRenderRect(a) || a.rect || {}; + const br = getRenderRect(b) || b.rect || {}; + const ya = ar.y || 0; + const yb = br.y || 0; + if (Math.abs(ya - yb) > 8) return ya - yb; + const xa = ar.x || 0; + const xb = br.x || 0; + if (Math.abs(xa - xb) > 8) return xa - xb; + return (a.__layoutOrder || 0) - (b.__layoutOrder || 0); + }); +} + +function shouldRenderGroupedNode(captureNode) { + if (!captureNode) return false; + if (shouldSkipImportNode(captureNode)) return false; + if (captureNode.type === "TEXT") return true; + if (isRasterFallbackNode(captureNode)) return false; + if (hasVisualStyle(captureNode)) return true; + if (captureNode.children?.length) return true; + return false; +} + +function getLayoutGroupName(captureNode, depth) { + const base = captureNode.name || captureNode.tag || "Group"; + if (depth !== 0) return base; + const section = String(captureNode.section || "").toLowerCase(); + const sectionLabel = section + ? section.charAt(0).toUpperCase() + section.slice(1) + : "Module"; + const layoutType = captureNode.layout?.type ? ` ${captureNode.layout.type}` : ""; + return `Layout - ${sectionLabel}${layoutType} - ${base}`; +} + +function compareVisualRowMajor(a, b) { + const ar = getRenderRect(a) || a.rect || {}; + const br = getRenderRect(b) || b.rect || {}; + const ay = ar.y || 0; + const by = br.y || 0; + if (Math.abs(ay - by) > 8) return ay - by; + const ax = ar.x || 0; + const bx = br.x || 0; + if (Math.abs(ax - bx) > 8) return ax - bx; + return 0; +} + +function compareVisualColumnMajor(a, b) { + const ar = getRenderRect(a) || a.rect || {}; + const br = getRenderRect(b) || b.rect || {}; + const ax = ar.x || 0; + const bx = br.x || 0; + if (Math.abs(ax - bx) > 8) return ax - bx; + const ay = ar.y || 0; + const by = br.y || 0; + if (Math.abs(ay - by) > 8) return ay - by; + return 0; +} + +function getOrderedLayoutChildren(captureNode) { + const children = [...(captureNode.children || [])]; + const layout = captureNode.layout || {}; + if (!children.length || !layout.type) return children; + if (layout.type === "grid") return children.sort(compareVisualRowMajor); + const direction = String(layout.direction || "").toLowerCase(); + if (direction.includes("column")) { + return children.sort((a, b) => compareVisualColumnMajor(a, b) || compareVisualRowMajor(a, b)); + } + return children.sort((a, b) => compareVisualRowMajor(a, b) || compareVisualColumnMajor(a, b)); +} + +async function renderGroupedSubtree(captureNode, parent, parentRect, assets, stats, scale, depth = 0) { + if (!shouldRenderGroupedNode(captureNode)) return null; + if (getLayerGroup(captureNode) === "text") return null; + const renderRect = getRenderRect(captureNode); + if (!renderRect || renderRect.width <= 0 || renderRect.height <= 0) return null; + + const node = createNodeForGroupedCapture(captureNode); + node.name = getLayoutGroupName(captureNode, depth); + setPosition(node, renderRect, parentRect, scale); + + if (captureNode.type === "TEXT") { + applyTextStyles(node, captureNode, scale); + expandTextBounds(node, captureNode, renderRect, scale); + } else { + applyVisualStyles(node, captureNode, assets, stats, scale); + if (applyAutoLayout(node, captureNode, scale) && stats) { + stats.layoutFrames += 1; + } + } + applyTransform(node, captureNode); + + parent.appendChild(node); + stats.nodesCreated += 1; + if (depth === 0) { + stats.layoutGroups += 1; + } + + const canContainChildren = typeof node.appendChild === "function" && captureNode.children?.length; + if (!canContainChildren) return node; + + for (const child of getOrderedLayoutChildren(captureNode)) { + try { + await renderGroupedSubtree(child, node, renderRect, assets, stats, scale, depth + 1); + } catch (error) { + console.warn("[Web to Pixso] Grouped layer import skipped", error); + stats.nodesSkipped += 1; + } + } + + return node; +} + +function collectRenderableNodes(captureNode, output = [], state = { order: 0 }) { + if (!captureNode) return output; + if ( + captureNode.tag !== "BODY" && + captureNode.tag !== "HTML" && + !shouldSkipImportNode(captureNode) && + hasVisualStyle(captureNode) + ) { + captureNode.__importOrder = state.order; + output.push(captureNode); + } + state.order += 1; + for (const child of captureNode.children || []) { + collectRenderableNodes(child, output, state); + } + return output; +} + +function sortRenderableNodes(nodes) { + return nodes.sort((a, b) => { + const pa = getLayerPriority(a); + const pb = getLayerPriority(b); + if (pa !== pb) return pa - pb; + + const za = parseZIndex(a.styles?.zIndex); + const zb = parseZIndex(b.styles?.zIndex); + if (za !== zb) return za - zb; + const ya = a.rect?.y || 0; + const yb = b.rect?.y || 0; + if (Math.abs(ya - yb) > 2000) return ya - yb; + return (a.__importOrder || 0) - (b.__importOrder || 0); + }); +} + +function createLayerFrame(name, width, height) { + const layer = app.createFrame(); + layer.name = name; + layer.x = 0; + layer.y = 0; + setSize(layer, width, height); + layer.fills = []; + if ("clipsContent" in layer) { + layer.clipsContent = true; + } + return layer; +} + +async function renderCaptureNode(captureNode, parent, rootRect, assets, stats, scale) { + const renderRect = getRenderRect(captureNode); + if (!renderRect) return null; + if (renderRect.width <= 0 || renderRect.height <= 0) return null; + + if (shouldUseClipWrapper(captureNode)) { + const wrapper = app.createFrame(); + wrapper.name = `${captureNode.name || captureNode.tag || "Layer"} clip`; + setPosition(wrapper, captureNode.visibleRect, rootRect, scale); + wrapper.fills = []; + if ("clipsContent" in wrapper) { + wrapper.clipsContent = true; + } + + const node = createNodeForCapture(captureNode); + node.name = captureNode.name || captureNode.tag || "Layer"; + setPosition(node, captureNode.rect, captureNode.visibleRect, scale); + applyVisualStyles(node, captureNode, assets, stats, scale); + if (applyAutoLayout(node, captureNode, scale) && stats) { + stats.layoutFrames += 1; + } + applyTransform(node, captureNode); + wrapper.appendChild(node); + parent.appendChild(wrapper); + + if (stats) { + stats.nodesCreated += 2; + stats.nodesClipped += 1; + } + return wrapper; + } + + const node = createNodeForCapture(captureNode); + node.name = captureNode.name || captureNode.tag || "Layer"; + setPosition(node, renderRect, rootRect, scale); + if (captureNode.clipped && stats) stats.nodesClipped += 1; + + if (captureNode.type === "TEXT") { + applyTextStyles(node, captureNode, scale); + expandTextBounds(node, captureNode, renderRect, scale); + } else { + applyVisualStyles(node, captureNode, assets, stats, scale); + if (applyAutoLayout(node, captureNode, scale) && stats) { + stats.layoutFrames += 1; + } + } + applyTransform(node, captureNode); + + parent.appendChild(node); + stats.nodesCreated += 1; + + return node; +} + +async function importCapture(data) { + if (!data || (data.format !== "pixso-design-capture" && data.schema !== "web-to-pixso-capture")) { + throw new Error("不是有效的 Web to Pixso 采集文件"); + } + + await loadDefaultFont(); + + const page = getPage(); + if (!page) { + throw new Error("没有找到当前 Pixso 页面"); + } + + const frame = app.createFrame(); + const sourceWidth = Math.max(data.source?.selection?.width || data.canvas?.width || data.source?.actualViewportWidth || data.source?.requestedViewportWidth || data.source?.viewport?.width || 1440, 1); + const scale = 1; + const width = Math.min(Math.max(sourceWidth, 1), 20000); + const height = Math.min(Math.max(data.canvas?.height || data.source?.selection?.height || data.source?.document?.height || 900, 1), 30000); + frame.name = data.source?.title || "Web Capture"; + frame.x = 0; + frame.y = 0; + setSize(frame, width, height); + frame.fills = [rgbToPaint(data.canvas?.backgroundColor, { r: 1, g: 1, b: 1, a: 1 })]; + if ("clipsContent" in frame) { + frame.clipsContent = true; + } + + page.appendChild(frame); + + const rootRect = { + x: data.canvas?.x || data.source?.selection?.x || 0, + y: data.canvas?.y || data.source?.selection?.y || 0, + width: sourceWidth, + height: data.canvas?.height || data.source?.selection?.height || data.source?.document?.height || 900 + }; + const stats = { + nodesCreated: 0, + imageCandidates: 0, + imagesImported: 0, + imageFailures: 0, + nodesSkipped: 0, + nodesClipped: 0, + layoutGroups: 0, + layoutFrames: 0, + transparentWrappers: 0 + }; + + const fallbackLayerName = `01 兜底截图层 / 对比底图-${Math.round(sourceWidth)}px`; + const fallbackLayer = createLayerFrame(fallbackLayerName, width, height); + const editableLayer = createLayerFrame("02 可编辑元素层", width, height); + const textLayer = createLayerFrame("03 文字编辑层", width, height); + frame.appendChild(fallbackLayer); + frame.appendChild(editableLayer); + frame.appendChild(textLayer); + + const consumedLayoutIds = new Set(); + if (data.nodes) { + const layoutRoots = sortLayoutRoots(collectLayoutRoots(data.nodes)); + for (const layoutRoot of layoutRoots) { + try { + await renderGroupedSubtree(layoutRoot, editableLayer, rootRect, data.assets || {}, stats, scale); + markSubtreeIds(layoutRoot, consumedLayoutIds); + } catch (error) { + console.warn("[Web to Pixso] Layout group import skipped", error); + stats.nodesSkipped += 1; + } + } + } + + if (data.nodes) { + const renderableNodes = sortRenderableNodes(collectRenderableNodes(data.nodes)); + for (const nodeData of renderableNodes) { + try { + if (!isRasterFallbackNode(nodeData) && consumedLayoutIds.has(nodeData.id)) { + continue; + } + const layerGroup = getLayerGroup(nodeData); + const parentLayer = layerGroup === "comparison" + ? fallbackLayer + : layerGroup === "text" + ? textLayer + : editableLayer; + await renderCaptureNode(nodeData, parentLayer, rootRect, data.assets || {}, stats, scale); + } catch (error) { + console.warn("[Web to Pixso] Layer import skipped", error); + stats.nodesSkipped += 1; + } + } + } + + if ("selection" in app) { + app.selection = [frame]; + } else if (app.state) { + app.state.currentSelection = [frame]; + } + + if (app.viewport?.scrollAndZoomIntoView) { + app.viewport.scrollAndZoomIntoView([frame]); + } else if (app.viewport?.zoomToFit) { + app.viewport.zoomToFit(); + } + + return { + nodeCount: data.diagnostics?.nodeCount || 0, + assetCount: data.diagnostics?.assetCount || 0, + importWidth: width, + ...stats + }; +} + +function postStatus(text, status = "info") { + app.ui?.postMessage?.({ text, status }); +} + +app.showUI(__html__, { width: PLUGIN_WIDTH, height: PLUGIN_HEIGHT }); + +app.ui.onmessage = async message => { + if (message?.type === "CLOSE") { + app.closePlugin?.(); + return; + } + + if (message?.type !== "IMPORT_CAPTURE") return; + + try { + postStatus("正在创建画板和图层...", "loading"); + const stats = await importCapture(message.data, message.options || {}); + const text = `导入完成:创建 ${stats.nodesCreated}/${stats.nodeCount} 层,布局组 ${stats.layoutGroups} 个,自动布局 ${stats.layoutFrames} 组,裁剪 ${stats.nodesClipped} 层,跳过 ${stats.nodesSkipped} 层,宽度 ${stats.importWidth}px,图片 ${stats.imagesImported}/${stats.imageCandidates}`; + postStatus(text, "success"); + notify(text, "success"); + } catch (error) { + const text = error.message || String(error); + postStatus(text, "error"); + notify(text, "error"); + } +}; diff --git a/web-to-pixso/pixso-plugin/manifest.json b/web-to-pixso/pixso-plugin/manifest.json new file mode 100644 index 0000000..38b6fcc --- /dev/null +++ b/web-to-pixso/pixso-plugin/manifest.json @@ -0,0 +1,19 @@ +{ + "identifier": "web-to-pixso", + "id": "web-to-pixso-local", + "name": "Web to Pixso", + "description": "Import a Web to Pixso capture JSON file as editable Pixso layers.", + "version": "1.1.1", + "api": "1.0.0", + "author": "大非", + "editorType": ["pixso", "preview"], + "main": "./main.js", + "ui": "./ui.html", + "icon": "./plugin-logo.png", + "menu": [ + { + "name": "导入网页采集文件", + "command": "import" + } + ] +} diff --git a/web-to-pixso/pixso-plugin/plugin-logo.png b/web-to-pixso/pixso-plugin/plugin-logo.png new file mode 100644 index 0000000..73f9bea Binary files /dev/null and b/web-to-pixso/pixso-plugin/plugin-logo.png differ diff --git a/web-to-pixso/pixso-plugin/plugin.json b/web-to-pixso/pixso-plugin/plugin.json new file mode 100644 index 0000000..d54656d --- /dev/null +++ b/web-to-pixso/pixso-plugin/plugin.json @@ -0,0 +1,35 @@ +{ + "identifier": "web-to-pixso", + "id": "web-to-pixso-local", + "name": "Web to Pixso", + "description": "Import a Web to Pixso capture JSON file as editable Pixso layers.", + "version": "1.1.1", + "api": "1.0.0", + "author": "大非", + "editorType": ["pixso", "preview"], + "main": "./main.js", + "ui": "./ui.html", + "icon": "./plugin-logo.png", + "menu": [ + { + "name": "导入网页采集文件", + "command": "import" + } + ], + "commands": [ + { + "name": "import", + "description": "导入 Web to Pixso 采集文件" + } + ], + "i18nManifest": { + "zh-CN": { + "name": "Web to Pixso", + "description": "导入网页采集文件并生成 Pixso 可编辑图层" + }, + "en-US": { + "name": "Web to Pixso", + "description": "Import webpage captures as editable Pixso layers" + } + } +} diff --git a/web-to-pixso/pixso-plugin/ui.html b/web-to-pixso/pixso-plugin/ui.html new file mode 100644 index 0000000..e60a446 --- /dev/null +++ b/web-to-pixso/pixso-plugin/ui.html @@ -0,0 +1,524 @@ + + + + + + + + +
+
+
+ + Web to Pixso + v1.1.1 +
+
+ +
+ + + + +
+ + +

导入后会创建一个以网页标题命名的画板,并尽量还原文本、图片、背景、边框和圆角。

+
+ 使用说明 +
+ + +
+ + + + diff --git a/web-to-pixso/popup-panel.js b/web-to-pixso/popup-panel.js new file mode 100644 index 0000000..4096987 --- /dev/null +++ b/web-to-pixso/popup-panel.js @@ -0,0 +1,521 @@ +(function () { + "use strict"; + + const ROOT_ID = "__web_to_pixso_panel_root__"; + const SETTINGS_KEY = "webToPixsoSettings"; + const MIN_CAPTURE_WIDTH = 320; + const MAX_CAPTURE_WIDTH = 3840; + const DEFAULT_SETTINGS = { + useProxy: false, + concurrency: "8", + captureMode: "mixed", + captureWidth: null + }; + + function normalizeCaptureWidth(value, fallback = null) { + const number = Number.parseInt(String(value || "").replace(/\D+/g, ""), 10); + if (!Number.isFinite(number)) return fallback; + return Math.max(MIN_CAPTURE_WIDTH, Math.min(MAX_CAPTURE_WIDTH, number)); + } + + function normalizeSettings(value = {}) { + const concurrency = String(value.concurrency || DEFAULT_SETTINGS.concurrency); + return { + useProxy: Boolean(value.useProxy), + concurrency: ["4", "6", "8", "10", "12", "16", "20", "infinite"].includes(concurrency) + ? concurrency + : DEFAULT_SETTINGS.concurrency, + captureMode: value.captureMode === "editable" ? "editable" : "mixed", + captureWidth: normalizeCaptureWidth(value.captureWidth, null) + }; + } + + function css() { + return ` + :host { + all: initial; + color-scheme: light; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + } + .backdrop { + background: transparent; + inset: 0; + pointer-events: none; + position: fixed; + z-index: 2147483647; + } + .panel { + background: #fff; + border: 1px solid rgba(10, 10, 18, 0.14); + border-radius: 22px; + box-shadow: 0 18px 46px rgba(10, 10, 18, 0.18); + box-sizing: border-box; + color: #1a1a2e; + overflow: hidden; + pointer-events: auto; + position: fixed; + right: 28px; + top: 24px; + width: 320px; + } + .header { + align-items: center; + border-bottom: 1px solid #f0f0f0; + display: flex; + justify-content: space-between; + padding: 16px 20px 15px; + } + .logo-title { + align-items: center; + display: flex; + gap: 10px; + } + .logo { + border-radius: 10px; + box-shadow: 0 4px 12px rgba(10, 10, 18, 0.12); + display: block; + height: 28px; + object-fit: cover; + width: 28px; + } + .title { + color: #1a1a2e; + font-size: 16px; + font-weight: 650; + line-height: 1; + } + .version-badge { + background: #f2f4ff; + border: 1px solid #dfe5ff; + border-radius: 999px; + color: #2450ff; + font-size: 10px; + font-weight: 600; + line-height: 1; + padding: 3px 6px; + white-space: nowrap; + } + button, select, input { + font: inherit; + } + .close-btn { + align-items: center; + background: transparent; + border: 0; + border-radius: 12px; + color: #999; + cursor: pointer; + display: flex; + font-size: 22px; + height: 28px; + justify-content: center; + line-height: 1; + transition: background 0.2s, color 0.2s; + width: 28px; + } + .close-btn:hover { + background: #f4f4f6; + color: #666; + } + .content { + padding: 20px; + } + .setting-row { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 16px; + } + .setting-label { + color: #1a1a2e; + font-size: 14px; + } + .setting-select { + appearance: none; + background: #fff; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-position: right 10px center; + background-repeat: no-repeat; + border: 1px solid #e4e4e4; + border-radius: 12px; + color: #1a1a2e; + cursor: pointer; + font-size: 14px; + min-width: 80px; + outline: none; + padding: 8px 30px 8px 13px; + } + .mode-select { + min-width: 116px; + } + .toggle-switch { + display: inline-block; + height: 26px; + position: relative; + width: 48px; + } + .toggle-switch input { + height: 0; + opacity: 0; + width: 0; + } + .toggle-slider { + background-color: #e4e4e4; + border-radius: 999px; + bottom: 0; + cursor: pointer; + left: 0; + position: absolute; + right: 0; + top: 0; + transition: 0.3s; + } + .toggle-slider::before { + background-color: #fff; + border-radius: 50%; + bottom: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + content: ""; + height: 20px; + left: 3px; + position: absolute; + transition: 0.3s; + width: 20px; + } + input:checked + .toggle-slider { + background-color: #1a1a2e; + } + input:checked + .toggle-slider::before { + transform: translateX(22px); + } + .width-input-wrap { + align-items: center; + background: #fff; + border: 1px solid #e4e4e4; + border-radius: 12px; + display: flex; + height: 36px; + min-width: 116px; + padding: 0 10px 0 12px; + } + .width-input { + background: transparent; + border: 0; + color: #1a1a2e; + font-size: 14px; + min-width: 0; + outline: none; + text-align: right; + width: 68px; + } + .width-input.invalid { + color: #ef4444; + } + .width-unit { + color: #999; + font-size: 12px; + margin-left: 5px; + } + .description { + color: #999; + font-size: 12px; + line-height: 1.6; + margin-bottom: 20px; + } + .capture-btn, + .select-btn { + border: 0; + border-radius: 12px; + cursor: pointer; + font-size: 15px; + font-weight: 600; + padding: 14px 24px; + transition: background 0.2s, transform 0.2s; + width: 100%; + } + .capture-btn { + background: #1a1a2e; + color: #fff; + } + .capture-btn:hover { + background: #2d2d44; + transform: translateY(-1px); + } + .select-btn { + background: #f4f4f6; + color: #1a1a2e; + margin-top: 10px; + } + .select-btn:hover { + background: #ececf1; + transform: translateY(-1px); + } + .capture-btn:disabled, + .select-btn:disabled { + cursor: not-allowed; + opacity: 0.6; + transform: none; + } + .capture-btn.success { + background: #10b981; + } + .capture-btn.error { + background: #ef4444; + } + .status { + margin-top: 16px; + padding: 12px 0 0; + } + .progress-bar { + background: #f0f0f0; + border-radius: 999px; + height: 4px; + margin-bottom: 8px; + overflow: hidden; + } + .progress-fill { + background: linear-gradient(90deg, #00d2ff, #7c3aed); + border-radius: 999px; + height: 100%; + transition: width 0.3s ease; + width: 0; + } + .status-text { + color: #666; + font-size: 12px; + } + .help-link { + align-items: center; + border: 1px solid #e6e8f2; + border-radius: 12px; + color: #2450ff; + display: flex; + font-size: 12px; + font-weight: 600; + justify-content: center; + margin-top: 16px; + padding: 10px 12px; + text-decoration: none; + transition: background 0.18s, border-color 0.18s, color 0.18s; + width: 100%; + } + .help-link:hover { + background: #f6f8ff; + border-color: #dfe5ff; + } + .footer { + align-items: center; + background: #fff; + border-top: 1px solid #f0f0f0; + color: #666; + display: flex; + font-size: 12px; + gap: 8px; + justify-content: space-between; + padding: 12px 20px; + } + .support-email { + color: #888; + font-size: 10px; + line-height: 1.35; + min-width: 0; + text-align: right; + text-decoration: none; + } + .support-email:hover { + color: #2450ff; + } + `; + } + + function panelHtml() { + return ` +
+
+
+
+ + Web to Pixso + v1.1.1 +
+ +
+
+
+ 采集模式 + +
+
+ 跨域图片代理模式 + +
+
+ 页面采集宽度 + +
+
+ 图片采集并发 + +
+

页面采集宽度默认使用当前窗口宽度,可输入 320-3840px 触发响应式布局后采集。

+ + + + 使用说明 +
+ +
+
+ `; + } + + function getField(root, name) { + return root.shadowRoot.querySelector(`[data-field="${name}"]`); + } + + function sanitizeWidth(input, clamp = false) { + const digits = input.value.replace(/\D+/g, ""); + input.value = digits; + const raw = Number.parseInt(digits, 10); + const invalid = Boolean(digits) && Number.isFinite(raw) && (raw < MIN_CAPTURE_WIDTH || raw > MAX_CAPTURE_WIDTH); + input.classList.toggle("invalid", invalid); + const normalized = normalizeCaptureWidth(digits, null); + if (clamp && digits) { + input.value = String(normalized); + input.classList.remove("invalid"); + } + return normalized; + } + + async function getSettings() { + const result = await chrome.storage.local.get({ [SETTINGS_KEY]: DEFAULT_SETTINGS }); + return normalizeSettings(result[SETTINGS_KEY]); + } + + async function saveSettings(settings) { + await chrome.storage.local.set({ [SETTINGS_KEY]: normalizeSettings(settings) }); + } + + function readSettings(root, options = {}) { + const { clampWidth = false } = options; + return normalizeSettings({ + captureMode: getField(root, "captureMode").value, + useProxy: getField(root, "useProxy").checked, + concurrency: getField(root, "concurrency").value, + captureWidth: sanitizeWidth(getField(root, "captureWidth"), clampWidth) + }); + } + + function setProgress(root, percent, text) { + const status = root.shadowRoot.querySelector(".status"); + const fill = root.shadowRoot.querySelector(".progress-fill"); + const statusText = root.shadowRoot.querySelector(".status-text"); + status.hidden = false; + fill.style.width = `${Math.max(0, Math.min(100, percent))}%`; + statusText.textContent = text; + } + + function setBusy(root, isBusy) { + const captureButton = root.shadowRoot.querySelector(".capture-btn"); + const selectButton = root.shadowRoot.querySelector(".select-btn"); + captureButton.disabled = isBusy; + selectButton.disabled = isBusy; + captureButton.classList.remove("success", "error"); + captureButton.textContent = isBusy ? "采集中..." : "开始采集"; + selectButton.textContent = isBusy ? "处理中..." : "打开页面浮窗"; + } + + function setResult(root, kind, text) { + const captureButton = root.shadowRoot.querySelector(".capture-btn"); + captureButton.classList.remove("success", "error"); + captureButton.classList.add(kind); + captureButton.textContent = text; + } + + async function startCapture(root) { + setBusy(root, true); + setProgress(root, 12, "准备当前网页..."); + const settings = readSettings(root, { clampWidth: true }); + await saveSettings(settings); + try { + setProgress(root, 28, "注入采集脚本..."); + const response = await chrome.runtime.sendMessage({ + type: "PIXSO_CAPTURE_CURRENT_TAB", + options: settings + }); + if (!response?.ok) throw new Error(response?.error || "采集失败"); + const widthText = response.actualViewportWidth ? `已按 ${response.actualViewportWidth}px 采集` : "采集完成"; + setProgress(root, 100, `${widthText}:${response.filename || ""}`); + setResult(root, "success", "采集完成"); + setTimeout(() => root.remove(), 900); + } catch (error) { + setProgress(root, 0, error.message || String(error)); + setResult(root, "error", "采集失败"); + setTimeout(() => setBusy(root, false), 2600); + } + } + + async function openToolbar(root) { + const settings = readSettings(root, { clampWidth: true }); + await saveSettings(settings); + root.remove(); + window.__webToPixsoShowCaptureToolbar?.(settings); + } + + async function bind(root) { + const settings = await getSettings(); + getField(root, "captureMode").value = settings.captureMode; + getField(root, "useProxy").checked = settings.useProxy; + getField(root, "concurrency").value = settings.concurrency; + getField(root, "captureWidth").value = String(settings.captureWidth || Math.round(window.innerWidth || document.documentElement.clientWidth || 1440)); + + for (const field of ["captureMode", "useProxy", "concurrency"]) { + getField(root, field).addEventListener("change", () => saveSettings(readSettings(root))); + } + getField(root, "captureWidth").addEventListener("input", event => { + sanitizeWidth(event.currentTarget); + saveSettings(readSettings(root, { clampWidth: false })); + }); + getField(root, "captureWidth").addEventListener("blur", event => { + sanitizeWidth(event.currentTarget, true); + saveSettings(readSettings(root, { clampWidth: true })); + }); + root.shadowRoot.querySelector(".close-btn").addEventListener("click", () => root.remove()); + root.shadowRoot.querySelector(".capture-btn").addEventListener("click", () => startCapture(root)); + root.shadowRoot.querySelector(".select-btn").addEventListener("click", () => openToolbar(root)); + } + + window.__webToPixsoShowPanel = async function showPanel() { + document.getElementById(ROOT_ID)?.remove(); + const root = document.createElement("div"); + root.id = ROOT_ID; + const shadow = root.attachShadow({ mode: "open" }); + shadow.innerHTML = `${panelHtml()}`; + document.documentElement.appendChild(root); + await bind(root); + }; +})(); diff --git a/web-to-pixso/popup.css b/web-to-pixso/popup.css new file mode 100644 index 0000000..62887b8 --- /dev/null +++ b/web-to-pixso/popup.css @@ -0,0 +1,379 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + background: transparent !important; + border-radius: 16px; + overflow: hidden; + width: 320px; +} + +body { + background: transparent !important; + color: #1a1a2e; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + padding: 0; + width: 320px; + overflow: hidden; +} + +.shell { + background: #fff; + border: 1px solid rgba(10, 10, 18, 0.14); + border-radius: 16px; + box-shadow: none; + overflow: hidden; + width: 320px; +} + +.header { + align-items: center; + border-bottom: 1px solid #f0f0f0; + display: flex; + justify-content: space-between; + padding: 16px 20px 15px; +} + +.logo-title { + align-items: center; + display: flex; + gap: 10px; +} + +.logo { + border-radius: 10px; + box-shadow: 0 4px 12px rgba(10, 10, 18, 0.12); + display: block; + height: 28px; + margin-left: -2px; + object-fit: cover; + width: 28px; +} + +.title { + color: #1a1a2e; + font-size: 16px; + font-weight: 600; +} + +.version-badge { + background: #f2f4ff; + border: 1px solid #dfe5ff; + border-radius: 999px; + color: #2450ff; + font-size: 10px; + font-weight: 600; + line-height: 1; + padding: 3px 6px; + white-space: nowrap; +} + +.close-btn { + align-items: center; + background: transparent; + border: 0; + border-radius: 12px; + color: #999; + cursor: pointer; + display: flex; + font-size: 18px; + height: 24px; + justify-content: center; + line-height: 1; + transition: background 0.2s, color 0.2s; + width: 24px; +} + +.close-btn:hover { + background: #f4f4f6; + color: #666; +} + +.content { + padding: 20px; +} + +.setting-row { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 16px; +} + +.setting-label { + color: #1a1a2e; + font-size: 14px; +} + +.toggle-switch { + display: inline-block; + height: 26px; + position: relative; + width: 48px; +} + +.toggle-switch input { + height: 0; + opacity: 0; + width: 0; +} + +.toggle-slider { + background-color: #e4e4e4; + border-radius: 999px; + bottom: 0; + cursor: pointer; + left: 0; + position: absolute; + right: 0; + top: 0; + transition: 0.3s; +} + +.toggle-slider::before { + background-color: #fff; + border-radius: 50%; + bottom: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + content: ""; + height: 20px; + left: 3px; + position: absolute; + transition: 0.3s; + width: 20px; +} + +input:checked + .toggle-slider { + background-color: #1a1a2e; +} + +input:checked + .toggle-slider::before { + transform: translateX(22px); +} + +.setting-select { + appearance: none; + background: #fff; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-position: right 10px center; + background-repeat: no-repeat; + border: 1px solid #e4e4e4; + border-radius: 9px; + color: #1a1a2e; + cursor: pointer; + font-size: 14px; + min-width: 80px; + padding: 7px 30px 7px 13px; + transition: border-color 0.18s, box-shadow 0.18s, background 0.18s; +} + +.setting-select:focus { + border-color: #2450ff; + box-shadow: 0 0 0 4px rgba(36, 80, 255, 0.1); + outline: none; +} + +.mode-select { + min-width: 116px; +} + +.width-input-wrap { + align-items: center; + background: #fff; + border: 1px solid #e4e4e4; + border-radius: 9px; + display: flex; + height: 36px; + min-width: 116px; + padding: 0 10px 0 12px; + transition: border-color 0.18s, box-shadow 0.18s; +} + +.width-input-wrap:focus-within { + border-color: #2450ff; + box-shadow: 0 0 0 4px rgba(36, 80, 255, 0.1); +} + +.width-input { + background: transparent; + border: 0; + color: #1a1a2e; + font: inherit; + font-size: 14px; + min-width: 0; + outline: none; + text-align: right; + width: 68px; +} + +.width-input.invalid { + color: #ef4444; +} + +.width-unit { + color: #999; + font-size: 12px; + margin-left: 5px; +} + +.description { + color: #999; + font-size: 12px; + line-height: 1.6; + margin-bottom: 20px; +} + +.capture-btn { + background: #1a1a2e; + border: 0; + border-radius: 9px; + color: #fff; + cursor: pointer; + font-size: 15px; + font-weight: 500; + padding: 14px 24px; + box-shadow: 0 8px 20px rgba(26, 26, 46, 0.12); + transition: background 0.2s, box-shadow 0.2s, transform 0.2s; + width: 100%; +} + +.capture-btn:hover { + background: #2d2d44; + box-shadow: 0 10px 24px rgba(26, 26, 46, 0.16); + transform: translateY(-1px); +} + +.capture-btn:active { + transform: translateY(0); +} + +.capture-btn:disabled { + cursor: not-allowed; + opacity: 0.6; + transform: none; +} + +.capture-btn.loading { + background: #666; +} + +.capture-btn.success { + background: #10b981; +} + +.capture-btn.error { + background: #ef4444; +} + +.select-btn { + background: #f4f4f6; + border: 0; + border-radius: 9px; + color: #1a1a2e; + cursor: pointer; + font-size: 15px; + font-weight: 500; + margin-top: 10px; + padding: 13px 24px; + transition: background 0.2s, box-shadow 0.2s, transform 0.2s; + width: 100%; +} + +.select-btn:hover { + background: #ececf1; + box-shadow: 0 8px 18px rgba(10, 10, 18, 0.08); + transform: translateY(-1px); +} + +.select-btn:active { + transform: translateY(0); +} + +.select-btn:disabled { + cursor: not-allowed; + opacity: 0.6; + transform: none; +} + +.select-btn.loading { + background: #e7e7ee; +} + +.status { + margin-top: 16px; + padding: 12px 0 0; +} + +.progress-bar { + background: #f0f0f0; + border-radius: 999px; + height: 4px; + margin-bottom: 8px; + overflow: hidden; +} + +.progress-fill { + background: linear-gradient(90deg, #00d2ff, #7c3aed); + border-radius: 999px; + height: 100%; + transition: width 0.3s ease; + width: 0; +} + +.status-text { + color: #666; + font-size: 12px; +} + +.help-link { + align-items: center; + border: 1px solid #e6e8f2; + border-radius: 9px; + color: #2450ff; + display: flex; + font-size: 12px; + font-weight: 500; + justify-content: center; + margin-top: 16px; + padding: 10px 12px; + text-decoration: none; + transition: background 0.18s, border-color 0.18s, color 0.18s; + width: 100%; +} + +.help-link:hover { + background: #f6f8ff; + border-color: #dfe5ff; +} + +.footer { + align-items: center; + background: #fff; + border-top: 1px solid #f0f0f0; + display: flex; + gap: 8px; + justify-content: space-between; + padding: 12px 20px; +} + +.author { + color: #666; + flex: 0 0 auto; + font-size: 12px; +} + +.support-email { + color: #888; + font-size: 10px; + line-height: 1.35; + min-width: 0; + text-align: right; + text-decoration: none; +} + +.support-email:hover { + color: #2450ff; +} diff --git a/web-to-pixso/popup.html b/web-to-pixso/popup.html new file mode 100644 index 0000000..52f50b4 --- /dev/null +++ b/web-to-pixso/popup.html @@ -0,0 +1,86 @@ + + + + + + Web to Pixso + + + +
+
+
+ + Web to Pixso + v1.1.1 +
+ +
+ +
+
+ 采集模式 + +
+ +
+ 跨域图片代理模式 + +
+ +
+ 页面采集宽度 + +
+ +
+ 图片采集并发 + +
+ +

页面采集宽度默认使用当前窗口宽度,可输入 320-3840px 触发响应式布局后采集。

+ + + + + + + 使用说明 +
+ + +
+ + + + diff --git a/web-to-pixso/popup.js b/web-to-pixso/popup.js new file mode 100644 index 0000000..569ff26 --- /dev/null +++ b/web-to-pixso/popup.js @@ -0,0 +1,221 @@ +const SETTINGS_KEY = "webToPixsoSettings"; +const HELP_URL = "https://z8qrcvi3n5.feishu.cn/wiki/RV8TwlhFyiGsEekQXk8cX5SHn6f"; +const DEFAULT_SETTINGS = { + useProxy: false, + concurrency: "8", + captureMode: "mixed", + captureWidth: null +}; +const MIN_CAPTURE_WIDTH = 320; +const MAX_CAPTURE_WIDTH = 3840; + +const captureModeSelect = document.getElementById("captureMode"); +const proxyToggle = document.getElementById("proxyToggle"); +const captureWidthInput = document.getElementById("captureWidth"); +const concurrencySelect = document.getElementById("concurrency"); +const captureBtn = document.getElementById("captureBtn"); +const btnText = document.getElementById("btnText"); +const selectBtn = document.getElementById("selectBtn"); +const selectBtnText = document.getElementById("selectBtnText"); +const status = document.getElementById("status"); +const statusText = document.getElementById("statusText"); +const progressFill = document.getElementById("progressFill"); +const closeBtn = document.getElementById("closeBtn"); +const helpLink = document.getElementById("helpLink"); + +function normalizeSettings(value = {}) { + const concurrency = String(value.concurrency || DEFAULT_SETTINGS.concurrency); + const captureWidth = normalizeCaptureWidth(value.captureWidth, null); + return { + useProxy: Boolean(value.useProxy), + concurrency: ["4", "6", "8", "10", "12", "16", "20", "infinite"].includes(concurrency) + ? concurrency + : DEFAULT_SETTINGS.concurrency, + captureMode: value.captureMode === "editable" ? "editable" : "mixed", + captureWidth + }; +} + +function normalizeCaptureWidth(value, fallback = null) { + const number = Number.parseInt(String(value || "").replace(/\D+/g, ""), 10); + if (!Number.isFinite(number)) return fallback; + return Math.max(MIN_CAPTURE_WIDTH, Math.min(MAX_CAPTURE_WIDTH, number)); +} + +async function getCurrentViewportWidth() { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) return null; + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => Math.round(window.innerWidth || document.documentElement.clientWidth || 0) + }); + return normalizeCaptureWidth(result, null); + } catch { + return null; + } +} + +async function getSettings() { + const result = await chrome.storage.local.get({ [SETTINGS_KEY]: DEFAULT_SETTINGS }); + return normalizeSettings(result[SETTINGS_KEY]); +} + +async function saveSettings(settings) { + await chrome.storage.local.set({ [SETTINGS_KEY]: normalizeSettings(settings) }); +} + +function setProgress(percent, text) { + status.hidden = false; + progressFill.style.width = `${Math.max(0, Math.min(100, percent))}%`; + statusText.textContent = text; +} + +function setBusy(isBusy) { + captureBtn.disabled = isBusy; + selectBtn.disabled = isBusy; + captureBtn.classList.toggle("loading", isBusy); + selectBtn.classList.toggle("loading", isBusy); + captureBtn.classList.remove("success", "error"); + btnText.textContent = isBusy ? "采集中..." : "开始采集"; + selectBtnText.textContent = isBusy ? "正在打开浮窗..." : "打开页面浮窗"; +} + +function setResult(kind, text) { + captureBtn.classList.remove("loading", "success", "error"); + captureBtn.classList.add(kind); + btnText.textContent = text; +} + +async function syncSettingsFromUI() { + await saveSettings({ + captureMode: captureModeSelect.value, + useProxy: proxyToggle.checked, + concurrency: concurrencySelect.value, + captureWidth: normalizeCaptureWidth(captureWidthInput.value, null) + }); +} + +function sanitizeCaptureWidthInput({ clamp = false } = {}) { + const digits = captureWidthInput.value.replace(/\D+/g, ""); + captureWidthInput.value = digits; + const width = normalizeCaptureWidth(digits, null); + const rawNumber = Number.parseInt(digits, 10); + let invalid = Boolean(digits) && Number.isFinite(rawNumber) && + (rawNumber < MIN_CAPTURE_WIDTH || rawNumber > MAX_CAPTURE_WIDTH); + captureWidthInput.classList.toggle("invalid", invalid); + if (clamp && digits) { + captureWidthInput.value = String(width); + invalid = false; + captureWidthInput.classList.remove("invalid"); + } + return width; +} + +function readSettingsFromUI() { + return normalizeSettings({ + captureMode: captureModeSelect.value, + useProxy: proxyToggle.checked, + concurrency: concurrencySelect.value, + captureWidth: sanitizeCaptureWidthInput({ clamp: true }) + }); +} + +async function startCapture() { + setBusy(true); + setProgress(12, "准备当前网页..."); + + const settings = readSettingsFromUI(); + await saveSettings(settings); + + try { + setProgress(28, "注入采集脚本..."); + const response = await chrome.runtime.sendMessage({ + type: "PIXSO_CAPTURE_START", + options: settings + }); + + if (!response || !response.ok) { + throw new Error(response?.error || "采集失败"); + } + + const viewportText = response.actualViewportWidth + ? `已按 ${response.actualViewportWidth}px 采集` + : "已按当前页面尺寸采集"; + setProgress(100, `${viewportText}:${response.filename}`); + setResult("success", "采集完成"); + setTimeout(() => window.close(), 900); + } catch (error) { + setProgress(0, error.message || String(error)); + setResult("error", "采集失败"); + setTimeout(() => { + setBusy(false); + status.hidden = true; + progressFill.style.width = "0"; + }, 2600); + } +} + +async function startFloatingToolbar() { + setBusy(true); + setProgress(18, "正在打开页面采集浮窗..."); + + const settings = readSettingsFromUI(); + await saveSettings(settings); + + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) throw new Error("没有可选择的当前标签页"); + + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["element-picker.js"] + }); + + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: pickerSettings => window.__webToPixsoShowCaptureToolbar?.(pickerSettings), + args: [settings] + }); + + setProgress(100, "请在页面浮窗中选择整页或元素采集。"); + setTimeout(() => window.close(), 500); + } catch (error) { + setProgress(0, error.message || String(error)); + setResult("error", "采集失败"); + setTimeout(() => { + setBusy(false); + status.hidden = true; + progressFill.style.width = "0"; + }, 2600); + } +} + +document.addEventListener("DOMContentLoaded", async () => { + const settings = await getSettings(); + const currentWidth = await getCurrentViewportWidth(); + captureModeSelect.value = settings.captureMode; + proxyToggle.checked = settings.useProxy; + concurrencySelect.value = settings.concurrency; + captureWidthInput.value = String(settings.captureWidth || currentWidth || ""); +}); + +captureModeSelect.addEventListener("change", syncSettingsFromUI); +proxyToggle.addEventListener("change", syncSettingsFromUI); +concurrencySelect.addEventListener("change", syncSettingsFromUI); +captureWidthInput.addEventListener("input", () => { + sanitizeCaptureWidthInput(); + syncSettingsFromUI(); +}); +captureWidthInput.addEventListener("blur", () => { + sanitizeCaptureWidthInput({ clamp: true }); + syncSettingsFromUI(); +}); +captureBtn.addEventListener("click", startCapture); +selectBtn.addEventListener("click", startFloatingToolbar); +closeBtn.addEventListener("click", () => window.close()); +helpLink?.addEventListener("click", async event => { + event.preventDefault(); + await chrome.tabs.create({ url: HELP_URL }); + window.close(); +}); diff --git a/web-to-pixso/runner.js b/web-to-pixso/runner.js new file mode 100644 index 0000000..e07574a --- /dev/null +++ b/web-to-pixso/runner.js @@ -0,0 +1,162 @@ +(function () { + "use strict"; + + const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + + function freezeAnimations() { + const style = document.createElement("style"); + style.id = "__web-to-pixso-freeze"; + style.textContent = ` + *, *::before, *::after { + animation-play-state: paused !important; + transition-duration: 0s !important; + scroll-behavior: auto !important; + } + .slick-track, + .swiper-wrapper { + transition-duration: 0s !important; + } + `; + document.documentElement.appendChild(style); + + for (const media of document.querySelectorAll("video, audio")) { + try { + media.pause(); + } catch { + // Ignore media elements that cannot be controlled by content scripts. + } + } + } + + function stabilizeCarousels() { + try { + if (window.jQuery) { + window.jQuery(".slick-slider").each((_, element) => { + try { + window.jQuery(element).slick("slickPause"); + window.jQuery(element).slick("slickGoTo", 0, true); + } catch { + // Some pages expose slick classes without the jQuery plugin instance. + } + }); + } + } catch { + // Best effort only. + } + + for (const element of document.querySelectorAll(".swiper, .swiper-container")) { + try { + if (element.swiper) { + element.swiper.autoplay?.stop?.(); + element.swiper.slideToLoop?.(0, 0, false); + element.swiper.slideTo?.(0, 0, false); + element.swiper.update?.(); + } + } catch { + // Keep DOM capture running even if a carousel API rejects. + } + } + + for (const wrapper of document.querySelectorAll(".swiper-wrapper, .slick-track")) { + wrapper.style.transitionDuration = "0s"; + wrapper.style.animationPlayState = "paused"; + } + } + + async function waitForStableTopLayer(timeout = 2500) { + const start = Date.now(); + const selector = [ + "header", + "nav", + "[role='navigation']", + "[class*='header' i]", + "[class*='nav' i]", + "[class*='top' i]" + ].join(","); + + while (Date.now() - start < timeout) { + const candidates = Array.from(document.querySelectorAll(selector)); + const hasVisibleCandidate = candidates.some(element => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 20 && + rect.height > 10 && + rect.bottom >= 0 && + rect.top < Math.max(160, window.innerHeight * 0.2) && + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity || 1) > 0; + }); + if (hasVisibleCandidate || document.readyState === "complete") { + await delay(300); + return; + } + await delay(120); + } + } + + async function scrollToLoadLazyContent() { + const maxScroll = Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight + ) - window.innerHeight; + + if (maxScroll <= 0) return; + + const step = Math.max(480, Math.floor(window.innerHeight * 0.8)); + for (let y = 0; y <= maxScroll; y += step) { + window.scrollTo(0, Math.min(y, maxScroll)); + await delay(160); + } + + window.scrollTo(0, maxScroll); + await delay(220); + window.scrollTo(0, 0); + await delay(220); + } + + async function waitForImages(timeout = 3500) { + const images = Array.from(document.images || []); + await Promise.race([ + Promise.allSettled(images.map(image => { + if (image.complete) return Promise.resolve(); + return new Promise(resolve => { + image.addEventListener("load", resolve, { once: true }); + image.addEventListener("error", resolve, { once: true }); + }); + })), + delay(timeout) + ]); + } + + async function waitForFonts(timeout = 3000) { + if (!document.fonts?.ready) return; + await Promise.race([document.fonts.ready, delay(timeout)]); + } + + window.__webToPixsoRunCapture = async function runCapture(options = {}) { + if (!window.__webToPixsoCapture) { + throw new Error("采集引擎未加载"); + } + + freezeAnimations(); + stabilizeCarousels(); + await waitForStableTopLayer(); + stabilizeCarousels(); + await scrollToLoadLazyContent(); + stabilizeCarousels(); + await waitForImages(); + await waitForFonts(); + stabilizeCarousels(); + await delay(200); + + return window.__webToPixsoCapture({ + useProxy: Boolean(options.useProxy), + concurrency: options.concurrency || "8", + captureMode: options.captureMode === "editable" ? "editable" : "mixed", + captureWidth: options.captureWidth, + selectionId: options.selectionId, + selectionWidth: options.selectionWidth + }); + }; +})(); diff --git a/web-to-pixso/发布使用说明-Web to Pixso.md b/web-to-pixso/发布使用说明-Web to Pixso.md new file mode 100644 index 0000000..ba1768d --- /dev/null +++ b/web-to-pixso/发布使用说明-Web to Pixso.md @@ -0,0 +1,118 @@ +# Web to Pixso 使用说明 + +Web to Pixso 可以将网页采集为可导入 Pixso 的高保真设计稿,适合网页参考稿采集、运营页转设计稿、竞品页面还原、开发页面转 Pixso 资产等场景。 + +插件包:`web-to-pixso-v1.1.1.zip` + +使用说明在线文档:[Web to Pixso 使用说明](https://z8qrcvi3n5.feishu.cn/wiki/RV8TwlhFyiGsEekQXk8cX5SHn6f) + +反馈邮箱:`270310136@qq.com`。给我发邮件哦,我光速改。 + +## 一、安装 Chrome 浏览器扩展 + +1. 解压 `web-to-pixso-v1.1.1.zip`。 +2. 打开 Chrome 浏览器,进入 `chrome://extensions/`。 +3. 打开右上角「开发者模式」。 +4. 点击「加载已解压的扩展程序」。 +5. 选择解压后的 `web-to-pixso` 文件夹。 +6. 安装成功后,浏览器右上角会出现 Web to Pixso 扩展图标。 + +## 二、安装 Pixso 插件 + +1. 打开 Pixso。 +2. 进入插件开发/导入插件入口。 +3. 选择 `web-to-pixso/pixso-plugin` 目录中的插件文件。 +4. 导入成功后,在 Pixso 插件面板中可以看到 Web to Pixso。 + +## 三、采集网页 + +1. 在 Chrome 中打开需要采集的目标网页。 +2. 点击浏览器右上角 Web to Pixso 扩展图标。 +3. 选择采集模式: + - 混合高保真:优先保留关键模块截图兜底,同时尽量保留文字可编辑。 + - 可编辑优先:优先转换为 Pixso 原生文本、图片、形状和组件图层。 +4. 可按需要设置: + - 跨域图片代理模式:用于减少图片丢失。 + - 页面采集宽度:用于按指定视口宽度触发响应式页面布局。 + - 图片采集并发:用于控制图片下载并发数量。 +5. 点击「开始采集」。 +6. 采集完成后,会下载一个 `.json` 文件。 + +## 四、导入 Pixso + +1. 在 Pixso 中打开 Web to Pixso 插件。 +2. 将 Chrome 扩展下载的 `.json` 文件拖拽到插件面板,或点击选择文件。 +3. 插件会自动识别页面宽度和页面高度。 +4. 点击「导入到 Pixso」。 +5. 导入完成后,会创建一个以网页标题命名的画板。 + +## 五、导入后的图层结构 + +导入后的设计稿会按用途分层,方便设计师编辑和对比: + +1. 对比底图层 + - 放置完整页面截图或模块截图。 + - 用于和上层可编辑元素进行还原度对比。 + - 可隐藏或锁定。 + +2. 可编辑元素层 + - 包含按钮、卡片、输入框、背景、图片、Logo、图标等。 + - 尽量保留尺寸、位置、圆角、边框、阴影、透明度和背景样式。 + +3. 文字编辑层 + - 包含网页中的可见文字。 + - 尽量保留字体、字号、颜色、行高、字重、对齐方式等样式。 + - 文字可在 Pixso 中直接编辑。 + +## 六、常见问题 + +### 1. 为什么有些区域是截图? + +部分网页使用复杂动画、轮播、视频、Canvas、伪元素、复杂背景或特殊渲染方式。为了保证视觉效果不丢失,插件会为这些区域生成截图兜底,同时尽量保留文字和主要元素可编辑。 + +### 2. 为什么有些图片没有显示? + +可能是目标网站启用了跨域限制、懒加载、防盗链或动态鉴权。可以尝试开启「跨域图片代理模式」后重新采集。 + +### 3. 为什么 Header 或 Hero 区域有时不可编辑? + +部分网站的导航栏、轮播图或首屏区域可能由复杂脚本、Shadow DOM、Canvas、视频或异步渲染生成。插件会优先转换为可编辑图层,并使用兜底截图保证视觉可对比。 + +### 4. 页面采集宽度有什么用? + +页面采集宽度用于模拟不同视口下的响应式布局。例如输入 `1920` 可以采集桌面宽屏布局,输入 `375` 可以采集移动端布局。 + +### 5. 导入后如何检查还原度? + +可以先显示「对比底图层」,再查看上方的可编辑元素层和文字编辑层是否与底图对齐。对比完成后,可以隐藏或锁定底图。 + +## 七、适用场景 + +- 网页转 Pixso 设计稿 +- 竞品页面采集 +- 运营活动页还原 +- 开发页面转设计资产 +- 设计走查和页面对比 +- 旧页面重构前的视觉备份 + +## 八、版本说明 + +当前版本:V1.1.1 + +支持从 Web to Pixso Chrome 扩展导出的 JSON 文件导入 Pixso,生成包含可编辑文本、图片、背景、按钮、卡片和对比底图的高保真网页设计稿。 + +本版重点: + +- Chrome 扩展和 Pixso 插件版本号统一为 `1.1.1`。 +- 插件面板显示版本号,避免用户混淆安装包。 +- Chrome 扩展和 Pixso 插件均新增「使用说明」入口,点击后打开飞书文档。 +- 两端插件底部均新增反馈邮箱:`270310136@qq.com`。 +- 采集 JSON 协议版本同步为 `1.1.1`,方便问题排查。 +- 保留页面采集宽度、混合高保真、可编辑优先、跨域图片代理和图片采集并发能力。 +- 继续强化三层结构:对比底图层、可编辑元素层、文字编辑层。 + +已知边界: + +- 复杂动画、Canvas、视频、动态轮播和强跨域资源可能仍需要兜底截图或人工微调。 +- 真实网页还原质量会受目标网站资源加载、登录态、懒加载和浏览器环境影响。 +- 对外使用时建议先用对比底图层检查还原度,再进行设计稿编辑。