svelte-flexiboards 0.1.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.
Files changed (44) hide show
  1. package/LICENSE.md +21 -0
  2. package/dist/components/flexi-add.svelte +35 -0
  3. package/dist/components/flexi-add.svelte.d.ts +16 -0
  4. package/dist/components/flexi-board.svelte +30 -0
  5. package/dist/components/flexi-board.svelte.d.ts +11 -0
  6. package/dist/components/flexi-delete.svelte +22 -0
  7. package/dist/components/flexi-delete.svelte.d.ts +13 -0
  8. package/dist/components/flexi-grab.svelte +20 -0
  9. package/dist/components/flexi-grab.svelte.d.ts +12 -0
  10. package/dist/components/flexi-grid.svelte +19 -0
  11. package/dist/components/flexi-grid.svelte.d.ts +8 -0
  12. package/dist/components/flexi-layout-loader.svelte +10 -0
  13. package/dist/components/flexi-layout-loader.svelte.d.ts +18 -0
  14. package/dist/components/flexi-resize.svelte +20 -0
  15. package/dist/components/flexi-resize.svelte.d.ts +12 -0
  16. package/dist/components/flexi-target-loader.svelte +10 -0
  17. package/dist/components/flexi-target-loader.svelte.d.ts +18 -0
  18. package/dist/components/flexi-target.svelte +93 -0
  19. package/dist/components/flexi-target.svelte.d.ts +42 -0
  20. package/dist/components/flexi-widget.svelte +37 -0
  21. package/dist/components/flexi-widget.svelte.d.ts +9 -0
  22. package/dist/components/index.d.ts +1 -0
  23. package/dist/components/index.js +1 -0
  24. package/dist/components/rendered-flexi-widget.svelte +44 -0
  25. package/dist/components/rendered-flexi-widget.svelte.d.ts +7 -0
  26. package/dist/index.d.ts +16 -0
  27. package/dist/index.js +10 -0
  28. package/dist/system/grid.svelte.d.ts +146 -0
  29. package/dist/system/grid.svelte.js +815 -0
  30. package/dist/system/index.d.ts +1 -0
  31. package/dist/system/index.js +1 -0
  32. package/dist/system/manage.svelte.d.ts +25 -0
  33. package/dist/system/manage.svelte.js +54 -0
  34. package/dist/system/provider.svelte.d.ts +51 -0
  35. package/dist/system/provider.svelte.js +290 -0
  36. package/dist/system/target.svelte.d.ts +179 -0
  37. package/dist/system/target.svelte.js +397 -0
  38. package/dist/system/types.d.ts +91 -0
  39. package/dist/system/types.js +1 -0
  40. package/dist/system/utils.svelte.d.ts +21 -0
  41. package/dist/system/utils.svelte.js +156 -0
  42. package/dist/system/widget.svelte.d.ts +225 -0
  43. package/dist/system/widget.svelte.js +447 -0
  44. package/package.json +57 -0
@@ -0,0 +1,397 @@
1
+ import { getContext, setContext, untrack } from "svelte";
2
+ import { getFlexiboardCtx, getInternalFlexiboardCtx, InternalFlexiBoardController } from "./provider.svelte.js";
3
+ import { FlexiWidgetController } from "./widget.svelte.js";
4
+ import { SvelteSet } from "svelte/reactivity";
5
+ import { FlowFlexiGrid, FlexiGrid, FreeFormFlexiGrid } from "./grid.svelte.js";
6
+ export class InternalFlexiTargetController {
7
+ widgets = $state(new SvelteSet());
8
+ provider = $state();
9
+ #providerTargetDefaults = $derived(this.provider?.config?.targetDefaults);
10
+ providerWidgetDefaults = $derived(this.provider?.config?.widgetDefaults);
11
+ /**
12
+ * Stores the underlying state of the target.
13
+ */
14
+ #state = $state({
15
+ hovered: false,
16
+ actionWidget: null,
17
+ prepared: false
18
+ });
19
+ #dropzoneWidget = $state({
20
+ value: null
21
+ });
22
+ #mouseCellPosition = $state({
23
+ x: 0,
24
+ y: 0
25
+ });
26
+ key;
27
+ #grid = null;
28
+ #preGrabSnapshot = null;
29
+ #gridSnapshot = null;
30
+ #targetConfig = $state(undefined);
31
+ config = $derived({
32
+ baseColumns: untrack(() => this.#targetConfig?.baseColumns ?? this.#providerTargetDefaults?.baseColumns ?? null),
33
+ baseRows: untrack(() => this.#targetConfig?.baseRows ?? this.#providerTargetDefaults?.baseRows ?? null),
34
+ layout: this.#targetConfig?.layout ?? this.#providerTargetDefaults?.layout ?? {
35
+ type: "flow",
36
+ flowAxis: "row",
37
+ placementStrategy: "append"
38
+ },
39
+ rowSizing: this.#targetConfig?.rowSizing ?? this.#providerTargetDefaults?.rowSizing ?? "minmax(1rem, auto)",
40
+ columnSizing: this.#targetConfig?.columnSizing ?? this.#providerTargetDefaults?.columnSizing ?? "minmax(0, 1fr)",
41
+ widgetDefaults: this.#targetConfig?.widgetDefaults
42
+ });
43
+ constructor(provider, config, key) {
44
+ this.provider = provider;
45
+ // If we weren't provided a key, the provider will generate one for us.
46
+ this.key = provider.addTarget(this, key);
47
+ this.#targetConfig = config;
48
+ }
49
+ #tryAddWidget(widget, x, y, width, height) {
50
+ const added = this.grid.tryPlaceWidget(widget, x, y, width, height);
51
+ if (added) {
52
+ this.widgets.add(widget);
53
+ widget.target = this;
54
+ }
55
+ return added;
56
+ }
57
+ createGrid() {
58
+ if (this.#grid) {
59
+ console.warn("A grid already exists but is being replaced. If this is due to a hot reload, this is no cause for alarm.");
60
+ }
61
+ const layout = this.config.layout;
62
+ switch (layout.type) {
63
+ case "free":
64
+ this.#grid = new FreeFormFlexiGrid(this, this.config);
65
+ break;
66
+ case "flow":
67
+ this.#grid = new FlowFlexiGrid(this, this.config);
68
+ break;
69
+ }
70
+ return this.#grid;
71
+ }
72
+ createWidget(config) {
73
+ const [x, y, width, height] = [config.x, config.y, config.width, config.height];
74
+ const widget = new FlexiWidgetController({
75
+ type: "target",
76
+ target: this,
77
+ config
78
+ });
79
+ // If the widget can't be added, it's probably a collision.
80
+ if (!this.#tryAddWidget(widget, x, y, width, height)) {
81
+ console.warn("Failed to add widget to target. Check that the widget's x and y coordinates do not lead to an unresolvable collision.");
82
+ return undefined;
83
+ }
84
+ return widget;
85
+ }
86
+ /**
87
+ * Deletes the given widget from this target, if it exists.
88
+ * @returns Whether the widget was deleted.
89
+ */
90
+ deleteWidget(widget) {
91
+ const deleted = this.widgets.delete(widget);
92
+ this.grid.removeWidget(widget);
93
+ return deleted;
94
+ }
95
+ /**
96
+ * Imports a layout of widgets into this target, replacing any existing widgets.
97
+ * @param layout The layout to import.
98
+ */
99
+ importLayout(layout) {
100
+ this.widgets.clear();
101
+ this.grid.clear();
102
+ for (const config of layout) {
103
+ this.createWidget(config);
104
+ }
105
+ }
106
+ /**
107
+ * Exports the current layout of widgets from this target.
108
+ * @returns The layout of widgets.
109
+ */
110
+ exportLayout() {
111
+ const result = [];
112
+ // Likely much more information than needed, but we've got it.
113
+ for (const widget of this.widgets) {
114
+ result.push({
115
+ component: widget.component,
116
+ componentProps: widget.componentProps,
117
+ snippet: widget.snippet,
118
+ width: widget.width,
119
+ height: widget.height,
120
+ x: widget.x,
121
+ y: widget.y,
122
+ draggable: widget.draggable,
123
+ resizability: widget.resizability,
124
+ className: widget.className,
125
+ metadata: widget.metadata
126
+ });
127
+ }
128
+ return result;
129
+ }
130
+ #createShadow(of) {
131
+ const shadow = new FlexiWidgetController({
132
+ type: "target",
133
+ target: this,
134
+ config: {
135
+ width: of.width,
136
+ height: of.height,
137
+ component: of.component,
138
+ draggable: of.draggable,
139
+ resizability: of.resizability,
140
+ snippet: of.snippet,
141
+ className: of.className,
142
+ componentProps: of.componentProps
143
+ },
144
+ isShadow: true
145
+ });
146
+ this.widgets.add(shadow);
147
+ return shadow;
148
+ }
149
+ // Events
150
+ onpointerenter() {
151
+ this.hovered = true;
152
+ this.provider.onpointerentertarget({
153
+ target: this
154
+ });
155
+ }
156
+ onpointerleave() {
157
+ this.hovered = false;
158
+ this.provider.onpointerleavetarget({
159
+ target: this
160
+ });
161
+ }
162
+ grabWidget(params) {
163
+ // Take a snapshot of the grid before the widget is removed, so if the widget is not successfully placed
164
+ // we can restore the grid to its original state.
165
+ this.#preGrabSnapshot = this.grid.takeSnapshot();
166
+ // Remove the widget from the grid as it's now in a floating state.
167
+ this.grid.removeWidget(params.widget);
168
+ return this.provider.onwidgetgrabbed({
169
+ ...params,
170
+ target: this
171
+ });
172
+ }
173
+ restorePreGrabSnapshot() {
174
+ if (!this.#preGrabSnapshot) {
175
+ return;
176
+ }
177
+ this.grid.restoreFromSnapshot(this.#preGrabSnapshot);
178
+ this.forgetPreGrabSnapshot();
179
+ }
180
+ forgetPreGrabSnapshot() {
181
+ this.#preGrabSnapshot = null;
182
+ }
183
+ startResizeWidget(params) {
184
+ // Remove the widget as it's now in a pseudo-floating state.
185
+ this.grid.removeWidget(params.widget);
186
+ const result = this.provider.onwidgetstartresize({
187
+ ...params,
188
+ target: this
189
+ });
190
+ if (result) {
191
+ this.actionWidget = {
192
+ action: "resize",
193
+ widget: params.widget
194
+ };
195
+ this.#createDropzoneWidget();
196
+ }
197
+ return result;
198
+ }
199
+ tryDropWidget(widget) {
200
+ const actionWidget = this.actionWidget;
201
+ if (!actionWidget) {
202
+ return false;
203
+ }
204
+ const [x, y, width, height] = this.#getDropzoneLocation(actionWidget);
205
+ this.actionWidget = null;
206
+ this.#removeDropzoneWidget();
207
+ // Try to formally place the widget in the grid, which will also serve as a final check that
208
+ // the drop is possible.
209
+ return this.#tryAddWidget(widget, x, y, width, height);
210
+ }
211
+ onmousegridcellmove(event) {
212
+ this.#updateMouseCellPosition(event.cellX, event.cellY);
213
+ this.#updateDropzoneWidget();
214
+ }
215
+ ongrabbedwidgetover(event) {
216
+ this.actionWidget = {
217
+ action: "grab",
218
+ widget: event.widget
219
+ };
220
+ this.#createDropzoneWidget();
221
+ }
222
+ ongrabbedwidgetleave() {
223
+ this.actionWidget = null;
224
+ this.#removeDropzoneWidget();
225
+ }
226
+ oninitialloadcomplete() {
227
+ this.#state.prepared = true;
228
+ }
229
+ #updateMouseCellPosition(x, y) {
230
+ this.#mouseCellPosition.x = x;
231
+ this.#mouseCellPosition.y = y;
232
+ }
233
+ #createDropzoneWidget() {
234
+ if (this.dropzoneWidget || !this.actionWidget) {
235
+ return;
236
+ }
237
+ const grid = this.grid;
238
+ // Take a snapshot of the grid so we can restore its state if the hover stops.
239
+ this.#gridSnapshot = grid.takeSnapshot();
240
+ const dropzoneWidget = this.#createShadow(this.actionWidget.widget);
241
+ this.dropzoneWidget = dropzoneWidget;
242
+ let [x, y, width, height] = this.#getDropzoneLocation(this.actionWidget);
243
+ grid.tryPlaceWidget(this.dropzoneWidget, x, y, width, height);
244
+ }
245
+ #updateDropzoneWidget() {
246
+ const dropzoneWidget = this.dropzoneWidget;
247
+ const actionWidget = this.actionWidget;
248
+ if (!dropzoneWidget || !actionWidget) {
249
+ return;
250
+ }
251
+ let [x, y, width, height] = this.#getDropzoneLocation(actionWidget);
252
+ const grid = this.grid;
253
+ // No change, no need to update.
254
+ if (x === dropzoneWidget.x && y === dropzoneWidget.y && width === dropzoneWidget.width && height === dropzoneWidget.height) {
255
+ return;
256
+ }
257
+ grid.removeWidget(dropzoneWidget);
258
+ grid.restoreFromSnapshot(this.#gridSnapshot);
259
+ grid.tryPlaceWidget(dropzoneWidget, x, y, width, height);
260
+ }
261
+ #getDropzoneLocation(actionWidget) {
262
+ const mouseCellPosition = this.#mouseCellPosition;
263
+ switch (actionWidget.action) {
264
+ case "grab":
265
+ return this.#getGrabbedDropzoneLocation(actionWidget.widget, mouseCellPosition);
266
+ case "resize":
267
+ return this.#getResizingDropzoneLocation(actionWidget.widget, mouseCellPosition);
268
+ }
269
+ }
270
+ #getGrabbedDropzoneLocation(grabbedWidget, mouseCellPosition) {
271
+ return [mouseCellPosition.x, mouseCellPosition.y, grabbedWidget.width, grabbedWidget.height];
272
+ }
273
+ #getResizingDropzoneLocation(resizingWidget, mouseCellPosition) {
274
+ const { width, height } = this.#getNewWidgetHeightAndWidth(resizingWidget, mouseCellPosition);
275
+ return [resizingWidget.x, resizingWidget.y, width, height];
276
+ }
277
+ #getNewWidgetHeightAndWidth(widget, mouseCellPosition) {
278
+ const grid = this.grid;
279
+ let newWidth = mouseCellPosition.x - widget.x;
280
+ let newHeight = mouseCellPosition.y - widget.y;
281
+ // If the widget is in a flow layout, then they can't change their flow axis dimensions.
282
+ // NEXT: show this visually to the user by faking the "horizontal"/"vertical" resizable modes.
283
+ if (this.config.layout.type == "flow" && this.config.layout.flowAxis == "row") {
284
+ newHeight = widget.height;
285
+ }
286
+ else if (this.config.layout.type == "flow" && this.config.layout.flowAxis == "column") {
287
+ newWidth = widget.width;
288
+ }
289
+ switch (widget.resizability) {
290
+ case "horizontal":
291
+ return { width: newWidth, height: widget.height };
292
+ case "vertical":
293
+ return { width: widget.width, height: newHeight };
294
+ case "both":
295
+ return { width: newWidth, height: newHeight };
296
+ }
297
+ return { width: widget.width, height: widget.height };
298
+ }
299
+ #removeDropzoneWidget() {
300
+ if (!this.dropzoneWidget) {
301
+ return;
302
+ }
303
+ const grid = this.grid;
304
+ grid.removeWidget(this.dropzoneWidget);
305
+ this.widgets.delete(this.dropzoneWidget);
306
+ grid.restoreFromSnapshot(this.#gridSnapshot);
307
+ this.#gridSnapshot = null;
308
+ this.dropzoneWidget = null;
309
+ }
310
+ // State-related getters and setters
311
+ /**
312
+ * Whether the target is currently being hovered over by the mouse.
313
+ */
314
+ get hovered() {
315
+ return this.#state.hovered;
316
+ }
317
+ set hovered(value) {
318
+ this.#state.hovered = value;
319
+ }
320
+ /**
321
+ * When set, this indicates that a widget is currently being hovered over this target.
322
+ */
323
+ get actionWidget() {
324
+ return this.#state.actionWidget;
325
+ }
326
+ set actionWidget(value) {
327
+ this.#state.actionWidget = value;
328
+ }
329
+ /**
330
+ * Whether the target is prepared and ready to render widgets.
331
+ */
332
+ get prepared() {
333
+ return this.#state.prepared;
334
+ }
335
+ /**
336
+ * The number of columns currently being used in the target grid.
337
+ * This value is readonly.
338
+ */
339
+ get columns() {
340
+ return this.#grid?.columns;
341
+ }
342
+ /**
343
+ * The number of rows currently being used in the target grid.
344
+ * This value is readonly.
345
+ */
346
+ get rows() {
347
+ return this.#grid?.rows;
348
+ }
349
+ get grid() {
350
+ const grid = this.#grid;
351
+ if (!grid) {
352
+ throw new Error("Grid is not initialised. Ensure that a FlexiGrid has been created before accessing it.");
353
+ }
354
+ return grid;
355
+ }
356
+ get dropzoneWidget() {
357
+ return this.#dropzoneWidget.value;
358
+ }
359
+ set dropzoneWidget(value) {
360
+ this.#dropzoneWidget.value = value;
361
+ }
362
+ }
363
+ const contextKey = Symbol('flexitarget');
364
+ /**
365
+ * Creates a new {@link FlexiTargetController} instance in the context of the current FlexiBoard.
366
+ * @returns A {@link FlexiTargetController} instance.
367
+ */
368
+ export function flexitarget(config, key) {
369
+ const provider = getInternalFlexiboardCtx();
370
+ const target = new InternalFlexiTargetController(provider, config, key);
371
+ setContext(contextKey, target);
372
+ return {
373
+ onpointerenter: () => target.onpointerenter(),
374
+ onpointerleave: () => target.onpointerleave(),
375
+ target: target
376
+ };
377
+ }
378
+ /**
379
+ * Gets the current {@link InternalFlexiTargetController} instance, if any. Throws an error if no target is found.
380
+ * @internal
381
+ * @returns An {@link InternalFlexiTargetController} instance.
382
+ */
383
+ export function getInternalFlexitargetCtx() {
384
+ const target = getContext(contextKey);
385
+ // No provider to attach to.
386
+ if (!target) {
387
+ throw new Error("Cannot get FlexiTarget context outside of a registered target. Ensure that flexitarget() (or <FlexiTarget>) is called within a <FlexiBoard> component.");
388
+ }
389
+ return target;
390
+ }
391
+ /**
392
+ * Gets the current {@link FlexiTargetController} instance, if any. Throws an error if no target is found.
393
+ * @returns A {@link FlexiTargetController} instance.
394
+ */
395
+ export function getFlexitargetCtx() {
396
+ return getInternalFlexitargetCtx();
397
+ }
@@ -0,0 +1,91 @@
1
+ import type { FlexiWidgetController, FlexiWidgetConfiguration } from "./widget.svelte.js";
2
+ import type { FlexiTargetController, InternalFlexiTargetController } from "./target.svelte.js";
3
+ import type { PointerPositionWatcher } from "./utils.svelte.js";
4
+ import type { FlexiAddController } from "./manage.svelte.js";
5
+ export type ProxiedValue<T> = {
6
+ value: T;
7
+ };
8
+ export type SvelteClassValue = string | import('clsx').ClassArray | import('clsx').ClassDictionary | undefined | null;
9
+ export type Position = {
10
+ x: number;
11
+ y: number;
12
+ };
13
+ export type FlexiCommonProps<T> = {
14
+ controller?: T;
15
+ onfirstcreate?: (instance: T) => void;
16
+ };
17
+ export type WidgetResizability = "none" | "horizontal" | "vertical" | "both";
18
+ export type WidgetGrabAction = {
19
+ action: 'grab';
20
+ widget: FlexiWidgetController;
21
+ target?: FlexiTargetController;
22
+ adder?: FlexiAddController;
23
+ offsetX: number;
24
+ offsetY: number;
25
+ positionWatcher: PointerPositionWatcher;
26
+ capturedHeightPx: number;
27
+ capturedWidthPx: number;
28
+ };
29
+ export type WidgetResizeAction = {
30
+ action: 'resize';
31
+ widget: FlexiWidgetController;
32
+ target: FlexiTargetController;
33
+ offsetX: number;
34
+ offsetY: number;
35
+ left: number;
36
+ top: number;
37
+ heightPx: number;
38
+ widthPx: number;
39
+ initialHeightUnits: number;
40
+ initialWidthUnits: number;
41
+ positionWatcher: PointerPositionWatcher;
42
+ };
43
+ export type WidgetAction = WidgetGrabAction | WidgetResizeAction;
44
+ export type WidgetGrabbedParams = {
45
+ widget: FlexiWidgetController;
46
+ xOffset: number;
47
+ yOffset: number;
48
+ capturedHeight: number;
49
+ capturedWidth: number;
50
+ };
51
+ export type WidgetStartResizeParams = {
52
+ widget: FlexiWidgetController;
53
+ xOffset: number;
54
+ yOffset: number;
55
+ left: number;
56
+ top: number;
57
+ heightPx: number;
58
+ widthPx: number;
59
+ };
60
+ export type WidgetGrabbedEvent = WidgetGrabbedParams & {
61
+ target?: InternalFlexiTargetController;
62
+ adder?: FlexiAddController;
63
+ };
64
+ export type WidgetStartResizeEvent = WidgetStartResizeParams & {
65
+ target: InternalFlexiTargetController;
66
+ };
67
+ /**
68
+ * Event object that captures widget grabbed event data.
69
+ */
70
+ export type WidgetDroppedEvent = {
71
+ widget: FlexiWidgetController;
72
+ preventDefault: () => void;
73
+ };
74
+ export type WidgetOverEvent = {
75
+ widget: FlexiWidgetController;
76
+ mousePosition: Position;
77
+ };
78
+ export type WidgetOutEvent = {
79
+ widget: FlexiWidgetController;
80
+ };
81
+ export type MouseGridCellMoveEvent = {
82
+ cellX: number;
83
+ cellY: number;
84
+ };
85
+ export type GrabbedWidgetMouseEvent = {
86
+ widget: FlexiWidgetController;
87
+ };
88
+ export type HoveredTargetEvent = {
89
+ target: InternalFlexiTargetController;
90
+ };
91
+ export type FlexiSavedLayout = Record<string, FlexiWidgetConfiguration[]>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import type { Position, ProxiedValue } from "./types.js";
2
+ import type { FlexiGrid } from "./grid.svelte.js";
3
+ import type { FlexiTargetConfiguration } from "./target.svelte.js";
4
+ export declare class PointerPositionWatcher {
5
+ #private;
6
+ constructor(ref: ProxiedValue<HTMLElement | null>);
7
+ get position(): Position;
8
+ get ref(): HTMLElement | null;
9
+ }
10
+ type CellPosition = {
11
+ row: number;
12
+ column: number;
13
+ };
14
+ export declare class GridDimensionTracker {
15
+ #private;
16
+ constructor(grid: FlexiGrid, targetConfig: FlexiTargetConfiguration);
17
+ watchGrid(): void;
18
+ updateGridDimensions(): void;
19
+ getCellFromPointerPosition(clientX: number, clientY: number): CellPosition | null;
20
+ }
21
+ export {};
@@ -0,0 +1,156 @@
1
+ /*
2
+ This MousePosition utility class is inspired by the MousePositionState class from Joy of Code's
3
+ 'Creating Reactive Browser APIs In Svelte' video, found at https://youtu.be/BKyENJQ6KdQ.
4
+ */
5
+ export class PointerPositionWatcher {
6
+ #position = $state({
7
+ x: 0,
8
+ y: 0
9
+ });
10
+ #ref = $state();
11
+ constructor(ref) {
12
+ this.#ref = ref;
13
+ const onPointerMove = (event) => {
14
+ if (!this.ref) {
15
+ return;
16
+ }
17
+ const rect = this.ref.getBoundingClientRect();
18
+ this.#position.x = event.clientX - rect.left;
19
+ this.#position.y = event.clientY - rect.top;
20
+ event.preventDefault();
21
+ };
22
+ $effect(() => {
23
+ window.addEventListener('pointermove', onPointerMove);
24
+ return () => {
25
+ window.removeEventListener('pointermove', onPointerMove);
26
+ };
27
+ });
28
+ }
29
+ get position() {
30
+ return this.#position;
31
+ }
32
+ get ref() {
33
+ return this.#ref.value;
34
+ }
35
+ }
36
+ export class GridDimensionTracker {
37
+ #dimensions = $state({
38
+ left: 0,
39
+ width: 0,
40
+ columns: [],
41
+ columnString: '',
42
+ columnGap: 0,
43
+ top: 0,
44
+ height: 0,
45
+ rows: [],
46
+ rowString: '',
47
+ rowGap: 0
48
+ });
49
+ #grid = $state(null);
50
+ #rows = $derived(this.#grid?.rows ?? 0);
51
+ #columns = $derived(this.#grid?.columns ?? 0);
52
+ #pointerPosition = $state({
53
+ x: 0,
54
+ y: 0
55
+ });
56
+ #targetConfig = $state({});
57
+ constructor(grid, targetConfig) {
58
+ this.#grid = grid;
59
+ this.#targetConfig = targetConfig;
60
+ }
61
+ watchGrid() {
62
+ // Whenever a change occurs to the grid's dimensions or the underlying widgets, update the sizes.
63
+ $effect(() => {
64
+ const grid = this.#grid;
65
+ const columns = grid.columns ?? 0;
66
+ const rows = grid.rows ?? 0;
67
+ this.updateGridDimensions();
68
+ });
69
+ // Whenever the grid is resized, update the sizes.
70
+ $effect(() => {
71
+ const grid = this.#grid.ref;
72
+ if (!grid) {
73
+ return;
74
+ }
75
+ const observer = new ResizeObserver((entries) => {
76
+ const entry = entries[0];
77
+ if (!entry || !grid) {
78
+ return;
79
+ }
80
+ this.updateGridDimensions();
81
+ });
82
+ observer.observe(grid);
83
+ return () => {
84
+ observer.disconnect();
85
+ };
86
+ });
87
+ }
88
+ updateGridDimensions() {
89
+ const grid = this.#grid;
90
+ const gridElement = grid?.ref;
91
+ if (!gridElement || !window) {
92
+ return;
93
+ }
94
+ const rect = gridElement.getBoundingClientRect();
95
+ const style = window.getComputedStyle(gridElement);
96
+ // Computed style gives us pixel values for each column and row of the grid.
97
+ const templateColumns = style.getPropertyValue('grid-template-columns');
98
+ const templateRows = style.getPropertyValue('grid-template-rows');
99
+ const gapX = style.getPropertyValue('grid-column-gap');
100
+ const gapY = style.getPropertyValue('grid-row-gap');
101
+ // If the dimensions are unchanged, we don't need to update them.
102
+ if (templateColumns == this.#dimensions.columnString
103
+ && templateRows == this.#dimensions.rowString
104
+ && this.#dimensions.left == rect.left
105
+ && this.#dimensions.top == rect.top
106
+ && this.#dimensions.width == rect.width
107
+ && this.#dimensions.height == rect.height
108
+ && grid.rows == this.#dimensions.rows.length
109
+ && grid.columns == this.#dimensions.columns.length) {
110
+ return;
111
+ }
112
+ const columns = templateColumns.split(' ').map(column => parseFloat(column.match(/(\d+\.?\d*)px/)?.[1] ?? '0'));
113
+ const rows = templateRows.split(' ').map(row => parseFloat(row.match(/(\d+\.?\d*)px/)?.[1] ?? '0'));
114
+ // Update in-place to avoid replacing the proxy object.
115
+ this.#dimensions.left = rect.left;
116
+ this.#dimensions.width = rect.width;
117
+ this.#dimensions.columns = columns;
118
+ this.#dimensions.top = rect.top;
119
+ this.#dimensions.height = rect.height;
120
+ this.#dimensions.rows = rows;
121
+ this.#dimensions.columnGap = parseFloat(gapX.match(/(\d+\.?\d*)px/)?.[1] ?? '0');
122
+ this.#dimensions.rowGap = parseFloat(gapY.match(/(\d+\.?\d*)px/)?.[1] ?? '0');
123
+ }
124
+ getCellFromPointerPosition(clientX, clientY) {
125
+ if (!this.#grid?.ref) {
126
+ return null;
127
+ }
128
+ this.#pointerPosition.x = clientX;
129
+ this.#pointerPosition.y = clientY;
130
+ let xCell = this.#findCell(clientX, this.#dimensions.left, this.#dimensions.width, this.#dimensions.columnGap, this.#dimensions.columns);
131
+ let yCell = this.#findCell(clientY, this.#dimensions.top, this.#dimensions.height, this.#dimensions.rowGap, this.#dimensions.rows);
132
+ return {
133
+ row: yCell,
134
+ column: xCell
135
+ };
136
+ }
137
+ #findCell(pointerLocation, start, size, gap, axisCoordinates) {
138
+ // If outside the axis, then return the ends.
139
+ if (pointerLocation < start) {
140
+ return 0;
141
+ }
142
+ if (pointerLocation >= start + size) {
143
+ return axisCoordinates.length;
144
+ }
145
+ let subtotal = start;
146
+ for (let i = 0; i < axisCoordinates.length; i++) {
147
+ const base = subtotal;
148
+ subtotal += axisCoordinates[i] + gap;
149
+ const proportionAlong = (pointerLocation - base) / (subtotal - base);
150
+ if (pointerLocation < subtotal) {
151
+ return i + proportionAlong;
152
+ }
153
+ }
154
+ return axisCoordinates.length;
155
+ }
156
+ }