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)} + + ))} +
{
+
+ {t("labels.collaborators")} + + {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;