Add user list component + snap to user functionality (#1749)

This commit is contained in:
Oliver Benns 2020-06-19 11:36:49 +01:00 committed by GitHub
parent 8f65e37dac
commit ca87ca6fe9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 333 additions and 32 deletions

View File

@ -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 (
<Avatar
color={background}
onClick={() => updateData(collaborator.pointer)}
>
{shortName}
</Avatar>
);
},
});

View File

@ -47,3 +47,5 @@ export {
} from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup";
export { actionGoToCollaborator } from "./actionNavigate";

View File

@ -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}
/>
);
}

View File

@ -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;

30
src/clients.ts Normal file
View File

@ -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();
};

View File

@ -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;
}

25
src/components/Avatar.tsx Normal file
View File

@ -0,0 +1,25 @@
import "./Avatar.scss";
import React from "react";
type AvatarProps = {
children: string;
className?: string;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string;
};
export const Avatar = ({
children,
className,
color,
onClick,
}: AvatarProps) => (
<div
className={`Avatar ${className}`}
style={{ background: color }}
onClick={onClick}
>
{children}
</div>
);

View File

@ -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 = () => (
<a
className={`encrypted-icon tooltip zen-mode-visibility ${
@ -203,7 +206,23 @@ const LayerUI = ({
</Stack.Col>
)}
</Section>
<div />
<UserList
className={`zen-mode-transition ${
zenModeEnabled && "transition-right"
}`}
>
{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]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", clientId)}
</Tooltip>
))}
</UserList>
</div>
{
<div

View File

@ -16,6 +16,7 @@ import { RoomDialog } from "./RoomDialog";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockIcon } from "./LockIcon";
import { LoadingMessage } from "./LoadingMessage";
import { UserList } from "./UserList";
type MobileMenuProps = {
appState: AppState;
@ -105,6 +106,22 @@ export const MobileMenu = ({
}}
/>
</fieldset>
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList mobile>
{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]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction(
"goToCollaborator",
clientId,
)}
</React.Fragment>
))}
</UserList>
</fieldset>
</Stack.Col>
</div>
</Section>

View File

@ -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;
}

View File

@ -0,0 +1,15 @@
import "./Tooltip.scss";
import React from "react";
type TooltipProps = {
children: React.ReactNode;
label: string;
};
export const Tooltip = ({ children, label }: TooltipProps) => (
<div className="Tooltip">
<span className="Tooltip__label">{label}</span>
{children}
</div>
);

View File

@ -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;
}

View File

@ -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 <div className={compClassName}>{children}</div>;
};

View File

@ -457,6 +457,7 @@ button,
.github-corner {
position: fixed;
top: 0;
z-index: 2;
:root[dir="ltr"] & {
right: 0;
}

View File

@ -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",

View File

@ -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<typeof handlerRectangles>;
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;

39
src/tests/clients.test.ts Normal file
View File

@ -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("?");
});
});

View File

@ -16,6 +16,16 @@ import { LinearElementEditor } from "./element/linearElementEditor";
export type FlooredNumber = number & { _brand: "FlooredNumber" };
export type Point = Readonly<RoughPoint>;
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<string, Collaborator>;
shouldCacheIgnoreZoom: boolean;
showShortcutsDialog: boolean;
zenModeEnabled: boolean;