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:
Copilot
2026-03-12 21:04:45 +01:00
committed by GitHub
parent 16d5eb5a28
commit b57e50f4a9
20 changed files with 892 additions and 688 deletions

View File

@@ -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;

View File

@@ -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 = `![${result.alt}](${result.relativePath})`;
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>

View File

@@ -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;

View File

@@ -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)

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View 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;
},
},
},
});
});