regular-layout 0.2.0 → 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.
@@ -34,11 +34,21 @@ import { updateOverlaySheet } from "./layout/generate_overlay.ts";
34
34
  import { calculate_edge } from "./layout/calculate_edge.ts";
35
35
  import { flatten } from "./layout/flatten.ts";
36
36
  import {
37
- CUSTOM_EVENT_NAME_PREFIX,
38
- OVERLAY_CLASSNAME,
39
- OVERLAY_DEFAULT,
37
+ DEFAULT_PHYSICS,
38
+ type PhysicsUpdate,
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;
@@ -80,21 +106,12 @@ export class RegularLayout extends HTMLElement {
80
106
  private _drag_target?: [LayoutDivider, number, number];
81
107
  private _cursor_override: boolean;
82
108
  private _dimensions?: { box: DOMRect; style: CSSStyleDeclaration };
109
+ private _physics: Physics;
83
110
 
84
111
  constructor() {
85
112
  super();
113
+ this._physics = DEFAULT_PHYSICS;
86
114
  this._panel = structuredClone(EMPTY_PANEL);
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
- // In addition, this model uses a single un-named `<slot>` to host all
96
- // light-DOM children, and the child's `"name"` attribute to identify
97
- // its position in the `Layout`. Alternatively, using named
98
115
  this._shadowRoot = this.attachShadow({ mode: "open" });
99
116
  this._shadowRoot.innerHTML = `<slot></slot>`;
100
117
  this._stylesheet = new CSSStyleSheet();
@@ -107,12 +124,14 @@ export class RegularLayout extends HTMLElement {
107
124
  }
108
125
 
109
126
  connectedCallback() {
127
+ this.addEventListener("dblclick", this.onDblClick);
110
128
  this.addEventListener("pointerdown", this.onPointerDown);
111
129
  this.addEventListener("pointerup", this.onPointerUp);
112
130
  this.addEventListener("pointermove", this.onPointerMove);
113
131
  }
114
132
 
115
133
  disconnectedCallback() {
134
+ this.removeEventListener("dblclick", this.onDblClick);
116
135
  this.removeEventListener("pointerdown", this.onPointerDown);
117
136
  this.removeEventListener("pointerup", this.onPointerUp);
118
137
  this.removeEventListener("pointermove", this.onPointerMove);
@@ -121,36 +140,23 @@ export class RegularLayout extends HTMLElement {
121
140
  /**
122
141
  * Determines which panel is at a given screen coordinate.
123
142
  *
124
- * @param column - X coordinate in screen pixels.
125
- * @param row - Y coordinate in screen pixels.
143
+ * @param coordinates - `PointerEvent`, `MouseEvent`, or just X and Y
144
+ * coordinates in screen pixels.
126
145
  * @returns Panel information if a panel is at that position, null otherwise.
127
146
  */
128
147
  calculateIntersect = (
129
- x: number,
130
- y: number,
131
- check_dividers: boolean = false,
132
- ): LayoutPath<Layout> | null => {
133
- const [col, row, box] = this.relativeCoordinates(x, y, false);
134
- const panel = calculate_intersection(
135
- col,
136
- row,
137
- this._panel,
138
- check_dividers ? box : null,
139
- );
140
-
141
- if (panel?.type === "layout-path") {
142
- return { ...panel, layout: this.save() };
143
- }
144
-
145
- return null;
148
+ coordinates: PointerEventCoordinates,
149
+ ): LayoutPath | null => {
150
+ const [col, row, _] = this.relativeCoordinates(coordinates, false);
151
+ return calculate_intersection(col, row, this._panel);
146
152
  };
147
153
 
148
154
  /**
149
155
  * Sets the visual overlay state during drag-and-drop operations.
150
156
  * Displays a preview of where a panel would be placed at the given coordinates.
151
157
  *
152
- * @param x - X coordinate in screen pixels.
153
- * @param y - Y coordinate in screen pixels.
158
+ * @param event - `PointerEvent`, `MouseEvent`, or just X and Y
159
+ * coordinates in screen pixels.
154
160
  * @param dragTarget - A `LayoutPath` (presumably from `calculateIntersect`)
155
161
  * which points to the drag element in the current layout.
156
162
  * @param className - The CSS class name to use for the overlay panel
@@ -160,43 +166,60 @@ export class RegularLayout extends HTMLElement {
160
166
  * "absolute".
161
167
  */
162
168
  setOverlayState = (
163
- x: number,
164
- y: number,
165
- { slot }: LayoutPath<unknown>,
166
- className: string = OVERLAY_CLASSNAME,
167
- mode: OverlayMode = OVERLAY_DEFAULT,
169
+ event: PointerEventCoordinates,
170
+ { slot }: LayoutPath,
171
+ className: string = this._physics.OVERLAY_CLASSNAME,
172
+ mode: OverlayMode = this._physics.OVERLAY_DEFAULT,
168
173
  ) => {
169
174
  const panel = remove_child(this._panel, slot);
170
- Array.from(this.children)
171
- .find((x) => x.getAttribute("name") === slot)
172
- ?.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
+ }
173
180
 
174
- 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);
175
183
  let drop_target = calculate_intersection(col, row, panel);
176
184
  if (drop_target) {
177
- drop_target = calculate_edge(col, row, panel, slot, drop_target, box);
185
+ drop_target = calculate_edge(
186
+ col,
187
+ row,
188
+ panel,
189
+ slot,
190
+ drop_target,
191
+ box,
192
+ this._physics,
193
+ );
178
194
  }
179
195
 
180
196
  if (mode === "grid" && drop_target) {
181
197
  const path: [string, string] = [slot, drop_target?.slot];
182
- const css = create_css_grid_layout(panel, false, path);
198
+ const css = create_css_grid_layout(panel, path, this._physics);
183
199
  this._stylesheet.replaceSync(css);
184
200
  } else if (mode === "absolute") {
185
- const grid_css = create_css_grid_layout(panel);
186
- const overlay_css = updateOverlaySheet(slot, box, style, drop_target);
201
+ const grid_css = create_css_grid_layout(panel, undefined, this._physics);
202
+ const overlay_css = updateOverlaySheet(
203
+ slot,
204
+ box,
205
+ style,
206
+ drop_target,
207
+ this._physics,
208
+ );
209
+
187
210
  this._stylesheet.replaceSync([grid_css, overlay_css].join("\n"));
188
211
  }
189
212
 
190
- const event_name = `${CUSTOM_EVENT_NAME_PREFIX}-before-update`;
191
- const event = new CustomEvent<Layout>(event_name, { detail: panel });
192
- this.dispatchEvent(event);
213
+ const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-before-update`;
214
+ const custom_event = new CustomEvent<Layout>(event_name, { detail: panel });
215
+ this.dispatchEvent(custom_event);
193
216
  };
194
217
 
195
218
  /**
196
219
  * Clears the overlay state and commits the panel placement.
197
220
  *
198
- * @param x - X coordinate in screen pixels.
199
- * @param y - Y coordinate in screen pixels.
221
+ * @param event - `PointerEvent`, `MouseEvent`, or just X and Y
222
+ * coordinates in screen pixels.
200
223
  * @param dragTarget - A `LayoutPath` (presumably from `calculateIntersect`)
201
224
  * which points to the drag element in the current layout.
202
225
  * @param className - The CSS class name to use for the overlay panel
@@ -205,41 +228,53 @@ export class RegularLayout extends HTMLElement {
205
228
  * passed to `setOverlayState`. Defaults to "absolute".
206
229
  */
207
230
  clearOverlayState = (
208
- x: number,
209
- y: number,
210
- drag_target: LayoutPath<Layout>,
211
- className: string = OVERLAY_CLASSNAME,
231
+ event: PointerEventCoordinates | null,
232
+ { slot, layout }: LayoutPath,
233
+ className: string = this._physics.OVERLAY_CLASSNAME,
212
234
  ) => {
213
235
  let panel = this._panel;
214
- panel = remove_child(panel, drag_target.slot);
215
- Array.from(this.children)
216
- .find((x) => x.getAttribute("name") === drag_target.slot)
217
- ?.classList.remove(className);
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
+ }
218
247
 
219
- const [col, row, box] = this.relativeCoordinates(x, y, false);
248
+ const [col, row, box] = this.relativeCoordinates(event, false);
220
249
  let drop_target = calculate_intersection(col, row, panel);
221
250
  if (drop_target) {
222
251
  drop_target = calculate_edge(
223
252
  col,
224
253
  row,
225
254
  panel,
226
- drag_target.slot,
255
+ slot,
227
256
  drop_target,
228
257
  box,
258
+ this._physics,
229
259
  );
230
260
  }
231
261
 
232
- const { path, orientation } = drop_target ? drop_target : drag_target;
233
- const new_layout = drop_target
234
- ? insert_child(
235
- panel,
236
- drag_target.slot,
237
- path,
238
- drop_target?.is_edge ? orientation : undefined,
239
- )
240
- : drag_target.layout;
241
-
242
- 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
+ }
243
278
  };
244
279
 
245
280
  /**
@@ -286,9 +321,10 @@ export class RegularLayout extends HTMLElement {
286
321
  */
287
322
  getPanel = (name: string, layout: Layout = this._panel): TabLayout | null => {
288
323
  if (layout.type === "child-panel") {
289
- if (layout.child.includes(name)) {
324
+ if (layout.tabs.includes(name)) {
290
325
  return layout;
291
326
  }
327
+
292
328
  return null;
293
329
  }
294
330
 
@@ -323,9 +359,9 @@ export class RegularLayout extends HTMLElement {
323
359
  */
324
360
  restore = (layout: Layout, _is_flattened: boolean = false) => {
325
361
  this._panel = !_is_flattened ? flatten(layout) : layout;
326
- const css = create_css_grid_layout(this._panel);
362
+ const css = create_css_grid_layout(this._panel, undefined, this._physics);
327
363
  this._stylesheet.replaceSync(css);
328
- const event_name = `${CUSTOM_EVENT_NAME_PREFIX}-update`;
364
+ const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-update`;
329
365
  const event = new CustomEvent<Layout>(event_name, { detail: this._panel });
330
366
  this.dispatchEvent(event);
331
367
  };
@@ -346,22 +382,42 @@ export class RegularLayout extends HTMLElement {
346
382
  return structuredClone(this._panel);
347
383
  };
348
384
 
385
+ /**
386
+ * Override this instance's global constants.
387
+ *
388
+ * @param physics
389
+ */
390
+ restorePhysics(physics: PhysicsUpdate) {
391
+ this._physics = Object.freeze({
392
+ ...this._physics,
393
+ ...physics,
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Get this instance's constants.
399
+ *
400
+ * @returns The current constants
401
+ */
402
+ savePhysics(): Physics {
403
+ return this._physics;
404
+ }
405
+
349
406
  /**
350
407
  * Converts screen coordinates to relative layout coordinates.
351
408
  *
352
409
  * Transforms absolute pixel positions into normalized coordinates (0-1 range)
353
410
  * relative to the layout's bounding box.
354
411
  *
355
- * @param clientX - X coordinate in screen pixels (client space).
356
- * @param clientY - Y coordinate in screen pixels (client space).
412
+ * @param coordinates - `PointerEvent`, `MouseEvent`, or just X and Y
413
+ * coordinates in screen pixels.
357
414
  * @returns A tuple containing:
358
415
  * - col: Normalized X coordinate (0 = left edge, 1 = right edge)
359
416
  * - row: Normalized Y coordinate (0 = top edge, 1 = bottom edge)
360
417
  * - box: The layout element's bounding rectangle
361
418
  */
362
419
  relativeCoordinates = (
363
- clientX: number,
364
- clientY: number,
420
+ event: PointerEventCoordinates,
365
421
  recalculate_bounds: boolean = true,
366
422
  ): [number, number, DOMRect, CSSStyleDeclaration] => {
367
423
  if (recalculate_bounds || !this._dimensions) {
@@ -374,12 +430,13 @@ export class RegularLayout extends HTMLElement {
374
430
  const box = this._dimensions.box;
375
431
  const style = this._dimensions.style;
376
432
  const col =
377
- (clientX - box.left - parseFloat(style.paddingLeft)) /
433
+ (event.clientX - box.left - parseFloat(style.paddingLeft)) /
378
434
  (box.width -
379
435
  parseFloat(style.paddingLeft) -
380
436
  parseFloat(style.paddingRight));
437
+
381
438
  const row =
382
- (clientY - box.top - parseFloat(style.paddingTop)) /
439
+ (event.clientY - box.top - parseFloat(style.paddingTop)) /
383
440
  (box.height -
384
441
  parseFloat(style.paddingTop) -
385
442
  parseFloat(style.paddingBottom));
@@ -387,14 +444,49 @@ export class RegularLayout extends HTMLElement {
387
444
  return [col, row, box, style];
388
445
  };
389
446
 
390
- private onPointerDown = (event: PointerEvent) => {
391
- if (event.target === this) {
392
- const [col, row, box] = this.relativeCoordinates(
393
- event.clientX,
394
- 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,
395
479
  );
396
480
 
397
- const hit = calculate_intersection(col, row, this._panel, box);
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 });
398
490
  if (hit && hit.type !== "layout-path") {
399
491
  this._drag_target = [hit, col, row];
400
492
  this.setPointerCapture(event.pointerId);
@@ -405,31 +497,36 @@ export class RegularLayout extends HTMLElement {
405
497
 
406
498
  private onPointerMove = (event: PointerEvent) => {
407
499
  if (this._drag_target) {
408
- const [col, row] = this.relativeCoordinates(
409
- event.clientX,
410
- event.clientY,
411
- false,
412
- );
413
-
500
+ const [col, row] = this.relativeCoordinates(event, false);
414
501
  const [{ path, type }, old_col, old_row] = this._drag_target;
415
502
  const offset = type === "horizontal" ? old_col - col : old_row - row;
416
503
  const panel = redistribute_panel_sizes(this._panel, path, offset);
417
- this._stylesheet.replaceSync(create_css_grid_layout(panel));
418
- } else if (event.target === this) {
419
- const [col, row, box] = this.relativeCoordinates(
420
- event.clientX,
421
- event.clientY,
422
- false,
504
+ this._stylesheet.replaceSync(
505
+ create_css_grid_layout(panel, undefined, this._physics),
423
506
  );
507
+ }
424
508
 
425
- const divider = calculate_intersection(col, row, this._panel, box);
426
- if (divider?.type === "vertical") {
427
- this._cursor_stylesheet.replaceSync(":host{cursor:row-resize");
428
- this._cursor_override = true;
429
- } else if (divider?.type === "horizontal") {
430
- this._cursor_stylesheet.replaceSync(":host{cursor:col-resize");
431
- this._cursor_override = true;
509
+ if (this._physics.GRID_DIVIDER_CHECK_TARGET && event.target !== this) {
510
+ if (this._cursor_override) {
511
+ this._cursor_override = false;
512
+ this._cursor_stylesheet.replaceSync("");
432
513
  }
514
+
515
+ return;
516
+ }
517
+
518
+ const [col, row, rect] = this.relativeCoordinates(event, false);
519
+ const divider = calculate_intersection(col, row, this._panel, {
520
+ rect,
521
+ size: this._physics.GRID_DIVIDER_SIZE,
522
+ });
523
+
524
+ if (divider?.type === "vertical") {
525
+ this._cursor_stylesheet.replaceSync(":host{cursor:row-resize");
526
+ this._cursor_override = true;
527
+ } else if (divider?.type === "horizontal") {
528
+ this._cursor_stylesheet.replaceSync(":host{cursor:col-resize");
529
+ this._cursor_override = true;
433
530
  } else if (this._cursor_override) {
434
531
  this._cursor_override = false;
435
532
  this._cursor_stylesheet.replaceSync("");
@@ -439,12 +536,7 @@ export class RegularLayout extends HTMLElement {
439
536
  private onPointerUp = (event: PointerEvent) => {
440
537
  if (this._drag_target) {
441
538
  this.releasePointerCapture(event.pointerId);
442
- const [col, row] = this.relativeCoordinates(
443
- event.clientX,
444
- event.clientY,
445
- false,
446
- );
447
-
539
+ const [col, row] = this.relativeCoordinates(event, false);
448
540
  const [{ path, type }, old_col, old_row] = this._drag_target;
449
541
  const offset = type === "horizontal" ? old_col - col : old_row - row;
450
542
  const panel = redistribute_panel_sizes(this._panel, path, offset);
package/themes/lorax.css CHANGED
@@ -18,7 +18,7 @@ regular-layout.lorax {
18
18
  regular-layout.lorax regular-layout-frame {
19
19
  margin: 3px;
20
20
  margin-top: 27px;
21
- border-radius: 0 0 6px 6px;
21
+ border-radius: 0 6px 6px 6px;
22
22
  border: 1px solid #666;
23
23
  box-shadow: 0px 6px 6px -4px rgba(150, 150, 180);
24
24
  }
@@ -30,6 +30,7 @@ regular-layout.lorax regular-layout-frame::part(titlebar) {
30
30
  margin-right: -1px;
31
31
  margin-bottom: 0px;
32
32
  margin-top: -24px;
33
+ padding-right: 6px;
33
34
  }
34
35
 
35
36
  regular-layout.lorax regular-layout-frame::part(tab) {