phase 6
This commit is contained in:
203
frontend/src/lib/canvas/Image.svelte
Normal file
203
frontend/src/lib/canvas/Image.svelte
Normal file
@@ -0,0 +1,203 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Konva Image wrapper component for canvas images
|
||||
* Wraps a Konva.Image with selection, dragging, and transformation support
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Konva from 'konva';
|
||||
import { isImageSelected } from '$lib/stores/selection';
|
||||
import { setupImageDrag } from './interactions/drag';
|
||||
import { setupImageSelection } from './interactions/select';
|
||||
|
||||
// Props
|
||||
export let id: string; // Board image ID
|
||||
export let imageUrl: string;
|
||||
export let x: number = 0;
|
||||
export let y: number = 0;
|
||||
export let width: number = 100;
|
||||
export let height: number = 100;
|
||||
export let rotation: number = 0;
|
||||
export let scaleX: number = 1;
|
||||
export let scaleY: number = 1;
|
||||
export let opacity: number = 1;
|
||||
export let layer: Konva.Layer | null = null;
|
||||
export let zOrder: number = 0;
|
||||
|
||||
// Callbacks
|
||||
export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined;
|
||||
export let onSelectionChange: ((id: string, isSelected: boolean) => void) | undefined = undefined;
|
||||
|
||||
let imageNode: Konva.Image | null = null;
|
||||
let imageGroup: Konva.Group | null = null;
|
||||
let imageObj: HTMLImageElement | null = null;
|
||||
let cleanupDrag: (() => void) | null = null;
|
||||
let cleanupSelection: (() => void) | null = null;
|
||||
let unsubscribeSelection: (() => void) | null = null;
|
||||
|
||||
// Subscribe to selection state for this image
|
||||
$: isSelected = isImageSelected(id);
|
||||
|
||||
onMount(() => {
|
||||
if (!layer) return;
|
||||
|
||||
// Load image
|
||||
imageObj = new Image();
|
||||
imageObj.crossOrigin = 'Anonymous';
|
||||
imageObj.onload = () => {
|
||||
if (!layer || !imageObj) return;
|
||||
|
||||
// Create Konva image
|
||||
imageNode = new Konva.Image({
|
||||
image: imageObj!,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
listening: true,
|
||||
});
|
||||
|
||||
// Create group for image and selection box
|
||||
imageGroup = new Konva.Group({
|
||||
x,
|
||||
y,
|
||||
rotation,
|
||||
scaleX,
|
||||
scaleY,
|
||||
opacity,
|
||||
draggable: true,
|
||||
id: `image-group-${id}`,
|
||||
});
|
||||
|
||||
imageGroup.add(imageNode);
|
||||
|
||||
// Set Z-index
|
||||
imageGroup.zIndex(zOrder);
|
||||
|
||||
layer.add(imageGroup);
|
||||
|
||||
// Setup interactions
|
||||
cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => {
|
||||
if (onDragEnd) {
|
||||
onDragEnd(imageId, newX, newY);
|
||||
}
|
||||
});
|
||||
|
||||
cleanupSelection = setupImageSelection(imageGroup, id, undefined, (imageId, _selected) => {
|
||||
updateSelectionVisual();
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(imageId, _selected);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to selection changes for visual updates
|
||||
unsubscribeSelection = isSelected.subscribe((_selected) => {
|
||||
updateSelectionVisual();
|
||||
});
|
||||
|
||||
layer.batchDraw();
|
||||
};
|
||||
|
||||
imageObj.src = imageUrl;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Clean up event listeners
|
||||
if (cleanupDrag) cleanupDrag();
|
||||
if (cleanupSelection) cleanupSelection();
|
||||
if (unsubscribeSelection) unsubscribeSelection();
|
||||
|
||||
// Destroy Konva nodes
|
||||
if (imageNode) imageNode.destroy();
|
||||
if (imageGroup) imageGroup.destroy();
|
||||
|
||||
// Redraw layer
|
||||
if (layer) layer.batchDraw();
|
||||
});
|
||||
|
||||
/**
|
||||
* Update selection visual (highlight border)
|
||||
*/
|
||||
function updateSelectionVisual() {
|
||||
if (!imageGroup || !$isSelected) return;
|
||||
|
||||
// Remove existing selection box
|
||||
const existingBox = imageGroup.findOne('.selection-box');
|
||||
if (existingBox) existingBox.destroy();
|
||||
|
||||
if ($isSelected && imageNode) {
|
||||
// Add selection box
|
||||
const selectionBox = new Konva.Rect({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: imageNode.width(),
|
||||
height: imageNode.height(),
|
||||
stroke: '#3b82f6',
|
||||
strokeWidth: 2,
|
||||
listening: false,
|
||||
name: 'selection-box',
|
||||
});
|
||||
|
||||
imageGroup.add(selectionBox);
|
||||
}
|
||||
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update image position
|
||||
*/
|
||||
$: if (imageGroup && (imageGroup.x() !== x || imageGroup.y() !== y)) {
|
||||
imageGroup.position({ x, y });
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update image transformations
|
||||
*/
|
||||
$: if (imageGroup) {
|
||||
if (imageGroup.rotation() !== rotation) {
|
||||
imageGroup.rotation(rotation);
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
if (imageGroup.scaleX() !== scaleX || imageGroup.scaleY() !== scaleY) {
|
||||
imageGroup.scale({ x: scaleX, y: scaleY });
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
if (imageGroup.opacity() !== opacity) {
|
||||
imageGroup.opacity(opacity);
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
if (imageGroup.zIndex() !== zOrder) {
|
||||
imageGroup.zIndex(zOrder);
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update image dimensions
|
||||
*/
|
||||
$: if (imageNode && (imageNode.width() !== width || imageNode.height() !== height)) {
|
||||
imageNode.size({ width, height });
|
||||
updateSelectionVisual();
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose image group for external manipulation
|
||||
*/
|
||||
export function getImageGroup(): Konva.Group | null {
|
||||
return imageGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose image node for external manipulation
|
||||
*/
|
||||
export function getImageNode(): Konva.Image | null {
|
||||
return imageNode;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- This component doesn't render any DOM, it only manages Konva nodes -->
|
||||
179
frontend/src/lib/canvas/SelectionBox.svelte
Normal file
179
frontend/src/lib/canvas/SelectionBox.svelte
Normal file
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Selection box visual indicator for canvas
|
||||
* Displays a border and resize handles around selected images
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Konva from 'konva';
|
||||
import { selection, selectionCount } from '$lib/stores/selection';
|
||||
|
||||
export let layer: Konva.Layer | null = null;
|
||||
export let getImageBounds: (
|
||||
id: string
|
||||
) => { x: number; y: number; width: number; height: number } | null;
|
||||
|
||||
let selectionGroup: Konva.Group | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (!layer) return;
|
||||
|
||||
// Create group for selection visuals
|
||||
selectionGroup = new Konva.Group({
|
||||
listening: false,
|
||||
name: 'selection-group',
|
||||
});
|
||||
|
||||
layer.add(selectionGroup);
|
||||
|
||||
// Subscribe to selection changes
|
||||
unsubscribe = selection.subscribe(() => {
|
||||
updateSelectionVisuals();
|
||||
});
|
||||
|
||||
layer.batchDraw();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
if (selectionGroup) {
|
||||
selectionGroup.destroy();
|
||||
selectionGroup = null;
|
||||
}
|
||||
if (layer) layer.batchDraw();
|
||||
});
|
||||
|
||||
/**
|
||||
* Update selection visual indicators
|
||||
*/
|
||||
function updateSelectionVisuals() {
|
||||
if (!selectionGroup || !layer) return;
|
||||
|
||||
// Clear existing visuals
|
||||
selectionGroup.destroyChildren();
|
||||
|
||||
const selectedIds = selection.getSelectedIds();
|
||||
if (selectedIds.length === 0) {
|
||||
layer.batchDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate bounding box of all selected images
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const bounds = getImageBounds(id);
|
||||
if (bounds) {
|
||||
minX = Math.min(minX, bounds.x);
|
||||
minY = Math.min(minY, bounds.y);
|
||||
maxX = Math.max(maxX, bounds.x + bounds.width);
|
||||
maxY = Math.max(maxY, bounds.y + bounds.height);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isFinite(minX) || !isFinite(minY)) {
|
||||
layer.batchDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
// Draw selection border
|
||||
const border = new Konva.Rect({
|
||||
x: minX,
|
||||
y: minY,
|
||||
width,
|
||||
height,
|
||||
stroke: '#3b82f6',
|
||||
strokeWidth: 2,
|
||||
dash: [8, 4],
|
||||
listening: false,
|
||||
});
|
||||
|
||||
selectionGroup.add(border);
|
||||
|
||||
// Draw resize handles if single selection
|
||||
if ($selectionCount === 1) {
|
||||
const handleSize = 8;
|
||||
const handlePositions = [
|
||||
{ x: minX, y: minY, cursor: 'nw-resize' }, // Top-left
|
||||
{ x: minX + width / 2, y: minY, cursor: 'n-resize' }, // Top-center
|
||||
{ x: maxX, y: minY, cursor: 'ne-resize' }, // Top-right
|
||||
{ x: maxX, y: minY + height / 2, cursor: 'e-resize' }, // Right-center
|
||||
{ x: maxX, y: maxY, cursor: 'se-resize' }, // Bottom-right
|
||||
{ x: minX + width / 2, y: maxY, cursor: 's-resize' }, // Bottom-center
|
||||
{ x: minX, y: maxY, cursor: 'sw-resize' }, // Bottom-left
|
||||
{ x: minX, y: minY + height / 2, cursor: 'w-resize' }, // Left-center
|
||||
];
|
||||
|
||||
handlePositions.forEach((pos) => {
|
||||
const handle = new Konva.Rect({
|
||||
x: pos.x - handleSize / 2,
|
||||
y: pos.y - handleSize / 2,
|
||||
width: handleSize,
|
||||
height: handleSize,
|
||||
fill: '#3b82f6',
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: 1,
|
||||
listening: false,
|
||||
});
|
||||
|
||||
selectionGroup!.add(handle);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw selection count badge if multiple selection
|
||||
if ($selectionCount > 1) {
|
||||
const badgeX = maxX - 30;
|
||||
const badgeY = minY - 30;
|
||||
|
||||
const badge = new Konva.Group({
|
||||
x: badgeX,
|
||||
y: badgeY,
|
||||
listening: false,
|
||||
});
|
||||
|
||||
const badgeBackground = new Konva.Rect({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 30,
|
||||
height: 24,
|
||||
fill: '#3b82f6',
|
||||
cornerRadius: 4,
|
||||
listening: false,
|
||||
});
|
||||
|
||||
const badgeText = new Konva.Text({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 30,
|
||||
height: 24,
|
||||
text: $selectionCount.toString(),
|
||||
fontSize: 14,
|
||||
fill: '#ffffff',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
listening: false,
|
||||
});
|
||||
|
||||
badge.add(badgeBackground);
|
||||
badge.add(badgeText);
|
||||
selectionGroup!.add(badge);
|
||||
}
|
||||
|
||||
layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force update of selection visuals (for external calls)
|
||||
*/
|
||||
export function update() {
|
||||
updateSelectionVisuals();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- This component doesn't render any DOM, it only manages Konva nodes -->
|
||||
184
frontend/src/lib/canvas/interactions/drag.ts
Normal file
184
frontend/src/lib/canvas/interactions/drag.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Image dragging interactions for canvas
|
||||
* Handles dragging images to reposition them
|
||||
*/
|
||||
|
||||
import Konva from 'konva';
|
||||
import { selection } from '$lib/stores/selection';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export interface DragState {
|
||||
isDragging: boolean;
|
||||
startPos: { x: number; y: number } | null;
|
||||
draggedImageId: string | null;
|
||||
}
|
||||
|
||||
const dragState: DragState = {
|
||||
isDragging: false,
|
||||
startPos: null,
|
||||
draggedImageId: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup drag interactions for an image
|
||||
*/
|
||||
export function setupImageDrag(
|
||||
image: Konva.Image | Konva.Group,
|
||||
imageId: string,
|
||||
onDragMove?: (imageId: string, x: number, y: number) => void,
|
||||
onDragEnd?: (imageId: string, x: number, y: number) => void
|
||||
): () => void {
|
||||
/**
|
||||
* Handle drag start
|
||||
*/
|
||||
function handleDragStart(e: Konva.KonvaEventObject<DragEvent>) {
|
||||
dragState.isDragging = true;
|
||||
dragState.startPos = { x: image.x(), y: image.y() };
|
||||
dragState.draggedImageId = imageId;
|
||||
|
||||
// If dragged image is not selected, select it
|
||||
const selectionState = get(selection);
|
||||
if (!selectionState.selectedIds.has(imageId)) {
|
||||
// Check if Ctrl/Cmd key is pressed
|
||||
if (e.evt.ctrlKey || e.evt.metaKey) {
|
||||
selection.addToSelection(imageId);
|
||||
} else {
|
||||
selection.selectOne(imageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Set dragging cursor
|
||||
const stage = image.getStage();
|
||||
if (stage) {
|
||||
stage.container().style.cursor = 'grabbing';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag move
|
||||
*/
|
||||
function handleDragMove(_e: Konva.KonvaEventObject<DragEvent>) {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const x = image.x();
|
||||
const y = image.y();
|
||||
|
||||
// Call callback if provided
|
||||
if (onDragMove) {
|
||||
onDragMove(imageId, x, y);
|
||||
}
|
||||
|
||||
// If multiple images are selected, move them together
|
||||
const selectionState = get(selection);
|
||||
if (selectionState.selectedIds.size > 1 && dragState.startPos) {
|
||||
const deltaX = x - dragState.startPos.x;
|
||||
const deltaY = y - dragState.startPos.y;
|
||||
|
||||
// Update start position for next delta calculation
|
||||
dragState.startPos = { x, y };
|
||||
|
||||
// Dispatch custom event to move other selected images
|
||||
const stage = image.getStage();
|
||||
if (stage) {
|
||||
stage.fire('multiDragMove', {
|
||||
draggedImageId: imageId,
|
||||
deltaX,
|
||||
deltaY,
|
||||
selectedIds: Array.from(selectionState.selectedIds),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag end
|
||||
*/
|
||||
function handleDragEnd(_e: Konva.KonvaEventObject<DragEvent>) {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const x = image.x();
|
||||
const y = image.y();
|
||||
|
||||
// Call callback if provided
|
||||
if (onDragEnd) {
|
||||
onDragEnd(imageId, x, y);
|
||||
}
|
||||
|
||||
// Reset drag state
|
||||
dragState.isDragging = false;
|
||||
dragState.startPos = null;
|
||||
dragState.draggedImageId = null;
|
||||
|
||||
// Reset cursor
|
||||
const stage = image.getStage();
|
||||
if (stage) {
|
||||
stage.container().style.cursor = 'default';
|
||||
}
|
||||
}
|
||||
|
||||
// Enable dragging
|
||||
image.draggable(true);
|
||||
|
||||
// Attach event listeners
|
||||
image.on('dragstart', handleDragStart);
|
||||
image.on('dragmove', handleDragMove);
|
||||
image.on('dragend', handleDragEnd);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
image.off('dragstart', handleDragStart);
|
||||
image.off('dragmove', handleDragMove);
|
||||
image.off('dragend', handleDragEnd);
|
||||
image.draggable(false);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move image to specific position (programmatic)
|
||||
*/
|
||||
export function moveImageTo(
|
||||
image: Konva.Image | Konva.Group,
|
||||
x: number,
|
||||
y: number,
|
||||
animate: boolean = false
|
||||
): void {
|
||||
if (animate) {
|
||||
// TODO: Add animation support using Konva.Tween
|
||||
image.to({
|
||||
x,
|
||||
y,
|
||||
duration: 0.3,
|
||||
easing: Konva.Easings.EaseOut,
|
||||
});
|
||||
} else {
|
||||
image.position({ x, y });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move image by delta (programmatic)
|
||||
*/
|
||||
export function moveImageBy(
|
||||
image: Konva.Image | Konva.Group,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
animate: boolean = false
|
||||
): void {
|
||||
const currentX = image.x();
|
||||
const currentY = image.y();
|
||||
moveImageTo(image, currentX + deltaX, currentY + deltaY, animate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current drag state (useful for debugging)
|
||||
*/
|
||||
export function getDragState(): DragState {
|
||||
return { ...dragState };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently dragging
|
||||
*/
|
||||
export function isDragging(): boolean {
|
||||
return dragState.isDragging;
|
||||
}
|
||||
234
frontend/src/lib/canvas/interactions/multiselect.ts
Normal file
234
frontend/src/lib/canvas/interactions/multiselect.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Rectangle selection (drag-to-select multiple images)
|
||||
* Allows selecting multiple images by dragging a selection rectangle
|
||||
*/
|
||||
|
||||
import Konva from 'konva';
|
||||
import { selection } from '$lib/stores/selection';
|
||||
|
||||
export interface SelectionRectangle {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
export interface MultiSelectState {
|
||||
isSelecting: boolean;
|
||||
startPos: { x: number; y: number } | null;
|
||||
currentRect: SelectionRectangle | null;
|
||||
}
|
||||
|
||||
const multiSelectState: MultiSelectState = {
|
||||
isSelecting: false,
|
||||
startPos: null,
|
||||
currentRect: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup rectangle selection on stage
|
||||
*/
|
||||
export function setupRectangleSelection(
|
||||
stage: Konva.Stage,
|
||||
layer: Konva.Layer,
|
||||
getImageBounds: () => Array<{
|
||||
id: string;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}>,
|
||||
onSelectionChange?: (selectedIds: string[]) => void
|
||||
): () => void {
|
||||
let selectionRect: Konva.Rect | null = null;
|
||||
|
||||
/**
|
||||
* Handle mouse/touch down to start selection
|
||||
*/
|
||||
function handleMouseDown(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
|
||||
// Only start rectangle selection if clicking on stage background
|
||||
if (e.target !== stage) return;
|
||||
|
||||
// Only if not pressing Ctrl (that's for pan)
|
||||
const isModifierPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
|
||||
if (isModifierPressed) return;
|
||||
|
||||
const pos = stage.getPointerPosition();
|
||||
if (!pos) return;
|
||||
|
||||
// Transform pointer position to account for stage transformations
|
||||
const transform = stage.getAbsoluteTransform().copy().invert();
|
||||
const localPos = transform.point(pos);
|
||||
|
||||
multiSelectState.isSelecting = true;
|
||||
multiSelectState.startPos = localPos;
|
||||
multiSelectState.currentRect = {
|
||||
x1: localPos.x,
|
||||
y1: localPos.y,
|
||||
x2: localPos.x,
|
||||
y2: localPos.y,
|
||||
};
|
||||
|
||||
// Create visual selection rectangle
|
||||
selectionRect = new Konva.Rect({
|
||||
x: localPos.x,
|
||||
y: localPos.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fill: 'rgba(0, 120, 255, 0.1)',
|
||||
stroke: 'rgba(0, 120, 255, 0.8)',
|
||||
strokeWidth: 1 / stage.scaleX(), // Adjust for zoom
|
||||
listening: false,
|
||||
});
|
||||
|
||||
layer.add(selectionRect);
|
||||
layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse/touch move to update selection rectangle
|
||||
*/
|
||||
function handleMouseMove(_e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
|
||||
if (!multiSelectState.isSelecting || !multiSelectState.startPos || !selectionRect) return;
|
||||
|
||||
const pos = stage.getPointerPosition();
|
||||
if (!pos) return;
|
||||
|
||||
// Transform pointer position
|
||||
const transform = stage.getAbsoluteTransform().copy().invert();
|
||||
const localPos = transform.point(pos);
|
||||
|
||||
multiSelectState.currentRect = {
|
||||
x1: multiSelectState.startPos.x,
|
||||
y1: multiSelectState.startPos.y,
|
||||
x2: localPos.x,
|
||||
y2: localPos.y,
|
||||
};
|
||||
|
||||
// Update visual rectangle
|
||||
const x = Math.min(multiSelectState.startPos.x, localPos.x);
|
||||
const y = Math.min(multiSelectState.startPos.y, localPos.y);
|
||||
const width = Math.abs(localPos.x - multiSelectState.startPos.x);
|
||||
const height = Math.abs(localPos.y - multiSelectState.startPos.y);
|
||||
|
||||
selectionRect.x(x);
|
||||
selectionRect.y(y);
|
||||
selectionRect.width(width);
|
||||
selectionRect.height(height);
|
||||
|
||||
layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse/touch up to complete selection
|
||||
*/
|
||||
function handleMouseUp(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
|
||||
if (!multiSelectState.isSelecting || !multiSelectState.currentRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all images that intersect with selection rectangle
|
||||
const selectedIds = getImagesInRectangle(multiSelectState.currentRect, getImageBounds());
|
||||
|
||||
// Check if Ctrl/Cmd is pressed for additive selection
|
||||
const isModifierPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
|
||||
|
||||
if (isModifierPressed && selectedIds.length > 0) {
|
||||
// Add to existing selection
|
||||
selection.addMultipleToSelection(selectedIds);
|
||||
} else if (selectedIds.length > 0) {
|
||||
// Replace selection
|
||||
selection.selectMultiple(selectedIds);
|
||||
} else {
|
||||
// Empty selection - clear
|
||||
selection.clearSelection();
|
||||
}
|
||||
|
||||
// Call callback
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(selectedIds);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (selectionRect) {
|
||||
selectionRect.destroy();
|
||||
selectionRect = null;
|
||||
layer.batchDraw();
|
||||
}
|
||||
|
||||
multiSelectState.isSelecting = false;
|
||||
multiSelectState.startPos = null;
|
||||
multiSelectState.currentRect = null;
|
||||
}
|
||||
|
||||
// Attach event listeners
|
||||
stage.on('mousedown touchstart', handleMouseDown);
|
||||
stage.on('mousemove touchmove', handleMouseMove);
|
||||
stage.on('mouseup touchend', handleMouseUp);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
stage.off('mousedown touchstart', handleMouseDown);
|
||||
stage.off('mousemove touchmove', handleMouseMove);
|
||||
stage.off('mouseup touchend', handleMouseUp);
|
||||
|
||||
if (selectionRect) {
|
||||
selectionRect.destroy();
|
||||
selectionRect = null;
|
||||
}
|
||||
|
||||
multiSelectState.isSelecting = false;
|
||||
multiSelectState.startPos = null;
|
||||
multiSelectState.currentRect = null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get images that intersect with selection rectangle
|
||||
*/
|
||||
function getImagesInRectangle(
|
||||
rect: SelectionRectangle,
|
||||
imageBounds: Array<{
|
||||
id: string;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}>
|
||||
): string[] {
|
||||
const x1 = Math.min(rect.x1, rect.x2);
|
||||
const y1 = Math.min(rect.y1, rect.y2);
|
||||
const x2 = Math.max(rect.x1, rect.x2);
|
||||
const y2 = Math.max(rect.y1, rect.y2);
|
||||
|
||||
return imageBounds
|
||||
.filter((item) => {
|
||||
const { x, y, width, height } = item.bounds;
|
||||
|
||||
// Check if rectangles intersect
|
||||
return !(x + width < x1 || x > x2 || y + height < y1 || y > y2);
|
||||
})
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently in rectangle selection mode
|
||||
*/
|
||||
export function isRectangleSelecting(): boolean {
|
||||
return multiSelectState.isSelecting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current selection rectangle
|
||||
*/
|
||||
export function getCurrentSelectionRect(): SelectionRectangle | null {
|
||||
return multiSelectState.currentRect ? { ...multiSelectState.currentRect } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel ongoing rectangle selection
|
||||
*/
|
||||
export function cancelRectangleSelection(layer: Konva.Layer): void {
|
||||
multiSelectState.isSelecting = false;
|
||||
multiSelectState.startPos = null;
|
||||
multiSelectState.currentRect = null;
|
||||
|
||||
// Remove any active selection rectangle
|
||||
const rects = layer.find('.selection-rect');
|
||||
rects.forEach((rect) => rect.destroy());
|
||||
layer.batchDraw();
|
||||
}
|
||||
157
frontend/src/lib/canvas/interactions/select.ts
Normal file
157
frontend/src/lib/canvas/interactions/select.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Click selection interactions for canvas
|
||||
* Handles single and multi-select (Ctrl+Click)
|
||||
*/
|
||||
|
||||
import type Konva from 'konva';
|
||||
import { selection } from '$lib/stores/selection';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export interface SelectOptions {
|
||||
multiSelectKey?: boolean; // Enable Ctrl/Cmd+Click for multi-select
|
||||
deselectOnBackground?: boolean; // Deselect when clicking empty canvas
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: SelectOptions = {
|
||||
multiSelectKey: true,
|
||||
deselectOnBackground: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup click selection for an image
|
||||
*/
|
||||
export function setupImageSelection(
|
||||
image: Konva.Image | Konva.Group,
|
||||
imageId: string,
|
||||
options: SelectOptions = DEFAULT_OPTIONS,
|
||||
onSelectionChange?: (imageId: string, isSelected: boolean) => void
|
||||
): () => void {
|
||||
/**
|
||||
* Handle click/tap on image
|
||||
*/
|
||||
function handleClick(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
|
||||
e.cancelBubble = true; // Prevent event from reaching stage
|
||||
|
||||
const isMultiSelectPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
|
||||
|
||||
const selectionState = get(selection);
|
||||
const isCurrentlySelected = selectionState.selectedIds.has(imageId);
|
||||
|
||||
if (options.multiSelectKey && isMultiSelectPressed) {
|
||||
// Multi-select mode (Ctrl+Click)
|
||||
if (isCurrentlySelected) {
|
||||
selection.removeFromSelection(imageId);
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(imageId, false);
|
||||
}
|
||||
} else {
|
||||
selection.addToSelection(imageId);
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(imageId, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single select mode
|
||||
if (!isCurrentlySelected) {
|
||||
selection.selectOne(imageId);
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(imageId, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attach click/tap listener
|
||||
image.on('click tap', handleClick);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
image.off('click tap', handleClick);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup background deselection (clicking empty canvas clears selection)
|
||||
*/
|
||||
export function setupBackgroundDeselect(stage: Konva.Stage, onDeselect?: () => void): () => void {
|
||||
/**
|
||||
* Handle click on stage background
|
||||
*/
|
||||
function handleStageClick(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
|
||||
// Only deselect if clicking on the stage itself (not on any shape)
|
||||
if (e.target === stage) {
|
||||
selection.clearSelection();
|
||||
if (onDeselect) {
|
||||
onDeselect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attach listener
|
||||
stage.on('click tap', handleStageClick);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
stage.off('click tap', handleStageClick);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select image programmatically
|
||||
*/
|
||||
export function selectImage(imageId: string, multiSelect: boolean = false): void {
|
||||
if (multiSelect) {
|
||||
selection.addToSelection(imageId);
|
||||
} else {
|
||||
selection.selectOne(imageId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect image programmatically
|
||||
*/
|
||||
export function deselectImage(imageId: string): void {
|
||||
selection.removeFromSelection(imageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle image selection programmatically
|
||||
*/
|
||||
export function toggleImageSelection(imageId: string): void {
|
||||
selection.toggleSelection(imageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all images programmatically
|
||||
*/
|
||||
export function selectAllImages(allImageIds: string[]): void {
|
||||
selection.selectAll(allImageIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selection programmatically
|
||||
*/
|
||||
export function clearAllSelection(): void {
|
||||
selection.clearSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected images count
|
||||
*/
|
||||
export function getSelectedCount(): number {
|
||||
return selection.getSelectionCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get array of selected image IDs
|
||||
*/
|
||||
export function getSelectedImageIds(): string[] {
|
||||
return selection.getSelectedIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an image is selected
|
||||
*/
|
||||
export function isImageSelected(imageId: string): boolean {
|
||||
return selection.isSelected(imageId);
|
||||
}
|
||||
188
frontend/src/lib/canvas/sync.ts
Normal file
188
frontend/src/lib/canvas/sync.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Position and transformation sync with backend
|
||||
* Handles debounced persistence of image position changes
|
||||
*/
|
||||
|
||||
import { apiClient } from '$lib/api/client';
|
||||
|
||||
// Debounce timeout for position sync (ms)
|
||||
const SYNC_DEBOUNCE_MS = 500;
|
||||
|
||||
interface PendingUpdate {
|
||||
boardId: string;
|
||||
imageId: string;
|
||||
position: { x: number; y: number };
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
// Track pending updates by image ID
|
||||
const pendingUpdates = new Map<string, PendingUpdate>();
|
||||
|
||||
/**
|
||||
* Schedule position sync for an image (debounced)
|
||||
*/
|
||||
export function syncImagePosition(boardId: string, imageId: string, x: number, y: number): void {
|
||||
// Cancel existing timeout if any
|
||||
const existing = pendingUpdates.get(imageId);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timeout);
|
||||
}
|
||||
|
||||
// Schedule new sync
|
||||
const timeout = setTimeout(async () => {
|
||||
await performPositionSync(boardId, imageId, x, y);
|
||||
pendingUpdates.delete(imageId);
|
||||
}, SYNC_DEBOUNCE_MS);
|
||||
|
||||
pendingUpdates.set(imageId, {
|
||||
boardId,
|
||||
imageId,
|
||||
position: { x, y },
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform actual position sync to backend
|
||||
*/
|
||||
async function performPositionSync(
|
||||
boardId: string,
|
||||
imageId: string,
|
||||
x: number,
|
||||
y: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
|
||||
position: { x, y },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to sync image position:', error);
|
||||
// Don't throw - this is a background operation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force immediate sync of all pending updates
|
||||
*/
|
||||
export async function forceSync(): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
pendingUpdates.forEach((update) => {
|
||||
clearTimeout(update.timeout);
|
||||
promises.push(
|
||||
performPositionSync(update.boardId, update.imageId, update.position.x, update.position.y)
|
||||
);
|
||||
});
|
||||
|
||||
pendingUpdates.clear();
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force immediate sync for specific image
|
||||
*/
|
||||
export async function forceSyncImage(imageId: string): Promise<void> {
|
||||
const update = pendingUpdates.get(imageId);
|
||||
if (!update) return;
|
||||
|
||||
clearTimeout(update.timeout);
|
||||
await performPositionSync(update.boardId, update.imageId, update.position.x, update.position.y);
|
||||
pendingUpdates.delete(imageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending sync for specific image
|
||||
*/
|
||||
export function cancelSync(imageId: string): void {
|
||||
const update = pendingUpdates.get(imageId);
|
||||
if (update) {
|
||||
clearTimeout(update.timeout);
|
||||
pendingUpdates.delete(imageId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending syncs
|
||||
*/
|
||||
export function cancelAllSync(): void {
|
||||
pendingUpdates.forEach((update) => {
|
||||
clearTimeout(update.timeout);
|
||||
});
|
||||
pendingUpdates.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending syncs
|
||||
*/
|
||||
export function getPendingSyncCount(): number {
|
||||
return pendingUpdates.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image has pending sync
|
||||
*/
|
||||
export function hasPendingSync(imageId: string): boolean {
|
||||
return pendingUpdates.has(imageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync image transformations (scale, rotation, etc.)
|
||||
*/
|
||||
export async function syncImageTransformations(
|
||||
boardId: string,
|
||||
imageId: string,
|
||||
transformations: {
|
||||
scale?: number;
|
||||
rotation?: number;
|
||||
opacity?: number;
|
||||
flipped_h?: boolean;
|
||||
flipped_v?: boolean;
|
||||
greyscale?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
|
||||
transformations,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to sync image transformations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync image Z-order
|
||||
*/
|
||||
export async function syncImageZOrder(
|
||||
boardId: string,
|
||||
imageId: string,
|
||||
zOrder: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
|
||||
z_order: zOrder,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to sync image Z-order:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync image group membership
|
||||
*/
|
||||
export async function syncImageGroup(
|
||||
boardId: string,
|
||||
imageId: string,
|
||||
groupId: string | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
|
||||
group_id: groupId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to sync image group:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
200
frontend/src/lib/stores/selection.ts
Normal file
200
frontend/src/lib/stores/selection.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Selection store for canvas image selection management
|
||||
* Tracks selected images and provides selection operations
|
||||
*/
|
||||
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export interface SelectedImage {
|
||||
id: string;
|
||||
boardImageId: string; // The junction table ID
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
selectedIds: Set<string>; // Set of board_image IDs
|
||||
lastSelectedId: string | null; // For shift-click range selection
|
||||
}
|
||||
|
||||
const DEFAULT_SELECTION: SelectionState = {
|
||||
selectedIds: new Set(),
|
||||
lastSelectedId: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create selection store with operations
|
||||
*/
|
||||
function createSelectionStore() {
|
||||
const { subscribe, set, update }: Writable<SelectionState> = writable(DEFAULT_SELECTION);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
update,
|
||||
|
||||
/**
|
||||
* Select a single image (clears previous selection)
|
||||
*/
|
||||
selectOne: (id: string) => {
|
||||
update(() => ({
|
||||
selectedIds: new Set([id]),
|
||||
lastSelectedId: id,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Add image to selection (for Ctrl+Click)
|
||||
*/
|
||||
addToSelection: (id: string) => {
|
||||
update((state) => {
|
||||
const newSelectedIds = new Set(state.selectedIds);
|
||||
newSelectedIds.add(id);
|
||||
return {
|
||||
selectedIds: newSelectedIds,
|
||||
lastSelectedId: id,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove image from selection (for Ctrl+Click on selected)
|
||||
*/
|
||||
removeFromSelection: (id: string) => {
|
||||
update((state) => {
|
||||
const newSelectedIds = new Set(state.selectedIds);
|
||||
newSelectedIds.delete(id);
|
||||
return {
|
||||
selectedIds: newSelectedIds,
|
||||
lastSelectedId: state.lastSelectedId === id ? null : state.lastSelectedId,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle selection of an image
|
||||
*/
|
||||
toggleSelection: (id: string) => {
|
||||
update((state) => {
|
||||
const newSelectedIds = new Set(state.selectedIds);
|
||||
if (newSelectedIds.has(id)) {
|
||||
newSelectedIds.delete(id);
|
||||
return {
|
||||
selectedIds: newSelectedIds,
|
||||
lastSelectedId: state.lastSelectedId === id ? null : state.lastSelectedId,
|
||||
};
|
||||
} else {
|
||||
newSelectedIds.add(id);
|
||||
return {
|
||||
selectedIds: newSelectedIds,
|
||||
lastSelectedId: id,
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Select multiple images (e.g., from rectangle selection)
|
||||
*/
|
||||
selectMultiple: (ids: string[]) => {
|
||||
update((_state) => ({
|
||||
selectedIds: new Set(ids),
|
||||
lastSelectedId: ids.length > 0 ? ids[ids.length - 1] : null,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Add multiple images to selection
|
||||
*/
|
||||
addMultipleToSelection: (ids: string[]) => {
|
||||
update((state) => {
|
||||
const newSelectedIds = new Set(state.selectedIds);
|
||||
ids.forEach((id) => newSelectedIds.add(id));
|
||||
return {
|
||||
selectedIds: newSelectedIds,
|
||||
lastSelectedId: ids.length > 0 ? ids[ids.length - 1] : state.lastSelectedId,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Select all images
|
||||
*/
|
||||
selectAll: (allIds: string[]) => {
|
||||
update(() => ({
|
||||
selectedIds: new Set(allIds),
|
||||
lastSelectedId: allIds.length > 0 ? allIds[allIds.length - 1] : null,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all selection
|
||||
*/
|
||||
clearSelection: () => {
|
||||
set(DEFAULT_SELECTION);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an image is selected
|
||||
*/
|
||||
isSelected: (id: string): boolean => {
|
||||
let result = false;
|
||||
const unsubscribe = subscribe((_state) => {
|
||||
result = _state.selectedIds.has(id);
|
||||
});
|
||||
unsubscribe();
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of selected images
|
||||
*/
|
||||
getSelectionCount: (): number => {
|
||||
let count = 0;
|
||||
const unsubscribe = subscribe((state) => {
|
||||
count = state.selectedIds.size;
|
||||
});
|
||||
unsubscribe();
|
||||
return count;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get array of selected IDs
|
||||
*/
|
||||
getSelectedIds: (): string[] => {
|
||||
let ids: string[] = [];
|
||||
const unsubscribe = subscribe((state) => {
|
||||
ids = Array.from(state.selectedIds);
|
||||
});
|
||||
unsubscribe();
|
||||
return ids;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const selection = createSelectionStore();
|
||||
|
||||
// Derived stores for common queries
|
||||
export const hasSelection = derived(selection, ($selection) => {
|
||||
return $selection.selectedIds.size > 0;
|
||||
});
|
||||
|
||||
export const selectionCount = derived(selection, ($selection) => {
|
||||
return $selection.selectedIds.size;
|
||||
});
|
||||
|
||||
export const isSingleSelection = derived(selection, ($selection) => {
|
||||
return $selection.selectedIds.size === 1;
|
||||
});
|
||||
|
||||
export const isMultiSelection = derived(selection, ($selection) => {
|
||||
return $selection.selectedIds.size > 1;
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to check if an ID is in the selection (reactive)
|
||||
*/
|
||||
export function isImageSelected(id: string) {
|
||||
return derived(selection, ($selection) => {
|
||||
return $selection.selectedIds.has(id);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user