Example of using tldraw styles (#3017)

Adds an example of how to use tldraw styles in a custom shape


- [x] `documentation` — Changes to the documentation only[^2]

### Release Notes

- shape with tldraw styles example

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Taha 2024-03-02 16:42:43 +00:00 committed by GitHub
parent 338501d656
commit 66a8b0a4a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 316 additions and 346 deletions

View file

@ -1,141 +0,0 @@
import {
BaseBoxShapeTool,
BaseBoxShapeUtil,
DefaultColorStyle,
HTMLContainer,
StyleProp,
T,
TLBaseShape,
TLDefaultColorStyle,
getDefaultColorTheme,
} from 'tldraw'
// There's a guide at the bottom of this file!
// [1]
export const MyFilterStyle = StyleProp.defineEnum('myApp:filter', {
defaultValue: 'none',
values: ['none', 'invert', 'grayscale', 'blur'],
})
export type MyFilterStyle = T.TypeOf<typeof MyFilterStyle>
// [2]
export type CardShape = TLBaseShape<
'card',
{
w: number
h: number
color: TLDefaultColorStyle
filter: MyFilterStyle
}
>
//[3]
export class CardShapeUtil extends BaseBoxShapeUtil<CardShape> {
static override type = 'card' as const
//[a]
static override props = {
w: T.number,
h: T.number,
color: DefaultColorStyle,
filter: MyFilterStyle,
}
override isAspectRatioLocked = (_shape: CardShape) => false
override canResize = (_shape: CardShape) => true
override canBind = (_shape: CardShape) => true
override getDefaultProps(): CardShape['props'] {
return {
w: 300,
h: 300,
color: 'black',
filter: 'none',
}
}
// [b]
component(shape: CardShape) {
const bounds = this.editor.getShapeGeometry(shape).bounds
const theme = getDefaultColorTheme({ isDarkMode: this.editor.user.getIsDarkMode() })
return (
<HTMLContainer
id={shape.id}
style={{
border: `4px solid ${theme[shape.props.color].solid}`,
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'all',
filter: this.filterStyleToCss(shape.props.filter),
backgroundColor: theme[shape.props.color].semi,
}}
>
🍇🫐🍏🍋🍊🍒 {bounds.w.toFixed()}x{bounds.h.toFixed()} 🍒🍊🍋🍏🫐🍇
</HTMLContainer>
)
}
indicator(shape: CardShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
//[c]
filterStyleToCss(filter: MyFilterStyle) {
if (filter === 'invert') return 'invert(100%)'
if (filter === 'grayscale') return 'grayscale(100%)'
if (filter === 'blur') return 'blur(10px)'
return 'none'
}
}
// [4]
export class CardShapeTool extends BaseBoxShapeTool {
static override id = 'card'
static override initial = 'idle'
override shapeType = 'card'
}
/*
Introduction:
This file contains the logic for how the custom shape and style work. This guide will
mostly focus on the custom style features, for a more in-depth look at creating a custom
shape check out the custom shapes/tools example. For a closer look at creating more
custom tool interactions, checkout out the screenshot example.
[1]
This is where we define our custom style. We use the `StyleProp.defineEnum` method to
define an enum style. This will create a style that can be one of the values we pass
in to the `values` property. We also pass in a `defaultValue` property, this will be
the default value for the style. It's important that the StyleProp is unique, so we
reccomend prefixing it with your app name.
[2]
Defining our shape's type. Here we import a type for color, the default tldraw style,
and also use our own type for our custom style: filter.
[3]
This is our util, where we define the logic for our shape, it's geometry, resize behaviour
and render method.
- [a] The props for our shape. We can import a validator for the default color style
and use our own for our custom style.
- [b] The render method for our custom shape, this is where we tell the browser how
how to render our different styles using the style attribute. Using the
getDefaultColorTheme function along with the getIsDarkMode method gives us access
to the tldraw default colorsand ensures they stay up to date when switching between
light and dark mode.
We apply our filter style using a method we've defined on the shape util called
filterStyleToCss
- [c] This is our method for converting the style the user selected into CSS
Check out FilterStyleUi.tsx to see how we render the UI for our custom style.
[4]
This is our tool, it's very simple, we just define the id and initial state. Extending the
BaseBoxShapeTool gives us a lot of the default behaviour for free.
*/

View file

@ -1,55 +0,0 @@
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import { CardShapeTool, CardShapeUtil } from './CardShape'
import { FilterStyleUi } from './FilterStyleUi'
import { components, uiOverrides } from './ui-overrides'
// There's a guide at the bottom of this file!
// [1]
const customShapeUtils = [CardShapeUtil]
const customTools = [CardShapeTool]
// [2]
export default function CustomStylesExample() {
return (
<div className="tldraw__editor">
<Tldraw
persistenceKey="custom-styles-example"
shapeUtils={customShapeUtils}
tools={customTools}
overrides={uiOverrides}
components={components}
>
<FilterStyleUi />
</Tldraw>
</div>
)
}
/*
Introduction:
This example shows how to create your own custom styles to use with your shapes.
It also shows how to create a very simple ui for your styles. In this example, we
create a custom style for a card shape that lets the user apply a filter to blur,
invert or grayscale the card.
[1]
We define an array to hold the custom shape util and custom tool. It's important to
do this outside of any React component so that this array doesn't get redefined on
every render. We'll pass this into the Tldraw component's `shapeUtils` and `tools`
props.
Check out CardShape.tsx to see how we define the shape util, tool and the custom
style.
[2]
We pass the custom shape util and tool into the Tldraw component's `shapeUtils` and
`tools` props. We also pass in the custom ui overrides, this will make an icon for
our shape/tool appear on the toolbar (see ui-overrides.ts). And render our
FilterStyleUi component inside the Tldraw component.
Check out FilterStyleUi.tsx to see how we render this Ui only when the user has
selected a shape that uses the custom style.
*/

View file

@ -1,80 +0,0 @@
import { track, useEditor } from 'tldraw'
import { MyFilterStyle } from './CardShape'
// There's a guide at the bottom of this file!
//[1]
export const FilterStyleUi = track(function FilterStyleUi() {
const editor = useEditor()
//[2]
const filterStyle = editor.getSharedStyles().get(MyFilterStyle)
if (!filterStyle) return null
return (
<div
className="tlui-style-panel__wrapper"
style={{
position: 'absolute',
zIndex: 300,
top: 50,
left: 8,
padding: 15,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
filter:{' '}
<select
value={filterStyle.type === 'mixed' ? 'mixed' : filterStyle.value}
// [3]
onChange={(e) => {
editor.batch(() => {
if (editor.isIn('select')) {
editor.setStyleForSelectedShapes(
MyFilterStyle,
MyFilterStyle.validate(e.target.value)
)
}
editor.setStyleForNextShapes(MyFilterStyle, MyFilterStyle.validate(e.target.value))
})
}}
>
<option value="mixed" disabled>
Mixed
</option>
<option value="none">None</option>
<option value="invert">Invert</option>
<option value="grayscale">Grayscale</option>
<option value="blur">Blur</option>
</select>
</div>
)
})
/*
Introduction:
This is a an example of how to create a custom ui for your custom style. We want
to render the UI when the user has selected our card tool, or when they've selected a card
shape. Here, we've chosen a drop-down to let the user select the filter type, and we render
it in the top left corner of the editor. You could render your UI anywhere you want. Check
out the zones example to see how to render your UI in a particular zone, or the custom-ui
example if you want to redo the entire ui.
[1]
We use the `track` function to wrap our component. This makes our component reactive- it will
re-render whenever the signals it is tracking change. Check out the signia docs for more:
https://signia.tldraw.dev/docs/API/signia_react/functions/track
[2]
Here we check if the user has selected a shape that uses our custom style, or if they've
selected a tool associated with our custom style. If they haven't, we return null and don't
render anything.
[3]
Here we add an event handler for when the user changes the value of the dropdown. We use the
`batch` method to batch our changes into a single undoable action. We check if the user has
selected any shapes, and if they have, we set the style for those shapes. We also set the style
for any shapes the user creates next.
*/

View file

@ -1,12 +0,0 @@
---
title: Custom styles
component: ./CustomStylesExample.tsx
category: shapes/tools
priority: 2
---
Styles are special properties that can be set on many shapes at once.
---
Create several shapes with the ⚫️ tool, then select them and try changing their filter style.

View file

@ -1,58 +0,0 @@
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
toolbarItem,
useTools,
} from 'tldraw'
// There's a guide at the bottom of this file!
export const uiOverrides: TLUiOverrides = {
tools(editor, tools) {
tools.card = {
id: 'card',
icon: 'color',
label: 'Card' as any,
kbd: 'c',
onSelect: () => {
editor.setCurrentTool('card')
},
}
return tools
},
toolbar(_app, toolbar, { tools }) {
toolbar.splice(4, 0, toolbarItem(tools.card))
return toolbar
},
}
export const components: TLComponents = {
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
return (
<DefaultKeyboardShortcutsDialog {...props}>
<DefaultKeyboardShortcutsDialogContent />
{/* Ideally, we'd interleave this into the tools section */}
<TldrawUiMenuItem {...tools['card']} />
</DefaultKeyboardShortcutsDialog>
)
},
}
/*
Here we add our custom tool to the toolbar. We do this by providing a custom
toolbar override to the Tldraw component. This override is a function that takes
the current editor, the default toolbar items, and the default tools. It returns
the new toolbar items. We use the toolbarItem helper to create a new toolbar item
for our custom tool. We then splice it into the toolbar items array at the 4th index.
This puts it after the eraser tool. We'll pass our overrides object into the
Tldraw component's `overrides` prop.
For this example the icon we use is the same as the color icon. For an example
of how to add a custom icon, see the screenshot or speech-bubble examples.
*/

View file

@ -0,0 +1,12 @@
---
title: Using custom styles
component: ./ShapeWithCustomStylesExample.tsx
category: shapes/tools
priority: 2
---
Using the custom styles API with your custom shapes.
---
In the Using tldraw styles example, we showed how to use tldraw's default styles in your own shapes. This example shows how to create your own styles and use them in your own shapes.

View file

@ -0,0 +1,168 @@
import {
BaseBoxShapeUtil,
DefaultStylePanel,
DefaultStylePanelContent,
HTMLContainer,
StyleProp,
T,
TLBaseShape,
Tldraw,
useEditor,
useRelevantStyles,
} from 'tldraw'
import 'tldraw/tldraw.css'
// [1]
const myRatingStyle = StyleProp.defineEnum('example:rating', {
defaultValue: 1,
values: [1, 2, 3, 4, 5],
})
// [2]
type MyRatingStyle = T.TypeOf<typeof myRatingStyle>
type IMyShape = TLBaseShape<
'myshape',
{
w: number
h: number
rating: MyRatingStyle
}
>
class MyShapeUtil extends BaseBoxShapeUtil<IMyShape> {
static override type = 'myshape' as const
// [3]
static override props = {
w: T.number,
h: T.number,
rating: myRatingStyle,
}
getDefaultProps(): IMyShape['props'] {
return {
w: 300,
h: 300,
rating: 4, // [4]
}
}
component(shape: IMyShape) {
// [5]
const stars = ['☆', '☆', '☆', '☆', '☆']
for (let i = 0; i < shape.props.rating; i++) {
stars[i] = '★'
}
return (
<HTMLContainer
id={shape.id}
style={{ backgroundColor: 'var(--color-low-border)', overflow: 'hidden' }}
>
{stars}
</HTMLContainer>
)
}
indicator(shape: IMyShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
// [6]
function CustomStylePanel() {
const editor = useEditor()
const styles = useRelevantStyles()
if (!styles) return null
const rating = styles.get(myRatingStyle)
return (
<DefaultStylePanel>
<DefaultStylePanelContent styles={styles} />
{rating !== undefined && (
<div>
<select
style={{ width: '100%', padding: 4 }}
value={rating.type === 'mixed' ? '' : rating.value}
onChange={(e) => {
editor.mark('changing rating')
const value = myRatingStyle.validate(+e.currentTarget.value)
editor.setStyleForSelectedShapes(myRatingStyle, value)
}}
>
{rating.type === 'mixed' ? <option value="">Mixed</option> : null}
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
<option value={5}>5</option>
</select>
</div>
)}
</DefaultStylePanel>
)
}
export default function ShapeWithTldrawStylesExample() {
return (
<div className="tldraw__editor">
<Tldraw
// [7]
shapeUtils={[MyShapeUtil]}
components={{
StylePanel: CustomStylePanel,
}}
onMount={(editor) => {
editor.createShape({ type: 'myshape', x: 100, y: 100 })
editor.selectAll()
editor.createShape({ type: 'myshape', x: 450, y: 250, props: { rating: 5 } })
}}
/>
</div>
)
}
/*
This file shows a custom shape that uses a user-created styles
For more on custom shapes, see our Custom Shape example.
[1]
In this example, our custom shape will use a new style called "rating".
We'll need to create the style so that we can pass it to the shape's props.
[2]
Here's we extract the type of the style's values. We use it below when
we define the shape's props.
[3]
We pass the style to the shape's props.
[4]
Since this property uses one a style, whatever value we put here in the
shape's default props will be overwritten by the editor's current value
for that style, which will either be the default value or the most
recent value the user has set. This is special behavior just for styles.
[5]
We can use the styles in the component just like any other prop.
[6]
Here we create a custom style panel that includes the default style panel
and also a dropdown for the rating style. We use the useRelevantStyles hook
to get the styles of the user's selected shapes, and the useEditor hook to
set the style for the selected shapes. For more on customizing the style
panel, see our custom style panel example.
[7]
We pass the custom shape util and custom components in as props.
[8]
And for this example, we create two shapes: the first does not specify a
rating, so it will use the editor's current style value (in this example,
this will be the style's default value of 4). The second specifies a
rating of 5, so it will use that value.
*/

View file

@ -0,0 +1,16 @@
---
title: Using tldraw styles
component: ./ShapeWithTldrawStylesExample.tsx
category: shapes/tools
priority: 1
---
Using the tldraw style panel with your custom shapes
---
The default tldraw UI will displaye UI for the styles of your selection or your current tool. For example, when you have two shapes selected that both have the tldraw's "size" style, the size selector will be displayed. If all of your selected shapes have the same value for this style, that value will be shown as selected in the panel. If they have different values, the panel will show the value as "mixed".
You can use tldraw's Styles API to create your own styles that behave in the same way, though you'll also need to create a custom UI for your style.
Alternatively, you can use tldraw's default styles in your own shapes. This example shows how to do that.

View file

@ -0,0 +1,120 @@
import {
BaseBoxShapeUtil,
DefaultColorStyle,
DefaultSizeStyle,
HTMLContainer,
T,
TLBaseShape,
TLDefaultColorStyle,
TLDefaultSizeStyle,
Tldraw,
} from 'tldraw'
import { useDefaultColorTheme } from 'tldraw/src/lib/shapes/shared/ShapeFill'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
const FONT_SIZES: Record<TLDefaultSizeStyle, number> = {
s: 14,
m: 25,
l: 38,
xl: 48,
}
type IMyShape = TLBaseShape<
'myshape',
{
w: number
h: number
// [1]
size: TLDefaultSizeStyle
color: TLDefaultColorStyle
}
>
class MyShapeUtil extends BaseBoxShapeUtil<IMyShape> {
static override type = 'myshape' as const
// [2]
static override props = {
w: T.number,
h: T.number,
size: DefaultSizeStyle,
color: DefaultColorStyle,
}
getDefaultProps(): IMyShape['props'] {
return {
w: 300,
h: 300,
size: 'm',
color: 'black',
}
}
component(shape: IMyShape) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme()
return (
<HTMLContainer
id={shape.id}
style={{ backgroundColor: 'var(--color-low-border)', overflow: 'hidden' }}
>
<div
style={{
// [3]
fontSize: FONT_SIZES[shape.props.size],
color: theme[shape.props.color].solid,
}}
>
Select the shape and use the style panel to change the font size and color
</div>
</HTMLContainer>
)
}
indicator(shape: IMyShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
const customShapeUtils = [MyShapeUtil]
export default function ShapeWithTldrawStylesExample() {
return (
<div className="tldraw__editor">
<Tldraw
shapeUtils={customShapeUtils}
onMount={(editor) => {
editor.createShape({ type: 'myshape', x: 100, y: 100 })
}}
/>
</div>
)
}
/*
This file shows a custom shape that uses tldraw's default styles.
For more on custom shapes, see our Custom Shape example.
[1]
In this example, our custom shape will use the size and color styles from the
default styles. When typing a custom shape, you can use our types for
these styles.
[2]
For the shape's props, we'll pass the DefaultSizeStyle and DefaultColorStyle
styles for the two properties, size and color. There's nothing special about
these styles except that the editor will notice when two shapes are selected
that share the same style. (You can use the useRelevantStyles hook to get the
styles of the user's selected shapes.)
[3]
Here in the component, we'll use the styles to change the way that our shape
appears. The style values themselves are just strings, like 'xl' or 'black',
so it's up to you to decide how to use them. In this example, we're using the
size to set the text's font-size property, and also using the default theme
(via the useDefaultColorTheme hook) to get the color for the text.
*/