diff --git a/src/App.tsx b/src/App.tsx index f083919..3cc8b43 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,8 @@ import { createRoot } from "react-dom/client" import { handleInitialPageLoad } from "./utils/handleInitialPageLoad" import { DailyProvider } from "@daily-co/daily-react" import Daily from "@daily-co/daily-js" +import { SlideTool } from "./tools/SlideTool" +import { SlideShape } from "./shapes/SlideShapeUtil" inject() @@ -29,8 +31,15 @@ const customShapeUtils = [ VideoChatShape, EmbedShape, MarkdownShape, + SlideShape, +] +const customTools = [ + ChatBoxTool, + VideoChatTool, + EmbedTool, + MarkdownTool, + SlideTool, ] -const customTools = [ChatBoxTool, VideoChatTool, EmbedTool, MarkdownTool] const callObject = Daily.createCallObject() diff --git a/src/components/SlideControls.tsx b/src/components/SlideControls.tsx new file mode 100644 index 0000000..6ec32a8 --- /dev/null +++ b/src/components/SlideControls.tsx @@ -0,0 +1,66 @@ +import { useCallback, useEffect } from "react" +import { useEditor } from "tldraw" +import { useSlides, useCurrentSlide, moveToSlide } from "../hooks/useSlide" + +export function SlideControls() { + const editor = useEditor() + const slides = useSlides() + const currentSlide = useCurrentSlide() + + const currentIndex = currentSlide ? slides.indexOf(currentSlide) : -1 + + const nextSlide = useCallback(() => { + if (currentIndex < slides.length - 1) { + moveToSlide(editor, slides[currentIndex + 1]) + } + }, [editor, slides, currentIndex]) + + const previousSlide = useCallback(() => { + if (currentIndex > 0) { + moveToSlide(editor, slides[currentIndex - 1]) + } + }, [editor, slides, currentIndex]) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "ArrowRight") nextSlide() + if (e.key === "ArrowLeft") previousSlide() + }, + [nextSlide, previousSlide], + ) + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [handleKeyDown]) + + if (slides.length === 0) return null + + return ( +
+ +
+ {currentIndex + 1} / {slides.length} +
+ +
+ ) +} diff --git a/src/components/SlidesPanel.tsx b/src/components/SlidesPanel.tsx new file mode 100644 index 0000000..0100c07 --- /dev/null +++ b/src/components/SlidesPanel.tsx @@ -0,0 +1,45 @@ +import { useEditor } from "tldraw" +import { useSlides, useCurrentSlide, moveToSlide } from "../hooks/useSlide" + +export function SlidesPanel() { + const editor = useEditor() + const slides = useSlides() + const currentSlide = useCurrentSlide() + + return ( +
+
+ Slides ({slides.length}) +
+ {slides.map((slide, i) => ( +
moveToSlide(editor, slide)} + > + Slide {i + 1} +
+ ))} +
+ ) +} diff --git a/src/css/slides.css b/src/css/slides.css new file mode 100644 index 0000000..5a229e1 --- /dev/null +++ b/src/css/slides.css @@ -0,0 +1,61 @@ +.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; + cursor: pointer; + padding: 8px; + transition: background-color 0.2s ease; +} + +.slides-panel-button:hover { + background-color: var(--color-hover); +} + +.slides-panel-button.selected { + background-color: var(--color-selected); +} + +.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; +} + +/* Scrollbar styling */ +.slides-panel::-webkit-scrollbar { + width: 8px; +} + +.slides-panel::-webkit-scrollbar-track { + background: var(--color-low); +} + +.slides-panel::-webkit-scrollbar-thumb { + background: var(--color-divider); + border-radius: 4px; +} + +.slides-panel::-webkit-scrollbar-thumb:hover { + background: var(--color-text-3); +} \ No newline at end of file diff --git a/src/hooks/useSlide.ts b/src/hooks/useSlide.ts new file mode 100644 index 0000000..8eea7b7 --- /dev/null +++ b/src/hooks/useSlide.ts @@ -0,0 +1,39 @@ +import { EASINGS, Editor, atom, useEditor, useValue } from "tldraw" +import { TLFrameShape } from "tldraw" + +// Create an atom for current slide state +export const $currentSlide = atom("current slide", null) + +// Function to move to a specific slide +export function moveToSlide(editor: Editor, frame: TLFrameShape) { + const bounds = editor.getShapePageBounds(frame.id) + if (!bounds) return + + $currentSlide.set(frame) + editor.selectNone() + editor.zoomToBounds(bounds, { + animation: { duration: 500, easing: EASINGS.easeInOutCubic }, + inset: 0, + }) +} + +// Hook to get all slides (frames) +export function useSlides() { + const editor = useEditor() + return useValue("frame shapes", () => getSlides(editor), [ + editor, + ]) +} + +// Hook to get current slide +export function useCurrentSlide() { + return useValue($currentSlide) +} + +// Helper to get all slides +export function getSlides(editor: Editor) { + return editor + .getSortedChildIdsForParent(editor.getCurrentPageId()) + .map((id) => editor.getShape(id)) + .filter((s): s is TLFrameShape => s?.type === "frame") +} diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 503e42d..d0b3e44 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -17,12 +17,22 @@ import { components } from "@/ui/components" import { overrides } from "@/ui/overrides" import { unfurlBookmarkUrl } from "../utils/unfurlBookmarkUrl" import { handleInitialPageLoad } from "@/utils/handleInitialPageLoad" +import { SlideControls } from "@/components/SlideControls" +import { SlideShape } from "@/shapes/SlideShapeUtil" +import { SlideTool } from "@/tools/SlideTool" +import { SlidesPanel } from "@/components/SlidesPanel" // Default to production URL if env var isn't available export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" -const shapeUtils = [ChatBoxShape, VideoChatShape, EmbedShape, MarkdownShape] -const tools = [ChatBoxTool, VideoChatTool, EmbedTool, MarkdownTool] // Array of tools +const shapeUtils = [ + ChatBoxShape, + VideoChatShape, + EmbedShape, + MarkdownShape, + SlideShape, +] +const tools = [ChatBoxTool, VideoChatTool, EmbedTool, MarkdownTool, SlideTool] export function Board() { const { slug } = useParams<{ slug: string }>() @@ -49,14 +59,16 @@ export function Board() { tools={tools} components={components} overrides={overrides} - //maxZoom={20} onMount={(editor) => { setEditor(editor) editor.registerExternalAssetHandler("url", unfurlBookmarkUrl) editor.setCurrentTool("hand") handleInitialPageLoad(editor) }} - /> + > + + {editor?.getCurrentTool().id === "Slide" && } + ) } diff --git a/src/shapes/SlideShapeUtil.tsx b/src/shapes/SlideShapeUtil.tsx new file mode 100644 index 0000000..575451e --- /dev/null +++ b/src/shapes/SlideShapeUtil.tsx @@ -0,0 +1,30 @@ +import { BaseBoxShapeUtil, TLBaseBoxShape, TLBaseShape } from "tldraw" + +export type ISlideShape = TLBaseShape< + "Slide", + { + w: number + h: number + currentSlide: number + } +> + +export class SlideShape extends BaseBoxShapeUtil { + static override type = "Slide" + + getDefaultProps(): ISlideShape["props"] { + return { + w: 720, // Standard slide width + h: 405, // 16:9 aspect ratio + currentSlide: 0, + } + } + + component(shape: ISlideShape) { + return null // Slides don't need visual representation + } + + indicator(shape: ISlideShape & TLBaseBoxShape) { + return null + } +} diff --git a/src/tools/SlideTool.ts b/src/tools/SlideTool.ts new file mode 100644 index 0000000..1c7f065 --- /dev/null +++ b/src/tools/SlideTool.ts @@ -0,0 +1,7 @@ +import { BaseBoxShapeTool } from "tldraw" + +export class SlideTool extends BaseBoxShapeTool { + static override id = "Slide" + shapeType = "Slide" + override initial = "idle" +} diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index 1b974c6..264d249 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -52,6 +52,14 @@ export function CustomToolbar() { isSelected={tools["Markdown"].id === editor.getCurrentToolId()} /> )} + {tools["Slide"] && ( + + )} ) } diff --git a/src/ui/overrides.tsx b/src/ui/overrides.tsx index d4a276c..97b5cb1 100644 --- a/src/ui/overrides.tsx +++ b/src/ui/overrides.tsx @@ -44,6 +44,14 @@ export const overrides: TLUiOverrides = { readonlyOk: true, onSelect: () => editor.setCurrentTool("Markdown"), }, + Slide: { + id: "Slide", + icon: "slideshow", + label: "Slideshow", + kbd: "alt+s", + readonlyOk: true, + onSelect: () => editor.setCurrentTool("Slide"), + }, } }, actions(editor, actions) {