| 
									
										
										
										
											2025-03-12 22:23:31 +08:00
										 |  |  | import { CaptureUpdateAction } from "@excalidraw/excalidraw"; | 
					
						
							|  |  |  | import { trackEvent } from "@excalidraw/excalidraw/analytics"; | 
					
						
							|  |  |  | import { encryptData } from "@excalidraw/excalidraw/data/encryption"; | 
					
						
							| 
									
										
										
										
											2025-05-10 05:01:33 +08:00
										 |  |  | import { newElementWith } from "@excalidraw/element"; | 
					
						
							| 
									
										
										
										
											2025-03-12 22:23:31 +08:00
										 |  |  | import throttle from "lodash.throttle"; | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-03-26 22:24:59 +08:00
										 |  |  | import type { UserIdleState } from "@excalidraw/common"; | 
					
						
							|  |  |  | import type { OrderedExcalidrawElement } from "@excalidraw/element/types"; | 
					
						
							| 
									
										
										
										
											2024-05-08 16:51:50 +08:00
										 |  |  | import type { | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |   OnUserFollowedPayload, | 
					
						
							| 
									
										
										
										
											2023-12-19 01:21:57 +08:00
										 |  |  |   SocketId, | 
					
						
							| 
									
										
										
										
											2025-02-28 23:49:09 +08:00
										 |  |  | } from "@excalidraw/excalidraw/types"; | 
					
						
							| 
									
										
										
										
											2025-03-12 22:23:31 +08:00
										 |  |  | 
 | 
					
						
							|  |  |  | import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants"; | 
					
						
							|  |  |  | import { isSyncableElement } from "../data"; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | import type { | 
					
						
							|  |  |  |   SocketUpdateData, | 
					
						
							|  |  |  |   SocketUpdateDataSource, | 
					
						
							|  |  |  |   SyncableExcalidrawElement, | 
					
						
							|  |  |  | } from "../data"; | 
					
						
							|  |  |  | import type { TCollabClass } from "./Collab"; | 
					
						
							| 
									
										
										
										
											2023-12-16 18:15:04 +08:00
										 |  |  | import type { Socket } from "socket.io-client"; | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  | 
 | 
					
						
							|  |  |  | class Portal { | 
					
						
							| 
									
										
										
										
											2022-07-05 22:03:40 +08:00
										 |  |  |   collab: TCollabClass; | 
					
						
							| 
									
										
										
										
											2023-12-16 07:23:59 +08:00
										 |  |  |   socket: Socket | null = null; | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  |   socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
 | 
					
						
							| 
									
										
										
										
											2020-11-30 00:32:51 +08:00
										 |  |  |   roomId: string | null = null; | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  |   roomKey: string | null = null; | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |   broadcastedElementVersions: Map<string, number> = new Map(); | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-07-05 22:03:40 +08:00
										 |  |  |   constructor(collab: TCollabClass) { | 
					
						
							| 
									
										
										
										
											2021-01-25 17:47:35 +08:00
										 |  |  |     this.collab = collab; | 
					
						
							| 
									
										
										
										
											2020-04-28 01:56:08 +08:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-12-16 07:23:59 +08:00
										 |  |  |   open(socket: Socket, id: string, key: string) { | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  |     this.socket = socket; | 
					
						
							| 
									
										
										
										
											2020-11-30 00:32:51 +08:00
										 |  |  |     this.roomId = id; | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  |     this.roomKey = key; | 
					
						
							| 
									
										
										
										
											2020-04-28 01:56:08 +08:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-01-25 17:47:35 +08:00
										 |  |  |     // Initialize socket listeners
 | 
					
						
							| 
									
										
										
										
											2020-04-29 00:49:00 +08:00
										 |  |  |     this.socket.on("init-room", () => { | 
					
						
							| 
									
										
										
										
											2020-04-28 01:56:08 +08:00
										 |  |  |       if (this.socket) { | 
					
						
							| 
									
										
										
										
											2020-11-30 00:32:51 +08:00
										 |  |  |         this.socket.emit("join-room", this.roomId); | 
					
						
							| 
									
										
										
										
											2021-03-10 23:45:37 +08:00
										 |  |  |         trackEvent("share", "room joined"); | 
					
						
							| 
									
										
										
										
											2020-04-28 01:56:08 +08:00
										 |  |  |       } | 
					
						
							|  |  |  |     }); | 
					
						
							| 
									
										
										
										
											2020-08-15 02:14:22 +08:00
										 |  |  |     this.socket.on("new-user", async (_socketId: string) => { | 
					
						
							| 
									
										
										
										
											2020-12-05 22:30:53 +08:00
										 |  |  |       this.broadcastScene( | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |         WS_SUBTYPES.INIT, | 
					
						
							| 
									
										
										
										
											2021-10-27 21:14:20 +08:00
										 |  |  |         this.collab.getSceneElementsIncludingDeleted(), | 
					
						
							| 
									
										
										
										
											2020-12-05 22:30:53 +08:00
										 |  |  |         /* syncAll */ true, | 
					
						
							|  |  |  |       ); | 
					
						
							| 
									
										
										
										
											2020-04-28 01:56:08 +08:00
										 |  |  |     }); | 
					
						
							| 
									
										
										
										
											2023-12-19 01:21:57 +08:00
										 |  |  |     this.socket.on("room-user-change", (clients: SocketId[]) => { | 
					
						
							| 
									
										
										
										
											2021-01-25 17:47:35 +08:00
										 |  |  |       this.collab.setCollaborators(clients); | 
					
						
							| 
									
										
										
										
											2020-04-29 00:49:00 +08:00
										 |  |  |     }); | 
					
						
							| 
									
										
										
										
											2022-03-06 22:59:56 +08:00
										 |  |  | 
 | 
					
						
							|  |  |  |     return socket; | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   close() { | 
					
						
							|  |  |  |     if (!this.socket) { | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2021-10-31 05:40:35 +08:00
										 |  |  |     this.queueFileUpload.flush(); | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  |     this.socket.close(); | 
					
						
							|  |  |  |     this.socket = null; | 
					
						
							| 
									
										
										
										
											2020-11-30 00:32:51 +08:00
										 |  |  |     this.roomId = null; | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  |     this.roomKey = null; | 
					
						
							| 
									
										
										
										
											2020-11-09 22:34:26 +08:00
										 |  |  |     this.socketInitialized = false; | 
					
						
							|  |  |  |     this.broadcastedElementVersions = new Map(); | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   isOpen() { | 
					
						
							|  |  |  |     return !!( | 
					
						
							|  |  |  |       this.socketInitialized && | 
					
						
							|  |  |  |       this.socket && | 
					
						
							| 
									
										
										
										
											2020-11-30 00:32:51 +08:00
										 |  |  |       this.roomId && | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  |       this.roomKey | 
					
						
							|  |  |  |     ); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   async _broadcastSocketData( | 
					
						
							|  |  |  |     data: SocketUpdateData, | 
					
						
							|  |  |  |     volatile: boolean = false, | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |     roomId?: string, | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  |   ) { | 
					
						
							|  |  |  |     if (this.isOpen()) { | 
					
						
							|  |  |  |       const json = JSON.stringify(data); | 
					
						
							|  |  |  |       const encoded = new TextEncoder().encode(json); | 
					
						
							| 
									
										
										
										
											2021-11-07 21:33:21 +08:00
										 |  |  |       const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-10-31 05:40:35 +08:00
										 |  |  |       this.socket?.emit( | 
					
						
							| 
									
										
										
										
											2022-04-18 04:40:39 +08:00
										 |  |  |         volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER, | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |         roomId ?? this.roomId, | 
					
						
							| 
									
										
										
										
											2021-11-07 21:33:21 +08:00
										 |  |  |         encryptedBuffer, | 
					
						
							|  |  |  |         iv, | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  |       ); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-10-22 04:05:48 +08:00
										 |  |  |   queueFileUpload = throttle(async () => { | 
					
						
							|  |  |  |     try { | 
					
						
							|  |  |  |       await this.collab.fileManager.saveFiles({ | 
					
						
							|  |  |  |         elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(), | 
					
						
							|  |  |  |         files: this.collab.excalidrawAPI.getFiles(), | 
					
						
							|  |  |  |       }); | 
					
						
							| 
									
										
										
										
											2021-11-02 20:24:16 +08:00
										 |  |  |     } catch (error: any) { | 
					
						
							| 
									
										
										
										
											2021-10-31 05:40:35 +08:00
										 |  |  |       if (error.name !== "AbortError") { | 
					
						
							|  |  |  |         this.collab.excalidrawAPI.updateScene({ | 
					
						
							|  |  |  |           appState: { | 
					
						
							|  |  |  |             errorMessage: error.message, | 
					
						
							|  |  |  |           }, | 
					
						
							|  |  |  |         }); | 
					
						
							|  |  |  |       } | 
					
						
							| 
									
										
										
										
											2021-10-22 04:05:48 +08:00
										 |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-08-24 02:27:57 +08:00
										 |  |  |     let isChanged = false; | 
					
						
							|  |  |  |     const newElements = this.collab.excalidrawAPI | 
					
						
							|  |  |  |       .getSceneElementsIncludingDeleted() | 
					
						
							|  |  |  |       .map((element) => { | 
					
						
							|  |  |  |         if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) { | 
					
						
							|  |  |  |           isChanged = true; | 
					
						
							|  |  |  |           // this will signal collaborators to pull image data from server
 | 
					
						
							|  |  |  |           // (using mutation instead of newElementWith otherwise it'd break
 | 
					
						
							|  |  |  |           // in-progress dragging)
 | 
					
						
							|  |  |  |           return newElementWith(element, { status: "saved" }); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |         return element; | 
					
						
							|  |  |  |       }); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (isChanged) { | 
					
						
							|  |  |  |       this.collab.excalidrawAPI.updateScene({ | 
					
						
							|  |  |  |         elements: newElements, | 
					
						
							| 
									
										
										
										
											2025-02-28 23:49:09 +08:00
										 |  |  |         captureUpdate: CaptureUpdateAction.NEVER, | 
					
						
							| 
									
										
										
										
											2024-08-24 02:27:57 +08:00
										 |  |  |       }); | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2021-10-22 04:05:48 +08:00
										 |  |  |   }, FILE_UPLOAD_TIMEOUT); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |   broadcastScene = async ( | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |     updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE, | 
					
						
							| 
									
										
										
										
											2024-04-04 20:51:11 +08:00
										 |  |  |     elements: readonly OrderedExcalidrawElement[], | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |     syncAll: boolean, | 
					
						
							|  |  |  |   ) => { | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |     if (updateType === WS_SUBTYPES.INIT && !syncAll) { | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |       throw new Error("syncAll must be true when sending SCENE.INIT"); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-10-27 21:14:20 +08:00
										 |  |  |     // sync out only the elements we think we need to to save bandwidth.
 | 
					
						
							|  |  |  |     // periodically we'll resync the whole thing to make sure no one diverges
 | 
					
						
							|  |  |  |     // due to a dropped message (server goes down etc).
 | 
					
						
							| 
									
										
										
										
											2024-04-04 20:51:11 +08:00
										 |  |  |     const syncableElements = elements.reduce((acc, element) => { | 
					
						
							|  |  |  |       if ( | 
					
						
							|  |  |  |         (syncAll || | 
					
						
							|  |  |  |           !this.broadcastedElementVersions.has(element.id) || | 
					
						
							|  |  |  |           element.version > this.broadcastedElementVersions.get(element.id)!) && | 
					
						
							|  |  |  |         isSyncableElement(element) | 
					
						
							|  |  |  |       ) { | 
					
						
							|  |  |  |         acc.push(element); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |       return acc; | 
					
						
							|  |  |  |     }, [] as SyncableExcalidrawElement[]); | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-04-18 04:40:39 +08:00
										 |  |  |     const data: SocketUpdateDataSource[typeof updateType] = { | 
					
						
							|  |  |  |       type: updateType, | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |       payload: { | 
					
						
							|  |  |  |         elements: syncableElements, | 
					
						
							|  |  |  |       }, | 
					
						
							|  |  |  |     }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     for (const syncableElement of syncableElements) { | 
					
						
							|  |  |  |       this.broadcastedElementVersions.set( | 
					
						
							|  |  |  |         syncableElement.id, | 
					
						
							|  |  |  |         syncableElement.version, | 
					
						
							|  |  |  |       ); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-10-22 04:05:48 +08:00
										 |  |  |     this.queueFileUpload(); | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-04-18 04:40:39 +08:00
										 |  |  |     await this._broadcastSocketData(data as SocketUpdateData); | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |   }; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-02-04 18:55:43 +08:00
										 |  |  |   broadcastIdleChange = (userState: UserIdleState) => { | 
					
						
							|  |  |  |     if (this.socket?.id) { | 
					
						
							|  |  |  |       const data: SocketUpdateDataSource["IDLE_STATUS"] = { | 
					
						
							| 
									
										
										
										
											2023-12-17 00:32:54 +08:00
										 |  |  |         type: WS_SUBTYPES.IDLE_STATUS, | 
					
						
							| 
									
										
										
										
											2021-02-04 18:55:43 +08:00
										 |  |  |         payload: { | 
					
						
							| 
									
										
										
										
											2023-12-19 01:21:57 +08:00
										 |  |  |           socketId: this.socket.id as SocketId, | 
					
						
							| 
									
										
										
										
											2021-02-04 18:55:43 +08:00
										 |  |  |           userState, | 
					
						
							|  |  |  |           username: this.collab.state.username, | 
					
						
							|  |  |  |         }, | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  |       return this._broadcastSocketData( | 
					
						
							|  |  |  |         data as SocketUpdateData, | 
					
						
							|  |  |  |         true, // volatile
 | 
					
						
							|  |  |  |       ); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   }; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |   broadcastMouseLocation = (payload: { | 
					
						
							|  |  |  |     pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"]; | 
					
						
							|  |  |  |     button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"]; | 
					
						
							|  |  |  |   }) => { | 
					
						
							|  |  |  |     if (this.socket?.id) { | 
					
						
							|  |  |  |       const data: SocketUpdateDataSource["MOUSE_LOCATION"] = { | 
					
						
							| 
									
										
										
										
											2023-12-17 00:32:54 +08:00
										 |  |  |         type: WS_SUBTYPES.MOUSE_LOCATION, | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |         payload: { | 
					
						
							| 
									
										
										
										
											2023-12-19 01:21:57 +08:00
										 |  |  |           socketId: this.socket.id as SocketId, | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |           pointer: payload.pointer, | 
					
						
							|  |  |  |           button: payload.button || "up", | 
					
						
							| 
									
										
										
										
											2021-11-01 21:24:05 +08:00
										 |  |  |           selectedElementIds: | 
					
						
							|  |  |  |             this.collab.excalidrawAPI.getAppState().selectedElementIds, | 
					
						
							| 
									
										
										
										
											2021-01-25 17:47:35 +08:00
										 |  |  |           username: this.collab.state.username, | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |         }, | 
					
						
							|  |  |  |       }; | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  | 
 | 
					
						
							|  |  |  |       return this._broadcastSocketData( | 
					
						
							|  |  |  |         data as SocketUpdateData, | 
					
						
							|  |  |  |         true, // volatile
 | 
					
						
							|  |  |  |       ); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   }; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-12-17 00:32:54 +08:00
										 |  |  |   broadcastVisibleSceneBounds = ( | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |     payload: { | 
					
						
							| 
									
										
										
										
											2023-12-17 00:32:54 +08:00
										 |  |  |       sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"]; | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |     }, | 
					
						
							|  |  |  |     roomId: string, | 
					
						
							|  |  |  |   ) => { | 
					
						
							|  |  |  |     if (this.socket?.id) { | 
					
						
							| 
									
										
										
										
											2023-12-17 00:32:54 +08:00
										 |  |  |       const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = { | 
					
						
							|  |  |  |         type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS, | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |         payload: { | 
					
						
							| 
									
										
										
										
											2023-12-19 01:21:57 +08:00
										 |  |  |           socketId: this.socket.id as SocketId, | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |           username: this.collab.state.username, | 
					
						
							| 
									
										
										
										
											2023-12-17 00:32:54 +08:00
										 |  |  |           sceneBounds: payload.sceneBounds, | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |         }, | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |       return this._broadcastSocketData( | 
					
						
							|  |  |  |         data as SocketUpdateData, | 
					
						
							|  |  |  |         true, // volatile
 | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  |         roomId, | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  |       ); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   }; | 
					
						
							| 
									
										
										
										
											2023-12-15 07:07:11 +08:00
										 |  |  | 
 | 
					
						
							|  |  |  |   broadcastUserFollowed = (payload: OnUserFollowedPayload) => { | 
					
						
							|  |  |  |     if (this.socket?.id) { | 
					
						
							|  |  |  |       this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   }; | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  | } | 
					
						
							| 
									
										
										
										
											2020-10-21 21:41:20 +08:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-04-12 18:54:52 +08:00
										 |  |  | export default Portal; |