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:
parent
9dac6862bf
commit
3d30f77ac1
8 changed files with 250 additions and 42 deletions
|
@ -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)
|
||||
|
|
|
@ -106,6 +106,7 @@ export {
|
|||
} from './lib/config/TLSessionStateSnapshot'
|
||||
export {
|
||||
USER_COLORS,
|
||||
defaultUserPreferences,
|
||||
getFreshUserPreferences,
|
||||
getUserPreferences,
|
||||
setUserPreferences,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue