diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:09:40 +0900 |
| commit | bc2821adb11a30244fc4663f4fafb756877d9508 (patch) | |
| tree | fea8d30d2eb1e67f895097dbf7ec4d152361243e /public/bluebey-studio/src/ui.js | |
| parent | 0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff) | |
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'public/bluebey-studio/src/ui.js')
| -rw-r--r-- | public/bluebey-studio/src/ui.js | 367 |
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, - }; -} |
