DesignGym / web /js /panels.js
AdithyaSK's picture
AdithyaSK HF Staff
Serve Crello corpus aafdb0e86816 from the mounted bucket
df84e45 verified
Raw History Blame Contribute Delete
27.2 kB
// Panels: layers, assets, the design inspector, text presets.
// Skins place and label them differently; the behaviour is identical.
import { openColorPanel, openFontPanel, typography } from "./figma.js";
import { openColorDialog } from "./photoshop.js";
import { cssColor, elementLabel, fmt, h } from "./util.js";
import { icon } from "./icons.js";
import { ensureFonts, fontCatalog, hasStyle } from "./fonts.js";
import {
colorField, commandButton, fontPicker, iconButton, numberField, segmented, slider, toggle,
} from "./widgets.js";
// ---------------------------------------------------------------- selection values
export function selectionModel(app) {
const s = app.store;
const els = () => s.selected();
const common = (prop, kinds = null) => {
const list = els().filter((e) => !kinds || kinds.includes(e.kind));
if (!list.length) return undefined;
const v = list[0][prop];
return list.every((e) => e[prop] === v) ? v : null;
};
const set = (props, kinds = null) => {
const list = els().filter((e) => !e.locked && (!kinds || kinds.includes(e.kind)));
return s.updateMany(list.map((e) => {
const p = typeof props === "function" ? props(e) : props;
// A whole-element text style replaces any per-run value for that style.
if (e.kind === "text" && e.runs && ("color" in p || "bold" in p || "italic" in p)) {
p.runs = e.runs.map((r) => {
const out = { text: r.text };
for (const k of ["color", "bold", "italic"]) if (!(k in p) && r[k] !== undefined && r[k] !== null) out[k] = r[k];
return out;
});
}
return [e.id, p];
}));
};
return { els, common, set };
}
function docColors(app) {
const out = [];
const doc = app.store.doc;
if (!doc) return out;
out.push(doc.background);
for (const e of doc.elements) {
for (const c of [e.color, e.fill, e.stroke, ...(e.runs || []).map((r) => r.color)]) if (c) out.push(c.slice(0, 7));
}
return [...new Set(out)];
}
// ---------------------------------------------------------------- layers
export function layersPanel(app, { thumbs = true, background = false, compact = false, frame = null } = {}) {
const s = app.store;
const list = h("div", { class: `layers ${compact ? "compact" : ""}`, role: "tree" });
const root = h("div", { class: "panel-body layers-panel" }, list);
let drag = null;
const thumb = (el) => {
if (!thumbs) return h("span", { class: "layer-icon", html: icon(el.kind === "text" ? "type" : el.kind === "shape" ? (el.shape === "ellipse" ? "circle" : "square") : "image", 14) });
if (el.kind === "image") {
const a = s.assetById.get(el.asset_id);
return h("span", { class: "layer-thumb checker" }, h("img", { src: a?.url, alt: "", draggable: "false" }));
}
if (el.kind === "shape") {
return h("span", { class: "layer-thumb" }, h("span", { class: "shape-swatch", style: {
background: cssColor(el.fill), borderRadius: el.shape === "ellipse" ? "50%" : "2px" } }));
}
return h("span", { class: "layer-thumb text-thumb" }, "T");
};
const refresh = () => {
if (!s.doc) return;
list.textContent = "";
const els = [...s.doc.elements].reverse();
if (frame) { // Figma: every layer sits inside the frame, which is itself selectable
const on = !!app.frameSelected;
const fr = h("div", { class: `layer-row fg-frame-row ${on ? "selected" : ""}`, role: "treeitem", "aria-selected": on ? "true" : "false",
"aria-expanded": "true", dataset: { id: "frame" } },
h("span", { class: "layer-caret", html: icon("chevron-down", 10) }), h("span", { class: "layer-icon", html: icon("hash", 14) }),
h("span", { class: "layer-name" }, frame));
fr.addEventListener("pointerdown", (e) => { if (e.button === 0) app.selectFrame(); });
list.append(fr);
}
if (!els.length && !frame) list.append(h("div", { class: "empty" }, "No layers yet. Add an asset or some text."));
for (const el of els) {
const selected = s.selection.includes(el.id);
const nameEl = h("span", { class: "layer-name", title: elementLabel(el) }, elementLabel(el));
const row = h("div", {
class: `layer-row ${frame ? "nested" : ""} ${selected ? "selected" : ""} ${el.visible === false ? "hidden" : ""} ${el.locked ? "locked" : ""}`,
role: "treeitem", "aria-selected": selected ? "true" : "false", dataset: { id: el.id },
},
iconButton(el.visible === false ? "eye-off" : "eye", el.visible === false ? "Show layer" : "Hide layer",
() => s.update(el.id, { visible: el.visible === false }), { cls: "layer-eye" }),
thumb(el), nameEl,
iconButton(el.locked ? "lock" : "lock-open", el.locked ? "Unlock layer" : "Lock layer",
() => s.update(el.id, { locked: !el.locked }), { cls: "layer-lock" }));
row.addEventListener("pointerdown", (e) => {
if (e.target.closest("button") || e.button !== 0) return;
if (e.shiftKey || e.metaKey || e.ctrlKey) s.select(el.id, { toggle: true });
else if (!selected) s.select(el.id);
drag = { id: el.id, y0: e.clientY, over: null, moved: false };
row.setPointerCapture(e.pointerId);
});
row.addEventListener("pointermove", (e) => {
if (!drag || drag.id !== el.id) return;
if (Math.abs(e.clientY - drag.y0) > 4) drag.moved = true;
if (!drag.moved) return;
const target = document.elementFromPoint(e.clientX, e.clientY)?.closest(".layer-row");
list.querySelectorAll(".drop-above,.drop-below").forEach((n) => n.classList.remove("drop-above", "drop-below"));
if (target && target !== row) {
const r = target.getBoundingClientRect();
const above = e.clientY < r.top + r.height / 2;
target.classList.add(above ? "drop-above" : "drop-below");
drag.over = { id: target.dataset.id, above };
}
});
row.addEventListener("pointerup", async () => {
const d = drag;
drag = null;
list.querySelectorAll(".drop-above,.drop-below").forEach((n) => n.classList.remove("drop-above", "drop-below"));
if (!d?.moved || !d.over) return;
// Rows run top (front) to bottom (back); convert to a stacking index.
const from = s.index(d.id);
let to = s.index(d.over.id) + (d.over.above ? 1 : 0);
if (from < to) to -= 1;
if (to !== from) await s.op("reorder_element", { element_id: d.id, to: String(to) });
});
nameEl.addEventListener("dblclick", () => {
const input = h("input", { class: "layer-rename", value: el.name || elementLabel(el) });
nameEl.replaceWith(input);
input.focus();
input.select();
const done = (save) => {
if (save && input.value.trim() && input.value !== elementLabel(el)) s.update(el.id, { name: input.value.trim() });
else refresh();
};
input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") done(true); if (e.key === "Escape") done(false); });
input.addEventListener("blur", () => done(true));
});
row.addEventListener("dblclick", (e) => {
if (el.kind === "text" && !e.target.closest(".layer-name")) app.stage.startEdit(el.id);
});
list.append(row);
}
if (background) {
list.append(h("div", { class: "layer-row background-row", dataset: { id: "background" } },
h("span", { class: "layer-eye-placeholder" }),
h("span", { class: "layer-thumb" }, h("span", { class: "shape-swatch", style: { background: cssColor(s.doc.background) } })),
h("span", { class: "layer-name bg-name" }, "Background"),
h("span", { class: "layer-lock fixed", html: icon("lock", 13) })));
}
};
return { el: root, refresh };
}
// ---------------------------------------------------------------- assets
export function assetsPanel(app, { cols = 2, heading = null, search = false, filter = null, empty = "", top = null,
placeholder = "Search assets" } = {}) {
const s = app.store;
const grid = h("div", { class: "asset-grid", style: { gridTemplateColumns: `repeat(${cols}, 1fr)` } });
const q = search ? h("input", { class: "panel-search", placeholder, spellcheck: "false", "aria-label": placeholder }) : null;
const hint = h("div", { class: "panel-hint" }, "Click to add, or drag onto the canvas.");
const root = h("div", { class: "panel-body assets-panel" }, heading ? h("div", { class: "panel-heading" }, heading) : null, q, top, grid, hint);
const shown = () => (filter ? s.assets.filter(filter) : s.assets);
const startDrag = (asset, e, tile) => {
const x0 = e.clientX, y0 = e.clientY;
let ghost = null;
tile.setPointerCapture(e.pointerId);
const move = (ev) => {
if (!ghost && Math.hypot(ev.clientX - x0, ev.clientY - y0) > 5) {
ghost = h("div", { class: "drag-ghost" }, h("img", { src: asset.url, alt: "" }));
document.body.append(ghost);
s.busy(true);
}
if (ghost) Object.assign(ghost.style, { left: `${ev.clientX}px`, top: `${ev.clientY}px` });
};
const up = async (ev) => {
tile.removeEventListener("pointermove", move);
tile.removeEventListener("pointerup", up);
if (!ghost) { await app.addAsset(asset); return; }
ghost.remove();
s.busy(false);
const p = app.stage.dropPoint(ev.clientX, ev.clientY);
if (p) await app.addAsset(asset, p);
};
tile.addEventListener("pointermove", move);
tile.addEventListener("pointerup", up);
};
const refresh = () => {
const list = shown();
const key = `${s.episodeId}:${list.map((a) => a.id).join(",")}`;
hint.textContent = list.length ? "Click to add, or drag onto the canvas." : empty;
if (grid.dataset.key === key) {
const used = new Set((s.doc?.elements || []).filter((e) => e.kind === "image").map((e) => e.asset_id));
for (const t of grid.children) t.classList.toggle("used", used.has(t.dataset.asset));
return;
}
grid.dataset.key = key;
grid.textContent = "";
for (const a of list) {
const tile = h("div", { class: "asset-tile", dataset: { asset: a.id }, title: `${a.id} 路 ${a.kind}`, tabindex: "0", role: "button",
"aria-label": `Asset ${a.id} (${a.kind})` },
h("div", { class: "asset-img checker" }, h("img", { src: a.url, alt: a.id, draggable: "false", loading: "lazy" })),
h("div", { class: "asset-meta" }, h("span", { class: "asset-id" }, a.id), h("span", { class: "asset-kind" }, a.kind)));
tile.addEventListener("pointerdown", (e) => { if (e.button === 0) startDrag(a, e, tile); });
tile.addEventListener("keydown", (e) => { if (e.key === "Enter") app.addAsset(a); });
grid.append(tile);
}
if (q) q.oninput = () => {
const t = q.value.trim().toLowerCase();
for (const tile of grid.children) tile.style.display = !t || tile.title.toLowerCase().includes(t) ? "" : "none";
};
refresh();
};
return { el: root, refresh };
}
// ---------------------------------------------------------------- inspector
export function inspector(app, { variant = "figma" } = {}) {
const s = app.store;
const m = selectionModel(app);
const root = h("div", { class: `panel-body inspector inspector-${variant}` });
const controls = [];
const reg = (c) => { controls.push(c); return c; };
const section = (title, ...rows) => h("section", { class: "insp-section" },
title ? h("div", { class: "insp-title" }, title) : null, ...rows);
const row = (...kids) => h("div", { class: "insp-row" }, ...kids);
const C = app.commands;
const cb = (id) => reg(commandButton(C[id]));
let aspectLock = false;
const geom = (prop, label, opts = {}) => reg(numberField({
label, title: opts.title || prop, digits: opts.digits ?? 2, unit: opts.unit || "",
min: opts.min ?? -1e5, max: opts.max ?? 1e5, step: opts.step ?? 1, cls: opts.cls || "",
iconName: opts.iconName || null, name: prop,
// Canva shows the unit inside the field ("721.5 px", "0掳") and reads it back.
...(variant === "canva" ? { format: (v) => `${fmt(v, 1)}${prop === "angle" ? "掳" : " px"}`, parse: (t) => parseFloat(t.replace(",", ".")) } : {}),
get: () => {
const els = m.els();
if (!els.length) return undefined;
if (opts.kinds && !els.every((e) => opts.kinds.includes(e.kind))) return undefined;
const v = m.common(prop, opts.kinds);
return opts.scale && v !== null && v !== undefined ? v * opts.scale : v;
},
set: (v) => {
const val = opts.scale ? v / opts.scale : v;
if ((prop === "width" || prop === "height") && aspectLock) {
return m.set((e) => {
const r = e.width / e.height;
return prop === "width" ? { width: val, height: val / r } : { height: val, width: val * r };
});
}
return m.set({ [prop]: val }, opts.kinds);
},
}));
// alignment
const alignRow = row(cb("alignLeft"), cb("alignCenter"), cb("alignRight"), h("span", { class: "sep" }),
cb("alignTop"), cb("alignMiddle"), cb("alignBottom"), h("span", { class: "sep" }), cb("distributeH"), cb("distributeV"));
alignRow.classList.add("align-row");
const lockBtn = reg(toggle("lock", "Constrain proportions", () => aspectLock, (v) => { aspectLock = v; refresh(); }, { cls: "aspect-lock" }));
// Section names follow each editor's own vocabulary.
const T = {
photoshop: { pos: "Transform", text: "Character", page: "Document" },
canva: { pos: "Position", text: "Text", page: "Page" },
figma: { pos: "Position", text: "Typography", page: "Page" },
slides: { pos: "Size & Rotation 路 Position", text: "Text", page: "Slide background" },
powerpoint: { pos: "Size & Position", text: "Text Options", page: "Background" },
inkscape: { pos: "Transform", text: "Text and Font", page: "Document Properties" },
}[variant] || { pos: "Position", text: "Text", page: "Page" };
const ui3 = variant === "figma"; // Figma UI3: Position (x, y, rotation) and Layout (w, h) are separate
const long = variant === "canva"; // Canva spells the labels out above each field
const whRow = row(geom("width", long ? "Width" : "W", { min: 1 }), geom("height", long ? "Height" : "H", { min: 1, kinds: ["image", "shape"] }), lockBtn);
const rotRow = row(geom("angle", long ? "Rotate" : "", { iconName: long ? null : "rotate-cw", title: "Rotation", unit: "掳", digits: 2, min: -360, max: 360 }),
cb("flipH"), cb("flipV"));
const sub = (t) => (ui3 ? h("div", { class: "insp-sub" }, t) : null); // Figma's small labels above field groups
const posSection = long // Canva's Advanced block: Width / Height / Ratio, then X / Y / Rotate
? section(T.pos, whRow, row(geom("x", "X"), geom("y", "Y"), rotRow.firstChild))
: section(T.pos,
sub("Alignment"), variant === "photoshop" || variant === "inkscape" ? null : alignRow,
sub("Position"), row(geom("x", "X"), geom("y", "Y")),
sub("Rotation"), ui3 ? rotRow : whRow, ui3 ? null : rotRow);
const layoutSection = ui3 ? section("Layout", sub("Dimensions"), whRow) : null;
const opGet = () => { const v = m.common("opacity"); return v === undefined || v === null ? v : Math.round(v * 100); };
const opacity = ui3 // Figma: a typed "100%" field (1-0 on the keyboard also set it)
? reg(numberField({ label: "", title: "Opacity", name: "opacity", iconName: "blend", min: 0, max: 100, cls: "fg-opacity",
get: opGet, set: (v) => m.set({ opacity: v / 100 }), format: (v) => `${fmt(v, 0)}%`, parse: (t) => parseFloat(t) }))
: reg(slider({ label: "Opacity", name: "opacity", unit: "%", min: 0, max: 100, get: opGet, set: (v) => m.set({ opacity: v / 100 }) }));
const radius = geom("radius", "", { iconName: "square-dashed", title: "Corner radius", min: 0, kinds: ["shape"] });
const appearance = ui3
? section("Appearance", h("div", { class: "insp-subrow" }, sub("Opacity"), sub("Corner radius")), row(opacity, radius))
: section("Appearance", row(opacity), row(radius));
const kindHead = ui3 ? h("div", { class: "insp-kind" }, h("span", { class: "insp-kind-name" }, "")) : null;
const extras = [];
const picker = ui3 ? openColorPanel : variant === "photoshop"
? (chip, get, set) => openColorDialog(chip, { title: "Color Picker", get, set, addSwatch: (c) => app.addSwatch?.(c) }) : null;
const fill = reg(colorField({ label: ui3 ? "" : "Fill", name: "fill", docColors: () => docColors(app), open: picker,
get: () => { const els = m.els(); if (!els.length || !els.every((e) => e.kind === "shape")) return undefined; return m.common("fill", ["shape"]); },
set: (c) => m.set({ fill: c }, ["shape"]) }));
const stroke = reg(colorField({ label: ui3 ? "" : "Stroke", name: "stroke", docColors: () => docColors(app), open: picker,
get: () => { const els = m.els(); if (!els.length || !els.every((e) => e.kind === "shape")) return undefined; return m.common("stroke", ["shape"]); },
set: (c) => m.set((e) => ({ stroke: c, stroke_width: e.stroke_width || 4 }), ["shape"]) }));
const strokeW = geom("stroke_width", "", { iconName: "minus", title: "Stroke width", min: 0, kinds: ["shape"] });
const fillSection = ui3 // Figma: Fill and Stroke are their own sections
? h("div", {}, section("Fill", row(fill)), section("Stroke", row(stroke, strokeW)))
: section("Fill", row(fill), row(stroke, strokeW));
// typography
const textOnly = () => { const els = m.els(); return els.length && els.every((e) => e.kind === "text"); };
const tget = (p) => () => (textOnly() ? m.common(p, ["text"]) : undefined);
const font = reg(fontPicker({ get: tget("font_family"), set: (f) => m.set({ font_family: f }, ["text"]),
open: ui3 ? openFontPanel : null, preview: !ui3,
docFonts: () => [...(s.task?.fonts_used || []), ...s.doc.elements.filter((e) => e.kind === "text").map((e) => e.font_family)] }));
const size = geom("font_size", "", { iconName: "type", title: "Font size", min: 1, max: 2000, digits: 2, kinds: ["text"], unit: "px" });
const styleBtn = (prop, iconName, title, styleName) => {
const b = reg(toggle(iconName, title, () => m.common(prop, ["text"]) === true,
(v) => m.set({ [prop]: v }, ["text"])));
const set0 = b.set;
b.set = () => {
set0();
const fam = textOnly() ? m.common("font_family", ["text"]) : null;
b.disabled = !textOnly() || (styleName && fam && !hasStyle(fam, styleName) && !hasStyle(fam, "bolditalic"));
};
return b;
};
const bold = styleBtn("bold", "bold", "Bold", "bold");
const italic = styleBtn("italic", "italic", "Italic", "italic");
const underline = styleBtn("underline", "underline", "Underline", null);
const upper = styleBtn("uppercase", "case-upper", "Uppercase", null);
const align = reg(segmented([["left", "align-left", "Align left"], ["center", "align-center", "Align center"],
["right", "align-right", "Align right"]], tget("align"), (v) => m.set({ align: v }, ["text"])));
const lineH = geom("line_height", "", { iconName: "list", title: "Line height", min: 0.1, max: 10, digits: 2, step: 0.05, kinds: ["text"] });
const spacing = geom("letter_spacing", "", { iconName: "arrow-left-right", title: "Letter spacing", min: -50, max: 500, digits: 2, kinds: ["text"], unit: "px" });
const color = reg(colorField({ label: ui3 ? "" : "Colour", name: "color", docColors: () => docColors(app), get: tget("color"), open: picker,
set: (c) => m.set({ color: c }, ["text"]) }));
// Figma: typography, then the text colour under Fill; elsewhere colour sits with the text.
const textSection = ui3
? section("Typography", row(font), ...typography(app, m, reg, textOnly).rows)
: section(T.text, row(font), row(size, bold, italic, underline, upper), row(align), row(lineH, spacing), row(color));
const textFill = ui3 ? section("Fill", row(color)) : null;
const arrange = section("Arrange", row(cb("bringFront"), cb("bringForward"), cb("sendBackward"), cb("sendBack"),
h("span", { class: "sep" }), cb("duplicate"), cb("lock"), cb("delete")));
// nothing selected: the page
const bg = reg(colorField({ label: ui3 ? "" : "Background", name: "background", docColors: () => docColors(app), open: picker,
get: () => s.doc?.background, set: (c) => s.op("set_background", { color: c }) }));
const canvasInfo = h("div", { class: "canvas-info" });
// Figma: with nothing selected the panel shows the page (the canvas colour around the design);
// the design's background is the frame's Fill, reached by selecting the frame.
const canvasColor = ui3 ? reg(colorField({ label: "", name: "canvas", open: picker,
get: () => app.canvasColor || getComputedStyle(document.documentElement).getPropertyValue("--canvas-bg").trim(),
set: (c) => app.setCanvasColor(c) })) : null;
const exportBtn = ui3 ? h("button", { class: "btn fg-export-btn", type: "button", dataset: { command: "submit" }, onclick: () => C.submit.run() }) : null;
const exportSection = ui3 ? section("Export", h("div", { class: "insp-row fg-export-row" },
h("span", { class: "fg-export-scale" }, "1x"), h("span", { class: "fg-export-fmt" }, "PNG")), h("div", { class: "insp-row" }, exportBtn)) : null;
const pageSection = ui3 ? section(T.page, row(canvasColor)) : section(T.page, row(bg), canvasInfo);
const frameHead = ui3 ? h("div", { class: "insp-kind fg-frame-head" }, h("span", { class: "insp-kind-name" }, "Frame")) : null;
const frameSize = ui3 ? h("div", { class: "insp-row fg-frame-size" }) : null;
const frameSection = ui3 ? h("div", { class: "fg-frame" }, frameHead,
section("Layout", h("div", { class: "insp-sub" }, "Dimensions"), frameSize), section("Fill", row(bg))) : null;
if (ui3) root.append(pageSection, frameSection, kindHead, posSection, layoutSection, appearance, textSection, textFill, fillSection, exportSection, ...extras);
else if (long) root.append(posSection); // Canva: styling lives in the toolbar, not the Position panel
else root.append(pageSection, posSection, textSection, fillSection, appearance, arrange);
const refresh = () => {
if (!s.doc) return;
const els = m.els();
const kinds = new Set(els.map((e) => e.kind));
const frame = ui3 && !els.length && !!app.frameSelected;
pageSection.hidden = els.length > 0 || frame;
if (frameSection) {
frameSection.hidden = !frame;
frameSize.textContent = "";
frameSize.append(h("span", { class: "fg-static" }, h("span", { class: "num-label" }, "W"), String(s.doc.width)),
h("span", { class: "fg-static" }, h("span", { class: "num-label" }, "H"), String(s.doc.height)));
exportBtn.textContent = s.done ? "Exported" : `Export ${app.skin.frameLabel || "Frame 1"}`; // the design is the frame
exportBtn.disabled = s.done;
}
posSection.hidden = els.length === 0;
textSection.hidden = !(els.length && kinds.size === 1 && kinds.has("text"));
if (layoutSection) layoutSection.hidden = els.length === 0;
if (kindHead) {
kindHead.hidden = els.length === 0;
kindHead.firstChild.textContent = els.length > 1 ? "Mixed" : { text: "Text", image: "Image", shape: els[0]?.shape === "ellipse" ? "Ellipse" : "Rectangle" }[els[0]?.kind] || "";
extras.forEach((x) => { x.hidden = els.length === 0; });
}
if (textFill) textFill.hidden = textSection.hidden;
fillSection.hidden = !(els.length && kinds.size === 1 && kinds.has("shape"));
appearance.hidden = els.length === 0;
radius.hidden = !(kinds.size === 1 && kinds.has("shape"));
arrange.hidden = els.length === 0;
canvasInfo.textContent = `${s.doc.width} 脳 ${s.doc.height} px 路 ${s.doc.elements.length} layers`;
for (const c of controls) c.set?.() ?? c.refresh?.();
};
return { el: root, refresh };
}
// ---------------------------------------------------------------- text presets (Canva)
export function textPresets(app) {
// Canva's Text panel: search, Add a text box, and the default heading / subheading / body styles.
// Searching lists fonts; a click adds a text box set in that font.
const C = app.commands;
const search = h("input", { class: "cv-textsearch", placeholder: "Search fonts and combinations", spellcheck: "false", "aria-label": "Search fonts and combinations" });
const results = h("div", { class: "text-results" });
const styles = h("div", { class: "text-styles" },
h("div", { class: "panel-heading" }, "Default text styles"),
h("button", { class: "preset preset-h1", type: "button", onclick: () => C.addHeading.run() }, "Add a heading"),
h("button", { class: "preset preset-h2", type: "button", onclick: () => C.addSubheading.run() }, "Add a subheading"),
h("button", { class: "preset preset-body", type: "button", onclick: () => C.addBody.run() }, "Add a little bit of body text"));
let families = [];
const paint = () => {
const q = search.value.trim().toLowerCase();
styles.hidden = !!q;
results.textContent = "";
if (!q) return;
const hits = families.filter((f) => f.toLowerCase().includes(q)).slice(0, 30);
ensureFonts(hits);
results.append(h("div", { class: "panel-heading" }, "Fonts"), ...hits.map((f) => h("button", { class: "font-sample", type: "button", style: { fontFamily: `'${f}'` },
onclick: () => app.addTextPreset({ text: f, font_family: f, font_size: Math.round(app.store.doc.height / 16) }) }, f)));
if (!hits.length) results.append(h("div", { class: "panel-hint" }, `No results for \u201c${search.value.trim()}\u201d`));
};
search.addEventListener("input", paint);
search.addEventListener("keydown", (e) => { if (e.key !== "Escape") e.stopPropagation(); });
const root = h("div", { class: "panel-body text-presets" },
h("div", { class: "cv-searchbox" }, h("span", { html: icon("search", 16) }), search),
// As in Canva: the text box lands on the page straight away, ready to type into.
h("button", { class: "btn btn-primary wide", type: "button", onclick: () => {
const d = app.store.doc;
if (d) app.addTextAt(d.width * 0.3, d.height * 0.5);
} }, h("span", { html: icon("type", 16) }), " Add a text box"),
styles, results);
const refresh = async () => { if (!families.length) families = (await fontCatalog()).map((f) => f.family); };
return { el: root, refresh };
}
// ---------------------------------------------------------------- shapes (Canva "Elements")
export function shapesPanel(app) {
const C = app.commands;
const shape = (cmd, cls) => h("button", { class: `shape-tile ${cls}`, type: "button", title: cmd.label, "aria-label": cmd.label,
onclick: () => cmd.run() }, h("span", { class: "shape-demo" }));
return {
el: h("div", { class: "panel-body shapes-panel" },
h("div", { class: "panel-heading" }, "Shapes"),
h("div", { class: "shape-grid" }, shape(C.addRect, "sq"), shape(C.addRounded, "rr"), shape(C.addEllipse, "ci"))),
refresh: () => {},
};
}
export function statusText(app) {
const s = app.store;
if (!s.doc) return "";
const z = Math.round(app.stage.zoom * 100);
return `${z}% 路 ${s.doc.width} 脳 ${s.doc.height} px 路 ${s.doc.elements.length} layers`;
}
export { fmt };