diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/clients.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/clients.ts')
| -rw-r--r-- | packages/excalidraw/clients.ts | 259 |
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(); + } +}; |
