diff --git a/.gitignore b/.gitignore index 21f8339..64bf9a8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ out .env extension.js extension.js.LICENSE.txt - \ No newline at end of file +local/ \ No newline at end of file diff --git a/README.md b/README.md index da8279f..7a05fcd 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,13 @@ Type `{{presentation}}` or `{{slides}}` in a block. Clicking the button will overlay a presentation directly from Roam! Exit the presentation by hitting 'ESC'. +An experimental native-renderer path is available alongside the current +renderer. Use `{{presentation2}}` or `{{slides2}}` to render slide content with +Roam's declarative React APIs while leaving `presentation` and `slides` +unchanged. The paired copy/paste baseline and native-component coverage deck +are in [`fixtures`](fixtures/README.md), and the two component trees are +documented in [`docs/native-renderer-architecture.md`](docs/native-renderer-architecture.md). + To specify what content is part of the presentation, create a child block for each slide. The text of each child will serve as the slide title. Each child block then in turn renders its children as the slide contents in a bulleted outline. For example, the Playground presentation below uses the following structure: ``` diff --git a/src/components/NativeRoamContent.tsx b/src/components/NativeRoamContent.tsx new file mode 100644 index 0000000..13ed3f9 --- /dev/null +++ b/src/components/NativeRoamContent.tsx @@ -0,0 +1,103 @@ +import React from "react"; + +type BlockContent = { + kind: "block"; + uid: string; + open?: boolean; + zoomPath?: boolean; + zoomStartAfterUid?: string; +}; + +type PageContent = { + kind: "page"; + hideMentions?: boolean; +} & ({ uid: string; title?: never } | { uid?: never; title: string }); + +type StringContent = { + kind: "string"; + string: string; +}; + +type SearchContent = { + kind: "search"; + searchQueryStr: string; + closed?: boolean; + groupByPage?: boolean; + hidePaths?: boolean; + onConfigChange?: (config: { + closed?: boolean; + groupByPage?: boolean; + hidePaths?: boolean; + }) => void; +}; + +export type NativeRoamContentProps = + BlockContent | PageContent | StringContent | SearchContent; + +type NativeReactComponents = { + Block: React.ComponentType>; + Page: React.ComponentType< + | { uid: string; title?: never; hideMentions?: boolean } + | { uid?: never; title: string; hideMentions?: boolean } + >; + Search: React.ComponentType>; + BlockString: React.ComponentType>; +}; + +const getNativeComponents = () => { + const components = ( + window.roamAlphaAPI?.ui as typeof window.roamAlphaAPI.ui & { + react?: NativeReactComponents; + } + )?.react; + if (!components) { + throw new Error( + "This version of Roam does not expose roamAlphaAPI.ui.react.", + ); + } + return components; +}; + +/** + * One typed entry point for Roam's native React renderers. + * + * Presentation2 currently uses `block` for slide trees and notes, and + * `string` for titles whose presentation directives have been removed. + * `page` and `search` are included here for page-oriented and search-result + * slides without introducing another rendering abstraction later. + */ +const NativeRoamContent = (props: NativeRoamContentProps) => { + const { Block, Page, Search, BlockString } = getNativeComponents(); + + switch (props.kind) { + case "block": + return ( + + ); + case "page": + return props.uid ? ( + + ) : ( + + ); + case "search": + return ( + + ); + case "string": + return ; + } +}; + +export default NativeRoamContent; diff --git a/src/components/Presentation.tsx b/src/components/Presentation.tsx index 0b87a63..bbb97f8 100644 --- a/src/components/Presentation.tsx +++ b/src/components/Presentation.tsx @@ -64,6 +64,7 @@ const parseSolo = ({ viewType: "document", children: [], uid: "", + parents: [], heading: 0, open: true, textAlign, @@ -588,7 +589,7 @@ const ContentSlide = ({ __html: parseRoamBlocks({ content: bullets, viewType }), }} style={{ - width: isImageLayout ? "50%" : "100%", + width: isSourceLayout && !isCenterLayout ? "50%" : "100%", transformOrigin: "left top", wordBreak: "break-word", display: isCenterLayout ? "none" : "block", @@ -838,7 +839,11 @@ const PresentationContent: React.FunctionComponent<{ }, [revealRef, initialized, startIndex, slides, mappedSlides]); return ( <> -
+
{mappedSlides.map((s: TreeNode & ContentSlideExtras, i) => ( diff --git a/src/components/Presentation2.tsx b/src/components/Presentation2.tsx new file mode 100644 index 0000000..aebd74a --- /dev/null +++ b/src/components/Presentation2.tsx @@ -0,0 +1,674 @@ +import { Button, Dialog, Overlay } from "@blueprintjs/core"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import ReactDOM from "react-dom"; +import Reveal from "reveal.js"; +import BlockErrorBoundary from "roamjs-components/components/BlockErrorBoundary"; +import getUids from "roamjs-components/dom/getUids"; +import getUidsFromButton from "roamjs-components/dom/getUidsFromButton"; +import addStyle from "roamjs-components/dom/addStyle"; +import { TreeNode } from "roamjs-components/types"; +import isControl from "roamjs-components/util/isControl"; +import { + ANIMATE_REGEX, + COLLAPSIBLE_REGEX, + TITLE_REGEX, + TRANSITION_REGEX, + VALID_THEMES, +} from "./Presentation"; +import NativeRoamContent from "./NativeRoamContent"; + +const HIDE_REGEX = /(?:\[\[{|{\[\[|{)hide(?:\]\]}|}\]\]|})/i; +const LAYOUTS = [ + "Image Left", + "Image Center", + "Image Right", + "Iframe Left", + "Iframe Center", + "Iframe Right", + "Media Left", + "Media Center", + "Media Right", +]; +const LAYOUT_REGEX = new RegExp( + `(?:\\[\\[{|{\\[\\[|{)layout:(${LAYOUTS.join("|")})(?:\\]\\]}|}\\]\\]|})`, + "is", +); +const STARTS_WITH_IMAGE = /^image /i; +const STARTS_WITH_IFRAME = /^iframe /i; +const STARTS_WITH_MEDIA = /^media /i; +const ENDS_WITH_LEFT = / left$/i; +const ENDS_WITH_CENTER = / center$/i; +const TITLE_SOURCE_REGEX = /^(?:!\[.*\]\(.*\)|{{(?:\[\[)?iframe)/i; + +type PreparedSlide = TreeNode & { + displayText: string; + isTitle: boolean; + layout: string; + collapsible: boolean; + animate: boolean; + transition?: string; + contentChildren: TreeNode[]; + note?: TreeNode; +}; + +type PresentationOptions = { + getSlides: () => TreeNode[]; + theme?: string; + notes?: string; + collapsible?: boolean; + animate?: boolean; + transition?: string; + windowId: string; +}; + +const collectHiddenUids = (nodes: TreeNode[]): string[] => + nodes.flatMap((node) => + HIDE_REGEX.test(node.text) + ? [node.uid] + : collectHiddenUids(node.children || []), + ); + +export const prepareSlides = ({ + slides, + showNotes, + globalCollapsible, + globalAnimate, + globalTransition, +}: { + slides: TreeNode[]; + showNotes: boolean; + globalCollapsible: boolean; + globalAnimate: boolean; + globalTransition?: string; +}): PreparedSlide[] => + slides + .filter((slide) => !HIDE_REGEX.test(slide.text)) + .map((slide) => { + let layout = "default"; + let collapsible = globalCollapsible; + let animate = globalAnimate; + let transition = globalTransition; + let isTitle = !slide.children.length; + const displayText = slide.text + .replace(LAYOUT_REGEX, (_, capture: string) => { + layout = capture; + return ""; + }) + .replace(COLLAPSIBLE_REGEX, (_, ignore: string) => { + collapsible = !ignore; + return ""; + }) + .replace(ANIMATE_REGEX, () => { + animate = true; + return ""; + }) + .replace(TRANSITION_REGEX, (_, value: string) => { + transition = value; + return ""; + }) + .replace(TITLE_REGEX, () => { + isTitle = true; + return ""; + }) + .trim(); + const note = showNotes + ? slide.children[slide.children.length - 1] + : undefined; + const contentChildren = showNotes + ? slide.children.slice(0, -1) + : slide.children; + + return { + ...slide, + displayText, + isTitle, + layout, + collapsible, + animate, + transition, + contentChildren, + note, + }; + }); + +const escapeAttributeValue = (value: string) => + value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + +const getBlockContainer = (element: Element | null) => + element?.closest( + ".roam-block-container, .rm-block-main, [data-block-uid]", + ) || null; + +const NativeCollapsibleNode = ({ node }: { node: TreeNode }) => { + const [expanded, setExpanded] = useState(false); + const children = node.children.filter( + (child) => !HIDE_REGEX.test(child.text), + ); + + return ( +
+
+ {!!children.length && ( + + )} + +
+ {expanded && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ); +}; + +const NativeExpandedSlideTree = ({ + slide, + hiddenUids, +}: { + slide: PreparedSlide; + hiddenUids: string[]; +}) => { + const rootRef = useRef(null); + + useEffect(() => { + const root = rootRef.current; + if (!root) return; + + const applyPresentationVisibility = () => { + const rootUid = escapeAttributeValue(slide.uid); + const rootUidElement = root.querySelector( + `[data-uid="${rootUid}"], [data-block-uid="${rootUid}"]`, + ); + const rootContainer = + getBlockContainer(rootUidElement) || + root.querySelector(".roam-block-container"); + const rootMain = + rootContainer?.querySelector(":scope > .rm-block-main") || + rootContainer?.querySelector(".rm-block-main"); + if (rootMain) rootMain.style.display = "none"; + + hiddenUids.forEach((uid) => { + const escapedUid = escapeAttributeValue(uid); + root + .querySelectorAll( + `[data-uid="${escapedUid}"], [data-block-uid="${escapedUid}"]`, + ) + .forEach((element) => { + const container = getBlockContainer(element); + if (container && container !== rootContainer) { + container.style.display = "none"; + } + }); + }); + + root.querySelectorAll("a").forEach((anchor) => { + anchor.target = "_blank"; + anchor.rel = "noreferrer"; + }); + }; + + applyPresentationVisibility(); + const observer = new MutationObserver(applyPresentationVisibility); + observer.observe(root, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [slide.uid, hiddenUids.join("|")]); + + return ( +
+ +
+ ); +}; + +const NativeSlideTree = ({ + slide, + hiddenUids, +}: { + slide: PreparedSlide; + hiddenUids: string[]; +}) => { + if (slide.collapsible) { + const hiddenUidSet = new Set(hiddenUids); + const visibleNodes = slide.contentChildren.filter( + (node) => !hiddenUidSet.has(node.uid) && !HIDE_REGEX.test(node.text), + ); + return ( +
+ {visibleNodes.map((node) => ( + + ))} +
+ ); + } + + return ; +}; + +const NativeNotes = ({ note }: { note?: TreeNode }) => + note ? ( + + ) : null; + +const NativeTitleSlide = ({ slide }: { slide: PreparedSlide }) => { + const visibleChildren = slide.contentChildren.filter( + (child) => !HIDE_REGEX.test(child.text), + ); + const props = { + style: { + textAlign: slide.textAlign || "left", + ...(TITLE_SOURCE_REGEX.test(slide.displayText) ? { bottom: 0 } : {}), + }, + ...(slide.animate ? { "data-auto-animate": true } : {}), + ...(slide.transition ? { "data-transition": slide.transition } : {}), + }; + + return ( +
+

+ +

+ {visibleChildren.map((child) => ( +

+ +

+ ))} + +
+ ); +}; + +const NativeContentSlide = ({ slide }: { slide: PreparedSlide }) => { + const isImageLayout = STARTS_WITH_IMAGE.test(slide.layout); + const isIframeLayout = STARTS_WITH_IFRAME.test(slide.layout); + const isMediaLayout = STARTS_WITH_MEDIA.test(slide.layout); + const isSourceLayout = isImageLayout || isIframeLayout || isMediaLayout; + const isLeftLayout = ENDS_WITH_LEFT.test(slide.layout); + const isCenterLayout = ENDS_WITH_CENTER.test(slide.layout); + const source = isSourceLayout ? slide.contentChildren[0] : undefined; + const hiddenUids = collectHiddenUids(slide.contentChildren).concat( + source?.uid || [], + slide.note?.uid || [], + ); + const [imageDialogSrc, setImageDialogSrc] = useState(""); + const onRootClick = useCallback((event: React.MouseEvent) => { + const target = event.target as HTMLElement; + if (target.tagName === "IMG") { + setImageDialogSrc((target as HTMLImageElement).src); + } + }, []); + const sectionProps = { + ...(slide.animate ? { "data-auto-animate": true } : {}), + ...(slide.transition ? { "data-transition": slide.transition } : {}), + }; + + return ( +
+

+ +

+
+
+ +
+ {source && ( +
+ +
+ )} +
+ + setImageDialogSrc("")} + portalClassName="roamjs-presentation-img-dialog" + style={{ paddingBottom: 0 }} + > + + +
+ ); +}; + +const scaleActiveSlide = (slidesElement: HTMLElement) => { + const container = slidesElement.querySelector( + ".present .roamjs-bullets-container", + ); + if (!container) return; + const containerHeight = container.offsetHeight; + const containerWidth = container.offsetWidth; + const content = container.firstElementChild as HTMLElement | null; + if (!content || containerHeight <= 0 || containerWidth <= 0) return; + const contentHeight = content.offsetHeight; + const contentWidth = content.offsetWidth; + if (contentHeight > containerHeight || contentWidth > containerWidth) { + const scale = Math.min( + containerHeight / contentHeight, + containerWidth / contentWidth, + ); + container.style.transform = `scale(${scale})`; + } else { + container.style.transform = "initial"; + } +}; + +const NativePresentationContent = ({ + slides, + onClose, + showNotes, + globalCollapsible, + globalAnimate, + globalTransition, + startIndex, +}: { + slides: TreeNode[]; + onClose: (index: number) => void; + showNotes: boolean; + globalCollapsible: boolean; + globalAnimate: boolean; + globalTransition?: string; + startIndex: number; +}) => { + const revealRef = useRef(null); + const slidesRef = useRef(null); + const preparedSlides = useMemo( + () => + prepareSlides({ + slides, + showNotes, + globalCollapsible, + globalAnimate, + globalTransition, + }), + [slides, showNotes, globalCollapsible, globalAnimate, globalTransition], + ); + const [initialized, setInitialized] = useState(false); + + const close = useCallback( + (event?: Event) => { + const reveal = revealRef.current; + if (!reveal) return; + reveal.getRevealElement().style.display = "none"; + const currentSlide = preparedSlides[reveal.getState().indexh]; + const actualIndex = currentSlide + ? slides.findIndex((slide) => slide.uid === currentSlide.uid) + : 0; + onClose(Math.max(0, actualIndex)); + event?.stopImmediatePropagation(); + }, + [onClose, preparedSlides, slides], + ); + + useEffect(() => { + if (!slidesRef.current) return; + const deck = new Reveal({ + embedded: true, + slideNumber: "c/t", + width: window.innerWidth * 0.9, + height: window.innerHeight * 0.9, + showNotes, + minScale: 1, + maxScale: 1, + }); + deck.initialize(); + revealRef.current = deck; + const observer = new MutationObserver(() => { + if (slidesRef.current) scaleActiveSlide(slidesRef.current); + revealRef.current?.layout?.(); + }); + observer.observe(slidesRef.current, { + attributes: true, + attributeFilter: ["class"], + childList: true, + subtree: true, + }); + setInitialized(true); + return () => { + observer.disconnect(); + (deck as Reveal & { destroy?: () => void }).destroy?.(); + }; + }, [showNotes]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + close(event); + } else if ( + isControl(event) && + event.key === "p" && + !event.shiftKey && + !event.altKey + ) { + revealRef.current.isPrintingPdf = () => true; + const injectedStyle = addStyle(`@media print { + body * { visibility: hidden; } + #roamjs-presentation-container #roamjs-reveal-root * { visibility: visible; } + #roamjs-presentation-container * { + position: absolute; + left: 0; + top: 0; + } +}`); + const onAfterPrint = () => { + injectedStyle.remove(); + window.removeEventListener("afterprint", onAfterPrint); + }; + window.addEventListener("afterprint", onAfterPrint); + window.print(); + event.preventDefault(); + } + }; + document.body.addEventListener("keydown", onKeyDown); + return () => document.body.removeEventListener("keydown", onKeyDown); + }, [close]); + + useEffect(() => { + if (!initialized || startIndex <= 0) return; + const selectedSlide = slides[startIndex]; + const actualIndex = preparedSlides.findIndex( + (slide) => slide.uid === selectedSlide?.uid, + ); + if (actualIndex >= 0) revealRef.current.slide(actualIndex); + }, [initialized, startIndex, slides, preparedSlides]); + + return ( + <> +
+
+ {preparedSlides.map((slide) => + slide.isTitle ? ( + + ) : ( + + ), + )} +
+
+