feat(markdown): upload and insert images (cloudreve/cloudreve#2593)

This commit is contained in:
Aaron Liu 2025-08-21 11:47:12 +08:00
parent 0feed521f0
commit b7bfbffda5
9 changed files with 125 additions and 18 deletions

View File

@ -1,7 +1,8 @@
import { Box, Skeleton, useTheme } from "@mui/material";
import { lazy, Suspense, useEffect, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { getEntityContent } from "../../../redux/thunks/file.ts";
import { markdownImagePreviewHandler } from "../../../redux/thunks/viewer.ts";
import Header from "../Sidebar/Header.tsx";
const MarkdownEditor = lazy(() => import("../../Viewers/MarkdownEditor/Editor.tsx"));
@ -40,6 +41,13 @@ const ReadMeContent = () => {
}
}, [readMeTarget]);
const imagePreviewHandler = useCallback(
async (imageSource: string) => {
return dispatch(markdownImagePreviewHandler(imageSource, readMeTarget?.path ?? ""));
},
[dispatch, readMeTarget],
);
return (
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
<Header target={readMeTarget} variant={"readme"} />
@ -62,6 +70,7 @@ const ReadMeContent = () => {
readOnly={true}
onChange={() => {}}
initialValue={value}
imagePreviewHandler={imagePreviewHandler}
/>
</Suspense>
)}

View File

@ -3,7 +3,12 @@ import { useSnackbar } from "notistack";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ContextMenuTypes } from "../../redux/fileManagerSlice.ts";
import { closeUploadTaskList, openUploadTaskList, setUploadProgress } from "../../redux/globalStateSlice.ts";
import {
closeUploadTaskList,
openUploadTaskList,
setUploadProgress,
setUploadRawFiles,
} from "../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../redux/hooks.ts";
import { refreshFileList, updateUserCapacity } from "../../redux/thunks/filemanager.ts";
import SessionManager, { UserSettings } from "../../session";
@ -38,6 +43,8 @@ const Uploader = () => {
const policy = useAppSelector((state) => state.fileManager[FileManagerIndex.main].list?.storage_policy);
const selectFileSignal = useAppSelector((state) => state.globalState.uploadFileSignal);
const selectFolderSignal = useAppSelector((state) => state.globalState.uploadFolderSignal);
const uploadRawPromiseId = useAppSelector((state) => state.globalState.uploadRawPromiseId);
const uploadRawFiles = useAppSelector((state) => state.globalState.uploadRawFiles);
const displayOpt = useActionDisplayOpt([], ContextMenuTypes.empty, parent, FileManagerIndex.main);
@ -160,6 +167,15 @@ const Uploader = () => {
[uploadManager, taskAdded, handleUploaderError, dispatch, getClipboardFileName],
);
useEffect(() => {
if (uploadRawFiles && uploadRawFiles.length > 0) {
uploadManager.addRawFiles(uploadRawFiles, getClipboardFileName, uploadRawPromiseId).catch((e) => {
handleUploaderError(e);
});
dispatch(setUploadRawFiles({ files: [], promiseId: [] }));
}
}, [uploadRawFiles, uploadRawPromiseId, handleUploaderError, uploadManager]);
useEffect(() => {
const unfinished = uploadManager.resumeTasks();
setUploaders((uploaders) => [

View File

@ -5,6 +5,7 @@ import Logger, { LogLevel } from "./logger";
import { Task, TaskType } from "./types";
import Base, { MessageColor } from "./uploader/base";
import COS from "./uploader/cos";
import KS3 from "./uploader/ks3";
import Local from "./uploader/local";
import OBS from "./uploader/obs.ts";
import OneDrive from "./uploader/onedrive";
@ -13,7 +14,6 @@ import ResumeHint from "./uploader/placeholder";
import Qiniu from "./uploader/qiniu";
import Remote from "./uploader/remote";
import S3 from "./uploader/s3";
import KS3 from "./uploader/ks3";
import Upyun from "./uploader/upyun";
import {
cleanupResumeCtx,
@ -196,13 +196,24 @@ export default class UploadManager {
cleanupResumeCtx(this.logger);
};
public addRawFiles = async (files: File[], getName?: (file: File) => string) => {
public addRawFiles = async (
files: File[],
getName?: (file: File) => string,
promiseIds?: string[],
): Promise<Base[] | undefined> => {
if (!this.currentPath) {
return;
return undefined;
}
const uploaders = await new Promise<Base[]>((resolve, reject) =>
this.addFiles(files, this.currentPath ?? defaultPath, resolve, reject, getName),
);
if (promiseIds) {
uploaders.forEach((u, i) => {
if (promiseIds[i]) {
u.promiseId = promiseIds[i];
}
});
}
this.o.onProactiveFileAdded && this.o.onProactiveFileAdded(uploaders);
};

View File

@ -1,15 +1,15 @@
// 所有 Uploader 的基类
import { Task } from "../types";
import UploadManager from "../index";
import Logger from "../logger";
import { validate } from "../utils/validator";
import { CancelToken } from "../utils/request";
import axios, { CanceledError, CancelTokenSource } from "axios";
import { createUploadSession, deleteUploadSession } from "../api";
import * as utils from "../utils";
import { UploaderError } from "../errors";
import { PolicyType } from "../../../../api/explorer.ts";
import CrUri from "../../../../util/uri.ts";
import { createUploadSession, deleteUploadSession } from "../api";
import { UploaderError } from "../errors";
import UploadManager from "../index";
import Logger from "../logger";
import { Task } from "../types";
import * as utils from "../utils";
import { CancelToken } from "../utils/request";
import { validate } from "../utils/validator";
export enum Status {
added,
@ -65,6 +65,13 @@ const resumePolicy = [
];
const deleteUploadSessionDelay = 500;
export const uploadPromisePool: {
[key: string]: {
resolve: (value: Task | PromiseLike<Task>) => void;
reject: (reason?: any) => void;
};
} = {};
export default abstract class Base {
public child?: Base[];
public status: Status = Status.added;
@ -81,6 +88,7 @@ export default abstract class Base {
public lastTime = Date.now();
public startTime = Date.now();
public promiseId: string | undefined;
constructor(
public task: Task,
@ -229,6 +237,21 @@ export default abstract class Base {
protected transit(status: Status) {
this.status = status;
if (this.promiseId && status === Status.finished) {
const promise = uploadPromisePool[this.promiseId];
delete uploadPromisePool[this.promiseId];
this.promiseId = undefined;
if (promise) {
switch (status) {
case Status.finished:
promise.resolve(this.task);
break;
default:
promise.reject(this.error);
break;
}
}
}
this.subscriber.onTransition(status);
}

View File

@ -56,6 +56,7 @@ export interface MarkdownEditorProps {
onSaveShortcut?: () => void;
imageAutocompleteSuggestions?: string[] | null;
imagePreviewHandler?: (imageSource: string) => Promise<string>;
imageUploadHandler?: ((image: File) => Promise<string>) | null;
}
function whenInAdmonition(editorInFocus: EditorInFocus | null) {
@ -196,9 +197,7 @@ const MarkdownEditor = (props: MarkdownEditorProps) => {
linkPlugin(),
linkDialogPlugin(),
imagePlugin({
imageUploadHandler: () => {
return Promise.resolve("https://picsum.photos/200/300");
},
imageUploadHandler: props.imageUploadHandler,
imagePreviewHandler: props.imagePreviewHandler,
imageAutocompleteSuggestions: props.imageAutocompleteSuggestions ?? undefined,
}),

View File

@ -9,6 +9,7 @@ import {
markdownImageAutocompleteSuggestions,
markdownImagePreviewHandler,
saveMarkdown,
uploadMarkdownImage,
} from "../../../redux/thunks/viewer.ts";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
import useActionDisplayOpt, { canUpdate } from "../../FileManager/ContextMenu/useActionDisplayOpt.ts";
@ -124,6 +125,13 @@ const MarkdownViewer = () => {
[dispatch, viewerState?.file?.path],
);
const onImageUpload = useCallback(
async (file: File): Promise<string> => {
return dispatch(uploadMarkdownImage(file));
},
[dispatch],
);
return (
<ViewerDialog
file={viewerState?.file}
@ -179,6 +187,7 @@ const MarkdownViewer = () => {
onSaveShortcut={onSaveShortcut}
imagePreviewHandler={imagePreviewHandler}
imageAutocompleteSuggestions={imageAutocompleteSuggestions}
imageUploadHandler={onImageUpload}
/>
</Suspense>
)}

View File

@ -232,6 +232,8 @@ export interface GlobalStateSlice {
uploadTaskCount?: number;
uploadTaskListOpen?: boolean;
uploadFromClipboardDialogOpen?: boolean;
uploadRawFiles?: File[];
uploadRawPromiseId?: string[];
policyOptionCache?: StoragePolicy[];
@ -272,6 +274,16 @@ export const globalStateSlice = createSlice({
name: "globalState",
initialState,
reducers: {
setUploadRawFiles: (
state,
action: PayloadAction<{
files: File[];
promiseId: string[];
}>,
) => {
state.uploadRawFiles = action.payload.files ?? [];
state.uploadRawPromiseId = action.payload.promiseId ?? [];
},
setShareReadmeDetect: (state, action: PayloadAction<boolean>) => {
state.shareReadmeDetect = action.payload ? (state.shareReadmeDetect ?? 0) + 1 : 0;
},
@ -737,6 +749,7 @@ export const globalStateSlice = createSlice({
export default globalStateSlice.reducer;
export const {
setUploadRawFiles,
setMobileDrawerOpen,
setDirectLinkDialog,
closeDirectLinkDialog,

View File

@ -8,6 +8,8 @@ import { getPaginationState } from "../../component/FileManager/Pagination/Pagin
import { Condition, ConditionType } from "../../component/FileManager/Search/AdvanceSearch/ConditionBox.tsx";
import { MinPageSize } from "../../component/FileManager/TopBar/ViewOptionPopover.tsx";
import { SelectType } from "../../component/Uploader/core";
import { Task } from "../../component/Uploader/core/types.ts";
import { uploadPromisePool } from "../../component/Uploader/core/uploader/base.ts";
import { defaultPath } from "../../hooks/useNavigation.tsx";
import { router } from "../../router";
import SessionManager, { UserSettings } from "../../session";
@ -49,9 +51,11 @@ import {
setSearchPopup,
setShareReadmeDetect,
setUploadFromClipboardDialog,
setUploadRawFiles,
} from "../globalStateSlice.ts";
import { Viewers, ViewersByID } from "../siteConfigSlice.ts";
import { AppThunk } from "../store.ts";
import { promiseId } from "./dialog.ts";
import { deleteFile, openFileContextMenu } from "./file.ts";
import { queueLoadShareInfo } from "./share.ts";
import { openViewer } from "./viewer.ts";
@ -785,6 +789,21 @@ export function applyGalleryWidth(index: number, width: number): AppThunk {
};
}
export function uploadRawFile(files: File): AppThunk<Promise<Task>> {
return async (dispatch, _getState) => {
const id = promiseId();
return new Promise<Task>((resolve, reject) => {
uploadPromisePool[id] = { resolve, reject };
dispatch(
setUploadRawFiles({
files: [files],
promiseId: [id],
}),
);
});
};
}
function sortByLocalCompare(files: FileResponse[], mixed?: boolean, isDesc?: boolean): FileResponse[] {
if (files.length === 0) {
return files;

View File

@ -35,6 +35,7 @@ import { Viewers, ViewersByID } from "../siteConfigSlice.ts";
import { AppThunk } from "../store.ts";
import { askSaveAs, askStaleVersionAction } from "./dialog.ts";
import { longRunningTaskWithSnackbar, refreshSingleFileSymbolicLinks } from "./file.ts";
import { uploadRawFile } from "./filemanager.ts";
export interface ExpandedViewerSetting {
[key: string]: Viewer[];
@ -718,8 +719,8 @@ export function markdownImagePreviewHandler(imageSource: string, mdFileUri: stri
}
try {
const file = await dispatch(getFileInfo({ uri: uri.toString() }));
const fileUrl = await dispatch(getFileEntityUrl({ uris: [getFileLinkedUri(file)], entity: file.primary_entity }));
const file = await dispatch(getFileInfo({ uri: uri.toString() }, true));
const fileUrl = await dispatch(getFileEntityUrl({ uris: [getFileLinkedUri(file)] }));
return fileUrl.urls[0].url;
} catch (e) {
return BROKEN_IMG_URI;
@ -742,3 +743,10 @@ export function markdownImageAutocompleteSuggestions(): AppThunk<string[] | null
return suggestions.map((f) => f.name);
};
}
export function uploadMarkdownImage(file: File): AppThunk<Promise<string>> {
return async (dispatch, getState) => {
const task = await dispatch(uploadRawFile(file));
return task.name;
};
}