- support diagrams

- support readonly mode
- use softer backdrop
- other enhancments
This commit is contained in:
Philipinho
2026-08-25 02:38:37 +01:00
parent 0e98064484
commit 18af60162f
14 changed files with 286 additions and 142 deletions
@@ -295,8 +295,6 @@
"Export {{type}}": "Export {{type}}", "Export {{type}}": "Export {{type}}",
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
"Media": "Media", "Media": "Media",
"Open image": "Open image",
"Open video": "Open video",
"Align left": "Align left", "Align left": "Align left",
"Align right": "Align right", "Align right": "Align right",
"Align center": "Align center", "Align center": "Align center",
@@ -23,11 +23,21 @@ import {
} from "@/features/comment/atoms/comment-atom"; } from "@/features/comment/atoms/comment-atom";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { v7 as uuid7 } from "uuid"; import { v7 as uuid7 } from "uuid";
import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext"; import {
isCellSelection,
isEditorReady,
isTextSelected,
} from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms"; import {
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom"; showAiMenuAtom,
showLinkMenuAtom,
} from "@/features/editor/atoms/editor-atoms";
import {
userAtom,
workspaceAtom,
} from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem { export interface BubbleMenuItem {
name: string; name: string;
@@ -217,7 +227,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<ActionIcon.Group> <ActionIcon.Group>
{items.map((item, index) => ( {items.map((item, index) => (
<Tooltip key={index} label={t(item.name)} withArrow> <Tooltip
key={index}
label={t(item.name)}
withArrow
withinPortal={false}
>
<ActionIcon <ActionIcon
key={index} key={index}
variant="default" variant="default"
@@ -226,7 +241,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
aria-label={t(item.name)} aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })} className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }} style={{ border: "none" }}
onClick={() => isEditorReady(props.editor) && item.command()} onClick={() =>
isEditorReady(props.editor) && item.command()
}
> >
<item.icon style={{ width: rem(16) }} stroke={2} /> <item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon> </ActionIcon>
@@ -256,7 +273,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
radius="6px" radius="6px"
aria-label={t(commentItem.name)} aria-label={t(commentItem.name)}
style={{ border: "none" }} style={{ border: "none" }}
onClick={() => isEditorReady(props.editor) && commentItem.command()} onClick={() =>
isEditorReady(props.editor) && commentItem.command()
}
> >
<IconMessage size={16} stroke={2} /> <IconMessage size={16} stroke={2} />
</ActionIcon> </ActionIcon>
@@ -129,8 +129,7 @@ function handleColorKeyNav(
grid: "text" | "highlight", grid: "text" | "highlight",
) { ) {
const cols = COLOR_GRID_COLS; const cols = COLOR_GRID_COLS;
const total = const total = grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
const col = index % cols; const col = index % cols;
if (e.key === "ArrowRight") { if (e.key === "ArrowRight") {
@@ -163,8 +162,7 @@ function handleColorKeyNav(
if (prev >= 0) { if (prev >= 0) {
focusSwatch(grid, prev); focusSwatch(grid, prev);
} else if (grid === "highlight") { } else if (grid === "highlight") {
const lastRowStart = const lastRowStart = Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1)); focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1));
} }
return; return;
@@ -222,7 +220,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
withArrow withArrow
> >
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text color")} withArrow> <Tooltip label={t("Text color")} withArrow withinPortal={false}>
<Button <Button
variant="default" variant="default"
radius="0" radius="0"
@@ -247,26 +245,26 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<Popover.Dropdown onMouseDown={(e) => e.preventDefault()}> <Popover.Dropdown onMouseDown={(e) => e.preventDefault()}>
<Stack gap="md" p="2px"> <Stack gap="md" p="2px">
<Box> <Box>
<Text size="sm" fw={600} mb="xs"> <Text size="sm" fw={600} mb="xs">
{t("Text color")} {t("Text color")}
</Text> </Text>
<SimpleGrid cols={5} spacing="xs"> <SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => { {TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => { const applyTextColor = () => {
if (!isEditorReady(editor)) return; if (!isEditorReady(editor)) return;
if (name === "Default") { if (name === "Default") {
editor.commands.unsetColor(); editor.commands.unsetColor();
} else { } else {
editor editor
.chain() .chain()
.focus() .focus()
.setColor(color || "") .setColor(color || "")
.run(); .run();
} }
setIsOpen(false); setIsOpen(false);
}; };
return ( return (
<Tooltip key={index} label={t(name)} withArrow> <Tooltip key={index} label={t(name)} withArrow>
<Box <Box
role="button" role="button"
@@ -306,34 +304,34 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
A A
</Box> </Box>
</Tooltip> </Tooltip>
); );
})} })}
</SimpleGrid> </SimpleGrid>
</Box> </Box>
<Box> <Box>
<Text size="sm" fw={600} mb="xs"> <Text size="sm" fw={600} mb="xs">
{t("Highlight color")} {t("Highlight color")}
</Text> </Text>
<SimpleGrid cols={5} spacing="xs"> <SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => { {HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => { const applyHighlight = () => {
if (!isEditorReady(editor)) return; if (!isEditorReady(editor)) return;
if (name === "Default") { if (name === "Default") {
editor.commands.unsetHighlight(); editor.commands.unsetHighlight();
} else { } else {
editor editor
.chain() .chain()
.focus() .focus()
.toggleMark("highlight", { .toggleMark("highlight", {
color: color || "", color: color || "",
colorName: name.toLowerCase() || "", colorName: name.toLowerCase() || "",
}) })
.run(); .run();
} }
setIsOpen(false); setIsOpen(false);
}; };
return ( return (
<Tooltip key={index} label={t(name)} withArrow> <Tooltip key={index} label={t(name)} withArrow>
<Box <Box
role="button" role="button"
@@ -378,37 +376,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)} )}
</Box> </Box>
</Tooltip> </Tooltip>
); );
})} })}
</SimpleGrid> </SimpleGrid>
</Box> </Box>
<Button <Button
variant="default" variant="default"
fullWidth fullWidth
data-color-grid="remove" data-color-grid="remove"
className={classes.removeColor} className={classes.removeColor}
onClick={() => { onClick={() => {
if (isEditorReady(editor)) { if (isEditorReady(editor)) {
editor.commands.unsetColor(); editor.commands.unsetColor();
editor.commands.unsetHighlight(); editor.commands.unsetHighlight();
} }
setIsOpen(false); setIsOpen(false);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "ArrowUp") { if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
const lastRowStart = const lastRowStart =
Math.floor( Math.floor((HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS) *
(HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS, COLOR_GRID_COLS;
) * COLOR_GRID_COLS; focusSwatch("highlight", lastRowStart);
focusSwatch("highlight", lastRowStart); }
} }}
}} >
> {t("Remove color")}
{t("Remove color")} </Button>
</Button> </Stack>
</Stack>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
); );
@@ -10,7 +10,7 @@ export const LinkSelector: FC = () => {
const setShowLinkMenu = useSetAtom(showLinkMenuAtom); const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
return ( return (
<Tooltip label={t("Add link")} withArrow> <Tooltip label={t("Add link")} withArrow withinPortal={false}>
<ActionIcon <ActionIcon
variant="default" variant="default"
size="lg" size="lg"
@@ -91,7 +91,12 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
onChange={setIsOpen} onChange={setIsOpen}
> >
<Menu.Target> <Menu.Target>
<Tooltip label={t("Text align")} withArrow disabled={isOpen}> <Tooltip
label={t("Text align")}
withArrow
disabled={isOpen}
withinPortal={false}
>
<Button <Button
variant="default" variant="default"
style={{ border: "none", height: "34px" }} style={{ border: "none", height: "34px" }}
@@ -1,5 +1,7 @@
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import type { Node as PMNode } from "@tiptap/pm/model";
import Lightbox, { type Slide } from "yet-another-react-lightbox"; import Lightbox, { type Slide } from "yet-another-react-lightbox";
import type { LightboxRequest } from "@/features/editor/atoms/editor-atoms";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import "yet-another-react-lightbox/styles.css"; import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css"; import "yet-another-react-lightbox/plugins/captions.css";
@@ -71,16 +73,36 @@ function getMedia(rawSrc: string, type?: string, alt?: string): Slide {
} }
} }
const LIGHTBOX_NODE_TYPES: Record<string, "image" | "video"> = {
image: "image",
video: "video",
drawio: "image",
excalidraw: "image",
};
// video is excluded: clicks there operate the native controls
const CLICK_TO_EXPAND_NODE_TYPES = new Set(["image", "drawio", "excalidraw"]);
export function getLightboxClickRequest(node: PMNode): LightboxRequest {
if (!CLICK_TO_EXPAND_NODE_TYPES.has(node.type.name)) return null;
const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
if (!src) return null;
return { src: getFileUrl(src), type: "image" };
}
function getPageMedia(editor: Editor): Slide[] { function getPageMedia(editor: Editor): Slide[] {
const media: Slide[] = []; const media: Slide[] = [];
editor.state.doc.descendants((node) => { editor.state.doc.descendants((node) => {
if (node.type.name !== "image" && node.type.name !== "video") return; const type = LIGHTBOX_NODE_TYPES[node.type.name];
if (!type) return;
const rawSrc = typeof node.attrs.src === "string" ? node.attrs.src : ""; const rawSrc = typeof node.attrs.src === "string" ? node.attrs.src : "";
if (!rawSrc) return; if (!rawSrc) return;
media.push(getMedia(rawSrc, node.type.name, node.attrs.alt)); media.push(getMedia(rawSrc, type, node.attrs.alt));
}); });
return media; return media;
@@ -103,7 +125,6 @@ export default function LightboxView({
const [pageSlides, setPageSlides] = useState<Slide[]>([]); const [pageSlides, setPageSlides] = useState<Slide[]>([]);
const [loadedMediaKey, setLoadedMediaKey] = useState<string | null>(null); const [loadedMediaKey, setLoadedMediaKey] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -141,6 +162,7 @@ export default function LightboxView({
index={index} index={index}
slides={slides} slides={slides}
plugins={[Captions, Download, Fullscreen, Video, Zoom]} plugins={[Captions, Download, Fullscreen, Video, Zoom]}
styles={{ container: { backgroundColor: "rgba(0, 0, 0, 0.8)" } }}
captions={{ descriptionTextAlign: "center" }} captions={{ descriptionTextAlign: "center" }}
video={{ controls: true, playsInline: true }} video={{ controls: true, playsInline: true }}
zoom={{ zoom={{
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext"; import { isEditorReady } from "@docmost/editor-ext";
import { import {
@@ -24,6 +25,7 @@ import {
IconDownload, IconDownload,
IconEdit, IconEdit,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts"; import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
@@ -39,10 +41,12 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx"; import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null); const drawioRef = useRef<DrawIoEmbedRef>(null);
@@ -328,6 +332,23 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Edit")} withinPortal={false}> <Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleOpen} onClick={handleOpen}
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext"; import { isEditorReady } from "@docmost/editor-ext";
import { import {
@@ -25,6 +26,7 @@ import {
IconDownload, IconDownload,
IconEdit, IconEdit,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
@@ -37,6 +39,7 @@ import ReactClearModal from "react-clear-modal";
import { useHandleLibrary } from "@excalidraw/excalidraw"; import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx"; import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
const ExcalidrawComponent = lazy(() => const ExcalidrawComponent = lazy(() =>
@@ -47,6 +50,7 @@ const ExcalidrawComponent = lazy(() =>
export function ExcalidrawMenu({ editor }: EditorMenuProps) { export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [excalidrawAPI, setExcalidrawAPI] = const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null); useState<ExcalidrawImperativeAPI>(null);
@@ -359,6 +363,23 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Edit")} withinPortal={false}> <Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleOpen} onClick={handleOpen}
@@ -15,9 +15,9 @@ import {
IconLayoutAlignLeft, IconLayoutAlignLeft,
IconLayoutAlignRight, IconLayoutAlignRight,
IconDownload, IconDownload,
IconMaximize,
IconRefresh, IconRefresh,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
@@ -169,25 +169,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
altTextPanel altTextPanel
) : ( ) : (
<div className={classes.toolbar}> <div className={classes.toolbar}>
<Tooltip position="top" label={t("Open image")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Open image")}
variant="subtle"
>
<IconMaximize size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Align left")} withinPortal={false}> <Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={alignImageLeft} onClick={alignImageLeft}
@@ -230,6 +211,23 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "image",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")} withinPortal={false}> <Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleDownload} onClick={handleDownload}
@@ -19,11 +19,15 @@ export default function SubpagesView(props: NodeViewProps) {
const { editor } = props; const { editor } = props;
const { spaceSlug, shareId, pageSlug } = useParams(); const { spaceSlug, shareId, pageSlug } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
// @ts-ignore // @ts-ignore
const storagePageId = editor.storage.pageId; const storagePageId = editor.storage.pageId;
const routePageId = extractPageSlugId(pageSlug); const routePageId = extractPageSlugId(pageSlug);
const currentPageId = storagePageId ?? routePageId; let currentPageId = storagePageId;
if (shareId){
currentPageId = routePageId;
}
// Get subpages from shared tree if we're in a shared context // Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId); const sharedSubpages = useSharedPageSubpages(currentPageId);
@@ -15,8 +15,8 @@ import {
IconLayoutAlignLeft, IconLayoutAlignLeft,
IconLayoutAlignRight, IconLayoutAlignRight,
IconDownload, IconDownload,
IconMaximize,
IconTrash, IconTrash,
IconZoomIn,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
@@ -145,25 +145,6 @@ export function VideoMenu({ editor }: EditorMenuProps) {
altTextPanel altTextPanel
) : ( ) : (
<div className={classes.toolbar}> <div className={classes.toolbar}>
<Tooltip position="top" label={t("Open video")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "video",
})
}
size="lg"
aria-label={t("Open video")}
variant="subtle"
>
<IconMaximize size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Align left")} withinPortal={false}> <Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={alignLeft} onClick={alignLeft}
@@ -206,6 +187,23 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
<ActionIcon
onClick={() =>
editorState?.src &&
setLightboxRequest({
src: getFileUrl(editorState.src),
type: "video",
})
}
size="lg"
aria-label={t("Expand")}
variant="subtle"
>
<IconZoomIn size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")} withinPortal={false}> <Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleDownload} onClick={handleDownload}
@@ -54,7 +54,9 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx"; import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx"; import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx"; import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import LightboxView from "@/features/editor/components/common/lightbox-view"; import LightboxView, {
getLightboxClickRequest,
} from "@/features/editor/components/common/lightbox-view";
import { import {
handleFileDrop, handleFileDrop,
handlePaste, handlePaste,
@@ -308,6 +310,22 @@ function CollabPageEditor({
return handleFileDrop(editorRef.current, event, moved, pageId); return handleFileDrop(editorRef.current, event, moved, pageId);
}, },
handleClickOn: (view, _pos, node) => {
if (view.editable) return false;
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
handleDoubleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
}, },
onCreate({ editor }) { onCreate({ editor }) {
if (editor) { if (editor) {
@@ -389,6 +407,7 @@ function CollabPageEditor({
setActiveCommentId(null); setActiveCommentId(null);
setShowCommentPopup(false); setShowCommentPopup(false);
setAsideState({ tab: "", isAsideOpen: false }); setAsideState({ tab: "", isAsideOpen: false });
setLightboxRequest(null);
}, [pageId]); }, [pageId]);
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
@@ -1,15 +1,27 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, {
import { EditorProvider } from "@tiptap/react"; useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Editor, EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document"; import { Document } from "@tiptap/extension-document";
import { Heading, UniqueID } from "@docmost/editor-ext"; import { Heading, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text"; import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import {
lightboxRequestAtom,
readOnlyEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll"; import { useEditorScroll } from "./hooks/use-editor-scroll";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import LightboxView, {
getLightboxClickRequest,
} from "@/features/editor/components/common/lightbox-view";
interface PageEditorProps { interface PageEditorProps {
title: string; title: string;
@@ -33,6 +45,8 @@ export default function ReadonlyPageEditor({
shareId, shareId,
}: PageEditorProps) { }: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [contentEditor, setContentEditor] = useState<Editor | null>(null);
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const editorCreated = useRef(false); const editorCreated = useRef(false);
@@ -49,6 +63,11 @@ export default function ReadonlyPageEditor({
isComponentMounted.current = true; isComponentMounted.current = true;
}, []); }, []);
useEffect(() => {
if (!shareId) return;
setLightboxRequest(null);
}, [pageId, shareId]);
const extensions = useMemo(() => { const extensions = useMemo(() => {
const excludedExtensions = new Set([ const excludedExtensions = new Set([
"uniqueID", "uniqueID",
@@ -97,6 +116,19 @@ export default function ReadonlyPageEditor({
textDirection="auto" textDirection="auto"
extensions={extensions} extensions={extensions}
content={content} content={content}
editorProps={
shareId
? {
handleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
if (!request) return false;
setLightboxRequest(request);
return true;
},
}
: undefined
}
onCreate={({ editor }) => { onCreate={({ editor }) => {
if (editor) { if (editor) {
if (pageId) { if (pageId) {
@@ -105,12 +137,22 @@ export default function ReadonlyPageEditor({
} }
// @ts-ignore // @ts-ignore
setReadOnlyEditor(editor); setReadOnlyEditor(editor);
setContentEditor(editor);
handleScrollTo(editor); handleScrollTo(editor);
editorCreated.current = true; editorCreated.current = true;
} }
}} }}
></EditorProvider> ></EditorProvider>
{shareId && contentEditor && (
<LightboxView
editor={contentEditor}
open={!!lightboxRequest}
src={lightboxRequest?.src ?? ""}
type={lightboxRequest?.type ?? "image"}
onClose={() => setLightboxRequest(null)}
/>
)}
<div style={{ paddingBottom: "20vh" }}></div> <div style={{ paddingBottom: "20vh" }}></div>
</TransclusionLookupProvider> </TransclusionLookupProvider>
); );