From 413838cd3d8d1e884a213bd1c74f812dfed99aa4 Mon Sep 17 00:00:00 2001 From: Lu Wilson Date: Wed, 17 Apr 2024 10:27:37 +0100 Subject: [PATCH] Add slides example (#3467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a slides use-case example. https://github.com/tldraw/tldraw/assets/15892272/89fdcb56-167d-4046-bfec-f93b18a83da2 ### Change Type - [ ] `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 - [ ] `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 --- apps/examples/src/examples/slides/README.md | 12 ++ .../src/examples/slides/SlideShapeTool.tsx | 7 + .../src/examples/slides/SlideShapeUtil.tsx | 127 ++++++++++++++++++ .../src/examples/slides/SlidesExample.tsx | 109 +++++++++++++++ .../src/examples/slides/SlidesPanel.tsx | 32 +++++ apps/examples/src/examples/slides/slides.css | 32 +++++ .../src/examples/slides/useSlides.tsx | 28 ++++ packages/editor/api/api.json | 4 +- packages/editor/src/lib/editor/Editor.ts | 4 +- 9 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 apps/examples/src/examples/slides/README.md create mode 100644 apps/examples/src/examples/slides/SlideShapeTool.tsx create mode 100644 apps/examples/src/examples/slides/SlideShapeUtil.tsx create mode 100644 apps/examples/src/examples/slides/SlidesExample.tsx create mode 100644 apps/examples/src/examples/slides/SlidesPanel.tsx create mode 100644 apps/examples/src/examples/slides/slides.css create mode 100644 apps/examples/src/examples/slides/useSlides.tsx diff --git a/apps/examples/src/examples/slides/README.md b/apps/examples/src/examples/slides/README.md new file mode 100644 index 000000000..b7fd7c097 --- /dev/null +++ b/apps/examples/src/examples/slides/README.md @@ -0,0 +1,12 @@ +--- +title: Slideshow +component: ./SlidesExample.tsx +category: use-cases +priority: 1 +--- + +Slideshow example. + +--- + +Make slides for a presentation. diff --git a/apps/examples/src/examples/slides/SlideShapeTool.tsx b/apps/examples/src/examples/slides/SlideShapeTool.tsx new file mode 100644 index 000000000..530f21956 --- /dev/null +++ b/apps/examples/src/examples/slides/SlideShapeTool.tsx @@ -0,0 +1,7 @@ +import { BaseBoxShapeTool } from 'tldraw' + +export class SlideShapeTool extends BaseBoxShapeTool { + static override id = 'slide' + static override initial = 'idle' + override shapeType = 'slide' +} diff --git a/apps/examples/src/examples/slides/SlideShapeUtil.tsx b/apps/examples/src/examples/slides/SlideShapeUtil.tsx new file mode 100644 index 000000000..7f826024a --- /dev/null +++ b/apps/examples/src/examples/slides/SlideShapeUtil.tsx @@ -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 { + static override type = 'slide' as const + static override props: ShapeProps = { + 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 = (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 ( + <> +
+ {`Slide ${index + 1}`} +
+ + + {bounds.sides.map((side, i) => { + const { strokeDasharray, strokeDashoffset } = getPerfectDashProps( + side[0].dist(side[1]), + 1 / zoomLevel, + { + style: 'dashed', + lengthRatio: 6, + } + ) + + return ( + + ) + })} + + + + ) + } + + indicator(shape: SlideShape) { + return + } +} diff --git a/apps/examples/src/examples/slides/SlidesExample.tsx b/apps/examples/src/examples/slides/SlidesExample.tsx new file mode 100644 index 000000000..493178898 --- /dev/null +++ b/apps/examples/src/examples/slides/SlidesExample.tsx @@ -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 ( + + + + + ) + }, + KeyboardShortcutsDialog: (props) => { + const tools = useTools() + return ( + + + + + ) + }, +} + +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 ( +
+ +
+ ) +}) + +export default SlidesExample diff --git a/apps/examples/src/examples/slides/SlidesPanel.tsx b/apps/examples/src/examples/slides/SlidesPanel.tsx new file mode 100644 index 000000000..9ed760b4b --- /dev/null +++ b/apps/examples/src/examples/slides/SlidesPanel.tsx @@ -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 ( +
stopEventPropagation(e)}> + {slides.map((slide, i) => { + const isSelected = selectedShapes.includes(slide) + return ( + 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}`} + + ) + })} +
+ ) +}) diff --git a/apps/examples/src/examples/slides/slides.css b/apps/examples/src/examples/slides/slides.css new file mode 100644 index 000000000..a98c2e028 --- /dev/null +++ b/apps/examples/src/examples/slides/slides.css @@ -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; +} diff --git a/apps/examples/src/examples/slides/useSlides.tsx b/apps/examples/src/examples/slides/useSlides.tsx new file mode 100644 index 000000000..9f135c2ea --- /dev/null +++ b/apps/examples/src/examples/slides/useSlides.tsx @@ -0,0 +1,28 @@ +import { EASINGS, Editor, atom, useEditor, useValue } from 'tldraw' +import { SlideShape } from './SlideShapeUtil' + +export const $currentSlide = atom('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('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[] +} diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index a8272a9ed..ce3f1394f 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -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", diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 6bb810551..93de351ff 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -1307,7 +1307,7 @@ export class Editor extends EventEmitter { * * @example * ```ts - * editor.isMenuOpen() + * editor.getIsMenuOpen() * ``` * * @public @@ -7084,7 +7084,7 @@ export class Editor extends EventEmitter { * * @example * ```ts - * editor.deleteShapes(['box1', 'box2']) + * editor.deleteShape(shape.id) * ``` * * @param id - The id of the shape to delete.