aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/ui.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/ui.js')
-rw-r--r--public/bluebey-studio/src/ui.js326
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,
+ };
+}