mirror of
https://github.com/usememos/memos.git
synced 2025-12-17 22:28:52 +08:00
feat(web): add upload progress UI for attachments in MemoEditor\n\n- Centralize uploads via MemoEditor context to show progress consistently\n- Show per-file progress bars during file selection, drag-and-drop, and paste\n- Disable Save while uploads are in-flight\n- Keep server logic unchanged\n\nRefs #5020
This commit is contained in:
parent
3be1b3a1e3
commit
222c3bb448
3 changed files with 83 additions and 43 deletions
|
|
@ -1,13 +1,10 @@
|
|||
import { t } from "i18next";
|
||||
import { LoaderIcon, PaperclipIcon } from "lucide-react";
|
||||
import mime from "mime";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useContext, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { attachmentStore } from "@/store";
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import { MemoEditorContext } from "../types";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -39,32 +36,15 @@ const UploadAttachmentButton = observer((props: Props) => {
|
|||
uploadingFlag: true,
|
||||
};
|
||||
});
|
||||
|
||||
const createdAttachmentList: Attachment[] = [];
|
||||
try {
|
||||
if (!fileInputRef.current || !fileInputRef.current.files) {
|
||||
return;
|
||||
}
|
||||
for (const file of fileInputRef.current.files) {
|
||||
const { name: filename, size, type } = file;
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const attachment = await attachmentStore.createAttachment({
|
||||
attachment: Attachment.fromPartial({
|
||||
filename,
|
||||
size,
|
||||
type: type || mime.getType(filename) || "text/plain",
|
||||
content: buffer,
|
||||
}),
|
||||
attachmentId: "",
|
||||
});
|
||||
createdAttachmentList.push(attachment);
|
||||
// Delegate to editor's upload handler so progress UI is consistent
|
||||
if (context.uploadFiles) {
|
||||
await context.uploadFiles(fileInputRef.current.files);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
}
|
||||
|
||||
context.setAttachmentList([...context.attachmentList, ...createdAttachmentList]);
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ interface State {
|
|||
relationList: MemoRelation[];
|
||||
location: Location | undefined;
|
||||
isUploadingAttachment: boolean;
|
||||
// Track in-flight uploads for UI progress bars
|
||||
uploadTasks: { id: string; name: string; progress: number }[];
|
||||
isRequesting: boolean;
|
||||
isComposing: boolean;
|
||||
isDraggingFile: boolean;
|
||||
|
|
@ -68,6 +70,7 @@ const MemoEditor = observer((props: Props) => {
|
|||
relationList: [],
|
||||
location: undefined,
|
||||
isUploadingAttachment: false,
|
||||
uploadTasks: [],
|
||||
isRequesting: false,
|
||||
isComposing: false,
|
||||
isDraggingFile: false,
|
||||
|
|
@ -199,16 +202,52 @@ const MemoEditor = observer((props: Props) => {
|
|||
};
|
||||
|
||||
const handleUploadResource = async (file: File) => {
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isUploadingAttachment: true,
|
||||
const taskId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
// Add task entry
|
||||
setState((prev) => ({ ...prev, uploadTasks: [...prev.uploadTasks, { id: taskId, name: file.name, progress: 0 }] }));
|
||||
|
||||
const setTaskProgress = (progress: number) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
uploadTasks: prev.uploadTasks.map((t) => (t.id === taskId ? { ...t, progress } : t)),
|
||||
}));
|
||||
};
|
||||
const removeTask = () => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
uploadTasks: prev.uploadTasks.filter((t) => t.id !== taskId),
|
||||
}));
|
||||
};
|
||||
|
||||
// Read file with progress (up to 30%)
|
||||
const buffer = await new Promise<Uint8Array>((resolve, reject) => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const frac = e.loaded / e.total;
|
||||
setTaskProgress(Math.min(0.3, frac * 0.3));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
|
||||
reader.onload = () => {
|
||||
setTaskProgress(0.3);
|
||||
resolve(new Uint8Array(reader.result as ArrayBuffer));
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
const { name: filename, size, type } = file;
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
// Simulate upload progress while the request is in-flight (30% -> 95%)
|
||||
let current = 0.3;
|
||||
const interval = window.setInterval(() => {
|
||||
current = Math.min(0.95, current + 0.02);
|
||||
setTaskProgress(current);
|
||||
}, 200);
|
||||
|
||||
const { name: filename, size, type } = file;
|
||||
try {
|
||||
const attachment = await attachmentStore.createAttachment({
|
||||
attachment: Attachment.fromPartial({
|
||||
|
|
@ -219,26 +258,22 @@ const MemoEditor = observer((props: Props) => {
|
|||
}),
|
||||
attachmentId: "",
|
||||
});
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isUploadingAttachment: false,
|
||||
};
|
||||
});
|
||||
window.clearInterval(interval);
|
||||
setTaskProgress(1);
|
||||
// Remove task shortly after completion
|
||||
window.setTimeout(removeTask, 500);
|
||||
return attachment;
|
||||
} catch (error: any) {
|
||||
window.clearInterval(interval);
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isUploadingAttachment: false,
|
||||
};
|
||||
});
|
||||
toast.error(error.details ?? "Upload failed");
|
||||
// Remove task on error as well
|
||||
removeTask();
|
||||
}
|
||||
};
|
||||
|
||||
const uploadMultiFiles = async (files: FileList) => {
|
||||
setState((prev) => ({ ...prev, isUploadingAttachment: true }));
|
||||
const uploadedAttachmentList: Attachment[] = [];
|
||||
for (const file of files) {
|
||||
const attachment = await handleUploadResource(file);
|
||||
|
|
@ -261,6 +296,8 @@ const MemoEditor = observer((props: Props) => {
|
|||
attachmentList: [...prevState.attachmentList, ...uploadedAttachmentList],
|
||||
}));
|
||||
}
|
||||
// If no more tasks in-flight, clear uploading flag
|
||||
setState((prev) => ({ ...prev, isUploadingAttachment: false }));
|
||||
};
|
||||
|
||||
const handleDropEvent = async (event: React.DragEvent) => {
|
||||
|
|
@ -478,6 +515,9 @@ const MemoEditor = observer((props: Props) => {
|
|||
relationList,
|
||||
}));
|
||||
},
|
||||
uploadFiles: async (files: FileList) => {
|
||||
await uploadMultiFiles(files);
|
||||
},
|
||||
memoName,
|
||||
}}
|
||||
>
|
||||
|
|
@ -497,6 +537,24 @@ const MemoEditor = observer((props: Props) => {
|
|||
onCompositionEnd={handleCompositionEnd}
|
||||
>
|
||||
<Editor ref={editorRef} {...editorConfig} />
|
||||
{state.uploadTasks.length > 0 && (
|
||||
<div className="w-full mt-2 space-y-2">
|
||||
{state.uploadTasks.map((task) => (
|
||||
<div key={task.id} className="w-full">
|
||||
<div className="flex items-center justify-between text-xs opacity-70 mb-1">
|
||||
<span className="truncate max-w-[70%]">{task.name}</span>
|
||||
<span className="tabular-nums">{Math.round(task.progress * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 rounded bg-muted">
|
||||
<div
|
||||
className="h-1.5 rounded bg-primary transition-[width] duration-200"
|
||||
style={{ width: `${Math.max(2, Math.round(task.progress * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AttachmentListView attachmentList={state.attachmentList} setAttachmentList={handleSetAttachmentList} />
|
||||
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
|
||||
<div className="relative w-full flex flex-row justify-between items-center py-1 gap-2" onFocus={(e) => e.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ interface Context {
|
|||
setAttachmentList: (attachmentList: Attachment[]) => void;
|
||||
setRelationList: (relationList: MemoRelation[]) => void;
|
||||
memoName?: string;
|
||||
// Optional: central upload handler so UI can show progress consistently
|
||||
uploadFiles?: (files: FileList) => Promise<void>;
|
||||
}
|
||||
|
||||
export const MemoEditorContext = createContext<Context>({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue