@griddle/svelte 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Trustybits
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # @griddle/svelte
2
+
3
+ Svelte bindings for [Griddle](https://github.com/Trustybits/griddle) — a headless,
4
+ zero-dependency grid/canvas engine. Provides a `<GriddleGrid />` component and a
5
+ `createGriddle()` store factory that wrap [`@griddle/core`](https://www.npmjs.com/package/@griddle/core)
6
+ with virtualized rendering, drag/resize handles, and animations.
7
+
8
+ Works with **Svelte 4 and Svelte 5** (the components are written in the portable
9
+ Svelte 4 style that Svelte 5 runs in legacy mode).
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm install @griddle/svelte @griddle/core
15
+ ```
16
+
17
+ `@griddle/core` and `svelte` are **peer dependencies**. On npm 7+ the peers are
18
+ installed automatically; with Yarn or pnpm, add `@griddle/core` yourself (as
19
+ shown above). `svelte` (^4 or ^5) is expected to already be in your app.
20
+
21
+ ## Usage
22
+
23
+ ```svelte
24
+ <script>
25
+ import { GriddleGrid, createGriddle } from '@griddle/svelte';
26
+
27
+ const api = createGriddle({
28
+ config: { cols: 12, rows: 12, unitWidth: 75, unitHeight: 75 },
29
+ tiles: [
30
+ { id: '1', col: 0, row: 0, w: 2, h: 2 },
31
+ { id: '2', col: 2, row: 0, w: 1, h: 1 },
32
+ ],
33
+ });
34
+ </script>
35
+
36
+ <GriddleGrid {api}>
37
+ <div slot="tile" let:tile>#{tile.id}</div>
38
+ </GriddleGrid>
39
+ ```
40
+
41
+ ## Loop mode (infinite gallery)
42
+
43
+ Enable `loop` in the config and the content repeats endlessly with drag-to-pan
44
+ physics — no scrollbars:
45
+
46
+ ```js
47
+ const api = createGriddle({
48
+ config: {
49
+ cols: 12, rows: 12, unitWidth: 120, unitHeight: 120,
50
+ loop: { enabled: true, interaction: 'pan' }, // 'pan' = viewer, 'edit' = ghost edit
51
+ },
52
+ tiles,
53
+ });
54
+ ```
55
+
56
+ `<GriddleGrid>` automatically switches to the loop renderer when
57
+ `config.loop.enabled` is `true`, so most apps never touch the loop component
58
+ directly. For advanced cases where you want to render the loop plane explicitly,
59
+ `GriddleLoopGrid` is also exported and takes the same props as `GriddleGrid`:
60
+
61
+ ```js
62
+ import { GriddleLoopGrid } from '@griddle/svelte';
63
+ ```
64
+
65
+ See the [main repository](https://github.com/Trustybits/griddle) for full docs.
66
+
67
+ ## License
68
+
69
+ MIT © Trustybits
@@ -0,0 +1,485 @@
1
+ <script>import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
2
+ import { visibleRange, visibleTiles, gridContentSize, DragController, computeTileLayout, resolveStickyStacking, isOutOfFlow, pixelsToPin, } from '@griddle/core';
3
+ import LoopGrid from './LoopGrid.svelte';
4
+ export let api;
5
+ export let height = '100%';
6
+ export let showGrid = true;
7
+ const dispatch = createEventDispatcher();
8
+ const DEFAULT_DRAG_IGNORE = 'a, button, input, textarea, select, [contenteditable]';
9
+ let scrollEl;
10
+ let viewport = { scrollX: 0, scrollY: 0, width: 1000, height: 800 };
11
+ const cfgStore = api.config;
12
+ const tilesStore = api.tiles;
13
+ const versionStore = api.version;
14
+ $: cfg = $cfgStore;
15
+ $: tilesAll = $tilesStore;
16
+ $: ver = $versionStore;
17
+ // Loop mode delegates rendering to LoopGrid.
18
+ $: loopOn = cfg.loop?.enabled === true;
19
+ $: colSize = cfg.unitWidth + (cfg.gap ?? 0);
20
+ $: rowSize = cfg.unitHeight + (cfg.gap ?? 0);
21
+ $: halfGap = (cfg.gap ?? 0) / 2;
22
+ $: range = visibleRange(cfg, viewport, 4);
23
+ $: rendered = visibleTiles(tilesAll, range);
24
+ $: contentSize = gridContentSize(cfg, tilesAll);
25
+ // ---- drag / resize state ---------------------------------------------
26
+ // Drag uses the live-preview state machine in core: as the cursor crosses
27
+ // cell boundaries we rewind any prior preview and re-attempt the move
28
+ // against a snapshot taken at pickup. The dragged tile follows the cursor
29
+ // freely (unsnapped) for the duration of the gesture; a separate drop-
30
+ // indicator div renders at the snapped candidate cell.
31
+ const dragController = new DragController(api.grid);
32
+ let drag = null;
33
+ let dragStartPointerX = 0;
34
+ let dragStartPointerY = 0;
35
+ let pinDrag = null;
36
+ let resize = null;
37
+ const tileNodes = new Map();
38
+ const prevRects = new Map();
39
+ function registerNode(node, id) {
40
+ tileNodes.set(id, node);
41
+ return {
42
+ update(newId) {
43
+ if (newId !== id) {
44
+ tileNodes.delete(id);
45
+ id = newId;
46
+ tileNodes.set(id, node);
47
+ }
48
+ },
49
+ destroy() {
50
+ tileNodes.delete(id);
51
+ },
52
+ };
53
+ }
54
+ function onTilePointerDown(e, tile) {
55
+ if (tile.draggable === false)
56
+ return;
57
+ if (e.target.dataset.griddleHandle)
58
+ return;
59
+ const ignoreSelector = cfg.dragIgnoreFrom ?? DEFAULT_DRAG_IGNORE;
60
+ if (ignoreSelector && e.target.closest(ignoreSelector))
61
+ return;
62
+ e.currentTarget.setPointerCapture(e.pointerId);
63
+ if (cfg.enablePositioning && isOutOfFlow(tile)) {
64
+ const layout = computeTileLayout({
65
+ tile,
66
+ config: cfg,
67
+ scrollX: viewport.scrollX,
68
+ scrollY: viewport.scrollY,
69
+ viewportWidth: viewport.width,
70
+ viewportHeight: viewport.height,
71
+ });
72
+ const startPinPx = tile.position === 'fixed'
73
+ ? { x: layout.left - viewport.scrollX, y: layout.top - viewport.scrollY }
74
+ : { x: layout.left, y: layout.top };
75
+ pinDrag = {
76
+ tileId: tile.id,
77
+ startPinPx,
78
+ startPointerX: e.clientX,
79
+ startPointerY: e.clientY,
80
+ };
81
+ dispatch('dragStart', { tileId: tile.id });
82
+ e.stopPropagation();
83
+ return;
84
+ }
85
+ if (!dragController.start(tile.id))
86
+ return;
87
+ dragStartPointerX = e.clientX;
88
+ dragStartPointerY = e.clientY;
89
+ drag = {
90
+ tileId: tile.id,
91
+ pickupCol: tile.col,
92
+ pickupRow: tile.row,
93
+ deltaX: 0,
94
+ deltaY: 0,
95
+ indicatorCol: tile.col,
96
+ indicatorRow: tile.row,
97
+ };
98
+ dispatch('dragStart', { tileId: tile.id });
99
+ e.stopPropagation();
100
+ }
101
+ function onResizeHandleDown(e, tile, c) {
102
+ if (tile.resizable === false)
103
+ return;
104
+ e.currentTarget.setPointerCapture(e.pointerId);
105
+ resize = {
106
+ tileId: tile.id, corner: c,
107
+ startPointerX: e.clientX, startPointerY: e.clientY,
108
+ startW: tile.w, startH: tile.h,
109
+ startCol: tile.col, startRow: tile.row,
110
+ previewW: tile.w, previewH: tile.h,
111
+ previewCol: tile.col, previewRow: tile.row,
112
+ };
113
+ dispatch('resizeStart', { tileId: tile.id });
114
+ e.stopPropagation();
115
+ }
116
+ function onPointerMove(e) {
117
+ if (pinDrag) {
118
+ const dx = e.clientX - pinDrag.startPointerX;
119
+ const dy = e.clientY - pinDrag.startPointerY;
120
+ const newPinPx = {
121
+ x: pinDrag.startPinPx.x + dx,
122
+ y: pinDrag.startPinPx.y + dy,
123
+ };
124
+ const newPin = pixelsToPin(newPinPx, cfg);
125
+ api.grid.setTilePinned(pinDrag.tileId, newPin);
126
+ }
127
+ if (drag) {
128
+ const dx = e.clientX - dragStartPointerX;
129
+ const dy = e.clientY - dragStartPointerY;
130
+ const candidateCol = drag.pickupCol + Math.round(dx / colSize);
131
+ const candidateRow = drag.pickupRow + Math.round(dy / rowSize);
132
+ const result = dragController.update({ col: candidateCol, row: candidateRow });
133
+ drag = {
134
+ ...drag,
135
+ deltaX: dx,
136
+ deltaY: dy,
137
+ indicatorCol: result.indicatorCell ? result.indicatorCell.col : null,
138
+ indicatorRow: result.indicatorCell ? result.indicatorCell.row : null,
139
+ };
140
+ // restoreTiles() doesn't emit change events, so force-sync the local
141
+ // tile list so FLIP picks up displaced tile resets (e.g. drag back to
142
+ // the pickup cell).
143
+ if (result.changed)
144
+ tilesAll = api.grid.tiles;
145
+ }
146
+ if (resize) {
147
+ const dx = e.clientX - resize.startPointerX;
148
+ const dy = e.clientY - resize.startPointerY;
149
+ let dw = 0, dh = 0, dcol = 0, drow = 0;
150
+ const stepsX = Math.round(dx / colSize);
151
+ const stepsY = Math.round(dy / rowSize);
152
+ if (resize.corner === 'se' || resize.corner === 'ne')
153
+ dw = stepsX;
154
+ if (resize.corner === 'se' || resize.corner === 'sw')
155
+ dh = stepsY;
156
+ if (resize.corner === 'sw' || resize.corner === 'nw') {
157
+ dw = -stepsX;
158
+ dcol = stepsX;
159
+ }
160
+ if (resize.corner === 'ne' || resize.corner === 'nw') {
161
+ dh = -stepsY;
162
+ drow = stepsY;
163
+ }
164
+ const tile = api.grid.getTile(resize.tileId);
165
+ const minW = tile?.minW ?? 1;
166
+ const minH = tile?.minH ?? 1;
167
+ const maxW = tile?.maxW ?? Infinity;
168
+ const maxH = tile?.maxH ?? Infinity;
169
+ const nW = Math.min(maxW, Math.max(minW, resize.startW + dw));
170
+ const nH = Math.min(maxH, Math.max(minH, resize.startH + dh));
171
+ const nC = resize.startCol + dcol;
172
+ const nR = resize.startRow + drow;
173
+ if (nW !== resize.previewW || nH !== resize.previewH || nC !== resize.previewCol || nR !== resize.previewRow) {
174
+ resize = { ...resize, previewW: nW, previewH: nH, previewCol: nC, previewRow: nR };
175
+ }
176
+ }
177
+ }
178
+ function onPointerUp() {
179
+ if (pinDrag) {
180
+ const tileId = pinDrag.tileId;
181
+ pinDrag = null;
182
+ dispatch('dragEnd', { tileId, committed: true });
183
+ }
184
+ if (drag) {
185
+ const tileId = drag.tileId;
186
+ const { committed } = dragController.end();
187
+ drag = null;
188
+ dispatch('dragEnd', { tileId, committed });
189
+ }
190
+ if (resize) {
191
+ const r = resize;
192
+ const t = api.grid.getTile(r.tileId);
193
+ let committed = false;
194
+ if (t) {
195
+ if (r.previewCol !== t.col || r.previewRow !== t.row) {
196
+ api.moveTile(r.tileId, { col: r.previewCol, row: r.previewRow });
197
+ }
198
+ if (r.previewW !== t.w || r.previewH !== t.h) {
199
+ committed = api.resizeTile(r.tileId, { w: r.previewW, h: r.previewH });
200
+ }
201
+ else {
202
+ committed = r.previewCol !== r.startCol || r.previewRow !== r.startRow
203
+ || r.previewW !== r.startW || r.previewH !== r.startH;
204
+ }
205
+ }
206
+ resize = null;
207
+ dispatch('resizeEnd', { tileId: r.tileId, committed });
208
+ }
209
+ }
210
+ function updateViewport() {
211
+ if (!scrollEl)
212
+ return;
213
+ viewport = {
214
+ scrollX: scrollEl.scrollLeft,
215
+ scrollY: scrollEl.scrollTop,
216
+ width: scrollEl.clientWidth,
217
+ height: scrollEl.clientHeight,
218
+ };
219
+ }
220
+ let ro;
221
+ onMount(() => {
222
+ updateViewport();
223
+ // scrollEl is absent while loop mode delegates to LoopGrid.
224
+ if (scrollEl) {
225
+ scrollEl.addEventListener('scroll', updateViewport, { passive: true });
226
+ ro = new ResizeObserver(updateViewport);
227
+ ro.observe(scrollEl);
228
+ }
229
+ window.addEventListener('pointermove', onPointerMove);
230
+ window.addEventListener('pointerup', onPointerUp);
231
+ window.addEventListener('pointercancel', onPointerUp);
232
+ });
233
+ onDestroy(() => {
234
+ if (scrollEl)
235
+ scrollEl.removeEventListener('scroll', updateViewport);
236
+ ro?.disconnect();
237
+ window.removeEventListener('pointermove', onPointerMove);
238
+ window.removeEventListener('pointerup', onPointerUp);
239
+ window.removeEventListener('pointercancel', onPointerUp);
240
+ });
241
+ // FLIP animation on version change. Skip the active dragger — it has its
242
+ // own pixel-level transform driven by the cursor delta and we don't want
243
+ // FLIP to fight with that.
244
+ $: runFlip(ver);
245
+ async function runFlip(_v) {
246
+ await tick();
247
+ const draggerId = drag?.tileId ?? null;
248
+ for (const t of tilesAll) {
249
+ const x = t.col * colSize + halfGap;
250
+ const y = t.row * rowSize + halfGap;
251
+ const p = prevRects.get(t.id);
252
+ if (t.id === draggerId) {
253
+ // Keep prevRect synced so the post-drop FLIP doesn't double-jump.
254
+ prevRects.set(t.id, { x, y });
255
+ continue;
256
+ }
257
+ const node = tileNodes.get(t.id);
258
+ if (p && node) {
259
+ const dx = p.x - x;
260
+ const dy = p.y - y;
261
+ if (dx !== 0 || dy !== 0) {
262
+ node.style.transition = 'none';
263
+ node.style.transform = `translate(${dx}px, ${dy}px)`;
264
+ requestAnimationFrame(() => {
265
+ node.style.transition = 'transform 220ms cubic-bezier(.2,.7,.2,1)';
266
+ node.style.transform = 'translate(0,0)';
267
+ });
268
+ }
269
+ }
270
+ prevRects.set(t.id, { x, y });
271
+ }
272
+ }
273
+ // Compute layouts for ALL rendered tiles in one pass so we can run
274
+ // resolveStickyStacking afterward — that helper needs to see every sticky
275
+ // tile to compute how they push each other. Dragger and resize overrides
276
+ // take precedence over the engine's positioning fields.
277
+ $: tileLayouts = (() => {
278
+ const out = new Map();
279
+ const stickyEntries = [];
280
+ for (const tile of rendered) {
281
+ let layout;
282
+ if (resize?.tileId === tile.id) {
283
+ const w = resize.previewW;
284
+ const h = resize.previewH;
285
+ layout = {
286
+ left: resize.previewCol * colSize + halfGap,
287
+ top: resize.previewRow * rowSize + halfGap,
288
+ width: w * cfg.unitWidth + (w - 1) * (cfg.gap ?? 0),
289
+ height: h * cfg.unitHeight + (h - 1) * (cfg.gap ?? 0),
290
+ zIndex: 10,
291
+ effective: 'static',
292
+ };
293
+ }
294
+ else if (drag?.tileId === tile.id) {
295
+ layout = {
296
+ left: drag.pickupCol * colSize + halfGap,
297
+ top: drag.pickupRow * rowSize + halfGap,
298
+ width: tile.w * cfg.unitWidth + (tile.w - 1) * (cfg.gap ?? 0),
299
+ height: tile.h * cfg.unitHeight + (tile.h - 1) * (cfg.gap ?? 0),
300
+ transform: `translate(${drag.deltaX}px, ${drag.deltaY}px)`,
301
+ zIndex: 20,
302
+ effective: 'static',
303
+ };
304
+ }
305
+ else {
306
+ layout = computeTileLayout({
307
+ tile,
308
+ config: cfg,
309
+ scrollX: viewport.scrollX,
310
+ scrollY: viewport.scrollY,
311
+ viewportWidth: viewport.width,
312
+ viewportHeight: viewport.height,
313
+ });
314
+ if (layout.effective === 'sticky') {
315
+ stickyEntries.push({ tile, layout });
316
+ }
317
+ }
318
+ out.set(tile.id, layout);
319
+ }
320
+ if (stickyEntries.length > 1)
321
+ resolveStickyStacking(stickyEntries);
322
+ return out;
323
+ })();
324
+ function tileLayoutFor(tile) {
325
+ return tileLayouts.get(tile.id) ?? {
326
+ left: 0, top: 0, width: 0, height: 0, zIndex: 1, effective: 'static',
327
+ };
328
+ }
329
+ function isDragging(id) { return drag?.tileId === id || pinDrag?.tileId === id; }
330
+ function isResizing(id) { return resize?.tileId === id; }
331
+ // Drop indicator size matches the dragger's footprint.
332
+ $: indicatorRect = (() => {
333
+ if (!drag || drag.indicatorCol === null || drag.indicatorRow === null)
334
+ return null;
335
+ const t = api.grid.getTile(drag.tileId);
336
+ if (!t)
337
+ return null;
338
+ return {
339
+ left: drag.indicatorCol * colSize + halfGap,
340
+ top: drag.indicatorRow * rowSize + halfGap,
341
+ width: t.w * cfg.unitWidth + (t.w - 1) * (cfg.gap ?? 0),
342
+ height: t.h * cfg.unitHeight + (t.h - 1) * (cfg.gap ?? 0),
343
+ };
344
+ })();
345
+ </script>
346
+
347
+ {#if loopOn}
348
+ <LoopGrid
349
+ {api}
350
+ {height}
351
+ {showGrid}
352
+ on:dragStart
353
+ on:dragEnd
354
+ on:resizeStart
355
+ on:resizeEnd
356
+ on:cameraChange
357
+ >
358
+ <svelte:fragment slot="tile" let:tile>
359
+ <slot name="tile" {tile} />
360
+ </svelte:fragment>
361
+ </LoopGrid>
362
+ {:else}
363
+ <div
364
+ bind:this={scrollEl}
365
+ class="griddle-scroll"
366
+ style:height={typeof height === 'number' ? height + 'px' : height}
367
+ >
368
+ <div
369
+ class="griddle-content"
370
+ class:grid-bg={showGrid}
371
+ style:width={contentSize.width ? contentSize.width + 'px' : '100%'}
372
+ style:height={contentSize.height ? contentSize.height + 'px' : '100%'}
373
+ style:--colsize={colSize + 'px'}
374
+ style:--rowsize={rowSize + 'px'}
375
+ style:--griddle-tile-radius={(cfg.tileRadius ?? 4) + 'px'}
376
+ >
377
+ {#if indicatorRect}
378
+ <div
379
+ class="griddle-drop-indicator"
380
+ style:left={indicatorRect.left + 'px'}
381
+ style:top={indicatorRect.top + 'px'}
382
+ style:width={indicatorRect.width + 'px'}
383
+ style:height={indicatorRect.height + 'px'}
384
+ ></div>
385
+ {/if}
386
+ {#each rendered as tile (tile.id)}
387
+ {@const layout = tileLayoutFor(tile)}
388
+ <div
389
+ class="griddle-tile"
390
+ class:griddle-dragging={isDragging(tile.id)}
391
+ class:griddle-resizing={isResizing(tile.id)}
392
+ class:griddle-relative={layout.effective === 'relative'}
393
+ class:griddle-absolute={layout.effective === 'absolute'}
394
+ class:griddle-fixed={layout.effective === 'fixed'}
395
+ class:griddle-sticky={layout.effective === 'sticky'}
396
+ data-griddle-tile={tile.id}
397
+ data-griddle-position={layout.effective}
398
+ use:registerNode={tile.id}
399
+ on:pointerdown={(e) => onTilePointerDown(e, tile)}
400
+ style:left={layout.left + 'px'}
401
+ style:top={layout.top + 'px'}
402
+ style:width={layout.width + 'px'}
403
+ style:height={layout.height + 'px'}
404
+ style:transform={layout.transform ?? ''}
405
+ style:z-index={layout.zIndex}
406
+ >
407
+ <slot name="tile" {tile} />
408
+ {#if tile.resizable !== false}
409
+ {#each (tile.resizeHandles ?? cfg.resizeHandles ?? ['se']) as c (c)}
410
+ <div
411
+ class="griddle-handle griddle-handle-{c}"
412
+ data-griddle-handle={c}
413
+ on:pointerdown={(e) => onResizeHandleDown(e, tile, c)}
414
+ ></div>
415
+ {/each}
416
+ {/if}
417
+ </div>
418
+ {/each}
419
+ </div>
420
+ </div>
421
+ {/if}
422
+
423
+ <style>
424
+ .griddle-scroll {
425
+ position: relative;
426
+ overflow: auto;
427
+ touch-action: none;
428
+ }
429
+ .griddle-content {
430
+ position: relative;
431
+ min-width: 100%;
432
+ min-height: 100%;
433
+ }
434
+ .grid-bg {
435
+ background-image:
436
+ linear-gradient(to right, rgba(0,0,0,0.08) 1px, transparent 1px),
437
+ linear-gradient(to bottom, rgba(0,0,0,0.08) 1px, transparent 1px);
438
+ background-size: var(--colsize) var(--rowsize);
439
+ }
440
+ .griddle-tile {
441
+ position: absolute;
442
+ box-sizing: border-box;
443
+ cursor: grab;
444
+ user-select: none;
445
+ will-change: transform;
446
+ z-index: 1;
447
+ }
448
+ .griddle-tile.griddle-resizing {
449
+ z-index: 10;
450
+ opacity: 0.85;
451
+ cursor: grabbing;
452
+ }
453
+ .griddle-tile.griddle-dragging {
454
+ z-index: 20;
455
+ cursor: grabbing;
456
+ opacity: 0.85;
457
+ /* Lift the tile off the grid: drop shadow + subtle scale. The transform
458
+ is set inline (cursor delta), so the scale is folded into a CSS filter
459
+ to avoid clobbering it. */
460
+ filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.18));
461
+ transition: filter 120ms ease-out, opacity 120ms ease-out;
462
+ }
463
+ .griddle-drop-indicator {
464
+ position: absolute;
465
+ box-sizing: border-box;
466
+ border: 2px dashed rgba(59, 91, 219, 0.55);
467
+ background: rgba(59, 91, 219, 0.08);
468
+ border-radius: var(--griddle-tile-radius, 4px);
469
+ pointer-events: none;
470
+ z-index: 5;
471
+ }
472
+ .griddle-handle {
473
+ position: absolute;
474
+ width: 12px;
475
+ height: 12px;
476
+ background: rgba(60,60,60,0.7);
477
+ border: 2px solid white;
478
+ border-radius: 3px;
479
+ touch-action: none;
480
+ }
481
+ .griddle-handle-nw { top: -6px; left: -6px; cursor: nw-resize; }
482
+ .griddle-handle-ne { top: -6px; right: -6px; cursor: ne-resize; }
483
+ .griddle-handle-sw { bottom: -6px; left: -6px; cursor: sw-resize; }
484
+ .griddle-handle-se { bottom: -6px; right: -6px; cursor: se-resize; }
485
+ </style>
@@ -0,0 +1,32 @@
1
+ import { SvelteComponent } from "svelte";
2
+ import type { Tile } from '@griddle/core';
3
+ import type { GriddleApi } from './griddleStore.js';
4
+ declare const __propDef: {
5
+ props: {
6
+ api: GriddleApi;
7
+ height?: number | string;
8
+ showGrid?: boolean;
9
+ };
10
+ events: {
11
+ dragStart: CustomEvent<any>;
12
+ dragEnd: CustomEvent<any>;
13
+ resizeStart: CustomEvent<any>;
14
+ resizeEnd: CustomEvent<any>;
15
+ cameraChange: CustomEvent<import("@griddle/core").CameraState>;
16
+ } & {
17
+ [evt: string]: CustomEvent<any>;
18
+ };
19
+ slots: {
20
+ tile: {
21
+ tile: Tile;
22
+ };
23
+ };
24
+ exports?: {} | undefined;
25
+ bindings?: string | undefined;
26
+ };
27
+ export type GriddleGridProps = typeof __propDef.props;
28
+ export type GriddleGridEvents = typeof __propDef.events;
29
+ export type GriddleGridSlots = typeof __propDef.slots;
30
+ export default class GriddleGrid extends SvelteComponent<GriddleGridProps, GriddleGridEvents, GriddleGridSlots> {
31
+ }
32
+ export {};
@@ -0,0 +1,521 @@
1
+ <script>// Loop-mode renderer ("object looping") — see @griddle/core loop.ts for the
2
+ // coordinate model. Mirrors the React GriddleLoopGrid: an overflow-hidden
3
+ // viewport, a transform-translated plane driven by an unbounded camera, and
4
+ // NO native scrolling (wheel deltas feed the camera directly).
5
+ import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
6
+ import { DragController, PanController, loopInstances, loopPeriod, resolveLoop, } from '@griddle/core';
7
+ export let api;
8
+ export let height = '100%';
9
+ export let showGrid = true;
10
+ const dispatch = createEventDispatcher();
11
+ const DEFAULT_DRAG_IGNORE = 'a, button, input, textarea, select, [contenteditable]';
12
+ const PAN_THRESHOLD_PX = 4;
13
+ let viewportEl;
14
+ let planeEl;
15
+ const cfgStore = api.config;
16
+ const tilesStore = api.tiles;
17
+ const versionStore = api.version;
18
+ $: cfg = $cfgStore;
19
+ $: tilesAll = $tilesStore;
20
+ $: ver = $versionStore;
21
+ $: loop = resolveLoop(cfg);
22
+ $: editable = loop?.interaction === 'edit';
23
+ $: gapPx = cfg.gap ?? 0;
24
+ $: halfGap = gapPx / 2;
25
+ $: colSize = cfg.unitWidth + gapPx;
26
+ $: rowSize = cfg.unitHeight + gapPx;
27
+ $: period = loopPeriod(cfg, tilesAll);
28
+ // ---- camera plumbing ----------------------------------------------------
29
+ const pan = new PanController();
30
+ $: if (loop) {
31
+ pan.setPhysics({ friction: loop.friction, ease: loop.ease, maxVelocity: loop.maxVelocity });
32
+ }
33
+ let view = { cxCell: 0, cyCell: 0, vw: 1000, vh: 800 };
34
+ let lastCam = null;
35
+ let raf = 0;
36
+ let resizeObserver = null;
37
+ function onWheel(e) {
38
+ e.preventDefault();
39
+ const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1;
40
+ pan.scrollBy(e.deltaX * k, e.deltaY * k);
41
+ }
42
+ function frame(now) {
43
+ raf = requestAnimationFrame(frame);
44
+ const st = pan.tick(now);
45
+ if (planeEl) {
46
+ planeEl.style.transform = `translate3d(${-st.x}px, ${-st.y}px, 0)`;
47
+ }
48
+ if (viewportEl && showGrid) {
49
+ viewportEl.style.backgroundPosition = `${-st.x % colSize}px ${-st.y % rowSize}px`;
50
+ }
51
+ const cxCell = Math.floor(st.x / colSize);
52
+ const cyCell = Math.floor(st.y / rowSize);
53
+ if (cxCell !== view.cxCell || cyCell !== view.cyCell) {
54
+ view = { ...view, cxCell, cyCell };
55
+ }
56
+ if (!lastCam || lastCam.x !== st.x || lastCam.y !== st.y ||
57
+ lastCam.isMoving !== st.isMoving || lastCam.isDragging !== st.isDragging) {
58
+ lastCam = st;
59
+ dispatch('cameraChange', st);
60
+ }
61
+ }
62
+ $: instances = (() => {
63
+ void ver; // re-run on grid changes
64
+ const bufX = 2 * colSize;
65
+ const bufY = 2 * rowSize;
66
+ return loopInstances(cfg, tilesAll, {
67
+ x: view.cxCell * colSize - bufX,
68
+ y: view.cyCell * rowSize - bufY,
69
+ width: view.vw + colSize + 2 * bufX,
70
+ height: view.vh + rowSize + 2 * bufY,
71
+ });
72
+ })();
73
+ // ---- pan gesture ----------------------------------------------------------
74
+ let panGesture = null;
75
+ let suppressClick = false;
76
+ function onContainerPointerDown(e) {
77
+ // Tiles that start drags stopPropagation, so reaching here from a tile
78
+ // means it was not draggable — panning is the right fallback.
79
+ if (!loop?.dragPan || e.button !== 0)
80
+ return;
81
+ const ignoreSelector = cfg.dragIgnoreFrom ?? DEFAULT_DRAG_IGNORE;
82
+ if (ignoreSelector && e.target.closest(ignoreSelector))
83
+ return;
84
+ panGesture = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, moved: false };
85
+ }
86
+ function onClickCapture(e) {
87
+ if (suppressClick) {
88
+ suppressClick = false;
89
+ e.preventDefault();
90
+ e.stopPropagation();
91
+ }
92
+ }
93
+ // ---- tile drag / resize ('edit' interaction) ----------------------------
94
+ const dragController = new DragController(api.grid);
95
+ let drag = null;
96
+ let dragStartPointerX = 0;
97
+ let dragStartPointerY = 0;
98
+ let resize = null;
99
+ function isBase(inst) {
100
+ return inst.kx === 0 && inst.ky === 0;
101
+ }
102
+ function onTilePointerDown(e, inst) {
103
+ if (!editable)
104
+ return; // pan mode: container pan handler takes it
105
+ if (!isBase(inst))
106
+ return; // ghosts are display-only
107
+ const tile = inst.tile;
108
+ if (tile.draggable === false)
109
+ return;
110
+ if (e.target.dataset.griddleHandle)
111
+ return;
112
+ const ignoreSelector = cfg.dragIgnoreFrom ?? DEFAULT_DRAG_IGNORE;
113
+ if (ignoreSelector && e.target.closest(ignoreSelector))
114
+ return;
115
+ // No pointer capture: the grabbed instance unmounts (drag renders as an
116
+ // overlay), which would release the capture mid-gesture.
117
+ if (!dragController.start(tile.id))
118
+ return;
119
+ dragStartPointerX = e.clientX;
120
+ dragStartPointerY = e.clientY;
121
+ drag = {
122
+ tileId: tile.id,
123
+ instanceLeft: inst.left,
124
+ instanceTop: inst.top,
125
+ pickupCol: tile.col,
126
+ pickupRow: tile.row,
127
+ deltaX: 0,
128
+ deltaY: 0,
129
+ indicatorCol: tile.col,
130
+ indicatorRow: tile.row,
131
+ };
132
+ dispatch('dragStart', { tileId: tile.id });
133
+ e.stopPropagation();
134
+ }
135
+ function onResizeHandleDown(e, inst, c) {
136
+ if (!editable)
137
+ return;
138
+ const tile = inst.tile;
139
+ if (tile.resizable === false)
140
+ return;
141
+ e.currentTarget.setPointerCapture(e.pointerId);
142
+ const baseLeft = tile.col * colSize + halfGap;
143
+ const baseTop = tile.row * rowSize + halfGap;
144
+ resize = {
145
+ tileId: tile.id,
146
+ instanceKey: inst.key,
147
+ instanceDx: inst.left - baseLeft,
148
+ instanceDy: inst.top - baseTop,
149
+ corner: c,
150
+ startPointerX: e.clientX,
151
+ startPointerY: e.clientY,
152
+ startW: tile.w,
153
+ startH: tile.h,
154
+ startCol: tile.col,
155
+ startRow: tile.row,
156
+ previewW: tile.w,
157
+ previewH: tile.h,
158
+ previewCol: tile.col,
159
+ previewRow: tile.row,
160
+ };
161
+ dispatch('resizeStart', { tileId: tile.id });
162
+ e.stopPropagation();
163
+ }
164
+ function onPointerMove(e) {
165
+ if (panGesture && e.pointerId === panGesture.pointerId) {
166
+ const now = performance.now();
167
+ if (!panGesture.moved) {
168
+ const dist = Math.hypot(e.clientX - panGesture.startX, e.clientY - panGesture.startY);
169
+ if (dist >= PAN_THRESHOLD_PX) {
170
+ panGesture.moved = true;
171
+ pan.dragStart(panGesture.startX, panGesture.startY, now);
172
+ pan.dragMove(e.clientX, e.clientY, now);
173
+ }
174
+ }
175
+ else {
176
+ pan.dragMove(e.clientX, e.clientY, now);
177
+ }
178
+ return;
179
+ }
180
+ if (drag) {
181
+ // Ghost edit: plain grid semantics in the base copy — no wrapping.
182
+ const dx = e.clientX - dragStartPointerX;
183
+ const dy = e.clientY - dragStartPointerY;
184
+ const candidate = {
185
+ col: drag.pickupCol + Math.round(dx / colSize),
186
+ row: drag.pickupRow + Math.round(dy / rowSize),
187
+ };
188
+ const result = dragController.update(candidate);
189
+ drag = {
190
+ ...drag,
191
+ deltaX: dx,
192
+ deltaY: dy,
193
+ indicatorCol: result.indicatorCell ? result.indicatorCell.col : null,
194
+ indicatorRow: result.indicatorCell ? result.indicatorCell.row : null,
195
+ };
196
+ // restoreTiles() doesn't emit change events; force-sync the local list.
197
+ if (result.changed)
198
+ tilesAll = api.grid.tiles;
199
+ }
200
+ if (resize) {
201
+ const dx = e.clientX - resize.startPointerX;
202
+ const dy = e.clientY - resize.startPointerY;
203
+ let dw = 0, dh = 0, dcol = 0, drow = 0;
204
+ const stepsX = Math.round(dx / colSize);
205
+ const stepsY = Math.round(dy / rowSize);
206
+ if (resize.corner === 'se' || resize.corner === 'ne')
207
+ dw = stepsX;
208
+ if (resize.corner === 'se' || resize.corner === 'sw')
209
+ dh = stepsY;
210
+ if (resize.corner === 'sw' || resize.corner === 'nw') {
211
+ dw = -stepsX;
212
+ dcol = stepsX;
213
+ }
214
+ if (resize.corner === 'ne' || resize.corner === 'nw') {
215
+ dh = -stepsY;
216
+ drow = stepsY;
217
+ }
218
+ const tile = api.grid.getTile(resize.tileId);
219
+ const minW = tile?.minW ?? 1;
220
+ const minH = tile?.minH ?? 1;
221
+ const maxW = Math.min(tile?.maxW ?? Infinity, cfg.cols);
222
+ const maxH = Math.min(tile?.maxH ?? Infinity, cfg.rows);
223
+ const nW = Math.min(maxW, Math.max(minW, resize.startW + dw));
224
+ const nH = Math.min(maxH, Math.max(minH, resize.startH + dh));
225
+ const nC = resize.startCol + dcol;
226
+ const nR = resize.startRow + drow;
227
+ if (nW !== resize.previewW || nH !== resize.previewH || nC !== resize.previewCol || nR !== resize.previewRow) {
228
+ resize = { ...resize, previewW: nW, previewH: nH, previewCol: nC, previewRow: nR };
229
+ }
230
+ }
231
+ }
232
+ function onPointerUp(e) {
233
+ if (panGesture && e.pointerId === panGesture.pointerId) {
234
+ if (panGesture.moved) {
235
+ pan.dragEnd(performance.now());
236
+ suppressClick = true;
237
+ }
238
+ panGesture = null;
239
+ return;
240
+ }
241
+ if (drag) {
242
+ const tileId = drag.tileId;
243
+ const { committed } = dragController.end();
244
+ drag = null;
245
+ tilesAll = api.grid.tiles;
246
+ dispatch('dragEnd', { tileId, committed });
247
+ }
248
+ if (resize) {
249
+ const r = resize;
250
+ const t = api.grid.getTile(r.tileId);
251
+ let committed = false;
252
+ if (t) {
253
+ const target = { col: r.previewCol, row: r.previewRow };
254
+ if (target.col !== t.col || target.row !== t.row) {
255
+ api.moveTile(r.tileId, target);
256
+ }
257
+ if (r.previewW !== t.w || r.previewH !== t.h) {
258
+ committed = api.resizeTile(r.tileId, { w: r.previewW, h: r.previewH });
259
+ }
260
+ else {
261
+ committed = r.previewCol !== r.startCol || r.previewRow !== r.startRow
262
+ || r.previewW !== r.startW || r.previewH !== r.startH;
263
+ }
264
+ }
265
+ resize = null;
266
+ dispatch('resizeEnd', { tileId: r.tileId, committed });
267
+ }
268
+ }
269
+ onMount(() => {
270
+ if (viewportEl) {
271
+ viewportEl.addEventListener('wheel', onWheel, { passive: false });
272
+ const measure = () => {
273
+ if (viewportEl.clientWidth !== view.vw || viewportEl.clientHeight !== view.vh) {
274
+ view = { ...view, vw: viewportEl.clientWidth, vh: viewportEl.clientHeight };
275
+ }
276
+ };
277
+ measure();
278
+ resizeObserver = new ResizeObserver(measure);
279
+ resizeObserver.observe(viewportEl);
280
+ }
281
+ raf = requestAnimationFrame(frame);
282
+ window.addEventListener('pointermove', onPointerMove);
283
+ window.addEventListener('pointerup', onPointerUp);
284
+ window.addEventListener('pointercancel', onPointerUp);
285
+ });
286
+ onDestroy(() => {
287
+ cancelAnimationFrame(raf);
288
+ if (viewportEl)
289
+ viewportEl.removeEventListener('wheel', onWheel);
290
+ resizeObserver?.disconnect();
291
+ window.removeEventListener('pointermove', onPointerMove);
292
+ window.removeEventListener('pointerup', onPointerUp);
293
+ window.removeEventListener('pointercancel', onPointerUp);
294
+ });
295
+ // ---- FLIP for edit-mode repacks (keyed by world key) --------------------
296
+ const tileNodes = new Map();
297
+ const prevRects = new Map();
298
+ function registerNode(node, key) {
299
+ tileNodes.set(key, node);
300
+ return {
301
+ update(newKey) {
302
+ if (newKey !== key) {
303
+ tileNodes.delete(key);
304
+ key = newKey;
305
+ tileNodes.set(key, node);
306
+ }
307
+ },
308
+ destroy() {
309
+ tileNodes.delete(key);
310
+ },
311
+ };
312
+ }
313
+ $: runFlip(ver);
314
+ async function runFlip(_v) {
315
+ await tick();
316
+ const draggerId = drag?.tileId ?? null;
317
+ const seen = new Set();
318
+ for (const inst of instances) {
319
+ const key = inst.key;
320
+ seen.add(key);
321
+ const x = inst.left;
322
+ const y = inst.top;
323
+ if (editable && inst.tile.id !== draggerId) {
324
+ const p = prevRects.get(key);
325
+ const node = tileNodes.get(key);
326
+ if (p && node) {
327
+ const dx = p.x - x;
328
+ const dy = p.y - y;
329
+ if ((dx !== 0 || dy !== 0) &&
330
+ Math.abs(dx) < period.width / 2 &&
331
+ Math.abs(dy) < period.height / 2) {
332
+ node.style.transition = 'none';
333
+ node.style.transform = `translate(${dx}px, ${dy}px)`;
334
+ requestAnimationFrame(() => {
335
+ node.style.transition = 'transform 220ms cubic-bezier(.2,.7,.2,1)';
336
+ node.style.transform = 'translate(0,0)';
337
+ });
338
+ }
339
+ }
340
+ }
341
+ prevRects.set(key, { x, y });
342
+ }
343
+ for (const key of prevRects.keys()) {
344
+ if (!seen.has(key))
345
+ prevRects.delete(key);
346
+ }
347
+ }
348
+ // ---- drop indicator + drag overlay ---------------------------------------
349
+ // Indicator renders in the base copy (ghost edit is plain-grid).
350
+ $: indicatorRect = (() => {
351
+ if (!drag || drag.indicatorCol === null || drag.indicatorRow === null)
352
+ return null;
353
+ const t = api.grid.getTile(drag.tileId);
354
+ if (!t)
355
+ return null;
356
+ return {
357
+ left: drag.indicatorCol * colSize + halfGap,
358
+ top: drag.indicatorRow * rowSize + halfGap,
359
+ width: t.w * cfg.unitWidth + (t.w - 1) * gapPx,
360
+ height: t.h * cfg.unitHeight + (t.h - 1) * gapPx,
361
+ };
362
+ })();
363
+ $: draggedTile = drag ? api.grid.getTile(drag.tileId) ?? null : null;
364
+ function instanceLayout(inst, r) {
365
+ if (r && r.instanceKey === inst.key) {
366
+ return {
367
+ left: r.previewCol * colSize + halfGap + r.instanceDx,
368
+ top: r.previewRow * rowSize + halfGap + r.instanceDy,
369
+ width: r.previewW * cfg.unitWidth + (r.previewW - 1) * gapPx,
370
+ height: r.previewH * cfg.unitHeight + (r.previewH - 1) * gapPx,
371
+ };
372
+ }
373
+ return { left: inst.left, top: inst.top, width: inst.width, height: inst.height };
374
+ }
375
+ </script>
376
+
377
+ <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
378
+ <div
379
+ bind:this={viewportEl}
380
+ class="griddle-viewport"
381
+ class:griddle-pannable={!editable && loop?.dragPan}
382
+ class:grid-bg={showGrid}
383
+ style:height={typeof height === 'number' ? height + 'px' : height}
384
+ style:--colsize={colSize + 'px'}
385
+ style:--rowsize={rowSize + 'px'}
386
+ style:--griddle-tile-radius={(cfg.tileRadius ?? 4) + 'px'}
387
+ on:pointerdown={onContainerPointerDown}
388
+ on:click|capture={onClickCapture}
389
+ >
390
+ <div bind:this={planeEl} class="griddle-plane">
391
+ {#if indicatorRect}
392
+ <div
393
+ class="griddle-drop-indicator"
394
+ style:left={indicatorRect.left + 'px'}
395
+ style:top={indicatorRect.top + 'px'}
396
+ style:width={indicatorRect.width + 'px'}
397
+ style:height={indicatorRect.height + 'px'}
398
+ ></div>
399
+ {/if}
400
+ {#each instances.filter((i) => !(drag && i.tile.id === drag.tileId)) as inst (inst.key)}
401
+ {@const layout = instanceLayout(inst, resize)}
402
+ {@const ghost = editable && !isBase(inst)}
403
+ <div
404
+ class="griddle-tile"
405
+ class:griddle-editable={editable && isBase(inst)}
406
+ class:griddle-ghost={ghost}
407
+ class:griddle-resizing={resize?.instanceKey === inst.key}
408
+ data-griddle-tile={inst.tile.id}
409
+ data-griddle-instance={inst.key}
410
+ data-griddle-ghost={ghost ? '' : undefined}
411
+ aria-hidden={isBase(inst) ? undefined : true}
412
+ use:registerNode={inst.key}
413
+ on:pointerdown={(e) => onTilePointerDown(e, inst)}
414
+ style:left={layout.left + 'px'}
415
+ style:top={layout.top + 'px'}
416
+ style:width={layout.width + 'px'}
417
+ style:height={layout.height + 'px'}
418
+ >
419
+ <slot name="tile" tile={inst.tile} />
420
+ {#if editable && isBase(inst) && inst.tile.resizable !== false}
421
+ {#each (inst.tile.resizeHandles ?? cfg.resizeHandles ?? ['se']) as c (c)}
422
+ <div
423
+ class="griddle-handle griddle-handle-{c}"
424
+ data-griddle-handle={c}
425
+ on:pointerdown={(e) => onResizeHandleDown(e, inst, c)}
426
+ ></div>
427
+ {/each}
428
+ {/if}
429
+ </div>
430
+ {/each}
431
+ {#if drag && draggedTile}
432
+ <div
433
+ class="griddle-tile griddle-drag-overlay"
434
+ data-griddle-tile={draggedTile.id}
435
+ style:left={drag.instanceLeft + 'px'}
436
+ style:top={drag.instanceTop + 'px'}
437
+ style:width={draggedTile.w * cfg.unitWidth + (draggedTile.w - 1) * gapPx + 'px'}
438
+ style:height={draggedTile.h * cfg.unitHeight + (draggedTile.h - 1) * gapPx + 'px'}
439
+ style:transform={`translate(${drag.deltaX}px, ${drag.deltaY}px)`}
440
+ >
441
+ <slot name="tile" tile={draggedTile} />
442
+ </div>
443
+ {/if}
444
+ </div>
445
+ </div>
446
+
447
+ <style>
448
+ .griddle-viewport {
449
+ position: relative;
450
+ overflow: hidden;
451
+ touch-action: none;
452
+ user-select: none;
453
+ }
454
+ .griddle-viewport.griddle-pannable {
455
+ cursor: grab;
456
+ }
457
+ .grid-bg {
458
+ background-image:
459
+ linear-gradient(to right, rgba(0,0,0,0.08) 1px, transparent 1px),
460
+ linear-gradient(to bottom, rgba(0,0,0,0.08) 1px, transparent 1px);
461
+ background-size: var(--colsize) var(--rowsize);
462
+ }
463
+ .griddle-plane {
464
+ position: absolute;
465
+ top: 0;
466
+ left: 0;
467
+ width: 0;
468
+ height: 0;
469
+ will-change: transform;
470
+ }
471
+ .griddle-tile {
472
+ position: absolute;
473
+ box-sizing: border-box;
474
+ user-select: none;
475
+ z-index: 1;
476
+ border-radius: var(--griddle-tile-radius, 4px);
477
+ }
478
+ .griddle-tile.griddle-editable {
479
+ cursor: grab;
480
+ }
481
+ /* Ghost repeats are display-only: pointer-transparent (the gesture falls
482
+ through to drag-pan) and dimmed to mark the editable copy. */
483
+ .griddle-tile.griddle-ghost {
484
+ pointer-events: none;
485
+ opacity: 0.55;
486
+ }
487
+ .griddle-tile.griddle-resizing {
488
+ z-index: 10;
489
+ opacity: 0.85;
490
+ cursor: grabbing;
491
+ }
492
+ .griddle-tile.griddle-drag-overlay {
493
+ z-index: 20;
494
+ cursor: grabbing;
495
+ opacity: 0.85;
496
+ pointer-events: none;
497
+ filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.18));
498
+ }
499
+ .griddle-drop-indicator {
500
+ position: absolute;
501
+ box-sizing: border-box;
502
+ border: 2px dashed rgba(59, 91, 219, 0.55);
503
+ background: rgba(59, 91, 219, 0.08);
504
+ border-radius: var(--griddle-tile-radius, 4px);
505
+ pointer-events: none;
506
+ z-index: 5;
507
+ }
508
+ .griddle-handle {
509
+ position: absolute;
510
+ width: 12px;
511
+ height: 12px;
512
+ background: rgba(60,60,60,0.7);
513
+ border: 2px solid white;
514
+ border-radius: 3px;
515
+ touch-action: none;
516
+ }
517
+ .griddle-handle-nw { top: -6px; left: -6px; cursor: nw-resize; }
518
+ .griddle-handle-ne { top: -6px; right: -6px; cursor: ne-resize; }
519
+ .griddle-handle-sw { bottom: -6px; left: -6px; cursor: sw-resize; }
520
+ .griddle-handle-se { bottom: -6px; right: -6px; cursor: se-resize; }
521
+ </style>
@@ -0,0 +1,42 @@
1
+ import { SvelteComponent } from "svelte";
2
+ import type { CameraState } from '@griddle/core';
3
+ import type { GriddleApi } from './griddleStore.js';
4
+ declare const __propDef: {
5
+ props: {
6
+ api: GriddleApi;
7
+ height?: number | string;
8
+ showGrid?: boolean;
9
+ };
10
+ events: {
11
+ dragStart: CustomEvent<{
12
+ tileId: string;
13
+ }>;
14
+ dragEnd: CustomEvent<{
15
+ tileId: string;
16
+ committed: boolean;
17
+ }>;
18
+ resizeStart: CustomEvent<{
19
+ tileId: string;
20
+ }>;
21
+ resizeEnd: CustomEvent<{
22
+ tileId: string;
23
+ committed: boolean;
24
+ }>;
25
+ cameraChange: CustomEvent<CameraState>;
26
+ } & {
27
+ [evt: string]: CustomEvent<any>;
28
+ };
29
+ slots: {
30
+ tile: {
31
+ tile: import("@griddle/core").Tile | null;
32
+ };
33
+ };
34
+ exports?: {} | undefined;
35
+ bindings?: string | undefined;
36
+ };
37
+ export type LoopGridProps = typeof __propDef.props;
38
+ export type LoopGridEvents = typeof __propDef.events;
39
+ export type LoopGridSlots = typeof __propDef.slots;
40
+ export default class LoopGrid extends SvelteComponent<LoopGridProps, LoopGridEvents, LoopGridSlots> {
41
+ }
42
+ export {};
@@ -0,0 +1,28 @@
1
+ import { type Readable } from 'svelte/store';
2
+ import { Grid } from '@griddle/core';
3
+ import type { GridConfig, Tile, GridSnapshot } from '@griddle/core';
4
+ export interface UseGriddleInit {
5
+ config: GridConfig;
6
+ tiles?: Tile[];
7
+ }
8
+ export interface GriddleApi {
9
+ grid: Grid;
10
+ tiles: Readable<Tile[]>;
11
+ config: Readable<GridConfig>;
12
+ version: Readable<number>;
13
+ moveTile: (id: string, target: {
14
+ col: number;
15
+ row: number;
16
+ }) => boolean;
17
+ resizeTile: (id: string, size: {
18
+ w: number;
19
+ h: number;
20
+ }) => boolean;
21
+ addTile: (t: Tile) => void;
22
+ removeTile: (id: string) => void;
23
+ updateConfig: (patch: Partial<GridConfig>) => void;
24
+ toJSON: () => GridSnapshot;
25
+ loadJSON: (snap: GridSnapshot) => void;
26
+ destroy: () => void;
27
+ }
28
+ export declare function createGriddle(init: UseGriddleInit): GriddleApi;
@@ -0,0 +1,28 @@
1
+ // Svelte store that wraps a Grid instance.
2
+ import { writable } from 'svelte/store';
3
+ import { Grid } from '@griddle/core';
4
+ export function createGriddle(init) {
5
+ const grid = new Grid(init.config, init.tiles ?? []);
6
+ const tiles = writable(grid.tiles);
7
+ const config = writable(grid.config);
8
+ const version = writable(0);
9
+ const off = grid.changes.on(() => {
10
+ tiles.set(grid.tiles);
11
+ config.set(grid.config);
12
+ version.update((v) => v + 1);
13
+ });
14
+ return {
15
+ grid,
16
+ tiles: { subscribe: tiles.subscribe },
17
+ config: { subscribe: config.subscribe },
18
+ version: { subscribe: version.subscribe },
19
+ moveTile: (id, target) => grid.moveTile(id, target),
20
+ resizeTile: (id, size) => grid.resizeTile(id, size),
21
+ addTile: (t) => grid.addTile(t),
22
+ removeTile: (id) => grid.removeTile(id),
23
+ updateConfig: (patch) => grid.updateConfig(patch),
24
+ toJSON: () => grid.toJSON(),
25
+ loadJSON: (snap) => grid.loadJSON(snap),
26
+ destroy: () => off(),
27
+ };
28
+ }
@@ -0,0 +1,3 @@
1
+ export { default as GriddleGrid } from './GriddleGrid.svelte';
2
+ export { default as GriddleLoopGrid } from './LoopGrid.svelte';
3
+ export { createGriddle, type GriddleApi, type UseGriddleInit } from './griddleStore.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { default as GriddleGrid } from './GriddleGrid.svelte';
2
+ export { default as GriddleLoopGrid } from './LoopGrid.svelte';
3
+ export { createGriddle } from './griddleStore.js';
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@griddle/svelte",
3
+ "version": "0.1.0",
4
+ "description": "Svelte bindings for Griddle — a headless, zero-dependency grid/canvas engine.",
5
+ "keywords": [
6
+ "grid",
7
+ "canvas",
8
+ "layout",
9
+ "drag-and-drop",
10
+ "drag",
11
+ "resize",
12
+ "headless",
13
+ "virtualization",
14
+ "tiles",
15
+ "svelte"
16
+ ],
17
+ "homepage": "https://github.com/Trustybits/griddle#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/Trustybits/griddle/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/Trustybits/griddle.git",
24
+ "directory": "packages/svelte"
25
+ },
26
+ "license": "MIT",
27
+ "author": "Trustybits",
28
+ "type": "module",
29
+ "sideEffects": ["**/*.svelte", "**/*.css"],
30
+ "svelte": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "svelte": "./dist/index.js"
36
+ }
37
+ },
38
+ "files": ["dist"],
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "scripts": {
46
+ "build": "svelte-package -i src -o dist",
47
+ "prepublishOnly": "npm run build"
48
+ },
49
+ "peerDependencies": {
50
+ "@griddle/core": "^0.1.0",
51
+ "svelte": "^4.0.0 || ^5.0.0"
52
+ },
53
+ "devDependencies": {
54
+ "@griddle/core": "^0.1.0",
55
+ "@sveltejs/package": "^2.5.8",
56
+ "svelte": "^4.2.0",
57
+ "svelte-check": "^4.7.2",
58
+ "svelte-preprocess": "^5.1.0"
59
+ }
60
+ }