Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

View 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>