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:
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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