Skip to content

Commit ed2a504

Browse files
authored
fix(tldraw): fix color picker crash by persisting the new styles (tldraw#8980)
Fixing the app example page for custom colors: the example did not take reloading into account and when a shape is created with a custom style unfortunately it crashes the app because we don't persist the new additions to palette and fonts ### Change type - [x] `bugfix` - [ ] `improvement` - [ ] `feature` - [ ] `api` - [ ] `other` ### Test plan 1. go to example `/color-picker` 2. add a colour in the palette and some fonts 3. create a shape with new colour and font 4. reload - [ ] Unit tests - [ ] End to end tests ### Release notes - Fixed a bug in color-picker example that occurs when reloading the canvas without persisting the added styles Co-authored-by: Guillaume <guillaume@tldraw.com>
1 parent f44f863 commit ed2a504

1 file changed

Lines changed: 226 additions & 72 deletions

File tree

apps/examples/src/examples/ui/color-picker/ColorPickerExample.tsx

Lines changed: 226 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,32 @@
1-
import { useEffect, useRef, useState } from 'react'
1+
import { useEffect, useMemo, useRef, useState } from 'react'
2+
import 'tldraw/tldraw.css'
3+
import './color-picker.css'
24
import {
35
DEFAULT_THEME,
46
DefaultColorStyle,
57
DefaultFontStyle,
68
Editor,
9+
JsonObject,
10+
TLDOCUMENT_ID,
711
TLDefaultColor,
12+
TLShape,
13+
TLShapePartial,
814
TLTheme,
915
TLThemeFont,
1016
TLThemes,
1117
TLUiOverrides,
1218
Tldraw,
13-
registerColorsFromThemes,
14-
registerFontsFromThemes,
19+
useValue,
1520
} from 'tldraw'
16-
import 'tldraw/tldraw.css'
17-
import './color-picker.css'
1821

1922
// [1] Pre-declare slot names for custom colors and fonts. The runtime only
2023
// fills the slots the user has actually added, but TypeScript needs concrete
2124
// keys to compute `TLDefaultColorStyle` / `TLDefaultFontStyle`.
2225
const MAX_CUSTOM_COLORS = 20
2326
const MAX_CUSTOM_FONTS = 10
2427

28+
const PERSISTENCE_KEY = 'color-picker-example'
29+
2530
type CustomColorKey =
2631
| 'custom-1'
2732
| 'custom-2'
@@ -171,69 +176,200 @@ function ensureGoogleFontsLoaded() {
171176
document.head.appendChild(link)
172177
}
173178

179+
// [8] The palette (which hex/family each `custom-*` / `gf-*` slot maps to) lives
180+
// on the tldraw document's `meta`. Shapes only persist the slot *key* (`custom-2`,
181+
// `gf-1`); this is where the slot's actual value is kept. Storing it in the
182+
// document — instead of a separate React/localStorage copy — means the editor
183+
// persists it (via `persistenceKey`), syncs it across tabs, and includes it in
184+
// undo/redo for free, always atomically with the shapes that reference it. There
185+
// is therefore nothing to hand-reconcile: no cross-tab listener, no load-time
186+
// recovery, no save-failure handling.
187+
const PALETTE_META_KEY = 'colorPickerPalette'
188+
189+
interface Palette {
190+
hexes: string[]
191+
fonts: GoogleFont[]
192+
}
193+
194+
function isGoogleFont(v: unknown): v is GoogleFont {
195+
return (
196+
typeof v === 'object' &&
197+
v !== null &&
198+
typeof (v as GoogleFont).family === 'string' &&
199+
typeof (v as GoogleFont).name === 'string'
200+
)
201+
}
202+
203+
// `meta` is free-form JSON, so normalise whatever we read into a known shape.
204+
// This also guards against a corrupt or hand-edited document.
205+
function parsePalette(raw: unknown): Palette {
206+
const obj = (raw ?? {}) as { hexes?: unknown; fonts?: unknown }
207+
return {
208+
hexes: Array.isArray(obj.hexes)
209+
? obj.hexes.filter((h): h is string => typeof h === 'string').slice(0, MAX_CUSTOM_COLORS)
210+
: [],
211+
fonts: Array.isArray(obj.fonts)
212+
? obj.fonts.filter(isGoogleFont).slice(0, MAX_CUSTOM_FONTS)
213+
: [],
214+
}
215+
}
216+
217+
function readRawPalette(editor: Editor): unknown {
218+
return editor.getDocumentSettings().meta[PALETTE_META_KEY]
219+
}
220+
221+
// Write the palette into the document `meta`. Call this inside `editor.run` so it
222+
// batches into one undo step with any shape repair that goes with it.
223+
function writePalette(editor: Editor, palette: Palette) {
224+
editor.store.update(TLDOCUMENT_ID, (doc) => ({
225+
...doc,
226+
meta: { ...doc.meta, [PALETTE_META_KEY]: palette as unknown as JsonObject },
227+
}))
228+
}
229+
230+
const isCustomColor = (v: unknown): v is string => typeof v === 'string' && /^custom-\d+$/.test(v)
231+
const isCustomFont = (v: unknown): v is string => typeof v === 'string' && /^gf-\d+$/.test(v)
232+
233+
const DEFAULT_COLOR = DefaultColorStyle.defaultValue
234+
const DEFAULT_FONT = DefaultFontStyle.defaultValue
235+
236+
// [10] When the palette is cleared, the slots leave the *live* theme, so every
237+
// shape and the per-tab "next shape" style that still names a `custom-*` / `gf-*`
238+
// slot is remapped back to a built-in value (`black` / `draw`). The enum keeps
239+
// every slot registered (see `buildCompleteTheme`), so this isn't needed to keep
240+
// records valid — it just stops those shapes from rendering with an empty slot.
241+
// Run inside the same `editor.run` as the palette write so the two move together
242+
// through undo/redo.
243+
function repairShapesUsingCustomStyles(editor: Editor) {
244+
const updates: TLShapePartial[] = []
245+
for (const record of editor.store.allRecords()) {
246+
if (record.typeName !== 'shape') continue
247+
const shape = record as TLShape
248+
const props = shape.props as Record<string, unknown>
249+
const next: Record<string, unknown> = {}
250+
if (isCustomColor(props.color)) next.color = DEFAULT_COLOR
251+
if (isCustomColor(props.labelColor)) next.labelColor = DEFAULT_COLOR
252+
if (isCustomFont(props.font)) next.font = DEFAULT_FONT
253+
if (Object.keys(next).length > 0) {
254+
updates.push({ id: shape.id, type: shape.type, props: next } as TLShapePartial)
255+
}
256+
}
257+
if (updates.length > 0) editor.updateShapes(updates)
258+
259+
// `stylesForNextShape` lives on the per-tab `instance` record; reset any entry
260+
// that points at a slot we're removing so the next created shape doesn't
261+
// re-apply it.
262+
const stylesForNextShape = editor.getInstanceState().stylesForNextShape
263+
const fixed: Record<string, unknown> = {}
264+
for (const [id, value] of Object.entries(stylesForNextShape)) {
265+
if (isCustomColor(value)) fixed[id] = DEFAULT_COLOR
266+
else if (isCustomFont(value)) fixed[id] = DEFAULT_FONT
267+
}
268+
if (Object.keys(fixed).length > 0) {
269+
editor.updateInstanceState({ stylesForNextShape: { ...stylesForNextShape, ...fixed } })
270+
}
271+
}
272+
174273
export default function ColorPickerExample() {
175-
const [customHexes, setCustomHexes] = useState<string[]>([])
176-
const [customFonts, setCustomFonts] = useState<GoogleFont[]>([])
177274
const [editor, setEditor] = useState<Editor | null>(null)
178-
const prevColorCountRef = useRef(0)
179-
const prevFontCountRef = useRef(0)
180-
181-
// [4] Imperative theme updates. Mutating the `themes` prop on <Tldraw>
182-
// re-renders the whole editor tree and causes a visible flash. Calling
183-
// `registerColorsFromThemes` + `editor.updateTheme` directly updates the
184-
// style enum and theme atom in place — the style panel and affected shapes
185-
// repaint reactively without remounting anything.
275+
// [9] Register *every* possible slot up front (with placeholder values). Passed
276+
// as the stable `themes` prop, this makes `createTLStore` register the full
277+
// style enum before the persisted document is validated on load, so shapes
278+
// referencing any slot pass validation even before the palette is read. The
279+
// visible colors/fonts are pushed separately via `editor.updateTheme` below.
280+
const [initialThemes] = useState<Partial<TLThemes>>(() => ({ default: buildCompleteTheme() }))
281+
282+
// [8b] Read the palette straight from the document `meta`, reactively. Because
283+
// the store drives local edits, undo/redo, and cross-tab sync alike, this one
284+
// read covers all three with no extra plumbing. `useValue` returns the stored
285+
// reference (stable until the document record changes, so unrelated edits like
286+
// moving a shape don't churn it); `useMemo` normalises it.
287+
const rawPalette = useValue('palette', () => (editor ? readRawPalette(editor) : undefined), [
288+
editor,
289+
])
290+
const palette = useMemo(() => parsePalette(rawPalette), [rawPalette])
291+
292+
// [4] Push the palette's actual colors/fonts into the live theme. The style
293+
// panel and canvas read this, so only slots the palette contains are shown; the
294+
// complete prop's placeholder slots stay hidden. Runs on load, on every palette
295+
// change, and on undo/redo (all surface through `palette` above).
186296
useEffect(() => {
187297
if (!editor) return
188-
const theme = buildTheme(customHexes, customFonts)
189-
const themes = { default: theme } as TLThemes
190-
registerColorsFromThemes(themes)
191-
registerFontsFromThemes(themes)
192-
editor.updateTheme(theme)
193-
194-
// [5] If this render added a new slot and the user has shapes selected,
195-
// push the new slot onto the selection. We do this inside the effect
196-
// (rather than in the click handler) so the enum is already updated by
197-
// `registerColorsFromThemes` — setting an unknown value on a shape
198-
// would throw.
199-
const hasSelection = editor.getSelectedShapeIds().length > 0
200-
if (hasSelection && customHexes.length > prevColorCountRef.current) {
201-
const key = `custom-${customHexes.length}` as CustomColorKey
202-
editor.setStyleForSelectedShapes(DefaultColorStyle, key)
203-
}
204-
if (hasSelection && customFonts.length > prevFontCountRef.current) {
205-
const key = `gf-${customFonts.length}` as CustomFontKey
206-
// The static type of DefaultFontStyle is narrowed to the four
207-
// built-in fonts; we registered the runtime value ourselves above.
208-
editor.setStyleForSelectedShapes(DefaultFontStyle, key as 'draw')
209-
}
210-
211-
prevColorCountRef.current = customHexes.length
212-
prevFontCountRef.current = customFonts.length
213-
}, [editor, customHexes, customFonts])
298+
editor.updateTheme(buildTheme(palette.hexes, palette.fonts))
299+
if (palette.fonts.length > 0) ensureGoogleFontsLoaded()
300+
}, [editor, palette])
214301

215302
const addColor = (hex: string) => {
216-
setCustomHexes((prev) => (prev.length >= MAX_CUSTOM_COLORS ? prev : [...prev, hex]))
303+
if (!editor || palette.hexes.length >= MAX_CUSTOM_COLORS) return
304+
const hexes = [...palette.hexes, hex]
305+
editor.run(() => {
306+
writePalette(editor, { hexes, fonts: palette.fonts })
307+
// [5] Put the new color into the live theme before applying it, so a
308+
// selected shape repaints with it immediately. The enum already knows the
309+
// slot (complete `themes` prop), so the style set never trips validation.
310+
editor.updateTheme(buildTheme(hexes, palette.fonts))
311+
if (editor.getSelectedShapeIds().length > 0) {
312+
const key = `custom-${hexes.length}` as CustomColorKey
313+
editor.setStyleForSelectedShapes(DefaultColorStyle, key)
314+
}
315+
})
217316
}
218317

219318
const addFont = (font: GoogleFont) => {
220-
setCustomFonts((prev) => {
221-
if (prev.length >= MAX_CUSTOM_FONTS) return prev
222-
if (prev.some((f) => f.family === font.family)) return prev
223-
return [...prev, font]
319+
if (!editor || palette.fonts.length >= MAX_CUSTOM_FONTS) return
320+
if (palette.fonts.some((f) => f.family === font.family)) return
321+
const fonts = [...palette.fonts, font]
322+
ensureGoogleFontsLoaded()
323+
editor.run(() => {
324+
writePalette(editor, { hexes: palette.hexes, fonts })
325+
editor.updateTheme(buildTheme(palette.hexes, fonts))
326+
if (editor.getSelectedShapeIds().length > 0) {
327+
// DefaultFontStyle's static type is the four built-ins; the runtime
328+
// enum (complete `themes` prop) knows this slot.
329+
const key = `gf-${fonts.length}` as CustomFontKey
330+
editor.setStyleForSelectedShapes(DefaultFontStyle, key as 'draw')
331+
}
332+
})
333+
}
334+
335+
// [11] Remove every custom color and font. We clear the palette and remap every
336+
// shape / next-shape style off the custom slots in a single `editor.run`, so
337+
// it's one undo step and the palette can never drift from the shapes that
338+
// reference it — undo restores both together, redo clears both together.
339+
const clearCustomStyles = () => {
340+
if (!editor) return
341+
editor.run(() => {
342+
repairShapesUsingCustomStyles(editor)
343+
writePalette(editor, { hexes: [], fonts: [] })
224344
})
225345
}
226346

347+
const hasCustomStyles = palette.hexes.length > 0 || palette.fonts.length > 0
348+
227349
return (
228350
<div className="tldraw__editor">
229-
<Tldraw persistenceKey="color-picker-example" overrides={uiOverrides} onMount={setEditor}>
351+
<Tldraw
352+
persistenceKey={PERSISTENCE_KEY}
353+
themes={initialThemes}
354+
overrides={uiOverrides}
355+
onMount={(editor) => setEditor(editor)}
356+
>
230357
<div className="color-picker-toolbar" onPointerDown={(e) => e.stopPropagation()}>
231-
<AddColorButton addColor={addColor} isFull={customHexes.length >= MAX_CUSTOM_COLORS} />
358+
<AddColorButton addColor={addColor} isFull={palette.hexes.length >= MAX_CUSTOM_COLORS} />
232359
<AddFontButton
233-
addedFamilies={customFonts.map((f) => f.family)}
360+
addedFamilies={palette.fonts.map((f) => f.family)}
234361
addFont={addFont}
235-
isFull={customFonts.length >= MAX_CUSTOM_FONTS}
362+
isFull={palette.fonts.length >= MAX_CUSTOM_FONTS}
236363
/>
364+
{hasCustomStyles && (
365+
<button
366+
className="color-picker-button"
367+
onClick={clearCustomStyles}
368+
title="Remove all custom colors and fonts, resetting any shapes that use them"
369+
>
370+
Clear custom styles
371+
</button>
372+
)}
237373
</div>
238374
</Tldraw>
239375
</div>
@@ -264,6 +400,26 @@ function buildTheme(hexes: string[], fonts: GoogleFont[]): TLTheme {
264400
}
265401
}
266402

403+
// A stable theme that registers *every* possible custom slot (`custom-1..N`,
404+
// `gf-1..N`). It's passed as the `themes` prop, which `createTLStore` registers
405+
// into the style enum *before* the persisted document is validated on load, and
406+
// which the editor re-registers on every render. Registering the full set means:
407+
// - persisted or cross-tab shapes can never reference a slot the enum doesn't
408+
// know, so they always pass validation (no load crash, no per-render drop);
409+
// - the enum is never narrowed, so adding/removing palette entries can't
410+
// invalidate existing shapes.
411+
// The placeholder values here are never shown: the style panel and canvas read
412+
// the *live* theme, which we set with `editor.updateTheme(buildTheme(...))` to
413+
// contain only the slots actually in the palette.
414+
function buildCompleteTheme(): TLTheme {
415+
const hexes = Array.from({ length: MAX_CUSTOM_COLORS }, () => '#000000')
416+
const fonts: GoogleFont[] = Array.from({ length: MAX_CUSTOM_FONTS }, (_, i) => ({
417+
name: `placeholder-${i + 1}`,
418+
family: 'sans-serif',
419+
}))
420+
return buildTheme(hexes, fonts)
421+
}
422+
267423
// [6] Color add flow — the native picker opens immediately on "+ Add color".
268424
// The picker fires onChange continuously while dragging, so we stage a preview
269425
// and only commit on the explicit Add button.
@@ -418,29 +574,27 @@ Module augmentation needs concrete property names, so we pre-declare every
418574
possible slot up front. Only the ones the user actually adds land in the
419575
theme at runtime.
420576
421-
[3]
422-
The link tag is lazily added on the first "+ Add font" open. Subsequent opens
423-
reuse the already-loaded CSS. We use a module-level flag instead of React
424-
state because the tag only needs to exist once in the document — remounting
425-
the example shouldn't add it again.
577+
[8]
578+
The palette lives on the document's `meta`, not in a separate React/localStorage
579+
copy. That single decision removes the cross-tab listener, the load-time
580+
recovery pass, and the save-failure handling that a side store would need: the
581+
editor already persists `meta` (via `persistenceKey`), syncs it to other tabs,
582+
and records it in undo/redo — always atomically with the shapes that reference
583+
the slots, so the two can never disagree.
426584
427585
[4]
428-
Before: we passed custom colors and fonts through the `themes` prop, which
429-
meant every state change rebuilt the prop, re-rendered `TldrawEditor`, and
430-
caused a visible flash. Now the `themes` prop is omitted entirely; we
431-
imperatively register + update the theme from an effect. The style panel
432-
enum and the theme atom both update in place.
433-
434-
[5]
435-
When the user adds a style while a shape is selected, we push the new slot
436-
onto the selection immediately via `queueMicrotask`. The microtask runs after
437-
the React commit and the effect in [4], so by the time we call
438-
`setStyleForSelectedShapes`, `DefaultColorStyle` / `DefaultFontStyle` already
439-
contains the new slot (otherwise the enum validator would reject it).
440-
441-
Removal of custom slots is intentionally not supported:
442-
`registerColorsFromThemes` / `registerFontsFromThemes` strip entries that are
443-
absent from the theme, which would invalidate any shape still naming the
444-
removed slot.
586+
The style enum is registered once, from a *complete* theme passed via the
587+
`themes` prop (`buildCompleteTheme`), not from the palette. `createTLStore`
588+
registers the prop before the persisted document is validated on load, and the
589+
editor re-registers it on every render — so a shape can never reference a slot
590+
the enum doesn't know. The palette's actual colors/fonts are pushed into the
591+
live theme with `editor.updateTheme`; the style panel reads that live theme, so
592+
only slots the palette actually contains are shown.
593+
594+
[11]
595+
"Clear custom styles" clears the palette and remaps every shape and the
596+
next-shape style off the custom slots back to the built-in defaults, both in one
597+
`editor.run`. Because the palette and the shapes are now both in the document,
598+
that single batch keeps them consistent through undo and redo.
445599
446600
*/

0 commit comments

Comments
 (0)