mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 00:14:10 +08:00
Live cursor
This commit is contained in:
@@ -14,7 +14,7 @@ import { svgStringToFile } from "@/lib";
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
import type { ExcalidrawImperativeAPI, Gesture } from "@excalidraw/excalidraw/types";
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
import ReactClearModal from "react-clear-modal";
|
import ReactClearModal from "react-clear-modal";
|
||||||
@@ -23,7 +23,7 @@ import { IconEdit } from "@tabler/icons-react";
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
import { useHandleLibrary, LiveCollaborationTrigger } from "@excalidraw/excalidraw";
|
||||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||||
import { useExcalidrawCollab } from "./use-excalidraw-collab";
|
import { useExcalidrawCollab } from "./use-excalidraw-collab";
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
const computedColorScheme = useComputedColorScheme();
|
const computedColorScheme = useComputedColorScheme();
|
||||||
|
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
const { broadcastScene } = useExcalidrawCollab(excalidrawAPI, pageId, opened);
|
const { broadcastScene, broadcastPointer, isCollaborating } = useExcalidrawCollab(excalidrawAPI, pageId, opened);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(elements: readonly ExcalidrawElement[]) => {
|
(elements: readonly ExcalidrawElement[]) => {
|
||||||
@@ -170,6 +170,13 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
}}
|
}}
|
||||||
theme={computedColorScheme}
|
theme={computedColorScheme}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onPointerUpdate={broadcastPointer}
|
||||||
|
renderTopRightUI={() => (
|
||||||
|
<LiveCollaborationTrigger
|
||||||
|
isCollaborating={isCollaborating}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,56 +1,98 @@
|
|||||||
import { useEffect, useRef, useCallback, useMemo } from "react";
|
import { useEffect, useRef, useCallback, useMemo, useState } from "react";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
|
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
|
import type { ExcalidrawImperativeAPI, Collaborator, Gesture } from "@excalidraw/excalidraw/types";
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
import { reconcileElements, getSceneVersion } from "@excalidraw/excalidraw";
|
import { reconcileElements, getSceneVersion } from "@excalidraw/excalidraw";
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
|
|
||||||
type Collaborator = {
|
// Message types for collaboration
|
||||||
socketId: string;
|
type SceneUpdateMessage = {
|
||||||
isCurrentUser?: boolean;
|
type: "SCENE_UPDATE";
|
||||||
|
payload: { elements: readonly ExcalidrawElement[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PointerUpdateMessage = {
|
||||||
|
type: "POINTER_UPDATE";
|
||||||
|
payload: {
|
||||||
|
socketId: string;
|
||||||
|
pointer: { x: number; y: number };
|
||||||
|
button: "down" | "up";
|
||||||
|
username: string;
|
||||||
|
selectedElementIds: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type CollabMessage = SceneUpdateMessage | PointerUpdateMessage;
|
||||||
|
|
||||||
export function useExcalidrawCollab(
|
export function useExcalidrawCollab(
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI | null,
|
excalidrawAPI: ExcalidrawImperativeAPI | null,
|
||||||
pageId: string | undefined,
|
pageId: string | undefined,
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
) {
|
) {
|
||||||
const [socket] = useAtom(socketAtom);
|
const [socket] = useAtom(socketAtom);
|
||||||
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const lastBroadcastedVersion = useRef(-1);
|
const lastBroadcastedVersion = useRef(-1);
|
||||||
const isInitialized = useRef(false);
|
const isInitialized = useRef(false);
|
||||||
|
const collaboratorsRef = useRef<Map<string, Collaborator>>(new Map());
|
||||||
|
const [isCollaborating, setIsCollaborating] = useState(false);
|
||||||
|
|
||||||
const roomId = pageId ? `excalidraw-${pageId}` : null;
|
const roomId = pageId ? `excalidraw-${pageId}` : null;
|
||||||
|
const username = currentUser?.user?.name || "Anonymous";
|
||||||
|
|
||||||
// Create stable throttled broadcast function
|
// Broadcast pointer/cursor updates (volatile - can be dropped)
|
||||||
|
const broadcastPointer = useMemo(
|
||||||
|
() =>
|
||||||
|
throttle(
|
||||||
|
(payload: {
|
||||||
|
pointer: { x: number; y: number };
|
||||||
|
button: "down" | "up";
|
||||||
|
pointersMap: Gesture["pointers"];
|
||||||
|
}) => {
|
||||||
|
if (!socket || !roomId || !isInitialized.current) return;
|
||||||
|
if (payload.pointersMap.size >= 2) return; // Skip multi-touch
|
||||||
|
|
||||||
|
const data: PointerUpdateMessage = {
|
||||||
|
type: "POINTER_UPDATE",
|
||||||
|
payload: {
|
||||||
|
socketId: socket.id!,
|
||||||
|
pointer: payload.pointer,
|
||||||
|
button: payload.button,
|
||||||
|
username,
|
||||||
|
selectedElementIds:
|
||||||
|
excalidrawAPI?.getAppState().selectedElementIds || {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = JSON.stringify(data);
|
||||||
|
socket.emit("server-volatile-broadcast", [roomId, json, null]);
|
||||||
|
},
|
||||||
|
50,
|
||||||
|
),
|
||||||
|
[socket, roomId, username, excalidrawAPI],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast scene changes
|
||||||
const broadcastScene = useMemo(
|
const broadcastScene = useMemo(
|
||||||
() =>
|
() =>
|
||||||
throttle((elements: readonly ExcalidrawElement[]) => {
|
throttle((elements: readonly ExcalidrawElement[]) => {
|
||||||
if (!socket || !roomId || !isInitialized.current) {
|
if (!socket || !roomId || !isInitialized.current) {
|
||||||
console.log("broadcastScene: not ready", {
|
|
||||||
socket: !!socket,
|
|
||||||
roomId,
|
|
||||||
isInitialized: isInitialized.current,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSceneVersion sums all element versions - increases on ANY change
|
|
||||||
const sceneVersion = getSceneVersion(elements);
|
const sceneVersion = getSceneVersion(elements);
|
||||||
|
|
||||||
if (sceneVersion <= lastBroadcastedVersion.current) {
|
if (sceneVersion <= lastBroadcastedVersion.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const data: SceneUpdateMessage = {
|
||||||
type: "SCENE_UPDATE",
|
type: "SCENE_UPDATE",
|
||||||
payload: { elements },
|
payload: { elements },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send as plain JSON for now (no encryption)
|
|
||||||
const json = JSON.stringify(data);
|
const json = JSON.stringify(data);
|
||||||
console.log("Broadcasting scene, version:", sceneVersion);
|
|
||||||
|
|
||||||
socket.emit("server-broadcast", [roomId, json, null]);
|
socket.emit("server-broadcast", [roomId, json, null]);
|
||||||
lastBroadcastedVersion.current = sceneVersion;
|
lastBroadcastedVersion.current = sceneVersion;
|
||||||
}, 100),
|
}, 100),
|
||||||
@@ -60,10 +102,10 @@ export function useExcalidrawCollab(
|
|||||||
// Handle incoming broadcasts
|
// Handle incoming broadcasts
|
||||||
const handleClientBroadcast = useCallback(
|
const handleClientBroadcast = useCallback(
|
||||||
(jsonData: string, _iv: Uint8Array | null) => {
|
(jsonData: string, _iv: Uint8Array | null) => {
|
||||||
if (!excalidrawAPI) return;
|
if (!excalidrawAPI || !socket) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(jsonData);
|
const data: CollabMessage = JSON.parse(jsonData);
|
||||||
|
|
||||||
if (data.type === "SCENE_UPDATE" && data.payload?.elements) {
|
if (data.type === "SCENE_UPDATE" && data.payload?.elements) {
|
||||||
const remoteElements = data.payload.elements;
|
const remoteElements = data.payload.elements;
|
||||||
@@ -80,14 +122,34 @@ export function useExcalidrawCollab(
|
|||||||
elements: reconciledElements,
|
elements: reconciledElements,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update version to prevent echo
|
|
||||||
lastBroadcastedVersion.current = getSceneVersion(reconciledElements);
|
lastBroadcastedVersion.current = getSceneVersion(reconciledElements);
|
||||||
|
} else if (data.type === "POINTER_UPDATE") {
|
||||||
|
const { socketId, pointer, button, username, selectedElementIds } =
|
||||||
|
data.payload;
|
||||||
|
|
||||||
|
// Don't update our own cursor
|
||||||
|
if (socketId === socket.id) return;
|
||||||
|
|
||||||
|
// Update collaborator with pointer info
|
||||||
|
const collaborator = collaboratorsRef.current.get(socketId) || {};
|
||||||
|
collaboratorsRef.current.set(socketId, {
|
||||||
|
...collaborator,
|
||||||
|
pointer,
|
||||||
|
button,
|
||||||
|
username,
|
||||||
|
selectedElementIds,
|
||||||
|
isCurrentUser: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
excalidrawAPI.updateScene({
|
||||||
|
collaborators: collaboratorsRef.current,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to process broadcast:", err);
|
console.error("Failed to process broadcast:", err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[excalidrawAPI],
|
[excalidrawAPI, socket],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle room user changes
|
// Handle room user changes
|
||||||
@@ -95,22 +157,32 @@ export function useExcalidrawCollab(
|
|||||||
(socketIds: string[]) => {
|
(socketIds: string[]) => {
|
||||||
if (!excalidrawAPI || !socket) return;
|
if (!excalidrawAPI || !socket) return;
|
||||||
|
|
||||||
const collaborators = new Map<string, Collaborator>();
|
// Update collaborators map, preserving existing data
|
||||||
|
const newCollaborators = new Map<string, Collaborator>();
|
||||||
for (const id of socketIds) {
|
for (const id of socketIds) {
|
||||||
collaborators.set(id, {
|
const existing = collaboratorsRef.current.get(id);
|
||||||
socketId: id,
|
newCollaborators.set(id, {
|
||||||
|
...existing,
|
||||||
isCurrentUser: id === socket.id,
|
isCurrentUser: id === socket.id,
|
||||||
|
username: existing?.username || (id === socket.id ? username : "User"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
|
||||||
excalidrawAPI.updateScene({ collaborators });
|
collaboratorsRef.current = newCollaborators;
|
||||||
|
excalidrawAPI.updateScene({ collaborators: newCollaborators });
|
||||||
|
|
||||||
|
// We're collaborating if there are other users
|
||||||
|
setIsCollaborating(socketIds.length > 1);
|
||||||
},
|
},
|
||||||
[excalidrawAPI, socket],
|
[excalidrawAPI, socket, username],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Join/leave room based on modal state
|
// Join/leave room based on modal state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket || !roomId || !isOpen) return;
|
if (!socket || !roomId || !isOpen) {
|
||||||
|
setIsCollaborating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Joining room:", roomId);
|
console.log("Joining room:", roomId);
|
||||||
socket.emit("join-room", roomId);
|
socket.emit("join-room", roomId);
|
||||||
@@ -138,8 +210,22 @@ export function useExcalidrawCollab(
|
|||||||
socket.off("new-user");
|
socket.off("new-user");
|
||||||
isInitialized.current = false;
|
isInitialized.current = false;
|
||||||
lastBroadcastedVersion.current = -1;
|
lastBroadcastedVersion.current = -1;
|
||||||
|
collaboratorsRef.current = new Map();
|
||||||
|
setIsCollaborating(false);
|
||||||
};
|
};
|
||||||
}, [socket, roomId, isOpen, handleClientBroadcast, handleRoomUserChange, broadcastScene, excalidrawAPI]);
|
}, [
|
||||||
|
socket,
|
||||||
|
roomId,
|
||||||
|
isOpen,
|
||||||
|
handleClientBroadcast,
|
||||||
|
handleRoomUserChange,
|
||||||
|
broadcastScene,
|
||||||
|
excalidrawAPI,
|
||||||
|
]);
|
||||||
|
|
||||||
return { broadcastScene };
|
return {
|
||||||
|
broadcastScene,
|
||||||
|
broadcastPointer,
|
||||||
|
isCollaborating,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user