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.js367
1 files changed, 0 insertions, 367 deletions
diff --git a/public/bluebey-studio/src/ui.js b/public/bluebey-studio/src/ui.js
deleted file mode 100644
index 91e852b..0000000
--- a/public/bluebey-studio/src/ui.js
+++ /dev/null
@@ -1,367 +0,0 @@
-/**
- * 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.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;
-}
-
-/** A small stroke icon built from raw SVG markup (see the icons in panel.js). */
-export function svgIcon(inner, { size = 16, viewBox = '0 0 24 24', className = 'grp-icon' } = {}) {
- const SVG_NS = 'http://www.w3.org/2000/svg';
- const svg = document.createElementNS(SVG_NS, 'svg');
- svg.setAttribute('viewBox', viewBox);
- svg.setAttribute('width', String(size));
- svg.setAttribute('height', String(size));
- 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('class', className);
- svg.innerHTML = inner;
- 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;
-}
-
-/**
- * A standalone 24x24 stroke icon (the same drawing as a tab's), for a plain
- * button that has no label of its own.
- */
-export function icon(d, size = 18) {
- const svg = tabIcon(d);
- svg.setAttribute('width', String(size));
- svg.setAttribute('height', String(size));
- 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,
- };
-}