svelte-flexiboards 0.3.2 → 0.4.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.
Files changed (49) hide show
  1. package/dist/components/flexi-add.svelte +2 -15
  2. package/dist/components/flexi-add.svelte.d.ts +0 -12
  3. package/dist/components/flexi-delete.svelte +3 -16
  4. package/dist/components/flexi-delete.svelte.d.ts +0 -12
  5. package/dist/components/flexi-grab.svelte +2 -2
  6. package/dist/components/flexi-target.svelte +8 -19
  7. package/dist/components/flexi-widget.svelte +2 -2
  8. package/dist/components/responsive-flexi-board.svelte +83 -0
  9. package/dist/components/responsive-flexi-board.svelte.d.ts +34 -0
  10. package/dist/index.d.ts +4 -1
  11. package/dist/index.js +4 -1
  12. package/dist/system/board/base.svelte.d.ts +15 -0
  13. package/dist/system/board/controller.svelte.d.ts +26 -4
  14. package/dist/system/board/controller.svelte.js +237 -28
  15. package/dist/system/board/types.d.ts +26 -0
  16. package/dist/system/grid/base.svelte.d.ts +9 -0
  17. package/dist/system/grid/base.svelte.js +12 -1
  18. package/dist/system/grid/flow-grid.svelte.js +105 -36
  19. package/dist/system/grid/free-grid.svelte.d.ts +6 -2
  20. package/dist/system/grid/free-grid.svelte.js +139 -20
  21. package/dist/system/misc/deleter.svelte.d.ts +0 -4
  22. package/dist/system/misc/deleter.svelte.js +1 -6
  23. package/dist/system/portal.js +0 -1
  24. package/dist/system/responsive/base.svelte.d.ts +46 -0
  25. package/dist/system/responsive/base.svelte.js +1 -0
  26. package/dist/system/responsive/controller.svelte.d.ts +78 -0
  27. package/dist/system/responsive/controller.svelte.js +264 -0
  28. package/dist/system/responsive/index.d.ts +16 -0
  29. package/dist/system/responsive/index.js +36 -0
  30. package/dist/system/responsive/types.d.ts +56 -0
  31. package/dist/system/responsive/types.js +1 -0
  32. package/dist/system/shared/event-bus.d.ts +3 -1
  33. package/dist/system/shared/utils.svelte.d.ts +2 -0
  34. package/dist/system/shared/utils.svelte.js +39 -22
  35. package/dist/system/target/controller.svelte.d.ts +7 -2
  36. package/dist/system/target/controller.svelte.js +103 -30
  37. package/dist/system/types.d.ts +13 -2
  38. package/dist/system/widget/base.svelte.d.ts +40 -1
  39. package/dist/system/widget/base.svelte.js +84 -2
  40. package/dist/system/widget/controller.svelte.d.ts +4 -1
  41. package/dist/system/widget/controller.svelte.js +106 -17
  42. package/dist/system/widget/events.js +10 -3
  43. package/dist/system/widget/interpolation-utils.d.ts +14 -0
  44. package/dist/system/widget/interpolation-utils.js +32 -0
  45. package/dist/system/widget/interpolator.svelte.d.ts +2 -1
  46. package/dist/system/widget/interpolator.svelte.js +63 -22
  47. package/dist/system/widget/triggers.svelte.js +1 -1
  48. package/dist/system/widget/types.d.ts +51 -6
  49. package/package.json +1 -1
@@ -1,8 +1,12 @@
1
1
  import { getFlexiEventBus } from '../shared/event-bus.js';
2
2
  import { AutoScrollService, getPointerService } from '../shared/utils.svelte.js';
3
3
  import { InternalFlexiTargetController } from '../target/controller.svelte.js';
4
+ import { getInternalResponsiveFlexiboardCtx, hasInternalResponsiveFlexiboardCtx } from '../responsive/index.js';
4
5
  export class InternalFlexiBoardController {
5
6
  #currentWidgetAction = $state(null);
7
+ #activeInterpolations = $state(0);
8
+ #scrollbarCompensation = $state(0);
9
+ #hasScrollbarCompensation = false;
6
10
  #targets = new Map();
7
11
  hoveredTarget = null;
8
12
  #hoveredOverDeleter = $state(false);
@@ -11,24 +15,95 @@ export class InternalFlexiBoardController {
11
15
  #autoScrollService = new AutoScrollService(this.#ref);
12
16
  #rawProps = $state(undefined);
13
17
  config = $derived(this.#rawProps?.config);
18
+ registry = $derived(this.#rawProps?.config?.registry);
14
19
  #nextTargetIndex = 0;
15
- #storedLoadLayout = null;
16
20
  #ready = false;
21
+ #storedLoadLayout;
17
22
  portal = null;
18
23
  #announcer = null;
19
24
  #eventBus;
20
25
  #unsubscribers = [];
26
+ #layoutChangeTimeout = null;
27
+ #layoutChangeDebounceMs = 150;
28
+ breakpoint;
29
+ /**
30
+ * Reference to the parent responsive controller, if this board is within a ResponsiveFlexiBoard.
31
+ */
32
+ #responsiveController = null;
21
33
  constructor(props) {
22
34
  // Track the props proxy so our config reactively updates.
23
35
  this.#rawProps = props;
24
36
  this.#eventBus = getFlexiEventBus();
25
- this.#unsubscribers.push(this.#eventBus.subscribe('widget:grabbed', this.onWidgetGrabbed.bind(this)), this.#eventBus.subscribe('widget:resizing', this.onWidgetResizing.bind(this)), this.#eventBus.subscribe('widget:release', this.handleWidgetRelease.bind(this)), this.#eventBus.subscribe('widget:cancel', this.handleWidgetCancel.bind(this)), this.#eventBus.subscribe('target:pointerenter', this.onPointerEnterTarget.bind(this)), this.#eventBus.subscribe('target:pointerleave', this.onPointerLeaveTarget.bind(this)));
37
+ // Check if we're inside a responsive context
38
+ if (hasInternalResponsiveFlexiboardCtx()) {
39
+ this.#responsiveController = getInternalResponsiveFlexiboardCtx();
40
+ // Infer breakpoint from responsive controller's current state
41
+ this.breakpoint = this.#responsiveController.currentBreakpoint;
42
+ }
43
+ else {
44
+ // Not in responsive context - use config breakpoint if provided (with warning)
45
+ this.breakpoint = this.#rawProps?.config?.breakpoint;
46
+ if (this.breakpoint) {
47
+ console.warn('Breakpoint is set for a non-responsive board. Ignoring breakpoint.');
48
+ }
49
+ }
50
+ this.#unsubscribers.push(this.#eventBus.subscribe('widget:grabbed', this.onWidgetGrabbed.bind(this)), this.#eventBus.subscribe('widget:resizing', this.onWidgetResizing.bind(this)), this.#eventBus.subscribe('widget:release', this.handleWidgetRelease.bind(this)), this.#eventBus.subscribe('widget:cancel', this.handleWidgetCancel.bind(this)), this.#eventBus.subscribe('target:pointerenter', this.onPointerEnterTarget.bind(this)), this.#eventBus.subscribe('target:pointerleave', this.onPointerLeaveTarget.bind(this)),
51
+ // Layout change events
52
+ this.#eventBus.subscribe('widget:dropped', this.#onLayoutChange.bind(this)), this.#eventBus.subscribe('widget:delete', this.#onLayoutChange.bind(this)),
53
+ // Responsive layout import events
54
+ this.#eventBus.subscribe('responsive:layoutimport', this.#onResponsiveLayoutImport.bind(this)));
55
+ }
56
+ #onLayoutChange(event) {
57
+ // Not our event
58
+ if (event.board !== this) {
59
+ return;
60
+ }
61
+ // No point exporting if nobody is listening
62
+ if (!this.config?.onLayoutChange && !this.#responsiveController) {
63
+ return;
64
+ }
65
+ // Debounce the callback
66
+ if (this.#layoutChangeTimeout) {
67
+ clearTimeout(this.#layoutChangeTimeout);
68
+ }
69
+ this.#layoutChangeTimeout = setTimeout(() => {
70
+ this.#layoutChangeTimeout = null;
71
+ const layout = this.#exportLayoutInternal();
72
+ // Emit event for responsive controller (and any other listeners)
73
+ this.#eventBus.dispatch('board:layoutchange', {
74
+ board: this,
75
+ layout,
76
+ breakpoint: this.breakpoint
77
+ });
78
+ // Also call local callback if provided
79
+ this.config?.onLayoutChange?.(layout);
80
+ }, this.#layoutChangeDebounceMs);
81
+ }
82
+ #onResponsiveLayoutImport(event) {
83
+ // Not our responsive controller
84
+ if (event.responsiveController !== this.#responsiveController) {
85
+ return;
86
+ }
87
+ // Get the layout for our breakpoint and import it
88
+ if (this.breakpoint) {
89
+ const layout = this.#responsiveController?.getLayoutForBreakpoint(this.breakpoint);
90
+ if (layout) {
91
+ this.#importLayoutInternal(layout);
92
+ }
93
+ }
26
94
  }
27
95
  style = $derived.by(() => {
96
+ const needsOverflowLock = this.#activeInterpolations > 0 || this.#currentWidgetAction;
97
+ const scrollbarPadding = this.#scrollbarCompensation > 0
98
+ ? ` padding-right: ${this.#scrollbarCompensation}px;`
99
+ : '';
100
+ const overflow = needsOverflowLock
101
+ ? ` overflow: hidden;${scrollbarPadding}`
102
+ : '';
28
103
  if (!this.#currentWidgetAction) {
29
- return 'position: relative;';
104
+ return `position: relative;${overflow}`;
30
105
  }
31
- return `position: relative; ${this.#getStyleForCurrentWidgetAction()}`;
106
+ return `position: relative;${overflow} ${this.#getStyleForCurrentWidgetAction()}`;
32
107
  });
33
108
  #getStyleForCurrentWidgetAction() {
34
109
  if (!this.#currentWidgetAction) {
@@ -41,6 +116,35 @@ export class InternalFlexiBoardController {
41
116
  return `cursor: nwse-resize;`;
42
117
  }
43
118
  }
119
+ notifyInterpolationStarted() {
120
+ this.#captureScrollbarWidthIfNeeded();
121
+ this.#activeInterpolations++;
122
+ }
123
+ notifyInterpolationEnded() {
124
+ this.#activeInterpolations = Math.max(0, this.#activeInterpolations - 1);
125
+ this.#scheduleScrollbarCompensationRelease();
126
+ }
127
+ #captureScrollbarWidthIfNeeded() {
128
+ if (this.#hasScrollbarCompensation) {
129
+ return;
130
+ }
131
+ if (this.ref) {
132
+ const scrollbarWidth = this.ref.offsetWidth - this.ref.clientWidth;
133
+ if (scrollbarWidth > 0) {
134
+ const existingPadding = parseFloat(getComputedStyle(this.ref).paddingRight) || 0;
135
+ this.#scrollbarCompensation = existingPadding + scrollbarWidth;
136
+ }
137
+ this.#hasScrollbarCompensation = true;
138
+ }
139
+ }
140
+ #scheduleScrollbarCompensationRelease() {
141
+ queueMicrotask(() => {
142
+ if (this.#activeInterpolations === 0 && !this.#currentWidgetAction) {
143
+ this.#scrollbarCompensation = 0;
144
+ this.#hasScrollbarCompensation = false;
145
+ }
146
+ });
147
+ }
44
148
  get ref() {
45
149
  return this.#ref.value;
46
150
  }
@@ -102,6 +206,7 @@ export class InternalFlexiBoardController {
102
206
  if (this.#currentWidgetAction || event.board !== this) {
103
207
  return;
104
208
  }
209
+ this.#captureScrollbarWidthIfNeeded();
105
210
  if (event.clientX !== undefined && event.clientY !== undefined) {
106
211
  this.#pointerService.updatePosition(event.clientX, event.clientY);
107
212
  }
@@ -121,6 +226,7 @@ export class InternalFlexiBoardController {
121
226
  if (this.#currentWidgetAction || event.board !== this) {
122
227
  return;
123
228
  }
229
+ this.#captureScrollbarWidthIfNeeded();
124
230
  this.#currentWidgetAction = {
125
231
  action: 'resize',
126
232
  widget: event.widget,
@@ -160,6 +266,8 @@ export class InternalFlexiBoardController {
160
266
  }
161
267
  this.#unlockViewport();
162
268
  const currentAction = this.#currentWidgetAction;
269
+ // Capture source target before any handlers might change widget.internalTarget
270
+ const sourceTarget = currentAction.widget.internalTarget;
163
271
  switch (currentAction.action) {
164
272
  case 'grab':
165
273
  this.#handleGrabbedWidgetRelease(currentAction);
@@ -168,10 +276,34 @@ export class InternalFlexiBoardController {
168
276
  this.#handleResizingWidgetRelease(currentAction);
169
277
  break;
170
278
  }
279
+ // Safety net: After all synchronous handlers complete, check if the source target
280
+ // still has a pre-grab snapshot (meaning the drop didn't happen). If so, restore it.
281
+ // This handles the case where the widget was released outside of all targets.
282
+ queueMicrotask(() => {
283
+ if (sourceTarget?.hasPreGrabSnapshot()) {
284
+ sourceTarget.restorePreGrabSnapshot();
285
+ sourceTarget.applyGridPostCompletionOperations();
286
+ }
287
+ });
171
288
  }
172
289
  handleWidgetCancel(event) {
290
+ // Not our event.
291
+ if (event.board !== this) {
292
+ return;
293
+ }
173
294
  this.#unlockViewport();
295
+ // Capture source target before releasing the action
296
+ const sourceTarget = this.#currentWidgetAction?.widget.internalTarget;
174
297
  this.#releaseCurrentWidgetAction();
298
+ // Safety net: After all synchronous handlers complete, check if the source target
299
+ // still has a pre-grab snapshot. If so, restore it.
300
+ // This handles the case where the widget was cancelled while outside of all targets.
301
+ queueMicrotask(() => {
302
+ if (sourceTarget?.hasPreGrabSnapshot()) {
303
+ sourceTarget.restorePreGrabSnapshot();
304
+ sourceTarget.applyGridPostCompletionOperations();
305
+ }
306
+ });
175
307
  }
176
308
  attachAnnouncer(announcer) {
177
309
  this.#announcer = announcer;
@@ -183,30 +315,95 @@ export class InternalFlexiBoardController {
183
315
  }
184
316
  oninitialloadcomplete() {
185
317
  this.#ready = true;
186
- // if(this.#storedLoadLayout) {
187
- // this.importLayout(this.#storedLoadLayout);
188
- // }
189
- }
190
- // NEXT: Add import/export layout.
191
- // importLayout(layout: FlexiSavedLayout) {
192
- // // The board isn't ready to import widgets yet, so we'll store the layout and import it later.
193
- // if(!this.#ready) {
194
- // this.#storedLoadLayout = layout;
195
- // return;
196
- // }
197
- // // Good to go - import the widgets into their respective targets.
198
- // this.#targets.forEach(target => {
199
- // target.importLayout(layout[target.key]);
200
- // });
201
- // }
202
- // exportLayout(): FlexiSavedLayout {
203
- // const result: FlexiSavedLayout = {};
204
- // // Grab the current layout of each target.
205
- // this.#targets.forEach(target => {
206
- // result[target.key] = target.exportLayout();
207
- // });
208
- // return result;
209
- // }
318
+ // Check for stored layout from early import attempt
319
+ if (this.#storedLoadLayout) {
320
+ this.#importLayoutInternal(this.#storedLoadLayout);
321
+ this.#storedLoadLayout = undefined;
322
+ return;
323
+ }
324
+ // If in responsive context, load from responsive controller
325
+ if (this.#responsiveController && this.breakpoint) {
326
+ const layout = this.#responsiveController.getLayoutForBreakpoint(this.breakpoint);
327
+ if (layout) {
328
+ this.#importLayoutInternal(layout);
329
+ return;
330
+ }
331
+ // Fall through to loadLayout callback if no stored layout for this breakpoint
332
+ }
333
+ // Check for loadLayout in config (for non-responsive boards OR first-time breakpoint)
334
+ const loadLayoutFn = this.config?.loadLayout;
335
+ if (loadLayoutFn) {
336
+ const layout = loadLayoutFn();
337
+ if (layout) {
338
+ this.#importLayoutInternal(this.#normalizeLayout(layout));
339
+ }
340
+ }
341
+ }
342
+ /**
343
+ * Imports a layout into this board.
344
+ *
345
+ * **Note**: If this board is under a ResponsiveFlexiBoard, prefer using
346
+ * `responsiveBoard.importLayout()` to import layouts for all breakpoints.
347
+ */
348
+ importLayout(layout) {
349
+ if (this.#responsiveController) {
350
+ console.warn('importLayout() called directly on a FlexiBoard under ResponsiveFlexiBoard. ' +
351
+ 'Use responsiveBoard.importLayout() instead to import layouts for all breakpoints.');
352
+ }
353
+ this.#importLayoutInternal(layout);
354
+ }
355
+ /**
356
+ * Internal implementation of importLayout - no warning, used by responsive controller.
357
+ */
358
+ #importLayoutInternal(layout) {
359
+ // The board isn't ready to import widgets yet, so we'll store the layout and import it later.
360
+ if (!this.#ready) {
361
+ this.#storedLoadLayout = layout;
362
+ return;
363
+ }
364
+ // Good to go - import the widgets into their respective targets.
365
+ this.#targets.forEach(target => {
366
+ const targetLayout = layout[target.key];
367
+ if (targetLayout) {
368
+ target.importLayout(targetLayout);
369
+ }
370
+ });
371
+ }
372
+ #normalizeLayout(layout) {
373
+ // If it's an array, assume single target (first one)
374
+ if (Array.isArray(layout)) {
375
+ const firstTargetKey = this.#targets.keys().next().value;
376
+ if (firstTargetKey) {
377
+ return { [firstTargetKey]: layout };
378
+ }
379
+ return {};
380
+ }
381
+ return layout;
382
+ }
383
+ /**
384
+ * Exports the current layout from this board.
385
+ *
386
+ * **Note**: If this board is under a ResponsiveFlexiBoard, prefer using
387
+ * `responsiveBoard.exportLayout()` to export layouts for all breakpoints.
388
+ */
389
+ exportLayout() {
390
+ if (this.#responsiveController) {
391
+ console.warn('exportLayout() called directly on a FlexiBoard under ResponsiveFlexiBoard. ' +
392
+ 'Use responsiveBoard.exportLayout() instead to export layouts for all breakpoints.');
393
+ }
394
+ return this.#exportLayoutInternal();
395
+ }
396
+ /**
397
+ * Internal implementation of exportLayout - no warning, used internally.
398
+ */
399
+ #exportLayoutInternal() {
400
+ const result = {};
401
+ // Grab the current layout of each target.
402
+ this.#targets.forEach(target => {
403
+ result[target.key] = target.exportLayout();
404
+ });
405
+ return result;
406
+ }
210
407
  #handleGrabbedWidgetRelease(action) {
211
408
  // If a deleter is hovered, then we'll delete the widget.
212
409
  if (this.#hoveredOverDeleter) {
@@ -230,6 +427,7 @@ export class InternalFlexiBoardController {
230
427
  }
231
428
  this.announce(`You have released the widget.`);
232
429
  this.#currentWidgetAction = null;
430
+ this.#scheduleScrollbarCompensationRelease();
233
431
  }
234
432
  /**
235
433
  * Moves a widget from one target to another.
@@ -258,6 +456,12 @@ export class InternalFlexiBoardController {
258
456
  get currentWidgetAction() {
259
457
  return this.#currentWidgetAction;
260
458
  }
459
+ /**
460
+ * Returns the parent responsive controller if this board is within a ResponsiveFlexiBoard.
461
+ */
462
+ get responsiveController() {
463
+ return this.#responsiveController;
464
+ }
261
465
  /**
262
466
  * Cleanup method to be called when the board is destroyed
263
467
  */
@@ -268,5 +472,10 @@ export class InternalFlexiBoardController {
268
472
  // Clean up event subscriptions
269
473
  this.#unsubscribers.forEach((unsubscribe) => unsubscribe());
270
474
  this.#unsubscribers = [];
475
+ // Clean up any pending layout change timeout
476
+ if (this.#layoutChangeTimeout) {
477
+ clearTimeout(this.#layoutChangeTimeout);
478
+ this.#layoutChangeTimeout = null;
479
+ }
271
480
  }
272
481
  }
@@ -1,6 +1,32 @@
1
1
  import type { FlexiTargetDefaults } from '../target/types.js';
2
2
  import type { FlexiWidgetDefaults } from '../widget/types.js';
3
+ export type FlexiLayoutChangeFn = (layout: FlexiLayout) => void;
3
4
  export type FlexiBoardConfiguration = {
4
5
  widgetDefaults?: FlexiWidgetDefaults;
5
6
  targetDefaults?: FlexiTargetDefaults;
7
+ /**
8
+ * Optional breakpoint override.
9
+ *
10
+ * When this board is inside a ResponsiveFlexiBoard, the breakpoint is automatically
11
+ * inferred from the responsive controller's `currentBreakpoint`. You typically don't
12
+ * need to set this manually.
13
+ *
14
+ * If set outside of a ResponsiveFlexiBoard context, a warning will be logged.
15
+ */
16
+ breakpoint?: string;
17
+ registry?: Record<string, FlexiRegistryEntry>;
18
+ loadLayout?: FlexiLoadLayoutFn;
19
+ onLayoutChange?: FlexiLayoutChangeFn;
6
20
  };
21
+ export type FlexiRegistryEntry = Omit<FlexiWidgetDefaults, 'width' | 'height' | 'draggable'>;
22
+ export type FlexiWidgetLayoutEntry = {
23
+ id?: string;
24
+ type: string;
25
+ x: number;
26
+ y: number;
27
+ width: number;
28
+ height: number;
29
+ metadata?: Record<string, any>;
30
+ };
31
+ export type FlexiLayout = Record<string, FlexiWidgetLayoutEntry[]>;
32
+ export type FlexiLoadLayoutFn = () => FlexiLayout | FlexiWidgetLayoutEntry[] | undefined;
@@ -28,6 +28,15 @@ export declare abstract class FlexiGrid {
28
28
  * Apply any post-completion operations like row/column collapsing.
29
29
  */
30
30
  applyPostCompletionOperations(): void;
31
+ /**
32
+ * Stores a drag snapshot so that mapRawCellToFinalCell can map cursor positions
33
+ * through the snapshot's widget positions instead of the live (displaced) grid.
34
+ */
35
+ setDragSnapshot(snapshot: unknown): void;
36
+ /**
37
+ * Clears the stored drag snapshot.
38
+ */
39
+ clearDragSnapshot(): void;
31
40
  _target: InternalFlexiTargetController;
32
41
  _targetConfig: FlexiTargetConfiguration;
33
42
  mouseCellPosition: {
@@ -8,6 +8,15 @@ export class FlexiGrid {
8
8
  * Apply any post-completion operations like row/column collapsing.
9
9
  */
10
10
  applyPostCompletionOperations() { }
11
+ /**
12
+ * Stores a drag snapshot so that mapRawCellToFinalCell can map cursor positions
13
+ * through the snapshot's widget positions instead of the live (displaced) grid.
14
+ */
15
+ setDragSnapshot(snapshot) { }
16
+ /**
17
+ * Clears the stored drag snapshot.
18
+ */
19
+ clearDragSnapshot() { }
11
20
  _target;
12
21
  _targetConfig;
13
22
  mouseCellPosition = $state({
@@ -53,7 +62,9 @@ export class FlexiGrid {
53
62
  this.mouseCellPosition.y = cell?.row ?? 0;
54
63
  this._target.onmousegridcellmove({
55
64
  cellX: this.mouseCellPosition.x,
56
- cellY: this.mouseCellPosition.y
65
+ cellY: this.mouseCellPosition.y,
66
+ rawCellX: rawCell?.column ?? 0,
67
+ rawCellY: rawCell?.row ?? 0
57
68
  });
58
69
  }
59
70
  watchGridElementDimensions() {