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": {
|
"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",
|
||||||
|
|||||||
@@ -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 {
|
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);
|
scene._createItem(textData, true);
|
||||||
canvas.setActiveObject(text);
|
scene._applyZOrder();
|
||||||
text.enterEditing();
|
ctx.onChange();
|
||||||
text.selectAll();
|
|
||||||
// Remove handler after placing text
|
// Remove handler after placing text
|
||||||
canvas.off('mouse:down', handler);
|
container.removeEventListener('pointerdown', onClick);
|
||||||
};
|
};
|
||||||
|
|
||||||
canvas.on('mouse:down', handler);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+438
-313
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user