feat: proper mobile zoom

This commit is contained in:
2025-07-08 13:20:27 +02:00
parent 133795f744
commit 05ad4128e4
3 changed files with 282 additions and 16 deletions

View File

@ -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"
>

View File

@ -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"
>

View File

@ -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<CardData[]>([]);
let maxZIndex = $state(0);
@ -304,13 +308,37 @@
function handleCanvasWheel(event: WheelEvent) {
event.preventDefault();
const scrollSpeed = 1;
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) {
if (event.button === 1 || event.button === 2) {
@ -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}
>
<div
class="canvas-content"
style="transform: translate({canvasOffset.x}px, {canvasOffset.y}px);"
style="transform: translate({canvasOffset.x}px, {canvasOffset.y}px) scale({canvasZoom});"
>
{#each cards as card (card.id)}
<Card
{...card}
backImage={card.isRiskCategory ? 'risks/risikocat_back.png' : backImage}
canvasOffset={canvasOffset}
canvasZoom={canvasZoom}
bind:flipped={card.flipped}
bind:x={card.x}
bind:y={card.y}
@ -509,6 +633,7 @@
<StickyNote
{...note}
canvasOffset={canvasOffset}
canvasZoom={canvasZoom}
bind:text={note.text}
bind:x={note.x}
bind:y={note.y}
@ -597,18 +722,40 @@
height: 100%;
overflow: hidden;
cursor: grab;
touch-action: none;
user-select: none;
}
.game-area:active {
cursor: grabbing;
}
@media (max-width: 768px) {
.controls {
top: 10px;
right: 10px;
gap: 5px;
flex-direction: column;
align-items: flex-end;
}
.control-group {
padding: 6px 10px;
}
.controls button {
padding: 8px 12px;
font-size: 14px;
}
}
.canvas-content {
position: relative;
width: 500vw;
height: 500vh;
min-width: 500vw;
min-height: 500vh;
transform-origin: 0 0;
}
.deck-area {