@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
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<GriddleLoopGrid
|
|
3
|
+
v-if="loopOn"
|
|
4
|
+
:api="api"
|
|
5
|
+
:class-name="className"
|
|
6
|
+
:height="height"
|
|
7
|
+
:show-grid="showGrid"
|
|
8
|
+
:selection="props.selection"
|
|
9
|
+
@selection-change="(s) => emit('selectionChange', s)"
|
|
10
|
+
@drag-start="(id) => emit('dragStart', id)"
|
|
11
|
+
@drag-end="(id, c) => emit('dragEnd', id, c)"
|
|
12
|
+
@resize-start="(id) => emit('resizeStart', id)"
|
|
13
|
+
@resize-end="(id, c) => emit('resizeEnd', id, c)"
|
|
14
|
+
@camera-change="(cam) => emit('cameraChange', cam)"
|
|
15
|
+
>
|
|
16
|
+
<template #tile="slotProps">
|
|
17
|
+
<slot name="tile" v-bind="slotProps" />
|
|
18
|
+
</template>
|
|
19
|
+
</GriddleLoopGrid>
|
|
20
|
+
<div
|
|
21
|
+
v-else
|
|
22
|
+
ref="scrollEl"
|
|
23
|
+
:class="className"
|
|
24
|
+
:style="scrollStyle"
|
|
25
|
+
@pointerdown="onBackgroundPointerDown"
|
|
26
|
+
>
|
|
27
|
+
<div :style="contentStyle">
|
|
28
|
+
<div
|
|
29
|
+
v-if="indicatorRect"
|
|
30
|
+
class="griddle-drop-indicator"
|
|
31
|
+
:style="{
|
|
32
|
+
position: 'absolute',
|
|
33
|
+
left: indicatorRect.left + 'px',
|
|
34
|
+
top: indicatorRect.top + 'px',
|
|
35
|
+
width: indicatorRect.width + 'px',
|
|
36
|
+
height: indicatorRect.height + 'px',
|
|
37
|
+
boxSizing: 'border-box',
|
|
38
|
+
border: '2px dashed rgba(59, 91, 219, 0.55)',
|
|
39
|
+
background: 'rgba(59, 91, 219, 0.08)',
|
|
40
|
+
borderRadius: (config.tileRadius ?? 4) + 'px',
|
|
41
|
+
pointerEvents: 'none',
|
|
42
|
+
zIndex: 5,
|
|
43
|
+
}"
|
|
44
|
+
></div>
|
|
45
|
+
<div
|
|
46
|
+
v-if="drawGhostRect"
|
|
47
|
+
class="griddle-draw-ghost"
|
|
48
|
+
:style="{
|
|
49
|
+
position: 'absolute',
|
|
50
|
+
left: drawGhostRect.left + 'px',
|
|
51
|
+
top: drawGhostRect.top + 'px',
|
|
52
|
+
width: drawGhostRect.width + 'px',
|
|
53
|
+
height: drawGhostRect.height + 'px',
|
|
54
|
+
boxSizing: 'border-box',
|
|
55
|
+
border: '2px dashed rgba(59, 91, 219, 0.55)',
|
|
56
|
+
background: 'rgba(59, 91, 219, 0.08)',
|
|
57
|
+
borderRadius: (config.tileRadius ?? 4) + 'px',
|
|
58
|
+
pointerEvents: 'none',
|
|
59
|
+
zIndex: 5,
|
|
60
|
+
display: 'flex',
|
|
61
|
+
alignItems: 'center',
|
|
62
|
+
justifyContent: 'center',
|
|
63
|
+
fontSize: '14px',
|
|
64
|
+
fontWeight: '600',
|
|
65
|
+
color: 'rgba(59, 91, 219, 0.8)',
|
|
66
|
+
userSelect: 'none',
|
|
67
|
+
}"
|
|
68
|
+
>{{ drawGhostRect.label }}</div>
|
|
69
|
+
<div
|
|
70
|
+
v-for="tile in rendered"
|
|
71
|
+
:key="tile.id"
|
|
72
|
+
:data-griddle-tile="tile.id"
|
|
73
|
+
:class="['griddle-tile', {
|
|
74
|
+
'griddle-dragging': drag?.tileId === tile.id || pinDrag?.tileId === tile.id || (groupDrag !== null && groupDragSet.has(tile.id)),
|
|
75
|
+
'griddle-resizing': resize?.tileId === tile.id,
|
|
76
|
+
'griddle-selected': selection.has(tile.id),
|
|
77
|
+
}]"
|
|
78
|
+
:style="tileStyle(tile)"
|
|
79
|
+
@pointerdown="(e) => onTilePointerDown(e, tile)"
|
|
80
|
+
:ref="(el) => registerNode(tile.id, el as HTMLDivElement | null)"
|
|
81
|
+
>
|
|
82
|
+
<slot name="tile" :tile="tile" :selected="selection.has(tile.id)" />
|
|
83
|
+
<template v-if="tile.resizable !== false">
|
|
84
|
+
<div
|
|
85
|
+
v-for="c in tileHandles(tile)"
|
|
86
|
+
:key="c"
|
|
87
|
+
:data-griddle-handle="c"
|
|
88
|
+
:style="handleStyle(c)"
|
|
89
|
+
@pointerdown="(e) => onResizeHandleDown(e, tile, c)"
|
|
90
|
+
></div>
|
|
91
|
+
</template>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</template>
|
|
96
|
+
|
|
97
|
+
<script setup lang="ts">
|
|
98
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue';
|
|
99
|
+
import type { CameraState, Corner, Tile } from '@griddle/core';
|
|
100
|
+
import GriddleLoopGrid from './LoopGrid.vue';
|
|
101
|
+
import {
|
|
102
|
+
visibleRange,
|
|
103
|
+
visibleTiles,
|
|
104
|
+
gridContentSize,
|
|
105
|
+
DragController,
|
|
106
|
+
GroupDragController,
|
|
107
|
+
computeTileLayout,
|
|
108
|
+
resolveStickyStacking,
|
|
109
|
+
isOutOfFlow,
|
|
110
|
+
pixelsToPin,
|
|
111
|
+
} from '@griddle/core';
|
|
112
|
+
import type { TileLayout } from '@griddle/core';
|
|
113
|
+
import type { GriddleApi } from './useGriddle.js';
|
|
114
|
+
|
|
115
|
+
const props = defineProps<{
|
|
116
|
+
api: GriddleApi;
|
|
117
|
+
className?: string;
|
|
118
|
+
height?: number | string;
|
|
119
|
+
showGrid?: boolean;
|
|
120
|
+
/** Controlled selection. If omitted, managed internally. */
|
|
121
|
+
selection?: Set<string>;
|
|
122
|
+
}>();
|
|
123
|
+
|
|
124
|
+
const emit = defineEmits<{
|
|
125
|
+
(e: 'selectionChange', selection: Set<string>): void;
|
|
126
|
+
(e: 'drawCreate', rect: { col: number; row: number; w: number; h: number }): void;
|
|
127
|
+
(e: 'dragStart', tileId: string): void;
|
|
128
|
+
(e: 'dragEnd', tileId: string, committed: boolean): void;
|
|
129
|
+
(e: 'resizeStart', tileId: string): void;
|
|
130
|
+
(e: 'resizeEnd', tileId: string, committed: boolean): void;
|
|
131
|
+
(e: 'cameraChange', camera: CameraState): void;
|
|
132
|
+
}>();
|
|
133
|
+
|
|
134
|
+
/** Loop mode delegates rendering to GriddleLoopGrid. */
|
|
135
|
+
const loopOn = computed(() => props.api.config.value.loop?.enabled === true);
|
|
136
|
+
|
|
137
|
+
const scrollEl = ref<HTMLDivElement | null>(null);
|
|
138
|
+
const viewport = ref({ scrollX: 0, scrollY: 0, width: 1000, height: 800 });
|
|
139
|
+
|
|
140
|
+
const config = computed(() => props.api.config.value);
|
|
141
|
+
const gap = computed(() => config.value.gap ?? 0);
|
|
142
|
+
const halfGap = computed(() => gap.value / 2);
|
|
143
|
+
const colSize = computed(() => config.value.unitWidth + gap.value);
|
|
144
|
+
const rowSize = computed(() => config.value.unitHeight + gap.value);
|
|
145
|
+
|
|
146
|
+
const range = computed(() => visibleRange(config.value, viewport.value, 4));
|
|
147
|
+
const rendered = computed(() => visibleTiles(props.api.tiles.value, range.value));
|
|
148
|
+
const contentSize = computed(() => gridContentSize(config.value, props.api.tiles.value));
|
|
149
|
+
|
|
150
|
+
const scrollStyle = computed(() => ({
|
|
151
|
+
position: 'relative' as const,
|
|
152
|
+
overflow: 'auto' as const,
|
|
153
|
+
height: props.height ?? '100%',
|
|
154
|
+
touchAction: 'none' as const,
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
const contentStyle = computed(() => {
|
|
158
|
+
const showGrid = props.showGrid !== false;
|
|
159
|
+
const bg = showGrid
|
|
160
|
+
? {
|
|
161
|
+
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)`,
|
|
162
|
+
backgroundSize: `${colSize.value}px ${rowSize.value}px`,
|
|
163
|
+
}
|
|
164
|
+
: {};
|
|
165
|
+
return {
|
|
166
|
+
position: 'relative' as const,
|
|
167
|
+
width: contentSize.value.width || '100%',
|
|
168
|
+
height: contentSize.value.height || '100%',
|
|
169
|
+
minWidth: '100%',
|
|
170
|
+
minHeight: '100%',
|
|
171
|
+
// Expose tile radius as a CSS variable so user tile content can pick it up.
|
|
172
|
+
['--griddle-tile-radius' as string]: (config.value.tileRadius ?? 4) + 'px',
|
|
173
|
+
...bg,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Selection state
|
|
178
|
+
const internalSelection = ref<Set<string>>(new Set());
|
|
179
|
+
const selection = computed(() => props.selection ?? internalSelection.value);
|
|
180
|
+
const groupDragSet = computed(() => new Set(groupDrag.value?.tileIds ?? []));
|
|
181
|
+
function setSelection(next: Set<string>) {
|
|
182
|
+
if (!props.selection) internalSelection.value = next;
|
|
183
|
+
emit('selectionChange', next);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const DEFAULT_DRAG_IGNORE = 'a, button, input, textarea, select, [contenteditable]';
|
|
187
|
+
|
|
188
|
+
const dragController = new DragController(props.api.grid);
|
|
189
|
+
const groupDragController = new GroupDragController(props.api.grid);
|
|
190
|
+
|
|
191
|
+
interface DragVisual {
|
|
192
|
+
tileId: string;
|
|
193
|
+
pickupCol: number;
|
|
194
|
+
pickupRow: number;
|
|
195
|
+
deltaX: number;
|
|
196
|
+
deltaY: number;
|
|
197
|
+
indicatorCol: number | null;
|
|
198
|
+
indicatorRow: number | null;
|
|
199
|
+
}
|
|
200
|
+
const drag = ref<DragVisual | null>(null);
|
|
201
|
+
let dragStartPointerX = 0;
|
|
202
|
+
let dragStartPointerY = 0;
|
|
203
|
+
|
|
204
|
+
interface GroupDragVisual {
|
|
205
|
+
tileIds: string[];
|
|
206
|
+
deltaX: number;
|
|
207
|
+
deltaY: number;
|
|
208
|
+
committedDcol: number;
|
|
209
|
+
committedDrow: number;
|
|
210
|
+
}
|
|
211
|
+
const groupDrag = ref<GroupDragVisual | null>(null);
|
|
212
|
+
|
|
213
|
+
interface PinDragState {
|
|
214
|
+
tileId: string;
|
|
215
|
+
startPinPx: { x: number; y: number };
|
|
216
|
+
startPointerX: number;
|
|
217
|
+
startPointerY: number;
|
|
218
|
+
}
|
|
219
|
+
const pinDrag = ref<PinDragState | null>(null);
|
|
220
|
+
|
|
221
|
+
interface ResizeState {
|
|
222
|
+
tileId: string;
|
|
223
|
+
corner: Corner;
|
|
224
|
+
startPointerX: number;
|
|
225
|
+
startPointerY: number;
|
|
226
|
+
startW: number;
|
|
227
|
+
startH: number;
|
|
228
|
+
startCol: number;
|
|
229
|
+
startRow: number;
|
|
230
|
+
previewW: number;
|
|
231
|
+
previewH: number;
|
|
232
|
+
previewCol: number;
|
|
233
|
+
previewRow: number;
|
|
234
|
+
}
|
|
235
|
+
const resize = ref<ResizeState | null>(null);
|
|
236
|
+
|
|
237
|
+
interface DrawState {
|
|
238
|
+
anchorCol: number;
|
|
239
|
+
anchorRow: number;
|
|
240
|
+
currentCol: number;
|
|
241
|
+
currentRow: number;
|
|
242
|
+
}
|
|
243
|
+
const drawState = ref<DrawState | null>(null);
|
|
244
|
+
|
|
245
|
+
function onTilePointerDown(e: PointerEvent, tile: Tile) {
|
|
246
|
+
if (tile.draggable === false) return;
|
|
247
|
+
if ((e.target as HTMLElement).dataset.griddleHandle) return;
|
|
248
|
+
|
|
249
|
+
const ignoreSelector = config.value.dragIgnoreFrom ?? DEFAULT_DRAG_IGNORE;
|
|
250
|
+
if (ignoreSelector && (e.target as HTMLElement).closest(ignoreSelector)) return;
|
|
251
|
+
|
|
252
|
+
const metaKey = e.metaKey || e.ctrlKey;
|
|
253
|
+
|
|
254
|
+
// Out-of-flow tiles get free-pixel drag (no selection).
|
|
255
|
+
if (config.value.enablePositioning && isOutOfFlow(tile)) {
|
|
256
|
+
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
|
257
|
+
const layout = computeTileLayout({
|
|
258
|
+
tile,
|
|
259
|
+
config: config.value,
|
|
260
|
+
scrollX: viewport.value.scrollX,
|
|
261
|
+
scrollY: viewport.value.scrollY,
|
|
262
|
+
viewportWidth: viewport.value.width,
|
|
263
|
+
viewportHeight: viewport.value.height,
|
|
264
|
+
});
|
|
265
|
+
const startPinPx =
|
|
266
|
+
tile.position === 'fixed'
|
|
267
|
+
? { x: layout.left - viewport.value.scrollX, y: layout.top - viewport.value.scrollY }
|
|
268
|
+
: { x: layout.left, y: layout.top };
|
|
269
|
+
pinDrag.value = {
|
|
270
|
+
tileId: tile.id,
|
|
271
|
+
startPinPx,
|
|
272
|
+
startPointerX: e.clientX,
|
|
273
|
+
startPointerY: e.clientY,
|
|
274
|
+
};
|
|
275
|
+
emit('dragStart', tile.id);
|
|
276
|
+
e.stopPropagation();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Cmd/Ctrl+click toggles selection without starting a drag.
|
|
281
|
+
if (metaKey) {
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
const next = new Set(selection.value);
|
|
284
|
+
if (next.has(tile.id)) {
|
|
285
|
+
next.delete(tile.id);
|
|
286
|
+
} else {
|
|
287
|
+
next.add(tile.id);
|
|
288
|
+
}
|
|
289
|
+
setSelection(next);
|
|
290
|
+
e.stopPropagation();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const tileIsSelected = selection.value.has(tile.id);
|
|
295
|
+
|
|
296
|
+
if (!tileIsSelected) {
|
|
297
|
+
setSelection(new Set([tile.id]));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Capture pointer only when starting a drag.
|
|
301
|
+
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
|
302
|
+
|
|
303
|
+
// Group drag if 2+ tiles selected including this one.
|
|
304
|
+
const effectiveSelection = tileIsSelected ? selection.value : new Set([tile.id]);
|
|
305
|
+
if (effectiveSelection.size > 1) {
|
|
306
|
+
const ids = Array.from(effectiveSelection);
|
|
307
|
+
if (!groupDragController.start(ids)) return;
|
|
308
|
+
dragStartPointerX = e.clientX;
|
|
309
|
+
dragStartPointerY = e.clientY;
|
|
310
|
+
groupDrag.value = {
|
|
311
|
+
tileIds: ids,
|
|
312
|
+
deltaX: 0,
|
|
313
|
+
deltaY: 0,
|
|
314
|
+
committedDcol: 0,
|
|
315
|
+
committedDrow: 0,
|
|
316
|
+
};
|
|
317
|
+
emit('dragStart', tile.id);
|
|
318
|
+
e.stopPropagation();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Single tile drag.
|
|
323
|
+
if (!dragController.start(tile.id)) return;
|
|
324
|
+
dragStartPointerX = e.clientX;
|
|
325
|
+
dragStartPointerY = e.clientY;
|
|
326
|
+
drag.value = {
|
|
327
|
+
tileId: tile.id,
|
|
328
|
+
pickupCol: tile.col,
|
|
329
|
+
pickupRow: tile.row,
|
|
330
|
+
deltaX: 0,
|
|
331
|
+
deltaY: 0,
|
|
332
|
+
indicatorCol: tile.col,
|
|
333
|
+
indicatorRow: tile.row,
|
|
334
|
+
};
|
|
335
|
+
emit('dragStart', tile.id);
|
|
336
|
+
e.stopPropagation();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function onBackgroundPointerDown(e: PointerEvent) {
|
|
340
|
+
if ((e.target as HTMLElement).closest('[data-griddle-tile]')) return;
|
|
341
|
+
setSelection(new Set());
|
|
342
|
+
|
|
343
|
+
const el = scrollEl.value;
|
|
344
|
+
if (!el) return;
|
|
345
|
+
const rect = el.getBoundingClientRect();
|
|
346
|
+
const x = e.clientX - rect.left + el.scrollLeft;
|
|
347
|
+
const y = e.clientY - rect.top + el.scrollTop;
|
|
348
|
+
const col = Math.floor(x / colSize.value);
|
|
349
|
+
const row = Math.floor(y / rowSize.value);
|
|
350
|
+
drawState.value = { anchorCol: col, anchorRow: row, currentCol: col, currentRow: row };
|
|
351
|
+
(el as HTMLDivElement).setPointerCapture(e.pointerId);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function onResizeHandleDown(e: PointerEvent, tile: Tile, c: Corner) {
|
|
355
|
+
if (tile.resizable === false) return;
|
|
356
|
+
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
|
357
|
+
resize.value = {
|
|
358
|
+
tileId: tile.id,
|
|
359
|
+
corner: c,
|
|
360
|
+
startPointerX: e.clientX,
|
|
361
|
+
startPointerY: e.clientY,
|
|
362
|
+
startW: tile.w,
|
|
363
|
+
startH: tile.h,
|
|
364
|
+
startCol: tile.col,
|
|
365
|
+
startRow: tile.row,
|
|
366
|
+
previewW: tile.w,
|
|
367
|
+
previewH: tile.h,
|
|
368
|
+
previewCol: tile.col,
|
|
369
|
+
previewRow: tile.row,
|
|
370
|
+
};
|
|
371
|
+
emit('resizeStart', tile.id);
|
|
372
|
+
e.stopPropagation();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function onPointerMove(e: PointerEvent) {
|
|
376
|
+
if (drawState.value) {
|
|
377
|
+
const el = scrollEl.value;
|
|
378
|
+
if (el) {
|
|
379
|
+
const rect = el.getBoundingClientRect();
|
|
380
|
+
const x = e.clientX - rect.left + el.scrollLeft;
|
|
381
|
+
const y = e.clientY - rect.top + el.scrollTop;
|
|
382
|
+
const col = Math.max(0, Math.floor(x / colSize.value));
|
|
383
|
+
const row = Math.max(0, Math.floor(y / rowSize.value));
|
|
384
|
+
drawState.value = { ...drawState.value, currentCol: col, currentRow: row };
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const pd = pinDrag.value;
|
|
389
|
+
const gd = groupDrag.value;
|
|
390
|
+
const d = drag.value;
|
|
391
|
+
const r = resize.value;
|
|
392
|
+
if (pd) {
|
|
393
|
+
const dx = e.clientX - pd.startPointerX;
|
|
394
|
+
const dy = e.clientY - pd.startPointerY;
|
|
395
|
+
const newPinPx = { x: pd.startPinPx.x + dx, y: pd.startPinPx.y + dy };
|
|
396
|
+
const newPin = pixelsToPin(newPinPx, config.value);
|
|
397
|
+
props.api.grid.setTilePinned(pd.tileId, newPin);
|
|
398
|
+
}
|
|
399
|
+
if (gd) {
|
|
400
|
+
const dx = e.clientX - dragStartPointerX;
|
|
401
|
+
const dy = e.clientY - dragStartPointerY;
|
|
402
|
+
const dcol = Math.round(dx / colSize.value);
|
|
403
|
+
const drow = Math.round(dy / rowSize.value);
|
|
404
|
+
const result = groupDragController.update({ dcol, drow });
|
|
405
|
+
groupDrag.value = {
|
|
406
|
+
...gd,
|
|
407
|
+
deltaX: dx,
|
|
408
|
+
deltaY: dy,
|
|
409
|
+
committedDcol: result.indicatorDelta?.dcol ?? gd.committedDcol,
|
|
410
|
+
committedDrow: result.indicatorDelta?.drow ?? gd.committedDrow,
|
|
411
|
+
};
|
|
412
|
+
if (result.changed) syncTiles();
|
|
413
|
+
}
|
|
414
|
+
if (d) {
|
|
415
|
+
const dx = e.clientX - dragStartPointerX;
|
|
416
|
+
const dy = e.clientY - dragStartPointerY;
|
|
417
|
+
const candidateCol = d.pickupCol + Math.round(dx / colSize.value);
|
|
418
|
+
const candidateRow = d.pickupRow + Math.round(dy / rowSize.value);
|
|
419
|
+
const result = dragController.update({ col: candidateCol, row: candidateRow });
|
|
420
|
+
drag.value = {
|
|
421
|
+
...d,
|
|
422
|
+
deltaX: dx,
|
|
423
|
+
deltaY: dy,
|
|
424
|
+
indicatorCol: result.indicatorCell ? result.indicatorCell.col : null,
|
|
425
|
+
indicatorRow: result.indicatorCell ? result.indicatorCell.row : null,
|
|
426
|
+
};
|
|
427
|
+
// DragController.restoreTiles() doesn't emit change events, so when the
|
|
428
|
+
// candidate cell changes (especially returning to pickup), force-sync the
|
|
429
|
+
// reactive tiles ref so the FLIP animation picks up displaced tile resets.
|
|
430
|
+
if (result.changed) syncTiles();
|
|
431
|
+
}
|
|
432
|
+
if (r) {
|
|
433
|
+
const dx = e.clientX - r.startPointerX;
|
|
434
|
+
const dy = e.clientY - r.startPointerY;
|
|
435
|
+
let dw = 0, dh = 0, dcol = 0, drow = 0;
|
|
436
|
+
const stepsX = Math.round(dx / colSize.value);
|
|
437
|
+
const stepsY = Math.round(dy / rowSize.value);
|
|
438
|
+
if (r.corner === 'se' || r.corner === 'ne') dw = stepsX;
|
|
439
|
+
if (r.corner === 'se' || r.corner === 'sw') dh = stepsY;
|
|
440
|
+
if (r.corner === 'sw' || r.corner === 'nw') { dw = -stepsX; dcol = stepsX; }
|
|
441
|
+
if (r.corner === 'ne' || r.corner === 'nw') { dh = -stepsY; drow = stepsY; }
|
|
442
|
+
const tile = props.api.grid.getTile(r.tileId);
|
|
443
|
+
const minW = tile?.minW ?? 1;
|
|
444
|
+
const minH = tile?.minH ?? 1;
|
|
445
|
+
const maxW = tile?.maxW ?? Infinity;
|
|
446
|
+
const maxH = tile?.maxH ?? Infinity;
|
|
447
|
+
const nW = Math.min(maxW, Math.max(minW, r.startW + dw));
|
|
448
|
+
const nH = Math.min(maxH, Math.max(minH, r.startH + dh));
|
|
449
|
+
const nC = r.startCol + dcol;
|
|
450
|
+
const nR = r.startRow + drow;
|
|
451
|
+
if (nW !== r.previewW || nH !== r.previewH || nC !== r.previewCol || nR !== r.previewRow) {
|
|
452
|
+
resize.value = { ...r, previewW: nW, previewH: nH, previewCol: nC, previewRow: nR };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function syncTiles() {
|
|
458
|
+
// DragController/GroupDragController may restore grid snapshots without
|
|
459
|
+
// emitting change events. Force-sync the reactive tiles ref so the UI
|
|
460
|
+
// reflects the grid's true state after a drag ends.
|
|
461
|
+
props.api.tiles.value = props.api.grid.tiles;
|
|
462
|
+
props.api.version.value++;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function onPointerUp() {
|
|
466
|
+
if (drawState.value) {
|
|
467
|
+
const ds = drawState.value;
|
|
468
|
+
const col = Math.min(ds.anchorCol, ds.currentCol);
|
|
469
|
+
const row = Math.min(ds.anchorRow, ds.currentRow);
|
|
470
|
+
const w = Math.max(1, Math.abs(ds.currentCol - ds.anchorCol) + 1);
|
|
471
|
+
const h = Math.max(1, Math.abs(ds.currentRow - ds.anchorRow) + 1);
|
|
472
|
+
drawState.value = null;
|
|
473
|
+
emit('drawCreate', { col, row, w, h });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (pinDrag.value) {
|
|
477
|
+
const tileId = pinDrag.value.tileId;
|
|
478
|
+
pinDrag.value = null;
|
|
479
|
+
emit('dragEnd', tileId, true);
|
|
480
|
+
}
|
|
481
|
+
if (groupDrag.value) {
|
|
482
|
+
const tileIds = groupDrag.value.tileIds;
|
|
483
|
+
const { committed } = groupDragController.end();
|
|
484
|
+
groupDrag.value = null;
|
|
485
|
+
syncTiles();
|
|
486
|
+
const firstId = tileIds[0];
|
|
487
|
+
if (firstId !== undefined) emit('dragEnd', firstId, committed);
|
|
488
|
+
}
|
|
489
|
+
if (drag.value) {
|
|
490
|
+
const tileId = drag.value.tileId;
|
|
491
|
+
const { committed } = dragController.end();
|
|
492
|
+
drag.value = null;
|
|
493
|
+
syncTiles();
|
|
494
|
+
emit('dragEnd', tileId, committed);
|
|
495
|
+
}
|
|
496
|
+
const r = resize.value;
|
|
497
|
+
if (r) {
|
|
498
|
+
const t = props.api.grid.getTile(r.tileId);
|
|
499
|
+
let committed = false;
|
|
500
|
+
if (t) {
|
|
501
|
+
if (r.previewCol !== t.col || r.previewRow !== t.row) {
|
|
502
|
+
props.api.moveTile(r.tileId, { col: r.previewCol, row: r.previewRow });
|
|
503
|
+
}
|
|
504
|
+
if (r.previewW !== t.w || r.previewH !== t.h) {
|
|
505
|
+
committed = props.api.resizeTile(r.tileId, { w: r.previewW, h: r.previewH });
|
|
506
|
+
} else {
|
|
507
|
+
committed = r.previewCol !== r.startCol || r.previewRow !== r.startRow
|
|
508
|
+
|| r.previewW !== r.startW || r.previewH !== r.startH;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
resize.value = null;
|
|
512
|
+
emit('resizeEnd', r.tileId, committed);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function updateViewport() {
|
|
517
|
+
const el = scrollEl.value;
|
|
518
|
+
if (!el) return;
|
|
519
|
+
viewport.value = {
|
|
520
|
+
scrollX: el.scrollLeft,
|
|
521
|
+
scrollY: el.scrollTop,
|
|
522
|
+
width: el.clientWidth,
|
|
523
|
+
height: el.clientHeight,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
528
|
+
if (e.key === 'Escape') setSelection(new Set());
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
let ro: ResizeObserver | null = null;
|
|
532
|
+
onMounted(() => {
|
|
533
|
+
updateViewport();
|
|
534
|
+
// scrollEl is absent while loop mode delegates to GriddleLoopGrid.
|
|
535
|
+
const el = scrollEl.value;
|
|
536
|
+
if (el) {
|
|
537
|
+
el.addEventListener('scroll', updateViewport, { passive: true });
|
|
538
|
+
ro = new ResizeObserver(updateViewport);
|
|
539
|
+
ro.observe(el);
|
|
540
|
+
}
|
|
541
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
542
|
+
window.addEventListener('pointerup', onPointerUp);
|
|
543
|
+
window.addEventListener('pointercancel', onPointerUp);
|
|
544
|
+
window.addEventListener('keydown', onKeyDown);
|
|
545
|
+
});
|
|
546
|
+
onBeforeUnmount(() => {
|
|
547
|
+
const el = scrollEl.value;
|
|
548
|
+
if (el) el.removeEventListener('scroll', updateViewport);
|
|
549
|
+
ro?.disconnect();
|
|
550
|
+
window.removeEventListener('pointermove', onPointerMove);
|
|
551
|
+
window.removeEventListener('pointerup', onPointerUp);
|
|
552
|
+
window.removeEventListener('pointercancel', onPointerUp);
|
|
553
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const tileNodes = new Map<string, HTMLDivElement>();
|
|
557
|
+
const prevRects = new Map<string, { x: number; y: number }>();
|
|
558
|
+
function registerNode(id: string, el: HTMLDivElement | null) {
|
|
559
|
+
if (el) tileNodes.set(id, el);
|
|
560
|
+
else tileNodes.delete(id);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
watch(() => props.api.version.value, async () => {
|
|
564
|
+
await nextTick();
|
|
565
|
+
const draggerId = drag.value?.tileId ?? null;
|
|
566
|
+
const gdSet = groupDragSet.value;
|
|
567
|
+
for (const t of props.api.tiles.value) {
|
|
568
|
+
const x = t.col * colSize.value + halfGap.value;
|
|
569
|
+
const y = t.row * rowSize.value + halfGap.value;
|
|
570
|
+
if (t.id === draggerId || gdSet.has(t.id)) {
|
|
571
|
+
prevRects.set(t.id, { x, y });
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const p = prevRects.get(t.id);
|
|
575
|
+
const node = tileNodes.get(t.id);
|
|
576
|
+
if (p && node) {
|
|
577
|
+
const dx = p.x - x;
|
|
578
|
+
const dy = p.y - y;
|
|
579
|
+
if (dx !== 0 || dy !== 0) {
|
|
580
|
+
node.style.transition = 'none';
|
|
581
|
+
node.style.transform = `translate(${dx}px, ${dy}px)`;
|
|
582
|
+
requestAnimationFrame(() => {
|
|
583
|
+
node.style.transition = 'transform 220ms cubic-bezier(.2,.7,.2,1)';
|
|
584
|
+
node.style.transform = 'translate(0,0)';
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
prevRects.set(t.id, { x, y });
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// Compute layouts for all rendered tiles in one pass so resolveStickyStacking
|
|
593
|
+
// can see every sticky tile and adjust them as a group. Recomputes whenever
|
|
594
|
+
// rendered, config, viewport, drag, resize, or pinDrag change.
|
|
595
|
+
const tileLayouts = computed(() => {
|
|
596
|
+
const out = new Map<string, TileLayout>();
|
|
597
|
+
const stickyEntries: { tile: Tile; layout: TileLayout }[] = [];
|
|
598
|
+
const gd = groupDrag.value;
|
|
599
|
+
const gdIds = groupDragSet.value;
|
|
600
|
+
for (const tile of rendered.value) {
|
|
601
|
+
let layout: TileLayout;
|
|
602
|
+
if (resize.value?.tileId === tile.id) {
|
|
603
|
+
const w = resize.value.previewW;
|
|
604
|
+
const h = resize.value.previewH;
|
|
605
|
+
layout = {
|
|
606
|
+
left: resize.value.previewCol * colSize.value + halfGap.value,
|
|
607
|
+
top: resize.value.previewRow * rowSize.value + halfGap.value,
|
|
608
|
+
width: w * config.value.unitWidth + (w - 1) * gap.value,
|
|
609
|
+
height: h * config.value.unitHeight + (h - 1) * gap.value,
|
|
610
|
+
zIndex: 10,
|
|
611
|
+
effective: 'static',
|
|
612
|
+
};
|
|
613
|
+
} else if (drag.value?.tileId === tile.id) {
|
|
614
|
+
const d = drag.value;
|
|
615
|
+
layout = {
|
|
616
|
+
left: d.pickupCol * colSize.value + halfGap.value,
|
|
617
|
+
top: d.pickupRow * rowSize.value + halfGap.value,
|
|
618
|
+
width: tile.w * config.value.unitWidth + (tile.w - 1) * gap.value,
|
|
619
|
+
height: tile.h * config.value.unitHeight + (tile.h - 1) * gap.value,
|
|
620
|
+
transform: `translate(${d.deltaX}px, ${d.deltaY}px)`,
|
|
621
|
+
zIndex: 20,
|
|
622
|
+
effective: 'static',
|
|
623
|
+
};
|
|
624
|
+
} else if (gd && gdIds.has(tile.id)) {
|
|
625
|
+
const pickup = groupDragController.pickupCell(tile.id);
|
|
626
|
+
const left = (pickup ? pickup.col * colSize.value : tile.col * colSize.value) + halfGap.value;
|
|
627
|
+
const top = (pickup ? pickup.row * rowSize.value : tile.row * rowSize.value) + halfGap.value;
|
|
628
|
+
layout = {
|
|
629
|
+
left,
|
|
630
|
+
top,
|
|
631
|
+
width: tile.w * config.value.unitWidth + (tile.w - 1) * gap.value,
|
|
632
|
+
height: tile.h * config.value.unitHeight + (tile.h - 1) * gap.value,
|
|
633
|
+
transform: `translate(${gd.deltaX}px, ${gd.deltaY}px)`,
|
|
634
|
+
zIndex: 20,
|
|
635
|
+
effective: 'static',
|
|
636
|
+
};
|
|
637
|
+
} else {
|
|
638
|
+
layout = computeTileLayout({
|
|
639
|
+
tile,
|
|
640
|
+
config: config.value,
|
|
641
|
+
scrollX: viewport.value.scrollX,
|
|
642
|
+
scrollY: viewport.value.scrollY,
|
|
643
|
+
viewportWidth: viewport.value.width,
|
|
644
|
+
viewportHeight: viewport.value.height,
|
|
645
|
+
});
|
|
646
|
+
if (layout.effective === 'sticky') {
|
|
647
|
+
stickyEntries.push({ tile, layout });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
out.set(tile.id, layout);
|
|
651
|
+
}
|
|
652
|
+
if (stickyEntries.length > 1) resolveStickyStacking(stickyEntries);
|
|
653
|
+
return out;
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
function tileStyle(tile: Tile) {
|
|
657
|
+
const isDragging = drag.value?.tileId === tile.id;
|
|
658
|
+
const isGroupDragging = groupDragSet.value.has(tile.id) && groupDrag.value !== null;
|
|
659
|
+
const isPinDragging = pinDrag.value?.tileId === tile.id;
|
|
660
|
+
const isResizing = resize.value?.tileId === tile.id;
|
|
661
|
+
const isSelected = selection.value.has(tile.id);
|
|
662
|
+
const layout = tileLayouts.value.get(tile.id) ?? {
|
|
663
|
+
left: 0, top: 0, width: 0, height: 0, zIndex: 1, effective: 'static' as const,
|
|
664
|
+
};
|
|
665
|
+
const lifted = isDragging || isPinDragging || isGroupDragging;
|
|
666
|
+
return {
|
|
667
|
+
position: 'absolute' as const,
|
|
668
|
+
left: layout.left + 'px',
|
|
669
|
+
top: layout.top + 'px',
|
|
670
|
+
width: layout.width + 'px',
|
|
671
|
+
height: layout.height + 'px',
|
|
672
|
+
boxSizing: 'border-box' as const,
|
|
673
|
+
cursor: lifted ? 'grabbing' : 'grab',
|
|
674
|
+
userSelect: 'none' as const,
|
|
675
|
+
zIndex: layout.zIndex,
|
|
676
|
+
opacity: lifted || isResizing ? 0.85 : 1,
|
|
677
|
+
willChange: 'transform',
|
|
678
|
+
transform: layout.transform ?? '',
|
|
679
|
+
filter: lifted ? 'drop-shadow(0 8px 16px rgba(0,0,0,0.18))' : '',
|
|
680
|
+
transition: lifted ? 'filter 120ms ease-out, opacity 120ms ease-out' : '',
|
|
681
|
+
boxShadow: isSelected
|
|
682
|
+
? '0 0 0 3px rgba(59, 91, 219, 0.85), inset 0 0 0 1px rgba(59, 91, 219, 0.3)'
|
|
683
|
+
: '',
|
|
684
|
+
borderRadius: (config.value.tileRadius ?? 4) + 'px',
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
const indicatorRect = computed(() => {
|
|
690
|
+
const d = drag.value;
|
|
691
|
+
if (!d || d.indicatorCol === null || d.indicatorRow === null) return null;
|
|
692
|
+
const t = props.api.grid.getTile(d.tileId);
|
|
693
|
+
if (!t) return null;
|
|
694
|
+
return {
|
|
695
|
+
left: d.indicatorCol * colSize.value + halfGap.value,
|
|
696
|
+
top: d.indicatorRow * rowSize.value + halfGap.value,
|
|
697
|
+
width: t.w * config.value.unitWidth + (t.w - 1) * gap.value,
|
|
698
|
+
height: t.h * config.value.unitHeight + (t.h - 1) * gap.value,
|
|
699
|
+
};
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
const drawGhostRect = computed(() => {
|
|
703
|
+
const ds = drawState.value;
|
|
704
|
+
if (!ds) return null;
|
|
705
|
+
const col = Math.min(ds.anchorCol, ds.currentCol);
|
|
706
|
+
const row = Math.min(ds.anchorRow, ds.currentRow);
|
|
707
|
+
const w = Math.max(1, Math.abs(ds.currentCol - ds.anchorCol) + 1);
|
|
708
|
+
const h = Math.max(1, Math.abs(ds.currentRow - ds.anchorRow) + 1);
|
|
709
|
+
return {
|
|
710
|
+
left: col * colSize.value + halfGap.value,
|
|
711
|
+
top: row * rowSize.value + halfGap.value,
|
|
712
|
+
width: w * config.value.unitWidth + (w - 1) * gap.value,
|
|
713
|
+
height: h * config.value.unitHeight + (h - 1) * gap.value,
|
|
714
|
+
label: `${w}×${h}`,
|
|
715
|
+
};
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
function tileHandles(tile: Tile): Corner[] {
|
|
719
|
+
return tile.resizeHandles ?? config.value.resizeHandles ?? ['se'];
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function handleStyle(c: Corner) {
|
|
723
|
+
const size = 12;
|
|
724
|
+
const base = {
|
|
725
|
+
position: 'absolute' as const,
|
|
726
|
+
width: size + 'px',
|
|
727
|
+
height: size + 'px',
|
|
728
|
+
background: 'rgba(60,60,60,0.7)',
|
|
729
|
+
border: '2px solid white',
|
|
730
|
+
borderRadius: '3px',
|
|
731
|
+
cursor: `${c}-resize`,
|
|
732
|
+
touchAction: 'none' as const,
|
|
733
|
+
} as Record<string, unknown>;
|
|
734
|
+
if (c === 'nw') return { ...base, top: -size / 2 + 'px', left: -size / 2 + 'px' };
|
|
735
|
+
if (c === 'ne') return { ...base, top: -size / 2 + 'px', right: -size / 2 + 'px' };
|
|
736
|
+
if (c === 'sw') return { ...base, bottom: -size / 2 + 'px', left: -size / 2 + 'px' };
|
|
737
|
+
return { ...base, bottom: -size / 2 + 'px', right: -size / 2 + 'px' };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const className = computed(() => props.className ?? '');
|
|
741
|
+
</script>
|