Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions src/styles.css
Original file line number Diff line number Diff line change
@@ -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'] {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 10 additions & 3 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 + '%';
Expand Down
7 changes: 5 additions & 2 deletions src/ui/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
8 changes: 6 additions & 2 deletions src/ui/schema-detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 11 additions & 4 deletions src/ui/splitters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
21 changes: 20 additions & 1 deletion tests/unit/dom.test.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/schema-detail.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/splitters.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand Down
Loading