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; y?: number;
zIndex?: number; zIndex?: number;
canvasOffset?: { x: number; y: number }; canvasOffset?: { x: number; y: number };
canvasZoom?: number;
onMove?: (id: number, x: number, y: number) => void; onMove?: (id: number, x: number, y: number) => void;
onClick?: (id: number) => void; onClick?: (id: number) => void;
onDrop?: (id: number, x: number, y: number) => void; onDrop?: (id: number, x: number, y: number) => void;
@ -22,6 +23,7 @@
y = $bindable(0), y = $bindable(0),
zIndex = $bindable(0), zIndex = $bindable(0),
canvasOffset = { x: 0, y: 0 }, canvasOffset = { x: 0, y: 0 },
canvasZoom = 1,
onMove, onMove,
onClick, onClick,
onDrop onDrop
@ -38,10 +40,11 @@
isDragging = true; isDragging = true;
hasMoved = false; hasMoved = false;
const rect = cardElement.getBoundingClientRect();
// Calculate offset relative to card position in canvas coordinates
dragOffset = { dragOffset = {
x: event.clientX - rect.left, x: (event.clientX - canvasOffset.x) / canvasZoom - x,
y: event.clientY - rect.top y: (event.clientY - canvasOffset.y) / canvasZoom - y
}; };
dragStartPos = { dragStartPos = {
x: event.clientX, x: event.clientX,
@ -61,8 +64,8 @@
hasMoved = true; hasMoved = true;
} }
const newX = event.clientX - dragOffset.x - canvasOffset.x; const newX = (event.clientX - canvasOffset.x) / canvasZoom - dragOffset.x;
const newY = event.clientY - dragOffset.y - canvasOffset.y; const newY = (event.clientY - canvasOffset.y) / canvasZoom - dragOffset.y;
x = newX; x = newX;
y = newY; 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(() => { $effect(() => {
if (isDragging) { if (isDragging) {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
@ -104,6 +161,9 @@
style="left: {x}px; top: {y}px; z-index: {zIndex};" style="left: {x}px; top: {y}px; z-index: {zIndex};"
onmousedown={handleMouseDown} onmousedown={handleMouseDown}
onclick={handleClick} onclick={handleClick}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
role="button" role="button"
tabindex="0" tabindex="0"
> >

View File

@ -7,6 +7,7 @@
zIndex: number; zIndex: number;
color: string; color: string;
canvasOffset?: { x: number; y: number }; canvasOffset?: { x: number; y: number };
canvasZoom?: number;
onMove?: (id: number, x: number, y: number) => void; onMove?: (id: number, x: number, y: number) => void;
onUpdate?: (id: number, text: string) => void; onUpdate?: (id: number, text: string) => void;
onDelete?: (id: number) => void; onDelete?: (id: number) => void;
@ -20,6 +21,7 @@
zIndex = $bindable(0), zIndex = $bindable(0),
color, color,
canvasOffset = { x: 0, y: 0 }, canvasOffset = { x: 0, y: 0 },
canvasZoom = 1,
onMove, onMove,
onUpdate, onUpdate,
onDelete onDelete
@ -38,10 +40,11 @@
isDragging = true; isDragging = true;
hasMoved = false; hasMoved = false;
const rect = noteElement.getBoundingClientRect();
// Calculate offset relative to note position in canvas coordinates
dragOffset = { dragOffset = {
x: event.clientX - rect.left, x: (event.clientX - canvasOffset.x) / canvasZoom - x,
y: event.clientY - rect.top y: (event.clientY - canvasOffset.y) / canvasZoom - y
}; };
dragStartPos = { dragStartPos = {
x: event.clientX, x: event.clientX,
@ -61,8 +64,8 @@
hasMoved = true; hasMoved = true;
} }
const newX = event.clientX - dragOffset.x - canvasOffset.x; const newX = (event.clientX - canvasOffset.x) / canvasZoom - dragOffset.x;
const newY = event.clientY - dragOffset.y - canvasOffset.y; const newY = (event.clientY - canvasOffset.y) / canvasZoom - dragOffset.y;
x = newX; x = newX;
y = newY; y = newY;
@ -102,6 +105,59 @@
onDelete?.(id); 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(() => { $effect(() => {
if (isDragging) { if (isDragging) {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
@ -122,6 +178,9 @@
style="left: {x}px; top: {y}px; z-index: {zIndex}; background-color: {color};" style="left: {x}px; top: {y}px; z-index: {zIndex}; background-color: {color};"
onmousedown={handleMouseDown} onmousedown={handleMouseDown}
onclick={handleClick} onclick={handleClick}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
role="button" role="button"
tabindex="0" tabindex="0"
> >

View File

@ -101,9 +101,13 @@
let deckPosition = $state({ x: 50, y: 300 }); let deckPosition = $state({ x: 50, y: 300 });
let canvasOffset = $state({ x: 0, y: 0 }); let canvasOffset = $state({ x: 0, y: 0 });
let canvasZoom = $state(1);
let isDragging = $state(false); let isDragging = $state(false);
let dragStart = $state({ x: 0, y: 0 }); let dragStart = $state({ x: 0, y: 0 });
let dragStartOffset = $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 cards = $state<CardData[]>([]);
let maxZIndex = $state(0); let maxZIndex = $state(0);
@ -304,12 +308,36 @@
function handleCanvasWheel(event: WheelEvent) { function handleCanvasWheel(event: WheelEvent) {
event.preventDefault(); 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)); if (event.ctrlKey || event.metaKey) {
canvasOffset.y = Math.max(-2000, Math.min(2000, canvasOffset.y)); // 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) { function handleCanvasMouseDown(event: MouseEvent) {
@ -344,6 +372,96 @@
event.preventDefault(); 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() { function addStickyNote() {
const randomColor = noteColors[Math.floor(Math.random() * noteColors.length)]; const randomColor = noteColors[Math.floor(Math.random() * noteColors.length)];
const newNote: StickyNote = { const newNote: StickyNote = {
@ -387,6 +505,7 @@
cards: cards.map(card => ({...card})), cards: cards.map(card => ({...card})),
stickyNotes: stickyNotes.map(note => ({...note})), stickyNotes: stickyNotes.map(note => ({...note})),
canvasOffset, canvasOffset,
canvasZoom,
deckPosition, deckPosition,
maxZIndex, maxZIndex,
nextNoteId, nextNoteId,
@ -431,6 +550,7 @@
cards = canvasState.cards || []; cards = canvasState.cards || [];
stickyNotes = canvasState.stickyNotes || []; stickyNotes = canvasState.stickyNotes || [];
canvasOffset = canvasState.canvasOffset || { x: 0, y: 0 }; canvasOffset = canvasState.canvasOffset || { x: 0, y: 0 };
canvasZoom = canvasState.canvasZoom || 1;
deckPosition = canvasState.deckPosition || { x: 50, y: 300 }; deckPosition = canvasState.deckPosition || { x: 50, y: 300 };
maxZIndex = canvasState.maxZIndex || cards.length; maxZIndex = canvasState.maxZIndex || cards.length;
nextNoteId = canvasState.nextNoteId || 1; nextNoteId = canvasState.nextNoteId || 1;
@ -485,16 +605,20 @@
onmousemove={handleCanvasMouseMove} onmousemove={handleCanvasMouseMove}
onmouseup={handleCanvasMouseUp} onmouseup={handleCanvasMouseUp}
oncontextmenu={handleCanvasContextMenu} oncontextmenu={handleCanvasContextMenu}
ontouchstart={handleCanvasTouchStart}
ontouchmove={handleCanvasTouchMove}
ontouchend={handleCanvasTouchEnd}
> >
<div <div
class="canvas-content" 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)} {#each cards as card (card.id)}
<Card <Card
{...card} {...card}
backImage={card.isRiskCategory ? 'risks/risikocat_back.png' : backImage} backImage={card.isRiskCategory ? 'risks/risikocat_back.png' : backImage}
canvasOffset={canvasOffset} canvasOffset={canvasOffset}
canvasZoom={canvasZoom}
bind:flipped={card.flipped} bind:flipped={card.flipped}
bind:x={card.x} bind:x={card.x}
bind:y={card.y} bind:y={card.y}
@ -509,6 +633,7 @@
<StickyNote <StickyNote
{...note} {...note}
canvasOffset={canvasOffset} canvasOffset={canvasOffset}
canvasZoom={canvasZoom}
bind:text={note.text} bind:text={note.text}
bind:x={note.x} bind:x={note.x}
bind:y={note.y} bind:y={note.y}
@ -597,18 +722,40 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
cursor: grab; cursor: grab;
touch-action: none;
user-select: none;
} }
.game-area:active { .game-area:active {
cursor: grabbing; 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 { .canvas-content {
position: relative; position: relative;
width: 500vw; width: 500vw;
height: 500vh; height: 500vh;
min-width: 500vw; min-width: 500vw;
min-height: 500vh; min-height: 500vh;
transform-origin: 0 0;
} }
.deck-area { .deck-area {