From ddd19b7eae7074369468312d105b10725c3baede Mon Sep 17 00:00:00 2001 From: hyrious Date: Mon, 5 Jun 2023 15:52:32 +0800 Subject: [PATCH 1/2] refactor(flat-pages): drop/paste images to temporary storage --- packages/flat-i18n/locales/en.json | 2 + packages/flat-i18n/locales/zh-CN.json | 2 + .../src/utils/drag-and-drop/image.ts | 69 ++++++++++++++----- packages/flat-server-api/src/storage.ts | 36 ++++++++++ 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/packages/flat-i18n/locales/en.json b/packages/flat-i18n/locales/en.json index 9a1dd0f135e..9d222db6b43 100644 --- a/packages/flat-i18n/locales/en.json +++ b/packages/flat-i18n/locales/en.json @@ -530,6 +530,7 @@ "pencil-tail": "Pencil tail" }, "upload-avatar-size-limit": "Avatar size should be less than 5MB", + "upload-image-size-limit": "Image size should be less than 5MB", "applications": "Applications", "developer": "Developer", "oauth-apps": "OAuth Apps", @@ -605,6 +606,7 @@ "teacher-has-turn-off-camera": "Teacher has turned your camera off", "teacher-has-turn-off-mic": "Teacher has turned your microphone off", "upload-concurrent-limit": "You have reached the upload concurrent limit", + "upload-image-concurrent-limit": "You have reached the upload limit of screenshot images", "file-is-too-big": "File is too big", "file-not-found": "File not found", "file-already-exists": "File already exists", diff --git a/packages/flat-i18n/locales/zh-CN.json b/packages/flat-i18n/locales/zh-CN.json index 7d0d000aaab..f67ea14bea0 100644 --- a/packages/flat-i18n/locales/zh-CN.json +++ b/packages/flat-i18n/locales/zh-CN.json @@ -530,6 +530,7 @@ "pencil-tail": "开启铅笔笔锋" }, "upload-avatar-size-limit": "头像大小不能超过 5MB", + "upload-image-size-limit": "图片大小不能超过 5MB", "applications": "应用管理", "developer": "开发者设置", "oauth-apps": "OAuth 应用", @@ -605,6 +606,7 @@ "teacher-has-turn-off-camera": "老师已关闭你的摄像头", "teacher-has-turn-off-mic": "老师已关闭你的麦克风", "upload-concurrent-limit": "上传并发数达到上限", + "upload-image-concurrent-limit": "今日上传截图数达到上限", "file-is-too-big": "文件过大", "file-not-found": "文件不存在", "file-already-exists": "文件已存在", diff --git a/packages/flat-pages/src/utils/drag-and-drop/image.ts b/packages/flat-pages/src/utils/drag-and-drop/image.ts index 9a07e8832d3..d5028332585 100644 --- a/packages/flat-pages/src/utils/drag-and-drop/image.ts +++ b/packages/flat-pages/src/utils/drag-and-drop/image.ts @@ -1,9 +1,17 @@ +import Axios from "axios"; import { message } from "antd"; import { v4 as v4uuid } from "uuid"; import { ApplianceNames, Room, Size } from "white-web-sdk"; -import { listFiles } from "@netless/flat-server-api"; -import { CloudStorageStore, UploadTask } from "@netless/flat-stores"; +import { CloudStorageStore } from "@netless/flat-stores"; import { FlatI18n } from "@netless/flat-i18n"; +import { + RequestErrorCode, + UploadTempPhotoResult, + isServerRequestError, + uploadTempPhotoFinish, + uploadTempPhotoStart, +} from "@netless/flat-server-api"; +import { CLOUD_STORAGE_OSS_ALIBABA_CONFIG } from "../../constants/process"; const ImageFileTypes = [ "image/png", @@ -22,41 +30,70 @@ export function isSupportedImageType(file: File): boolean { return ImageFileTypes.includes(file.type); } +const TEMP_IMAGE_SIZE_LIMIT = 5242880; // 5MB + export async function onDropImage( file: File, x: number, y: number, room: Room, - cloudStorageStore: CloudStorageStore, + _cloudStorageStore: CloudStorageStore, ): Promise { if (!isSupportedImageType(file)) { console.log("[dnd:image] unsupported file type:", file.type, file.name); return; } + if (file.size > TEMP_IMAGE_SIZE_LIMIT) { + message.info(FlatI18n.t("upload-image-size-limit")); + throw new Error("upload image size limit"); + } + const hideLoading = message.loading(FlatI18n.t("inserting-courseware-tips")); const getSize = getImageSize(file); - const task = new UploadTask(file, cloudStorageStore.parentDirectoryPath); - await task.upload(); - const { files } = await listFiles({ - page: 1, - order: "DESC", - directoryPath: cloudStorageStore.parentDirectoryPath, + + let ticket: UploadTempPhotoResult; + try { + ticket = await uploadTempPhotoStart(file.name, file.size); + } catch (err) { + if (isServerRequestError(err) && err.errorCode === RequestErrorCode.UploadConcurrentLimit) { + message.error(FlatI18n.t("upload-image-concurrent-limit")); + } + throw err; + } + + const fileURL = `${ticket.ossDomain}/${ticket.ossFilePath}`; + + const formData = new FormData(); + const encodedFileName = encodeURIComponent(file.name); + formData.append("key", ticket.ossFilePath); + formData.append("name", file.name); + formData.append("policy", ticket.policy); + formData.append("OSSAccessKeyId", CLOUD_STORAGE_OSS_ALIBABA_CONFIG.accessKey); + formData.append("success_action_status", "200"); + formData.append("callback", ""); + formData.append("signature", ticket.signature); + formData.append( + "Content-Disposition", + `attachment; filename="${encodedFileName}"; filename*=UTF-8''${encodedFileName}`, + ); + formData.append("file", file); + + await Axios.post(ticket.ossDomain, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, }); - const cloudFile = files.find(f => f.fileUUID === task.fileUUID); - hideLoading(); + await uploadTempPhotoFinish(ticket.fileUUID); - if (!cloudFile?.fileURL) { - console.log("[dnd:image] upload failed:", file.name); - return; - } + hideLoading(); const uuid = v4uuid(); const { width, height } = await getSize; room.insertImage({ uuid, centerX: x, centerY: y, width, height, locked: false }); - room.completeImageUpload(uuid, cloudFile.fileURL); + room.completeImageUpload(uuid, fileURL); room.setMemberState({ currentApplianceName: ApplianceNames.selector }); } diff --git a/packages/flat-server-api/src/storage.ts b/packages/flat-server-api/src/storage.ts index 8ba60af9efd..0f84449c753 100644 --- a/packages/flat-server-api/src/storage.ts +++ b/packages/flat-server-api/src/storage.ts @@ -192,3 +192,39 @@ export function getWhiteboardTaskData( return null; } + +export interface UploadTempPhotoStartPayload { + fileName: string; + fileSize: number; +} + +export interface UploadTempPhotoResult { + fileUUID: string; + ossDomain: string; + ossFilePath: string; + policy: string; + signature: string; +} + +export async function uploadTempPhotoStart( + fileName: string, + fileSize: number, +): Promise { + return await postV2( + "temp-photo/upload/start", + { + fileName, + fileSize, + }, + ); +} + +export interface UploadTempPhotoFinishPayload { + fileUUID: string; +} + +export async function uploadTempPhotoFinish(fileUUID: string): Promise { + return await postV2("temp-photo/upload/finish", { + fileUUID, + }); +} From 4e0645fe79e2843d62098fc362cef8f7e28add9b Mon Sep 17 00:00:00 2001 From: hyrious Date: Mon, 5 Jun 2023 16:24:12 +0800 Subject: [PATCH 2/2] refactor(flat-pages): add drag over effect on whiteboard and storage --- .../CloudStorageContainer/index.tsx | 35 +++++++-- .../CloudStorageContainer/style.less | 73 ++++++++++++++++--- packages/flat-i18n/locales/en.json | 4 +- packages/flat-i18n/locales/zh-CN.json | 4 +- .../flat-pages/src/components/Whiteboard.less | 37 ++++++++++ .../flat-pages/src/components/Whiteboard.tsx | 15 ++++ 6 files changed, 146 insertions(+), 22 deletions(-) diff --git a/packages/flat-components/src/containers/CloudStorageContainer/index.tsx b/packages/flat-components/src/containers/CloudStorageContainer/index.tsx index a0de6dc4bc1..2a35c083ec4 100644 --- a/packages/flat-components/src/containers/CloudStorageContainer/index.tsx +++ b/packages/flat-components/src/containers/CloudStorageContainer/index.tsx @@ -45,19 +45,13 @@ const areAllSupportedFiles = (files: FileList): boolean => { return Array.from(files).every(file => isSupportedFile(file)); }; -const onDragOver = (event: React.DragEvent): void => { - event.preventDefault(); - if (areAllSupportedFiles(event.dataTransfer.files)) { - event.dataTransfer.dropEffect = "copy"; - } -}; - /** CloudStorage page with MobX Store */ export const CloudStorageContainer = /* @__PURE__ */ observer( function CloudStorageContainer({ store, path, pushHistory }) { const t = useTranslate(); const cloudStorageContainerRef = useRef(null); const [skeletonsVisible, setSkeletonsVisible] = useState(false); + const [tipsVisible, setTipsVisible] = useState(false); const [isAtTheBottom, setIsAtTheBottom] = useState(false); // Wait 200ms before showing skeletons to reduce flashing. @@ -75,12 +69,25 @@ export const CloudStorageContainer = /* @__PURE__ */ observer): void => { + event.preventDefault(); + if (areAllSupportedFiles(event.dataTransfer.files)) { + event.dataTransfer.dropEffect = "copy"; + setTipsVisible(true); + } + }, []); + + const onDragLeave = useCallback((): void => { + setTipsVisible(false); + }, []); + const onDrop = useCallback( (event: React.DragEvent): void => { event.preventDefault(); if (areAllSupportedFiles(event.dataTransfer.files)) { store.onDropFile(event.dataTransfer.files); } + setTipsVisible(false); }, [store], ); @@ -159,7 +166,12 @@ export const CloudStorageContainer = /* @__PURE__ */ observer +
{!store.compact && (
@@ -233,6 +245,13 @@ export const CloudStorageContainer = /* @__PURE__ */ observer{containerBtns}
)} + {tipsVisible && ( +
+
+ {t("drop-to-storage")} +
+
+ )}
); }, diff --git a/packages/flat-components/src/containers/CloudStorageContainer/style.less b/packages/flat-components/src/containers/CloudStorageContainer/style.less index a399982540b..6ff1c1c2796 100644 --- a/packages/flat-components/src/containers/CloudStorageContainer/style.less +++ b/packages/flat-components/src/containers/CloudStorageContainer/style.less @@ -36,23 +36,23 @@ align-items: center; margin-left: auto; - & > * { + &>* { margin-left: 1em !important; } - .ant-btn-group > .ant-btn:first-child:not(:last-child), - .ant-btn-group > span:first-child:not(:last-child) > .ant-btn { + .ant-btn-group>.ant-btn:first-child:not(:last-child), + .ant-btn-group>span:first-child:not(:last-child)>.ant-btn { border-top-left-radius: 5px; border-bottom-left-radius: 5px; } - .ant-btn-group > .ant-btn:last-child:not(:first-child), + .ant-btn-group>.ant-btn:last-child:not(:first-child), .ant-btn-group .ant-btn-primary:not(:first-child):not(:last-child) { border-top-right-radius: 5px; border-bottom-right-radius: 5px; } - & > button { + &>button { border-radius: 5px; } @@ -77,7 +77,7 @@ display: inline-block; margin-left: 14px; - > span { + >span { position: absolute; top: 10px; width: 9px; @@ -85,10 +85,12 @@ background-color: var(--grey-6); display: inline-block; transition: all .2s ease; + &:first-of-type { left: 0; transform: rotate(45deg); } + &:last-of-type { right: 0; transform: rotate(-45deg); @@ -104,11 +106,12 @@ .cloud-storage-container-btns .ant-dropdown:hover, .cloud-storage-container-dropdown-btn:hover { .cloud-storage-container-btn-arrow { - > span { + >span { &:first-of-type { background-color: var(--primary); transform: rotate(-45deg); } + &:last-of-type { background-color: var(--primary); transform: rotate(45deg); @@ -117,11 +120,12 @@ } .cloud-storage-container-btn-arrow-primary { - > span { + >span { &:first-of-type { background-color: #fff; transform: rotate(-45deg); } + &:last-of-type { background-color: #fff; transform: rotate(45deg); @@ -130,8 +134,8 @@ } } -.cloud-storage-container-btn-arrow-primary:extend(.cloud-storage-container-btn-arrow){ - > span { +.cloud-storage-container-btn-arrow-primary:extend(.cloud-storage-container-btn-arrow) { + >span { background-color: #fff; } } @@ -184,18 +188,59 @@ .cloud-storage-container-mask-enter { opacity: 0; } + .cloud-storage-container-mask-enter-active { opacity: 1; transition: opacity 0.4s; } + .cloud-storage-container-mask-exit { opacity: 1; } + .cloud-storage-container-mask-exit-active { opacity: 0; transition: opacity 0.4s; } +.cloud-storage-container-tips { + position: absolute; + z-index: 100; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, .75); + pointer-events: none; +} + +.cloud-storage-container-tips-content { + position: absolute; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + padding: 6px 16px; + border-radius: 4px; + background: var(--primary); + color: #fff; + opacity: .95; + animation: bouncing 1s infinite cubic-bezier(0.5, 1, 0.89, 1); +} + +@keyframes bouncing { + 0% { + transform: translateX(-50%) translateY(0); + } + + 50% { + transform: translateX(-50%) translateY(-10px); + } + + 100% { + transform: translateX(-50%) translateY(0); + } +} + .flat-color-scheme-dark { .cloud-storage-container-footer { border-top-color: var(--grey-8); @@ -215,8 +260,12 @@ } .cloud-storage-container-btn-arrow { - > span { + >span { background-color: var(--grey-3); } - } + } + + .cloud-storage-container-tips { + background: rgba(0, 0, 0, .75); + } } diff --git a/packages/flat-i18n/locales/en.json b/packages/flat-i18n/locales/en.json index 9d222db6b43..e45a8a23c20 100644 --- a/packages/flat-i18n/locales/en.json +++ b/packages/flat-i18n/locales/en.json @@ -681,5 +681,7 @@ "teal": "Teal", "grey": "Grey", "green": "Green" - } + }, + "drop-to-storage": "Drop file to cloud storage", + "drop-to-board": "Drop file to whiteboard" } diff --git a/packages/flat-i18n/locales/zh-CN.json b/packages/flat-i18n/locales/zh-CN.json index f67ea14bea0..0cd08b05892 100644 --- a/packages/flat-i18n/locales/zh-CN.json +++ b/packages/flat-i18n/locales/zh-CN.json @@ -681,5 +681,7 @@ "teal": "藏青", "grey": "浅灰", "green": "墨绿" - } + }, + "drop-to-storage": "拖拽文件到云盘", + "drop-to-board": "拖拽文件到白板" } diff --git a/packages/flat-pages/src/components/Whiteboard.less b/packages/flat-pages/src/components/Whiteboard.less index fc45a844244..2dd8e61b88c 100644 --- a/packages/flat-pages/src/components/Whiteboard.less +++ b/packages/flat-pages/src/components/Whiteboard.less @@ -215,6 +215,43 @@ opacity: 0.9; } +.whiteboard-container-tips { + position: absolute; + z-index: 100; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.whiteboard-container-tips-content { + position: absolute; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + padding: 6px 16px; + border-radius: 4px; + background: var(--primary); + color: #fff; + opacity: .95; + animation: bouncing 1s infinite cubic-bezier(0.5, 1, 0.89, 1); +} + +@keyframes bouncing { + 0% { + transform: translateX(-50%) translateY(0); + } + + 50% { + transform: translateX(-50%) translateY(-10px); + } + + 100% { + transform: translateX(-50%) translateY(0); + } +} + .is-readonly .whiteboard-scroll-page { opacity: 0; } diff --git a/packages/flat-pages/src/components/Whiteboard.tsx b/packages/flat-pages/src/components/Whiteboard.tsx index 04b15de771f..fee40adbc0d 100644 --- a/packages/flat-pages/src/components/Whiteboard.tsx +++ b/packages/flat-pages/src/components/Whiteboard.tsx @@ -52,6 +52,7 @@ export const Whiteboard = observer(function Whiteboard({ const [page, setPage] = useState(0); const [maxPage, setMaxPage] = useState(Infinity); const [showPage, setShowPage] = useState(false); + const [tipsVisible, setTipsVisible] = useState(false); const isReconnecting = phase === RoomPhase.Reconnecting; const handRaisingCount = classRoomStore.users.handRaisingJoiners.length; @@ -123,6 +124,7 @@ export const Whiteboard = observer(function Whiteboard({ const onDragOver = useCallback( (event: React.DragEvent) => { event.preventDefault(); + setTipsVisible(true); const file = event.dataTransfer.files[0]; if (room && file && isSupportedFileExt(file)) { event.dataTransfer.dropEffect = "copy"; @@ -131,9 +133,14 @@ export const Whiteboard = observer(function Whiteboard({ [room], ); + const onDragLeave = useCallback(() => { + setTipsVisible(false); + }, []); + const onDrop = useCallback( async (event: React.DragEvent) => { event.preventDefault(); + setTipsVisible(false); const file = event.dataTransfer.files[0]; if (room && file) { if (isSupportedImageType(file)) { @@ -196,6 +203,7 @@ export const Whiteboard = observer(function Whiteboard({ className={classNames("whiteboard-container", { "is-readonly": !whiteboardStore.allowDrawing, })} + onDragLeave={onDragLeave} onDragOver={onDragOver} onDrop={onDrop} > @@ -271,6 +279,13 @@ export const Whiteboard = observer(function Whiteboard({
{t("no-one-raising-hand")}
)}
+ {tipsVisible && ( +
+
+ {t("drop-to-board")} +
+
+ )} )}