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

@@ -522,33 +522,31 @@ export class MediaEngine extends EventEmitter {
return mimeTypes[ext] || 'application/octet-stream';
}
async importMedia(sourcePath: string, metadata?: Partial<MediaData>): Promise<MediaData> {
private async importMediaBytes(sourceBuffer: Buffer, originalName: string, metadata?: Partial<MediaData>, sourcePath?: string): Promise<MediaData> {
const db = getDatabase().getLocal();
const id = uuidv4();
const now = new Date();
// Use provided createdAt date or current date
const createdAt = metadata?.createdAt ?? now;
const updatedAt = metadata?.updatedAt ?? now;
const sourceBuffer = await fs.readFile(sourcePath);
const originalName = path.basename(sourcePath);
const ext = path.extname(originalName);
const filename = `${id}${ext}`;
// Use date-based directory structure (media/YYYY/MM/) based on createdAt
const mediaDir = this.getMediaDirForDate(createdAt);
await fs.mkdir(mediaDir, { recursive: true });
const destPath = path.join(mediaDir, filename);
// Copy file to media directory
await fs.copyFile(sourcePath, destPath);
if (sourcePath) {
await fs.copyFile(sourcePath, destPath);
} else {
await fs.writeFile(destPath, sourceBuffer);
}
const mimeType = metadata?.mimeType || this.getMimeType(originalName);
let width = metadata?.width;
let height = metadata?.height;
// Get image dimensions using sharp if it's an image
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
try {
const sharp = (await import('sharp')).default;
@@ -582,7 +580,6 @@ export class MediaEngine extends EventEmitter {
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
const checksum = this.calculateChecksum(sourceBuffer);
// Generate thumbnails for images (async, non-blocking)
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
this.generateThumbnails(id, destPath).catch(err => {
console.error('Failed to generate thumbnails:', err);
@@ -613,7 +610,6 @@ export class MediaEngine extends EventEmitter {
await db.insert(media).values(dbMedia);
// Update FTS index
await this.updateFTSIndex({
id: mediaData.id,
projectId: this.currentProjectId,
@@ -629,6 +625,16 @@ export class MediaEngine extends EventEmitter {
return mediaData;
}
async importMedia(sourcePath: string, metadata?: Partial<MediaData>): Promise<MediaData> {
const sourceBuffer = await fs.readFile(sourcePath);
const originalName = path.basename(sourcePath);
return this.importMediaBytes(sourceBuffer, originalName, metadata, sourcePath);
}
async importMediaBuffer(sourceBytes: Uint8Array, originalName: string, metadata?: Partial<MediaData>): Promise<MediaData> {
return this.importMediaBytes(Buffer.from(sourceBytes), originalName, metadata);
}
async updateMedia(id: string, data: Partial<MediaData>): Promise<MediaData | null> {
const db = getDatabase().getLocal();
const existing = await this.getMedia(id);

View File

@@ -973,6 +973,18 @@ export async function autoTranslatePost(postId: string, targetLanguage: string,
}
}
/**
* Analyze a media image for auto-analysis workflows (called from IPC handlers).
*/
export async function autoAnalyzeMediaImage(mediaId: string, language: string): Promise<{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }> {
try {
await ensureInitialized();
return await retryWithBackoff(() => getOneShotTasks().analyzeMediaImage(mediaId, language));
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Translate media metadata for auto-translation workflows (called from event handlers).
*/

View File

@@ -21,9 +21,12 @@ import { registerEmbeddingHandlers } from './embeddingHandlers';
import { isOfflineModeActive } from './chatHandlers';
import type { EngineBundle } from '../engine/EngineBundle';
import { resolveUiLanguageFromSystemLocale, translateMenu } from '../shared/i18n';
import { autoTranslatePost, autoTranslateMediaMetadata } from './chatHandlers';
import { autoTranslatePost, autoTranslateMediaMetadata, autoAnalyzeMediaImage } from './chatHandlers';
import { v4 as uuidv4 } from 'uuid';
const SUPPORTED_DROP_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']);
const SUPPORTED_DROP_IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/bmp']);
/**
* Wrap an IPC handler so that "Database is closing" errors during shutdown
* are silently swallowed instead of being logged as scary red error messages.
@@ -127,6 +130,88 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
}
}
function assertSupportedDropImage(params: { filePath?: string; fileName?: string; mimeType?: string }): void {
const mimeType = params.mimeType?.trim().toLowerCase();
if (mimeType && SUPPORTED_DROP_IMAGE_MIME_TYPES.has(mimeType)) {
return;
}
const nameForExtension = params.filePath || params.fileName || '';
const extension = path.extname(nameForExtension).toLowerCase();
if (SUPPORTED_DROP_IMAGE_EXTENSIONS.has(extension)) {
return;
}
throw new Error('Only image files can be imported into the media library from drag-and-drop or paste.');
}
async function handleDroppedImageImport(
bundle: EngineBundle,
postId: string,
importMedia: (metadata: Partial<MediaData>) => Promise<MediaData>,
): Promise<{ mediaId: string; alt: string; relativePath: string }> {
const mediaEngine = bundle.mediaEngine;
const postMediaEngine = bundle.postMediaEngine;
const postEngine = bundle.postEngine;
const metaEngine = bundle.metaEngine;
const projectMetadata = await metaEngine.getProjectMetadata();
const mainLanguage = projectMetadata?.mainLanguage || 'en';
const blogLanguages: string[] = projectMetadata?.blogLanguages || [];
const metadata: Partial<MediaData> = {};
if (projectMetadata?.defaultAuthor) {
metadata.author = projectMetadata.defaultAuthor;
}
metadata.language = mainLanguage;
const importedMedia = await importMedia(metadata);
const db = getDatabase().getLocal();
const dbMedia = await db.select().from(media).where(eq(media.id, importedMedia.id)).get();
if (dbMedia?.filePath && importedMedia.mimeType.startsWith('image/') && !importedMedia.mimeType.includes('svg')) {
try {
await mediaEngine.generateThumbnails(importedMedia.id, dbMedia.filePath);
} catch {
// Non-critical: AI analysis will fall back to other thumbnail sizes
}
}
await postMediaEngine.linkMediaToPost(postId, importedMedia.id);
let alt = importedMedia.alt || '';
const analysis = await autoAnalyzeMediaImage(importedMedia.id, mainLanguage);
if (analysis.success) {
await mediaEngine.updateMedia(importedMedia.id, {
title: analysis.title,
alt: analysis.alt,
caption: analysis.caption,
language: mainLanguage,
});
alt = analysis.alt || alt;
const otherLanguages = blogLanguages.filter(lang => lang !== mainLanguage);
for (const targetLang of otherLanguages) {
await autoTranslateMediaMetadata(importedMedia.id, targetLang).catch(() => {
// Non-critical: translation failure shouldn't block the drop
});
}
}
const post = await postEngine.getPost(postId);
if (post?.status === 'published') {
await postEngine.updatePost(postId, { content: post.content || '', status: 'draft' });
}
const relativePath = await mediaEngine.getRelativePath(importedMedia.id);
return {
mediaId: importedMedia.id,
alt,
relativePath: relativePath || `media/${importedMedia.filename}`,
};
}
function buildMcpAgentConfigOptions(bundle: EngineBundle): import('../engine/MCPAgentConfigEngine').MCPAgentConfigOptions {
const os = require('os') as typeof import('os');
const scriptPath = app.isPackaged
@@ -1331,7 +1416,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return engine.getProjectMetadata();
});
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('../shared/picoThemes').PicoThemeName; categoryMetadata?: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }>; semanticSimilarityEnabled?: boolean }) => {
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('../shared/picoThemes').PicoThemeName; categoryMetadata?: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }>; semanticSimilarityEnabled?: boolean; blogLanguages?: string[] }) => {
const engine = bundle.metaEngine;
await ensureMetaContext(engine);
const previousMetadata = await engine.getProjectMetadata();
@@ -1470,6 +1555,19 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return engine.importMediaForPost(postId, filePath);
});
safeHandle('postMedia:dropImport', async (_, postId: string, filePath: string) => {
assertSupportedDropImage({ filePath });
return handleDroppedImageImport(bundle, postId, (metadata) => bundle.mediaEngine.importMedia(filePath, metadata));
});
safeHandle('postMedia:dropImportBuffer', async (_, postId: string, payload: { fileName: string; mimeType: string; bytes: Uint8Array }) => {
assertSupportedDropImage({ fileName: payload.fileName, mimeType: payload.mimeType });
return handleDroppedImageImport(bundle, postId, (metadata) => bundle.mediaEngine.importMediaBuffer(payload.bytes, payload.fileName, {
...metadata,
mimeType: payload.mimeType,
}));
});
safeHandle('postMedia:rebuild', async () => {
const engine = bundle.postMediaEngine;
return engine.rebuildFromSidecars();

View File

@@ -149,6 +149,8 @@ export const electronAPI: ElectronAPI = {
reorder: (postId: string, mediaIds: string[]) => ipcRenderer.invoke('postMedia:reorder', postId, mediaIds),
isLinked: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:isLinked', postId, mediaId),
import: (postId: string, filePath: string) => ipcRenderer.invoke('postMedia:import', postId, filePath),
dropImport: (postId: string, filePath: string) => ipcRenderer.invoke('postMedia:dropImport', postId, filePath),
dropImportBuffer: (postId: string, payload: { fileName: string; mimeType: string; bytes: Uint8Array }) => ipcRenderer.invoke('postMedia:dropImportBuffer', postId, payload),
rebuild: () => ipcRenderer.invoke('postMedia:rebuild'),
},
@@ -201,7 +203,7 @@ export const electronAPI: ElectronAPI = {
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'),
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata),
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }>; semanticSimilarityEnabled?: boolean }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }>; semanticSimilarityEnabled?: boolean; blogLanguages?: string[] }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
getPublishingPreferences: () => ipcRenderer.invoke('meta:getPublishingPreferences'),
setPublishingPreferences: (prefs: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) => ipcRenderer.invoke('meta:setPublishingPreferences', prefs),
clearPublishingPreferences: () => ipcRenderer.invoke('meta:clearPublishingPreferences'),

View File

@@ -55,6 +55,13 @@ export interface ProjectMetadata {
categoryMetadata?: Record<string, CategoryMetadata>;
categorySettings?: Record<string, CategoryRenderSettings>;
semanticSimilarityEnabled?: boolean;
blogLanguages?: string[];
}
export interface DropImportBufferPayload {
fileName: string;
mimeType: string;
bytes: Uint8Array;
}
export interface CategoryRenderSettings {
@@ -769,6 +776,8 @@ export interface ElectronAPI {
reorder: (postId: string, mediaIds: string[]) => Promise<void>;
isLinked: (postId: string, mediaId: string) => Promise<boolean>;
import: (postId: string, filePath: string) => Promise<MediaLinkData>;
dropImport: (postId: string, filePath: string) => Promise<{ mediaId: string; alt: string; relativePath: string }>;
dropImportBuffer: (postId: string, payload: DropImportBufferPayload) => Promise<{ mediaId: string; alt: string; relativePath: string }>;
rebuild: () => Promise<void>;
};
sync: {

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