Conversation
…stomizing default shapes (#8410) ## Summary This PR introduces a first-class theme system, replacing the previous approach where colors were hardcoded and resolved inline. Themes are now named, registered objects that shape utils consume via a structured display values pipeline. https://github.com/user-attachments/assets/a536084f-b999-41e7-981b-a365b1f5b6c3 ### Concepts | Term | Type | Meaning | |------|------|---------| | **Theme** | `TLTheme` | A named set of colors and typographic values (`fontSize`, `lineHeight`, `strokeWidth`) for both light and dark modes. Register custom themes via `TLThemes` module augmentation for type-safe IDs. | | **Theme ID** | `TLThemeId` | A type-safe key into `TLThemes` (e.g. `'default'`, `'corporate'`). Derived as `keyof TLThemes & string`. | | **Theme default colors** | `TLThemeDefaultColors` | The full color interface for one mode. Augmentable to add custom named colors. | | **Theme colors** | `TLThemeColors` | The resolved palette type used by `TLTheme`. Equals `TLThemeDefaultColors` minus any keys in `TLRemovedDefaultThemeColors`. | | **Color mode** | `'light' \| 'dark'` | The resolved appearance, derived from the user's color scheme preference. | | **Color scheme** | `'light' \| 'dark' \| 'system'` | The user's preference. `'system'` resolves to a color mode based on OS settings. | | **Display values** | `getDefaultDisplayValues` / `getCustomDisplayValues` | Shape util callbacks that produce cached, computed visual properties (colors, stroke widths, font sizes) from the theme and color mode. | | **ThemeManager** | `ThemeManager` | Editor manager that stores themes, tracks the active theme, and exposes the resolved color mode. | ### Module augmentation ```ts // Register a new theme ID declare module '@tldraw/tlschema' { interface TLThemes { corporate: TLTheme } } // Register a new color name declare module '@tldraw/tlschema' { interface TLThemeDefaultColors { pink: TLDefaultColor } } // Remove built-in colors from the palette declare module '@tldraw/tlschema' { interface TLRemovedDefaultThemeColors { 'light-violet': true 'light-blue': true } } ``` ### Removing colors To remove built-in palette colors, augment `TLRemovedDefaultThemeColors`. Each key you add is omitted from `TLThemeColors`, so TypeScript no longer expects it in theme definitions and the color won't appear in the style panel. UI infrastructure colors (`text`, `background`, `negativeSpace`, `solid`, `cursor`, `noteBorder`) cannot be removed. ### Editor API | Method | Description | | --- | --- | | `getCurrentTheme()` | Get the current theme | | `getCurrentThemeId()` | Get the id of the current theme | | `setCurrentTheme(id)` | Set the current theme by id | | `getThemes()` | Get all registered themes | | `getTheme(id)` | Get a theme by id | | `updateTheme(id, partial)` | Update a named theme (callback receives a `structuredClone` deep copy, so you can mutate directly) | | `updateThemes(themesOrFn)` | Update or remove themes (callback receives a deep copy; must keep `'default'`) | | `getColorMode()` | Get the resolved color mode (`'light'` or `'dark'`) | | `setColorMode(mode)` | Set the color mode (`'light'` or `'dark'`) | ### Component props | Prop | Type | Description | | --- | --- | --- | | `themes` | `Partial<TLThemes>` | Named themes for the editor | | `initialTheme` | `TLThemeId` | The initially active theme (default: `'default'`) | ### Display values for default shapes Each default shape util defines `getDefaultDisplayValues` in its `options`, which resolves the shape's visual properties from the current theme and color mode. These are consumed via `getDisplayValues(util, shape)` and cached automatically. Here's what each default shape returns: | Shape | Display values | | --- | --- | | **draw** | `strokeColor`, `strokeWidth`, `fillColor`, `patternFillFallbackColor` | | **geo** | `strokeColor`, `strokeWidth`, `strokeRoundness`, `fillColor`, `patternFillFallbackColor`, `labelColor`, `labelFontFamily`, `labelFontSize`, `labelMinWidth`, `labelExtraPadding`, `labelLineHeight`, `labelFontWeight`, `labelFontVariant`, `labelFontStyle`, `labelHorizontalAlign`, `labelVerticalAlign`, `labelPadding`, `labelEdgeMargin`, `minSizeWithLabel` | | **arrow** | `strokeColor`, `strokeWidth`, `fillColor`, `patternFillFallbackColor`, `labelColor`, `labelFontFamily`, `labelFontSize`, `labelLineHeight`, `labelPadding`, `labelBorderRadius` | | **text** | `color`, `fontFamily`, `fontSize`, `lineHeight`, `fontWeight`, `fontStyle`, `fontVariant` | | **note** | `noteWidth`, `noteHeight`, `noteBackgroundColor`, `borderColor`, `borderWidth`, `labelColor`, `labelFontFamily`, `labelFontSize`, `labelLineHeight`, `labelFontWeight`, `labelFontVariant`, `labelFontStyle`, `labelPadding`, `labelHorizontalAlign`, `labelVerticalAlign` | | **line** | `strokeColor`, `strokeWidth` | | **highlight** | `strokeColor`, `strokeWidth`, `underlayOpacity`, `overlayOpacity` | | **frame** | `fillColor`, `strokeColor`, `showColorsFillColor`, `showColorsStrokeColor`, `headingFillColor`, `headingStrokeColor`, `headingTextColor`, `showColorsHeadingFillColor`, `showColorsHeadingStrokeColor`, `showColorsHeadingTextColor` | | **embed** | `showShadow` | | **image**, **video**, **bookmark** | _(none)_ | Example — how `DrawShapeUtil` defines and consumes display values: ```ts // In DrawShapeUtil options: getDefaultDisplayValues(_editor, shape, theme, colorMode) { const { color, fill, size } = shape.props const colors = theme.colors[colorMode] return { strokeColor: getColorValue(colors, color, 'solid'), strokeWidth: theme.strokeWidth * STROKE_SIZES[size], fillColor: fill === 'none' ? 'transparent' : fill === 'semi' ? colors.solid : getColorValue(colors, color, DEFAULT_FILL_COLOR_NAMES[fill]), patternFillFallbackColor: getColorValue(colors, color, 'semi'), } } // In DrawShapeUtil.component(): component(shape: TLDrawShape) { const dv = getDisplayValues(this, shape) return ( <SVGContainer> <DrawShapeSvg shape={shape} strokeColor={dv.strokeColor} strokeWidth={dv.strokeWidth} fillColor={dv.fillColor} patternFillFallbackColor={dv.patternFillFallbackColor} /> </SVGContainer> ) } ``` To override display values for a default shape, use `getCustomDisplayValues`: ```ts const MyDrawShapeUtil = DrawShapeUtil.configure({ getCustomDisplayValues(_editor, _shape, _theme, _colorMode) { return { strokeWidth: 10 } // always use a thick stroke }, }) ``` ## FAQ **How do I customize the default theme's colors?** ```ts editor.updateTheme('default', (theme) => { // The callback receives a deep copy, so mutate directly theme.colors.light.black.solid = 'aqua' return theme }) ``` **How do I register and switch to a custom theme?** ```tsx <Tldraw themes={{ corporate: myCorporateTheme }} initialTheme="corporate" /> ``` **How do I add a custom color that shapes can use?** Augment `TLThemeDefaultColors` and include the color in your theme's palettes. See the [custom theme example](apps/examples/src/examples/ui/custom-theme/). **How do I remove built-in colors?** Augment `TLRemovedDefaultThemeColors` to list the colors you want removed. They'll be omitted from the palette type and won't appear in the style panel. See the [custom theme example](apps/examples/src/examples/ui/custom-theme/). **How do I remove a theme at runtime?** ```ts editor.updateThemes(({ myOldTheme, ...rest }) => rest) ``` **How do I change the font size?** `theme.fontSize` is a base multiplier used by all default shape utils to compute their final font sizes. For example, a text shape's rendered size is `theme.fontSize * FONT_SIZES[size]`, where `FONT_SIZES` maps the size style (`'s'`, `'m'`, `'l'`, `'xl'`) to a relative scale. The same pattern applies to geo labels, note labels, arrow labels, and highlight stroke widths. Changing `theme.fontSize` scales all of them proportionally. ```ts editor.updateTheme('default', (theme) => { theme.fontSize = 24 return theme }) ``` **Do I need to use display values for my custom shapes?** No. The display values system (`getDefaultDisplayValues` / `getCustomDisplayValues` / `getDisplayValues`) is designed primarily for tldraw's default shapes — it's the mechanism that lets them resolve colors, font sizes, and stroke widths from the current theme and color mode, and that lets downstream users override those values without forking the shape util. If you're building custom shapes, you don't need to opt into display values at all. You can read theme data directly via `editor.getCurrentTheme()` and `editor.getColorMode()` if you want to, or hardcode colors, or use your own styling system entirely. **How do I read the current colors for a shape util?** Use `getDisplayValues(util, shape)` — it returns cached display values computed from the current theme and color mode. Override via `getCustomDisplayValues` in shape options. ## New examples - **Custom theme** (`ui/custom-theme`): Creating a fully custom theme with custom colors, custom fonts, removed colors, and adjustable stroke widths / font sizes. - **Multiple themes** (`ui/multiple-themes`): `TLThemes` module augmentation, registering multiple named themes, and switching between them at runtime. - **Display options** (`configuration/display-options`): Configuring display values like stroke widths, font sizes, and line heights via `getCustomDisplayValues`. - **Dark mode** (`ui/dark-mode`): Using the new `colorScheme` prop (renamed from `inferDarkMode`). ### Change type - [x] `feature` ### Test plan - [x] `yarn typecheck` passes - [x] `displayValues.test.ts` — 16 tests pass (caching, theme changes, dark mode, overrides) - [ ] Manual: verify shapes render correctly in light and dark mode - [ ] Manual: verify SVG export uses correct colors for both modes ### Release notes - Add first-class theme system with named themes, type-safe theme IDs, and display values for customizing default shapes - Add `TLRemovedDefaultThemeColors` interface for type-safe removal of built-in palette colors - Add `negativeSpace` theme color for frame heading knockouts and text outlines - Add `updateTheme` / `updateThemes` methods with mutable deep-copy callbacks - Add custom font support via `TLThemeFonts` module augmentation ### API changes **Added:** - Added `TLTheme`, `TLThemeId`, `TLThemes`, `TLThemeDefaultColors`, `TLThemeColors`, `TLThemeUiColorKeys`, `TLRemovedDefaultThemeColors`, `TLDefaultColor`, `TLThemeFont`, `TLThemeFonts` types - Added `ThemeManager` class with `getCurrentTheme()`, `getCurrentThemeId()`, `setCurrentTheme()`, `getThemes()`, `getTheme()`, `updateTheme()`, `updateThemes()`, `getColorMode()` - Added `getDisplayValues()`, `getColorValue()`, `DEFAULT_THEME`, `DEFAULT_THEME_FONTS` - Added `themes` and `initialTheme` props to `<Tldraw>` and `<TldrawEditor>` **Breaking — removed from `@tldraw/tlschema`:** - `defaultColorNames` — removed (colors are now defined by `TLThemeDefaultColors`) - `DefaultColorThemePalette` — removed (use `editor.getCurrentTheme().colors` instead) - `DefaultLabelColorStyle` — removed (use `DefaultColorStyle` instead) - `TLDefaultColorTheme` type — removed (replaced by `TLThemeColors`) - `TLDefaultColorThemeColor` interface — renamed to `TLDefaultColor` - `getDefaultColorTheme()` — removed (use `editor.getCurrentTheme().colors[colorMode]` instead) - `getColorValue(theme, color, variant)` → `getColorValue(colors, color, variant)` (first arg is now `TLThemeColors` instead of `TLDefaultColorTheme`) - `registerColorsFromThemeDefinitions` → `registerColorsFromThemes` **Breaking — removed from `@tldraw/tldraw`:** - `ARROW_LABEL_FONT_SIZES` — removed (now resolved via display values) - `FONT_FAMILIES` — removed (use `getFontFamily(theme, font)` or display values instead) - `FONT_SIZES` — removed (now resolved via display values) - `LABEL_FONT_SIZES` — removed (now resolved via display values) - `STROKE_SIZES` — removed (now resolved via display values) - `TEXT_PROPS` — removed (now resolved via display values) - `useDefaultColorTheme()` — removed (use `editor.getCurrentTheme()` + `useColorMode()` instead) **Breaking — renamed / changed in `@tldraw/editor`:** - `inferDarkMode` prop → `colorScheme` prop (`boolean` → `'light' | 'dark' | 'system'`) - `useIsDarkMode()` → `useColorMode()` (returns `'dark' | 'light'` instead of `boolean`) - `SvgExportContext.themeId` → `SvgExportContext.colorMode` (`string` → `'light' | 'dark'`) - `TLFontFace` and `TLFontFaceSource` moved from `@tldraw/editor` to `@tldraw/tlschema` - `UserPreferencesManager` constructor: `inferDarkMode: boolean` → `colorScheme: 'dark' | 'light' | 'system'` **Breaking — changed in `@tldraw/tldraw`:** - `PlainTextLabelProps`: `font` → `fontFamily`, `align` → `textAlign`, `fill` removed, `verticalAlign` type narrowed to `'start' | 'middle' | 'end'` - `RichTextLabelProps`: `font` → `fontFamily`, `align` → `textAlign`, `fill` removed, `verticalAlign` type narrowed to `'start' | 'middle' | 'end'` - `RichTextSVGProps`: `font` → `fontFamily`, `align` → `textAlign`, added `lineHeight`, `verticalAlign` type narrowed to `'start' | 'middle' | 'end'` - `HighlightShapeOptions`: `overlayOpacity` and `underlayOpacity` moved to display values - `HighlightShapeUtil.toSvg()` and `toBackgroundSvg()` now require `SvgExportContext` parameter - `LineShapeUtil.toSvg()` now requires `SvgExportContext` parameter - All shape option interfaces now extend `ShapeOptionsWithDisplayValues` (adds `getDefaultDisplayValues` and `getCustomDisplayValues`) - `EnumStyleProp.values` changed from `readonly T[]` to mutable `T[]` (added `addValues()` / `removeValues()` methods) **Breaking — schema changes:** - `TLDefaultColorStyle` is now a computed type derived from `TLThemeDefaultColors` (no longer a simple enum union) - `TLDefaultFontStyle` is now `keyof TLThemeFonts & string` (no longer a simple enum union) - `TLNoteShapeProps.fontSizeAdjustment` changed from `number` to `null | number` ### Code changes | Section | LOC change | | --- | --- | | Core code | +3924 / -2575 | | Tests | +369 / -100 | | Automated files | +514 / -226 | | Documentation | +1677 / -542 | | Apps | +1 / -1 | | Templates | +117 / -109 | --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Mime Čuvalo <mimecuvalo@gmail.com>
…ose (#8422) In order to prevent stale state and leaked reactive subscriptions when editors are remounted (e.g. React strict mode, deep links), this PR adds cleanup for in-progress camera animations, user following, and open menus to `Editor.dispose()`. Relates to #8396. Previously, if the editor was disposed while a camera animation was running, a user was being followed, or menus were open, these resources would leak: - **Camera animations**: `stopCameraAnimation()` was never called, leaving tick listeners and `once('stop-camera-animation')` handlers attached to the EventEmitter. - **Following user**: The `react('update current page')` reactive subscription would continue running after dispose since `stopFollowingUser()` was never called — the most significant leak, as this is a live `EffectScheduler` that keeps reacting to store changes. - **Open menus**: Entries in the global `tlmenus` atom scoped to the disposed editor's context would persist indefinitely. ### Change type - [x] `bugfix` ### Test plan 1. In `apps/examples/src/index.tsx`, set `ENABLE_STRICT_MODE = true` 2. Run `yarn dev`, open any example 3. Start a camera animation (e.g. zoom to fit), then quickly navigate away and back 4. Verify the editor works correctly after remount 5. Follow a user in a multiplayer room, then dispose the editor — verify no console errors from stale reactive subscriptions - [x] Unit tests (existing tests pass — 774 editor + 2208 tldraw) ### Release notes - Fixed leaked camera animations, following subscriptions, and stale menu state when the editor is disposed during active operations. ### Code changes | Section | LOC change | | ---------- | ---------- | | Core code | +11 / -0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
See Commits and Changes for more details.
Created by
pull[bot] (v2.0.0-alpha.4)
Can you help keep this open source service alive? 💖 Please sponsor : )