/** * Build the editor's left-side tool palette. * * Pure DOM construction — no module state. The big tool-switch logic * (cursor swap, control-section toggle, transform entry, inpaint * mask plumbing, etc.) stays in the caller and arrives here as the * `onSelectTool` callback. * * Layout mirrors PhotoCraft's rail: ordered sections, with related tools * collapsed into a single flyout slot (face = current member, caret opens * the family). Selecting a flyout member promotes it to the slot face. * * @param {{ * currentTool: string, * onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void, * onClearSelection: (which: 'marquee'|'lasso'|'wand'|'sam') => void, * }} ctx * @returns {{ * toolbar: HTMLDivElement, * toolKeyMap: Record, * syncActive: (toolId: string) => void, * }} */ import { TOOL_SHORTCUTS } from '../tool-shortcuts.js'; import { loadBrushPresets } from '../brush-presets.js'; const CLEAR_X = ''; const CARET = ''; export const TOOLS = { move: { label: 'Move', icon: '' }, hand: { label: 'Hand', icon: '' }, zoom: { label: 'Zoom', icon: '' }, crop: { label: 'Crop', icon: '' }, transform: { label: 'Transform', icon: '' }, text: { label: 'Text', icon: '' }, shape: { label: 'Shape', icon: '' }, brush: { label: 'Brush', icon: '' }, gradient: { label: 'Gradient', icon: '' }, bucket: { label: 'Paint Bucket', icon: '' }, eraser: { label: 'Eraser', icon: '' }, eyedropper: { label: 'Eyedropper', icon: '' }, smudge: { label: 'Smudge', icon: '' }, clone: { label: 'Clone', icon: '' }, heal: { label: 'Healing', icon: '' }, patch: { label: 'Patch', icon: '' }, dodge: { label: 'Dodge', icon: '' }, burn: { label: 'Burn', icon: '' }, marquee: { label: 'Marquee', icon: '' }, lasso: { label: 'Lasso', icon: '' }, pen: { label: 'Pen selection', icon: '' }, path: { label: 'Pen Path', icon: '' }, wand: { label: 'Wand', icon: '' }, quickselect: { label: 'Quick Select', icon: '' }, objectselect: { label: 'Object Select', icon: '' }, sam: { label: 'SAM', ai: true, icon: '' }, inpaint: { label: 'Inpaint', ai: true, icon: '' }, rembg: { label: 'Bg Remove', ai: true, icon: '' }, sharpen: { label: 'Sharpen', ai: true, icon: '' }, }; // Ordered sections. A string is a single-tool slot; an array is a flyout // slot whose first member is the default face. const SECTIONS = [ ['move'], [['marquee', 'lasso'], ['wand', 'quickselect', 'objectselect', 'sam']], [['crop', 'transform'], ['brush', 'eraser'], ['clone', 'heal', 'patch'], ['smudge', 'dodge', 'burn'], ['gradient', 'bucket'], 'eyedropper'], [['pen', 'path'], ['text', 'shape'], ['inpaint', 'rembg', 'sharpen']], [['hand', 'zoom']], ]; export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) { const toolbar = document.createElement('div'); toolbar.className = 'ge-toolbar'; const toolKeyMap = {}; const slots = []; for (const id of Object.keys(TOOLS)) { const key = TOOL_SHORTCUTS[id]; if (key) toolKeyMap[key.toLowerCase()] = id; } const setInner = (btn, toolId) => { const t = TOOLS[toolId]; btn.dataset.tool = toolId; btn.title = t.label + (TOOL_SHORTCUTS[toolId] ? ` (${TOOL_SHORTCUTS[toolId]})` : ''); if (toolId === 'pen') btn.title += ' - Click anchors; drag for curves; close path or press Enter'; if (toolId === 'path') btn.title += ' - Click to add anchors; double-click or press Enter to finish'; btn.classList.toggle('is-ai', !!t.ai); const aiStar = t.ai ? '✦' : ''; const clearTitle = toolId === 'sam' ? 'Open SAM prompt' : 'Clear selection'; const clearBadge = ['marquee', 'lasso', 'quickselect', 'objectselect', 'wand', 'sam'].includes(toolId) ? '' + CLEAR_X + '' : ''; btn.innerHTML = `${aiStar}${t.icon}${clearBadge}`; btn.querySelector('.ge-tool-clear')?.addEventListener('click', (ev) => { ev.stopPropagation(); onClearSelection(ev.currentTarget.dataset.clearTool); }); }; const closeAllFlyouts = () => { toolbar.querySelectorAll('.ge-tool-slot.open').forEach(s => s.classList.remove('open')); }; const openFlyout = (slotEl, face) => { const isOpen = slotEl.classList.contains('open'); closeAllFlyouts(); if (isOpen) return; slotEl.classList.add('open'); const flyout = slotEl.querySelector('.ge-tool-flyout'); if (!flyout) return; const r = face.getBoundingClientRect(); // The flyout is position:fixed, but an ancestor with a transform (the // gallery modal sets an identity matrix) becomes its containing block, so // viewport coords must be offset by that block's origin or the popup lands // far from the button. const cb = flyout.offsetParent; const cbRect = cb ? cb.getBoundingClientRect() : { left: 0, top: 0 }; const cbX = cbRect.left + (cb ? cb.clientLeft : 0); const cbY = cbRect.top + (cb ? cb.clientTop : 0); if (window.innerWidth <= 820) { flyout.style.left = (Math.max(r.left, 4) - cbX) + 'px'; flyout.style.top = (r.bottom + 4 - cbY) + 'px'; } else { flyout.style.left = (r.right + 4 - cbX) + 'px'; flyout.style.top = (Math.max(r.top, 4) - cbY) + 'px'; } }; document.addEventListener('click', (ev) => { if (!toolbar.contains(ev.target)) closeAllFlyouts(); }); let first = true; for (const section of SECTIONS) { if (!first) { const sep = document.createElement('div'); sep.className = 'ge-tool-sep'; toolbar.appendChild(sep); } first = false; for (const entry of section) { const members = Array.isArray(entry) ? entry : [entry]; const slotEl = document.createElement('div'); slotEl.className = 'ge-tool-slot'; const face = document.createElement('button'); face.type = 'button'; face.className = 'ge-tool-btn ge-tool-slot-face'; const faceId = members.includes(currentTool) ? currentTool : members[0]; setInner(face, faceId); face.addEventListener('click', () => { closeAllFlyouts(); onSelectTool(face.dataset.tool, face, toolbar); }); slotEl.appendChild(face); const slot = { el: slotEl, face, members, current: faceId, items: [] }; if (members.length > 1) { // Photoshop-style corner triangle marks a slot whose family has // more tools behind it. face.insertAdjacentHTML('beforeend', ''); const tri = face.querySelector('.ge-tool-flyout-tri'); tri?.addEventListener('click', (ev) => { ev.stopPropagation(); openFlyout(slotEl, face); }); const flyout = document.createElement('div'); flyout.className = 'ge-tool-flyout'; for (const id of members) { const item = document.createElement('button'); item.type = 'button'; item.className = 'ge-tool-btn ge-tool-flyout-item'; setInner(item, id); // Keep `data-tool` unique to the slot face so `[data-tool="X"]` // selectors are unambiguous; flyout members expose the option id. item.dataset.toolOption = id; delete item.dataset.tool; item.addEventListener('click', (ev) => { ev.stopPropagation(); setInner(face, id); slot.current = id; closeAllFlyouts(); onSelectTool(id, face, toolbar); }); item.insertAdjacentHTML('beforeend', `${TOOLS[id].label}`); flyout.appendChild(item); slot.items.push(item); } // Photoshop-style: the Brush slot's flyout also lists the brush // presets, so the Pixel brush (and any saved presets) are reachable // straight from the tool button. Picking one switches to the Brush // and applies the preset through the options-bar picker so the size // and stroke controls stay in sync. if (members.includes('brush')) { const brushPresets = loadBrushPresets(); if (brushPresets.length) { const sep = document.createElement('div'); sep.className = 'ge-tool-flyout-sep'; flyout.appendChild(sep); const heading = document.createElement('div'); heading.className = 'ge-tool-flyout-heading'; heading.textContent = 'Brushes'; flyout.appendChild(heading); for (const preset of brushPresets) { const presetItem = document.createElement('button'); presetItem.type = 'button'; presetItem.className = 'ge-tool-btn ge-tool-flyout-item ge-tool-flyout-preset'; presetItem.dataset.brushPreset = preset.id; presetItem.insertAdjacentHTML('beforeend', `${preset.name}`); presetItem.addEventListener('click', (ev) => { ev.stopPropagation(); setInner(face, 'brush'); slot.current = 'brush'; onSelectTool('brush', face, toolbar); const picker = document.getElementById('ge-brush-preset'); if (picker) { picker.value = preset.id; picker.dispatchEvent(new Event('change', { bubbles: true })); } closeAllFlyouts(); }); flyout.appendChild(presetItem); } } } slotEl.appendChild(flyout); face.addEventListener('contextmenu', (ev) => { ev.preventDefault(); openFlyout(slotEl, face); }); } slots.push(slot); toolbar.appendChild(slotEl); } } // Foreground / background color swatches (Photoshop-style) pinned to // the bottom of the tool rail, with a swap control. const swatches = document.createElement('div'); swatches.className = 'ge-tool-swatches'; swatches.innerHTML = ` `; toolbar.appendChild(swatches); const syncActive = (toolId) => { for (const slot of slots) { if (!slot.members.includes(toolId)) continue; if (slot.current !== toolId) setInner(slot.face, toolId); slot.current = toolId; slot.face.classList.add('active'); slot.items.forEach(it => it.classList.toggle('active', (it.dataset.toolOption || it.dataset.tool) === toolId)); } }; // Initial active state. syncActive(currentTool); return { toolbar, toolKeyMap, syncActive }; }