Files
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 lines
10 KiB
HTML
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.
<!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 doesnt 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>