paint-brush
构建 ChatPlus:感觉像移动应用程序的开源 PWA经过@aladinyo
896 讀數
896 讀數

构建 ChatPlus:感觉像移动应用程序的开源 PWA

经过 Alaa Eddine Boubekeur29m2024/07/10
Read on Terminal Reader

太長; 讀書

ChatPlus 是我制作的最棒的应用程序之一,它以其生动的用户界面和多种消息传递和呼叫功能吸引了人们的注意,这些功能都是在网络上实现的,它提供了跨平台体验,因为它的前端被设计为 PWA 应用程序,能够随处安装并像独立应用程序一样运行,具有推送通知等功能,ChatPlus 是轻量级应用程序的定义,它可以使用 Web 技术提供移动应用程序的全部功能。
featured image - 构建 ChatPlus:感觉像移动应用程序的开源 PWA
Alaa Eddine Boubekeur HackerNoon profile picture
0-item
1-item

ChatPlus 是一款出色的聊天 PWA 💬✨🤩

ChatPlus 是一款使用 React、NodeJS、Firebase 和其他服务开发的渐进式 Web 应用程序。

您可以与所有朋友实时交谈🗣️✨🧑‍🤝‍🧑❤️

您可以打电话给您的朋友并与他们进行视频和音频通话🎥🔉🤩

向你的朋友发送图像和音频信息,你就会拥有一个可以把你的语音转换为文本的人工智能,无论你说法语、英语还是西班牙语🤖✨

该网络应用程序可以安装在任何设备上并可接收通知⬇️🔔🎉

非常感谢您的支持,在 Github 存储库上给我们留个星,并与您的朋友分享⭐✨

查看此 Github 存储库以获取完整的安装和部署文档: https://github.com/aladinyo/ChatPlus

简洁的用户界面和用户体验

ChatPlus 移动视图


ChatPlus 桌面视图


那么 ChatPlus 是什么?

ChatPlus 是我制作的最棒的应用程序之一,它以其生动的用户界面和多种消息传递和呼叫功能吸引了人们的注意,这些功能都是在网络上实现的,它提供了跨平台体验,因为它的前端被设计为 PWA 应用程序,能够安装在任何地方并像独立应用程序一样运行,具有推送通知等功能,ChatPlus 是轻量级应用程序的定义,它可以使用 Web 技术提供移动应用程序的全部功能。

软件架构

我们的应用遵循 MVC 软件架构,MVC(模型-视图-控制器)是软件设计中常用于实现用户界面、数据和控制逻辑的模式。它强调软件业务逻辑与显示之间的分离。这种“关注点分离”可以更好地进行分工并改善维护。

用户界面层(View)

它由多个 React 界面组件组成:

  • MaterialUI: Material UI 是一个开源 React 组件库,实现了 Google 的 Material Design。它功能全面,开箱即用,我们将使用它来制作图标按钮和界面中的一些元素,例如音频滑块。

  • LoginView:一个简单的登录视图,允许用户输入他们的用户名和密码或使用谷歌登录。

  • SidebarViews :多个视图组件,如“sidebar__header”、“sidebar__search”、“侧边栏菜单”和“SidebarChats”,它由一个用于显示用户信息的标题、一个用于搜索用户的搜索栏、一个用于在聊天、群组和用户之间导航的侧边栏菜单,以及一个 SidebarChats 组件组成,用于显示您最近发送的所有聊天消息,它显示用户名和照片以及最后一条消息。

  • ChatViews:它由以下许多组件组成:

    1.'chat__header ' 包含您正在交谈的用户的信息、他的在线状态、个人资料图片,并显示音频通话和视频通话的按钮,还显示用户是否正在输入。

    2.'chat__body --container' 包含我们与其他用户发送的消息信息,它具有消息组件,显示文本消息、带有消息的图像以及音频消息及其信息(如音频时间以及音频是否播放),并且在这个组件的末尾我们有'seen'元素,显示消息是否被看到。

    3.'AudioPlayer ':一个 React 组件,可以向我们显示一个带有滑块来导航的音频,显示音频的完整时间和当前时间,这个视图组件加载在'chat__body--容器'内。

    4.'ChatFooter ':它包含一个用于输入消息的输入框,一个用于在输入框中输入消息的按钮,否则该按钮将允许您录制音频,一个用于导入图像和文件的按钮。

    5.'MediaPreview ':一个 React 组件,允许预览我们选择在聊天中发送的图像或文件,它们显示在轮播上,用户可以滑动图像或文件并为每个图像或文件输入特定的消息

    6.'ImagePreview ':当我们在聊天中发送图像时,此组件将以流畅的动画全屏显示图像,单击图像后组件就会安装。

  • scalePage:一种视图功能,当在大屏幕(如全高清屏幕和 4K 屏幕)上显示时,它可以增加我们的 Web 应用程序的尺寸。

  • CallViews:一组包含所有呼叫视图元素的反应组件,它们可以拖动到整个屏幕,它包括:

    1 ‘按钮’:一个红色版本的呼叫按钮和一个绿色的视频呼叫按钮。

    2 'AudioCallView':一个视图组件,允许应答来电音频呼叫并使用计时器显示呼叫,并允许取消呼叫。

    3 'StartVideoCallView':一个视图组件,通过连接到本地 MediaAPI 来显示我们自己的视频,并等待其他用户接受呼叫,或者显示一个按钮让我们接听来电。

    4 'VideoCallView':一个显示我们和其他用户的视频的视图组件,它允许切换摄像头、禁用摄像头和音频,也可以全屏显示。

  • RouteViews:包含我们所有视图的 React 组件,为了创建本地组件导航,我们得到了“VideoCallRoute”、“SideBarMenuRoute”和“ChatsRoute”

客户端模型(模型)

客户端模型是允许我们的前端与数据库和多个本地和服务器端 API 交互的逻辑,它包括:

  • Firebase SDK:它是用于构建我们的 Web 应用程序数据库的 SDK。
  • AppModel:经过身份验证后生成用户的模型,它还确保我们拥有最新版本的网络资产。
  • ChatModels:它由向数据库发送消息的模型逻辑组成,建立监听器来监听新消息,监听其他用户是否在线以及是否正在输入,它还将我们的媒体(如图像和音频)发送到数据库存储。SidebarChatsModel 监听用户最新消息的逻辑,并为我们提供来自用户的所有新消息的数组,它还提供未读消息的数量和用户的在线状态,它还根据最后一条消息的时间来组织用户。
  • UsersSearchModel:在我们的数据库中搜索用户的逻辑,它使用 Algolia 搜索,该搜索具有我们的用户列表,并将其链接到服务器上的数据库
  • CallModel:使用 Daily SDK 在我们的网络应用上创建呼叫并将数据发送到我们的服务器并与 DailyAPI 交互的逻辑。

客户端控制器(Controller)

它由 React 组件组成,将我们的视图与其特定模型链接起来:

  • 应用程序控制器:将经过身份验证的用户链接到所有组件并运行 scalePage 函数来调整应用程序的大小,它还加载 firebase 并附加所有组件,我们可以将其视为我们组件的包装器。
  • SideBarController:链接用户数据和他最近的聊天列表,它还将我们的菜单与其模型逻辑链接起来,它还将搜索栏与 algolia 搜索 API 链接起来。
  • ChatController:这是一个非常大的控制器,它链接了大多数消息和聊天功能。
  • CallController:将呼叫模型与其视图链接起来。

服务器端模型

并非所有功能都是在前端完成的,因为我们使用的 SDK 需要一些服务器端功能,它们包括:

  • CallServerModel:通过与 Daily API 交互并更新我们的 firestore 数据库,我们可以创建通话房间的逻辑。
  • TranscriptModel:服务器上接收音频文件并与 Google Cloud 语音转文本 API 交互的逻辑,它为音频消息提供记录。
  • 在线状态处理程序:监听用户在线状态并相应更新数据库的监听器通知模型:向其他用户发送通知的服务。
  • AlgoliaSaver:一个监听器,它监听我们数据库中的新用户并相应地更新 Algolia,以便我们可以将其用于前端的搜索功能。
  • 服务器端控制器: CallServer:包含 callModel Worker 的 API 端点:运行我们所有 Firebase 处理服务的工作服务。

聊天流程图

聊天流程图


侧边栏流程图

侧边栏流程图

模型调用流程图:

模型调用流程图

查看呼叫流程图

查看呼叫流程图

后端工作流图

后端工作流图

数据库设计

我们的 Web 应用程序使用 Firestore 来存储我们的数据库,这是一个 Firebase NoSql 数据库,我们存储用户信息,我们存储所有消息的列表,我们存储聊天列表,我们还存储房间聊天,这些是存储在我们数据库中的数据:

  • 身份验证后的用户数据。
  • 包含所有消息详细信息的房间。
  • 每个用户的最新聊天列表。
  • 要发送的通知列表。
  • 需要转录的音频列表。

数据库 UML

魔法代码解释🔮

在接下来的章节中,我将对 ChatPlus 中的某些功能进行简要说明和教程,我将向您展示 JS 代码并解释其背后的算法,并提供正确的集成工具来将您的代码与数据库链接起来。

处理在线状态

用户的在线状态是通过使用 Firebase 数据库连接功能来实现的,通过连接到前端的“.info/connected”并相应地更新 Firestore 和数据库:

 var disconnectRef; function setOnlineStatus(uid) { try { console.log("setting up online status"); const isOfflineForDatabase = { state: 'offline', last_changed: createTimestamp2, id: uid, }; const isOnlineForDatabase = { state: 'online', last_changed: createTimestamp2, id: uid }; const userStatusFirestoreRef = db.collection("users").doc(uid); const userStatusDatabaseRef = db2.ref('/status/' + uid); // Firestore uses a different server timestamp value, so we'll // create two more constants for Firestore state. const isOfflineForFirestore = { state: 'offline', last_changed: createTimestamp(), }; const isOnlineForFirestore = { state: 'online', last_changed: createTimestamp(), }; disconnectRef = db2.ref('.info/connected').on('value', function (snapshot) { console.log("listening to database connected info") if (snapshot.val() === false) { // Instead of simply returning, we'll also set Firestore's state // to 'offline'. This ensures that our Firestore cache is aware // of the switch to 'offline.' userStatusFirestoreRef.set(isOfflineForFirestore, { merge: true }); return; }; userStatusDatabaseRef.onDisconnect().set(isOfflineForDatabase).then(function () { userStatusDatabaseRef.set(isOnlineForDatabase); // We'll also add Firestore set here for when we come online. userStatusFirestoreRef.set(isOnlineForFirestore, { merge: true }); }); }); } catch (error) { console.log("error setting onlins status: ", error); } };

在我们的后端,我们还设置了一个监听器,用于监听数据库的变化并相应地更新 firestore,这个函数还可以实时为我们提供用户的在线状态,以便我们也可以在其中运行其他函数:

 async function handleOnlineStatus(data, event) { try { console.log("setting online status with event: ", event); // Get the data written to Realtime Database const eventStatus = data.val(); // Then use other event data to create a reference to the // corresponding Firestore document. const userStatusFirestoreRef = db.doc(`users/${eventStatus.id}`); // It is likely that the Realtime Database change that triggered // this event has already been overwritten by a fast change in // online / offline status, so we'll re-read the current data // and compare the timestamps. const statusSnapshot = await data.ref.once('value'); const status = statusSnapshot.val(); // If the current timestamp for this data is newer than // the data that triggered this event, we exit this function. if (eventStatus.state === "online") { console.log("event status: ", eventStatus) console.log("status: ", status) } if (status.last_changed <= eventStatus.last_changed) { // Otherwise, we convert the last_changed field to a Date eventStatus.last_changed = new Date(eventStatus.last_changed); //handle the call delete handleCallDelete(eventStatus); // ... and write it to Firestore. await userStatusFirestoreRef.set(eventStatus, { merge: true }); console.log("user: " + eventStatus.id + " online status was succesfully updated with data: " + eventStatus.state); } else { console.log("next status timestamp is newer for user: ", eventStatus.id); } } catch (error) { console.log("handle online status crashed with error :", error) } }

通知

通知是一项很棒的功能,它们是使用 Firebase 消息传递实现的,在我们的前端,如果用户的浏览器支持通知,那么我们会对其进行配置并检索用户的 Firebase 消息传递令牌:

 const configureNotif = (docID) => { messaging.getToken().then((token) => { console.log(token); db.collection("users").doc(docID).set({ token: token }, { merge: true }) }).catch(e => { console.log(e.message); db.collection("users").doc(docID).set({ token: "" }, { merge: true }); }); }

每当用户发送消息时,我们都会向数据库添加通知:

 db.collection("notifications").add({ userID: user.uid, title: user.displayName, body: inputText, photoURL: user.photoURL, token: token, });


在我们的后端,我们监听通知集合,并使用 Firebase 消息传递将其发送给用户

let listening = false; db.collection("notifications").onSnapshot(snap => { if (!listening) { console.log("listening for notifications..."); listening = true; } const docs = snap.docChanges(); if (docs.length > 0) { docs.forEach(async change => { if (change.type === "added") { const data = change.doc.data(); if (data) { const message = { data: data, token: data.token }; await db.collection("notifications").doc(change.doc.id).delete(); try { const response = await messaging.send(message); console.log("notification successfully sent :", data); } catch (error) { console.log("error sending notification ", error); }; }; }; }); }; });

AI 音频转录

我们的网络应用程序允许用户互相发送音频消息,它的一个功能是能够将此音频转换为用英语、法语和西班牙语录制的文本,此功能是通过 Google Cloud Speech to Text 功能实现的,我们的后端会收听添加到 Firestore 的新记录并将其转录,然后将其写入数据库:

 db.collection("transcripts").onSnapshot(snap => { const docs = snap.docChanges(); if (docs.length > 0) { docs.forEach(async change => { if (change.type === "added") { const data = change.doc.data(); if (data) { db.collection("transcripts").doc(change.doc.id).delete(); try { const text = await textToAudio(data.audioName, data.short, data.supportWebM); const roomRef = db.collection("rooms").doc(data.roomID).collection("messages").doc(data.messageID); db.runTransaction(async transaction => { const roomDoc = await transaction.get(roomRef); if (roomDoc.exists && !roomDoc.data()?.delete) { transaction.update(roomRef, { transcript: text }); console.log("transcript added with text: ", text); return; } else { console.log("room is deleted"); return; } }) db.collection("rooms").doc(data.roomID).collection("messages").doc(data.messageID).update({ transcript: text }); } catch (error) { console.log("error transcripting audio: ", error); }; }; }; }); }; });

显然,你正在看着那个 textToAudio 函数,并且你想知道我是怎么做到的,别担心,我明白了:

 // Imports the Google Cloud client library const speech = require('@google-cloud/speech').v1p1beta1; const { gcsUriLink } = require("./configKeys") // Creates a client const client = new speech.SpeechClient({ keyFilename: "./audio_transcript.json" }); async function textToAudio(audioName, isShort) { // The path to the remote LINEAR16 file const gcsUri = gcsUriLink + "/audios/" + audioName; // The audio file's encoding, sample rate in hertz, and BCP-47 language code const audio = { uri: gcsUri, }; const config = { encoding: "MP3", sampleRateHertz: 48000, languageCode: 'en-US', alternativeLanguageCodes: ['es-ES', 'fr-FR'] }; console.log("audio config: ", config); const request = { audio: audio, config: config, }; // Detects speech in the audio file if (isShort) { const [response] = await client.recognize(request); return response.results.map(result => result.alternatives[0].transcript).join('\n'); } const [operation] = await client.longRunningRecognize(request); const [response] = await operation.promise().catch(e => console.log("response promise error: ", e)); return response.results.map(result => result.alternatives[0].transcript).join('\n'); }; module.exports = textToAudio;

视频通话功能

我们的 Web 应用程序使用 Daily API 来实现实时 Web RTC 连接,它允许用户互相进行视频通话,因此我们首先设置一个后端调用服务器,该服务器具有许多 api 入口点,用于在 Daily 中创建和删除房间:

 const app = express(); app.use(cors()); app.use(express.json()); app.delete("/delete-call", async (req, res) => { console.log("delete call data: ", req.body); deleteCallFromUser(req.body.id1); deleteCallFromUser(req.body.id2); try { fetch("https://api.daily.co/v1/rooms/" + req.body.roomName, { headers: { Authorization: `Bearer ${dailyApiKey}`, "Content-Type": "application/json" }, method: "DELETE" }); } catch(e) { console.log("error deleting room for call delete!!"); console.log(e); } res.status(200).send("delete-call success !!"); }); app.post("/create-room/:roomName", async (req, res) => { var room = await fetch("https://api.daily.co/v1/rooms/", { headers: { Authorization: `Bearer ${dailyApiKey}`, "Content-Type": "application/json" }, method: "POST", body: JSON.stringify({ name: req.params.roomName }) }); room = await room.json(); console.log(room); res.json(room); }); app.delete("/delete-room/:roomName", async (req, res) => { var deleteResponse = await fetch("https://api.daily.co/v1/rooms/" + req.params.roomName, { headers: { Authorization: `Bearer ${dailyApiKey}`, "Content-Type": "application/json" }, method: "DELETE" }); deleteResponse = await deleteResponse.json(); console.log(deleteResponse); res.json(deleteResponse); }) app.listen(process.env.PORT || 7000, () => { console.log("call server is running"); }); const deleteCallFromUser = userID => db.collection("users").doc(userID).collection("call").doc("call").delete();

在我们的前端我们有多个函数来调用这个 API:

 import { callAPI as api } from "../../configKeys"; //const api = "http://localhost:7000" export async function createRoom(roomName) { var room = await fetch(`${api}/create-room/${roomName}`, { method: "POST", }); room = await room.json(); console.log(room); return room; } export async function deleteRoom(roomName) { var deletedRoom = await fetch(`${api}/delete-room/${roomName}`, { method: "DELETE", }); deletedRoom = await deletedRoom.json(); console.log(deletedRoom); console.log("deleted"); }; export function deleteCall() { window.callDelete && fetch(`${api}/delete-call`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify(window.callDelete) }); };

太好了,现在只需创建呼叫室并使用每日 JS SDK 连接到这些房间并从它们发送和接收数据:

 export default async function startVideoCall(dispatch, receiverQuery, userQuery, id, otherID, userName, otherUserName, sendNotif, userPhoto, otherPhoto, audio) { var room = null; const call = new DailyIframe.createCallObject(); const roomName = nanoid(); window.callDelete = { id1: id, id2: otherID, roomName } dispatch({ type: "set_other_user_name", otherUserName }); console.log("audio: ", audio); if (audio) { dispatch({ type: "set_other_user_photo", photo: otherPhoto }); dispatch({ type: "set_call_type", callType: "audio" }); } else { dispatch({ type: "set_other_user_photo", photo: null }); dispatch({ type: "set_call_type", callType: "video" }); } dispatch({ type: "set_caller", caller: true }); dispatch({ type: "set_call", call }); dispatch({ type: "set_call_state", callState: "state_creating" }); try { room = await createRoom(roomName); console.log("created room: ", room); dispatch({ type: "set_call_room", callRoom: room }); } catch (error) { room = null; console.log('Error creating room', error); await call.destroy(); dispatch({ type: "set_call_room", callRoom: null }); dispatch({ type: "set_call", call: null }); dispatch({ type: "set_call_state", callState: "state_idle" }); window.callDelete = null; //destroy the call object; }; if (room) { dispatch({ type: "set_call_state", callState: "state_joining" }); dispatch({ type: "set_call_queries", callQueries: { userQuery, receiverQuery } }); try { await db.runTransaction(async transaction => { console.log("runing transaction"); var userData = (await transaction.get(receiverQuery)).data(); //console.log("user data: ", userData); if (!userData || !userData?.callerID || userData?.otherUserLeft) { console.log("runing set"); transaction.set(receiverQuery, { room, callType: audio ? "audio" : "video", isCaller: false, otherUserLeft: false, callerID: id, otherID, otherUserName: userName, otherUserRatio: window.screen.width / window.screen.height, photo: audio ? userPhoto : "" }); transaction.set(userQuery, { room, callType: audio ? "audio" : "video", isCaller: true, otherUserLeft: false, otherUserJoined: false, callerID: id, otherID }); } else { console.log('transaction failed'); throw userData; } }); if (sendNotif) { sendNotif(); const notifTimeout = setInterval(() => { sendNotif(); }, 1500); dispatch({ type: "set_notif_tiemout", notifTimeout }); } call.join({ url: room.url, videoSource: !audio }); } catch (userData) { //delete the room we made deleteRoom(roomName); await call.destroy(); if (userData.otherID === id) { console.log("you and the other user are calling each other at the same time"); joinCall(dispatch, receiverQuery, userQuery, userData.room, userName, audio ? userPhoto : "", userData.callType); } else { console.log("other user already in a call"); dispatch({ type: "set_call_room", callRoom: null }); dispatch({ type: "set_call", call: null }); dispatch({ type: "set_call_state", callState: "state_otherUser_calling" }); } }; }; };

OtherUserQuery 和 UserQuery 只是 firebase firestore 文档路径,现在应用程序的其余部分具有视图组件,这些组件对上述函数触发的状态变化做出反应,并且我们的调用 UI 元素将相应地出现。

移动呼叫元素:

下一个函数是魔术函数,它允许您将调用元素拖到整个页面:

 export function dragElement(elmnt, page) { var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0, top, left, prevTop = 0, prevLeft = 0, x, y, maxTop, maxLeft; const widthRatio = page.width / window.innerWidth; const heightRatio = page.height / window.innerHeight; //clear element's mouse listeners closeDragElement(); // setthe listener elmnt.addEventListener("mousedown", dragMouseDown); elmnt.addEventListener("touchstart", dragMouseDown, { passive: false }); function dragMouseDown(e) { e = e || window.event; // get the mouse cursor position at startup: if (e.type === "touchstart") { if (typeof(e.target.className) === "string") { if (!e.target.className.includes("btn")) { e.preventDefault(); } } else if (!typeof(e.target.className) === "function") { e.stopPropagation(); } pos3 = e.touches[0].clientX * widthRatio; pos4 = e.touches[0].clientY * heightRatio; } else { e.preventDefault(); pos3 = e.clientX * widthRatio; pos4 = e.clientY * heightRatio; }; maxTop = elmnt.offsetParent.offsetHeight - elmnt.offsetHeight; maxLeft = elmnt.offsetParent.offsetWidth - elmnt.offsetWidth; document.addEventListener("mouseup", closeDragElement); document.addEventListener("touchend", closeDragElement, { passive: false }); // call a function whenever the cursor moves: document.addEventListener("mousemove", elementDrag); document.addEventListener("touchmove", elementDrag, { passive: false }); } function elementDrag(e) { e = e || window.event; e.preventDefault(); // calculate the new cursor position: if (e.type === "touchmove") { x = e.touches[0].clientX * widthRatio; y = e.touches[0].clientY * heightRatio; } else { e.preventDefault(); x = e.clientX * widthRatio; y = e.clientY * heightRatio; }; pos1 = pos3 - x; pos2 = pos4 - y; pos3 = x pos4 = y; // set the element's new position: top = elmnt.offsetTop - pos2; left = elmnt.offsetLeft - pos1; //prevent the element from overflowing the viewport if (top >= 0 && top <= maxTop) { elmnt.style.top = top + "px"; } else if ((top > maxTop && pos4 < prevTop) || (top < 0 && pos4 > prevTop)) { elmnt.style.top = top + "px"; }; if (left >= 0 && left <= maxLeft) { elmnt.style.left = left + "px"; } else if ((left > maxLeft && pos3 < prevLeft) || (left < 0 && pos3 > prevLeft)) { elmnt.style.left = left + "px"; }; prevTop = y; prevLeft = x; } function closeDragElement() { // stop moving when mouse button is released: document.removeEventListener("mouseup", closeDragElement); document.removeEventListener("touchend", closeDragElement); document.removeEventListener("mousemove", elementDrag); document.removeEventListener("touchmove", elementDrag); }; return function() { elmnt.removeEventListener("mousedown", dragMouseDown); elmnt.removeEventListener("touchstart", dragMouseDown); closeDragElement(); }; };

拖放图像

您可以在聊天中拖放图像并将其发送给其他用户,通过运行以下命令可实现此功能:

 useEffect(() => { const dropArea = document.querySelector(".chat"); ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, e => { e.preventDefault(); e.stopPropagation(); }, false); }); ['dragenter', 'dragover'].forEach(eventName => { dropArea.addEventListener(eventName, () => setShowDrag(true), false) }); ['dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, () => setShowDrag(false), false) }); dropArea.addEventListener('drop', e => { if (window.navigator.onLine) { if (e.dataTransfer?.files) { const dropedFile = e.dataTransfer.files; console.log("dropped file: ", dropedFile); const { imageFiles, imagesSrc } = mediaIndexer(dropedFile); setSRC(prevImages => [...prevImages, ...imagesSrc]); setImage(prevFiles => [...prevFiles, ...imageFiles]); setIsMedia("images_dropped"); }; }; }, false); }, []);

mediaIndexer 是一个简单的函数,它索引我们提供给它的图像块:

 function mediaIndexer(files) { const imagesSrc = []; const filesArray = Array.from(files); filesArray.forEach((file, index) => { imagesSrc[index] = URL.createObjectURL(file); }); return { imagesSrc, imageFiles: filesArray }; }