diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index ad1170e83..fd6b8cea0 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -476,6 +476,16 @@ export const DefaultSpinner: TLSpinnerComponent; // @public (undocumented) export const DefaultSvgDefs: () => null; +// @public (undocumented) +export const defaultUserPreferences: Readonly<{ + name: "New User"; + locale: "ar" | "ca" | "da" | "de" | "en" | "es" | "fa" | "fi" | "fr" | "gl" | "he" | "hi-in" | "hu" | "it" | "ja" | "ko-kr" | "ku" | "my" | "ne" | "no" | "pl" | "pt-br" | "pt-pt" | "ro" | "ru" | "sv" | "te" | "th" | "tr" | "uk" | "vi" | "zh-cn" | "zh-tw"; + color: "#02B1CC" | "#11B3A3" | "#39B178" | "#55B467" | "#7B66DC" | "#9D5BD2" | "#BD54C6" | "#E34BA9" | "#EC5E41" | "#F04F88" | "#F2555A" | "#FF802B"; + isDarkMode: false; + animationSpeed: 0 | 1; + isSnapMode: false; +}>; + // @public export function degreesToRadians(d: number): number; @@ -537,7 +547,7 @@ export class Edge2d extends Geometry2d { // @public (undocumented) export class Editor extends EventEmitter { - constructor({ store, user, shapeUtils, tools, getContainer, initialState }: TLEditorOptions); + constructor({ store, user, shapeUtils, tools, getContainer, initialState, inferDarkMode, }: TLEditorOptions); addOpenMenu(id: string): this; alignShapes(shapes: TLShape[] | TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this; animateShape(partial: null | TLShapePartial | undefined, animationOptions?: TLAnimationOptions): this; @@ -2002,6 +2012,7 @@ export interface TldrawEditorBaseProps { children?: any; className?: string; components?: Partial; + inferDarkMode?: boolean; initialState?: string; onMount?: TLOnMountHandler; shapeUtils?: readonly TLAnyShapeUtilConstructor[]; @@ -2033,6 +2044,7 @@ export type TLEditorComponents = { // @public (undocumented) export interface TLEditorOptions { getContainer: () => HTMLElement; + inferDarkMode?: boolean; initialState?: string; shapeUtils: readonly TLShapeUtilConstructor[]; store: TLStore; @@ -2552,19 +2564,19 @@ export type TLTickEvent = (elapsed: number) => void; // @public export interface TLUserPreferences { // (undocumented) - animationSpeed: number; + animationSpeed?: null | number; // (undocumented) - color: string; + color?: null | string; // (undocumented) id: string; // (undocumented) - isDarkMode: boolean; + isDarkMode?: boolean | null; // (undocumented) - isSnapMode: boolean; + isSnapMode?: boolean | null; // (undocumented) - locale: string; + locale?: null | string; // (undocumented) - name: string; + name?: null | string; } // @public (undocumented) diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 452815232..758652b28 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -106,6 +106,7 @@ export { } from './lib/config/TLSessionStateSnapshot' export { USER_COLORS, + defaultUserPreferences, getFreshUserPreferences, getUserPreferences, setUserPreferences, diff --git a/packages/editor/src/lib/TldrawEditor.tsx b/packages/editor/src/lib/TldrawEditor.tsx index c79438e16..5780ea030 100644 --- a/packages/editor/src/lib/TldrawEditor.tsx +++ b/packages/editor/src/lib/TldrawEditor.tsx @@ -106,6 +106,11 @@ export interface TldrawEditorBaseProps { * The user interacting with the editor. */ user?: TLUser + + /** + * Whether to infer dark mode from the user's OS. Defaults to false. + */ + inferDarkMode?: boolean } /** @@ -253,6 +258,7 @@ function TldrawEditorWithReadyStore({ autoFocus, user, initialState, + inferDarkMode, }: Required< TldrawEditorProps & { store: TLStore @@ -272,6 +278,7 @@ function TldrawEditorWithReadyStore({ getContainer: () => container, user, initialState, + inferDarkMode, }) ;(window as any).app = editor ;(window as any).editor = editor @@ -280,7 +287,7 @@ function TldrawEditorWithReadyStore({ return () => { editor.dispose() } - }, [container, shapeUtils, tools, store, user, initialState]) + }, [container, shapeUtils, tools, store, user, initialState, inferDarkMode]) const crashingError = useSyncExternalStore( useCallback( diff --git a/packages/editor/src/lib/config/TLUserPreferences.ts b/packages/editor/src/lib/config/TLUserPreferences.ts index 7ae67d2c2..a7f76d0e4 100644 --- a/packages/editor/src/lib/config/TLUserPreferences.ts +++ b/packages/editor/src/lib/config/TLUserPreferences.ts @@ -13,12 +13,12 @@ const USER_DATA_KEY = 'TLDRAW_USER_DATA_v3' */ export interface TLUserPreferences { id: string - name: string - locale: string - color: string - isDarkMode: boolean - animationSpeed: number - isSnapMode: boolean + name?: string | null + locale?: string | null + color?: string | null + isDarkMode?: boolean | null + animationSpeed?: number | null + isSnapMode?: boolean | null } interface UserDataSnapshot { @@ -34,21 +34,22 @@ interface UserChangeBroadcastMessage { const userTypeValidator: T.Validator = T.object({ id: T.string, - name: T.string, - locale: T.string, - color: T.string, - isDarkMode: T.boolean, - animationSpeed: T.number, - isSnapMode: T.boolean, + name: T.string.nullable().optional(), + locale: T.string.nullable().optional(), + color: T.string.nullable().optional(), + isDarkMode: T.boolean.nullable().optional(), + animationSpeed: T.number.nullable().optional(), + isSnapMode: T.boolean.nullable().optional(), }) const Versions = { AddAnimationSpeed: 1, AddIsSnapMode: 2, + MakeFieldsNullable: 3, } as const const userMigrations = defineMigrations({ - currentVersion: Versions.AddIsSnapMode, + currentVersion: Versions.MakeFieldsNullable, migrators: { [Versions.AddAnimationSpeed]: { up: (user) => { @@ -69,6 +70,22 @@ const userMigrations = defineMigrations({ return user }, }, + [Versions.MakeFieldsNullable]: { + up: (user: TLUserPreferences) => { + return user + }, + down: (user: TLUserPreferences) => { + return { + id: user.id, + name: user.name ?? defaultUserPreferences.name, + locale: user.locale ?? defaultUserPreferences.locale, + color: user.color ?? defaultUserPreferences.color, + isDarkMode: user.isDarkMode ?? defaultUserPreferences.isDarkMode, + animationSpeed: user.animationSpeed ?? defaultUserPreferences.animationSpeed, + isSnapMode: user.isSnapMode ?? defaultUserPreferences.isSnapMode, + } + }, + }, }, }) @@ -92,17 +109,36 @@ function getRandomColor() { return USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)] } +/** @internal */ +export function userPrefersDarkUI() { + if (typeof window === 'undefined') { + return false + } + return window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false +} + +/** @internal */ +export function userPrefersReducedMotion() { + if (typeof window === 'undefined') { + return false + } + return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false +} + +/** @public */ +export const defaultUserPreferences = Object.freeze({ + name: 'New User', + locale: getDefaultTranslationLocale(), + color: getRandomColor(), + isDarkMode: false, + animationSpeed: userPrefersReducedMotion() ? 0 : 1, + isSnapMode: false, +}) satisfies Readonly> + /** @public */ export function getFreshUserPreferences(): TLUserPreferences { return { id: uniqueId(), - locale: typeof window !== 'undefined' ? getDefaultTranslationLocale() : 'en', - name: 'New User', - color: getRandomColor(), - // TODO: detect dark mode - isDarkMode: false, - animationSpeed: 1, - isSnapMode: false, } } diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 66a7064d8..52e95759e 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -171,18 +171,30 @@ export interface TLEditorOptions { * (optional) The editor's initial active tool (or other state node id). */ initialState?: string + /** + * (optional) Whether to infer dark mode from the user's system preferences. Defaults to false. + */ + inferDarkMode?: boolean } /** @public */ export class Editor extends EventEmitter { - constructor({ store, user, shapeUtils, tools, getContainer, initialState }: TLEditorOptions) { + constructor({ + store, + user, + shapeUtils, + tools, + getContainer, + initialState, + inferDarkMode, + }: TLEditorOptions) { super() this.store = store this.snaps = new SnapManager(this) - this.user = new UserPreferencesManager(user ?? createTLUser()) + this.user = new UserPreferencesManager(user ?? createTLUser(), inferDarkMode ?? false) this.getContainer = getContainer ?? (() => document.body) diff --git a/packages/editor/src/lib/editor/managers/UserPreferencesManager.ts b/packages/editor/src/lib/editor/managers/UserPreferencesManager.ts index 0fe35dcd7..c6e0de920 100644 --- a/packages/editor/src/lib/editor/managers/UserPreferencesManager.ts +++ b/packages/editor/src/lib/editor/managers/UserPreferencesManager.ts @@ -1,46 +1,60 @@ import { computed } from '@tldraw/state' -import { TLUserPreferences } from '../../config/TLUserPreferences' +import { + TLUserPreferences, + defaultUserPreferences, + userPrefersDarkUI, +} from '../../config/TLUserPreferences' import { TLUser } from '../../config/createTLUser' export class UserPreferencesManager { - constructor(private readonly user: TLUser) {} + constructor(private readonly user: TLUser, private readonly inferDarkMode: boolean) {} updateUserPreferences = (userPreferences: Partial) => { this.user.setUserPreferences({ - ...this.userPreferences, + ...this.user.userPreferences.value, ...userPreferences, }) } - @computed get userPreferences() { - return this.user.userPreferences.value + return { + id: this.id, + name: this.name, + locale: this.locale, + color: this.color, + isDarkMode: this.isDarkMode, + animationSpeed: this.animationSpeed, + isSnapMode: this.isSnapMode, + } } @computed get isDarkMode() { - return this.userPreferences.isDarkMode + return ( + this.user.userPreferences.value.isDarkMode ?? + (this.inferDarkMode ? userPrefersDarkUI() : false) + ) } @computed get animationSpeed() { - return this.userPreferences.animationSpeed + return this.user.userPreferences.value.animationSpeed ?? defaultUserPreferences.animationSpeed } @computed get id() { - return this.userPreferences.id + return this.user.userPreferences.value.id } @computed get name() { - return this.userPreferences.name + return this.user.userPreferences.value.name ?? defaultUserPreferences.name } @computed get locale() { - return this.userPreferences.locale + return this.user.userPreferences.value.locale ?? defaultUserPreferences.locale } @computed get color() { - return this.userPreferences.color + return this.user.userPreferences.value.color ?? defaultUserPreferences.color } @computed get isSnapMode() { - return this.userPreferences.isSnapMode + return this.user.userPreferences.value.isSnapMode ?? defaultUserPreferences.isSnapMode } } diff --git a/packages/tldraw/src/test/Editor.test.tsx b/packages/tldraw/src/test/Editor.test.tsx index fe998aed5..410636abf 100644 --- a/packages/tldraw/src/test/Editor.test.tsx +++ b/packages/tldraw/src/test/Editor.test.tsx @@ -608,3 +608,57 @@ describe('snapshots', () => { expect(editor.store.serialize()).toEqual(newEditor.store.serialize()) }) }) + +describe('when the user prefers dark UI', () => { + beforeEach(() => { + window.matchMedia = jest.fn().mockImplementation((query) => { + return { + matches: query === '(prefers-color-scheme: dark)', + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + } + }) + }) + it('isDarkMode should be false by default', () => { + editor = new TestEditor({}) + expect(editor.user.isDarkMode).toBe(false) + }) + it('isDarkMode should be false when inferDarkMode is false', () => { + editor = new TestEditor({ inferDarkMode: false }) + expect(editor.user.isDarkMode).toBe(false) + }) + it('should be true if the editor was instantiated with inferDarkMode', () => { + editor = new TestEditor({ inferDarkMode: true }) + expect(editor.user.isDarkMode).toBe(true) + }) +}) + +describe('when the user prefers light UI', () => { + beforeEach(() => { + window.matchMedia = jest.fn().mockImplementation((query) => { + return { + matches: false, + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + } + }) + }) + it('isDarkMode should be false by default', () => { + editor = new TestEditor({}) + expect(editor.user.isDarkMode).toBe(false) + }) + it('isDarkMode should be false when inferDarkMode is false', () => { + editor = new TestEditor({ inferDarkMode: false }) + expect(editor.user.isDarkMode).toBe(false) + }) + it('should be false if the editor was instantiated with inferDarkMode', () => { + editor = new TestEditor({ inferDarkMode: true }) + expect(editor.user.isDarkMode).toBe(false) + }) +}) diff --git a/packages/tldraw/src/test/TLUserPreferences.test.ts b/packages/tldraw/src/test/TLUserPreferences.test.ts index ab1b20794..22642310c 100644 --- a/packages/tldraw/src/test/TLUserPreferences.test.ts +++ b/packages/tldraw/src/test/TLUserPreferences.test.ts @@ -48,4 +48,76 @@ describe('TLUserPreferences', () => { expect(editor.user.isDarkMode).toBe(true) expect(userPreferences.value.isDarkMode).toBe(true) }) + + it('can have null values and it will use defaults', () => { + const userPreferences = atom('userPreferences', { + id: '123', + animationSpeed: null, + color: null, + isDarkMode: null, + isSnapMode: null, + locale: null, + name: null, + }) + const setUserPreferences = jest.fn((preferences) => userPreferences.set(preferences)) + + editor = new TestEditor({ + user: createTLUser({ + setUserPreferences, + userPreferences, + }), + }) + + expect(editor.user.animationSpeed).toBe(1) + expect(editor.user.isDarkMode).toBe(false) + expect(editor.user.isSnapMode).toBe(false) + expect(editor.user.locale).toBe('en') + expect(editor.user.name).toBe('New User') + }) + + it('can have unspecified values and it will use defaults', () => { + const userPreferences = atom('userPreferences', { + id: '123', + name: 'blah', + }) + const setUserPreferences = jest.fn((preferences) => userPreferences.set(preferences)) + + editor = new TestEditor({ + user: createTLUser({ + setUserPreferences, + userPreferences, + }), + }) + + expect(editor.user.animationSpeed).toBe(1) + expect(editor.user.isDarkMode).toBe(false) + expect(editor.user.isSnapMode).toBe(false) + expect(editor.user.locale).toBe('en') + expect(editor.user.name).toBe('blah') + }) + + it('allows setting values to null', () => { + const userPreferences = atom('userPreferences', { + id: '123', + name: 'blah', + }) + const setUserPreferences = jest.fn((preferences) => userPreferences.set(preferences)) + + editor = new TestEditor({ + user: createTLUser({ + setUserPreferences, + userPreferences, + }), + }) + + expect(editor.user.name).toBe('blah') + editor.user.updateUserPreferences({ name: null }) + + expect(editor.user.name).toBe('New User') + expect(setUserPreferences).toHaveBeenCalledTimes(1) + expect(setUserPreferences).toHaveBeenLastCalledWith({ + id: '123', + name: null, + }) + }) })