// Controls shared by all three skins. Each one is a plain function returning a // DOM node plus a `set(value)` method, so panels can refresh them in place // without losing focus while the user is typing. import { clamp, fmt, h, shortcut } from "./util.js"; import { icon } from "./icons.js"; import { ensureFonts, fontCatalog } from "./fonts.js"; // ---------------------------------------------------------------- basics export function iconButton(name, title, onClick, { cls = "", label = null, size = 16 } = {}) { const b = h("button", { class: `icon-btn ${cls}`, title, "aria-label": title, type: "button" }); b.innerHTML = icon(name, size) + (label ? `${label}` : ""); b.addEventListener("click", (e) => { e.stopPropagation(); onClick?.(e); }); return b; } export function commandButton(cmd, opts = {}) { const b = iconButton(cmd.icon || "square", `${cmd.label}${cmd.key ? ` (${shortcut(cmd.key)})` : ""}`, () => cmd.run(), opts); b.dataset.command = cmd.id; b.refresh = () => { b.disabled = cmd.enabled ? !cmd.enabled() : false; }; return b; } export function toggle(name, title, getter, onChange, opts = {}) { const b = iconButton(name, title, () => onChange(!getter()), opts); b.set = () => { b.classList.toggle("on", !!getter()); }; return b; } export function segmented(options, getter, onChange, { cls = "" } = {}) { const wrap = h("div", { class: `segmented ${cls}`, role: "radiogroup" }); const buttons = options.map(([value, iconName, title]) => { const b = iconButton(iconName, title, () => onChange(value)); b.dataset.value = value; b.setAttribute("role", "radio"); wrap.append(b); return b; }); wrap.set = () => { const v = getter(); for (const b of buttons) { b.classList.toggle("on", b.dataset.value === v); b.setAttribute("aria-checked", b.dataset.value === v ? "true" : "false"); } }; return wrap; } // ---------------------------------------------------------------- number field // A labelled numeric input. The label scrubs: drag it sideways to change the // value, as in Figma. Enter or blur commits; arrow keys step (Shift = x10). export function numberField({ label, title, unit = "", digits = 0, step = 1, min = -1e6, max = 1e6, get, set, cls = "", iconName = null, width = null, name = null, format = null, parse = null }) { // `format(v)` / `parse(text)` let a field show and read units the way its editor does // (Figma's "120%" line height, "2px" letter spacing); plain numbers otherwise. const input = h("input", { class: "num-input", type: "text", inputmode: "decimal", spellcheck: "false", "aria-label": title || label }); const lab = h("span", { class: "num-label", title: title || label }); if (iconName) lab.innerHTML = icon(iconName, 12); else lab.textContent = label; const wrap = h("label", { class: `num-field ${cls}`, dataset: { field: name || title || label || "" } }, lab, input, unit ? h("span", { class: "num-unit" }, unit) : null); if (width) wrap.style.width = width; let editing = false; let dirty = false; // the text differs from what we last displayed const commit = () => { if (!dirty) return; dirty = false; let v; if (parse) { v = parse(input.value.trim()); } else { const raw = input.value.trim().replace(/[^\d.+\-*/() ]/g, ""); if (!raw) { refresh(true); return; } try { v = Function(`"use strict";return (${raw})`)(); } catch { v = parseFloat(raw); } } if (!Number.isFinite(v)) { refresh(true); return; } v = clamp(v, min, max); const cur = get(); if (cur === null || Math.abs(v - cur) > 1e-9) set(v); refresh(true); }; const refresh = (force = false) => { if (editing && !force) return; const v = get(); dirty = false; input.value = v === null || v === undefined ? "" : format ? format(v) : fmt(v, digits); input.placeholder = v === null ? "Mixed" : ""; wrap.classList.toggle("disabled", v === undefined); input.disabled = v === undefined; }; input.addEventListener("focus", () => { editing = true; input.select(); }); input.addEventListener("input", () => { dirty = true; }); input.addEventListener("blur", () => { editing = false; commit(); }); input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") { commit(); input.select(); } else if (e.key === "Escape") { editing = false; refresh(true); input.blur(); } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); const cur = parse ? (get() ?? 0) : parseFloat(input.value) || 0; const d = (e.key === "ArrowUp" ? 1 : -1) * step * (e.shiftKey ? 10 : 1); input.value = format ? format(clamp(cur + d, min, max)) : fmt(clamp(cur + d, min, max), digits); dirty = true; commit(); } }); lab.addEventListener("pointerdown", (e) => { const v0 = get(); if (v0 === null || v0 === undefined) return; e.preventDefault(); lab.setPointerCapture(e.pointerId); const x0 = e.clientX; let last = v0; const move = (ev) => { last = clamp(v0 + Math.round((ev.clientX - x0) / 2) * step * (ev.shiftKey ? 10 : 1), min, max); input.value = format ? format(last) : fmt(last, digits); }; const up = () => { lab.removeEventListener("pointermove", move); lab.removeEventListener("pointerup", up); if (last !== v0) set(last); }; lab.addEventListener("pointermove", move); lab.addEventListener("pointerup", up); }); wrap.set = refresh; wrap.input = input; return wrap; } export function slider({ get, set, min = 0, max = 100, step = 1, label = "", unit = "", name = null }) { const range = h("input", { type: "range", min, max, step, class: "range" }); const out = h("input", { type: "text", class: "num-input range-num", "aria-label": label }); const wrap = h("div", { class: "slider-field", dataset: { field: name || label } }, label ? h("span", { class: "slider-label" }, label) : null, range, out, unit ? h("span", { class: "num-unit" }, unit) : null); range.addEventListener("input", () => { out.value = range.value; }); range.addEventListener("change", () => set(parseFloat(range.value))); out.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") { sdirty = false; set(clamp(parseFloat(out.value) || 0, min, max)); } }); let sdirty = false; out.addEventListener("input", () => { sdirty = true; }); out.addEventListener("blur", () => { const v = parseFloat(out.value); if (sdirty && Number.isFinite(v) && v !== get()) set(clamp(v, min, max)); sdirty = false; }); wrap.set = () => { const v = get(); if (document.activeElement !== out) out.value = v === null || v === undefined ? "" : fmt(v, step < 1 ? 2 : 0); range.value = v ?? min; range.disabled = out.disabled = v === undefined; }; return wrap; } // ---------------------------------------------------------------- popovers let openPop = null; export function popoverOpen() { return !!openPop?.isConnected; } export function closePopover() { if (openPop) { openPop.remove(); openPop = null; } } export function popover(anchor, content, { align = "start", cls = "", offset = 6, beside = null } = {}) { closePopover(); const pop = h("div", { class: `popover ${cls}`, role: "dialog" }, content); document.body.append(pop); const r = anchor.getBoundingClientRect(); const pr = pop.getBoundingClientRect(); let left = align === "end" ? r.right - pr.width : r.left; let top = r.bottom + offset; if (top + pr.height > innerHeight - 8) top = Math.max(8, r.top - pr.height - offset); if (beside) { // a floating panel to the left of the side panel, level with the control (Figma's pickers) const side = anchor.closest(beside)?.getBoundingClientRect() || r; left = side.left - pr.width - 8; top = clamp(r.top - 36, 8, innerHeight - pr.height - 8); } left = clamp(left, 8, innerWidth - pr.width - 8); Object.assign(pop.style, { left: `${left}px`, top: `${top}px` }); openPop = pop; const off = (e) => { if (!pop.isConnected) { document.removeEventListener("pointerdown", off, true); return; } if (!pop.contains(e.target) && !anchor.contains(e.target)) { closePopover(); document.removeEventListener("pointerdown", off, true); } }; setTimeout(() => document.addEventListener("pointerdown", off, true), 0); pop.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.stopPropagation(); closePopover(); anchor.focus?.(); } }); // Floating panels move by their header, as Figma's colour and font panels do. pop.addEventListener("pointerdown", (e) => { const grip = e.target.closest(".float-drag"); if (!grip || e.button !== 0 || e.target.closest("button, input, select")) return; e.preventDefault(); const x0 = e.clientX, y0 = e.clientY, b = pop.getBoundingClientRect(); const move = (ev) => Object.assign(pop.style, { left: `${clamp(b.left + ev.clientX - x0, 0, innerWidth - 60)}px`, top: `${clamp(b.top + ev.clientY - y0, 0, innerHeight - 40)}px` }); const up = () => { removeEventListener("pointermove", move); removeEventListener("pointerup", up); }; addEventListener("pointermove", move); addEventListener("pointerup", up); }); return pop; } /** A floating panel with a title bar (drag to move) and a close button. */ export function floatPanel(title, body, { cls = "", actions = [] } = {}) { return h("div", { class: `float-panel ${cls}` }, h("div", { class: "float-head float-drag" }, h("span", { class: "float-title" }, title), h("span", { class: "grow" }), ...actions, iconButton("x", "Close", () => closePopover(), { cls: "float-x", size: 14 })), body); } // ---------------------------------------------------------------- colour export function hexToRgb(hex) { const m = /^#?([0-9a-f]{6})/i.exec(hex || ""); if (!m) return [0, 0, 0]; const n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } export function rgbToHex(r, g, b) { return "#" + [r, g, b].map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, "0")).join(""); } export function rgbToHsv(r, g, b) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min; let hue = 0; if (d) { if (max === r) hue = ((g - b) / d) % 6; else if (max === g) hue = (b - r) / d + 2; else hue = (r - g) / d + 4; hue *= 60; if (hue < 0) hue += 360; } return [hue, max ? d / max : 0, max]; } export function hsvToRgb(hue, s, v) { const c = v * s, x = c * (1 - Math.abs(((hue / 60) % 2) - 1)), m = v - c; const [r, g, b] = hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x]; return [(r + m) * 255, (g + m) * 255, (b + m) * 255]; } const PALETTE = ["#000000", "#545454", "#737373", "#a6a6a6", "#d9d9d9", "#ffffff", "#ff3131", "#ff5757", "#ff66c4", "#cb6ce6", "#8c52ff", "#5e17eb", "#0097b2", "#0cc0df", "#5ce1e6", "#38b6ff", "#5170ff", "#004aad", "#00bf63", "#7ed957", "#c1ff72", "#ffde59", "#ffbd59", "#ff914d"]; export function colorPicker(initial, onPick, docColors = []) { let [hue, sat, val] = rgbToHsv(...hexToRgb(initial)); const sv = h("div", { class: "cp-sv" }, h("div", { class: "cp-sv-knob" })); const hueBar = h("div", { class: "cp-hue" }, h("div", { class: "cp-hue-knob" })); const hex = h("input", { class: "cp-hex num-input", value: (initial || "#000000").slice(0, 7), spellcheck: "false", "aria-label": "Hex colour" }); const swatch = (c) => h("button", { class: "cp-swatch", style: { background: c }, title: c, type: "button", onclick: () => { [hue, sat, val] = rgbToHsv(...hexToRgb(c)); paint(); onPick(c); } }); const root = h("div", { class: "color-picker" }, sv, hueBar, h("div", { class: "cp-row" }, h("span", { class: "cp-hash" }, "Hex"), hex), docColors.length ? h("div", { class: "cp-section" }, "Document colours") : null, docColors.length ? h("div", { class: "cp-swatches" }, docColors.slice(0, 12).map(swatch)) : null, h("div", { class: "cp-section" }, "Default colours"), h("div", { class: "cp-swatches" }, PALETTE.map(swatch))); const current = () => rgbToHex(...hsvToRgb(hue, sat, val)); const paint = () => { sv.style.background = `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${hue}, 100%, 50%))`; sv.firstChild.style.left = `${sat * 100}%`; sv.firstChild.style.top = `${(1 - val) * 100}%`; sv.firstChild.style.background = current(); hueBar.firstChild.style.left = `${(hue / 360) * 100}%`; if (document.activeElement !== hex) hex.value = current(); }; const drag = (el, fn) => el.addEventListener("pointerdown", (e) => { el.setPointerCapture(e.pointerId); const at = (ev) => { const r = el.getBoundingClientRect(); fn(clamp((ev.clientX - r.left) / r.width, 0, 1), clamp((ev.clientY - r.top) / r.height, 0, 1)); paint(); }; at(e); const move = (ev) => at(ev); const up = () => { el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); onPick(current()); }; el.addEventListener("pointermove", move); el.addEventListener("pointerup", up); }); drag(sv, (x, y) => { sat = x; val = 1 - y; }); drag(hueBar, (x) => { hue = x * 359.9; }); hex.addEventListener("keydown", (e) => { if (e.key !== "Escape") e.stopPropagation(); // Escape bubbles to the popover, which closes it if (e.key === "Enter") { const v = hex.value.trim().replace(/^#?/, "#"); if (/^#[0-9a-f]{6}$/i.test(v)) { [hue, sat, val] = rgbToHsv(...hexToRgb(v)); paint(); onPick(v.toLowerCase()); } } }); paint(); return root; } export function colorField({ get, set, label = "", docColors = () => [], cls = "", showHex = true, name = null, open = null }) { const chip = h("button", { class: "color-chip", type: "button", "aria-label": `${label || "Colour"} swatch` }); const input = h("input", { class: "num-input hex-input", spellcheck: "false", "aria-label": label || "Colour hex" }); const wrap = h("div", { class: `color-field ${cls}`, dataset: { field: name || label } }, label ? h("span", { class: "field-label" }, label) : null, chip, showHex ? input : null); chip.addEventListener("click", (e) => { e.stopPropagation(); if (open) open(chip, get, set, docColors()); // a layout's own picker (Figma's floating panel) else popover(chip, colorPicker(get() || "#000000", (c) => set(c), docColors()), { cls: "pop-color" }); }); // Enter or blur (Tab, clicking away) commits a valid hex, like the number fields; Escape reverts. let cancelled = false; const commit = () => { const v = input.value.trim().replace(/^#?/, "#"); if (/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v) && v.toLowerCase() !== (get() || "").toLowerCase()) set(v.toLowerCase()); }; input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") { commit(); input.select(); } else if (e.key === "Escape") { cancelled = true; input.blur(); } }); input.addEventListener("focus", () => { cancelled = false; input.select(); }); input.addEventListener("blur", () => { if (!cancelled) commit(); cancelled = false; wrap.set(); }); wrap.set = () => { const v = get(); chip.style.background = v ? v : "repeating-conic-gradient(#ccc 0 25%, #fff 0 50%) 0 0/8px 8px"; chip.classList.toggle("none", !v); if (document.activeElement !== input) input.value = v ? v.replace("#", "").toUpperCase().slice(0, 6) : "None"; wrap.classList.toggle("disabled", v === undefined); }; wrap.chip = chip; return wrap; } // ---------------------------------------------------------------- font picker export function fontPicker({ get, set, docFonts = () => [], cls = "", open = null, preview = true }) { const btn = h("button", { class: `font-btn ${cls}`, type: "button", "aria-label": "Font family" }); const name = h("span", { class: "font-name" }); btn.append(name); btn.insertAdjacentHTML("beforeend", icon("chevron-down", 14)); btn.addEventListener("click", async (e) => { e.stopPropagation(); if (open) { open(btn, get, set, docFonts()); return; } const families = (await fontCatalog()).map((f) => f.family); const search = h("input", { class: "font-search", placeholder: "Search fonts", spellcheck: "false", "aria-label": "Search fonts" }); const list = h("div", { class: "font-list", role: "listbox" }); const docs = [...new Set(docFonts())]; ensureFonts(docs); const row = (fam, preview) => { const r = h("button", { class: `font-row ${fam === get() ? "on" : ""}`, type: "button", role: "option", dataset: { family: fam }, onclick: () => { set(fam); ensureFonts([fam]); closePopover(); } }, fam); if (preview) r.style.fontFamily = `'${fam}'`; return r; }; const paint = () => { const q = search.value.trim().toLowerCase(); list.textContent = ""; const docMatch = docs.filter((f) => f.toLowerCase().includes(q)); if (docMatch.length) { list.append(h("div", { class: "font-group" }, "Fonts in this design")); docMatch.forEach((f) => list.append(row(f, true))); } list.append(h("div", { class: "font-group" }, "All fonts")); families.filter((f) => f.toLowerCase().includes(q)).forEach((f) => list.append(row(f, false))); }; search.addEventListener("input", paint); search.addEventListener("keydown", (ev) => { if (ev.key !== "Escape") ev.stopPropagation(); // Escape bubbles to the popover, which closes it if (ev.key === "Enter") { const first = list.querySelector(".font-row"); first?.click(); } }); paint(); popover(btn, h("div", { class: "font-pop" }, search, list), { cls: "pop-font" }); search.focus(); list.querySelector(".font-row.on")?.scrollIntoView({ block: "center" }); }); btn.set = () => { const v = get(); name.textContent = v === null ? "Mixed" : v || "—"; if (v && preview) { name.style.fontFamily = `'${v}'`; ensureFonts([v]); } btn.disabled = v === undefined; }; return btn; } // ---------------------------------------------------------------- menus // Menus only list what works: permanently disabled entries and commands hidden in this // session are dropped, along with separators and headers they leave dangling. export function usefulItems(items) { const kept = items.filter((it) => it === "-" || it.header || !(it.enabled === false || it.cmd?.hidden?.())); const out = []; for (let i = 0; i < kept.length; i++) { const it = kept[i]; if (it === "-" && (!out.length || out[out.length - 1] === "-")) continue; if (it.header) { // keep a header only if an item follows before the next break const next = kept.slice(i + 1).find((x) => x === "-" || x.header || x); if (!next || next === "-" || next.header) continue; } out.push(it); } while (out.length && (out[out.length - 1] === "-" || out[out.length - 1].header)) out.pop(); return out; } function menuList(items, cls, host, depth = 0) { const list = h("div", { class: `menu ${cls}`, role: "menu" }); // Submenus nest (Format ▸ Text ▸ Capitalization ▸): opening one closes only those deeper than this list. const closeSub = () => { host().querySelectorAll(".menu-sub").forEach((x) => { if (+x.dataset.depth > depth) x.remove(); }); list.querySelectorAll(".has-sub.open").forEach((x) => x.classList.remove("open")); }; for (const it of usefulItems(items)) { if (it === "-") { list.append(h("div", { class: "menu-sep" })); continue; } if (it.header) { list.append(h("div", { class: "menu-header" }, it.header)); continue; } if (it.items) { // a submenu (File ▸, Edit ▸ ...): opens beside its row on hover or click const kids = usefulItems(it.items); if (!kids.length) continue; const b = h("button", { class: "menu-item has-sub", role: "menuitem", type: "button", "aria-haspopup": "menu", dataset: { submenu: it.label } }); b.innerHTML = `${it.label}${icon("chevron-right", 12)}`; const open = () => { if (b.classList.contains("open")) return; closeSub(); b.classList.add("open"); const sub = h("div", { class: "popover pop-menu menu-sub", dataset: { depth: String(depth + 1) } }, menuList(kids, "", host, depth + 1)); host().append(sub); const r = b.getBoundingClientRect(), sr = sub.getBoundingClientRect(); const left = r.right + sr.width + 4 > innerWidth ? r.left - sr.width - 4 : r.right + 4; Object.assign(sub.style, { left: `${left}px`, top: `${clamp(r.top - 6, 8, innerHeight - sr.height - 8)}px` }); }; b.addEventListener("mouseenter", open); b.addEventListener("click", (e) => { e.stopPropagation(); open(); }); list.append(b); continue; } const cmd = it.cmd; const enabled = cmd ? (cmd.enabled ? cmd.enabled() : true) : it.enabled !== false; const b = h("button", { class: "menu-item", role: "menuitem", type: "button", disabled: !enabled, dataset: cmd ? { command: cmd.id } : {} }); b.innerHTML = `${cmd?.icon || it.icon ? icon(cmd?.icon || it.icon, 14) : ""}` + `${it.label || cmd.label}` + `${shortcut(it.key || cmd?.key || "")}`; b.addEventListener("click", (e) => { e.stopPropagation(); closePopover(); (it.run || cmd.run)(); }); b.addEventListener("mouseenter", closeSub); list.append(b); } return list; } export function menu(anchor, items, { align = "start", cls = "" } = {}) { let pop = null; const list = menuList(items, cls, () => pop); pop = popover(anchor, list, { align, cls: "pop-menu", offset: 2 }); return pop; } export function contextMenuAt(x, y, items) { const ghost = h("div", { style: { position: "fixed", left: `${x}px`, top: `${y}px`, width: "1px", height: "1px" } }); document.body.append(ghost); const pop = menu(ghost, items, { cls: "context" }); setTimeout(() => ghost.remove(), 0); return pop; } // ---------------------------------------------------------------- modal, toast export function modal(title, body, { actions = [], cls = "", onClose = null } = {}) { const close = () => { back.remove(); onClose?.(); }; const head = h("div", { class: "modal-head" }, h("div", { class: "modal-title" }, title), iconButton("x", "Close", close, { cls: "modal-x" })); const foot = actions.length ? h("div", { class: "modal-foot" }, actions.map((a) => h("button", { class: `btn ${a.primary ? "btn-primary" : ""}`, type: "button", onclick: async () => { const keep = await a.run?.(); if (keep !== true) close(); } }, a.label))) : null; const box = h("div", { class: `modal ${cls}`, role: "dialog", "aria-modal": "true" }, head, h("div", { class: "modal-body" }, body), foot); const back = h("div", { class: "modal-back", onclick: (e) => { if (e.target === back) close(); } }, box); back.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.stopPropagation(); close(); } }); document.body.append(back); return { close, box }; } let toastTimer = null; export function toast(msg, kind = "") { let t = document.querySelector(".toast"); if (!t) { t = h("div", { class: "toast", role: "status" }); document.body.append(t); } t.textContent = msg; t.className = `toast show ${kind}`; clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2600); }