feat: live collaboration
This commit is contained in:
		@ -72,6 +72,14 @@ io.on('connection', (socket) => {
 | 
				
			|||||||
    socket.to(data.room).emit('flip-all-cards', data);
 | 
					    socket.to(data.room).emit('flip-all-cards', data);
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  socket.on('shuffle-mode-change', (data) => {
 | 
				
			||||||
 | 
					    socket.to(data.room).emit('shuffle-mode-change', data);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  socket.on('import-canvas', (data) => {
 | 
				
			||||||
 | 
					    socket.to(data.room).emit('import-canvas', data);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  socket.on('disconnect', () => {
 | 
					  socket.on('disconnect', () => {
 | 
				
			||||||
    console.log('user disconnected');
 | 
					    console.log('user disconnected');
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,8 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
	import Card from '$lib/Card.svelte';
 | 
						import Card from '$lib/Card.svelte';
 | 
				
			||||||
	import StickyNote from '$lib/StickyNote.svelte';
 | 
						import StickyNote from '$lib/StickyNote.svelte';
 | 
				
			||||||
	import { onMount } from 'svelte';
 | 
						import { onMount, onDestroy } from 'svelte';
 | 
				
			||||||
 | 
						import { io, Socket } from 'socket.io-client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	interface CardData {
 | 
						interface CardData {
 | 
				
			||||||
		id: number;
 | 
							id: number;
 | 
				
			||||||
@ -21,7 +22,7 @@
 | 
				
			|||||||
		riskCategories?: string[];
 | 
							riskCategories?: string[];
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	interface StickyNote {
 | 
						interface StickyNoteData {
 | 
				
			||||||
		id: number;
 | 
							id: number;
 | 
				
			||||||
		text: string;
 | 
							text: string;
 | 
				
			||||||
		x: number;
 | 
							x: number;
 | 
				
			||||||
@ -111,11 +112,153 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	let cards = $state<CardData[]>([]);
 | 
						let cards = $state<CardData[]>([]);
 | 
				
			||||||
	let maxZIndex = $state(0);
 | 
						let maxZIndex = $state(0);
 | 
				
			||||||
	let stickyNotes = $state<StickyNote[]>([]);
 | 
						let stickyNotes = $state<StickyNoteData[]>([]);
 | 
				
			||||||
	let nextNoteId = $state(1);
 | 
						let nextNoteId = $state(1);
 | 
				
			||||||
	let noteColors = ['#ffeb3b', '#4caf50', '#2196f3', '#ff9800', '#e91e63', '#9c27b0'];
 | 
						let noteColors = ['#ffeb3b', '#4caf50', '#2196f3', '#ff9800', '#e91e63', '#9c27b0'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function initializeDeck() {
 | 
						let socket: Socket;
 | 
				
			||||||
 | 
						let room: string;
 | 
				
			||||||
 | 
						let isStateSynced = false;
 | 
				
			||||||
 | 
						let lastMoveTime = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						onMount(() => {
 | 
				
			||||||
 | 
							const urlParams = new URLSearchParams(window.location.search);
 | 
				
			||||||
 | 
							room = urlParams.get('sessid') || `room-${Math.random().toString(36).substr(2, 9)}`;
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							if (!urlParams.has('sessid')) {
 | 
				
			||||||
 | 
								window.history.replaceState({}, '', `?sessid=${room}`);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket = io();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('connect', () => {
 | 
				
			||||||
 | 
								socket.emit('join', room);
 | 
				
			||||||
 | 
								
 | 
				
			||||||
 | 
								// Request state from other clients, but only if we haven't synced yet
 | 
				
			||||||
 | 
								if (!isStateSynced) {
 | 
				
			||||||
 | 
									socket.emit('request-state', { room });
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// If no one sends state within 2 seconds, initialize the deck
 | 
				
			||||||
 | 
							setTimeout(() => {
 | 
				
			||||||
 | 
								if (!isStateSynced) {
 | 
				
			||||||
 | 
									initializeDeck();
 | 
				
			||||||
 | 
									isStateSynced = true;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}, 2000);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('request-state', (data) => {
 | 
				
			||||||
 | 
								const fullState = {
 | 
				
			||||||
 | 
									cards,
 | 
				
			||||||
 | 
									stickyNotes,
 | 
				
			||||||
 | 
									selectedDeck,
 | 
				
			||||||
 | 
									shuffleMode,
 | 
				
			||||||
 | 
									deckPosition,
 | 
				
			||||||
 | 
									maxZIndex,
 | 
				
			||||||
 | 
									nextNoteId
 | 
				
			||||||
 | 
								};
 | 
				
			||||||
 | 
								socket.emit('sync-state', { to: data.from, room, state: fullState });
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('sync-state', (data) => {
 | 
				
			||||||
 | 
								if (!isStateSynced) {
 | 
				
			||||||
 | 
									restoreCanvasState(data.state);
 | 
				
			||||||
 | 
									isStateSynced = true;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('card-move', (data) => {
 | 
				
			||||||
 | 
								const card = cards.find(c => c.id === data.id);
 | 
				
			||||||
 | 
								if (card) {
 | 
				
			||||||
 | 
									card.x = data.x;
 | 
				
			||||||
 | 
									card.y = data.y;
 | 
				
			||||||
 | 
									card.zIndex = data.zIndex;
 | 
				
			||||||
 | 
									if (data.zIndex > maxZIndex) maxZIndex = data.zIndex;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('card-click', (data) => {
 | 
				
			||||||
 | 
								const card = cards.find(c => c.id === data.id);
 | 
				
			||||||
 | 
								if (card) {
 | 
				
			||||||
 | 
									card.flipped = !card.flipped;
 | 
				
			||||||
 | 
									card.zIndex = data.zIndex;
 | 
				
			||||||
 | 
									if (data.zIndex > maxZIndex) maxZIndex = data.zIndex;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('card-drop', (data) => {
 | 
				
			||||||
 | 
								const card = cards.find(c => c.id === data.id);
 | 
				
			||||||
 | 
								if (card) {
 | 
				
			||||||
 | 
									Object.assign(card, data.cardState);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('sticky-note-add', (data) => {
 | 
				
			||||||
 | 
								stickyNotes.push(data.note);
 | 
				
			||||||
 | 
								if (data.note.id >= nextNoteId) nextNoteId = data.note.id + 1;
 | 
				
			||||||
 | 
								if (data.note.zIndex > maxZIndex) maxZIndex = data.note.zIndex;
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('sticky-note-move', (data) => {
 | 
				
			||||||
 | 
								const note = stickyNotes.find(n => n.id === data.id);
 | 
				
			||||||
 | 
								if (note) {
 | 
				
			||||||
 | 
									note.x = data.x;
 | 
				
			||||||
 | 
									note.y = data.y;
 | 
				
			||||||
 | 
									note.zIndex = data.zIndex;
 | 
				
			||||||
 | 
									if (data.zIndex > maxZIndex) maxZIndex = data.zIndex;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('sticky-note-update', (data) => {
 | 
				
			||||||
 | 
								const note = stickyNotes.find(n => n.id === data.id);
 | 
				
			||||||
 | 
								if (note) {
 | 
				
			||||||
 | 
									note.text = data.text;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('sticky-note-delete', (data) => {
 | 
				
			||||||
 | 
								const index = stickyNotes.findIndex(n => n.id === data.id);
 | 
				
			||||||
 | 
								if (index >= 0) {
 | 
				
			||||||
 | 
									stickyNotes.splice(index, 1);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('deck-change', (data) => {
 | 
				
			||||||
 | 
								selectedDeck = data.selectedDeck;
 | 
				
			||||||
 | 
								initializeDeck(false); // Don't broadcast, just init
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('shuffle-deck', (data) => {
 | 
				
			||||||
 | 
								cards = data.cards;
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('collect-to-deck', (data) => {
 | 
				
			||||||
 | 
								cards = data.cards;
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('deal-cards', (data) => {
 | 
				
			||||||
 | 
								cards = data.cards;
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							socket.on('flip-all-cards', () => {
 | 
				
			||||||
 | 
								cards.forEach(card => card.flipped = !card.flipped);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							socket.on('shuffle-mode-change', (data) => {
 | 
				
			||||||
 | 
								shuffleMode = data.shuffleMode;
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
							
 | 
				
			||||||
 | 
							socket.on('import-canvas', (data) => {
 | 
				
			||||||
 | 
								restoreCanvasState(data.state);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return () => {
 | 
				
			||||||
 | 
								socket.disconnect();
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						function initializeDeck(broadcast = true) {
 | 
				
			||||||
		const config = deckConfigs[selectedDeck];
 | 
							const config = deckConfigs[selectedDeck];
 | 
				
			||||||
		cards = config.cards.map((image, index) => ({
 | 
							cards = config.cards.map((image, index) => ({
 | 
				
			||||||
			id: index + 1,
 | 
								id: index + 1,
 | 
				
			||||||
@ -128,11 +271,13 @@
 | 
				
			|||||||
			isRiskCategory: selectedDeck === 1 && image.includes('1.png')
 | 
								isRiskCategory: selectedDeck === 1 && image.includes('1.png')
 | 
				
			||||||
		}));
 | 
							}));
 | 
				
			||||||
		maxZIndex = cards.length;
 | 
							maxZIndex = cards.length;
 | 
				
			||||||
	}
 | 
							stickyNotes = [];
 | 
				
			||||||
 | 
							nextNoteId = 1;
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
	onMount(() => {
 | 
							if (broadcast && socket) {
 | 
				
			||||||
		initializeDeck();
 | 
								socket.emit('deck-change', { room, selectedDeck });
 | 
				
			||||||
	});
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function handleDeckChange() {
 | 
						function handleDeckChange() {
 | 
				
			||||||
		initializeDeck();
 | 
							initializeDeck();
 | 
				
			||||||
@ -144,17 +289,21 @@
 | 
				
			|||||||
			card.x = x;
 | 
								card.x = x;
 | 
				
			||||||
			card.y = y;
 | 
								card.y = y;
 | 
				
			||||||
			card.zIndex = ++maxZIndex;
 | 
								card.zIndex = ++maxZIndex;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								const now = Date.now();
 | 
				
			||||||
 | 
								if (now - lastMoveTime > 50) { // Throttle to 20fps
 | 
				
			||||||
 | 
									socket.emit('card-move', { room, id, x, y, zIndex: card.zIndex });
 | 
				
			||||||
 | 
									lastMoveTime = now;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function handleCardDrop(id: number, x: number, y: number) {
 | 
						function handleCardDrop(id: number, x: number, y: number) {
 | 
				
			||||||
		const card = cards.find(c => c.id === id);
 | 
							const card = cards.find(c => c.id === id);
 | 
				
			||||||
		if (card) {
 | 
							if (card) {
 | 
				
			||||||
			// Calculate card center position (card is 220x300px)
 | 
					 | 
				
			||||||
			const cardCenterX = x + 110;
 | 
								const cardCenterX = x + 110;
 | 
				
			||||||
			const cardCenterY = y + 150;
 | 
								const cardCenterY = y + 150;
 | 
				
			||||||
			
 | 
								
 | 
				
			||||||
			// Check if card center is dropped on deck area
 | 
					 | 
				
			||||||
			if (
 | 
								if (
 | 
				
			||||||
				cardCenterX >= deckPosition.x && 
 | 
									cardCenterX >= deckPosition.x && 
 | 
				
			||||||
				cardCenterX <= deckPosition.x + 220 && 
 | 
									cardCenterX <= deckPosition.x + 220 && 
 | 
				
			||||||
@ -162,7 +311,6 @@
 | 
				
			|||||||
				cardCenterY <= deckPosition.y + 300 &&
 | 
									cardCenterY <= deckPosition.y + 300 &&
 | 
				
			||||||
				!card.inDeck
 | 
									!card.inDeck
 | 
				
			||||||
			) {
 | 
								) {
 | 
				
			||||||
				// Move card to top of deck
 | 
					 | 
				
			||||||
				const deckCards = cards.filter(c => c.inDeck);
 | 
									const deckCards = cards.filter(c => c.inDeck);
 | 
				
			||||||
				const topZ = deckCards.length > 0 ? Math.max(...deckCards.map(c => c.zIndex)) + 1 : 0;
 | 
									const topZ = deckCards.length > 0 ? Math.max(...deckCards.map(c => c.zIndex)) + 1 : 0;
 | 
				
			||||||
				
 | 
									
 | 
				
			||||||
@ -172,9 +320,9 @@
 | 
				
			|||||||
				card.flipped = true;
 | 
									card.flipped = true;
 | 
				
			||||||
				card.inDeck = true;
 | 
									card.inDeck = true;
 | 
				
			||||||
			} else if (card.inDeck) {
 | 
								} else if (card.inDeck) {
 | 
				
			||||||
				// Card was moved out of deck
 | 
					 | 
				
			||||||
				card.inDeck = false;
 | 
									card.inDeck = false;
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
								socket.emit('card-drop', { room, id, cardState: { ...card } });
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -182,6 +330,7 @@
 | 
				
			|||||||
		const card = cards.find(c => c.id === id);
 | 
							const card = cards.find(c => c.id === id);
 | 
				
			||||||
		if (card) {
 | 
							if (card) {
 | 
				
			||||||
			card.zIndex = ++maxZIndex;
 | 
								card.zIndex = ++maxZIndex;
 | 
				
			||||||
 | 
								socket.emit('card-click', { room, id, zIndex: card.zIndex });
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -200,6 +349,7 @@
 | 
				
			|||||||
		} else {
 | 
							} else {
 | 
				
			||||||
			normalShuffle(deckCards);
 | 
								normalShuffle(deckCards);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
							socket.emit('shuffle-deck', { room, cards });
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function normalShuffle(deckCards: CardData[]) {
 | 
						function normalShuffle(deckCards: CardData[]) {
 | 
				
			||||||
@ -288,6 +438,7 @@
 | 
				
			|||||||
			card.flipped = true;
 | 
								card.flipped = true;
 | 
				
			||||||
			card.inDeck = true;
 | 
								card.inDeck = true;
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
							socket.emit('collect-to-deck', { room, cards });
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function dealCards() {
 | 
						function dealCards() {
 | 
				
			||||||
@ -298,45 +449,34 @@
 | 
				
			|||||||
			card.flipped = false;
 | 
								card.flipped = false;
 | 
				
			||||||
			card.inDeck = false;
 | 
								card.inDeck = false;
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
							socket.emit('deal-cards', { room, cards });
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function flipAllCards() {
 | 
						function flipAllCards() {
 | 
				
			||||||
		cards.forEach(card => {
 | 
							cards.forEach(card => {
 | 
				
			||||||
			card.flipped = !card.flipped;
 | 
								card.flipped = !card.flipped;
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 | 
							socket.emit('flip-all-cards', { room });
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function handleCanvasWheel(event: WheelEvent) {
 | 
						function handleCanvasWheel(event: WheelEvent) {
 | 
				
			||||||
		event.preventDefault();
 | 
							event.preventDefault();
 | 
				
			||||||
		
 | 
							
 | 
				
			||||||
		if (event.ctrlKey || event.metaKey) {
 | 
							if (event.ctrlKey || event.metaKey) {
 | 
				
			||||||
			// Zoom with mouse wheel + ctrl/cmd towards viewport center
 | 
					 | 
				
			||||||
			const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
 | 
								const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
 | 
				
			||||||
			const newZoom = Math.max(0.1, Math.min(3, canvasZoom * zoomFactor));
 | 
								const newZoom = Math.max(0.1, Math.min(3, canvasZoom * zoomFactor));
 | 
				
			||||||
			
 | 
					 | 
				
			||||||
			// Get viewport center
 | 
					 | 
				
			||||||
			const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
 | 
								const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
 | 
				
			||||||
			const centerX = rect.width / 2;
 | 
								const centerX = rect.width / 2;
 | 
				
			||||||
			const centerY = rect.height / 2;
 | 
								const centerY = rect.height / 2;
 | 
				
			||||||
			
 | 
					 | 
				
			||||||
			// Convert viewport center to canvas coordinates before zoom
 | 
					 | 
				
			||||||
			const canvasCenterX = (centerX - canvasOffset.x) / canvasZoom;
 | 
								const canvasCenterX = (centerX - canvasOffset.x) / canvasZoom;
 | 
				
			||||||
			const canvasCenterY = (centerY - canvasOffset.y) / canvasZoom;
 | 
								const canvasCenterY = (centerY - canvasOffset.y) / canvasZoom;
 | 
				
			||||||
			
 | 
					 | 
				
			||||||
			// Update zoom
 | 
					 | 
				
			||||||
			canvasZoom = newZoom;
 | 
								canvasZoom = newZoom;
 | 
				
			||||||
			
 | 
					 | 
				
			||||||
			// Adjust offset to keep the viewport center fixed
 | 
					 | 
				
			||||||
			canvasOffset.x = centerX - canvasCenterX * canvasZoom;
 | 
								canvasOffset.x = centerX - canvasCenterX * canvasZoom;
 | 
				
			||||||
			canvasOffset.y = centerY - canvasCenterY * canvasZoom;
 | 
								canvasOffset.y = centerY - canvasCenterY * canvasZoom;
 | 
				
			||||||
		} else {
 | 
							} else {
 | 
				
			||||||
			// Pan with mouse wheel
 | 
								const scrollSpeed = 1;
 | 
				
			||||||
			const scrollSpeed = 1 / canvasZoom; // Adjust pan speed for zoom level
 | 
					 | 
				
			||||||
			canvasOffset.x -= event.deltaX * scrollSpeed;
 | 
								canvasOffset.x -= event.deltaX * scrollSpeed;
 | 
				
			||||||
			canvasOffset.y -= event.deltaY * 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));
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -356,9 +496,8 @@
 | 
				
			|||||||
			event.preventDefault();
 | 
								event.preventDefault();
 | 
				
			||||||
			const deltaX = event.clientX - dragStart.x;
 | 
								const deltaX = event.clientX - dragStart.x;
 | 
				
			||||||
			const deltaY = event.clientY - dragStart.y;
 | 
								const deltaY = event.clientY - dragStart.y;
 | 
				
			||||||
			
 | 
								canvasOffset.x = dragStartOffset.x + deltaX;
 | 
				
			||||||
			canvasOffset.x = Math.max(-2000, Math.min(2000, dragStartOffset.x + deltaX));
 | 
								canvasOffset.y = dragStartOffset.y + deltaY;
 | 
				
			||||||
			canvasOffset.y = Math.max(-2000, Math.min(2000, dragStartOffset.y + deltaY));
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -373,7 +512,6 @@
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function getTouchDistance(touches: TouchList) {
 | 
						function getTouchDistance(touches: TouchList) {
 | 
				
			||||||
		if (touches.length < 2) return 0;
 | 
					 | 
				
			||||||
		const touch1 = touches[0];
 | 
							const touch1 = touches[0];
 | 
				
			||||||
		const touch2 = touches[1];
 | 
							const touch2 = touches[1];
 | 
				
			||||||
		return Math.sqrt(
 | 
							return Math.sqrt(
 | 
				
			||||||
@ -383,7 +521,6 @@
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function getTouchCenter(touches: TouchList) {
 | 
						function getTouchCenter(touches: TouchList) {
 | 
				
			||||||
		if (touches.length < 2) return { x: 0, y: 0 };
 | 
					 | 
				
			||||||
		const touch1 = touches[0];
 | 
							const touch1 = touches[0];
 | 
				
			||||||
		const touch2 = touches[1];
 | 
							const touch2 = touches[1];
 | 
				
			||||||
		return {
 | 
							return {
 | 
				
			||||||
@ -393,19 +530,12 @@
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function handleCanvasTouchStart(event: TouchEvent) {
 | 
						function handleCanvasTouchStart(event: TouchEvent) {
 | 
				
			||||||
		isDragging = true;
 | 
					 | 
				
			||||||
		if (event.touches.length >= 2) {
 | 
							if (event.touches.length >= 2) {
 | 
				
			||||||
			// Two or more touches - pinch zoom
 | 
					 | 
				
			||||||
			isPinching = true;
 | 
								isPinching = true;
 | 
				
			||||||
			lastPinchDistance = getTouchDistance(event.touches);
 | 
								lastPinchDistance = getTouchDistance(event.touches);
 | 
				
			||||||
			pinchCenter = getTouchCenter(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) {
 | 
							} else if (event.touches.length === 1) {
 | 
				
			||||||
			// Single touch - pan
 | 
								isDragging = true;
 | 
				
			||||||
			const touch = event.touches[0];
 | 
								const touch = event.touches[0];
 | 
				
			||||||
			dragStart.x = touch.clientX;
 | 
								dragStart.x = touch.clientX;
 | 
				
			||||||
			dragStart.y = touch.clientY;
 | 
								dragStart.y = touch.clientY;
 | 
				
			||||||
@ -417,29 +547,27 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	function handleCanvasTouchMove(event: TouchEvent) {
 | 
						function handleCanvasTouchMove(event: TouchEvent) {
 | 
				
			||||||
		if (event.touches.length === 1 && isDragging && !isPinching) {
 | 
							if (event.touches.length === 1 && isDragging && !isPinching) {
 | 
				
			||||||
			// Single touch pan
 | 
					 | 
				
			||||||
			const touch = event.touches[0];
 | 
								const touch = event.touches[0];
 | 
				
			||||||
			const deltaX = touch.clientX - dragStart.x;
 | 
								const deltaX = touch.clientX - dragStart.x;
 | 
				
			||||||
			const deltaY = touch.clientY - dragStart.y;
 | 
								const deltaY = touch.clientY - dragStart.y;
 | 
				
			||||||
			
 | 
								canvasOffset.x = dragStartOffset.x + deltaX;
 | 
				
			||||||
			canvasOffset.x = Math.max(-2000, Math.min(2000, dragStartOffset.x + deltaX));
 | 
								canvasOffset.y = dragStartOffset.y + deltaY;
 | 
				
			||||||
			canvasOffset.y = Math.max(-2000, Math.min(2000, dragStartOffset.y + deltaY));
 | 
					 | 
				
			||||||
		} else if (event.touches.length === 2 && isPinching) {
 | 
							} else if (event.touches.length === 2 && isPinching) {
 | 
				
			||||||
			// Pinch zoom
 | 
					 | 
				
			||||||
			const currentDistance = getTouchDistance(event.touches);
 | 
								const currentDistance = getTouchDistance(event.touches);
 | 
				
			||||||
			const zoomFactor = currentDistance / lastPinchDistance;
 | 
								const zoomFactor = currentDistance / lastPinchDistance;
 | 
				
			||||||
			const newZoom = Math.max(0.1, Math.min(3, canvasZoom * zoomFactor));
 | 
								const newZoom = Math.max(0.1, Math.min(3, canvasZoom * zoomFactor));
 | 
				
			||||||
			
 | 
								
 | 
				
			||||||
			// Convert pinch center to canvas coordinates before zoom
 | 
								const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
 | 
				
			||||||
			const canvasCenterX = (pinchCenter.x - canvasOffset.x) / canvasZoom;
 | 
								const pinchCenterX = pinchCenter.x - rect.left;
 | 
				
			||||||
			const canvasCenterY = (pinchCenter.y - canvasOffset.y) / canvasZoom;
 | 
								const pinchCenterY = pinchCenter.y - rect.top;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								const canvasCenterX = (pinchCenterX - canvasOffset.x) / canvasZoom;
 | 
				
			||||||
 | 
								const canvasCenterY = (pinchCenterY - canvasOffset.y) / canvasZoom;
 | 
				
			||||||
			
 | 
								
 | 
				
			||||||
			// Update zoom
 | 
					 | 
				
			||||||
			canvasZoom = newZoom;
 | 
								canvasZoom = newZoom;
 | 
				
			||||||
			
 | 
								
 | 
				
			||||||
			// Adjust offset to keep the pinch center fixed
 | 
								canvasOffset.x = pinchCenterX - canvasCenterX * canvasZoom;
 | 
				
			||||||
			canvasOffset.x = pinchCenter.x - canvasCenterX * canvasZoom;
 | 
								canvasOffset.y = pinchCenterY - canvasCenterY * canvasZoom;
 | 
				
			||||||
			canvasOffset.y = pinchCenter.y - canvasCenterY * canvasZoom;
 | 
					 | 
				
			||||||
			
 | 
								
 | 
				
			||||||
			lastPinchDistance = currentDistance;
 | 
								lastPinchDistance = currentDistance;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@ -447,38 +575,29 @@
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function handleCanvasTouchEnd(event: TouchEvent) {
 | 
						function handleCanvasTouchEnd(event: TouchEvent) {
 | 
				
			||||||
		if (event.touches.length === 0) {
 | 
							if (event.touches.length < 2) isPinching = false;
 | 
				
			||||||
			isDragging = false;
 | 
							if (event.touches.length < 1) 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: StickyNoteData = {
 | 
				
			||||||
			id: nextNoteId++,
 | 
								id: nextNoteId++,
 | 
				
			||||||
			text: 'New note',
 | 
								text: '',
 | 
				
			||||||
			x: 400 - canvasOffset.x,
 | 
								x: (400 - canvasOffset.x) / canvasZoom,
 | 
				
			||||||
			y: 200 - canvasOffset.y,
 | 
								y: (200 - canvasOffset.y) / canvasZoom,
 | 
				
			||||||
			zIndex: ++maxZIndex,
 | 
								zIndex: ++maxZIndex,
 | 
				
			||||||
			color: randomColor
 | 
								color: randomColor
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
		stickyNotes.push(newNote);
 | 
							stickyNotes.push(newNote);
 | 
				
			||||||
 | 
							socket.emit('sticky-note-add', { room, note: newNote });
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function updateStickyNote(id: number, text: string) {
 | 
						function updateStickyNote(id: number, text: string) {
 | 
				
			||||||
		const note = stickyNotes.find(n => n.id === id);
 | 
							const note = stickyNotes.find(n => n.id === id);
 | 
				
			||||||
		if (note) {
 | 
							if (note) {
 | 
				
			||||||
			note.text = text;
 | 
								note.text = text;
 | 
				
			||||||
 | 
								socket.emit('sticky-note-update', { room, id, text });
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -488,6 +607,7 @@
 | 
				
			|||||||
			note.x = x;
 | 
								note.x = x;
 | 
				
			||||||
			note.y = y;
 | 
								note.y = y;
 | 
				
			||||||
			note.zIndex = ++maxZIndex;
 | 
								note.zIndex = ++maxZIndex;
 | 
				
			||||||
 | 
								socket.emit('sticky-note-move', { room, id, x, y, zIndex: note.zIndex });
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -495,6 +615,7 @@
 | 
				
			|||||||
		const index = stickyNotes.findIndex(n => n.id === id);
 | 
							const index = stickyNotes.findIndex(n => n.id === id);
 | 
				
			||||||
		if (index >= 0) {
 | 
							if (index >= 0) {
 | 
				
			||||||
			stickyNotes.splice(index, 1);
 | 
								stickyNotes.splice(index, 1);
 | 
				
			||||||
 | 
								socket.emit('sticky-note-delete', { room, id });
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -504,8 +625,6 @@
 | 
				
			|||||||
			shuffleMode,
 | 
								shuffleMode,
 | 
				
			||||||
			cards: cards.map(card => ({...card})),
 | 
								cards: cards.map(card => ({...card})),
 | 
				
			||||||
			stickyNotes: stickyNotes.map(note => ({...note})),
 | 
								stickyNotes: stickyNotes.map(note => ({...note})),
 | 
				
			||||||
			canvasOffset,
 | 
					 | 
				
			||||||
			canvasZoom,
 | 
					 | 
				
			||||||
			deckPosition,
 | 
								deckPosition,
 | 
				
			||||||
			maxZIndex,
 | 
								maxZIndex,
 | 
				
			||||||
			nextNoteId,
 | 
								nextNoteId,
 | 
				
			||||||
@ -525,6 +644,16 @@
 | 
				
			|||||||
		URL.revokeObjectURL(url);
 | 
							URL.revokeObjectURL(url);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						function restoreCanvasState(state: any) {
 | 
				
			||||||
 | 
							selectedDeck = state.selectedDeck ?? 0;
 | 
				
			||||||
 | 
							shuffleMode = state.shuffleMode ?? 'normal';
 | 
				
			||||||
 | 
							cards = state.cards ?? [];
 | 
				
			||||||
 | 
							stickyNotes = state.stickyNotes ?? [];
 | 
				
			||||||
 | 
							deckPosition = state.deckPosition ?? { x: 50, y: 300 };
 | 
				
			||||||
 | 
							maxZIndex = state.maxZIndex ?? (cards.length + (stickyNotes.length || 0));
 | 
				
			||||||
 | 
							nextNoteId = state.nextNoteId ?? 1;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function importCanvas() {
 | 
						function importCanvas() {
 | 
				
			||||||
		const input = document.createElement('input');
 | 
							const input = document.createElement('input');
 | 
				
			||||||
		input.type = 'file';
 | 
							input.type = 'file';
 | 
				
			||||||
@ -538,22 +667,15 @@
 | 
				
			|||||||
				try {
 | 
									try {
 | 
				
			||||||
					const canvasState = JSON.parse(e.target?.result as string);
 | 
										const canvasState = JSON.parse(e.target?.result as string);
 | 
				
			||||||
					
 | 
										
 | 
				
			||||||
					// Validate the imported data structure
 | 
					 | 
				
			||||||
					if (!canvasState.cards || !Array.isArray(canvasState.cards)) {
 | 
										if (!canvasState.cards || !Array.isArray(canvasState.cards)) {
 | 
				
			||||||
						alert('Invalid canvas file format');
 | 
											alert('Invalid canvas file format');
 | 
				
			||||||
						return;
 | 
											return;
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
					// Restore state
 | 
										restoreCanvasState(canvasState);
 | 
				
			||||||
					selectedDeck = canvasState.selectedDeck || 0;
 | 
										
 | 
				
			||||||
					shuffleMode = canvasState.shuffleMode || 'normal';
 | 
										// Broadcast imported state to others
 | 
				
			||||||
					cards = canvasState.cards || [];
 | 
										socket.emit('import-canvas', { room, state: canvasState });
 | 
				
			||||||
					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;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
				} catch (error) {
 | 
									} catch (error) {
 | 
				
			||||||
					alert('Error reading canvas file: ' + error);
 | 
										alert('Error reading canvas file: ' + error);
 | 
				
			||||||
@ -565,6 +687,12 @@
 | 
				
			|||||||
		input.click();
 | 
							input.click();
 | 
				
			||||||
		document.body.removeChild(input);
 | 
							document.body.removeChild(input);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						$effect(() => {
 | 
				
			||||||
 | 
							if (socket) {
 | 
				
			||||||
 | 
								socket.emit('shuffle-mode-change', { room, shuffleMode });
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="game-container">
 | 
					<div class="game-container">
 | 
				
			||||||
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user