mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
feat(packages/excalidraw): do not dedupe collaborators (#11835)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user