feat: Presentation mode (#11678)

* wip

* fix scaling, query string, icons, refactor

* refactor
This commit is contained in:
Tom Moor
2026-03-07 09:17:47 -05:00
committed by GitHub
parent aeb6d12f17
commit 3066b7ba4e
12 changed files with 538 additions and 8 deletions
+21
View File
@@ -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: <EmbedIcon />,
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,
+32
View File
@@ -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 (
<Suspense fallback={null}>
<PresentationMode
title={ui.presentationData.title}
icon={ui.presentationData.icon}
iconColor={ui.presentationData.color}
data={ui.presentationData.data}
onClose={() => {
ui.setPresentingDocument(null);
}}
/>
</Suspense>
);
}
export default observer(Presentation);
+2 -2
View File
@@ -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 */
+2
View File
@@ -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,
+2
View File
@@ -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) {
</ScrollToTop>
<Toasts />
<Dialogs />
<Presentation />
<Desktop />
</PageScroll>
</LazyMotion>
@@ -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<LocationState>();
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 ? (
<ErrorOffline />
@@ -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<HTMLDivElement>(null);
const slideContentRef = React.useRef<HTMLDivElement>(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(
<Container ref={containerRef} $background={theme.background} $idle={isIdle}>
<TopBar $idle={isIdle}>
<Flex align="center" gap={12}>
<Tooltip content={t("Previous slide")} delay={500}>
<Button onClick={goPrev} disabled={currentSlide === 0}>
<ArrowLeftIcon />
</Button>
</Tooltip>
<SlideCounter>
{currentSlide + 1} / {totalSlides}
</SlideCounter>
<Tooltip content={t("Next slide")} delay={500}>
<Button
onClick={goNext}
disabled={currentSlide === totalSlides - 1}
>
<ArrowRightIcon color="currentColor" />
</Button>
</Tooltip>
</Flex>
<RightButtons>
<Tooltip content={t("Toggle fullscreen")} delay={500}>
<Button onClick={toggleFullscreen}>
{isFullscreen ? (
<ShrinkIcon color="currentColor" />
) : (
<GrowIcon color="currentColor" />
)}
</Button>
</Tooltip>
<Tooltip content={t("Close")} delay={500}>
<Button onClick={onClose}>
<CloseIcon />
</Button>
</Tooltip>
</RightButtons>
</TopBar>
<SlideArea onClick={goNext}>
<SlideContent ref={slideContentRef}>
{slide.type === "title" ? (
<TitleSlide>
{slide.icon && (
<TitleIcon>
<Icon
value={slide.icon}
color={slide.iconColor ?? colorPalette[0]}
size={64}
initial={slide.title[0]}
/>
</TitleIcon>
)}
<TitleText>{slide.title}</TitleText>
</TitleSlide>
) : slideData ? (
<Editor
key={currentSlide}
defaultValue={slideData}
extensions={extensions}
readOnly
grow={false}
placeholder=""
/>
) : null}
</SlideContent>
</SlideArea>
</Container>,
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;
+9
View File
@@ -108,6 +108,15 @@ function KeyboardShortcuts({ defaultQuery = "" }: Props) {
),
label: t("Go to link"),
},
{
shortcut: (
<>
<Key symbol>{metaDisplay}</Key> + <Key symbol>{altDisplay}</Key>{" "}
+ <Key>p</Key>
</>
),
label: t("Present document"),
},
{
shortcut: (
<>
+27
View File
@@ -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,
+1 -1
View File
@@ -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",
+2
View File
@@ -14,6 +14,8 @@ const depths = {
titleBarDivider: 10000,
loadingIndicatorBar: 20000,
commandBar: 30000,
presentation: 40000,
tooltip: 50000,
};
export default depths;
+5 -5
View File
@@ -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"