You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
260 lines
7.3 KiB
TypeScript
260 lines
7.3 KiB
TypeScript
import {
|
|
COLOR_CHARCOAL_BLACK,
|
|
COLOR_VOICE_CALL,
|
|
COLOR_WHITE,
|
|
THEME,
|
|
} from "./constants";
|
|
import { roundRect } from "./renderer/roundRect";
|
|
import type { InteractiveCanvasRenderConfig } from "./scene/types";
|
|
import type {
|
|
Collaborator,
|
|
InteractiveCanvasAppState,
|
|
SocketId,
|
|
} from "./types";
|
|
import { UserIdleState } from "./types";
|
|
|
|
function hashToInteger(id: string) {
|
|
let hash = 0;
|
|
if (id.length === 0) {
|
|
return hash;
|
|
}
|
|
for (let i = 0; i < id.length; i++) {
|
|
const char = id.charCodeAt(i);
|
|
hash = (hash << 5) - hash + char;
|
|
}
|
|
return hash;
|
|
}
|
|
|
|
export const getClientColor = (
|
|
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(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;
|
|
const saturation = 100;
|
|
const lightness = 83;
|
|
|
|
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
|
};
|
|
|
|
/**
|
|
* returns first char, capitalized
|
|
*/
|
|
export const getNameInitial = (name?: string | null) => {
|
|
// first char can be a surrogate pair, hence using codePointAt
|
|
const firstCodePoint = name?.trim()?.codePointAt(0);
|
|
return (
|
|
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();
|
|
}
|
|
};
|