regular-layout 0.2.1 → 0.3.0

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.
@@ -25,6 +25,7 @@ import type {
25
25
  TabLayout,
26
26
  OverlayMode,
27
27
  Orientation,
28
+ LayoutPathTraversal,
28
29
  } from "./layout/types.ts";
29
30
  import { calculate_intersection } from "./layout/calculate_intersect.ts";
30
31
  import { remove_child } from "./layout/remove_child.ts";
@@ -33,12 +34,23 @@ import { redistribute_panel_sizes } from "./layout/redistribute_panel_sizes.ts";
33
34
  import { updateOverlaySheet } from "./layout/generate_overlay.ts";
34
35
  import { calculate_edge } from "./layout/calculate_edge.ts";
35
36
  import { flatten } from "./layout/flatten.ts";
37
+ import { calculate_path } from "./layout/calculate_path.ts";
36
38
  import {
37
39
  DEFAULT_PHYSICS,
38
40
  type PhysicsUpdate,
39
41
  type Physics,
40
42
  } from "./layout/constants.ts";
41
43
 
44
+ /**
45
+ * An interface which models the fields of `PointerEvent` that
46
+ * `<regular-layour>` actually uses, making it easier to sub out with an
47
+ * JavaScript object lieral when you don't have a `PointerEvent` handy.
48
+ */
49
+ export interface PointerEventCoordinates {
50
+ clientX: number;
51
+ clientY: number;
52
+ }
53
+
42
54
  /**
43
55
  * A Web Component that provides a resizable panel layout system.
44
56
  * Panels are arranged using CSS Grid and can be resized by dragging dividers.
@@ -71,6 +83,22 @@ import {
71
83
  * layout.restore(state);
72
84
  * ```
73
85
  *
86
+ * @remarks
87
+ *
88
+ * Why does this implementation use a `<slot>` at all? We must use
89
+ * `<slot>` and the Shadow DOM to scope the grid CSS rules to each
90
+ * instance of `<regular-layout>` (without e.g. giving them unique
91
+ * `"id"` and injecting into `document,head`), and we can only select
92
+ * `::slotted` light DOM children from `adoptedStyleSheets` on the
93
+ * `ShadowRoot`.
94
+ *
95
+ * Why does this implementation use a single `<slot>` and the child
96
+ * `"name"` attribute, as opposed to a named `<slot name="my_slot">`
97
+ * and the built-in `"slot"` child attribute? Children with a `"slot"`
98
+ * attribute don't fallback to the un-named `<slot>`, so using the
99
+ * latter implementation would require synchronizing the light DOM
100
+ * and shadow DOM slots/slotted children continuously.
101
+ *
74
102
  */
75
103
  export class RegularLayout extends HTMLElement {
76
104
  private _shadowRoot: ShadowRoot;
@@ -86,17 +114,6 @@ export class RegularLayout extends HTMLElement {
86
114
  super();
87
115
  this._physics = DEFAULT_PHYSICS;
88
116
  this._panel = structuredClone(EMPTY_PANEL);
89
-
90
- // Why does this implementation use a `<slot>` at all? We must use
91
- // `<slot>` and the Shadow DOM to scope the grid CSS rules to each
92
- // instance of `<regular-layout>` (without e.g. giving them unique
93
- // `"id"` and injecting into `document,head`), and we can only select
94
- // `::slotted` light DOM children from `adoptedStyleSheets` on the
95
- // `ShadowRoot`.
96
-
97
- // In addition, this model uses a single un-named `<slot>` to host all
98
- // light-DOM children, and the child's `"name"` attribute to identify
99
- // its position in the `Layout`. Alternatively, using named
100
117
  this._shadowRoot = this.attachShadow({ mode: "open" });
101
118
  this._shadowRoot.innerHTML = `<slot></slot>`;
102
119
  this._stylesheet = new CSSStyleSheet();
@@ -109,12 +126,14 @@ export class RegularLayout extends HTMLElement {
109
126
  }
110
127
 
111
128
  connectedCallback() {
129
+ this.addEventListener("dblclick", this.onDblClick);
112
130
  this.addEventListener("pointerdown", this.onPointerDown);
113
131
  this.addEventListener("pointerup", this.onPointerUp);
114
132
  this.addEventListener("pointermove", this.onPointerMove);
115
133
  }
116
134
 
117
135
  disconnectedCallback() {
136
+ this.removeEventListener("dblclick", this.onDblClick);
118
137
  this.removeEventListener("pointerdown", this.onPointerDown);
119
138
  this.removeEventListener("pointerup", this.onPointerUp);
120
139
  this.removeEventListener("pointermove", this.onPointerMove);
@@ -123,36 +142,33 @@ export class RegularLayout extends HTMLElement {
123
142
  /**
124
143
  * Determines which panel is at a given screen coordinate.
125
144
  *
126
- * @param column - X coordinate in screen pixels.
127
- * @param row - Y coordinate in screen pixels.
145
+ * @param coordinates - `PointerEvent`, `MouseEvent`, or just X and Y
146
+ * coordinates in screen pixels.
128
147
  * @returns Panel information if a panel is at that position, null otherwise.
129
148
  */
130
149
  calculateIntersect = (
131
- x: number,
132
- y: number,
133
- check_dividers: boolean = false,
134
- ): LayoutPath<Layout> | null => {
135
- const [col, row, rect] = this.relativeCoordinates(x, y, false);
136
- const panel = calculate_intersection(
137
- col,
138
- row,
139
- this._panel,
140
- check_dividers ? { rect, size: this._physics.GRID_DIVIDER_SIZE } : null,
141
- );
142
-
143
- if (panel?.type === "layout-path") {
144
- return { ...panel, layout: this.save() };
145
- }
150
+ coordinates: PointerEventCoordinates,
151
+ ): LayoutPath | null => {
152
+ const [col, row, _] = this.relativeCoordinates(coordinates, false);
153
+ return calculate_intersection(col, row, this._panel);
154
+ };
146
155
 
147
- return null;
156
+ /**
157
+ * Calculates the index path for a panel with the given name.
158
+ *
159
+ * @param name - The name of the panel to find.
160
+ * @returns The panel's index path if found, `null` otherwise.
161
+ */
162
+ calculatePath = (name: string): number[] | null => {
163
+ return calculate_path(name, this._panel);
148
164
  };
149
165
 
150
166
  /**
151
167
  * Sets the visual overlay state during drag-and-drop operations.
152
168
  * Displays a preview of where a panel would be placed at the given coordinates.
153
169
  *
154
- * @param x - X coordinate in screen pixels.
155
- * @param y - Y coordinate in screen pixels.
170
+ * @param event - `PointerEvent`, `MouseEvent`, or just X and Y
171
+ * coordinates in screen pixels.
156
172
  * @param dragTarget - A `LayoutPath` (presumably from `calculateIntersect`)
157
173
  * which points to the drag element in the current layout.
158
174
  * @param className - The CSS class name to use for the overlay panel
@@ -162,18 +178,20 @@ export class RegularLayout extends HTMLElement {
162
178
  * "absolute".
163
179
  */
164
180
  setOverlayState = (
165
- x: number,
166
- y: number,
167
- { slot }: LayoutPath<unknown>,
181
+ event: PointerEventCoordinates,
182
+ { slot }: LayoutPath,
168
183
  className: string = this._physics.OVERLAY_CLASSNAME,
169
184
  mode: OverlayMode = this._physics.OVERLAY_DEFAULT,
170
185
  ) => {
171
186
  const panel = remove_child(this._panel, slot);
172
- Array.from(this.children)
173
- .find((x) => x.getAttribute(this._physics.CHILD_ATTRIBUTE_NAME) === slot)
174
- ?.classList.add(className);
187
+ const query = `:scope > [${this._physics.CHILD_ATTRIBUTE_NAME}="${slot}"]`;
188
+ const drag_element = this.querySelector(query);
189
+ if (drag_element) {
190
+ drag_element.classList.add(className);
191
+ }
175
192
 
176
- const [col, row, box, style] = this.relativeCoordinates(x, y, false);
193
+ // TODO: Don't recalculate box (but this currently protects against resize).
194
+ const [col, row, box, style] = this.relativeCoordinates(event, true);
177
195
  let drop_target = calculate_intersection(col, row, panel);
178
196
  if (drop_target) {
179
197
  drop_target = calculate_edge(
@@ -193,7 +211,6 @@ export class RegularLayout extends HTMLElement {
193
211
  this._stylesheet.replaceSync(css);
194
212
  } else if (mode === "absolute") {
195
213
  const grid_css = create_css_grid_layout(panel, undefined, this._physics);
196
-
197
214
  const overlay_css = updateOverlaySheet(
198
215
  slot,
199
216
  box,
@@ -206,15 +223,15 @@ export class RegularLayout extends HTMLElement {
206
223
  }
207
224
 
208
225
  const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-before-update`;
209
- const event = new CustomEvent<Layout>(event_name, { detail: panel });
210
- this.dispatchEvent(event);
226
+ const custom_event = new CustomEvent<Layout>(event_name, { detail: panel });
227
+ this.dispatchEvent(custom_event);
211
228
  };
212
229
 
213
230
  /**
214
231
  * Clears the overlay state and commits the panel placement.
215
232
  *
216
- * @param x - X coordinate in screen pixels.
217
- * @param y - Y coordinate in screen pixels.
233
+ * @param event - `PointerEvent`, `MouseEvent`, or just X and Y
234
+ * coordinates in screen pixels.
218
235
  * @param dragTarget - A `LayoutPath` (presumably from `calculateIntersect`)
219
236
  * which points to the drag element in the current layout.
220
237
  * @param className - The CSS class name to use for the overlay panel
@@ -223,46 +240,53 @@ export class RegularLayout extends HTMLElement {
223
240
  * passed to `setOverlayState`. Defaults to "absolute".
224
241
  */
225
242
  clearOverlayState = (
226
- x: number,
227
- y: number,
228
- drag_target: LayoutPath<Layout>,
243
+ event: PointerEventCoordinates | null,
244
+ { slot, layout }: LayoutPath,
229
245
  className: string = this._physics.OVERLAY_CLASSNAME,
230
246
  ) => {
231
247
  let panel = this._panel;
232
- panel = remove_child(panel, drag_target.slot);
233
- Array.from(this.children)
234
- .find(
235
- (x) =>
236
- x.getAttribute(this._physics.CHILD_ATTRIBUTE_NAME) ===
237
- drag_target.slot,
238
- )
239
- ?.classList.remove(className);
240
-
241
- const [col, row, box] = this.relativeCoordinates(x, y, false);
248
+ panel = remove_child(panel, slot);
249
+ const query = `:scope > [${this._physics.CHILD_ATTRIBUTE_NAME}="${slot}"]`;
250
+ const drag_element = this.querySelector(query);
251
+ if (drag_element) {
252
+ drag_element.classList.remove(className);
253
+ }
254
+
255
+ if (event === null) {
256
+ this.restore(layout);
257
+ return;
258
+ }
259
+
260
+ const [col, row, box] = this.relativeCoordinates(event, false);
242
261
  let drop_target = calculate_intersection(col, row, panel);
243
262
  if (drop_target) {
244
263
  drop_target = calculate_edge(
245
264
  col,
246
265
  row,
247
266
  panel,
248
- drag_target.slot,
267
+ slot,
249
268
  drop_target,
250
269
  box,
251
270
  this._physics,
252
271
  );
253
272
  }
254
273
 
255
- const { path, orientation } = drop_target ? drop_target : drag_target;
256
- const new_layout = drop_target
257
- ? insert_child(
258
- panel,
259
- drag_target.slot,
260
- path,
261
- drop_target?.is_edge ? orientation : undefined,
262
- )
263
- : drag_target.layout;
264
-
265
- this.restore(new_layout);
274
+ if (drop_target) {
275
+ const orientation = drop_target?.is_edge
276
+ ? drop_target.orientation
277
+ : undefined;
278
+
279
+ const new_layout = insert_child(
280
+ panel,
281
+ slot,
282
+ drop_target.path,
283
+ orientation,
284
+ );
285
+
286
+ this.restore(new_layout);
287
+ } else {
288
+ this.restore(layout);
289
+ }
266
290
  };
267
291
 
268
292
  /**
@@ -278,7 +302,7 @@ export class RegularLayout extends HTMLElement {
278
302
  */
279
303
  insertPanel = (
280
304
  name: string,
281
- path: number[] = [],
305
+ path: LayoutPathTraversal = [],
282
306
  split?: boolean | Orientation,
283
307
  ) => {
284
308
  let orientation: Orientation | undefined;
@@ -308,10 +332,11 @@ export class RegularLayout extends HTMLElement {
308
332
  * @returns The TabLayout containing the panel if found, null otherwise.
309
333
  */
310
334
  getPanel = (name: string, layout: Layout = this._panel): TabLayout | null => {
311
- if (layout.type === "child-panel") {
312
- if (layout.child.includes(name)) {
335
+ if (layout.type === "tab-layout") {
336
+ if (layout.tabs.includes(name)) {
313
337
  return layout;
314
338
  }
339
+
315
340
  return null;
316
341
  }
317
342
 
@@ -347,7 +372,6 @@ export class RegularLayout extends HTMLElement {
347
372
  restore = (layout: Layout, _is_flattened: boolean = false) => {
348
373
  this._panel = !_is_flattened ? flatten(layout) : layout;
349
374
  const css = create_css_grid_layout(this._panel, undefined, this._physics);
350
-
351
375
  this._stylesheet.replaceSync(css);
352
376
  const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-update`;
353
377
  const event = new CustomEvent<Layout>(event_name, { detail: this._panel });
@@ -397,16 +421,15 @@ export class RegularLayout extends HTMLElement {
397
421
  * Transforms absolute pixel positions into normalized coordinates (0-1 range)
398
422
  * relative to the layout's bounding box.
399
423
  *
400
- * @param clientX - X coordinate in screen pixels (client space).
401
- * @param clientY - Y coordinate in screen pixels (client space).
424
+ * @param coordinates - `PointerEvent`, `MouseEvent`, or just X and Y
425
+ * coordinates in screen pixels.
402
426
  * @returns A tuple containing:
403
427
  * - col: Normalized X coordinate (0 = left edge, 1 = right edge)
404
428
  * - row: Normalized Y coordinate (0 = top edge, 1 = bottom edge)
405
429
  * - box: The layout element's bounding rectangle
406
430
  */
407
431
  relativeCoordinates = (
408
- clientX: number,
409
- clientY: number,
432
+ event: PointerEventCoordinates,
410
433
  recalculate_bounds: boolean = true,
411
434
  ): [number, number, DOMRect, CSSStyleDeclaration] => {
412
435
  if (recalculate_bounds || !this._dimensions) {
@@ -419,12 +442,13 @@ export class RegularLayout extends HTMLElement {
419
442
  const box = this._dimensions.box;
420
443
  const style = this._dimensions.style;
421
444
  const col =
422
- (clientX - box.left - parseFloat(style.paddingLeft)) /
445
+ (event.clientX - box.left - parseFloat(style.paddingLeft)) /
423
446
  (box.width -
424
447
  parseFloat(style.paddingLeft) -
425
448
  parseFloat(style.paddingRight));
449
+
426
450
  const row =
427
- (clientY - box.top - parseFloat(style.paddingTop)) /
451
+ (event.clientY - box.top - parseFloat(style.paddingTop)) /
428
452
  (box.height -
429
453
  parseFloat(style.paddingTop) -
430
454
  parseFloat(style.paddingBottom));
@@ -432,17 +456,49 @@ export class RegularLayout extends HTMLElement {
432
456
  return [col, row, box, style];
433
457
  };
434
458
 
435
- private onPointerDown = (event: PointerEvent) => {
436
- if (!this._physics.GRID_DIVIDER_CHECK_TARGET || event.target === this) {
437
- const [col, row, rect] = this.relativeCoordinates(
438
- event.clientX,
439
- event.clientY,
459
+ /**
460
+ * Calculates the Euclidean distance in pixels between the current pointer
461
+ * coordinates and a drag target's position within the layout.
462
+ *
463
+ * @param coordinates - The current pointer event coordinates.
464
+ * @param drag_target - The layout path representing the drag target
465
+ * position.
466
+ * @returns The distance in pixels between the coordinates and the drag
467
+ * target.
468
+ */
469
+ diffCoordinates = (
470
+ event: PointerEventCoordinates,
471
+ drag_target: LayoutPath,
472
+ ): number => {
473
+ const [column, row, box] = this.relativeCoordinates(event, false);
474
+ const dx = (column - drag_target.column) * box.width;
475
+ const dy = (row - drag_target.row) * box.height;
476
+ return Math.sqrt(dx ** 2 + dy ** 2);
477
+ };
478
+
479
+ private onDblClick = (event: MouseEvent) => {
480
+ const [col, row, rect] = this.relativeCoordinates(event, false);
481
+ const divider = calculate_intersection(col, row, this._panel, {
482
+ rect,
483
+ size: this._physics.GRID_DIVIDER_SIZE,
484
+ });
485
+
486
+ if (divider?.type === "horizontal" || divider?.type === "vertical") {
487
+ const panel = redistribute_panel_sizes(
488
+ this._panel,
489
+ divider.path,
490
+ undefined,
440
491
  );
441
492
 
442
- const hit = calculate_intersection(col, row, this._panel, {
443
- rect,
444
- size: this._physics.GRID_DIVIDER_SIZE,
445
- });
493
+ this.restore(panel, true);
494
+ }
495
+ };
496
+
497
+ private onPointerDown = (event: PointerEvent) => {
498
+ if (!this._physics.GRID_DIVIDER_CHECK_TARGET || event.target === this) {
499
+ const [col, row, rect] = this.relativeCoordinates(event);
500
+ const size = this._physics.GRID_DIVIDER_SIZE;
501
+ const hit = calculate_intersection(col, row, this._panel, { rect, size });
446
502
  if (hit && hit.type !== "layout-path") {
447
503
  this._drag_target = [hit, col, row];
448
504
  this.setPointerCapture(event.pointerId);
@@ -453,12 +509,7 @@ export class RegularLayout extends HTMLElement {
453
509
 
454
510
  private onPointerMove = (event: PointerEvent) => {
455
511
  if (this._drag_target) {
456
- const [col, row] = this.relativeCoordinates(
457
- event.clientX,
458
- event.clientY,
459
- false,
460
- );
461
-
512
+ const [col, row] = this.relativeCoordinates(event, false);
462
513
  const [{ path, type }, old_col, old_row] = this._drag_target;
463
514
  const offset = type === "horizontal" ? old_col - col : old_row - row;
464
515
  const panel = redistribute_panel_sizes(this._panel, path, offset);
@@ -476,12 +527,7 @@ export class RegularLayout extends HTMLElement {
476
527
  return;
477
528
  }
478
529
 
479
- const [col, row, rect] = this.relativeCoordinates(
480
- event.clientX,
481
- event.clientY,
482
- false,
483
- );
484
-
530
+ const [col, row, rect] = this.relativeCoordinates(event, false);
485
531
  const divider = calculate_intersection(col, row, this._panel, {
486
532
  rect,
487
533
  size: this._physics.GRID_DIVIDER_SIZE,
@@ -502,12 +548,7 @@ export class RegularLayout extends HTMLElement {
502
548
  private onPointerUp = (event: PointerEvent) => {
503
549
  if (this._drag_target) {
504
550
  this.releasePointerCapture(event.pointerId);
505
- const [col, row] = this.relativeCoordinates(
506
- event.clientX,
507
- event.clientY,
508
- false,
509
- );
510
-
551
+ const [col, row] = this.relativeCoordinates(event, false);
511
552
  const [{ path, type }, old_col, old_row] = this._drag_target;
512
553
  const offset = type === "horizontal" ? old_col - col : old_row - row;
513
554
  const panel = redistribute_panel_sizes(this._panel, path, offset);