import { Fragment, type ReactElement, useEffect, useState } from 'react'
import {
type Editor,
type StyleProp,
type TLDefaultColorStyle,
type TLDefaultDashStyle,
type TLDefaultFillStyle,
type TLDefaultSizeStyle,
DefaultColorStyle,
DefaultDashStyle,
DefaultFillStyle,
DefaultFontStyle,
DefaultHorizontalAlignStyle,
DefaultSizeStyle,
DefaultVerticalAlignStyle,
GeoShapeGeoStyle,
react
} from 'tldraw'
import { motion } from 'motion/react'
import {
IconArrowUpRight,
IconEraser,
IconHandStop,
IconHighlight,
IconLine,
IconPencil,
IconPointer2,
IconSketching,
IconSquare,
IconSticker2,
IconTypography
} from '@tabler/icons-react'
import { Button } from '@/client/components/ui/button'
import { cn } from '@/client/lib/cn'
type IconC = typeof IconPointer2
// The curated left-bar entries, grouped by `sep` dividers. `tool` selects a
// tldraw tool; `geo` selects the geo tool (rectangle); `menu` is a placeholder
// (noop for now); `sep` is a divider.
type ToolEntry =
| { kind: 'tool'; id: string; label: string; Icon: IconC }
| { kind: 'geo'; label: string; Icon: IconC }
| { kind: 'menu'; label: string; Icon: IconC }
| { kind: 'sep' }
const TOOL_ENTRIES: ToolEntry[] = [
{ kind: 'tool', id: 'select', label: 'Select', Icon: IconPointer2 },
{ kind: 'tool', id: 'hand', label: 'Hand', Icon: IconHandStop },
{ kind: 'sep' },
{ kind: 'tool', id: 'draw', label: 'Pencil', Icon: IconPencil },
{ kind: 'geo', label: 'Rectangle', Icon: IconSquare },
{ kind: 'tool', id: 'note', label: 'Sticker', Icon: IconSticker2 },
{ kind: 'tool', id: 'text', label: 'Text', Icon: IconTypography },
{ kind: 'tool', id: 'line', label: 'Line', Icon: IconLine },
{ kind: 'tool', id: 'arrow', label: 'Arrow', Icon: IconArrowUpRight },
{ kind: 'sep' },
{ kind: 'tool', id: 'highlight', label: 'Highlighter', Icon: IconHighlight },
{ kind: 'tool', id: 'eraser', label: 'Eraser', Icon: IconEraser }
// Temporarily hidden — `menu` kind + IconDots import kept for future use.
// { kind: "menu", label: "Menu", Icon: IconDots },
]
type ToolButtonProps = {
label: string
active: boolean
Icon: IconC
onClick: () => void
}
function ToolButton({ label, active, Icon, onClick }: ToolButtonProps) {
return (
)
}
type ScratchToolbarProps = { editor: Editor }
// tldraw shares style props (dash, size, font…) globally across tools, so a
// dashed pen would leak into a freshly-picked rect. On each tool activation we
// pin the styles that tool doesn't expose to fixed values, keeping every tool
// visually independent.
function applyToolLocks(editor: Editor, toolId: string) {
if (toolId === 'geo') {
// Rects: always a rough (hand-drawn) outline at a consistent stroke — never
// inherit the pen's dashed/solid or its size.
editor.setStyleForNextShapes(DefaultDashStyle, 'draw')
editor.setStyleForNextShapes(DefaultSizeStyle, 'm')
} else if (toolId === 'note') {
editor.setStyleForNextShapes(DefaultFontStyle, 'draw')
editor.setStyleForNextShapes(DefaultHorizontalAlignStyle, 'middle')
editor.setStyleForNextShapes(DefaultVerticalAlignStyle, 'middle')
ensurePaletteColor(editor, 'sticker', 'yellow')
} else if (toolId === 'text') {
editor.setStyleForNextShapes(DefaultFontStyle, 'draw')
editor.setStyleForNextShapes(DefaultHorizontalAlignStyle, 'middle')
editor.setStyleForNextShapes(DefaultVerticalAlignStyle, 'middle')
} else if (toolId === 'highlight') {
ensurePaletteColor(editor, 'highlight', 'yellow')
} else if (toolId === 'arrow') {
// Arrows don't expose dash — pin it rough to match the hand-drawn look and
// never inherit a dashed/solid stroke from a previous tool.
editor.setStyleForNextShapes(DefaultDashStyle, 'draw')
} else if (toolId === 'draw' || toolId === 'line') {
// Pen/line only offer rough or dashed — if we're arriving from a rect's
// 'solid', fall back to rough so a valid dash is always selected.
const dash = editor.getStyleForNextShape(DefaultDashStyle)
if (dash !== 'draw' && dash !== 'dashed') {
editor.setStyleForNextShapes(DefaultDashStyle, 'draw')
}
}
}
// Custom vertical tool bar, rendered as an overlay over the canvas (outside
// tldraw's own UI layout). Tracks the current tool and applies the pencil cursor
// to tldraw's own container so both affordances change together.
const pencilCursorClass = '[&_.tl-canvas]:cursor-pencil'
export function ScratchToolbar({ editor }: ScratchToolbarProps) {
const [toolId, setToolId] = useState(() => editor.getCurrentToolId())
useEffect(() => {
const container = editor.getContainer()
const updateTool = () => {
const currentToolId = editor.getCurrentToolId()
setToolId(currentToolId)
container.classList.toggle(pencilCursorClass, currentToolId === 'draw')
}
updateTool()
const stopTracking = react('scratch current tool', updateTool)
return () => {
stopTracking()
container.classList.remove(pencilCursorClass)
}
}, [editor])
const activate = (toolId: string) => {
editor.run(() => {
applyToolLocks(editor, toolId)
editor.setCurrentTool(toolId)
})
}
const selectRectangle = () => {
editor.run(() => {
editor.setStyleForNextShapes(GeoShapeGeoStyle, 'rectangle')
applyToolLocks(editor, 'geo')
editor.setCurrentTool('geo')
})
}
return (
{TOOL_ENTRIES.map((entry, i) => {
if (entry.kind === 'sep') {
return
}
if (entry.kind === 'geo') {
return (
)
}
if (entry.kind === 'menu') {
return (
{}}
/>
)
}
return (
activate(entry.id)}
/>
)
})}
)
}
// --- Second bar: per-tool contextual style controls -----------------------
// Each tool exposes a different subset of controls (color/size/dash/fill). The
// sub-bar shows the active tool's set, or — when the Select tool has a selection
// — the union of the selected shapes' sets. Size maps small→'m', large→'xl'.
// Sticker & text also lock align=center and font=rough (set on mount, not shown).
type StyleControl = 'color' | 'size' | 'dash' | 'fill'
const CONTROL_ORDER: StyleControl[] = ['color', 'size', 'dash', 'fill']
// Controls exposed while a creating tool is active.
const CONTROLS_BY_TOOL: Record = {
draw: ['color', 'size', 'dash'],
geo: ['color', 'fill'],
note: ['color', 'size'],
text: ['color', 'size'],
line: ['color', 'dash', 'size'],
highlight: ['color', 'size'],
arrow: ['color', 'size']
}
// Controls exposed per selected shape type (Select tool with a selection).
const CONTROLS_BY_SHAPE: Record = {
draw: ['color', 'size', 'dash'],
geo: ['color', 'fill'],
note: ['color', 'size'],
text: ['color', 'size'],
line: ['color', 'dash', 'size'],
highlight: ['color', 'size'],
arrow: ['color', 'size']
}
// Three contextual color palettes (all subsets of tldraw's color enum):
// regular (most tools), highlighter-friendly, and sticky-note pastels.
type Swatch = { value: TLDefaultColorStyle; klass: string }
type PaletteKind = 'regular' | 'highlight' | 'sticker'
type Palette = { swatches: Swatch[] }
// Swatch display hexes are tldraw's actual light-theme colors, picked to match
// how each tool paints: regular → stroke `solid`, highlight → `highlightSrgb`,
// sticker → `noteFill`.
const PALETTES: Record = {
regular: {
swatches: [
{ value: 'black', klass: 'bg-[#1d1d1d]' },
{ value: 'red', klass: 'bg-[#e03131]' },
{ value: 'yellow', klass: 'bg-[#f1ac4b]' },
{ value: 'green', klass: 'bg-[#099268]' },
{ value: 'blue', klass: 'bg-[#4465e9]' },
{ value: 'grey', klass: 'bg-[#9fa8b2]' }
]
},
highlight: {
swatches: [
{ value: 'yellow', klass: 'bg-[#fddd00]' },
{ value: 'green', klass: 'bg-[#00ffc8]' },
{ value: 'blue', klass: 'bg-[#10acff]' }
]
},
sticker: {
swatches: [
{ value: 'yellow', klass: 'bg-[#fed49a]' },
{ value: 'blue', klass: 'bg-[#8aa3ff]' },
{ value: 'green', klass: 'bg-[#6fc896]' }
]
}
}
// Which palette applies right now — by active creating tool, else by the single
// selected shape type, else regular.
function paletteKind(editor: Editor): PaletteKind {
const toolId = editor.getCurrentToolId()
if (toolId === 'highlight') return 'highlight'
if (toolId === 'note') return 'sticker'
if (CONTROLS_BY_TOOL[toolId]) return 'regular'
const types = new Set(editor.getSelectedShapeIds().map(id => editor.getShape(id)?.type))
if (types.size === 1) {
if (types.has('highlight')) return 'highlight'
if (types.has('note')) return 'sticker'
}
return 'regular'
}
// If the next-shape color isn't in `kind`'s palette, snap it to a sensible
// default so the swatch row always has an active selection.
function ensurePaletteColor(editor: Editor, kind: PaletteKind, fallback: TLDefaultColorStyle) {
const values = PALETTES[kind].swatches.map(s => s.value)
if (!values.includes(editor.getStyleForNextShape(DefaultColorStyle))) {
editor.setStyleForNextShapes(DefaultColorStyle, fallback)
}
}
// Two sizes only — small→'m', large→'xl'. Each option carries previews for the
// three size renderings (see sizeGlyphKind): `dot` blob for most tools, `stroke`
// pen icon for the pencil, `glyph` letter px for text.
const SIZE_OPTIONS: {
value: TLDefaultSizeStyle
label: string
dot: string
glyph: number
}[] = [
{ value: 'm', label: 'Small', dot: 'size-1.5', glyph: 16 },
{ value: 'xl', label: 'Large', dot: 'size-3', glyph: 24 }
]
// Rough (hand-drawn) vs dashed stroke.
const DASH_OPTIONS: { value: TLDefaultDashStyle; label: string; preview: string }[] = [
{ value: 'draw', label: 'Rough', preview: 'border-b-2 border-current' },
{ value: 'dashed', label: 'Dash', preview: 'border-b-2 border-dashed border-current' }
]
// Three fills: empty, full color, hatch. (tldraw's translucent 'semi' read as
// "stroke only", so we drop it — 'solid' is the fullest native color fill.)
const FILL_OPTIONS: { value: TLDefaultFillStyle; label: string; preview: string }[] = [
// tldraw quirk (see defaultFills): fillStyle 'solid' paints the lighter "semi"
// color, while 'fill' paints the true solid (body === border color).
{ value: 'none', label: 'None', preview: 'border border-foreground/50' },
{ value: 'solid', label: 'Semi', preview: 'border border-foreground/40 bg-foreground/25' },
{ value: 'fill', label: 'Solid', preview: 'border border-foreground bg-foreground' },
{
value: 'pattern',
label: 'Pattern',
preview:
'border border-foreground/40 bg-[repeating-linear-gradient(45deg,currentColor_0,currentColor_1px,transparent_1px,transparent_3px)]'
}
]
// Read the active value of a style: shared value across the selection, or the
// next-shape value when nothing is selected.
function readStyleValue(editor: Editor, style: StyleProp): T | undefined {
if (editor.getSelectedShapeIds().length > 0) {
return editor.getSharedStyles().getAsKnownValue(style)
}
return editor.getStyleForNextShape(style)
}
// Apply to the selection (if any) and make it the default for the next shape.
function applyStyle(editor: Editor, style: StyleProp, value: T) {
editor.run(() => {
editor.setStyleForNextShapes(style, value)
if (editor.getSelectedShapeIds().length > 0) {
editor.setStyleForSelectedShapes(style, value)
}
})
}
// Which controls the sub-bar should show right now: the active creating tool's
// set, else the union for the current selection, else none (bar hidden).
function activeControls(editor: Editor): StyleControl[] {
const toolControls = CONTROLS_BY_TOOL[editor.getCurrentToolId()]
if (toolControls) return toolControls
const ids = editor.getSelectedShapeIds()
if (ids.length === 0) return []
const set = new Set()
for (const id of ids) {
const shape = editor.getShape(id)
if (!shape) continue
for (const control of CONTROLS_BY_SHAPE[shape.type] ?? []) set.add(control)
}
return CONTROL_ORDER.filter(control => set.has(control))
}
// How the size control previews itself, matched to what "size" means for the
// active tool: the pencil's is a stroke weight (pen glyph at varying strokes),
// text's is a font size (a letter at varying sizes), everything else a plain blob.
// Keyed off the active tool, or a uniform selection under the Select tool.
function sizeGlyphKind(editor: Editor): 'pen' | 'letter' | 'dot' {
const toolId = editor.getCurrentToolId()
if (toolId === 'draw') return 'pen'
if (toolId === 'text') return 'letter'
const ids = editor.getSelectedShapeIds()
const types = new Set(ids.map(id => editor.getShape(id)?.type))
if (types.size === 1) {
if (types.has('draw')) return 'pen'
if (types.has('text')) return 'letter'
}
return 'dot'
}
type StyleBarState = {
controls: StyleControl[]
palette: Palette
color: TLDefaultColorStyle | undefined
size: TLDefaultSizeStyle | undefined
sizeGlyph: 'pen' | 'letter' | 'dot'
dash: TLDefaultDashStyle | undefined
fill: TLDefaultFillStyle | undefined
}
function readStyleBarState(editor: Editor): StyleBarState {
return {
controls: activeControls(editor),
palette: PALETTES[paletteKind(editor)],
color: readStyleValue(editor, DefaultColorStyle),
size: readStyleValue(editor, DefaultSizeStyle),
sizeGlyph: sizeGlyphKind(editor),
dash: readStyleValue(editor, DefaultDashStyle),
fill: readStyleValue(editor, DefaultFillStyle)
}
}
type ScratchStyleBarProps = { editor: Editor }
export function ScratchStyleBar({ editor }: ScratchStyleBarProps) {
const [state, setState] = useState(() => readStyleBarState(editor))
useEffect(() => {
setState(readStyleBarState(editor))
return react('scratch styles', () => setState(readStyleBarState(editor)))
}, [editor])
if (state.controls.length === 0) return null
const groups: Record = {
color: (
{state.palette.swatches.map(c => (
),
size: (
{SIZE_OPTIONS.map(s => (
))}
),
dash: (
{DASH_OPTIONS.map(d => (
))}
),
fill: (
{FILL_OPTIONS.map(f => (
))}
)
}
return (
{state.controls.map((control, i) => (
{i > 0 && }
{groups[control]}
))}
)
}