diff --git a/package.json b/package.json index 2efc662..10fa9ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "regular-layout", - "version": "0.5.0", + "version": "0.6.0", "description": "A regular CSS `grid` container", "keywords": [], "license": "Apache-2.0", diff --git a/src/core/types.ts b/src/core/types.ts index 2043e6a..a17ba8b 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -100,6 +100,18 @@ export interface LayoutPath { */ export interface PresizeDetail { calculatePresizePaths(): Record; + + /** + * When the transition is a drag preview, the dragged panel's name and the + * {@link LayoutPath} of its preview box. The dragged panel is removed from + * the layout tree during a drag (so it is absent from + * {@link PresizeDetail.calculatePresizePaths}), but `path.view_window` here + * is exactly where the overlay renders it, letting consumers presize the + * dragged panel like any other. `null` when the pointer is outside every + * drop target (the dragged panel is hidden), `undefined` for non-drag + * transitions. + */ + overlay?: { slot: string; path: LayoutPath } | null; } /** diff --git a/src/model/overlay_controller.ts b/src/model/overlay_controller.ts index 89af0e9..dd01c13 100644 --- a/src/model/overlay_controller.ts +++ b/src/model/overlay_controller.ts @@ -70,36 +70,49 @@ export class OverlayController { ); } - await host.presizeQueue.run(panel, () => { - if (mode === "grid" && drop_target) { - const path: [string, string] = [slot, drop_target?.slot]; - const css = create_css_grid_layout(panel, path, host.physics); - host.stylesheet.replaceSync(css); - } else if (mode === "absolute") { - const grid_css = create_css_grid_layout(panel, undefined, host.physics); - - while (drag_element?.tagName === "SLOT" && drag_element) { - drag_element = ( - drag_element as HTMLSlotElement - ).assignedElements()[0]; + // The dragged panel is removed from `panel` above, so it is absent from + // the presize paths - `drop_target` carries its preview box instead + // (null when the pointer is outside every drop target and the dragged + // panel is hidden). + const overlay = drop_target ? { slot, path: drop_target } : null; + await host.presizeQueue.run( + panel, + () => { + if (mode === "grid" && drop_target) { + const path: [string, string] = [slot, drop_target?.slot]; + const css = create_css_grid_layout(panel, path, host.physics); + host.stylesheet.replaceSync(css); + } else if (mode === "absolute") { + const grid_css = create_css_grid_layout( + panel, + undefined, + host.physics, + ); + + while (drag_element?.tagName === "SLOT" && drag_element) { + drag_element = ( + drag_element as HTMLSlotElement + ).assignedElements()[0]; + } + + const margin = drag_element + ? getComputedStyle(drag_element) + : undefined; + + const overlay_css = updateOverlaySheet( + slot, + box, + style, + drop_target, + host.physics, + margin, + ); + + host.stylesheet.replaceSync([grid_css, overlay_css].join("\n")); } - - const margin = drag_element - ? getComputedStyle(drag_element) - : undefined; - - const overlay_css = updateOverlaySheet( - slot, - box, - style, - drop_target, - host.physics, - margin, - ); - - host.stylesheet.replaceSync([grid_css, overlay_css].join("\n")); - } - }); + }, + overlay, + ); const event_name = `${host.physics.CUSTOM_EVENT_NAME_PREFIX}-before-update`; const custom_event = new CustomEvent(event_name, { diff --git a/src/model/presize_queue.ts b/src/model/presize_queue.ts index 2d5e494..e55bcb2 100644 --- a/src/model/presize_queue.ts +++ b/src/model/presize_queue.ts @@ -9,9 +9,15 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { Layout, PresizeDetail } from "../core/types.ts"; +import type { Layout, LayoutPath, PresizeDetail } from "../core/types.ts"; import { calculate_presize_paths } from "../layout/calculate_presize_paths.ts"; +/** + * The dragged panel's preview geometry during a drag transition - see + * {@link PresizeDetail.overlay}. + */ +export type PresizeOverlay = { slot: string; path: LayoutPath } | null; + /** * Manages cancelable pre-resize gating for layout updates. * @@ -22,27 +28,40 @@ import { calculate_presize_paths } from "../layout/calculate_presize_paths.ts"; */ export class PresizeQueue { #resizing = false; - #queued: { layout: Layout; fn: () => void } | null = null; + #queued: { + layout: Layout; + fn: () => void; + overlay?: PresizeOverlay; + } | null = null; #pending: (() => void) | null = null; constructor( private _target: EventTarget, private _eventName: string, + private _onDispatch?: () => void, ) {} - async run(layout: Layout, fn: () => void): Promise { + async run( + layout: Layout, + fn: () => void, + overlay?: PresizeOverlay, + ): Promise { if (this.#resizing) { - this.#queued = { layout, fn }; + this.#queued = { layout, fn, overlay }; return; } this.#resizing = true; try { - await this.#dispatchAndMaybeWait(layout, fn); + await this.#dispatchAndMaybeWait(layout, fn, overlay); while (this.#queued) { - const { layout: nextLayout, fn: nextFn } = this.#queued; + const { + layout: nextLayout, + fn: nextFn, + overlay: nextOverlay, + } = this.#queued; this.#queued = null; - await this.#dispatchAndMaybeWait(nextLayout, nextFn); + await this.#dispatchAndMaybeWait(nextLayout, nextFn, nextOverlay); } } finally { this.#resizing = false; @@ -57,9 +76,15 @@ export class PresizeQueue { } } - async #dispatchAndMaybeWait(layout: Layout, fn: () => void): Promise { + async #dispatchAndMaybeWait( + layout: Layout, + fn: () => void, + overlay?: PresizeOverlay, + ): Promise { + this._onDispatch?.(); const detail: PresizeDetail = { calculatePresizePaths: () => calculate_presize_paths(layout), + overlay, }; const event = new CustomEvent(this._eventName, { diff --git a/src/regular-layout-frame.ts b/src/regular-layout-frame.ts index 005dec6..041f522 100644 --- a/src/regular-layout-frame.ts +++ b/src/regular-layout-frame.ts @@ -27,7 +27,7 @@ const CSS = ` `; const HTML_TEMPLATE = ` -
+
`; diff --git a/src/regular-layout.ts b/src/regular-layout.ts index 932edbf..4129735 100644 --- a/src/regular-layout.ts +++ b/src/regular-layout.ts @@ -130,6 +130,7 @@ export class RegularLayout extends HTMLElement { private _presizeQueue: PresizeQueue; private _overlayController: OverlayController; private _maximized?: string; + private _resize_observer?: ResizeObserver; constructor() { super(); @@ -141,7 +142,13 @@ export class RegularLayout extends HTMLElement { this._cursor_stylesheet = new CSSStyleSheet(); this._cursor_override = false; const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-before-resize`; - this._presizeQueue = new PresizeQueue(this, event_name); + // Refresh the bounds cache once per layout transition, so consumers + // reading coordinates from a `before-resize` handler always see the + // element's current position - a `ResizeObserver` alone can't catch + // position-only moves of the host. + this._presizeQueue = new PresizeQueue(this, event_name, () => { + this._dimensions = undefined; + }); this._overlayController = new OverlayController(this.create_overlay_host()); this._shadowRoot.adoptedStyleSheets = [ this._stylesheet, @@ -150,6 +157,11 @@ export class RegularLayout extends HTMLElement { } connectedCallback() { + this._resize_observer ??= new ResizeObserver(() => { + this._dimensions = undefined; + }); + + this._resize_observer.observe(this); this.addEventListener("dblclick", this.onDblClick); this.addEventListener("pointerdown", this.onPointerDown); this.addEventListener("pointerup", this.onPointerUp); @@ -157,6 +169,7 @@ export class RegularLayout extends HTMLElement { } disconnectedCallback() { + this._resize_observer?.disconnect(); this.removeEventListener("dblclick", this.onDblClick); this.removeEventListener("pointerdown", this.onPointerDown); this.removeEventListener("pointerup", this.onPointerUp); @@ -334,34 +347,54 @@ export class RegularLayout extends HTMLElement { * {@link save}, and any subsequent {@link restore} resets the layout to the * minimized (normal, multi-panel) view. * + * Before applying, dispatches a cancelable `regular-layout-before-resize` + * event (as {@link restore} does) whose paths describe the post-maximize + * geometry - a single full-window entry for `name`. If the event is + * cancelled via `preventDefault()`, the update is suspended until + * {@link resumeResize} is called. + * * Has no effect if `name` is not present in the current layout. * * @param name - The name of the panel to maximize. */ - maximize = (name: string) => { + maximize = async (name: string) => { if (!this.getPanel(name)) { return; } - this._maximized = name; - this._stylesheet.replaceSync( - create_css_maximize_layout(name, this._physics), - ); + // The post-maximize geometry as a `Layout`: `name` alone, full-window. + // Panels hidden by the maximize are absent from the presize paths, + // consistent with `calculate_presize_paths`' visible-panels-only + // semantics. + const layout: Layout = { type: "tab-layout", tabs: [name], selected: 0 }; + await this._presizeQueue.run(layout, () => { + this._maximized = name; + this._stylesheet.replaceSync( + create_css_maximize_layout(name, this._physics), + ); + }); }; /** * Restores the normal multi-panel view after a {@link maximize}, without * altering the layout tree. No-op if no panel is currently maximized. + * + * Before applying, dispatches a cancelable `regular-layout-before-resize` + * event (as {@link restore} does) with paths for the restored multi-panel + * geometry. If the event is cancelled via `preventDefault()`, the update + * is suspended until {@link resumeResize} is called. */ - minimize = () => { + minimize = async () => { if (this._maximized === undefined) { return; } - this._maximized = undefined; - this._stylesheet.replaceSync( - create_css_grid_layout(this._panel, undefined, this._physics), - ); + await this._presizeQueue.run(this._panel, () => { + this._maximized = undefined; + this._stylesheet.replaceSync( + create_css_grid_layout(this._panel, undefined, this._physics), + ); + }); }; /** @@ -371,6 +404,7 @@ export class RegularLayout extends HTMLElement { * @param layout - The layout tree to restore */ restoreSync = (layout: Layout, _is_flattened: boolean = false) => { + this._dimensions = undefined; this._maximized = undefined; this._panel = !_is_flattened ? flatten(layout) : layout; const css = create_css_grid_layout(this._panel, undefined, this._physics); diff --git a/tests/integration/maximize.spec.ts b/tests/integration/maximize.spec.ts index 2c71122..36f53a7 100644 --- a/tests/integration/maximize.spec.ts +++ b/tests/integration/maximize.spec.ts @@ -22,8 +22,8 @@ const panelDisplay = (page: import("@playwright/test").Page, name: string) => test("should maximize a panel, hiding the others", async ({ page }) => { await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); - await page.evaluate(() => { - document.querySelector("regular-layout")?.maximize("AAA"); + await page.evaluate(async () => { + await document.querySelector("regular-layout")?.maximize("AAA"); }); // The maximized panel fills the layout; the other is hidden. @@ -31,13 +31,15 @@ test("should maximize a panel, hiding the others", async ({ page }) => { expect(await panelDisplay(page, "BBB")).toBe("none"); }); -test("should minimize back to the normal multi-panel view", async ({ page }) => { +test("should minimize back to the normal multi-panel view", async ({ + page, +}) => { await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); - await page.evaluate(() => { + await page.evaluate(async () => { const layout = document.querySelector("regular-layout"); - layout?.maximize("AAA"); - layout?.minimize(); + await layout?.maximize("AAA"); + await layout?.minimize(); }); expect(await panelDisplay(page, "AAA")).toBe("flex"); @@ -48,8 +50,8 @@ test("should not persist maximize state in `save`", async ({ page }) => { await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); const before = await saveLayout(page); - await page.evaluate(() => { - document.querySelector("regular-layout")?.maximize("AAA"); + await page.evaluate(async () => { + await document.querySelector("regular-layout")?.maximize("AAA"); }); // `save` ignores transient maximize state - the tree is unchanged. @@ -61,7 +63,7 @@ test("should reset to minimized on `restore`", async ({ page }) => { await page.evaluate(async (layout) => { const element = document.querySelector("regular-layout"); - element?.maximize("AAA"); + await element?.maximize("AAA"); // Restoring (even the identical layout) always returns to minimized. await element?.restore(layout as never); }, LAYOUTS.TWO_HORIZONTAL_EQUAL); @@ -73,11 +75,103 @@ test("should reset to minimized on `restore`", async ({ page }) => { test("should ignore `maximize` for an absent panel", async ({ page }) => { await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); - await page.evaluate(() => { - document.querySelector("regular-layout")?.maximize("ZZZ"); + await page.evaluate(async () => { + await document.querySelector("regular-layout")?.maximize("ZZZ"); }); // Both panels remain visible; the no-op did not hide anything. expect(await panelDisplay(page, "AAA")).toBe("flex"); expect(await panelDisplay(page, "BBB")).toBe("flex"); }); + +test("should fire before-resize on maximize with a single full-window path", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + const paths = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + let presizePaths: Record> | null = null; + layout?.addEventListener( + "regular-layout-before-resize", + (e) => { + presizePaths = (e as CustomEvent).detail.calculatePresizePaths(); + }, + { once: true }, + ); + + await layout?.maximize("AAA"); + return presizePaths; + }); + + // The paths describe the post-maximize geometry: the maximized panel + // alone, full-window; hidden panels are absent. + expect(paths).not.toBeNull(); + // biome-ignore lint/style/noNonNullAssertion: test assertion + expect(Object.keys(paths!)).toStrictEqual(["AAA"]); + // biome-ignore lint/style/noNonNullAssertion: test assertion + expect(paths!.AAA.view_window).toStrictEqual({ + col_start: 0, + col_end: 1, + row_start: 0, + row_end: 1, + }); +}); + +test("should fire before-resize on minimize with the multi-panel paths", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + const paths = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + await layout?.maximize("AAA"); + let presizePaths: Record> | null = null; + layout?.addEventListener( + "regular-layout-before-resize", + (e) => { + presizePaths = (e as CustomEvent).detail.calculatePresizePaths(); + }, + { once: true }, + ); + + await layout?.minimize(); + return presizePaths; + }); + + expect(paths).not.toBeNull(); + // biome-ignore lint/style/noNonNullAssertion: test assertion + expect(Object.keys(paths!).sort()).toStrictEqual(["AAA", "BBB"]); +}); + +test("should suspend maximize until resumeResize when cancelled", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + const result = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + layout?.addEventListener( + "regular-layout-before-resize", + (e) => e.preventDefault(), + { once: true }, + ); + + const display = () => { + const panel = document.querySelector('[name="BBB"]') as HTMLElement; + return getComputedStyle(panel).display; + }; + + const promise = layout?.maximize("AAA"); + await new Promise((r) => setTimeout(r, 50)); + const during = display(); + layout?.resumeResize(); + await promise; + return { during, after: display() }; + }); + + // While suspended the other panel is still visible; the maximize applies + // only after `resumeResize`. + expect(result.during).toBe("flex"); + expect(result.after).toBe("none"); +}); diff --git a/tests/integration/overlay-absolute.spec.ts b/tests/integration/overlay-absolute.spec.ts index cfbfd84..a3ee7ef 100644 --- a/tests/integration/overlay-absolute.spec.ts +++ b/tests/integration/overlay-absolute.spec.ts @@ -66,7 +66,7 @@ test("should dispatch regular-layout-update event in absolute mode", async ({ clientX: x, clientY: y, }); - + if (layoutPath) { layout?.setOverlayState( { clientX: x, clientY: y }, diff --git a/tests/integration/real-coordinates.spec.ts b/tests/integration/real-coordinates.spec.ts index 5dbd117..94f9174 100644 --- a/tests/integration/real-coordinates.spec.ts +++ b/tests/integration/real-coordinates.spec.ts @@ -78,3 +78,67 @@ test("realCoordinates matches getBoundingClientRect for 3 horizontal children", expect(real.height, `${name} height`).toBeCloseTo(actual.height, 0); } }); + +test("realCoordinates refreshes stale bounds on before-resize after the host moves", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + const result = await page.evaluate(async (fixture) => { + const layout = document.querySelector("regular-layout"); + if (!layout) return null; + const full = { col_start: 0, col_end: 1, row_start: 0, row_end: 1 }; + // Prime the bounds cache at the original position. + const before = layout.realCoordinates(full); + + // A position-only move: `transform` changes the client rect without + // resizing the element, so only the before-resize refresh (not the + // ResizeObserver) can catch it. + layout.style.transform = "translateY(100px)"; + + let during: DOMRect | null = null; + layout.addEventListener( + "regular-layout-before-resize", + () => { + during = layout.realCoordinates(full); + }, + { once: true }, + ); + + await layout.restore(fixture as never); + return { beforeTop: before.top, duringTop: during?.top }; + }, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + expect(result).not.toBeNull(); + // The handler reads coordinates in the element's *current* (moved) frame. + // biome-ignore lint/style/noNonNullAssertion: playwright expectation + expect(result!.duringTop).toBeCloseTo(result!.beforeTop + 100, 0); +}); + +test("realCoordinates refreshes stale bounds after the host resizes", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + const result = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + if (!layout) return null; + const full = { col_start: 0, col_end: 1, row_start: 0, row_end: 1 }; + // Prime the bounds cache at the original size. + const before = layout.realCoordinates(full); + + // Shrink the host; the `ResizeObserver` invalidates the cache with no + // layout transition involved. + layout.style.bottom = "150px"; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + + const after = layout.realCoordinates(full); + return { beforeHeight: before.height, afterHeight: after.height }; + }); + + expect(result).not.toBeNull(); + // biome-ignore lint/style/noNonNullAssertion: playwright expectation + expect(result!.beforeHeight - result!.afterHeight).toBeCloseTo(150, 0); +}); diff --git a/tests/integration/resize-before.spec.ts b/tests/integration/resize-before.spec.ts index e9d5691..134bc09 100644 --- a/tests/integration/resize-before.spec.ts +++ b/tests/integration/resize-before.spec.ts @@ -346,6 +346,91 @@ test("should not fire resize-before on restoreSync", async ({ page }) => { }); }); +test("should expose the dragged panel's preview box in detail.overlay", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + const result = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + if (!layout) return null; + const center = (name: string) => { + const panel = document.querySelector(`[name="${name}"]`) as HTMLElement; + const rect = panel.getBoundingClientRect(); + return { + clientX: rect.x + rect.width / 2, + clientY: rect.y + rect.height / 2, + }; + }; + + const path = layout.calculateIntersect(center("AAA")); + if (!path) return null; + let captured: { + overlay: unknown; + paths: string[]; + } | null = null; + + layout.addEventListener( + "regular-layout-before-resize", + (e) => { + const detail = (e as CustomEvent).detail; + captured = { + overlay: detail.overlay, + paths: Object.keys(detail.calculatePresizePaths()), + }; + }, + { once: true }, + ); + + // Drag AAA over BBB - AAA is removed from the transition's layout tree, + // so its preview box arrives via `detail.overlay` instead of the paths. + await layout.setOverlayState(center("BBB"), path); + return captured; + }); + + expect(result).not.toBeNull(); + // biome-ignore lint/style/noNonNullAssertion: playwright expectation + expect(result!.paths).toStrictEqual(["BBB"]); + // biome-ignore lint/style/noNonNullAssertion: playwright expectation + const overlay = result!.overlay as { + slot: string; + path: { view_window: Record }; + }; + + expect(overlay.slot).toBe("AAA"); + expect(overlay.path.view_window).toBeDefined(); + for (const key of ["col_start", "col_end", "row_start", "row_end"]) { + expect(typeof overlay.path.view_window[key]).toBe("number"); + } +}); + +test("should not define detail.overlay for non-drag transitions", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + const overlay = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + let captured: { value: unknown } | null = null; + layout?.addEventListener( + "regular-layout-before-resize", + (e) => { + captured = { value: (e as CustomEvent).detail.overlay }; + }, + { once: true }, + ); + + await layout?.restore({ type: "tab-layout", tabs: ["BBB"] }); + return { + fired: captured !== null, + isUndefined: captured + ? (captured as { value: unknown }).value === undefined + : false, + }; + }); + + expect(overlay.fired).toBe(true); + expect(overlay.isUndefined).toBe(true); +}); + test("should resumeResize be a no-op when no resize is pending", async ({ page, }) => { diff --git a/tests/integration/tabs.spec.ts b/tests/integration/tabs.spec.ts index 72fab8c..cee9ab8 100644 --- a/tests/integration/tabs.spec.ts +++ b/tests/integration/tabs.spec.ts @@ -62,7 +62,11 @@ test("should switch between tabs by clicking", async ({ page }) => { expect(await activeLabel(page, "BBB")).toBe('"BBB"'); expect( await page.evaluate(() => document.querySelector("regular-layout")?.save()), - ).toMatchObject({ type: "tab-layout", tabs: ["AAA", "BBB", "CCC"], selected: 1 }); + ).toMatchObject({ + type: "tab-layout", + tabs: ["AAA", "BBB", "CCC"], + selected: 1, + }); }); test("should move a panel by dragging a selected tab", async ({ page }) => {