Port image drop & paste from Fabric.js to PixiJS
Replace Fabric Canvas/FabricImage/Rect with PixiJS Graphics and pixi-viewport's toWorld() for coordinate mapping. Upload responses now dispatch to SceneManager.addImageFromUpload() for images or create VideoSprite directly for video media types based on the backend's asset_key and media_type fields.
This commit is contained in:
+120
-130
@@ -1,18 +1,86 @@
|
|||||||
import { Canvas, FabricImage, Rect } from 'fabric';
|
import { Graphics } from 'pixi.js';
|
||||||
|
import type { Viewport } from 'pixi-viewport';
|
||||||
|
import type { SceneManager } from './SceneManager';
|
||||||
|
import { VideoSprite } from './sprites/VideoSprite';
|
||||||
import { uploadImage, uploadImageFromUrl } from '../api';
|
import { uploadImage, uploadImageFromUrl } from '../api';
|
||||||
|
|
||||||
type OnImageAdded = () => void;
|
type OnChange = () => void;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Placeholder helper */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function createPlaceholder(viewport: Viewport, x: number, y: number): Graphics {
|
||||||
|
const g = new Graphics();
|
||||||
|
g.rect(0, 0, 200, 150);
|
||||||
|
g.fill({ color: 0x3d3d3d });
|
||||||
|
g.stroke({ width: 2, color: 0x4a9eff });
|
||||||
|
g.position.set(x, y);
|
||||||
|
g.interactive = false;
|
||||||
|
viewport.addChild(g);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePlaceholder(viewport: Viewport, g: Graphics) {
|
||||||
|
viewport.removeChild(g);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Handle upload response (image or video) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function handleUploadResult(
|
||||||
|
res: any,
|
||||||
|
viewport: Viewport,
|
||||||
|
sceneManager: SceneManager,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
onChange: OnChange,
|
||||||
|
) {
|
||||||
|
const imgData = res.data.image || res.data;
|
||||||
|
const mediaType: string = imgData.media_type || 'image';
|
||||||
|
const assetKey: string | undefined = imgData.asset_key;
|
||||||
|
const w: number = imgData.width || 400;
|
||||||
|
const h: number = imgData.height || 300;
|
||||||
|
|
||||||
|
// Cap large media to 600px max dimension
|
||||||
|
const maxDim = 600;
|
||||||
|
let finalW = w;
|
||||||
|
let finalH = h;
|
||||||
|
if (w > maxDim || h > maxDim) {
|
||||||
|
const scale = maxDim / Math.max(w, h);
|
||||||
|
finalW = Math.round(w * scale);
|
||||||
|
finalH = Math.round(h * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaType === 'video' && assetKey) {
|
||||||
|
const url = imgData.public_url;
|
||||||
|
const video = new VideoSprite(assetKey, finalW, finalH, url);
|
||||||
|
video.position.set(x, y);
|
||||||
|
viewport.addChild(video);
|
||||||
|
} else if (assetKey) {
|
||||||
|
sceneManager.addImageFromUpload(assetKey, finalW, finalH, x, y);
|
||||||
|
} else {
|
||||||
|
// Fallback: legacy response without asset_key — use addImageFromUpload with public_url as key
|
||||||
|
const fallbackKey = imgData.public_url || imgData.id || 'unknown';
|
||||||
|
sceneManager.addImageFromUpload(fallbackKey, finalW, finalH, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Drag & Drop */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function setupDragDrop(
|
export function setupDragDrop(
|
||||||
canvas: Canvas,
|
container: HTMLElement,
|
||||||
|
viewport: Viewport,
|
||||||
|
sceneManager: SceneManager,
|
||||||
boardId: string,
|
boardId: string,
|
||||||
onImageAdded: OnImageAdded
|
onChange: OnChange,
|
||||||
): () => void {
|
): () => void {
|
||||||
const canvasEl = canvas.getSelectionElement();
|
|
||||||
const upperCanvas = canvas.upperCanvasEl || canvasEl;
|
|
||||||
const wrapper = upperCanvas?.parentElement || canvasEl.parentElement;
|
|
||||||
if (!wrapper) return () => {};
|
|
||||||
|
|
||||||
function onDragOver(e: DragEvent) {
|
function onDragOver(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -27,43 +95,28 @@ export function setupDragDrop(
|
|||||||
|
|
||||||
const files = e.dataTransfer?.files;
|
const files = e.dataTransfer?.files;
|
||||||
|
|
||||||
// Handle URL drops (dragged image URL from browser)
|
// Handle URL drops (dragged image/video URL from browser)
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
const url = e.dataTransfer?.getData('text/uri-list') || e.dataTransfer?.getData('text/plain') || '';
|
const url =
|
||||||
if (url && (url.startsWith('http://') || url.startsWith('https://')) && /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(url)) {
|
e.dataTransfer?.getData('text/uri-list') ||
|
||||||
const rect = wrapper!.getBoundingClientRect();
|
e.dataTransfer?.getData('text/plain') ||
|
||||||
const vpt = canvas.viewportTransform!;
|
'';
|
||||||
const x = (e.clientX - rect.left - vpt[4]) / vpt[0];
|
if (
|
||||||
const y = (e.clientY - rect.top - vpt[5]) / vpt[3];
|
url &&
|
||||||
|
(url.startsWith('http://') || url.startsWith('https://')) &&
|
||||||
const placeholder = new Rect({
|
/\.(png|jpe?g|gif|webp|svg|mp4|webm|mov)(\?|$)/i.test(url)
|
||||||
left: x, top: y, width: 200, height: 150,
|
) {
|
||||||
fill: '#3d3d3d', stroke: '#4a9eff', strokeWidth: 2,
|
const rect = container.getBoundingClientRect();
|
||||||
strokeDashArray: [8, 4], selectable: false, evented: false,
|
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
});
|
const placeholder = createPlaceholder(viewport, world.x, world.y);
|
||||||
canvas.add(placeholder);
|
|
||||||
canvas.requestRenderAll();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await uploadImageFromUrl(boardId, url);
|
const res = await uploadImageFromUrl(boardId, url);
|
||||||
const imgData = res.data.image || res.data;
|
removePlaceholder(viewport, placeholder);
|
||||||
const imgUrl = imgData.public_url;
|
handleUploadResult(res, viewport, sceneManager, world.x, world.y, onChange);
|
||||||
canvas.remove(placeholder);
|
|
||||||
|
|
||||||
const imgEl = await FabricImage.fromURL(imgUrl, { crossOrigin: 'anonymous' });
|
|
||||||
imgEl.set({ left: x, top: y, id: imgData.id } as any);
|
|
||||||
const maxDim = 600;
|
|
||||||
if (imgEl.width! > maxDim || imgEl.height! > maxDim) {
|
|
||||||
const scale = maxDim / Math.max(imgEl.width!, imgEl.height!);
|
|
||||||
imgEl.scale(scale);
|
|
||||||
}
|
|
||||||
canvas.add(imgEl);
|
|
||||||
canvas.requestRenderAll();
|
|
||||||
onImageAdded();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('URL image upload failed:', err);
|
console.error('URL image upload failed:', err);
|
||||||
canvas.remove(placeholder);
|
removePlaceholder(viewport, placeholder);
|
||||||
canvas.requestRenderAll();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -71,73 +124,41 @@ export function setupDragDrop(
|
|||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
if (!file.type.startsWith('image/')) continue;
|
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) continue;
|
||||||
|
|
||||||
// Calculate drop position in canvas coordinates
|
const rect = container.getBoundingClientRect();
|
||||||
const rect = wrapper!.getBoundingClientRect();
|
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
const vpt = canvas.viewportTransform!;
|
const placeholder = createPlaceholder(viewport, world.x, world.y);
|
||||||
const x = (e.clientX - rect.left - vpt[4]) / vpt[0];
|
|
||||||
const y = (e.clientY - rect.top - vpt[5]) / vpt[3];
|
|
||||||
|
|
||||||
// Create placeholder
|
|
||||||
const placeholder = new Rect({
|
|
||||||
left: x,
|
|
||||||
top: y,
|
|
||||||
width: 200,
|
|
||||||
height: 150,
|
|
||||||
fill: '#3d3d3d',
|
|
||||||
stroke: '#4a9eff',
|
|
||||||
strokeWidth: 2,
|
|
||||||
strokeDashArray: [8, 4],
|
|
||||||
selectable: false,
|
|
||||||
evented: false,
|
|
||||||
});
|
|
||||||
canvas.add(placeholder);
|
|
||||||
canvas.requestRenderAll();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await uploadImage(boardId, file);
|
const res = await uploadImage(boardId, file);
|
||||||
const imgData = res.data.image || res.data;
|
removePlaceholder(viewport, placeholder);
|
||||||
const url = imgData.public_url;
|
handleUploadResult(res, viewport, sceneManager, world.x, world.y, onChange);
|
||||||
|
|
||||||
canvas.remove(placeholder);
|
|
||||||
|
|
||||||
const imgEl = await FabricImage.fromURL(url, { crossOrigin: 'anonymous' });
|
|
||||||
imgEl.set({
|
|
||||||
left: x,
|
|
||||||
top: y,
|
|
||||||
id: imgData.id,
|
|
||||||
} as any);
|
|
||||||
// Scale down large images
|
|
||||||
const maxDim = 600;
|
|
||||||
if (imgEl.width! > maxDim || imgEl.height! > maxDim) {
|
|
||||||
const scale = maxDim / Math.max(imgEl.width!, imgEl.height!);
|
|
||||||
imgEl.scale(scale);
|
|
||||||
}
|
|
||||||
canvas.add(imgEl);
|
|
||||||
canvas.requestRenderAll();
|
|
||||||
onImageAdded();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Image upload failed:', err);
|
console.error('Image upload failed:', err);
|
||||||
canvas.remove(placeholder);
|
removePlaceholder(viewport, placeholder);
|
||||||
canvas.requestRenderAll();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapper.addEventListener('dragover', onDragOver);
|
container.addEventListener('dragover', onDragOver);
|
||||||
wrapper.addEventListener('drop', onDrop);
|
container.addEventListener('drop', onDrop);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
wrapper.removeEventListener('dragover', onDragOver);
|
container.removeEventListener('dragover', onDragOver);
|
||||||
wrapper.removeEventListener('drop', onDrop);
|
container.removeEventListener('drop', onDrop);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Clipboard Paste */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function setupPaste(
|
export function setupPaste(
|
||||||
canvas: Canvas,
|
viewport: Viewport,
|
||||||
|
sceneManager: SceneManager,
|
||||||
boardId: string,
|
boardId: string,
|
||||||
onImageAdded: OnImageAdded
|
onChange: OnChange,
|
||||||
): () => void {
|
): () => void {
|
||||||
async function onPaste(e: ClipboardEvent) {
|
async function onPaste(e: ClipboardEvent) {
|
||||||
const items = e.clipboardData?.items;
|
const items = e.clipboardData?.items;
|
||||||
@@ -145,57 +166,26 @@ export function setupPaste(
|
|||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
if (!item.type.startsWith('image/')) continue;
|
if (!item.type.startsWith('image/') && !item.type.startsWith('video/')) continue;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (!file) continue;
|
if (!file) continue;
|
||||||
|
|
||||||
// Place at canvas center
|
// Place at viewport center (world coords)
|
||||||
const vpt = canvas.viewportTransform!;
|
const center = viewport.center;
|
||||||
const cx = (canvas.width! / 2 - vpt[4]) / vpt[0];
|
const cx = center.x;
|
||||||
const cy = (canvas.height! / 2 - vpt[5]) / vpt[3];
|
const cy = center.y;
|
||||||
|
|
||||||
const placeholder = new Rect({
|
const placeholder = createPlaceholder(viewport, cx - 100, cy - 75);
|
||||||
left: cx - 100,
|
|
||||||
top: cy - 75,
|
|
||||||
width: 200,
|
|
||||||
height: 150,
|
|
||||||
fill: '#3d3d3d',
|
|
||||||
stroke: '#4a9eff',
|
|
||||||
strokeWidth: 2,
|
|
||||||
strokeDashArray: [8, 4],
|
|
||||||
selectable: false,
|
|
||||||
evented: false,
|
|
||||||
});
|
|
||||||
canvas.add(placeholder);
|
|
||||||
canvas.requestRenderAll();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await uploadImage(boardId, file);
|
const res = await uploadImage(boardId, file);
|
||||||
const imgData = res.data.image || res.data;
|
removePlaceholder(viewport, placeholder);
|
||||||
const url = imgData.public_url;
|
handleUploadResult(res, viewport, sceneManager, cx - 100, cy - 75, onChange);
|
||||||
|
|
||||||
canvas.remove(placeholder);
|
|
||||||
|
|
||||||
const imgEl = await FabricImage.fromURL(url, { crossOrigin: 'anonymous' });
|
|
||||||
imgEl.set({
|
|
||||||
left: cx - (imgEl.width! * (imgEl.scaleX || 1)) / 2,
|
|
||||||
top: cy - (imgEl.height! * (imgEl.scaleY || 1)) / 2,
|
|
||||||
id: imgData.id,
|
|
||||||
} as any);
|
|
||||||
const maxDim = 600;
|
|
||||||
if (imgEl.width! > maxDim || imgEl.height! > maxDim) {
|
|
||||||
const scale = maxDim / Math.max(imgEl.width!, imgEl.height!);
|
|
||||||
imgEl.scale(scale);
|
|
||||||
}
|
|
||||||
canvas.add(imgEl);
|
|
||||||
canvas.requestRenderAll();
|
|
||||||
onImageAdded();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Image paste upload failed:', err);
|
console.error('Image paste upload failed:', err);
|
||||||
canvas.remove(placeholder);
|
removePlaceholder(viewport, placeholder);
|
||||||
canvas.requestRenderAll();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user