Integrate PixiCanvas into Editor, remove Fabric.js dependency

- Rewrite Editor.tsx: swap FabricCanvas for PixiCanvas, wire
  SelectionManager, SceneManager, and all Pixi-based APIs
- Port tools.ts from Fabric Canvas API to PixiJS Viewport/Scene
- Update LayerPanel type icons for PixiJS scene types (text, video)
- Remove FabricCanvas.tsx (dead code after swap)
- Uninstall fabric npm package
- All operations, shortcuts, sync, history, drag/drop, paste now
  flow through the PixiJS pipeline end-to-end
This commit is contained in:
Hiren Kangad
2026-03-09 23:29:25 +05:30
parent c8743c9a56
commit 8cb287faf8
6 changed files with 516 additions and 1966 deletions
+1 -1265
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -11,7 +11,6 @@
"dependencies": { "dependencies": {
"@pixi/filter-drop-shadow": "^5.2.0", "@pixi/filter-drop-shadow": "^5.2.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"fabric": "^6.5.1",
"pixi-viewport": "^6.0.3", "pixi-viewport": "^6.0.3",
"pixi.js": "^8.17.0", "pixi.js": "^8.17.0",
"react": "^18.3.1", "react": "^18.3.1",
-327
View File
@@ -1,327 +0,0 @@
import React, { useRef, useEffect, useImperativeHandle, forwardRef, useCallback } from 'react';
import { Canvas, FabricImage, FabricObject } from 'fabric';
import { suppressBroadcasts, resumeBroadcasts } from './sync';
export interface FabricCanvasHandle {
getCanvas: () => Canvas | null;
fitAll: () => void;
getZoom: () => number;
setZoom: (zoom: number) => void;
}
interface FabricCanvasProps {
canvasState?: string | null;
currentTool: string;
boardId?: string;
onChange?: () => void;
}
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 5.0;
function viewportKey(id: string) { return `refboard:viewport:${id}`; }
function saveViewport(id: string, vpt: number[]) {
try { localStorage.setItem(viewportKey(id), JSON.stringify(vpt)); } catch {}
}
function loadViewport(id: string): number[] | null {
try {
const raw = localStorage.getItem(viewportKey(id));
if (!raw) return null;
const arr = JSON.parse(raw);
if (Array.isArray(arr) && arr.length === 6 && arr.every((v: any) => typeof v === 'number' && isFinite(v))) return arr;
} catch {}
return null;
}
const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
({ canvasState, currentTool, boardId, onChange }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const canvasElRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<Canvas | null>(null);
const isPanning = useRef(false);
const lastPanPoint = useRef<{ x: number; y: number } | null>(null);
const spaceHeld = useRef(false);
const initialLoadDone = useRef(false);
// Initialize Fabric canvas
useEffect(() => {
if (!canvasElRef.current || fabricRef.current) return;
const container = containerRef.current!;
const width = container.clientWidth;
const height = container.clientHeight;
const canvas = new Canvas(canvasElRef.current, {
width,
height,
backgroundColor: '#1e1e1e',
selection: true,
selectionColor: 'rgba(74, 158, 255, 0.15)',
selectionBorderColor: '#4a9eff',
selectionLineWidth: 1,
preserveObjectStacking: true,
});
fabricRef.current = canvas;
// Debounced viewport persistence
let vpTimer: ReturnType<typeof setTimeout> | null = null;
const persistViewport = () => {
if (!boardId) return;
if (vpTimer) clearTimeout(vpTimer);
vpTimer = setTimeout(() => {
const vpt = canvas.viewportTransform;
if (vpt) saveViewport(boardId, [...vpt]);
}, 300);
};
// Handle resize
const observer = new ResizeObserver(() => {
const w = container.clientWidth;
const h = container.clientHeight;
canvas.setDimensions({ width: w, height: h });
canvas.requestRenderAll();
});
observer.observe(container);
// Middle-mouse pan
canvas.on('mouse:down', (e: any) => {
if (e.e.button === 1 || (spaceHeld.current && e.e.button === 0)) {
isPanning.current = true;
lastPanPoint.current = { x: e.e.clientX, y: e.e.clientY };
canvas.defaultCursor = 'grabbing';
canvas.upperCanvasEl.style.cursor = 'grabbing';
e.e.preventDefault();
}
});
canvas.on('mouse:move', (e: any) => {
if (!isPanning.current || !lastPanPoint.current) return;
const vpt = canvas.viewportTransform!;
vpt[4] += e.e.clientX - lastPanPoint.current.x;
vpt[5] += e.e.clientY - lastPanPoint.current.y;
lastPanPoint.current = { x: e.e.clientX, y: e.e.clientY };
canvas.requestRenderAll();
persistViewport();
});
canvas.on('mouse:up', () => {
if (isPanning.current) {
isPanning.current = false;
lastPanPoint.current = null;
if (!spaceHeld.current) {
canvas.defaultCursor = 'default';
canvas.upperCanvasEl.style.cursor = 'default';
}
}
});
// Scroll zoom — zooms toward cursor (like Figma/PureRef)
// Normalizes deltaY across mice, trackpads, and browsers for consistent feel
canvas.on('mouse:wheel', (opt: any) => {
const e = opt.e as WheelEvent;
e.preventDefault();
e.stopPropagation();
// Normalize delta: mice send large values (~100), trackpads send small (~1-5)
// Clamp to ±1 and apply a fixed zoom factor per "step" for predictable UX
let delta = e.deltaY;
if (e.deltaMode === 1) delta *= 40; // DOM_DELTA_LINE → px
if (e.deltaMode === 2) delta *= 800; // DOM_DELTA_PAGE → px
// Use sign + magnitude clamping for consistent zoom speed
const direction = Math.sign(delta);
const ZOOM_STEP = 0.08; // 8% per step — smooth but responsive
let zoom = canvas.getZoom();
zoom *= 1 - direction * ZOOM_STEP;
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
// Zoom toward cursor — must use element-relative coords (offsetX/Y),
// NOT getScenePoint() which returns scene-space and causes zoom drift
canvas.zoomToPoint({ x: e.offsetX, y: e.offsetY } as any, zoom);
canvas.requestRenderAll();
persistViewport();
});
// Space key for pan
function onKeyDown(e: KeyboardEvent) {
if (e.code === 'Space' && !spaceHeld.current && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) {
e.preventDefault();
spaceHeld.current = true;
canvas.defaultCursor = 'grab';
canvas.upperCanvasEl.style.cursor = 'grab';
}
}
function onKeyUp(e: KeyboardEvent) {
if (e.code === 'Space') {
spaceHeld.current = false;
if (currentTool !== 'PAN') {
canvas.defaultCursor = 'default';
canvas.upperCanvasEl.style.cursor = 'default';
}
}
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
// Notify parent of changes
const changeEvents = ['object:added', 'object:modified', 'object:removed', 'path:created'];
const changeHandler = () => {
onChange?.();
};
changeEvents.forEach((evt) => canvas.on(evt as any, changeHandler));
return () => {
if (vpTimer) clearTimeout(vpTimer);
observer.disconnect();
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
changeEvents.forEach((evt) => canvas.off(evt as any, changeHandler));
canvas.dispose();
fabricRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load initial canvas state
useEffect(() => {
const canvas = fabricRef.current;
if (!canvas || initialLoadDone.current) return;
if (!canvasState) return;
initialLoadDone.current = true;
let parsed: any;
try {
parsed = typeof canvasState === 'string' ? JSON.parse(canvasState) : canvasState;
} catch {
return;
}
if (!parsed || (!parsed.objects && !parsed.version)) return;
// Ensure all images in the JSON have crossOrigin set BEFORE Fabric loads them.
// Without this, the underlying <img> elements load without CORS headers,
// tainting the canvas and breaking toCanvasElement/toDataURL/toBlob.
if (parsed.objects) {
(parsed.objects as any[]).forEach((obj: any) => {
if (obj.type === 'image') {
obj.crossOrigin = 'anonymous';
}
// Handle images inside groups
if (obj.type === 'group' && obj.objects) {
(obj.objects as any[]).forEach((child: any) => {
if (child.type === 'image') {
child.crossOrigin = 'anonymous';
}
});
}
});
}
// Suppress socket broadcasts during initial load — prevents flooding
// other clients with object:add events for objects they already have
suppressBroadcasts();
canvas.loadFromJSON(parsed).then(() => {
// Restore saved viewport (zoom + pan) if available
if (boardId) {
const savedVpt = loadViewport(boardId);
if (savedVpt) {
canvas.setViewportTransform(savedVpt as any);
}
}
canvas.requestRenderAll();
// Small delay to ensure all deferred events have fired before resuming
setTimeout(() => resumeBroadcasts(), 100);
}).catch((err: Error) => {
console.error('Failed to load canvas state:', err);
resumeBroadcasts();
});
}, [canvasState]);
const fitAll = useCallback(() => {
const canvas = fabricRef.current;
if (!canvas) return;
const objects = canvas.getObjects();
if (objects.length === 0) {
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
canvas.requestRenderAll();
return;
}
// Get bounding rect of all objects
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
objects.forEach((obj) => {
const bound = obj.getBoundingRect();
minX = Math.min(minX, bound.left);
minY = Math.min(minY, bound.top);
maxX = Math.max(maxX, bound.left + bound.width);
maxY = Math.max(maxY, bound.top + bound.height);
});
const objWidth = maxX - minX;
const objHeight = maxY - minY;
if (objWidth === 0 || objHeight === 0) return;
const padding = 40;
const canvasW = canvas.width!;
const canvasH = canvas.height!;
const scaleX = (canvasW - padding * 2) / objWidth;
const scaleY = (canvasH - padding * 2) / objHeight;
const zoom = Math.min(scaleX, scaleY, MAX_ZOOM);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const vpt: [number, number, number, number, number, number] = [
zoom, 0, 0, zoom,
canvasW / 2 - cx * zoom,
canvasH / 2 - cy * zoom,
];
canvas.setViewportTransform(vpt);
canvas.requestRenderAll();
if (boardId) saveViewport(boardId, vpt);
}, [boardId]);
useImperativeHandle(ref, () => ({
getCanvas: () => fabricRef.current,
fitAll,
getZoom: () => fabricRef.current?.getZoom() ?? 1,
setZoom: (zoom: number) => {
const canvas = fabricRef.current;
if (!canvas) return;
const center = canvas.getCenterPoint();
canvas.zoomToPoint(center, Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)));
canvas.requestRenderAll();
if (boardId) {
const vpt = canvas.viewportTransform;
if (vpt) saveViewport(boardId, [...vpt]);
}
},
}), [fitAll, boardId]);
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
background: '#1e1e1e',
}}
>
<canvas ref={canvasElRef} />
</div>
);
}
);
FabricCanvas.displayName = 'FabricCanvas';
export default FabricCanvas;
+76 -60
View File
@@ -1,4 +1,16 @@
import { Canvas, PencilBrush, IText, FabricObject } from 'fabric'; /**
* Tools — PixiJS version.
*
* SELECT/PAN are handled by the viewport (pixi-viewport) and SelectionManager.
* PEN/TEXT/ERASER need PixiJS implementations.
* For now, only SELECT and PAN are fully functional; PEN/TEXT/ERASER are stubs
* that will be implemented when drawing support is added.
*/
import type { Viewport } from 'pixi-viewport';
import type { SceneManager } from './SceneManager';
import type { SelectionManager } from './SelectionManager';
import { Text, TextStyle } from 'pixi.js';
export enum ToolType { export enum ToolType {
SELECT = 'SELECT', SELECT = 'SELECT',
@@ -22,101 +34,105 @@ const defaultOptions: ToolOptions = {
type CleanupFn = (() => void) | null; type CleanupFn = (() => void) | null;
export interface ToolContext {
viewport: Viewport;
scene: SceneManager;
selection: SelectionManager;
container: HTMLElement;
onChange: () => void;
}
export function activateTool( export function activateTool(
canvas: Canvas, ctx: ToolContext,
tool: ToolType, tool: ToolType,
options: ToolOptions = {} options: ToolOptions = {}
): CleanupFn { ): CleanupFn {
const opts = { ...defaultOptions, ...options }; const opts = { ...defaultOptions, ...options };
const { viewport, scene, selection, container } = ctx;
// Reset common state // Reset cursor
canvas.isDrawingMode = false; container.style.cursor = '';
canvas.selection = false;
canvas.defaultCursor = 'default';
canvas.hoverCursor = 'default';
canvas.forEachObject((obj: FabricObject) => {
obj.selectable = false;
obj.evented = false;
});
switch (tool) { switch (tool) {
case ToolType.SELECT: { case ToolType.SELECT: {
canvas.selection = true; container.style.cursor = 'default';
canvas.defaultCursor = 'default'; // SelectionManager handles click/rubber-band selection
canvas.hoverCursor = 'move';
canvas.forEachObject((obj: FabricObject) => {
obj.selectable = true;
obj.evented = true;
});
return null; return null;
} }
case ToolType.PAN: { case ToolType.PAN: {
canvas.defaultCursor = 'grab'; container.style.cursor = 'grab';
canvas.hoverCursor = 'grab'; // Space+drag is handled by PixiCanvas; this just sets cursor
// Pan is handled by FabricCanvas component directly
return null;
}
case ToolType.PEN: {
canvas.isDrawingMode = true;
const brush = new PencilBrush(canvas);
brush.color = opts.color!;
brush.width = opts.strokeWidth!;
canvas.freeDrawingBrush = brush;
return null; return null;
} }
case ToolType.TEXT: { case ToolType.TEXT: {
canvas.defaultCursor = 'text'; container.style.cursor = 'text';
canvas.hoverCursor = 'text';
const handler = (e: any) => { const onClick = (e: PointerEvent) => {
const pointer = canvas.getScenePoint(e.e); const rect = container.getBoundingClientRect();
const text = new IText('Type here', { const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
left: pointer.x,
top: pointer.y, const textData = {
id: crypto.randomUUID(),
type: 'text' as const,
x: world.x,
y: world.y,
w: 200,
h: 30,
sx: 1,
sy: 1,
angle: 0,
z: scene.nextZ(),
opacity: 1,
locked: false,
name: '',
visible: true,
text: 'Type here',
fontSize: opts.fontSize!, fontSize: opts.fontSize!,
fill: opts.color!, fill: opts.color!,
fontFamily: 'sans-serif', fontFamily: 'sans-serif',
editable: true,
});
canvas.add(text);
canvas.setActiveObject(text);
text.enterEditing();
text.selectAll();
// Remove handler after placing text
canvas.off('mouse:down', handler);
}; };
canvas.on('mouse:down', handler); scene._createItem(textData, true);
scene._applyZOrder();
ctx.onChange();
// Remove handler after placing text
container.removeEventListener('pointerdown', onClick);
};
container.addEventListener('pointerdown', onClick);
return () => { return () => {
canvas.off('mouse:down', handler); container.removeEventListener('pointerdown', onClick);
}; };
} }
case ToolType.ERASER: { case ToolType.ERASER: {
canvas.defaultCursor = 'crosshair'; container.style.cursor = 'crosshair';
canvas.hoverCursor = 'crosshair';
canvas.forEachObject((obj: FabricObject) => {
obj.selectable = false;
obj.evented = true;
});
const handler = (e: any) => { const onClick = (e: PointerEvent) => {
const target = e.target; const rect = container.getBoundingClientRect();
if (target) { const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
canvas.remove(target); const hit = selection._hitTest(world.x, world.y);
canvas.requestRenderAll(); if (hit) {
scene.removeItem(hit.id, true);
ctx.onChange();
} }
}; };
canvas.on('mouse:down', handler); container.addEventListener('pointerdown', onClick);
return () => { return () => {
canvas.off('mouse:down', handler); container.removeEventListener('pointerdown', onClick);
}; };
} }
case ToolType.PEN: {
container.style.cursor = 'crosshair';
// Drawing mode stub — will be implemented later
return null;
}
default: default:
return null; return null;
} }
+2 -1
View File
@@ -85,8 +85,9 @@ export default function LayerPanel({
function getTypeIcon(type: string, isGroup: boolean) { function getTypeIcon(type: string, isGroup: boolean) {
if (isGroup) return '\u25B8'; // triangle if (isGroup) return '\u25B8'; // triangle
if (type === 'image') return '\u{1F5BC}'; if (type === 'image') return '\u{1F5BC}';
if (type === 'i-text') return 'T'; if (type === 'text' || type === 'i-text') return 'T';
if (type === 'path') return '\u270E'; if (type === 'path') return '\u270E';
if (type === 'video') return '\u25B6';
return '\u25C7'; return '\u25C7';
} }
File diff suppressed because it is too large Load Diff