From dab5f43447728de15ab071632528593d12fb6882 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Tue, 13 Jan 2026 21:46:55 -0500 Subject: [PATCH] Expose constants and a drop target bug --- build.ts | 1 + examples/index.ts | 7 +- src/layout/calculate_edge.ts | 38 +++--- src/layout/calculate_intersect.ts | 16 +-- src/layout/constants.ts | 132 +++++++++++++++------ src/layout/generate_grid.ts | 92 ++++++++++---- src/layout/generate_overlay.ts | 6 +- src/layout/redistribute_panel_sizes.ts | 13 +- src/layout/types.ts | 1 + src/regular-layout-frame.ts | 13 +- src/regular-layout.ts | 129 ++++++++++++++------ tests/unit/calculate_edge.spec.ts | 4 +- tests/unit/css_grid_layout.spec.ts | 55 +++++++-- tests/unit/css_grid_layout_partial.spec.ts | 8 +- tests/unit/hit_detection.spec.ts | 29 +++-- themes/lorax.css | 3 +- 16 files changed, 397 insertions(+), 150 deletions(-) diff --git a/build.ts b/build.ts index fab0409..50d3010 100644 --- a/build.ts +++ b/build.ts @@ -30,6 +30,7 @@ const browserConfig: esbuild.BuildOptions = { minify: true, minifyWhitespace: true, minifyIdentifiers: true, + mangleProps: /^[_#]/, outfile: "dist/index.js", platform: "browser", format: "esm", diff --git a/examples/index.ts b/examples/index.ts index a46f093..768f503 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -16,6 +16,10 @@ const add = document.querySelector("#add") as HTMLButtonElement; const save = document.querySelector("#save") as HTMLButtonElement; const restore = document.querySelector("#restore") as HTMLButtonElement; const clear = document.querySelector("#clear") as HTMLButtonElement; + +// biome-ignore lint/style/noNonNullAssertion: demo +const layout = document.querySelector("regular-layout")!; + add.addEventListener("click", () => { // Note: this *demo* implementation leaks `div` elements, because they // are not removed from the light DOM by `clear` or `restore`. You must @@ -36,12 +40,11 @@ add.addEventListener("click", () => { themes.addEventListener("change", (_event) => { layout.className = themes.value; -}) +}); const req = await fetch("./layout.json"); let state = await req.json(); -const layout = document.querySelector("regular-layout") as any; layout.restore(state); save.addEventListener("click", () => { state = layout.save(); diff --git a/src/layout/calculate_edge.ts b/src/layout/calculate_edge.ts index 0bea297..c9678c0 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 { SPLIT_EDGE_TOLERANCE, SPLIT_ROOT_EDGE_TOLERANCE } from "./constants"; +import { DEFAULT_PHYSICS, type Physics } from "./constants"; import { insert_child } from "./insert_child"; import type { Layout, LayoutPath, Orientation, ViewWindow } from "./types"; @@ -33,39 +33,47 @@ export function calculate_edge( slot: string, drop_target: LayoutPath, box?: DOMRect, + physics: Physics = DEFAULT_PHYSICS, ): LayoutPath { // Check root edges first - if (col < SPLIT_ROOT_EDGE_TOLERANCE) { + if (col < physics.SPLIT_ROOT_EDGE_TOLERANCE) { return insert_root_edge(panel, slot, drop_target, [0], true, "horizontal"); } - if (col > 1 - SPLIT_ROOT_EDGE_TOLERANCE) { + if (col > 1 - physics.SPLIT_ROOT_EDGE_TOLERANCE) { return insert_root_edge( panel, slot, drop_target, - drop_target.path.length > 0 ? drop_target.path : [], + drop_target.path.length > 0 ? drop_target.path : [1], false, "horizontal", ); } - if (row < SPLIT_ROOT_EDGE_TOLERANCE) { + if (row < physics.SPLIT_ROOT_EDGE_TOLERANCE) { return insert_root_edge(panel, slot, drop_target, [0], true, "vertical"); } - if (row > 1 - SPLIT_ROOT_EDGE_TOLERANCE) { - return insert_root_edge(panel, slot, drop_target, [], false, "vertical"); + if (row > 1 - physics.SPLIT_ROOT_EDGE_TOLERANCE) { + return insert_root_edge( + panel, + slot, + drop_target, + drop_target.path.length > 0 ? drop_target.path : [1], + false, + "vertical", + ); } // Check panel edges const is_column_edge = - drop_target.column_offset < SPLIT_EDGE_TOLERANCE || - drop_target.column_offset > 1 - SPLIT_EDGE_TOLERANCE; + drop_target.column_offset < physics.SPLIT_EDGE_TOLERANCE || + drop_target.column_offset > 1 - physics.SPLIT_EDGE_TOLERANCE; const is_row_edge = - drop_target.row_offset < SPLIT_EDGE_TOLERANCE || - drop_target.row_offset > 1 - SPLIT_EDGE_TOLERANCE; + drop_target.row_offset < physics.SPLIT_EDGE_TOLERANCE || + drop_target.row_offset > 1 - physics.SPLIT_EDGE_TOLERANCE; // If both edges triggered, choose closer axis if (is_column_edge && is_row_edge) { @@ -86,8 +94,8 @@ export function calculate_edge( slot, drop_target, use_column - ? drop_target.column_offset < SPLIT_EDGE_TOLERANCE - : drop_target.row_offset < SPLIT_EDGE_TOLERANCE, + ? drop_target.column_offset < physics.SPLIT_EDGE_TOLERANCE + : drop_target.row_offset < physics.SPLIT_EDGE_TOLERANCE, use_column ? "horizontal" : "vertical", ); } @@ -97,7 +105,7 @@ export function calculate_edge( panel, slot, drop_target, - drop_target.column_offset < SPLIT_EDGE_TOLERANCE, + drop_target.column_offset < physics.SPLIT_EDGE_TOLERANCE, "horizontal", ); } @@ -107,7 +115,7 @@ export function calculate_edge( panel, slot, drop_target, - drop_target.row_offset < SPLIT_EDGE_TOLERANCE, + drop_target.row_offset < physics.SPLIT_EDGE_TOLERANCE, "vertical", ); } diff --git a/src/layout/calculate_intersect.ts b/src/layout/calculate_intersect.ts index 197f08b..3ca5163 100644 --- a/src/layout/calculate_intersect.ts +++ b/src/layout/calculate_intersect.ts @@ -9,7 +9,6 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { GRID_DIVIDER_SIZE } from "./constants.ts"; import type { LayoutPath, LayoutDivider, Layout, ViewWindow } from "./types.ts"; const VIEW_WINDOW = { @@ -42,21 +41,21 @@ export function calculate_intersection( column: number, row: number, layout: Layout, - check_dividers?: DOMRect, + check_dividers?: { rect: DOMRect; size: number }, ): LayoutPath | null | LayoutDivider; export function calculate_intersection( column: number, row: number, layout: Layout, - check_dividers?: DOMRect | null, + check_dividers?: { rect: DOMRect; size: number } | null, ): LayoutPath | null | LayoutDivider; export function calculate_intersection( column: number, row: number, layout: Layout, - check_dividers: DOMRect | null = null, + check_dividers: { rect: DOMRect; size: number } | null = null, ): LayoutPath | null | LayoutDivider { return calculate_intersection_recursive(column, row, layout, check_dividers); } @@ -65,7 +64,7 @@ function calculate_intersection_recursive( column: number, row: number, panel: Layout, - check_dividers: DOMRect | null, + check_dividers: { rect: DOMRect; size: number } | null, parent_orientation: "horizontal" | "vertical" | null = null, view_window: ViewWindow = structuredClone(VIEW_WINDOW), path: number[] = [], @@ -99,7 +98,10 @@ function calculate_intersection_recursive( const position = is_vertical ? row : column; const start_key = is_vertical ? "row_start" : "col_start"; const end_key = is_vertical ? "row_end" : "col_end"; - const rect_dim = is_vertical ? check_dividers?.height : check_dividers?.width; + const rect_dim = is_vertical + ? check_dividers?.rect?.height + : check_dividers?.rect?.width; + let current_pos = view_window[start_key]; const total_size = view_window[end_key] - view_window[start_key]; for (let i = 0; i < panel.children.length; i++) { @@ -107,7 +109,7 @@ function calculate_intersection_recursive( // Check if position is on a divider if (check_dividers && rect_dim) { - const divider_threshold = GRID_DIVIDER_SIZE / rect_dim; + const divider_threshold = check_dividers.size / rect_dim; if (Math.abs(position - next_pos) < divider_threshold) { return { path: [...path, i], diff --git a/src/layout/constants.ts b/src/layout/constants.ts index c9c0689..92f7634 100644 --- a/src/layout/constants.ts +++ b/src/layout/constants.ts @@ -12,52 +12,108 @@ import type { OverlayMode } from "./types"; /** - * The prefix to use for `CustomEvent`s generated by `regular-layout`, e.g. - * `"regular-layout-before-update"`. + * Instance-specific constants which define the behavior and rendering details + * of a ``. */ -export const CUSTOM_EVENT_NAME_PREFIX = "regular-layout"; +export interface Physics { + /** + * The prefix to use for `CustomEvent`s generated by `regular-layout`, e.g. + * `"regular-layout-before-update"`. + */ + CUSTOM_EVENT_NAME_PREFIX: string; -/** - * The minimum number of pixels the mouse must move to be considered a drag. - */ -export const MIN_DRAG_DISTANCE = 10; + /** + * The attribute name to use for matching child `Element`s to grid + * positions. + */ + CHILD_ATTRIBUTE_NAME: string; -/** - * Class name to use for child elements in overlay position (dragging). - */ -export const OVERLAY_CLASSNAME = "overlay"; + /** + * The minimum number of pixels the mouse must move to be considered a drag. + */ + MIN_DRAG_DISTANCE: number; -/** - * The percentage of the maximum resize distance that will be clamped. - * - */ -export const MINIMUM_REDISTRIBUTION_SIZE_THRESHOLD = 0.15; + /** + * Should floating point pixel calculations be rounded. Useful for testing. + */ + SHOULD_ROUND: boolean; -/** - * Threshold from panel edge that is considered a split vs drop action. - */ -export const SPLIT_EDGE_TOLERANCE = 0.25; + /** + * Class name to use for child elements in overlay position (dragging). + */ + OVERLAY_CLASSNAME: string; -/** - * Threshold from _container_ edge that is considered a split action on the root - * node. - */ -export const SPLIT_ROOT_EDGE_TOLERANCE = 0.01; + /** + * The percentage of the maximum resize distance that will be clamped. + */ + MINIMUM_REDISTRIBUTION_SIZE_THRESHOLD: number; -/** - * Tolerance threshold for considering two grid track positions as identical. - * - * When collecting and deduplicating track positions, any positions closer than - * this value are treated as the same position to avoid redundant grid tracks. - */ -export const GRID_TRACK_COLLAPSE_TOLERANCE = 0.001; + /** + * Threshold from panel edge that is considered a split vs drop action. + */ + SPLIT_EDGE_TOLERANCE: number; -/** - * The overlay default behavior. - */ -export const OVERLAY_DEFAULT: OverlayMode = "absolute"; + /** + * Threshold from _container_ edge that is considered a split action on the root + * node. + */ + SPLIT_ROOT_EDGE_TOLERANCE: number; + + /** + * Tolerance threshold for considering two grid track positions as identical. + * + * When collecting and deduplicating track positions, any positions closer than + * this value are treated as the same position to avoid redundant grid tracks. + */ + GRID_TRACK_COLLAPSE_TOLERANCE: number; + + /** + * The overlay default behavior. + */ + OVERLAY_DEFAULT: OverlayMode; + + /** + * Width of split panel dividers in pixels (for hit-test purposes). + */ + GRID_DIVIDER_SIZE: number; + + /** + * Whether the grid should trigger column resize if the grid itself is not + * the `event.target`. + */ + GRID_DIVIDER_CHECK_TARGET: boolean; +} /** - * Width of split panel dividers in pixels (for hit-test purposes). + * Like `GlobalPhysics`, but suitable for partial definition for incremental + * updates. */ -export const GRID_DIVIDER_SIZE = 6; +export interface PhysicsUpdate { + CUSTOM_EVENT_NAME_PREFIX?: string; + CHILD_ATTRIBUTE_NAME?: string; + MIN_DRAG_DISTANCE?: number; + SHOULD_ROUND?: boolean; + OVERLAY_CLASSNAME?: string; + MINIMUM_REDISTRIBUTION_SIZE_THRESHOLD?: number; + SPLIT_EDGE_TOLERANCE?: number; + SPLIT_ROOT_EDGE_TOLERANCE?: number; + GRID_TRACK_COLLAPSE_TOLERANCE?: number; + OVERLAY_DEFAULT?: OverlayMode; + GRID_DIVIDER_SIZE?: number; + GRID_DIVIDER_CHECK_TARGET?: boolean; +} + +export const DEFAULT_PHYSICS: Physics = Object.freeze({ + CUSTOM_EVENT_NAME_PREFIX: "regular-layout", + CHILD_ATTRIBUTE_NAME: "name", + MIN_DRAG_DISTANCE: 10, + SHOULD_ROUND: false, + OVERLAY_CLASSNAME: "overlay", + MINIMUM_REDISTRIBUTION_SIZE_THRESHOLD: 0.15, + SPLIT_EDGE_TOLERANCE: 0.25, + SPLIT_ROOT_EDGE_TOLERANCE: 0.01, + GRID_TRACK_COLLAPSE_TOLERANCE: 0.001, + OVERLAY_DEFAULT: "absolute", + GRID_DIVIDER_SIZE: 6, + GRID_DIVIDER_CHECK_TARGET: true, +}); diff --git a/src/layout/generate_grid.ts b/src/layout/generate_grid.ts index 12ace79..f5d0119 100644 --- a/src/layout/generate_grid.ts +++ b/src/layout/generate_grid.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { GRID_TRACK_COLLAPSE_TOLERANCE } from "./constants.ts"; +import { DEFAULT_PHYSICS, type Physics } from "./constants.ts"; import type { Layout } from "./types.ts"; interface GridCell { @@ -20,10 +20,11 @@ interface GridCell { rowEnd: number; } -function dedupe_sort(result: number[], pos: number) { +function dedupe_sort(physics: Physics, result: number[], pos: number) { if ( result.length === 0 || - Math.abs(pos - result[result.length - 1]) > GRID_TRACK_COLLAPSE_TOLERANCE + Math.abs(pos - result[result.length - 1]) > + physics.GRID_TRACK_COLLAPSE_TOLERANCE ) { result.push(pos); } @@ -31,9 +32,9 @@ function dedupe_sort(result: number[], pos: number) { return result; } -function dedupe_positions(positions: number[]): number[] { +function dedupe_positions(physics: Physics, positions: number[]): number[] { const sorted = positions.sort((a, b) => a - b); - return sorted.reduce(dedupe_sort, []); + return sorted.reduce(dedupe_sort.bind(undefined, physics), []); } function collect_track_positions( @@ -41,6 +42,7 @@ function collect_track_positions( orientation: "horizontal" | "vertical", start: number, end: number, + physics: Physics, ): number[] { if (panel.type === "child-panel") { return [start, end]; @@ -59,6 +61,7 @@ function collect_track_positions( orientation, current, next, + physics, ), ); @@ -67,17 +70,21 @@ function collect_track_positions( } else { for (const child of panel.children) { positions.push( - ...collect_track_positions(child, orientation, start, end), + ...collect_track_positions(child, orientation, start, end, physics), ); } } - return dedupe_positions(positions); + return dedupe_positions(physics, positions); } -function find_track_index(positions: number[], value: number): number { +function find_track_index( + physics: Physics, + positions: number[], + value: number, +): number { const index = positions.findIndex( - (pos) => Math.abs(pos - value) < GRID_TRACK_COLLAPSE_TOLERANCE, + (pos) => Math.abs(pos - value) < physics.GRID_TRACK_COLLAPSE_TOLERANCE, ); return index === -1 ? 0 : index; @@ -91,16 +98,17 @@ function build_cells( colEnd: number, rowStart: number, rowEnd: number, + physics: Physics, ): GridCell[] { if (panel.type === "child-panel") { const selected = panel.selected ?? 0; return [ { child: panel.child[selected], - colStart: find_track_index(colPositions, colStart), - colEnd: find_track_index(colPositions, colEnd), - rowStart: find_track_index(rowPositions, rowStart), - rowEnd: find_track_index(rowPositions, rowEnd), + colStart: find_track_index(physics, colPositions, colStart), + colEnd: find_track_index(physics, colPositions, colEnd), + rowStart: find_track_index(physics, rowPositions, rowStart), + rowEnd: find_track_index(physics, rowPositions, rowEnd), }, ]; } @@ -122,6 +130,7 @@ function build_cells( next, rowStart, rowEnd, + physics, ), ); } else { @@ -134,6 +143,7 @@ function build_cells( colEnd, current, next, + physics, ), ); } @@ -147,8 +157,13 @@ function build_cells( const host_template = (rowTemplate: string, colTemplate: string) => `:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:${rowTemplate};grid-template-columns:${colTemplate}}`; -const child_template = (slot: string, rowPart: string, colPart: string) => - `:host ::slotted([name="${slot}"]){display:flex;grid-column:${colPart};grid-row:${rowPart}}`; +const child_template = ( + physics: Physics, + slot: string, + rowPart: string, + colPart: string, +) => + `:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){display:flex;grid-column:${colPart};grid-row:${rowPart}}`; /** * Generates CSS Grid styles to render a layout tree. @@ -182,12 +197,15 @@ const child_template = (slot: string, rowPart: string, colPart: string) => */ export function create_css_grid_layout( layout: Layout, - round: boolean = false, overlay?: [string, string], + physics: Physics = DEFAULT_PHYSICS, ): string { if (layout.type === "child-panel") { const selected = layout.selected ?? 0; - return `${host_template("100%", "100%")}\n${child_template(layout.child[selected], "1", "1")}`; + return [ + host_template("100%", "100%"), + child_template(physics, layout.child[selected], "1", "1"), + ].join("\n"); } const createTemplate = (positions: number[]) => { @@ -195,26 +213,52 @@ export function create_css_grid_layout( .slice(0, -1) .map((pos, i) => positions[i + 1] - pos); return sizes - .map((s) => `${round ? Math.round(s * 100) : s * 100}fr`) + .map((s) => `${physics.SHOULD_ROUND ? Math.round(s * 100) : s * 100}fr`) .join(" "); }; - const colPositions = collect_track_positions(layout, "horizontal", 0, 1); + const colPositions = collect_track_positions( + layout, + "horizontal", + 0, + 1, + physics, + ); + const colTemplate = createTemplate(colPositions); - const rowPositions = collect_track_positions(layout, "vertical", 0, 1); + const rowPositions = collect_track_positions( + layout, + "vertical", + 0, + 1, + physics, + ); + const rowTemplate = createTemplate(rowPositions); const formatGridLine = (start: number, end: number) => end - start === 1 ? `${start + 1}` : `${start + 1} / ${end + 1}`; - const cells = build_cells(layout, colPositions, rowPositions, 0, 1, 0, 1); + const cells = build_cells( + layout, + colPositions, + rowPositions, + 0, + 1, + 0, + 1, + physics, + ); + const css = [host_template(rowTemplate, colTemplate)]; for (const cell of cells) { const colPart = formatGridLine(cell.colStart, cell.colEnd); const rowPart = formatGridLine(cell.rowStart, cell.rowEnd); - css.push(child_template(cell.child, rowPart, colPart)); + css.push(child_template(physics, cell.child, rowPart, colPart)); if (cell.child === overlay?.[1]) { - css.push(child_template(overlay[0], rowPart, colPart)); - css.push(`:host ::slotted([name=${overlay[0]}]){z-index:1}`); + css.push(child_template(physics, overlay[0], rowPart, colPart)); + css.push( + `:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}=${overlay[0]}]){z-index:1}`, + ); } } diff --git a/src/layout/generate_overlay.ts b/src/layout/generate_overlay.ts index 67510c9..b16d46d 100644 --- a/src/layout/generate_overlay.ts +++ b/src/layout/generate_overlay.ts @@ -9,6 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import { DEFAULT_PHYSICS } from "./constants"; import type { LayoutPath } from "./types"; export function updateOverlaySheet( @@ -16,9 +17,10 @@ export function updateOverlaySheet( box: DOMRect, style: CSSStyleDeclaration, drag_target: LayoutPath | null, + physics = DEFAULT_PHYSICS, ) { if (!drag_target) { - return `:host ::slotted([name="${slot}"]){display:none;}`; + return `:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){display:none;}`; } const { @@ -36,5 +38,5 @@ export function updateOverlaySheet( 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;`; - return `::slotted([name="${slot}"]){${css}}`; + return `::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){${css}}`; } diff --git a/src/layout/redistribute_panel_sizes.ts b/src/layout/redistribute_panel_sizes.ts index a5bcf70..1816689 100644 --- a/src/layout/redistribute_panel_sizes.ts +++ b/src/layout/redistribute_panel_sizes.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { MINIMUM_REDISTRIBUTION_SIZE_THRESHOLD } from "./constants.ts"; +import { DEFAULT_PHYSICS } from "./constants.ts"; import type { Layout } from "./types.ts"; /** @@ -33,6 +33,7 @@ export function redistribute_panel_sizes( panel: Layout, path: number[], delta: number, + physics = DEFAULT_PHYSICS, ): Layout { // Clone the entire panel structure const result = structuredClone(panel); @@ -55,7 +56,12 @@ export function redistribute_panel_sizes( // It would be fun to remove this condition. if (index < current.sizes.length - 1) { - current.sizes = add_and_redistribute(current.sizes, index, delta); + current.sizes = add_and_redistribute( + physics, + current.sizes, + index, + delta, + ); } } @@ -63,6 +69,7 @@ export function redistribute_panel_sizes( } function add_and_redistribute( + physics: typeof DEFAULT_PHYSICS, arr: number[], index: number, delta: number, @@ -83,7 +90,7 @@ function add_and_redistribute( Math.sign(delta) * Math.min( Math.abs(delta), - (1 - MINIMUM_REDISTRIBUTION_SIZE_THRESHOLD) * + (1 - physics.MINIMUM_REDISTRIBUTION_SIZE_THRESHOLD) * (delta > 0 ? before_total : after_total), ); diff --git a/src/layout/types.ts b/src/layout/types.ts index 7b7c5e6..5d81a13 100644 --- a/src/layout/types.ts +++ b/src/layout/types.ts @@ -34,6 +34,7 @@ export interface ViewWindow { col_end: number; } + /** * A split panel that divides space among multiple child layouts * . diff --git a/src/regular-layout-frame.ts b/src/regular-layout-frame.ts index c040638..b3201c2 100644 --- a/src/regular-layout-frame.ts +++ b/src/regular-layout-frame.ts @@ -9,7 +9,6 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { MIN_DRAG_DISTANCE, OVERLAY_CLASSNAME } from "./layout/constants.ts"; import type { Layout, LayoutPath } from "./layout/types.ts"; import type { RegularLayoutEvent } from "./extensions.ts"; import type { RegularLayout } from "./regular-layout.ts"; @@ -115,6 +114,8 @@ export class RegularLayoutFrame extends HTMLElement { private onPointerMove = (event: PointerEvent): void => { if (this._drag_state) { + const physics = this._layout.savePhysics(); + // Only initiate a drag if the cursor has moved sufficiently. if (!this._drag_moved) { const [current_col, current_row, box] = @@ -122,7 +123,7 @@ export class RegularLayoutFrame extends HTMLElement { const dx = (current_col - this._drag_state.column) * box.width; const dy = (current_row - this._drag_state.row) * box.height; - if (Math.sqrt(dx * dx + dy * dy) <= MIN_DRAG_DISTANCE) { + if (Math.sqrt(dx * dx + dy * dy) <= physics.MIN_DRAG_DISTANCE) { return; } } @@ -132,7 +133,7 @@ export class RegularLayoutFrame extends HTMLElement { event.clientX, event.clientY, this._drag_state, - OVERLAY_CLASSNAME, + physics.OVERLAY_CLASSNAME, ); } }; @@ -143,7 +144,6 @@ export class RegularLayoutFrame extends HTMLElement { event.clientX, event.clientY, this._drag_state, - OVERLAY_CLASSNAME, ); } @@ -165,7 +165,10 @@ export class RegularLayoutFrame extends HTMLElement { }; private drawTabs = (event: RegularLayoutEvent) => { - const slot = this.getAttribute("name"); + const slot = this.getAttribute( + this._layout.savePhysics().CHILD_ATTRIBUTE_NAME, + ); + if (!slot) { return; } diff --git a/src/regular-layout.ts b/src/regular-layout.ts index a9ae89c..1415baa 100644 --- a/src/regular-layout.ts +++ b/src/regular-layout.ts @@ -34,9 +34,9 @@ import { updateOverlaySheet } from "./layout/generate_overlay.ts"; import { calculate_edge } from "./layout/calculate_edge.ts"; import { flatten } from "./layout/flatten.ts"; import { - CUSTOM_EVENT_NAME_PREFIX, - OVERLAY_CLASSNAME, - OVERLAY_DEFAULT, + DEFAULT_PHYSICS, + type PhysicsUpdate, + type Physics, } from "./layout/constants.ts"; /** @@ -80,9 +80,11 @@ export class RegularLayout extends HTMLElement { private _drag_target?: [LayoutDivider, number, number]; private _cursor_override: boolean; private _dimensions?: { box: DOMRect; style: CSSStyleDeclaration }; + private _physics: Physics; constructor() { super(); + this._physics = DEFAULT_PHYSICS; this._panel = structuredClone(EMPTY_PANEL); // Why does this implementation use a `` at all? We must use @@ -130,12 +132,12 @@ export class RegularLayout extends HTMLElement { y: number, check_dividers: boolean = false, ): LayoutPath | null => { - const [col, row, box] = this.relativeCoordinates(x, y, false); + const [col, row, rect] = this.relativeCoordinates(x, y, false); const panel = calculate_intersection( col, row, this._panel, - check_dividers ? box : null, + check_dividers ? { rect, size: this._physics.GRID_DIVIDER_SIZE } : null, ); if (panel?.type === "layout-path") { @@ -163,31 +165,47 @@ export class RegularLayout extends HTMLElement { x: number, y: number, { slot }: LayoutPath, - className: string = OVERLAY_CLASSNAME, - mode: OverlayMode = OVERLAY_DEFAULT, + className: string = this._physics.OVERLAY_CLASSNAME, + mode: OverlayMode = this._physics.OVERLAY_DEFAULT, ) => { const panel = remove_child(this._panel, slot); Array.from(this.children) - .find((x) => x.getAttribute("name") === slot) + .find((x) => x.getAttribute(this._physics.CHILD_ATTRIBUTE_NAME) === slot) ?.classList.add(className); const [col, row, box, style] = this.relativeCoordinates(x, y, false); let drop_target = calculate_intersection(col, row, panel); if (drop_target) { - drop_target = calculate_edge(col, row, panel, slot, drop_target, box); + 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, false, path); + 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); - const overlay_css = updateOverlaySheet(slot, box, style, drop_target); + 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 = `${CUSTOM_EVENT_NAME_PREFIX}-before-update`; + const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-before-update`; const event = new CustomEvent(event_name, { detail: panel }); this.dispatchEvent(event); }; @@ -208,12 +226,16 @@ export class RegularLayout extends HTMLElement { x: number, y: number, drag_target: LayoutPath, - className: string = OVERLAY_CLASSNAME, + className: string = this._physics.OVERLAY_CLASSNAME, ) => { let panel = this._panel; panel = remove_child(panel, drag_target.slot); Array.from(this.children) - .find((x) => x.getAttribute("name") === drag_target.slot) + .find( + (x) => + x.getAttribute(this._physics.CHILD_ATTRIBUTE_NAME) === + drag_target.slot, + ) ?.classList.remove(className); const [col, row, box] = this.relativeCoordinates(x, y, false); @@ -226,6 +248,7 @@ export class RegularLayout extends HTMLElement { drag_target.slot, drop_target, box, + this._physics, ); } @@ -323,9 +346,10 @@ export class RegularLayout extends HTMLElement { */ restore = (layout: Layout, _is_flattened: boolean = false) => { this._panel = !_is_flattened ? flatten(layout) : layout; - const css = create_css_grid_layout(this._panel); + const css = create_css_grid_layout(this._panel, undefined, this._physics); + this._stylesheet.replaceSync(css); - const event_name = `${CUSTOM_EVENT_NAME_PREFIX}-update`; + const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-update`; const event = new CustomEvent(event_name, { detail: this._panel }); this.dispatchEvent(event); }; @@ -346,6 +370,27 @@ export class RegularLayout extends HTMLElement { return structuredClone(this._panel); }; + /** + * Override this instance's global constants. + * + * @param physics + */ + restorePhysics(physics: PhysicsUpdate) { + this._physics = Object.freeze({ + ...this._physics, + ...physics, + }); + } + + /** + * Get this instance's constants. + * + * @returns The current constants + */ + savePhysics(): Physics { + return this._physics; + } + /** * Converts screen coordinates to relative layout coordinates. * @@ -388,13 +433,16 @@ export class RegularLayout extends HTMLElement { }; private onPointerDown = (event: PointerEvent) => { - if (event.target === this) { - const [col, row, box] = this.relativeCoordinates( + if (!this._physics.GRID_DIVIDER_CHECK_TARGET || event.target === this) { + const [col, row, rect] = this.relativeCoordinates( event.clientX, event.clientY, ); - const hit = calculate_intersection(col, row, this._panel, box); + const hit = calculate_intersection(col, row, this._panel, { + rect, + size: this._physics.GRID_DIVIDER_SIZE, + }); if (hit && hit.type !== "layout-path") { this._drag_target = [hit, col, row]; this.setPointerCapture(event.pointerId); @@ -414,22 +462,37 @@ export class RegularLayout extends HTMLElement { 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)); - } else if (event.target === this) { - const [col, row, box] = this.relativeCoordinates( - event.clientX, - event.clientY, - false, + this._stylesheet.replaceSync( + create_css_grid_layout(panel, undefined, this._physics), ); + } - const divider = calculate_intersection(col, row, this._panel, box); - if (divider?.type === "vertical") { - this._cursor_stylesheet.replaceSync(":host{cursor:row-resize"); - this._cursor_override = true; - } else if (divider?.type === "horizontal") { - this._cursor_stylesheet.replaceSync(":host{cursor:col-resize"); - this._cursor_override = true; + if (this._physics.GRID_DIVIDER_CHECK_TARGET && event.target !== this) { + if (this._cursor_override) { + this._cursor_override = false; + this._cursor_stylesheet.replaceSync(""); } + + return; + } + + const [col, row, rect] = this.relativeCoordinates( + event.clientX, + event.clientY, + false, + ); + + const divider = calculate_intersection(col, row, this._panel, { + rect, + size: this._physics.GRID_DIVIDER_SIZE, + }); + + if (divider?.type === "vertical") { + this._cursor_stylesheet.replaceSync(":host{cursor:row-resize"); + this._cursor_override = true; + } else if (divider?.type === "horizontal") { + this._cursor_stylesheet.replaceSync(":host{cursor:col-resize"); + this._cursor_override = true; } else if (this._cursor_override) { this._cursor_override = false; this._cursor_stylesheet.replaceSync(""); diff --git a/tests/unit/calculate_edge.spec.ts b/tests/unit/calculate_edge.spec.ts index 2e289ac..42222e9 100644 --- a/tests/unit/calculate_edge.spec.ts +++ b/tests/unit/calculate_edge.spec.ts @@ -13,7 +13,7 @@ import { expect, test } from "@playwright/test"; import { LAYOUTS } from "../helpers/fixtures.ts"; import { calculate_edge } from "../../src/layout/calculate_edge.ts"; import { calculate_intersection } from "../../src/layout/calculate_intersect.ts"; -import type { LayoutPath, TabLayout } from "../../src/layout/types.ts"; +import type { LayoutPath } from "../../src/layout/types.ts"; test("cursor in center of panel - no split", () => { const drop_target = calculate_intersection(0.3, 0.5, LAYOUTS.NESTED_BASIC); @@ -245,7 +245,7 @@ test("integrated far right edge", () => { } expect(drop_target?.is_edge).toBe(true); - expect(drop_target?.path).toStrictEqual([1]); + expect(drop_target?.path).toStrictEqual([2]); expect(drop_target?.view_window).toStrictEqual({ col_end: 1, col_start: 0.5, diff --git a/tests/unit/css_grid_layout.spec.ts b/tests/unit/css_grid_layout.spec.ts index 4af2180..31e87f7 100644 --- a/tests/unit/css_grid_layout.spec.ts +++ b/tests/unit/css_grid_layout.spec.ts @@ -14,6 +14,7 @@ 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"; const RESULT = ` :host ::slotted(*){display:none}:host{display:grid;grid-template-rows:30fr 70fr;grid-template-columns:60fr 40fr} @@ -23,7 +24,10 @@ const RESULT = ` `.trim(); test("simple test", async () => { - const css = create_css_grid_layout(LAYOUTS.NESTED_BASIC, true); + const css = create_css_grid_layout(LAYOUTS.NESTED_BASIC, undefined, { + ...DEFAULT_PHYSICS, + SHOULD_ROUND: true, + }); expect(css).toBe(RESULT); }); @@ -33,7 +37,12 @@ test("single child panel", () => { child: ["ONLY"], }; - expect(create_css_grid_layout(singleChild, true)).toEqual( + expect( + create_css_grid_layout(singleChild, undefined, { + ...DEFAULT_PHYSICS, + SHOULD_ROUND: true, + }), + ).toEqual( `:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:100%;grid-template-columns:100%}\n:host ::slotted([name="ONLY"]){display:flex;grid-column:1;grid-row:1}`, ); }); @@ -66,7 +75,12 @@ test("regressions", () => { orientation: "horizontal", }; - expect(create_css_grid_layout(test, true)).toEqual( + expect( + create_css_grid_layout(test, undefined, { + ...DEFAULT_PHYSICS, + SHOULD_ROUND: true, + }), + ).toEqual( ` :host ::slotted(*){display:none}:host{display:grid;grid-template-rows:80fr 20fr;grid-template-columns:60fr 40fr} :host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1} @@ -119,7 +133,12 @@ test("deeply nested css grid", () => { orientation: "horizontal", }; - expect(create_css_grid_layout(test, true)).toEqual( + expect( + create_css_grid_layout(test, undefined, { + ...DEFAULT_PHYSICS, + SHOULD_ROUND: true, + }), + ).toEqual( ` :host ::slotted(*){display:none}:host{display:grid;grid-template-rows:30fr 60fr 10fr;grid-template-columns:30fr 30fr 40fr} :host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1} @@ -185,7 +204,12 @@ test("Deeply nested CSS grid part 2", () => { orientation: "horizontal", }; - expect(create_css_grid_layout(test, true)).toEqual( + expect( + create_css_grid_layout(test, undefined, { + ...DEFAULT_PHYSICS, + SHOULD_ROUND: true, + }), + ).toEqual( ` :host ::slotted(*){display:none}:host{display:grid;grid-template-rows:30fr 60fr 10fr;grid-template-columns:30fr 30fr 20fr 20fr} :host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1} @@ -230,7 +254,12 @@ test("parallel", () => { orientation: "horizontal", }; - expect(create_css_grid_layout(test, true)).toEqual( + expect( + create_css_grid_layout(test, undefined, { + ...DEFAULT_PHYSICS, + SHOULD_ROUND: true, + }), + ).toEqual( ` :host ::slotted(*){display:none}:host{display:grid;grid-template-rows:30fr 70fr;grid-template-columns:33fr 33fr 33fr} :host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1} @@ -280,7 +309,12 @@ test("Parallel split-panels with different sizes", () => { orientation: "horizontal", }; - expect(create_css_grid_layout(test, true)).toEqual( + expect( + create_css_grid_layout(test, undefined, { + ...DEFAULT_PHYSICS, + SHOULD_ROUND: true, + }), + ).toEqual( ` :host ::slotted(*){display:none}:host{display:grid;grid-template-rows:30fr 40fr 30fr;grid-template-columns:50fr 50fr} :host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1} @@ -341,7 +375,12 @@ test("Deeply alternating split", () => { orientation: "horizontal", }; - expect(create_css_grid_layout(test, true)).toEqual( + expect( + create_css_grid_layout(test, undefined, { + ...DEFAULT_PHYSICS, + SHOULD_ROUND: true, + }), + ).toEqual( ` :host ::slotted(*){display:none}:host{display:grid;grid-template-rows:15fr 15fr 70fr;grid-template-columns:30fr 30fr 40fr} :host ::slotted([name="VfssXzLK"]){display:flex;grid-column:1;grid-row:1 / 3} diff --git a/tests/unit/css_grid_layout_partial.spec.ts b/tests/unit/css_grid_layout_partial.spec.ts index e827678..42fd979 100644 --- a/tests/unit/css_grid_layout_partial.spec.ts +++ b/tests/unit/css_grid_layout_partial.spec.ts @@ -13,6 +13,7 @@ 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"; test("Deeply alternating split with grid-based overlay", () => { const test: Layout = { @@ -64,7 +65,12 @@ test("Deeply alternating split with grid-based overlay", () => { orientation: "horizontal", }; - expect(create_css_grid_layout(test, false, ["BBB", "AAA"])).toEqual( + expect( + create_css_grid_layout(test, ["BBB", "AAA"], { + ...DEFAULT_PHYSICS, + SHOULD_ROUND: true, + }), + ).toEqual( ` :host ::slotted(*){display:none}:host{display:grid;grid-template-rows:15fr 15fr 70fr;grid-template-columns:30fr 30fr 40fr} :host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1 / 3} diff --git a/tests/unit/hit_detection.spec.ts b/tests/unit/hit_detection.spec.ts index dfc2c8e..feb21a5 100644 --- a/tests/unit/hit_detection.spec.ts +++ b/tests/unit/hit_detection.spec.ts @@ -12,6 +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"; test("AAA", () => { const result = calculate_intersection(0.1, 0.1, LAYOUTS.NESTED_BASIC); @@ -81,9 +82,13 @@ test("CCC", () => { test("gap", () => { const result = calculate_intersection(0.6, 0.1, LAYOUTS.NESTED_BASIC, { - width: 100, - height: 100, - } as DOMRect); + size: DEFAULT_PHYSICS.GRID_DIVIDER_SIZE, + rect: { + width: 100, + height: 100, + } as DOMRect, + }); + expect(result).toStrictEqual({ path: [0], type: "horizontal", @@ -98,9 +103,12 @@ test("gap", () => { test("nested gap", () => { const result = calculate_intersection(0.1, 0.3, LAYOUTS.NESTED_BASIC, { - width: 100, - height: 100, - } as DOMRect); + size: DEFAULT_PHYSICS.GRID_DIVIDER_SIZE, + rect: { + width: 100, + height: 100, + } as DOMRect, + }); expect(result).toStrictEqual({ path: [0, 0], type: "vertical", @@ -115,9 +123,12 @@ test("nested gap", () => { test("single AAA", () => { const result = calculate_intersection(0.1, 0.3, LAYOUTS.SINGLE_AAA, { - width: 100, - height: 100, - } as DOMRect); + size: DEFAULT_PHYSICS.GRID_DIVIDER_SIZE, + rect: { + width: 100, + height: 100, + } as DOMRect, + }); expect(result).toStrictEqual({ layout: undefined, column: 0.1, diff --git a/themes/lorax.css b/themes/lorax.css index 46cf0ad..37046c2 100644 --- a/themes/lorax.css +++ b/themes/lorax.css @@ -18,7 +18,7 @@ regular-layout.lorax { regular-layout.lorax regular-layout-frame { margin: 3px; margin-top: 27px; - border-radius: 0 0 6px 6px; + border-radius: 0 6px 6px 6px; border: 1px solid #666; box-shadow: 0px 6px 6px -4px rgba(150, 150, 180); } @@ -30,6 +30,7 @@ regular-layout.lorax regular-layout-frame::part(titlebar) { margin-right: -1px; margin-bottom: 0px; margin-top: -24px; + padding-right: 6px; } regular-layout.lorax regular-layout-frame::part(tab) {