diff --git a/src/lib/Card.svelte b/src/lib/Card.svelte index 73089e3..cd7b4d7 100644 --- a/src/lib/Card.svelte +++ b/src/lib/Card.svelte @@ -8,6 +8,7 @@ y?: number; zIndex?: number; canvasOffset?: { x: number; y: number }; + canvasZoom?: number; onMove?: (id: number, x: number, y: number) => void; onClick?: (id: number) => void; onDrop?: (id: number, x: number, y: number) => void; @@ -22,6 +23,7 @@ y = $bindable(0), zIndex = $bindable(0), canvasOffset = { x: 0, y: 0 }, + canvasZoom = 1, onMove, onClick, onDrop @@ -38,10 +40,11 @@ isDragging = true; hasMoved = false; - const rect = cardElement.getBoundingClientRect(); + + // Calculate offset relative to card position in canvas coordinates dragOffset = { - x: event.clientX - rect.left, - y: event.clientY - rect.top + x: (event.clientX - canvasOffset.x) / canvasZoom - x, + y: (event.clientY - canvasOffset.y) / canvasZoom - y }; dragStartPos = { x: event.clientX, @@ -61,8 +64,8 @@ hasMoved = true; } - const newX = event.clientX - dragOffset.x - canvasOffset.x; - const newY = event.clientY - dragOffset.y - canvasOffset.y; + const newX = (event.clientX - canvasOffset.x) / canvasZoom - dragOffset.x; + const newY = (event.clientY - canvasOffset.y) / canvasZoom - dragOffset.y; x = newX; y = newY; @@ -84,6 +87,60 @@ } } + function handleTouchStart(event: TouchEvent) { + if (event.touches.length !== 1) return; + event.stopPropagation(); // Prevent canvas panning + + const touch = event.touches[0]; + isDragging = true; + hasMoved = false; + + // Calculate offset relative to card position in canvas coordinates + dragOffset = { + x: (touch.clientX - canvasOffset.x) / canvasZoom - x, + y: (touch.clientY - canvasOffset.y) / canvasZoom - y + }; + dragStartPos = { + x: touch.clientX, + y: touch.clientY + }; + + event.preventDefault(); + } + + function handleTouchMove(event: TouchEvent) { + if (!isDragging || event.touches.length !== 1) return; + + const touch = event.touches[0]; + const deltaX = Math.abs(touch.clientX - dragStartPos.x); + const deltaY = Math.abs(touch.clientY - dragStartPos.y); + + if (deltaX > 10 || deltaY > 10) { + hasMoved = true; + } + + const newX = (touch.clientX - canvasOffset.x) / canvasZoom - dragOffset.x; + const newY = (touch.clientY - canvasOffset.y) / canvasZoom - dragOffset.y; + + x = newX; + y = newY; + + onMove?.(id, newX, newY); + event.preventDefault(); + } + + function handleTouchEnd(event: TouchEvent) { + if (isDragging) { + onDrop?.(id, x, y); + if (!hasMoved) { + flipped = !flipped; + onClick?.(id); + } + } + isDragging = false; + event.preventDefault(); + } + $effect(() => { if (isDragging) { document.addEventListener('mousemove', handleMouseMove); @@ -104,6 +161,9 @@ style="left: {x}px; top: {y}px; z-index: {zIndex};" onmousedown={handleMouseDown} onclick={handleClick} + ontouchstart={handleTouchStart} + ontouchmove={handleTouchMove} + ontouchend={handleTouchEnd} role="button" tabindex="0" > diff --git a/src/lib/StickyNote.svelte b/src/lib/StickyNote.svelte index a9ee642..4ce0c63 100644 --- a/src/lib/StickyNote.svelte +++ b/src/lib/StickyNote.svelte @@ -7,6 +7,7 @@ zIndex: number; color: string; canvasOffset?: { x: number; y: number }; + canvasZoom?: number; onMove?: (id: number, x: number, y: number) => void; onUpdate?: (id: number, text: string) => void; onDelete?: (id: number) => void; @@ -20,6 +21,7 @@ zIndex = $bindable(0), color, canvasOffset = { x: 0, y: 0 }, + canvasZoom = 1, onMove, onUpdate, onDelete @@ -38,10 +40,11 @@ isDragging = true; hasMoved = false; - const rect = noteElement.getBoundingClientRect(); + + // Calculate offset relative to note position in canvas coordinates dragOffset = { - x: event.clientX - rect.left, - y: event.clientY - rect.top + x: (event.clientX - canvasOffset.x) / canvasZoom - x, + y: (event.clientY - canvasOffset.y) / canvasZoom - y }; dragStartPos = { x: event.clientX, @@ -61,8 +64,8 @@ hasMoved = true; } - const newX = event.clientX - dragOffset.x - canvasOffset.x; - const newY = event.clientY - dragOffset.y - canvasOffset.y; + const newX = (event.clientX - canvasOffset.x) / canvasZoom - dragOffset.x; + const newY = (event.clientY - canvasOffset.y) / canvasZoom - dragOffset.y; x = newX; y = newY; @@ -102,6 +105,59 @@ onDelete?.(id); } + function handleTouchStart(event: TouchEvent) { + if (event.touches.length !== 1) return; + event.stopPropagation(); // Prevent canvas panning + + const touch = event.touches[0]; + isDragging = true; + hasMoved = false; + + // Calculate offset relative to note position in canvas coordinates + dragOffset = { + x: (touch.clientX - canvasOffset.x) / canvasZoom - x, + y: (touch.clientY - canvasOffset.y) / canvasZoom - y + }; + dragStartPos = { + x: touch.clientX, + y: touch.clientY + }; + + event.preventDefault(); + } + + function handleTouchMove(event: TouchEvent) { + if (!isDragging || event.touches.length !== 1) return; + + const touch = event.touches[0]; + const deltaX = Math.abs(touch.clientX - dragStartPos.x); + const deltaY = Math.abs(touch.clientY - dragStartPos.y); + + if (deltaX > 5 || deltaY > 5) { + hasMoved = true; + } + + const newX = (touch.clientX - canvasOffset.x) / canvasZoom - dragOffset.x; + const newY = (touch.clientY - canvasOffset.y) / canvasZoom - dragOffset.y; + + x = newX; + y = newY; + + onMove?.(id, newX, newY); + event.preventDefault(); + } + + function handleTouchEnd(event: TouchEvent) { + if (isDragging) { + if (!hasMoved) { + isEditing = true; + setTimeout(() => textareaElement?.focus(), 0); + } + } + isDragging = false; + event.preventDefault(); + } + $effect(() => { if (isDragging) { document.addEventListener('mousemove', handleMouseMove); @@ -122,6 +178,9 @@ style="left: {x}px; top: {y}px; z-index: {zIndex}; background-color: {color};" onmousedown={handleMouseDown} onclick={handleClick} + ontouchstart={handleTouchStart} + ontouchmove={handleTouchMove} + ontouchend={handleTouchEnd} role="button" tabindex="0" > diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9e8a25c..4c61015 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -101,9 +101,13 @@ let deckPosition = $state({ x: 50, y: 300 }); let canvasOffset = $state({ x: 0, y: 0 }); + let canvasZoom = $state(1); let isDragging = $state(false); let dragStart = $state({ x: 0, y: 0 }); let dragStartOffset = $state({ x: 0, y: 0 }); + let isPinching = $state(false); + let lastPinchDistance = $state(0); + let pinchCenter = $state({ x: 0, y: 0 }); let cards = $state([]); let maxZIndex = $state(0); @@ -304,12 +308,36 @@ function handleCanvasWheel(event: WheelEvent) { event.preventDefault(); - const scrollSpeed = 1; - canvasOffset.x -= event.deltaX * scrollSpeed; - canvasOffset.y -= event.deltaY * scrollSpeed; - canvasOffset.x = Math.max(-2000, Math.min(2000, canvasOffset.x)); - canvasOffset.y = Math.max(-2000, Math.min(2000, canvasOffset.y)); + if (event.ctrlKey || event.metaKey) { + // Zoom with mouse wheel + ctrl/cmd towards viewport center + const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1; + const newZoom = Math.max(0.1, Math.min(3, canvasZoom * zoomFactor)); + + // Get viewport center + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + const centerX = rect.width / 2; + const centerY = rect.height / 2; + + // Convert viewport center to canvas coordinates before zoom + const canvasCenterX = (centerX - canvasOffset.x) / canvasZoom; + const canvasCenterY = (centerY - canvasOffset.y) / canvasZoom; + + // Update zoom + canvasZoom = newZoom; + + // Adjust offset to keep the viewport center fixed + canvasOffset.x = centerX - canvasCenterX * canvasZoom; + canvasOffset.y = centerY - canvasCenterY * canvasZoom; + } else { + // Pan with mouse wheel + const scrollSpeed = 1 / canvasZoom; // Adjust pan speed for zoom level + canvasOffset.x -= event.deltaX * scrollSpeed; + canvasOffset.y -= event.deltaY * scrollSpeed; + + canvasOffset.x = Math.max(-2000, Math.min(2000, canvasOffset.x)); + canvasOffset.y = Math.max(-2000, Math.min(2000, canvasOffset.y)); + } } function handleCanvasMouseDown(event: MouseEvent) { @@ -344,6 +372,96 @@ event.preventDefault(); } + function getTouchDistance(touches: TouchList) { + if (touches.length < 2) return 0; + const touch1 = touches[0]; + const touch2 = touches[1]; + return Math.sqrt( + Math.pow(touch2.clientX - touch1.clientX, 2) + + Math.pow(touch2.clientY - touch1.clientY, 2) + ); + } + + function getTouchCenter(touches: TouchList) { + if (touches.length < 2) return { x: 0, y: 0 }; + const touch1 = touches[0]; + const touch2 = touches[1]; + return { + x: (touch1.clientX + touch2.clientX) / 2, + y: (touch1.clientY + touch2.clientY) / 2 + }; + } + + function handleCanvasTouchStart(event: TouchEvent) { + isDragging = true; + if (event.touches.length >= 2) { + // Two or more touches - pinch zoom + isPinching = true; + lastPinchDistance = getTouchDistance(event.touches); + pinchCenter = getTouchCenter(event.touches); + + // Convert pinch center to canvas coordinates + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + pinchCenter.x -= rect.left; + pinchCenter.y -= rect.top; + } else if (event.touches.length === 1) { + // Single touch - pan + const touch = event.touches[0]; + dragStart.x = touch.clientX; + dragStart.y = touch.clientY; + dragStartOffset.x = canvasOffset.x; + dragStartOffset.y = canvasOffset.y; + } + event.preventDefault(); + } + + function handleCanvasTouchMove(event: TouchEvent) { + if (event.touches.length === 1 && isDragging && !isPinching) { + // Single touch pan + const touch = event.touches[0]; + const deltaX = touch.clientX - dragStart.x; + const deltaY = touch.clientY - dragStart.y; + + canvasOffset.x = Math.max(-2000, Math.min(2000, dragStartOffset.x + deltaX)); + canvasOffset.y = Math.max(-2000, Math.min(2000, dragStartOffset.y + deltaY)); + } else if (event.touches.length === 2 && isPinching) { + // Pinch zoom + const currentDistance = getTouchDistance(event.touches); + const zoomFactor = currentDistance / lastPinchDistance; + const newZoom = Math.max(0.1, Math.min(3, canvasZoom * zoomFactor)); + + // Convert pinch center to canvas coordinates before zoom + const canvasCenterX = (pinchCenter.x - canvasOffset.x) / canvasZoom; + const canvasCenterY = (pinchCenter.y - canvasOffset.y) / canvasZoom; + + // Update zoom + canvasZoom = newZoom; + + // Adjust offset to keep the pinch center fixed + canvasOffset.x = pinchCenter.x - canvasCenterX * canvasZoom; + canvasOffset.y = pinchCenter.y - canvasCenterY * canvasZoom; + + lastPinchDistance = currentDistance; + } + event.preventDefault(); + } + + function handleCanvasTouchEnd(event: TouchEvent) { + if (event.touches.length === 0) { + isDragging = false; + isPinching = false; + } else if (event.touches.length === 1) { + isPinching = false; + // A pinch ended. Reset pan state for the remaining finger to prevent a jump. + const touch = event.touches[0]; + dragStart.x = touch.clientX; + dragStart.y = touch.clientY; + dragStartOffset.x = canvasOffset.x; + dragStartOffset.y = canvasOffset.y; + } + event.preventDefault(); + } + function addStickyNote() { const randomColor = noteColors[Math.floor(Math.random() * noteColors.length)]; const newNote: StickyNote = { @@ -387,6 +505,7 @@ cards: cards.map(card => ({...card})), stickyNotes: stickyNotes.map(note => ({...note})), canvasOffset, + canvasZoom, deckPosition, maxZIndex, nextNoteId, @@ -431,6 +550,7 @@ cards = canvasState.cards || []; stickyNotes = canvasState.stickyNotes || []; canvasOffset = canvasState.canvasOffset || { x: 0, y: 0 }; + canvasZoom = canvasState.canvasZoom || 1; deckPosition = canvasState.deckPosition || { x: 50, y: 300 }; maxZIndex = canvasState.maxZIndex || cards.length; nextNoteId = canvasState.nextNoteId || 1; @@ -485,16 +605,20 @@ onmousemove={handleCanvasMouseMove} onmouseup={handleCanvasMouseUp} oncontextmenu={handleCanvasContextMenu} + ontouchstart={handleCanvasTouchStart} + ontouchmove={handleCanvasTouchMove} + ontouchend={handleCanvasTouchEnd} >
{#each cards as card (card.id)}