UNPKG

sanity

Version:

Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches

219 lines (180 loc) • 6.54 kB
import {COLOR_HUES, type ColorHueKey, type ColorTintKey, hues} from '@sanity/color' import {type ThemeColorSchemeKey} from '@sanity/ui' import {Observable} from 'rxjs' import {shareReplay} from 'rxjs/operators' import {type UserColor, type UserColorHue, type UserColorManager, type UserId} from './types' /** @internal */ export interface UserColorManagerOptions { anonymousColor?: UserColor userStore?: {me: Observable<{id: string} | null>} colors?: Record<UserColorHue, UserColor> currentUserColor?: UserColorHue scheme: ThemeColorSchemeKey } const DEFAULT_CURRENT_USER_HUE: ColorHueKey = 'purple' // Exclude green and red because they can be confused with "add" and "remove" // Exclude gray because it looks like "color not found" const USER_COLOR_EXCLUDE_HUES = ['green', 'red', 'gray'] const defaultHues: ColorHueKey[] = COLOR_HUES.filter( (hue) => !USER_COLOR_EXCLUDE_HUES.includes(hue), ) const getTints = (scheme: ThemeColorSchemeKey): Record<string, ColorTintKey> => { const isDarkScheme = scheme === 'dark' return { background: isDarkScheme ? '900' : '100', border: isDarkScheme ? '700' : '300', text: isDarkScheme ? '200' : '700', } } const getDefaultColors = (scheme: ThemeColorSchemeKey): Record<string, UserColor> => { const {background, border, text} = getTints(scheme) return defaultHues.reduce( (colors, hue) => { colors[hue] = { name: hue, background: hues[hue][background].hex, border: hues[hue][border].hex, text: hues[hue][text].hex, tints: hues[hue], } return colors }, {} as Record<ColorHueKey, UserColor>, ) } const getAnonymousColor = (scheme: ThemeColorSchemeKey): UserColor => { const {background, border, text} = getTints(scheme) return { name: 'gray', background: hues.gray[background].hex, border: hues.gray[border].hex, text: hues.gray[text].hex, tints: hues.gray, } } /** @internal */ export function createUserColorManager(options: UserColorManagerOptions): UserColorManager { const { anonymousColor: anonymousColorProp, colors, currentUserColor: currentUserColorProp, scheme, } = options const userColors = colors || getDefaultColors(scheme) const anonymousColor = anonymousColorProp || getAnonymousColor(scheme) const currentUserColor = currentUserColorProp || DEFAULT_CURRENT_USER_HUE if (!userColors.hasOwnProperty(currentUserColor)) { throw new Error(`'colors' must contain 'currentUserColor' (${currentUserColor})`) } const userColorKeys: UserColorHue[] = Object.keys(userColors) const subscriptions = new Map<UserId, Observable<UserColor>>() const previouslyAssigned = new Map<UserId, UserColorHue>() const assignedCounts: Record<UserColorHue, number> = userColorKeys.reduce( (counts, color) => { counts[color] = 0 return counts }, {} as Record<UserColorHue, number>, ) // This isn't really needed because we're reusing subscriptions, // but is useful for debugging and poses a minimal overhead const assigned = new Map<UserId, UserColorHue>() let currentUserId: UserId | null if (options?.userStore) { options.userStore.me.subscribe((user) => setCurrentUser(user ? user.id : null)) } return {get, listen} function get(userId: UserId | null): UserColor { if (!userId) { return anonymousColor } return userColors[getUserHue(userId)] } function getUserHue(userId: UserId): UserColorHue { if (userId === currentUserId) { return currentUserColor } const assignedHue = assigned.get(userId) if (assignedHue) { return assignedHue } // Prefer to reuse the color previously assigned, BUT: // ONLY if it's unused -or- there are no other unused colors const prevHue = previouslyAssigned.get(userId) if (prevHue && (assignedCounts[prevHue] === 0 || !hasUnusedColor())) { return assignHue(userId, prevHue) } // Prefer "static" color based on user ID if unused const preferredHue = getPreferredHue(userId) if (assignedCounts[preferredHue] === 0) { return assignHue(userId, preferredHue) } // Fall back to least used color, with a preference on the previous // used color if there are ties for least used return assignHue(userId, getLeastUsedHue(prevHue)) } function listen(userId: string): Observable<UserColor> { let subscription = subscriptions.get(userId) if (subscription) { return subscription } const hue = getUserHue(userId) subscription = getObservableColor(userId, hue) subscriptions.set(userId, subscription) return subscription } function assignHue(userId: string, hue: UserColorHue): UserColorHue { assigned.set(userId, hue) previouslyAssigned.set(userId, hue) assignedCounts[hue]++ return hue } function unassignHue(userId: string, hue: UserColorHue) { assigned.delete(userId) assignedCounts[hue]-- } function getUnusedColor(): UserColorHue | undefined { return userColorKeys.find((colorHue) => assignedCounts[colorHue] === 0) } function hasUnusedColor(): boolean { return Boolean(getUnusedColor()) } function getLeastUsedHue(tieBreakerPreference?: UserColorHue): UserColorHue { let leastUses = +Infinity let leastUsed: UserColorHue[] = [] userColorKeys.forEach((colorHue) => { const uses = assignedCounts[colorHue] if (uses === leastUses) { leastUsed.push(colorHue) } else if (uses < leastUses) { leastUses = uses leastUsed = [colorHue] } }) return tieBreakerPreference && leastUsed.includes(tieBreakerPreference) ? tieBreakerPreference : leastUsed[0] } function getObservableColor(userId: string, hue: UserColorHue): Observable<UserColor> { return new Observable<UserColor>((subscriber) => { const userColor = userColors[hue] subscriber.next(userColor) return () => { subscriptions.delete(userId) unassignHue(userId, hue) } }).pipe(shareReplay({refCount: true})) } function setCurrentUser(userId: string | null) { currentUserId = userId assignedCounts[currentUserColor] += userId ? 1 : -1 } function getPreferredHue(userId: string): UserColorHue { let hash = 0 for (let i = 0; i < userId.length; i++) { // eslint-disable-next-line no-bitwise hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0 } return userColorKeys[Math.abs(hash) % userColorKeys.length] } }