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:
@@ -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++) {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user