From b6e3499be40fc6bde00902f12b848021b04fe53f Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sun, 28 Jun 2026 17:08:46 +0200 Subject: [PATCH] fix(zoom): bridge html{zoom} in the full-view panel + drag/popover coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page ships `html { zoom: 1.2 }`, but `zoom` does not divide viewport units, and getBoundingClientRect returns post-zoom px while layout values (style.width, flexBasis, fixed top/right) are pre-zoom. Several spots mixed the two: - Schema full-view panel rendered 1.2× tall (100vh × zoom), pushing the detail pane's CREATE TABLE DDL below the viewport with no way to scroll to it. - Sidebar splitter and the detail-pane resize handle drifted ~20% ahead of the cursor (visual-px delta applied as layout px). - Save popover / user menu mis-anchored (fixed top/right re-scaled by zoom). Fixes: - Introduce a `--zoom` CSS variable; size the full-viewport graph panels (overlay, schema tab, and the tab body) as `calc(... / var(--zoom))` so they fit exactly. - Bridge the splitter ('col' axis), the detail-pane resize handle, and the anchored popovers through the existing `zoomScale()` helper; measure the zoom once per drag, not per mousemove. - Harden `zoomScale()` to fall back to 1 for Infinity/0/negative ratios, not just NaN (its docstring already promised this), since three new callers rely on it. `dragValue` stays pure (injected `scale` param). Tests added for every new path; splitters/schema-detail/dom remain at 100% coverage. All 1010 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019kE9qbgBNBrfNgwg9fRsMJ --- src/styles.css | 27 ++++++++++++++++++++++----- src/ui/app.js | 13 ++++++++++--- src/ui/dom.js | 7 +++++-- src/ui/schema-detail.js | 8 ++++++-- src/ui/splitters.js | 15 +++++++++++---- tests/unit/dom.test.js | 21 ++++++++++++++++++++- tests/unit/schema-detail.test.js | 15 +++++++++++++++ tests/unit/splitters.test.js | 15 +++++++++++++++ 8 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/styles.css b/src/styles.css index 1cbba7b..6a9fc64 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,12 +1,17 @@ *, *::before, *::after { box-sizing: border-box; } html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; } -html { zoom: 1.2; } +html { zoom: var(--zoom); } :root { --ui: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; --mono: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace; --accent: #0079AD; --accent-dim: #005F8A; + /* Page zoom. Normal flow scales cleanly, but `zoom` does NOT divide viewport + units, so an in-flow `100vh`/`100vw`/`100%`-tall element renders --zoom times + too big and overflows the window. The two full-viewport graph panels below + divide their viewport sizing by --zoom to fit; bump both together here. */ + --zoom: 1.2; } [data-theme='dark'] { @@ -717,7 +722,11 @@ body { display: flex; align-items: center; justify-content: center; padding: 24px; } .graph-overlay-panel { - width: 100%; height: 100%; + /* `.graph-overlay` is a fixed inset:0 backdrop with 24px padding. A plain + 100% here would render --zoom times too tall (see --zoom) and spill past the + viewport; divide the viewport box (minus the 48px padding) by --zoom so the + panel fits with its breathing room intact. */ + width: calc((100vw - 48px) / var(--zoom)); height: calc((100vh - 48px) / var(--zoom)); background: var(--bg-modal); border: 1px solid var(--border); border-radius: 11px; overflow: hidden; display: flex; flex-direction: column; @@ -766,9 +775,17 @@ body { .graph-overlay-canvas.schema-canvas.modkey .eg-card rect, .graph-overlay-canvas.schema-canvas.modkey .eg-card text { cursor: move; } -/* The schema graph's own browser tab: the panel fills the viewport chromelessly. */ -body.schema-tab { margin: 0; height: 100vh; overflow: hidden; background: var(--bg-editor); } -body.schema-tab .graph-overlay-panel { height: 100vh; border: none; border-radius: 0; } +/* The schema graph's own browser tab: the panel fills the viewport chromelessly. + Divide by --zoom like the panel (see --zoom) so the body is exactly one screen + tall rather than 1.2× (which would otherwise only be hidden by overflow:hidden). */ +body.schema-tab { margin: 0; height: calc(100vh / var(--zoom)); overflow: hidden; background: var(--bg-editor); } +/* Full-bleed in its own browser tab — no backdrop padding, but still divide the + viewport sizing by --zoom (see --zoom) so the panel fills exactly one screen + instead of overflowing the bottom (which hid the detail pane's DDL). */ +body.schema-tab .graph-overlay-panel { + width: calc(100vw / var(--zoom)); height: calc(100vh / var(--zoom)); + border: none; border-radius: 0; +} /* ------------ schema node detail pane (fullscreen graph) ------------ */ .schema-detail { diff --git a/src/ui/app.js b/src/ui/app.js index 2d541aa..9375dab 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -4,7 +4,7 @@ // window, location, fetch, crypto, sessionStorage) is injected so the whole // controller is testable under happy-dom with stubs. -import { h } from './dom.js'; +import { h, zoomScale } from './dom.js'; import { Icon } from './icons.js'; import { createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, tabChart, @@ -730,9 +730,13 @@ export function createApp(env = {}) { }; app.dom[refKey] = node; const r = anchorEl.getBoundingClientRect(); + // Bridge the shipped html{zoom}: getBoundingClientRect is post-zoom px, but a + // fixed element's top/right are re-scaled by zoom on paint — divide by scale so + // the popover anchors under the button (same as the File menu / editor popovers). + const scale = zoomScale(anchorEl); node.style.position = 'fixed'; - node.style.top = (r.bottom + 6) + 'px'; - node.style.right = Math.max(8, (win.innerWidth || 0) - r.right) + 'px'; + node.style.top = (r.bottom / scale + 6) + 'px'; + node.style.right = Math.max(8, ((win.innerWidth || 0) - r.right) / scale) + 'px'; doc.body.appendChild(node); doc.addEventListener('keydown', onKey, true); doc.addEventListener('mousedown', onOutside, true); @@ -886,6 +890,9 @@ export function renderApp(app, helpers) { const dragCtx = { state, rectFor, + // Only the px-based 'col' axis needs the html{zoom} bridge (the '%' axes use a + // zoom-cancelling ratio); measure the sidebar, which lives in the zoomed tree. + scale: (axis) => (axis === 'col' ? zoomScale(sidebar) : 1), apply: (axis, value) => { if (axis === 'col') sidebar.style.width = value + 'px'; else if (axis === 'sideRow') sidebar.firstElementChild.style.height = value + '%'; diff --git a/src/ui/dom.js b/src/ui/dom.js index c49c28d..0c8b7b8 100644 --- a/src/ui/dom.js +++ b/src/ui/dom.js @@ -60,7 +60,10 @@ export function s(tag, props, ...children) { // post-zoom px while layout (offsetWidth) is pre-zoom CSS px, so their ratio is // the zoom. The single source of truth for bridging `html{zoom}` when mapping // between client coords and CSS px (editor popovers, results column-resize). -// Falls back to 1 when the element isn't laid out (offsetWidth 0 → NaN). +// Falls back to 1 for any non-positive/non-finite ratio — an unlaid-out element +// gives 0/0 → NaN, and offsetWidth 0 with a non-zero rect gives Infinity; both +// (and a degenerate 0-width) must read as "no zoom", not blow up a divisor. export function zoomScale(el) { - return (el.getBoundingClientRect().width / el.offsetWidth) || 1; + const s = el.getBoundingClientRect().width / el.offsetWidth; + return Number.isFinite(s) && s > 0 ? s : 1; } diff --git a/src/ui/schema-detail.js b/src/ui/schema-detail.js index 5faa1e9..5f9f5f7 100644 --- a/src/ui/schema-detail.js +++ b/src/ui/schema-detail.js @@ -4,7 +4,7 @@ // its DDL — plus an "Insert SHOW CREATE" action. Pure DOM over the app controller; // the data is fetched by app.actions.openNodeDetail (ch.loadTableDetail). -import { h, withDocument } from './dom.js'; +import { h, withDocument, zoomScale } from './dom.js'; import { Icon } from './icons.js'; import { clamp, formatRows, formatBytes, qualifyIdent } from '../core/format.js'; import { columnRoles } from '../core/schema-cards.js'; @@ -81,7 +81,11 @@ function buildDetailPane(app, node, detail, panel) { // The panel is the fixed full-screen overlay — its box is stable for the drag, // so measure once here rather than reflowing on every mousemove. const r = panel.getBoundingClientRect(); - const onMove = (ev) => { pane.style.flexBasis = clamp(r.bottom - ev.clientY, MIN_H, r.height - TOP_MARGIN) + 'px'; }; + // Bridge html{zoom}: r/clientY are post-zoom px but flexBasis is layout px, so + // divide the drag delta (and the panel-height bound) by the zoom factor — else + // the pane grows --zoom× faster than the cursor and the handle drifts away. + const scale = zoomScale(pane); + const onMove = (ev) => { pane.style.flexBasis = clamp((r.bottom - ev.clientY) / scale, MIN_H, r.height / scale - TOP_MARGIN) + 'px'; }; const onUp = () => { doc.removeEventListener('mousemove', onMove); doc.removeEventListener('mouseup', onUp); }; doc.addEventListener('mousemove', onMove); doc.addEventListener('mouseup', onUp); diff --git a/src/ui/splitters.js b/src/ui/splitters.js index b303eef..af52b0a 100644 --- a/src/ui/splitters.js +++ b/src/ui/splitters.js @@ -7,10 +7,14 @@ import { clamp } from '../core/format.js'; /** * Compute the new size for a drag. `axis` is 'col' (sidebar px), 'sideRow' * (sidebar vertical %), or 'row' (editor/results %). `rect` is the bounding - * rect of the container being split (unused for 'col'). + * rect of the container being split (unused for 'col'). `scale` is the page + * `html{zoom}` factor: `clientX` is post-zoom px but the sidebar width is set in + * layout px, so 'col' divides by it or the handle drifts from the cursor. The + * '%'-based axes derive from a (clientY-top)/(height) ratio where zoom cancels, + * so they ignore `scale`. */ -export function dragValue(axis, ev, rect) { - if (axis === 'col') return clamp(ev.clientX, 180, 420); +export function dragValue(axis, ev, rect, scale = 1) { + if (axis === 'col') return clamp(ev.clientX / scale, 180, 420); const pct = clamp(((ev.clientY - rect.top) / (rect.bottom - rect.top)) * 100, axis === 'sideRow' ? 25 : 15, 85); return pct; @@ -27,8 +31,11 @@ export function startDrag(ev, axis, ctx) { const handle = ev.currentTarget; const win = ctx.win || window; handle.classList.add('dragging'); + // Page zoom is constant for the drag's lifetime, so measure it once here rather + // than reflowing (getBoundingClientRect/offsetWidth) on every mousemove. + const scale = ctx.scale ? ctx.scale(axis) : 1; const onMove = (move) => { - const value = dragValue(axis, move, ctx.rectFor(axis)); + const value = dragValue(axis, move, ctx.rectFor(axis), scale); if (axis === 'col') ctx.state.sidebarPx = value; else if (axis === 'sideRow') ctx.state.sideSplitPct = value; else ctx.state.editorPct = value; diff --git a/tests/unit/dom.test.js b/tests/unit/dom.test.js index 40b99df..f8a5987 100644 --- a/tests/unit/dom.test.js +++ b/tests/unit/dom.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { h, s, withDocument } from '../../src/ui/dom.js'; +import { h, s, withDocument, zoomScale } from '../../src/ui/dom.js'; const SVG_NS = 'http://www.w3.org/2000/svg'; @@ -37,6 +37,25 @@ describe('withDocument', () => { }); }); +describe('zoomScale', () => { + const stub = (rectWidth, offsetWidth) => ({ + getBoundingClientRect: () => ({ width: rectWidth }), + offsetWidth, + }); + it('returns the post-zoom / layout width ratio for a laid-out element', () => { + expect(zoomScale(stub(120, 100))).toBe(1.2); + }); + it('falls back to 1 when the element is not laid out (0/0 → NaN)', () => { + expect(zoomScale(stub(0, 0))).toBe(1); + }); + it('falls back to 1 when offsetWidth is 0 but the rect is non-zero (Infinity)', () => { + expect(zoomScale(stub(800, 0))).toBe(1); + }); + it('falls back to 1 for a degenerate 0-width rect (ratio 0)', () => { + expect(zoomScale(stub(0, 100))).toBe(1); + }); +}); + describe('s (SVG namespace)', () => { it('creates elements in the SVG namespace with attrs, style, events, and children', () => { const onclick = vi.fn(); diff --git a/tests/unit/schema-detail.test.js b/tests/unit/schema-detail.test.js index dfe9b34..9e80d14 100644 --- a/tests/unit/schema-detail.test.js +++ b/tests/unit/schema-detail.test.js @@ -71,6 +71,21 @@ describe('openDetailPane', () => { void panel; }); + it('divides the drag delta by the pane zoom scale (html{zoom} bridge)', () => { + mountPanel(); + const pane = openDetailPane(APP(), NODE, DETAIL); + // zoomScale(pane) = rect.width / offsetWidth = 720 / 600 = 1.2 + pane.getBoundingClientRect = () => ({ left: 0, top: 0, right: 720, bottom: 600, width: 720, height: 600 }); + Object.defineProperty(pane, 'offsetWidth', { value: 600, configurable: true }); + const handle = pane.querySelector('.schema-detail-handle'); + handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + document.dispatchEvent(new MouseEvent('mousemove', { clientY: 240, bubbles: true })); + expect(pane.style.flexBasis).toBe('300px'); // (600 - 240) / 1.2 + document.dispatchEvent(new MouseEvent('mousemove', { clientY: 0, bubbles: true })); // tall → clamp to max + expect(pane.style.flexBasis).toBe('400px'); // (height 600 / 1.2) - TOP_MARGIN(100) + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + it('re-opening for another node replaces the pane, not stacks it', () => { mountPanel(); openDetailPane(APP(), NODE, DETAIL); diff --git a/tests/unit/splitters.test.js b/tests/unit/splitters.test.js index c4efcfa..9627555 100644 --- a/tests/unit/splitters.test.js +++ b/tests/unit/splitters.test.js @@ -8,6 +8,10 @@ describe('dragValue', () => { expect(dragValue('col', { clientX: 250 })).toBe(250); expect(dragValue('col', { clientX: 999 })).toBe(420); }); + it('col divides clientX by the zoom scale before clamping', () => { + expect(dragValue('col', { clientX: 360 }, null, 1.2)).toBe(300); // 360 visual px → 300 layout px + expect(dragValue('col', { clientX: 60 }, null, 1.2)).toBe(180); // 50 layout → clamp to 180 + }); it('sideRow maps Y to % clamped [25,85]', () => { expect(dragValue('sideRow', { clientY: 200 }, rect)).toBe(50); expect(dragValue('sideRow', { clientY: 100 }, rect)).toBe(25); // 0% → clamp 25 @@ -53,6 +57,17 @@ describe('startDrag', () => { expect(save).toHaveBeenCalledWith('sidebarPx', 300); expect(win._has('mousemove')).toBe(false); }); + it('col: applies ctx.scale to the dragged width', () => { + const win = fakeWin(); + const handle = document.createElement('div'); + const state = { sidebarPx: 0 }; + const apply = vi.fn(); + const ctx = { win, state, apply, save: vi.fn(), rectFor: () => ({}), scale: () => 1.2 }; + startDrag({ preventDefault: vi.fn(), currentTarget: handle }, 'col', ctx); + win._fire('mousemove', { clientX: 360 }); + expect(state.sidebarPx).toBe(300); // 360 / 1.2 + expect(apply).toHaveBeenCalledWith('col', 300); + }); it('sideRow: updates sideSplitPct + persists', () => { const { win, state, save } = harness('sideRow'); win._fire('mousemove', { clientY: 50 });