From 3066b7ba4e92f4de3daafe6c3eb76907543a0e52 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 7 Mar 2026 09:17:47 -0500 Subject: [PATCH] feat: Presentation mode (#11678) * wip * fix scaling, query string, icons, refactor * refactor --- app/actions/definitions/documents.tsx | 21 + app/components/Presentation.tsx | 32 ++ app/components/Tooltip.tsx | 4 +- app/hooks/useDocumentMenuAction.tsx | 2 + app/index.tsx | 2 + app/scenes/Document/components/DataLoader.tsx | 9 + .../Document/components/PresentationMode.tsx | 426 ++++++++++++++++++ app/scenes/KeyboardShortcuts.tsx | 9 + app/stores/UiStore.ts | 27 ++ package.json | 2 +- shared/styles/depths.ts | 2 + yarn.lock | 10 +- 12 files changed, 538 insertions(+), 8 deletions(-) create mode 100644 app/components/Presentation.tsx create mode 100644 app/scenes/Document/components/PresentationMode.tsx diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 645ff7d2e3..6280504ea7 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -32,6 +32,7 @@ import { CaseSensitiveIcon, RestoreIcon, EditIcon, + EmbedIcon, } from "outline-icons"; import { toast } from "sonner"; import Icon from "@shared/components/Icon"; @@ -944,6 +945,25 @@ export const printDocument = createAction({ }, }); +export const presentDocument = createAction({ + name: ({ t, isMenu }) => (isMenu ? t("Present") : t("Present document")), + analyticsName: "Present document", + section: ActiveDocumentSection, + icon: , + shortcut: ["Meta+Alt+p"], + visible: ({ activeDocumentId }) => !!activeDocumentId, + perform: ({ activeDocumentId, stores }) => { + const document = activeDocumentId + ? stores.documents.get(activeDocumentId) + : undefined; + if (!document) { + return; + } + + stores.ui.setPresentingDocument(document); + }, +}); + export const importDocument = createAction({ name: ({ t }) => t("Import document"), analyticsName: "Import document", @@ -1487,6 +1507,7 @@ export const rootDocumentActions = [ openRandomDocument, permanentlyDeleteDocument, permanentlyDeleteDocumentsInTrash, + presentDocument, printDocument, pinDocumentToCollection, pinDocumentToHome, diff --git a/app/components/Presentation.tsx b/app/components/Presentation.tsx new file mode 100644 index 0000000000..dd8645329c --- /dev/null +++ b/app/components/Presentation.tsx @@ -0,0 +1,32 @@ +import { observer } from "mobx-react"; +import { Suspense } from "react"; +import useStores from "~/hooks/useStores"; +import lazyWithRetry from "~/utils/lazyWithRetry"; + +const PresentationMode = lazyWithRetry( + () => import("~/scenes/Document/components/PresentationMode") +); + +function Presentation() { + const { ui } = useStores(); + + if (!ui.presentationData) { + return null; + } + + return ( + + { + ui.setPresentingDocument(null); + }} + /> + + ); +} + +export default observer(Presentation); diff --git a/app/components/Tooltip.tsx b/app/components/Tooltip.tsx index 9ab1d0888c..a674be684c 100644 --- a/app/components/Tooltip.tsx +++ b/app/components/Tooltip.tsx @@ -2,7 +2,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { transparentize } from "polished"; import * as React from "react"; import styled, { keyframes } from "styled-components"; -import { s } from "@shared/styles"; +import { s, depths } from "@shared/styles"; import useMobile from "~/hooks/useMobile"; import { useTooltipContext } from "./TooltipContext"; @@ -267,7 +267,7 @@ const StyledContent = styled(TooltipPrimitive.Content)` white-space: normal; outline: 0; padding: 5px 9px; - z-index: 9999; + z-index: ${depths.tooltip}; max-width: calc(100vw - 10px); /* Animation */ diff --git a/app/hooks/useDocumentMenuAction.tsx b/app/hooks/useDocumentMenuAction.tsx index d71e363cfb..693474d045 100644 --- a/app/hooks/useDocumentMenuAction.tsx +++ b/app/hooks/useDocumentMenuAction.tsx @@ -26,6 +26,7 @@ import { openDocumentInsights, downloadDocument, copyDocument, + presentDocument, printDocument, searchInDocument, deleteDocument, @@ -106,6 +107,7 @@ export function useDocumentMenuAction({ openDocumentComments, openDocumentHistory, openDocumentInsights, + presentDocument, downloadDocument, copyDocument, printDocument, diff --git a/app/index.tsx b/app/index.tsx index caec779f57..b9ca18abe8 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -11,6 +11,7 @@ import { Router } from "react-router-dom"; import stores from "~/stores"; import Analytics from "~/components/Analytics"; import Dialogs from "~/components/Dialogs"; +import Presentation from "~/components/Presentation"; import ErrorBoundary from "~/components/ErrorBoundary"; import PageTheme from "~/components/PageTheme"; import ScrollToTop from "~/components/ScrollToTop"; @@ -72,6 +73,7 @@ if (element) { + diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 3ce76dc8ff..8d5c1bfecf 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -16,6 +16,7 @@ import { useDocumentContext } from "~/components/DocumentContext"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; +import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; import type { Properties } from "~/types"; import Logger from "~/utils/Logger"; @@ -88,6 +89,7 @@ function DataLoader({ match, children }: Props) { const isEditing = isEditRoute || !user?.separateEditMode; const can = usePolicy(document); const location = useLocation(); + const query = useQuery(); const missingPolicy = !can || Object.keys(can).length === 0; useDocumentSidebar(); @@ -205,6 +207,13 @@ function DataLoader({ match, children }: Props) { revisionId, ]); + // Auto-enter presentation mode when ?present=true query param is set + React.useEffect(() => { + if (document && query.has("present") && !ui.presentationData) { + ui.setPresentingDocument(document); + } + }, [document, query, ui]); + if (error) { return error instanceof OfflineError ? ( diff --git a/app/scenes/Document/components/PresentationMode.tsx b/app/scenes/Document/components/PresentationMode.tsx new file mode 100644 index 0000000000..1ad215be66 --- /dev/null +++ b/app/scenes/Document/components/PresentationMode.tsx @@ -0,0 +1,426 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { ShrinkIcon, GrowIcon, CloseIcon } from "outline-icons"; +import styled, { useTheme } from "styled-components"; +import Icon from "@shared/components/Icon"; +import { richExtensions } from "@shared/editor/nodes"; +import { s, depths, hover } from "@shared/styles"; +import type { ProsemirrorData } from "@shared/types"; +import { colorPalette } from "@shared/utils/collections"; +import Editor from "~/components/Editor"; +import NudeButton from "~/components/NudeButton"; +import Text from "~/components/Text"; +import Flex from "~/components/Flex"; +import Tooltip from "~/components/Tooltip"; +import useIdle from "~/hooks/useIdle"; +import useKeyDown from "~/hooks/useKeyDown"; +import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon"; + +/** Activity events that reset the idle timer — excludes keyboard to stay idle during navigation. */ +const idleEvents = [ + "click", + "mousemove", + "mousedown", + "touchstart", + "touchmove", +]; + +type Slide = + | { + type: "title"; + title: string; + icon?: string | null; + iconColor?: string | null; + } + | { type: "content"; content: ProsemirrorData[] }; + +interface Props { + /** The document title. */ + title: string; + /** The document icon. */ + icon?: string | null; + /** The document icon color. */ + iconColor?: string | null; + /** The prosemirror data for the document. */ + data: ProsemirrorData; + /** Callback when presentation mode is closed. */ + onClose: () => void; +} + +/** + * Splits a ProseMirror document into slides based on heading and divider nodes. + * A dedicated title slide is prepended. Each h1/h2 heading or horizontal rule + * starts a new content slide. Divider nodes are consumed as separators and not + * rendered on slides. + * + * @param data the prosemirror document data. + * @param title the document title. + * @param icon the document icon. + * @param iconColor the document icon color. + * @returns an array of slides. + */ +function splitIntoSlides( + data: ProsemirrorData, + title: string, + icon?: string | null, + iconColor?: string | null +): Slide[] { + const content = data.content ?? []; + const slides: Slide[] = [{ type: "title", title, icon, iconColor }]; + let currentNodes: ProsemirrorData[] = []; + + for (const node of content) { + const isDivider = node.type === "horizontal_rule" || node.type === "hr"; + const isHeadingBreak = + node.type === "heading" && + node.attrs && + typeof node.attrs.level === "number" && + node.attrs.level <= 2; + + if (isDivider) { + if (currentNodes.length > 0) { + slides.push({ type: "content", content: currentNodes }); + currentNodes = []; + } + continue; + } + + if (isHeadingBreak && currentNodes.length > 0) { + slides.push({ type: "content", content: currentNodes }); + currentNodes = []; + } + + currentNodes.push(node); + } + + if (currentNodes.length > 0) { + slides.push({ type: "content", content: currentNodes }); + } + + return slides; +} + +/** + * Full-screen presentation mode that splits a document into slides by headings + * and dividers, and allows navigating through them with keyboard controls. + */ +function PresentationMode({ title, icon, iconColor, data, onClose }: Props) { + const { t } = useTranslation(); + const theme = useTheme(); + const [currentSlide, setCurrentSlide] = React.useState(0); + const containerRef = React.useRef(null); + const slideContentRef = React.useRef(null); + const [isFullscreen, setIsFullscreen] = React.useState(false); + const isIdle = useIdle(3000, idleEvents); + + const slides = React.useMemo( + () => splitIntoSlides(data, title, icon, iconColor), + [data, title, icon, iconColor] + ); + + const totalSlides = slides.length; + + const goNext = React.useCallback(() => { + setCurrentSlide((prev) => Math.min(prev + 1, totalSlides - 1)); + }, [totalSlides]); + + const goPrev = React.useCallback(() => { + setCurrentSlide((prev) => Math.max(prev - 1, 0)); + }, []); + + const goFirst = React.useCallback(() => { + setCurrentSlide(0); + }, []); + + const goLast = React.useCallback(() => { + setCurrentSlide(totalSlides - 1); + }, [totalSlides]); + + const toggleFullscreen = React.useCallback(() => { + const el = containerRef.current; + if (!el) { + return; + } + + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => { + // ignore + }); + } else { + el.requestFullscreen().catch(() => { + // ignore + }); + } + }, []); + + useKeyDown("Escape", onClose); + useKeyDown("ArrowRight", goNext); + useKeyDown("ArrowDown", goNext); + useKeyDown("PageDown", goNext); + useKeyDown("ArrowLeft", goPrev); + useKeyDown("ArrowUp", goPrev); + useKeyDown("PageUp", goPrev); + useKeyDown("Home", goFirst); + useKeyDown("End", goLast); + useKeyDown(" ", goNext); + useKeyDown("f", toggleFullscreen); + + // Prevent body scrolling while presentation is open + React.useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, []); + + // Track fullscreen state changes + React.useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => { + document.removeEventListener("fullscreenchange", handleFullscreenChange); + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => { + // ignore + }); + } + }; + }, []); + + // Measure natural size once per slide, then apply scale directly to the DOM + // to avoid React re-render loops during window resize. + const naturalSize = React.useRef({ width: 0, height: 0 }); + + React.useEffect(() => { + const el = slideContentRef.current; + const container = containerRef.current; + if (!el || !container) { + return; + } + + const applyScale = () => { + const { width, height } = naturalSize.current; + if (width === 0 || height === 0) { + el.style.transform = "scale(1)"; + return; + } + + const availableWidth = container.clientWidth - 160; + const availableHeight = container.clientHeight - 48 - 160; + const scaleX = availableWidth / width; + const scaleY = availableHeight / height; + const newScale = Math.min(scaleX, scaleY, 1.5); + el.style.transform = `scale(${Math.max(newScale, 0.5)})`; + }; + + // Measure natural size with scale removed, then apply + el.style.transform = "none"; + requestAnimationFrame(() => { + naturalSize.current = { + width: el.scrollWidth, + height: el.scrollHeight, + }; + applyScale(); + window.addEventListener("resize", applyScale); + }); + + return () => { + window.removeEventListener("resize", applyScale); + }; + }, [currentSlide]); + + const slide = slides[currentSlide]; + + const slideData: ProsemirrorData | undefined = React.useMemo( + () => + slide.type === "content" + ? { type: "doc", content: slide.content } + : undefined, + [slide] + ); + + const extensions = React.useMemo(() => richExtensions, []); + + return createPortal( + + + + + + + + {currentSlide + 1} / {totalSlides} + + + + + + + + + + + + + + + + + {slide.type === "title" ? ( + + {slide.icon && ( + + + + )} + {slide.title} + + ) : slideData ? ( + + ) : null} + + + , + document.body + ); +} + +const Container = styled.div<{ $background: string; $idle: boolean }>` + position: fixed; + inset: 0; + z-index: ${depths.presentation}; + background: ${(props) => props.$background}; + display: flex; + flex-direction: column; + user-select: none; + cursor: ${(props) => (props.$idle ? "none" : "default")}; + + * { + cursor: inherit; + } +`; + +const SlideArea = styled.div` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 80px; +`; + +const SlideContent = styled.div` + max-width: 960px; + width: 100%; + transform-origin: center center; + + .ProseMirror { + padding: 0; + font-size: 1.4em; + } + + h1 { + font-size: 2.4em; + } + h2 { + font-size: 1.8em; + } + h3 { + font-size: 1.4em; + } +`; + +const TitleSlide = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 24px; + min-height: 200px; +`; + +const TitleIcon = styled.div` + flex-shrink: 0; +`; + +const TitleText = styled.h1` + font-size: 3em; + font-weight: 600; + line-height: 1.25; + margin: 0; + color: ${s("text")}; +`; + +const TopBar = styled.div<{ $idle: boolean }>` + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + position: relative; + opacity: ${(props) => (props.$idle ? 0 : 1)}; + transition: opacity 300ms ease; +`; + +const SlideCounter = styled(Text)` + font-variant-numeric: tabular-nums; + color: ${s("textTertiary")}; + font-size: 14px; + min-width: 60px; + text-align: center; +`; + +const RightButtons = styled(Flex).attrs({ align: "center", gap: 16 })` + position: absolute; + right: 16px; +`; + +const Button = styled(NudeButton).attrs({ size: 32 })` + &:not(:disabled) { + color: ${s("textTertiary")}; + + &:${hover}, + &:active { + color: ${s("text")}; + } + } + + &:disabled { + color: ${s("textTertiary")}; + opacity: 0.5; + } +`; + +export default PresentationMode; diff --git a/app/scenes/KeyboardShortcuts.tsx b/app/scenes/KeyboardShortcuts.tsx index 75f212763a..d72b828856 100644 --- a/app/scenes/KeyboardShortcuts.tsx +++ b/app/scenes/KeyboardShortcuts.tsx @@ -108,6 +108,15 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) { ), label: t("Go to link"), }, + { + shortcut: ( + <> + {metaDisplay} + {altDisplay}{" "} + + p + + ), + label: t("Present document"), + }, { shortcut: ( <> diff --git a/app/stores/UiStore.ts b/app/stores/UiStore.ts index 36cda31c2b..0f7ca7d10c 100644 --- a/app/stores/UiStore.ts +++ b/app/stores/UiStore.ts @@ -1,6 +1,7 @@ import { action, computed, observable } from "mobx"; import { flushSync } from "react-dom"; import { light as defaultTheme } from "@shared/styles/theme"; +import type { ProsemirrorData } from "@shared/types"; import Storage from "@shared/utils/Storage"; import Document from "~/models/Document"; import type Model from "~/models/base/Model"; @@ -92,6 +93,32 @@ class UiStore { @observable debugSafeArea = false; + /** Data for the currently active presentation, if any. */ + @observable + presentationData: { + title: string; + icon?: string | null; + color?: string | null; + data: ProsemirrorData; + } | null = null; + + /** + * Enter presentation mode for the given document. + * + * @param document the document to present, or null to exit. + */ + @action + setPresentingDocument = (document: Document | null): void => { + this.presentationData = document + ? { + title: document.title, + icon: document.icon, + color: document.color, + data: document.data, + } + : null; + }; + /** Tracks active export toasts for in-place updates when export completes */ exportToasts = observable.map< string, diff --git a/package.json b/package.json index e9f2496f95..928d7a6456 100644 --- a/package.json +++ b/package.json @@ -180,7 +180,7 @@ "node-fetch": "2.7.0", "nodemailer": "^7.0.11", "octokit": "^3.2.2", - "outline-icons": "^4.1.0", + "outline-icons": "^4.2.0", "oy-vey": "^0.12.1", "pako": "^2.1.0", "passport": "^0.7.0", diff --git a/shared/styles/depths.ts b/shared/styles/depths.ts index 862c66ce15..92003cbfeb 100644 --- a/shared/styles/depths.ts +++ b/shared/styles/depths.ts @@ -14,6 +14,8 @@ const depths = { titleBarDivider: 10000, loadingIndicatorBar: 20000, commandBar: 30000, + presentation: 40000, + tooltip: 50000, }; export default depths; diff --git a/yarn.lock b/yarn.lock index 06676cc986..c52a53fe0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17580,12 +17580,12 @@ __metadata: languageName: node linkType: hard -"outline-icons@npm:^4.1.0": - version: 4.1.0 - resolution: "outline-icons@npm:4.1.0" +"outline-icons@npm:^4.2.0": + version: 4.2.0 + resolution: "outline-icons@npm:4.2.0" peerDependencies: react: ^17.0.0 || ^18.0.0 - checksum: 10c0/883c72d53b3b81e71748c0abd60f164034e86f9723b538c211fc437aa2c8efbfce3b1fe1d41bc608ed402f09142ad8383b1bde2506266a7116a949b575664d04 + checksum: 10c0/e607c1542e99aa675c1268763e6a442fc74f2aefacb81aa6a3a318fa853c76c062e288a9e03d963a1c2b90c2e06a40028b4c6c1ae319d0d1fe93cf8d7feb25a1 languageName: node linkType: hard @@ -17811,7 +17811,7 @@ __metadata: nodemailer: "npm:^7.0.11" nodemon: "npm:^3.1.11" octokit: "npm:^3.2.2" - outline-icons: "npm:^4.1.0" + outline-icons: "npm:^4.2.0" oxlint: "npm:1.11.2" oxlint-tsgolint: "npm:^0.1.6" oy-vey: "npm:^0.12.1"