feat(beta): public spaces

This commit is contained in:
Philipinho
2026-09-05 11:25:50 +01:00
parent 5b85464561
commit a12bf42078
117 changed files with 7593 additions and 716 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={{
@@ -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={{
@@ -273,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={{
@@ -156,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={{
@@ -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);
@@ -260,6 +283,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);
}
@@ -276,8 +320,11 @@ export default function LinkView(props: MarkViewProps) {
location.pathname,
isInternal,
isShareRoute,
isPublicSpaceRoute,
slugId,
shareId,
spaceSlug,
publicSpacePageData,
pageTitle,
pageSlug,
]);
@@ -319,12 +366,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 = (
@@ -374,6 +427,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";
@@ -21,17 +23,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;
@@ -87,7 +102,61 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{isPageMention && !isShareRoute && isError && (
{isPageMention && 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. */}
{isPageMention && 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>
)}
{isPageMention && !isShareRoute && !isPublicSpaceRoute && isError && (
<Anchor
component={Link}
fw={500}
@@ -111,7 +180,7 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{isPageMention && !isShareRoute && !isError && (
{isPageMention && !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={{
@@ -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,22 +3,30 @@ 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, pageSlug } = useParams();
const { t } = useTranslation();
const location = useLocation();
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
// @ts-ignore
const storagePageId = editor.storage.pageId;
@@ -29,11 +37,25 @@ export default function SubpagesView(props: NodeViewProps) {
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(() => {
@@ -48,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">
@@ -96,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);
@@ -132,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={{
@@ -35,6 +35,16 @@ interface PageEditorProps {
* that isn't itself shared.
*/
shareId?: string;
/**
* When rendering inside a public space, pass the space slug. Transclusion
* lookups then resolve against the published space instead of the viewer's
* personal permissions.
*/
spaceSlug?: string;
/** Rendered between the title and the content. */
byline?: React.ReactNode;
/** Set false when the consumer renders its own end matter (e.g. prev/next). */
trailingSpace?: boolean;
}
export default function ReadonlyPageEditor({
@@ -43,12 +53,16 @@ export default function ReadonlyPageEditor({
pageId,
printMode = false,
shareId,
spaceSlug,
byline,
trailingSpace = true,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [contentEditor, setContentEditor] = useState<Editor | null>(null);
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
const isPublicView = Boolean(shareId || spaceSlug);
const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current,
@@ -64,9 +78,9 @@ export default function ReadonlyPageEditor({
}, []);
useEffect(() => {
if (!shareId) return;
if (!isPublicView) return;
setLightboxRequest(null);
}, [pageId, shareId]);
}, [pageId, isPublicView]);
const extensions = useMemo(() => {
const excludedExtensions = new Set([
@@ -99,7 +113,7 @@ export default function ReadonlyPageEditor({
];
return (
<TransclusionLookupProvider shareId={shareId}>
<TransclusionLookupProvider shareId={shareId} spaceSlug={spaceSlug}>
<div className="page-title">
<EditorProvider
editable={false}
@@ -110,6 +124,8 @@ export default function ReadonlyPageEditor({
></EditorProvider>
</div>
{byline}
<EditorProvider
editable={false}
immediatelyRender={true}
@@ -117,7 +133,7 @@ export default function ReadonlyPageEditor({
extensions={extensions}
content={content}
editorProps={
shareId
isPublicView
? {
handleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
@@ -144,7 +160,7 @@ export default function ReadonlyPageEditor({
}
}}
></EditorProvider>
{shareId && contentEditor && (
{isPublicView && contentEditor && (
<LightboxView
editor={contentEditor}
open={!!lightboxRequest}
@@ -153,7 +169,7 @@ export default function ReadonlyPageEditor({
onClose={() => setLightboxRequest(null)}
/>
)}
<div style={{ paddingBottom: "20vh" }}></div>
{trailingSpace && <div style={{ paddingBottom: "20vh" }}></div>}
</TransclusionLookupProvider>
);
}