Add user list component + snap to user functionality (#1749)
parent
8f65e37dac
commit
ca87ca6fe9
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
@ -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();
|
||||
};
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
@ -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;
|
||||
}
|
@ -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>;
|
||||
};
|
@ -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("?");
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue