examples: clean up Canvas/Store events and make UiEvents have code snippets (#2770)

Fixes https://linear.app/tldraw/issue/TLD-2059

<img width="1220" alt="Screenshot 2024-02-07 at 12 38 09"
src="https://github.com/tldraw/tldraw/assets/469604/15dc4298-670d-489b-8bee-810d34a0fbae">


### Change Type

- [x] `internal` — Any other changes that don't affect the published
package[^2]

### Release Notes

- Examples: add an interactive example that shows code snippets for the
SDK.
This commit is contained in:
Mime Čuvalo 2024-02-07 16:51:04 +00:00 committed by GitHub
parent e2a03abf5c
commit f16e597761
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 273 additions and 22 deletions

View file

@ -40,12 +40,14 @@
"@vercel/analytics": "^1.1.1",
"classnames": "^2.3.2",
"lazyrepo": "0.0.0-alpha.27",
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.17.0",
"vite": "^5.0.0"
},
"devDependencies": {
"@types/lodash": "^4.14.188",
"@vitejs/plugin-react": "^4.2.0",
"dotenv": "^16.3.1",
"remark": "^15.0.1",

View file

@ -64,6 +64,11 @@ export function ExamplePage({
</a>
</div>
</div>
<div className="example__sidebar__header-links">
<a className="example__sidebar__header-link" href="/develop">
Develop
</a>
</div>
<ul className="example__sidebar__categories scroll-light">
{categories.map((currentCategory) => (
<li key={currentCategory} className="example__sidebar__category">

View file

@ -5,10 +5,23 @@ import { useCallback, useState } from 'react'
// There's a guide at the bottom of this file!
export default function CanvasEventsExample() {
const [events, setEvents] = useState<string[]>([])
const [events, setEvents] = useState<any[]>([])
const handleEvent = useCallback((data: TLEventInfo) => {
setEvents((events) => [JSON.stringify(data, null, '\t'), ...events.slice(0, 100)])
setEvents((events) => {
const newEvents = events.slice(0, 100)
if (
newEvents[newEvents.length - 1] &&
newEvents[newEvents.length - 1].type === 'pointer' &&
data.type === 'pointer' &&
data.target === 'canvas'
) {
newEvents[newEvents.length - 1] = data
} else {
newEvents.unshift(data)
}
return newEvents
})
}, [])
return (
@ -36,9 +49,7 @@ export default function CanvasEventsExample() {
whiteSpace: 'pre-wrap',
}}
>
{events.map((t, i) => (
<div key={i}>{t}</div>
))}
<div>{JSON.stringify(events, undefined, 2)}</div>
</div>
</div>
)

View file

@ -1,5 +1,6 @@
import { Editor, TLEventMapHandler, Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import _ from 'lodash'
import { useCallback, useEffect, useState } from 'react'
// There's a guide at the bottom of this file!
@ -17,7 +18,7 @@ export default function StoreEventsExample() {
if (!editor) return
function logChangeEvent(eventName: string) {
setStoreEvents((events) => [eventName, ...events])
setStoreEvents((events) => [...events, eventName])
}
//[1]
@ -25,7 +26,7 @@ export default function StoreEventsExample() {
// Added
for (const record of Object.values(change.changes.added)) {
if (record.typeName === 'shape') {
logChangeEvent(`created shape (${record.type})`)
logChangeEvent(`created shape (${record.type})\n`)
}
}
@ -37,13 +38,29 @@ export default function StoreEventsExample() {
from.currentPageId !== to.currentPageId
) {
logChangeEvent(`changed page (${from.currentPageId}, ${to.currentPageId})`)
} else if (from.id.startsWith('shape') && to.id.startsWith('shape')) {
let diff = _.reduce(
from,
(result: any[], value, key: string) =>
_.isEqual(value, (to as any)[key]) ? result : result.concat([key, value]),
[]
)
if (diff?.[0] === 'props') {
diff = _.reduce(
(from as any).props,
(result: any[], value, key) =>
_.isEqual(value, (to as any).props[key]) ? result : result.concat([key, value]),
[]
)
}
logChangeEvent(`updated shape (${JSON.stringify(diff)})\n`)
}
}
// Removed
for (const record of Object.values(change.changes.removed)) {
if (record.typeName === 'shape') {
logChangeEvent(`deleted shape (${record.type})`)
logChangeEvent(`deleted shape (${record.type})\n`)
}
}
}
@ -76,9 +93,7 @@ export default function StoreEventsExample() {
overflow: 'auto',
}}
>
{storeEvents.map((t, i) => (
<div key={i}>{t}</div>
))}
<pre>{storeEvents}</pre>
</div>
</div>
)

View file

@ -1,14 +1,19 @@
import { TLUiEventHandler, Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import { useCallback, useState } from 'react'
import { Fragment, useCallback, useState } from 'react'
import { getCodeSnippet } from './codeSnippets'
// There's a guide at the bottom of this file!
export default function UiEventsExample() {
const [uiEvents, setUiEvents] = useState<string[]>([])
const handleUiEvent = useCallback<TLUiEventHandler>((name, data) => {
setUiEvents((events) => [`${name} ${JSON.stringify(data)}`, ...events])
const handleUiEvent = useCallback<TLUiEventHandler>((name, data: any) => {
const codeSnippet = getCodeSnippet(name, data)
setUiEvents((events) => [
`event: ${name} ${JSON.stringify(data)}${codeSnippet && `\ncode: ${codeSnippet}`}`,
...events,
])
}, [])
return (
@ -32,7 +37,11 @@ export default function UiEventsExample() {
}}
>
{uiEvents.map((t, i) => (
<div key={i}>{t}</div>
<Fragment key={i}>
<pre style={{ borderBottom: '1px solid #000', marginBottom: 0, paddingBottom: '12px' }}>
{t}
</pre>
</Fragment>
))}
</div>
</div>
@ -45,6 +54,9 @@ grouping shapes, zooming etc. Events are included even if they are triggered by
However, interactions with the style panel are not included. For a full list of events and sources,
check out the TLUiEventSource and TLUiEventMap types.
It also shows the relevant code snippet for each event. This is useful for debugging and learning
the tldraw SDK.
We can pass a handler function to the onUiEvent prop of the Tldraw component. This handler function
will be called with the name of the event and the data associated with the event. We're going to
display these events in a list on the right side of the screen.

View file

@ -0,0 +1,161 @@
const STYLE_EVENT = {
'tldraw:color': 'DefaultColorStyle',
'tldraw:dash': 'DefaultDashStyle',
'tldraw:fill': 'DefaultFillStyle',
'tldraw:font': 'DefaultFontStyle',
'tldraw:horizontalAlign': 'DefaultHorizontalAlignStyle',
'tldraw:size': 'DefaultSizeStyle',
'tldraw:verticalAlign': 'DefaultVerticalAlignStyle',
'tldraw:geo': 'GeoShapeGeoStyle',
}
const REORDER_EVENT = {
toFront: 'bringToFront',
forward: 'bringForward',
backward: 'sendBackward',
toBack: 'sendToBack',
}
const SHAPES_META_EVENT = {
'group-shapes': 'groupShapes',
'ungroup-shapes': 'ungroupShapes',
'delete-shapes': 'deleteShapes',
}
const SHAPES_EVENT = {
'distribute-shapes': 'distributeShapes',
'align-shapes': 'alignShapes',
'stretch-shapes': 'stretchShapes',
'flip-shapes': 'flipShapes',
}
const USER_PREFS_EVENT = {
'toggle-snap-mode': 'isSnapMode',
'toggle-dark-mode': 'isDarkMode',
'toggle-reduce-motion': 'animationSpeed',
'toggle-edge-scrolling': 'edgeScrollSpeed',
}
const PREFS_EVENT = {
'toggle-transparent': 'exportBackground',
'toggle-tool-lock': 'isToolLocked',
'toggle-focus-mode': 'isFocusMode',
'toggle-grid-mode': 'isGridMode',
'toggle-debug-mode': 'isDebugMode',
}
const ZOOM_EVENT = {
'zoom-in': 'zoomIn',
'zoom-out': 'zoomOut',
'reset-zoom': 'resetZoom',
'zoom-to-fit': 'zoomToFit',
'zoom-to-selection': 'zoomToSelection',
'zoom-to-content': 'zoomToContent',
}
export function getCodeSnippet(name: string, data: any) {
let codeSnippet = ''
if (name === 'set-style') {
if (data.id === 'opacity') {
codeSnippet = `editor.setOpacityForNextShapes(${data.value});`
} else {
codeSnippet = `editor.setStyleForNextShapes(${
STYLE_EVENT[data.id as keyof typeof STYLE_EVENT] ?? '?'
}, '${data.value}');`
}
} else if (['rotate-ccw', 'rotate-cw'].includes(name)) {
codeSnippet = 'editor.rotateShapesBy(editor.getSelectedShapeIds(), <number>)'
} else if (name === 'edit-link') {
codeSnippet =
'editor.updateShapes([{ id: editor.getOnlySelectedShape().id, type: editor.getOnlySelectedShape().type, props: { url: <url> }, }, ])'
} else if (name.startsWith('export-as')) {
codeSnippet = `exportAs(editor.getSelectedShapeIds(), '${data.format}')`
} else if (name.startsWith('copy-as')) {
codeSnippet = `copyAs(editor.getSelectedShapeIds(), '${data.format}')`
} else if (name === 'select-all-shapes') {
codeSnippet = `editor.selectAll()`
} else if (name === 'select-none-shapes') {
codeSnippet = `editor.selectNone()`
} else if (name === 'reorder-shapes') {
codeSnippet = `editor.${
REORDER_EVENT[data.operation as keyof typeof REORDER_EVENT] ?? '?'
}(editor.getSelectedShapeIds())`
} else if (['group-shapes', 'ungroup-shapes', 'delete-shapes'].includes(name)) {
codeSnippet = `editor.${
SHAPES_META_EVENT[name as keyof typeof SHAPES_META_EVENT] ?? '?'
}(editor.getSelectedShapeIds())`
} else if (name === 'stack-shapes') {
codeSnippet = `editor.stackShapes(editor.getSelectedShapeIds(), '${data.operation}', 16)`
} else if (name === 'pack-shapes') {
codeSnippet = `editor.packShapes(editor.getSelectedShapeIds(), 16)`
} else if (name === 'duplicate-shapes') {
codeSnippet = `editor.duplicateShapes(editor.getSelectedShapeIds(), {x: <value>, y: <value>})`
} else if (name.endsWith('-shapes')) {
codeSnippet = `editor.${
SHAPES_EVENT[name as keyof typeof SHAPES_EVENT] ?? '?'
}(editor.getSelectedShapeIds(), '${data.operation}')`
} else if (name === 'select-tool') {
if (data.id === 'media') {
codeSnippet = 'insertMedia()'
} else if (data.id.startsWith('geo-')) {
codeSnippet = `\n editor.updateInstanceState({
stylesForNextShape: {
...editor.getInstanceState().stylesForNextShape,
[GeoShapeGeoStyle.id]: '${data.id.replace('geo-', '')}',
},
}, { ephemeral: true });
editor.setCurrentTool('${data.id}')`
} else {
codeSnippet = `editor.setCurrentTool('${data.id}')`
}
} else if (name === 'print') {
codeSnippet = 'printSelectionOrPages()'
} else if (name === 'unlock-all') {
codeSnippet = `\n const updates = [] as TLShapePartial[]
for (const shape of editor.getCurrentPageShapes()) {
if (shape.isLocked) {
updates.push({ id: shape.id, type: shape.type, isLocked: false })
}
}
if (updates.length > 0) {
editor.updateShapes(updates)
}`
} else if (['undo', 'redo'].includes(name)) {
codeSnippet = `editor.${name}()`
} else if (['cut', 'copy'].includes(name)) {
codeSnippet = `\n const { ${name} } = useMenuClipboardEvents();\n ${name}()`
} else if (name === 'paste') {
codeSnippet = `\n const { paste } = useMenuClipboardEvents();\n navigator.clipboard?.read().then((clipboardItems) => {\n paste(clipboardItems)\n })`
} else if (name === 'stop-following') {
codeSnippet = `editor.stopFollowingUser()`
} else if (name === 'exit-pen-mode') {
codeSnippet = `editor.updateInstanceState({ isPenMode: false })`
} else if (name === 'remove-frame') {
codeSnippet = `removeFrame(editor, editor.getSelectedShapes().map((shape) => shape.id))`
} else if (name === 'fit-frame-to-content') {
codeSnippet = `fitFrameToContent(editor, editor.getOnlySelectedShape().id)`
} else if (name.startsWith('zoom-') || name === 'reset-zoom') {
if (name === 'zoom-to-content') {
codeSnippet = 'editor.zoomToContent()'
} else {
codeSnippet = `editor.${ZOOM_EVENT[name as keyof typeof ZOOM_EVENT]}(${
name !== 'zoom-to-fit' && name !== 'zoom-to-selection'
? 'editor.getViewportScreenCenter(), '
: ''
}{ duration: 320 })`
}
} else if (name.startsWith('toggle-')) {
if (name === 'toggle-lock') {
codeSnippet = `editor.toggleLock(editor.getSelectedShapeIds())`
} else {
const userPrefName = USER_PREFS_EVENT[name as keyof typeof USER_PREFS_EVENT]
const prefName = PREFS_EVENT[name as keyof typeof PREFS_EVENT]
codeSnippet = userPrefName
? `editor.user.updateUserPreferences({ ${userPrefName}: <value> })`
: `editor.updateInstanceState({ ${prefName}: !editor.getInstanceState().${prefName} })`
}
}
return codeSnippet
}

View file

@ -213,8 +213,9 @@ li.examples__sidebar__item {
align-items: stretch;
}
/* ----------------- Footer Buttons ----------------- */
/* ----------------- Header/Footer Buttons ----------------- */
.example__sidebar__header-links,
.example__sidebar__footer-links {
display: flex;
flex-direction: column;
@ -225,6 +226,7 @@ li.examples__sidebar__item {
border-top: 1px solid #e8e8e8;
}
.example__sidebar__header-link,
.example__sidebar__footer-link {
padding: 8px 8px;
border-radius: 6px;
@ -241,7 +243,7 @@ li.examples__sidebar__item {
0px 1px 3px rgba(0, 0, 0, 0.04);
}
.example__sidebar__footer-link > a {
a.example__sidebar__header-link {
color: white;
}

View file

@ -1533,6 +1533,11 @@ export interface TLUiEventMap {
id: string;
};
// (undocumented)
'set-style': {
id: string;
value: number | string;
};
// (undocumented)
'stack-shapes': {
operation: 'horizontal' | 'vertical';
};
@ -1597,7 +1602,7 @@ export interface TLUiEventMap {
}
// @public (undocumented)
export type TLUiEventSource = 'actions-menu' | 'context-menu' | 'debug-panel' | 'dialog' | 'export-menu' | 'help-menu' | 'helper-buttons' | 'kbd' | 'menu' | 'navigation-zone' | 'page-menu' | 'people-menu' | 'quick-actions' | 'share-menu' | 'toolbar' | 'unknown' | 'zoom-menu';
export type TLUiEventSource = 'actions-menu' | 'context-menu' | 'debug-panel' | 'dialog' | 'export-menu' | 'help-menu' | 'helper-buttons' | 'kbd' | 'menu' | 'navigation-zone' | 'page-menu' | 'people-menu' | 'quick-actions' | 'share-menu' | 'style-panel' | 'toolbar' | 'unknown' | 'zoom-menu';
// @public (undocumented)
export type TLUiHelpMenuSchemaContextType = TLUiMenuSchema;

View file

@ -17370,6 +17370,33 @@
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tldraw!TLUiEventMap#\"set-style\":member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "'set-style': "
},
{
"kind": "Content",
"text": "{\n id: string;\n value: number | string;\n }"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "\"set-style\"",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tldraw!TLUiEventMap#\"stack-shapes\":member",
@ -18167,7 +18194,7 @@
},
{
"kind": "Content",
"text": "'actions-menu' | 'context-menu' | 'debug-panel' | 'dialog' | 'export-menu' | 'help-menu' | 'helper-buttons' | 'kbd' | 'menu' | 'navigation-zone' | 'page-menu' | 'people-menu' | 'quick-actions' | 'share-menu' | 'toolbar' | 'unknown' | 'zoom-menu'"
"text": "'actions-menu' | 'context-menu' | 'debug-panel' | 'dialog' | 'export-menu' | 'help-menu' | 'helper-buttons' | 'kbd' | 'menu' | 'navigation-zone' | 'page-menu' | 'people-menu' | 'quick-actions' | 'share-menu' | 'style-panel' | 'toolbar' | 'unknown' | 'zoom-menu'"
},
{
"kind": "Content",

View file

@ -2,7 +2,7 @@ import { track, useEditor } from '@tldraw/editor'
import { useActions } from '../hooks/useActions'
import { Button } from './primitives/Button'
export const StopFollowing = track(function ExitPenMode() {
export const StopFollowing = track(function StopFollowing() {
const editor = useEditor()
const actions = useActions()

View file

@ -17,6 +17,7 @@ import {
useEditor,
} from '@tldraw/editor'
import React, { useCallback } from 'react'
import { useUiEvents } from '../../hooks/useEventsProvider'
import { useRelevantStyles } from '../../hooks/useRevelantStyles'
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
import { Button } from '../primitives/Button'
@ -73,6 +74,7 @@ export const StylePanel = function StylePanel({ isMobile }: StylePanelProps) {
function useStyleChangeCallback() {
const editor = useEditor()
const trackEvent = useUiEvents()
return React.useMemo(() => {
return function handleStyleChange<T>(style: StyleProp<T>, value: T, squashing: boolean) {
@ -83,8 +85,10 @@ function useStyleChangeCallback() {
editor.setStyleForNextShapes(style, value, { squashing })
editor.updateInstanceState({ isChangingStyle: true })
})
trackEvent('set-style', { source: 'style-panel', id: style.id, value: value as string })
}
}, [editor])
}, [editor, trackEvent])
}
const tldrawSupportedOpacities = [0.1, 0.25, 0.5, 0.75, 1] as const
@ -97,6 +101,7 @@ function CommonStylePickerSet({
opacity: SharedStyle<number>
}) {
const editor = useEditor()
const trackEvent = useUiEvents()
const msg = useTranslation()
const handleValueChange = useStyleChangeCallback()
@ -111,8 +116,10 @@ function CommonStylePickerSet({
editor.setOpacityForNextShapes(item, { ephemeral })
editor.updateInstanceState({ isChangingStyle: true })
})
trackEvent('set-style', { source: 'style-panel', id: 'opacity', value })
},
[editor]
[editor, trackEvent]
)
const color = styles.get(DefaultColorStyle)

View file

@ -18,6 +18,7 @@ export type TLUiEventSource =
| 'dialog'
| 'help-menu'
| 'helper-buttons'
| 'style-panel'
| 'unknown'
/** @public */
@ -72,6 +73,7 @@ export interface TLUiEventMap {
copy: null
paste: null
cut: null
'set-style': { id: string; value: string | number }
'toggle-transparent': null
'toggle-snap-mode': null
'toggle-tool-lock': null

View file

@ -13565,11 +13565,13 @@ __metadata:
"@radix-ui/react-alert-dialog": "npm:^1.0.5"
"@tldraw/assets": "workspace:*"
"@tldraw/tldraw": "workspace:*"
"@types/lodash": "npm:^4.14.188"
"@vercel/analytics": "npm:^1.1.1"
"@vitejs/plugin-react": "npm:^4.2.0"
classnames: "npm:^2.3.2"
dotenv: "npm:^16.3.1"
lazyrepo: "npm:0.0.0-alpha.27"
lodash: "npm:^4.17.21"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-router-dom: "npm:^6.17.0"