feat(world): panel layout engine integrated onto origin/main + free live-news video
Rebuilds the free-form + snap-to-grid layout engine on top of the advanced origin/main (footer dock + responsive work) and unifies the two grid-cell mechanisms into one: - grid-config drives the dock's `--panel-col-min` (default 160px, range 140–360, matching the base .panels-grid rule + the dock slider) — the duplicate `--grid-cell` override is gone. One variable, one way. Default 160 = the grid is byte-identical until the slider moves it, so no fixed-span panel shrinks. - window.worldGrid exposes the engine to the footer dock's Grid⇄Free toggle + cell-size slider (App.gridApi delegates to it). - main.css layout-engine section is append-only after the dock CSS; only NEW selectors (corner grip, snap overlay, free-mode absolute layout, mobile guard). Live News (video) now resizes freely with the video filling at any size (CTO ask): the panel carries id="live-news" so the intended `#live-news .panel-content` fill CSS (padding:0 + flex-column) finally applies — the 16:9 `.live-news-player` fills the container edge-to-edge (was 16px short under generic padding). With the new right-edge + corner grips and the uncapped column snap (span up to full width), Live News drags to 2-3 cols or full-width-top in grid mode and to any pixel size in free mode; the width-driven video fills at every size. Tests (13, all green; typecheck + build pass): 4 original drag/resize + 7 layout-engine (grid snap, corner resize, overlay, cell re-snap, free drag+resize persist across reload, map participates, toggle) + 2 real-app live-news (grid default→3col→full-width fill setup + resize, free arbitrary-size). Screenshot: e2e/layout-shots/live-news-fullwidth.png (full-width-top, large video area). Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
This commit is contained in:
@@ -4,6 +4,9 @@ import { expect, test, type Page } from '@playwright/test';
|
||||
// FLIP reflow, snapping resize) through the deterministic harness.
|
||||
|
||||
const HARNESS = '/tests/panel-drag-harness.html';
|
||||
const LAYOUT_HARNESS = '/tests/layout-harness.html';
|
||||
// Screenshots land in a repo-relative artifacts dir (Playwright creates it).
|
||||
const SHOTS = 'e2e/layout-shots';
|
||||
|
||||
interface PanelDragHarness {
|
||||
ready: boolean;
|
||||
@@ -12,9 +15,31 @@ interface PanelDragHarness {
|
||||
order: () => string[];
|
||||
}
|
||||
|
||||
interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LayoutHarness {
|
||||
ready: boolean;
|
||||
mode: () => 'grid' | 'free';
|
||||
setMode: (m: 'grid' | 'free') => void;
|
||||
toggle: () => 'grid' | 'free';
|
||||
cell: () => number;
|
||||
setCell: (px: number) => void;
|
||||
rect: (id: string) => Rect | null;
|
||||
colStep: () => { step: number; cols: number; padL: number };
|
||||
order: () => string[];
|
||||
overlayVisible: () => boolean;
|
||||
gridColumnOf: (id: string) => string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__panelDragHarness?: PanelDragHarness;
|
||||
__layoutHarness?: LayoutHarness;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,3 +128,297 @@ test.describe('panel drag + resize', () => {
|
||||
expect(span).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Layout engine: grid ⇄ free, corner resize, cell-size, overlay ──────────
|
||||
// Drives the real Panel grips + grid-config against the true main.css, at
|
||||
// 1440x900 (see playwright.layout.config.ts).
|
||||
|
||||
const lh = async (page: Page): Promise<void> => {
|
||||
await page.goto(LAYOUT_HARNESS);
|
||||
await page.waitForFunction(() => window.__layoutHarness?.ready === true);
|
||||
await page.waitForTimeout(60); // let the queued registerPanel microtasks settle
|
||||
};
|
||||
|
||||
const rect = (page: Page, id: string): Promise<Rect> =>
|
||||
page.evaluate((pid) => window.__layoutHarness!.rect(pid)!, id);
|
||||
|
||||
const headerBox = async (page: Page, id: string) =>
|
||||
(await page.locator(`[data-panel="${id}"] .panel-header`).first().boundingBox())!;
|
||||
|
||||
test.describe('layout engine', () => {
|
||||
// Gate viewport: 1440x900 (independent of the base config's default size).
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test('grid mode: a dropped panel lands on a cell boundary', async ({ page }) => {
|
||||
await lh(page);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('grid');
|
||||
|
||||
const src = await headerBox(page, 'charlie');
|
||||
const dst = (await page.locator('[data-panel="echo"]').boundingBox())!;
|
||||
|
||||
await page.mouse.move(src.x + 30, src.y + src.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(src.x + 60, src.y + src.height / 2, { steps: 4 });
|
||||
await page.mouse.move(dst.x + dst.width * 0.8, dst.y + dst.height / 2, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Final resting position is snapped to a grid cell (multiple of the column step).
|
||||
const { step, padL } = await page.evaluate(() => window.__layoutHarness!.colStep());
|
||||
const r = await rect(page, 'charlie');
|
||||
const k = Math.round((r.left - padL) / step);
|
||||
expect(Math.abs(r.left - padL - k * step)).toBeLessThan(4);
|
||||
|
||||
await page.screenshot({ path: `${SHOTS}/grid-snap.png` });
|
||||
});
|
||||
|
||||
test('grid mode: bottom-right corner resizes width + height (snapped)', async ({ page }) => {
|
||||
await lh(page);
|
||||
const corner = (await page
|
||||
.locator('[data-panel="delta"] .panel-corner-resize-handle')
|
||||
.boundingBox())!;
|
||||
const { step } = await page.evaluate(() => window.__layoutHarness!.colStep());
|
||||
|
||||
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2);
|
||||
await page.mouse.down();
|
||||
// Pull out ~1.5 columns wide and ~250px tall.
|
||||
await page.mouse.move(
|
||||
corner.x + corner.width / 2 + step * 1.5,
|
||||
corner.y + corner.height / 2 + 250,
|
||||
{ steps: 16 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
// Height grew to a taller row-span and width snapped to multiple columns.
|
||||
await expect(page.locator('[data-panel="delta"]')).toHaveClass(/span-2/);
|
||||
const gc = await page.evaluate(() => window.__layoutHarness!.gridColumnOf('delta'));
|
||||
expect(gc).toMatch(/span [2-9]/);
|
||||
|
||||
await page.screenshot({ path: `${SHOTS}/resized-from-corner.png` });
|
||||
});
|
||||
|
||||
test('grid mode: overlay appears only while dragging', async ({ page }) => {
|
||||
await lh(page);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.overlayVisible())).toBe(false);
|
||||
|
||||
const src = await headerBox(page, 'bravo');
|
||||
await page.mouse.move(src.x + 30, src.y + src.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(src.x + 120, src.y + 40, { steps: 8 });
|
||||
|
||||
// Mid-drag: the faint track overlay is shown.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__layoutHarness!.overlayVisible()))
|
||||
.toBe(true);
|
||||
await page.screenshot({ path: `${SHOTS}/grid-overlay.png` });
|
||||
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__layoutHarness!.overlayVisible()))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test('grid mode: changing cell size re-snaps the grid', async ({ page }) => {
|
||||
await lh(page);
|
||||
const before = await page.evaluate(() => window.__layoutHarness!.colStep());
|
||||
|
||||
await page.evaluate(() => window.__layoutHarness!.setCell(240));
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
const after = await page.evaluate(() => window.__layoutHarness!.colStep());
|
||||
// Wider cells ⇒ fewer, wider columns: the panels re-snap to a new track grid.
|
||||
expect(after.step).toBeGreaterThan(before.step + 20);
|
||||
expect(after.cols).toBeLessThanOrEqual(before.cols);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.cell())).toBe(240);
|
||||
});
|
||||
|
||||
test('free mode: pixel drag + corner resize persist across reload', async ({ page }) => {
|
||||
await lh(page);
|
||||
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
|
||||
await page.waitForTimeout(40);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
|
||||
|
||||
// Drag alpha by an arbitrary (non-cell) pixel delta.
|
||||
const start = await rect(page, 'alpha');
|
||||
const hdr = await headerBox(page, 'alpha');
|
||||
const DX = 223;
|
||||
const DY = -137;
|
||||
await page.mouse.move(hdr.x + 30, hdr.y + hdr.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(hdr.x + 40, hdr.y + hdr.height / 2, { steps: 3 });
|
||||
await page.mouse.move(hdr.x + 30 + DX, hdr.y + hdr.height / 2 + DY, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
|
||||
const moved = await rect(page, 'alpha');
|
||||
expect(Math.abs(moved.left - (start.left + DX))).toBeLessThan(6);
|
||||
expect(Math.abs(moved.top - (start.top + DY))).toBeLessThan(6);
|
||||
// Arbitrary pixel position — not snapped to a cell.
|
||||
await page.screenshot({ path: `${SHOTS}/free-form.png` });
|
||||
|
||||
// Resize from the corner to an arbitrary size.
|
||||
const corner = (await page
|
||||
.locator('[data-panel="alpha"] .panel-corner-resize-handle')
|
||||
.boundingBox())!;
|
||||
const WD = 118;
|
||||
const HD = 94;
|
||||
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
corner.x + corner.width / 2 + WD,
|
||||
corner.y + corner.height / 2 + HD,
|
||||
{ steps: 16 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
const resized = await rect(page, 'alpha');
|
||||
expect(Math.abs(resized.width - (moved.width + WD))).toBeLessThan(6);
|
||||
expect(Math.abs(resized.height - (moved.height + HD))).toBeLessThan(6);
|
||||
|
||||
// Reload: the mode + exact geometry are restored.
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => window.__layoutHarness?.ready === true);
|
||||
await page.waitForTimeout(80);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
|
||||
const restored = await rect(page, 'alpha');
|
||||
expect(Math.abs(restored.left - resized.left)).toBeLessThan(3);
|
||||
expect(Math.abs(restored.top - resized.top)).toBeLessThan(3);
|
||||
expect(Math.abs(restored.width - resized.width)).toBeLessThan(3);
|
||||
expect(Math.abs(restored.height - resized.height)).toBeLessThan(3);
|
||||
});
|
||||
|
||||
test('free mode: the map participates with a 240px floor', async ({ page }) => {
|
||||
await lh(page);
|
||||
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
|
||||
await page.waitForTimeout(40);
|
||||
const pos = await page.evaluate(
|
||||
() => getComputedStyle(document.querySelector('[data-panel="map"]')!).position,
|
||||
);
|
||||
expect(pos).toBe('absolute');
|
||||
const r = await rect(page, 'map');
|
||||
expect(r.width).toBeGreaterThanOrEqual(240);
|
||||
expect(r.height).toBeGreaterThanOrEqual(240);
|
||||
});
|
||||
|
||||
test('toggle flips grid ⇄ free and back', async ({ page }) => {
|
||||
await lh(page);
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.toggle())).toBe('free');
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
|
||||
expect(await page.evaluate(() => window.__layoutHarness!.toggle())).toBe('grid');
|
||||
// Back in grid mode the free inline geometry is stripped.
|
||||
const pos = await page.evaluate(
|
||||
() => document.querySelector<HTMLElement>('[data-panel="alpha"]')!.style.position,
|
||||
);
|
||||
expect(pos).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Live News (video) resizes freely; the video fills the panel at any size ──
|
||||
// Real app: proves the CTO requirement — Live News can grow to 2-3 cols / full
|
||||
// width (grid) or any pixel size (free), and the 16:9 video (`.live-news-player`,
|
||||
// width-driven) scales to fill, never capped small. NOTE: in the offline e2e
|
||||
// runtime the YouTube embed eventually errors and replaces `.live-news-player`
|
||||
// with a message, so the video-fill invariant is asserted where it's reliably
|
||||
// present (default size) and the resize is proved via the player's containing
|
||||
// block (`.panel-content`, always present), which the player fills 1:1.
|
||||
|
||||
const rectW = (page: Page, sel: string): Promise<number> =>
|
||||
page.evaluate((s) => {
|
||||
const el = document.querySelector<HTMLElement>(s);
|
||||
return el ? Math.round(el.getBoundingClientRect().width) : -1;
|
||||
}, sel);
|
||||
|
||||
const LN = '[data-panel="live-news"]';
|
||||
const LN_CONTENT = `${LN} .panel-content`;
|
||||
const LN_PLAYER = `${LN} .live-news-player`;
|
||||
|
||||
test.describe('live news video resize (real app)', () => {
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test('grid: default → 3 cols → full-width; the video fills at every width', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector(LN, { timeout: 45000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const grid = (await page.locator('#panelsGrid').boundingBox())!;
|
||||
const ln = page.locator(LN);
|
||||
const colHandle = ln.locator('.panel-col-resize-handle');
|
||||
|
||||
// The fill setup is active: the panel-content is a full-bleed flex column
|
||||
// (padding 0) so the width-driven 16:9 `.live-news-player` fills it edge-to-edge
|
||||
// at any width. (Keyed on #live-news — dead until the id fix in LiveNewsPanel.)
|
||||
const setup = await page.evaluate((sel) => {
|
||||
const c = document.querySelector<HTMLElement>(sel);
|
||||
if (!c) return null;
|
||||
const s = getComputedStyle(c);
|
||||
return { display: s.display, dir: s.flexDirection, padLeft: s.paddingLeft };
|
||||
}, LN_CONTENT);
|
||||
expect(setup).toEqual({ display: 'flex', dir: 'column', padLeft: '0px' });
|
||||
|
||||
// The video, while present (offline embed errors after a beat), fills the
|
||||
// container 1:1 at the default width.
|
||||
const startContent = await rectW(page, LN_CONTENT);
|
||||
const startVideo = await rectW(page, LN_PLAYER);
|
||||
if (startVideo > 0) expect(Math.abs(startVideo - startContent)).toBeLessThan(4);
|
||||
|
||||
// Drag the right edge out to ~3 columns.
|
||||
const h1 = (await colHandle.boundingBox())!;
|
||||
await page.mouse.move(h1.x + h1.width / 2, h1.y + h1.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(grid.x + grid.width * 0.42, h1.y + h1.height / 2, { steps: 14 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
const midContent = await rectW(page, LN_CONTENT);
|
||||
expect(midContent).toBeGreaterThan(startContent + 40); // genuinely wider
|
||||
|
||||
// Drag the right edge all the way out → full width. No column cap.
|
||||
const h2 = (await colHandle.boundingBox())!;
|
||||
await page.mouse.move(h2.x + h2.width / 2, h2.y + h2.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(grid.x + grid.width + 200, h2.y + h2.height / 2, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
const fullContent = await rectW(page, LN_CONTENT);
|
||||
expect(fullContent).toBeGreaterThan(midContent);
|
||||
expect(fullContent).toBeGreaterThan(grid.width * 0.9); // ~full grid width
|
||||
// The video (when present) fills the now-full-width container 1:1.
|
||||
const fullVideo = await rectW(page, LN_PLAYER);
|
||||
if (fullVideo > 0) expect(Math.abs(fullVideo - fullContent)).toBeLessThan(6);
|
||||
|
||||
// Make it tall too (drag the bottom edge down) so the 16:9 video is large.
|
||||
const bottom = ln.locator('.panel-resize-handle');
|
||||
const b = (await bottom.boundingBox())!;
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + 520, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(250);
|
||||
await page.evaluate(() => document.querySelector('[data-panel="live-news"]')?.scrollIntoView({ block: 'start' }));
|
||||
await page.waitForTimeout(150);
|
||||
await page.screenshot({ path: 'e2e/layout-shots/live-news-fullwidth.png' });
|
||||
|
||||
// Container is now full-width AND tall → a large video area.
|
||||
expect(await rectW(page, LN_CONTENT)).toBeGreaterThan(900);
|
||||
});
|
||||
|
||||
test('free: Live News resizes to an arbitrary pixel size; video fills', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector(LN_PLAYER, { timeout: 45000 });
|
||||
await page.waitForTimeout(600);
|
||||
const startContent = await rectW(page, LN_CONTENT);
|
||||
await page.evaluate(() => (window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('free'));
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const corner = page.locator(`${LN} .panel-corner-resize-handle`);
|
||||
const c = (await corner.boundingBox())!;
|
||||
await page.mouse.move(c.x + c.width / 2, c.y + c.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(c.x + 240, c.y + 260, { steps: 18 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const content = await rectW(page, LN_CONTENT);
|
||||
expect(content).toBeGreaterThan(startContent + 120); // grew to an arbitrary pixel width
|
||||
const video = await rectW(page, LN_PLAYER);
|
||||
if (video > 0) expect(Math.abs(video - content)).toBeLessThan(6); // fills at that size
|
||||
await page.evaluate(() => (window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('grid'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// Private config for the layout-engine work: runs on port 4273 so it never
|
||||
// collides with the concurrent responsive-fix agent's server on 4173.
|
||||
// 1440x900 viewport per the gate.
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
workers: 1,
|
||||
timeout: 90000,
|
||||
expect: { timeout: 30000 },
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4273',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
colorScheme: 'dark',
|
||||
locale: 'en-US',
|
||||
timezoneId: 'UTC',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'off',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
launchOptions: { args: ['--use-angle=swiftshader', '--use-gl=swiftshader'] },
|
||||
},
|
||||
},
|
||||
],
|
||||
snapshotPathTemplate: '{testDir}/{testFileName}-snapshots/{arg}{ext}',
|
||||
webServer: {
|
||||
command: 'VITE_E2E=1 npm run dev -- --host 127.0.0.1 --port 4273',
|
||||
url: 'http://127.0.0.1:4273/tests/panel-drag-harness.html',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
@@ -123,6 +123,11 @@ export class LiveNewsPanel extends Panel {
|
||||
super({ id: 'live-news', title: t('panels.liveNews') });
|
||||
this.youtubeOrigin = LiveNewsPanel.resolveYouTubeOrigin();
|
||||
this.playerElementId = `live-news-player-${Date.now()}`;
|
||||
// The panel-content / player layout CSS is keyed on `#live-news` (padding:0 +
|
||||
// flex-column so the 16:9 video fills the panel edge-to-edge at any width).
|
||||
// data-panel alone never matched it, leaving the video under the generic 8px
|
||||
// padding — set the id so the intended fill CSS actually applies.
|
||||
this.element.id = 'live-news';
|
||||
this.element.classList.add('panel-wide');
|
||||
this.createLiveButton();
|
||||
this.createMuteButton();
|
||||
|
||||
+92
-24
@@ -2,7 +2,12 @@ import { escapeHtml } from '../utils/sanitize';
|
||||
import { isDesktopRuntime, canConfigureKeys } from '../services/runtime';
|
||||
import { invokeTauri } from '../services/tauri-bridge';
|
||||
import { t } from '../services/i18n';
|
||||
import { attachPanelResize } from '../services/panel-drag';
|
||||
import {
|
||||
attachPanelResize,
|
||||
attachPanelColResize,
|
||||
attachPanelCornerResize,
|
||||
} from '../services/panel-drag';
|
||||
import { getGridCols, setGridCols } from '../services/grid-config';
|
||||
|
||||
export interface PanelOptions {
|
||||
id: string;
|
||||
@@ -64,7 +69,9 @@ export class Panel {
|
||||
protected panelId: string;
|
||||
private tooltipCloseHandler: (() => void) | null = null;
|
||||
private resizeHandle: HTMLElement | null = null;
|
||||
private resizeCleanup: (() => void) | null = null;
|
||||
private colResizeHandle: HTMLElement | null = null;
|
||||
private cornerResizeHandle: HTMLElement | null = null;
|
||||
private resizeCleanups: Array<() => void> = [];
|
||||
|
||||
constructor(options: PanelOptions) {
|
||||
this.panelId = options.id;
|
||||
@@ -158,12 +165,26 @@ export class Panel {
|
||||
this.element.appendChild(this.header);
|
||||
this.element.appendChild(this.content);
|
||||
|
||||
// Add resize handle
|
||||
// Resize affordances: bottom edge (height), right edge (width), and the
|
||||
// bottom-right corner (both). Grips are subtle until the panel is hovered.
|
||||
this.resizeHandle = document.createElement('div');
|
||||
this.resizeHandle.className = 'panel-resize-handle';
|
||||
this.resizeHandle.title = 'Drag to resize (double-click to reset)';
|
||||
this.resizeHandle.title = 'Drag to resize height (double-click to reset)';
|
||||
this.resizeHandle.draggable = false; // Prevent parent's drag from capturing
|
||||
this.element.appendChild(this.resizeHandle);
|
||||
|
||||
this.colResizeHandle = document.createElement('div');
|
||||
this.colResizeHandle.className = 'panel-col-resize-handle';
|
||||
this.colResizeHandle.title = 'Drag to resize width';
|
||||
this.colResizeHandle.draggable = false;
|
||||
this.element.appendChild(this.colResizeHandle);
|
||||
|
||||
this.cornerResizeHandle = document.createElement('div');
|
||||
this.cornerResizeHandle.className = 'panel-corner-resize-handle';
|
||||
this.cornerResizeHandle.title = 'Drag to resize';
|
||||
this.cornerResizeHandle.draggable = false;
|
||||
this.element.appendChild(this.cornerResizeHandle);
|
||||
|
||||
this.setupResizeHandlers();
|
||||
|
||||
// Restore saved span. Any saved tier other than the default span-1 is applied,
|
||||
@@ -178,24 +199,65 @@ export class Panel {
|
||||
}
|
||||
|
||||
private setupResizeHandlers(): void {
|
||||
if (!this.resizeHandle) return;
|
||||
const handle = this.resizeHandle;
|
||||
if (!this.resizeHandle || !this.colResizeHandle || !this.cornerResizeHandle) return;
|
||||
const el = this.element;
|
||||
const id = this.panelId;
|
||||
|
||||
// Pointer-driven resize (mouse + touch + pen). Height snaps to the nearest
|
||||
// tier in PANEL_SPAN_HEIGHTS — including the new span-0 (120px) tiny tier —
|
||||
// so the panel can go smaller and the low-end steps are finer (~100px). The
|
||||
// module owns pointer math; Panel owns the span→class mapping and persistence.
|
||||
this.resizeCleanup = attachPanelResize(this.element, handle, {
|
||||
minSpan: 0,
|
||||
maxSpan: 4,
|
||||
snapHeights: PANEL_SPAN_HEIGHTS,
|
||||
getStartSpan: () => currentSpan(this.element),
|
||||
onPreview: (span) => setSpanClass(this.element, span),
|
||||
onCommit: (span) => savePanelSpan(this.panelId, span),
|
||||
});
|
||||
// Current grid column-span, read from the live inline style (grid-config
|
||||
// restores it on load) or the persisted store.
|
||||
const startCols = (): number => {
|
||||
const m = el.style.gridColumn.match(/span\s+(\d+)/);
|
||||
if (m && m[1]) return parseInt(m[1], 10);
|
||||
return getGridCols(id) ?? 1;
|
||||
};
|
||||
const previewCols = (cols: number): void => {
|
||||
el.style.gridColumn = cols > 1 ? `span ${cols}` : '';
|
||||
};
|
||||
|
||||
// Double-click the handle to reset to default height.
|
||||
handle.addEventListener('dblclick', () => this.resetHeight());
|
||||
// Bottom edge → height. Grid mode snaps to the PANEL_SPAN_HEIGHTS tier ladder
|
||||
// (span-0 = 120px tiny … span-4 = 800px); free mode is pixel-exact. The module
|
||||
// owns pointer math; Panel owns the span→class mapping and persistence.
|
||||
this.resizeCleanups.push(
|
||||
attachPanelResize(el, this.resizeHandle, {
|
||||
minSpan: 0,
|
||||
maxSpan: 4,
|
||||
snapHeights: PANEL_SPAN_HEIGHTS,
|
||||
getStartSpan: () => currentSpan(el),
|
||||
onPreview: (span) => setSpanClass(el, span),
|
||||
onCommit: (span) => savePanelSpan(id, span),
|
||||
}),
|
||||
);
|
||||
|
||||
// Right edge → width. Grid mode snaps to whole columns (persisted in
|
||||
// grid-config); free mode is pixel-exact.
|
||||
this.resizeCleanups.push(
|
||||
attachPanelColResize(el, this.colResizeHandle, {
|
||||
getGrid: () => document.getElementById('panelsGrid'),
|
||||
getStartCols: startCols,
|
||||
onPreview: (cols) => previewCols(cols),
|
||||
onCommit: (cols) => setGridCols(id, cols),
|
||||
}),
|
||||
);
|
||||
|
||||
// Bottom-right corner → width AND height together.
|
||||
this.resizeCleanups.push(
|
||||
attachPanelCornerResize(el, this.cornerResizeHandle, {
|
||||
getGrid: () => document.getElementById('panelsGrid'),
|
||||
getStartSpan: () => currentSpan(el),
|
||||
getStartCols: startCols,
|
||||
snapHeights: PANEL_SPAN_HEIGHTS,
|
||||
minSpan: 0,
|
||||
maxSpan: 4,
|
||||
onPreviewSpan: (span) => setSpanClass(el, span),
|
||||
onPreviewCols: (cols) => previewCols(cols),
|
||||
onCommitSpan: (span) => savePanelSpan(id, span),
|
||||
onCommitCols: (cols) => setGridCols(id, cols),
|
||||
}),
|
||||
);
|
||||
|
||||
// Double-click either edge handle to reset to default size.
|
||||
this.resizeHandle.addEventListener('dblclick', () => this.resetHeight());
|
||||
this.colResizeHandle.addEventListener('dblclick', () => this.resetWidth());
|
||||
}
|
||||
|
||||
|
||||
@@ -338,6 +400,14 @@ export class Panel {
|
||||
localStorage.setItem(PANEL_SPANS_KEY, JSON.stringify(spans));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset panel width (grid column-span) to default (single column).
|
||||
*/
|
||||
public resetWidth(): void {
|
||||
this.element.style.gridColumn = '';
|
||||
setGridCols(this.panelId, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up event listeners and resources
|
||||
*/
|
||||
@@ -346,9 +416,7 @@ export class Panel {
|
||||
document.removeEventListener('click', this.tooltipCloseHandler);
|
||||
this.tooltipCloseHandler = null;
|
||||
}
|
||||
if (this.resizeCleanup) {
|
||||
this.resizeCleanup();
|
||||
this.resizeCleanup = null;
|
||||
}
|
||||
for (const cleanup of this.resizeCleanups) cleanup();
|
||||
this.resizeCleanups = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Realistic harness for the layout engine (services/grid-config.ts +
|
||||
// services/panel-drag.ts + components/Panel.ts). It mounts REAL Panel instances
|
||||
// and a map panel wired exactly like App does, under the real main.css, so
|
||||
// Playwright drives the true grid ⇄ free mechanism (snap, corner resize,
|
||||
// free-form drag/resize, overlay, cell-size re-snap). Exposes
|
||||
// window.__layoutHarness for assertions.
|
||||
|
||||
import '../styles/main.css';
|
||||
import { Panel } from '../components/Panel';
|
||||
import {
|
||||
attachPanelDrag,
|
||||
attachPanelResize,
|
||||
attachPanelColResize,
|
||||
} from '../services/panel-drag';
|
||||
import {
|
||||
getLayoutMode,
|
||||
setLayoutMode,
|
||||
toggleLayoutMode,
|
||||
getCellSize,
|
||||
setCellSize,
|
||||
type LayoutMode,
|
||||
} from '../services/grid-config';
|
||||
|
||||
interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LayoutHarness {
|
||||
ready: boolean;
|
||||
mode: () => LayoutMode;
|
||||
setMode: (m: LayoutMode) => void;
|
||||
toggle: () => LayoutMode;
|
||||
cell: () => number;
|
||||
setCell: (px: number) => void;
|
||||
rect: (id: string) => Rect | null;
|
||||
colStep: () => { step: number; cols: number; padL: number };
|
||||
order: () => string[];
|
||||
overlayVisible: () => boolean;
|
||||
gridColumnOf: (id: string) => string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__layoutHarness?: LayoutHarness;
|
||||
}
|
||||
}
|
||||
|
||||
const PANELS: Array<{ id: string; title: string }> = [
|
||||
{ id: 'alpha', title: 'Alpha' },
|
||||
{ id: 'bravo', title: 'Bravo' },
|
||||
{ id: 'charlie', title: 'Charlie' },
|
||||
{ id: 'delta', title: 'Delta' },
|
||||
{ id: 'echo', title: 'Echo' },
|
||||
{ id: 'foxtrot', title: 'Foxtrot' },
|
||||
];
|
||||
|
||||
function currentSpan(el: HTMLElement): number {
|
||||
if (el.classList.contains('span-4')) return 4;
|
||||
if (el.classList.contains('span-3')) return 3;
|
||||
if (el.classList.contains('span-2')) return 2;
|
||||
if (el.classList.contains('span-0')) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function build(): void {
|
||||
const app = document.getElementById('app');
|
||||
if (!app) return;
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'panels-grid';
|
||||
grid.id = 'panelsGrid';
|
||||
app.appendChild(grid);
|
||||
|
||||
const makeDraggable = (el: HTMLElement, key: string): void => {
|
||||
el.dataset.panel = key;
|
||||
attachPanelDrag(el, {
|
||||
getGrid: () => grid,
|
||||
onReorder: () => {
|
||||
/* order read back from the DOM in tests */
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ── Map panel, wired like App.setupMapPanel (row + col resize, drag) ──
|
||||
const mapSection = document.createElement('div');
|
||||
mapSection.className = 'panel map-section map-panel';
|
||||
mapSection.dataset.panel = 'map';
|
||||
mapSection.innerHTML = `
|
||||
<div class="panel-header map-drag-grip" aria-label="map — drag to move"></div>
|
||||
<div class="map-container" id="mapContainer"></div>
|
||||
<div class="map-resize-handle" id="mapResizeHandle"></div>
|
||||
<div class="panel-col-resize-handle" id="mapColResizeHandle"></div>`;
|
||||
mapSection.classList.add('span-2');
|
||||
mapSection.style.gridColumn = '1 / -1';
|
||||
grid.appendChild(mapSection);
|
||||
makeDraggable(mapSection, 'map');
|
||||
attachPanelResize(mapSection, mapSection.querySelector('#mapResizeHandle')!, {
|
||||
minSpan: 1,
|
||||
maxSpan: 4,
|
||||
rowPx: 200,
|
||||
getStartSpan: () => currentSpan(mapSection),
|
||||
onPreview: (span) => {
|
||||
mapSection.classList.remove('span-1', 'span-2', 'span-3', 'span-4');
|
||||
mapSection.classList.add(`span-${span}`);
|
||||
},
|
||||
onCommit: () => {
|
||||
/* span persisted via Panel's store elsewhere; not needed here */
|
||||
},
|
||||
});
|
||||
attachPanelColResize(mapSection, mapSection.querySelector('#mapColResizeHandle')!, {
|
||||
getGrid: () => grid,
|
||||
getStartCols: () => {
|
||||
const m = mapSection.style.gridColumn.match(/span\s+(\d+)/);
|
||||
return m && m[1] ? parseInt(m[1], 10) : 99;
|
||||
},
|
||||
onPreview: (cols, total) => {
|
||||
mapSection.style.gridColumn = cols >= total ? '1 / -1' : `span ${cols}`;
|
||||
},
|
||||
onCommit: () => {
|
||||
/* map cols persisted by App in prod; the harness only needs the geometry */
|
||||
},
|
||||
});
|
||||
|
||||
// ── Real Panel instances (own their bottom / right / corner grips) ──
|
||||
for (const spec of PANELS) {
|
||||
const panel = new Panel({ id: spec.id, title: spec.title, trackActivity: false });
|
||||
const el = panel.getElement();
|
||||
panel.setContent(`<div style="padding:10px;color:var(--text-dim)">${spec.title}</div>`);
|
||||
makeDraggable(el, spec.id);
|
||||
grid.appendChild(el);
|
||||
}
|
||||
|
||||
const rectOf = (id: string): Rect | null => {
|
||||
const el = grid.querySelector<HTMLElement>(`[data-panel="${id}"]`);
|
||||
if (!el) return null;
|
||||
const g = grid.getBoundingClientRect();
|
||||
const r = el.getBoundingClientRect();
|
||||
return { left: r.left - g.left, top: r.top - g.top, width: r.width, height: r.height };
|
||||
};
|
||||
|
||||
const colStep = (): { step: number; cols: number; padL: number } => {
|
||||
const cs = getComputedStyle(grid);
|
||||
const tracks = cs.gridTemplateColumns.split(' ').filter(Boolean);
|
||||
const n = Math.max(1, tracks.length);
|
||||
const gap = parseFloat(cs.columnGap || '0') || 0;
|
||||
const rect = grid.getBoundingClientRect();
|
||||
const padL = parseFloat(cs.paddingLeft || '0') || 0;
|
||||
const padR = parseFloat(cs.paddingRight || '0') || 0;
|
||||
const inner = rect.width - padL - padR;
|
||||
const colW = (inner - gap * (n - 1)) / n;
|
||||
return { step: colW + gap, cols: n, padL };
|
||||
};
|
||||
|
||||
window.__layoutHarness = {
|
||||
ready: true,
|
||||
mode: () => getLayoutMode(),
|
||||
setMode: (m) => setLayoutMode(m),
|
||||
toggle: () => toggleLayoutMode(),
|
||||
cell: () => getCellSize(),
|
||||
setCell: (px) => setCellSize(px),
|
||||
rect: rectOf,
|
||||
colStep,
|
||||
order: () => Array.from(grid.children).map((c) => (c as HTMLElement).dataset.panel ?? ''),
|
||||
overlayVisible: () => document.body.classList.contains('layout-snapping'),
|
||||
gridColumnOf: (id) =>
|
||||
grid.querySelector<HTMLElement>(`[data-panel="${id}"]`)?.style.gridColumn ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
build();
|
||||
@@ -0,0 +1,389 @@
|
||||
// Layout engine: two modes for the panel grid, one mechanism, one config store.
|
||||
//
|
||||
// • "grid" (default) — the existing CSS-Grid reflow. Panels flow into cells;
|
||||
// drag reorders (FLIP), resize snaps to grid tracks. cellSize drives the
|
||||
// track floor (`--panel-col-min`), so changing it re-snaps every panel. A faint
|
||||
// overlay of the live tracks is shown only while dragging/resizing.
|
||||
// • "free" — pixel-perfect. The grid becomes a plain positioned block and each
|
||||
// panel is absolutely placed at its own {x,y,w,h}; drag follows the cursor,
|
||||
// resize is exact, nothing snaps, no overlay.
|
||||
//
|
||||
// This module owns ONLY the mode/config state, its persistence (per-variant),
|
||||
// and the DOM application of a panel's saved geometry. All pointer math lives in
|
||||
// panel-drag.ts, which reads getLayoutMode()/getCellSize() and calls back here to
|
||||
// persist. Grid mode is left byte-for-byte untouched so it can never regress.
|
||||
|
||||
import { SITE_VARIANT } from '../config/variant';
|
||||
|
||||
export type LayoutMode = 'grid' | 'free';
|
||||
|
||||
export interface GridConfig {
|
||||
enabled: boolean;
|
||||
cellSize: number;
|
||||
gap: number;
|
||||
}
|
||||
|
||||
export interface FreeRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
interface LayoutState {
|
||||
mode: LayoutMode;
|
||||
cellSize: number;
|
||||
free: Record<string, FreeRect>;
|
||||
gridCols: Record<string, number>;
|
||||
}
|
||||
|
||||
// Per-variant so the tech / finance / full dashboards each keep their own layout.
|
||||
const STORAGE_KEY = `worldmonitor-layout:${SITE_VARIANT}`;
|
||||
|
||||
// px — the grid column-track floor, exposed to CSS as `--panel-col-min` (the
|
||||
// variable the base .panels-grid rule and the footer dock already read). 160
|
||||
// matches the pre-existing default exactly, so the grid is byte-identical until
|
||||
// the slider moves it — fixed-span panels (the live-news video, .panel-wide) are
|
||||
// never shrunk by default. Range mirrors the dock slider (140–360).
|
||||
export const DEFAULT_CELL_SIZE = 160;
|
||||
const DEFAULT_GAP = 4; // matches .panels-grid gap
|
||||
const MIN_CELL_SIZE = 140;
|
||||
const MAX_CELL_SIZE = 360;
|
||||
// The CSS custom property that drives the grid column floor. Owned by the base
|
||||
// .panels-grid rule (origin/main); the dock's fallback sets the same one — one
|
||||
// variable, one way. grid-config is the source of truth when window.worldGrid is
|
||||
// present.
|
||||
const CELL_VAR = '--panel-col-min';
|
||||
|
||||
// Fired on document whenever the mode or cell size changes, so the toolbar can
|
||||
// keep its toggle/slider in sync with programmatic (analyst / reset) changes.
|
||||
export const LAYOUT_MODE_EVENT = 'layout-mode-change';
|
||||
export const LAYOUT_CELL_EVENT = 'layout-cell-change';
|
||||
|
||||
const GRID_SELECTOR = '.panels-grid';
|
||||
|
||||
function readState(): LayoutState {
|
||||
const base: LayoutState = { mode: 'grid', cellSize: DEFAULT_CELL_SIZE, free: {}, gridCols: {} };
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return base;
|
||||
const parsed = JSON.parse(raw) as Partial<LayoutState>;
|
||||
return {
|
||||
mode: parsed.mode === 'free' ? 'free' : 'grid',
|
||||
cellSize:
|
||||
typeof parsed.cellSize === 'number'
|
||||
? clampCell(parsed.cellSize)
|
||||
: DEFAULT_CELL_SIZE,
|
||||
free: parsed.free && typeof parsed.free === 'object' ? parsed.free : {},
|
||||
gridCols: parsed.gridCols && typeof parsed.gridCols === 'object' ? parsed.gridCols : {},
|
||||
};
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(next: LayoutState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* private mode — layout simply won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
function clampCell(px: number): number {
|
||||
return Math.min(MAX_CELL_SIZE, Math.max(MIN_CELL_SIZE, Math.round(px)));
|
||||
}
|
||||
|
||||
let state = readState();
|
||||
|
||||
function grid(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>(GRID_SELECTOR);
|
||||
}
|
||||
|
||||
function panelsIn(g: HTMLElement): HTMLElement[] {
|
||||
return Array.from(g.children).filter(
|
||||
(c): c is HTMLElement => c instanceof HTMLElement && c.classList.contains('panel'),
|
||||
);
|
||||
}
|
||||
|
||||
// ── public config API (also consumed by the toolbar agent) ──────────────────
|
||||
|
||||
export function getLayoutMode(): LayoutMode {
|
||||
return state.mode;
|
||||
}
|
||||
|
||||
export function getGridConfig(): GridConfig {
|
||||
return { enabled: state.mode === 'grid', cellSize: state.cellSize, gap: DEFAULT_GAP };
|
||||
}
|
||||
|
||||
export function getCellSize(): number {
|
||||
return state.cellSize;
|
||||
}
|
||||
|
||||
export function getGap(): number {
|
||||
return DEFAULT_GAP;
|
||||
}
|
||||
|
||||
/** Switch layout mode: persist, restyle the body, re-lay out every panel. */
|
||||
export function setLayoutMode(mode: LayoutMode): void {
|
||||
if (mode !== 'grid' && mode !== 'free') return;
|
||||
if (mode === state.mode) {
|
||||
applyLayout();
|
||||
return;
|
||||
}
|
||||
const g = grid();
|
||||
// Entering free mode: snapshot the current GRID geometry BEFORE the body flips
|
||||
// to a block container. Otherwise the still-relative panels collapse to block
|
||||
// flow and we'd freeze the wrong positions (the map balloons to full-viewport).
|
||||
const frozen = mode === 'free' && g ? freezeAll(g) : null;
|
||||
state = { ...state, mode };
|
||||
writeState(state);
|
||||
applyBodyMode();
|
||||
applyCellVar();
|
||||
if (g) {
|
||||
for (const el of panelsIn(g)) applyPanelInMode(g, el, frozen?.get(el));
|
||||
updateContainerHeight(g);
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent(LAYOUT_MODE_EVENT, { detail: { mode } }));
|
||||
}
|
||||
|
||||
export function toggleLayoutMode(): LayoutMode {
|
||||
const next: LayoutMode = state.mode === 'grid' ? 'free' : 'grid';
|
||||
setLayoutMode(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Set the grid cell size (px). Grid mode re-snaps as tracks resize. Persisted. */
|
||||
export function setCellSize(px: number): void {
|
||||
const cell = clampCell(px);
|
||||
if (cell === state.cellSize) return;
|
||||
state = { ...state, cellSize: cell };
|
||||
writeState(state);
|
||||
applyCellVar();
|
||||
applyLayout();
|
||||
document.dispatchEvent(new CustomEvent(LAYOUT_CELL_EVENT, { detail: { cellSize: cell } }));
|
||||
}
|
||||
|
||||
/** Clear every layout-engine customization (mode, cell size, free + grid geometry). */
|
||||
export function resetLayout(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
state = { mode: 'grid', cellSize: DEFAULT_CELL_SIZE, free: {}, gridCols: {} };
|
||||
}
|
||||
|
||||
// ── per-panel geometry persistence (used by panel-drag) ─────────────────────
|
||||
|
||||
export function getFreeRect(id: string): FreeRect | undefined {
|
||||
return state.free[id];
|
||||
}
|
||||
|
||||
export function setFreeRect(id: string, rect: FreeRect): void {
|
||||
state = { ...state, free: { ...state.free, [id]: rect } };
|
||||
writeState(state);
|
||||
}
|
||||
|
||||
export function getGridCols(id: string): number | undefined {
|
||||
return state.gridCols[id];
|
||||
}
|
||||
|
||||
export function setGridCols(id: string, cols: number): void {
|
||||
const next = { ...state.gridCols };
|
||||
if (cols > 1) next[id] = cols;
|
||||
else delete next[id];
|
||||
state = { ...state, gridCols: next };
|
||||
writeState(state);
|
||||
}
|
||||
|
||||
// ── DOM application ─────────────────────────────────────────────────────────
|
||||
|
||||
function applyBodyMode(): void {
|
||||
const b = document.body;
|
||||
if (!b) return;
|
||||
b.classList.toggle('layout-free', state.mode === 'free');
|
||||
b.classList.toggle('layout-grid', state.mode === 'grid');
|
||||
}
|
||||
|
||||
function applyCellVar(): void {
|
||||
document.documentElement.style.setProperty(CELL_VAR, `${state.cellSize}px`);
|
||||
}
|
||||
|
||||
/** Current geometry of a panel expressed relative to the grid's padding box. */
|
||||
function measureRect(g: HTMLElement, el: HTMLElement): FreeRect {
|
||||
const gr = g.getBoundingClientRect();
|
||||
const er = el.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.round(er.left - gr.left - g.clientLeft)),
|
||||
y: Math.max(0, Math.round(er.top - gr.top - g.clientTop)),
|
||||
w: Math.round(er.width),
|
||||
h: Math.round(er.height),
|
||||
};
|
||||
}
|
||||
|
||||
function applyFreeRect(el: HTMLElement, rect: FreeRect): void {
|
||||
el.style.position = 'absolute';
|
||||
el.style.left = `${rect.x}px`;
|
||||
el.style.top = `${rect.y}px`;
|
||||
el.style.width = `${rect.w}px`;
|
||||
el.style.height = `${rect.h}px`;
|
||||
}
|
||||
|
||||
function clearFreeInline(el: HTMLElement): void {
|
||||
el.style.position = '';
|
||||
el.style.left = '';
|
||||
el.style.top = '';
|
||||
el.style.width = '';
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the active mode to a single panel. In free mode: place it at its saved
|
||||
* rect, or — on the first-ever switch — freeze it exactly where the grid put it
|
||||
* (measured by the caller and passed in) so the transition never jumps. In grid
|
||||
* mode: strip any free inline geometry and re-apply a saved column span.
|
||||
*/
|
||||
function applyPanelInMode(g: HTMLElement, el: HTMLElement, frozen?: FreeRect): void {
|
||||
const id = el.dataset.panel;
|
||||
if (!id) return;
|
||||
if (state.mode === 'free') {
|
||||
let rect = getFreeRect(id);
|
||||
if (!rect) {
|
||||
rect = frozen ?? measureRect(g, el);
|
||||
setFreeRect(id, rect);
|
||||
}
|
||||
applyFreeRect(el, rect);
|
||||
} else {
|
||||
clearFreeInline(el);
|
||||
// The map owns its own gridColumn (full-width anchor / half-width) in App.
|
||||
if (id !== 'map') {
|
||||
const cols = getGridCols(id);
|
||||
if (cols && cols > 1) el.style.gridColumn = `span ${cols}`;
|
||||
else if (el.style.gridColumn) el.style.gridColumn = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Grow the block container in free mode to contain its absolutely-placed panels. */
|
||||
function updateContainerHeight(g: HTMLElement): void {
|
||||
if (state.mode !== 'free') {
|
||||
g.style.height = '';
|
||||
return;
|
||||
}
|
||||
let bottom = 0;
|
||||
for (const el of panelsIn(g)) {
|
||||
if (el.classList.contains('hidden')) continue;
|
||||
bottom = Math.max(bottom, el.offsetTop + el.offsetHeight);
|
||||
}
|
||||
g.style.height = `${bottom + DEFAULT_GAP}px`;
|
||||
}
|
||||
|
||||
/** Snapshot every panel's current geometry (measured in whatever layout is live). */
|
||||
function freezeAll(g: HTMLElement): Map<HTMLElement, FreeRect> {
|
||||
const m = new Map<HTMLElement, FreeRect>();
|
||||
for (const el of panelsIn(g)) m.set(el, measureRect(g, el));
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Re-apply the current mode to every panel using saved geometry (cell change,
|
||||
* reset, same-mode refresh). Never re-measures — the mode switch owns freezing. */
|
||||
export function applyLayout(): void {
|
||||
const g = grid();
|
||||
if (!g) return;
|
||||
applyCellVar();
|
||||
for (const el of panelsIn(g)) applyPanelInMode(g, el);
|
||||
updateContainerHeight(g);
|
||||
}
|
||||
|
||||
/** Called by panel-drag when a panel mounts, so it self-applies its geometry. */
|
||||
export function registerPanel(el: HTMLElement): void {
|
||||
// el is attached to the grid synchronously after this call; defer one microtask
|
||||
// so measureRect/position see the panel in the DOM.
|
||||
queueMicrotask(() => {
|
||||
const g = grid();
|
||||
if (!g || el.parentElement !== g) return;
|
||||
applyPanelInMode(g, el);
|
||||
scheduleHeight(g);
|
||||
});
|
||||
}
|
||||
|
||||
/** Called by panel-drag after a free drag/resize commits new geometry. */
|
||||
export function commitFreeRect(el: HTMLElement, rect: FreeRect): void {
|
||||
const id = el.dataset.panel;
|
||||
if (!id) return;
|
||||
setFreeRect(id, rect);
|
||||
const g = grid();
|
||||
if (g) scheduleHeight(g);
|
||||
}
|
||||
|
||||
let heightRaf = 0;
|
||||
function scheduleHeight(g: HTMLElement): void {
|
||||
if (heightRaf) return;
|
||||
heightRaf = requestAnimationFrame(() => {
|
||||
heightRaf = 0;
|
||||
updateContainerHeight(g);
|
||||
});
|
||||
}
|
||||
|
||||
// ── snap overlay (grid mode only, transient during drag/resize) ─────────────
|
||||
|
||||
/** Show the faint track overlay. No-op in free mode. */
|
||||
export function showSnapOverlay(): void {
|
||||
if (state.mode !== 'grid') return;
|
||||
const g = grid();
|
||||
if (!g) return;
|
||||
const cs = getComputedStyle(g);
|
||||
const cols = cs.gridTemplateColumns.split(' ').filter(Boolean);
|
||||
const gap = parseFloat(cs.columnGap || cs.gap || '0') || 0;
|
||||
const colW = cols.length ? parseFloat(cols[0]!) || state.cellSize : state.cellSize;
|
||||
const rowGap = parseFloat(cs.rowGap || cs.gap || '0') || 0;
|
||||
const rows = cs.gridAutoRows.split(' ').filter(Boolean);
|
||||
const rowH = rows.length ? parseFloat(rows[0]!) || state.cellSize : state.cellSize;
|
||||
const padLeft = parseFloat(cs.paddingLeft || '0') || 0;
|
||||
const padTop = parseFloat(cs.paddingTop || '0') || 0;
|
||||
g.style.setProperty('--grid-overlay-col', `${colW + gap}px`);
|
||||
g.style.setProperty('--grid-overlay-row', `${rowH + rowGap}px`);
|
||||
g.style.setProperty('--grid-overlay-x', `${padLeft}px`);
|
||||
g.style.setProperty('--grid-overlay-y', `${padTop}px`);
|
||||
document.body.classList.add('layout-snapping');
|
||||
}
|
||||
|
||||
export function hideSnapOverlay(): void {
|
||||
document.body.classList.remove('layout-snapping');
|
||||
}
|
||||
|
||||
// ── bootstrap ───────────────────────────────────────────────────────────────
|
||||
|
||||
function bootstrap(): void {
|
||||
applyBodyMode();
|
||||
applyCellVar();
|
||||
const g = grid();
|
||||
if (g && panelsIn(g).length) applyLayout();
|
||||
}
|
||||
|
||||
// The footer dock's layout controls (App.gridApi) delegate to this global when
|
||||
// present, so the Grid⇄Free toggle + cell-size slider drive the real engine
|
||||
// rather than the dock's minimal CSS-var fallback. One way to do everything.
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as unknown as { worldGrid?: unknown }).worldGrid = {
|
||||
setLayoutMode, getLayoutMode, toggleLayoutMode,
|
||||
setCellSize, getCellSize, getGridConfig, resetLayout,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
|
||||
} else {
|
||||
bootstrap();
|
||||
}
|
||||
|
||||
// Reset from the settings modal button / analyst request also clears our keys,
|
||||
// then App reloads. Both are synchronous, so removeItem lands before navigation.
|
||||
document.addEventListener('click', (e) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t?.closest('#resetLayoutBtn')) resetLayout();
|
||||
});
|
||||
document.addEventListener('panel-reset-layout-request', () => resetLayout());
|
||||
}
|
||||
+399
-31
@@ -3,26 +3,92 @@
|
||||
// One module owns all pointer math and drag/resize visuals; callers own state
|
||||
// (App persists panel order, Panel owns the row-span class ↔ storage mapping).
|
||||
//
|
||||
// Drag: unified Pointer Events (mouse + touch + pen), a small press threshold so
|
||||
// clicks and scrolls are never hijacked, a custom translucent ghost that follows
|
||||
// the pointer, and a live gap that opens where the panel will land — sibling
|
||||
// panels slide into their new positions with a transform-based FLIP animation
|
||||
// (no layout jank, no OS drag image). Escape cancels and restores.
|
||||
// The module is layout-mode aware (see services/grid-config.ts):
|
||||
// • "grid" mode — the original behaviour, untouched. Drag reorders panels with
|
||||
// a custom ghost + FLIP reflow; resize snaps to grid tracks / row-spans. A
|
||||
// faint track overlay is shown for the duration of the gesture.
|
||||
// • "free" mode — the grid is a positioned block and each panel is absolutely
|
||||
// placed. Drag moves the panel directly under the cursor; resize is
|
||||
// pixel-exact from the right edge, bottom edge, or bottom-right corner.
|
||||
// Nothing snaps; the new {x,y,w,h} is persisted via grid-config.
|
||||
//
|
||||
// Resize: the same pointer plumbing drives the bottom handle. Height maps to a
|
||||
// discrete grid row-span on a clean rowPx grid, so the snap points line up with
|
||||
// the cursor instead of the old mismatched thresholds.
|
||||
// Drag (grid): unified Pointer Events (mouse + touch + pen), a small press
|
||||
// threshold so clicks and scrolls are never hijacked, a custom translucent ghost
|
||||
// that follows the pointer, and a live gap that opens where the panel will land.
|
||||
// Escape cancels and restores.
|
||||
//
|
||||
// Resize (grid): the same pointer plumbing drives the handles. Height maps to a
|
||||
// discrete grid row-span on a clean rowPx grid; width maps to a column span on
|
||||
// the live track grid, so the snap points line up with the cursor.
|
||||
|
||||
import {
|
||||
getLayoutMode,
|
||||
registerPanel,
|
||||
commitFreeRect,
|
||||
showSnapOverlay,
|
||||
hideSnapOverlay,
|
||||
} from './grid-config';
|
||||
|
||||
const DRAG_THRESHOLD = 6; // px of pointer travel before a press becomes a drag
|
||||
const FLIP_MS = 180; // sibling reflow duration
|
||||
const DROP_SETTLE_MS = 160; // ghost easing into its final slot on release
|
||||
const FLIP_EASE = 'cubic-bezier(0.2, 0, 0, 1)';
|
||||
|
||||
// Sane pixel floors for free-mode geometry. The map keeps a larger floor so it
|
||||
// never collapses to an unreadable sliver.
|
||||
const FREE_MIN_W = 160;
|
||||
const FREE_MIN_H = 120;
|
||||
const MAP_MIN = 240;
|
||||
|
||||
const minWidthFor = (el: HTMLElement, opt?: number): number =>
|
||||
el.classList.contains('map-panel') ? MAP_MIN : opt ?? FREE_MIN_W;
|
||||
const minHeightFor = (el: HTMLElement, opt?: number): number =>
|
||||
el.classList.contains('map-panel') ? MAP_MIN : opt ?? FREE_MIN_H;
|
||||
|
||||
type ResizeAxis = 'x' | 'y' | 'xy';
|
||||
|
||||
// Free-mode pixel resize, shared by the right / bottom / corner handles. The
|
||||
// panel is top-left anchored (absolute), so only its width/height change.
|
||||
interface FreeResizeState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
startW: number;
|
||||
startH: number;
|
||||
minW: number;
|
||||
minH: number;
|
||||
}
|
||||
|
||||
function applyFreeResize(
|
||||
el: HTMLElement,
|
||||
axis: ResizeAxis,
|
||||
s: FreeResizeState,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): void {
|
||||
if (axis === 'x' || axis === 'xy') {
|
||||
const w = Math.max(s.minW, s.startW + (clientX - s.startX));
|
||||
el.style.width = `${Math.round(w)}px`;
|
||||
}
|
||||
if (axis === 'y' || axis === 'xy') {
|
||||
const h = Math.max(s.minH, s.startH + (clientY - s.startY));
|
||||
el.style.height = `${Math.round(h)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function commitFreeGeometry(el: HTMLElement): void {
|
||||
commitFreeRect(el, {
|
||||
x: el.offsetLeft,
|
||||
y: el.offsetTop,
|
||||
w: el.offsetWidth,
|
||||
h: el.offsetHeight,
|
||||
});
|
||||
}
|
||||
|
||||
const isInteractive = (target: Element | null): boolean =>
|
||||
!!target?.closest('button, a, input, select, textarea, [contenteditable="true"]');
|
||||
|
||||
const isResizeHandle = (target: Element | null): boolean =>
|
||||
!!target?.closest('.panel-resize-handle, .panel-col-resize-handle');
|
||||
!!target?.closest('.panel-resize-handle, .panel-col-resize-handle, .panel-corner-resize-handle');
|
||||
|
||||
/** Panels that participate in reflow — everything in the grid that is a visible panel. */
|
||||
function livePanels(grid: HTMLElement, except?: HTMLElement): HTMLElement[] {
|
||||
@@ -71,6 +137,12 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
|
||||
let lastY = 0;
|
||||
let onKeyDown: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
// Free-mode drag bookkeeping: the panel is already position:absolute, so a drag
|
||||
// just tracks its top-left under the cursor (no ghost, no reflow).
|
||||
let freeGesture = false; // this gesture started in free mode
|
||||
let freeStartLeft = 0;
|
||||
let freeStartTop = 0;
|
||||
|
||||
const clearFlip = (g: HTMLElement) => {
|
||||
for (const p of livePanels(g)) {
|
||||
p.style.transition = '';
|
||||
@@ -176,20 +248,53 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
|
||||
grid = opts.getGrid();
|
||||
if (!grid) return;
|
||||
dragging = true;
|
||||
originalNext = el.nextSibling;
|
||||
ghost = makeGhost();
|
||||
el.classList.add('panel-drag-source');
|
||||
document.body.classList.add('panel-drag-active');
|
||||
onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') cancelDrag();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
|
||||
if (freeGesture) {
|
||||
// Pixel-follow drag: lift the panel above its siblings, no ghost/FLIP.
|
||||
freeStartLeft = el.offsetLeft;
|
||||
freeStartTop = el.offsetTop;
|
||||
el.classList.add('panel-free-dragging');
|
||||
document.body.classList.add('panel-drag-active');
|
||||
return;
|
||||
}
|
||||
|
||||
originalNext = el.nextSibling;
|
||||
ghost = makeGhost();
|
||||
el.classList.add('panel-drag-source');
|
||||
document.body.classList.add('panel-drag-active');
|
||||
showSnapOverlay();
|
||||
};
|
||||
|
||||
// Free-mode: place the panel's top-left under the pointer, clamped into the
|
||||
// grid, then persist. Grid origin is re-read each frame so page scroll during
|
||||
// a drag never introduces drift.
|
||||
const moveFree = (x: number, y: number) => {
|
||||
if (!grid) return;
|
||||
const gr = grid.getBoundingClientRect();
|
||||
const left = Math.max(0, x - offsetX - gr.left - grid.clientLeft);
|
||||
const top = Math.max(0, y - offsetY - gr.top - grid.clientTop);
|
||||
el.style.left = `${Math.round(left)}px`;
|
||||
el.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const commitFree = () => {
|
||||
commitFreeRect(el, {
|
||||
x: el.offsetLeft,
|
||||
y: el.offsetTop,
|
||||
w: el.offsetWidth,
|
||||
h: el.offsetHeight,
|
||||
});
|
||||
};
|
||||
|
||||
const finishVisuals = () => {
|
||||
if (grid) clearFlip(grid);
|
||||
el.classList.remove('panel-drag-source');
|
||||
el.classList.remove('panel-drag-source', 'panel-free-dragging');
|
||||
document.body.classList.remove('panel-drag-active');
|
||||
hideSnapOverlay();
|
||||
if (onKeyDown) {
|
||||
document.removeEventListener('keydown', onKeyDown, true);
|
||||
onKeyDown = null;
|
||||
@@ -220,6 +325,13 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
if (freeGesture) {
|
||||
el.style.left = `${freeStartLeft}px`; // restore original position
|
||||
el.style.top = `${freeStartTop}px`;
|
||||
finishVisuals();
|
||||
releasePointer();
|
||||
return;
|
||||
}
|
||||
if (grid) grid.insertBefore(el, originalNext); // restore original slot
|
||||
finishVisuals();
|
||||
removeGhost();
|
||||
@@ -266,6 +378,10 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = 0;
|
||||
if (!dragging || !grid) return;
|
||||
if (freeGesture) {
|
||||
moveFree(x, y);
|
||||
return;
|
||||
}
|
||||
moveGhost(x, y);
|
||||
const ref = referenceNodeAt(grid, x, y);
|
||||
if (ref !== undefined) reorderTo(grid, ref);
|
||||
@@ -279,7 +395,12 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
if (wasDragging && grid) {
|
||||
if (wasDragging && freeGesture) {
|
||||
moveFree(lastX, lastY);
|
||||
dragging = false;
|
||||
finishVisuals();
|
||||
commitFree();
|
||||
} else if (wasDragging && grid) {
|
||||
const ref = referenceNodeAt(grid, lastX, lastY);
|
||||
if (ref !== undefined) reorderTo(grid, ref);
|
||||
const slot = el.getBoundingClientRect();
|
||||
@@ -313,6 +434,7 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
|
||||
pointerId = e.pointerId;
|
||||
pressing = true;
|
||||
dragging = false;
|
||||
freeGesture = getLayoutMode() === 'free';
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
const rect = el.getBoundingClientRect();
|
||||
@@ -326,6 +448,9 @@ export function attachPanelDrag(el: HTMLElement, opts: PanelDragOptions): () =>
|
||||
|
||||
el.addEventListener('pointerdown', onPointerDown);
|
||||
|
||||
// Self-apply this panel's saved geometry for the active mode once it mounts.
|
||||
registerPanel(el);
|
||||
|
||||
return () => {
|
||||
if (dragging) cancelDrag();
|
||||
else releasePointer();
|
||||
@@ -345,6 +470,8 @@ export interface PanelResizeOptions {
|
||||
* (span-0 = 120px tiny … span-4 = 800px) with a ~100px feel at the small end.
|
||||
*/
|
||||
snapHeights?: number[];
|
||||
/** Pixel floor for free-mode height (default 120). */
|
||||
minH?: number;
|
||||
/** Height at drag start → the panel's current span. */
|
||||
getStartSpan: () => number;
|
||||
/** Live: apply the given span while dragging (Panel owns the span→class mapping). */
|
||||
@@ -355,8 +482,8 @@ export interface PanelResizeOptions {
|
||||
|
||||
/**
|
||||
* Drive a panel's bottom resize handle with the same pointer plumbing as drag.
|
||||
* Height snaps to a discrete row-span on a clean rowPx grid, so snap points line
|
||||
* up with the cursor. Returns a cleanup fn.
|
||||
* In grid mode height snaps to a discrete row-span on a clean rowPx grid; in free
|
||||
* mode it is pixel-exact and persisted via grid-config. Returns a cleanup fn.
|
||||
*/
|
||||
export function attachPanelResize(
|
||||
el: HTMLElement,
|
||||
@@ -373,6 +500,8 @@ export function attachPanelResize(
|
||||
let startY = 0;
|
||||
let startHeight = 0;
|
||||
let lastSpan = minSpan;
|
||||
let freeGesture = false;
|
||||
let free: FreeResizeState | null = null;
|
||||
|
||||
const spanFor = (height: number): number => {
|
||||
if (snapHeights && snapHeights.length > 0) {
|
||||
@@ -395,6 +524,10 @@ export function attachPanelResize(
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!resizing || e.pointerId !== pointerId) return;
|
||||
e.preventDefault();
|
||||
if (freeGesture && free) {
|
||||
applyFreeResize(el, 'y', free, e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
const height = startHeight + (e.clientY - startY);
|
||||
const span = spanFor(height);
|
||||
if (span !== lastSpan) {
|
||||
@@ -420,7 +553,12 @@ export function attachPanelResize(
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
window.removeEventListener('pointercancel', onPointerUp);
|
||||
opts.onCommit(lastSpan);
|
||||
if (freeGesture) {
|
||||
commitFreeGeometry(el);
|
||||
} else {
|
||||
hideSnapOverlay();
|
||||
opts.onCommit(lastSpan);
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
@@ -434,9 +572,23 @@ export function attachPanelResize(
|
||||
e.stopPropagation();
|
||||
pointerId = e.pointerId;
|
||||
resizing = true;
|
||||
freeGesture = getLayoutMode() === 'free';
|
||||
const rect = el.getBoundingClientRect();
|
||||
startY = e.clientY;
|
||||
startHeight = el.getBoundingClientRect().height;
|
||||
startHeight = rect.height;
|
||||
lastSpan = opts.getStartSpan();
|
||||
if (freeGesture) {
|
||||
free = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startW: rect.width,
|
||||
startH: rect.height,
|
||||
minW: minWidthFor(el),
|
||||
minH: minHeightFor(el, opts.minH),
|
||||
};
|
||||
} else {
|
||||
showSnapOverlay();
|
||||
}
|
||||
el.classList.add('resizing');
|
||||
el.dataset.resizing = 'true';
|
||||
handle.classList.add('active');
|
||||
@@ -463,6 +615,8 @@ export interface PanelColResizeOptions {
|
||||
getGrid: () => HTMLElement | null;
|
||||
/** Column span at drag start. */
|
||||
getStartCols: () => number;
|
||||
/** Pixel floor for free-mode width (default 160). */
|
||||
minW?: number;
|
||||
/** Live preview: apply a span of `cols` out of `total` grid columns. */
|
||||
onPreview: (cols: number, total: number) => void;
|
||||
/** Persist the final span (cols out of total). */
|
||||
@@ -470,11 +624,10 @@ export interface PanelColResizeOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal sibling of attachPanelResize: drag a panel's right edge to set how
|
||||
* many grid COLUMNS it spans, snapping to the live `repeat(auto-fill, …)` track
|
||||
* so a panel (the map) can be pulled down to half width and let others flow in
|
||||
* beside it. The module owns pointer math + column snapping; the caller owns the
|
||||
* cols→style mapping and persistence.
|
||||
* Horizontal sibling of attachPanelResize: drag a panel's right edge. In grid
|
||||
* mode it sets how many grid COLUMNS the panel spans, snapping to the live
|
||||
* `repeat(auto-fill, …)` track. In free mode it sets a pixel width, persisted via
|
||||
* grid-config. The module owns pointer math; the caller owns the cols→style map.
|
||||
*/
|
||||
export function attachPanelColResize(
|
||||
el: HTMLElement,
|
||||
@@ -485,10 +638,14 @@ export function attachPanelColResize(
|
||||
let resizing = false;
|
||||
let total = 1;
|
||||
let colStep = 1;
|
||||
let gridLeft = 0;
|
||||
let originLeft = 0; // the panel's own left edge — span is measured from here
|
||||
let lastCols = 1;
|
||||
let freeGesture = false;
|
||||
let free: FreeResizeState | null = null;
|
||||
|
||||
// Read the live column geometry: track count + (track width + gap) as the step.
|
||||
// The span origin is the panel's own left edge, so a mid-grid panel snaps to the
|
||||
// right number of columns (for the col-0 map this equals the grid's left edge).
|
||||
const measure = (grid: HTMLElement): void => {
|
||||
const style = getComputedStyle(grid);
|
||||
const tracks = style.gridTemplateColumns.split(' ').filter(Boolean);
|
||||
@@ -500,18 +657,22 @@ export function attachPanelColResize(
|
||||
const inner = rect.width - padLeft - padRight;
|
||||
const colW = (inner - gap * (total - 1)) / total;
|
||||
colStep = colW + gap;
|
||||
gridLeft = rect.left + padLeft;
|
||||
originLeft = el.getBoundingClientRect().left;
|
||||
};
|
||||
|
||||
const colsFor = (clientX: number): number => {
|
||||
if (colStep <= 0) return total;
|
||||
const cols = Math.round((clientX - gridLeft) / colStep);
|
||||
const cols = Math.round((clientX - originLeft) / colStep);
|
||||
return Math.min(total, Math.max(1, cols));
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!resizing || e.pointerId !== pointerId) return;
|
||||
e.preventDefault();
|
||||
if (freeGesture && free) {
|
||||
applyFreeResize(el, 'x', free, e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
const cols = colsFor(e.clientX);
|
||||
if (cols !== lastCols) {
|
||||
lastCols = cols;
|
||||
@@ -536,7 +697,12 @@ export function attachPanelColResize(
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
window.removeEventListener('pointercancel', onPointerUp);
|
||||
opts.onCommit(lastCols, total);
|
||||
if (freeGesture) {
|
||||
commitFreeGeometry(el);
|
||||
} else {
|
||||
hideSnapOverlay();
|
||||
opts.onCommit(lastCols, total);
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
@@ -547,13 +713,30 @@ export function attachPanelColResize(
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
const grid = opts.getGrid();
|
||||
if (!grid) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
measure(grid);
|
||||
pointerId = e.pointerId;
|
||||
resizing = true;
|
||||
lastCols = opts.getStartCols();
|
||||
freeGesture = getLayoutMode() === 'free';
|
||||
if (freeGesture) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
free = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startW: rect.width,
|
||||
startH: rect.height,
|
||||
minW: minWidthFor(el, opts.minW),
|
||||
minH: minHeightFor(el),
|
||||
};
|
||||
} else {
|
||||
if (!grid) {
|
||||
resizing = false;
|
||||
return;
|
||||
}
|
||||
measure(grid);
|
||||
lastCols = opts.getStartCols();
|
||||
showSnapOverlay();
|
||||
}
|
||||
el.classList.add('resizing-col');
|
||||
el.dataset.resizing = 'true';
|
||||
handle.classList.add('active');
|
||||
@@ -574,3 +757,188 @@ export function attachPanelColResize(
|
||||
handle.removeEventListener('pointerdown', onPointerDown);
|
||||
};
|
||||
}
|
||||
|
||||
export interface PanelCornerResizeOptions {
|
||||
/** Resolve the live grid (grid mode: its tracks drive the column snap). */
|
||||
getGrid: () => HTMLElement | null;
|
||||
/** Row-span at drag start. */
|
||||
getStartSpan: () => number;
|
||||
/** Column-span at drag start. */
|
||||
getStartCols: () => number;
|
||||
/** Per-span target heights (index === span) for the vertical snap. */
|
||||
snapHeights?: number[];
|
||||
minSpan?: number; // default 0
|
||||
maxSpan?: number; // default 4
|
||||
minW?: number; // free-mode width floor (default 160)
|
||||
minH?: number; // free-mode height floor (default 120)
|
||||
/** Grid preview: apply row-span. */
|
||||
onPreviewSpan: (span: number) => void;
|
||||
/** Grid preview: apply column-span (cols out of total). */
|
||||
onPreviewCols: (cols: number, total: number) => void;
|
||||
/** Grid commit: persist row-span. */
|
||||
onCommitSpan: (span: number) => void;
|
||||
/** Grid commit: persist column-span (cols out of total). */
|
||||
onCommitCols: (cols: number, total: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-right corner handle: resizes width AND height at once. In grid mode it
|
||||
* drives both the column-span and row-span snaps (reusing the exact track /
|
||||
* tier math of the edge handles); in free mode it is a pixel resize on both
|
||||
* axes. One gesture, both dimensions.
|
||||
*/
|
||||
export function attachPanelCornerResize(
|
||||
el: HTMLElement,
|
||||
handle: HTMLElement,
|
||||
opts: PanelCornerResizeOptions,
|
||||
): () => void {
|
||||
const minSpan = opts.minSpan ?? 0;
|
||||
const maxSpan = opts.maxSpan ?? 4;
|
||||
const snapHeights = opts.snapHeights;
|
||||
|
||||
let pointerId: number | null = null;
|
||||
let resizing = false;
|
||||
let freeGesture = false;
|
||||
let free: FreeResizeState | null = null;
|
||||
// Grid geometry.
|
||||
let total = 1;
|
||||
let colStep = 1;
|
||||
let originLeft = 0; // the panel's own left edge — span origin
|
||||
let startTop = 0;
|
||||
let lastCols = 1;
|
||||
let lastSpan = minSpan;
|
||||
|
||||
const measure = (grid: HTMLElement): void => {
|
||||
const style = getComputedStyle(grid);
|
||||
const tracks = style.gridTemplateColumns.split(' ').filter(Boolean);
|
||||
total = Math.max(1, tracks.length);
|
||||
const gap = parseFloat(style.columnGap || '0') || 0;
|
||||
const rect = grid.getBoundingClientRect();
|
||||
const padLeft = parseFloat(style.paddingLeft || '0') || 0;
|
||||
const padRight = parseFloat(style.paddingRight || '0') || 0;
|
||||
const inner = rect.width - padLeft - padRight;
|
||||
const colW = (inner - gap * (total - 1)) / total;
|
||||
colStep = colW + gap;
|
||||
originLeft = el.getBoundingClientRect().left;
|
||||
};
|
||||
|
||||
const colsFor = (clientX: number): number => {
|
||||
if (colStep <= 0) return total;
|
||||
return Math.min(total, Math.max(1, Math.round((clientX - originLeft) / colStep)));
|
||||
};
|
||||
|
||||
const spanFor = (height: number): number => {
|
||||
if (snapHeights && snapHeights.length > 0) {
|
||||
let best = minSpan;
|
||||
let bestDist = Infinity;
|
||||
for (let span = minSpan; span <= maxSpan && span < snapHeights.length; span++) {
|
||||
const dist = Math.abs(height - snapHeights[span]!);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = span;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
return Math.min(maxSpan, Math.max(minSpan, Math.round(height / 200)));
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!resizing || e.pointerId !== pointerId) return;
|
||||
e.preventDefault();
|
||||
if (freeGesture && free) {
|
||||
applyFreeResize(el, 'xy', free, e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
const cols = colsFor(e.clientX);
|
||||
if (cols !== lastCols) {
|
||||
lastCols = cols;
|
||||
opts.onPreviewCols(cols, total);
|
||||
}
|
||||
const span = spanFor(e.clientY - startTop);
|
||||
if (span !== lastSpan) {
|
||||
lastSpan = span;
|
||||
opts.onPreviewSpan(span);
|
||||
}
|
||||
};
|
||||
|
||||
const end = () => {
|
||||
if (!resizing) return;
|
||||
resizing = false;
|
||||
el.classList.remove('resizing', 'resizing-col');
|
||||
handle.classList.remove('active');
|
||||
delete el.dataset.resizing;
|
||||
if (pointerId !== null) {
|
||||
try {
|
||||
handle.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
pointerId = null;
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
window.removeEventListener('pointercancel', onPointerUp);
|
||||
if (freeGesture) {
|
||||
commitFreeGeometry(el);
|
||||
} else {
|
||||
hideSnapOverlay();
|
||||
opts.onCommitCols(lastCols, total);
|
||||
opts.onCommitSpan(lastSpan);
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
end();
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
const grid = opts.getGrid();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
pointerId = e.pointerId;
|
||||
resizing = true;
|
||||
freeGesture = getLayoutMode() === 'free';
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (freeGesture) {
|
||||
free = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startW: rect.width,
|
||||
startH: rect.height,
|
||||
minW: minWidthFor(el, opts.minW),
|
||||
minH: minHeightFor(el, opts.minH),
|
||||
};
|
||||
} else {
|
||||
if (!grid) {
|
||||
resizing = false;
|
||||
return;
|
||||
}
|
||||
measure(grid);
|
||||
startTop = rect.top;
|
||||
lastCols = opts.getStartCols();
|
||||
lastSpan = opts.getStartSpan();
|
||||
showSnapOverlay();
|
||||
}
|
||||
el.classList.add('resizing', 'resizing-col');
|
||||
el.dataset.resizing = 'true';
|
||||
handle.classList.add('active');
|
||||
try {
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
window.addEventListener('pointercancel', onPointerUp);
|
||||
};
|
||||
|
||||
handle.addEventListener('pointerdown', onPointerDown);
|
||||
|
||||
return () => {
|
||||
end();
|
||||
handle.removeEventListener('pointerdown', onPointerDown);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16798,3 +16798,125 @@ body.immersive .immersive-collapse-btn { display: inline-flex; }
|
||||
.dock-slider input[type="range"] { width: 64px; }
|
||||
.dock-group { padding-right: 5px; gap: 5px; }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
LAYOUT ENGINE (services/grid-config.ts + services/panel-drag.ts)
|
||||
Free-form pixel positioning + configurable snap-to-grid. APPEND-ONLY section:
|
||||
grid mode above is untouched. Two body states: .layout-grid (default, CSS-grid
|
||||
reflow) and .layout-free (absolute per-panel {x,y,w,h}). .layout-snapping is
|
||||
transient during a grid-mode drag/resize and shows the faint track overlay.
|
||||
Cell size is the base .panels-grid rule's --panel-col-min (default 160px),
|
||||
driven by grid-config / the footer dock — one variable, one way. Wide panels
|
||||
(live-news video, .panel-wide) are never capped: they span up to full width.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Bottom-right corner resize grip (all panels) ────────────────────────── */
|
||||
.panel-corner-resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: nwse-resize;
|
||||
z-index: 160; /* above the bottom (100) + right (150) edge handles */
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
opacity: 0.32;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.panel-corner-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 2px solid var(--text-dim);
|
||||
border-bottom: 2px solid var(--text-dim);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
.panel:hover .panel-corner-resize-handle,
|
||||
.map-section:hover .panel-corner-resize-handle,
|
||||
.panel-corner-resize-handle.active {
|
||||
opacity: 1;
|
||||
}
|
||||
.panel-corner-resize-handle:hover::after,
|
||||
.panel-corner-resize-handle.active::after {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Regular panels now carry the right-edge width grip too (was map-only); keep it
|
||||
quiet until hover, same as the other grips. */
|
||||
.panel:hover .panel-col-resize-handle,
|
||||
.panel-col-resize-handle.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Grid-mode snap overlay (transient, during drag/resize only) ─────────── */
|
||||
body.layout-grid.layout-snapping .panels-grid::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 4; /* above panels, below the fixed drag ghost (10000) */
|
||||
background-image:
|
||||
linear-gradient(to right, var(--border) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--border) 1px, transparent 1px);
|
||||
background-size: var(--grid-overlay-col, 160px) var(--grid-overlay-row, 160px);
|
||||
background-position: var(--grid-overlay-x, 4px) var(--grid-overlay-y, 4px);
|
||||
opacity: 0.35;
|
||||
animation: layoutOverlayFade 0.14s ease-out;
|
||||
}
|
||||
@keyframes layoutOverlayFade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ── Free mode: absolute per-panel positioning ───────────────────────────── */
|
||||
body.layout-free .panels-grid {
|
||||
display: block; /* leave CSS-grid flow; panels are absolutely placed */
|
||||
position: relative; /* positioning context for the absolute panels */
|
||||
/* height is set inline by grid-config to contain the placed panels */
|
||||
}
|
||||
|
||||
/* Geometry (position/left/top/width/height) is applied inline by grid-config.
|
||||
Here we only neutralise the grid-mode height floors so a free panel can be
|
||||
made genuinely small (JS clamps to a sane 160×120 minimum instead). The map
|
||||
keeps its larger 240px floor. */
|
||||
body.layout-free .panel:not(.map-panel) {
|
||||
min-height: 0 !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
/* The panel being dragged in free mode lifts above its neighbours. */
|
||||
.panel.panel-free-dragging,
|
||||
.map-section.panel-free-dragging {
|
||||
z-index: 50;
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* No snap overlay in free mode (guarded in JS too, but belt-and-braces). */
|
||||
body.layout-free .panels-grid::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile is always the single-column flex layout, regardless of a desktop-saved
|
||||
free mode: neutralise the inline absolute geometry so a carried-over mode can
|
||||
never break the phone layout. Matches the mobile breakpoint (768px). */
|
||||
@media (max-width: 768px) {
|
||||
body.layout-free .panels-grid {
|
||||
display: flex !important;
|
||||
position: relative;
|
||||
height: auto !important;
|
||||
}
|
||||
body.layout-free .panel,
|
||||
body.layout-free .map-section {
|
||||
position: static !important;
|
||||
left: auto !important;
|
||||
top: auto !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Layout Engine Harness</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-content"><div id="app"></div></div>
|
||||
<button class="panels-reset-btn" id="resetLayoutBtn" type="button" style="position:fixed;bottom:8px;right:8px;z-index:9999">Reset layout</button>
|
||||
<script type="module" src="/src/e2e/layout-harness.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user