Port operations.ts from Fabric.js to PixiJS SceneItem API

Replace all FabricObject usage with SceneItem, accessing item.data.x/y/w/h/sx/sy
instead of obj.left/top/width*scaleX. Sync displayObject position/scale after
mutations. Remove breakSelection (no ActiveSelection needed), setCoords calls,
and all fabric imports. Toggle grayscale uses PixiJS ColorMatrixFilter.desaturate().
arrangeByZOrder now uses item.data.z directly instead of canvas object index.
This commit is contained in:
Hiren Kangad
2026-03-09 23:19:34 +05:30
parent 673df6af21
commit 483d31a2eb
+208 -191
View File
@@ -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);
});
}
}