/** * Small DOM widget kit for the control panel: hyperscript, collapsible sections * and a handful of labelled controls. Everything returns `{ el, set, get }` so * callers can push state back into the widgets when presets are applied. */ export function h(tag, props = {}, ...children) { const node = document.createElement(tag); for (const [key, value] of Object.entries(props ?? {})) { if (value == null || value === false) continue; if (key === 'class') node.className = value; else if (key === 'text') node.textContent = value; else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value); else if (key === 'dataset' && typeof value === 'object') Object.assign(node.dataset, value); else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value); else if (value === true) node.setAttribute(key, ''); else node.setAttribute(key, String(value)); } for (const child of children.flat()) { if (child == null || child === false) continue; node.append(child instanceof Node ? child : document.createTextNode(String(child))); } return node; } const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); let toastTimer = 0; export function toast(message) { const node = document.getElementById('toast'); if (!node) return; node.textContent = message; node.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => node.classList.remove('show'), 2400); } export function section(parent, title, { open = false, icon = null } = {}) { const body = h('div', { class: 'sec-body' }); const head = h('button', { type: 'button', class: 'sec-head' }, h('span', { class: 'sec-title' }, icon ? secIcon(icon) : null, h('span', { text: title })), h('span', { class: 'chev', text: '▾' })); const el = h('section', { class: `sec${open ? '' : ' closed'}` }, head, body); head.addEventListener('click', () => el.classList.toggle('closed')); parent.append(el); // `add` accepts either an element or a widget object (`{ el }`), so callers // cannot accidentally append `[object Object]`. return { el, body, add: (child) => (body.append(child?.el ?? child), child), setOpen: (open) => el.classList.toggle('closed', !open) }; } /** A label + control row. Pass `label` as null for a full-width control. */ export function controlRow(label, control, { wide = false } = {}) { if (label == null || wide) return h('div', { class: 'row wide' }, label ? h('span', { class: 'label', text: label }) : null, control); return h('div', { class: 'row' }, h('span', { class: 'label', text: label, title: label }), control); } /** * A tab's optional icon: one 24x24 stroke path, drawn in `currentColor` so it * follows the tab's text colour (and turns white when the tab is active). */ function tabIcon(d) { const SVG_NS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(SVG_NS, 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', '18'); svg.setAttribute('height', '18'); svg.setAttribute('fill', 'none'); svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '1.8'); svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round'); svg.setAttribute('aria-hidden', 'true'); // Sit the icon on the label's text baseline; the span and the text node keep // the two from touching. svg.style.verticalAlign = 'baseline'; svg.style.flex = '0 0 auto'; const path = document.createElementNS(SVG_NS, 'path'); path.setAttribute('d', d); svg.append(path); return svg; } function secIcon(d) { const svg = tabIcon(d); svg.setAttribute('class', 'sec-icon'); svg.setAttribute('width', '16'); svg.setAttribute('height', '16'); svg.style.verticalAlign = 'middle'; return svg; } /** * Tabbed container: returns the per-tab panels to fill in. When a definition * carries an `icon` (an SVG path `d`) the tab shows only that icon, and the * label becomes its tooltip and accessible name; without one the label shows. */ export function tabs(parent, definitions) { const bar = h('div', { class: 'tabs' }); const buttons = new Map(); const panels = {}; let active = definitions[0]?.id ?? null; const select = (id) => { if (!panels[id]) return; active = id; for (const [key, button] of buttons) button.classList.toggle('active', key === id); for (const [key, panel] of Object.entries(panels)) panel.hidden = key !== id; }; for (const definition of definitions) { const button = h('button', { type: 'button', class: 'tab' }); if (definition.icon) { button.append(tabIcon(definition.icon)); button.title = definition.label; button.setAttribute('aria-label', definition.label); } else { button.append(h('span', { class: 'tab-label', text: definition.label })); } button.addEventListener('click', () => select(definition.id)); buttons.set(definition.id, button); bar.append(button); panels[definition.id] = h('div', { class: 'tab-panel' }); } parent.append(bar); for (const definition of definitions) parent.append(panels[definition.id]); select(active); return { select, panels, get active() { return active; } }; } /** A collapsed "more options" block, using the native details element. */ export function details(parent, summary, { open = false } = {}) { const body = h('div', { class: 'details-body' }); const el = h('details', { class: 'details' }, h('summary', { text: summary }), body); if (open) el.open = true; parent.append(el); return { el, body, add: (child) => (body.append(child?.el ?? child), child) }; } export function subhead(text) { return h('div', { class: 'subhead', text }); } export function hint(...lines) { return h('p', { class: 'hint' }, ...lines.flat().map((line) => (line instanceof Node ? line : line))); } export function slider({ label, min = 0, max = 1, step = 0.01, value = 0, format, onInput, onCommit, wide = false }) { const input = h('input', { type: 'range', min, max, step, value }); const num = h('span', { class: 'num' }); const fmt = format ?? ((v) => (step >= 1 ? String(Math.round(v)) : v.toFixed(2))); const sync = () => { num.textContent = fmt(Number(input.value)); }; input.addEventListener('input', () => { sync(); onInput?.(Number(input.value)); }); input.addEventListener('change', () => onCommit?.(Number(input.value))); sync(); const control = h('div', { class: 'control' }, input, num); return { el: controlRow(label, control, { wide }), set(v) { input.value = String(v); sync(); }, get: () => Number(input.value), }; } export function check({ label, value = false, onChange, title }) { const input = h('input', { type: 'checkbox' }); input.checked = !!value; input.addEventListener('change', () => onChange?.(input.checked)); const el = h('label', { class: 'chk', title: title ?? '' }, input, h('span', { text: label })); return { el, set: (v) => { input.checked = !!v; }, get: () => input.checked }; } export function segmented({ label, options, value, onChange, wide = false }) { const el = h('div', { class: 'seg' }); const nodes = new Map(); let current = value; const sync = () => { for (const [key, node] of nodes) node.classList.toggle('active', key === current); }; const build = (list) => { el.replaceChildren(); nodes.clear(); for (const option of list) { const node = h('button', { type: 'button', title: option.title ?? option.label, text: option.label }); node.addEventListener('click', () => { if (current === option.value) return; current = option.value; sync(); onChange?.(option.value); }); nodes.set(option.value, node); el.append(node); } sync(); }; build(options); return { el: controlRow(label, el, { wide }), set(v) { current = v; sync(); }, get: () => current, setOptions: build, }; } export function buttons({ label, items, wide = true }) { const el = h('div', { class: 'buttons' }); const nodes = new Map(); for (const item of items) { const node = h('button', { type: 'button', class: `btn${item.primary ? ' primary' : ''}`, title: item.title ?? '', text: item.label, }); node.addEventListener('click', () => item.onClick?.()); const key = item.id ?? item.label; nodes.set(key, node); el.append(node); } return { el: controlRow(label, el, { wide }), button: (key) => nodes.get(key)?.el, setDisabled(key, disabled) { const node = nodes.get(key); if (node) node.disabled = disabled; }, setLabel(key, text) { const node = nodes.get(key); if (node) node.textContent = text; }, }; } export function colorField({ label, value = '#ffffff', onChange, swatches = [], wide = false }) { const input = h('input', { type: 'color', value }); input.addEventListener('input', () => onChange?.(input.value)); const control = h('div', { class: 'control' }, input); if (swatches.length) { const strip = h('div', { class: 'swatches' }); for (const color of swatches) { const chip = h('button', { type: 'button', class: 'swatch', title: color, style: { background: color } }); chip.addEventListener('click', () => { input.value = color; onChange?.(color); }); strip.append(chip); } control.append(strip); } return { el: controlRow(label, control, { wide }), set(v) { input.value = v; }, get: () => input.value, }; } /** * Two-axis drag pad. `value` is `{ x, y }` with both components in -1..1 and * `y` positive upwards (screen-like, but flipped so up = up). */ export function xyPad({ value = { x: 0, y: 0 }, onChange, onCommit, center = null }) { const dot = h('span', { class: 'pad-dot' }); const el = h('div', { class: 'pad' }, center === 'eye' ? h('span', { class: 'pad-eye' }) : null, dot); let current = { x: value.x ?? 0, y: value.y ?? 0 }; let dragging = false; const place = () => { dot.style.left = `${((current.x + 1) / 2) * 100}%`; dot.style.top = `${((1 - (current.y + 1) / 2)) * 100}%`; }; const fromEvent = (event) => { const rect = el.getBoundingClientRect(); const x = clamp(((event.clientX - rect.left) / rect.width) * 2 - 1, -1, 1); const y = clamp(1 - ((event.clientY - rect.top) / rect.height) * 2, -1, 1); current = { x, y }; place(); onChange?.({ ...current }); }; el.addEventListener('pointerdown', (event) => { dragging = true; el.setPointerCapture(event.pointerId); fromEvent(event); }); el.addEventListener('pointermove', (event) => { if (dragging) fromEvent(event); }); const end = (event) => { if (!dragging) return; dragging = false; if (el.hasPointerCapture(event.pointerId)) el.releasePointerCapture(event.pointerId); onCommit?.({ ...current }); }; el.addEventListener('pointerup', end); el.addEventListener('pointercancel', end); el.addEventListener('dblclick', () => { current = { x: 0, y: 0 }; place(); onChange?.({ ...current }); onCommit?.({ ...current }); }); place(); return { el, set(v) { current = { x: v.x ?? 0, y: v.y ?? 0 }; place(); }, get: () => ({ ...current }), }; } /** * Tail-direction picker: a 3x3 grid of dots whose *position* is the direction, * which reads at a glance in a way a flat row of "左上/右上" buttons does not. * The centre dot means no tail. Same `{ el, set, get }` contract as the rest. */ export function tailPad({ label, value = 'left', onChange }) { // Row-major, so the array order is what the user sees on screen. const CELLS = [ ['topLeft', '左上'], ['top', '上'], ['topRight', '右上'], ['left', '左'], ['none', 'なし'], ['right', '右'], ['bottomLeft', '左下'], ['bottom', '下'], ['bottomRight', '右下'], ]; const el = h('div', { class: 'tail-pad' }); const nodes = new Map(); let current = value; const sync = () => { for (const [key, node] of nodes) node.classList.toggle('active', key === current); }; for (const [cell, text] of CELLS) { const node = h('button', { type: 'button', class: 'tail-dot', title: text, 'aria-label': text }); node.addEventListener('click', () => { if (current === cell) return; current = cell; sync(); onChange?.(cell); }); nodes.set(cell, node); el.append(node); } sync(); return { el: controlRow(label, el), set(v) { current = v; sync(); }, get: () => current, }; } export function selectField({ label, options, value, onChange, wide = false }) { const select = h('select', { class: 'select' }); for (const option of options) select.append(h('option', { value: option.value, text: option.label })); select.value = value; select.addEventListener('change', () => onChange?.(select.value)); return { el: controlRow(label, select, { wide }), set(v) { select.value = v; }, get: () => select.value, }; }