diff --git a/benchmarks/performance.spec.ts b/benchmarks/performance.spec.ts new file mode 100644 index 0000000..44b7163 --- /dev/null +++ b/benchmarks/performance.spec.ts @@ -0,0 +1,300 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ +// ┃ * of the Regular Layout library, distributed under the terms of the * ┃ +// ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test } from "@playwright/test"; +import type { CDPSession } from "@playwright/test"; +import { + setupLayout, + restoreLayout, + dragMouse, + getLayoutBounds, +} from "../tests/helpers/integration.ts"; +import type { Layout, SplitLayout, TabLayout } from "../src/layout/types.ts"; + +interface PerformanceMetrics { + ProcessTime: number; + JSHeapUsedSize: number; + Documents: number; + Frames: number; + LayoutCount: number; + LayoutDuration: number; + RecalcStyleCount: number; + RecalcStyleDuration: number; + ScriptDuration: number; + TaskDuration: number; +} + +interface PerformanceStats { + testName: string; + iterations: number; + durationMs: number; + startMetrics: PerformanceMetrics; + endMetrics: PerformanceMetrics; + deltaMetrics: Partial; + operationsPerSecond: number; +} + +function generateLargeLayout(depth: number, panelsPerLevel: number): Layout { + let panelCounter = 0; + function generatePanel(): TabLayout { + const name = `Panel${panelCounter++}`; + return { + type: "child-panel", + child: [name], + }; + } + + function generateSplit( + currentDepth: number, + orientation: "horizontal" | "vertical", + ): Layout { + if (currentDepth === 0) { + return generatePanel(); + } + + const children: Layout[] = []; + const sizes: number[] = []; + const nextOrientation = + orientation === "horizontal" ? "vertical" : "horizontal"; + + for (let i = 0; i < panelsPerLevel; i++) { + children.push(generateSplit(currentDepth - 1, nextOrientation)); + sizes.push(1 / panelsPerLevel); + } + + return { + type: "split-panel", + orientation, + children, + sizes, + } as SplitLayout; + } + + return generateSplit(depth, "horizontal"); +} + +function countPanels(layout: Layout): number { + if (layout.type === "child-panel") { + return 1; + } + return layout.children.reduce((sum, child) => sum + countPanels(child), 0); +} + +function getPanelNames(layout: Layout): string[] { + if (layout.type === "child-panel") { + return layout.child; + } + return layout.children.flatMap((child) => getPanelNames(child)); +} + +async function getMetrics(cdp: CDPSession): Promise { + const { metrics } = await cdp.send("Performance.getMetrics"); + const result: Record = {}; + for (const metric of metrics) { + result[metric.name] = metric.value; + } + return result as unknown as PerformanceMetrics; +} + +function calculateDelta( + start: PerformanceMetrics, + end: PerformanceMetrics, +): Partial { + return { + ProcessTime: end.ProcessTime - start.ProcessTime, + LayoutCount: end.LayoutCount - start.LayoutCount, + LayoutDuration: end.LayoutDuration - start.LayoutDuration, + RecalcStyleCount: end.RecalcStyleCount - start.RecalcStyleCount, + RecalcStyleDuration: end.RecalcStyleDuration - start.RecalcStyleDuration, + ScriptDuration: end.ScriptDuration - start.ScriptDuration, + TaskDuration: end.TaskDuration - start.TaskDuration, + }; +} + +function printStats(stats: PerformanceStats): void { + const d = stats.deltaMetrics; + const scriptMs = ((d.ScriptDuration ?? 0) * 1000).toFixed(1); + const layoutMs = ((d.LayoutDuration ?? 0) * 1000).toFixed(1); + const styleMs = ((d.RecalcStyleDuration ?? 0) * 1000).toFixed(1); + const taskMs = ((d.TaskDuration ?? 0) * 1000).toFixed(1); + console.log( + `\n[${stats.testName}]` + + `\n${stats.iterations} ops in ${stats.durationMs.toFixed(0)}ms ` + + `(${stats.operationsPerSecond.toFixed(1)} ops/s)` + + `\nScript: ${scriptMs}ms` + + `\nLayout: ${layoutMs}ms (${d.LayoutCount}x)` + + `\nStyle: ${styleMs}ms (${d.RecalcStyleCount}x)` + + `\nTask: ${taskMs}ms\n`, + ); +} + +const PERF_CONFIG = { + layoutDepth: 4, + panelsPerLevel: 2, + resizeIterations: 100, + dragDropIterations: 100, + operationDelayMs: 0, +}; + +test.describe("Performance Tests", () => { + test("drag resize performance with large layout", async ({ page }) => { + const largeLayout = generateLargeLayout( + PERF_CONFIG.layoutDepth, + PERF_CONFIG.panelsPerLevel, + ); + + const panelCount = countPanels(largeLayout); + console.log(`\nGenerated layout with ${panelCount} panels`); + await setupLayout(page); + await page.evaluate((panelNames: string[]) => { + const layout = document.querySelector("regular-layout"); + if (!layout) return; + for (const name of panelNames) { + const panel = document.createElement("regular-layout-frame"); + panel.setAttribute("name", name); + panel.textContent = name; + layout.appendChild(panel); + } + }, getPanelNames(largeLayout)); + + await restoreLayout(page, largeLayout); + await page.waitForTimeout(100); + const bounds = await getLayoutBounds(page); + const cdp = await page.context().newCDPSession(page); + await cdp.send("Performance.enable"); + const startMetrics = await getMetrics(cdp); + const startTime = performance.now(); + for (let i = 0; i < PERF_CONFIG.resizeIterations; i++) { + const startX = bounds.x + bounds.width * (0.3 + (i % 5) * 0.1); + const startY = bounds.y + bounds.height * (0.3 + (i % 4) * 0.1); + const deltaX = i % 2 === 0 ? (i % 10) - 5 : 0; + const deltaY = i % 2 === 1 ? (i % 10) - 5 : 0; + await dragMouse( + page, + startX, + startY, + startX + deltaX * 10, + startY + deltaY * 10, + ); + + if (PERF_CONFIG.operationDelayMs > 0) { + await page.waitForTimeout(PERF_CONFIG.operationDelayMs); + } + } + + const endTime = performance.now(); + const endMetrics = await getMetrics(cdp); + const stats: PerformanceStats = { + testName: `Drag Resize (${panelCount} panels)`, + iterations: PERF_CONFIG.resizeIterations, + durationMs: endTime - startTime, + startMetrics, + endMetrics, + deltaMetrics: calculateDelta(startMetrics, endMetrics), + operationsPerSecond: + (PERF_CONFIG.resizeIterations / (endTime - startTime)) * 1000, + }; + + printStats(stats); + await cdp.detach(); + }); + + test("drag-drop move performance with large layout", async ({ page }) => { + const largeLayout = generateLargeLayout( + PERF_CONFIG.layoutDepth, + PERF_CONFIG.panelsPerLevel, + ); + + const panelCount = countPanels(largeLayout); + const panelNames = getPanelNames(largeLayout); + console.log(`\nGenerated layout with ${panelCount} panels`); + await setupLayout(page); + await page.evaluate((names: string[]) => { + const layout = document.querySelector("regular-layout"); + if (!layout) return; + for (const name of names) { + const panel = document.createElement("regular-layout-frame"); + panel.setAttribute("name", name); + panel.textContent = name; + layout.appendChild(panel); + } + }, panelNames); + + await restoreLayout(page, largeLayout); + await page.waitForTimeout(100); + const bounds = await getLayoutBounds(page); + const cdp = await page.context().newCDPSession(page); + await cdp.send("Performance.enable"); + const startMetrics = await getMetrics(cdp); + const startTime = performance.now(); + for (let i = 0; i < PERF_CONFIG.dragDropIterations; i++) { + const sourceX = bounds.x + bounds.width * (0.2 + (i % 6) * 0.1); + const sourceY = bounds.y + bounds.height * (0.2 + (i % 5) * 0.12); + const targetX = bounds.x + bounds.width * (0.8 - (i % 6) * 0.1); + const targetY = bounds.y + bounds.height * (0.8 - (i % 5) * 0.12); + await page.evaluate( + ({ sx, sy, tx, ty }) => { + const layout = document.querySelector("regular-layout"); + if (!layout) return; + const sourcePath = layout.calculateIntersect({ + clientX: sx, + clientY: sy, + }); + + if (!sourcePath) return; + layout.setOverlayState( + { clientX: tx, clientY: ty }, + sourcePath, + "overlay", + "absolute", + ); + + const targetPath = layout.calculateIntersect({ + clientX: tx, + clientY: ty, + }); + + if (!targetPath) { + layout.clearOverlayState(null, sourcePath, "overlay"); + return; + } + + layout.clearOverlayState( + { clientX: tx, clientY: ty }, + sourcePath, + "overlay", + ); + }, + { sx: sourceX, sy: sourceY, tx: targetX, ty: targetY }, + ); + + if (PERF_CONFIG.operationDelayMs > 0) { + await page.waitForTimeout(PERF_CONFIG.operationDelayMs); + } + } + + const endTime = performance.now(); + const endMetrics = await getMetrics(cdp); + const stats: PerformanceStats = { + testName: `Drag-Drop Move (${panelCount} panels)`, + iterations: PERF_CONFIG.dragDropIterations, + durationMs: endTime - startTime, + startMetrics, + endMetrics, + deltaMetrics: calculateDelta(startMetrics, endMetrics), + operationsPerSecond: + (PERF_CONFIG.dragDropIterations / (endTime - startTime)) * 1000, + }; + + printStats(stats); + await cdp.detach(); + }); +}); diff --git a/package.json b/package.json index 8068749..116307e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "browser": "dist/index.js", "main": "dist/index.js", + "types": "dist/index.d.ts", "type": "module", "files": [ "dist/**/*", @@ -20,7 +21,8 @@ "build": "tsx build.ts", "build:watch": "tsx build.ts --watch", "clean": "rm -rf dist", - "test": "playwright test", + "test": "playwright test tests/integration tests/unit", + "test:perf": "playwright test --workers=1 ./benchmarks", "example": "tsx serve.ts", "deploy": "tsx deploy.ts", "lint": "biome lint src tests", diff --git a/playwright.config.ts b/playwright.config.ts index 6c4b498..03f10e5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,7 +12,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ - testDir: "./tests", + testDir: ".", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, diff --git a/src/layout/calculate_edge.ts b/src/layout/calculate_edge.ts index c9678c0..3e79e35 100644 --- a/src/layout/calculate_edge.ts +++ b/src/layout/calculate_edge.ts @@ -87,7 +87,9 @@ export function calculate_edge( (box?.height || 1) * (drop_target.view_window.row_end - drop_target.view_window.row_start); - const use_column = col_distance * col_scale > row_distance * row_scale; + const use_column = + col_scale / 2 - col_distance * col_scale < + row_scale / 2 - row_distance * row_scale; return insert_axis( panel, diff --git a/src/layout/calculate_intersect.ts b/src/layout/calculate_intersect.ts index 3ca5163..6beaccb 100644 --- a/src/layout/calculate_intersect.ts +++ b/src/layout/calculate_intersect.ts @@ -80,7 +80,7 @@ function calculate_intersection_recursive( const row_height = view_window.row_end - view_window.row_start; return { type: "layout-path", - layout: undefined, + layout: panel, slot: panel.child[selected], path, view_window, diff --git a/src/layout/generate_overlay.ts b/src/layout/generate_overlay.ts index b16d46d..b9dc03c 100644 --- a/src/layout/generate_overlay.ts +++ b/src/layout/generate_overlay.ts @@ -16,7 +16,7 @@ export function updateOverlaySheet( slot: string, box: DOMRect, style: CSSStyleDeclaration, - drag_target: LayoutPath | null, + drag_target: LayoutPath | null, physics = DEFAULT_PHYSICS, ) { if (!drag_target) { diff --git a/src/layout/types.ts b/src/layout/types.ts index 5d81a13..cfcac7e 100644 --- a/src/layout/types.ts +++ b/src/layout/types.ts @@ -34,7 +34,6 @@ export interface ViewWindow { col_end: number; } - /** * A split panel that divides space among multiple child layouts * . @@ -76,12 +75,8 @@ export interface LayoutDivider { /** * Represents a panel location result from hit detection. - * - * Contains both the panel identifier and its grid position in relative units. - * The generic parameter `T` allows DOM-only properties (e.g. `DOMRect`) to be - * shared in this cross-platform module. */ -export interface LayoutPath { +export interface LayoutPath { type: "layout-path"; slot: string; path: number[]; @@ -92,7 +87,7 @@ export interface LayoutPath { row_offset: number; orientation: Orientation; is_edge: boolean; - layout: T; + layout: Layout; } /** diff --git a/src/regular-layout-frame.ts b/src/regular-layout-frame.ts index b3201c2..fd79be0 100644 --- a/src/regular-layout-frame.ts +++ b/src/regular-layout-frame.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { Layout, LayoutPath } from "./layout/types.ts"; +import type { LayoutPath } from "./layout/types.ts"; import type { RegularLayoutEvent } from "./extensions.ts"; import type { RegularLayout } from "./regular-layout.ts"; import type { RegularLayoutTab } from "./regular-layout-tab.ts"; @@ -29,6 +29,8 @@ const HTML_TEMPLATE = ` `; +type DragState = { moved?: boolean; path: LayoutPath }; + /** * A custom element that represents a draggable panel within a * ``. @@ -55,29 +57,31 @@ const HTML_TEMPLATE = ` * ``` */ export class RegularLayoutFrame extends HTMLElement { - private _shadowRoot: ShadowRoot; - private _container_sheet: CSSStyleSheet; + private _shadowRoot!: ShadowRoot; + private _container_sheet!: CSSStyleSheet; private _layout!: RegularLayout; private _header!: HTMLElement; - private _drag_state: LayoutPath | null = null; - private _drag_moved: boolean = false; + private _drag: DragState | null = null; private _tab_to_index_map: WeakMap = new WeakMap(); - constructor() { - super(); - this._container_sheet = new CSSStyleSheet(); - this._container_sheet.replaceSync(CSS); - this._shadowRoot = this.attachShadow({ mode: "open" }); - this._shadowRoot.adoptedStyleSheets = [this._container_sheet]; - } + /** + * Initializes this elements. Override this method and + * `disconnectedCallback` to modify how this subclass renders the Shadow + * DOM and registers events. + */ connectedCallback() { + this._container_sheet ??= new CSSStyleSheet(); + this._container_sheet.replaceSync(CSS); + this._shadowRoot ??= this.attachShadow({ mode: "open" }); + this._shadowRoot.adoptedStyleSheets = [this._container_sheet]; this._shadowRoot.innerHTML = HTML_TEMPLATE; this._layout = this.parentElement as RegularLayout; this._header = this._shadowRoot.children[0] as HTMLElement; this._header.addEventListener("pointerdown", this.onPointerDown); - this._header.addEventListener("pointermove", this.onPointerMove); - this._header.addEventListener("pointerup", this.onPointerUp); - this._header.addEventListener("lostpointercapture", this.onPointerLost); + this.addEventListener("pointermove", this.onPointerMove); + this.addEventListener("pointerup", this.onPointerUp); + this.addEventListener("pointercancel", this.onPointerCancel); + this.addEventListener("lostpointercapture", this.onPointerLost); this._layout.addEventListener("regular-layout-update", this.drawTabs); this._layout.addEventListener( "regular-layout-before-update", @@ -85,11 +89,15 @@ export class RegularLayoutFrame extends HTMLElement { ); } + /** + * Destroys this element. + */ disconnectedCallback() { this._header.removeEventListener("pointerdown", this.onPointerDown); - this._header.removeEventListener("pointermove", this.onPointerMove); - this._header.removeEventListener("pointerup", this.onPointerUp); - this._header.removeEventListener("lostpointercapture", this.onPointerLost); + this.removeEventListener("pointermove", this.onPointerMove); + this.removeEventListener("pointerup", this.onPointerUp); + this.removeEventListener("pointercancel", this.onPointerUp); + this.removeEventListener("lostpointercapture", this.onPointerLost); this._layout.removeEventListener("regular-layout-update", this.drawTabs); this._layout.removeEventListener( "regular-layout-before-update", @@ -100,75 +108,52 @@ export class RegularLayoutFrame extends HTMLElement { private onPointerDown = (event: PointerEvent): void => { const elem = event.target as RegularLayoutTab; if (elem.part.contains("tab")) { - this._drag_state = this._layout.calculateIntersect( - event.clientX, - event.clientY, - ); - - if (this._drag_state) { - this._header.setPointerCapture(event.pointerId); + const path = this._layout.calculateIntersect(event); + if (path) { + this._drag = { path }; + this.setPointerCapture(event.pointerId); event.preventDefault(); + } else { + this._drag = null; } } }; private onPointerMove = (event: PointerEvent): void => { - if (this._drag_state) { + if (this._drag) { const physics = this._layout.savePhysics(); - - // Only initiate a drag if the cursor has moved sufficiently. - if (!this._drag_moved) { - const [current_col, current_row, box] = - this._layout.relativeCoordinates(event.clientX, event.clientY); - - const dx = (current_col - this._drag_state.column) * box.width; - const dy = (current_row - this._drag_state.row) * box.height; - if (Math.sqrt(dx * dx + dy * dy) <= physics.MIN_DRAG_DISTANCE) { + if (!this._drag.moved) { + const diff = this._layout.diffCoordinates(event, this._drag.path); + if (diff <= physics.MIN_DRAG_DISTANCE) { return; } } - this._drag_moved = true; - this._layout.setOverlayState( - event.clientX, - event.clientY, - this._drag_state, - physics.OVERLAY_CLASSNAME, - ); + this._drag.moved = true; + this._layout.setOverlayState(event, this._drag.path); } }; private onPointerUp = (event: PointerEvent): void => { - if (this._drag_state && this._drag_moved) { - this._layout.clearOverlayState( - event.clientX, - event.clientY, - this._drag_state, - ); + if (this._drag?.moved) { + this._layout.clearOverlayState(event, this._drag.path); } - - // TODO This may be handled by `onPointerLost`, not sure if this is - // browser-specific behavior ... - this._header.releasePointerCapture(event.pointerId); - this._drag_state = null; - this._drag_moved = false; }; - private onPointerLost = (event: PointerEvent): void => { - if (this._drag_state) { - this._layout.clearOverlayState(-1, -1, this._drag_state); + private onPointerCancel = (_: PointerEvent): void => { + if (this._drag?.moved) { + this._layout.clearOverlayState(null, this._drag.path); } + }; - this._header.releasePointerCapture(event.pointerId); - this._drag_state = null; - this._drag_moved = false; + private onPointerLost = (event: PointerEvent): void => { + this.releasePointerCapture(event.pointerId); + this._drag = null; }; private drawTabs = (event: RegularLayoutEvent) => { - const slot = this.getAttribute( - this._layout.savePhysics().CHILD_ATTRIBUTE_NAME, - ); - + const attr = this._layout.savePhysics().CHILD_ATTRIBUTE_NAME; + const slot = this.getAttribute(attr); if (!slot) { return; } diff --git a/src/regular-layout.ts b/src/regular-layout.ts index 1415baa..97f152a 100644 --- a/src/regular-layout.ts +++ b/src/regular-layout.ts @@ -39,6 +39,16 @@ import { type Physics, } from "./layout/constants.ts"; +/** + * An interface which models the fields of `PointerEvent` that + * `` actually uses, making it easier to sub out with an + * JavaScript object lieral when you don't have a `PointerEvent` handy. + */ +export interface PointerEventCoordinates { + clientX: number; + clientY: number; +} + /** * A Web Component that provides a resizable panel layout system. * Panels are arranged using CSS Grid and can be resized by dragging dividers. @@ -71,6 +81,22 @@ import { * layout.restore(state); * ``` * + * @remarks + * + * Why does this implementation use a `` at all? We must use + * `` and the Shadow DOM to scope the grid CSS rules to each + * instance of `` (without e.g. giving them unique + * `"id"` and injecting into `document,head`), and we can only select + * `::slotted` light DOM children from `adoptedStyleSheets` on the + * `ShadowRoot`. + * + * Why does this implementation use a single `` and the child + * `"name"` attribute, as opposed to a named `` + * and the built-in `"slot"` child attribute? Children with a `"slot"` + * attribute don't fallback to the un-named ``, so using the + * latter implementation would require synchronizing the light DOM + * and shadow DOM slots/slotted children continuously. + * */ export class RegularLayout extends HTMLElement { private _shadowRoot: ShadowRoot; @@ -86,17 +112,6 @@ export class RegularLayout extends HTMLElement { super(); this._physics = DEFAULT_PHYSICS; this._panel = structuredClone(EMPTY_PANEL); - - // Why does this implementation use a `` at all? We must use - // `` and the Shadow DOM to scope the grid CSS rules to each - // instance of `` (without e.g. giving them unique - // `"id"` and injecting into `document,head`), and we can only select - // `::slotted` light DOM children from `adoptedStyleSheets` on the - // `ShadowRoot`. - - // In addition, this model uses a single un-named `` to host all - // light-DOM children, and the child's `"name"` attribute to identify - // its position in the `Layout`. Alternatively, using named this._shadowRoot = this.attachShadow({ mode: "open" }); this._shadowRoot.innerHTML = ``; this._stylesheet = new CSSStyleSheet(); @@ -123,36 +138,23 @@ export class RegularLayout extends HTMLElement { /** * Determines which panel is at a given screen coordinate. * - * @param column - X coordinate in screen pixels. - * @param row - Y coordinate in screen pixels. + * @param coordinates - `PointerEvent`, `MouseEvent`, or just X and Y + * coordinates in screen pixels. * @returns Panel information if a panel is at that position, null otherwise. */ calculateIntersect = ( - x: number, - y: number, - check_dividers: boolean = false, - ): LayoutPath | null => { - const [col, row, rect] = this.relativeCoordinates(x, y, false); - const panel = calculate_intersection( - col, - row, - this._panel, - check_dividers ? { rect, size: this._physics.GRID_DIVIDER_SIZE } : null, - ); - - if (panel?.type === "layout-path") { - return { ...panel, layout: this.save() }; - } - - return null; + coordinates: PointerEventCoordinates, + ): LayoutPath | null => { + const [col, row, _] = this.relativeCoordinates(coordinates, false); + return calculate_intersection(col, row, this._panel); }; /** * Sets the visual overlay state during drag-and-drop operations. * Displays a preview of where a panel would be placed at the given coordinates. * - * @param x - X coordinate in screen pixels. - * @param y - Y coordinate in screen pixels. + * @param event - `PointerEvent`, `MouseEvent`, or just X and Y + * coordinates in screen pixels. * @param dragTarget - A `LayoutPath` (presumably from `calculateIntersect`) * which points to the drag element in the current layout. * @param className - The CSS class name to use for the overlay panel @@ -162,18 +164,20 @@ export class RegularLayout extends HTMLElement { * "absolute". */ setOverlayState = ( - x: number, - y: number, - { slot }: LayoutPath, + event: PointerEventCoordinates, + { slot }: LayoutPath, className: string = this._physics.OVERLAY_CLASSNAME, mode: OverlayMode = this._physics.OVERLAY_DEFAULT, ) => { const panel = remove_child(this._panel, slot); - Array.from(this.children) - .find((x) => x.getAttribute(this._physics.CHILD_ATTRIBUTE_NAME) === slot) - ?.classList.add(className); + const query = `:scope > [${this._physics.CHILD_ATTRIBUTE_NAME}="${slot}"]`; + const drag_element = this.querySelector(query); + if (drag_element) { + drag_element.classList.add(className); + } - const [col, row, box, style] = this.relativeCoordinates(x, y, false); + // TODO: Don't recalculate box (but this currently protects against resize). + const [col, row, box, style] = this.relativeCoordinates(event, true); let drop_target = calculate_intersection(col, row, panel); if (drop_target) { drop_target = calculate_edge( @@ -193,7 +197,6 @@ export class RegularLayout extends HTMLElement { this._stylesheet.replaceSync(css); } else if (mode === "absolute") { const grid_css = create_css_grid_layout(panel, undefined, this._physics); - const overlay_css = updateOverlaySheet( slot, box, @@ -206,15 +209,15 @@ export class RegularLayout extends HTMLElement { } const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-before-update`; - const event = new CustomEvent(event_name, { detail: panel }); - this.dispatchEvent(event); + const custom_event = new CustomEvent(event_name, { detail: panel }); + this.dispatchEvent(custom_event); }; /** * Clears the overlay state and commits the panel placement. * - * @param x - X coordinate in screen pixels. - * @param y - Y coordinate in screen pixels. + * @param event - `PointerEvent`, `MouseEvent`, or just X and Y + * coordinates in screen pixels. * @param dragTarget - A `LayoutPath` (presumably from `calculateIntersect`) * which points to the drag element in the current layout. * @param className - The CSS class name to use for the overlay panel @@ -223,46 +226,53 @@ export class RegularLayout extends HTMLElement { * passed to `setOverlayState`. Defaults to "absolute". */ clearOverlayState = ( - x: number, - y: number, - drag_target: LayoutPath, + event: PointerEventCoordinates | null, + { slot, layout }: LayoutPath, className: string = this._physics.OVERLAY_CLASSNAME, ) => { let panel = this._panel; - panel = remove_child(panel, drag_target.slot); - Array.from(this.children) - .find( - (x) => - x.getAttribute(this._physics.CHILD_ATTRIBUTE_NAME) === - drag_target.slot, - ) - ?.classList.remove(className); - - const [col, row, box] = this.relativeCoordinates(x, y, false); + panel = remove_child(panel, slot); + const query = `:scope > [${this._physics.CHILD_ATTRIBUTE_NAME}="${slot}"]`; + const drag_element = this.querySelector(query); + if (drag_element) { + drag_element.classList.remove(className); + } + + if (event === null) { + this.restore(layout); + return; + } + + const [col, row, box] = this.relativeCoordinates(event, false); let drop_target = calculate_intersection(col, row, panel); if (drop_target) { drop_target = calculate_edge( col, row, panel, - drag_target.slot, + slot, drop_target, box, this._physics, ); } - const { path, orientation } = drop_target ? drop_target : drag_target; - const new_layout = drop_target - ? insert_child( - panel, - drag_target.slot, - path, - drop_target?.is_edge ? orientation : undefined, - ) - : drag_target.layout; - - this.restore(new_layout); + if (drop_target) { + const orientation = drop_target?.is_edge + ? drop_target.orientation + : undefined; + + const new_layout = insert_child( + panel, + slot, + drop_target.path, + orientation, + ); + + this.restore(new_layout); + } else { + this.restore(layout); + } }; /** @@ -312,6 +322,7 @@ export class RegularLayout extends HTMLElement { if (layout.child.includes(name)) { return layout; } + return null; } @@ -347,7 +358,6 @@ export class RegularLayout extends HTMLElement { restore = (layout: Layout, _is_flattened: boolean = false) => { this._panel = !_is_flattened ? flatten(layout) : layout; const css = create_css_grid_layout(this._panel, undefined, this._physics); - this._stylesheet.replaceSync(css); const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-update`; const event = new CustomEvent(event_name, { detail: this._panel }); @@ -397,16 +407,15 @@ export class RegularLayout extends HTMLElement { * Transforms absolute pixel positions into normalized coordinates (0-1 range) * relative to the layout's bounding box. * - * @param clientX - X coordinate in screen pixels (client space). - * @param clientY - Y coordinate in screen pixels (client space). + * @param coordinates - `PointerEvent`, `MouseEvent`, or just X and Y + * coordinates in screen pixels. * @returns A tuple containing: * - col: Normalized X coordinate (0 = left edge, 1 = right edge) * - row: Normalized Y coordinate (0 = top edge, 1 = bottom edge) * - box: The layout element's bounding rectangle */ relativeCoordinates = ( - clientX: number, - clientY: number, + event: PointerEventCoordinates, recalculate_bounds: boolean = true, ): [number, number, DOMRect, CSSStyleDeclaration] => { if (recalculate_bounds || !this._dimensions) { @@ -419,12 +428,13 @@ export class RegularLayout extends HTMLElement { const box = this._dimensions.box; const style = this._dimensions.style; const col = - (clientX - box.left - parseFloat(style.paddingLeft)) / + (event.clientX - box.left - parseFloat(style.paddingLeft)) / (box.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)); + const row = - (clientY - box.top - parseFloat(style.paddingTop)) / + (event.clientY - box.top - parseFloat(style.paddingTop)) / (box.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)); @@ -432,17 +442,31 @@ export class RegularLayout extends HTMLElement { return [col, row, box, style]; }; + /** + * Calculates the Euclidean distance in pixels between the current pointer + * coordinates and a drag target's position within the layout. + * + * @param coordinates - The current pointer event coordinates. + * @param drag_target - The layout path representing the drag target + * position. + * @returns The distance in pixels between the coordinates and the drag + * target. + */ + diffCoordinates = ( + event: PointerEventCoordinates, + drag_target: LayoutPath, + ): number => { + const [column, row, box] = this.relativeCoordinates(event, false); + const dx = (column - drag_target.column) * box.width; + const dy = (row - drag_target.row) * box.height; + return Math.sqrt(dx ** 2 + dy ** 2); + }; + private onPointerDown = (event: PointerEvent) => { if (!this._physics.GRID_DIVIDER_CHECK_TARGET || event.target === this) { - const [col, row, rect] = this.relativeCoordinates( - event.clientX, - event.clientY, - ); - - const hit = calculate_intersection(col, row, this._panel, { - rect, - size: this._physics.GRID_DIVIDER_SIZE, - }); + const [col, row, rect] = this.relativeCoordinates(event); + const size = this._physics.GRID_DIVIDER_SIZE; + const hit = calculate_intersection(col, row, this._panel, { rect, size }); if (hit && hit.type !== "layout-path") { this._drag_target = [hit, col, row]; this.setPointerCapture(event.pointerId); @@ -453,12 +477,7 @@ export class RegularLayout extends HTMLElement { private onPointerMove = (event: PointerEvent) => { if (this._drag_target) { - const [col, row] = this.relativeCoordinates( - event.clientX, - event.clientY, - false, - ); - + const [col, row] = this.relativeCoordinates(event, false); const [{ path, type }, old_col, old_row] = this._drag_target; const offset = type === "horizontal" ? old_col - col : old_row - row; const panel = redistribute_panel_sizes(this._panel, path, offset); @@ -476,12 +495,7 @@ export class RegularLayout extends HTMLElement { return; } - const [col, row, rect] = this.relativeCoordinates( - event.clientX, - event.clientY, - false, - ); - + const [col, row, rect] = this.relativeCoordinates(event, false); const divider = calculate_intersection(col, row, this._panel, { rect, size: this._physics.GRID_DIVIDER_SIZE, @@ -502,12 +516,7 @@ export class RegularLayout extends HTMLElement { private onPointerUp = (event: PointerEvent) => { if (this._drag_target) { this.releasePointerCapture(event.pointerId); - const [col, row] = this.relativeCoordinates( - event.clientX, - event.clientY, - false, - ); - + const [col, row] = this.relativeCoordinates(event, false); const [{ path, type }, old_col, old_row] = this._drag_target; const offset = type === "horizontal" ? old_col - col : old_row - row; const panel = redistribute_panel_sizes(this._panel, path, offset); diff --git a/tests/helpers/integration.ts b/tests/helpers/integration.ts index 4e77a9c..04d8515 100644 --- a/tests/helpers/integration.ts +++ b/tests/helpers/integration.ts @@ -94,29 +94,6 @@ export async function removePanel( }, pathOrName); } -/** - * Calls setOverlayState with the given coordinates and options. - */ -export async function setOverlayState( - page: Page, - x: number, - y: number, - slot: string, - className?: string, - mode?: "grid" | "absolute", -): Promise { - await page.evaluate( - ({ x, y, slot, className, mode }) => { - const layout = document.querySelector("regular-layout"); - const layoutPath = layout?.calculateIntersect(x, y); - if (layoutPath) { - layout?.setOverlayState(x, y, { ...layoutPath, slot }, className, mode); - } - }, - { x, y, slot, className, mode }, - ); -} - /** * Gets the layout bounds for testing overlay coordinates. */ diff --git a/tests/integration/overlay-absolute.spec.ts b/tests/integration/overlay-absolute.spec.ts index 4503c7f..9f339b6 100644 --- a/tests/integration/overlay-absolute.spec.ts +++ b/tests/integration/overlay-absolute.spec.ts @@ -25,9 +25,14 @@ test("should apply overlay class to dragged panel in absolute mode", async ({ await page.evaluate( ({ x, y }) => { const layout = document.querySelector("regular-layout"); - const layoutPath = layout?.calculateIntersect(x, y); + const layoutPath = layout?.calculateIntersect({ clientX: x, clientY: y }); if (layoutPath) { - layout?.setOverlayState(x, y, layoutPath, "overlay", "absolute"); + layout?.setOverlayState( + { clientX: x, clientY: y }, + layoutPath, + "overlay", + "absolute", + ); } }, { x, y }, @@ -57,9 +62,18 @@ test("should dispatch regular-layout-update event in absolute mode", async ({ { once: true }, ); - const layoutPath = layout?.calculateIntersect(x, y); + const layoutPath = layout?.calculateIntersect({ + clientX: x, + clientY: y, + }); + if (layoutPath) { - layout?.setOverlayState(x, y, layoutPath, "overlay", "absolute"); + layout?.setOverlayState( + { clientX: x, clientY: y }, + layoutPath, + "overlay", + "absolute", + ); } else { resolve(false); } @@ -81,11 +95,10 @@ test("should handle custom className in absolute mode", async ({ page }) => { await page.evaluate( ({ x, y }) => { const layout = document.querySelector("regular-layout"); - const layoutPath = layout?.calculateIntersect(x, y); + const layoutPath = layout?.calculateIntersect({ clientX: x, clientY: y }); if (layoutPath) { layout?.setOverlayState( - x, - y, + { clientX: x, clientY: y }, layoutPath, "custom-drag-class", "absolute", diff --git a/tests/integration/overlay-grid.spec.ts b/tests/integration/overlay-grid.spec.ts index 748e0e9..5464706 100644 --- a/tests/integration/overlay-grid.spec.ts +++ b/tests/integration/overlay-grid.spec.ts @@ -16,16 +16,19 @@ import { LAYOUTS } from "../helpers/fixtures.ts"; test("should update CSS with grid preview in grid mode", async ({ page }) => { await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); const bounds = await getLayoutBounds(page); - const x = bounds.x + bounds.width * 0.75; const y = bounds.y + bounds.height * 0.5; - await page.evaluate( ({ x, y }) => { const layout = document.querySelector("regular-layout"); - const layoutPath = layout?.calculateIntersect(x, y); + const layoutPath = layout?.calculateIntersect({ clientX: x, clientY: y }); if (layoutPath) { - layout?.setOverlayState(x, y, layoutPath, "overlay", "grid"); + layout?.setOverlayState( + { clientX: x, clientY: y }, + layoutPath, + "overlay", + "grid", + ); } }, { x, y }, @@ -37,7 +40,7 @@ test("should update CSS with grid preview in grid mode", async ({ page }) => { return stylesheet?.cssRules.length || 0; }); - expect(cssRules).toBeGreaterThan(0); + expect(cssRules).toBe(3); }); test("should dispatch regular-layout-update event in grid mode", async ({ @@ -45,10 +48,8 @@ test("should dispatch regular-layout-update event in grid mode", async ({ }) => { await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); const bounds = await getLayoutBounds(page); - const x = bounds.x + bounds.width * 0.25; const y = bounds.y + bounds.height * 0.5; - const eventReceived = await page.evaluate( ({ x, y }) => { return new Promise((resolve) => { @@ -61,9 +62,18 @@ test("should dispatch regular-layout-update event in grid mode", async ({ { once: true }, ); - const layoutPath = layout?.calculateIntersect(x, y); + const layoutPath = layout?.calculateIntersect({ + clientX: x, + clientY: y, + }); + if (layoutPath) { - layout?.setOverlayState(x, y, layoutPath, "overlay", "grid"); + layout?.setOverlayState( + { clientX: x, clientY: y }, + layoutPath, + "overlay", + "grid", + ); } else { resolve(false); } diff --git a/tests/unit/calculate_edge.spec.ts b/tests/unit/calculate_edge.spec.ts index 42222e9..7ec3564 100644 --- a/tests/unit/calculate_edge.spec.ts +++ b/tests/unit/calculate_edge.spec.ts @@ -13,7 +13,7 @@ import { expect, test } from "@playwright/test"; import { LAYOUTS } from "../helpers/fixtures.ts"; import { calculate_edge } from "../../src/layout/calculate_edge.ts"; import { calculate_intersection } from "../../src/layout/calculate_intersect.ts"; -import type { LayoutPath } from "../../src/layout/types.ts"; +import type { Layout, LayoutPath } from "../../src/layout/types.ts"; test("cursor in center of panel - no split", () => { const drop_target = calculate_intersection(0.3, 0.5, LAYOUTS.NESTED_BASIC); @@ -284,7 +284,7 @@ test("cursor in top-left corner prioritizes row offset", () => { type: "layout-path", slot: "AAA", path: [], - layout: undefined, + layout: undefined as unknown as Layout, view_window: { row_start: 0, row_end: 1, col_start: 0, col_end: 1 }, column: 0.1, row: 0.05, @@ -310,7 +310,7 @@ test("cursor in bottom-right corner prioritizes row offset", () => { const drop_target: LayoutPath = { type: "layout-path", slot: "AAA", - layout: undefined, + layout: undefined as unknown as Layout, path: [], view_window: { row_start: 0, row_end: 1, col_start: 0, col_end: 1 }, column: 0.9, @@ -322,16 +322,70 @@ test("cursor in bottom-right corner prioritizes row offset", () => { }; const result = calculate_edge(0.9, 0.95, singlePanel, "BBB", drop_target); - expect(result.is_edge).toBe(true); + expect(result).toStrictEqual({ + column: 0.9, + column_offset: 0.9, + is_edge: true, + layout: undefined, + orientation: "vertical", + path: [1], + row: 0.95, + row_offset: 0.95, + slot: "AAA", + type: "layout-path", + view_window: { + col_end: 1, + col_start: 0, + row_end: 1, + row_start: 0.5, + }, + }); }); -test("cursor near edge with offset exactly at tolerance threshold", () => { +test("cursor in bottom-right corner prioritizes column offset", () => { const singlePanel = LAYOUTS.SINGLE_AAA; const drop_target: LayoutPath = { type: "layout-path", slot: "AAA", + layout: undefined as unknown as Layout, path: [], + view_window: { row_start: 0, row_end: 1, col_start: 0, col_end: 1 }, + column: 0.95, + row: 0.9, + column_offset: 0.95, + row_offset: 0.9, + orientation: "horizontal", + is_edge: false, + }; + + const result = calculate_edge(0.95, 0.9, singlePanel, "BBB", drop_target); + expect(result).toStrictEqual({ + column: 0.95, + column_offset: 0.95, + is_edge: true, layout: undefined, + orientation: "horizontal", + path: [1], + row: 0.9, + row_offset: 0.9, + slot: "AAA", + type: "layout-path", + view_window: { + col_end: 1, + col_start: 0.5, + row_end: 1, + row_start: 0, + }, + }); +}); + +test("cursor near edge with offset exactly at tolerance threshold", () => { + const singlePanel = LAYOUTS.SINGLE_AAA; + const drop_target: LayoutPath = { + type: "layout-path", + slot: "AAA", + path: [], + layout: undefined as unknown as Layout, view_window: { row_start: 0, row_end: 1, col_start: 0, col_end: 1 }, column: 0.3, row: 0.5, @@ -352,7 +406,7 @@ test("cursor near edge with offset just below tolerance threshold", () => { type: "layout-path", slot: "AAA", path: [], - layout: undefined, + layout: undefined as unknown as Layout, view_window: { row_start: 0, row_end: 1, col_start: 0, col_end: 1 }, column: 0.14, row: 0.5, @@ -411,7 +465,7 @@ test("nested panel with vertical orientation at right edge2", () => { expect(result.path).toEqual([0, 1, 1]); }); -test("summertime", () => { +test("arbitrary regression", () => { const drop_target = calculate_intersection(0.8, 0.5, LAYOUTS.NESTED_BASIC); const result = calculate_edge( 0.8, diff --git a/tests/unit/composite.spec.ts b/tests/unit/composite.spec.ts index 7856424..ab2f57e 100644 --- a/tests/unit/composite.spec.ts +++ b/tests/unit/composite.spec.ts @@ -151,7 +151,7 @@ test("cursor near bottom edge but with opposite orientation", () => { }); }); -test("", () => { +test("arbitrary regression also", () => { const PANEL: Layout = { type: "split-panel", orientation: "horizontal", diff --git a/tests/unit/hit_detection.spec.ts b/tests/unit/hit_detection.spec.ts index feb21a5..ba1f487 100644 --- a/tests/unit/hit_detection.spec.ts +++ b/tests/unit/hit_detection.spec.ts @@ -19,7 +19,7 @@ test("AAA", () => { expect(result).toStrictEqual({ slot: "AAA", path: [0, 0], - layout: undefined, + layout: { type: "child-panel", child: ["AAA"] }, type: "layout-path", is_edge: false, orientation: "vertical", @@ -41,7 +41,7 @@ test("BBB", () => { expect(result).toStrictEqual({ slot: "BBB", path: [0, 1], - layout: undefined, + layout: { type: "child-panel", child: ["BBB"] }, type: "layout-path", is_edge: false, orientation: "vertical", @@ -63,7 +63,7 @@ test("CCC", () => { expect(result).toStrictEqual({ slot: "CCC", path: [1], - layout: undefined, + layout: { type: "child-panel", child: ["CCC"] }, type: "layout-path", is_edge: false, orientation: "horizontal", @@ -130,7 +130,7 @@ test("single AAA", () => { } as DOMRect, }); expect(result).toStrictEqual({ - layout: undefined, + layout: { type: "child-panel", child: ["AAA"] }, column: 0.1, column_offset: 0.1, is_edge: false,