feat: redesigned collab cursors (#6659)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
b4abfad638
commit
5ca3613cc3
@ -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}
|
||||||
|
@ -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}%)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user