154 lines
3.4 KiB
TypeScript
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
|
|
}
|