189 lines
8.0 KiB
JavaScript
189 lines
8.0 KiB
JavaScript
/**
|
|
* 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 <body>. 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;
|
|
});
|
|
}
|
|
}
|
|
}
|