mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: expose more collaborator status icons (#7777)
This commit is contained in:
parent
b7babe554b
commit
068895db0e
18 changed files with 652 additions and 335 deletions
|
@ -1,3 +1,18 @@
|
|||
import {
|
||||
COLOR_CHARCOAL_BLACK,
|
||||
COLOR_VOICE_CALL,
|
||||
COLOR_WHITE,
|
||||
THEME,
|
||||
} from "./constants";
|
||||
import { roundRect } from "./renderer/roundRect";
|
||||
import { InteractiveCanvasRenderConfig } from "./scene/types";
|
||||
import {
|
||||
Collaborator,
|
||||
InteractiveCanvasAppState,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
} from "./types";
|
||||
|
||||
function hashToInteger(id: string) {
|
||||
let hash = 0;
|
||||
if (id.length === 0) {
|
||||
|
@ -11,14 +26,12 @@ function hashToInteger(id: string) {
|
|||
}
|
||||
|
||||
export const getClientColor = (
|
||||
/**
|
||||
* any uniquely identifying key, such as user id or socket id
|
||||
*/
|
||||
id: string,
|
||||
socketId: SocketId,
|
||||
collaborator: Collaborator | undefined,
|
||||
) => {
|
||||
// to get more even distribution in case `id` is not uniformly distributed to
|
||||
// begin with, we hash it
|
||||
const hash = Math.abs(hashToInteger(id));
|
||||
const hash = Math.abs(hashToInteger(collaborator?.id || socketId));
|
||||
// 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;
|
||||
|
@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => {
|
|||
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
|
||||
).toUpperCase();
|
||||
};
|
||||
|
||||
export const renderRemoteCursors = ({
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
normalizedWidth,
|
||||
normalizedHeight,
|
||||
}: {
|
||||
context: CanvasRenderingContext2D;
|
||||
renderConfig: InteractiveCanvasRenderConfig;
|
||||
appState: InteractiveCanvasAppState;
|
||||
normalizedWidth: number;
|
||||
normalizedHeight: number;
|
||||
}) => {
|
||||
// Paint remote pointers
|
||||
for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) {
|
||||
let { x, y } = pointer;
|
||||
|
||||
const collaborator = appState.collaborators.get(socketId);
|
||||
|
||||
x -= appState.offsetLeft;
|
||||
y -= appState.offsetTop;
|
||||
|
||||
const width = 11;
|
||||
const height = 14;
|
||||
|
||||
const isOutOfBounds =
|
||||
x < 0 ||
|
||||
x > normalizedWidth - width ||
|
||||
y < 0 ||
|
||||
y > normalizedHeight - height;
|
||||
|
||||
x = Math.max(x, 0);
|
||||
x = Math.min(x, normalizedWidth - width);
|
||||
y = Math.max(y, 0);
|
||||
y = Math.min(y, normalizedHeight - height);
|
||||
|
||||
const background = getClientColor(socketId, collaborator);
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = background;
|
||||
context.fillStyle = background;
|
||||
|
||||
const userState = renderConfig.remotePointerUserStates.get(socketId);
|
||||
const isInactive =
|
||||
isOutOfBounds ||
|
||||
userState === UserIdleState.IDLE ||
|
||||
userState === UserIdleState.AWAY;
|
||||
|
||||
if (isInactive) {
|
||||
context.globalAlpha = 0.3;
|
||||
}
|
||||
|
||||
if (renderConfig.remotePointerButton.get(socketId) === "down") {
|
||||
context.beginPath();
|
||||
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
||||
context.lineWidth = 3;
|
||||
context.strokeStyle = "#ffffff88";
|
||||
context.stroke();
|
||||
context.closePath();
|
||||
|
||||
context.beginPath();
|
||||
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
||||
context.lineWidth = 1;
|
||||
context.strokeStyle = background;
|
||||
context.stroke();
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
// TODO remove the dark theme color after we stop inverting canvas colors
|
||||
const IS_SPEAKING_COLOR =
|
||||
appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL;
|
||||
|
||||
const isSpeaking = collaborator?.isSpeaking;
|
||||
|
||||
if (isSpeaking) {
|
||||
// cursor outline for currently speaking user
|
||||
context.fillStyle = IS_SPEAKING_COLOR;
|
||||
context.strokeStyle = IS_SPEAKING_COLOR;
|
||||
context.lineWidth = 10;
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
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.stroke();
|
||||
context.fill();
|
||||
}
|
||||
|
||||
// Background (white outline) for arrow
|
||||
context.fillStyle = COLOR_WHITE;
|
||||
context.strokeStyle = COLOR_WHITE;
|
||||
context.lineWidth = 6;
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
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.stroke();
|
||||
context.fill();
|
||||
|
||||
// Arrow
|
||||
context.fillStyle = background;
|
||||
context.strokeStyle = background;
|
||||
context.lineWidth = 2;
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
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 username = renderConfig.remotePointerUsernames.get(socketId) || "";
|
||||
|
||||
if (!isOutOfBounds && username) {
|
||||
context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
|
||||
|
||||
const offsetX = (isSpeaking ? x + 0 : x) + width / 2;
|
||||
const offsetY = (isSpeaking ? y + 0 : y) + height + 2;
|
||||
const paddingHorizontal = 5;
|
||||
const paddingVertical = 3;
|
||||
const measure = context.measureText(username);
|
||||
const measureHeight =
|
||||
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
|
||||
const finalHeight = Math.max(measureHeight, 12);
|
||||
|
||||
const boxX = offsetX - 1;
|
||||
const boxY = offsetY - 1;
|
||||
const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
|
||||
const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
|
||||
if (context.roundRect) {
|
||||
context.beginPath();
|
||||
context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
|
||||
context.fillStyle = background;
|
||||
context.fill();
|
||||
context.strokeStyle = COLOR_WHITE;
|
||||
context.stroke();
|
||||
|
||||
if (isSpeaking) {
|
||||
context.beginPath();
|
||||
context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8);
|
||||
context.strokeStyle = IS_SPEAKING_COLOR;
|
||||
context.stroke();
|
||||
}
|
||||
} else {
|
||||
roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE);
|
||||
}
|
||||
context.fillStyle = COLOR_CHARCOAL_BLACK;
|
||||
|
||||
context.fillText(
|
||||
username,
|
||||
offsetX + paddingHorizontal + 1,
|
||||
offsetY +
|
||||
paddingVertical +
|
||||
measure.actualBoundingBoxAscent +
|
||||
Math.floor((finalHeight - measureHeight) / 2) +
|
||||
2,
|
||||
);
|
||||
|
||||
// draw three vertical bars signalling someone is speaking
|
||||
if (isSpeaking) {
|
||||
context.fillStyle = IS_SPEAKING_COLOR;
|
||||
const barheight = 8;
|
||||
const margin = 8;
|
||||
const gap = 5;
|
||||
context.fillRect(
|
||||
boxX + boxWidth + margin,
|
||||
boxY + (boxHeight / 2 - barheight / 2),
|
||||
2,
|
||||
barheight,
|
||||
);
|
||||
context.fillRect(
|
||||
boxX + boxWidth + margin + gap,
|
||||
boxY + (boxHeight / 2 - (barheight * 2) / 2),
|
||||
2,
|
||||
barheight * 2,
|
||||
);
|
||||
context.fillRect(
|
||||
boxX + boxWidth + margin + gap * 2,
|
||||
boxY + (boxHeight / 2 - barheight / 2),
|
||||
2,
|
||||
barheight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
context.restore();
|
||||
context.closePath();
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue