mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ 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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,3 +7,4 @@ export {
|
||||
} from './EditorCanvas';
|
||||
export { EditorErrorBoundary } from './ErrorBoundary';
|
||||
export { default as InlineToolbar, type InlineToolbarProps } from './InlineToolbar';
|
||||
export { useImageUpload } from './useImageUpload';
|
||||
|
||||
28
src/features/EditorCanvas/useImageUpload.ts
Normal file
28
src/features/EditorCanvas/useImageUpload.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
@@ -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/*');
|
||||
|
||||
Reference in New Issue
Block a user