[Experiment] Allow users to use system's appearance (dark / light) mode (#3703)

Allow the users to fully use the same colour scheme as their system.
Allows the users to either: force dark colour scheme, force light colour
scheme, or use the system one.

It's reactive to the system changes.


https://github.com/tldraw/tldraw/assets/2523721/6d4cef03-9ef0-4098-b299-6bf5d7513e98


### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.

---------

Co-authored-by: David Sheldrick <d.j.sheldrick@gmail.com>
This commit is contained in:
Mitja Bezenšek 2024-06-17 16:46:04 +02:00 committed by GitHub
parent 6c7b8febbf
commit 12aea7ed68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 124 additions and 48 deletions

View file

@ -208,7 +208,7 @@ test.describe('Export snapshots', () => {
for (const [name, shapes] of filteredSnapshots) { for (const [name, shapes] of filteredSnapshots) {
test(`Exports with ${name} in dark mode`, async ({ page, api }) => { test(`Exports with ${name} in dark mode`, async ({ page, api }) => {
await page.evaluate((shapes) => { await page.evaluate((shapes) => {
editor.user.updateUserPreferences({ isDarkMode: true }) editor.user.updateUserPreferences({ colorScheme: 'dark' })
editor editor
.updateInstanceState({ exportBackground: false }) .updateInstanceState({ exportBackground: false })
.selectAll() .selectAll()

View file

@ -187,7 +187,7 @@ test.describe('Keyboard Shortcuts', () => {
test('Toggle dark mode', async () => { test('Toggle dark mode', async () => {
await page.keyboard.press('Control+/') await page.keyboard.press('Control+/')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({ expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'toggle-dark-mode', name: 'color-scheme',
data: { source: 'kbd' }, data: { source: 'kbd' },
}) })
}) })

View file

@ -73,7 +73,7 @@ export default function TldrawImageExample() {
onMount={(editor: Editor) => { onMount={(editor: Editor) => {
setEditor(editor) setEditor(editor)
editor.updateInstanceState({ isDebugMode: false }) editor.updateInstanceState({ isDebugMode: false })
editor.user.updateUserPreferences({ isDarkMode }) editor.user.updateUserPreferences({ colorScheme: isDarkMode ? 'dark' : 'light' })
if (currentPageId) { if (currentPageId) {
editor.setCurrentPage(currentPageId) editor.setCurrentPage(currentPageId)
} }

View file

@ -115,6 +115,9 @@
"action.zoom-to-selection": "Zoom to selection", "action.zoom-to-selection": "Zoom to selection",
"assets.files.upload-failed": "Upload failed", "assets.files.upload-failed": "Upload failed",
"assets.url.failed": "Couldn't load URL preview", "assets.url.failed": "Couldn't load URL preview",
"color-scheme.dark": "Dark",
"color-scheme.light": "Light",
"color-scheme.system": "System",
"color-style.white": "White", "color-style.white": "White",
"color-style.black": "Black", "color-style.black": "Black",
"color-style.blue": "Blue", "color-style.blue": "Blue",
@ -228,6 +231,7 @@
"tool.embed": "Embed", "tool.embed": "Embed",
"tool.text": "Text", "tool.text": "Text",
"menu.title": "Menu", "menu.title": "Menu",
"menu.color-scheme": "Color scheme",
"menu.copy-as": "Copy as", "menu.copy-as": "Copy as",
"menu.edit": "Edit", "menu.edit": "Edit",
"menu.export-as": "Export as", "menu.export-as": "Export as",

View file

@ -718,7 +718,6 @@ export const defaultUserPreferences: Readonly<{
animationSpeed: 0 | 1; animationSpeed: 0 | 1;
color: "#02B1CC" | "#11B3A3" | "#39B178" | "#55B467" | "#7B66DC" | "#9D5BD2" | "#BD54C6" | "#E34BA9" | "#EC5E41" | "#F04F88" | "#F2555A" | "#FF802B"; color: "#02B1CC" | "#11B3A3" | "#39B178" | "#55B467" | "#7B66DC" | "#9D5BD2" | "#BD54C6" | "#E34BA9" | "#EC5E41" | "#F04F88" | "#F2555A" | "#FF802B";
edgeScrollSpeed: 1; edgeScrollSpeed: 1;
isDarkMode: false;
isDynamicSizeMode: false; isDynamicSizeMode: false;
isSnapMode: false; isSnapMode: false;
isWrapMode: false; isWrapMode: false;
@ -3343,12 +3342,12 @@ export interface TLUserPreferences {
// (undocumented) // (undocumented)
color?: null | string; color?: null | string;
// (undocumented) // (undocumented)
colorScheme?: 'dark' | 'light' | 'system';
// (undocumented)
edgeScrollSpeed?: null | number; edgeScrollSpeed?: null | number;
// (undocumented) // (undocumented)
id: string; id: string;
// (undocumented) // (undocumented)
isDarkMode?: boolean | null;
// (undocumented)
isDynamicSizeMode?: boolean | null; isDynamicSizeMode?: boolean | null;
// (undocumented) // (undocumented)
isSnapMode?: boolean | null; isSnapMode?: boolean | null;
@ -3470,6 +3469,7 @@ export class UserPreferencesManager {
getUserPreferences(): { getUserPreferences(): {
animationSpeed: number; animationSpeed: number;
color: string; color: string;
colorScheme: "dark" | "light" | "system" | undefined;
id: string; id: string;
isDarkMode: boolean; isDarkMode: boolean;
isDynamicResizeMode: boolean; isDynamicResizeMode: boolean;
@ -3479,6 +3479,8 @@ export class UserPreferencesManager {
name: string; name: string;
}; };
// (undocumented) // (undocumented)
systemColorScheme: Atom<"dark" | "light", unknown>;
// (undocumented)
updateUserPreferences: (userPreferences: Partial<TLUserPreferences>) => void; updateUserPreferences: (userPreferences: Partial<TLUserPreferences>) => void;
} }

View file

@ -261,7 +261,7 @@ const TldrawEditorWithLoadingStore = memo(function TldrawEditorBeforeLoading({
const container = useContainer() const container = useContainer()
useLayoutEffect(() => { useLayoutEffect(() => {
if (user.userPreferences.get().isDarkMode) { if (user.userPreferences.get().colorScheme === 'dark') {
container.classList.remove('tl-theme__light') container.classList.remove('tl-theme__light')
container.classList.add('tl-theme__dark') container.classList.add('tl-theme__dark')
} }

View file

@ -18,7 +18,7 @@ export interface TLUserPreferences {
color?: string | null color?: string | null
animationSpeed?: number | null animationSpeed?: number | null
edgeScrollSpeed?: number | null edgeScrollSpeed?: number | null
isDarkMode?: boolean | null colorScheme?: 'light' | 'dark' | 'system'
isSnapMode?: boolean | null isSnapMode?: boolean | null
isWrapMode?: boolean | null isWrapMode?: boolean | null
isDynamicSizeMode?: boolean | null isDynamicSizeMode?: boolean | null
@ -40,9 +40,9 @@ const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPrefere
name: T.string.nullable().optional(), name: T.string.nullable().optional(),
locale: T.string.nullable().optional(), locale: T.string.nullable().optional(),
color: T.string.nullable().optional(), color: T.string.nullable().optional(),
colorScheme: T.literalEnum('light', 'dark', 'system').optional(),
animationSpeed: T.number.nullable().optional(), animationSpeed: T.number.nullable().optional(),
edgeScrollSpeed: T.number.nullable().optional(), edgeScrollSpeed: T.number.nullable().optional(),
isDarkMode: T.boolean.nullable().optional(),
isSnapMode: T.boolean.nullable().optional(), isSnapMode: T.boolean.nullable().optional(),
isWrapMode: T.boolean.nullable().optional(), isWrapMode: T.boolean.nullable().optional(),
isDynamicSizeMode: T.boolean.nullable().optional(), isDynamicSizeMode: T.boolean.nullable().optional(),
@ -55,6 +55,7 @@ const Versions = {
AddEdgeScrollSpeed: 4, AddEdgeScrollSpeed: 4,
AddExcalidrawSelectMode: 5, AddExcalidrawSelectMode: 5,
AddDynamicSizeMode: 6, AddDynamicSizeMode: 6,
AllowSystemColorScheme: 7,
} as const } as const
const CURRENT_VERSION = Math.max(...Object.values(Versions)) const CURRENT_VERSION = Math.max(...Object.values(Versions))
@ -75,6 +76,14 @@ function migrateSnapshot(data: { version: number; user: any }) {
if (data.version < Versions.AddExcalidrawSelectMode) { if (data.version < Versions.AddExcalidrawSelectMode) {
data.user.isWrapMode = false 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) { if (data.version < Versions.AddDynamicSizeMode) {
data.user.isDynamicSizeMode = false data.user.isDynamicSizeMode = false
@ -104,14 +113,6 @@ 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 */ /** @internal */
export function userPrefersReducedMotion() { export function userPrefersReducedMotion() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@ -125,7 +126,6 @@ export const defaultUserPreferences = Object.freeze({
name: 'New User', name: 'New User',
locale: getDefaultTranslationLocale(), locale: getDefaultTranslationLocale(),
color: getRandomColor(), color: getRandomColor(),
isDarkMode: false,
edgeScrollSpeed: 1, edgeScrollSpeed: 1,
animationSpeed: userPrefersReducedMotion() ? 0 : 1, animationSpeed: userPrefersReducedMotion() ? 0 : 1,
isSnapMode: false, isSnapMode: false,

View file

@ -1,17 +1,28 @@
import { computed } from '@tldraw/state' import { atom, computed } from '@tldraw/state'
import { import { TLUserPreferences, defaultUserPreferences } from '../../config/TLUserPreferences'
TLUserPreferences,
defaultUserPreferences,
userPrefersDarkUI,
} from '../../config/TLUserPreferences'
import { TLUser } from '../../config/createTLUser' import { TLUser } from '../../config/createTLUser'
/** @public */ /** @public */
export class UserPreferencesManager { export class UserPreferencesManager {
systemColorScheme = atom<'dark' | 'light'>('systemColorScheme', 'light')
constructor( constructor(
private readonly user: TLUser, private readonly user: TLUser,
private readonly inferDarkMode: boolean private readonly inferDarkMode: boolean
) {} ) {
if (window) {
const darkModeMediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)')
if (darkModeMediaQuery?.matches) {
this.systemColorScheme.set('dark')
}
darkModeMediaQuery?.addEventListener('change', (e) => {
if (e.matches) {
this.systemColorScheme.set('dark')
} else {
this.systemColorScheme.set('light')
}
})
}
}
updateUserPreferences = (userPreferences: Partial<TLUserPreferences>) => { updateUserPreferences = (userPreferences: Partial<TLUserPreferences>) => {
this.user.setUserPreferences({ this.user.setUserPreferences({
@ -27,16 +38,23 @@ export class UserPreferencesManager {
color: this.getColor(), color: this.getColor(),
animationSpeed: this.getAnimationSpeed(), animationSpeed: this.getAnimationSpeed(),
isSnapMode: this.getIsSnapMode(), isSnapMode: this.getIsSnapMode(),
colorScheme: this.user.userPreferences.get().colorScheme,
isDarkMode: this.getIsDarkMode(), isDarkMode: this.getIsDarkMode(),
isWrapMode: this.getIsWrapMode(), isWrapMode: this.getIsWrapMode(),
isDynamicResizeMode: this.getIsDynamicResizeMode(), isDynamicResizeMode: this.getIsDynamicResizeMode(),
} }
} }
@computed getIsDarkMode() { @computed getIsDarkMode() {
return ( switch (this.user.userPreferences.get().colorScheme) {
this.user.userPreferences.get().isDarkMode ?? case 'dark':
(this.inferDarkMode ? userPrefersDarkUI() : false) return true
) case 'light':
return false
case 'system':
return this.systemColorScheme.get() === 'dark'
default:
return this.inferDarkMode ? this.systemColorScheme.get() === 'dark' : false
}
} }
/** /**

View file

@ -19,7 +19,7 @@ describe('user', () => {
}) })
it('gets a user with the correct', () => { it('gets a user with the correct', () => {
editor.user.updateUserPreferences({ isDarkMode: true }) editor.user.updateUserPreferences({ colorScheme: 'dark' })
expect(editor.user.getIsDarkMode()).toBe(true) expect(editor.user.getIsDarkMode()).toBe(true)
}) })
}) })

File diff suppressed because one or more lines are too long

View file

@ -222,7 +222,6 @@ export {
ReorderMenuSubmenu, ReorderMenuSubmenu,
SelectAllMenuItem, SelectAllMenuItem,
ToggleAutoSizeMenuItem, ToggleAutoSizeMenuItem,
ToggleDarkModeItem,
ToggleDebugModeItem, ToggleDebugModeItem,
ToggleEdgeScrollingItem, ToggleEdgeScrollingItem,
ToggleFocusModeItem, ToggleFocusModeItem,

View file

@ -0,0 +1,41 @@
import { useEditor, useValue } from '@tldraw/editor'
import { useUiEvents } from '../context/events'
import { TldrawUiMenuCheckboxItem } from './primitives/menus/TldrawUiMenuCheckboxItem'
import { TldrawUiMenuGroup } from './primitives/menus/TldrawUiMenuGroup'
import { TldrawUiMenuSubmenu } from './primitives/menus/TldrawUiMenuSubmenu'
const COLOR_SCHEMES = [
{ colorScheme: 'light' as const, label: 'color-scheme.light' },
{ colorScheme: 'dark' as const, label: 'color-scheme.dark' },
{ colorScheme: 'system' as const, label: 'color-scheme.system' },
]
/** @public @react */
export function ColorSchemeMenu() {
const editor = useEditor()
const trackEvent = useUiEvents()
const currentColorScheme = useValue(
'colorScheme',
() => editor.user.getUserPreferences().colorScheme,
[editor]
)
return (
<TldrawUiMenuSubmenu id="help menu color-scheme" label="menu.color-scheme">
<TldrawUiMenuGroup id="languages">
{COLOR_SCHEMES.map(({ colorScheme, label }) => (
<TldrawUiMenuCheckboxItem
id={`color-scheme-${colorScheme}`}
key={colorScheme}
label={label}
checked={colorScheme === currentColorScheme}
onSelect={() => {
editor.user.updateUserPreferences({ colorScheme })
trackEvent('color-scheme', { source: 'menu', value: colorScheme })
}}
/>
))}
</TldrawUiMenuGroup>
</TldrawUiMenuSubmenu>
)
}

View file

@ -1,6 +1,7 @@
import { useEditor, useValue } from '@tldraw/editor' import { useEditor, useValue } from '@tldraw/editor'
import { useActions } from '../../context/actions' import { useActions } from '../../context/actions'
import { useCanRedo, useCanUndo } from '../../hooks/menu-hooks' import { useCanRedo, useCanUndo } from '../../hooks/menu-hooks'
import { ColorSchemeMenu } from '../ColorSchemeMenu'
import { LanguageMenu } from '../LanguageMenu' import { LanguageMenu } from '../LanguageMenu'
import { import {
ClipboardMenuGroup, ClipboardMenuGroup,
@ -14,7 +15,6 @@ import {
RemoveFrameMenuItem, RemoveFrameMenuItem,
SelectAllMenuItem, SelectAllMenuItem,
ToggleAutoSizeMenuItem, ToggleAutoSizeMenuItem,
ToggleDarkModeItem,
ToggleDebugModeItem, ToggleDebugModeItem,
ToggleDynamicSizeModeItem, ToggleDynamicSizeModeItem,
ToggleEdgeScrollingItem, ToggleEdgeScrollingItem,
@ -170,13 +170,15 @@ export function PreferencesGroup() {
<ToggleToolLockItem /> <ToggleToolLockItem />
<ToggleGridItem /> <ToggleGridItem />
<ToggleWrapModeItem /> <ToggleWrapModeItem />
<ToggleDarkModeItem />
<ToggleFocusModeItem /> <ToggleFocusModeItem />
<ToggleEdgeScrollingItem /> <ToggleEdgeScrollingItem />
<ToggleReduceMotionItem /> <ToggleReduceMotionItem />
<ToggleDynamicSizeModeItem /> <ToggleDynamicSizeModeItem />
<ToggleDebugModeItem /> <ToggleDebugModeItem />
</TldrawUiMenuGroup> </TldrawUiMenuGroup>
<TldrawUiMenuGroup id="color-scheme">
<ColorSchemeMenu />
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="language"> <TldrawUiMenuGroup id="language">
<LanguageMenu /> <LanguageMenu />
</TldrawUiMenuGroup> </TldrawUiMenuGroup>

View file

@ -1110,8 +1110,11 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '$/', kbd: '$/',
readonlyOk: true, readonlyOk: true,
onSelect(source) { onSelect(source) {
trackEvent('toggle-dark-mode', { source }) const value = editor.user.getIsDarkMode() ? 'light' : 'dark'
editor.user.updateUserPreferences({ isDarkMode: !editor.user.getIsDarkMode() }) trackEvent('color-scheme', { source, value })
editor.user.updateUserPreferences({
colorScheme: value,
})
}, },
checkbox: true, checkbox: true,
}, },

View file

@ -84,7 +84,6 @@ export interface TLUiEventMap {
'toggle-snap-mode': null 'toggle-snap-mode': null
'toggle-tool-lock': null 'toggle-tool-lock': null
'toggle-grid-mode': null 'toggle-grid-mode': null
'toggle-dark-mode': null
'toggle-wrap-mode': null 'toggle-wrap-mode': null
'toggle-focus-mode': null 'toggle-focus-mode': null
'toggle-debug-mode': null 'toggle-debug-mode': null
@ -92,6 +91,7 @@ export interface TLUiEventMap {
'toggle-lock': null 'toggle-lock': null
'toggle-reduce-motion': null 'toggle-reduce-motion': null
'toggle-edge-scrolling': null 'toggle-edge-scrolling': null
'color-scheme': { value: string }
'exit-pen-mode': null 'exit-pen-mode': null
'stop-following': null 'stop-following': null
'open-cursor-chat': null 'open-cursor-chat': null

View file

@ -119,6 +119,9 @@ export type TLUiTranslationKey =
| 'action.zoom-to-selection' | 'action.zoom-to-selection'
| 'assets.files.upload-failed' | 'assets.files.upload-failed'
| 'assets.url.failed' | 'assets.url.failed'
| 'color-scheme.dark'
| 'color-scheme.light'
| 'color-scheme.system'
| 'color-style.white' | 'color-style.white'
| 'color-style.black' | 'color-style.black'
| 'color-style.blue' | 'color-style.blue'
@ -232,6 +235,7 @@ export type TLUiTranslationKey =
| 'tool.embed' | 'tool.embed'
| 'tool.text' | 'tool.text'
| 'menu.title' | 'menu.title'
| 'menu.color-scheme'
| 'menu.copy-as' | 'menu.copy-as'
| 'menu.edit' | 'menu.edit'
| 'menu.export-as' | 'menu.export-as'

View file

@ -119,6 +119,9 @@ export const DEFAULT_TRANSLATION = {
'action.zoom-to-selection': 'Zoom to selection', 'action.zoom-to-selection': 'Zoom to selection',
'assets.files.upload-failed': 'Upload failed', 'assets.files.upload-failed': 'Upload failed',
'assets.url.failed': "Couldn't load URL preview", 'assets.url.failed': "Couldn't load URL preview",
'color-scheme.dark': 'Dark',
'color-scheme.light': 'Light',
'color-scheme.system': 'System',
'color-style.white': 'White', 'color-style.white': 'White',
'color-style.black': 'Black', 'color-style.black': 'Black',
'color-style.blue': 'Blue', 'color-style.blue': 'Blue',
@ -232,6 +235,7 @@ export const DEFAULT_TRANSLATION = {
'tool.embed': 'Embed', 'tool.embed': 'Embed',
'tool.text': 'Text', 'tool.text': 'Text',
'menu.title': 'Menu', 'menu.title': 'Menu',
'menu.color-scheme': 'Color scheme',
'menu.copy-as': 'Copy as', 'menu.copy-as': 'Copy as',
'menu.edit': 'Edit', 'menu.edit': 'Edit',
'menu.export-as': 'Export as', 'menu.export-as': 'Export as',

View file

@ -318,5 +318,5 @@ export async function parseAndLoadDocument(
editor.updateInstanceState({ isFocused }) editor.updateInstanceState({ isFocused })
}) })
if (forceDarkMode) editor.user.updateUserPreferences({ isDarkMode: true }) if (forceDarkMode) editor.user.updateUserPreferences({ colorScheme: 'dark' })
} }

View file

@ -21,7 +21,7 @@ describe('TLUserPreferences', () => {
animationSpeed: 1, animationSpeed: 1,
color: '#000000', color: '#000000',
id: '123', id: '123',
isDarkMode: true, colorScheme: 'dark',
isSnapMode: false, isSnapMode: false,
locale: 'en', locale: 'en',
name: 'test', name: 'test',
@ -38,15 +38,15 @@ describe('TLUserPreferences', () => {
userPreferences.set({ userPreferences.set({
...userPreferences.get(), ...userPreferences.get(),
isDarkMode: false, colorScheme: 'light',
}) })
expect(editor.user.getIsDarkMode()).toBe(false) expect(editor.user.getIsDarkMode()).toBe(false)
editor.user.updateUserPreferences({ isDarkMode: true }) editor.user.updateUserPreferences({ colorScheme: 'dark' })
expect(editor.user.getIsDarkMode()).toBe(true) expect(editor.user.getIsDarkMode()).toBe(true)
expect(userPreferences.get().isDarkMode).toBe(true) expect(userPreferences.get().colorScheme).toBe('dark')
}) })
it('can have null values and it will use defaults', () => { it('can have null values and it will use defaults', () => {
@ -54,7 +54,7 @@ describe('TLUserPreferences', () => {
id: '123', id: '123',
animationSpeed: null, animationSpeed: null,
color: null, color: null,
isDarkMode: null, colorScheme: 'system',
isSnapMode: null, isSnapMode: null,
locale: null, locale: null,
name: null, name: null,