diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 03e10c53e..5348a81a3 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -161,6 +161,11 @@ "Pending": "Pending", "Please confirm your action": "Please confirm your action", "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", "Profile": "Profile", "Recently updated": "Recently updated", diff --git a/apps/client/src/features/editor/components/presentation/presentation-modal.module.css b/apps/client/src/features/editor/components/presentation/presentation-modal.module.css new file mode 100644 index 000000000..93a36f1cd --- /dev/null +++ b/apps/client/src/features/editor/components/presentation/presentation-modal.module.css @@ -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; +} diff --git a/apps/client/src/features/editor/components/presentation/presentation-modal.tsx b/apps/client/src/features/editor/components/presentation/presentation-modal.tsx new file mode 100644 index 000000000..f31aee8b8 --- /dev/null +++ b/apps/client/src/features/editor/components/presentation/presentation-modal.tsx @@ -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( + null + ); + const slideAreaRef = useRef(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 ( + + + + + + + + + + + + {opened && contentSlides === null && ( + + )} + + {currentSlide?.kind === "title" ? ( +
+ + {title || t("Untitled")} + +
+ ) : ( + currentSlide && ( + + + + ) + )} +
+ +
+ + + + + + {t("Slide {{current}} of {{total}}", { + current: slideIndex + 1, + total: slides.length, + })} + + + + + +
+
+
+
+ ); +} diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 9b02a4596..a01bb66fa 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -12,6 +12,7 @@ import { IconMarkdown, IconMessage, IconPaperclip, + IconPresentation, IconPrinter, IconStar, IconStarFilled, @@ -44,6 +45,7 @@ import { formattedDate } from "@/lib/time.ts"; import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx"; import MovePageModal from "@/features/page/components/move-page-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 { PageShareModal } from "@/ee/page-permission"; import { @@ -163,6 +165,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { attachmentsOpened, { open: openAttachmentsModal, close: closeAttachmentsModal }, ] = useDisclosure(false); + const [ + presentationOpened, + { open: openPresentationModal, close: closePresentationModal }, + ] = useDisclosure(false); const [pageEditor] = useAtom(pageEditorAtom); const pageUpdatedAt = useTimeAgo(page?.updatedAt); const favoriteIds = useFavoriteIds("page", page?.spaceId); @@ -204,6 +210,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { openDeleteModal({ onConfirm: () => handleDelete(page.id) }); }; + const presentationContent = React.useMemo( + () => (presentationOpened ? pageEditor?.getJSON() : null) ?? { + type: "doc", + content: [], + }, + [presentationOpened, pageEditor], + ); + const handleToggleFavorite = () => { if (!page?.id) return; const params = { type: "page" as const, pageId: page.id }; @@ -308,6 +322,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { )} + {!page?.isBase && ( + } + onClick={openPresentationModal} + > + {t("Presentation mode")} + + )} + {!readOnly && !page?.isBase && ( + + ); }