diff --git a/frontend/src/canvas/image-drop.ts b/frontend/src/canvas/image-drop.ts index e529d18..eab6514 100644 --- a/frontend/src/canvas/image-drop.ts +++ b/frontend/src/canvas/image-drop.ts @@ -324,11 +324,38 @@ export function setupPaste( 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++) { diff --git a/frontend/src/canvas/shortcut-definitions.ts b/frontend/src/canvas/shortcut-definitions.ts index e898c09..e49ca36 100644 --- a/frontend/src/canvas/shortcut-definitions.ts +++ b/frontend/src/canvas/shortcut-definitions.ts @@ -493,36 +493,19 @@ export const shortcuts: ShortcutDef[] = [ id: 'paste', keys: { key: 'v', ctrl: true }, category: 'editing', description: 'Paste', handler: async (ctx) => { - // Strategy: Check system clipboard for images first. - // - If system clipboard has an image AND we did NOT just do an internal copy - // (or it's been a while), paste from system clipboard (external image). - // - If we just did an internal copy (lastInternalCopyTime is recent), - // use internal clipboard to duplicate scene items (preserves vector data). - // - If system clipboard has no images, fall back to internal clipboard. - + // Only handle recent internal copies here. + // For external clipboard content (images, text, HTML), do NOT call + // e.preventDefault() — let the native paste event fire through to setupPaste + // in image-drop.ts, which is the single path for all external clipboard content. const timeSinceInternalCopy = Date.now() - _lastInternalCopyTime; const hasInternalItems = ctx.clipboardRef.current.length > 0; const recentInternalCopy = hasInternalItems && timeSinceInternalCopy < 500; - // If we JUST did an internal copy (<500ms ago), use internal clipboard - // (the system clipboard image is just the rasterized version of what we copied) if (recentInternalCopy) { await _pasteInternal(ctx); return; } - - // Try system clipboard first - try { - const result = await ctx.pasteFromSystemClipboard(); - if (result === 'Pasted image') return; - } catch { - // Clipboard API denied or unavailable — fall through - } - - // Fall back to internal clipboard - if (hasInternalItems) { - await _pasteInternal(ctx); - } + // For external clipboard content: do nothing, let native paste event fire through to setupPaste }, }, { diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index 89eacd4..e99f1fb 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -56,6 +56,10 @@ interface CanvasSetupDeps { showToast: (msg: string) => void; setOnlineUsers: React.Dispatch>; setSelectedLayerIds: React.Dispatch>; + pasteOpts?: { + onTextPaste?: (data: { text: string; html: string; hasImage: boolean }) => void; + onShortTextPaste?: (text: string) => void; + }; } /** @@ -67,6 +71,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { boardData, resolvedBoardId, user, isPublicView, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, + pasteOpts, } = deps; const dropCleanupRef = useRef<(() => void) | null>(null); @@ -487,7 +492,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { if (dropTarget) { dropCleanupRef.current = setupDragDrop(dropTarget, viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager); } - pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager); + pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager, pasteOpts); } }, 50); diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index b9f402d..8828c4a 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -43,6 +43,7 @@ import { StickySprite } from '../canvas/sprites/StickySprite'; import * as ops from '../canvas/operations'; import ReactDOM from 'react-dom'; import MarkdownReadView from '../components/MarkdownReadView'; +import PasteChoicePopup from '../components/PasteChoicePopup'; const LazyMarkdownEditView = React.lazy(() => import('../components/MarkdownEditView')); // Hooks @@ -131,6 +132,7 @@ export default function Editor({ isPublicView }: EditorProps) { | null >(null); const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | null>(null); + const [pastePopup, setPastePopup] = useState<{ x: number; y: number; text: string; html: string; hasImage: boolean } | null>(null); const [showMinimap, setShowMinimap] = useState(true); const [minimapData, setMinimapData] = useState<{ items: any[]; viewportBounds: any; contentBounds: any }>({ items: [], viewportBounds: { x: 0, y: 0, w: 1, h: 1 }, contentBounds: { x: 0, y: 0, w: 1, h: 1 }, @@ -199,11 +201,46 @@ export default function Editor({ isPublicView }: EditorProps) { getViewport, }); + // Paste detection callbacks — wired into setupPaste via useCanvasSetup + const handleTextPaste = useCallback((data: { text: string; html: string; hasImage: boolean }) => { + // Show popup at screen center + const container = canvasContainerRef.current; + const rect = container?.getBoundingClientRect(); + const sx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2; + const sy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2; + setPastePopup({ x: sx, y: sy, text: data.text, html: data.html, hasImage: data.hasImage }); + }, [canvasContainerRef]); + + const handleShortTextPaste = useCallback((text: string) => { + const scene = canvasRef.current?.getScene(); + const viewport = canvasRef.current?.getViewport(); + if (!scene || !viewport) return; + const cx = viewport.center.x; + const cy = viewport.center.y; + const textData = { + id: crypto.randomUUID(), + type: 'text' as const, + x: cx - 100, y: cy - 20, w: 200, h: 40, + sx: 1, sy: 1, angle: 0, z: scene.nextZ(), + opacity: 1, locked: false, name: '', visible: true, + text, + fontSize: 24, fontFamily: 'Inter, system-ui, sans-serif', + fill: '#ffffff', + }; + scene._createItem(textData, true); + scene._applyZOrder(); + onCanvasChange([textData.id]); + }, [canvasRef, onCanvasChange]); + // Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox, annotations) const { annotationStore, pinOverlay, textEditor, cropOverlayRef, mdOverlay } = useCanvasSetup({ boardData, resolvedBoardId, user, isPublicView, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, + pasteOpts: { + onTextPaste: handleTextPaste, + onShortTextPaste: handleShortTextPaste, + }, }); // ── Markdown overlay — sync visible card IDs for React portal rendering ── @@ -1098,6 +1135,63 @@ export default function Editor({ isPublicView }: EditorProps) { /> )} + {/* Paste choice popup */} + {pastePopup && ( + { + setPastePopup(null); + const scene = canvasRef.current?.getScene(); + const viewport = canvasRef.current?.getViewport(); + if (!scene || !viewport) return; + if (choice === 'markdown') { + let content = pastePopup.text; + if (pastePopup.html) { + const { default: TurndownService } = await import('turndown'); + const td = new TurndownService(); + content = td.turndown(pastePopup.html); + } + const cx = viewport.center.x; + const cy = viewport.center.y; + const mdData = { + id: crypto.randomUUID(), + type: 'markdown' as const, + x: cx - 225, y: cy - 30, w: 450, h: 60, + sx: 1, sy: 1, angle: 0, z: scene.nextZ(), + opacity: 1, locked: false, name: '', visible: true, + content, + bgColor: '#232336', textColor: '#e0e0e0', accentColor: '#7950f2', + padding: 16, cornerRadius: 10, + }; + scene._createItem(mdData, true); + scene._applyZOrder(); + onCanvasChange([mdData.id]); + } else if (choice === 'text') { + const cx = viewport.center.x; + const cy = viewport.center.y; + const textData = { + id: crypto.randomUUID(), + type: 'text' as const, + x: cx - 100, y: cy - 20, w: 200, h: 40, + sx: 1, sy: 1, angle: 0, z: scene.nextZ(), + opacity: 1, locked: false, name: '', visible: true, + text: pastePopup.text, + fontSize: 24, fontFamily: 'Inter, system-ui, sans-serif', + fill: '#ffffff', + }; + scene._createItem(textData, true); + scene._applyZOrder(); + onCanvasChange([textData.id]); + } else if (choice === 'image') { + // Image paste handled by existing setupPaste media loop + } + }} + onDismiss={() => setPastePopup(null)} + /> + )} + {/* Layer panel */} {showLayers && (