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) {
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!);
}
}
</script>

View File

@@ -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<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 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();
}
}

View File

@@ -72,11 +72,11 @@ function createState() {
});
let assets = $state<AssetStore>({});
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 editingGlobal = $state<boolean>(false);
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 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,