diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
| commit | e332019acb312ec64893c26cf3d797d5ce472f26 (patch) | |
| tree | d97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/ui.js | |
| parent | 99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff) | |
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/ui.js')
| -rw-r--r-- | public/bluebey-studio/src/ui.js | 326 |
1 files changed, 326 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/ui.js b/public/bluebey-studio/src/ui.js new file mode 100644 index 0000000..71f79b4 --- /dev/null +++ b/public/bluebey-studio/src/ui.js @@ -0,0 +1,326 @@ +/** + * 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 } = {}) { + const body = h('div', { class: 'sec-body' }); + const head = h('button', { type: 'button', class: 'sec-head' }, + 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; +} + +/** + * Tabbed container: returns the per-tab panels to fill in. Each definition may + * carry an `icon` (an SVG path `d`); the label stays visible next to it. + */ +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' }, + definition.icon ? tabIcon(definition.icon) : null, + definition.icon ? ' ' : null, + 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, + }; +} |
