/** * Contextual tool options bar — a Photoshop-style strip that sits * directly under the topbar and shows quick controls for the active * tool. * * This is a *mirror* of the authoritative controls in the right-hand * panel: it never owns state. Every value is delegated to the existing * input element (by selector) so the two views can never disagree — * editing here re-dispatches the same `input`/`change` event the real * control listens for, and the real control pushes updates back into * the bar. * * On phones (<= 820px) the bar is hidden by CSS and the right-panel * bottom sheet remains the single surface, exactly as before. */ import { TOOLS } from './toolbar.js?v=20261008flyouts'; import { state } from '../state.js'; // Per-tool option sources. Each value is a CSS selector for an existing // control inside the right panel; `null` means "no such control". const BRUSH_TOOLS = { brush: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' }, smudge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' }, dodge: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' }, burn: { size: '.ge-size-slider', opacity: '#ge-brush-opacity', flow: '#ge-brush-flow', softness: '#ge-brush-softness' }, eraser: { size: '.ge-size-slider', opacity: '#ge-eraser-opacity', flow: '#ge-eraser-flow', softness: '#ge-eraser-softness' }, clone: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' }, heal: { size: '.ge-size-slider', opacity: '#ge-clone-opacity', flow: '#ge-clone-flow', softness: '#ge-clone-softness' }, }; const SELECTION_TOOLS = { marquee: { modes: '#ge-marquee-section', feather: null }, lasso: { modes: '#ge-lasso-section', feather: '#ge-lasso-feather' }, wand: { modes: '#ge-wand-section', feather: '#ge-wand-feather' }, quickselect: { modes: '#ge-quickselect-section', feather: null }, objectselect: { modes: '#ge-objectselect-section', feather: null }, sam: { modes: '#ge-sam-section', feather: null }, }; const MODE_LABELS = [['replace', 'New'], ['add', '+'], ['subtract', '−'], ['intersect', '∩']]; function resolve(selector) { if (!selector) return null; return selector.startsWith('#') ? document.getElementById(selector.slice(1)) : document.querySelector(selector); } function makeRange(label, sourceSelector, { suffix = '' } = {}) { const source = resolve(sourceSelector); const value = source ? source.value : '0'; const min = source?.getAttribute('min') ?? '0'; const max = source?.getAttribute('max') ?? '100'; const step = source?.getAttribute('step') ?? '1'; return `
`; } function makeSelect(label, sourceSelector) { const source = resolve(sourceSelector); const options = source ? [...source.options].map(o => ``).join('') : ''; const value = source ? source.value : ''; return `
`; } function makeAutoSelect() { return `
`; } function makeModes(sectionSelector) { const buttons = MODE_LABELS.map(([mode, text]) => `` ).join(''); return `
Mode${buttons}
`; } function buildToolControls(toolId) { const parts = []; if (BRUSH_TOOLS[toolId]) { const src = BRUSH_TOOLS[toolId]; parts.push(makeRange('Size', src.size, { suffix: 'px' })); parts.push(makeRange('Opacity', src.opacity)); parts.push(makeRange('Flow', src.flow)); parts.push(makeRange('Softness', src.softness)); } else if (SELECTION_TOOLS[toolId]) { const src = SELECTION_TOOLS[toolId]; parts.push(makeModes(src.modes)); if (src.feather) parts.push(makeRange('Feather', src.feather, { suffix: 'px' })); } else if (toolId === 'gradient') { parts.push(makeSelect('Type', '#ge-gradient-type')); parts.push(makeRange('Opacity', '#ge-gradient-opacity')); } else if (toolId === 'move') { parts.push(makeAutoSelect()); } if (!parts.length) { return ''; } return parts.join(''); } function valueLabelOf(source, value) { // Prefer the sibling label the right panel already maintains so the // two surfaces always show the same formatted number. const row = source?.closest?.('.ge-eraser-row, .ge-control-row'); const label = row?.querySelector('span[id$="-label"], span[id$="-opacity-label"], span[id$="-flow-label"], span[id$="-softness-label"]'); const text = label?.textContent?.trim(); return text || (typeof value === 'string' ? value : String(value)); } /** Refresh every mirrored control inside `bar` from its source. */ export function syncOptionsBar(bar) { if (!bar) return; bar.querySelectorAll('[data-src]').forEach(el => { const source = resolve(el.dataset.src); if (!source) return; if (el.tagName === 'SELECT') { el.value = source.value; return; } el.value = source.value; const field = el.closest('.ge-opt'); const out = field?.querySelector('[data-opt-value]'); if (out) out.textContent = valueLabelOf(source, source.value); }); // Selection mode buttons. bar.querySelectorAll('[data-opt-mode]').forEach(btn => { const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`); btn.classList.toggle('active', !!source && source.classList.contains('active')); }); // Move-tool auto-select (owned here rather than in the right panel). const autoSelect = bar.querySelector('#ge-auto-select-layer'); if (autoSelect) autoSelect.checked = !!state.autoSelectLayer; const target = bar.querySelector('#ge-auto-select-target'); if (target) target.value = state.autoSelectTarget === 'group' ? 'group' : 'layer'; } /** Render the controls for `toolId` and wire delegation + mirroring. */ export function renderOptionsBar(bar, toolId) { if (!bar) return; bar.dataset.optionsTool = toolId; const hasControls = !!(BRUSH_TOOLS[toolId] || SELECTION_TOOLS[toolId] || toolId === 'gradient' || toolId === 'move'); // Keep the strip mounted at a stable height for every tool. Hiding it // for tools without options reflows the centered canvas-area and makes // the canvas jump vertically when switching tools; an empty strip is // visually harmless. bar.classList.toggle('ge-options-empty', !hasControls); const icon = bar.querySelector('.ge-options-tool-icon'); const name = bar.querySelector('.ge-options-tool-name'); const meta = TOOLS[toolId]; if (name) name.textContent = meta?.label || toolId || ''; if (icon) icon.innerHTML = meta?.icon || ''; const body = bar.querySelector('.ge-options-body'); if (body) body.innerHTML = buildToolControls(toolId); // Mirror right-panel changes back into the bar. bar.querySelectorAll('[data-src]').forEach(el => { const source = resolve(el.dataset.src); if (!source || source.dataset.geOptMirror) return; source.dataset.geOptMirror = '1'; source.addEventListener('input', () => syncOptionsBar(bar)); source.addEventListener('change', () => syncOptionsBar(bar)); }); syncOptionsBar(bar); } /** * Build the (empty) options-bar shell. Call `renderOptionsBar` after * the right-panel controls exist so delegation can resolve them. */ export function buildOptionsBar() { const bar = document.createElement('div'); bar.className = 'ge-options-bar'; bar.id = 'ge-options-bar'; bar.innerHTML = `
`; const more = bar.querySelector('#ge-options-more'); more.addEventListener('click', (event) => { event.stopPropagation(); const open = bar.classList.toggle('ge-more-open'); more.setAttribute('aria-expanded', open ? 'true' : 'false'); }); // Delegation: ranges + selects write through to their real control. bar.addEventListener('input', event => { const el = event.target.closest('[data-opt-range]'); if (!el) return; const source = resolve(el.dataset.src); if (!source) return; source.value = el.value; source.dispatchEvent(new Event('input', { bubbles: true })); const out = el.closest('.ge-opt')?.querySelector('[data-opt-value]'); if (out) out.textContent = valueLabelOf(source, source.value); }); bar.addEventListener('change', event => { if (event.target.id === 'ge-auto-select-layer') { state.autoSelectLayer = event.target.checked; bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true })); return; } if (event.target.id === 'ge-auto-select-target') { state.autoSelectTarget = event.target.value === 'group' ? 'group' : 'layer'; bar.dispatchEvent(new CustomEvent('ge-editor-prefs-change', { bubbles: true })); return; } const el = event.target.closest('[data-opt-select]'); if (!el) return; const source = resolve(el.dataset.src); if (!source) return; source.value = el.value; source.dispatchEvent(new Event('change', { bubbles: true })); }); bar.addEventListener('click', event => { const btn = event.target.closest('[data-opt-mode]'); if (!btn) return; const source = resolve(`${btn.dataset.section} [data-wand-mode="${btn.dataset.optMode}"]`); source?.click(); syncOptionsBar(bar); }); return bar; }