feat: 加入 web-to-pixso 插件源码
Chrome 扩展,用于从网页提取 DOM 树为 JSON 格式。 核心文件:capture.js(76KB,提取逻辑)
@@ -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 中二次微调。
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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 '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="M7 9h10"></path></svg>';
|
||||||
|
}
|
||||||
|
if (type === "select") {
|
||||||
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v3"></path><path d="M12 18v3"></path><path d="M3 12h3"></path><path d="M18 12h3"></path><path d="M5.6 5.6l2.1 2.1"></path><path d="M16.3 16.3l2.1 2.1"></path><path d="M18.4 5.6l-2.1 2.1"></path><path d="M7.7 16.3l-2.1 2.1"></path><path d="M12 9l2.2 6.1L16 13.2l2.8 2.8"></path></svg>';
|
||||||
|
}
|
||||||
|
if (type === "copy") {
|
||||||
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"></rect><path d="M4 16V6a2 2 0 0 1 2-2h10"></path></svg>';
|
||||||
|
}
|
||||||
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<style>${rootCss()}</style>
|
||||||
|
<div class="w2p-island">
|
||||||
|
<div class="w2p-status">
|
||||||
|
${busy ? '<span class="w2p-spinner"></span>' : ""}
|
||||||
|
<span class="w2p-text"></span>
|
||||||
|
</div>
|
||||||
|
<button class="w2p-cancel" type="button">取消</button>
|
||||||
|
</div>`;
|
||||||
|
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 = `<style>${rootCss()}</style>
|
||||||
|
<div class="w2p-island">
|
||||||
|
<button class="w2p-action" data-action="clipboard" type="button">${icon("copy")}<span>复制到剪贴板</span></button>
|
||||||
|
<button class="w2p-action" data-action="screen" type="button">${icon("screen")}<span>整个屏幕</span></button>
|
||||||
|
<button class="w2p-action active" data-action="select" type="button">${icon("select")}<span>选择元素</span></button>
|
||||||
|
<button class="w2p-close" data-action="close" type="button" aria-label="关闭">${icon("close")}</button>
|
||||||
|
</div>`;
|
||||||
|
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;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#00D2FF;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#7C3AED;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 背景圆 -->
|
||||||
|
<circle cx="64" cy="64" r="60" fill="url(#grad1)"/>
|
||||||
|
|
||||||
|
<!-- 网页图标 -->
|
||||||
|
<rect x="24" y="32" width="80" height="60" rx="4" fill="#fff" opacity="0.95"/>
|
||||||
|
|
||||||
|
<!-- 网页内容线 -->
|
||||||
|
<rect x="32" y="44" width="40" height="4" rx="2" fill="#00D2FF"/>
|
||||||
|
<rect x="32" y="54" width="64" height="3" rx="1.5" fill="#E2E8F0"/>
|
||||||
|
<rect x="32" y="62" width="56" height="3" rx="1.5" fill="#E2E8F0"/>
|
||||||
|
<rect x="32" y="70" width="48" height="3" rx="1.5" fill="#E2E8F0"/>
|
||||||
|
|
||||||
|
<!-- 箭头 -->
|
||||||
|
<path d="M72 80 L88 96 L104 80" stroke="#fff" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Pixso 标志 -->
|
||||||
|
<circle cx="96" cy="56" r="16" fill="#fff"/>
|
||||||
|
<text x="96" y="61" text-anchor="middle" font-size="14" font-weight="bold" fill="url(#grad1)">P</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||||
|
<rect width="16" height="16" rx="2" fill="#00D2FF"/>
|
||||||
|
<text x="8" y="12" text-anchor="middle" font-size="10" font-weight="bold" fill="white">P</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 244 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect width="32" height="32" rx="4" fill="#00D2FF"/>
|
||||||
|
<text x="16" y="22" text-anchor="middle" font-size="14" font-weight="bold" fill="white">P</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 245 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||||
|
<rect width="48" height="48" rx="6" fill="#00D2FF"/>
|
||||||
|
<text x="24" y="32" text-anchor="middle" font-size="20" font-weight="bold" fill="white">P</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 245 B |
|
After Width: | Height: | Size: 112 KiB |
@@ -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": ["<all_urls>"],
|
||||||
|
"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": ["<all_urls>"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 112 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 112 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 `
|
||||||
|
<div class="backdrop">
|
||||||
|
<main class="panel" role="dialog" aria-label="Web to Pixso">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo-title">
|
||||||
|
<img src="${chrome.runtime.getURL("logo/plugin-logo.png")}" alt="" class="logo">
|
||||||
|
<span class="title">Web to Pixso</span>
|
||||||
|
<span class="version-badge">v1.1.1</span>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" type="button" aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">采集模式</span>
|
||||||
|
<select class="setting-select mode-select" data-field="captureMode">
|
||||||
|
<option value="mixed">混合高保真</option>
|
||||||
|
<option value="editable">可编辑优先</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">跨域图片代理模式</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" data-field="useProxy">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">页面采集宽度</span>
|
||||||
|
<label class="width-input-wrap">
|
||||||
|
<input class="width-input" data-field="captureWidth" type="text" inputmode="numeric" autocomplete="off">
|
||||||
|
<span class="width-unit">px</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">图片采集并发</span>
|
||||||
|
<select class="setting-select" data-field="concurrency">
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="6">6</option>
|
||||||
|
<option value="8">8</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="12">12</option>
|
||||||
|
<option value="16">16</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="infinite">无限</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="description">页面采集宽度默认使用当前窗口宽度,可输入 320-3840px 触发响应式布局后采集。</p>
|
||||||
|
<button class="capture-btn" type="button">开始采集</button>
|
||||||
|
<button class="select-btn" type="button">打开页面浮窗</button>
|
||||||
|
<div class="status" hidden>
|
||||||
|
<div class="progress-bar" aria-hidden="true"><div class="progress-fill"></div></div>
|
||||||
|
<span class="status-text">准备中...</span>
|
||||||
|
</div>
|
||||||
|
<a class="help-link" href="https://z8qrcvi3n5.feishu.cn/wiki/RV8TwlhFyiGsEekQXk8cX5SHn6f" target="_blank" rel="noopener noreferrer">使用说明</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<span>by 大非</span>
|
||||||
|
<a class="support-email" href="mailto:270310136@qq.com">270310136@qq.com 给我发邮件哦,我光速改</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<style>${css()}</style>${panelHtml()}`;
|
||||||
|
document.documentElement.appendChild(root);
|
||||||
|
await bind(root);
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN" style="background: transparent;">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Web to Pixso</title>
|
||||||
|
<link rel="stylesheet" href="popup.css">
|
||||||
|
</head>
|
||||||
|
<body style="background: transparent;">
|
||||||
|
<main class="shell">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo-title">
|
||||||
|
<img src="logo/plugin-logo.png" alt="" class="logo">
|
||||||
|
<span class="title">Web to Pixso</span>
|
||||||
|
<span class="version-badge">v1.1.1</span>
|
||||||
|
</div>
|
||||||
|
<button class="close-btn" id="closeBtn" type="button" aria-label="关闭">x</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">采集模式</span>
|
||||||
|
<select class="setting-select mode-select" id="captureMode">
|
||||||
|
<option value="mixed" selected>混合高保真</option>
|
||||||
|
<option value="editable">可编辑优先</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">跨域图片代理模式</span>
|
||||||
|
<label class="toggle-switch" for="proxyToggle">
|
||||||
|
<input type="checkbox" id="proxyToggle">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">页面采集宽度</span>
|
||||||
|
<label class="width-input-wrap" for="captureWidth">
|
||||||
|
<input class="width-input" id="captureWidth" type="text" inputmode="numeric" autocomplete="off" aria-label="页面采集宽度">
|
||||||
|
<span class="width-unit">px</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">图片采集并发</span>
|
||||||
|
<select class="setting-select" id="concurrency">
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="6">6</option>
|
||||||
|
<option value="8" selected>8</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="12">12</option>
|
||||||
|
<option value="16">16</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="infinite">无限</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="description">页面采集宽度默认使用当前窗口宽度,可输入 320-3840px 触发响应式布局后采集。</p>
|
||||||
|
|
||||||
|
<button class="capture-btn" id="captureBtn" type="button">
|
||||||
|
<span id="btnText">开始采集</span>
|
||||||
|
</button>
|
||||||
|
<button class="select-btn" id="selectBtn" type="button">
|
||||||
|
<span id="selectBtnText">打开页面浮窗</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="status" id="status" hidden>
|
||||||
|
<div class="progress-bar" aria-hidden="true">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
<span class="status-text" id="statusText">准备中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="help-link" id="helpLink" href="https://z8qrcvi3n5.feishu.cn/wiki/RV8TwlhFyiGsEekQXk8cX5SHn6f" target="_blank" rel="noopener noreferrer">使用说明</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span class="author">by 大非</span>
|
||||||
|
<a class="support-email" href="mailto:270310136@qq.com">270310136@qq.com 给我发邮件哦,我光速改</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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、视频、动态轮播和强跨域资源可能仍需要兜底截图或人工微调。
|
||||||
|
- 真实网页还原质量会受目标网站资源加载、登录态、懒加载和浏览器环境影响。
|
||||||
|
- 对外使用时建议先用对比底图层检查还原度,再进行设计稿编辑。
|
||||||