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)
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<TLEventMap> {
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<TLEditorComponents>;
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<TLUnknownShape>[];
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)

View file

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

View file

@ -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(

View file

@ -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<TLUserPreferences> = T.object<TLUserPreferences>({
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<Omit<TLUserPreferences, 'id'>>
/** @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,
}
}

View file

@ -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<TLEventMap> {
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)

View file

@ -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<TLUserPreferences>) => {
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
}
}

View file

@ -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)
})
})

View file

@ -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<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,
})
})
})