Merge branch 'main' into sandboxed

This commit is contained in:
Philipinho
2026-09-07 14:46:04 +01:00
244 changed files with 15624 additions and 1995 deletions
@@ -85,6 +85,9 @@ export function AudioMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`audio-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -23,11 +23,21 @@ import {
} from "@/features/comment/atoms/comment-atom";
import { useAtom, useAtomValue } from "jotai";
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 { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
import {
showAiMenuAtom,
showLinkMenuAtom,
} from "@/features/editor/atoms/editor-atoms";
import {
userAtom,
workspaceAtom,
} from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
name: string;
@@ -217,7 +227,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
<ActionIcon.Group>
{items.map((item, index) => (
<Tooltip key={index} label={t(item.name)} withArrow>
<Tooltip
key={index}
label={t(item.name)}
withArrow
withinPortal={false}
>
<ActionIcon
key={index}
variant="default"
@@ -226,7 +241,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }}
onClick={() => isEditorReady(props.editor) && item.command()}
onClick={() =>
isEditorReady(props.editor) && item.command()
}
>
<item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon>
@@ -256,7 +273,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
radius="6px"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={() => isEditorReady(props.editor) && commentItem.command()}
onClick={() =>
isEditorReady(props.editor) && commentItem.command()
}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
@@ -129,8 +129,7 @@ function handleColorKeyNav(
grid: "text" | "highlight",
) {
const cols = COLOR_GRID_COLS;
const total =
grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
const total = grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
const col = index % cols;
if (e.key === "ArrowRight") {
@@ -163,8 +162,7 @@ function handleColorKeyNav(
if (prev >= 0) {
focusSwatch(grid, prev);
} else if (grid === "highlight") {
const lastRowStart =
Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
const lastRowStart = Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1));
}
return;
@@ -222,7 +220,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
withArrow
>
<Popover.Target>
<Tooltip label={t("Text color")} withArrow>
<Tooltip label={t("Text color")} withArrow withinPortal={false}>
<Button
variant="default"
radius="0"
@@ -247,26 +245,26 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<Popover.Dropdown onMouseDown={(e) => e.preventDefault()}>
<Stack gap="md" p="2px">
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
};
return (
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
};
return (
<Tooltip key={index} label={t(name)} withArrow>
<Box
role="button"
@@ -306,34 +304,34 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
A
</Box>
</Tooltip>
);
})}
</SimpleGrid>
</Box>
);
})}
</SimpleGrid>
</Box>
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Highlight color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetHighlight();
} else {
editor
.chain()
.focus()
.toggleMark("highlight", {
color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
}
setIsOpen(false);
};
return (
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Highlight color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") {
editor.commands.unsetHighlight();
} else {
editor
.chain()
.focus()
.toggleMark("highlight", {
color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
}
setIsOpen(false);
};
return (
<Tooltip key={index} label={t(name)} withArrow>
<Box
role="button"
@@ -378,37 +376,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)}
</Box>
</Tooltip>
);
})}
</SimpleGrid>
</Box>
);
})}
</SimpleGrid>
</Box>
<Button
variant="default"
fullWidth
data-color-grid="remove"
className={classes.removeColor}
onClick={() => {
if (isEditorReady(editor)) {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
}
setIsOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
const lastRowStart =
Math.floor(
(HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS,
) * COLOR_GRID_COLS;
focusSwatch("highlight", lastRowStart);
}
}}
>
{t("Remove color")}
</Button>
</Stack>
<Button
variant="default"
fullWidth
data-color-grid="remove"
className={classes.removeColor}
onClick={() => {
if (isEditorReady(editor)) {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
}
setIsOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
const lastRowStart =
Math.floor((HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS) *
COLOR_GRID_COLS;
focusSwatch("highlight", lastRowStart);
}
}}
>
{t("Remove color")}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
@@ -10,7 +10,7 @@ export const LinkSelector: FC = () => {
const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
return (
<Tooltip label={t("Add link")} withArrow>
<Tooltip label={t("Add link")} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
@@ -91,7 +91,12 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
onChange={setIsOpen}
>
<Menu.Target>
<Tooltip label={t("Text align")} withArrow disabled={isOpen}>
<Tooltip
label={t("Text align")}
withArrow
disabled={isOpen}
withinPortal={false}
>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
@@ -121,12 +121,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`callout-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "bottom",
// offset: 233, // // offset: [0, 10],
// zIndex: 99,
flip: false,
}}
shouldShow={shouldShow}
@@ -257,6 +257,9 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey="columns-menu"
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -0,0 +1,188 @@
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 type { LightboxRequest } from "@/features/editor/atoms/editor-atoms";
import { getFileUrl } from "@/lib/config.ts";
import "yet-another-react-lightbox/styles.css";
import Download from "yet-another-react-lightbox/plugins/download";
import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
import Video from "yet-another-react-lightbox/plugins/video";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import { useEffect, useMemo, useState } from "react";
import i18n from "@/i18n.ts";
import { useTranslation } from "react-i18next";
type LightboxViewProps = {
editor: Editor;
open: boolean;
src: string;
type: "image" | "video";
onClose: () => void;
};
function getVideoMimeType(src: string) {
const extension = src.split(/[?#]/, 1)[0].split(".").pop()?.toLowerCase();
switch (extension) {
case "webm":
return "video/webm";
case "ogv":
return "video/ogg";
case "mov":
return "video/quicktime";
case "m4v":
return "video/x-m4v";
default:
return "video/mp4";
}
}
function getFilename(src: string) {
const filename = src.split(/[?#]/, 1)[0].split("/").pop();
if (!filename) return i18n.t("Media");
try {
return decodeURIComponent(filename);
} catch {
return filename;
}
}
function getMedia(rawSrc: string, type?: string, alt?: string): Slide {
const src = getFileUrl(rawSrc);
const filename = getFilename(rawSrc);
if (type === "video") {
return {
type: "video",
sources: [{ src, type: getVideoMimeType(rawSrc) }],
download: { url: src, filename },
};
} else {
return {
type: "image",
src,
alt: alt || undefined,
download: { url: src, filename },
};
}
}
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[] {
const media: Slide[] = [];
editor.state.doc.descendants((node) => {
const type = LIGHTBOX_NODE_TYPES[node.type.name];
if (!type) return;
const rawSrc = typeof node.attrs.src === "string" ? node.attrs.src : "";
if (!rawSrc) return;
media.push(getMedia(rawSrc, type, node.attrs.alt));
});
return media;
}
export default function LightboxView({
editor,
open,
src,
type,
onClose,
}: LightboxViewProps) {
const { i18n: i18nInstance } = useTranslation();
const selectedSlide = useMemo(
() => getMedia(src, type),
[src, type, i18nInstance.language]
);
const [pageSlides, setPageSlides] = useState<Slide[]>([]);
const [loadedMediaKey, setLoadedMediaKey] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (!open) setIsFullscreen(false);
}, [open]);
useEffect(() => {
if (!open) return;
setLoadedMediaKey(null);
const frame = requestAnimationFrame(() => {
const slides = getPageMedia(editor);
setPageSlides(slides);
setLoadedMediaKey(`${type}:${src}`);
});
return () => cancelAnimationFrame(frame);
}, [editor, open, type, src]);
const slides = loadedMediaKey === `${type}:${src}` ? pageSlides : [selectedSlide];
const index = useMemo(() => {
if (!(pageSlides.length > 0)) {
return 0;
}
const idx = slides.findIndex((slide) =>
type === "video"
? "sources" in slide && slide.sources.some((s) => s.src === src)
: "src" in slide && slide.src === src
);
return idx >= 0 ? idx : 0;
}, [slides, src, type]);
return (
<Lightbox
open={open}
close={onClose}
index={index}
slides={slides}
plugins={[Download, Fullscreen, Video, Zoom]}
styles={{
container: { backgroundColor: "rgba(0, 0, 0, 0.8)" },
icon: { width: 24, height: 24 },
toolbar: {
margin: 8,
borderRadius: 8,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
}}
controller={{ closeOnBackdropClick: !isFullscreen }}
on={{
enterFullscreen: () => setIsFullscreen(true),
exitFullscreen: () => setIsFullscreen(false),
}}
video={{ controls: true, playsInline: true }}
zoom={{
scrollToZoom: true,
maxZoomPixelRatio: 4,
maxZoom: 4,
supports: ["video"],
}}
/>
);
}
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -24,6 +25,7 @@ import {
IconDownload,
IconEdit,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
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 { modals } from "@mantine/modals";
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";
export function DrawioMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false);
const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null);
@@ -269,6 +273,9 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -328,6 +335,23 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<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}>
<ActionIcon
onClick={handleOpen}
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -25,6 +26,7 @@ import {
IconDownload,
IconEdit,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
@@ -37,6 +39,7 @@ import ReactClearModal from "react-clear-modal";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
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";
const ExcalidrawComponent = lazy(() =>
@@ -47,6 +50,7 @@ const ExcalidrawComponent = lazy(() =>
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const [opened, { open, close }] = useDisclosure(false);
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null);
@@ -359,6 +363,23 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<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}>
<ActionIcon
onClick={handleOpen}
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -16,16 +17,19 @@ import {
IconDownload,
IconRefresh,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.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";
export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({
editor,
@@ -152,6 +156,9 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`image-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -207,6 +214,23 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<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}>
<ActionIcon
onClick={handleDownload}
@@ -26,7 +26,12 @@ import { INTERNAL_LINK_REGEX } from "@/lib/constants";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
import { normalizeUrl } from "@/lib/utils";
@@ -60,9 +65,10 @@ export default function LinkView(props: MarkViewProps) {
const href = mark.attrs.href as string;
const navigate = useNavigate();
const location = useLocation();
const { shareId, pageSlug } = useParams();
const { shareId, spaceSlug, pageSlug } = useParams();
const { t } = useTranslation();
const isShareRoute = location.pathname.startsWith("/share");
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const [popoverState, setPopoverState] = useState<
"closed" | "preview" | "edit"
@@ -84,16 +90,33 @@ export default function LinkView(props: MarkViewProps) {
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
const { data: linkedPage } = usePageQuery({
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
pageId:
isPopoverVisible && slugId && !isShareRoute && !isPublicSpaceRoute
? slugId
: null,
});
const { data: sharedPageData } = useSharePageQuery({
pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null,
});
const pageTitle = isShareRoute
? sharedPageData?.page?.title
: linkedPage?.title;
// Resolved eagerly (not gated on the popover): an unresolvable target must
// render as inert text rather than a link that dead-ends at /login.
const { data: publicSpacePageData } = usePublicSpacePageQuery({
spaceSlug: isPublicSpaceRoute && slugId ? spaceSlug : undefined,
pageSlugId: slugId,
contentless: true,
});
const isUnresolvedPublicLink =
isPublicSpaceRoute && isInternal && !publicSpacePageData?.page && !slugId;
let pageTitle = linkedPage?.title;
if (isShareRoute) {
pageTitle = sharedPageData?.page?.title;
} else if (isPublicSpaceRoute) {
pageTitle = publicSpacePageData?.page?.title;
}
const pendingTitleRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
@@ -270,6 +293,27 @@ export default function LinkView(props: MarkViewProps) {
anchorId: anchor || undefined,
});
navigate(sharedUrl);
} else if (isPublicSpaceRoute) {
if (slugId && publicSpacePageData?.page) {
navigate(
buildPublicSpaceUrl({
// cross-space targets resolve to their own space's public URL
spaceSlug: publicSpacePageData.space?.slug ?? spaceSlug,
pageSlugId: slugId,
pageTitle: pageTitle,
anchorId: anchor || undefined,
}),
);
} else if (slugId) {
// no public URL: the /p/ resolver redirects members straight to the
// page and funnels anonymous visitors through login first; a new tab
// keeps the docs tab's history intact through that redirect chain
window.open(
buildPageUrl(undefined, slugId, pageTitle, anchor || undefined),
"_blank",
"noopener,noreferrer",
);
}
} else {
navigate(anchor ? `${targetPath}#${anchor}` : targetPath);
}
@@ -286,8 +330,11 @@ export default function LinkView(props: MarkViewProps) {
location.pathname,
isInternal,
isShareRoute,
isPublicSpaceRoute,
slugId,
shareId,
spaceSlug,
publicSpacePageData,
pageTitle,
pageSlug,
]);
@@ -329,12 +376,18 @@ export default function LinkView(props: MarkViewProps) {
setPopoverState("closed");
}, [editor]);
const internalHref = () => {
if (isShareRoute && slugId) {
return buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle });
}
if (isPublicSpaceRoute && slugId && publicSpacePageData?.page) {
return buildPublicSpaceUrl({ spaceSlug, pageSlugId: slugId, pageTitle });
}
return href;
};
const displayHref = sanitizeUrl(
isInternal
? isShareRoute && slugId
? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle })
: href
: normalizeUrl(href),
isInternal ? internalHref() : normalizeUrl(href),
);
const linkTitleInput = (
@@ -384,6 +437,16 @@ export default function LinkView(props: MarkViewProps) {
</>
);
// Targets outside the published space have no public URL, so the label is
// rendered as inert text instead of a link that dead-ends at /login.
if (isUnresolvedPublicLink) {
return (
<span ref={wrapperRef} className={classes.linkWrapper}>
<MarkViewContent />
</span>
);
}
return (
<Popover
opened={isPopoverVisible}
@@ -4,8 +4,10 @@ import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
@@ -22,17 +24,30 @@ export default function MentionView(props: NodeViewProps) {
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const {
data: page,
isLoading,
isError,
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
} = usePageQuery({
pageId:
isPageMention && !isShareRoute && !isPublicSpaceRoute ? slugId : null,
});
const { data: sharedPage } = useSharePageQuery({
pageId: isPageMention && isShareRoute ? slugId : undefined,
});
// Without the slugId guard the request resolves to the space home, which
// would render a mention pointing at the wrong page.
const { data: publicPageData } = usePublicSpacePageQuery({
spaceSlug:
isPageMention && isPublicSpaceRoute && slugId ? spaceSlug : undefined,
pageSlugId: slugId,
contentless: true,
});
const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId;
@@ -103,7 +118,61 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{hasTarget && !isShareRoute && isError && (
{hasTarget && isPublicSpaceRoute && publicPageData?.page && (
<Anchor
component={Link}
fw={500}
to={buildPublicSpaceUrl({
spaceSlug: publicPageData.space?.slug ?? spaceSlug,
pageSlugId: slugId,
pageTitle: publicPageData.page.title || label,
anchorId,
})}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{publicPageData.page.title || label}
</span>
</Anchor>
)}
{/* No public URL: the /p/ resolver redirects members to the page and
funnels anonymous visitors through login first. New tab, so the
redirect chain never rewrites the docs tab's history. */}
{hasTarget && isPublicSpaceRoute && !publicPageData?.page && (
<Anchor
fw={500}
href={buildPageUrl(undefined, slugId, label, anchorId)}
target="_blank"
rel="noopener noreferrer"
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>{label}</span>
</Anchor>
)}
{hasTarget && !isShareRoute && !isPublicSpaceRoute && isError && (
<Anchor
component={Link}
fw={500}
@@ -127,7 +196,7 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{hasTarget && !isShareRoute && !isError && (
{hasTarget && !isShareRoute && !isPublicSpaceRoute && !isError && (
<Anchor
component={Link}
fw={500}
@@ -106,6 +106,9 @@ export function PdfMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`pdf-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -66,7 +66,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
}
// Clear search term in editor
if (isEditorReady(editor)) {
editor.commands.setSearchTerm("");
editor.commands.setSearchTerms([""]);
}
};
@@ -117,7 +117,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
useEffect(() => {
if (!isEditorReady(editor)) return;
editor.commands.setSearchTerm(searchText);
editor.commands.setSearchTerms([searchText]);
editor.commands.resetIndex();
editor.commands.selectCurrentItem();
}, [searchText]);
@@ -181,8 +181,10 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
const location = useLocation();
useEffect(() => {
closeDialog();
}, [location]);
if (pageFindState.isOpen) {
closeDialog();
}
}, [location.pathname]);
return (
<Dialog
@@ -0,0 +1,205 @@
import { ActionIcon, Dialog, Flex, Text, Tooltip } from "@mantine/core";
import {
IconArrowNarrowDown,
IconArrowNarrowUp,
IconX,
} from "@tabler/icons-react";
import { useEditor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
import React, { useCallback, useEffect, useRef, useState } from "react";
import classes from "./search-replace.module.css";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
interface SearchNavigationDialogProps {
editor: ReturnType<typeof useEditor>;
}
interface SearchNavigationEvent extends CustomEvent {
detail: {
searchTerms: string[];
wholeWord?: boolean;
};
}
function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const openRef = useRef(false);
const [resultState, setResultState] = useState({
resultIndex: 0,
resultsLength: 0,
});
const goToSelection = () => {
if (!isEditorReady(editor)) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
const position = results[resultIndex];
setResultState({
resultsLength: results.length,
resultIndex,
});
if (!position) return;
requestAnimationFrame(() => {
document
.querySelector(".search-result-current")
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
};
const next = () => {
if (!isEditorReady(editor)) return;
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
if (!isEditorReady(editor)) return;
editor.commands.previousSearchResult();
goToSelection();
};
const close = useCallback(() => {
if (!openRef.current) return;
openRef.current = false;
setOpen(false);
if (isEditorReady(editor)) {
editor.commands.setSearchTerms([""]);
}
const nextParams = new URLSearchParams(location.search);
nextParams.delete("q");
nextParams.delete("m");
const nextSearch = nextParams.toString();
navigate(
{
pathname: location.pathname,
search: nextSearch ? `?${nextSearch}` : "",
hash: location.hash,
},
{ replace: true },
);
}, [editor, location.hash, location.pathname, location.search, navigate]);
useEffect(() => {
const handleOpen = (event: Event) => {
const { searchTerms: terms, wholeWord = true } = (
event as SearchNavigationEvent
).detail;
if (!terms?.length || !isEditorReady(editor)) return;
openRef.current = false;
editor.commands.setSearchTerms(terms);
editor.commands.setWholeWord(wholeWord);
editor.commands.resetIndex();
const { results, resultIndex } = editor.storage.searchAndReplace;
openRef.current = true;
if (results.length === 0) {
close();
return;
}
setOpen(true);
setResultState({
resultIndex,
resultsLength: results.length,
});
goToSelection();
};
const handleClose = () => {
if (openRef.current) {
close();
}
};
document.addEventListener("openSearchNavigationDialog", handleOpen);
document.addEventListener("openFindDialogFromEditor", handleClose);
document.addEventListener("closeFindDialogFromEditor", handleClose);
return () => {
document.removeEventListener("openSearchNavigationDialog", handleOpen);
document.removeEventListener("openFindDialogFromEditor", handleClose);
document.removeEventListener("closeFindDialogFromEditor", handleClose);
};
}, [close, editor]);
useEffect(() => {
const handleTransaction = () => {
if (!openRef.current || editor.isDestroyed) return;
const { results } = editor.storage.searchAndReplace;
if (results.length === 0) {
close();
}
};
editor.on("transaction", handleTransaction);
return () => {
editor.off("transaction", handleTransaction);
};
}, [close, editor]);
return (
<Dialog
className={classes.findDialog}
opened={open}
size="xs"
radius="md"
w="auto"
position={{ top: 90, right: 50 }}
withBorder
aria-label="Search navigation"
>
<Flex align="center" gap="xs">
<Text size="xs" style={{ flex: 1 }}>
{resultState.resultsLength > 0
? `${resultState.resultIndex + 1}/${resultState.resultsLength}`
: t("Not found")}
</Text>
<Tooltip label="Previous match">
<ActionIcon
variant="subtle"
color="gray"
onClick={previous}
aria-label="Previous match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Next match">
<ActionIcon
variant="subtle"
color="gray"
onClick={next}
aria-label="Next match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowDown size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Close">
<ActionIcon
variant="subtle"
color="gray"
onClick={close}
aria-label="Close"
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Dialog>
);
}
export default SearchNavigationDialog;
@@ -0,0 +1,47 @@
import { useEffect, useRef } from "react";
import type { useEditor } from "@tiptap/react";
interface UseSearchNavigationParamsProps {
editor: ReturnType<typeof useEditor>;
isSynced: boolean;
pageId: string;
searchParams: URLSearchParams;
showStatic: boolean;
}
export function useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
}: UseSearchNavigationParamsProps) {
const appliedSearchKeyRef = useRef<string | null>(null);
const searchKey = `${pageId}:${searchParams.toString()}`;
useEffect(() => {
const searchQueries = searchParams.getAll("q");
if (!searchQueries.length) {
appliedSearchKeyRef.current = null;
return;
}
if (
!editor ||
editor.isDestroyed ||
!editor.view.dom.isConnected ||
appliedSearchKeyRef.current === searchKey
) {
return;
}
const match = searchParams.get("m");
appliedSearchKeyRef.current = searchKey;
document.dispatchEvent(
new CustomEvent("openSearchNavigationDialog", {
detail: { searchTerms: searchQueries, wholeWord: match === "whole" },
}),
);
}, [editor, isSynced, searchKey, searchParams, showStatic]);
}
@@ -61,6 +61,9 @@ export const SubpagesMenu = React.memo(
<BaseBubbleMenu
editor={editor}
pluginKey={`subpages-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
shouldShow={shouldShow}
>
@@ -3,30 +3,59 @@ import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import { Link, useLocation, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
import styles from "../mention/mention.module.css";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
import { useAtomValue } from "jotai";
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
import { findSubpagesInTree } from "@/features/share/utils";
import { extractPageSlugId } from "@/lib";
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { spaceSlug, shareId } = useParams();
const { spaceSlug, shareId, pageSlug } = useParams();
const { t } = useTranslation();
const location = useLocation();
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
//@ts-ignore
const currentPageId = editor.storage.pageId;
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
// @ts-ignore
const storagePageId = editor.storage.pageId;
const routePageId = extractPageSlugId(pageSlug);
let currentPageId = storagePageId;
if (shareId){
currentPageId = routePageId;
}
// Public docs must resolve the page from the route, not editor storage:
// storage.pageId is set after this view's first render and is not reactive,
// which froze the list at "No subpages" until something re-rendered it. The
// space home renders at the bare URL, so it falls back to the first root.
if (isPublicSpaceRoute) {
currentPageId = routePageId ?? publicSpaceTreeData?.[0]?.slugId;
}
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
const publicSpaceSubpages = useMemo(
() => findSubpagesInTree(publicSpaceTreeData, currentPageId),
[publicSpaceTreeData, currentPageId],
);
const isPublicView = Boolean(shareId) || isPublicSpaceRoute;
const { data, isLoading, error } = useGetSidebarPagesQuery(
shareId ? null : { pageId: currentPageId },
isPublicView ? null : { pageId: currentPageId },
);
const subpages = useMemo(() => {
@@ -41,17 +70,33 @@ export default function SubpagesView(props: NodeViewProps) {
}));
}
if (isPublicSpaceRoute) {
return publicSpaceSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
}));
}
// Otherwise use the API data
if (!data?.pages) return [];
const allPages = data.pages.flatMap((page) => page.items);
return sortPositionKeys(allPages);
}, [data, shareId, sharedSubpages]);
}, [
data,
shareId,
sharedSubpages,
isPublicSpaceRoute,
publicSpaceSubpages,
]);
if (isLoading && !shareId) {
if (isLoading && !isPublicView) {
return null;
}
if (error && !shareId) {
if (error && !isPublicView) {
return (
<NodeViewWrapper data-drag-handle>
<Text c="dimmed" size="md" py="md">
@@ -89,7 +134,13 @@ export default function SubpagesView(props: NodeViewProps) {
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
: isPublicSpaceRoute
? buildPublicSpaceUrl({
spaceSlug,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
}
underline="never"
className={styles.pageMentionLink}
@@ -18,7 +18,7 @@ export type HeadingLink = {
position: number;
};
const recalculateLinks = (nodePos: NodePos[]) => {
export const recalculateLinks = (nodePos: NodePos[]) => {
const nodes: HTMLElement[] = [];
const links: HeadingLink[] = Array.from(nodePos).reduce<HeadingLink[]>(
@@ -9,6 +9,7 @@ import React, {
} from "react";
import {
lookupTransclusion,
lookupTransclusionForPublicSpace,
lookupTransclusionForShare,
} from "@/features/transclusion/services/transclusion-api";
import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types";
@@ -38,6 +39,7 @@ const TransclusionLookupContext = createContext<ContextValue | null>(null);
export function TransclusionLookupProvider({
children,
shareId,
spaceSlug,
}: {
children: React.ReactNode;
/**
@@ -47,6 +49,11 @@ export function TransclusionLookupProvider({
* app, where personal permissions gate access.
*/
shareId?: string;
/**
* When set, lookups go through the public-space endpoint and are gated by
* the published space. Used by the public docs viewer.
*/
spaceSlug?: string;
}) {
const subscribersRef = useRef(new Map<LookupKey, Subscriber[]>());
const queueRef = useRef(new Set<LookupKey>());
@@ -55,6 +62,8 @@ export function TransclusionLookupProvider({
// memoized callbacks (and thus doesn't re-render every consumer).
const shareIdRef = useRef<string | undefined>(shareId);
shareIdRef.current = shareId;
const spaceSlugRef = useRef<string | undefined>(spaceSlug);
spaceSlugRef.current = spaceSlug;
// Last looked-up value for each key. Re-subscribers (e.g. when the editor
// remounts after switching from static to live) get this immediately
// instead of triggering a duplicate fetch.
@@ -91,12 +100,18 @@ export function TransclusionLookupProvider({
try {
const activeShareId = shareIdRef.current;
const activeSpaceSlug = spaceSlugRef.current;
const { items } = activeShareId
? await lookupTransclusionForShare({
shareId: activeShareId,
references,
})
: await lookupTransclusion({ references });
: activeSpaceSlug
? await lookupTransclusionForPublicSpace({
spaceSlug: activeSpaceSlug,
references,
})
: await lookupTransclusion({ references });
for (const r of items) {
const key = `${r.sourcePageId}::${r.transclusionId}`;
resultCacheRef.current.set(key, r);
@@ -1,6 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { useSetAtom } from "jotai";
import { Node as PMNode } from "@tiptap/pm/model";
import { isEditorReady } from "@docmost/editor-ext";
import {
@@ -15,14 +16,17 @@ import {
IconLayoutAlignRight,
IconDownload,
IconTrash,
IconZoomIn,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
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";
export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
const editorState = useEditorState({
editor,
@@ -128,6 +132,9 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`video-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -183,6 +190,23 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<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}>
<ActionIcon
onClick={handleDownload}