feat: support image upload in editor with desktop file picker (#12285)

- Add handleShowOpenDialog and handlePickFile IPC methods for Electron
- Create useImageUpload hook for editor image upload with progress
- Refactor ReactImagePlugin config to support handleUpload and onPickFile
- Simplify slash command image insertion by delegating upload to plugin
- Upgrade @lobehub/editor to ^3.16.1
This commit is contained in:
Innei
2026-02-13 01:27:22 +08:00
committed by GitHub
parent 2d1eec4482
commit 9a9147ca7e
8 changed files with 212 additions and 99 deletions

View File

@@ -1,33 +1,37 @@
/* eslint-disable unicorn/no-array-push-push */
import {
EditLocalFileParams,
EditLocalFileResult,
GlobFilesParams,
GlobFilesResult,
GrepContentParams,
GrepContentResult,
ListLocalFileParams,
LocalMoveFilesResultItem,
LocalReadFileParams,
LocalReadFileResult,
LocalReadFilesParams,
LocalSearchFilesParams,
MoveLocalFilesParams,
OpenLocalFileParams,
OpenLocalFolderParams,
RenameLocalFileResult,
ShowSaveDialogParams,
ShowSaveDialogResult,
WriteLocalFileParams,
} from '@lobechat/electron-client-ipc';
import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders';
import { createPatch } from 'diff';
import { dialog, shell } from 'electron';
import { constants } from 'node:fs';
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
import { access, mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
import * as path from 'node:path';
import { FileResult, SearchOptions } from '@/modules/fileSearch';
import {
type EditLocalFileParams,
type EditLocalFileResult,
type GlobFilesParams,
type GlobFilesResult,
type GrepContentParams,
type GrepContentResult,
type ListLocalFileParams,
type LocalMoveFilesResultItem,
type LocalReadFileParams,
type LocalReadFileResult,
type LocalReadFilesParams,
type LocalSearchFilesParams,
type MoveLocalFilesParams,
type OpenLocalFileParams,
type OpenLocalFolderParams,
type PickFileParams,
type PickFileResult,
type RenameLocalFileResult,
type ShowOpenDialogParams,
type ShowOpenDialogResult,
type ShowSaveDialogParams,
type ShowSaveDialogResult,
type WriteLocalFileParams,
} from '@lobechat/electron-client-ipc';
import { loadFile, SYSTEM_FILES_TO_IGNORE } from '@lobechat/file-loaders';
import { createPatch } from 'diff';
import { dialog, shell } from 'electron';
import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
import ContentSearchService from '@/services/contentSearchSrv';
import FileSearchService from '@/services/fileSearchSrv';
import { makeSureDirExist } from '@/utils/file-system';
@@ -85,6 +89,67 @@ export default class LocalFileCtr extends ControllerModule {
}
}
@IpcMethod()
async handleShowOpenDialog({
filters,
multiple,
title,
}: ShowOpenDialogParams): Promise<ShowOpenDialogResult> {
logger.debug('Showing open dialog:', { filters, multiple, title });
const result = await dialog.showOpenDialog({
filters,
properties: multiple ? ['openFile', 'multiSelections'] : ['openFile'],
title,
});
logger.debug('Open dialog result:', { canceled: result.canceled, filePaths: result.filePaths });
return {
canceled: result.canceled,
filePaths: result.filePaths,
};
}
@IpcMethod()
async handlePickFile({ filters, title }: PickFileParams): Promise<PickFileResult> {
logger.debug('Picking file:', { filters, title });
const result = await dialog.showOpenDialog({
filters,
properties: ['openFile'],
title,
});
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true };
}
const filePath = result.filePaths[0];
const data = await readFile(filePath);
const name = path.basename(filePath);
const ext = path.extname(filePath).toLowerCase().slice(1);
const MIME_MAP: Record<string, string> = {
avif: 'image/avif',
gif: 'image/gif',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
png: 'image/png',
svg: 'image/svg+xml',
webp: 'image/webp',
};
return {
canceled: false,
file: {
data: new Uint8Array(data),
mimeType: MIME_MAP[ext] || 'application/octet-stream',
name,
},
};
}
@IpcMethod()
async handleShowSaveDialog({
defaultPath,

View File

@@ -217,7 +217,7 @@
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@lobehub/chat-plugins-gateway": "^1.9.0",
"@lobehub/desktop-ipc-typings": "workspace:*",
"@lobehub/editor": "^3.14.0",
"@lobehub/editor": "^3.16.1",
"@lobehub/icons": "^4.1.0",
"@lobehub/market-sdk": "0.29.2",
"@lobehub/tts": "^4.0.2",

View File

@@ -273,6 +273,48 @@ export interface EditLocalFileResult {
success: boolean;
}
// Open Dialog types
export interface ShowOpenDialogParams {
/**
* File type filters
*/
filters?: { extensions: string[]; name: string }[];
/**
* Allow selecting multiple files
*/
multiple?: boolean;
/**
* Dialog title
*/
title?: string;
}
export interface ShowOpenDialogResult {
/**
* Whether the dialog was cancelled
*/
canceled: boolean;
/**
* The selected file paths (empty if cancelled)
*/
filePaths: string[];
}
// Pick File (dialog + read in one IPC call)
export interface PickFileParams {
filters?: { extensions: string[]; name: string }[];
title?: string;
}
export interface PickFileResult {
canceled: boolean;
file?: {
data: Uint8Array;
mimeType: string;
name: string;
};
}
// Save Dialog types
export interface ShowSaveDialogParams {
/**

View File

@@ -1,6 +1,7 @@
'use client';
import type { IEditor } from '@lobehub/editor';
import { isDesktop } from '@lobechat/const';
import { type IEditor } from '@lobehub/editor';
import {
ReactCodemirrorPlugin,
ReactCodePlugin,
@@ -14,16 +15,21 @@ import {
ReactToolbarPlugin,
} from '@lobehub/editor';
import { Editor, useEditorState } from '@lobehub/editor/react';
import { memo, useEffect, useMemo, useRef } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { EditorCanvasProps } from './EditorCanvas';
import { type EditorCanvasProps } from './EditorCanvas';
import InlineToolbar from './InlineToolbar';
import { useImageUpload } from './useImageUpload';
const IMAGE_FILTERS = [
{ extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif'], name: 'Images' },
];
/**
* Base plugins for the editor (without toolbar)
* Base plugins for the editor (without image and toolbar, which need dynamic config)
*/
const BASE_PLUGINS = [
const STATIC_PLUGINS = [
ReactLiteXmlPlugin,
ReactListPlugin,
ReactCodePlugin,
@@ -32,9 +38,6 @@ const BASE_PLUGINS = [
ReactLinkPlugin,
ReactTablePlugin,
ReactMathPlugin,
Editor.withProps(ReactImagePlugin, {
defaultBlockImage: true,
}),
];
export interface InternalEditorProps extends EditorCanvasProps {
@@ -62,6 +65,19 @@ const InternalEditor = memo<InternalEditorProps>(
}) => {
const { t } = useTranslation('file');
const editorState = useEditorState(editor);
const handleImageUpload = useImageUpload();
const handlePickFile = useCallback(async (): Promise<File | null> => {
if (!isDesktop) return null;
const { ensureElectronIpc } = await import('@/utils/electron/ipc');
const ipc = ensureElectronIpc();
const result = await (ipc as any).localSystem.handlePickFile({
filters: IMAGE_FILTERS,
});
if (result.canceled || !result.file) return null;
const { data, mimeType, name } = result.file;
return new File([data], name, { type: mimeType });
}, []);
const finalPlaceholder = placeholder || t('pageEditor.editorPlaceholder');
@@ -70,8 +86,16 @@ const InternalEditor = memo<InternalEditorProps>(
// If custom plugins provided, use them directly
if (customPlugins) return customPlugins;
const imagePlugin = Editor.withProps(ReactImagePlugin, {
defaultBlockImage: true,
handleUpload: handleImageUpload,
onPickFile: isDesktop ? handlePickFile : undefined,
});
// Build base plugins with optional extra plugins prepended
const basePlugins = extraPlugins ? [...extraPlugins, ...BASE_PLUGINS] : BASE_PLUGINS;
const basePlugins = extraPlugins
? [...extraPlugins, ...STATIC_PLUGINS, imagePlugin]
: [...STATIC_PLUGINS, imagePlugin];
// Add toolbar if enabled
if (floatingToolbar) {
@@ -91,7 +115,16 @@ const InternalEditor = memo<InternalEditorProps>(
}
return basePlugins;
}, [customPlugins, editor, editorState, extraPlugins, floatingToolbar, toolbarExtraItems]);
}, [
customPlugins,
editor,
editorState,
extraPlugins,
floatingToolbar,
handleImageUpload,
handlePickFile,
toolbarExtraItems,
]);
useEffect(() => {
// for easier debug, mount editor instance to window

View File

@@ -7,3 +7,4 @@ export {
} from './EditorCanvas';
export { EditorErrorBoundary } from './ErrorBoundary';
export { default as InlineToolbar, type InlineToolbarProps } from './InlineToolbar';
export { useImageUpload } from './useImageUpload';

View File

@@ -0,0 +1,28 @@
import { useCallback } from 'react';
import { useFileStore } from '@/store/file';
/**
* Shared hook for editor image upload.
* Returns a handler compatible with ReactImagePlugin's `handleUpload` signature.
*/
export const useImageUpload = () => {
const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
return useCallback(
async (file: File): Promise<{ url: string }> => {
try {
const result = await uploadWithProgress({
file,
skipCheckFileType: false,
source: 'page-editor',
});
if (!result) throw new Error('Upload returned empty result');
return { url: result.url };
} catch (error) {
throw new Error('Image upload failed', { cause: error });
}
},
[uploadWithProgress],
);
};

View File

@@ -21,7 +21,7 @@ const EditorCanvas = memo<EditorCanvasProps>(({ placeholder, style }) => {
const editor = usePageEditorStore((s) => s.editor);
const documentId = usePageEditorStore((s) => s.documentId);
const slashItems = useSlashItems(editor);
const slashItems = useSlashItems();
const askCopilotItem = useAskCopilotItem(editor);
return (

View File

@@ -1,4 +1,4 @@
import { type IEditor, type SlashOptions } from '@lobehub/editor';
import { type SlashOptions } from '@lobehub/editor';
import {
INSERT_CHECK_LIST_COMMAND,
INSERT_CODEMIRROR_COMMAND,
@@ -28,66 +28,10 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { openFileSelector } from '@/features/EditorCanvas';
import { useFileStore } from '@/store/file';
export const useSlashItems = (editor: IEditor | undefined): SlashOptions['items'] => {
export const useSlashItems = (): SlashOptions['items'] => {
const { t } = useTranslation('editor');
const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
const handleImageUpload = async (file: File) => {
if (!editor) return;
try {
// Create a blob URL for immediate preview
const blobUrl = URL.createObjectURL(file);
// Insert the image immediately with blob URL for preview
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
file,
});
// Upload the image to storage in background
const result = await uploadWithProgress({
file,
skipCheckFileType: false,
source: 'page-editor',
});
if (result) {
// Replace the blob URL with permanent URL in the editor state
const currentDoc = editor.getDocument('json');
if (currentDoc) {
// Recursively search and replace blob URL with permanent URL
const replaceBlobUrl = (obj: any): any => {
if (typeof obj === 'string' && obj.startsWith('blob:')) {
// Replace any blob URL with our permanent URL
return result.url;
}
if (obj && typeof obj === 'object') {
const newObj: any = Array.isArray(obj) ? [] : {};
const entries = Object.entries(obj);
for (const [key, value] of entries) {
newObj[key] = replaceBlobUrl(value);
}
return newObj;
}
return obj;
};
const updatedDoc = replaceBlobUrl(currentDoc);
editor.setDocument('json', JSON.stringify(updatedDoc));
}
// Clean up the blob URL
URL.revokeObjectURL(blobUrl);
console.log('Image uploaded and URL updated:', result.url);
}
} catch (error) {
console.error('Failed to upload image:', error);
}
};
return useMemo(() => {
const data: SlashOptions['items'] = [
{
@@ -148,11 +92,11 @@ export const useSlashItems = (editor: IEditor | undefined): SlashOptions['items'
icon: ImageIcon,
key: 'image',
label: t('typobar.image'),
onSelect: () => {
onSelect: (editor) => {
openFileSelector((files) => {
for (const file of files) {
if (file && file.type.startsWith('image/')) {
void handleImageUpload(file);
editor.dispatchCommand(INSERT_IMAGE_COMMAND, { file });
}
}
}, 'image/*');