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",
|
||||
"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",
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in a new issue