diff --git a/frontend/src/canvas/operations.ts b/frontend/src/canvas/operations.ts index 12a6d0a..23667db 100644 --- a/frontend/src/canvas/operations.ts +++ b/frontend/src/canvas/operations.ts @@ -1,358 +1,375 @@ /** * Canvas operations — pure functions for alignment, arrangement, normalize, flip, etc. - * Each mutates objects in place and calls setCoords(). Caller must requestRenderAll(). - * - * IMPORTANT: When multiple objects are selected in Fabric.js, they live inside an - * ActiveSelection group. Their left/top are RELATIVE to the group center, not the canvas. - * We must use getBoundingRect() or the canvas-level coordinates for position calculations, - * then convert back. The helper getAbsPos/setAbsPos handles this. + * Each mutates item.data in place and syncs the displayObject. Caller should trigger + * any needed re-render or persistence. */ -import { Canvas, FabricObject } from 'fabric'; +import type { SceneItem } from './SceneManager'; +import type { ImageObject } from './scene-format'; +import { ColorMatrixFilter } from 'pixi.js'; // ─── Helpers ─── -function scaledW(o: FabricObject): number { - return (o.width || 0) * (o.scaleX || 1); +function scaledW(item: SceneItem): number { + return item.data.w * item.data.sx; } -function scaledH(o: FabricObject): number { - return (o.height || 0) * (o.scaleY || 1); +function scaledH(item: SceneItem): number { + return item.data.h * item.data.sy; } -/** - * Break ActiveSelection so objects have canvas-level coordinates. - * Returns a cleanup function to restore selection afterward. - */ -export function breakSelection(canvas: Canvas): { objects: FabricObject[]; restore: () => void } { - const activeObj = canvas.getActiveObject(); - const objects = canvas.getActiveObjects(); - if (!activeObj || objects.length <= 1) { - return { objects, restore: () => {} }; - } - // Discard the ActiveSelection — this updates each object's left/top to canvas coordinates - canvas.discardActiveObject(); - return { - objects, - restore: () => { - // Re-select after operation - const fabricNs = (window as any).fabric; - if (fabricNs?.ActiveSelection && objects.length > 1) { - const sel = new fabricNs.ActiveSelection(objects, { canvas }); - canvas.setActiveObject(sel); - } else if (objects.length === 1) { - canvas.setActiveObject(objects[0]); - } - }, - }; +/** Sync displayObject position from item.data. */ +function syncPosition(item: SceneItem): void { + item.displayObject.position.set(item.data.x, item.data.y); +} + +/** Sync displayObject scale from item.data. */ +function syncScale(item: SceneItem): void { + item.displayObject.scale.set(item.data.sx, item.data.sy); +} + +/** Sync both position and scale. */ +function syncTransform(item: SceneItem): void { + syncPosition(item); + syncScale(item); + item.displayObject.angle = item.data.angle; } // ─── Alignment ─── -export function alignLeft(objects: FabricObject[]) { +export function alignLeft(objects: SceneItem[]) { if (objects.length < 2) return; - const minLeft = Math.min(...objects.map((o) => o.left ?? 0)); - objects.forEach((o) => { o.set({ left: minLeft } as any); o.setCoords(); }); + const minLeft = Math.min(...objects.map((item) => item.data.x)); + objects.forEach((item) => { + item.data.x = minLeft; + syncPosition(item); + }); } -export function alignRight(objects: FabricObject[]) { +export function alignRight(objects: SceneItem[]) { if (objects.length < 2) return; - const maxRight = Math.max(...objects.map((o) => (o.left ?? 0) + scaledW(o))); - objects.forEach((o) => { o.set({ left: maxRight - scaledW(o) } as any); o.setCoords(); }); + const maxRight = Math.max(...objects.map((item) => item.data.x + scaledW(item))); + objects.forEach((item) => { + item.data.x = maxRight - scaledW(item); + syncPosition(item); + }); } -export function alignTop(objects: FabricObject[]) { +export function alignTop(objects: SceneItem[]) { if (objects.length < 2) return; - const minTop = Math.min(...objects.map((o) => o.top ?? 0)); - objects.forEach((o) => { o.set({ top: minTop } as any); o.setCoords(); }); + const minTop = Math.min(...objects.map((item) => item.data.y)); + objects.forEach((item) => { + item.data.y = minTop; + syncPosition(item); + }); } -export function alignBottom(objects: FabricObject[]) { +export function alignBottom(objects: SceneItem[]) { if (objects.length < 2) return; - const maxBottom = Math.max(...objects.map((o) => (o.top ?? 0) + scaledH(o))); - objects.forEach((o) => { o.set({ top: maxBottom - scaledH(o) } as any); o.setCoords(); }); + const maxBottom = Math.max(...objects.map((item) => item.data.y + scaledH(item))); + objects.forEach((item) => { + item.data.y = maxBottom - scaledH(item); + syncPosition(item); + }); } // ─── Distribution ─── -export function distributeHorizontal(objects: FabricObject[]) { +export function distributeHorizontal(objects: SceneItem[]) { if (objects.length < 3) return; - const sorted = [...objects].sort((a, b) => (a.left ?? 0) - (b.left ?? 0)); + const sorted = [...objects].sort((a, b) => a.data.x - b.data.x); const first = sorted[0]; const last = sorted[sorted.length - 1]; - const totalSpan = (last.left ?? 0) + scaledW(last) - (first.left ?? 0); - const totalWidth = sorted.reduce((s, o) => s + scaledW(o), 0); + const totalSpan = last.data.x + scaledW(last) - first.data.x; + const totalWidth = sorted.reduce((s, item) => s + scaledW(item), 0); const gap = (totalSpan - totalWidth) / (sorted.length - 1); - let x = (first.left ?? 0) + scaledW(first) + gap; + let x = first.data.x + scaledW(first) + gap; for (let i = 1; i < sorted.length - 1; i++) { - sorted[i].set({ left: x } as any); - sorted[i].setCoords(); + sorted[i].data.x = x; + syncPosition(sorted[i]); x += scaledW(sorted[i]) + gap; } } -export function distributeVertical(objects: FabricObject[]) { +export function distributeVertical(objects: SceneItem[]) { if (objects.length < 3) return; - const sorted = [...objects].sort((a, b) => (a.top ?? 0) - (b.top ?? 0)); + const sorted = [...objects].sort((a, b) => a.data.y - b.data.y); const first = sorted[0]; const last = sorted[sorted.length - 1]; - const totalSpan = (last.top ?? 0) + scaledH(last) - (first.top ?? 0); - const totalHeight = sorted.reduce((s, o) => s + scaledH(o), 0); + const totalSpan = last.data.y + scaledH(last) - first.data.y; + const totalHeight = sorted.reduce((s, item) => s + scaledH(item), 0); const gap = (totalSpan - totalHeight) / (sorted.length - 1); - let y = (first.top ?? 0) + scaledH(first) + gap; + let y = first.data.y + scaledH(first) + gap; for (let i = 1; i < sorted.length - 1; i++) { - sorted[i].set({ top: y } as any); - sorted[i].setCoords(); + sorted[i].data.y = y; + syncPosition(sorted[i]); y += scaledH(sorted[i]) + gap; } } // ─── Normalize ─── -export function normalizeSize(objects: FabricObject[]) { +export function normalizeSize(objects: SceneItem[]) { if (objects.length < 2) return; - const areas = objects.map((o) => scaledW(o) * scaledH(o)); + const areas = objects.map((item) => scaledW(item) * scaledH(item)); const avgArea = areas.reduce((a, b) => a + b, 0) / areas.length; - objects.forEach((o) => { - const currentArea = scaledW(o) * scaledH(o); + objects.forEach((item) => { + const currentArea = scaledW(item) * scaledH(item); if (currentArea <= 0) return; const ratio = Math.sqrt(avgArea / currentArea); - o.set({ scaleX: (o.scaleX || 1) * ratio, scaleY: (o.scaleY || 1) * ratio } as any); - o.setCoords(); + item.data.sx *= ratio; + item.data.sy *= ratio; + syncScale(item); }); } -export function normalizeScale(objects: FabricObject[]) { +export function normalizeScale(objects: SceneItem[]) { if (objects.length < 2) return; - const avgSX = objects.reduce((s, o) => s + (o.scaleX || 1), 0) / objects.length; - const avgSY = objects.reduce((s, o) => s + (o.scaleY || 1), 0) / objects.length; - objects.forEach((o) => { o.set({ scaleX: avgSX, scaleY: avgSY } as any); o.setCoords(); }); + const avgSX = objects.reduce((s, item) => s + item.data.sx, 0) / objects.length; + const avgSY = objects.reduce((s, item) => s + item.data.sy, 0) / objects.length; + objects.forEach((item) => { + item.data.sx = avgSX; + item.data.sy = avgSY; + syncScale(item); + }); } -export function normalizeHeight(objects: FabricObject[]) { +export function normalizeHeight(objects: SceneItem[]) { if (objects.length < 2) return; - const avgH = objects.reduce((s, o) => s + scaledH(o), 0) / objects.length; - objects.forEach((o) => { - const h = scaledH(o); + const avgH = objects.reduce((s, item) => s + scaledH(item), 0) / objects.length; + objects.forEach((item) => { + const h = scaledH(item); if (h <= 0) return; const ratio = avgH / h; - o.set({ scaleX: (o.scaleX || 1) * ratio, scaleY: (o.scaleY || 1) * ratio } as any); - o.setCoords(); + item.data.sx *= ratio; + item.data.sy *= ratio; + syncScale(item); }); } -export function normalizeWidth(objects: FabricObject[]) { +export function normalizeWidth(objects: SceneItem[]) { if (objects.length < 2) return; - const avgW = objects.reduce((s, o) => s + scaledW(o), 0) / objects.length; - objects.forEach((o) => { - const w = scaledW(o); + const avgW = objects.reduce((s, item) => s + scaledW(item), 0) / objects.length; + objects.forEach((item) => { + const w = scaledW(item); if (w <= 0) return; const ratio = avgW / w; - o.set({ scaleX: (o.scaleX || 1) * ratio, scaleY: (o.scaleY || 1) * ratio } as any); - o.setCoords(); + item.data.sx *= ratio; + item.data.sy *= ratio; + syncScale(item); }); } // ─── Arrangement ─── -export function arrangeOptimal(objects: FabricObject[]) { +export function arrangeOptimal(objects: SceneItem[]) { if (objects.length < 2) return; // Shelf-based bin packing, sorted by height descending const sorted = [...objects].sort((a, b) => scaledH(b) - scaledH(a)); const gap = 10; - const totalArea = sorted.reduce((s, o) => s + scaledW(o) * scaledH(o), 0); + const totalArea = sorted.reduce((s, item) => s + scaledW(item) * scaledH(item), 0); const shelfWidth = Math.sqrt(totalArea) * 1.3; - const startX = sorted[0]?.left ?? 0; - const startY = sorted[0]?.top ?? 0; + const startX = sorted[0].data.x; + const startY = sorted[0].data.y; let x = 0, y = 0, shelfHeight = 0; - sorted.forEach((obj) => { - const w = scaledW(obj); - const h = scaledH(obj); + sorted.forEach((item) => { + const w = scaledW(item); + const h = scaledH(item); if (x + w > shelfWidth && x > 0) { x = 0; y += shelfHeight + gap; shelfHeight = 0; } - obj.set({ left: startX + x, top: startY + y } as any); - obj.setCoords(); + item.data.x = startX + x; + item.data.y = startY + y; + syncPosition(item); shelfHeight = Math.max(shelfHeight, h); x += w + gap; }); } -export function arrangeGrid(objects: FabricObject[]) { +export function arrangeGrid(objects: SceneItem[]) { if (objects.length < 2) return; const gap = 20; const cols = Math.ceil(Math.sqrt(objects.length)); - const startX = objects[0]?.left ?? 0; - const startY = objects[0]?.top ?? 0; - // Find max cell size + const startX = objects[0].data.x; + const startY = objects[0].data.y; const maxW = Math.max(...objects.map(scaledW)); const maxH = Math.max(...objects.map(scaledH)); - objects.forEach((obj, i) => { + objects.forEach((item, i) => { const col = i % cols; const row = Math.floor(i / cols); - obj.set({ left: startX + col * (maxW + gap), top: startY + row * (maxH + gap) } as any); - obj.setCoords(); + item.data.x = startX + col * (maxW + gap); + item.data.y = startY + row * (maxH + gap); + syncPosition(item); }); } -export function arrangeRow(objects: FabricObject[]) { +export function arrangeRow(objects: SceneItem[]) { if (objects.length < 2) return; const gap = 20; - const sorted = [...objects].sort((a, b) => (a.left ?? 0) - (b.left ?? 0)); - const startY = sorted[0]?.top ?? 0; - let x = sorted[0]?.left ?? 0; - sorted.forEach((obj) => { - obj.set({ left: x, top: startY } as any); - obj.setCoords(); - x += scaledW(obj) + gap; + const sorted = [...objects].sort((a, b) => a.data.x - b.data.x); + const startY = sorted[0].data.y; + let x = sorted[0].data.x; + sorted.forEach((item) => { + item.data.x = x; + item.data.y = startY; + syncPosition(item); + x += scaledW(item) + gap; }); } -export function arrangeColumn(objects: FabricObject[]) { +export function arrangeColumn(objects: SceneItem[]) { if (objects.length < 2) return; const gap = 20; - const sorted = [...objects].sort((a, b) => (a.top ?? 0) - (b.top ?? 0)); - const startX = sorted[0]?.left ?? 0; - let y = sorted[0]?.top ?? 0; - sorted.forEach((obj) => { - obj.set({ left: startX, top: y } as any); - obj.setCoords(); - y += scaledH(obj) + gap; + const sorted = [...objects].sort((a, b) => a.data.y - b.data.y); + const startX = sorted[0].data.x; + let y = sorted[0].data.y; + sorted.forEach((item) => { + item.data.x = startX; + item.data.y = y; + syncPosition(item); + y += scaledH(item) + gap; }); } -export function stackObjects(objects: FabricObject[]) { +export function stackObjects(objects: SceneItem[]) { if (objects.length < 2) return; - const cx = objects.reduce((s, o) => s + (o.left ?? 0) + scaledW(o) / 2, 0) / objects.length; - const cy = objects.reduce((s, o) => s + (o.top ?? 0) + scaledH(o) / 2, 0) / objects.length; - objects.forEach((o) => { - o.set({ left: cx - scaledW(o) / 2, top: cy - scaledH(o) / 2 } as any); - o.setCoords(); + const cx = objects.reduce((s, item) => s + item.data.x + scaledW(item) / 2, 0) / objects.length; + const cy = objects.reduce((s, item) => s + item.data.y + scaledH(item) / 2, 0) / objects.length; + objects.forEach((item) => { + item.data.x = cx - scaledW(item) / 2; + item.data.y = cy - scaledH(item) / 2; + syncPosition(item); }); } -export function arrangeByName(objects: FabricObject[]) { +export function arrangeByName(objects: SceneItem[]) { const sorted = [...objects].sort((a, b) => - ((a as any).name || '').localeCompare((b as any).name || '') + (a.data.name || '').localeCompare(b.data.name || '') ); layoutAsGrid(sorted); } -export function arrangeByZOrder(objects: FabricObject[], canvasObjects: FabricObject[]) { - // Sort by z-order (position in canvas.getObjects()) - const indexMap = new Map(canvasObjects.map((o, i) => [o, i])); - const sorted = [...objects].sort((a, b) => (indexMap.get(a) ?? 0) - (indexMap.get(b) ?? 0)); +export function arrangeByZOrder(objects: SceneItem[]) { + // Sort by z-order stored in item.data.z + const sorted = [...objects].sort((a, b) => a.data.z - b.data.z); layoutAsGrid(sorted); } -export function arrangeRandomly(objects: FabricObject[]) { +export function arrangeRandomly(objects: SceneItem[]) { if (objects.length < 2) return; - // Get bounding box of current positions - const minX = Math.min(...objects.map((o) => o.left ?? 0)); - const minY = Math.min(...objects.map((o) => o.top ?? 0)); - const maxX = Math.max(...objects.map((o) => (o.left ?? 0) + scaledW(o))); - const maxY = Math.max(...objects.map((o) => (o.top ?? 0) + scaledH(o))); - objects.forEach((o) => { - o.set({ - left: minX + Math.random() * (maxX - minX - scaledW(o)), - top: minY + Math.random() * (maxY - minY - scaledH(o)), - } as any); - o.setCoords(); + const minX = Math.min(...objects.map((item) => item.data.x)); + const minY = Math.min(...objects.map((item) => item.data.y)); + const maxX = Math.max(...objects.map((item) => item.data.x + scaledW(item))); + const maxY = Math.max(...objects.map((item) => item.data.y + scaledH(item))); + objects.forEach((item) => { + item.data.x = minX + Math.random() * (maxX - minX - scaledW(item)); + item.data.y = minY + Math.random() * (maxY - minY - scaledH(item)); + syncPosition(item); }); } -function layoutAsGrid(sorted: FabricObject[]) { +function layoutAsGrid(sorted: SceneItem[]) { if (sorted.length < 2) return; const gap = 20; const cols = Math.ceil(Math.sqrt(sorted.length)); - const startX = sorted[0]?.left ?? 0; - const startY = sorted[0]?.top ?? 0; + const startX = sorted[0].data.x; + const startY = sorted[0].data.y; const maxW = Math.max(...sorted.map(scaledW)); const maxH = Math.max(...sorted.map(scaledH)); - sorted.forEach((obj, i) => { + sorted.forEach((item, i) => { const col = i % cols; const row = Math.floor(i / cols); - obj.set({ left: startX + col * (maxW + gap), top: startY + row * (maxH + gap) } as any); - obj.setCoords(); + item.data.x = startX + col * (maxW + gap); + item.data.y = startY + row * (maxH + gap); + syncPosition(item); }); } // ─── Flip ─── -export function flipHorizontal(objects: FabricObject[]) { - objects.forEach((o) => { o.set({ flipX: !o.flipX } as any); o.setCoords(); }); +export function flipHorizontal(objects: SceneItem[]) { + objects.forEach((item) => { + item.data.flipX = !item.data.flipX; + item.displayObject.scale.x = item.data.sx * (item.data.flipX ? -1 : 1); + }); } -export function flipVertical(objects: FabricObject[]) { - objects.forEach((o) => { o.set({ flipY: !o.flipY } as any); o.setCoords(); }); +export function flipVertical(objects: SceneItem[]) { + objects.forEach((item) => { + item.data.flipY = !item.data.flipY; + item.displayObject.scale.y = item.data.sy * (item.data.flipY ? -1 : 1); + }); } // ─── Transform ─── -export function resetTransform(objects: FabricObject[]) { - objects.forEach((o) => { - o.set({ - scaleX: 1, scaleY: 1, angle: 0, - skewX: 0, skewY: 0, flipX: false, flipY: false, - } as any); - o.setCoords(); +export function resetTransform(objects: SceneItem[]) { + objects.forEach((item) => { + item.data.sx = 1; + item.data.sy = 1; + item.data.angle = 0; + item.data.flipX = false; + item.data.flipY = false; + syncTransform(item); }); } // ─── Grayscale ─── -export function toggleGrayscale(objects: FabricObject[]) { - objects.forEach((o) => { - if (o.type !== 'image') return; - const img = o as any; - if (!img.filters) img.filters = []; - // Check if grayscale filter already applied - const idx = img.filters.findIndex((f: any) => f?.type === 'Grayscale'); - if (idx >= 0) { - img.filters.splice(idx, 1); +export function toggleGrayscale(objects: SceneItem[]) { + objects.forEach((item) => { + if (item.type !== 'image') return; + const imgData = item.data as ImageObject; + const obj = item.displayObject; + + // Check if a desaturate filter is already applied + const hasGrayscale = imgData.filters.includes('Grayscale'); + + if (hasGrayscale) { + // Remove grayscale: clear the ColorMatrixFilter and remove from data + imgData.filters = imgData.filters.filter((f) => f !== 'Grayscale'); + obj.filters = (obj.filters || []).filter((f) => !(f instanceof ColorMatrixFilter)); } else { - // Dynamically access Grayscale filter from Fabric - const fabric = (window as any).fabric; - if (fabric?.filters?.Grayscale) { - img.filters.push(new fabric.filters.Grayscale()); - } + // Add grayscale + imgData.filters.push('Grayscale'); + const filter = new ColorMatrixFilter(); + filter.desaturate(); + obj.filters = [...(obj.filters || []), filter]; } - img.applyFilters?.(); }); } // ─── Lock ─── -export function toggleLocked(objects: FabricObject[]) { - objects.forEach((o) => { - const isLocked = !o.selectable; - o.set({ - selectable: isLocked, - evented: isLocked, - } as any); +export function toggleLocked(objects: SceneItem[]) { + objects.forEach((item) => { + item.data.locked = !item.data.locked; + item.displayObject.eventMode = item.data.locked ? 'none' : 'static'; }); } // ─── Overlay / Compare ─── -export function overlayCompare(objects: FabricObject[]) { +export function overlayCompare(objects: SceneItem[]) { if (objects.length < 2) return; // If all are at 0.5 opacity, restore to 1; otherwise set to 0.5 and center-stack - const allHalf = objects.every((o) => Math.abs((o.opacity ?? 1) - 0.5) < 0.05); + const allHalf = objects.every((item) => Math.abs(item.data.opacity - 0.5) < 0.05); if (allHalf) { - objects.forEach((o) => { o.set({ opacity: 1 } as any); }); + objects.forEach((item) => { + item.data.opacity = 1; + item.displayObject.alpha = 1; + }); } else { - const cx = objects.reduce((s, o) => s + (o.left ?? 0) + scaledW(o) / 2, 0) / objects.length; - const cy = objects.reduce((s, o) => s + (o.top ?? 0) + scaledH(o) / 2, 0) / objects.length; - objects.forEach((o) => { - o.set({ - opacity: 0.5, - left: cx - scaledW(o) / 2, - top: cy - scaledH(o) / 2, - } as any); - o.setCoords(); + const cx = objects.reduce((s, item) => s + item.data.x + scaledW(item) / 2, 0) / objects.length; + const cy = objects.reduce((s, item) => s + item.data.y + scaledH(item) / 2, 0) / objects.length; + objects.forEach((item) => { + item.data.opacity = 0.5; + item.data.x = cx - scaledW(item) / 2; + item.data.y = cy - scaledH(item) / 2; + item.displayObject.alpha = 0.5; + syncPosition(item); }); } }