Multi-selection

This commit is contained in:
2025-12-03 11:09:02 +01:00
parent 0a93942771
commit 9e05868b4a
3 changed files with 122 additions and 53 deletions

View File

@@ -30,7 +30,7 @@
function handleMouseUp(e: MouseEvent) { function handleMouseUp(e: MouseEvent) {
if (isPanning && !hasPanned && e.target === container) { if (isPanning && !hasPanned && e.target === container) {
state.select(null); state.clearSelection();
state.focus(null); state.focus(null);
} }
isPanning = false; isPanning = false;
@@ -142,38 +142,50 @@
const centerX = (rect.width / 2 - state.viewport.x) / state.viewport.zoom; const centerX = (rect.width / 2 - state.viewport.x) / state.viewport.zoom;
const centerY = (rect.height / 2 - state.viewport.y) / 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(); e.preventDefault();
state.copyItem(state.selectedId); state.copySelected();
} else if (mod && e.key === 'v') { } else if (mod && e.key === 'v') {
e.preventDefault(); e.preventDefault();
const newId = state.pasteItem(centerX, centerY); const newIds = state.pasteItems(centerX, centerY);
if (newId) state.select(newId); if (newIds.length > 0) state.select(newIds[0]);
} else if (mod && e.key === 'd' && state.selectedId) { for (let i = 1; i < newIds.length; i++) {
state.toggleSelection(newIds[i]);
}
} else if (mod && e.key === 'd' && hasSelection) {
e.preventDefault(); e.preventDefault();
const newId = state.duplicateItem(state.selectedId, centerX, centerY); const newIds = state.duplicateSelected(centerX, centerY);
if (newId) state.select(newId); 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') { } else if (e.key === 'Escape') {
state.select(null); state.clearSelection();
state.focus(null); state.focus(null);
} else if (e.key === '[' && state.selectedId) { } else if (e.key === '[' && hasSelection) {
e.preventDefault(); e.preventDefault();
state.sendBackward(state.selectedId); for (const id of state.selectedIds) {
} else if (e.key === ']' && state.selectedId) { state.sendBackward(id);
}
} else if (e.key === ']' && hasSelection) {
e.preventDefault(); e.preventDefault();
state.bringForward(state.selectedId); for (const id of state.selectedIds) {
} else if (e.key.startsWith('Arrow') && state.selectedId) { state.bringForward(id);
}
} else if (e.key.startsWith('Arrow') && hasSelection) {
e.preventDefault(); e.preventDefault();
const step = e.shiftKey ? 10 : 1; 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 dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? 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 }); state.moveSelected(dx, dy);
} else if (e.key === 'e' && state.selectedId && !mod) { } else if (e.key === 'e' && singleSelection && !mod) {
e.preventDefault(); e.preventDefault();
state.focus(state.selectedId); state.focus(firstSelectedId!);
state.edit(state.selectedId); state.edit(firstSelectedId!);
} }
} }
</script> </script>

View File

@@ -5,12 +5,13 @@
let { item }: { item: Item } = $props(); 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 isFocused = $derived(appState.focusedId === item.id);
let isDragging = $state(false); let isDragging = $state(false);
let isResizing = $state(false); let isResizing = $state(false);
let isRotating = $state(false); let isRotating = $state(false);
let dragStart = { x: 0, y: 0, itemX: 0, itemY: 0 }; let dragStart = { x: 0, y: 0, itemX: 0, itemY: 0 };
let dragStartPositions: Map<string, { x: number; y: number }> = new Map();
let resizeStart = { x: 0, y: 0, width: 0, height: 0, itemX: 0, itemY: 0, corner: '', aspectRatio: 1 }; let resizeStart = { x: 0, y: 0, width: 0, height: 0, itemX: 0, itemY: 0, corner: '', aspectRatio: 1 };
let rotateStart = { angle: 0, startAngle: 0 }; let rotateStart = { angle: 0, startAngle: 0 };
@@ -39,8 +40,13 @@
if (e.button !== 0) return; if (e.button !== 0) return;
if (isFocused) return; if (isFocused) return;
e.stopPropagation(); e.stopPropagation();
if (e.shiftKey) {
appState.toggleSelection(item.id);
} else if (!isSelected) {
appState.select(item.id); appState.select(item.id);
appState.bringToFront(item.id); appState.bringToFront(item.id);
}
isDragging = true; isDragging = true;
dragStart = { dragStart = {
@@ -49,6 +55,13 @@
itemX: item.x, itemX: item.x,
itemY: item.y 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) { function handleResizeStart(e: MouseEvent, corner: string) {
@@ -84,10 +97,12 @@
if (isDragging) { if (isDragging) {
const dx = (e.clientX - dragStart.x) / appState.viewport.zoom; const dx = (e.clientX - dragStart.x) / appState.viewport.zoom;
const dy = (e.clientY - dragStart.y) / appState.viewport.zoom; const dy = (e.clientY - dragStart.y) / appState.viewport.zoom;
appState.updateItem(item.id, { for (const [id, startPos] of dragStartPositions) {
x: dragStart.itemX + dx, appState.updateItem(id, {
y: dragStart.itemY + dy x: startPos.x + dx,
y: startPos.y + dy
}); });
}
} else if (isResizing) { } else if (isResizing) {
const dx = (e.clientX - resizeStart.x) / appState.viewport.zoom; const dx = (e.clientX - resizeStart.x) / appState.viewport.zoom;
const dy = (e.clientY - resizeStart.y) / appState.viewport.zoom; const dy = (e.clientY - resizeStart.y) / appState.viewport.zoom;
@@ -147,7 +162,7 @@
if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if ((e.target as HTMLElement)?.isContentEditable) return; if ((e.target as HTMLElement)?.isContentEditable) return;
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
appState.removeItem(item.id); appState.removeSelected();
} }
} }

View File

@@ -72,11 +72,11 @@ function createState() {
}); });
let assets = $state<AssetStore>({}); let assets = $state<AssetStore>({});
let viewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 }); let viewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 });
let selectedId = $state<string | null>(null); let selectedIds = $state<Set<string>>(new Set());
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
let editingGlobal = $state<boolean>(false); let editingGlobal = $state<boolean>(false);
let focusedId = $state<string | null>(null); let focusedId = $state<string | null>(null);
let clipboard = $state<Omit<Item, 'id'> | null>(null); let clipboard = $state<Omit<Item, 'id'>[]>([]);
let saveTimeout: ReturnType<typeof setTimeout> | null = null; let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let animationId: number | null = null; let animationId: number | null = null;
@@ -153,7 +153,7 @@ function createState() {
delete assets[item.assetId]; delete assets[item.assetId];
} }
manifest.items.splice(idx, 1); manifest.items.splice(idx, 1);
if (selectedId === id) selectedId = null; selectedIds.delete(id);
if (editingId === id) editingId = null; if (editingId === id) editingId = null;
save(); save();
} }
@@ -168,7 +168,21 @@ function createState() {
} }
function select(id: string | null) { 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) { function edit(id: string | null) {
@@ -202,24 +216,48 @@ function createState() {
save(); save();
} }
function copyItem(id: string) { function copySelected() {
const item = manifest.items.find((i) => i.id === id); if (selectedIds.size === 0) return;
if (!item) return; const items = manifest.items.filter((i) => selectedIds.has(i.id));
const { id: _, ...rest } = item; clipboard = items.map(({ id, ...rest }) => rest);
clipboard = rest;
} }
function pasteItem(x: number, y: number): string | null { function pasteItems(x: number, y: number): string[] {
if (!clipboard) return null; 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 id = crypto.randomUUID();
const newItem = { ...clipboard, id, x, y, zIndex: maxZIndex + 1 }; const offsetX = item.x - centerX;
addItem(newItem); const offsetY = item.y - centerY;
return id; 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 { function duplicateSelected(x: number, y: number): string[] {
copyItem(id); copySelected();
return pasteItem(x, y); 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) { function sendBackward(id: string) {
@@ -324,7 +362,7 @@ function createState() {
}; };
assets = {}; assets = {};
viewport = { x: 0, y: 0, zoom: 1 }; viewport = { x: 0, y: 0, zoom: 1 };
selectedId = null; selectedIds = new Set();
editingId = null; editingId = null;
editingGlobal = false; editingGlobal = false;
focusedId = null; focusedId = null;
@@ -336,7 +374,7 @@ function createState() {
manifest = newManifest; manifest = newManifest;
assets = newAssets; assets = newAssets;
viewport = { x: 0, y: 0, zoom: 1 }; viewport = { x: 0, y: 0, zoom: 1 };
selectedId = null; selectedIds = new Set();
editingId = null; editingId = null;
editingGlobal = false; editingGlobal = false;
focusedId = null; focusedId = null;
@@ -355,8 +393,8 @@ function createState() {
get viewport() { get viewport() {
return viewport; return viewport;
}, },
get selectedId() { get selectedIds() {
return selectedId; return selectedIds;
}, },
get editingId() { get editingId() {
return editingId; return editingId;
@@ -376,15 +414,19 @@ function createState() {
addAsset, addAsset,
getItem, getItem,
select, select,
toggleSelection,
clearSelection,
edit, edit,
editGlobal, editGlobal,
focus, focus,
updateSharedCss, updateSharedCss,
updateAppCss, updateAppCss,
bringToFront, bringToFront,
copyItem, copySelected,
pasteItem, pasteItems,
duplicateItem, duplicateSelected,
removeSelected,
moveSelected,
sendBackward, sendBackward,
bringForward, bringForward,
pan, pan,