regular-layout 0.2.1 → 0.2.2

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.
@@ -40,11 +40,11 @@ export class RegularLayoutTab extends HTMLElement {
40
40
  (index === this._tab_panel?.selected);
41
41
 
42
42
  const index_changed =
43
- tab_changed || this._tab_panel?.child[index] !== tab_panel.child[index];
43
+ tab_changed || this._tab_panel?.tabs[index] !== tab_panel.tabs[index];
44
44
 
45
45
  if (index_changed) {
46
46
  const selected = tab_panel.selected === index;
47
- const slot = tab_panel.child[index];
47
+ const slot = tab_panel.tabs[index];
48
48
  this.children[0].textContent = slot;
49
49
 
50
50
  if (selected) {
@@ -56,7 +56,7 @@ export class RegularLayoutTab extends HTMLElement {
56
56
  }
57
57
  }
58
58
  } else {
59
- const slot = tab_panel.child[index];
59
+ const slot = tab_panel.tabs[index];
60
60
  const selected = tab_panel.selected === index;
61
61
  const parts = selected ? "active-close close" : "close";
62
62
  this.innerHTML = `<div part="title"></div><button part="${parts}"></button>`;
@@ -78,7 +78,7 @@ export class RegularLayoutTab extends HTMLElement {
78
78
 
79
79
  private onTabClose = (_: Event) => {
80
80
  if (this._tab_panel !== undefined && this._index !== undefined) {
81
- this._layout?.removePanel(this._tab_panel.child[this._index]);
81
+ this._layout?.removePanel(this._tab_panel.tabs[this._index]);
82
82
  }
83
83
  };
84
84
 
@@ -90,7 +90,7 @@ export class RegularLayoutTab extends HTMLElement {
90
90
  ) {
91
91
  const new_layout = this._layout?.save();
92
92
  const new_tab_panel = this._layout?.getPanel(
93
- this._tab_panel.child[this._index],
93
+ this._tab_panel.tabs[this._index],
94
94
  new_layout,
95
95
  );
96
96
 
@@ -39,6 +39,16 @@ import {
39
39
  type Physics,
40
40
  } from "./layout/constants.ts";
41
41
 
42
+ /**
43
+ * An interface which models the fields of `PointerEvent` that
44
+ * `<regular-layour>` actually uses, making it easier to sub out with an
45
+ * JavaScript object lieral when you don't have a `PointerEvent` handy.
46
+ */
47
+ export interface PointerEventCoordinates {
48
+ clientX: number;
49
+ clientY: number;
50
+ }
51
+
42
52
  /**
43
53
  * A Web Component that provides a resizable panel layout system.
44
54
  * Panels are arranged using CSS Grid and can be resized by dragging dividers.
@@ -71,6 +81,22 @@ import {
71
81
  * layout.restore(state);
72
82
  * ```
73
83
  *
84
+ * @remarks
85
+ *
86
+ * Why does this implementation use a `<slot>` at all? We must use
87
+ * `<slot>` and the Shadow DOM to scope the grid CSS rules to each
88
+ * instance of `<regular-layout>` (without e.g. giving them unique
89
+ * `"id"` and injecting into `document,head`), and we can only select
90
+ * `::slotted` light DOM children from `adoptedStyleSheets` on the
91
+ * `ShadowRoot`.
92
+ *
93
+ * Why does this implementation use a single `<slot>` and the child
94
+ * `"name"` attribute, as opposed to a named `<slot name="my_slot">`
95
+ * and the built-in `"slot"` child attribute? Children with a `"slot"`
96
+ * attribute don't fallback to the un-named `<slot>`, so using the
97
+ * latter implementation would require synchronizing the light DOM
98
+ * and shadow DOM slots/slotted children continuously.
99
+ *
74
100
  */
75
101
  export class RegularLayout extends HTMLElement {
76
102
  private _shadowRoot: ShadowRoot;
@@ -86,17 +112,6 @@ export class RegularLayout extends HTMLElement {
86
112
  super();
87
113
  this._physics = DEFAULT_PHYSICS;
88
114
  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
115
  this._shadowRoot = this.attachShadow({ mode: "open" });
101
116
  this._shadowRoot.innerHTML = `<slot></slot>`;
102
117
  this._stylesheet = new CSSStyleSheet();
@@ -109,12 +124,14 @@ export class RegularLayout extends HTMLElement {
109
124
  }
110
125
 
111
126
  connectedCallback() {
127
+ this.addEventListener("dblclick", this.onDblClick);
112
128
  this.addEventListener("pointerdown", this.onPointerDown);
113
129
  this.addEventListener("pointerup", this.onPointerUp);
114
130
  this.addEventListener("pointermove", this.onPointerMove);
115
131
  }
116
132
 
117
133
  disconnectedCallback() {
134
+ this.removeEventListener("dblclick", this.onDblClick);
118
135
  this.removeEventListener("pointerdown", this.onPointerDown);
119
136
  this.removeEventListener("pointerup", this.onPointerUp);
120
137
  this.removeEventListener("pointermove", this.onPointerMove);
@@ -123,36 +140,23 @@ export class RegularLayout extends HTMLElement {
123
140
  /**
124
141
  * Determines which panel is at a given screen coordinate.
125
142
  *
126
- * @param column - X coordinate in screen pixels.
127
- * @param row - Y coordinate in screen pixels.
143
+ * @param coordinates - `PointerEvent`, `MouseEvent`, or just X and Y
144
+ * coordinates in screen pixels.
128
145
  * @returns Panel information if a panel is at that position, null otherwise.
129
146
  */
130
147
  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
- }
146
-
147
- return null;
148
+ coordinates: PointerEventCoordinates,
149
+ ): LayoutPath | null => {
150
+ const [col, row, _] = this.relativeCoordinates(coordinates, false);
151
+ return calculate_intersection(col, row, this._panel);
148
152
  };
149
153
 
150
154
  /**
151
155
  * Sets the visual overlay state during drag-and-drop operations.
152
156
  * Displays a preview of where a panel would be placed at the given coordinates.
153
157
  *
154
- * @param x - X coordinate in screen pixels.
155
- * @param y - Y coordinate in screen pixels.
158
+ * @param event - `PointerEvent`, `MouseEvent`, or just X and Y
159
+ * coordinates in screen pixels.
156
160
  * @param dragTarget - A `LayoutPath` (presumably from `calculateIntersect`)
157
161
  * which points to the drag element in the current layout.
158
162
  * @param className - The CSS class name to use for the overlay panel
@@ -162,18 +166,20 @@ export class RegularLayout extends HTMLElement {
162
166
  * "absolute".
163
167
  */
164
168
  setOverlayState = (
165
- x: number,
166
- y: number,
167
- { slot }: LayoutPath<unknown>,
169
+ event: PointerEventCoordinates,
170
+ { slot }: LayoutPath,
168
171
  className: string = this._physics.OVERLAY_CLASSNAME,
169
172
  mode: OverlayMode = this._physics.OVERLAY_DEFAULT,
170
173
  ) => {
171
174
  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);
175
+ const query = `:scope > [${this._physics.CHILD_ATTRIBUTE_NAME}="${slot}"]`;
176
+ const drag_element = this.querySelector(query);
177
+ if (drag_element) {
178
+ drag_element.classList.add(className);
179
+ }
175
180
 
176
- const [col, row, box, style] = this.relativeCoordinates(x, y, false);
181
+ // TODO: Don't recalculate box (but this currently protects against resize).
182
+ const [col, row, box, style] = this.relativeCoordinates(event, true);
177
183
  let drop_target = calculate_intersection(col, row, panel);
178
184
  if (drop_target) {
179
185
  drop_target = calculate_edge(
@@ -193,7 +199,6 @@ export class RegularLayout extends HTMLElement {
193
199
  this._stylesheet.replaceSync(css);
194
200
  } else if (mode === "absolute") {
195
201
  const grid_css = create_css_grid_layout(panel, undefined, this._physics);
196
-
197
202
  const overlay_css = updateOverlaySheet(
198
203
  slot,
199
204
  box,
@@ -206,15 +211,15 @@ export class RegularLayout extends HTMLElement {
206
211
  }
207
212
 
208
213
  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);
214
+ const custom_event = new CustomEvent<Layout>(event_name, { detail: panel });
215
+ this.dispatchEvent(custom_event);
211
216
  };
212
217
 
213
218
  /**
214
219
  * Clears the overlay state and commits the panel placement.
215
220
  *
216
- * @param x - X coordinate in screen pixels.
217
- * @param y - Y coordinate in screen pixels.
221
+ * @param event - `PointerEvent`, `MouseEvent`, or just X and Y
222
+ * coordinates in screen pixels.
218
223
  * @param dragTarget - A `LayoutPath` (presumably from `calculateIntersect`)
219
224
  * which points to the drag element in the current layout.
220
225
  * @param className - The CSS class name to use for the overlay panel
@@ -223,46 +228,53 @@ export class RegularLayout extends HTMLElement {
223
228
  * passed to `setOverlayState`. Defaults to "absolute".
224
229
  */
225
230
  clearOverlayState = (
226
- x: number,
227
- y: number,
228
- drag_target: LayoutPath<Layout>,
231
+ event: PointerEventCoordinates | null,
232
+ { slot, layout }: LayoutPath,
229
233
  className: string = this._physics.OVERLAY_CLASSNAME,
230
234
  ) => {
231
235
  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);
236
+ panel = remove_child(panel, slot);
237
+ const query = `:scope > [${this._physics.CHILD_ATTRIBUTE_NAME}="${slot}"]`;
238
+ const drag_element = this.querySelector(query);
239
+ if (drag_element) {
240
+ drag_element.classList.remove(className);
241
+ }
242
+
243
+ if (event === null) {
244
+ this.restore(layout);
245
+ return;
246
+ }
247
+
248
+ const [col, row, box] = this.relativeCoordinates(event, false);
242
249
  let drop_target = calculate_intersection(col, row, panel);
243
250
  if (drop_target) {
244
251
  drop_target = calculate_edge(
245
252
  col,
246
253
  row,
247
254
  panel,
248
- drag_target.slot,
255
+ slot,
249
256
  drop_target,
250
257
  box,
251
258
  this._physics,
252
259
  );
253
260
  }
254
261
 
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);
262
+ if (drop_target) {
263
+ const orientation = drop_target?.is_edge
264
+ ? drop_target.orientation
265
+ : undefined;
266
+
267
+ const new_layout = insert_child(
268
+ panel,
269
+ slot,
270
+ drop_target.path,
271
+ orientation,
272
+ );
273
+
274
+ this.restore(new_layout);
275
+ } else {
276
+ this.restore(layout);
277
+ }
266
278
  };
267
279
 
268
280
  /**
@@ -309,9 +321,10 @@ export class RegularLayout extends HTMLElement {
309
321
  */
310
322
  getPanel = (name: string, layout: Layout = this._panel): TabLayout | null => {
311
323
  if (layout.type === "child-panel") {
312
- if (layout.child.includes(name)) {
324
+ if (layout.tabs.includes(name)) {
313
325
  return layout;
314
326
  }
327
+
315
328
  return null;
316
329
  }
317
330
 
@@ -347,7 +360,6 @@ export class RegularLayout extends HTMLElement {
347
360
  restore = (layout: Layout, _is_flattened: boolean = false) => {
348
361
  this._panel = !_is_flattened ? flatten(layout) : layout;
349
362
  const css = create_css_grid_layout(this._panel, undefined, this._physics);
350
-
351
363
  this._stylesheet.replaceSync(css);
352
364
  const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-update`;
353
365
  const event = new CustomEvent<Layout>(event_name, { detail: this._panel });
@@ -397,16 +409,15 @@ export class RegularLayout extends HTMLElement {
397
409
  * Transforms absolute pixel positions into normalized coordinates (0-1 range)
398
410
  * relative to the layout's bounding box.
399
411
  *
400
- * @param clientX - X coordinate in screen pixels (client space).
401
- * @param clientY - Y coordinate in screen pixels (client space).
412
+ * @param coordinates - `PointerEvent`, `MouseEvent`, or just X and Y
413
+ * coordinates in screen pixels.
402
414
  * @returns A tuple containing:
403
415
  * - col: Normalized X coordinate (0 = left edge, 1 = right edge)
404
416
  * - row: Normalized Y coordinate (0 = top edge, 1 = bottom edge)
405
417
  * - box: The layout element's bounding rectangle
406
418
  */
407
419
  relativeCoordinates = (
408
- clientX: number,
409
- clientY: number,
420
+ event: PointerEventCoordinates,
410
421
  recalculate_bounds: boolean = true,
411
422
  ): [number, number, DOMRect, CSSStyleDeclaration] => {
412
423
  if (recalculate_bounds || !this._dimensions) {
@@ -419,12 +430,13 @@ export class RegularLayout extends HTMLElement {
419
430
  const box = this._dimensions.box;
420
431
  const style = this._dimensions.style;
421
432
  const col =
422
- (clientX - box.left - parseFloat(style.paddingLeft)) /
433
+ (event.clientX - box.left - parseFloat(style.paddingLeft)) /
423
434
  (box.width -
424
435
  parseFloat(style.paddingLeft) -
425
436
  parseFloat(style.paddingRight));
437
+
426
438
  const row =
427
- (clientY - box.top - parseFloat(style.paddingTop)) /
439
+ (event.clientY - box.top - parseFloat(style.paddingTop)) /
428
440
  (box.height -
429
441
  parseFloat(style.paddingTop) -
430
442
  parseFloat(style.paddingBottom));
@@ -432,17 +444,49 @@ export class RegularLayout extends HTMLElement {
432
444
  return [col, row, box, style];
433
445
  };
434
446
 
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,
447
+ /**
448
+ * Calculates the Euclidean distance in pixels between the current pointer
449
+ * coordinates and a drag target's position within the layout.
450
+ *
451
+ * @param coordinates - The current pointer event coordinates.
452
+ * @param drag_target - The layout path representing the drag target
453
+ * position.
454
+ * @returns The distance in pixels between the coordinates and the drag
455
+ * target.
456
+ */
457
+ diffCoordinates = (
458
+ event: PointerEventCoordinates,
459
+ drag_target: LayoutPath,
460
+ ): number => {
461
+ const [column, row, box] = this.relativeCoordinates(event, false);
462
+ const dx = (column - drag_target.column) * box.width;
463
+ const dy = (row - drag_target.row) * box.height;
464
+ return Math.sqrt(dx ** 2 + dy ** 2);
465
+ };
466
+
467
+ private onDblClick = (event: MouseEvent) => {
468
+ const [col, row, rect] = this.relativeCoordinates(event, false);
469
+ const divider = calculate_intersection(col, row, this._panel, {
470
+ rect,
471
+ size: this._physics.GRID_DIVIDER_SIZE,
472
+ });
473
+
474
+ if (divider?.type === "horizontal" || divider?.type === "vertical") {
475
+ const panel = redistribute_panel_sizes(
476
+ this._panel,
477
+ divider.path,
478
+ undefined,
440
479
  );
441
480
 
442
- const hit = calculate_intersection(col, row, this._panel, {
443
- rect,
444
- size: this._physics.GRID_DIVIDER_SIZE,
445
- });
481
+ this.restore(panel, true);
482
+ }
483
+ };
484
+
485
+ private onPointerDown = (event: PointerEvent) => {
486
+ if (!this._physics.GRID_DIVIDER_CHECK_TARGET || event.target === this) {
487
+ const [col, row, rect] = this.relativeCoordinates(event);
488
+ const size = this._physics.GRID_DIVIDER_SIZE;
489
+ const hit = calculate_intersection(col, row, this._panel, { rect, size });
446
490
  if (hit && hit.type !== "layout-path") {
447
491
  this._drag_target = [hit, col, row];
448
492
  this.setPointerCapture(event.pointerId);
@@ -453,12 +497,7 @@ export class RegularLayout extends HTMLElement {
453
497
 
454
498
  private onPointerMove = (event: PointerEvent) => {
455
499
  if (this._drag_target) {
456
- const [col, row] = this.relativeCoordinates(
457
- event.clientX,
458
- event.clientY,
459
- false,
460
- );
461
-
500
+ const [col, row] = this.relativeCoordinates(event, false);
462
501
  const [{ path, type }, old_col, old_row] = this._drag_target;
463
502
  const offset = type === "horizontal" ? old_col - col : old_row - row;
464
503
  const panel = redistribute_panel_sizes(this._panel, path, offset);
@@ -476,12 +515,7 @@ export class RegularLayout extends HTMLElement {
476
515
  return;
477
516
  }
478
517
 
479
- const [col, row, rect] = this.relativeCoordinates(
480
- event.clientX,
481
- event.clientY,
482
- false,
483
- );
484
-
518
+ const [col, row, rect] = this.relativeCoordinates(event, false);
485
519
  const divider = calculate_intersection(col, row, this._panel, {
486
520
  rect,
487
521
  size: this._physics.GRID_DIVIDER_SIZE,
@@ -502,12 +536,7 @@ export class RegularLayout extends HTMLElement {
502
536
  private onPointerUp = (event: PointerEvent) => {
503
537
  if (this._drag_target) {
504
538
  this.releasePointerCapture(event.pointerId);
505
- const [col, row] = this.relativeCoordinates(
506
- event.clientX,
507
- event.clientY,
508
- false,
509
- );
510
-
539
+ const [col, row] = this.relativeCoordinates(event, false);
511
540
  const [{ path, type }, old_col, old_row] = this._drag_target;
512
541
  const offset = type === "horizontal" ? old_col - col : old_row - row;
513
542
  const panel = redistribute_panel_sizes(this._panel, path, offset);