From cc97ed14e362378670f60bca45ca529581990266 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Sun, 22 Mar 2026 00:47:26 -0400 Subject: [PATCH] Add pre-size and inverse coordinates calculation APIs --- .gitignore | 1 + package.json | 7 +- pnpm-lock.yaml | 189 +++++++++ src/{layout => core}/constants.ts | 0 src/{layout => core}/types.ts | 7 + src/extensions.ts | 14 +- src/index.ts | 4 +- src/layout/calculate_edge.ts | 4 +- src/layout/calculate_intersect.ts | 7 +- src/layout/calculate_path.ts | 2 +- src/layout/calculate_presize_paths.ts | 93 +++++ src/layout/flatten.ts | 2 +- src/layout/generate_grid.ts | 4 +- src/layout/generate_overlay.ts | 64 +++- src/layout/insert_child.ts | 2 +- src/layout/redistribute_panel_sizes.ts | 4 +- src/layout/remove_child.ts | 4 +- src/model/overlay_controller.ts | 162 ++++++++ src/model/presize_queue.ts | 79 ++++ src/regular-layout-frame.ts | 2 +- src/regular-layout-tab.ts | 2 +- src/regular-layout.ts | 297 ++++++++------- test.ts | 66 ++++ tests/helpers/coverage.ts | 55 +++ tests/helpers/integration.ts | 12 +- tests/integration/adopted-stylesheets.spec.ts | 2 +- tests/integration/calculate-path.spec.ts | 233 ++++++++++++ tests/integration/insert-panel.spec.ts | 30 +- tests/integration/overlay-absolute.spec.ts | 2 +- tests/integration/overlay-grid.spec.ts | 2 +- tests/integration/real-coordinates.spec.ts | 77 ++++ tests/integration/remove-panel.spec.ts | 2 +- tests/integration/resize-before.spec.ts | 360 ++++++++++++++++++ tests/integration/resize.spec.ts | 2 +- tests/integration/save-restore.spec.ts | 2 +- tests/integration/tabs.spec.ts | 14 +- tests/unit/calculate_intersect.spec.ts | 141 +++++++ tests/unit/css_grid_layout.spec.ts | 4 +- tests/unit/css_grid_layout_partial.spec.ts | 4 +- tests/unit/hit_detection.spec.ts | 2 +- themes/lorax.css | 10 +- 41 files changed, 1760 insertions(+), 210 deletions(-) rename src/{layout => core}/constants.ts (100%) rename src/{layout => core}/types.ts (96%) create mode 100644 src/layout/calculate_presize_paths.ts create mode 100644 src/model/overlay_controller.ts create mode 100644 src/model/presize_queue.ts create mode 100644 test.ts create mode 100644 tests/helpers/coverage.ts create mode 100644 tests/integration/calculate-path.spec.ts create mode 100644 tests/integration/real-coordinates.spec.ts create mode 100644 tests/integration/resize-before.spec.ts create mode 100644 tests/unit/calculate_intersect.spec.ts diff --git a/.gitignore b/.gitignore index 436ea8c..8d86c86 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ !/.gitignore !/.github +build dist node_modules playwright-report diff --git a/package.json b/package.json index 2a50c1c..337fcad 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,10 @@ ], "scripts": { "build": "tsx build.ts", - "build:watch": "tsx build.ts --watch", + "watch": "tsx build.ts --watch", "clean": "rm -rf dist", - "test": "playwright test tests/integration tests/unit", - "test:perf": "playwright test --workers=1 ./benchmarks", + "test": "tsx test.ts", + "bench": "playwright test --workers=1 ./benchmarks", "example": "tsx serve.ts", "deploy": "tsx deploy.ts", "lint": "biome lint src tests", @@ -34,6 +34,7 @@ "@playwright/test": "^1.57.0", "@types/node": "^22.10.5", "esbuild": "^0.27.2", + "monocart-coverage-reports": "^2.12.2", "tsx": "^4.21.0", "typescript": "^5.9.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93a25c3..f29548f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: esbuild: specifier: ^0.27.2 version: 0.27.2 + monocart-coverage-reports: + specifier: ^2.12.2 + version: 2.12.9 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -246,11 +249,42 @@ packages: '@types/node@22.19.3': resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} + acorn-loose@8.5.2: + resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==} + engines: {node: '>=0.4.0'} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + console-grid@2.2.3: + resolution: {integrity: sha512-+mecFacaFxGl+1G31IsCx41taUXuW2FxX+4xIE0TIPhgML+Jb9JFcBWGhhWerd1/vhScubdmHqTwOhB0KCUUAg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + eight-colors@1.3.1: + resolution: {integrity: sha512-7nXPYDeKh6DgJDR/mpt2G7N/hCNSGwwoPVmoI3+4TEwOb07VFN1WMPG0DFf6nMEjrkgdj8Og7l7IaEEk3VE6Zg==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -264,6 +298,46 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + lz-utils@2.1.0: + resolution: {integrity: sha512-CMkfimAypidTtWjNDxY8a1bc1mJdyEh04V2FfEQ5Zh8Nx4v7k850EYa+dOWGn9hKG5xOyHP5MkuduAZCTHRvJw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + monocart-coverage-reports@2.12.9: + resolution: {integrity: sha512-vtFqbC3Egl4nVa1FSIrQvMPO6HZtb9lo+3IW7/crdvrLNW2IH8lUsxaK0TsKNmMO2mhFWwqQywLV2CZelqPgwA==} + hasBin: true + + monocart-locator@1.0.2: + resolution: {integrity: sha512-v8W5hJLcWMIxLCcSi/MHh+VeefI+ycFmGz23Froer9QzWjrbg4J3gFJBuI/T1VLNoYxF47bVPPxq8ZlNX4gVCw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + playwright-core@1.57.0: resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} engines: {node: '>=18'} @@ -277,6 +351,27 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -290,6 +385,11 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + snapshots: '@biomejs/biome@2.3.10': @@ -413,6 +513,28 @@ snapshots: dependencies: undici-types: 6.21.0 + acorn-loose@8.5.2: + dependencies: + acorn: 8.16.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + commander@14.0.3: {} + + console-grid@2.2.3: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + eight-colors@1.3.1: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -442,6 +564,11 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fsevents@2.3.2: optional: true @@ -452,6 +579,50 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + lz-utils@2.1.0: {} + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + + monocart-coverage-reports@2.12.9: + dependencies: + acorn: 8.16.0 + acorn-loose: 8.5.2 + acorn-walk: 8.3.5 + commander: 14.0.3 + console-grid: 2.2.3 + eight-colors: 1.3.1 + foreground-child: 3.3.1 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + lz-utils: 2.1.0 + monocart-locator: 1.0.2 + + monocart-locator@1.0.2: {} + + path-key@3.1.1: {} + playwright-core@1.57.0: {} playwright@1.57.0: @@ -462,6 +633,20 @@ snapshots: resolve-pkg-maps@1.0.0: {} + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tsx@4.21.0: dependencies: esbuild: 0.27.2 @@ -472,3 +657,7 @@ snapshots: typescript@5.9.3: {} undici-types@6.21.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 diff --git a/src/layout/constants.ts b/src/core/constants.ts similarity index 100% rename from src/layout/constants.ts rename to src/core/constants.ts diff --git a/src/layout/types.ts b/src/core/types.ts similarity index 96% rename from src/layout/types.ts rename to src/core/types.ts index 76d2be3..b053445 100644 --- a/src/layout/types.ts +++ b/src/core/types.ts @@ -95,6 +95,13 @@ export interface LayoutPath { layout: Layout; } +/** + * The detail payload of the `regular-layout-resize-before` event. + */ +export interface PresizeDetail { + calculatePresizePaths(): Record; +} + /** * An empty `Layout` with no panels. */ diff --git a/src/extensions.ts b/src/extensions.ts index e085a89..ba7532b 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -11,7 +11,7 @@ import { RegularLayout } from "./regular-layout.ts"; import { RegularLayoutFrame } from "./regular-layout-frame.ts"; -import type { Layout } from "./layout/types.ts"; +import type { Layout, PresizeDetail } from "./core/types.ts"; import { RegularLayoutTab } from "./regular-layout-tab.ts"; customElements.define("regular-layout", RegularLayout); @@ -59,6 +59,12 @@ declare global { options?: { signal: AbortSignal }, ): void; + addEventListener( + name: "regular-layout-resize-before", + cb: (e: RegularLayoutPresizeEvent) => void, + options?: { signal: AbortSignal }, + ): void; + removeEventListener( name: "regular-layout-update", cb: (e: RegularLayoutEvent) => void, @@ -68,7 +74,13 @@ declare global { name: "regular-layout-before-update", cb: (e: RegularLayoutEvent) => void, ): void; + + removeEventListener( + name: "regular-layout-resize-before", + cb: (e: RegularLayoutPresizeEvent) => void, + ): void; } } export type RegularLayoutEvent = CustomEvent; +export type RegularLayoutPresizeEvent = CustomEvent; diff --git a/src/index.ts b/src/index.ts index e01ba50..1fbf453 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,10 +59,12 @@ * @packageDocumentation */ -export type * from "./layout/types.ts"; +export type * from "./core/types.ts"; export { RegularLayout } from "./regular-layout.ts"; export { RegularLayoutFrame } from "./regular-layout-frame.ts"; +export type * from "./extensions.ts"; + // Side effects import "./extensions.ts"; diff --git a/src/layout/calculate_edge.ts b/src/layout/calculate_edge.ts index e3fc39b..0f04395 100644 --- a/src/layout/calculate_edge.ts +++ b/src/layout/calculate_edge.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { DEFAULT_PHYSICS, type Physics } from "./constants"; +import { DEFAULT_PHYSICS, type Physics } from "../core/constants"; import { insert_child } from "./insert_child"; import type { Layout, @@ -17,7 +17,7 @@ import type { LayoutPathTraversal, Orientation, ViewWindow, -} from "./types"; +} from "../core/types"; /** * Calculates an insertion point (which may involve splitting a single diff --git a/src/layout/calculate_intersect.ts b/src/layout/calculate_intersect.ts index c17198c..c289197 100644 --- a/src/layout/calculate_intersect.ts +++ b/src/layout/calculate_intersect.ts @@ -15,7 +15,7 @@ import type { Layout, ViewWindow, LayoutPathTraversal, -} from "./types.ts"; +} from "../core/types.ts"; const VIEW_WINDOW = { row_start: 0, @@ -130,7 +130,10 @@ function calculate_intersection_recursive( } // Check if position falls within this child's bounds - if (position >= current_pos && position < next_pos) { + if ( + position >= current_pos && + (position < next_pos || i === panel.children.length - 1) + ) { return calculate_intersection_recursive( column, row, diff --git a/src/layout/calculate_path.ts b/src/layout/calculate_path.ts index 6cbe0e1..54a00b8 100644 --- a/src/layout/calculate_path.ts +++ b/src/layout/calculate_path.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { Layout, LayoutPathTraversal } from "./types.ts"; +import type { Layout, LayoutPathTraversal } from "../core/types.ts"; /** * Calculates the index path for a panel with the given name. diff --git a/src/layout/calculate_presize_paths.ts b/src/layout/calculate_presize_paths.ts new file mode 100644 index 0000000..46da548 --- /dev/null +++ b/src/layout/calculate_presize_paths.ts @@ -0,0 +1,93 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * 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 type { + Layout, + LayoutPath, + Orientation, + ViewWindow, +} from "../core/types.ts"; + +/** + * Walks a layout tree and returns a {@link LayoutPath} for every visible + * panel, keyed by panel name. Each path's `column`/`row` are set to the + * center of the panel's view window, with offsets of 0.5. + * + * @param layout - The layout tree to walk. + * @returns A record mapping panel names to their layout paths. + */ +export function calculate_presize_paths( + layout: Layout, +): Record { + const result: Record = {}; + walk_layout(layout, result, [], null, { + row_start: 0, + row_end: 1, + col_start: 0, + col_end: 1, + }); + + return result; +} + +function walk_layout( + layout: Layout, + result: Record, + path: number[], + parentOrientation: Orientation | null, + viewWindow: ViewWindow, +): void { + if (layout.type === "tab-layout") { + const selected = layout.selected ?? 0; + const slot = layout.tabs[selected]; + const col = (viewWindow.col_start + viewWindow.col_end) / 2; + const row = (viewWindow.row_start + viewWindow.row_end) / 2; + result[slot] = { + type: "layout-path", + layout, + slot, + path, + view_window: viewWindow, + is_edge: false, + column: col, + row: row, + column_offset: 0.5, + row_offset: 0.5, + orientation: parentOrientation || "horizontal", + }; + + return; + } + + const isVertical = layout.orientation === "vertical"; + const startKey = isVertical ? "row_start" : "col_start"; + const endKey = isVertical ? "row_end" : "col_end"; + let currentPos = viewWindow[startKey]; + const totalSize = viewWindow[endKey] - viewWindow[startKey]; + for (let i = 0; i < layout.children.length; i++) { + const nextPos = currentPos + totalSize * layout.sizes[i]; + const childWindow: ViewWindow = { + ...viewWindow, + [startKey]: currentPos, + [endKey]: nextPos, + }; + + walk_layout( + layout.children[i], + result, + [...path, i], + layout.orientation, + childWindow, + ); + + currentPos = nextPos; + } +} diff --git a/src/layout/flatten.ts b/src/layout/flatten.ts index cd390eb..e46c004 100644 --- a/src/layout/flatten.ts +++ b/src/layout/flatten.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { Layout } from "./types.ts"; +import type { Layout } from "../core/types.ts"; /** * Flattens the layout tree by merging parent and child split panels that have diff --git a/src/layout/generate_grid.ts b/src/layout/generate_grid.ts index fb67243..134ff73 100644 --- a/src/layout/generate_grid.ts +++ b/src/layout/generate_grid.ts @@ -9,8 +9,8 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { DEFAULT_PHYSICS, type Physics } from "./constants.ts"; -import type { Layout } from "./types.ts"; +import { DEFAULT_PHYSICS, type Physics } from "../core/constants.ts"; +import type { Layout } from "../core/types.ts"; interface GridCell { child: string; diff --git a/src/layout/generate_overlay.ts b/src/layout/generate_overlay.ts index b9dc03c..65f14ee 100644 --- a/src/layout/generate_overlay.ts +++ b/src/layout/generate_overlay.ts @@ -9,8 +9,46 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { DEFAULT_PHYSICS } from "./constants"; -import type { LayoutPath } from "./types"; +import { DEFAULT_PHYSICS } from "../core/constants"; +import type { LayoutPath, ViewWindow } from "../core/types"; + +/** + * Converts a {@link ViewWindow} to element-relative pixel coordinates, + * accounting for padding and optionally CSS `gap` and child `margin`. + * + * @param window - The view window in normalized 0–1 coordinates. + * @param box - The element's bounding client rect. + * @param style - The element's computed style (for padding). + * @param margin - Optional child element's computed style (for margin inset). + * @returns Pixel coordinates relative to the element's border-box origin. + */ +export function viewWindowToLocalRect( + window: ViewWindow, + box: DOMRect, + style: CSSStyleDeclaration, + margin?: CSSStyleDeclaration, +): { x: number; y: number; width: number; height: number } { + const paddingLeft = parseFloat(style.paddingLeft); + const paddingTop = parseFloat(style.paddingTop); + const contentWidth = box.width - paddingLeft - parseFloat(style.paddingRight); + const contentHeight = + box.height - paddingTop - parseFloat(style.paddingBottom); + + const x = paddingLeft + window.col_start * contentWidth; + const y = paddingTop + window.row_start * contentHeight; + let width = (window.col_end - window.col_start) * contentWidth; + let height = (window.row_end - window.row_start) * contentHeight; + if (margin) { + const marginTop = parseFloat(margin.marginTop); + const marginRight = parseFloat(margin.marginRight); + const marginBottom = parseFloat(margin.marginBottom); + const marginLeft = parseFloat(margin.marginLeft); + width -= marginLeft + marginRight; + height -= marginTop + marginBottom; + } + + return { x, y, width, height }; +} export function updateOverlaySheet( slot: string, @@ -18,25 +56,19 @@ export function updateOverlaySheet( style: CSSStyleDeclaration, drag_target: LayoutPath | null, physics = DEFAULT_PHYSICS, + margin?: CSSStyleDeclaration, ) { if (!drag_target) { return `:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){display:none;}`; } - const { - view_window: { row_start, row_end, col_start, col_end }, - } = drag_target; - - const box_height = - box.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); - - const box_width = - box.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + const local = viewWindowToLocalRect( + drag_target.view_window, + box, + style, + margin, + ); - const top = row_start * box_height + parseFloat(style.paddingTop); - const left = col_start * box_width + parseFloat(style.paddingLeft); - const height = (row_end - row_start) * box_height; - const width = (col_end - col_start) * box_width; - const css = `display:flex;position:absolute!important;z-index:1;top:${top}px;left:${left}px;height:${height}px;width:${width}px;`; + const css = `display:flex;position:absolute!important;z-index:1;top:${local.y}px;left:${local.x}px;height:${local.height}px;width:${local.width}px;`; return `::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){${css}}`; } diff --git a/src/layout/insert_child.ts b/src/layout/insert_child.ts index d009942..b2623d3 100644 --- a/src/layout/insert_child.ts +++ b/src/layout/insert_child.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { Layout, LayoutPathTraversal } from "./types.ts"; +import type { Layout, LayoutPathTraversal } from "../core/types.ts"; /** * Inserts a new child panel into the layout tree at a specified location. diff --git a/src/layout/redistribute_panel_sizes.ts b/src/layout/redistribute_panel_sizes.ts index 4c206e5..1f51b92 100644 --- a/src/layout/redistribute_panel_sizes.ts +++ b/src/layout/redistribute_panel_sizes.ts @@ -9,8 +9,8 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { DEFAULT_PHYSICS } from "./constants.ts"; -import type { Layout, LayoutPathTraversal } from "./types.ts"; +import { DEFAULT_PHYSICS } from "../core/constants.ts"; +import type { Layout, LayoutPathTraversal } from "../core/types.ts"; /** * Adjusts panel sizes during a drag operation on a divider. diff --git a/src/layout/remove_child.ts b/src/layout/remove_child.ts index 4c698ee..38178f6 100644 --- a/src/layout/remove_child.ts +++ b/src/layout/remove_child.ts @@ -9,8 +9,8 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { Layout, TabLayout } from "./types.ts"; -import { EMPTY_PANEL } from "./types.ts"; +import type { Layout, TabLayout } from "../core/types.ts"; +import { EMPTY_PANEL } from "../core/types.ts"; /** * Removes a child panel from the layout tree by its name. diff --git a/src/model/overlay_controller.ts b/src/model/overlay_controller.ts new file mode 100644 index 0000000..340af6f --- /dev/null +++ b/src/model/overlay_controller.ts @@ -0,0 +1,162 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * 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 type { Layout, LayoutPath, OverlayMode } from "../core/types.ts"; +import type { Physics } from "../core/constants.ts"; +import type { PresizeQueue } from "./presize_queue.ts"; +import { calculate_intersection } from "../layout/calculate_intersect.ts"; +import { calculate_edge } from "../layout/calculate_edge.ts"; +import { create_css_grid_layout } from "../layout/generate_grid.ts"; +import { updateOverlaySheet } from "../layout/generate_overlay.ts"; +import { remove_child } from "../layout/remove_child.ts"; +import { insert_child } from "../layout/insert_child.ts"; + +export interface OverlayHost { + readonly panel: Layout; + readonly physics: Physics; + readonly stylesheet: CSSStyleSheet; + readonly presizeQueue: PresizeQueue; + relativeCoordinates( + event: { clientX: number; clientY: number }, + recalculate: boolean, + ): [number, number, DOMRect, CSSStyleDeclaration]; + restore(layout: Layout, isFlattened?: boolean): Promise; + querySelector(selectors: string): Element | null; + dispatchEvent(event: Event): boolean; +} + +/** + * Manages overlay state during drag-and-drop panel rearrangement. + * + * Handles rendering a preview of where a dragged panel would land, + * and committing or cancelling the placement when the drag ends. + */ +export class OverlayController { + constructor(private _host: OverlayHost) {} + + async set( + event: { clientX: number; clientY: number }, + { slot }: LayoutPath, + className: string = this._host.physics.OVERLAY_CLASSNAME, + mode: OverlayMode = this._host.physics.OVERLAY_DEFAULT, + ): Promise { + const host = this._host; + const panel = remove_child(host.panel, slot); + const query = `:scope > [${host.physics.CHILD_ATTRIBUTE_NAME}="${slot}"]`; + let drag_element = host.querySelector(query); + if (drag_element) { + drag_element.classList.add(className); + } + + const [col, row, box, style] = host.relativeCoordinates(event, true); + let drop_target = calculate_intersection(col, row, panel); + console.log(row, col, drop_target); + if (drop_target) { + drop_target = calculate_edge( + col, + row, + panel, + slot, + drop_target, + box, + host.physics, + ); + } + + 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 event_name = `${host.physics.CUSTOM_EVENT_NAME_PREFIX}-before-update`; + const custom_event = new CustomEvent(event_name, { + detail: panel, + }); + host.dispatchEvent(custom_event); + } + + async clear( + event: { clientX: number; clientY: number } | null, + { slot, layout }: LayoutPath, + className: string = this._host.physics.OVERLAY_CLASSNAME, + ): Promise { + const host = this._host; + const panel = remove_child(host.panel, slot); + const query = `:scope > [${host.physics.CHILD_ATTRIBUTE_NAME}="${slot}"]`; + const drag_element = host.querySelector(query); + + if (event === null) { + await host.restore(layout); + return; + } + + const [col, row, box] = host.relativeCoordinates(event, false); + let drop_target = calculate_intersection(col, row, panel); + if (drop_target) { + drop_target = calculate_edge( + col, + row, + panel, + slot, + drop_target, + box, + host.physics, + ); + } + + if (drop_target) { + const orientation = drop_target?.is_edge + ? drop_target.orientation + : undefined; + + const new_layout = insert_child( + panel, + slot, + drop_target.path, + orientation, + ); + + await host.restore(new_layout); + } else { + await host.restore(layout); + } + + if (drag_element) { + drag_element.classList.remove(className); + } + } +} diff --git a/src/model/presize_queue.ts b/src/model/presize_queue.ts new file mode 100644 index 0000000..2d5e494 --- /dev/null +++ b/src/model/presize_queue.ts @@ -0,0 +1,79 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * 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 type { Layout, PresizeDetail } from "../core/types.ts"; +import { calculate_presize_paths } from "../layout/calculate_presize_paths.ts"; + +/** + * Manages cancelable pre-resize gating for layout updates. + * + * Before each layout change, a cancelable `resize-before` event is dispatched + * on the target. If the event is cancelled via `preventDefault()`, the update + * is suspended until {@link resume} is called. Concurrent updates are queued + * and processed sequentially. + */ +export class PresizeQueue { + #resizing = false; + #queued: { layout: Layout; fn: () => void } | null = null; + #pending: (() => void) | null = null; + + constructor( + private _target: EventTarget, + private _eventName: string, + ) {} + + async run(layout: Layout, fn: () => void): Promise { + if (this.#resizing) { + this.#queued = { layout, fn }; + return; + } + + this.#resizing = true; + try { + await this.#dispatchAndMaybeWait(layout, fn); + while (this.#queued) { + const { layout: nextLayout, fn: nextFn } = this.#queued; + this.#queued = null; + await this.#dispatchAndMaybeWait(nextLayout, nextFn); + } + } finally { + this.#resizing = false; + } + } + + resume(): void { + if (this.#pending) { + const resolve = this.#pending; + this.#pending = null; + resolve(); + } + } + + async #dispatchAndMaybeWait(layout: Layout, fn: () => void): Promise { + const detail: PresizeDetail = { + calculatePresizePaths: () => calculate_presize_paths(layout), + }; + + const event = new CustomEvent(this._eventName, { + cancelable: true, + detail, + }); + + const proceed = this._target.dispatchEvent(event); + if (!proceed) { + await new Promise((resolve) => { + this.#pending = resolve; + }); + } + + fn(); + } +} diff --git a/src/regular-layout-frame.ts b/src/regular-layout-frame.ts index a5fc585..e99a9b6 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 { LayoutPath } from "./layout/types.ts"; +import type { LayoutPath } from "./core/types.ts"; import type { RegularLayoutEvent } from "./extensions.ts"; import type { RegularLayout } from "./regular-layout.ts"; import type { RegularLayoutTab } from "./regular-layout-tab.ts"; diff --git a/src/regular-layout-tab.ts b/src/regular-layout-tab.ts index cdee74c..0d60ab8 100644 --- a/src/regular-layout-tab.ts +++ b/src/regular-layout-tab.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { TabLayout } from "./layout/types.ts"; +import type { TabLayout } from "./core/types.ts"; import type { RegularLayout } from "./regular-layout.ts"; /** diff --git a/src/regular-layout.ts b/src/regular-layout.ts index 39275f7..fe95a87 100644 --- a/src/regular-layout.ts +++ b/src/regular-layout.ts @@ -16,7 +16,7 @@ * @packageDocumentation */ -import { EMPTY_PANEL } from "./layout/types.ts"; +import { EMPTY_PANEL } from "./core/types.ts"; import { create_css_grid_layout } from "./layout/generate_grid.ts"; import type { LayoutPath, @@ -26,20 +26,27 @@ import type { OverlayMode, Orientation, LayoutPathTraversal, -} from "./layout/types.ts"; + ViewWindow, +} from "./core/types.ts"; + import { calculate_intersection } from "./layout/calculate_intersect.ts"; import { remove_child } from "./layout/remove_child.ts"; import { insert_child } from "./layout/insert_child.ts"; import { redistribute_panel_sizes } from "./layout/redistribute_panel_sizes.ts"; -import { updateOverlaySheet } from "./layout/generate_overlay.ts"; -import { calculate_edge } from "./layout/calculate_edge.ts"; +import { viewWindowToLocalRect } from "./layout/generate_overlay.ts"; import { flatten } from "./layout/flatten.ts"; import { calculate_path } from "./layout/calculate_path.ts"; +import { PresizeQueue } from "./model/presize_queue.ts"; +import { + OverlayController, + type OverlayHost, +} from "./model/overlay_controller.ts"; + import { DEFAULT_PHYSICS, type PhysicsUpdate, type Physics, -} from "./layout/constants.ts"; +} from "./core/constants.ts"; /** * An interface which models the fields of `PointerEvent` that @@ -109,6 +116,8 @@ export class RegularLayout extends HTMLElement { private _cursor_override: boolean; private _dimensions?: { box: DOMRect; style: CSSStyleDeclaration }; private _physics: Physics; + private _presizeQueue: PresizeQueue; + private _overlayController: OverlayController; constructor() { super(); @@ -119,6 +128,9 @@ export class RegularLayout extends HTMLElement { this._stylesheet = new CSSStyleSheet(); this._cursor_stylesheet = new CSSStyleSheet(); this._cursor_override = false; + const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-resize-before`; + this._presizeQueue = new PresizeQueue(this, event_name); + this._overlayController = new OverlayController(this.create_overlay_host()); this._shadowRoot.adoptedStyleSheets = [ this._stylesheet, this._cursor_stylesheet, @@ -177,54 +189,13 @@ export class RegularLayout extends HTMLElement { * the target, "absolute" positions the panel absolutely. Defaults to * "absolute". */ - setOverlayState = ( + setOverlayState = async ( event: PointerEventCoordinates, - { slot }: LayoutPath, - className: string = this._physics.OVERLAY_CLASSNAME, - mode: OverlayMode = this._physics.OVERLAY_DEFAULT, + target: LayoutPath, + className?: string, + mode?: OverlayMode, ) => { - const panel = remove_child(this._panel, slot); - const query = `:scope > [${this._physics.CHILD_ATTRIBUTE_NAME}="${slot}"]`; - const drag_element = this.querySelector(query); - if (drag_element) { - drag_element.classList.add(className); - } - - // 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( - col, - row, - panel, - slot, - drop_target, - box, - this._physics, - ); - } - - if (mode === "grid" && drop_target) { - const path: [string, string] = [slot, drop_target?.slot]; - const css = create_css_grid_layout(panel, path, this._physics); - 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, - style, - drop_target, - this._physics, - ); - - this._stylesheet.replaceSync([grid_css, overlay_css].join("\n")); - } - - const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-before-update`; - const custom_event = new CustomEvent(event_name, { detail: panel }); - this.dispatchEvent(custom_event); + await this._overlayController.set(event, target, className, mode); }; /** @@ -239,54 +210,12 @@ export class RegularLayout extends HTMLElement { * @param mode - Overlay rendering mode that was used, must match the mode * passed to `setOverlayState`. Defaults to "absolute". */ - clearOverlayState = ( + clearOverlayState = async ( event: PointerEventCoordinates | null, - { slot, layout }: LayoutPath, - className: string = this._physics.OVERLAY_CLASSNAME, + target: LayoutPath, + className?: string, ) => { - let panel = this._panel; - 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, - slot, - drop_target, - box, - this._physics, - ); - } - - 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); - } + await this._overlayController.clear(event, target, className); }; /** @@ -300,7 +229,7 @@ export class RegularLayout extends HTMLElement { * the new `SplitPanel` _if_ there is an option of orientation (e.g. if * the layout had no pre-existing `SplitPanel`) */ - insertPanel = ( + insertPanel = async ( name: string, path: LayoutPathTraversal = [], split?: boolean | Orientation, @@ -312,7 +241,7 @@ export class RegularLayout extends HTMLElement { orientation = split; } - this.restore(insert_child(this._panel, name, path, orientation)); + await this.restore(insert_child(this._panel, name, path, orientation)); }; /** @@ -320,8 +249,8 @@ export class RegularLayout extends HTMLElement { * * @param name - Name of the panel to remove */ - removePanel = (name: string) => { - this.restore(remove_child(this._panel, name)); + removePanel = async (name: string) => { + await this.restore(remove_child(this._panel, name)); }; /** @@ -353,29 +282,61 @@ export class RegularLayout extends HTMLElement { /** * Clears the entire layout, unslotting all panels. */ - clear = () => { - this.restore(EMPTY_PANEL); + clear = async () => { + await this.restore(EMPTY_PANEL); + }; + + /** + * Restores the layout from a saved state synchronously, without + * dispatching the `regular-layout-resize-before` event. + * + * @param layout - The layout tree to restore + */ + restoreSync = (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 }); + this.dispatchEvent(event); }; /** * Restores the layout from a saved state. * + * Before applying, dispatches a cancelable `regular-layout-resize-before` + * event. If the event is cancelled via `preventDefault()`, the layout + * update is suspended until {@link resumeResize} is called. + * * @param layout - The layout tree to restore * * @example * ```typescript * const layout = document.querySelector('regular-layout'); * const savedState = JSON.parse(localStorage.getItem('layout')); - * layout.restore(savedState); + * await layout.restore(savedState); * ``` */ - 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 }); - this.dispatchEvent(event); + restore = async (layout: Layout, _is_flattened: boolean = false) => { + const panel = !_is_flattened ? flatten(layout) : layout; + await this._presizeQueue.run(panel, () => { + this._panel = panel; + 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, + }); + this.dispatchEvent(event); + }); + }; + + /** + * Resumes a layout update that was suspended by cancelling the + * `regular-layout-resize-before` event. + */ + resumeResize = () => { + this._presizeQueue.resume(); }; /** @@ -441,21 +402,53 @@ export class RegularLayout extends HTMLElement { const box = this._dimensions.box; const style = this._dimensions.style; - const col = - (event.clientX - box.left - parseFloat(style.paddingLeft)) / - (box.width - - parseFloat(style.paddingLeft) - - parseFloat(style.paddingRight)); - - const row = - (event.clientY - box.top - parseFloat(style.paddingTop)) / - (box.height - - parseFloat(style.paddingTop) - - parseFloat(style.paddingBottom)); - + const paddingLeft = parseFloat(style.paddingLeft); + const paddingTop = parseFloat(style.paddingTop); + const contentWidth = + box.width - paddingLeft - parseFloat(style.paddingRight); + + const contentHeight = + box.height - paddingTop - parseFloat(style.paddingBottom); + + const localX = event.clientX - box.left - paddingLeft; + const localY = event.clientY - box.top - paddingTop; + const col = Math.max(0, Math.min(1, localX / contentWidth)); + const row = Math.max(0, Math.min(1, localY / contentHeight)); return [col, row, box, style]; }; + /** + * Converts a {@link ViewWindow} (normalized 0–1 coordinates) to a + * `DOMRect` in screen pixels, accounting for padding and optionally + * CSS `gap` and child `margin`. + * + * @param window - The view window to convert. + * @returns A `DOMRect` representing the window in screen coordinates. + */ + realCoordinates = (window: ViewWindow, child?: HTMLElement): DOMRect => { + if (!this._dimensions) { + this._dimensions = { + box: this.getBoundingClientRect(), + style: getComputedStyle(this), + }; + } + + const box = this._dimensions.box; + const style = this._dimensions.style; + let childStyle: CSSStyleDeclaration | undefined; + if (child) { + childStyle = getComputedStyle(child); + } + + const local = viewWindowToLocalRect(window, box, style, childStyle); + return new DOMRect( + box.left + local.x, + box.top + local.y, + local.width, + local.height, + ); + }; + /** * Calculates the Euclidean distance in pixels between the current pointer * coordinates and a drag target's position within the layout. @@ -476,7 +469,47 @@ export class RegularLayout extends HTMLElement { return Math.sqrt(dx ** 2 + dy ** 2); }; - private onDblClick = (event: MouseEvent) => { + // ▇█████████████████■■■■ȺȺȺȺȺ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ȺȺȺȺȺ■■■■■█████████████████ + // ███████▛▀▀█▃▄▄▅▆▆▆▇▇▇██████████████████████████████▇▇▇▆▆▆▅▅▄▃█▀▀▜███████ + // ████▛▚▅▇███▍ ▗▄▃¯"▜██▘ ▜██▍ ▀█▋ ▐█▀▔▂▄▃ ▔▜█ ▗▃▃▃▄▊ ▄▄▃ ▔▜██▇▅▃▀████ + // ███▋╺██████▍ ▐██▋ █▘ ◨Ƚ "██▍ ▙▁ ▀ ▐▋ █▛▀▀▀▜█ ´▂▂Ŋ█▊ °"▔ ▃██████▍▐███ + // ████▄▝▜████▍ ▝▀▀ ▂▟▘ ▄▄▄▄ "█▍ ██▄ ▐█▃ ▝▀▀ ▐█ ▝▀▀▀▀▊ ██▖ ▝████▛▀▄████ + // ██████▇▆▄▃█▀▀Ⱥ▼■███▇▇████▇▇█▇▇████▇████▇▇▇████▇▇▇▇▇▇▇█▇■◘▀▀▀▀▂▃▄▅▇██████ + // ███████████████▇▇▆▆▆▅▅▅▅▅▅▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▅▅▅▅▅▅▆▆▆▇▇███████████████ + // + // + // ┏▅▅▖ ▗▅▅▶ ▅▅▅▅▅▅▅ ▗▅▅▅▅▅▅▖ ▅▅▅▅▅▄▄▂ ▃▅▆▆▆▄▁ ┏▅▅▖ ▅▅▖▅▅▅▅▅▅▅▅▅ + // ┣██▌▗██▛ ██▊▔▔▔▔ ▐██▋▔▔▔` ███▍"▜██▙ ▟██▘▔███▖ ▐██▌ ██▌^▔▔███^▔^ + // ┣██▙██▋ ██▊▂▂▂ ▐██▋▂▂▂ ███▍ ▐██▋ ▐███ ▐██▋ ▐██▌ ██▌ ███▎ + // ┣██████▌ ███▀▀▀ ▐███▀▀▘ ██████▛▀ ▐███ ▐██▋ ▐██▌ ██▌ ███▎ + // ┣██▛ ▜██▖ ██▊ ▐██▋ ███▍ ▝███ ▟██▌ ▐██▌ ██▌ ███▎ + // ┣██▌ ███ ███▆▆▆▆▎▐███▆▆▆▅ ███▍ ▀██▙▅██▛ ▝███▅▆██▘ ███▎ + // ▔▔▔` ´▔▔` ▔▔▔▔▔▔▔ ´▔▔▔▔▔▔▔ ▔▔▔ ▔""▔¯ ▔""^▔ ▔▔▔ + + private create_overlay_host(): OverlayHost { + const self = this; + return { + get panel() { + return self._panel; + }, + get physics() { + return self._physics; + }, + get stylesheet() { + return self._stylesheet; + }, + get presizeQueue() { + return self._presizeQueue; + }, + relativeCoordinates: (event, recalculate) => + self.relativeCoordinates(event, recalculate), + restore: (layout, isFlattened) => self.restore(layout, isFlattened), + querySelector: (selectors) => self.querySelector(selectors), + dispatchEvent: (event) => self.dispatchEvent(event), + }; + } + + private onDblClick = async (event: MouseEvent) => { const [col, row, rect] = this.relativeCoordinates(event, false); const divider = calculate_intersection(col, row, this._panel, { rect, @@ -490,7 +523,7 @@ export class RegularLayout extends HTMLElement { undefined, ); - this.restore(panel, true); + await this.restore(panel, true); } }; @@ -507,15 +540,17 @@ export class RegularLayout extends HTMLElement { } }; - private onPointerMove = (event: PointerEvent) => { + private onPointerMove = async (event: PointerEvent) => { if (this._drag_target) { 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); - this._stylesheet.replaceSync( - create_css_grid_layout(panel, undefined, this._physics), - ); + await this._presizeQueue.run(panel, () => { + this._stylesheet.replaceSync( + create_css_grid_layout(panel, undefined, this._physics), + ); + }); } if (this._physics.GRID_DIVIDER_CHECK_TARGET && event.target !== this) { @@ -545,14 +580,14 @@ export class RegularLayout extends HTMLElement { } }; - private onPointerUp = (event: PointerEvent) => { + private onPointerUp = async (event: PointerEvent) => { if (this._drag_target) { this.releasePointerCapture(event.pointerId); 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); - this.restore(panel, true); + await this.restore(panel, true); this._drag_target = undefined; } }; diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..f141ae9 --- /dev/null +++ b/test.ts @@ -0,0 +1,66 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * 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 * as fs from "node:fs"; +import * as path from "node:path"; +import { CoverageReport } from "monocart-coverage-reports"; +import { execSync } from "node:child_process"; + +const COVERAGE_DIR = path.join(process.cwd(), "build", "coverage"); +const RAW_DIR = path.join(COVERAGE_DIR, "raw"); +const REPORT_DIR = COVERAGE_DIR; + +async function generateReport(): Promise { + if (!fs.existsSync(RAW_DIR)) { + console.error("No coverage data found. Run tests with COVERAGE=1 first."); + process.exit(1); + } + + const rawFiles = fs.readdirSync(RAW_DIR).filter((f) => f.endsWith(".json")); + if (rawFiles.length === 0) { + console.error("No coverage data found in .coverage/raw/"); + process.exit(1); + } + + console.log(`Processing ${rawFiles.length} coverage file(s)...`); + const report = new CoverageReport({ + reports: ["text", "html", "lcovonly"], + outputDir: REPORT_DIR, + sourceFilter: (sourcePath: string) => sourcePath.startsWith("src/"), + }); + + for (const file of rawFiles) { + const entries = JSON.parse( + fs.readFileSync(path.join(RAW_DIR, file), "utf-8"), + ); + + for (const entry of entries) { + const urlPath = new URL(entry.url).pathname; + entry.url = path.join(process.cwd(), urlPath); + } + + await report.add(entries); + } + + await report.generate(); + console.log(`\nCoverage report generated at ${REPORT_DIR}/index.html`); + console.log(`LCOV data written to ${REPORT_DIR}/lcov.info`); +} + +execSync( + `rm -rf build/coverage && COVERAGE=1 playwright test tests/unit tests/integration`, + { stdio: "inherit" }, +); + +generateReport().catch((err) => { + console.error("Failed to generate coverage report:", err); + process.exit(1); +}); diff --git a/tests/helpers/coverage.ts b/tests/helpers/coverage.ts new file mode 100644 index 0000000..1eed331 --- /dev/null +++ b/tests/helpers/coverage.ts @@ -0,0 +1,55 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * 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 as base, expect } from "@playwright/test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as crypto from "node:crypto"; + +const COVERAGE_DIR = path.join(process.cwd(), "build", "coverage", "raw"); + +/** + * Extended Playwright test fixture that collects V8 JS coverage from + * Chromium's CDP coverage API. Coverage entries are written as JSON + * files to `build/coverage/raw/` for later merging and report generation. + * + * Only active when the `COVERAGE` environment variable is set. + */ +export const test = base.extend({ + page: async ({ page }, use) => { + const enabled = !!process.env.COVERAGE; + if (enabled) { + await page.coverage.startJSCoverage({ + resetOnNavigation: false, + }); + } + + await use(page); + + if (enabled) { + const coverage = await page.coverage.stopJSCoverage(); + const relevant = coverage.filter((entry) => + entry.url.includes(".esbuild-serve/"), + ); + + if (relevant.length > 0) { + fs.mkdirSync(COVERAGE_DIR, { recursive: true }); + const id = crypto.randomUUID(); + fs.writeFileSync( + path.join(COVERAGE_DIR, `${id}.json`), + JSON.stringify(relevant), + ); + } + } + }, +}); + +export { expect }; diff --git a/tests/helpers/integration.ts b/tests/helpers/integration.ts index ee3e125..ff2ac9a 100644 --- a/tests/helpers/integration.ts +++ b/tests/helpers/integration.ts @@ -42,9 +42,9 @@ export async function saveLayout(page: Page): Promise { * Restores a layout to the given state. */ export async function restoreLayout(page: Page, state: Layout): Promise { - await page.evaluate((s) => { + await page.evaluate(async (s) => { const layout = document.querySelector("regular-layout"); - layout?.restore(s as Layout); + await layout?.restore(s as Layout); }, state); } @@ -73,9 +73,9 @@ export async function insertPanel( path: LayoutPathTraversal, ): Promise { await page.evaluate( - ({ name, p }) => { + async ({ name, p }) => { const layout = document.querySelector("regular-layout"); - layout?.insertPanel(name, p); + await layout?.insertPanel(name, p); }, { name: panelName, p: path }, ); @@ -88,9 +88,9 @@ export async function removePanel( page: Page, pathOrName: number[] | string, ): Promise { - await page.evaluate((p) => { + await page.evaluate(async (p) => { const layout = document.querySelector("regular-layout"); - layout?.removePanel(p as string); + await layout?.removePanel(p as string); }, pathOrName); } diff --git a/tests/integration/adopted-stylesheets.spec.ts b/tests/integration/adopted-stylesheets.spec.ts index 870c6d9..20c717f 100644 --- a/tests/integration/adopted-stylesheets.spec.ts +++ b/tests/integration/adopted-stylesheets.spec.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../helpers/coverage.ts"; import type { Page } from "@playwright/test"; import { setupLayout, restoreLayout } from "../helpers/integration.ts"; import { LAYOUTS } from "../helpers/fixtures.ts"; diff --git a/tests/integration/calculate-path.spec.ts b/tests/integration/calculate-path.spec.ts new file mode 100644 index 0000000..bdceb46 --- /dev/null +++ b/tests/integration/calculate-path.spec.ts @@ -0,0 +1,233 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * 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 { expect, test } from "../helpers/coverage.ts"; +import { + setupLayout, + insertPanel, + removePanel, +} from "../helpers/integration.ts"; +import { LAYOUTS } from "../helpers/fixtures.ts"; + +test("should return empty path for single panel", async ({ page }) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + const path = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return layout?.calculatePath("AAA"); + }); + expect(path).toStrictEqual([]); +}); + +test("should return null for panel not in layout", async ({ page }) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + const path = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return layout?.calculatePath("ZZZ"); + }); + expect(path).toBeNull(); +}); + +test("should find panels in horizontal split", async ({ page }) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + const paths = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return { + aaa: layout?.calculatePath("AAA"), + bbb: layout?.calculatePath("BBB"), + }; + }); + expect(paths.aaa).toStrictEqual([0]); + expect(paths.bbb).toStrictEqual([1]); +}); + +test("should find panels in vertical split", async ({ page }) => { + await setupLayout(page, LAYOUTS.TWO_VERTICAL); + const paths = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return { + aaa: layout?.calculatePath("AAA"), + bbb: layout?.calculatePath("BBB"), + }; + }); + expect(paths.aaa).toStrictEqual([0]); + expect(paths.bbb).toStrictEqual([1]); +}); + +test("should find panels in nested layout", async ({ page }) => { + await setupLayout(page, LAYOUTS.NESTED_BASIC); + const paths = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return { + aaa: layout?.calculatePath("AAA"), + bbb: layout?.calculatePath("BBB"), + ccc: layout?.calculatePath("CCC"), + }; + }); + expect(paths.aaa).toStrictEqual([0, 0]); + expect(paths.bbb).toStrictEqual([0, 1]); + expect(paths.ccc).toStrictEqual([1]); +}); + +test("should find panels in deeply nested layout", async ({ page }) => { + await setupLayout(page, LAYOUTS.DEEPLY_NESTED); + const paths = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return { + aaa: layout?.calculatePath("AAA"), + bbb: layout?.calculatePath("BBB"), + ccc: layout?.calculatePath("CCC"), + ddd: layout?.calculatePath("DDD"), + }; + }); + expect(paths.aaa).toStrictEqual([0, 0]); + expect(paths.bbb).toStrictEqual([0, 1]); + expect(paths.ccc).toStrictEqual([1, 0]); + expect(paths.ddd).toStrictEqual([1, 1]); +}); + +test("should update paths after inserting a panel", async ({ page }) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + const before = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return { + aaa: layout?.calculatePath("AAA"), + bbb: layout?.calculatePath("BBB"), + ccc: layout?.calculatePath("CCC"), + }; + }); + expect(before.aaa).toStrictEqual([0]); + expect(before.bbb).toStrictEqual([1]); + expect(before.ccc).toBeNull(); + + await insertPanel(page, "CCC", []); + + const after = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return { + aaa: layout?.calculatePath("AAA"), + bbb: layout?.calculatePath("BBB"), + ccc: layout?.calculatePath("CCC"), + }; + }); + expect(after.aaa).toStrictEqual([0]); + expect(after.bbb).toStrictEqual([1]); + // CCC is inserted as a tab alongside the root + expect(after.ccc).not.toBeNull(); +}); + +test("should update paths after removing a panel", async ({ page }) => { + await setupLayout(page, LAYOUTS.THREE_HORIZONTAL); + const before = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return { + aaa: layout?.calculatePath("AAA"), + bbb: layout?.calculatePath("BBB"), + ccc: layout?.calculatePath("CCC"), + }; + }); + expect(before.aaa).toStrictEqual([0]); + expect(before.bbb).toStrictEqual([1]); + expect(before.ccc).toStrictEqual([2]); + + await removePanel(page, "BBB"); + + const after = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return { + aaa: layout?.calculatePath("AAA"), + bbb: layout?.calculatePath("BBB"), + ccc: layout?.calculatePath("CCC"), + }; + }); + expect(after.aaa).toStrictEqual([0]); + expect(after.bbb).toBeNull(); + expect(after.ccc).toStrictEqual([1]); +}); + +test("should find non-selected tab by name", async ({ page }) => { + await setupLayout(page, LAYOUTS.SINGLE_TABS); + const paths = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return { + aaa: layout?.calculatePath("AAA"), + bbb: layout?.calculatePath("BBB"), + ccc: layout?.calculatePath("CCC"), + }; + }); + // All tabs share the same path since they're in the same tab-layout + expect(paths.aaa).toStrictEqual([]); + expect(paths.bbb).toStrictEqual([]); + expect(paths.ccc).toStrictEqual([]); +}); + +test("should return null after clearing layout", async ({ page }) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + const before = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return layout?.calculatePath("AAA"); + }); + expect(before).toStrictEqual([0]); + + await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + await layout?.clear(); + }); + + const after = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return layout?.calculatePath("AAA"); + }); + expect(after).toBeNull(); +}); + +test("should reflect paths after restore to different layout", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + const path1 = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return layout?.calculatePath("AAA"); + }); + expect(path1).toStrictEqual([]); + + await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + await layout?.restore({ + type: "split-layout", + orientation: "horizontal", + children: [ + { type: "tab-layout", tabs: ["BBB"] }, + { + type: "split-layout", + orientation: "vertical", + children: [ + { type: "tab-layout", tabs: ["AAA"] }, + { type: "tab-layout", tabs: ["CCC"] }, + ], + sizes: [0.5, 0.5], + }, + ], + sizes: [0.4, 0.6], + }); + }); + + const paths = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + return { + aaa: layout?.calculatePath("AAA"), + bbb: layout?.calculatePath("BBB"), + ccc: layout?.calculatePath("CCC"), + }; + }); + expect(paths.bbb).toStrictEqual([0]); + expect(paths.aaa).toStrictEqual([1, 0]); + expect(paths.ccc).toStrictEqual([1, 1]); +}); diff --git a/tests/integration/insert-panel.spec.ts b/tests/integration/insert-panel.spec.ts index 714d9ca..332537c 100644 --- a/tests/integration/insert-panel.spec.ts +++ b/tests/integration/insert-panel.spec.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../helpers/coverage.ts"; import type { Layout } from "../../dist/index.js"; import { setupLayout, @@ -71,14 +71,14 @@ test("should insert panel at specific path in split panel", async ({ test("should insert panel into nested split panel", async ({ page }) => { await page.goto("/examples/index.html"); await page.waitForSelector("regular-layout"); - await page.evaluate((layoutConfig) => { + await page.evaluate(async (layoutConfig) => { const layout = document.querySelector("regular-layout"); - layout?.restore(layoutConfig as Layout); + await layout?.restore(layoutConfig as Layout); }, LAYOUTS.NESTED_BASIC); - await page.evaluate(() => { + await page.evaluate(async () => { const layout = document.querySelector("regular-layout"); - layout?.insertPanel("DDD", [0, 2]); + await layout?.insertPanel("DDD", [0, 2]); }); const afterInsert = await page.evaluate(() => { @@ -128,14 +128,14 @@ test("should split existing panel when inserting at deeper path", async ({ await page.goto("/examples/index.html"); await page.waitForSelector("regular-layout"); - await page.evaluate((layoutConfig) => { + await page.evaluate(async (layoutConfig) => { const layout = document.querySelector("regular-layout"); - layout?.restore(layoutConfig as Layout); + await layout?.restore(layoutConfig as Layout); }, LAYOUTS.TWO_HORIZONTAL_EQUAL); - await page.evaluate(() => { + await page.evaluate(async () => { const layout = document.querySelector("regular-layout"); - layout?.insertPanel("CCC", [1, 1], "vertical"); + await layout?.insertPanel("CCC", [1, 1], "vertical"); }); const afterInsert = await page.evaluate(() => { @@ -179,14 +179,14 @@ test("should preserve state with save/restore after insertPanel", async ({ }) => { await page.goto("/examples/index.html"); await page.waitForSelector("regular-layout"); - await page.evaluate((layoutConfig) => { + await page.evaluate(async (layoutConfig) => { const layout = document.querySelector("regular-layout"); - layout?.restore(layoutConfig as Layout); + await layout?.restore(layoutConfig as Layout); }, LAYOUTS.SINGLE_AAA); - await page.evaluate(() => { + await page.evaluate(async () => { const layout = document.querySelector("regular-layout"); - layout?.insertPanel("BBB", []); + await layout?.insertPanel("BBB", []); }); const stateAfterInsert = await page.evaluate(() => { @@ -194,9 +194,9 @@ test("should preserve state with save/restore after insertPanel", async ({ return layout?.save(); }); - await page.evaluate((state) => { + await page.evaluate(async (state) => { const layout = document.querySelector("regular-layout"); - layout?.restore(state as Layout); + await layout?.restore(state as Layout); }, stateAfterInsert); const restoredState = await page.evaluate(() => { diff --git a/tests/integration/overlay-absolute.spec.ts b/tests/integration/overlay-absolute.spec.ts index 9f339b6..ec39f9e 100644 --- a/tests/integration/overlay-absolute.spec.ts +++ b/tests/integration/overlay-absolute.spec.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../helpers/coverage.ts"; import { setupLayout, getLayoutBounds } from "../helpers/integration.ts"; import { LAYOUTS } from "../helpers/fixtures.ts"; diff --git a/tests/integration/overlay-grid.spec.ts b/tests/integration/overlay-grid.spec.ts index 5464706..8292b3d 100644 --- a/tests/integration/overlay-grid.spec.ts +++ b/tests/integration/overlay-grid.spec.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../helpers/coverage.ts"; import { setupLayout, getLayoutBounds } from "../helpers/integration.ts"; import { LAYOUTS } from "../helpers/fixtures.ts"; diff --git a/tests/integration/real-coordinates.spec.ts b/tests/integration/real-coordinates.spec.ts new file mode 100644 index 0000000..451d8bd --- /dev/null +++ b/tests/integration/real-coordinates.spec.ts @@ -0,0 +1,77 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * 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 { expect, test } from "../helpers/coverage.ts"; +import { setupLayout } from "../helpers/integration.ts"; +import { LAYOUTS } from "../helpers/fixtures.ts"; + +test("realCoordinates matches getBoundingClientRect for 3 horizontal children", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.THREE_HORIZONTAL); + + const result = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + if (!layout) return null; + const panels = ["AAA", "BBB", "CCC"]; + const results: Record< + string, + { + real: { x: number; y: number; width: number; height: number }; + actual: { x: number; y: number; width: number; height: number }; + } + > = {}; + + for (const name of panels) { + const panel = document.querySelector(`[name="${name}"]`) as HTMLElement; + + if (!panel) continue; + const panelRect = panel.getBoundingClientRect(); + const centerX = panelRect.x + panelRect.width / 2; + const centerY = panelRect.y + panelRect.height / 2; + const hit = layout.calculateIntersect({ + clientX: centerX, + clientY: centerY, + }); + + if (!hit || hit.slot !== name) continue; + const rect = layout.realCoordinates(hit.view_window); + results[name] = { + real: { + x: rect.x + 3, + y: rect.y + 27, + width: rect.width - 6, + height: rect.height - 30, + }, + actual: { + x: panelRect.x, + y: panelRect.y, + width: panelRect.width, + height: panelRect.height, + }, + }; + } + + return results; + }); + + expect(result).not.toBeNull(); + + // biome-ignore lint/style/noNonNullAssertion: playwright expectation + const panels = Object.entries(result!); + expect(panels.length).toBe(3); + for (const [name, { real, actual }] of panels) { + expect(real.x, `${name} x`).toBeCloseTo(actual.x, 0); + expect(real.y, `${name} y`).toBeCloseTo(actual.y, 0); + expect(real.width, `${name} width`).toBeCloseTo(actual.width, 0); + expect(real.height, `${name} height`).toBeCloseTo(actual.height, 0); + } +}); diff --git a/tests/integration/remove-panel.spec.ts b/tests/integration/remove-panel.spec.ts index b99d9d8..a03014d 100644 --- a/tests/integration/remove-panel.spec.ts +++ b/tests/integration/remove-panel.spec.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../helpers/coverage.ts"; import { setupLayout, saveLayout, diff --git a/tests/integration/resize-before.spec.ts b/tests/integration/resize-before.spec.ts new file mode 100644 index 0000000..c0f2f25 --- /dev/null +++ b/tests/integration/resize-before.spec.ts @@ -0,0 +1,360 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * 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 { expect, test } from "../helpers/coverage.ts"; +import { setupLayout, saveLayout, dragMouse } from "../helpers/integration.ts"; +import { LAYOUTS } from "../helpers/fixtures.ts"; + +test("should fire regular-layout-resize-before on restore", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + const fired = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + let eventFired = false; + layout?.addEventListener("regular-layout-resize-before", () => { + eventFired = true; + }); + await layout?.restore({ + type: "tab-layout", + tabs: ["BBB"], + }); + return eventFired; + }); + expect(fired).toBe(true); +}); + +test("should provide calculatePresizePaths in event detail", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + const paths = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + const presizePaths: { value: Record | null } = { + value: null, + }; + + layout?.addEventListener("regular-layout-resize-before", (e) => { + presizePaths.value = ( + e as CustomEvent + ).detail.calculatePresizePaths() as Record; + }); + + // The event detail contains paths for the *incoming* layout + await layout?.restore({ + type: "split-layout", + orientation: "horizontal", + children: [ + { type: "tab-layout", tabs: ["AAA"] }, + { type: "tab-layout", tabs: ["BBB"] }, + ], + sizes: [0.4, 0.6], + }); + + if (presizePaths.value === null) { + throw new Error("Event listener not fired"); + } + + return presizePaths.value; + }); + + expect(paths).not.toBeNull(); + expect(paths).toHaveProperty("AAA"); + expect(paths).toHaveProperty("BBB"); + const aaa = (paths as Record>)["AAA"]; + expect(aaa.type).toBe("layout-path"); + expect(aaa.slot).toBe("AAA"); +}); + +test("should suspend resize when preventDefault is called", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + const result = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + layout?.addEventListener("regular-layout-resize-before", (e) => { + e.preventDefault(); + }); + + const beforeState = layout?.save(); + + // Start restore but don't await - it will be suspended + layout?.restore({ + type: "tab-layout", + tabs: ["BBB"], + }); + + // Give a tick for the event to fire and be cancelled + await new Promise((r) => setTimeout(r, 50)); + + // Layout should still be the old state since resize is suspended + const duringState = layout?.save(); + return { + beforeTabs: (beforeState as { tabs: string[] })?.tabs, + duringTabs: (duringState as { tabs: string[] })?.tabs, + }; + }); + + expect(result.beforeTabs).toStrictEqual(["AAA"]); + expect(result.duringTabs).toStrictEqual(["AAA"]); +}); + +test("should resume suspended resize when resumeResize is called", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + const result = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + layout?.addEventListener("regular-layout-resize-before", (e) => { + e.preventDefault(); + }); + + const restorePromise = layout?.restore({ + type: "tab-layout", + tabs: ["BBB"], + }); + + await new Promise((r) => setTimeout(r, 50)); + const beforeResume = layout?.save(); + layout?.resumeResize(); + await restorePromise; + const afterResume = layout?.save(); + return { + beforeResumeTabs: (beforeResume as { tabs: string[] })?.tabs, + afterResumeTabs: (afterResume as { tabs: string[] })?.tabs, + }; + }); + + expect(result.beforeResumeTabs).toStrictEqual(["AAA"]); + expect(result.afterResumeTabs).toStrictEqual(["BBB"]); +}); + +test("should proceed immediately when event is not cancelled", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + const tabs = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + let eventCount = 0; + layout?.addEventListener("regular-layout-resize-before", () => { + eventCount++; + }); + await layout?.restore({ + type: "tab-layout", + tabs: ["BBB"], + }); + return { + tabs: (layout?.save() as { tabs: string[] })?.tabs, + eventCount, + }; + }); + + expect(tabs.tabs).toStrictEqual(["BBB"]); + expect(tabs.eventCount).toBe(1); +}); + +test("should fire resize-before on drag resize", async ({ page }) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + (window as unknown as Record).__resizeBeforeCount = 0; + layout?.addEventListener("regular-layout-resize-before", () => { + (window as unknown as Record).__resizeBeforeCount++; + }); + }); + + const layoutBox = await page.locator("regular-layout").boundingBox(); + expect(layoutBox).not.toBeNull(); + // biome-ignore lint/style/noNonNullAssertion: playwright expectation + const dividerX = layoutBox!.x + layoutBox!.width * 0.5; + // biome-ignore lint/style/noNonNullAssertion: playwright expectation + const dividerY = layoutBox!.y + layoutBox!.height * 0.5; + await dragMouse(page, dividerX, dividerY, dividerX - 50, dividerY); + + const count = await page.evaluate( + () => (window as unknown as Record).__resizeBeforeCount, + ); + expect(count).toBeGreaterThan(0); +}); + +test("should queue concurrent resizes and process sequentially", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + const result = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + const events: string[] = []; + let callCount = 0; + let cancelFirst = true; + + layout?.addEventListener("regular-layout-resize-before", (e) => { + callCount++; + events.push(`before-${callCount}`); + if (cancelFirst) { + cancelFirst = false; + e.preventDefault(); + } + }); + + // First restore - will be suspended + const promise1 = layout?.restore({ + type: "tab-layout", + tabs: ["BBB"], + }); + + await new Promise((r) => setTimeout(r, 50)); + + // Second restore - should be queued + const promise2 = layout?.restore({ + type: "tab-layout", + tabs: ["CCC"], + }); + + await new Promise((r) => setTimeout(r, 50)); + + // Resume first resize, which should then process the queued one + layout?.resumeResize(); + await Promise.all([promise1, promise2]); + + return { + events, + finalTabs: (layout?.save() as { tabs: string[] })?.tabs, + }; + }); + + expect(result.events).toStrictEqual(["before-1", "before-2"]); + expect(result.finalTabs).toStrictEqual(["CCC"]); +}); + +test("should provide pre-resize panel paths for nested layout", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + // Restore to a nested layout so calculatePresizePaths has multiple panels + const paths = await page.evaluate(async () => { + const layout = document.querySelector("regular-layout"); + let presizePaths: Record> | null = null; + layout?.addEventListener("regular-layout-resize-before", (e) => { + presizePaths = (e as CustomEvent).detail.calculatePresizePaths(); + }); + + await layout?.restore({ + type: "split-layout", + orientation: "horizontal", + children: [ + { + type: "split-layout", + orientation: "vertical", + children: [ + { type: "tab-layout", tabs: ["AAA"] }, + { type: "tab-layout", tabs: ["BBB"] }, + ], + sizes: [0.3, 0.7], + }, + { + type: "split-layout", + orientation: "vertical", + children: [ + { type: "tab-layout", tabs: ["CCC"] }, + { type: "tab-layout", tabs: ["DDD"] }, + ], + sizes: [0.5, 0.5], + }, + ], + sizes: [0.6, 0.4], + }); + return presizePaths; + }); + + expect(paths).not.toBeNull(); + expect(paths).toHaveProperty("AAA"); + expect(paths).toHaveProperty("BBB"); + expect(paths).toHaveProperty("CCC"); + expect(paths).toHaveProperty("DDD"); + for (const name of ["AAA", "BBB", "CCC", "DDD"]) { + // biome-ignore lint/style/noNonNullAssertion: test assertion + const path = paths![name] as Record; + expect(path.type).toBe("layout-path"); + expect(path.view_window).toBeDefined(); + const vw = path.view_window as Record; + expect(vw.col_start).toBeGreaterThanOrEqual(0); + expect(vw.col_end).toBeLessThanOrEqual(1); + expect(vw.row_start).toBeGreaterThanOrEqual(0); + expect(vw.row_end).toBeLessThanOrEqual(1); + } +}); + +test("should fire resize-before on double-click equalize", async ({ page }) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL); + + await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + (window as unknown as Record).__resizeBeforeFired = false; + layout?.addEventListener("regular-layout-resize-before", () => { + (window as unknown as Record).__resizeBeforeFired = true; + }); + }); + + const layoutBox = await page.locator("regular-layout").boundingBox(); + expect(layoutBox).not.toBeNull(); + // Double-click on divider area (30% split = divider at 30%) + // biome-ignore lint/style/noNonNullAssertion: playwright expectation + const dividerX = layoutBox!.x + layoutBox!.width * 0.3; + // biome-ignore lint/style/noNonNullAssertion: playwright expectation + const dividerY = layoutBox!.y + layoutBox!.height * 0.5; + await page.mouse.dblclick(dividerX, dividerY); + + await page.waitForTimeout(100); + + const result = await page.evaluate( + () => (window as unknown as Record).__resizeBeforeFired, + ); + expect(result).toBe(true); +}); + +test("should not fire resize-before on restoreSync", async ({ page }) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + const fired = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + let eventFired = false; + layout?.addEventListener("regular-layout-resize-before", () => { + eventFired = true; + }); + layout?.restoreSync({ + type: "tab-layout", + tabs: ["BBB"], + }); + return eventFired; + }); + expect(fired).toBe(false); + + // But the layout should still have been updated + const state = await saveLayout(page); + expect(state).toStrictEqual({ + type: "tab-layout", + tabs: ["BBB"], + selected: 0, + }); +}); + +test("should resumeResize be a no-op when no resize is pending", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + // This should not throw or cause issues + const result = await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + layout?.resumeResize(); + return (layout?.save() as { tabs: string[] })?.tabs; + }); + expect(result).toStrictEqual(["AAA"]); +}); diff --git a/tests/integration/resize.spec.ts b/tests/integration/resize.spec.ts index a539c38..b3f50d5 100644 --- a/tests/integration/resize.spec.ts +++ b/tests/integration/resize.spec.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../helpers/coverage.ts"; import { setupLayout, saveLayout, diff --git a/tests/integration/save-restore.spec.ts b/tests/integration/save-restore.spec.ts index e93fd75..2e74bef 100644 --- a/tests/integration/save-restore.spec.ts +++ b/tests/integration/save-restore.spec.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../helpers/coverage.ts"; import { setupLayout, saveLayout, diff --git a/tests/integration/tabs.spec.ts b/tests/integration/tabs.spec.ts index 04173bf..ecfb833 100644 --- a/tests/integration/tabs.spec.ts +++ b/tests/integration/tabs.spec.ts @@ -9,16 +9,16 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { expect, test } from "@playwright/test"; +import { expect, test } from "../helpers/coverage.ts"; import type { Layout } from "../../dist/index.js"; import { LAYOUTS } from "../helpers/fixtures.ts"; test("should switch between tabs by clicking", async ({ page }) => { await page.goto("/examples/index.html"); await page.waitForSelector("regular-layout"); - await page.evaluate((layout) => { + await page.evaluate(async (layout) => { const layoutElement = document.querySelector("regular-layout"); - layoutElement?.restore(layout as Layout); + await layoutElement?.restore(layout as Layout); }, LAYOUTS.SINGLE_TABS_WITH_SELECTED); const getSelectedTab = async (slot: string) => { @@ -70,9 +70,9 @@ test("should switch between tabs by clicking", async ({ page }) => { test("should move a panel by dragging a selected tab", async ({ page }) => { await page.goto("/examples/index.html"); await page.waitForSelector("regular-layout"); - await page.evaluate((layout) => { + await page.evaluate(async (layout) => { const layoutElement = document.querySelector("regular-layout"); - layoutElement?.restore(layout as Layout); + await layoutElement?.restore(layout as Layout); }, LAYOUTS.TWO_HORIZONTAL_WITH_TABS); const dragCoords = await page.evaluate(() => { @@ -131,9 +131,9 @@ test("should move a panel by dragging a selected tab", async ({ page }) => { test("should move a panel by dragging a deselected tab", async ({ page }) => { await page.goto("/examples/index.html"); await page.waitForSelector("regular-layout"); - await page.evaluate((layout) => { + await page.evaluate(async (layout) => { const layoutElement = document.querySelector("regular-layout"); - layoutElement?.restore(layout as Layout); + await layoutElement?.restore(layout as Layout); }, LAYOUTS.TWO_HORIZONTAL_WITH_TABS); const layoutBefore = await page.evaluate(() => { diff --git a/tests/unit/calculate_intersect.spec.ts b/tests/unit/calculate_intersect.spec.ts new file mode 100644 index 0000000..3f6c113 --- /dev/null +++ b/tests/unit/calculate_intersect.spec.ts @@ -0,0 +1,141 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * 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 { expect, test } from "@playwright/test"; +import { LAYOUTS } from "../helpers/fixtures.ts"; +import { calculate_intersection } from "../../src/layout/calculate_intersect.ts"; + +test("returns null for out-of-bounds coordinates", () => { + expect(calculate_intersection(-0.1, 0.5, LAYOUTS.SINGLE_AAA)).toBeNull(); + expect(calculate_intersection(0.5, -0.1, LAYOUTS.SINGLE_AAA)).toBeNull(); + expect(calculate_intersection(1.1, 0.5, LAYOUTS.SINGLE_AAA)).toBeNull(); + expect(calculate_intersection(0.5, 1.1, LAYOUTS.SINGLE_AAA)).toBeNull(); +}); + +test("returns panel path for single tab-layout", () => { + const result = calculate_intersection(0.5, 0.5, LAYOUTS.SINGLE_AAA); + expect(result).toStrictEqual({ + type: "layout-path", + layout: LAYOUTS.SINGLE_AAA, + slot: "AAA", + path: [], + view_window: { row_start: 0, row_end: 1, col_start: 0, col_end: 1 }, + is_edge: false, + column: 0.5, + row: 0.5, + column_offset: 0.5, + row_offset: 0.5, + orientation: "horizontal", + }); +}); + +test("returns selected tab for right edge", () => { + const result = calculate_intersection(1, 0.5, LAYOUTS.TWO_HORIZONTAL); + expect(result).not.toBeNull(); + expect(result?.slot).toBe("BBB"); +}); + +test("returns selected tab for multi-tab layout", () => { + const result = calculate_intersection(0.5, 0.5, LAYOUTS.SINGLE_TABS); + expect(result).not.toBeNull(); + expect(result?.slot).toBe("AAA"); +}); + +test("hits left panel in horizontal split", () => { + const result = calculate_intersection(0.1, 0.5, LAYOUTS.TWO_HORIZONTAL); + expect(result).not.toBeNull(); + expect(result?.slot).toBe("AAA"); + expect(result?.path).toStrictEqual([0]); +}); + +test("hits right panel in horizontal split", () => { + const result = calculate_intersection(0.5, 0.5, LAYOUTS.TWO_HORIZONTAL); + expect(result).not.toBeNull(); + expect(result?.slot).toBe("BBB"); + expect(result?.path).toStrictEqual([1]); +}); + +test("hits top panel in vertical split", () => { + const result = calculate_intersection(0.5, 0.1, LAYOUTS.TWO_VERTICAL); + expect(result).not.toBeNull(); + expect(result?.slot).toBe("AAA"); + expect(result?.path).toStrictEqual([0]); +}); + +test("hits bottom panel in vertical split", () => { + const result = calculate_intersection(0.5, 0.7, LAYOUTS.TWO_VERTICAL); + expect(result).not.toBeNull(); + expect(result?.slot).toBe("BBB"); + expect(result?.path).toStrictEqual([1]); +}); + +test("hits nested panel in deeply nested layout", () => { + // DEEPLY_NESTED: horizontal [0.6, 0.4] -> left is vertical [0.3, 0.7] + // Left-top: col 0-0.6, row 0-0.3 -> AAA + const result = calculate_intersection(0.1, 0.1, LAYOUTS.DEEPLY_NESTED); + expect(result).not.toBeNull(); + expect(result?.slot).toBe("AAA"); + expect(result?.path).toStrictEqual([0, 0]); +}); + +test("hits sibling nested panel", () => { + // Right-bottom: col 0.6-1.0, row 0.5-1.0 -> DDD + const result = calculate_intersection(0.8, 0.8, LAYOUTS.DEEPLY_NESTED); + expect(result).not.toBeNull(); + expect(result?.slot).toBe("DDD"); + expect(result?.path).toStrictEqual([1, 1]); +}); + +test("computes correct view_window for child panel", () => { + // TWO_HORIZONTAL_EQUAL: horizontal [0.5, 0.5] + // Right panel: col_start=0.5, col_end=1.0 + const result = calculate_intersection( + 0.75, + 0.5, + LAYOUTS.TWO_HORIZONTAL_EQUAL, + ); + expect(result).not.toBeNull(); + expect(result?.view_window).toStrictEqual({ + row_start: 0, + row_end: 1, + col_start: 0.5, + col_end: 1, + }); +}); + +test("computes correct column_offset and row_offset", () => { + // TWO_HORIZONTAL_EQUAL: right panel spans col 0.5-1.0 + // column=0.75 -> offset = (0.75 - 0.5) / 0.5 = 0.5 + const result = calculate_intersection( + 0.75, + 0.25, + LAYOUTS.TWO_HORIZONTAL_EQUAL, + ); + expect(result).not.toBeNull(); + expect(result?.column_offset).toBe(0.5); + expect(result?.row_offset).toBe(0.25); +}); + +test("sets orientation from parent split", () => { + const horiz = calculate_intersection(0.1, 0.5, LAYOUTS.TWO_HORIZONTAL); + expect(horiz?.orientation).toBe("horizontal"); + + const vert = calculate_intersection(0.5, 0.1, LAYOUTS.TWO_VERTICAL); + expect(vert?.orientation).toBe("vertical"); +}); + +test("hits middle panel in three-way split", () => { + // THREE_HORIZONTAL: sizes [0.3, 0.3, 0.4] -> BBB is col 0.3-0.6 + const result = calculate_intersection(0.45, 0.5, LAYOUTS.THREE_HORIZONTAL); + expect(result).not.toBeNull(); + expect(result?.slot).toBe("BBB"); + expect(result?.path).toStrictEqual([1]); +}); diff --git a/tests/unit/css_grid_layout.spec.ts b/tests/unit/css_grid_layout.spec.ts index 4c37c4c..588f2de 100644 --- a/tests/unit/css_grid_layout.spec.ts +++ b/tests/unit/css_grid_layout.spec.ts @@ -13,8 +13,8 @@ import { expect, test } from "@playwright/test"; import { LAYOUTS } from "../helpers/fixtures.ts"; import { create_css_grid_layout } from "../../src/layout/generate_grid.ts"; -import type { Layout } from "../../src/layout/types.ts"; -import { DEFAULT_PHYSICS } from "../../src/layout/constants.ts"; +import type { Layout } from "../../src/core/types.ts"; +import { DEFAULT_PHYSICS } from "../../src/core/constants.ts"; const RESULT = ` :host ::slotted(*){display:none}:host{display:grid;grid-template-rows:30fr 70fr;grid-template-columns:60fr 40fr} diff --git a/tests/unit/css_grid_layout_partial.spec.ts b/tests/unit/css_grid_layout_partial.spec.ts index b031802..037b0ea 100644 --- a/tests/unit/css_grid_layout_partial.spec.ts +++ b/tests/unit/css_grid_layout_partial.spec.ts @@ -12,8 +12,8 @@ import { expect, test } from "@playwright/test"; import { create_css_grid_layout } from "../../src/layout/generate_grid.ts"; -import type { Layout } from "../../src/layout/types.ts"; -import { DEFAULT_PHYSICS } from "../../src/layout/constants.ts"; +import type { Layout } from "../../src/core/types.ts"; +import { DEFAULT_PHYSICS } from "../../src/core/constants.ts"; test("Deeply alternating split with grid-based overlay", () => { const test: Layout = { diff --git a/tests/unit/hit_detection.spec.ts b/tests/unit/hit_detection.spec.ts index f4bad83..3d82f19 100644 --- a/tests/unit/hit_detection.spec.ts +++ b/tests/unit/hit_detection.spec.ts @@ -12,7 +12,7 @@ import { expect, test } from "@playwright/test"; import { calculate_intersection } from "../../src/layout/calculate_intersect.ts"; import { LAYOUTS } from "../helpers/fixtures.ts"; -import { DEFAULT_PHYSICS } from "../../src/layout/constants.ts"; +import { DEFAULT_PHYSICS } from "../../src/core/constants.ts"; test("AAA", () => { const result = calculate_intersection(0.1, 0.1, LAYOUTS.NESTED_BASIC); diff --git a/themes/lorax.css b/themes/lorax.css index 37046c2..89017d8 100644 --- a/themes/lorax.css +++ b/themes/lorax.css @@ -63,7 +63,7 @@ regular-layout.lorax regular-layout-frame::part(close) { regular-layout.lorax regular-layout-frame::part(close):hover { transition: background-color 0.2s; - background-color: rgba(255,0,0,0.2); + background-color: rgba(255, 0, 0, 0.2); } /* Frame in Overlay Mode */ @@ -71,7 +71,7 @@ regular-layout.lorax regular-layout-frame.overlay { background-color: rgba(0, 0, 0, 0.2); border: 1px dashed rgb(0, 0, 0); border-radius: 6px; - margin: 0; + margin: 3px; box-shadow: none; transition: top 0.1s ease-in-out, @@ -80,11 +80,13 @@ regular-layout.lorax regular-layout-frame.overlay { left 0.1s ease-in-out; } -regular-layout.lorax regular-layout-frame::part(container), regular-layout.lorax regular-layout-frame::part(titlebar) { +regular-layout.lorax regular-layout-frame::part(container), +regular-layout.lorax regular-layout-frame::part(titlebar) { display: none; } -regular-layout.lorax regular-layout-frame:not(.overlay)::part(container), regular-layout.lorax regular-layout-frame:not(.overlay)::part(titlebar) { +regular-layout.lorax regular-layout-frame:not(.overlay)::part(container), +regular-layout.lorax regular-layout-frame:not(.overlay)::part(titlebar) { display: flex; }