diff --git a/src/actions/actionNavigate.tsx b/src/actions/actionNavigate.tsx
new file mode 100644
index 00000000..1e079795
--- /dev/null
+++ b/src/actions/actionNavigate.tsx
@@ -0,0 +1,52 @@
+import React from "react";
+import { Avatar } from "../components/Avatar";
+import { register } from "./register";
+import { getClientColors, getClientInitials } from "../clients";
+import { Collaborator } from "../types";
+import { normalizeScroll } from "../scene";
+
+export const actionGoToCollaborator = register({
+ name: "goToCollaborator",
+ perform: (_elements, appState, value) => {
+ const point = value as Collaborator["pointer"];
+ if (!point) {
+ return { appState, commitToHistory: false };
+ }
+
+ return {
+ appState: {
+ ...appState,
+ scrollX: normalizeScroll(window.innerWidth / 2 - point.x),
+ scrollY: normalizeScroll(window.innerHeight / 2 - point.y),
+ // Close mobile menu
+ openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
+ },
+ commitToHistory: false,
+ };
+ },
+ PanelComponent: ({ appState, updateData, id }) => {
+ const clientId = id;
+
+ if (!clientId) {
+ return null;
+ }
+
+ const collaborator = appState.collaborators.get(clientId);
+
+ if (!collaborator) {
+ return null;
+ }
+
+ const { background } = getClientColors(clientId);
+ const shortName = getClientInitials(collaborator.username);
+
+ return (
+ updateData(collaborator.pointer)}
+ >
+ {shortName}
+
+ );
+ },
+});
diff --git a/src/actions/index.ts b/src/actions/index.ts
index 7bd662b3..959f0b5d 100644
--- a/src/actions/index.ts
+++ b/src/actions/index.ts
@@ -47,3 +47,5 @@ export {
} from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup";
+
+export { actionGoToCollaborator } from "./actionNavigate";
diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx
index 86aed55a..ece81d90 100644
--- a/src/actions/manager.tsx
+++ b/src/actions/manager.tsx
@@ -101,7 +101,11 @@ export class ActionManager implements ActionsManagerInterface {
}));
}
- renderAction = (name: ActionName) => {
+ // Id is an attribute that we can use to pass in data like keys.
+ // This is needed for dynamically generated action components
+ // like the user list. We can use this key to extract more
+ // data from app state. This is an alternative to generic prop hell!
+ renderAction = (name: ActionName, id?: string) => {
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
@@ -120,6 +124,7 @@ export class ActionManager implements ActionsManagerInterface {
elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()}
updateData={updateData}
+ id={id}
/>
);
}
diff --git a/src/actions/types.ts b/src/actions/types.ts
index e0189398..49b6426e 100644
--- a/src/actions/types.ts
+++ b/src/actions/types.ts
@@ -58,7 +58,8 @@ export type ActionName =
| "toggleFullScreen"
| "toggleShortcuts"
| "group"
- | "ungroup";
+ | "ungroup"
+ | "goToCollaborator";
export interface Action {
name: ActionName;
@@ -66,6 +67,7 @@ export interface Action {
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData?: any) => void;
+ id?: string;
}>;
perform: ActionFn;
keyPriority?: number;
diff --git a/src/clients.ts b/src/clients.ts
new file mode 100644
index 00000000..01de96ef
--- /dev/null
+++ b/src/clients.ts
@@ -0,0 +1,30 @@
+import colors from "./colors";
+
+export const getClientColors = (clientId: string) => {
+ // Naive way of getting an integer out of the clientId
+ const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
+
+ // Skip transparent background.
+ const backgrounds = colors.elementBackground.slice(1);
+ const strokes = colors.elementStroke.slice(1);
+ return {
+ background: backgrounds[sum % backgrounds.length],
+ stroke: strokes[sum % strokes.length],
+ };
+};
+
+export const getClientInitials = (username?: string | null) => {
+ if (!username) {
+ return "?";
+ }
+ const names = username.trim().split(" ");
+
+ if (names.length < 2) {
+ return names[0].substring(0, 2).toUpperCase();
+ }
+
+ const firstName = names[0];
+ const lastName = names[names.length - 1];
+
+ return (firstName[0] + lastName[0]).toUpperCase();
+};
diff --git a/src/components/Avatar.scss b/src/components/Avatar.scss
new file mode 100644
index 00000000..e1baaa63
--- /dev/null
+++ b/src/components/Avatar.scss
@@ -0,0 +1,14 @@
+@import "../css/_variables";
+
+.Avatar {
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 1.25rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: $oc-white;
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-weight: 500;
+}
diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx
new file mode 100644
index 00000000..418f25fc
--- /dev/null
+++ b/src/components/Avatar.tsx
@@ -0,0 +1,25 @@
+import "./Avatar.scss";
+
+import React from "react";
+
+type AvatarProps = {
+ children: string;
+ className?: string;
+ onClick: (e: React.MouseEvent) => void;
+ color: string;
+};
+
+export const Avatar = ({
+ children,
+ className,
+ color,
+ onClick,
+}: AvatarProps) => (
+
+ {children}
+
+);
diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx
index ddbefe34..57e0fc34 100644
--- a/src/components/LayerUI.tsx
+++ b/src/components/LayerUI.tsx
@@ -10,6 +10,7 @@ import { ActionManager } from "../actions/manager";
import { Island } from "./Island";
import Stack from "./Stack";
import { FixedSideContainer } from "./FixedSideContainer";
+import { UserList } from "./UserList";
import { LockIcon } from "./LockIcon";
import { ExportDialog, ExportCB } from "./ExportDialog";
import { LanguageList } from "./LanguageList";
@@ -28,6 +29,7 @@ import { LoadingMessage } from "./LoadingMessage";
import { CLASSES } from "../constants";
import { shield } from "./icons";
import { GitHubCorner } from "./GitHubCorner";
+import { Tooltip } from "./Tooltip";
import "./LayerUI.scss";
@@ -61,6 +63,7 @@ const LayerUI = ({
}: LayerUIProps) => {
const isMobile = useIsMobile();
+ // TODO: Extend tooltip component and use here.
const renderEncryptedIcon = () => (
)}
-
+
+ {Array.from(appState.collaborators)
+ // Collaborator is either not initialized or is actually the current user.
+ .filter(([_, client]) => Object.keys(client).length !== 0)
+ .map(([clientId, client]) => (
+
+ {actionManager.renderAction("goToCollaborator", clientId)}
+
+ ))}
+
{
+
diff --git a/src/components/Tooltip.scss b/src/components/Tooltip.scss
new file mode 100644
index 00000000..a42f3430
--- /dev/null
+++ b/src/components/Tooltip.scss
@@ -0,0 +1,48 @@
+@import "../css/_variables";
+
+.Tooltip {
+ position: relative;
+}
+
+.Tooltip__label {
+ --arrow-size: 4px;
+ visibility: hidden;
+ width: 10ch;
+ background: $oc-black;
+ color: $oc-white;
+ text-align: center;
+ border-radius: 4px;
+ padding: 4px;
+ position: absolute;
+ z-index: 10;
+ font-size: 0.7rem;
+ line-height: 1.5;
+ top: calc(100% + var(--arrow-size) + 3px);
+ // extra pixel offset for unknown reasons
+ left: calc(-50% + var(--arrow-size) / 2 - 1px);
+ word-wrap: break-word;
+
+ &::after {
+ content: "";
+ border: var(--arrow-size) solid transparent;
+ border-bottom-color: $oc-black;
+ position: absolute;
+ bottom: 100%;
+ left: calc(50% - var(--arrow-size));
+ }
+}
+
+// the following 3 rules ensure that the tooltip doesn't show (nor affect
+// the cursor) when you drag over when you draw on canvas, but at the same
+// time it still works when clicking on the link/shield
+body:active .Tooltip:not(:hover) {
+ pointer-events: none;
+}
+
+body:not(:active) .Tooltip:hover .Tooltip__label {
+ visibility: visible;
+}
+
+.Tooltip__label:hover {
+ visibility: visible;
+}
diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx
new file mode 100644
index 00000000..084d3ca8
--- /dev/null
+++ b/src/components/Tooltip.tsx
@@ -0,0 +1,15 @@
+import "./Tooltip.scss";
+
+import React from "react";
+
+type TooltipProps = {
+ children: React.ReactNode;
+ label: string;
+};
+
+export const Tooltip = ({ children, label }: TooltipProps) => (
+
+ {label}
+ {children}
+
+);
diff --git a/src/components/UserList.css b/src/components/UserList.css
new file mode 100644
index 00000000..32d5ada8
--- /dev/null
+++ b/src/components/UserList.css
@@ -0,0 +1,22 @@
+.UserList {
+ pointer-events: none;
+ /*github corner*/
+ padding: var(--space-factor) 40px var(--space-factor) var(--space-factor);
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.UserList > * {
+ pointer-events: all;
+ margin: 0 0 var(--space-factor) var(--space-factor);
+}
+
+.UserList_mobile {
+ padding: 0;
+ justify-content: normal;
+}
+
+.UserList_mobile > * {
+ margin: 0 var(--space-factor) var(--space-factor) 0;
+}
diff --git a/src/components/UserList.tsx b/src/components/UserList.tsx
new file mode 100644
index 00000000..e2487d0e
--- /dev/null
+++ b/src/components/UserList.tsx
@@ -0,0 +1,23 @@
+import "./UserList.css";
+
+import React from "react";
+
+type UserListProps = {
+ children: React.ReactNode;
+ className?: string;
+ mobile?: boolean;
+};
+
+export const UserList = ({ children, className, mobile }: UserListProps) => {
+ let compClassName = "UserList";
+
+ if (className) {
+ compClassName += ` ${className}`;
+ }
+
+ if (mobile) {
+ compClassName += " UserList_mobile";
+ }
+
+ return {children}
;
+};
diff --git a/src/css/styles.scss b/src/css/styles.scss
index a68db059..5c871d2d 100644
--- a/src/css/styles.scss
+++ b/src/css/styles.scss
@@ -457,6 +457,7 @@ button,
.github-corner {
position: fixed;
top: 0;
+ z-index: 2;
:root[dir="ltr"] & {
right: 0;
}
diff --git a/src/locales/en.json b/src/locales/en.json
index ad9731af..803fd363 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -61,7 +61,8 @@
"yourName": "Your name",
"madeWithExcalidraw": "Made with Excalidraw",
"group": "Group selection",
- "ungroup": "Ungroup selection"
+ "ungroup": "Ungroup selection",
+ "collaborators": "Collaborators"
},
"buttons": {
"clearReset": "Reset the canvas",
diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts
index 8d6245b5..309ee971 100644
--- a/src/renderer/renderScene.ts
+++ b/src/renderer/renderScene.ts
@@ -28,7 +28,7 @@ import {
import { getSelectedElements } from "../scene/selection";
import { renderElement, renderElementToSvg } from "./renderElement";
-import colors from "../colors";
+import { getClientColors } from "../clients";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
@@ -39,19 +39,6 @@ import {
type HandlerRectanglesRet = keyof ReturnType;
-const colorsForClientId = (clientId: string) => {
- // Naive way of getting an integer out of the clientId
- const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
-
- // Skip transparent background.
- const backgrounds = colors.elementBackground.slice(1);
- const strokes = colors.elementStroke.slice(1);
- return {
- background: backgrounds[sum % backgrounds.length],
- stroke: strokes[sum % strokes.length],
- };
-};
-
const strokeRectWithRotation = (
context: CanvasRenderingContext2D,
x: number,
@@ -232,7 +219,7 @@ export const renderScene = (
if (sceneState.remoteSelectedElementIds[element.id]) {
selectionColors.push(
...sceneState.remoteSelectedElementIds[element.id].map((socketId) => {
- const { background } = colorsForClientId(socketId);
+ const { background } = getClientColors(socketId);
return background;
}),
);
@@ -444,7 +431,7 @@ export const renderScene = (
y = Math.max(y, 0);
y = Math.min(y, normalizedCanvasHeight - height);
- const { background, stroke } = colorsForClientId(clientId);
+ const { background, stroke } = getClientColors(clientId);
const strokeStyle = context.strokeStyle;
const fillStyle = context.fillStyle;
diff --git a/src/tests/clients.test.ts b/src/tests/clients.test.ts
new file mode 100644
index 00000000..61594fc7
--- /dev/null
+++ b/src/tests/clients.test.ts
@@ -0,0 +1,39 @@
+import { getClientInitials } from "../clients";
+
+describe("getClientInitials", () => {
+ it("returns substring if one name provided", () => {
+ const result = getClientInitials("Alan");
+ expect(result).toBe("AL");
+ });
+
+ it("returns initials", () => {
+ const result = getClientInitials("John Doe");
+ expect(result).toBe("JD");
+ });
+
+ it("returns correct initials if many names provided", () => {
+ const result = getClientInitials("John Alan Doe");
+ expect(result).toBe("JD");
+ });
+
+ it("returns single initial if 1 letter provided", () => {
+ const result = getClientInitials("z");
+ expect(result).toBe("Z");
+ });
+
+ it("trims trailing whitespace", () => {
+ const result = getClientInitials(" q ");
+ expect(result).toBe("Q");
+ });
+
+ it('returns "?" if falsey value provided', () => {
+ let result = getClientInitials("");
+ expect(result).toBe("?");
+
+ result = getClientInitials(undefined);
+ expect(result).toBe("?");
+
+ result = getClientInitials(null);
+ expect(result).toBe("?");
+ });
+});
diff --git a/src/types.ts b/src/types.ts
index ecdd4a63..9e205d2c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -16,6 +16,16 @@ import { LinearElementEditor } from "./element/linearElementEditor";
export type FlooredNumber = number & { _brand: "FlooredNumber" };
export type Point = Readonly;
+export type Collaborator = {
+ pointer?: {
+ x: number;
+ y: number;
+ };
+ button?: "up" | "down";
+ selectedElementIds?: AppState["selectedElementIds"];
+ username?: string | null;
+};
+
export type AppState = {
isLoading: boolean;
errorMessage: string | null;
@@ -58,18 +68,7 @@ export type AppState = {
lastPointerDownWith: PointerType;
selectedElementIds: { [id: string]: boolean };
previousSelectedElementIds: { [id: string]: boolean };
- collaborators: Map<
- string,
- {
- pointer?: {
- x: number;
- y: number;
- };
- button?: "up" | "down";
- selectedElementIds?: AppState["selectedElementIds"];
- username?: string | null;
- }
- >;
+ collaborators: Map;
shouldCacheIgnoreZoom: boolean;
showShortcutsDialog: boolean;
zenModeEnabled: boolean;