@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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/GriddleGrid.vue.d.ts +57 -0
- package/dist/GriddleGrid.vue.d.ts.map +1 -0
- package/dist/LoopGrid.vue.d.ts +44 -0
- package/dist/LoopGrid.vue.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +872 -0
- package/dist/index.js.map +1 -0
- package/dist/useGriddle.d.ts +27 -0
- package/dist/useGriddle.d.ts.map +1 -0
- package/package.json +61 -0
- package/src/GriddleGrid.vue +741 -0
- package/src/LoopGrid.vue +629 -0
- package/src/index.ts +3 -0
- package/src/useGriddle.ts +43 -0
package/src/LoopGrid.vue
ADDED
|
@@ -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,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>;
|