This commit is contained in:
Danilo Reyes
2025-11-02 14:03:01 -06:00
parent f85ae4d417
commit 3700ba02ea
14 changed files with 3103 additions and 19 deletions

View 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 -->

View 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 -->

View 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;
}

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

View 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);
}

View 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;
}
}

View 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);
});
}