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,
|
onChange: OnChange,
|
||||||
selection?: SelectionManager | null,
|
selection?: SelectionManager | null,
|
||||||
uploads?: UploadManager | null,
|
uploads?: UploadManager | null,
|
||||||
|
opts?: {
|
||||||
|
onTextPaste?: (data: { text: string; html: string; hasImage: boolean }) => void;
|
||||||
|
onShortTextPaste?: (text: string) => void;
|
||||||
|
},
|
||||||
): () => void {
|
): () => void {
|
||||||
async function onPaste(e: ClipboardEvent) {
|
async function onPaste(e: ClipboardEvent) {
|
||||||
const items = e.clipboardData?.items;
|
const items = e.clipboardData?.items;
|
||||||
if (!items) return;
|
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[] = [];
|
const newItemIds: string[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
|||||||
@@ -493,36 +493,19 @@ export const shortcuts: ShortcutDef[] = [
|
|||||||
id: 'paste', keys: { key: 'v', ctrl: true },
|
id: 'paste', keys: { key: 'v', ctrl: true },
|
||||||
category: 'editing', description: 'Paste',
|
category: 'editing', description: 'Paste',
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
// Strategy: Check system clipboard for images first.
|
// Only handle recent internal copies here.
|
||||||
// - If system clipboard has an image AND we did NOT just do an internal copy
|
// For external clipboard content (images, text, HTML), do NOT call
|
||||||
// (or it's been a while), paste from system clipboard (external image).
|
// e.preventDefault() — let the native paste event fire through to setupPaste
|
||||||
// - If we just did an internal copy (lastInternalCopyTime is recent),
|
// in image-drop.ts, which is the single path for all external clipboard content.
|
||||||
// use internal clipboard to duplicate scene items (preserves vector data).
|
|
||||||
// - If system clipboard has no images, fall back to internal clipboard.
|
|
||||||
|
|
||||||
const timeSinceInternalCopy = Date.now() - _lastInternalCopyTime;
|
const timeSinceInternalCopy = Date.now() - _lastInternalCopyTime;
|
||||||
const hasInternalItems = ctx.clipboardRef.current.length > 0;
|
const hasInternalItems = ctx.clipboardRef.current.length > 0;
|
||||||
const recentInternalCopy = hasInternalItems && timeSinceInternalCopy < 500;
|
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) {
|
if (recentInternalCopy) {
|
||||||
await _pasteInternal(ctx);
|
await _pasteInternal(ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// For external clipboard content: do nothing, let native paste event fire through to setupPaste
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ interface CanvasSetupDeps {
|
|||||||
showToast: (msg: string) => void;
|
showToast: (msg: string) => void;
|
||||||
setOnlineUsers: React.Dispatch<React.SetStateAction<OnlineUser[]>>;
|
setOnlineUsers: React.Dispatch<React.SetStateAction<OnlineUser[]>>;
|
||||||
setSelectedLayerIds: React.Dispatch<React.SetStateAction<string[]>>;
|
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,
|
boardData, resolvedBoardId, user, isPublicView,
|
||||||
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
|
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
|
||||||
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
||||||
|
pasteOpts,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
const dropCleanupRef = useRef<(() => void) | null>(null);
|
const dropCleanupRef = useRef<(() => void) | null>(null);
|
||||||
@@ -487,7 +492,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
if (dropTarget) {
|
if (dropTarget) {
|
||||||
dropCleanupRef.current = setupDragDrop(dropTarget, viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager);
|
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);
|
}, 50);
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { StickySprite } from '../canvas/sprites/StickySprite';
|
|||||||
import * as ops from '../canvas/operations';
|
import * as ops from '../canvas/operations';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import MarkdownReadView from '../components/MarkdownReadView';
|
import MarkdownReadView from '../components/MarkdownReadView';
|
||||||
|
import PasteChoicePopup from '../components/PasteChoicePopup';
|
||||||
const LazyMarkdownEditView = React.lazy(() => import('../components/MarkdownEditView'));
|
const LazyMarkdownEditView = React.lazy(() => import('../components/MarkdownEditView'));
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
@@ -131,6 +132,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
const [videoCtrl, setVideoCtrl] = useState<{ videoSprite: VideoSprite; screenRect: { x: number; y: number; w: number; h: number } } | 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 [showMinimap, setShowMinimap] = useState(true);
|
||||||
const [minimapData, setMinimapData] = useState<{ items: any[]; viewportBounds: any; contentBounds: any }>({
|
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 },
|
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,
|
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)
|
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox, annotations)
|
||||||
const { annotationStore, pinOverlay, textEditor, cropOverlayRef, mdOverlay } = useCanvasSetup({
|
const { annotationStore, pinOverlay, textEditor, cropOverlayRef, mdOverlay } = useCanvasSetup({
|
||||||
boardData, resolvedBoardId, user, isPublicView,
|
boardData, resolvedBoardId, user, isPublicView,
|
||||||
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
|
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
|
||||||
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
||||||
|
pasteOpts: {
|
||||||
|
onTextPaste: handleTextPaste,
|
||||||
|
onShortTextPaste: handleShortTextPaste,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Markdown overlay — sync visible card IDs for React portal rendering ──
|
// ── 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 */}
|
{/* Layer panel */}
|
||||||
{showLayers && (
|
{showLayers && (
|
||||||
<LayerPanel
|
<LayerPanel
|
||||||
|
|||||||
Reference in New Issue
Block a user