feat(packages/excalidraw): do not dedupe collaborators (#11835)

This commit is contained in:
David Luzar
2026-08-05 14:20:18 +02:00
committed by GitHub
parent 5e375b4331
commit e33a1c0e85
4 changed files with 111 additions and 34 deletions
@@ -70,6 +70,10 @@
flex-wrap: wrap;
align-items: center;
gap: var(--avatarList-gap);
> .is-current-user {
order: -1;
}
}
.UserList__collaborator-name {
@@ -189,6 +193,8 @@
}
.UserList__collaborators {
display: flex;
flex-direction: column;
top: auto;
max-height: 50vh;
// leave room for the focus/hover ring (box-shadow) on collaborator rows —
@@ -197,6 +203,14 @@
// every row, and top/bottom for the first/last one
padding: 2px;
box-sizing: border-box;
> .hint {
order: -2;
}
> .is-current-user {
order: -1;
}
}
.UserList__menu-divider {
@@ -0,0 +1,79 @@
import React from "react";
import { Excalidraw } from "../index";
import {
act,
fireEvent,
getByText,
render,
waitFor,
} from "../tests/test-utils";
import type { Collaborator, SocketId } from "../types";
const socketId = (id: string) => id as SocketId;
describe("UserList", () => {
it("retains the local dropdown when the same account has multiple clients", async () => {
const { container } = await render(
<Excalidraw
currentUserControls={<button type="button">Spotlight me</button>}
/>,
);
const currentSocketId = socketId("current");
const otherSocketId = socketId("other");
act(() => {
window.h.app.updateScene({
collaborators: new Map<SocketId, Collaborator>([
[
otherSocketId,
{
id: "user",
socketId: otherSocketId,
username: "Ada",
isCurrentUser: false,
},
],
[
currentSocketId,
{
id: "user",
socketId: currentSocketId,
username: "Ada",
isCurrentUser: true,
},
],
]),
});
});
await waitFor(() => {
expect(container.querySelectorAll(".UserList__pill")).toHaveLength(1);
});
const currentUserPill =
container.querySelector<HTMLElement>(".UserList__pill");
expect(currentUserPill).not.toBeNull();
// Radix Popover measures its content when opened.
(global as any).ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
fireEvent.click(currentUserPill!);
await waitFor(() => {
const dropdown = document.querySelector(".UserList__collaborators");
const dropdownCollaborators = dropdown?.querySelectorAll(
".UserList__collaborator",
);
expect(dropdownCollaborators).toHaveLength(2);
expect(
dropdown?.querySelectorAll(".UserList__collaborator.is-current-user"),
).toHaveLength(1);
expect(getByText(document.body, "Spotlight me")).toBeVisible();
});
});
});
+17 -33
View File
@@ -4,8 +4,6 @@ import React, { useLayoutEffect } from "react";
import { supportsResizeObserver, isShallowEqual } from "@excalidraw/common";
import type { MarkRequired } from "@excalidraw/common/utility-types";
import { t } from "../i18n";
import { useExcalidrawActionManager } from "./App";
@@ -27,9 +25,6 @@ export type GoToCollaboratorComponentProps = {
isBeingFollowed: boolean;
};
/** collaborator user id or socket id (fallback) */
type ClientId = string & { _brand: "UserId" };
const DEFAULT_MAX_AVATARS = 4;
const SHOW_COLLABORATORS_FILTER_AT = 8;
@@ -138,28 +133,21 @@ export const UserList = React.memo(
? currentUserControls(!!mobile)
: currentUserControls;
const uniqueCollaboratorsMap = new Map<
ClientId,
MarkRequired<Collaborator, "socketId">
>();
collaborators.forEach((collaborator, socketId) => {
const userId = (collaborator.id || socketId) as ClientId;
uniqueCollaboratorsMap.set(
// filter on user id, else fall back on unique socketId
userId,
{ ...collaborator, socketId },
);
});
const uniqueCollaboratorsArray = Array.from(
uniqueCollaboratorsMap.values(),
const collaboratorsArray = Array.from(
collaborators,
([socketId, collaborator]) => ({
...collaborator,
socketId,
}),
).filter((collaborator) => collaborator.username?.trim());
const currentUser = collaboratorsArray.find((c) => c.isCurrentUser);
const otherCollaborators = collaboratorsArray.filter(
(c) => !c.isCurrentUser,
);
const [searchTerm, setSearchTerm] = React.useState("");
const filteredCollaborators = uniqueCollaboratorsArray.filter(
(collaborator) =>
collaborator.username?.toLowerCase().includes(searchTerm),
const filteredCollaborators = collaboratorsArray.filter((collaborator) =>
collaborator.username?.toLowerCase().includes(searchTerm),
);
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
@@ -195,11 +183,6 @@ export const UserList = React.memo(
DEFAULT_MAX_AVATARS * AVATAR_SLOT_WIDTH,
);
const currentUser = uniqueCollaboratorsArray.find((c) => c.isCurrentUser);
const otherCollaborators = uniqueCollaboratorsArray.filter(
(c) => !c.isCurrentUser,
);
// the current user's avatar is always shown, reserved as the last
// slot — it's the single entry point for the collaborators +
// currentUserControls dropdown, so it's never hidden by/counted in
@@ -255,8 +238,7 @@ export const UserList = React.memo(
sideOffset={10}
>
<Island padding={2}>
{uniqueCollaboratorsArray.length >=
SHOW_COLLABORATORS_FILTER_AT && (
{collaboratorsArray.length >= SHOW_COLLABORATORS_FILTER_AT && (
<QuickSearch
placeholder={t("quickSearch.placeholder")}
onChange={setSearchTerm}
@@ -269,7 +251,9 @@ export const UserList = React.memo(
{/* The list checks for `Children.count()`, hence defensively returning empty list */}
{filteredCollaborators.length > 0
? [
<div className="hint">{t("userList.hint.text")}</div>,
<div key="hint" className="hint">
{t("userList.hint.text")}
</div>,
filteredCollaborators.map((c) =>
renderCollaborator({
actionManager,
@@ -309,7 +293,7 @@ export const UserList = React.memo(
return mobile ? (
<div className={clsx("UserList UserList_mobile", className)}>
<div className="UserList_mobile__avatars">
{uniqueCollaboratorsArray.map((collaborator) =>
{collaboratorsArray.map((collaborator) =>
renderCollaborator({
actionManager,
collaborator,
+1 -1
View File
@@ -84,7 +84,7 @@ export type Collaborator = Readonly<{
// The url of the collaborator's avatar, defaults to username initials
// if not present
avatarUrl?: string;
// user id. If supplied, we'll filter out duplicates when rendering user avatars.
// Stable user id. A user may have multiple collaborators, one per client.
id?: string;
socketId?: SocketId;
isCurrentUser?: boolean;