Make user preferences optional (#1963)

This PR makes it so that user preferences can be in a 'null' state,
where we use the default values and/or infer from the system
preferences.

Before this PR it was impossible to allow a user to change their locale
via their system config rather than selecting an explicit value in the
tldraw editor menu. Similarly, it was impossible to adapt to changes in
the user's system preferences for dark/light mode.

That's because we saved the full user preference values the first time
the user loaded tldraw, and the only way for them to change after that
is by saving new values.

After this PR, if a value is `null` we will use the 'default' version of
it, which can be inferred based on the user's system preferences in the
case of dark mode, locale, and animation speed. Then if the user changes
their system config and refreshes the page their changes should be
picked up by tldraw where they previously wouldn't have been.

Dark mode inference is opt-in by setting a prop `inferDarkMode: true` on
the `Editor` instance (and the `<Tldraw />` components), because we
don't want it to be a surprise for existing library users.


### Change Type

- [ ] `patch` — Bug fix
- [ ] `minor` — New feature
- [x] `major` — Breaking change

[^1]: publishes a `patch` release, for devDependencies use `internal`
[^2]: will not publish a new version
This commit is contained in:
David Sheldrick 2023-09-29 16:20:39 +01:00 committed by GitHub
parent 9dac6862bf
commit 3d30f77ac1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 250 additions and 42 deletions

View file

@ -476,6 +476,16 @@ export const DefaultSpinner: TLSpinnerComponent;
// @public (undocumented) // @public (undocumented)
export const DefaultSvgDefs: () => null; 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 // @public
export function degreesToRadians(d: number): number; export function degreesToRadians(d: number): number;
@ -537,7 +547,7 @@ export class Edge2d extends Geometry2d {
// @public (undocumented) // @public (undocumented)
export class Editor extends EventEmitter<TLEventMap> { export class Editor extends EventEmitter<TLEventMap> {
constructor({ store, user, shapeUtils, tools, getContainer, initialState }: TLEditorOptions); constructor({ store, user, shapeUtils, tools, getContainer, initialState, inferDarkMode, }: TLEditorOptions);
addOpenMenu(id: string): this; addOpenMenu(id: string): this;
alignShapes(shapes: TLShape[] | TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this; alignShapes(shapes: TLShape[] | TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this;
animateShape(partial: null | TLShapePartial | undefined, animationOptions?: TLAnimationOptions): this; animateShape(partial: null | TLShapePartial | undefined, animationOptions?: TLAnimationOptions): this;
@ -2002,6 +2012,7 @@ export interface TldrawEditorBaseProps {
children?: any; children?: any;
className?: string; className?: string;
components?: Partial<TLEditorComponents>; components?: Partial<TLEditorComponents>;
inferDarkMode?: boolean;
initialState?: string; initialState?: string;
onMount?: TLOnMountHandler; onMount?: TLOnMountHandler;
shapeUtils?: readonly TLAnyShapeUtilConstructor[]; shapeUtils?: readonly TLAnyShapeUtilConstructor[];
@ -2033,6 +2044,7 @@ export type TLEditorComponents = {
// @public (undocumented) // @public (undocumented)
export interface TLEditorOptions { export interface TLEditorOptions {
getContainer: () => HTMLElement; getContainer: () => HTMLElement;
inferDarkMode?: boolean;
initialState?: string; initialState?: string;
shapeUtils: readonly TLShapeUtilConstructor<TLUnknownShape>[]; shapeUtils: readonly TLShapeUtilConstructor<TLUnknownShape>[];
store: TLStore; store: TLStore;
@ -2552,19 +2564,19 @@ export type TLTickEvent = (elapsed: number) => void;
// @public // @public
export interface TLUserPreferences { export interface TLUserPreferences {
// (undocumented) // (undocumented)
animationSpeed: number; animationSpeed?: null | number;
// (undocumented) // (undocumented)
color: string; color?: null | string;
// (undocumented) // (undocumented)
id: string; id: string;
// (undocumented) // (undocumented)
isDarkMode: boolean; isDarkMode?: boolean | null;
// (undocumented) // (undocumented)
isSnapMode: boolean; isSnapMode?: boolean | null;
// (undocumented) // (undocumented)
locale: string; locale?: null | string;
// (undocumented) // (undocumented)
name: string; name?: null | string;
} }
// @public (undocumented) // @public (undocumented)

View file

@ -106,6 +106,7 @@ export {
} from './lib/config/TLSessionStateSnapshot' } from './lib/config/TLSessionStateSnapshot'
export { export {
USER_COLORS, USER_COLORS,
defaultUserPreferences,
getFreshUserPreferences, getFreshUserPreferences,
getUserPreferences, getUserPreferences,
setUserPreferences, setUserPreferences,

View file

@ -106,6 +106,11 @@ export interface TldrawEditorBaseProps {
* The user interacting with the editor. * The user interacting with the editor.
*/ */
user?: TLUser user?: TLUser
/**
* Whether to infer dark mode from the user's OS. Defaults to false.
*/
inferDarkMode?: boolean
} }
/** /**
@ -253,6 +258,7 @@ function TldrawEditorWithReadyStore({
autoFocus, autoFocus,
user, user,
initialState, initialState,
inferDarkMode,
}: Required< }: Required<
TldrawEditorProps & { TldrawEditorProps & {
store: TLStore store: TLStore
@ -272,6 +278,7 @@ function TldrawEditorWithReadyStore({
getContainer: () => container, getContainer: () => container,
user, user,
initialState, initialState,
inferDarkMode,
}) })
;(window as any).app = editor ;(window as any).app = editor
;(window as any).editor = editor ;(window as any).editor = editor
@ -280,7 +287,7 @@ function TldrawEditorWithReadyStore({
return () => { return () => {
editor.dispose() editor.dispose()
} }
}, [container, shapeUtils, tools, store, user, initialState]) }, [container, shapeUtils, tools, store, user, initialState, inferDarkMode])
const crashingError = useSyncExternalStore( const crashingError = useSyncExternalStore(
useCallback( useCallback(

View file

@ -13,12 +13,12 @@ const USER_DATA_KEY = 'TLDRAW_USER_DATA_v3'
*/ */
export interface TLUserPreferences { export interface TLUserPreferences {
id: string id: string
name: string name?: string | null
locale: string locale?: string | null
color: string color?: string | null
isDarkMode: boolean isDarkMode?: boolean | null
animationSpeed: number animationSpeed?: number | null
isSnapMode: boolean isSnapMode?: boolean | null
} }
interface UserDataSnapshot { interface UserDataSnapshot {
@ -34,21 +34,22 @@ interface UserChangeBroadcastMessage {
const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPreferences>({ const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPreferences>({
id: T.string, id: T.string,
name: T.string, name: T.string.nullable().optional(),
locale: T.string, locale: T.string.nullable().optional(),
color: T.string, color: T.string.nullable().optional(),
isDarkMode: T.boolean, isDarkMode: T.boolean.nullable().optional(),
animationSpeed: T.number, animationSpeed: T.number.nullable().optional(),
isSnapMode: T.boolean, isSnapMode: T.boolean.nullable().optional(),
}) })
const Versions = { const Versions = {
AddAnimationSpeed: 1, AddAnimationSpeed: 1,
AddIsSnapMode: 2, AddIsSnapMode: 2,
MakeFieldsNullable: 3,
} as const } as const
const userMigrations = defineMigrations({ const userMigrations = defineMigrations({
currentVersion: Versions.AddIsSnapMode, currentVersion: Versions.MakeFieldsNullable,
migrators: { migrators: {
[Versions.AddAnimationSpeed]: { [Versions.AddAnimationSpeed]: {
up: (user) => { up: (user) => {
@ -69,6 +70,22 @@ const userMigrations = defineMigrations({
return user 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)] 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<Omit<TLUserPreferences, 'id'>>
/** @public */ /** @public */
export function getFreshUserPreferences(): TLUserPreferences { export function getFreshUserPreferences(): TLUserPreferences {
return { return {
id: uniqueId(), id: uniqueId(),
locale: typeof window !== 'undefined' ? getDefaultTranslationLocale() : 'en',
name: 'New User',
color: getRandomColor(),
// TODO: detect dark mode
isDarkMode: false,
animationSpeed: 1,
isSnapMode: false,
} }
} }

View file

@ -171,18 +171,30 @@ export interface TLEditorOptions {
* (optional) The editor's initial active tool (or other state node id). * (optional) The editor's initial active tool (or other state node id).
*/ */
initialState?: string initialState?: string
/**
* (optional) Whether to infer dark mode from the user's system preferences. Defaults to false.
*/
inferDarkMode?: boolean
} }
/** @public */ /** @public */
export class Editor extends EventEmitter<TLEventMap> { export class Editor extends EventEmitter<TLEventMap> {
constructor({ store, user, shapeUtils, tools, getContainer, initialState }: TLEditorOptions) { constructor({
store,
user,
shapeUtils,
tools,
getContainer,
initialState,
inferDarkMode,
}: TLEditorOptions) {
super() super()
this.store = store this.store = store
this.snaps = new SnapManager(this) this.snaps = new SnapManager(this)
this.user = new UserPreferencesManager(user ?? createTLUser()) this.user = new UserPreferencesManager(user ?? createTLUser(), inferDarkMode ?? false)
this.getContainer = getContainer ?? (() => document.body) this.getContainer = getContainer ?? (() => document.body)

View file

@ -1,46 +1,60 @@
import { computed } from '@tldraw/state' import { computed } from '@tldraw/state'
import { TLUserPreferences } from '../../config/TLUserPreferences' import {
TLUserPreferences,
defaultUserPreferences,
userPrefersDarkUI,
} from '../../config/TLUserPreferences'
import { TLUser } from '../../config/createTLUser' import { TLUser } from '../../config/createTLUser'
export class UserPreferencesManager { export class UserPreferencesManager {
constructor(private readonly user: TLUser) {} constructor(private readonly user: TLUser, private readonly inferDarkMode: boolean) {}
updateUserPreferences = (userPreferences: Partial<TLUserPreferences>) => { updateUserPreferences = (userPreferences: Partial<TLUserPreferences>) => {
this.user.setUserPreferences({ this.user.setUserPreferences({
...this.userPreferences, ...this.user.userPreferences.value,
...userPreferences, ...userPreferences,
}) })
} }
@computed get 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() { @computed get isDarkMode() {
return this.userPreferences.isDarkMode return (
this.user.userPreferences.value.isDarkMode ??
(this.inferDarkMode ? userPrefersDarkUI() : false)
)
} }
@computed get animationSpeed() { @computed get animationSpeed() {
return this.userPreferences.animationSpeed return this.user.userPreferences.value.animationSpeed ?? defaultUserPreferences.animationSpeed
} }
@computed get id() { @computed get id() {
return this.userPreferences.id return this.user.userPreferences.value.id
} }
@computed get name() { @computed get name() {
return this.userPreferences.name return this.user.userPreferences.value.name ?? defaultUserPreferences.name
} }
@computed get locale() { @computed get locale() {
return this.userPreferences.locale return this.user.userPreferences.value.locale ?? defaultUserPreferences.locale
} }
@computed get color() { @computed get color() {
return this.userPreferences.color return this.user.userPreferences.value.color ?? defaultUserPreferences.color
} }
@computed get isSnapMode() { @computed get isSnapMode() {
return this.userPreferences.isSnapMode return this.user.userPreferences.value.isSnapMode ?? defaultUserPreferences.isSnapMode
} }
} }

View file

@ -608,3 +608,57 @@ describe('snapshots', () => {
expect(editor.store.serialize()).toEqual(newEditor.store.serialize()) 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)
})
})

View file

@ -48,4 +48,76 @@ describe('TLUserPreferences', () => {
expect(editor.user.isDarkMode).toBe(true) expect(editor.user.isDarkMode).toBe(true)
expect(userPreferences.value.isDarkMode).toBe(true) expect(userPreferences.value.isDarkMode).toBe(true)
}) })
it('can have null values and it will use defaults', () => {
const userPreferences = atom<TLUserPreferences>('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<TLUserPreferences>('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<TLUserPreferences>('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,
})
})
}) })