/** * Topbar wiring — undo/redo/history, Save dropdown, zoom buttons, * Save/Export/Download/Project, Edge popup, and the cross-dropdown * coordination (close-others + global outside-click). * * #ge-undo / #ge-redo / #ge-history-btn * #ge-save-menu-btn + #ge-save-menu (Save / Save as / Download / * Save project / Load project) * #ge-zoom-out / #ge-zoom-in / #ge-zoom-fit / #ge-zoom-100 * #ge-export-gallery / #ge-download * #ge-save-project / #ge-load-project * #ge-edge-menu-btn + #ge-edge-menu (Width input + Feather / Delete * action buttons) * * Dropdown coordination: every menu hides any sibling menu when it * opens (closeOtherTopbarMenus), and a global outside-click handler * closes every open menu if the user clicks anywhere outside. * * @param {{ * undo: () => void, * redo: () => void, * toggleHistoryPanel: () => void, * fitZoom: () => void, * applyZoom: () => void, * exportToGallery: () => void, * downloadPNG: () => void, * saveProject: () => void, * loadProjectPrompt: () => void, * activeLayer: () => object | null, * saveState: (label?: string) => void, * applyEdgeFeather: (layer: object, width: number, hardDelete: boolean) => void, * composite: () => void, * registerDocClickAway: (handler: (e: Event) => void) => void, * uiModule: object, * }} deps */ import { state } from './state.js'; const TOPBAR_MENU_IDS = ['ge-image-menu', 'ge-filter-menu', 'ge-resize-menu', 'ge-save-menu']; const TOPBAR_TRIGGER_IDS = ['ge-image-menu-btn', 'ge-filter-menu-btn', 'ge-resize-menu-btn', 'ge-save-menu-btn']; /** * Close every topbar dropdown except an optional "keep open" one. * Exported so the Image / Filter / Resize menus (wired elsewhere) * can call it from their own open handlers. */ export function closeOtherTopbarMenus(keepId) { for (const id of TOPBAR_MENU_IDS) { if (id === keepId) continue; const m = document.getElementById(id); if (m && !m.hidden) m.hidden = true; } } export function wireTopbar(deps) { const { undo, redo, toggleHistoryPanel, fitZoom, applyZoom, exportToGallery, downloadPNG, saveProject, loadProjectPrompt, activeLayer, saveState, applyEdgeFeather, composite, registerDocClickAway, uiModule, } = deps; // Undo / Redo / History. document.getElementById('ge-undo')?.addEventListener('click', undo); document.getElementById('ge-redo')?.addEventListener('click', redo); document.getElementById('ge-history-btn')?.addEventListener('click', toggleHistoryPanel); // Save dropdown — "Save ▾" toggles a small menu (Save / Save-as / // Download / Save project / Load project). Inner items keep their // original IDs so the standalone handlers below wire to them // unchanged. { const saveBtn = document.getElementById('ge-save-menu-btn'); const saveMenu = document.getElementById('ge-save-menu'); if (saveBtn && saveMenu) { const saveTopbar = saveBtn.closest('.ge-topbar'); // Reparent the menu to . Without this, the menu inherits // the gallery modal's containing block (the modal applies a // `transform: scale(...)` for its enter animation — and any // non-`none` transform on an ancestor makes that ancestor the // containing block for `position: fixed` descendants, even after // the animation lands on identity). The JS math below assumes // viewport-relative coords, so without the reparent the menu // ends up "way off" the button on desktop. if (saveMenu.parentNode !== document.body) { document.body.appendChild(saveMenu); } const setSaveMenuOpen = (open) => { saveMenu.hidden = !open; saveTopbar?.classList.toggle('ge-topbar-menu-open', !!open); }; const positionSaveMenu = () => { const r = saveBtn.getBoundingClientRect(); saveMenu.style.top = `${r.bottom + 2}px`; saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`; saveMenu.style.left = 'auto'; }; saveBtn.addEventListener('click', (e) => { e.stopPropagation(); const willOpen = saveMenu.hidden; setSaveMenuOpen(willOpen); if (willOpen) positionSaveMenu(); }); saveMenu.addEventListener('click', () => { setSaveMenuOpen(false); }); window.addEventListener('resize', () => { if (!saveMenu.hidden) positionSaveMenu(); }); registerDocClickAway((e) => { if (!saveMenu.hidden && !saveMenu.contains(e.target) && e.target !== saveBtn) { setSaveMenuOpen(false); } }); } } // Zoom buttons. document.getElementById('ge-zoom-fit')?.addEventListener('click', fitZoom); document.getElementById('ge-zoom-100')?.addEventListener('click', () => { state.zoom = 1; applyZoom(); }); document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); applyZoom(); }); document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); applyZoom(); }); // Export / Download / Project Save / Project Load. document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery); document.getElementById('ge-download')?.addEventListener('click', downloadPNG); document.getElementById('ge-save-project')?.addEventListener('click', saveProject); document.getElementById('ge-load-project')?.addEventListener('click', loadProjectPrompt); // Global outside-click — closes EVERY editor dropdown when the // user clicks anywhere that isn't a menu or trigger button. Each // menu has its own click-away handler too; this is a defence-in- // depth net for cross-menu clicks / mobile touches that miss the // individual handlers. document.addEventListener('pointerdown', (e) => { for (const id of TOPBAR_MENU_IDS.concat(TOPBAR_TRIGGER_IDS)) { const el = document.getElementById(id); if (el && el.contains(e.target)) return; } for (const id of TOPBAR_MENU_IDS) { const m = document.getElementById(id); if (m && !m.hidden) m.hidden = true; } }); // Edge popup — Width input + Feather / Delete action buttons. function applyEdgeAction(hardDelete) { const layer = activeLayer(); if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; } const widthInput = document.getElementById('ge-edge-width'); const width = parseInt(widthInput?.value || '8'); if (isNaN(width) || width < 1) { uiModule.showToast('Invalid width'); return; } saveState(); applyEdgeFeather(layer, width, hardDelete); composite(); uiModule.showToast(hardDelete ? `Edges deleted ${width}px` : `Edges feathered ${width}px`); } { const btn = document.getElementById('ge-edge-menu-btn'); const menu = document.getElementById('ge-edge-menu'); if (btn && menu) { btn.addEventListener('click', (e) => { e.stopPropagation(); const willOpen = menu.hidden; if (willOpen) closeOtherTopbarMenus('ge-edge-menu'); menu.hidden = !menu.hidden; if (!menu.hidden) { // Autofocus the width input so users can type immediately. setTimeout(() => document.getElementById('ge-edge-width')?.select(), 0); } }); document.getElementById('ge-edge-feather')?.addEventListener('click', () => { menu.hidden = true; applyEdgeAction(false); }); document.getElementById('ge-edge-delete')?.addEventListener('click', () => { menu.hidden = true; applyEdgeAction(true); }); document.getElementById('ge-edge-width')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { menu.hidden = true; applyEdgeAction(false); } }); registerDocClickAway((e) => { if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) menu.hidden = true; }); } } }