Add slides example (#3467)

This PR adds a slides use-case example.


https://github.com/tldraw/tldraw/assets/15892272/89fdcb56-167d-4046-bfec-f93b18a83da2


### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [x] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [x] `dunno` — I don't know


### Test Plan

1. Try out the slideshow example! (scroll to the bottom to see it).

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Docs: Added a slideshow example

---------

Co-authored-by: Mitja Bezenšek <mitja.bezensek@gmail.com>
This commit is contained in:
Lu Wilson 2024-04-17 10:27:37 +01:00 committed by GitHub
parent 7104515c9c
commit 413838cd3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 351 additions and 4 deletions

View file

@ -0,0 +1,12 @@
---
title: Slideshow
component: ./SlidesExample.tsx
category: use-cases
priority: 1
---
Slideshow example.
---
Make slides for a presentation.

View file

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from 'tldraw'
export class SlideShapeTool extends BaseBoxShapeTool {
static override id = 'slide'
static override initial = 'idle'
override shapeType = 'slide'
}

View file

@ -0,0 +1,127 @@
import { useCallback } from 'react'
import {
Geometry2d,
Rectangle2d,
SVGContainer,
ShapeProps,
ShapeUtil,
T,
TLBaseShape,
TLOnResizeHandler,
resizeBox,
useValue,
} from 'tldraw'
import { getPerfectDashProps } from 'tldraw/src/lib/shapes/shared/getPerfectDashProps'
import { moveToSlide, useSlides } from './useSlides'
export type SlideShape = TLBaseShape<
'slide',
{
w: number
h: number
}
>
export class SlideShapeUtil extends ShapeUtil<SlideShape> {
static override type = 'slide' as const
static override props: ShapeProps<SlideShape> = {
w: T.number,
h: T.number,
}
override canBind = () => false
override hideRotateHandle = () => true
getDefaultProps(): SlideShape['props'] {
return {
w: 720,
h: 480,
}
}
getGeometry(shape: SlideShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: false,
})
}
override onRotate = (initial: SlideShape) => initial
override onResize: TLOnResizeHandler<SlideShape> = (shape, info) => {
return resizeBox(shape, info)
}
override onDoubleClick = (shape: SlideShape) => {
moveToSlide(this.editor, shape)
this.editor.selectNone()
}
override onDoubleClickEdge = (shape: SlideShape) => {
moveToSlide(this.editor, shape)
this.editor.selectNone()
}
component(shape: SlideShape) {
const bounds = this.editor.getShapeGeometry(shape).bounds
// eslint-disable-next-line react-hooks/rules-of-hooks
const zoomLevel = useValue('zoom level', () => this.editor.getZoomLevel(), [this.editor])
// eslint-disable-next-line react-hooks/rules-of-hooks
const slides = useSlides()
const index = slides.findIndex((s) => s.id === shape.id)
// eslint-disable-next-line react-hooks/rules-of-hooks
const handleLabelPointerDown = useCallback(() => this.editor.select(shape.id), [shape.id])
if (!bounds) return null
return (
<>
<div onPointerDown={handleLabelPointerDown} className="slide-shape-label">
{`Slide ${index + 1}`}
</div>
<SVGContainer>
<g
style={{
stroke: 'var(--color-text)',
strokeWidth: 'calc(1px * var(--tl-scale))',
opacity: 0.25,
}}
pointerEvents="none"
strokeLinecap="round"
strokeLinejoin="round"
>
{bounds.sides.map((side, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
side[0].dist(side[1]),
1 / zoomLevel,
{
style: 'dashed',
lengthRatio: 6,
}
)
return (
<line
key={i}
x1={side[0].x}
y1={side[0].y}
x2={side[1].x}
y2={side[1].y}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
</g>
</SVGContainer>
</>
)
}
indicator(shape: SlideShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}

View file

@ -0,0 +1,109 @@
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
DefaultToolbar,
DefaultToolbarContent,
TLComponents,
TLUiOverrides,
Tldraw,
TldrawUiMenuItem,
computed,
track,
useIsToolSelected,
useTools,
} from 'tldraw'
import 'tldraw/tldraw.css'
import { SlideShapeTool } from './SlideShapeTool'
import { SlideShapeUtil } from './SlideShapeUtil'
import { SlidesPanel } from './SlidesPanel'
import './slides.css'
import { $currentSlide, getSlides, moveToSlide } from './useSlides'
const components: TLComponents = {
HelperButtons: SlidesPanel,
Minimap: null,
Toolbar: (props) => {
const tools = useTools()
const isSlideSelected = useIsToolSelected(tools['slide'])
return (
<DefaultToolbar {...props}>
<TldrawUiMenuItem {...tools['slide']} isSelected={isSlideSelected} />
<DefaultToolbarContent />
</DefaultToolbar>
)
},
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
return (
<DefaultKeyboardShortcutsDialog {...props}>
<TldrawUiMenuItem {...tools['slide']} />
<DefaultKeyboardShortcutsDialogContent />
</DefaultKeyboardShortcutsDialog>
)
},
}
const overrides: TLUiOverrides = {
actions(editor, actions) {
const $slides = computed('slides', () => getSlides(editor))
return {
...actions,
'next-slide': {
id: 'next-slide',
label: 'Next slide',
kbd: 'right',
onSelect() {
const slides = $slides.get()
const currentSlide = $currentSlide.get()
const index = slides.findIndex((s) => s.id === currentSlide?.id)
const nextSlide = slides[index + 1] ?? currentSlide ?? slides[0]
if (nextSlide) {
editor.stopCameraAnimation()
moveToSlide(editor, nextSlide)
}
},
},
'previous-slide': {
id: 'previous-slide',
label: 'Previous slide',
kbd: 'left',
onSelect() {
const slides = $slides.get()
const currentSlide = $currentSlide.get()
const index = slides.findIndex((s) => s.id === currentSlide?.id)
const previousSlide = slides[index - 1] ?? currentSlide ?? slides[slides.length - 1]
if (previousSlide) {
editor.stopCameraAnimation()
moveToSlide(editor, previousSlide)
}
},
},
}
},
tools(editor, tools) {
tools.slide = {
id: 'slide',
icon: 'group',
label: 'Slide',
kbd: 's',
onSelect: () => editor.setCurrentTool('slide'),
}
return tools
},
}
const SlidesExample = track(() => {
return (
<div className="tldraw__editor">
<Tldraw
persistenceKey="slideshow_example"
shapeUtils={[SlideShapeUtil]}
tools={[SlideShapeTool]}
components={components}
overrides={overrides}
/>
</div>
)
})
export default SlidesExample

View file

@ -0,0 +1,32 @@
import { TldrawUiButton, stopEventPropagation, track, useEditor, useValue } from 'tldraw'
import { moveToSlide, useCurrentSlide, useSlides } from './useSlides'
export const SlidesPanel = track(() => {
const editor = useEditor()
const slides = useSlides()
const currentSlide = useCurrentSlide()
const selectedShapes = useValue('selected shapes', () => editor.getSelectedShapes(), [editor])
if (slides.length === 0) return null
return (
<div className="slides-panel scroll-light" onPointerDown={(e) => stopEventPropagation(e)}>
{slides.map((slide, i) => {
const isSelected = selectedShapes.includes(slide)
return (
<TldrawUiButton
key={'slides-panel-button:' + slide.id}
type="normal"
className="slides-panel-button"
onClick={() => moveToSlide(editor, slide)}
style={{
background: currentSlide?.id === slide.id ? 'var(--color-background)' : 'transparent',
outline: isSelected ? 'var(--color-selection-stroke) solid 1.5px' : 'none',
}}
>
{`Slide ${i + 1}`}
</TldrawUiButton>
)
})}
</div>
)
})

View file

@ -0,0 +1,32 @@
.slides-panel {
display: flex;
flex-direction: column;
gap: 4px;
max-height: calc(100% - 110px);
margin: 50px 0px;
padding: 4px;
background-color: var(--color-low);
pointer-events: all;
border-top-right-radius: var(--radius-4);
border-bottom-right-radius: var(--radius-4);
overflow: auto;
border-right: 2px solid var(--color-background);
border-bottom: 2px solid var(--color-background);
border-top: 2px solid var(--color-background);
}
.slides-panel-button {
border-radius: var(--radius-4);
outline-offset: -1px;
}
.slide-shape-label {
pointer-events: all;
position: absolute;
background: var(--color-low);
padding: calc(12px * var(--tl-scale));
border-bottom-right-radius: calc(var(--radius-4) * var(--tl-scale));
font-size: calc(12px * var(--tl-scale));
color: var(--color-text);
white-space: nowrap;
}

View file

@ -0,0 +1,28 @@
import { EASINGS, Editor, atom, useEditor, useValue } from 'tldraw'
import { SlideShape } from './SlideShapeUtil'
export const $currentSlide = atom<SlideShape | null>('current slide', null)
export function moveToSlide(editor: Editor, slide: SlideShape) {
const bounds = editor.getShapePageBounds(slide.id)
if (!bounds) return
$currentSlide.set(slide)
editor.selectNone()
editor.zoomToBounds(bounds, { duration: 500, easing: EASINGS.easeInOutCubic, inset: 0 })
}
export function useSlides() {
const editor = useEditor()
return useValue<SlideShape[]>('slide shapes', () => getSlides(editor), [editor])
}
export function useCurrentSlide() {
return useValue($currentSlide)
}
export function getSlides(editor: Editor) {
return editor
.getSortedChildIdsForParent(editor.getCurrentPageId())
.map((id) => editor.getShape(id))
.filter((s) => s?.type === 'slide') as SlideShape[]
}

View file

@ -8673,7 +8673,7 @@
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#deleteShape:member(1)",
"docComment": "/**\n * Delete a shape.\n *\n * @param id - The id of the shape to delete.\n *\n * @example\n * ```ts\n * editor.deleteShapes(['box1', 'box2'])\n * ```\n *\n * @public\n */\n",
"docComment": "/**\n * Delete a shape.\n *\n * @param id - The id of the shape to delete.\n *\n * @example\n * ```ts\n * editor.deleteShape(shape.id)\n * ```\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
@ -11233,7 +11233,7 @@
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getIsMenuOpen:member(1)",
"docComment": "/**\n * Get whether any menus are open.\n *\n * @example\n * ```ts\n * editor.isMenuOpen()\n * ```\n *\n * @public\n */\n",
"docComment": "/**\n * Get whether any menus are open.\n *\n * @example\n * ```ts\n * editor.getIsMenuOpen()\n * ```\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",

View file

@ -1307,7 +1307,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @example
* ```ts
* editor.isMenuOpen()
* editor.getIsMenuOpen()
* ```
*
* @public
@ -7084,7 +7084,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @example
* ```ts
* editor.deleteShapes(['box1', 'box2'])
* editor.deleteShape(shape.id)
* ```
*
* @param id - The id of the shape to delete.