项目文件夹

文件
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

274 行
10 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
padding: 16px;
font-family: Inter, system-ui, -apple-system, sans-serif;
color: #111827;
background: #fff;
font-size: 13px;
}
h1 { font-size: 14px; margin: 0 0 4px; }
p.lead { margin: 0 0 14px; color: #6b7280; line-height: 1.45; }
.drop {
border: 1.5px dashed #d1d5db;
border-radius: 10px;
padding: 18px 12px;
text-align: center;
color: #6b7280;
cursor: pointer;
transition: border-color 160ms, background 160ms;
}
.drop:hover, .drop.over { border-color: #2563eb; background: #f8faff; color: #2563eb; }
.drop strong { color: #111827; }
.or { text-align: center; color: #9ca3af; font-size: 11px; margin: 10px 0; }
textarea {
width: 100%;
height: 96px;
resize: vertical;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
}
.row { display: flex; gap: 8px; margin-top: 12px; }
button {
flex: 1;
height: 34px;
border-radius: 8px;
border: 1px solid transparent;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.primary { background: #2563eb; color: #fff; }
.primary:hover { background: #1d4ed8; }
.primary:disabled { background: #93b4f5; cursor: default; }
.ghost { background: #fff; border-color: #e5e7eb; color: #111827; }
.ghost:hover { background: #f9fafb; }
.msg { margin-top: 10px; min-height: 16px; font-size: 12px; }
.msg[data-kind='ok'] { color: #16a34a; }
.msg[data-kind='err'] { color: #dc2626; }
input[type='file'] { display: none; }
</style>
</head>
<body>
<h1>OD Figma Import</h1>
<p class="lead">
Load an <strong>.od-figma.json</strong> capture from the OD Clipper or the OD Library
(“Download Figma”) to rebuild it as editable layers.
</p>
<label class="drop" id="drop">
Drop or <strong>choose a .od-figma.json file</strong>
<input id="file" type="file" accept=".json,application/json" />
</label>
<div class="or">— or paste the JSON —</div>
<textarea id="json" placeholder='{ "version": 1, "root": { … } }'></textarea>
<div class="row">
<button class="ghost" id="cancel" type="button">Close</button>
<button class="primary" id="import" type="button" disabled>Import</button>
</div>
<div class="msg" id="msg" role="status"></div>
<script>
const $ = (id) => document.getElementById(id);
let pending = null; // parsed IR awaiting import
function setMsg(text, kind) {
const el = $('msg');
el.textContent = text || '';
el.dataset.kind = kind || '';
}
// --- image-fill normalization --------------------------------------
//
// figma.createImage() (used by code.js) only decodes PNG and JPEG, but a
// web capture's image fills can be SVG, WebP, GIF, AVIF or BMP — Figma
// rejects those, so on the main thread they would silently drop and the
// imported layout would be missing logos, icons and hero images.
//
// This UI runs in a real browser iframe (Figma desktop is Chromium), so
// it CAN decode every format an <img> understands and re-encode it to a
// PNG data URI here, before the IR is posted to the main thread. SVG (a
// vector) is rasterized at 2x its box for crispness; raster formats are
// re-encoded at their own pixel size. Anything that fails to decode is
// dropped so a single bad image can't abort the import.
const FIGMA_SAFE_IMAGE = /^image\/(png|jpe?g)$/i;
const MAX_RASTER_DIM = 4096; // Figma image ceiling
function dataUriMime(uri) {
const m = /^data:([^;,]+)/.exec(uri || '');
return m ? m[1] : '';
}
// Decode any <img>-renderable data URI and re-encode it as PNG. `boxW/boxH`
// are the fill's layout box, used to size vectors / dimensionless images.
function rasterizeToPng(dataUri, boxW, boxH, isVector) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
try {
let w = img.naturalWidth || 0;
let h = img.naturalHeight || 0;
if (!w || !h) {
w = Math.max(1, Math.round(boxW) || 300);
h = Math.max(1, Math.round(boxH) || 150);
}
const scale = isVector ? 2 : 1; // upscale vectors so they stay crisp
w = Math.max(1, Math.round(w * scale));
h = Math.max(1, Math.round(h * scale));
const longest = Math.max(w, h);
if (longest > MAX_RASTER_DIM) {
const r = MAX_RASTER_DIM / longest;
w = Math.max(1, Math.round(w * r));
h = Math.max(1, Math.round(h * r));
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/png'));
} catch {
resolve(null);
}
};
img.onerror = () => resolve(null);
img.src = dataUri;
});
}
// Walk the IR and convert every non-PNG/JPEG image fill to PNG in place.
// Returns { converted, dropped } for the status line.
async function normalizeImageFills(ir) {
const tasks = [];
const walk = (node) => {
if (!node) return;
if (Array.isArray(node.fills)) {
for (const f of node.fills) {
if (!f || f.type !== 'IMAGE' || typeof f.dataUri !== 'string') continue;
if (FIGMA_SAFE_IMAGE.test(dataUriMime(f.dataUri))) continue;
const isVector = /svg/i.test(dataUriMime(f.dataUri));
tasks.push(
rasterizeToPng(f.dataUri, node.width, node.height, isVector).then((png) => {
if (png) { f.dataUri = png; f.__ok = true; }
else { f.__drop = true; }
}),
);
}
}
if (Array.isArray(node.children)) node.children.forEach(walk);
};
if (ir && ir.root) walk(ir.root);
await Promise.all(tasks);
let converted = 0;
let dropped = 0;
const prune = (node) => {
if (!node) return;
if (Array.isArray(node.fills)) {
node.fills = node.fills.filter((f) => {
if (f && f.__ok) { delete f.__ok; converted += 1; return true; }
if (f && f.__drop) { dropped += 1; return false; }
return true;
});
}
if (Array.isArray(node.children)) node.children.forEach(prune);
};
if (ir && ir.root) prune(ir.root);
return { converted, dropped };
}
function accept(text, label) {
try {
const ir = JSON.parse(text);
if (!ir || !ir.root) throw new Error('missing root');
pending = ir;
$('import').disabled = false;
setMsg(`Ready: ${label || (ir.source && ir.source.title) || 'capture'}`, 'ok');
} catch (e) {
pending = null;
$('import').disabled = true;
setMsg('That doesn’t look like an OD Figma capture.', 'err');
}
}
$('file').addEventListener('change', (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => accept(String(reader.result), f.name);
reader.onerror = () => setMsg('Could not read that file.', 'err');
reader.readAsText(f);
});
const drop = $('drop');
['dragenter', 'dragover'].forEach((ev) =>
drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('over'); }),
);
['dragleave', 'drop'].forEach((ev) =>
drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('over'); }),
);
drop.addEventListener('drop', (e) => {
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => accept(String(reader.result), f.name);
reader.readAsText(f);
});
$('json').addEventListener('input', (e) => {
const v = e.target.value.trim();
if (v) accept(v, 'pasted JSON');
else { pending = null; $('import').disabled = true; setMsg(''); }
});
let lastImageStats = null; // { converted, dropped } from the most recent import
$('import').addEventListener('click', async () => {
if (!pending) return;
$('import').disabled = true;
try {
setMsg('Preparing images…');
lastImageStats = await normalizeImageFills(pending);
setMsg('Importing…');
parent.postMessage({ pluginMessage: { type: 'import', ir: pending } }, '*');
} catch (e) {
setMsg(`Could not prepare images: ${(e && e.message) || e}`, 'err');
$('import').disabled = false;
}
});
$('cancel').addEventListener('click', () => {
parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*');
});
window.onmessage = (e) => {
const m = e.data && e.data.pluginMessage;
if (!m) return;
if (m.type === 'done') {
let note = `Imported “${m.name}”.`;
if (lastImageStats && lastImageStats.converted) {
note += ` Converted ${lastImageStats.converted} image${lastImageStats.converted === 1 ? '' : 's'} (SVG/WebP/GIF → PNG).`;
}
if (lastImageStats && lastImageStats.dropped) {
note += ` ${lastImageStats.dropped} image${lastImageStats.dropped === 1 ? '' : 's'} could not be decoded.`;
}
setMsg(note, 'ok');
$('import').disabled = false;
} else if (m.type === 'error') {
setMsg(`Failed: ${m.message}`, 'err');
$('import').disabled = false;
}
};
</script>
</body>
</html>