Files
html2pptx/web-to-pixso/popup-panel.js
T
李进 330f4e96ce feat: 加入 web-to-pixso 插件源码
Chrome 扩展,用于从网页提取 DOM 树为 JSON 格式。
核心文件:capture.js(76KB,提取逻辑)
2026-07-27 12:22:27 +08:00

522 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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);
};
})();