aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/clients.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/clients.ts')
-rw-r--r--packages/excalidraw/clients.ts259
1 files changed, 259 insertions, 0 deletions
diff --git a/packages/excalidraw/clients.ts b/packages/excalidraw/clients.ts
new file mode 100644
index 0000000..29d2400
--- /dev/null
+++ b/packages/excalidraw/clients.ts
@@ -0,0 +1,259 @@
+import {
+ COLOR_CHARCOAL_BLACK,
+ COLOR_VOICE_CALL,
+ COLOR_WHITE,
+ THEME,
+ UserIdleState,
+} from "./constants";
+import { roundRect } from "./renderer/roundRect";
+import type { InteractiveCanvasRenderConfig } from "./scene/types";
+import type {
+ Collaborator,
+ InteractiveCanvasAppState,
+ SocketId,
+} 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();
+ }
+};