From 809e0f8faefcfc1402ce1b2f2188901159387161 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 28 May 2026 09:44:29 -0400 Subject: [PATCH] Pixel editor: fix dropdown clipping when toolbar wraps near the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pixel-art canvas-size dropdown (and the PNG-export dropdown that shares the same class) used `position: absolute; right: 0` relative to its toolbar wrap. The wrap lives inside `.pixelEditorBar`, which has `flex-wrap: wrap`. When the bar wrapped and the trigger button landed on the LEFT of its row, the menu's right-anchor pushed it 260px to the LEFT — straight under the main sidebar. Fix: - Switched .pixelExportMenu to `position: fixed` (only when open). - Added a `placeFlexMenu(menu, btn)` helper that anchors the menu's right edge to the button's right edge by default, flips to the button's left edge if anchoring right would push the menu past the sidebar (using the aside's actual right edge as the safe-left boundary, not just the viewport edge), and finally clamps to the viewport on both sides. - Added a fixed width (260px) + box-sizing border-box to keep the menu from expanding to its natural content width once it's freed from the previous absolute-positioning constraint. - Window-resize handler auto-closes any open menu — the fixed coords were computed against the previous layout, so resizing would otherwise strand the menu away from its button. Verified via CDP across 920px / 1000px / 1100px / 1440px viewports and in both sidebar-open and sidebar-collapsed states. Also exercised the specific failure scenario (button moved to the leftmost slot of the bar, simulating the worst-case wrap layout) — menu now opens to the right of the button, fully inside the canvas area. Co-Authored-By: Claude Opus 4.7 --- TODO.md | 18 +++++++++++------ src/index.html | 54 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index 0e0fabd..86ef6c0 100644 --- a/TODO.md +++ b/TODO.md @@ -5,12 +5,8 @@ Running list of deferred work. Detailed specs for the larger items live in ## Adam's working list -- **Pixel canvas size dropdown — fix sidebar clipping.** When the - sidebar is open and the canvas size row wraps to a second line on - the left, the pixel-art canvas-size dropdown is clipped by the - sidebar. Easiest fix is probably "always right-align when wrapped" - so the dropdown opens away from the sidebar; or move the dropdown - to a placement that can't collide with the sidebar. +_(Empty — see "Shipped (recent)" below for the items that just +landed. Drop new items in here as they come up.)_ ## QR code layer — remaining phases @@ -88,6 +84,16 @@ Each has a full spec in `docs/`. ## Shipped (recent) +- **Pixel-art dropdowns — no more sidebar clipping.** The pixel-editor + resize-grid and PNG-export menus were `position: absolute; right: 0` + inside their toolbar wrap. When the toolbar wrapped and the trigger + button landed at the left of its row, the menu slid leftward under + the main sidebar. Switched both menus to `position: fixed` with a + JS-placed coord that anchors to the button's right edge by default, + flips to its left edge if anchoring right would clip past the + sidebar (sidebar's actual right edge, not just the viewport), + and clamps inside the viewport on both sides. Also auto-closes any + open menu on window resize to avoid stranded fixed-position remnants. - **Top-right action strip — permanent, never wraps.** Pulled the canvas-size readouts and the undo / redo pair out of the topbar's flex flow and pinned them in a fixed strip directly under the Nostr diff --git a/src/index.html b/src/index.html index 082ee32..95bd51d 100644 --- a/src/index.html +++ b/src/index.html @@ -889,14 +889,20 @@ /* PNG export dropdown in the pixel editor toolbar */ .pixelExportWrap { position: relative; } + /* The menu is `position: fixed` so we can clamp it inside the viewport + regardless of which row its button wrapped onto in the toolbar. When + the button lands at the left of a wrapped row, an absolute-positioned + menu anchored `right: 0` slides under the sidebar — fixed + JS + placement avoids that. The actual top/left are set by placeFlexMenu(). */ .pixelExportMenu { - position: absolute; right: 0; top: calc(100% + 6px); z-index: 20; - min-width: 260px; padding: 10px; + position: fixed; z-index: 20; + width: 260px; padding: 10px; background: var(--panel, #fff); color: var(--ink); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 8px 24px rgba(15,23,42,.18); flex-direction: column; gap: 8px; display: none; + box-sizing: border-box; } .pixelExportMenu.open { display: flex; } .pixelExportRow { display: flex; align-items: center; gap: 8px; } @@ -9069,8 +9075,51 @@ const willOpen = forceOpen !== undefined ? forceOpen : !isOpen; menu.classList.toggle('open', willOpen); btn.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); + if (willOpen) placeFlexMenu(menu, btn); } + // Position a `.pixelExportMenu` (fixed) below its trigger button, + // right-aligned to the button by default and clamped inside the + // viewport so it can't slide under the sidebar or off the right edge. + // The safe-left boundary is the sidebar's right edge when visible + // (not just the viewport edge) — otherwise a button that wrapped to + // the left of its toolbar row would let the menu slide under the + // sidebar. + function placeFlexMenu(menu, btn) { + if (!menu || !btn) return; + const PAD = 8; + const b = btn.getBoundingClientRect(); + const menuW = menu.offsetWidth || 260; + const vw = window.innerWidth; + const aside = document.querySelector('aside'); + const asideRect = aside ? aside.getBoundingClientRect() : null; + // Sidebar is "visible at the left" when it's not collapsed AND its + // right edge is inside the viewport (rules out the mobile 1-col layout + // where the aside stacks above the canvas). + const sidebarRight = (asideRect && asideRect.right > 0 && asideRect.left <= 0) + ? asideRect.right + : 0; + const minLeft = Math.max(PAD, sidebarRight + PAD); + let left = b.right - menuW; // default: anchor right of button + if (left < minLeft) left = b.left; // would clip left → anchor left of button + if (left < minLeft) left = minLeft; // still clipped → clamp to sidebar edge + if (left + menuW > vw - PAD) left = vw - menuW - PAD; + menu.style.left = `${Math.round(left)}px`; + menu.style.top = `${Math.round(b.bottom + 6)}px`; + menu.style.right = 'auto'; + } + + // Close any open .pixelExportMenu on window resize — its `position: fixed` + // coords were computed against the previous layout, so on resize they'd + // strand the menu away from its button. + window.addEventListener('resize', () => { + document.querySelectorAll('.pixelExportMenu.open').forEach((menu) => { + menu.classList.remove('open'); + const btn = menu.parentElement?.querySelector('[aria-haspopup="true"]'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + }); + }); + // Draw a pixel-art layer onto an export canvas (ctx centred on the layer). function drawPixelArtToCanvas(ctx, layer, w, h) { try { @@ -9640,6 +9689,7 @@ const wi = $('pixelResizeW'); if (wi) wi.value = _pixelEdit.w; const hi = $('pixelResizeH'); if (hi) hi.value = _pixelEdit.h; } + if (willOpen) placeFlexMenu(menu, btn); } function pixelFloodFill(x, y, idx) {