feat(markdown): add paste detection with PasteChoicePopup for text/markdown/image

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.
This commit is contained in:
Hiren Kangad
2026-03-13 11:21:28 +05:30
parent 375e7adc4a
commit aa3112a5bc
4 changed files with 132 additions and 23 deletions
+27
View File
@@ -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++) {
+5 -22
View File
@@ -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
},
},
{
+6 -1
View File
@@ -56,6 +56,10 @@ interface CanvasSetupDeps {
showToast: (msg: string) => void;
setOnlineUsers: React.Dispatch<React.SetStateAction<OnlineUser[]>>;
setSelectedLayerIds: React.Dispatch<React.SetStateAction<string[]>>;
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);
+94
View File
@@ -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 && (
<PasteChoicePopup
x={pastePopup.x}
y={pastePopup.y}
showImage={pastePopup.hasImage}
onChoice={async (choice) => {
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 && (
<LayerPanel