Drag-and-drop image insertion for Milkdown and Monaco editors (#47)
* Initial plan * Implement drag-and-drop image insertion for both Milkdown and Monaco editors Co-authored-by: rfc1437 <774975+rfc1437@users.noreply.github.com> * Address code review: simplify Monaco type assertion, fix lint warning Co-authored-by: rfc1437 <774975+rfc1437@users.noreply.github.com> * feat: additional work on image drag-and-drop * chore: updated documentation --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rfc1437 <774975+rfc1437@users.noreply.github.com> Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
@@ -278,6 +278,13 @@
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* Drop zone visual feedback for markdown editor */
|
||||
.editor-body.drop-target-active {
|
||||
outline: 2px dashed var(--vscode-focusBorder, #007acc);
|
||||
outline-offset: -2px;
|
||||
background-color: color-mix(in srgb, var(--vscode-focusBorder, #007acc) 8%, transparent);
|
||||
}
|
||||
|
||||
.editor-body label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useEntityLoader, useSaveShortcut } from '../../navigation/useEntityEdit
|
||||
import { useI18n } from '../../i18n';
|
||||
import { SUPPORTED_POST_LANGUAGES, POST_LANGUAGE_FLAGS } from '../../../main/shared/i18n';
|
||||
import { UI_DATE_LOCALE, getMediaDisplayName } from './editorUtils';
|
||||
import { dropImageContext, hasImageFiles, importImageFile } from '../../plugins/dropImagePlugin';
|
||||
|
||||
function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
@@ -821,6 +822,125 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Drag-and-drop image import handler ──────────────────────────────
|
||||
|
||||
const runDropImport = useCallback(async (request: () => Promise<{ mediaId: string; alt: string; relativePath: string } | null | undefined>): Promise<{ mediaId: string; alt: string; relativePath: string } | null> => {
|
||||
try {
|
||||
showToast.info(tr('editor.dropImage.importing'));
|
||||
const result = await request();
|
||||
if (result) {
|
||||
showToast.success(tr('editor.dropImage.success'));
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Drop import failed:', error);
|
||||
showToast.error(tr('editor.dropImage.failed'));
|
||||
return null;
|
||||
}
|
||||
}, [tr]);
|
||||
|
||||
const refreshPostAfterDropImport = useCallback(async (pid: string) => {
|
||||
const refreshed = await window.electronAPI?.posts.get(pid);
|
||||
if (refreshed) {
|
||||
setPost(refreshed as PostData);
|
||||
updatePost(pid, refreshed as Partial<PostData>);
|
||||
}
|
||||
}, [updatePost]);
|
||||
|
||||
const handleDropImport = useCallback(async (pid: string, filePath: string): Promise<{ mediaId: string; alt: string; relativePath: string } | null> => {
|
||||
const result = await runDropImport(() => window.electronAPI?.postMedia.dropImport(pid, filePath));
|
||||
if (result) {
|
||||
await refreshPostAfterDropImport(pid);
|
||||
}
|
||||
return result;
|
||||
}, [refreshPostAfterDropImport, runDropImport]);
|
||||
|
||||
const handleDropImportBuffer = useCallback(async (pid: string, payload: { fileName: string; mimeType: string; bytes: Uint8Array }): Promise<{ mediaId: string; alt: string; relativePath: string } | null> => {
|
||||
const result = await runDropImport(() => window.electronAPI?.postMedia.dropImportBuffer(pid, payload));
|
||||
if (result) {
|
||||
await refreshPostAfterDropImport(pid);
|
||||
}
|
||||
return result;
|
||||
}, [refreshPostAfterDropImport, runDropImport]);
|
||||
|
||||
const handleDropImportFile = useCallback(async (pid: string, file: File): Promise<{ mediaId: string; alt: string; relativePath: string } | null> => {
|
||||
return importImageFile(pid, file, {
|
||||
importFromPath: handleDropImport,
|
||||
importFromBuffer: handleDropImportBuffer,
|
||||
});
|
||||
}, [handleDropImport, handleDropImportBuffer]);
|
||||
|
||||
// Keep the shared dropImageContext in sync so the Milkdown plugin can access it
|
||||
useEffect(() => {
|
||||
dropImageContext.postId = postId;
|
||||
dropImageContext.onDropImportFile = handleDropImportFile;
|
||||
return () => {
|
||||
dropImageContext.postId = null;
|
||||
dropImageContext.onDropImportFile = null;
|
||||
};
|
||||
}, [postId, handleDropImportFile]);
|
||||
|
||||
// Drag-and-drop handler for the Monaco markdown editor
|
||||
const handleEditorBodyDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (editorMode !== 'markdown') return;
|
||||
if (!e.dataTransfer?.types?.includes('Files')) return;
|
||||
const hasImages = Array.from(e.dataTransfer.items).some(
|
||||
(item) => item.kind === 'file' && item.type.startsWith('image/'),
|
||||
);
|
||||
if (hasImages) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
editorBodyRef.current?.classList.add('drop-target-active');
|
||||
}
|
||||
}, [editorMode]);
|
||||
|
||||
const handleEditorBodyDragLeave = useCallback((e: React.DragEvent) => {
|
||||
// Only remove if leaving the editor-body bounds
|
||||
if (editorBodyRef.current && !editorBodyRef.current.contains(e.relatedTarget as Node)) {
|
||||
editorBodyRef.current.classList.remove('drop-target-active');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEditorBodyDrop = useCallback(async (e: React.DragEvent) => {
|
||||
editorBodyRef.current?.classList.remove('drop-target-active');
|
||||
if (editorMode !== 'markdown') return;
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
if (!hasImageFiles(files)) {
|
||||
showToast.info(tr('editor.dropImage.invalidType'));
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
const result = await handleDropImportFile(postId, file);
|
||||
if (result) {
|
||||
// Insert markdown image reference at cursor position in Monaco
|
||||
const monacoEditor = editorRef.current as any;
|
||||
const imageMarkdown = ``;
|
||||
|
||||
if (monacoEditor?.executeEdits && monacoEditor?.getPosition) {
|
||||
const position = monacoEditor.getPosition();
|
||||
if (position) {
|
||||
monacoEditor.executeEdits('drop-image', [{
|
||||
range: {
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: position.column,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
},
|
||||
text: `\n${imageMarkdown}\n`,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [editorMode, handleDropImportFile, postId, tr]);
|
||||
|
||||
// Close quick actions menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!showPostQuickActions) return;
|
||||
@@ -1442,7 +1562,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="editor-body" ref={editorBodyRef}>
|
||||
<div className="editor-body" ref={editorBodyRef} onDragOver={handleEditorBodyDragOver} onDragLeave={handleEditorBodyDragLeave} onDrop={handleEditorBodyDrop}>
|
||||
<div className="editor-toolbar">
|
||||
<div className="editor-toolbar-left">
|
||||
<label>{tr('editor.field.content')}</label>
|
||||
|
||||
@@ -78,6 +78,13 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Drop zone visual feedback */
|
||||
.milkdown-content.drop-target-active {
|
||||
outline: 2px dashed var(--vscode-focusBorder, #007acc);
|
||||
outline-offset: -2px;
|
||||
background-color: color-mix(in srgb, var(--vscode-focusBorder, #007acc) 8%, transparent);
|
||||
}
|
||||
|
||||
.milkdown-content .milkdown .ProseMirror {
|
||||
flex: 1;
|
||||
outline: none;
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { Plugin } from 'unified';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { macroPlugin } from '../../plugins/macroPlugin';
|
||||
import { imageResolverPlugin } from '../../plugins/imageResolverPlugin';
|
||||
import { dropImagePlugin } from '../../plugins/dropImagePlugin';
|
||||
// Import macros module to register all macro definitions
|
||||
import '../../macros';
|
||||
import './MilkdownEditor.css';
|
||||
@@ -346,6 +347,7 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(imageResolverPlugin)
|
||||
.use(dropImagePlugin)
|
||||
.use(macroPlugin)
|
||||
.use(history)
|
||||
.use(listener)
|
||||
|
||||
@@ -755,6 +755,10 @@
|
||||
"linkedMediaPanel.toast.unlinkFailed": "Lösen des Mediums fehlgeschlagen",
|
||||
"linkedMediaPanel.toast.linked": "Medium mit Beitrag verknüpft",
|
||||
"linkedMediaPanel.toast.linkFailed": "Verknüpfen des Mediums fehlgeschlagen",
|
||||
"editor.dropImage.importing": "Bild wird importiert…",
|
||||
"editor.dropImage.success": "Bild importiert und verknüpft",
|
||||
"editor.dropImage.failed": "Import des abgelegten Bildes fehlgeschlagen",
|
||||
"editor.dropImage.invalidType": "Nur Bilddateien können hier abgelegt werden",
|
||||
"styleView.title": "Stil",
|
||||
"styleView.subtitle": "Wähle ein Pico-CSS-Theme und sieh dir vor dem Anwenden eine Vorschau der Top-Beiträge an.",
|
||||
"styleView.themePickerAria": "Pico-Theme-Auswahl",
|
||||
|
||||
@@ -755,6 +755,10 @@
|
||||
"linkedMediaPanel.toast.unlinkFailed": "Failed to unlink media",
|
||||
"linkedMediaPanel.toast.linked": "Media linked to post",
|
||||
"linkedMediaPanel.toast.linkFailed": "Failed to link media",
|
||||
"editor.dropImage.importing": "Importing image…",
|
||||
"editor.dropImage.success": "Image imported and linked",
|
||||
"editor.dropImage.failed": "Failed to import dropped image",
|
||||
"editor.dropImage.invalidType": "Only image files can be dropped here",
|
||||
"styleView.title": "Style",
|
||||
"styleView.subtitle": "Select a Pico CSS theme and preview the top posts before applying.",
|
||||
"styleView.themePickerAria": "Pico theme picker",
|
||||
|
||||
@@ -755,6 +755,10 @@
|
||||
"linkedMediaPanel.toast.unlinkFailed": "Error al desvincular medio",
|
||||
"linkedMediaPanel.toast.linked": "Medio vinculado a la entrada",
|
||||
"linkedMediaPanel.toast.linkFailed": "Error al vincular medio",
|
||||
"editor.dropImage.importing": "Importando imagen…",
|
||||
"editor.dropImage.success": "Imagen importada y vinculada",
|
||||
"editor.dropImage.failed": "Error al importar la imagen arrastrada",
|
||||
"editor.dropImage.invalidType": "Solo se pueden soltar archivos de imagen aquí",
|
||||
"styleView.title": "Estilo",
|
||||
"styleView.subtitle": "Selecciona un tema de Pico CSS y previsualiza las entradas principales antes de aplicarlo.",
|
||||
"styleView.themePickerAria": "Selector de tema Pico",
|
||||
|
||||
@@ -755,6 +755,10 @@
|
||||
"linkedMediaPanel.toast.unlinkFailed": "Échec du déliage du média",
|
||||
"linkedMediaPanel.toast.linked": "Média lié à l'article",
|
||||
"linkedMediaPanel.toast.linkFailed": "Échec de la liaison du média",
|
||||
"editor.dropImage.importing": "Importation de l'image…",
|
||||
"editor.dropImage.success": "Image importée et liée",
|
||||
"editor.dropImage.failed": "Échec de l'importation de l'image déposée",
|
||||
"editor.dropImage.invalidType": "Seuls les fichiers image peuvent être déposés ici",
|
||||
"styleView.title": "Style",
|
||||
"styleView.subtitle": "Sélectionnez un thème Pico CSS et prévisualisez les principaux articles avant application.",
|
||||
"styleView.themePickerAria": "Sélecteur de thème Pico",
|
||||
|
||||
@@ -755,6 +755,10 @@
|
||||
"linkedMediaPanel.toast.unlinkFailed": "Scollegamento media non riuscito",
|
||||
"linkedMediaPanel.toast.linked": "Media collegato al post",
|
||||
"linkedMediaPanel.toast.linkFailed": "Collegamento media non riuscito",
|
||||
"editor.dropImage.importing": "Importazione immagine…",
|
||||
"editor.dropImage.success": "Immagine importata e collegata",
|
||||
"editor.dropImage.failed": "Importazione dell'immagine trascinata non riuscita",
|
||||
"editor.dropImage.invalidType": "Solo file immagine possono essere trascinati qui",
|
||||
"styleView.title": "Stile",
|
||||
"styleView.subtitle": "Seleziona un tema Pico CSS e visualizza l'anteprima dei post principali prima di applicarlo.",
|
||||
"styleView.themePickerAria": "Selettore tema Pico",
|
||||
|
||||
269
src/renderer/plugins/dropImagePlugin.ts
Normal file
269
src/renderer/plugins/dropImagePlugin.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Milkdown Drop Image Plugin
|
||||
*
|
||||
* Handles drag-and-drop of image files from the filesystem into the editor.
|
||||
* Dropped images are imported into the media library, linked to the current
|
||||
* post, and inserted as markdown image nodes. AI analysis generates alt text,
|
||||
* title, and caption automatically.
|
||||
*
|
||||
* This plugin also handles paste events with image files (e.g. screenshots).
|
||||
*/
|
||||
|
||||
import { $prose } from '@milkdown/kit/utils';
|
||||
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
|
||||
import type { EditorView } from '@milkdown/kit/prose/view';
|
||||
|
||||
/** File extensions accepted for image drop/paste. */
|
||||
export const SUPPORTED_IMAGE_EXTENSIONS = new Set([
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp',
|
||||
]);
|
||||
|
||||
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/bmp',
|
||||
]);
|
||||
|
||||
const MIME_TYPE_TO_EXTENSION: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/svg+xml': 'svg',
|
||||
'image/bmp': 'bmp',
|
||||
};
|
||||
|
||||
export interface DropImageImportResult {
|
||||
mediaId: string;
|
||||
alt: string;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
export type ImageImportPayload =
|
||||
| { kind: 'path'; filePath: string }
|
||||
| { kind: 'buffer'; fileName: string; mimeType: string; bytes: Uint8Array };
|
||||
|
||||
export interface ImageImportCallbacks {
|
||||
importFromPath: (postId: string, filePath: string) => Promise<DropImageImportResult | null>;
|
||||
importFromBuffer: (postId: string, payload: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<DropImageImportResult | null>;
|
||||
}
|
||||
|
||||
function getFileExtension(file: Pick<File, 'name'>): string {
|
||||
return file.name.split('.').pop()?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
function normalizeSupportedImageMimeType(file: Pick<File, 'name' | 'type'>): string | null {
|
||||
const mimeType = file.type.trim().toLowerCase();
|
||||
if (SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
const extension = getFileExtension(file);
|
||||
if (extension === 'jpg' || extension === 'jpeg') return 'image/jpeg';
|
||||
if (extension === 'png') return 'image/png';
|
||||
if (extension === 'gif') return 'image/gif';
|
||||
if (extension === 'webp') return 'image/webp';
|
||||
if (extension === 'svg') return 'image/svg+xml';
|
||||
if (extension === 'bmp') return 'image/bmp';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isSupportedImageFile(file: Pick<File, 'name' | 'type'>): boolean {
|
||||
return normalizeSupportedImageMimeType(file) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when every file in the list is a supported image type.
|
||||
* Returns false for empty lists.
|
||||
*/
|
||||
export function hasImageFiles(files: FileList | File[]): boolean {
|
||||
if (!files || files.length === 0) return false;
|
||||
return Array.from(files).every((file) => isSupportedImageFile(file));
|
||||
}
|
||||
|
||||
export async function createImageImportPayload(file: File): Promise<ImageImportPayload | null> {
|
||||
if (!isSupportedImageFile(file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = (file as File & { path?: string }).path;
|
||||
if (filePath) {
|
||||
return { kind: 'path', filePath };
|
||||
}
|
||||
|
||||
if (typeof file.arrayBuffer !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mimeType = normalizeSupportedImageMimeType(file);
|
||||
if (!mimeType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const extension = MIME_TYPE_TO_EXTENSION[mimeType] || getFileExtension(file) || 'png';
|
||||
const trimmedName = file.name.trim();
|
||||
const fileName = trimmedName.length > 0 ? trimmedName : `pasted-image.${extension}`;
|
||||
|
||||
return {
|
||||
kind: 'buffer',
|
||||
fileName,
|
||||
mimeType,
|
||||
bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importImageFile(
|
||||
postId: string,
|
||||
file: File,
|
||||
callbacks: ImageImportCallbacks,
|
||||
): Promise<DropImageImportResult | null> {
|
||||
const payload = await createImageImportPayload(file);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.kind === 'path') {
|
||||
return callbacks.importFromPath(postId, payload.filePath);
|
||||
}
|
||||
|
||||
return callbacks.importFromBuffer(postId, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the drag event contains external files (from OS).
|
||||
* Internal editor drags (text, nodes) are ignored.
|
||||
*/
|
||||
function isDragWithFiles(event: DragEvent): boolean {
|
||||
return !!event.dataTransfer?.types?.includes('Files');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared ref container so the PostEditor can set the postId and callbacks
|
||||
* that the plugin reads at event time.
|
||||
*/
|
||||
export interface DropImageContext {
|
||||
postId: string | null;
|
||||
onDropImportFile: ((postId: string, file: File) => Promise<DropImageImportResult | null>) | null;
|
||||
}
|
||||
|
||||
/** Module-level context ref updated by the PostEditor component. */
|
||||
export const dropImageContext: DropImageContext = {
|
||||
postId: null,
|
||||
onDropImportFile: null,
|
||||
};
|
||||
|
||||
const dropImagePluginKey = new PluginKey('dropImagePlugin');
|
||||
|
||||
/**
|
||||
* Insert a markdown image node at the given position (or cursor).
|
||||
*/
|
||||
function insertImageAtPos(view: EditorView, pos: number, src: string, alt: string): void {
|
||||
const { schema } = view.state;
|
||||
const imageType = schema.nodes.image;
|
||||
if (!imageType) return;
|
||||
|
||||
const node = imageType.create({ src, alt, title: '' });
|
||||
const tr = view.state.tr.insert(pos, node);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process dropped / pasted image files: import each via IPC and insert into
|
||||
* the editor at the given document position.
|
||||
*/
|
||||
async function processImageFiles(view: EditorView, files: File[], pos: number): Promise<void> {
|
||||
const { postId, onDropImportFile } = dropImageContext;
|
||||
if (!postId || !onDropImportFile) return;
|
||||
|
||||
for (const file of files) {
|
||||
const result = await onDropImportFile(postId, file);
|
||||
if (result) {
|
||||
insertImageAtPos(view, pos, result.relativePath, result.alt);
|
||||
// Shift position for subsequent images so they appear in order
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ProseMirror plugin that intercepts drag-and-drop and paste events
|
||||
* containing image files.
|
||||
*/
|
||||
export const dropImagePlugin = $prose(() => {
|
||||
return new Plugin({
|
||||
key: dropImagePluginKey,
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
dragover: (_view: EditorView, event: Event) => {
|
||||
const dragEvent = event as DragEvent;
|
||||
if (!isDragWithFiles(dragEvent)) return false;
|
||||
|
||||
// Check for image files
|
||||
if (dragEvent.dataTransfer?.items) {
|
||||
const hasImages = Array.from(dragEvent.dataTransfer.items).some(
|
||||
(item) => item.kind === 'file' && item.type.startsWith('image/'),
|
||||
);
|
||||
if (hasImages) {
|
||||
dragEvent.preventDefault();
|
||||
// Add visual feedback class
|
||||
const editorEl = (event.target as HTMLElement).closest?.('.milkdown-content');
|
||||
editorEl?.classList.add('drop-target-active');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
dragleave: (_view: EditorView, event: Event) => {
|
||||
const editorEl = (event.target as HTMLElement).closest?.('.milkdown-content');
|
||||
editorEl?.classList.remove('drop-target-active');
|
||||
return false;
|
||||
},
|
||||
|
||||
drop: (view: EditorView, event: Event) => {
|
||||
const dragEvent = event as DragEvent;
|
||||
|
||||
// Remove visual feedback
|
||||
const editorEl = (event.target as HTMLElement).closest?.('.milkdown-content');
|
||||
editorEl?.classList.remove('drop-target-active');
|
||||
|
||||
if (!isDragWithFiles(dragEvent)) return false;
|
||||
|
||||
const files = dragEvent.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return false;
|
||||
if (!hasImageFiles(files)) return false;
|
||||
|
||||
dragEvent.preventDefault();
|
||||
dragEvent.stopPropagation();
|
||||
|
||||
// Determine drop position in the document
|
||||
const dropPos = view.posAtCoords({
|
||||
left: dragEvent.clientX,
|
||||
top: dragEvent.clientY,
|
||||
});
|
||||
const pos = dropPos ? dropPos.pos : view.state.selection.from;
|
||||
|
||||
void processImageFiles(view, Array.from(files), pos);
|
||||
return true;
|
||||
},
|
||||
|
||||
paste: (view: EditorView, event: Event) => {
|
||||
const clipboardEvent = event as ClipboardEvent;
|
||||
const files = clipboardEvent.clipboardData?.files;
|
||||
if (!files || files.length === 0) return false;
|
||||
if (!hasImageFiles(files)) return false;
|
||||
|
||||
clipboardEvent.preventDefault();
|
||||
|
||||
const pos = view.state.selection.from;
|
||||
void processImageFiles(view, Array.from(files), pos);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user