mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
page presentation init
This commit is contained in:
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user