@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.
- package/README.md +25 -0
- package/dist/GriddleGrid.vue.d.ts.map +1 -1
- package/dist/LoopGrid.vue.d.ts.map +1 -1
- package/dist/animation.d.ts +5 -0
- package/dist/animation.d.ts.map +1 -0
- package/dist/animation.js +36 -0
- package/dist/animation.js.map +1 -0
- package/dist/index.js +617 -583
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/src/GriddleGrid.vue +115 -53
- package/src/LoopGrid.vue +3 -6
- package/src/animation.ts +62 -0
package/src/GriddleGrid.vue
CHANGED
|
@@ -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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
278
|
+
dragStartPointerX = pending.startPointerX;
|
|
279
|
+
dragStartPointerY = pending.startPointerY;
|
|
257
280
|
|
|
258
|
-
|
|
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
|
-
//
|
|
307
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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.
|
|
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 ?
|
|
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.
|
|
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,
|
package/src/animation.ts
ADDED
|
@@ -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
|
+
}
|