[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:
parent
6c7b8febbf
commit
12aea7ed68
19 changed files with 124 additions and 48 deletions
|
@ -208,7 +208,7 @@ test.describe('Export snapshots', () => {
|
|||
for (const [name, shapes] of filteredSnapshots) {
|
||||
test(`Exports with ${name} in dark mode`, async ({ page, api }) => {
|
||||
await page.evaluate((shapes) => {
|
||||
editor.user.updateUserPreferences({ isDarkMode: true })
|
||||
editor.user.updateUserPreferences({ colorScheme: 'dark' })
|
||||
editor
|
||||
.updateInstanceState({ exportBackground: false })
|
||||
.selectAll()
|
||||
|
|
|
@ -187,7 +187,7 @@ test.describe('Keyboard Shortcuts', () => {
|
|||
test('Toggle dark mode', async () => {
|
||||
await page.keyboard.press('Control+/')
|
||||
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
|
||||
name: 'toggle-dark-mode',
|
||||
name: 'color-scheme',
|
||||
data: { source: 'kbd' },
|
||||
})
|
||||
})
|
||||
|
|
|
@ -73,7 +73,7 @@ export default function TldrawImageExample() {
|
|||
onMount={(editor: Editor) => {
|
||||
setEditor(editor)
|
||||
editor.updateInstanceState({ isDebugMode: false })
|
||||
editor.user.updateUserPreferences({ isDarkMode })
|
||||
editor.user.updateUserPreferences({ colorScheme: isDarkMode ? 'dark' : 'light' })
|
||||
if (currentPageId) {
|
||||
editor.setCurrentPage(currentPageId)
|
||||
}
|
||||
|
|
|
@ -115,6 +115,9 @@
|
|||
"action.zoom-to-selection": "Zoom to selection",
|
||||
"assets.files.upload-failed": "Upload failed",
|
||||
"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.black": "Black",
|
||||
"color-style.blue": "Blue",
|
||||
|
@ -228,6 +231,7 @@
|
|||
"tool.embed": "Embed",
|
||||
"tool.text": "Text",
|
||||
"menu.title": "Menu",
|
||||
"menu.color-scheme": "Color scheme",
|
||||
"menu.copy-as": "Copy as",
|
||||
"menu.edit": "Edit",
|
||||
"menu.export-as": "Export as",
|
||||
|
|
|
@ -718,7 +718,6 @@ export const defaultUserPreferences: Readonly<{
|
|||
animationSpeed: 0 | 1;
|
||||
color: "#02B1CC" | "#11B3A3" | "#39B178" | "#55B467" | "#7B66DC" | "#9D5BD2" | "#BD54C6" | "#E34BA9" | "#EC5E41" | "#F04F88" | "#F2555A" | "#FF802B";
|
||||
edgeScrollSpeed: 1;
|
||||
isDarkMode: false;
|
||||
isDynamicSizeMode: false;
|
||||
isSnapMode: false;
|
||||
isWrapMode: false;
|
||||
|
@ -3343,12 +3342,12 @@ export interface TLUserPreferences {
|
|||
// (undocumented)
|
||||
color?: null | string;
|
||||
// (undocumented)
|
||||
colorScheme?: 'dark' | 'light' | 'system';
|
||||
// (undocumented)
|
||||
edgeScrollSpeed?: null | number;
|
||||
// (undocumented)
|
||||
id: string;
|
||||
// (undocumented)
|
||||
isDarkMode?: boolean | null;
|
||||
// (undocumented)
|
||||
isDynamicSizeMode?: boolean | null;
|
||||
// (undocumented)
|
||||
isSnapMode?: boolean | null;
|
||||
|
@ -3470,6 +3469,7 @@ export class UserPreferencesManager {
|
|||
getUserPreferences(): {
|
||||
animationSpeed: number;
|
||||
color: string;
|
||||
colorScheme: "dark" | "light" | "system" | undefined;
|
||||
id: string;
|
||||
isDarkMode: boolean;
|
||||
isDynamicResizeMode: boolean;
|
||||
|
@ -3479,6 +3479,8 @@ export class UserPreferencesManager {
|
|||
name: string;
|
||||
};
|
||||
// (undocumented)
|
||||
systemColorScheme: Atom<"dark" | "light", unknown>;
|
||||
// (undocumented)
|
||||
updateUserPreferences: (userPreferences: Partial<TLUserPreferences>) => void;
|
||||
}
|
||||
|
||||
|
|
|
@ -261,7 +261,7 @@ const TldrawEditorWithLoadingStore = memo(function TldrawEditorBeforeLoading({
|
|||
const container = useContainer()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (user.userPreferences.get().isDarkMode) {
|
||||
if (user.userPreferences.get().colorScheme === 'dark') {
|
||||
container.classList.remove('tl-theme__light')
|
||||
container.classList.add('tl-theme__dark')
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ export interface TLUserPreferences {
|
|||
color?: string | null
|
||||
animationSpeed?: number | null
|
||||
edgeScrollSpeed?: number | null
|
||||
isDarkMode?: boolean | null
|
||||
colorScheme?: 'light' | 'dark' | 'system'
|
||||
isSnapMode?: boolean | null
|
||||
isWrapMode?: boolean | null
|
||||
isDynamicSizeMode?: boolean | null
|
||||
|
@ -40,9 +40,9 @@ const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPrefere
|
|||
name: T.string.nullable().optional(),
|
||||
locale: T.string.nullable().optional(),
|
||||
color: T.string.nullable().optional(),
|
||||
colorScheme: T.literalEnum('light', 'dark', 'system').optional(),
|
||||
animationSpeed: T.number.nullable().optional(),
|
||||
edgeScrollSpeed: T.number.nullable().optional(),
|
||||
isDarkMode: T.boolean.nullable().optional(),
|
||||
isSnapMode: T.boolean.nullable().optional(),
|
||||
isWrapMode: T.boolean.nullable().optional(),
|
||||
isDynamicSizeMode: T.boolean.nullable().optional(),
|
||||
|
@ -55,6 +55,7 @@ const Versions = {
|
|||
AddEdgeScrollSpeed: 4,
|
||||
AddExcalidrawSelectMode: 5,
|
||||
AddDynamicSizeMode: 6,
|
||||
AllowSystemColorScheme: 7,
|
||||
} as const
|
||||
|
||||
const CURRENT_VERSION = Math.max(...Object.values(Versions))
|
||||
|
@ -75,6 +76,14 @@ function migrateSnapshot(data: { version: number; user: any }) {
|
|||
if (data.version < Versions.AddExcalidrawSelectMode) {
|
||||
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) {
|
||||
data.user.isDynamicSizeMode = false
|
||||
|
@ -104,14 +113,6 @@ 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') {
|
||||
|
@ -125,7 +126,6 @@ export const defaultUserPreferences = Object.freeze({
|
|||
name: 'New User',
|
||||
locale: getDefaultTranslationLocale(),
|
||||
color: getRandomColor(),
|
||||
isDarkMode: false,
|
||||
edgeScrollSpeed: 1,
|
||||
animationSpeed: userPrefersReducedMotion() ? 0 : 1,
|
||||
isSnapMode: false,
|
||||
|
|
|
@ -1,17 +1,28 @@
|
|||
import { computed } from '@tldraw/state'
|
||||
import {
|
||||
TLUserPreferences,
|
||||
defaultUserPreferences,
|
||||
userPrefersDarkUI,
|
||||
} from '../../config/TLUserPreferences'
|
||||
import { atom, computed } from '@tldraw/state'
|
||||
import { TLUserPreferences, defaultUserPreferences } from '../../config/TLUserPreferences'
|
||||
import { TLUser } from '../../config/createTLUser'
|
||||
|
||||
/** @public */
|
||||
export class UserPreferencesManager {
|
||||
systemColorScheme = atom<'dark' | 'light'>('systemColorScheme', 'light')
|
||||
constructor(
|
||||
private readonly user: TLUser,
|
||||
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>) => {
|
||||
this.user.setUserPreferences({
|
||||
|
@ -27,16 +38,23 @@ export class UserPreferencesManager {
|
|||
color: this.getColor(),
|
||||
animationSpeed: this.getAnimationSpeed(),
|
||||
isSnapMode: this.getIsSnapMode(),
|
||||
colorScheme: this.user.userPreferences.get().colorScheme,
|
||||
isDarkMode: this.getIsDarkMode(),
|
||||
isWrapMode: this.getIsWrapMode(),
|
||||
isDynamicResizeMode: this.getIsDynamicResizeMode(),
|
||||
}
|
||||
}
|
||||
@computed getIsDarkMode() {
|
||||
return (
|
||||
this.user.userPreferences.get().isDarkMode ??
|
||||
(this.inferDarkMode ? userPrefersDarkUI() : false)
|
||||
)
|
||||
switch (this.user.userPreferences.get().colorScheme) {
|
||||
case 'dark':
|
||||
return true
|
||||
case 'light':
|
||||
return false
|
||||
case 'system':
|
||||
return this.systemColorScheme.get() === 'dark'
|
||||
default:
|
||||
return this.inferDarkMode ? this.systemColorScheme.get() === 'dark' : false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -19,7 +19,7 @@ describe('user', () => {
|
|||
})
|
||||
|
||||
it('gets a user with the correct', () => {
|
||||
editor.user.updateUserPreferences({ isDarkMode: true })
|
||||
editor.user.updateUserPreferences({ colorScheme: 'dark' })
|
||||
expect(editor.user.getIsDarkMode()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -222,7 +222,6 @@ export {
|
|||
ReorderMenuSubmenu,
|
||||
SelectAllMenuItem,
|
||||
ToggleAutoSizeMenuItem,
|
||||
ToggleDarkModeItem,
|
||||
ToggleDebugModeItem,
|
||||
ToggleEdgeScrollingItem,
|
||||
ToggleFocusModeItem,
|
||||
|
|
41
packages/tldraw/src/lib/ui/components/ColorSchemeMenu.tsx
Normal file
41
packages/tldraw/src/lib/ui/components/ColorSchemeMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import { useEditor, useValue } from '@tldraw/editor'
|
||||
import { useActions } from '../../context/actions'
|
||||
import { useCanRedo, useCanUndo } from '../../hooks/menu-hooks'
|
||||
import { ColorSchemeMenu } from '../ColorSchemeMenu'
|
||||
import { LanguageMenu } from '../LanguageMenu'
|
||||
import {
|
||||
ClipboardMenuGroup,
|
||||
|
@ -14,7 +15,6 @@ import {
|
|||
RemoveFrameMenuItem,
|
||||
SelectAllMenuItem,
|
||||
ToggleAutoSizeMenuItem,
|
||||
ToggleDarkModeItem,
|
||||
ToggleDebugModeItem,
|
||||
ToggleDynamicSizeModeItem,
|
||||
ToggleEdgeScrollingItem,
|
||||
|
@ -170,13 +170,15 @@ export function PreferencesGroup() {
|
|||
<ToggleToolLockItem />
|
||||
<ToggleGridItem />
|
||||
<ToggleWrapModeItem />
|
||||
<ToggleDarkModeItem />
|
||||
<ToggleFocusModeItem />
|
||||
<ToggleEdgeScrollingItem />
|
||||
<ToggleReduceMotionItem />
|
||||
<ToggleDynamicSizeModeItem />
|
||||
<ToggleDebugModeItem />
|
||||
</TldrawUiMenuGroup>
|
||||
<TldrawUiMenuGroup id="color-scheme">
|
||||
<ColorSchemeMenu />
|
||||
</TldrawUiMenuGroup>
|
||||
<TldrawUiMenuGroup id="language">
|
||||
<LanguageMenu />
|
||||
</TldrawUiMenuGroup>
|
||||
|
|
|
@ -1110,8 +1110,11 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
kbd: '$/',
|
||||
readonlyOk: true,
|
||||
onSelect(source) {
|
||||
trackEvent('toggle-dark-mode', { source })
|
||||
editor.user.updateUserPreferences({ isDarkMode: !editor.user.getIsDarkMode() })
|
||||
const value = editor.user.getIsDarkMode() ? 'light' : 'dark'
|
||||
trackEvent('color-scheme', { source, value })
|
||||
editor.user.updateUserPreferences({
|
||||
colorScheme: value,
|
||||
})
|
||||
},
|
||||
checkbox: true,
|
||||
},
|
||||
|
|
|
@ -84,7 +84,6 @@ export interface TLUiEventMap {
|
|||
'toggle-snap-mode': null
|
||||
'toggle-tool-lock': null
|
||||
'toggle-grid-mode': null
|
||||
'toggle-dark-mode': null
|
||||
'toggle-wrap-mode': null
|
||||
'toggle-focus-mode': null
|
||||
'toggle-debug-mode': null
|
||||
|
@ -92,6 +91,7 @@ export interface TLUiEventMap {
|
|||
'toggle-lock': null
|
||||
'toggle-reduce-motion': null
|
||||
'toggle-edge-scrolling': null
|
||||
'color-scheme': { value: string }
|
||||
'exit-pen-mode': null
|
||||
'stop-following': null
|
||||
'open-cursor-chat': null
|
||||
|
|
|
@ -119,6 +119,9 @@ export type TLUiTranslationKey =
|
|||
| 'action.zoom-to-selection'
|
||||
| 'assets.files.upload-failed'
|
||||
| 'assets.url.failed'
|
||||
| 'color-scheme.dark'
|
||||
| 'color-scheme.light'
|
||||
| 'color-scheme.system'
|
||||
| 'color-style.white'
|
||||
| 'color-style.black'
|
||||
| 'color-style.blue'
|
||||
|
@ -232,6 +235,7 @@ export type TLUiTranslationKey =
|
|||
| 'tool.embed'
|
||||
| 'tool.text'
|
||||
| 'menu.title'
|
||||
| 'menu.color-scheme'
|
||||
| 'menu.copy-as'
|
||||
| 'menu.edit'
|
||||
| 'menu.export-as'
|
||||
|
|
|
@ -119,6 +119,9 @@ export const DEFAULT_TRANSLATION = {
|
|||
'action.zoom-to-selection': 'Zoom to selection',
|
||||
'assets.files.upload-failed': 'Upload failed',
|
||||
'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.black': 'Black',
|
||||
'color-style.blue': 'Blue',
|
||||
|
@ -232,6 +235,7 @@ export const DEFAULT_TRANSLATION = {
|
|||
'tool.embed': 'Embed',
|
||||
'tool.text': 'Text',
|
||||
'menu.title': 'Menu',
|
||||
'menu.color-scheme': 'Color scheme',
|
||||
'menu.copy-as': 'Copy as',
|
||||
'menu.edit': 'Edit',
|
||||
'menu.export-as': 'Export as',
|
||||
|
|
|
@ -318,5 +318,5 @@ export async function parseAndLoadDocument(
|
|||
editor.updateInstanceState({ isFocused })
|
||||
})
|
||||
|
||||
if (forceDarkMode) editor.user.updateUserPreferences({ isDarkMode: true })
|
||||
if (forceDarkMode) editor.user.updateUserPreferences({ colorScheme: 'dark' })
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ describe('TLUserPreferences', () => {
|
|||
animationSpeed: 1,
|
||||
color: '#000000',
|
||||
id: '123',
|
||||
isDarkMode: true,
|
||||
colorScheme: 'dark',
|
||||
isSnapMode: false,
|
||||
locale: 'en',
|
||||
name: 'test',
|
||||
|
@ -38,15 +38,15 @@ describe('TLUserPreferences', () => {
|
|||
|
||||
userPreferences.set({
|
||||
...userPreferences.get(),
|
||||
isDarkMode: false,
|
||||
colorScheme: 'light',
|
||||
})
|
||||
|
||||
expect(editor.user.getIsDarkMode()).toBe(false)
|
||||
|
||||
editor.user.updateUserPreferences({ isDarkMode: true })
|
||||
editor.user.updateUserPreferences({ colorScheme: 'dark' })
|
||||
|
||||
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', () => {
|
||||
|
@ -54,7 +54,7 @@ describe('TLUserPreferences', () => {
|
|||
id: '123',
|
||||
animationSpeed: null,
|
||||
color: null,
|
||||
isDarkMode: null,
|
||||
colorScheme: 'system',
|
||||
isSnapMode: null,
|
||||
locale: null,
|
||||
name: null,
|
||||
|
|
Loading…
Reference in a new issue