diff --git a/src/lib/Canvas.svelte b/src/lib/Canvas.svelte index 80e209a..4b7e0ba 100644 --- a/src/lib/Canvas.svelte +++ b/src/lib/Canvas.svelte @@ -30,7 +30,7 @@ function handleMouseUp(e: MouseEvent) { if (isPanning && !hasPanned && e.target === container) { - state.select(null); + state.clearSelection(); state.focus(null); } isPanning = false; @@ -142,38 +142,50 @@ const centerX = (rect.width / 2 - state.viewport.x) / state.viewport.zoom; const centerY = (rect.height / 2 - state.viewport.y) / state.viewport.zoom; - if (mod && e.key === 'c' && state.selectedId) { + const hasSelection = state.selectedIds.size > 0; + const singleSelection = state.selectedIds.size === 1; + const firstSelectedId = singleSelection ? [...state.selectedIds][0] : null; + + if (mod && e.key === 'c' && hasSelection) { e.preventDefault(); - state.copyItem(state.selectedId); + state.copySelected(); } else if (mod && e.key === 'v') { e.preventDefault(); - const newId = state.pasteItem(centerX, centerY); - if (newId) state.select(newId); - } else if (mod && e.key === 'd' && state.selectedId) { + const newIds = state.pasteItems(centerX, centerY); + if (newIds.length > 0) state.select(newIds[0]); + for (let i = 1; i < newIds.length; i++) { + state.toggleSelection(newIds[i]); + } + } else if (mod && e.key === 'd' && hasSelection) { e.preventDefault(); - const newId = state.duplicateItem(state.selectedId, centerX, centerY); - if (newId) state.select(newId); + const newIds = state.duplicateSelected(centerX, centerY); + if (newIds.length > 0) state.select(newIds[0]); + for (let i = 1; i < newIds.length; i++) { + state.toggleSelection(newIds[i]); + } } else if (e.key === 'Escape') { - state.select(null); + state.clearSelection(); state.focus(null); - } else if (e.key === '[' && state.selectedId) { + } else if (e.key === '[' && hasSelection) { e.preventDefault(); - state.sendBackward(state.selectedId); - } else if (e.key === ']' && state.selectedId) { + for (const id of state.selectedIds) { + state.sendBackward(id); + } + } else if (e.key === ']' && hasSelection) { e.preventDefault(); - state.bringForward(state.selectedId); - } else if (e.key.startsWith('Arrow') && state.selectedId) { + for (const id of state.selectedIds) { + state.bringForward(id); + } + } else if (e.key.startsWith('Arrow') && hasSelection) { e.preventDefault(); const step = e.shiftKey ? 10 : 1; - const item = state.getItem(state.selectedId); - if (!item) return; const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0; const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0; - state.updateItem(state.selectedId, { x: item.x + dx, y: item.y + dy }); - } else if (e.key === 'e' && state.selectedId && !mod) { + state.moveSelected(dx, dy); + } else if (e.key === 'e' && singleSelection && !mod) { e.preventDefault(); - state.focus(state.selectedId); - state.edit(state.selectedId); + state.focus(firstSelectedId!); + state.edit(firstSelectedId!); } } diff --git a/src/lib/Item.svelte b/src/lib/Item.svelte index 323f061..1d71744 100644 --- a/src/lib/Item.svelte +++ b/src/lib/Item.svelte @@ -5,12 +5,13 @@ let { item }: { item: Item } = $props(); - let isSelected = $derived(appState.selectedId === item.id); + let isSelected = $derived(appState.selectedIds.has(item.id)); let isFocused = $derived(appState.focusedId === item.id); let isDragging = $state(false); let isResizing = $state(false); let isRotating = $state(false); let dragStart = { x: 0, y: 0, itemX: 0, itemY: 0 }; + let dragStartPositions: Map = new Map(); let resizeStart = { x: 0, y: 0, width: 0, height: 0, itemX: 0, itemY: 0, corner: '', aspectRatio: 1 }; let rotateStart = { angle: 0, startAngle: 0 }; @@ -39,8 +40,13 @@ if (e.button !== 0) return; if (isFocused) return; e.stopPropagation(); - appState.select(item.id); - appState.bringToFront(item.id); + + if (e.shiftKey) { + appState.toggleSelection(item.id); + } else if (!isSelected) { + appState.select(item.id); + appState.bringToFront(item.id); + } isDragging = true; dragStart = { @@ -49,6 +55,13 @@ itemX: item.x, itemY: item.y }; + dragStartPositions = new Map(); + for (const id of appState.selectedIds) { + const selectedItem = appState.getItem(id); + if (selectedItem) { + dragStartPositions.set(id, { x: selectedItem.x, y: selectedItem.y }); + } + } } function handleResizeStart(e: MouseEvent, corner: string) { @@ -84,10 +97,12 @@ if (isDragging) { const dx = (e.clientX - dragStart.x) / appState.viewport.zoom; const dy = (e.clientY - dragStart.y) / appState.viewport.zoom; - appState.updateItem(item.id, { - x: dragStart.itemX + dx, - y: dragStart.itemY + dy - }); + for (const [id, startPos] of dragStartPositions) { + appState.updateItem(id, { + x: startPos.x + dx, + y: startPos.y + dy + }); + } } else if (isResizing) { const dx = (e.clientX - resizeStart.x) / appState.viewport.zoom; const dy = (e.clientY - resizeStart.y) / appState.viewport.zoom; @@ -147,7 +162,7 @@ if (tag === 'INPUT' || tag === 'TEXTAREA') return; if ((e.target as HTMLElement)?.isContentEditable) return; if (e.key === 'Delete' || e.key === 'Backspace') { - appState.removeItem(item.id); + appState.removeSelected(); } } diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index d0b332e..fdab407 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -72,11 +72,11 @@ function createState() { }); let assets = $state({}); let viewport = $state({ x: 0, y: 0, zoom: 1 }); - let selectedId = $state(null); + let selectedIds = $state>(new Set()); let editingId = $state(null); let editingGlobal = $state(false); let focusedId = $state(null); - let clipboard = $state | null>(null); + let clipboard = $state[]>([]); let saveTimeout: ReturnType | null = null; let animationId: number | null = null; @@ -153,7 +153,7 @@ function createState() { delete assets[item.assetId]; } manifest.items.splice(idx, 1); - if (selectedId === id) selectedId = null; + selectedIds.delete(id); if (editingId === id) editingId = null; save(); } @@ -168,7 +168,21 @@ function createState() { } function select(id: string | null) { - selectedId = id; + selectedIds = new Set(id ? [id] : []); + } + + function toggleSelection(id: string) { + const newSet = new Set(selectedIds); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + selectedIds = newSet; + } + + function clearSelection() { + selectedIds = new Set(); } function edit(id: string | null) { @@ -202,24 +216,48 @@ function createState() { save(); } - function copyItem(id: string) { - const item = manifest.items.find((i) => i.id === id); - if (!item) return; - const { id: _, ...rest } = item; - clipboard = rest; + function copySelected() { + if (selectedIds.size === 0) return; + const items = manifest.items.filter((i) => selectedIds.has(i.id)); + clipboard = items.map(({ id, ...rest }) => rest); } - function pasteItem(x: number, y: number): string | null { - if (!clipboard) return null; - const id = crypto.randomUUID(); - const newItem = { ...clipboard, id, x, y, zIndex: maxZIndex + 1 }; - addItem(newItem); - return id; + function pasteItems(x: number, y: number): string[] { + if (clipboard.length === 0) return []; + const newIds: string[] = []; + const centerX = clipboard.reduce((sum, i) => sum + i.x, 0) / clipboard.length; + const centerY = clipboard.reduce((sum, i) => sum + i.y, 0) / clipboard.length; + for (const item of clipboard) { + const id = crypto.randomUUID(); + const offsetX = item.x - centerX; + const offsetY = item.y - centerY; + addItem({ ...item, id, x: x + offsetX, y: y + offsetY, zIndex: maxZIndex + 1 }); + newIds.push(id); + } + return newIds; } - function duplicateItem(id: string, x: number, y: number): string | null { - copyItem(id); - return pasteItem(x, y); + function duplicateSelected(x: number, y: number): string[] { + copySelected(); + return pasteItems(x, y); + } + + function removeSelected() { + const ids = [...selectedIds]; + for (const id of ids) { + removeItem(id); + } + } + + function moveSelected(dx: number, dy: number) { + for (const id of selectedIds) { + const item = manifest.items.find((i) => i.id === id); + if (item) { + item.x += dx; + item.y += dy; + } + } + save(); } function sendBackward(id: string) { @@ -324,7 +362,7 @@ function createState() { }; assets = {}; viewport = { x: 0, y: 0, zoom: 1 }; - selectedId = null; + selectedIds = new Set(); editingId = null; editingGlobal = false; focusedId = null; @@ -336,7 +374,7 @@ function createState() { manifest = newManifest; assets = newAssets; viewport = { x: 0, y: 0, zoom: 1 }; - selectedId = null; + selectedIds = new Set(); editingId = null; editingGlobal = false; focusedId = null; @@ -355,8 +393,8 @@ function createState() { get viewport() { return viewport; }, - get selectedId() { - return selectedId; + get selectedIds() { + return selectedIds; }, get editingId() { return editingId; @@ -376,15 +414,19 @@ function createState() { addAsset, getItem, select, + toggleSelection, + clearSelection, edit, editGlobal, focus, updateSharedCss, updateAppCss, bringToFront, - copyItem, - pasteItem, - duplicateItem, + copySelected, + pasteItems, + duplicateSelected, + removeSelected, + moveSelected, sendBackward, bringForward, pan,