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
9.5 KiB
JavaScript

// OD Figma Import — plugin main thread.
//
// Rebuilds an OD Figma capture IR (produced by the OD Clipper / OD Library; see
// IR.md) into editable Figma layers. The UI (ui.html) hands us a parsed IR
// object; we preload its fonts, then walk the node-tree creating FRAME / TEXT /
// RECTANGLE nodes at their captured geometry with fills, strokes, corner radii
// and shadows.
//
// Standalone, dependency-free, no build step — loaded via
// "Plugins → Development → Import plugin from manifest…".
figma.showUI(__html__, { width: 340, height: 420, title: 'OD Figma Import' });
figma.ui.onmessage = async (msg) => {
if (!msg) return;
if (msg.type === 'cancel') {
figma.closePlugin();
return;
}
if (msg.type === 'import') {
try {
const ir = msg.ir;
if (!ir || !ir.root) throw new Error('Not an OD Figma capture (missing root).');
const container = await importIr(ir);
figma.ui.postMessage({ type: 'done', name: container.name });
figma.notify(`Imported “${container.name}”`);
} catch (err) {
figma.ui.postMessage({ type: 'error', message: String((err && err.message) || err) });
}
}
};
// --- font preloading -------------------------------------------------------
let LOADED_FONTS = new Set();
const fontKey = (family, style) => `${family}${style}`;
async function tryLoadFont(family, style) {
const key = fontKey(family, style);
if (LOADED_FONTS.has(key)) return true;
try {
await figma.loadFontAsync({ family, style });
LOADED_FONTS.add(key);
return true;
} catch {
return false;
}
}
async function preloadFonts(fonts) {
LOADED_FONTS = new Set();
await tryLoadFont('Inter', 'Regular'); // guaranteed fallback
for (const f of fonts || []) {
const family = f && f.family;
if (!family) continue;
const styles = (f.styles && f.styles.length ? f.styles : ['Regular']);
for (const style of styles) await tryLoadFont(family, style);
}
}
// Resolve a usable, already-loaded font for a text node.
function resolveFont(family, style) {
if (LOADED_FONTS.has(fontKey(family, style))) return { family, style };
if (LOADED_FONTS.has(fontKey(family, 'Regular'))) return { family, style: 'Regular' };
return { family: 'Inter', style: 'Regular' };
}
// --- paint / effect helpers ------------------------------------------------
function solidPaint(fill) {
if (!fill || !fill.color) return null;
const c = fill.color;
return {
type: 'SOLID',
color: { r: clamp01(c.r), g: clamp01(c.g), b: clamp01(c.b) },
opacity: fill.opacity == null ? 1 : clamp01(fill.opacity),
};
}
function imagePaint(fill) {
if (!fill || !fill.dataUri) return null;
try {
const bytes = decodeDataUri(fill.dataUri);
if (!bytes) return null;
const image = figma.createImage(bytes);
return { type: 'IMAGE', scaleMode: fill.scaleMode || 'FILL', imageHash: image.hash };
} catch {
return null;
}
}
function toPaints(fills) {
const out = [];
for (const f of fills || []) {
const paint = f && f.type === 'IMAGE' ? imagePaint(f) : solidPaint(f);
if (paint) out.push(paint);
}
return out;
}
function toEffects(effects) {
const out = [];
for (const e of effects || []) {
if (!e || e.type !== 'DROP_SHADOW') continue;
const c = e.color || { r: 0, g: 0, b: 0, a: 0.25 };
out.push({
type: 'DROP_SHADOW',
color: { r: clamp01(c.r), g: clamp01(c.g), b: clamp01(c.b), a: c.a == null ? 0.25 : clamp01(c.a) },
offset: { x: (e.offset && e.offset.x) || 0, y: (e.offset && e.offset.y) || 0 },
radius: Math.max(0, e.radius || 0),
spread: Math.max(0, e.spread || 0),
visible: true,
blendMode: 'NORMAL',
});
}
return out;
}
function applyBoxProps(node, spec) {
const fills = toPaints(spec.fills);
if ('fills' in node) node.fills = fills;
if (spec.strokes && 'strokes' in node) {
const strokes = toPaints(spec.strokes);
if (strokes.length) {
node.strokes = strokes;
if (spec.strokeWeight && 'strokeWeight' in node) node.strokeWeight = spec.strokeWeight;
}
}
if ('cornerRadius' in node && typeof spec.cornerRadius === 'number') {
node.cornerRadius = spec.cornerRadius;
}
if (spec.rectangleCornerRadii && 'topLeftRadius' in node) {
const r = spec.rectangleCornerRadii;
node.topLeftRadius = r.topLeft || 0;
node.topRightRadius = r.topRight || 0;
node.bottomRightRadius = r.bottomRight || 0;
node.bottomLeftRadius = r.bottomLeft || 0;
}
const effects = toEffects(spec.effects);
if (effects.length && 'effects' in node) node.effects = effects;
if (typeof spec.opacity === 'number' && 'opacity' in node) node.opacity = spec.opacity;
}
// --- tree construction -----------------------------------------------------
async function importIr(ir) {
await preloadFonts(ir.fonts);
const root = ir.root;
const container = figma.createFrame();
container.name = (ir.source && ir.source.title) || 'OD Capture';
container.clipsContent = true;
container.resize(Math.max(1, root.width || 1), Math.max(1, root.height || 1));
applyBoxProps(container, root);
// White backstop when the page root declared no background.
if (!container.fills || !container.fills.length) {
container.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
}
figma.currentPage.appendChild(container);
container.x = 0;
container.y = 0;
const rootAbsX = root.x || 0;
const rootAbsY = root.y || 0;
for (const child of root.children || []) {
await buildNode(child, container, rootAbsX, rootAbsY);
}
figma.currentPage.selection = [container];
figma.viewport.scrollAndZoomIntoView([container]);
return container;
}
// Build one IR node under `parent`. `pax/pay` are the parent's absolute
// document coordinates, so child placement converts to parent-relative.
async function buildNode(spec, parent, pax, pay) {
try {
if (spec.type === 'TEXT') {
const t = figma.createText();
const font = resolveFont(spec.fontFamily || 'Inter', spec.fontStyle || 'Regular');
t.fontName = font;
t.characters = spec.characters || '';
if (spec.fontSize) t.fontSize = Math.max(1, spec.fontSize);
if (spec.lineHeight) t.lineHeight = { value: spec.lineHeight, unit: 'PIXELS' };
if (spec.letterSpacing) t.letterSpacing = { value: spec.letterSpacing, unit: 'PIXELS' };
t.textAlignHorizontal = spec.textAlign || 'LEFT';
const color = spec.color || { r: 0, g: 0, b: 0 };
t.fills = [{ type: 'SOLID', color: { r: clamp01(color.r), g: clamp01(color.g), b: clamp01(color.b) },
opacity: spec.opacity == null ? 1 : clamp01(spec.opacity) }];
t.textAutoResize = 'NONE';
safeResize(t, spec.width, spec.height);
parent.appendChild(t);
t.x = (spec.x || 0) - pax;
t.y = (spec.y || 0) - pay;
return t;
}
const hasChildren = Array.isArray(spec.children) && spec.children.length;
const node = spec.type === 'RECTANGLE' && !hasChildren ? figma.createRectangle() : figma.createFrame();
node.name = spec.name || spec.type.toLowerCase();
if (node.type === 'FRAME') {
node.clipsContent = Boolean(spec.clipsContent);
node.fills = []; // default frame fill removed; applyBoxProps sets real fills
}
safeResize(node, spec.width, spec.height);
applyBoxProps(node, spec);
parent.appendChild(node);
node.x = (spec.x || 0) - pax;
node.y = (spec.y || 0) - pay;
if (hasChildren && node.type === 'FRAME') {
const ax = spec.x || 0;
const ay = spec.y || 0;
for (const child of spec.children) await buildNode(child, node, ax, ay);
}
return node;
} catch {
return null; // one bad node must not abort the whole import
}
}
// --- low-level helpers -----------------------------------------------------
function clamp01(n) {
const v = Number(n);
if (!Number.isFinite(v)) return 0;
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function safeResize(node, w, h) {
const width = Math.max(1, Math.round(Number(w) || 1));
const height = Math.max(1, Math.round(Number(h) || 1));
try {
node.resize(width, height);
} catch {
// text with empty characters can't resize to <1; ignore
}
}
// Decode a `data:<mime>;base64,<payload>` URI into a Uint8Array. We decode
// base64 by hand so we don't depend on atob / figma.base64Decode availability.
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function decodeDataUri(dataUri) {
const comma = dataUri.indexOf(',');
if (comma === -1) return null;
const meta = dataUri.slice(0, comma);
let payload = dataUri.slice(comma + 1);
if (!/;base64/i.test(meta)) {
// percent-encoded text payload
const decoded = decodeURIComponent(payload);
const bytes = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i++) bytes[i] = decoded.charCodeAt(i) & 0xff;
return bytes;
}
payload = payload.replace(/[^A-Za-z0-9+/=]/g, '');
const lookup = new Int16Array(256).fill(-1);
for (let i = 0; i < B64.length; i++) lookup[B64.charCodeAt(i)] = i;
const len = payload.length;
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
const outLen = Math.floor((len * 3) / 4) - padding;
const out = new Uint8Array(outLen);
let p = 0;
for (let i = 0; i < len; i += 4) {
const a = lookup[payload.charCodeAt(i)];
const b = lookup[payload.charCodeAt(i + 1)];
const c = lookup[payload.charCodeAt(i + 2)];
const d = lookup[payload.charCodeAt(i + 3)];
const chunk = (a << 18) | (b << 12) | ((c & 63) << 6) | (d & 63);
if (p < outLen) out[p++] = (chunk >> 16) & 0xff;
if (p < outLen) out[p++] = (chunk >> 8) & 0xff;
if (p < outLen) out[p++] = chunk & 0xff;
}
return out;
}