@griddle/vue 0.1.1 → 0.1.3

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.
@@ -111,6 +111,7 @@ import {
111
111
  } from '@griddle/core';
112
112
  import type { TileLayout } from '@griddle/core';
113
113
  import type { GriddleApi } from './useGriddle.js';
114
+ import { animateReposition, liftTransition } from './animation.js';
114
115
 
115
116
  const props = defineProps<{
116
117
  api: GriddleApi;
@@ -190,6 +191,7 @@ function setSelection(next: Set<string>) {
190
191
  }
191
192
 
192
193
  const DEFAULT_DRAG_IGNORE = 'a, button, input, textarea, select, [contenteditable]';
194
+ const DRAG_START_THRESHOLD_PX = 12;
193
195
 
194
196
  const dragController = new DragController(props.api.grid);
195
197
  const groupDragController = new GroupDragController(props.api.grid);
@@ -248,18 +250,35 @@ interface DrawState {
248
250
  }
249
251
  const drawState = ref<DrawState | null>(null);
250
252
 
251
- function onTilePointerDown(e: PointerEvent, tile: Tile) {
252
- if (tile.draggable === false) return;
253
- if ((e.target as HTMLElement).dataset.griddleHandle) return;
253
+ interface PendingDragState {
254
+ pointerId: number;
255
+ tileId: string;
256
+ tileElement: HTMLDivElement;
257
+ startPointerX: number;
258
+ startPointerY: number;
259
+ mode: 'pin' | 'group' | 'single';
260
+ groupTileIds: string[];
261
+ }
262
+ let pendingDrag: PendingDragState | null = null;
263
+
264
+ function beginPendingDrag(pending: PendingDragState): boolean {
265
+ const tile = props.api.grid.getTile(pending.tileId);
266
+ if (!tile) return false;
267
+
268
+ // Capture only once movement proves this is a drag. Capturing on
269
+ // pointer-down retargets pointer-up away from editable descendants and turns
270
+ // ordinary clicks into drag start/end cycles.
271
+ try {
272
+ pending.tileElement.setPointerCapture(pending.pointerId);
273
+ } catch {
274
+ // The pointer may already have ended or the tile may have unmounted.
275
+ return false;
276
+ }
254
277
 
255
- const ignoreSelector = config.value.dragIgnoreFrom ?? DEFAULT_DRAG_IGNORE;
256
- if (ignoreSelector && (e.target as HTMLElement).closest(ignoreSelector)) return;
278
+ dragStartPointerX = pending.startPointerX;
279
+ dragStartPointerY = pending.startPointerY;
257
280
 
258
- const metaKey = e.metaKey || e.ctrlKey;
259
-
260
- // Out-of-flow tiles get free-pixel drag (no selection).
261
- if (config.value.enablePositioning && isOutOfFlow(tile)) {
262
- (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
281
+ if (pending.mode === 'pin') {
263
282
  const layout = computeTileLayout({
264
283
  tile,
265
284
  config: config.value,
@@ -275,10 +294,61 @@ function onTilePointerDown(e: PointerEvent, tile: Tile) {
275
294
  pinDrag.value = {
276
295
  tileId: tile.id,
277
296
  startPinPx,
297
+ startPointerX: pending.startPointerX,
298
+ startPointerY: pending.startPointerY,
299
+ };
300
+ emit('dragStart', tile.id);
301
+ return true;
302
+ }
303
+
304
+ if (pending.mode === 'group') {
305
+ if (!groupDragController.start(pending.groupTileIds)) return false;
306
+ groupDrag.value = {
307
+ tileIds: pending.groupTileIds,
308
+ deltaX: 0,
309
+ deltaY: 0,
310
+ committedDcol: 0,
311
+ committedDrow: 0,
312
+ };
313
+ emit('dragStart', tile.id);
314
+ return true;
315
+ }
316
+
317
+ if (!dragController.start(tile.id)) return false;
318
+ drag.value = {
319
+ tileId: tile.id,
320
+ pickupCol: tile.col,
321
+ pickupRow: tile.row,
322
+ deltaX: 0,
323
+ deltaY: 0,
324
+ indicatorCol: tile.col,
325
+ indicatorRow: tile.row,
326
+ };
327
+ emit('dragStart', tile.id);
328
+ return true;
329
+ }
330
+
331
+ function onTilePointerDown(e: PointerEvent, tile: Tile) {
332
+ if (e.button !== 0) return;
333
+ if (tile.draggable === false) return;
334
+ if ((e.target as HTMLElement).dataset.griddleHandle) return;
335
+
336
+ const ignoreSelector = config.value.dragIgnoreFrom ?? DEFAULT_DRAG_IGNORE;
337
+ if (ignoreSelector && (e.target as HTMLElement).closest(ignoreSelector)) return;
338
+
339
+ const metaKey = e.metaKey || e.ctrlKey;
340
+
341
+ // Out-of-flow tiles get free-pixel drag (no selection).
342
+ if (config.value.enablePositioning && isOutOfFlow(tile)) {
343
+ pendingDrag = {
344
+ pointerId: e.pointerId,
345
+ tileId: tile.id,
346
+ tileElement: e.currentTarget as HTMLDivElement,
278
347
  startPointerX: e.clientX,
279
348
  startPointerY: e.clientY,
349
+ mode: 'pin',
350
+ groupTileIds: [],
280
351
  };
281
- emit('dragStart', tile.id);
282
352
  e.stopPropagation();
283
353
  return;
284
354
  }
@@ -303,42 +373,20 @@ function onTilePointerDown(e: PointerEvent, tile: Tile) {
303
373
  setSelection(new Set([tile.id]));
304
374
  }
305
375
 
306
- // Capture pointer only when starting a drag.
307
- (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
308
-
309
- // Group drag if 2+ tiles selected including this one.
376
+ // Defer capture and drag state until the pointer crosses the movement
377
+ // threshold. A stationary pointer-down/up remains a normal click, including
378
+ // when it starts inside contenteditable tile content.
310
379
  const effectiveSelection = tileIsSelected ? selection.value : new Set([tile.id]);
311
- if (effectiveSelection.size > 1) {
312
- const ids = Array.from(effectiveSelection);
313
- if (!groupDragController.start(ids)) return;
314
- dragStartPointerX = e.clientX;
315
- dragStartPointerY = e.clientY;
316
- groupDrag.value = {
317
- tileIds: ids,
318
- deltaX: 0,
319
- deltaY: 0,
320
- committedDcol: 0,
321
- committedDrow: 0,
322
- };
323
- emit('dragStart', tile.id);
324
- e.stopPropagation();
325
- return;
326
- }
327
-
328
- // Single tile drag.
329
- if (!dragController.start(tile.id)) return;
330
- dragStartPointerX = e.clientX;
331
- dragStartPointerY = e.clientY;
332
- drag.value = {
380
+ const groupTileIds = Array.from(effectiveSelection);
381
+ pendingDrag = {
382
+ pointerId: e.pointerId,
333
383
  tileId: tile.id,
334
- pickupCol: tile.col,
335
- pickupRow: tile.row,
336
- deltaX: 0,
337
- deltaY: 0,
338
- indicatorCol: tile.col,
339
- indicatorRow: tile.row,
384
+ tileElement: e.currentTarget as HTMLDivElement,
385
+ startPointerX: e.clientX,
386
+ startPointerY: e.clientY,
387
+ mode: groupTileIds.length > 1 ? 'group' : 'single',
388
+ groupTileIds,
340
389
  };
341
- emit('dragStart', tile.id);
342
390
  e.stopPropagation();
343
391
  }
344
392
 
@@ -394,6 +442,20 @@ function onPointerMove(e: PointerEvent) {
394
442
  }
395
443
  return;
396
444
  }
445
+
446
+ const pending = pendingDrag;
447
+ if (pending && e.pointerId === pending.pointerId) {
448
+ const distance = Math.hypot(
449
+ e.clientX - pending.startPointerX,
450
+ e.clientY - pending.startPointerY,
451
+ );
452
+ if (distance <= DRAG_START_THRESHOLD_PX) return;
453
+
454
+ pendingDrag = null;
455
+ if (!beginPendingDrag(pending)) return;
456
+ e.preventDefault();
457
+ }
458
+
397
459
  const pd = pinDrag.value;
398
460
  const gd = groupDrag.value;
399
461
  const d = drag.value;
@@ -471,7 +533,11 @@ function syncTiles() {
471
533
  props.api.version.value++;
472
534
  }
473
535
 
474
- function onPointerUp() {
536
+ function onPointerUp(e: PointerEvent) {
537
+ if (pendingDrag?.pointerId === e.pointerId) {
538
+ pendingDrag = null;
539
+ return;
540
+ }
475
541
  if (drawState.value) {
476
542
  const ds = drawState.value;
477
543
  const col = Math.min(ds.anchorCol, ds.currentCol);
@@ -553,6 +619,7 @@ onMounted(() => {
553
619
  window.addEventListener('keydown', onKeyDown);
554
620
  });
555
621
  onBeforeUnmount(() => {
622
+ pendingDrag = null;
556
623
  const el = scrollEl.value;
557
624
  if (el) el.removeEventListener('scroll', updateViewport);
558
625
  ro?.disconnect();
@@ -586,12 +653,7 @@ watch(() => props.api.version.value, async () => {
586
653
  const dx = p.x - x;
587
654
  const dy = p.y - y;
588
655
  if (dx !== 0 || dy !== 0) {
589
- node.style.transition = 'none';
590
- node.style.transform = `translate(${dx}px, ${dy}px)`;
591
- requestAnimationFrame(() => {
592
- node.style.transition = 'transform 220ms cubic-bezier(.2,.7,.2,1)';
593
- node.style.transform = 'translate(0,0)';
594
- });
656
+ animateReposition(node, dx, dy, config.value.animation);
595
657
  }
596
658
  }
597
659
  prevRects.set(t.id, { x, y });
@@ -683,10 +745,10 @@ function tileStyle(tile: Tile) {
683
745
  userSelect: 'none' as const,
684
746
  zIndex: layout.zIndex,
685
747
  opacity: lifted || isResizing ? 0.85 : 1,
686
- willChange: 'transform',
748
+ willChange: 'transform, translate',
687
749
  transform: layout.transform ?? '',
688
750
  filter: lifted ? 'drop-shadow(0 8px 16px rgba(0,0,0,0.18))' : '',
689
- transition: lifted ? 'filter 120ms ease-out, opacity 120ms ease-out' : '',
751
+ transition: lifted ? liftTransition(config.value.animation) : '',
690
752
  boxShadow: isSelected
691
753
  ? '0 0 0 3px rgba(59, 91, 219, 0.85), inset 0 0 0 1px rgba(59, 91, 219, 0.3)'
692
754
  : '',
package/src/LoopGrid.vue CHANGED
@@ -79,6 +79,7 @@ import {
79
79
  } from '@griddle/core';
80
80
  import type { LoopTileInstance } from '@griddle/core';
81
81
  import type { GriddleApi } from './useGriddle.js';
82
+ import { animateReposition } from './animation.js';
82
83
 
83
84
  const props = defineProps<{
84
85
  api: GriddleApi;
@@ -480,12 +481,7 @@ watch(() => props.api.version.value, async () => {
480
481
  Math.abs(dx) < period.value.width / 2 &&
481
482
  Math.abs(dy) < period.value.height / 2
482
483
  ) {
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
- });
484
+ animateReposition(node, dx, dy, config.value.animation);
489
485
  }
490
486
  }
491
487
  }
@@ -512,6 +508,7 @@ const viewportStyle = computed(() => {
512
508
  height: typeof props.height === 'number' ? props.height + 'px' : (props.height ?? '100%'),
513
509
  touchAction: 'none' as const,
514
510
  userSelect: 'none' as const,
511
+ willChange: 'translate',
515
512
  cursor: !editable.value && loop.value?.dragPan ? 'grab' : undefined,
516
513
  ['--griddle-tile-radius' as string]: (config.value.tileRadius ?? 4) + 'px',
517
514
  ...bg,
@@ -0,0 +1,62 @@
1
+ import {
2
+ resolveAnimationConfig,
3
+ type GridAnimationConfig,
4
+ } from '@griddle/core';
5
+
6
+ const pendingFrames = new WeakMap<HTMLElement, number>();
7
+
8
+ function reducedMotionRequested(config: GridAnimationConfig | undefined): boolean {
9
+ const animation = resolveAnimationConfig(config);
10
+ return animation.respectReducedMotion &&
11
+ typeof window !== 'undefined' &&
12
+ typeof window.matchMedia === 'function' &&
13
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
14
+ }
15
+
16
+ function currentTranslation(node: HTMLElement): { x: number; y: number } {
17
+ if (typeof window === 'undefined') return { x: 0, y: 0 };
18
+ const translate = window.getComputedStyle(node).translate;
19
+ if (!translate || translate === 'none') return { x: 0, y: 0 };
20
+ const [x = '0', y = '0'] = translate.split(/\s+/);
21
+ return { x: Number.parseFloat(x) || 0, y: Number.parseFloat(y) || 0 };
22
+ }
23
+
24
+ /** Continue rapid repacks from the tile's current visual position. */
25
+ export function animateReposition(
26
+ node: HTMLElement,
27
+ deltaX: number,
28
+ deltaY: number,
29
+ config?: GridAnimationConfig,
30
+ ): void {
31
+ const pending = pendingFrames.get(node);
32
+ if (pending !== undefined) cancelAnimationFrame(pending);
33
+
34
+ const animation = resolveAnimationConfig(config);
35
+ if (!animation.enabled || animation.repositionDurationMs === 0 || reducedMotionRequested(config)) {
36
+ node.style.transition = 'none';
37
+ node.style.translate = '0px 0px';
38
+ pendingFrames.delete(node);
39
+ return;
40
+ }
41
+
42
+ const current = currentTranslation(node);
43
+ node.style.transition = 'none';
44
+ node.style.translate = `${deltaX + current.x}px ${deltaY + current.y}px`;
45
+ void node.offsetWidth;
46
+
47
+ const frame = requestAnimationFrame(() => {
48
+ pendingFrames.delete(node);
49
+ node.style.transition =
50
+ `translate ${animation.repositionDurationMs}ms ${animation.repositionEasing}`;
51
+ node.style.translate = '0px 0px';
52
+ });
53
+ pendingFrames.set(node, frame);
54
+ }
55
+
56
+ export function liftTransition(config?: GridAnimationConfig): string {
57
+ const animation = resolveAnimationConfig(config);
58
+ const duration = !animation.enabled || reducedMotionRequested(config)
59
+ ? 0
60
+ : animation.liftDurationMs;
61
+ return `filter ${duration}ms ${animation.liftEasing}, opacity ${duration}ms ${animation.liftEasing}`;
62
+ }