Simplify Ctrl+V shortcut to only handle recent internal copies, letting native paste events flow to setupPaste for external clipboard content. Extend setupPaste with text/HTML detection and popup callbacks. Wire PasteChoicePopup in Editor.tsx with markdown (turndown HTML-to-MD), plain text, and image paste choices.
427 lines
15 KiB
TypeScript
427 lines
15 KiB
TypeScript
import { Graphics } from 'pixi.js';
|
|
import type { Viewport } from 'pixi-viewport';
|
|
import type { SceneManager } from './SceneManager';
|
|
import type { SelectionManager } from './SelectionManager';
|
|
import type { UploadManager } from '../stores/uploadManager';
|
|
import { uploadImage, uploadImageFromUrl } from '../api';
|
|
|
|
type OnChange = () => void;
|
|
|
|
/** Client-side file size limit (matches backend MAX_FILE_SIZE_MB default) */
|
|
const MAX_FILE_SIZE_MB = 200;
|
|
const MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024;
|
|
|
|
/** Explicit MIME allowlist — matches backend ALLOWED_MIME_TYPES exactly. */
|
|
const ALLOWED_MIME_TYPES = new Set([
|
|
'image/png',
|
|
'image/jpeg',
|
|
'image/gif',
|
|
'image/webp',
|
|
'image/svg+xml',
|
|
'video/mp4',
|
|
'video/webm',
|
|
'video/quicktime',
|
|
]);
|
|
|
|
/** Fallback: accepted file extensions (for when browser reports empty/wrong MIME). */
|
|
const ALLOWED_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|mp4|webm|mov)$/i;
|
|
|
|
/** Check if a file should be accepted (MIME or extension fallback). */
|
|
function isFileAllowed(file: File): boolean {
|
|
if (ALLOWED_MIME_TYPES.has(file.type)) return true;
|
|
// Fallback to extension when MIME is empty or non-standard
|
|
if (file.name && ALLOWED_EXTENSIONS.test(file.name)) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Check if a file is media-like but unsupported (for rejection feedback). */
|
|
function isUnsupportedMedia(file: File): boolean {
|
|
if (!file.type && !file.name) return false;
|
|
if (file.type.startsWith('image/') || file.type.startsWith('video/')) return true;
|
|
if (file.name && /\.(bmp|tiff?|avif|heic|heif|psd|raw|dng|ico|apng)$/i.test(file.name)) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Human-readable extension from MIME type, for error messages. */
|
|
function extFromMime(mime: string): string {
|
|
const map: Record<string, string> = {
|
|
'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif',
|
|
'image/webp': '.webp', 'image/svg+xml': '.svg',
|
|
'video/mp4': '.mp4', 'video/webm': '.webm', 'video/quicktime': '.mov',
|
|
'image/bmp': '.bmp', 'image/tiff': '.tiff', 'image/avif': '.avif',
|
|
'image/heic': '.heic', 'image/heif': '.heif',
|
|
'application/pdf': '.pdf', 'image/x-adobe-dng': '.dng',
|
|
};
|
|
return map[mime] || mime.split('/')[1] || mime;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/** Returns the placed dimensions and item ID so callers can offset subsequent items and select them. */
|
|
function handleUploadResult(
|
|
res: any,
|
|
viewport: Viewport,
|
|
sceneManager: SceneManager,
|
|
x: number,
|
|
y: number,
|
|
onChange: OnChange,
|
|
): { w: number; h: number; id: string } {
|
|
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);
|
|
}
|
|
|
|
let item;
|
|
if (mediaType === 'video' && assetKey) {
|
|
const posterKey: string | undefined = imgData.poster_asset_key;
|
|
const duration: number | undefined = imgData.duration;
|
|
item = sceneManager.addVideoFromUpload(assetKey, finalW, finalH, x, y, posterKey, duration);
|
|
} else if (assetKey) {
|
|
item = 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';
|
|
item = sceneManager.addImageFromUpload(fallbackKey, finalW, finalH, x, y);
|
|
}
|
|
|
|
onChange();
|
|
return { w: finalW, h: finalH, id: item.id };
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Drag & Drop */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function setupDragDrop(
|
|
container: HTMLElement,
|
|
viewport: Viewport,
|
|
sceneManager: SceneManager,
|
|
boardId: string,
|
|
onChange: OnChange,
|
|
selection?: SelectionManager | null,
|
|
uploads?: UploadManager | null,
|
|
): () => void {
|
|
function onDragOver(e: DragEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.dataTransfer) {
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
}
|
|
|
|
async function onDrop(e: DragEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const files = e.dataTransfer?.files;
|
|
|
|
// Handle URL drops (dragged image/video URL from browser)
|
|
if (!files || files.length === 0) {
|
|
const url =
|
|
e.dataTransfer?.getData('text/uri-list') ||
|
|
e.dataTransfer?.getData('text/plain') ||
|
|
'';
|
|
const isHttpUrl = url && (url.startsWith('http://') || url.startsWith('https://'));
|
|
const supportedUrlPattern = /\.(png|jpe?g|gif|webp|svg|mp4|webm|mov)(\?|#|$)/i;
|
|
// Broader media pattern: URLs that look like media but aren't in our supported set
|
|
const mediaUrlPattern = /\.(png|jpe?g|gif|webp|svg|mp4|webm|mov|bmp|tiff?|avif|heic|heif|psd|raw|dng|ico|apng)(\?|#|$)/i;
|
|
|
|
if (isHttpUrl && !supportedUrlPattern.test(url) && mediaUrlPattern.test(url)) {
|
|
// URL looks like media but isn't supported — show rejection
|
|
const ext = url.split('?')[0].split('#')[0].match(/\.(\w+)$/)?.[1] || 'unknown';
|
|
uploads?.addRejected(
|
|
url.split('/').pop()?.split('?')[0] || 'url-import',
|
|
`Unsupported format: .${ext}`,
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
isHttpUrl &&
|
|
supportedUrlPattern.test(url)
|
|
) {
|
|
const rect = container.getBoundingClientRect();
|
|
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
const placeholder = createPlaceholder(viewport, world.x, world.y);
|
|
|
|
// Derive a filename from the URL for the upload manager
|
|
const urlFilename = url.split('/').pop()?.split('?')[0] || 'url-import';
|
|
const isVideoUrl = /\.(mp4|webm|mov)(\?|$)/i.test(url);
|
|
const jobId = uploads?.addUrlJob(urlFilename, isVideoUrl ? 'video' : 'image');
|
|
|
|
try {
|
|
const res = await uploadImageFromUrl(boardId, url);
|
|
removePlaceholder(viewport, placeholder);
|
|
const { id } = handleUploadResult(res, viewport, sceneManager, world.x, world.y, onChange);
|
|
selection?.selectOnly(id);
|
|
const imgData = res.data.image || res.data;
|
|
if (jobId) uploads?.uploadComplete(jobId, imgData.id);
|
|
} catch (err: any) {
|
|
console.error('URL image upload failed:', err);
|
|
removePlaceholder(viewport, placeholder);
|
|
if (jobId) uploads?.setFailed(jobId, err.response?.data?.error || err.message || 'URL import failed');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
|
|
const GAP = 20;
|
|
|
|
// Count valid media files to determine grid columns
|
|
let mediaCount = 0;
|
|
for (let i = 0; i < files.length; i++) {
|
|
if (isFileAllowed(files[i])) mediaCount++;
|
|
}
|
|
const cols = Math.ceil(Math.sqrt(mediaCount)); // square-ish grid
|
|
let col = 0;
|
|
let row = 0;
|
|
let rowMaxH = 0;
|
|
let cursorY = world.y;
|
|
const colWidths: number[] = new Array(cols).fill(0); // track widest per column
|
|
const newItemIds: string[] = [];
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
|
|
// Reject unsupported file types with immediate feedback
|
|
if (!isFileAllowed(file)) {
|
|
if (isUnsupportedMedia(file)) {
|
|
const ext = file.name?.match(/\.(\w+)$/)?.[1] || file.type.split('/')[1] || 'unknown';
|
|
uploads?.addRejected(
|
|
file.name || 'unknown',
|
|
`Unsupported format: .${ext}`,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Compute x from column widths so far
|
|
let cursorX = world.x;
|
|
for (let c = 0; c < col; c++) cursorX += (colWidths[c] || 220) + GAP;
|
|
|
|
// Client-side size validation
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
const jobId = uploads?.addJob(file, boardId);
|
|
if (jobId) uploads?.setFailed(jobId, `File too large (${(file.size / 1024 / 1024).toFixed(0)}MB, max ${MAX_FILE_SIZE_MB}MB)`);
|
|
col++;
|
|
if (col >= cols) { col = 0; row++; cursorY += rowMaxH + GAP; rowMaxH = 0; }
|
|
continue;
|
|
}
|
|
|
|
const jobId = uploads?.addJob(file, boardId);
|
|
|
|
// Check if job was cancelled while queued (user clicked cancel)
|
|
if (jobId && uploads?.isCancelled(jobId)) {
|
|
col++;
|
|
if (col >= cols) { col = 0; row++; cursorY += rowMaxH + GAP; rowMaxH = 0; }
|
|
continue;
|
|
}
|
|
|
|
const placeholder = createPlaceholder(viewport, cursorX, cursorY);
|
|
if (jobId) uploads?.startUpload(jobId);
|
|
|
|
try {
|
|
const res = await uploadImage(boardId, file, (p) => {
|
|
if (jobId) uploads?.setProgress(jobId, p);
|
|
});
|
|
removePlaceholder(viewport, placeholder);
|
|
const { w: placedW, h: placedH, id } = handleUploadResult(res, viewport, sceneManager, cursorX, cursorY, onChange);
|
|
newItemIds.push(id);
|
|
if (placedW > (colWidths[col] || 0)) colWidths[col] = placedW;
|
|
if (placedH > rowMaxH) rowMaxH = placedH;
|
|
// Link upload job to DB image for processing tracking
|
|
const imgData = res.data.image || res.data;
|
|
if (jobId) uploads?.uploadComplete(jobId, imgData.id);
|
|
} catch (err: any) {
|
|
console.error('Image upload failed:', err);
|
|
removePlaceholder(viewport, placeholder);
|
|
if (jobId) uploads?.setFailed(jobId, err.response?.data?.error || err.message || 'Upload failed');
|
|
if (220 > (colWidths[col] || 0)) colWidths[col] = 220;
|
|
if (150 > rowMaxH) rowMaxH = 150;
|
|
}
|
|
|
|
col++;
|
|
if (col >= cols) {
|
|
col = 0;
|
|
row++;
|
|
cursorY += rowMaxH + GAP;
|
|
rowMaxH = 0;
|
|
}
|
|
}
|
|
|
|
// Select all newly imported items
|
|
if (selection && newItemIds.length > 0) {
|
|
selection.selectOnly(newItemIds[0]);
|
|
for (let i = 1; i < newItemIds.length; i++) {
|
|
selection.toggle(newItemIds[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prevent browser default (opening file in new tab) on the whole document
|
|
function onDocDragOver(e: DragEvent) {
|
|
e.preventDefault();
|
|
}
|
|
function onDocDrop(e: DragEvent) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
document.addEventListener('dragover', onDocDragOver);
|
|
document.addEventListener('drop', onDocDrop);
|
|
container.addEventListener('dragover', onDragOver);
|
|
container.addEventListener('drop', onDrop);
|
|
|
|
return () => {
|
|
document.removeEventListener('dragover', onDocDragOver);
|
|
document.removeEventListener('drop', onDocDrop);
|
|
container.removeEventListener('dragover', onDragOver);
|
|
container.removeEventListener('drop', onDrop);
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Clipboard Paste */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function setupPaste(
|
|
viewport: Viewport,
|
|
sceneManager: SceneManager,
|
|
boardId: string,
|
|
onChange: OnChange,
|
|
selection?: SelectionManager | null,
|
|
uploads?: UploadManager | null,
|
|
opts?: {
|
|
onTextPaste?: (data: { text: string; html: string; hasImage: boolean }) => void;
|
|
onShortTextPaste?: (text: string) => void;
|
|
},
|
|
): () => void {
|
|
async function onPaste(e: ClipboardEvent) {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
|
|
// ── Text/HTML detection — before media loop ──
|
|
let hasMedia = false;
|
|
let textContent = '';
|
|
let htmlContent = '';
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i];
|
|
if (item.type.startsWith('image/') || item.type.startsWith('video/')) hasMedia = true;
|
|
if (item.type === 'text/plain') textContent = e.clipboardData?.getData('text/plain') || '';
|
|
if (item.type === 'text/html') htmlContent = e.clipboardData?.getData('text/html') || '';
|
|
}
|
|
|
|
if (textContent) {
|
|
e.preventDefault();
|
|
if (textContent.length > 20) {
|
|
opts?.onTextPaste?.({ text: textContent, html: htmlContent, hasImage: hasMedia });
|
|
} else {
|
|
opts?.onShortTextPaste?.(textContent);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── Media paste logic (unchanged) ──
|
|
const newItemIds: string[] = [];
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i];
|
|
|
|
// Skip non-media clipboard items (text, html, etc.)
|
|
if (!item.type.startsWith('image/') && !item.type.startsWith('video/')) continue;
|
|
|
|
e.preventDefault();
|
|
|
|
// Reject unsupported media types with feedback
|
|
if (!ALLOWED_MIME_TYPES.has(item.type)) {
|
|
const ext = extFromMime(item.type);
|
|
uploads?.addRejected(
|
|
`pasted-item${ext.startsWith('.') ? ext : '.' + ext}`,
|
|
`Unsupported format: ${ext}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const file = item.getAsFile();
|
|
if (!file) continue;
|
|
|
|
// Client-side size validation
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
const jobId = uploads?.addJob(file, boardId);
|
|
if (jobId) uploads?.setFailed(jobId, `File too large (${(file.size / 1024 / 1024).toFixed(0)}MB, max ${MAX_FILE_SIZE_MB}MB)`);
|
|
continue;
|
|
}
|
|
|
|
// Place at viewport center (world coords)
|
|
const center = viewport.center;
|
|
const cx = center.x;
|
|
const cy = center.y;
|
|
|
|
const placeholder = createPlaceholder(viewport, cx - 100, cy - 75);
|
|
const jobId = uploads?.addJob(file, boardId);
|
|
|
|
try {
|
|
const res = await uploadImage(boardId, file, (p) => {
|
|
if (jobId) uploads?.setProgress(jobId, p);
|
|
});
|
|
removePlaceholder(viewport, placeholder);
|
|
const { id } = handleUploadResult(res, viewport, sceneManager, cx - 100, cy - 75, onChange);
|
|
newItemIds.push(id);
|
|
const imgData = res.data.image || res.data;
|
|
if (jobId) uploads?.uploadComplete(jobId, imgData.id);
|
|
} catch (err: any) {
|
|
console.error('Image paste upload failed:', err);
|
|
removePlaceholder(viewport, placeholder);
|
|
if (jobId) uploads?.setFailed(jobId, err.response?.data?.error || err.message || 'Upload failed');
|
|
}
|
|
}
|
|
|
|
// Select all pasted items
|
|
if (selection && newItemIds.length > 0) {
|
|
selection.selectOnly(newItemIds[0]);
|
|
for (let i = 1; i < newItemIds.length; i++) {
|
|
selection.toggle(newItemIds[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('paste', onPaste);
|
|
return () => {
|
|
document.removeEventListener('paste', onPaste);
|
|
};
|
|
}
|