Merge branch 'develop' into feat/footnotes

Resolve conflicts at shared registration points by unioning both features
(footnotes + the already-merged html-embed / page-embed work):
- slash-menu/menu-items.ts, editor extensions.ts: keep both imports + configures
- collaboration.util.ts: register footnote nodes and pageEmbed
- editor-ext marked.utils.ts: register footnote + html-embed markdown extensions
- editor-ext package.json/tsconfig.json/vitest.config.ts: union of test config
  (jsdom env for footnote DOM tests + combined test/spec include glob)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-20 22:21:07 +03:00
193 changed files with 14325 additions and 3395 deletions

View File

@@ -0,0 +1,43 @@
.htmlEmbedNodeView {
position: relative;
}
/* The container the raw source is injected into. */
.htmlEmbedContent {
width: 100%;
}
/* Edit affordance overlay, only shown while editing the document. */
.htmlEmbedToolbar {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
opacity: 0;
transition: opacity 0.15s ease;
}
.htmlEmbedNodeView:hover .htmlEmbedToolbar {
opacity: 1;
}
/* Placeholder card shown when the source is empty (edit mode only). */
.htmlEmbedPlaceholder {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
border: 1px dashed var(--mantine-color-gray-4);
border-radius: 8px;
color: var(--mantine-color-dimmed);
@mixin dark {
border-color: var(--mantine-color-dark-3);
}
}
.htmlEmbedSelected {
outline: 2px solid var(--mantine-color-blue-5);
border-radius: 8px;
}

View File

@@ -0,0 +1,185 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import {
ActionIcon,
Button,
Group,
Modal,
Text,
Textarea,
} from "@mantine/core";
import { IconCode, IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import useUserRole from "@/hooks/use-user-role.tsx";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import classes from "./html-embed-view.module.css";
/**
* Inject raw HTML (including <script> tags) into `container`, executing any
* scripts.
*
* Setting `innerHTML` does NOT run inline or external <script> tags the browser
* parses that way: the HTML spec marks scripts inserted via innerHTML as
* "already started" so they never execute. To get the tracker/analytics
* use-case working we walk the freshly-parsed scripts and replace each with a
* brand-new <script> element copying its attributes and inline code. A
* programmatically created+inserted <script> DOES execute, so this restores
* normal script behaviour in the wiki origin (Variant C).
*/
function renderRawHtml(container: HTMLElement, source: string) {
// Clear any previous render (re-render on source change).
container.innerHTML = "";
if (!source) return;
container.innerHTML = source;
const scripts = Array.from(container.querySelectorAll("script"));
for (const oldScript of scripts) {
const newScript = document.createElement("script");
// Copy every attribute (src, type, async, defer, data-*, etc.).
for (const attr of Array.from(oldScript.attributes)) {
newScript.setAttribute(attr.name, attr.value);
}
// Copy inline code.
newScript.text = oldScript.textContent ?? "";
// Replacing the node in place triggers execution.
oldScript.parentNode?.replaceChild(newScript, oldScript);
}
}
export default function HtmlEmbedView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected, updateAttributes, editor } = props;
const { source } = node.attrs as { source: string };
const { isAdmin } = useUserRole();
// Defense in depth: only execute the raw HTML/JS when the workspace HTML embed
// feature toggle is ON. When OFF (the default), we render a neutral disabled
// placeholder and inject nothing — so turning the feature off neutralizes
// existing embeds at render time as well as on the next server-side save.
const workspace = useAtomValue(workspaceAtom);
const htmlEmbedEnabled = workspace?.settings?.htmlEmbed === true;
// Execution policy split by editor mode:
// - READ-ONLY / public-share view: the SERVER already decided whether to
// include the embed (it strips htmlEmbed from shared content when the
// workspace toggle is OFF). An anonymous viewer has no workspace and thus
// reads `htmlEmbedEnabled` as false, so we must NOT gate execution on it
// here — we execute exactly the `source` the server chose to serve.
// - EDITABLE editor (admin authoring): keep gating on the per-workspace
// toggle so an admin sees the inert placeholder when the feature is OFF.
const shouldExecute = !editor.isEditable || htmlEmbedEnabled;
const contentRef = useRef<HTMLDivElement | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [draft, setDraft] = useState<string>(source || "");
// (Re)render the raw source whenever it changes. This runs in BOTH the
// editable editor and the read-only / public-share editor (same NodeView),
// so trackers fire for readers too — that is the intended behaviour. When the
// feature toggle is OFF we clear the container and inject/execute nothing.
useEffect(() => {
if (!contentRef.current) return;
if (shouldExecute) {
renderRawHtml(contentRef.current, source || "");
} else {
contentRef.current.innerHTML = "";
}
}, [source, shouldExecute]);
const openEditor = useCallback(() => {
setDraft(source || "");
setModalOpen(true);
}, [source]);
const onSave = useCallback(() => {
if (editor.isEditable) {
updateAttributes({ source: draft });
}
setModalOpen(false);
}, [draft, editor.isEditable, updateAttributes]);
// The edit affordance is only meaningful in edit mode, is restricted to admins
// (the server strips the node for non-admins anyway), and is offered only when
// the workspace feature toggle is ON.
const canEdit = editor.isEditable && isAdmin && htmlEmbedEnabled;
return (
<NodeViewWrapper
data-drag-handle
className={clsx(classes.htmlEmbedNodeView, {
[classes.htmlEmbedSelected]: selected,
})}
>
{canEdit && (
<div className={classes.htmlEmbedToolbar}>
<ActionIcon
variant="default"
size="sm"
aria-label={t("Edit HTML embed")}
onClick={openEditor}
>
<IconEdit size={16} />
</ActionIcon>
</div>
)}
{!shouldExecute ? (
// Feature disabled for this workspace AND we're in the editable editor:
// never inject/execute the source. Show a neutral placeholder so an
// existing embed is visibly inert for the authoring admin. Read-only /
// share viewers never hit this branch (`shouldExecute` is always true
// there) — they execute exactly the source the server chose to serve.
<div className={classes.htmlEmbedPlaceholder}>
<IconCode size={18} />
<Text size="sm">
{t("HTML embed is disabled in this workspace")}
</Text>
</div>
) : source ? (
// Raw HTML/CSS/JS rendered into the wiki origin. Scripts are re-created
// in renderRawHtml so they execute.
<div ref={contentRef} className={classes.htmlEmbedContent} />
) : canEdit ? (
<div className={classes.htmlEmbedPlaceholder} onClick={openEditor}>
<IconCode size={18} />
<Text size="sm">{t("Click to add HTML / CSS / JS")}</Text>
</div>
) : (
// Empty source, non-editor: render nothing visible.
<div ref={contentRef} className={classes.htmlEmbedContent} />
)}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={t("Edit HTML embed")}
size="lg"
>
<Text size="xs" c="dimmed" mb="xs">
{t(
"This HTML/CSS/JS runs in the page origin for everyone who views it. Admins only.",
)}
</Text>
<Textarea
autosize
minRows={10}
maxRows={24}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
placeholder={t("<script>...</script>")}
styles={{ input: { fontFamily: "monospace" } }}
data-autofocus
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setModalOpen(false)}>
{t("Cancel")}
</Button>
<Button onClick={onSave}>{t("Save")}</Button>
</Group>
</Modal>
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,53 @@
import React, { createContext, useContext, useMemo } from "react";
/** Hard cap on nesting depth for whole-page embeds (cycle/runaway guard). */
export const PAGE_EMBED_MAX_DEPTH = 5;
type AncestryValue = {
/** sourcePageIds of every ancestor pageEmbed up the render tree. */
chain: string[];
/** Includes the host page id so a top-level self-embed is also caught. */
hostPageId: string | null;
};
const PageEmbedAncestryContext = createContext<AncestryValue>({
chain: [],
hostPageId: null,
});
/**
* Carries the ancestor `sourcePageId` chain down the nested read-only editors.
* The node view reads it to detect cycles (current id already in the chain) and
* to enforce a hard depth limit before mounting a deeper nested editor.
*/
export function PageEmbedAncestryProvider({
sourcePageId,
hostPageId,
children,
}: {
sourcePageId?: string | null;
hostPageId?: string | null;
children: React.ReactNode;
}) {
const parent = useContext(PageEmbedAncestryContext);
const value = useMemo<AncestryValue>(() => {
const nextHost = parent.hostPageId ?? hostPageId ?? null;
if (!sourcePageId) {
return { chain: parent.chain, hostPageId: nextHost };
}
return {
chain: [...parent.chain, sourcePageId],
hostPageId: nextHost,
};
}, [parent, sourcePageId, hostPageId]);
return (
<PageEmbedAncestryContext.Provider value={value}>
{children}
</PageEmbedAncestryContext.Provider>
);
}
export function usePageEmbedAncestry() {
return useContext(PageEmbedAncestryContext);
}

View File

@@ -0,0 +1,49 @@
import { EditorProvider } from "@tiptap/react";
import { useMemo } from "react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { UniqueID } from "@docmost/editor-ext";
type Props = {
content: unknown;
};
/**
* Read-only nested renderer for embedded whole-page content. Same pattern as
* the transclusion read-only renderer: drop uniqueID/globalDragHandle, never
* write back, and isolate pointer/drag events from the host editor. Nested
* `pageEmbed`/`transclusionReference` nodes inside the content render with
* their own views (the cycle/depth guard lives in the node view itself).
*/
export default function PageEmbedContent({ content }: Props) {
const extensions = useMemo(() => {
const filtered = mainExtensions.filter(
(e: any) => e.name !== "uniqueID" && e.name !== "globalDragHandle",
);
return [
...filtered,
UniqueID.configure({
types: ["heading", "paragraph", "transclusionSource"],
updateDocument: false,
}),
];
}, []);
const stop = (e: React.SyntheticEvent) => e.stopPropagation();
return (
<div
onMouseDown={stop}
onClick={stop}
onDragStart={stop}
onDragOver={stop}
onDrop={stop}
>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={extensions}
content={content as any}
/>
</div>
);
}

View File

@@ -0,0 +1,162 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { lookupTemplate } from "@/features/page-embed/services/page-embed-api";
import type { PageTemplateLookup } from "@/features/page-embed/types/page-embed.types";
type ContextValue = {
subscribe: (s: {
sourcePageId: string;
setResult: (r: PageTemplateLookup) => void;
}) => () => void;
refresh: (sourcePageId: string) => Promise<void>;
};
const PageEmbedLookupContext = createContext<ContextValue | null>(null);
/**
* Batching/de-dup lookup context for whole-page embeds (pageEmbed). Mirrors the
* transclusion lookup context but keys purely on `sourcePageId`. On public
* shares there is no lookup in MVP, so the context simply isn't mounted (the
* node view renders a placeholder when the context is absent).
*/
export function PageEmbedLookupProvider({
children,
}: {
children: React.ReactNode;
}) {
const subscribersRef = useRef(new Map<string, Array<(r: PageTemplateLookup) => void>>());
const queueRef = useRef(new Set<string>());
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resultCacheRef = useRef(new Map<string, PageTemplateLookup>());
const inFlightRef = useRef(new Set<string>());
const pendingRef = useRef(new Map<string, Array<() => void>>());
const flush = useCallback(async () => {
tickRef.current = null;
const ids = Array.from(queueRef.current);
queueRef.current.clear();
if (ids.length === 0) return;
for (const id of ids) inFlightRef.current.add(id);
const resolveWaiters = (id: string) => {
const waiters = pendingRef.current.get(id);
if (!waiters) return;
pendingRef.current.delete(id);
for (const w of waiters) w();
};
try {
const { items } = await lookupTemplate({ sourcePageIds: ids });
for (const r of items) {
resultCacheRef.current.set(r.sourcePageId, r);
inFlightRef.current.delete(r.sourcePageId);
const subs = subscribersRef.current.get(r.sourcePageId);
if (subs) {
for (const set of subs) set(r);
}
resolveWaiters(r.sourcePageId);
}
} catch (err) {
// Surface the failure: errors must never be swallowed silently.
console.error("[pageEmbed] template lookup failed", err);
for (const id of ids) {
inFlightRef.current.delete(id);
resolveWaiters(id);
}
}
}, []);
const enqueue = useCallback(
(id: string) => {
queueRef.current.add(id);
if (tickRef.current === null) {
tickRef.current = setTimeout(flush, 10);
}
},
[flush],
);
const subscribe = useCallback<ContextValue["subscribe"]>(
({ sourcePageId, setResult }) => {
const list = subscribersRef.current.get(sourcePageId) ?? [];
list.push(setResult);
subscribersRef.current.set(sourcePageId, list);
const cached = resultCacheRef.current.get(sourcePageId);
if (cached) {
setResult(cached);
} else if (!inFlightRef.current.has(sourcePageId)) {
enqueue(sourcePageId);
}
return () => {
const cur = subscribersRef.current.get(sourcePageId) ?? [];
const next = cur.filter((x) => x !== setResult);
if (next.length === 0) subscribersRef.current.delete(sourcePageId);
else subscribersRef.current.set(sourcePageId, next);
};
},
[enqueue],
);
const refresh = useCallback<ContextValue["refresh"]>(
(sourcePageId) =>
new Promise<void>((resolve) => {
resultCacheRef.current.delete(sourcePageId);
inFlightRef.current.delete(sourcePageId);
const waiters = pendingRef.current.get(sourcePageId) ?? [];
waiters.push(resolve);
pendingRef.current.set(sourcePageId, waiters);
enqueue(sourcePageId);
}),
[enqueue],
);
useEffect(
() => () => {
if (tickRef.current) clearTimeout(tickRef.current);
},
[],
);
const value = useMemo<ContextValue>(
() => ({ subscribe, refresh }),
[subscribe, refresh],
);
return (
<PageEmbedLookupContext.Provider value={value}>
{children}
</PageEmbedLookupContext.Provider>
);
}
export function usePageEmbedLookup(sourcePageId: string | null | undefined): {
result: PageTemplateLookup | null;
refresh: () => Promise<void>;
available: boolean;
} {
const ctx = useContext(PageEmbedLookupContext);
const [result, setResult] = useState<PageTemplateLookup | null>(null);
useEffect(() => {
if (!ctx || !sourcePageId) return;
const unsubscribe = ctx.subscribe({ sourcePageId, setResult });
return unsubscribe;
}, [ctx, sourcePageId]);
const refresh = useCallback(async () => {
if (!ctx || !sourcePageId) return;
await ctx.refresh(sourcePageId);
}, [ctx, sourcePageId]);
return { result, refresh, available: Boolean(ctx) };
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from "react";
import { Modal, ScrollArea, TextInput, Text, UnstyledButton, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { IconFileText, IconSearch } from "@tabler/icons-react";
import type { Editor, Range } from "@tiptap/core";
import { searchSuggestions } from "@/features/search/services/search-service";
import type { IPage } from "@/features/page/types/page.types";
export const PAGE_EMBED_PICKER_EVENT = "open-page-embed-picker";
type PickerDetail = {
editor: Editor;
range: Range;
/** Host page id, used to forbid self-embed in the picker. */
hostPageId?: string;
};
/**
* Modal page picker for inserting a `pageEmbed`. Queries search-suggestions
* with `onlyTemplates` so only template-flagged pages are offered. Forbids
* selecting the current (host) page (self-embed guard at insertion time).
* Mounted once per editor; opened via a CustomEvent dispatched by the slash
* command item.
*/
export default function PageEmbedPicker() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [query, setQuery] = useState("");
const detailRef = useRef<PickerDetail | null>(null);
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<PickerDetail>).detail;
if (!detail?.editor) return;
detailRef.current = detail;
setQuery("");
setOpened(true);
};
document.addEventListener(PAGE_EMBED_PICKER_EVENT, handler);
return () => document.removeEventListener(PAGE_EMBED_PICKER_EVENT, handler);
}, []);
const { data, isFetching } = useQuery({
queryKey: ["page-embed-template-picker", query],
queryFn: () =>
searchSuggestions({
query,
includePages: true,
onlyTemplates: true,
limit: 20,
}),
enabled: opened,
staleTime: 30 * 1000,
});
const hostPageId = detailRef.current?.hostPageId;
const pages = ((data?.pages ?? []) as IPage[]).filter(
(p) => p && p.id !== hostPageId,
);
const handleSelect = (page: IPage) => {
const detail = detailRef.current;
if (!detail) return;
const { editor, range } = detail;
editor
.chain()
.focus()
.deleteRange(range)
.insertPageEmbed({ sourcePageId: page.id })
.run();
setOpened(false);
};
return (
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={t("Embed page")}
size="md"
>
<TextInput
placeholder={t("Search templates...")}
leftSection={<IconSearch size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
autoFocus
mb="sm"
/>
<ScrollArea.Autosize mah={320}>
{pages.length === 0 && !isFetching && (
<Text size="sm" c="dimmed" ta="center" py="md">
{t("No templates found")}
</Text>
)}
{pages.map((page) => (
<UnstyledButton
key={page.id}
onClick={() => handleSelect(page)}
style={{ display: "block", width: "100%", padding: "8px 4px" }}
>
<Group gap="xs" wrap="nowrap">
{page.icon ? (
<span>{page.icon}</span>
) : (
<IconFileText size={16} />
)}
<Text size="sm" truncate>
{page.title || t("Untitled")}
</Text>
</Group>
</UnstyledButton>
))}
</ScrollArea.Autosize>
</Modal>
);
}

View File

@@ -0,0 +1,253 @@
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}
</>
);
}

View File

@@ -29,7 +29,9 @@ import {
IconMoodSmile,
IconRotate2,
IconSuperscript,
IconArrowsMaximize,
} from "@tabler/icons-react";
import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed/page-embed-picker";
import {
CommandProps,
SlashMenuGroupedItemsType,
@@ -544,6 +546,29 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
{
title: "Embed page",
description: "Insert a live, read-only copy of another page.",
searchTerms: [
"template",
"embed",
"embed page",
"page",
"live",
"include",
"reuse",
],
icon: IconArrowsMaximize,
command: ({ editor, range }: CommandProps) => {
// @ts-ignore - editor.storage.pageId is set by the host editor
const hostPageId: string | undefined = editor.storage?.pageId;
document.dispatchEvent(
new CustomEvent(PAGE_EMBED_PICKER_EVENT, {
detail: { editor, range, hostPageId },
}),
);
},
},
{
title: "2 Columns",
description: "Split content into two columns.",
@@ -596,6 +621,22 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.insertColumns({ layout: "five_equal" })
.run(),
},
{
title: "HTML embed",
description: "Embed raw HTML, CSS and JavaScript (admins only).",
searchTerms: ["html", "css", "js", "javascript", "script", "tracker", "analytics", "raw", "embed"],
icon: IconCode,
adminOnly: true,
requiresHtmlEmbedFeature: true,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setHtmlEmbed({ source: "" })
.run();
},
},
{
title: "Iframe embed",
description: "Embed any Iframe",
@@ -753,6 +794,43 @@ const CommandGroups: SlashMenuGroupedItemsType = {
],
};
/**
* Read whether the current user is a workspace admin/owner from the persisted
* `currentUser` (the same payload `currentUserAtom` stores via localStorage).
* Used to hide admin-only slash items (e.g. raw HTML embed). This is a UI gate
* only; the server independently strips admin-only nodes from non-admin writes.
*/
function isCurrentUserAdmin(): boolean {
try {
const raw = localStorage.getItem("currentUser");
if (!raw) return false;
const parsed = JSON.parse(raw);
const role = parsed?.user?.role;
return role === "owner" || role === "admin";
} catch {
return false;
}
}
/**
* Read the workspace-level HTML embed feature toggle from the persisted
* `currentUser` payload (the same localStorage entry `currentUserAtom` writes,
* carrying `workspace.settings`). ABSENT/false => OFF (the default). The slash
* `getSuggestionItems` is a plain function (no React/atom context), so we read
* the persisted state the same way `isCurrentUserAdmin()` does. UI gate only;
* the server independently strips htmlEmbed from every non-allowed write.
*/
function isHtmlEmbedFeatureEnabled(): boolean {
try {
const raw = localStorage.getItem("currentUser");
if (!raw) return false;
const parsed = JSON.parse(raw);
return parsed?.workspace?.settings?.htmlEmbed === true;
} catch {
return false;
}
}
export const getSuggestionItems = ({
query,
excludeItems,
@@ -762,6 +840,8 @@ export const getSuggestionItems = ({
}): SlashMenuGroupedItemsType => {
const search = query.toLowerCase();
const filteredGroups: SlashMenuGroupedItemsType = {};
const isAdmin = isCurrentUserAdmin();
const htmlEmbedFeatureEnabled = isHtmlEmbedFeatureEnabled();
const fuzzyMatch = (query: string, target: string) => {
let queryIndex = 0;
@@ -776,6 +856,11 @@ export const getSuggestionItems = ({
for (const [group, items] of Object.entries(CommandGroups)) {
const filteredItems = items.filter((item) => {
if (excludeItems?.has(item.title)) return false;
// Hide admin-only items (raw HTML embed) from non-admins.
if (item.adminOnly && !isAdmin) return false;
// Hide HTML-embed-gated items unless the workspace feature toggle is ON.
if (item.requiresHtmlEmbedFeature && !htmlEmbedFeatureEnabled)
return false;
return (
fuzzyMatch(search, item.title) ||
item.description.toLowerCase().includes(search) ||

View File

@@ -21,6 +21,14 @@ export type SlashMenuItemType = {
searchTerms: string[];
command: (props: CommandProps) => void;
disable?: (editor: ReturnType<typeof useEditor>) => boolean;
// When true, the item is only offered to workspace admins/owners. This is a
// UI convenience only — the real authoring gate is enforced server-side.
adminOnly?: boolean;
// When true, the item is hidden unless the workspace HTML embed feature toggle
// is ON. Combined with adminOnly, the item shows only for admins in workspaces
// where the feature is enabled. UI gate only — the server strips htmlEmbed on
// every write where the toggle is OFF or the user is not an admin.
requiresHtmlEmbedFeature?: boolean;
};
export type SlashMenuGroupedItemsType = {