feat: redesigned collab cursors (#6659)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Arnost Pleskot 2023-06-12 15:43:14 +02:00 committed by GitHub
parent b4abfad638
commit 5ca3613cc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 100 additions and 96 deletions

View File

@ -1,4 +1,4 @@
import { getClientColors } from "../clients"; import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types"; import { Collaborator } from "../types";
@ -31,15 +31,14 @@ export const actionGoToCollaborator = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ appState, updateData, data }) => { PanelComponent: ({ updateData, data }) => {
const [clientId, collaborator] = data as [string, Collaborator]; const [clientId, collaborator] = data as [string, Collaborator];
const { background, stroke } = getClientColors(clientId, appState); const background = getClientColor(clientId);
return ( return (
<Avatar <Avatar
color={background} color={background}
border={stroke}
onClick={() => updateData(collaborator.pointer)} onClick={() => updateData(collaborator.pointer)}
name={collaborator.username || ""} name={collaborator.username || ""}
src={collaborator.avatarUrl} src={collaborator.avatarUrl}

View File

@ -1,31 +1,31 @@
import { function hashToInteger(id: string) {
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, let hash = 0;
DEFAULT_ELEMENT_STROKE_COLOR_INDEX, if (id.length === 0) {
getAllColorsSpecificShade, return hash;
} from "./colors";
import { AppState } from "./types";
const BG_COLORS = getAllColorsSpecificShade(
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
);
const STROKE_COLORS = getAllColorsSpecificShade(
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
);
export const getClientColors = (clientId: string, appState: AppState) => {
if (appState?.collaborators) {
const currentUser = appState.collaborators.get(clientId);
if (currentUser?.color) {
return currentUser.color;
}
} }
// Naive way of getting an integer out of the clientId for (let i = 0; i < id.length; i++) {
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0); const char = id.charCodeAt(i);
hash = (hash << 5) - hash + char;
}
return hash;
}
return { export const getClientColor = (
background: BG_COLORS[sum % BG_COLORS.length], /**
stroke: STROKE_COLORS[sum % STROKE_COLORS.length], * any uniquely identifying key, such as user id or socket id
}; */
id: string,
) => {
// to get more even distribution in case `id` is not uniformly distributed to
// begin with, we hash it
const hash = Math.abs(hashToInteger(id));
// we want to get a multiple of 10 number in the range of 0-360 (in other
// words a hue value of step size 10). There are 37 such values including 0.
const hue = (hash % 37) * 10;
const saturation = 100;
const lightness = 83;
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}; };
/** /**

View File

@ -10,10 +10,9 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: $oc-white;
cursor: pointer; cursor: pointer;
font-size: 0.625rem; font-size: 0.75rem;
font-weight: 500; font-weight: 800;
line-height: 1; line-height: 1;
&-img { &-img {

View File

@ -6,7 +6,6 @@ import { getNameInitial } from "../clients";
type AvatarProps = { type AvatarProps = {
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string; color: string;
border: string;
name: string; name: string;
src?: string; src?: string;
}; };

View File

@ -30,7 +30,7 @@ import {
import { getSelectedElements } from "../scene/selection"; import { getSelectedElements } from "../scene/selection";
import { renderElement, renderElementToSvg } from "./renderElement"; import { renderElement, renderElementToSvg } from "./renderElement";
import { getClientColors } from "../clients"; import { getClientColor } from "../clients";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { import {
isSelectedViaGroup, isSelectedViaGroup,
@ -48,11 +48,7 @@ import {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
} from "../element/transformHandles"; } from "../element/transformHandles";
import { import { viewportCoordsToSceneCoords, throttleRAF } from "../utils";
viewportCoordsToSceneCoords,
supportsEmoji,
throttleRAF,
} from "../utils";
import { UserIdleState } from "../types"; import { UserIdleState } from "../types";
import { THEME_FILTER } from "../constants"; import { THEME_FILTER } from "../constants";
import { import {
@ -61,7 +57,6 @@ import {
} from "../element/Hyperlink"; } from "../element/Hyperlink";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
const hasEmojiSupport = supportsEmoji();
export const DEFAULT_SPACING = 2; export const DEFAULT_SPACING = 2;
const strokeRectWithRotation = ( const strokeRectWithRotation = (
@ -159,7 +154,6 @@ const strokeGrid = (
const renderSingleLinearPoint = ( const renderSingleLinearPoint = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: AppState,
renderConfig: RenderConfig, renderConfig: RenderConfig,
point: Point, point: Point,
radius: number, radius: number,
@ -206,14 +200,7 @@ const renderLinearPointHandles = (
const isSelected = const isSelected =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx); !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
renderSingleLinearPoint( renderSingleLinearPoint(context, renderConfig, point, radius, isSelected);
context,
appState,
renderConfig,
point,
radius,
isSelected,
);
}); });
//Rendering segment mid points //Rendering segment mid points
@ -237,7 +224,6 @@ const renderLinearPointHandles = (
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
renderSingleLinearPoint( renderSingleLinearPoint(
context, context,
appState,
renderConfig, renderConfig,
segmentMidPoint, segmentMidPoint,
radius, radius,
@ -248,7 +234,6 @@ const renderLinearPointHandles = (
highlightPoint(segmentMidPoint, context, renderConfig); highlightPoint(segmentMidPoint, context, renderConfig);
renderSingleLinearPoint( renderSingleLinearPoint(
context, context,
appState,
renderConfig, renderConfig,
segmentMidPoint, segmentMidPoint,
radius, radius,
@ -258,7 +243,6 @@ const renderLinearPointHandles = (
} else if (appState.editingLinearElement || points.length === 2) { } else if (appState.editingLinearElement || points.length === 2) {
renderSingleLinearPoint( renderSingleLinearPoint(
context, context,
appState,
renderConfig, renderConfig,
segmentMidPoint, segmentMidPoint,
POINT_HANDLE_SIZE / 2, POINT_HANDLE_SIZE / 2,
@ -527,7 +511,7 @@ export const _renderScene = ({
selectionColors.push( selectionColors.push(
...renderConfig.remoteSelectedElementIds[element.id].map( ...renderConfig.remoteSelectedElementIds[element.id].map(
(socketId) => { (socketId) => {
const { background } = getClientColors(socketId, appState); const background = getClientColor(socketId);
return background; return background;
}, },
), ),
@ -647,7 +631,7 @@ export const _renderScene = ({
x -= appState.offsetLeft; x -= appState.offsetLeft;
y -= appState.offsetTop; y -= appState.offsetTop;
const width = 9; const width = 11;
const height = 14; const height = 14;
const isOutOfBounds = const isOutOfBounds =
@ -661,15 +645,20 @@ export const _renderScene = ({
y = Math.max(y, 0); y = Math.max(y, 0);
y = Math.min(y, normalizedCanvasHeight - height); y = Math.min(y, normalizedCanvasHeight - height);
const { background, stroke } = getClientColors(clientId, appState); const background = getClientColor(clientId);
context.save(); context.save();
context.strokeStyle = stroke; context.strokeStyle = background;
context.fillStyle = background; context.fillStyle = background;
const userState = renderConfig.remotePointerUserStates[clientId]; const userState = renderConfig.remotePointerUserStates[clientId];
if (isOutOfBounds || userState === UserIdleState.AWAY) { const isInactive =
context.globalAlpha = 0.48; isOutOfBounds ||
userState === UserIdleState.IDLE ||
userState === UserIdleState.AWAY;
if (isInactive) {
context.globalAlpha = 0.3;
} }
if ( if (
@ -686,73 +675,91 @@ export const _renderScene = ({
context.beginPath(); context.beginPath();
context.arc(x, y, 15, 0, 2 * Math.PI, false); context.arc(x, y, 15, 0, 2 * Math.PI, false);
context.lineWidth = 1; context.lineWidth = 1;
context.strokeStyle = stroke; context.strokeStyle = background;
context.stroke(); context.stroke();
context.closePath(); context.closePath();
} }
// Background (white outline) for arrow
context.fillStyle = oc.white;
context.strokeStyle = oc.white;
context.lineWidth = 6;
context.lineJoin = "round";
context.beginPath(); context.beginPath();
context.moveTo(x, y); context.moveTo(x, y);
context.lineTo(x + 1, y + 14); context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9); context.lineTo(x + 4, y + 9);
context.lineTo(x + 9, y + 10); context.lineTo(x + 11, y + 8);
context.lineTo(x, y); context.closePath();
context.fill();
context.stroke(); context.stroke();
context.fill();
const username = renderConfig.remotePointerUsernames[clientId]; // Arrow
context.fillStyle = background;
let idleState = ""; context.strokeStyle = background;
if (userState === UserIdleState.AWAY) { context.lineWidth = 2;
idleState = hasEmojiSupport ? "⚫️" : ` (${UserIdleState.AWAY})`; context.lineJoin = "round";
} else if (userState === UserIdleState.IDLE) { context.beginPath();
idleState = hasEmojiSupport ? "💤" : ` (${UserIdleState.IDLE})`; if (isInactive) {
context.moveTo(x - 1, y - 1);
context.lineTo(x - 1, y + 15);
context.lineTo(x + 5, y + 10);
context.lineTo(x + 12, y + 9);
context.closePath();
context.fill();
} else {
context.moveTo(x, y);
context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8);
context.closePath();
context.fill();
context.stroke();
} }
const usernameAndIdleState = `${username || ""}${ const username = renderConfig.remotePointerUsernames[clientId] || "";
idleState ? ` ${idleState}` : ""
}`;
if (!isOutOfBounds && usernameAndIdleState) { if (!isOutOfBounds && username) {
const offsetX = x + width; context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
const offsetY = y + height;
const paddingHorizontal = 4; const offsetX = x + width / 2;
const paddingVertical = 4; const offsetY = y + height + 2;
const measure = context.measureText(usernameAndIdleState); const paddingHorizontal = 5;
const paddingVertical = 3;
const measure = context.measureText(username);
const measureHeight = const measureHeight =
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
const finalHeight = Math.max(measureHeight, 12);
const boxX = offsetX - 1; const boxX = offsetX - 1;
const boxY = offsetY - 1; const boxY = offsetY - 1;
const boxWidth = measure.width + 2 * paddingHorizontal + 2; const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
const boxHeight = measureHeight + 2 * paddingVertical + 2; const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
if (context.roundRect) { if (context.roundRect) {
context.beginPath(); context.beginPath();
context.roundRect( context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
boxX,
boxY,
boxWidth,
boxHeight,
4 / renderConfig.zoom.value,
);
context.fillStyle = background; context.fillStyle = background;
context.fill(); context.fill();
context.fillStyle = stroke; context.strokeStyle = oc.white;
context.stroke(); context.stroke();
} else { } else {
// Border // Border
context.fillStyle = stroke; context.fillStyle = oc.white;
context.fillRect(boxX, boxY, boxWidth, boxHeight); context.fillRect(boxX, boxY, boxWidth, boxHeight);
// Background // Background
context.fillStyle = background; context.fillStyle = background;
context.fillRect(offsetX, offsetY, boxWidth - 2, boxHeight - 2); context.fillRect(offsetX, offsetY, boxWidth - 2, boxHeight - 2);
} }
context.fillStyle = oc.white; context.fillStyle = oc.black;
context.fillText( context.fillText(
usernameAndIdleState, username,
offsetX + paddingHorizontal, offsetX + paddingHorizontal + 1,
offsetY + paddingVertical + measure.actualBoundingBoxAscent, offsetY +
paddingVertical +
measure.actualBoundingBoxAscent +
Math.floor((finalHeight - measureHeight) / 2) +
1,
); );
} }
@ -1145,7 +1152,7 @@ export const renderSceneToSvg = (
return; return;
} }
// render elements // render elements
elements.forEach((element, index) => { elements.forEach((element) => {
if (!element.isDeleted) { if (!element.isDeleted) {
try { try {
renderElementToSvg( renderElementToSvg(