ultisuite-client/lib/drive/guest-editor-identity.ts
R3D347HR4Y cdff12490a
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
hocuspocus
2026-06-09 14:31:07 +02:00

154 lines
3.4 KiB
TypeScript

const ADJECTIVES_FR = [
"Agile",
"Brave",
"Calme",
"Curieux",
"Discret",
"Elegant",
"Fidele",
"Gentil",
"Habile",
"Joyeux",
"Leger",
"Malin",
"Noble",
"Paisible",
"Rapide",
"Sage",
"Timide",
"Vif",
"Zen",
"Zeste",
] as const
const ANIMALS_FR = [
"Lynx",
"Panda",
"Faucon",
"Loutre",
"Renard",
"Heron",
"Castor",
"Koala",
"Loup",
"Ours",
"Tigre",
"Aigle",
"Dauphin",
"Phoque",
"Ecureuil",
"Chouette",
"Cerf",
"Lama",
"Belette",
"Pieuvre",
] as const
/** Background colors for caret labels (white text — WCAG AA 4.5:1+). */
const CURSOR_COLORS = [
"#6D28D9", // violet
"#BE123C", // rose
"#C2410C", // orange
"#A16207", // gold (not light yellow)
"#1D4ED8", // blue
"#0F766E", // teal
"#15803D", // green
"#A21CAF", // fuchsia
"#4338CA", // indigo
"#B45309", // amber
] as const
const MIN_CURSOR_CONTRAST_WITH_WHITE = 4.5
function hexLuminance(hex: string): number {
const raw = hex.replace("#", "")
if (raw.length !== 6) return 1
const channel = (i: number) => {
const c = parseInt(raw.slice(i, i + 2), 16) / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
}
function contrastWithWhite(bgHex: string): number {
const bg = hexLuminance(bgHex)
return (1 + 0.05) / (bg + 0.05)
}
export function isReadableCursorColor(hex: string): boolean {
return contrastWithWhite(hex) >= MIN_CURSOR_CONTRAST_WITH_WHITE
}
function resolveCursorColor(id: string, stored?: string): string {
if (stored && isReadableCursorColor(stored)) return stored
return colorForGuestId(id)
}
export type GuestEditorIdentity = {
guestId: string
guestName: string
color: string
}
const STORAGE_PREFIX = "ultidrive-guest-identity:"
function pick<T>(items: readonly T[]): T {
return items[Math.floor(Math.random() * items.length)]!
}
function hashString(input: string): number {
let hash = 0
for (let i = 0; i < input.length; i++) {
hash = (hash << 5) - hash + input.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}
export function generateGuestDisplayName(): string {
return `${pick(ADJECTIVES_FR)} ${pick(ANIMALS_FR)}`
}
export function colorForGuestId(guestId: string): string {
return CURSOR_COLORS[hashString(guestId) % CURSOR_COLORS.length]!
}
function newGuestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID()
}
return `guest-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
export function getGuestEditorIdentity(shareToken: string): GuestEditorIdentity {
const key = `${STORAGE_PREFIX}${shareToken}`
if (typeof sessionStorage !== "undefined") {
const raw = sessionStorage.getItem(key)
if (raw) {
try {
const parsed = JSON.parse(raw) as GuestEditorIdentity
if (parsed.guestId && parsed.guestName) {
const color = resolveCursorColor(parsed.guestId, parsed.color)
return {
guestId: parsed.guestId,
guestName: parsed.guestName,
color,
}
}
} catch {
/* regenerate */
}
}
}
const guestId = newGuestId()
const identity: GuestEditorIdentity = {
guestId,
guestName: generateGuestDisplayName(),
color: colorForGuestId(guestId),
}
if (typeof sessionStorage !== "undefined") {
sessionStorage.setItem(key, JSON.stringify(identity))
}
return identity
}