mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd261e784 | ||
|
|
36a0c49f35 | ||
|
|
107e7b3776 | ||
|
|
6556a1b5a0 |
@@ -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",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
.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;
|
||||
align-items: center;
|
||||
padding: 6vh 5vw 10vh;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
overflow-y: auto;
|
||||
align-items: flex-start;
|
||||
zoom: 0.85;
|
||||
}
|
||||
|
||||
.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,337 @@
|
||||
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";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
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",
|
||||
]);
|
||||
|
||||
const isEmptyJsonNode = (node: JSONContent) =>
|
||||
node?.type === "paragraph" && !node.text?.trim() && !node.content?.length;
|
||||
|
||||
const isDivider = (node: JSONContent) => node.type === "horizontalRule";
|
||||
|
||||
const isSectionHeading = (node: JSONContent) =>
|
||||
node.type === "heading" && node.attrs?.level === 1;
|
||||
|
||||
const isEndOfSection = (node: JSONContent) =>
|
||||
node.type === "base" || node.type === "table";
|
||||
|
||||
function getAvailableHeight(area: HTMLElement): number {
|
||||
const styles = window.getComputedStyle(area);
|
||||
return (
|
||||
area.clientHeight -
|
||||
parseFloat(styles.paddingTop) -
|
||||
parseFloat(styles.paddingBottom)
|
||||
);
|
||||
}
|
||||
|
||||
export default function PresentationModal({
|
||||
title,
|
||||
content,
|
||||
opened,
|
||||
onClose,
|
||||
}: PresentationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
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) {
|
||||
onClose();
|
||||
}
|
||||
}, [pageSlug]);
|
||||
|
||||
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 renderedNodes = Array.from(
|
||||
editor.view.dom.children
|
||||
) as HTMLElement[];
|
||||
if (!renderedNodes.length) {
|
||||
setContentSlides([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const availableHeight = getAvailableHeight(area);
|
||||
const slides: MeasuredContentSlide[] = [];
|
||||
let currentSlide: JSONContent[] = [];
|
||||
let currentSlideTop = 0;
|
||||
let currentSlideBottom = 0;
|
||||
|
||||
const saveCurrentSlide = () => {
|
||||
if (!currentSlide.length) return;
|
||||
|
||||
slides.push({
|
||||
doc: { type: "doc", content: currentSlide },
|
||||
scrollable: currentSlideBottom - currentSlideTop > availableHeight,
|
||||
});
|
||||
currentSlide = [];
|
||||
};
|
||||
|
||||
for (let index = 0; index < renderedNodes.length; index++) {
|
||||
const node = nodes[index];
|
||||
const renderedNode = renderedNodes[index];
|
||||
if (!renderedNode || isEmptyJsonNode(node)) continue;
|
||||
|
||||
const rect = renderedNode.getBoundingClientRect();
|
||||
|
||||
if (isDivider(node)) {
|
||||
saveCurrentSlide();
|
||||
currentSlideTop = rect.bottom;
|
||||
currentSlideBottom = rect.bottom;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentSlide.length === 0) {
|
||||
currentSlideTop = rect.top;
|
||||
} else if (
|
||||
isSectionHeading(node) ||
|
||||
rect.bottom - currentSlideTop > availableHeight
|
||||
) {
|
||||
saveCurrentSlide();
|
||||
currentSlideTop = rect.top;
|
||||
}
|
||||
|
||||
currentSlide.push(node);
|
||||
currentSlideBottom = rect.bottom;
|
||||
|
||||
if (isEndOfSection(node)) {
|
||||
saveCurrentSlide();
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
opened
|
||||
? [
|
||||
["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];
|
||||
const scrollable = currentSlide.kind === "content" && currentSlide.scrollable;
|
||||
|
||||
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
|
||||
onClick={goToNext}
|
||||
className={`${classes.slideArea} ${
|
||||
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,
|
||||
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) {
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!page?.isBase && (
|
||||
<Menu.Item
|
||||
leftSection={<IconPresentation size={16} />}
|
||||
onClick={openPresentationModal}
|
||||
>
|
||||
{t("Presentation mode")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!readOnly && !page?.isBase && (
|
||||
<PageVerificationMenuItem
|
||||
pageId={page?.id}
|
||||
@@ -416,6 +439,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
open={attachmentsOpened}
|
||||
onClose={closeAttachmentsModal}
|
||||
/>
|
||||
|
||||
<PresentationModal
|
||||
title={page.title}
|
||||
content={presentationContent}
|
||||
opened={presentationOpened}
|
||||
onClose={closePresentationModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user