@griddle/vue 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.
@@ -0,0 +1,629 @@
1
+ <template>
2
+ <div
3
+ ref="viewportEl"
4
+ :class="className"
5
+ :style="viewportStyle"
6
+ @pointerdown="onContainerPointerDown"
7
+ @click.capture="onClickCapture"
8
+ >
9
+ <div ref="planeEl" :style="planeStyle">
10
+ <div
11
+ v-if="indicatorRect"
12
+ class="griddle-drop-indicator"
13
+ :style="{
14
+ position: 'absolute',
15
+ left: indicatorRect.left + 'px',
16
+ top: indicatorRect.top + 'px',
17
+ width: indicatorRect.width + 'px',
18
+ height: indicatorRect.height + 'px',
19
+ boxSizing: 'border-box',
20
+ border: '2px dashed rgba(59, 91, 219, 0.55)',
21
+ background: 'rgba(59, 91, 219, 0.08)',
22
+ borderRadius: (config.tileRadius ?? 4) + 'px',
23
+ pointerEvents: 'none',
24
+ zIndex: 5,
25
+ }"
26
+ ></div>
27
+ <template v-for="inst in instances" :key="inst.key">
28
+ <div
29
+ v-if="!(drag && inst.tile.id === drag.tileId)"
30
+ :data-griddle-tile="inst.tile.id"
31
+ :data-griddle-instance="inst.key"
32
+ :data-griddle-ghost="isGhost(inst) ? '' : undefined"
33
+ :aria-hidden="isBase(inst) ? undefined : true"
34
+ :class="['griddle-tile', {
35
+ 'griddle-resizing': resize?.instanceKey === inst.key,
36
+ 'griddle-selected': editable && isBase(inst) && selection.has(inst.tile.id),
37
+ }]"
38
+ :style="instanceStyle(inst)"
39
+ :ref="(el) => registerNode(inst.key, el as HTMLDivElement | null)"
40
+ @pointerdown="(e) => onTilePointerDown(e, inst)"
41
+ >
42
+ <slot name="tile" :tile="inst.tile" :selected="editable && isBase(inst) && selection.has(inst.tile.id)" />
43
+ <template v-if="editable && isBase(inst) && inst.tile.resizable !== false">
44
+ <div
45
+ v-for="c in tileHandles(inst.tile)"
46
+ :key="c"
47
+ :data-griddle-handle="c"
48
+ :style="handleStyle(c)"
49
+ @pointerdown="(e) => onResizeHandleDown(e, inst, c)"
50
+ ></div>
51
+ </template>
52
+ </div>
53
+ </template>
54
+ <div
55
+ v-if="drag && draggedTile"
56
+ :data-griddle-tile="draggedTile.id"
57
+ class="griddle-tile griddle-dragging"
58
+ :style="dragOverlayStyle"
59
+ >
60
+ <slot name="tile" :tile="draggedTile" :selected="selection.has(draggedTile.id)" />
61
+ </div>
62
+ </div>
63
+ </div>
64
+ </template>
65
+
66
+ <script setup lang="ts">
67
+ // Loop-mode renderer ("object looping") — see @griddle/core loop.ts for the
68
+ // coordinate model. Mirrors the React GriddleLoopGrid: an overflow-hidden
69
+ // viewport, a transform-translated plane driven by an unbounded camera, and
70
+ // NO native scrolling (wheel deltas feed the camera directly).
71
+ import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue';
72
+ import type { CameraState, CellPos, Corner, Tile } from '@griddle/core';
73
+ import {
74
+ DragController,
75
+ PanController,
76
+ loopInstances,
77
+ loopPeriod,
78
+ resolveLoop,
79
+ } from '@griddle/core';
80
+ import type { LoopTileInstance } from '@griddle/core';
81
+ import type { GriddleApi } from './useGriddle.js';
82
+
83
+ const props = defineProps<{
84
+ api: GriddleApi;
85
+ className?: string;
86
+ height?: number | string;
87
+ showGrid?: boolean;
88
+ selection?: Set<string>;
89
+ }>();
90
+
91
+ const emit = defineEmits<{
92
+ (e: 'selectionChange', selection: Set<string>): void;
93
+ (e: 'dragStart', tileId: string): void;
94
+ (e: 'dragEnd', tileId: string, committed: boolean): void;
95
+ (e: 'resizeStart', tileId: string): void;
96
+ (e: 'resizeEnd', tileId: string, committed: boolean): void;
97
+ (e: 'cameraChange', camera: CameraState): void;
98
+ }>();
99
+
100
+ const DEFAULT_DRAG_IGNORE = 'a, button, input, textarea, select, [contenteditable]';
101
+ const PAN_THRESHOLD_PX = 4;
102
+
103
+ const viewportEl = ref<HTMLDivElement | null>(null);
104
+ const planeEl = ref<HTMLDivElement | null>(null);
105
+
106
+ const config = computed(() => props.api.config.value);
107
+ const loop = computed(() => resolveLoop(config.value));
108
+ const editable = computed(() => loop.value?.interaction === 'edit');
109
+ const gap = computed(() => config.value.gap ?? 0);
110
+ const halfGap = computed(() => gap.value / 2);
111
+ const colSize = computed(() => config.value.unitWidth + gap.value);
112
+ const rowSize = computed(() => config.value.unitHeight + gap.value);
113
+ const period = computed(() => loopPeriod(config.value, props.api.tiles.value));
114
+
115
+ // ---- camera plumbing ------------------------------------------------------
116
+ const pan = new PanController();
117
+ watch(loop, (l) => {
118
+ if (l) pan.setPhysics({ friction: l.friction, ease: l.ease, maxVelocity: l.maxVelocity });
119
+ }, { immediate: true });
120
+
121
+ const view = ref({ cxCell: 0, cyCell: 0, vw: 1000, vh: 800 });
122
+ let lastCam: CameraState | null = null;
123
+ let raf = 0;
124
+ let resizeObserver: ResizeObserver | null = null;
125
+
126
+ function onWheel(e: WheelEvent) {
127
+ e.preventDefault();
128
+ const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1;
129
+ pan.scrollBy(e.deltaX * k, e.deltaY * k);
130
+ }
131
+
132
+ function frame(now: number) {
133
+ raf = requestAnimationFrame(frame);
134
+ const st = pan.tick(now);
135
+
136
+ const plane = planeEl.value;
137
+ if (plane) {
138
+ plane.style.transform = `translate3d(${-st.x}px, ${-st.y}px, 0)`;
139
+ }
140
+ const el = viewportEl.value;
141
+ if (el && props.showGrid !== false) {
142
+ el.style.backgroundPosition = `${-st.x % colSize.value}px ${-st.y % rowSize.value}px`;
143
+ }
144
+
145
+ const cxCell = Math.floor(st.x / colSize.value);
146
+ const cyCell = Math.floor(st.y / rowSize.value);
147
+ const cur = view.value;
148
+ if (cxCell !== cur.cxCell || cyCell !== cur.cyCell) {
149
+ view.value = { ...cur, cxCell, cyCell };
150
+ }
151
+
152
+ if (
153
+ !lastCam || lastCam.x !== st.x || lastCam.y !== st.y ||
154
+ lastCam.isMoving !== st.isMoving || lastCam.isDragging !== st.isDragging
155
+ ) {
156
+ lastCam = st;
157
+ emit('cameraChange', st);
158
+ }
159
+ }
160
+
161
+ const instances = computed(() => {
162
+ void props.api.version.value; // re-run on grid changes
163
+ const bufX = 2 * colSize.value;
164
+ const bufY = 2 * rowSize.value;
165
+ return loopInstances(config.value, props.api.tiles.value, {
166
+ x: view.value.cxCell * colSize.value - bufX,
167
+ y: view.value.cyCell * rowSize.value - bufY,
168
+ width: view.value.vw + colSize.value + 2 * bufX,
169
+ height: view.value.vh + rowSize.value + 2 * bufY,
170
+ });
171
+ });
172
+
173
+ // ---- pan gesture ----------------------------------------------------------
174
+ let panGesture: { pointerId: number; startX: number; startY: number; moved: boolean } | null = null;
175
+ let suppressClick = false;
176
+
177
+ function onContainerPointerDown(e: PointerEvent) {
178
+ const onTile = !!(e.target as HTMLElement).closest('[data-griddle-tile]');
179
+ if (editable.value && !onTile) setSelection(new Set());
180
+ // Tiles that start drags stopPropagation, so reaching here from a tile
181
+ // means it was not draggable — panning is the right fallback.
182
+ if (!loop.value?.dragPan || e.button !== 0) return;
183
+ const ignoreSelector = config.value.dragIgnoreFrom ?? DEFAULT_DRAG_IGNORE;
184
+ if (ignoreSelector && (e.target as HTMLElement).closest(ignoreSelector)) return;
185
+ panGesture = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, moved: false };
186
+ }
187
+
188
+ function onClickCapture(e: MouseEvent) {
189
+ if (suppressClick) {
190
+ suppressClick = false;
191
+ e.preventDefault();
192
+ e.stopPropagation();
193
+ }
194
+ }
195
+
196
+ // ---- selection ('edit' interaction) ---------------------------------------
197
+ const internalSelection = ref<Set<string>>(new Set());
198
+ const selection = computed(() => props.selection ?? internalSelection.value);
199
+ function setSelection(next: Set<string>) {
200
+ if (!props.selection) internalSelection.value = next;
201
+ emit('selectionChange', next);
202
+ }
203
+
204
+ // ---- tile drag / resize ('edit' interaction) ------------------------------
205
+ const dragController = new DragController(props.api.grid);
206
+
207
+ interface LoopDragVisual {
208
+ tileId: string;
209
+ instanceLeft: number;
210
+ instanceTop: number;
211
+ pickupCol: number;
212
+ pickupRow: number;
213
+ deltaX: number;
214
+ deltaY: number;
215
+ indicatorCol: number | null;
216
+ indicatorRow: number | null;
217
+ }
218
+ const drag = ref<LoopDragVisual | null>(null);
219
+ let dragStartPointerX = 0;
220
+ let dragStartPointerY = 0;
221
+
222
+ interface LoopResizeState {
223
+ tileId: string;
224
+ instanceKey: string;
225
+ instanceDx: number;
226
+ instanceDy: number;
227
+ corner: Corner;
228
+ startPointerX: number;
229
+ startPointerY: number;
230
+ startW: number;
231
+ startH: number;
232
+ startCol: number;
233
+ startRow: number;
234
+ previewW: number;
235
+ previewH: number;
236
+ previewCol: number;
237
+ previewRow: number;
238
+ }
239
+ const resize = ref<LoopResizeState | null>(null);
240
+
241
+ function syncTiles() {
242
+ props.api.tiles.value = props.api.grid.tiles;
243
+ props.api.version.value++;
244
+ }
245
+
246
+ function isBase(inst: LoopTileInstance): boolean {
247
+ return inst.kx === 0 && inst.ky === 0;
248
+ }
249
+ function isGhost(inst: LoopTileInstance): boolean {
250
+ return editable.value && !isBase(inst);
251
+ }
252
+
253
+ function onTilePointerDown(e: PointerEvent, inst: LoopTileInstance) {
254
+ if (!editable.value) return; // pan mode: container pan handler takes it
255
+ if (!isBase(inst)) return; // ghosts are display-only
256
+ const tile = inst.tile;
257
+ if (tile.draggable === false) return;
258
+ if ((e.target as HTMLElement).dataset.griddleHandle) return;
259
+ const ignoreSelector = config.value.dragIgnoreFrom ?? DEFAULT_DRAG_IGNORE;
260
+ if (ignoreSelector && (e.target as HTMLElement).closest(ignoreSelector)) return;
261
+
262
+ if (e.metaKey || e.ctrlKey) {
263
+ e.preventDefault();
264
+ const next = new Set(selection.value);
265
+ if (next.has(tile.id)) next.delete(tile.id);
266
+ else next.add(tile.id);
267
+ setSelection(next);
268
+ e.stopPropagation();
269
+ return;
270
+ }
271
+ setSelection(new Set([tile.id]));
272
+
273
+ // No pointer capture: the grabbed instance unmounts (drag renders as an
274
+ // overlay), which would release the capture mid-gesture.
275
+ if (!dragController.start(tile.id)) return;
276
+ dragStartPointerX = e.clientX;
277
+ dragStartPointerY = e.clientY;
278
+ drag.value = {
279
+ tileId: tile.id,
280
+ instanceLeft: inst.left,
281
+ instanceTop: inst.top,
282
+ pickupCol: tile.col,
283
+ pickupRow: tile.row,
284
+ deltaX: 0,
285
+ deltaY: 0,
286
+ indicatorCol: tile.col,
287
+ indicatorRow: tile.row,
288
+ };
289
+ emit('dragStart', tile.id);
290
+ e.stopPropagation();
291
+ }
292
+
293
+ function onResizeHandleDown(e: PointerEvent, inst: LoopTileInstance, c: Corner) {
294
+ if (!editable.value) return;
295
+ const tile = inst.tile;
296
+ if (tile.resizable === false) return;
297
+ (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
298
+ const baseLeft = tile.col * colSize.value + halfGap.value;
299
+ const baseTop = tile.row * rowSize.value + halfGap.value;
300
+ resize.value = {
301
+ tileId: tile.id,
302
+ instanceKey: inst.key,
303
+ instanceDx: inst.left - baseLeft,
304
+ instanceDy: inst.top - baseTop,
305
+ corner: c,
306
+ startPointerX: e.clientX,
307
+ startPointerY: e.clientY,
308
+ startW: tile.w,
309
+ startH: tile.h,
310
+ startCol: tile.col,
311
+ startRow: tile.row,
312
+ previewW: tile.w,
313
+ previewH: tile.h,
314
+ previewCol: tile.col,
315
+ previewRow: tile.row,
316
+ };
317
+ emit('resizeStart', tile.id);
318
+ e.stopPropagation();
319
+ }
320
+
321
+ function onPointerMove(e: PointerEvent) {
322
+ if (panGesture && e.pointerId === panGesture.pointerId) {
323
+ const now = performance.now();
324
+ if (!panGesture.moved) {
325
+ const dist = Math.hypot(e.clientX - panGesture.startX, e.clientY - panGesture.startY);
326
+ if (dist >= PAN_THRESHOLD_PX) {
327
+ panGesture.moved = true;
328
+ pan.dragStart(panGesture.startX, panGesture.startY, now);
329
+ pan.dragMove(e.clientX, e.clientY, now);
330
+ }
331
+ } else {
332
+ pan.dragMove(e.clientX, e.clientY, now);
333
+ }
334
+ return;
335
+ }
336
+
337
+ const d = drag.value;
338
+ if (d) {
339
+ // Ghost edit: plain grid semantics in the base copy — no wrapping.
340
+ const dx = e.clientX - dragStartPointerX;
341
+ const dy = e.clientY - dragStartPointerY;
342
+ const candidate: CellPos = {
343
+ col: d.pickupCol + Math.round(dx / colSize.value),
344
+ row: d.pickupRow + Math.round(dy / rowSize.value),
345
+ };
346
+ const result = dragController.update(candidate);
347
+ drag.value = {
348
+ ...d,
349
+ deltaX: dx,
350
+ deltaY: dy,
351
+ indicatorCol: result.indicatorCell ? result.indicatorCell.col : null,
352
+ indicatorRow: result.indicatorCell ? result.indicatorCell.row : null,
353
+ };
354
+ if (result.changed) syncTiles();
355
+ }
356
+
357
+ const r = resize.value;
358
+ if (r) {
359
+ const dx = e.clientX - r.startPointerX;
360
+ const dy = e.clientY - r.startPointerY;
361
+ let dw = 0, dh = 0, dcol = 0, drow = 0;
362
+ const stepsX = Math.round(dx / colSize.value);
363
+ const stepsY = Math.round(dy / rowSize.value);
364
+ if (r.corner === 'se' || r.corner === 'ne') dw = stepsX;
365
+ if (r.corner === 'se' || r.corner === 'sw') dh = stepsY;
366
+ if (r.corner === 'sw' || r.corner === 'nw') { dw = -stepsX; dcol = stepsX; }
367
+ if (r.corner === 'ne' || r.corner === 'nw') { dh = -stepsY; drow = stepsY; }
368
+ const tile = props.api.grid.getTile(r.tileId);
369
+ const minW = tile?.minW ?? 1;
370
+ const minH = tile?.minH ?? 1;
371
+ const maxW = Math.min(tile?.maxW ?? Infinity, config.value.cols);
372
+ const maxH = Math.min(tile?.maxH ?? Infinity, config.value.rows);
373
+ const nW = Math.min(maxW, Math.max(minW, r.startW + dw));
374
+ const nH = Math.min(maxH, Math.max(minH, r.startH + dh));
375
+ const nC = r.startCol + dcol;
376
+ const nR = r.startRow + drow;
377
+ if (nW !== r.previewW || nH !== r.previewH || nC !== r.previewCol || nR !== r.previewRow) {
378
+ resize.value = { ...r, previewW: nW, previewH: nH, previewCol: nC, previewRow: nR };
379
+ }
380
+ }
381
+ }
382
+
383
+ function onPointerUp(e: PointerEvent) {
384
+ if (panGesture && e.pointerId === panGesture.pointerId) {
385
+ if (panGesture.moved) {
386
+ pan.dragEnd(performance.now());
387
+ suppressClick = true;
388
+ }
389
+ panGesture = null;
390
+ return;
391
+ }
392
+
393
+ if (drag.value) {
394
+ const tileId = drag.value.tileId;
395
+ const { committed } = dragController.end();
396
+ drag.value = null;
397
+ syncTiles();
398
+ emit('dragEnd', tileId, committed);
399
+ }
400
+ const r = resize.value;
401
+ if (r) {
402
+ const t = props.api.grid.getTile(r.tileId);
403
+ let committed = false;
404
+ if (t) {
405
+ const target = { col: r.previewCol, row: r.previewRow };
406
+ if (target.col !== t.col || target.row !== t.row) {
407
+ props.api.moveTile(r.tileId, target);
408
+ }
409
+ if (r.previewW !== t.w || r.previewH !== t.h) {
410
+ committed = props.api.resizeTile(r.tileId, { w: r.previewW, h: r.previewH });
411
+ } else {
412
+ committed = r.previewCol !== r.startCol || r.previewRow !== r.startRow
413
+ || r.previewW !== r.startW || r.previewH !== r.startH;
414
+ }
415
+ }
416
+ resize.value = null;
417
+ emit('resizeEnd', r.tileId, committed);
418
+ }
419
+ }
420
+
421
+ function onKeyDown(e: KeyboardEvent) {
422
+ if (e.key === 'Escape' && editable.value) setSelection(new Set());
423
+ }
424
+
425
+ onMounted(() => {
426
+ const el = viewportEl.value;
427
+ if (el) {
428
+ el.addEventListener('wheel', onWheel, { passive: false });
429
+ const measure = () => {
430
+ const cur = view.value;
431
+ if (el.clientWidth !== cur.vw || el.clientHeight !== cur.vh) {
432
+ view.value = { ...cur, vw: el.clientWidth, vh: el.clientHeight };
433
+ }
434
+ };
435
+ measure();
436
+ resizeObserver = new ResizeObserver(measure);
437
+ resizeObserver.observe(el);
438
+ }
439
+ raf = requestAnimationFrame(frame);
440
+ window.addEventListener('pointermove', onPointerMove);
441
+ window.addEventListener('pointerup', onPointerUp);
442
+ window.addEventListener('pointercancel', onPointerUp);
443
+ window.addEventListener('keydown', onKeyDown);
444
+ });
445
+ onBeforeUnmount(() => {
446
+ cancelAnimationFrame(raf);
447
+ viewportEl.value?.removeEventListener('wheel', onWheel);
448
+ resizeObserver?.disconnect();
449
+ window.removeEventListener('pointermove', onPointerMove);
450
+ window.removeEventListener('pointerup', onPointerUp);
451
+ window.removeEventListener('pointercancel', onPointerUp);
452
+ window.removeEventListener('keydown', onKeyDown);
453
+ });
454
+
455
+ // ---- FLIP for edit-mode repacks (keyed by world key) ----------------------
456
+ const tileNodes = new Map<string, HTMLDivElement>();
457
+ const prevRects = new Map<string, { x: number; y: number }>();
458
+ function registerNode(key: string, el: HTMLDivElement | null) {
459
+ if (el) tileNodes.set(key, el);
460
+ else tileNodes.delete(key);
461
+ }
462
+
463
+ watch(() => props.api.version.value, async () => {
464
+ await nextTick();
465
+ const draggerId = drag.value?.tileId ?? null;
466
+ const seen = new Set<string>();
467
+ for (const inst of instances.value) {
468
+ const key = inst.key;
469
+ seen.add(key);
470
+ const x = inst.left;
471
+ const y = inst.top;
472
+ if (editable.value && inst.tile.id !== draggerId) {
473
+ const p = prevRects.get(key);
474
+ const node = tileNodes.get(key);
475
+ if (p && node) {
476
+ const dx = p.x - x;
477
+ const dy = p.y - y;
478
+ if (
479
+ (dx !== 0 || dy !== 0) &&
480
+ Math.abs(dx) < period.value.width / 2 &&
481
+ Math.abs(dy) < period.value.height / 2
482
+ ) {
483
+ node.style.transition = 'none';
484
+ node.style.transform = `translate(${dx}px, ${dy}px)`;
485
+ requestAnimationFrame(() => {
486
+ node.style.transition = 'transform 220ms cubic-bezier(.2,.7,.2,1)';
487
+ node.style.transform = 'translate(0,0)';
488
+ });
489
+ }
490
+ }
491
+ }
492
+ prevRects.set(key, { x, y });
493
+ }
494
+ for (const key of prevRects.keys()) {
495
+ if (!seen.has(key)) prevRects.delete(key);
496
+ }
497
+ });
498
+
499
+ // ---- styles ----------------------------------------------------------------
500
+ const viewportStyle = computed(() => {
501
+ const showGrid = props.showGrid !== false;
502
+ const bg = showGrid
503
+ ? {
504
+ backgroundImage: `linear-gradient(to right, rgba(0,0,0,0.08) 1px, transparent 1px), linear-gradient(to bottom, rgba(0,0,0,0.08) 1px, transparent 1px)`,
505
+ backgroundSize: `${colSize.value}px ${rowSize.value}px`,
506
+ backgroundPosition: '0 0',
507
+ }
508
+ : {};
509
+ return {
510
+ position: 'relative' as const,
511
+ overflow: 'hidden' as const,
512
+ height: typeof props.height === 'number' ? props.height + 'px' : (props.height ?? '100%'),
513
+ touchAction: 'none' as const,
514
+ userSelect: 'none' as const,
515
+ cursor: !editable.value && loop.value?.dragPan ? 'grab' : undefined,
516
+ ['--griddle-tile-radius' as string]: (config.value.tileRadius ?? 4) + 'px',
517
+ ...bg,
518
+ };
519
+ });
520
+
521
+ const planeStyle = computed(() => ({
522
+ position: 'absolute' as const,
523
+ top: '0',
524
+ left: '0',
525
+ width: '0',
526
+ height: '0',
527
+ willChange: 'transform',
528
+ }));
529
+
530
+ function instanceStyle(inst: LoopTileInstance) {
531
+ const r = resize.value;
532
+ const isResizingInst = r?.instanceKey === inst.key;
533
+ let left = inst.left;
534
+ let top = inst.top;
535
+ let width = inst.width;
536
+ let heightPx = inst.height;
537
+ if (isResizingInst && r) {
538
+ left = r.previewCol * colSize.value + halfGap.value + r.instanceDx;
539
+ top = r.previewRow * rowSize.value + halfGap.value + r.instanceDy;
540
+ width = r.previewW * config.value.unitWidth + (r.previewW - 1) * gap.value;
541
+ heightPx = r.previewH * config.value.unitHeight + (r.previewH - 1) * gap.value;
542
+ }
543
+ const ghost = isGhost(inst);
544
+ const isSelected = editable.value && isBase(inst) && selection.value.has(inst.tile.id);
545
+ return {
546
+ position: 'absolute' as const,
547
+ left: left + 'px',
548
+ top: top + 'px',
549
+ width: width + 'px',
550
+ height: heightPx + 'px',
551
+ boxSizing: 'border-box' as const,
552
+ cursor: editable.value && isBase(inst) ? 'grab' : undefined,
553
+ userSelect: 'none' as const,
554
+ // Ghosts are display-only: pointer-transparent (so the gesture falls
555
+ // through to drag-pan) and dimmed to mark the editable copy.
556
+ pointerEvents: ghost ? ('none' as const) : undefined,
557
+ zIndex: isResizingInst ? 10 : 1,
558
+ opacity: isResizingInst ? 0.85 : ghost ? 0.55 : 1,
559
+ boxShadow: isSelected
560
+ ? '0 0 0 3px rgba(59, 91, 219, 0.85), inset 0 0 0 1px rgba(59, 91, 219, 0.3)'
561
+ : '',
562
+ borderRadius: (config.value.tileRadius ?? 4) + 'px',
563
+ };
564
+ }
565
+
566
+ const draggedTile = computed<Tile | null>(() =>
567
+ drag.value ? props.api.grid.getTile(drag.value.tileId) ?? null : null,
568
+ );
569
+
570
+ const dragOverlayStyle = computed(() => {
571
+ const d = drag.value;
572
+ const t = draggedTile.value;
573
+ if (!d || !t) return {};
574
+ return {
575
+ position: 'absolute' as const,
576
+ left: d.instanceLeft + 'px',
577
+ top: d.instanceTop + 'px',
578
+ width: t.w * config.value.unitWidth + (t.w - 1) * gap.value + 'px',
579
+ height: t.h * config.value.unitHeight + (t.h - 1) * gap.value + 'px',
580
+ boxSizing: 'border-box' as const,
581
+ cursor: 'grabbing',
582
+ userSelect: 'none' as const,
583
+ zIndex: 20,
584
+ opacity: 0.85,
585
+ transform: `translate(${d.deltaX}px, ${d.deltaY}px)`,
586
+ filter: 'drop-shadow(0 8px 16px rgba(0,0,0,0.18))',
587
+ borderRadius: (config.value.tileRadius ?? 4) + 'px',
588
+ pointerEvents: 'none' as const,
589
+ };
590
+ });
591
+
592
+ // Drop indicator: rendered in the base copy (ghost edit is plain-grid).
593
+ const indicatorRect = computed(() => {
594
+ const d = drag.value;
595
+ if (!d || d.indicatorCol === null || d.indicatorRow === null) return null;
596
+ const t = props.api.grid.getTile(d.tileId);
597
+ if (!t) return null;
598
+ return {
599
+ left: d.indicatorCol * colSize.value + halfGap.value,
600
+ top: d.indicatorRow * rowSize.value + halfGap.value,
601
+ width: t.w * config.value.unitWidth + (t.w - 1) * gap.value,
602
+ height: t.h * config.value.unitHeight + (t.h - 1) * gap.value,
603
+ };
604
+ });
605
+
606
+ function tileHandles(tile: Tile): Corner[] {
607
+ return tile.resizeHandles ?? config.value.resizeHandles ?? ['se'];
608
+ }
609
+
610
+ function handleStyle(c: Corner) {
611
+ const size = 12;
612
+ const base = {
613
+ position: 'absolute' as const,
614
+ width: size + 'px',
615
+ height: size + 'px',
616
+ background: 'rgba(60,60,60,0.7)',
617
+ border: '2px solid white',
618
+ borderRadius: '3px',
619
+ cursor: `${c}-resize`,
620
+ touchAction: 'none' as const,
621
+ } as Record<string, unknown>;
622
+ if (c === 'nw') return { ...base, top: -size / 2 + 'px', left: -size / 2 + 'px' };
623
+ if (c === 'ne') return { ...base, top: -size / 2 + 'px', right: -size / 2 + 'px' };
624
+ if (c === 'sw') return { ...base, bottom: -size / 2 + 'px', left: -size / 2 + 'px' };
625
+ return { ...base, bottom: -size / 2 + 'px', right: -size / 2 + 'px' };
626
+ }
627
+
628
+ const className = computed(() => props.className ?? '');
629
+ </script>
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { default as GriddleGrid } from './GriddleGrid.vue';
2
+ export { default as GriddleLoopGrid } from './LoopGrid.vue';
3
+ export { useGriddle, type GriddleApi, type UseGriddleInit } from './useGriddle.js';
@@ -0,0 +1,43 @@
1
+ // Vue composable that wraps a Grid instance with reactive state.
2
+
3
+ import { onBeforeUnmount, reactive, ref, shallowRef } from 'vue';
4
+ import { Grid } from '@griddle/core';
5
+ import type { GridConfig, Tile, GridSnapshot } from '@griddle/core';
6
+
7
+ export interface UseGriddleInit {
8
+ config: GridConfig;
9
+ tiles?: Tile[];
10
+ }
11
+
12
+ export function useGriddle(init: UseGriddleInit) {
13
+ const grid = new Grid(init.config, init.tiles ?? []);
14
+
15
+ // Reactive mirrors of tiles and config. We re-assign on changes.
16
+ const tiles = shallowRef<Tile[]>(grid.tiles);
17
+ const config = shallowRef<GridConfig>(grid.config);
18
+ const version = ref(0);
19
+
20
+ const off = grid.changes.on(() => {
21
+ tiles.value = grid.tiles;
22
+ config.value = grid.config;
23
+ version.value++;
24
+ });
25
+ onBeforeUnmount(off);
26
+
27
+ const api = {
28
+ grid,
29
+ tiles,
30
+ config,
31
+ version,
32
+ moveTile: (id: string, target: { col: number; row: number }) => grid.moveTile(id, target),
33
+ resizeTile: (id: string, size: { w: number; h: number }) => grid.resizeTile(id, size),
34
+ addTile: (t: Tile) => grid.addTile(t),
35
+ removeTile: (id: string) => grid.removeTile(id),
36
+ updateConfig: (patch: Partial<GridConfig>) => grid.updateConfig(patch),
37
+ toJSON: () => grid.toJSON(),
38
+ loadJSON: (snap: GridSnapshot) => grid.loadJSON(snap),
39
+ };
40
+ return api;
41
+ }
42
+
43
+ export type GriddleApi = ReturnType<typeof useGriddle>;