mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-07 21:29:06 +00:00
Initial commit
This commit is contained in:
575
routes/dashboard/DraggableGrid.svelte
Normal file
575
routes/dashboard/DraggableGrid.svelte
Normal file
@@ -0,0 +1,575 @@
|
||||
<script lang="ts" module>
|
||||
export interface GridItemLayout {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
items: GridItemLayout[];
|
||||
cols?: number;
|
||||
rowHeight?: number;
|
||||
gap?: number;
|
||||
minW?: number;
|
||||
maxW?: number;
|
||||
minH?: number;
|
||||
maxH?: number;
|
||||
onchange?: (items: GridItemLayout[]) => void;
|
||||
onitemclick?: (id: number) => void;
|
||||
children: import('svelte').Snippet<[{ item: GridItemLayout; width: number; height: number }]>;
|
||||
}
|
||||
|
||||
let {
|
||||
items = $bindable([]),
|
||||
cols = 4,
|
||||
rowHeight = 175,
|
||||
gap = 10,
|
||||
minW = 1,
|
||||
maxW = 2,
|
||||
minH = 1,
|
||||
maxH = 4,
|
||||
onchange,
|
||||
onitemclick,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
// Drag threshold - if mouse moves less than this, treat as click
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
let containerRef: HTMLDivElement;
|
||||
let containerWidth = $state(0);
|
||||
|
||||
// Calculate column width based on container
|
||||
const colWidth = $derived(containerWidth > 0 ? (containerWidth - (cols - 1) * gap) / cols : 280);
|
||||
|
||||
// Calculate max rows based on items
|
||||
const maxRow = $derived(
|
||||
items.length > 0 ? Math.max(...items.map((item) => item.y + item.h)) : 1
|
||||
);
|
||||
|
||||
// Grid height based on content
|
||||
const gridHeight = $derived(maxRow * rowHeight + (maxRow - 1) * gap);
|
||||
|
||||
// Dragging state
|
||||
let dragItem = $state<GridItemLayout | null>(null);
|
||||
let dragStartX = $state(0);
|
||||
let dragStartY = $state(0);
|
||||
let dragOffsetX = $state(0);
|
||||
let dragOffsetY = $state(0);
|
||||
|
||||
// Resizing state
|
||||
let resizeItem = $state<GridItemLayout | null>(null);
|
||||
let resizeStartW = $state(0);
|
||||
let resizeStartH = $state(0);
|
||||
let resizeStartX = $state(0);
|
||||
let resizeStartY = $state(0);
|
||||
// Pixel-based resize offsets for smooth visual feedback
|
||||
let resizePixelDeltaX = $state(0);
|
||||
let resizePixelDeltaY = $state(0);
|
||||
|
||||
// Preview position during drag/resize
|
||||
let previewX = $state(0);
|
||||
let previewY = $state(0);
|
||||
let previewW = $state(1);
|
||||
let previewH = $state(1);
|
||||
|
||||
// Store original positions before drag/resize for real-time displacement
|
||||
let originalPositions = $state<Map<number, { x: number; y: number }>>(new Map());
|
||||
let lastPreviewX = $state(-1);
|
||||
let lastPreviewY = $state(-1);
|
||||
let lastPreviewW = $state(-1);
|
||||
let lastPreviewH = $state(-1);
|
||||
let dragActuallyMoved = $state(false);
|
||||
|
||||
// Convert pixel position to grid units
|
||||
function pixelToGrid(px: number, cellSize: number): number {
|
||||
return Math.round(px / (cellSize + gap));
|
||||
}
|
||||
|
||||
// Convert grid units to pixel position
|
||||
function gridToPixel(grid: number, cellSize: number): number {
|
||||
return grid * (cellSize + gap);
|
||||
}
|
||||
|
||||
// Get item position in pixels
|
||||
function getItemStyle(item: GridItemLayout): string {
|
||||
const left = gridToPixel(item.x, colWidth);
|
||||
const top = gridToPixel(item.y, rowHeight);
|
||||
const width = item.w * colWidth + (item.w - 1) * gap;
|
||||
const height = item.h * rowHeight + (item.h - 1) * gap;
|
||||
return `left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;`;
|
||||
}
|
||||
|
||||
// Check for collisions with other items
|
||||
function hasCollision(
|
||||
item: GridItemLayout,
|
||||
testItem: { x: number; y: number; w: number; h: number }
|
||||
): boolean {
|
||||
if (item.id === dragItem?.id || item.id === resizeItem?.id) return false;
|
||||
return !(
|
||||
testItem.x + testItem.w <= item.x ||
|
||||
testItem.x >= item.x + item.w ||
|
||||
testItem.y + testItem.h <= item.y ||
|
||||
testItem.y >= item.y + item.h
|
||||
);
|
||||
}
|
||||
|
||||
// Find valid position avoiding collisions
|
||||
function findValidPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
excludeId: number
|
||||
): { x: number; y: number } {
|
||||
// Clamp to grid bounds
|
||||
x = Math.max(0, Math.min(x, cols - w));
|
||||
y = Math.max(0, y);
|
||||
|
||||
const testItem = { x, y, w, h };
|
||||
|
||||
// Check for collisions
|
||||
const hasAnyCollision = items.some((item) => item.id !== excludeId && hasCollision(item, testItem));
|
||||
|
||||
if (!hasAnyCollision) {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// If collision, try to find nearby valid position
|
||||
for (let offsetY = 0; offsetY <= 10; offsetY++) {
|
||||
for (let offsetX = -2; offsetX <= 2; offsetX++) {
|
||||
const testX = Math.max(0, Math.min(x + offsetX, cols - w));
|
||||
const testY = Math.max(0, y + offsetY);
|
||||
const test = { x: testX, y: testY, w, h };
|
||||
const collision = items.some((item) => item.id !== excludeId && hasCollision(item, test));
|
||||
if (!collision) {
|
||||
return { x: testX, y: testY };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// Push colliding items down (returns new array)
|
||||
function pushCollidingItems(movedItem: GridItemLayout, sourceItems: GridItemLayout[]): GridItemLayout[] {
|
||||
const newItems = sourceItems.map(item => ({ ...item }));
|
||||
let changed = true;
|
||||
let iterations = 0;
|
||||
const maxIterations = 100; // Prevent infinite loops
|
||||
|
||||
while (changed && iterations < maxIterations) {
|
||||
changed = false;
|
||||
iterations++;
|
||||
for (const item of newItems) {
|
||||
if (item.id === movedItem.id) continue;
|
||||
|
||||
if (hasCollision(item, movedItem)) {
|
||||
// Push this item down
|
||||
item.y = movedItem.y + movedItem.h;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newItems;
|
||||
}
|
||||
|
||||
// Push items in real-time during drag/resize
|
||||
function pushItemsRealTime(ghostItem: { x: number; y: number; w: number; h: number }, excludeId: number) {
|
||||
// First restore original positions
|
||||
const baseItems = items.map(item => {
|
||||
const orig = originalPositions.get(item.id);
|
||||
if (orig && item.id !== excludeId) {
|
||||
return { ...item, x: orig.x, y: orig.y };
|
||||
}
|
||||
return { ...item };
|
||||
});
|
||||
|
||||
// Then push items based on ghost position
|
||||
const movedItem = { ...ghostItem, id: excludeId };
|
||||
items = pushCollidingItems(movedItem as GridItemLayout, baseItems);
|
||||
}
|
||||
|
||||
// Drag handlers
|
||||
function handleDragStart(e: PointerEvent, item: GridItemLayout) {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
|
||||
dragItem = item;
|
||||
dragStartX = e.clientX;
|
||||
dragStartY = e.clientY;
|
||||
dragOffsetX = 0;
|
||||
dragOffsetY = 0;
|
||||
dragActuallyMoved = false;
|
||||
previewX = item.x;
|
||||
previewY = item.y;
|
||||
previewW = item.w;
|
||||
previewH = item.h;
|
||||
lastPreviewX = item.x;
|
||||
lastPreviewY = item.y;
|
||||
|
||||
// Store original positions for all items
|
||||
originalPositions = new Map(items.map(i => [i.id, { x: i.x, y: i.y }]));
|
||||
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
window.addEventListener('pointermove', handleDragMove);
|
||||
window.addEventListener('pointerup', handleDragEnd);
|
||||
}
|
||||
|
||||
function handleDragMove(e: PointerEvent) {
|
||||
if (!dragItem) return;
|
||||
|
||||
dragOffsetX = e.clientX - dragStartX;
|
||||
dragOffsetY = e.clientY - dragStartY;
|
||||
|
||||
// Check if movement exceeds threshold (for click vs drag detection)
|
||||
if (!dragActuallyMoved && (Math.abs(dragOffsetX) > DRAG_THRESHOLD || Math.abs(dragOffsetY) > DRAG_THRESHOLD)) {
|
||||
dragActuallyMoved = true;
|
||||
}
|
||||
|
||||
// Calculate new grid position
|
||||
const startLeft = gridToPixel(dragItem.x, colWidth);
|
||||
const startTop = gridToPixel(dragItem.y, rowHeight);
|
||||
const newLeft = startLeft + dragOffsetX;
|
||||
const newTop = startTop + dragOffsetY;
|
||||
|
||||
const newX = pixelToGrid(newLeft, colWidth);
|
||||
const newY = pixelToGrid(newTop, rowHeight);
|
||||
|
||||
// Clamp to valid range
|
||||
previewX = Math.max(0, Math.min(newX, cols - dragItem.w));
|
||||
previewY = Math.max(0, newY);
|
||||
|
||||
// Push items in real-time if preview position changed
|
||||
if (previewX !== lastPreviewX || previewY !== lastPreviewY) {
|
||||
lastPreviewX = previewX;
|
||||
lastPreviewY = previewY;
|
||||
pushItemsRealTime({ x: previewX, y: previewY, w: dragItem.w, h: dragItem.h }, dragItem.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd(e: PointerEvent) {
|
||||
if (!dragItem) return;
|
||||
|
||||
const clickedItemId = dragItem.id;
|
||||
|
||||
// If we didn't actually drag (just clicked), treat as a click
|
||||
if (!dragActuallyMoved) {
|
||||
window.removeEventListener('pointermove', handleDragMove);
|
||||
window.removeEventListener('pointerup', handleDragEnd);
|
||||
dragItem = null;
|
||||
dragOffsetX = 0;
|
||||
dragOffsetY = 0;
|
||||
originalPositions = new Map();
|
||||
onitemclick?.(clickedItemId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update item position (items are already pushed during drag)
|
||||
items = items.map((item) =>
|
||||
item.id === dragItem!.id ? { ...item, x: previewX, y: previewY } : item
|
||||
);
|
||||
|
||||
onchange?.(items);
|
||||
|
||||
window.removeEventListener('pointermove', handleDragMove);
|
||||
window.removeEventListener('pointerup', handleDragEnd);
|
||||
dragItem = null;
|
||||
dragOffsetX = 0;
|
||||
dragOffsetY = 0;
|
||||
originalPositions = new Map();
|
||||
}
|
||||
|
||||
// Resize handlers
|
||||
function handleResizeStart(e: PointerEvent, item: GridItemLayout) {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
resizeItem = item;
|
||||
resizeStartW = item.w;
|
||||
resizeStartH = item.h;
|
||||
resizeStartX = e.clientX;
|
||||
resizeStartY = e.clientY;
|
||||
resizePixelDeltaX = 0;
|
||||
resizePixelDeltaY = 0;
|
||||
previewX = item.x;
|
||||
previewY = item.y;
|
||||
previewW = item.w;
|
||||
previewH = item.h;
|
||||
lastPreviewW = item.w;
|
||||
lastPreviewH = item.h;
|
||||
|
||||
// Store original positions for all items
|
||||
originalPositions = new Map(items.map(i => [i.id, { x: i.x, y: i.y }]));
|
||||
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
window.addEventListener('pointermove', handleResizeMove);
|
||||
window.addEventListener('pointerup', handleResizeEnd);
|
||||
}
|
||||
|
||||
function handleResizeMove(e: PointerEvent) {
|
||||
if (!resizeItem) return;
|
||||
|
||||
const deltaX = e.clientX - resizeStartX;
|
||||
const deltaY = e.clientY - resizeStartY;
|
||||
|
||||
// Track raw pixel delta for smooth visual feedback
|
||||
resizePixelDeltaX = deltaX;
|
||||
resizePixelDeltaY = deltaY;
|
||||
|
||||
// Calculate new size in grid units
|
||||
const newW = Math.round(resizeStartW + deltaX / (colWidth + gap));
|
||||
const newH = Math.round(resizeStartH + deltaY / (rowHeight + gap));
|
||||
|
||||
// Clamp to min/max
|
||||
previewW = Math.max(minW, Math.min(maxW, newW, cols - resizeItem.x));
|
||||
previewH = Math.max(minH, Math.min(maxH, newH));
|
||||
|
||||
// Push items in real-time if preview size changed
|
||||
if (previewW !== lastPreviewW || previewH !== lastPreviewH) {
|
||||
lastPreviewW = previewW;
|
||||
lastPreviewH = previewH;
|
||||
pushItemsRealTime({ x: resizeItem.x, y: resizeItem.y, w: previewW, h: previewH }, resizeItem.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResizeEnd(e: PointerEvent) {
|
||||
if (!resizeItem) return;
|
||||
|
||||
// Update item size (items are already pushed during resize)
|
||||
items = items.map((item) =>
|
||||
item.id === resizeItem!.id ? { ...item, w: previewW, h: previewH } : item
|
||||
);
|
||||
|
||||
onchange?.(items);
|
||||
|
||||
window.removeEventListener('pointermove', handleResizeMove);
|
||||
window.removeEventListener('pointerup', handleResizeEnd);
|
||||
resizeItem = null;
|
||||
resizePixelDeltaX = 0;
|
||||
resizePixelDeltaY = 0;
|
||||
originalPositions = new Map();
|
||||
}
|
||||
|
||||
// Observe container width
|
||||
$effect(() => {
|
||||
if (!containerRef) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
containerWidth = entries[0].contentRect.width;
|
||||
});
|
||||
observer.observe(containerRef);
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="draggable-grid"
|
||||
bind:this={containerRef}
|
||||
style="height: {gridHeight}px;"
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
{@const isDragTarget = dragItem?.id === item.id}
|
||||
{@const isDragging = isDragTarget && dragActuallyMoved}
|
||||
{@const isResizing = resizeItem?.id === item.id}
|
||||
{@const isActive = isDragging || isResizing}
|
||||
{@const itemWidth = item.w * colWidth + (item.w - 1) * gap}
|
||||
{@const itemHeight = item.h * rowHeight + (item.h - 1) * gap}
|
||||
|
||||
<!-- Preview placeholder during drag/resize -->
|
||||
{#if isDragging || isResizing}
|
||||
<div
|
||||
class="grid-item-preview"
|
||||
data-size="{isResizing ? previewW : previewW}×{isResizing ? previewH : previewH}"
|
||||
style="left: {gridToPixel(isResizing ? item.x : previewX, colWidth)}px; top: {gridToPixel(isResizing ? item.y : previewY, rowHeight)}px; width: {previewW * colWidth + (previewW - 1) * gap}px; height: {previewH * rowHeight + (previewH - 1) * gap}px;"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Actual item -->
|
||||
{@const baseWidth = item.w * colWidth + (item.w - 1) * gap}
|
||||
{@const baseHeight = item.h * rowHeight + (item.h - 1) * gap}
|
||||
{@const minPixelW = minW * colWidth + (minW - 1) * gap}
|
||||
{@const maxPixelW = Math.min(maxW, cols - item.x) * colWidth + (Math.min(maxW, cols - item.x) - 1) * gap}
|
||||
{@const minPixelH = minH * rowHeight + (minH - 1) * gap}
|
||||
{@const maxPixelH = maxH * rowHeight + (maxH - 1) * gap}
|
||||
{@const currentWidth = isResizing ? Math.max(minPixelW, Math.min(maxPixelW, baseWidth + resizePixelDeltaX)) : baseWidth}
|
||||
{@const currentHeight = isResizing ? Math.max(minPixelH, Math.min(maxPixelH, baseHeight + resizePixelDeltaY)) : baseHeight}
|
||||
<div
|
||||
class="grid-item"
|
||||
class:dragging={isDragging}
|
||||
class:resizing={isResizing}
|
||||
style="left: {gridToPixel(item.x, colWidth)}px; top: {gridToPixel(item.y, rowHeight)}px; width: {currentWidth}px; height: {currentHeight}px; {isDragTarget ? `transform: translate(${dragOffsetX}px, ${dragOffsetY}px);` : ''}"
|
||||
onpointerdown={(e) => {
|
||||
// Check if clicking on resize handle area (bottom-right 28x28 corner)
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const isInResizeArea = x > rect.width - 28 && y > rect.height - 28;
|
||||
|
||||
if (isInResizeArea) {
|
||||
handleResizeStart(e, item);
|
||||
} else {
|
||||
handleDragStart(e, item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- Content -->
|
||||
<div class="grid-item-content">
|
||||
{@render children({ item, width: itemWidth, height: itemHeight })}
|
||||
</div>
|
||||
|
||||
<!-- Resize handle visual indicator -->
|
||||
<div class="tile-resize-handle">
|
||||
<svg viewBox="0 0 10 10" fill="currentColor">
|
||||
<path d="M9 1v8H1" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.draggable-grid {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
position: absolute;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.grid-item.dragging {
|
||||
z-index: 100;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.grid-item.dragging,
|
||||
.grid-item.dragging * {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.grid-item.resizing {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.grid-item.resizing,
|
||||
.grid-item.resizing * {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.grid-item.dragging *,
|
||||
.grid-item.resizing * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.grid-item-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tile-resize-handle {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: grab;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
padding: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tile-resize-handle svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: hsl(var(--muted-foreground) / 0.6);
|
||||
transition: color 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-item:hover .tile-resize-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tile-resize-handle:hover svg {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.grid-item-preview {
|
||||
position: absolute;
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
border: 2px dashed hsl(var(--primary) / 0.6);
|
||||
border-radius: 8px;
|
||||
z-index: 0;
|
||||
transition: left 0.15s ease, top 0.15s ease, width 0.15s ease, height 0.15s ease;
|
||||
box-shadow:
|
||||
inset 0 0 20px hsl(var(--primary) / 0.1),
|
||||
0 0 15px hsl(var(--primary) / 0.15);
|
||||
}
|
||||
|
||||
.grid-item-preview::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 6px;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 8px,
|
||||
hsl(var(--primary) / 0.05) 8px,
|
||||
hsl(var(--primary) / 0.05) 16px
|
||||
);
|
||||
animation: stripes 0.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes stripes {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 22.6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Size indicator badge on preview */
|
||||
.grid-item-preview::after {
|
||||
content: attr(data-size);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user