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.
- package/LICENSE.md +194 -0
- package/README.md +55 -0
- package/dist/common/calculate_intersect.d.ts +18 -0
- package/dist/common/calculate_split.d.ts +2 -0
- package/dist/common/flatten.d.ts +13 -0
- package/dist/common/generate_grid.d.ts +32 -0
- package/dist/common/generate_overlay.d.ts +2 -0
- package/dist/common/insert_child.d.ts +15 -0
- package/dist/common/layout_config.d.ts +92 -0
- package/dist/common/redistribute_panel_sizes.d.ts +19 -0
- package/dist/common/remove_child.d.ts +13 -0
- package/dist/extensions.d.ts +15 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +7 -0
- package/dist/regular-layout-frame.d.ts +38 -0
- package/dist/regular-layout.d.ts +120 -0
- package/package.json +35 -0
- package/src/common/calculate_intersect.ts +176 -0
- package/src/common/calculate_split.ts +53 -0
- package/src/common/flatten.ts +57 -0
- package/src/common/generate_grid.ts +249 -0
- package/src/common/generate_overlay.ts +25 -0
- package/src/common/insert_child.ts +129 -0
- package/src/common/layout_config.ts +127 -0
- package/src/common/redistribute_panel_sizes.ts +100 -0
- package/src/common/remove_child.ts +102 -0
- package/src/extensions.ts +40 -0
- package/src/index.ts +72 -0
- package/src/regular-layout-frame.ts +114 -0
- package/src/regular-layout.ts +334 -0
|
@@ -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
|
+
}
|