page presentation init

This commit is contained in:
Salihu
2026-09-08 23:26:25 +01:00
parent cd9c166927
commit 6556a1b5a0
4 changed files with 417 additions and 0 deletions
@@ -161,6 +161,11 @@
"Pending": "Pending", "Pending": "Pending",
"Please confirm your action": "Please confirm your action", "Please confirm your action": "Please confirm your action",
"Preferences": "Preferences", "Preferences": "Preferences",
"Presentation mode": "Presentation mode",
"Previous slide": "Previous slide",
"Next slide": "Next slide",
"Exit presentation mode": "Exit presentation mode",
"Slide {{current}} of {{total}}": "Slide {{current}} of {{total}}",
"Print PDF": "Print PDF", "Print PDF": "Print PDF",
"Profile": "Profile", "Profile": "Profile",
"Recently updated": "Recently updated", "Recently updated": "Recently updated",
@@ -0,0 +1,91 @@
.content {
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
overflow: hidden;
}
.body {
position: relative;
height: 100vh;
display: flex;
flex-direction: column;
padding: 0;
}
.closeButton {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
}
.slideArea {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
justify-content: center;
padding: 6vh 5vw 10vh;
}
.scrollable {
overflow-y: auto;
}
.measurementArea {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
justify-content: center;
padding: 6vh 5vw 10vh;
overflow: visible;
visibility: hidden;
pointer-events: none;
}
.slideArea :global(.ProseMirror) {
width: 100%;
max-width: 900px;
font-size: 1.3rem;
line-height: 1.6;
}
.slideArea :global(.ProseMirror) h1 {
font-size: 3rem;
}
.slideArea :global(.ProseMirror) h2 {
font-size: 2.25rem;
}
.slideArea :global(.ProseMirror) h3 {
font-size: 1.75rem;
}
.titleSlide {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 900px;
text-align: center;
}
.titleSlideText {
font-size: 3.5rem;
font-weight: 700;
}
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 12px 0 20px;
}
.slideCounter {
min-width: 100px;
text-align: center;
}
@@ -0,0 +1,291 @@
import "@/features/editor/styles/index.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActionIcon, Box, Modal, Text, Title, Tooltip } from "@mantine/core";
import { IconChevronLeft, IconChevronRight, IconX } from "@tabler/icons-react";
import { useHotkeys } from "@mantine/hooks";
import { EditorProvider, type JSONContent } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import classes from "./presentation-modal.module.css";
type PresentationSlide =
| { kind: "title" }
| { kind: "content"; doc: JSONContent; scrollable: boolean };
type MeasuredContentSlide = {
doc: JSONContent;
scrollable: boolean;
};
interface PresentationModalProps {
title: string;
content: JSONContent;
opened: boolean;
onClose: () => void;
}
const excludedExtensionNames = new Set([
"uniqueID",
"tableHeaderPin",
"tableReadonlySort",
]);
export default function PresentationModal({
title,
content,
opened,
onClose,
}: PresentationModalProps) {
const { t } = useTranslation();
const [slideIndex, setSlideIndex] = useState(0);
const [contentSlides, setContentSlides] = useState<MeasuredContentSlide[] | null>(
null
);
const slideAreaRef = useRef<HTMLDivElement>(null);
const extensions = useMemo(
() => mainExtensions.filter((ext) => !excludedExtensionNames.has(ext.name)),
[]
);
useEffect(() => {
if (opened) {
setSlideIndex(0);
setContentSlides(null);
}
}, [opened, content]);
useEffect(() => {
if (!opened || !slideAreaRef.current) return;
const slideArea = slideAreaRef.current;
let previousWidth = slideArea.clientWidth;
let previousHeight = slideArea.clientHeight;
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
if (width === previousWidth && height === previousHeight) {
return;
}
previousWidth = width;
previousHeight = height;
setSlideIndex(0);
setContentSlides(null);
});
observer.observe(slideArea);
return () => observer.disconnect();
}, [opened]);
const slides: PresentationSlide[] = useMemo(
() => [
{ kind: "title" as const },
...(contentSlides ?? []).map(({ doc, scrollable }) => ({
kind: "content" as const,
doc,
scrollable,
})),
],
[contentSlides]
);
const paginateContent = useCallback(
(editor: { view: { dom: HTMLElement } }) => {
const nodes = content.content ?? [];
const area = slideAreaRef.current;
if (!nodes.length || !area) {
setContentSlides([]);
return;
}
const editorElement = editor.view.dom;
const renderedNodes = Array.from(editorElement.children) as HTMLElement[];
const areaStyles = window.getComputedStyle(area);
const availableHeight =
area.clientHeight -
parseFloat(areaStyles.paddingTop) -
parseFloat(areaStyles.paddingBottom);
const slides: MeasuredContentSlide[] = [];
let currentSlide: JSONContent[] = [];
let currentSlideTop = renderedNodes[0].getBoundingClientRect().top;
let currentSlideBottom = currentSlideTop;
const saveCurrentSlide = () => {
if (currentSlide.length === 0) return;
slides.push({
doc: { type: "doc", content: currentSlide },
scrollable: currentSlideBottom - currentSlideTop > availableHeight,
});
};
renderedNodes.forEach((renderedNode, index) => {
const node = nodes[index];
const nodeRect = renderedNode.getBoundingClientRect();
const startsNewSection =
node.type === "horizontalRule" ||
(node.type === "heading" && node.attrs?.level === 1);
const exceedsPage =
currentSlide.length > 0 &&
nodeRect.bottom - currentSlideTop > availableHeight;
if (currentSlide.length > 0 && (startsNewSection || exceedsPage)) {
saveCurrentSlide();
currentSlide = [];
currentSlideTop = nodeRect.top;
}
currentSlide.push(node);
currentSlideBottom = nodeRect.bottom;
});
saveCurrentSlide();
setContentSlides(slides);
},
[content]
);
const goToNext = useCallback(() => {
setSlideIndex((index) => Math.min(index + 1, slides.length - 1));
}, [slides.length]);
const goToPrevious = useCallback(() => {
setSlideIndex((index) => Math.max(index - 1, 0));
}, []);
const goToFirst = useCallback(() => {
setSlideIndex(0);
}, []);
const goToLast = useCallback(() => {
setSlideIndex(slides.length - 1);
}, [slides.length]);
useHotkeys([
["ArrowRight", goToNext, { preventDefault: true }],
["ArrowDown", goToNext, { preventDefault: true }],
["PageDown", goToNext, { preventDefault: true }],
["space", goToNext, { preventDefault: true }],
["ArrowLeft", goToPrevious, { preventDefault: true }],
["ArrowUp", goToPrevious, { preventDefault: true }],
["PageUp", goToPrevious, { preventDefault: true }],
["Home", goToFirst, { preventDefault: true }],
["End", goToLast, { preventDefault: true }],
]);
const currentSlide = slides[slideIndex];
return (
<Modal.Root
opened={opened}
onClose={onClose}
fullScreen
closeOnEscape
transitionProps={{ duration: 150 }}
>
<Modal.Overlay />
<Modal.Content className={classes.content}>
<Modal.Body className={classes.body}>
<Tooltip label={t("Exit presentation mode")} openDelay={250}>
<ActionIcon
className={classes.closeButton}
variant="subtle"
color="gray"
size="lg"
aria-label={t("Exit presentation mode")}
onClick={onClose}
>
<IconX size={22} />
</ActionIcon>
</Tooltip>
<Box
className={`${classes.slideArea} ${
currentSlide?.kind === "content" && currentSlide.scrollable
? classes.scrollable
: ""
}`}
ref={slideAreaRef}
>
{opened && contentSlides === null && (
<div className={classes.measurementArea} aria-hidden="true">
<TransclusionLookupProvider>
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content}
onCreate={({ editor }) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => paginateContent(editor));
});
}}
></EditorProvider>
</TransclusionLookupProvider>
</div>
)}
{currentSlide?.kind === "title" ? (
<div className={classes.titleSlide}>
<Title order={1} className={classes.titleSlideText}>
{title || t("Untitled")}
</Title>
</div>
) : (
currentSlide && (
<TransclusionLookupProvider>
<EditorProvider
key={slideIndex}
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={currentSlide.doc}
></EditorProvider>
</TransclusionLookupProvider>
)
)}
</Box>
<div className={classes.controls}>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
aria-label={t("Previous slide")}
disabled={slideIndex === 0}
onClick={goToPrevious}
>
<IconChevronLeft size={22} />
</ActionIcon>
<Text size="sm" c="dimmed" className={classes.slideCounter}>
{t("Slide {{current}} of {{total}}", {
current: slideIndex + 1,
total: slides.length,
})}
</Text>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
aria-label={t("Next slide")}
disabled={slideIndex === slides.length - 1}
onClick={goToNext}
>
<IconChevronRight size={22} />
</ActionIcon>
</div>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -12,6 +12,7 @@ import {
IconMarkdown, IconMarkdown,
IconMessage, IconMessage,
IconPaperclip, IconPaperclip,
IconPresentation,
IconPrinter, IconPrinter,
IconStar, IconStar,
IconStarFilled, IconStarFilled,
@@ -44,6 +45,7 @@ import { formattedDate } from "@/lib/time.ts";
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx"; import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx"; import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
import PresentationModal from "@/features/editor/components/presentation/presentation-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission"; import { PageShareModal } from "@/ee/page-permission";
import { import {
@@ -163,6 +165,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
attachmentsOpened, attachmentsOpened,
{ open: openAttachmentsModal, close: closeAttachmentsModal }, { open: openAttachmentsModal, close: closeAttachmentsModal },
] = useDisclosure(false); ] = useDisclosure(false);
const [
presentationOpened,
{ open: openPresentationModal, close: closePresentationModal },
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom); const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt); const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page", page?.spaceId); const favoriteIds = useFavoriteIds("page", page?.spaceId);
@@ -204,6 +210,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
openDeleteModal({ onConfirm: () => handleDelete(page.id) }); openDeleteModal({ onConfirm: () => handleDelete(page.id) });
}; };
const presentationContent = React.useMemo(
() => (presentationOpened ? pageEditor?.getJSON() : null) ?? {
type: "doc",
content: [],
},
[presentationOpened, pageEditor],
);
const handleToggleFavorite = () => { const handleToggleFavorite = () => {
if (!page?.id) return; if (!page?.id) return;
const params = { type: "page" as const, pageId: page.id }; const params = { type: "page" as const, pageId: page.id };
@@ -308,6 +322,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Item> </Menu.Item>
)} )}
{!page?.isBase && (
<Menu.Item
leftSection={<IconPresentation size={16} />}
onClick={openPresentationModal}
>
{t("Presentation mode")}
</Menu.Item>
)}
{!readOnly && !page?.isBase && ( {!readOnly && !page?.isBase && (
<PageVerificationMenuItem <PageVerificationMenuItem
pageId={page?.id} pageId={page?.id}
@@ -416,6 +439,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
open={attachmentsOpened} open={attachmentsOpened}
onClose={closeAttachmentsModal} onClose={closeAttachmentsModal}
/> />
<PresentationModal
title={page.title}
content={presentationContent}
opened={presentationOpened}
onClose={closePresentationModal}
/>
</> </>
); );
} }