regular-layout 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,334 @@
1
+ // ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
2
+ // ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░
3
+ // ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░
4
+ // ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░
5
+ // ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
6
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
7
+ // ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃
8
+ // ┃ * of the Regular Layout library, distributed under the terms of the * ┃
9
+ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃
10
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
11
+
12
+ /**
13
+ * This module defines the `<regular-layout>` custom element for browser
14
+ * environments.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ import { EMPTY_PANEL, iter_panel_children } from "./common/layout_config.ts";
20
+ import { create_css_grid_layout } from "./common/generate_grid.ts";
21
+ import type {
22
+ LayoutPath,
23
+ Layout,
24
+ LayoutDivider,
25
+ } from "./common/layout_config.ts";
26
+ import { calculate_intersection } from "./common/calculate_intersect.ts";
27
+ import { remove_child } from "./common/remove_child.ts";
28
+ import { insert_child } from "./common/insert_child.ts";
29
+ import { redistribute_panel_sizes } from "./common/redistribute_panel_sizes.ts";
30
+ import { updateOverlaySheet } from "./common/generate_overlay.ts";
31
+ import { calculate_split } from "./common/calculate_split.ts";
32
+ import { flatten } from "./common/flatten.ts";
33
+
34
+ export type OverlayMode = "grid" | "absolute" | "interactive";
35
+
36
+ const OVERLAY_DEFAULT: OverlayMode = "absolute";
37
+
38
+ /**
39
+ * A Web Component that provides a resizable panel layout system.
40
+ * Panels are arranged using CSS Grid and can be resized by dragging dividers.
41
+ *
42
+ * The component uses Shadow DOM and CSS Grid to manage layout.
43
+ *
44
+ * @example
45
+ * ```html
46
+ * <regular-layout>
47
+ * <div slot="sidebar">Sidebar content</div>
48
+ * <div slot="main">Main content</div>
49
+ * </regular-layout>
50
+ * ```
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * const layout = document.querySelector('regular-layout');
55
+ *
56
+ * // Insert panels into the grid layout
57
+ * layout.insertPanel('main');
58
+ * layout.insertPanel('sidebar');
59
+ *
60
+ * // Remove a panel (DOM child is still connected, but not slotted)
61
+ * layout.removePanel('sidebar');
62
+ *
63
+ * // Save current layout
64
+ * const state = layout.save();
65
+ *
66
+ * // Restore layout later
67
+ * layout.restore(state);
68
+ * ```
69
+ *
70
+ */
71
+ export class RegularLayout extends HTMLElement {
72
+ private _shadowRoot: ShadowRoot;
73
+ private _panel: Layout;
74
+ private _stylesheet: CSSStyleSheet;
75
+ private _dragPath?: [LayoutDivider, number, number];
76
+ private _slots: Map<string, HTMLSlotElement>;
77
+ private _unslotted_slot: HTMLSlotElement;
78
+
79
+ constructor() {
80
+ super();
81
+ this._panel = structuredClone(EMPTY_PANEL);
82
+ this._stylesheet = new CSSStyleSheet();
83
+ this._unslotted_slot = document.createElement("slot");
84
+ this._shadowRoot = this.attachShadow({ mode: "open" });
85
+ this._shadowRoot.adoptedStyleSheets = [this._stylesheet];
86
+ this._shadowRoot.appendChild(this._unslotted_slot);
87
+ this._slots = new Map();
88
+ this.onPointerDown = this.onPointerDown.bind(this);
89
+ this.onPointerMove = this.onPointerMove.bind(this);
90
+ this.onPointerUp = this.onPointerUp.bind(this);
91
+ }
92
+
93
+ connectedCallback() {
94
+ this.addEventListener("pointerdown", this.onPointerDown);
95
+ this.addEventListener("pointerup", this.onPointerUp);
96
+ this.addEventListener("pointermove", this.onPointerMove);
97
+ }
98
+
99
+ disconnectedCallback() {
100
+ this.removeEventListener("pointerdown", this.onPointerDown);
101
+ this.removeEventListener("pointerup", this.onPointerUp);
102
+ this.removeEventListener("pointermove", this.onPointerMove);
103
+ }
104
+
105
+ /**
106
+ * Sets the visual overlay state during drag-and-drop operations.
107
+ * Displays a preview of where a panel would be placed at the given coordinates.
108
+ *
109
+ * @param x - X coordinate in screen pixels.
110
+ * @param y - Y coordinate in screen pixels.
111
+ * @param layoutPath - Layout path containing the slot identifier.
112
+ * @param mode - Overlay rendering mode: "grid" highlights the target,
113
+ * "absolute" positions the panel absolutely, "interactive" updates the
114
+ * actual layout in real-time. Defaults to "absolute".
115
+ */
116
+ setOverlayState<T>(
117
+ x: number,
118
+ y: number,
119
+ { slot }: LayoutPath<T>,
120
+ mode: "grid" | "absolute" | "interactive" = OVERLAY_DEFAULT,
121
+ ) {
122
+ let panel = this._panel;
123
+ if (mode === "absolute") {
124
+ panel = remove_child(panel, slot);
125
+ this._slots.get(slot)?.assignedElements()[0]?.removeAttribute("slot");
126
+ }
127
+
128
+ const [col, row, box] = this.relativeCoordinates(x, y);
129
+ let drop_target = calculate_intersection(col, row, panel, false);
130
+ if (drop_target) {
131
+ drop_target = calculate_split(col, row, panel, slot, drop_target);
132
+ }
133
+
134
+ if (drop_target) {
135
+ if (mode === "interactive") {
136
+ let new_panel = remove_child(this._panel, slot);
137
+ new_panel = flatten(insert_child(new_panel, slot, drop_target.path));
138
+ const css = create_css_grid_layout(new_panel);
139
+ this._stylesheet.replaceSync(css);
140
+ } else if (mode === "grid") {
141
+ const path: [string, string] = [slot, drop_target.slot];
142
+ const css = create_css_grid_layout(this._panel, false, path);
143
+ this._stylesheet.replaceSync(css);
144
+ } else if (mode === "absolute") {
145
+ const css = `${create_css_grid_layout(panel)}\n${updateOverlaySheet({ ...drop_target, box })}`;
146
+ this._stylesheet.replaceSync(css);
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Clears the overlay state and commits the panel placement.
153
+ *
154
+ * @param x - X coordinate in screen pixels.
155
+ * @param y - Y coordinate in screen pixels.
156
+ * @param layout_path - Layout path containing the slot identifier.
157
+ * @param mode - Overlay rendering mode that was used, must match the mode
158
+ * passed to `setOverlayState`. Defaults to "absolute".
159
+ */
160
+ clearOverlayState<T>(
161
+ x: number,
162
+ y: number,
163
+ drag_target: LayoutPath<T>,
164
+ mode: "grid" | "absolute" | "interactive" = OVERLAY_DEFAULT,
165
+ ) {
166
+ let panel = this._panel;
167
+ if (mode === "absolute") {
168
+ panel = remove_child(panel, drag_target.slot);
169
+ this._unslotted_slot
170
+ .assignedElements()[0]
171
+ ?.setAttribute("slot", drag_target.slot);
172
+ }
173
+
174
+ const [col, row, _] = this.relativeCoordinates(x, y);
175
+ let drop_target = calculate_intersection(col, row, panel, false);
176
+ if (drop_target) {
177
+ // TODO I think I only need the new path here?
178
+ drop_target = calculate_split(
179
+ col,
180
+ row,
181
+ panel,
182
+ drag_target.slot,
183
+ drop_target,
184
+ );
185
+ }
186
+
187
+ const { path } = drop_target ? drop_target : drag_target;
188
+ this.removePanel(drag_target.slot);
189
+ this.insertPanel(drag_target.slot, path);
190
+ }
191
+
192
+ /**
193
+ * Inserts a new panel into the layout at a specified path.
194
+ *
195
+ * @param name - Unique identifier for the new panel.
196
+ * @param path - Index path defining where to insert.
197
+ */
198
+ insertPanel(name: string, path: number[] = []) {
199
+ this.restore(insert_child(this._panel, name, path));
200
+ }
201
+
202
+ /**
203
+ * Removes a panel from the layout by name.
204
+ *
205
+ * @param name - Name of the panel to remove
206
+ */
207
+ removePanel(name: string) {
208
+ this.restore(remove_child(this._panel, name));
209
+ }
210
+
211
+ /**
212
+ * Determines which panel is at a given screen coordinate.
213
+ * Useful for drag-and-drop operations or custom interactions.
214
+ *
215
+ * @param column - X coordinate in screen pixels.
216
+ * @param row - Y coordinate in screen pixels.
217
+ * @returns Panel information if a panel is at that position, null otherwise.
218
+ */
219
+ calculateIntersect(
220
+ x: number,
221
+ y: number,
222
+ check_dividers: boolean = false,
223
+ ): LayoutPath<DOMRect> | null {
224
+ const [col, row, box] = this.relativeCoordinates(x, y);
225
+ const panel = calculate_intersection(col, row, this._panel, check_dividers);
226
+ if (panel?.type === "layout-path") {
227
+ return { ...panel, box };
228
+ }
229
+
230
+ return null;
231
+ }
232
+
233
+ /**
234
+ * Restores the layout from a saved state.
235
+ *
236
+ * @param layout - The layout tree to restore
237
+ *
238
+ * @example
239
+ * ```typescript
240
+ * const layout = document.querySelector('regular-layout');
241
+ * const savedState = JSON.parse(localStorage.getItem('layout'));
242
+ * layout.restore(savedState);
243
+ * ```
244
+ */
245
+ restore(layout: Layout, _is_flattened: boolean = false) {
246
+ this._panel = !_is_flattened ? flatten(layout) : layout;
247
+ const css = create_css_grid_layout(layout);
248
+ this._stylesheet.replaceSync(css);
249
+ const old = new Set(this._slots.keys());
250
+ for (const name of iter_panel_children(layout)) {
251
+ old.delete(name);
252
+ if (!this._slots.has(name)) {
253
+ const slot = document.createElement("slot");
254
+ slot.setAttribute("name", name);
255
+ this._shadowRoot.appendChild(slot);
256
+ this._slots.set(name, slot);
257
+ }
258
+ }
259
+
260
+ for (const key of old) {
261
+ const child = this._slots.get(key);
262
+ if (child) {
263
+ this._shadowRoot.removeChild(child);
264
+ this._slots.delete(key);
265
+ }
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Serializes the current layout state, which can be restored via `restore`.
271
+ *
272
+ * @returns The current layout tree
273
+ *
274
+ * @example
275
+ * ```typescript
276
+ * const layout = document.querySelector('regular-layout');
277
+ * const state = layout.save();
278
+ * localStorage.setItem('layout', JSON.stringify(state));
279
+ * ```
280
+ */
281
+ save(): Layout {
282
+ return structuredClone(this._panel);
283
+ }
284
+
285
+ private relativeCoordinates(
286
+ clientX: number,
287
+ clientY: number,
288
+ ): [number, number, DOMRect] {
289
+ const box = this.getBoundingClientRect();
290
+ const col = (clientX - box.left) / (box.right - box.left);
291
+ const row = (clientY - box.top) / (box.bottom - box.top);
292
+ return [col, row, box];
293
+ }
294
+
295
+ private onPointerDown(event: PointerEvent) {
296
+ const [col, row] = this.relativeCoordinates(event.clientX, event.clientY);
297
+ const hit = calculate_intersection(col, row, this._panel);
298
+ if (hit && hit.type !== "layout-path") {
299
+ this._dragPath = [hit, col, row];
300
+ this.setPointerCapture(event.pointerId);
301
+ event.preventDefault();
302
+ event.stopImmediatePropagation();
303
+ }
304
+ }
305
+
306
+ private onPointerMove(event: PointerEvent) {
307
+ if (this._dragPath) {
308
+ const [col, row] = this.relativeCoordinates(event.clientX, event.clientY);
309
+ const old_panel = this._panel;
310
+ const [{ path, type }, old_col, old_row] = this._dragPath;
311
+ const offset = type === "horizontal" ? old_col - col : old_row - row;
312
+ const panel = redistribute_panel_sizes(old_panel, path, offset);
313
+ this._stylesheet.replaceSync(create_css_grid_layout(panel));
314
+ }
315
+ }
316
+
317
+ private onPointerUp(event: PointerEvent) {
318
+ if (this._dragPath) {
319
+ this.releasePointerCapture(event.pointerId);
320
+ const [col, row] = this.relativeCoordinates(event.clientX, event.clientY);
321
+ const old_panel = this._panel;
322
+ const [{ path }, old_col, old_row] = this._dragPath;
323
+ if (this._dragPath[0].type === "horizontal") {
324
+ const panel = redistribute_panel_sizes(old_panel, path, old_col - col);
325
+ this.restore(panel, true);
326
+ } else {
327
+ const panel = redistribute_panel_sizes(old_panel, path, old_row - row);
328
+ this.restore(panel, true);
329
+ }
330
+
331
+ this._dragPath = undefined;
332
+ }
333
+ }
334
+ }