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:
Generated
+1
-1265
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@
|
||||
"dependencies": {
|
||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||
"axios": "^1.7.9",
|
||||
"fabric": "^6.5.1",
|
||||
"pixi-viewport": "^6.0.3",
|
||||
"pixi.js": "^8.17.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
SELECT = 'SELECT',
|
||||
@@ -22,101 +34,105 @@ const defaultOptions: ToolOptions = {
|
||||
|
||||
type CleanupFn = (() => void) | null;
|
||||
|
||||
export interface ToolContext {
|
||||
viewport: Viewport;
|
||||
scene: SceneManager;
|
||||
selection: SelectionManager;
|
||||
container: HTMLElement;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export function activateTool(
|
||||
canvas: Canvas,
|
||||
ctx: ToolContext,
|
||||
tool: ToolType,
|
||||
options: ToolOptions = {}
|
||||
): CleanupFn {
|
||||
const opts = { ...defaultOptions, ...options };
|
||||
const { viewport, scene, selection, container } = ctx;
|
||||
|
||||
// Reset common state
|
||||
canvas.isDrawingMode = false;
|
||||
canvas.selection = false;
|
||||
canvas.defaultCursor = 'default';
|
||||
canvas.hoverCursor = 'default';
|
||||
canvas.forEachObject((obj: FabricObject) => {
|
||||
obj.selectable = false;
|
||||
obj.evented = false;
|
||||
});
|
||||
// Reset cursor
|
||||
container.style.cursor = '';
|
||||
|
||||
switch (tool) {
|
||||
case ToolType.SELECT: {
|
||||
canvas.selection = true;
|
||||
canvas.defaultCursor = 'default';
|
||||
canvas.hoverCursor = 'move';
|
||||
canvas.forEachObject((obj: FabricObject) => {
|
||||
obj.selectable = true;
|
||||
obj.evented = true;
|
||||
});
|
||||
container.style.cursor = 'default';
|
||||
// SelectionManager handles click/rubber-band selection
|
||||
return null;
|
||||
}
|
||||
|
||||
case ToolType.PAN: {
|
||||
canvas.defaultCursor = 'grab';
|
||||
canvas.hoverCursor = 'grab';
|
||||
// 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;
|
||||
container.style.cursor = 'grab';
|
||||
// Space+drag is handled by PixiCanvas; this just sets cursor
|
||||
return null;
|
||||
}
|
||||
|
||||
case ToolType.TEXT: {
|
||||
canvas.defaultCursor = 'text';
|
||||
canvas.hoverCursor = 'text';
|
||||
container.style.cursor = 'text';
|
||||
|
||||
const handler = (e: any) => {
|
||||
const pointer = canvas.getScenePoint(e.e);
|
||||
const text = new IText('Type here', {
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
const onClick = (e: PointerEvent) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||
|
||||
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!,
|
||||
fill: opts.color!,
|
||||
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 () => {
|
||||
canvas.off('mouse:down', handler);
|
||||
container.removeEventListener('pointerdown', onClick);
|
||||
};
|
||||
}
|
||||
|
||||
case ToolType.ERASER: {
|
||||
canvas.defaultCursor = 'crosshair';
|
||||
canvas.hoverCursor = 'crosshair';
|
||||
canvas.forEachObject((obj: FabricObject) => {
|
||||
obj.selectable = false;
|
||||
obj.evented = true;
|
||||
});
|
||||
container.style.cursor = 'crosshair';
|
||||
|
||||
const handler = (e: any) => {
|
||||
const target = e.target;
|
||||
if (target) {
|
||||
canvas.remove(target);
|
||||
canvas.requestRenderAll();
|
||||
const onClick = (e: PointerEvent) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||
const hit = selection._hitTest(world.x, world.y);
|
||||
if (hit) {
|
||||
scene.removeItem(hit.id, true);
|
||||
ctx.onChange();
|
||||
}
|
||||
};
|
||||
|
||||
canvas.on('mouse:down', handler);
|
||||
container.addEventListener('pointerdown', onClick);
|
||||
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:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -85,8 +85,9 @@ export default function LayerPanel({
|
||||
function getTypeIcon(type: string, isGroup: boolean) {
|
||||
if (isGroup) return '\u25B8'; // triangle
|
||||
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 === 'video') return '\u25B6';
|
||||
return '\u25C7';
|
||||
}
|
||||
|
||||
|
||||
+433
-308
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user