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 { t } from "i18next";
|
||||||
import { LoaderIcon, PaperclipIcon } from "lucide-react";
|
import { LoaderIcon, PaperclipIcon } from "lucide-react";
|
||||||
import mime from "mime";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useContext, useRef, useState } from "react";
|
import { useContext, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
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";
|
import { MemoEditorContext } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -39,32 +36,15 @@ const UploadAttachmentButton = observer((props: Props) => {
|
||||||
uploadingFlag: true,
|
uploadingFlag: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdAttachmentList: Attachment[] = [];
|
|
||||||
try {
|
try {
|
||||||
if (!fileInputRef.current || !fileInputRef.current.files) {
|
// Delegate to editor's upload handler so progress UI is consistent
|
||||||
return;
|
if (context.uploadFiles) {
|
||||||
}
|
await context.uploadFiles(fileInputRef.current.files);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.details);
|
toast.error(error.details);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.setAttachmentList([...context.attachmentList, ...createdAttachmentList]);
|
|
||||||
setState((state) => {
|
setState((state) => {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ interface State {
|
||||||
relationList: MemoRelation[];
|
relationList: MemoRelation[];
|
||||||
location: Location | undefined;
|
location: Location | undefined;
|
||||||
isUploadingAttachment: boolean;
|
isUploadingAttachment: boolean;
|
||||||
|
// Track in-flight uploads for UI progress bars
|
||||||
|
uploadTasks: { id: string; name: string; progress: number }[];
|
||||||
isRequesting: boolean;
|
isRequesting: boolean;
|
||||||
isComposing: boolean;
|
isComposing: boolean;
|
||||||
isDraggingFile: boolean;
|
isDraggingFile: boolean;
|
||||||
|
|
@ -68,6 +70,7 @@ const MemoEditor = observer((props: Props) => {
|
||||||
relationList: [],
|
relationList: [],
|
||||||
location: undefined,
|
location: undefined,
|
||||||
isUploadingAttachment: false,
|
isUploadingAttachment: false,
|
||||||
|
uploadTasks: [],
|
||||||
isRequesting: false,
|
isRequesting: false,
|
||||||
isComposing: false,
|
isComposing: false,
|
||||||
isDraggingFile: false,
|
isDraggingFile: false,
|
||||||
|
|
@ -199,16 +202,52 @@ const MemoEditor = observer((props: Props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUploadResource = async (file: File) => {
|
const handleUploadResource = async (file: File) => {
|
||||||
setState((state) => {
|
const taskId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
return {
|
// Add task entry
|
||||||
...state,
|
setState((prev) => ({ ...prev, uploadTasks: [...prev.uploadTasks, { id: taskId, name: file.name, progress: 0 }] }));
|
||||||
isUploadingAttachment: true,
|
|
||||||
};
|
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;
|
// Simulate upload progress while the request is in-flight (30% -> 95%)
|
||||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
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 {
|
try {
|
||||||
const attachment = await attachmentStore.createAttachment({
|
const attachment = await attachmentStore.createAttachment({
|
||||||
attachment: Attachment.fromPartial({
|
attachment: Attachment.fromPartial({
|
||||||
|
|
@ -219,26 +258,22 @@ const MemoEditor = observer((props: Props) => {
|
||||||
}),
|
}),
|
||||||
attachmentId: "",
|
attachmentId: "",
|
||||||
});
|
});
|
||||||
setState((state) => {
|
window.clearInterval(interval);
|
||||||
return {
|
setTaskProgress(1);
|
||||||
...state,
|
// Remove task shortly after completion
|
||||||
isUploadingAttachment: false,
|
window.setTimeout(removeTask, 500);
|
||||||
};
|
|
||||||
});
|
|
||||||
return attachment;
|
return attachment;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
window.clearInterval(interval);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.details);
|
toast.error(error.details ?? "Upload failed");
|
||||||
setState((state) => {
|
// Remove task on error as well
|
||||||
return {
|
removeTask();
|
||||||
...state,
|
|
||||||
isUploadingAttachment: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadMultiFiles = async (files: FileList) => {
|
const uploadMultiFiles = async (files: FileList) => {
|
||||||
|
setState((prev) => ({ ...prev, isUploadingAttachment: true }));
|
||||||
const uploadedAttachmentList: Attachment[] = [];
|
const uploadedAttachmentList: Attachment[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const attachment = await handleUploadResource(file);
|
const attachment = await handleUploadResource(file);
|
||||||
|
|
@ -261,6 +296,8 @@ const MemoEditor = observer((props: Props) => {
|
||||||
attachmentList: [...prevState.attachmentList, ...uploadedAttachmentList],
|
attachmentList: [...prevState.attachmentList, ...uploadedAttachmentList],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
// If no more tasks in-flight, clear uploading flag
|
||||||
|
setState((prev) => ({ ...prev, isUploadingAttachment: false }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDropEvent = async (event: React.DragEvent) => {
|
const handleDropEvent = async (event: React.DragEvent) => {
|
||||||
|
|
@ -478,6 +515,9 @@ const MemoEditor = observer((props: Props) => {
|
||||||
relationList,
|
relationList,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
uploadFiles: async (files: FileList) => {
|
||||||
|
await uploadMultiFiles(files);
|
||||||
|
},
|
||||||
memoName,
|
memoName,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -497,6 +537,24 @@ const MemoEditor = observer((props: Props) => {
|
||||||
onCompositionEnd={handleCompositionEnd}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
>
|
>
|
||||||
<Editor ref={editorRef} {...editorConfig} />
|
<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} />
|
<AttachmentListView attachmentList={state.attachmentList} setAttachmentList={handleSetAttachmentList} />
|
||||||
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
|
<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()}>
|
<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;
|
setAttachmentList: (attachmentList: Attachment[]) => void;
|
||||||
setRelationList: (relationList: MemoRelation[]) => void;
|
setRelationList: (relationList: MemoRelation[]) => void;
|
||||||
memoName?: string;
|
memoName?: string;
|
||||||
|
// Optional: central upload handler so UI can show progress consistently
|
||||||
|
uploadFiles?: (files: FileList) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemoEditorContext = createContext<Context>({
|
export const MemoEditorContext = createContext<Context>({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue