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:
parent
7104515c9c
commit
413838cd3d
9 changed files with 351 additions and 4 deletions
12
apps/examples/src/examples/slides/README.md
Normal file
12
apps/examples/src/examples/slides/README.md
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
title: Slideshow
|
||||||
|
component: ./SlidesExample.tsx
|
||||||
|
category: use-cases
|
||||||
|
priority: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
Slideshow example.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Make slides for a presentation.
|
7
apps/examples/src/examples/slides/SlideShapeTool.tsx
Normal file
7
apps/examples/src/examples/slides/SlideShapeTool.tsx
Normal 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'
|
||||||
|
}
|
127
apps/examples/src/examples/slides/SlideShapeUtil.tsx
Normal file
127
apps/examples/src/examples/slides/SlideShapeUtil.tsx
Normal 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} />
|
||||||
|
}
|
||||||
|
}
|
109
apps/examples/src/examples/slides/SlidesExample.tsx
Normal file
109
apps/examples/src/examples/slides/SlidesExample.tsx
Normal 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
|
32
apps/examples/src/examples/slides/SlidesPanel.tsx
Normal file
32
apps/examples/src/examples/slides/SlidesPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
32
apps/examples/src/examples/slides/slides.css
Normal file
32
apps/examples/src/examples/slides/slides.css
Normal 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;
|
||||||
|
}
|
28
apps/examples/src/examples/slides/useSlides.tsx
Normal file
28
apps/examples/src/examples/slides/useSlides.tsx
Normal 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[]
|
||||||
|
}
|
|
@ -8673,7 +8673,7 @@
|
||||||
{
|
{
|
||||||
"kind": "Method",
|
"kind": "Method",
|
||||||
"canonicalReference": "@tldraw/editor!Editor#deleteShape:member(1)",
|
"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": [
|
"excerptTokens": [
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
|
@ -11233,7 +11233,7 @@
|
||||||
{
|
{
|
||||||
"kind": "Method",
|
"kind": "Method",
|
||||||
"canonicalReference": "@tldraw/editor!Editor#getIsMenuOpen:member(1)",
|
"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": [
|
"excerptTokens": [
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
|
|
|
@ -1307,7 +1307,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* editor.isMenuOpen()
|
* editor.getIsMenuOpen()
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @public
|
* @public
|
||||||
|
@ -7084,7 +7084,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* editor.deleteShapes(['box1', 'box2'])
|
* editor.deleteShape(shape.id)
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param id - The id of the shape to delete.
|
* @param id - The id of the shape to delete.
|
||||||
|
|
Loading…
Reference in a new issue