Embed another page's LIVE content into a host page (it updates when the source
changes, not a static copy). A page can be flagged a template for discovery in
the picker; any accessible page can be embedded.
Server:
- migrations: pages.is_template (+ partial index) and page_template_references
(whole-page back-refs); db.d.ts/entity types hand-merged (db.d.ts is curated).
- POST /pages/toggle-template (CASL Edit) flips is_template; is_template is
returned by findById + the sidebar tree select so the tree menu label
reflects state. Search suggestions gain an onlyTemplates filter for the picker.
- POST /pages/template/lookup ({sourcePageIds[]}, <=50): returns each accessible
source's {title, icon, slugId, content, sourceUpdatedAt} with comment marks
stripped (same access path as transclusion: filterViewerAccessiblePageIds;
inaccessible -> no_access, missing -> not_found; error path -> not_found, never
raw content).
- reference sync (collectPageEmbedsFromPmJson + syncPageTemplateReferences) on
the Yjs save hook; duplicatePage remaps pageEmbed.sourcePageId + inserts refs.
Known MVP gap: REST content updates don't resync refs (lookup uses in-doc ids).
Client:
- pageEmbed node (editor-ext, registered in BOTH client + server schemas);
read-only NodeView with a batching lookup; '/Embed page' slash + template
picker (self-embed prevented); 'Make/Unset template' in the tree node menu.
- Cycle guard: an ancestry-chain context + depth cap (5) render a 'circular
embed' placeholder instead of recursing.
- Public shares show a placeholder (no public lookup in MVP).
MVP excludes (follow-ups): public-share lookup, unsync->static copy, server-side
expansion for export/RAG, MCP schema mirror, point-in-time snapshots.
Implements docs/page-templates-plan.md (MVP, variant A).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
254 lines
7.2 KiB
TypeScript
254 lines
7.2 KiB
TypeScript
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
|
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
|
import {
|
|
IconAlertTriangle,
|
|
IconArrowsMaximize,
|
|
IconDots,
|
|
IconExternalLink,
|
|
IconEyeOff,
|
|
IconInfoCircle,
|
|
IconRefresh,
|
|
IconRepeat,
|
|
IconTrash,
|
|
} from "@tabler/icons-react";
|
|
import { useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ErrorBoundary } from "react-error-boundary";
|
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
|
import classes from "../transclusion/transclusion.module.css";
|
|
import { usePageEmbedLookup } from "./page-embed-lookup-context";
|
|
import {
|
|
PageEmbedAncestryProvider,
|
|
usePageEmbedAncestry,
|
|
PAGE_EMBED_MAX_DEPTH,
|
|
} from "./page-embed-ancestry-context";
|
|
import PageEmbedContent from "./page-embed-content";
|
|
|
|
function Placeholder({
|
|
icon,
|
|
label,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<div className={classes.placeholder}>
|
|
<span className={classes.placeholderIcon}>{icon}</span>
|
|
<span>{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function PageEmbedView(props: NodeViewProps) {
|
|
const isEditable = props.editor.isEditable;
|
|
const sourcePageId: string | null = props.node.attrs.sourcePageId ?? null;
|
|
const [openMenus, setOpenMenus] = useState(0);
|
|
const trackOpen = (open: boolean) =>
|
|
setOpenMenus((n) => Math.max(0, n + (open ? 1 : -1)));
|
|
|
|
return (
|
|
<NodeViewWrapper
|
|
className={classes.includeWrap}
|
|
data-editable={isEditable ? "true" : "false"}
|
|
data-focused={isEditable && props.selected ? "true" : "false"}
|
|
data-menu-open={openMenus > 0 ? "true" : "false"}
|
|
contentEditable={false}
|
|
>
|
|
<ErrorBoundary
|
|
resetKeys={[sourcePageId]}
|
|
onError={(err) =>
|
|
// Never swallow: log the full error with the offending source id.
|
|
console.error("[pageEmbed] render error", { sourcePageId, err })
|
|
}
|
|
fallback={
|
|
<Placeholder
|
|
icon={<IconAlertTriangle size={18} stroke={1.6} />}
|
|
label="Failed to load this embedded page"
|
|
/>
|
|
}
|
|
>
|
|
<PageEmbedBody {...props} trackOpen={trackOpen} />
|
|
</ErrorBoundary>
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|
|
|
|
function PageEmbedBody({
|
|
editor,
|
|
node,
|
|
deleteNode,
|
|
trackOpen,
|
|
}: NodeViewProps & { trackOpen: (open: boolean) => void }) {
|
|
const { t } = useTranslation();
|
|
const sourcePageId: string | null = node.attrs.sourcePageId ?? null;
|
|
const isEditable = editor.isEditable;
|
|
const ancestry = usePageEmbedAncestry();
|
|
|
|
// @ts-ignore - editor.storage.pageId is set by the host editor
|
|
const hostPageId: string | undefined = editor.storage?.pageId;
|
|
|
|
const { result, refresh, available } = usePageEmbedLookup(sourcePageId);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const handleRefresh = async () => {
|
|
setRefreshing(true);
|
|
try {
|
|
await refresh();
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
// --- Cycle / depth guard (evaluated before any lookup is rendered) ---------
|
|
// Self-embed or a source already present in the ancestor chain → cycle.
|
|
const isCycle =
|
|
!!sourcePageId &&
|
|
(ancestry.chain.includes(sourcePageId) ||
|
|
ancestry.hostPageId === sourcePageId);
|
|
const isTooDeep = ancestry.chain.length >= PAGE_EMBED_MAX_DEPTH;
|
|
|
|
const sourceTitle =
|
|
result && !("status" in result) ? result.title : null;
|
|
const sourceIcon = result && !("status" in result) ? result.icon : null;
|
|
// The app routes pages by slugId, not the raw UUID. Build the link from the
|
|
// resolved slugId (the `/p/:pageSlug` route redirects to the full URL).
|
|
const sourceSlugId =
|
|
result && !("status" in result) ? result.slugId : null;
|
|
const sourceHref = sourceSlugId
|
|
? buildPageUrl(undefined, sourceSlugId, sourceTitle ?? undefined)
|
|
: null;
|
|
|
|
const controls = isEditable ? (
|
|
<div
|
|
className={classes.includeControls}
|
|
contentEditable={false}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
>
|
|
<Tooltip label={t("Refresh")}>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
size="sm"
|
|
onClick={handleRefresh}
|
|
loading={refreshing}
|
|
disabled={!sourcePageId}
|
|
>
|
|
<IconRefresh size={14} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
{sourceHref && (
|
|
<Tooltip label={t("Open source page")}>
|
|
<ActionIcon
|
|
component={Link}
|
|
to={sourceHref}
|
|
variant="subtle"
|
|
color="gray"
|
|
size="sm"
|
|
style={{ textDecoration: "none", borderBottom: "none" }}
|
|
>
|
|
<IconExternalLink size={14} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
<Menu position="bottom-end" withinPortal onChange={trackOpen}>
|
|
<Menu.Target>
|
|
<ActionIcon variant="subtle" color="gray" size="sm">
|
|
<IconDots size={14} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Item
|
|
color="red"
|
|
leftSection={<IconTrash size={14} />}
|
|
onClick={() => deleteNode()}
|
|
>
|
|
{t("Remove from page")}
|
|
</Menu.Item>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</div>
|
|
) : null;
|
|
|
|
const header =
|
|
sourceTitle || sourceIcon ? (
|
|
<div className={classes.transclusionBadge}>
|
|
{sourceIcon ? `${sourceIcon} ` : <IconArrowsMaximize size={12} />}
|
|
{sourceHref ? (
|
|
<Link
|
|
to={sourceHref}
|
|
style={{ borderBottom: "none", textDecoration: "none" }}
|
|
>
|
|
{sourceTitle || t("Untitled")}
|
|
</Link>
|
|
) : (
|
|
sourceTitle || t("Untitled")
|
|
)}
|
|
</div>
|
|
) : null;
|
|
|
|
let body: React.ReactNode;
|
|
if (!sourcePageId) {
|
|
body = (
|
|
<Placeholder
|
|
icon={<IconInfoCircle size={18} stroke={1.6} />}
|
|
label={t("No page selected")}
|
|
/>
|
|
);
|
|
} else if (isCycle) {
|
|
body = (
|
|
<Placeholder
|
|
icon={<IconRepeat size={18} stroke={1.6} />}
|
|
label={t("Circular embed: this page is already shown above")}
|
|
/>
|
|
);
|
|
} else if (isTooDeep) {
|
|
body = (
|
|
<Placeholder
|
|
icon={<IconRepeat size={18} stroke={1.6} />}
|
|
label={t("Embed nesting limit reached")}
|
|
/>
|
|
);
|
|
} else if (!available) {
|
|
// No lookup context (e.g. public share) → placeholder, no fetch in MVP.
|
|
body = (
|
|
<Placeholder
|
|
icon={<IconEyeOff size={18} stroke={1.6} />}
|
|
label={t("Embedded page is not available here")}
|
|
/>
|
|
);
|
|
} else if (!result) {
|
|
body = <div style={{ minHeight: 24 }} />;
|
|
} else if (!("status" in result)) {
|
|
body = (
|
|
<PageEmbedAncestryProvider
|
|
sourcePageId={sourcePageId}
|
|
hostPageId={hostPageId}
|
|
>
|
|
<PageEmbedContent content={result.content} />
|
|
</PageEmbedAncestryProvider>
|
|
);
|
|
} else if (result.status === "no_access") {
|
|
body = (
|
|
<Placeholder
|
|
icon={<IconEyeOff size={18} stroke={1.6} />}
|
|
label={t("You don't have access to this page")}
|
|
/>
|
|
);
|
|
} else {
|
|
body = (
|
|
<Placeholder
|
|
icon={<IconInfoCircle size={18} stroke={1.6} />}
|
|
label={t("The embedded page no longer exists")}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{controls}
|
|
{header}
|
|
{body}
|
|
</>
|
|
);
|
|
}
|