UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

254 lines (220 loc) • 6.53 kB
import { atom } from '@tldraw/state' import { getDefaultTranslationLocale } from '@tldraw/tlschema' import { getFromLocalStorage, setInLocalStorage, structuredClone, uniqueId } from '@tldraw/utils' import { T } from '@tldraw/validate' const USER_DATA_KEY = 'TLDRAW_USER_DATA_v3' /** * A user of tldraw * * @public */ export interface TLUserPreferences { id: string name?: string | null color?: string | null // N.B. These are duplicated in TLdrawAppUser. locale?: string | null animationSpeed?: number | null areKeyboardShortcutsEnabled?: boolean | null edgeScrollSpeed?: number | null colorScheme?: 'light' | 'dark' | 'system' isSnapMode?: boolean | null isWrapMode?: boolean | null isDynamicSizeMode?: boolean | null isPasteAtCursorMode?: boolean | null } interface UserDataSnapshot { version: number user: TLUserPreferences } interface UserChangeBroadcastMessage { type: typeof broadcastEventKey origin: string data: UserDataSnapshot } /** @public */ export const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPreferences>({ id: T.string, name: T.string.nullable().optional(), color: T.string.nullable().optional(), // N.B. These are duplicated in TLdrawAppUser. locale: T.string.nullable().optional(), animationSpeed: T.number.nullable().optional(), areKeyboardShortcutsEnabled: T.boolean.nullable().optional(), edgeScrollSpeed: T.number.nullable().optional(), colorScheme: T.literalEnum('light', 'dark', 'system').optional(), isSnapMode: T.boolean.nullable().optional(), isWrapMode: T.boolean.nullable().optional(), isDynamicSizeMode: T.boolean.nullable().optional(), isPasteAtCursorMode: T.boolean.nullable().optional(), }) const Versions = { AddAnimationSpeed: 1, AddIsSnapMode: 2, MakeFieldsNullable: 3, AddEdgeScrollSpeed: 4, AddExcalidrawSelectMode: 5, AddDynamicSizeMode: 6, AllowSystemColorScheme: 7, AddPasteAtCursor: 8, AddKeyboardShortcuts: 9, } as const const CURRENT_VERSION = Math.max(...Object.values(Versions)) function migrateSnapshot(data: { version: number; user: any }) { if (data.version < Versions.AddAnimationSpeed) { data.user.animationSpeed = 1 } if (data.version < Versions.AddIsSnapMode) { data.user.isSnapMode = false } if (data.version < Versions.MakeFieldsNullable) { // noop } if (data.version < Versions.AddEdgeScrollSpeed) { data.user.edgeScrollSpeed = 1 } if (data.version < Versions.AddExcalidrawSelectMode) { data.user.isWrapMode = false } if (data.version < Versions.AllowSystemColorScheme) { if (data.user.isDarkMode === true) { data.user.colorScheme = 'dark' } else if (data.user.isDarkMode === false) { data.user.colorScheme = 'light' } delete data.user.isDarkMode } if (data.version < Versions.AddDynamicSizeMode) { data.user.isDynamicSizeMode = false } if (data.version < Versions.AddPasteAtCursor) { data.user.isPasteAtCursorMode = false } if (data.version < Versions.AddKeyboardShortcuts) { data.user.areKeyboardShortcutsEnabled = true } // finally data.version = CURRENT_VERSION } /** @internal */ export const USER_COLORS = [ '#FF802B', '#EC5E41', '#F2555A', '#F04F88', '#E34BA9', '#BD54C6', '#9D5BD2', '#7B66DC', '#02B1CC', '#11B3A3', '#39B178', '#55B467', ] as const function getRandomColor() { return USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)] } /** @internal */ export function userPrefersReducedMotion() { if (typeof window !== 'undefined' && 'matchMedia' in window) { return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false } return false } /** @public */ export const defaultUserPreferences = Object.freeze({ name: '', locale: getDefaultTranslationLocale(), color: getRandomColor(), // N.B. These are duplicated in TLdrawAppUser. edgeScrollSpeed: 1, animationSpeed: userPrefersReducedMotion() ? 0 : 1, areKeyboardShortcutsEnabled: true, isSnapMode: false, isWrapMode: false, isDynamicSizeMode: false, isPasteAtCursorMode: false, colorScheme: 'light', }) satisfies Readonly<Omit<TLUserPreferences, 'id'>> /** @public */ export function getFreshUserPreferences(): TLUserPreferences { return { id: uniqueId(), color: getRandomColor(), } } function migrateUserPreferences(userData: unknown): TLUserPreferences { if (userData === null || typeof userData !== 'object') { return getFreshUserPreferences() } if (!('version' in userData) || !('user' in userData) || typeof userData.version !== 'number') { return getFreshUserPreferences() } const snapshot = structuredClone(userData) as any migrateSnapshot(snapshot) try { return userTypeValidator.validate(snapshot.user) } catch { return getFreshUserPreferences() } } function loadUserPreferences(): TLUserPreferences { const userData = (JSON.parse(getFromLocalStorage(USER_DATA_KEY) || 'null') ?? null) as null | UserDataSnapshot return migrateUserPreferences(userData) } const globalUserPreferences = atom<TLUserPreferences | null>('globalUserData', null) function storeUserPreferences() { setInLocalStorage( USER_DATA_KEY, JSON.stringify({ version: CURRENT_VERSION, user: globalUserPreferences.get(), }) ) } /** @public */ export function setUserPreferences(user: TLUserPreferences) { userTypeValidator.validate(user) globalUserPreferences.set(user) storeUserPreferences() broadcastUserPreferencesChange() } const isTest = typeof process !== 'undefined' && process.env.NODE_ENV === 'test' const channel = typeof BroadcastChannel !== 'undefined' && !isTest ? new BroadcastChannel('tldraw-user-sync') : null channel?.addEventListener('message', (e) => { const data = e.data as undefined | UserChangeBroadcastMessage if (data?.type === broadcastEventKey && data?.origin !== getBroadcastOrigin()) { globalUserPreferences.set(migrateUserPreferences(data.data)) } }) let _broadcastOrigin = null as null | string function getBroadcastOrigin() { if (_broadcastOrigin === null) { _broadcastOrigin = uniqueId() } return _broadcastOrigin } const broadcastEventKey = 'tldraw-user-preferences-change' as const function broadcastUserPreferencesChange() { channel?.postMessage({ type: broadcastEventKey, origin: getBroadcastOrigin(), data: { user: getUserPreferences(), version: CURRENT_VERSION, }, } satisfies UserChangeBroadcastMessage) } /** @public */ export function getUserPreferences(): TLUserPreferences { let prefs = globalUserPreferences.get() if (!prefs) { prefs = loadUserPreferences() setUserPreferences(prefs) } return prefs }