import React, { useEffect, useLayoutEffect, useMemo, useState } from "react";
import { fileSave } from "browser-fs-access"
import { Api as RevealApi } from "reveal.js"
import {
Box,
Editor,
Tldraw,
DefaultQuickActions,
DefaultQuickActionsContent,
TldrawUiMenuItem,
AlignMenuItems,
DistributeMenuItems,
EditLinkMenuItem,
GroupOrUngroupMenuItem,
ReorderMenuItems,
RotateCWMenuItem,
StackMenuItems,
DefaultActionsMenu,
createTLStore,
defaultShapeUtils,
throttle,
PageRecordType,
TldrawUiMenuSubmenu,
TldrawUiMenuGroup,
TLUiActionsContextType,
useActions,
DefaultMainMenu,
EditSubmenu,
ExportFileContentSubMenu,
ExtrasGroup,
PreferencesGroup,
transact,
TLShapeId,
createTLUser,
TLUserPreferences,
TldrawUiMenuCheckboxItem,
TLStore,
TLStoreSnapshot,
DefaultToolbar,
SelectToolbarItem,
DrawToolbarItem,
EraserToolbarItem,
ArrowToolbarItem,
TextToolbarItem,
NoteToolbarItem,
AssetToolbarItem,
RectangleToolbarItem,
ArrowDownToolbarItem,
ArrowLeftToolbarItem,
ArrowRightToolbarItem,
ArrowUpToolbarItem,
CheckBoxToolbarItem,
CloudToolbarItem,
DiamondToolbarItem,
EllipseToolbarItem,
FrameToolbarItem,
HexagonToolbarItem,
HighlightToolbarItem,
LaserToolbarItem,
LineToolbarItem,
OvalToolbarItem,
RhombusToolbarItem,
StarToolbarItem,
TriangleToolbarItem,
XBoxToolbarItem,
loadSnapshot,
getSnapshot,
} from "tldraw"
import { useAtom } from "@tldraw/state-react"
import { makeInt, parseOptionalBoolean } from "./util"
import { defaultStyleProps, getTldrevealConfig } from "./config";
// TODO:
// - store.loadSnapshot is deprecated
// - Somehow create overlaid pages for fragment navigation
// - Fix the overlay in scroll mode
// - Fix the 40 slides with drawings limit (that's tldraw's (artificial) page limit)
const TLDREVEAL_FILE_EXTENSION = ".tldrev"
interface FileSubmenuProps {
canUseLocalStorage: boolean
saveToLocalStorage: boolean
}
function FileSubmenu({ canUseLocalStorage, saveToLocalStorage }: FileSubmenuProps) {
const actions = useActions()
const mainGroup =
if (canUseLocalStorage) {
return (
{mainGroup}
)
} else {
return mainGroup
}
}
function ClearSubmenu() {
const actions = useActions()
return (
)
}
function CustomMainMenu({ fileProps }: { fileProps: FileSubmenuProps }) {
const actions = useActions()
return (
)
}
function CustomQuickActions() {
const actions = useActions()
return (
)
}
function CustomActionsMenu() {
return (
)
}
function CustomToolbar() {
return (
)
}
export interface TldrevealOverlayProps {
/// The instance of Reveal this overlaid on
reveal: RevealApi
/// The container element in which the overlay is rendered
container: HTMLDivElement
}
export function TldrevealOverlay({ reveal, container }: TldrevealOverlayProps) {
const config = getTldrevealConfig(reveal)
function tryGetId(element: HTMLElement) : string | undefined {
return element.getAttribute("data-tlid") || element.id || undefined
}
const deckId : string | undefined = useMemo(() =>
tryGetId(reveal.getSlidesElement()) || tryGetId(reveal.getRevealElement())
, [])
const saveToLocalStorageKey = deckId && `TLDREVEAL_SAVE_TO_LOCALSTORAGE__${deckId}`
const localStorageKey = deckId && `TLDREVEAL_SNAPSHOT__${deckId}`
const [store] = useState(() => createTLStore({ shapeUtils: defaultShapeUtils }))
const [editor, setEditor] = useState()
// Use a local user preferences atom, to prevent sharing dark mode status
// across multiple instances
const userPreferences = useAtom("userPreferences", { id: "tldreveal", colorScheme: config.isDarkMode ? "dark" : "light" })
const [isolatedUser] = useState(() => createTLUser({ userPreferences, setUserPreferences: userPreferences.set }))
const [saveToLocalStorage, setSaveToLocalStorage_] =
useState(parseOptionalBoolean(localStorage.getItem(saveToLocalStorageKey)) ?? config.useLocalStorage)
function setSaveToLocalStorage(value: boolean) {
setSaveToLocalStorage_(value)
localStorage.setItem(saveToLocalStorageKey, value ? "true" : "false")
}
const [isReady, setIsReady] = useState(false)
const [isShown, setIsShown] = useState(true)
const [isEditing, setIsEditing] = useState(false)
const [currentSlide, setCurrentSlide] = useState<{ h: number, v: number }>(reveal.getIndices())
const slideWidth = makeInt(reveal.getConfig().width)
const slideHeight = makeInt(reveal.getConfig().height)
const bounds = new Box(0, 0, slideWidth, slideHeight)
function getSlideId(index: { h: number, v: number }) : string {
const slideElement = reveal.getSlide(index.h, index.v)
const givenId = tryGetId(slideElement)
if (givenId !== undefined) {
return givenId
}
if (index.v !== 0) {
const stackId = tryGetId(slideElement.parentElement)
if (stackId !== undefined) {
return `${stackId}.${index.v}`
}
const firstInStackId = getSlideId({ h: index.h, v: 0 })
if (firstInStackId.match("^\\d+\\.0$")) {
return firstInStackId.replace(".0", `.${index.v}`)
} else {
return `${firstInStackId}.${index.v}`
}
}
return `${index.h}.${index.v}`
}
const currentSlideId = useMemo(() => getSlideId(currentSlide), [ currentSlide ])
function getTimestampedSnapshot(store: TLStore) : TLStoreSnapshot & { timestamp: number } {
const { document } = getSnapshot(store)
return {
timestamp: Date.now(),
...document
}
}
async function loadInitial(store: TLStore) {
let localStorageSnapshot: (TLStoreSnapshot & { timestamp: number }) | undefined
if (localStorageKey) {
const snapshotJson = localStorage.getItem(localStorageKey)
if (snapshotJson) {
localStorageSnapshot = JSON.parse(snapshotJson)
}
}
let uriSnapshot: (TLStoreSnapshot & { timestamp: number }) | undefined
if (config.snapshotUrl) {
let uri
if (config.snapshotUrl === "auto") {
const path = window.location.pathname
const ext = [ ".html", ".htm" ].find(ext => path.endsWith(ext))
if (ext) {
uri = path.substring(0, path.length - ext.length) + ".tldrev"
} else {
uri = "index.tldrev"
}
} else {
uri = config.snapshotUrl.url
}
try {
const res = await fetch(uri)
if (res.ok) {
const snapshotJson = await res.text()
try {
const snapshot = JSON.parse(snapshotJson)
if (!(snapshot.timestamp && snapshot.store && snapshot.schema)) {
console.warn("Received invalid snapshot from", uri)
} else {
uriSnapshot = snapshot
}
} catch {
console.warn("Received invalid snapshot from", uri)
}
} else {
if (config.snapshotUrl === "auto") {
console.log("No saved drawings found at auto-detected uri:", uri, "Got status:", res.status, res.statusText)
} else {
console.warn("Failed to load saved drawings from", uri, "Got status:", res.status, res.statusText)
}
}
} catch (err) {
console.warn("Failed to fetch drawings from uri. Error:", err)
}
}
let snapshot: (TLStoreSnapshot & { timestamp: number }) | undefined
if (localStorageSnapshot && uriSnapshot) {
if (localStorageSnapshot.timestamp >= uriSnapshot.timestamp) {
snapshot = localStorageSnapshot
} else {
// TODO: dialog, for now always load newest
snapshot = uriSnapshot
}
} else {
snapshot = localStorageSnapshot || uriSnapshot
}
if (snapshot) {
loadSnapshot(store, snapshot)
}
setIsReady(true)
}
// https://tldraw.dev/examples/data/assets/local-storage
useLayoutEffect(() => {
loadInitial(store)
}, [ store ])
useEffect(() => {
// If we can and want to use local storage, then listen to changes of
// the store and save them
if (localStorageKey && saveToLocalStorage) {
const cleanupStoreListen = store.listen(
throttle(() => {
const snapshot = getTimestampedSnapshot(store)
localStorage.setItem(localStorageKey, JSON.stringify(snapshot))
}, 500)
)
return () => {
cleanupStoreListen()
}
}
}, [ store, saveToLocalStorage ])
function initializeEditor(state = { editor, currentSlide }) {
state.editor.setCurrentTool("draw")
for (const style of Object.keys(config.defaultStyles)) {
if (config.defaultStyles[style]) {
state.editor.setStyleForNextShapes(defaultStyleProps[style], config.defaultStyles[style])
}
}
state.editor.updateInstanceState({
isDebugMode: false,
exportBackground: false
})
// Lock the camera
state.editor.setCameraOptions({
// Not necessary, but the wheel shouldn't move the camera
wheelBehavior: "none",
// There are no zoom steps available to the user
zoomSteps: [],
constraints: {
baseZoom: "fit-max",
behavior: "fixed",
bounds: bounds,
initialZoom: "fit-max",
origin: { x: 0, y: 0 },
padding: { x: 0, y: 0 }
}
})
syncEditor(state)
}
function onTldrawMount(editor: Editor) {
setEditor(editor)
initializeEditor({ editor, currentSlide })
}
useEffect(() => {
container.classList.toggle("tldreveal-hidden", !isShown)
if (isShown && isEditing) {
container.classList.remove("tldreveal-inactive")
container.setAttribute("data-prevent-swipe", "true")
} else {
if (!container.classList.contains("tldreveal-inactive")) {
container.classList.add("tldreveal-inactive")
}
if (container.hasAttribute("data-prevent-swipe")) {
container.removeAttribute("data-prevent-swipe")
}
}
}, [ isShown, isEditing ])
function handleDKey() {
setIsShown(true)
setIsEditing(true)
}
function handleVKey() {
setIsEditing(false)
setIsShown(isShown => !isShown)
}
const handleKeydown = (state = { isEditing }) => (event: KeyboardEvent) => {
if (state.isEditing && event.key === "Escape") {
setIsEditing(false)
event.stopImmediatePropagation()
}
}
const handleDblclick = (state = { isEditing }) => (event: MouseEvent) => {
if (!state.isEditing) {
setIsShown(true)
setIsEditing(true)
event.stopImmediatePropagation()
}
}
useEffect(() => {
const state = { isEditing }
const handleKeydown_ = handleKeydown(state)
const handleDblclick_ = handleDblclick(state)
reveal.addKeyBinding({ keyCode: 68, key: "D", description: "Enter drawing mode" }, handleDKey)
reveal.addKeyBinding({ keyCode: 86, key: "V", description: "Toggle drawing visibility" }, handleVKey)
window.addEventListener("dblclick", handleDblclick_, true)
window.addEventListener("keydown", handleKeydown_, true)
return () => {
reveal.removeKeyBinding(68)
window.removeEventListener("dblclick", handleDblclick_, true)
window.removeEventListener("keydown", handleKeydown_, true)
}
}, [ isEditing ])
function handleReady(event) {
setCurrentSlide({ h: event.indexh, v: event.indexv })
}
const handleSlidechanged = (state = { currentSlideId }) => event => {
const currentTransition: string | undefined =
reveal.getConfig().transition
|| event.currentSlide.getAttribute("data-transition")
const noTransition = currentTransition === "none" || currentTransition?.includes("none-in")
const hasSameSlideId = getSlideId({ h: event.indexh, v: event.indexv }) === state.currentSlideId
if (noTransition || hasSameSlideId) {
setCurrentSlide({ h: event.indexh, v: event.indexv })
} else {
container.classList.toggle("start-transition", true)
setTimeout(() => {
setCurrentSlide({ h: event.indexh, v: event.indexv })
container.classList.toggle("transitioning", true)
}, 200)
}
}
function handleSlidetransitionend(_event) {
container.classList.toggle("start-transition", false)
container.classList.toggle("transitioning", false)
}
function handleOverviewshown(_event) {
setIsShown(false)
}
function handleOverviewhidden(_event) {
setIsShown(true)
}
function handlePaused(_event) {
setIsShown(false)
}
function handleResumed(_event) {
setIsShown(true)
}
useEffect(() => {
reveal.on("ready", handleReady)
// beforeslidechange
reveal.on("slidetransitionend", handleSlidetransitionend)
reveal.on("overviewshown", handleOverviewshown)
reveal.on("overviewhidden", handleOverviewhidden)
reveal.on("paused", handlePaused)
reveal.on("resumed", handleResumed)
return () => {
reveal.off("ready", handleReady)
reveal.off("overviewshown", handleOverviewshown)
reveal.off("overviewhidden", handleOverviewhidden)
reveal.off("paused", handlePaused)
reveal.off("resumed", handleResumed)
}
}, [])
useEffect(() => {
const handleSlidechanged_ = handleSlidechanged({ currentSlideId })
reveal.on("slidechanged", handleSlidechanged_)
return () => {
reveal.off("slidechanged", handleSlidechanged_)
}
}, [ currentSlideId ])
const handleResize = (state = { editor }) => {
return () => resetCamera(state)
}
useEffect(() => {
const state = { editor }
const handleResize_ = handleResize(state)
window.addEventListener("resize", handleResize_)
return () => {
window.removeEventListener("resize", handleResize_)
}
}, [ editor ])
function resetCamera(state = { editor }) {
if (state.editor) {
state.editor.setCamera(state.editor.getCamera(), { reset: true })
}
}
function syncEditor(state = { editor, currentSlide }) {
if (state.editor) {
// Find the correct page, or create it if there isn't one
const pageId = PageRecordType.createId(currentSlideId)
if (!state.editor.getPage(pageId)) {
state.editor.createPage({ id: pageId, name: currentSlideId })
}
// Navigate to the correct page if we're not there yet, and delete
// the previous page if it is empty
const oldCurrentPageId = state.editor.getCurrentPageId()
if (oldCurrentPageId !== pageId) {
// Delete the old current page if it has no shapes on it
const deleteOldCurrent =
state.editor.getCurrentPageShapeIds().size === 0
state.editor.setCurrentPage(pageId)
// Reset undo/redo to prevent undoing changes on other pages
state.editor.clearHistory()
if (deleteOldCurrent) {
state.editor.deletePage(oldCurrentPageId)
}
}
if (config.automaticDarkMode) {
const currentSlideClasses = reveal.getSlide(currentSlide.h, currentSlide.v).classList
const currentVSlideClasses = reveal.getSlide(currentSlide.h).classList
if (currentSlideClasses.contains("has-dark-background")) {
userPreferences.update(u => ({ ...u, colorScheme: "dark" }))
} else if (currentSlideClasses.contains("has-light-background")) {
userPreferences.update(u => ({ ...u, colorScheme: "light" }))
} else if (currentVSlideClasses.contains("has-dark-background")) {
userPreferences.update(u => ({ ...u, colorScheme: "dark" }))
} else if (currentVSlideClasses.contains("has-light-background")) {
userPreferences.update(u => ({ ...u, colorScheme: "light" }))
} else {
userPreferences.update(u => ({ ...u, colorScheme: config.isDarkMode ? "dark" : "light" }))
}
}
// Reset the camera on each new page
resetCamera(state)
}
}
useEffect(() => {
syncEditor({ editor, currentSlide })
}, [ editor, currentSlide ])
const customTranslations = {
en: {
"tldreveal.menu.file": "File",
"tldreveal.menu.clear": "Clear",
"tldreveal.action.close": "Exit drawing mode",
"tldreveal.action.save-file": "Save file",
"tldreveal.action.clear-localstorage": "Clear browser storage",
"tldreveal.action.clear-page": "Clear current slide",
"tldreveal.action.clear-deck": "Clear deck",
"tldreveal.options.save-to-localstorage": "Save in browser",
// Set the default name for built-in export functions
"document.default-name": deckId || "unknown"
}
}
const customActions : TLUiActionsContextType = {
["tldreveal.close"]: {
id: "tldreveal.close",
label: "tldreveal.action.close",
// Available icon types: https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/ui/icon-types.ts
icon: "cross-2",
readonlyOk: true,
async onSelect(_source) {
setIsEditing(false)
}
},
["tldreveal.save-file"]: {
id: "tldreveal.save-file",
label: "tldreveal.action.save-file",
readonlyOk: true,
kbd: "$s",
async onSelect(_source) {
const snapshot = getTimestampedSnapshot(store)
await fileSave(new Blob([ JSON.stringify(snapshot) ]), {
fileName: (deckId || "untitled") + TLDREVEAL_FILE_EXTENSION,
extensions: [ TLDREVEAL_FILE_EXTENSION ]
})
}
},
["tldreveal.toggle-save-to-localstorage"]: {
id: "tldreveal.toggle-save-to-localstorage",
label: "tldreveal.options.save-to-localstorage",
readonlyOk: true,
checkbox: true,
async onSelect(_source) {
setSaveToLocalStorage(localStorageKey && !saveToLocalStorage)
}
},
["tldreveal.clear-localstorage"]: {
id: "tldreveal.clear-localstorage",
label: "tldreveal.action.clear-localstorage",
async onSelect(_source) {
if (localStorageKey) {
setSaveToLocalStorage(false)
localStorage.removeItem(localStorageKey)
}
}
},
["tldreveal.clear-page"]: {
id: "tldreveal.clear-page",
label: "tldreveal.action.clear-page",
async onSelect(_source) {
transact(() => {
// Delete all shapes on the current page
editor.deleteShapes(editor.getCurrentPageShapes())
})
}
},
["tldreveal.clear-deck"]: {
id: "tldreveal.clear-deck",
label: "tldreveal.action.clear-deck",
async onSelect(_source) {
transact(() => {
// Find all shapes from the store and delete them
const allShapeIds =
store.allRecords()
.filter(record => record.typeName === "shape")
.map(record => record.id as TLShapeId)
editor.deleteShapes(allShapeIds)
// Delete all assets
editor.deleteAssets(editor.getAssets())
// Delete all pages except the current
const currentPage = editor.getCurrentPage()
for (const page of editor.getPages()) {
if (page.id !== currentPage.id) editor.deletePage(page)
}
})
}
}
}
if (isReady) {
return (
CustomMainMenu({
fileProps: {
canUseLocalStorage: localStorageKey !== undefined,
saveToLocalStorage
}
}),
ActionsMenu: CustomActionsMenu,
QuickActions: CustomQuickActions,
Toolbar: CustomToolbar
}}
overrides={{
translations: customTranslations,
tools(editor, tools) {
// Remove the keyboard shortcut for the hand tool
tools.hand.kbd = undefined
return tools
},
// Remove actions related to zooming
actions(editor, actions) {
delete actions["select-zoom-tool"]
delete actions["zoom-in"]
delete actions["zoom-out"]
delete actions["zoom-to-100"]
delete actions["zoom-to-fit"]
delete actions["zoom-to-selection"]
delete actions["back-to-content"]
return { ...actions, ...customActions }
}
}}
>
)
}
}