|
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' |
2 | 4 | import { |
3 | 5 | DEFAULT_THEME, |
4 | 6 | DefaultColorStyle, |
5 | 7 | DefaultFontStyle, |
6 | 8 | Editor, |
| 9 | + JsonObject, |
| 10 | + TLDOCUMENT_ID, |
7 | 11 | TLDefaultColor, |
| 12 | + TLShape, |
| 13 | + TLShapePartial, |
8 | 14 | TLTheme, |
9 | 15 | TLThemeFont, |
10 | 16 | TLThemes, |
11 | 17 | TLUiOverrides, |
12 | 18 | Tldraw, |
13 | | - registerColorsFromThemes, |
14 | | - registerFontsFromThemes, |
| 19 | + useValue, |
15 | 20 | } from 'tldraw' |
16 | | -import 'tldraw/tldraw.css' |
17 | | -import './color-picker.css' |
18 | 21 |
|
19 | 22 | // [1] Pre-declare slot names for custom colors and fonts. The runtime only |
20 | 23 | // fills the slots the user has actually added, but TypeScript needs concrete |
21 | 24 | // keys to compute `TLDefaultColorStyle` / `TLDefaultFontStyle`. |
22 | 25 | const MAX_CUSTOM_COLORS = 20 |
23 | 26 | const MAX_CUSTOM_FONTS = 10 |
24 | 27 |
|
| 28 | +const PERSISTENCE_KEY = 'color-picker-example' |
| 29 | + |
25 | 30 | type CustomColorKey = |
26 | 31 | | 'custom-1' |
27 | 32 | | 'custom-2' |
@@ -171,69 +176,200 @@ function ensureGoogleFontsLoaded() { |
171 | 176 | document.head.appendChild(link) |
172 | 177 | } |
173 | 178 |
|
| 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 | + |
174 | 273 | export default function ColorPickerExample() { |
175 | | - const [customHexes, setCustomHexes] = useState<string[]>([]) |
176 | | - const [customFonts, setCustomFonts] = useState<GoogleFont[]>([]) |
177 | 274 | 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). |
186 | 296 | useEffect(() => { |
187 | 297 | 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]) |
214 | 301 |
|
215 | 302 | 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 | + }) |
217 | 316 | } |
218 | 317 |
|
219 | 318 | 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: [] }) |
224 | 344 | }) |
225 | 345 | } |
226 | 346 |
|
| 347 | + const hasCustomStyles = palette.hexes.length > 0 || palette.fonts.length > 0 |
| 348 | + |
227 | 349 | return ( |
228 | 350 | <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 | + > |
230 | 357 | <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} /> |
232 | 359 | <AddFontButton |
233 | | - addedFamilies={customFonts.map((f) => f.family)} |
| 360 | + addedFamilies={palette.fonts.map((f) => f.family)} |
234 | 361 | addFont={addFont} |
235 | | - isFull={customFonts.length >= MAX_CUSTOM_FONTS} |
| 362 | + isFull={palette.fonts.length >= MAX_CUSTOM_FONTS} |
236 | 363 | /> |
| 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 | + )} |
237 | 373 | </div> |
238 | 374 | </Tldraw> |
239 | 375 | </div> |
@@ -264,6 +400,26 @@ function buildTheme(hexes: string[], fonts: GoogleFont[]): TLTheme { |
264 | 400 | } |
265 | 401 | } |
266 | 402 |
|
| 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 | + |
267 | 423 | // [6] Color add flow — the native picker opens immediately on "+ Add color". |
268 | 424 | // The picker fires onChange continuously while dragging, so we stage a preview |
269 | 425 | // and only commit on the explicit Add button. |
@@ -418,29 +574,27 @@ Module augmentation needs concrete property names, so we pre-declare every |
418 | 574 | possible slot up front. Only the ones the user actually adds land in the |
419 | 575 | theme at runtime. |
420 | 576 |
|
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. |
426 | 584 |
|
427 | 585 | [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. |
445 | 599 |
|
446 | 600 | */ |
0 commit comments