mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 15:34:05 +08:00
feat: synced blocks (transclusion) (#2163)
* feat: synced blocks (transclusion) * fix:remove name * make placeholders smaller * feat: enforce strict transclusion schema * fix: scope synced blocks to workspace, gate unsync on edit permission * fix collab module error
This commit is contained in:
@@ -54,7 +54,6 @@
|
||||
"react-router-dom": "^7.13.1",
|
||||
"semver": "^7.7.4",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -872,6 +872,10 @@
|
||||
"Search chats...": "Search chats...",
|
||||
"Search chats": "Search chats",
|
||||
"Ask anything... Use @ to mention pages": "Ask anything... Use @ to mention pages",
|
||||
"Ask anything or search your workspace": "Ask anything or search your workspace",
|
||||
"Welcome to {{name}}": "Welcome to {{name}}",
|
||||
"Add files": "Add files",
|
||||
"Mention a page": "Mention a page",
|
||||
"Start a new chat to see it here.": "Start a new chat to see it here.",
|
||||
"Summarize this page": "Summarize this page",
|
||||
"Toggle AI Chat": "Toggle AI Chat",
|
||||
@@ -929,4 +933,18 @@
|
||||
"AI navigation": "AI navigation",
|
||||
"Breadcrumb": "Breadcrumb",
|
||||
"Skip to main content": "Skip to main content"
|
||||
"Synced block": "Synced block",
|
||||
"Create a block that stays in sync across pages.": "Create a block that stays in sync across pages.",
|
||||
"Editing original": "Editing original",
|
||||
"Copy synced block": "Copy synced block",
|
||||
"Unsync": "Unsync",
|
||||
"Delete synced block": "Delete synced block",
|
||||
"Synced to {{count}} other page_one": "Synced to {{count}} other page",
|
||||
"Synced to {{count}} other page_other": "Synced to {{count}} other pages",
|
||||
"ORIGINAL": "ORIGINAL",
|
||||
"THIS PAGE": "THIS PAGE",
|
||||
"No pages": "No pages",
|
||||
"The original synced block no longer exists": "The original synced block no longer exists",
|
||||
"You don't have access to this synced block": "You don't have access to this synced block",
|
||||
"Failed to load this synced block": "Failed to load this synced block"
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
|
||||
isStreaming={isStreaming}
|
||||
onSend={onSend}
|
||||
onStop={onStop}
|
||||
placeholder="Ask anything... Use @ to mention pages"
|
||||
placeholder={t("Ask anything... Use @ to mention pages")}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function ChatInput({
|
||||
link: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || "Ask anything... Use @ to mention pages",
|
||||
placeholder: placeholder || t("Ask anything... Use @ to mention pages"),
|
||||
}),
|
||||
CharacterCount.configure({
|
||||
limit: 50000,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IconInfoCircle,
|
||||
IconList,
|
||||
IconListNumbers,
|
||||
IconQuote,
|
||||
IconTypography,
|
||||
} from "@tabler/icons-react";
|
||||
import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
|
||||
@@ -59,6 +60,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
isCodeBlock: ctx.editor.isActive("codeBlock"),
|
||||
isCallout: ctx.editor.isActive("callout"),
|
||||
isDetails: ctx.editor.isActive("details"),
|
||||
isTransclusionSource: ctx.editor.isActive("transclusionSource"),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -122,6 +124,12 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
.run(),
|
||||
isActive: () => editorState?.isBlockquote,
|
||||
},
|
||||
{
|
||||
name: "Synced block",
|
||||
icon: IconQuote,
|
||||
command: () => editor.chain().focus().toggleTransclusionSource().run(),
|
||||
isActive: () => editorState?.isTransclusionSource,
|
||||
},
|
||||
{
|
||||
name: "Code",
|
||||
icon: IconCode,
|
||||
@@ -149,7 +157,12 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
return (
|
||||
<Popover opened={isOpen} withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}>
|
||||
<Tooltip
|
||||
label={t("Turn into")}
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
disabled={isOpen}
|
||||
>
|
||||
<Button
|
||||
className={classes.buttonRoot}
|
||||
variant="default"
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
IconColumns3,
|
||||
IconColumns2,
|
||||
IconTag,
|
||||
IconRotate2,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
@@ -231,7 +232,15 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
{
|
||||
title: "Audio",
|
||||
description: "Upload any audio from your device.",
|
||||
searchTerms: ["audio", "music", "sound", "mp3", "media", "file", "attachment"],
|
||||
searchTerms: [
|
||||
"audio",
|
||||
"music",
|
||||
"sound",
|
||||
"mp3",
|
||||
"media",
|
||||
"file",
|
||||
"attachment",
|
||||
],
|
||||
icon: IconMusic,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
@@ -484,6 +493,28 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Synced block",
|
||||
description: "Create a block that stays in sync across pages.",
|
||||
searchTerms: [
|
||||
"sync",
|
||||
"synced",
|
||||
"synced block",
|
||||
"excerpt",
|
||||
"transclusion",
|
||||
"reusable",
|
||||
"snippet",
|
||||
],
|
||||
icon: IconRotate2,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertTransclusionSource()
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "2 Columns",
|
||||
description: "Split content into two columns.",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./transclusion.module.css";
|
||||
|
||||
export default function ErrorPlaceholder() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={classes.placeholder}>
|
||||
<IconAlertTriangle
|
||||
size={18}
|
||||
stroke={1.6}
|
||||
className={classes.placeholderIcon}
|
||||
/>
|
||||
<span>{t("Failed to load this synced block")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IconEyeOff } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./transclusion.module.css";
|
||||
|
||||
export default function NoAccessPlaceholder() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={classes.placeholder}>
|
||||
<IconEyeOff size={18} stroke={1.6} className={classes.placeholderIcon} />
|
||||
<span>{t("You don't have access to this synced block")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./transclusion.module.css";
|
||||
|
||||
export default function NotFoundPlaceholder() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={classes.placeholder}>
|
||||
<IconInfoCircle
|
||||
size={18}
|
||||
stroke={1.6}
|
||||
className={classes.placeholderIcon}
|
||||
/>
|
||||
<span>{t("The original synced block no longer exists")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export default function TransclusionContent({ 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,
|
||||
}),
|
||||
];
|
||||
}, []);
|
||||
|
||||
// Isolate the nested read-only editor's events from the host editor:
|
||||
// - mousedown/click would otherwise make the host node-select the atom
|
||||
// wrapper, blocking native text selection inside.
|
||||
// - dragstart/dragover/drop would otherwise let the host treat events
|
||||
// inside the nested view as drops on the host, duplicating dropped
|
||||
// files at the transclusion's position.
|
||||
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>
|
||||
);
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
lookupTransclusion,
|
||||
lookupTransclusionForShare,
|
||||
} from "@/features/transclusion/services/transclusion-api";
|
||||
import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types";
|
||||
|
||||
type LookupKey = string; // `${sourcePageId}::${transclusionId}`
|
||||
|
||||
type Subscriber = {
|
||||
key: LookupKey;
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
setResult: (r: TransclusionLookup) => void;
|
||||
};
|
||||
|
||||
type ContextValue = {
|
||||
/** Register a subscriber. Returns an unsubscribe function. */
|
||||
subscribe: (s: Subscriber) => () => void;
|
||||
/**
|
||||
* Force a re-fetch of `key` and resolve when the response arrives (or the
|
||||
* request fails). Bypasses the cache and any in-flight de-dup so the user
|
||||
* always sees a fresh server read.
|
||||
*/
|
||||
refresh: (key: LookupKey) => Promise<void>;
|
||||
};
|
||||
|
||||
const TransclusionLookupContext = createContext<ContextValue | null>(null);
|
||||
|
||||
export function TransclusionLookupProvider({
|
||||
children,
|
||||
shareId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* When set, lookups go through the share-scoped public endpoint and are
|
||||
* gated by the share graph (source page must have its own share or inherit
|
||||
* one). Used by the public share viewer; left undefined in the authenticated
|
||||
* app, where personal permissions gate access.
|
||||
*/
|
||||
shareId?: string;
|
||||
}) {
|
||||
const subscribersRef = useRef(new Map<LookupKey, Subscriber[]>());
|
||||
const queueRef = useRef(new Set<LookupKey>());
|
||||
const tickRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Read inside flush() via ref so changing share context doesn't churn the
|
||||
// memoized callbacks (and thus doesn't re-render every consumer).
|
||||
const shareIdRef = useRef<string | undefined>(shareId);
|
||||
shareIdRef.current = shareId;
|
||||
// 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.
|
||||
const resultCacheRef = useRef(new Map<LookupKey, TransclusionLookup>());
|
||||
// Keys that are currently in flight in a batch request. A second subscribe
|
||||
// for the same key while the first request is pending is a no-op; the
|
||||
// subscriber is added to subscribersRef and will be notified when the
|
||||
// pending request completes.
|
||||
const inFlightRef = useRef(new Set<LookupKey>());
|
||||
// Resolvers waiting on the next response for a key. Populated by refresh()
|
||||
// so callers can await the fetch round-trip; resolved on success and on
|
||||
// network error so the UI never hangs in a loading state.
|
||||
const pendingRef = useRef(new Map<LookupKey, Array<() => void>>());
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
tickRef.current = null;
|
||||
const keys = Array.from(queueRef.current);
|
||||
queueRef.current.clear();
|
||||
if (keys.length === 0) return;
|
||||
|
||||
for (const k of keys) inFlightRef.current.add(k);
|
||||
|
||||
const references = keys.map((k) => {
|
||||
const [sourcePageId, transclusionId] = k.split("::");
|
||||
return { sourcePageId, transclusionId };
|
||||
});
|
||||
|
||||
const resolveWaiters = (key: LookupKey) => {
|
||||
const waiters = pendingRef.current.get(key);
|
||||
if (!waiters) return;
|
||||
pendingRef.current.delete(key);
|
||||
for (const w of waiters) w();
|
||||
};
|
||||
|
||||
try {
|
||||
const activeShareId = shareIdRef.current;
|
||||
const { items } = activeShareId
|
||||
? await lookupTransclusionForShare({
|
||||
shareId: activeShareId,
|
||||
references,
|
||||
})
|
||||
: await lookupTransclusion({ references });
|
||||
for (const r of items) {
|
||||
const key = `${r.sourcePageId}::${r.transclusionId}`;
|
||||
resultCacheRef.current.set(key, r);
|
||||
inFlightRef.current.delete(key);
|
||||
const subs = subscribersRef.current.get(key);
|
||||
if (subs) {
|
||||
for (const s of subs) s.setResult(r);
|
||||
}
|
||||
resolveWaiters(key);
|
||||
}
|
||||
} catch {
|
||||
// Network error — leave subscribers in pending state and clear the
|
||||
// in-flight flag so a future subscribe can retry.
|
||||
for (const k of keys) {
|
||||
inFlightRef.current.delete(k);
|
||||
resolveWaiters(k);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(key: LookupKey) => {
|
||||
queueRef.current.add(key);
|
||||
if (tickRef.current === null) {
|
||||
tickRef.current = setTimeout(flush, 10);
|
||||
}
|
||||
},
|
||||
[flush],
|
||||
);
|
||||
|
||||
const subscribe = useCallback<ContextValue["subscribe"]>(
|
||||
(s) => {
|
||||
const list = subscribersRef.current.get(s.key) ?? [];
|
||||
list.push(s);
|
||||
subscribersRef.current.set(s.key, list);
|
||||
|
||||
const cached = resultCacheRef.current.get(s.key);
|
||||
if (cached) {
|
||||
s.setResult(cached);
|
||||
} else if (!inFlightRef.current.has(s.key)) {
|
||||
enqueue(s.key);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const cur = subscribersRef.current.get(s.key) ?? [];
|
||||
const next = cur.filter((x) => x !== s);
|
||||
if (next.length === 0) subscribersRef.current.delete(s.key);
|
||||
else subscribersRef.current.set(s.key, next);
|
||||
};
|
||||
},
|
||||
[enqueue],
|
||||
);
|
||||
|
||||
const refresh = useCallback<ContextValue["refresh"]>(
|
||||
(key) =>
|
||||
new Promise<void>((resolve) => {
|
||||
resultCacheRef.current.delete(key);
|
||||
inFlightRef.current.delete(key);
|
||||
const waiters = pendingRef.current.get(key) ?? [];
|
||||
waiters.push(resolve);
|
||||
pendingRef.current.set(key, waiters);
|
||||
enqueue(key);
|
||||
}),
|
||||
[enqueue],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (tickRef.current) clearTimeout(tickRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo<ContextValue>(
|
||||
() => ({ subscribe, refresh }),
|
||||
[subscribe, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<TransclusionLookupContext.Provider value={value}>
|
||||
{children}
|
||||
</TransclusionLookupContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTransclusionLookup(
|
||||
sourcePageId: string | null | undefined,
|
||||
transclusionId: string | null | undefined,
|
||||
): {
|
||||
result: TransclusionLookup | null;
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const ctx = useContext(TransclusionLookupContext);
|
||||
const [result, setResult] = useState<TransclusionLookup | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx || !sourcePageId || !transclusionId) return;
|
||||
const key = `${sourcePageId}::${transclusionId}`;
|
||||
const unsubscribe = ctx.subscribe({
|
||||
key,
|
||||
sourcePageId,
|
||||
transclusionId,
|
||||
setResult,
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [ctx, sourcePageId, transclusionId]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!ctx || !sourcePageId || !transclusionId) return;
|
||||
await ctx.refresh(`${sourcePageId}::${transclusionId}`);
|
||||
}, [ctx, sourcePageId, transclusionId]);
|
||||
|
||||
return { result, refresh };
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconDots,
|
||||
IconLinkOff,
|
||||
IconPencil,
|
||||
IconRefresh,
|
||||
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 { useTransclusionLookup } from "./transclusion-lookup-context";
|
||||
import TransclusionContent from "./transclusion-content";
|
||||
import NoAccessPlaceholder from "./no-access-placeholder";
|
||||
import NotFoundPlaceholder from "./not-found-placeholder";
|
||||
import ErrorPlaceholder from "./error-placeholder";
|
||||
import classes from "./transclusion.module.css";
|
||||
import SyncBlockReferencesDropdown from "@/features/transclusion/components/sync-block-references-dropdown";
|
||||
import {
|
||||
useReferencesQuery,
|
||||
useUnsyncReferenceMutation,
|
||||
} from "@/features/transclusion/queries/transclusion-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
|
||||
export default function TransclusionReferenceView(props: NodeViewProps) {
|
||||
const isEditable = props.editor.isEditable;
|
||||
const sourcePageId: string | null = props.node.attrs.sourcePageId ?? null;
|
||||
const transclusionId: string | null = props.node.attrs.transclusionId ?? 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-focused={isEditable && props.selected ? "true" : "false"}
|
||||
data-menu-open={openMenus > 0 ? "true" : "false"}
|
||||
contentEditable={false}
|
||||
>
|
||||
<ErrorBoundary
|
||||
resetKeys={[sourcePageId, transclusionId]}
|
||||
fallback={<ErrorPlaceholder />}
|
||||
>
|
||||
<TransclusionReferenceBody {...props} trackOpen={trackOpen} />
|
||||
</ErrorBoundary>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function TransclusionReferenceBody({
|
||||
editor,
|
||||
node,
|
||||
deleteNode,
|
||||
getPos,
|
||||
trackOpen,
|
||||
}: NodeViewProps & { trackOpen: (open: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const sourcePageId: string | null = node.attrs.sourcePageId ?? null;
|
||||
const transclusionId: string | null = node.attrs.transclusionId ?? null;
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const { result, refresh } = useTransclusionLookup(
|
||||
sourcePageId,
|
||||
transclusionId,
|
||||
);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
// @ts-ignore - editor.storage.pageId is set by the host editor
|
||||
const hostPageId: string | undefined = editor.storage?.pageId;
|
||||
const unsyncMutation = useUnsyncReferenceMutation();
|
||||
// Cached against the dropdown's identical query so the source link target
|
||||
// is ready as soon as the controls fade in on hover, without a second
|
||||
// fetch.
|
||||
const referencesQuery = useReferencesQuery(
|
||||
sourcePageId,
|
||||
transclusionId,
|
||||
isEditable,
|
||||
);
|
||||
const sourcePageHref = (() => {
|
||||
const source = referencesQuery.data?.source;
|
||||
const base = source?.spaceSlug
|
||||
? buildPageUrl(source.spaceSlug, source.slugId, source.title)
|
||||
: sourcePageId
|
||||
? `/p/${sourcePageId}`
|
||||
: null;
|
||||
if (!base) return null;
|
||||
return transclusionId ? `${base}#${transclusionId}` : base;
|
||||
})();
|
||||
|
||||
const handleUnsync = async () => {
|
||||
if (!hostPageId || !sourcePageId || !transclusionId) return;
|
||||
try {
|
||||
const { content } = await unsyncMutation.mutateAsync({
|
||||
referencePageId: hostPageId,
|
||||
sourcePageId,
|
||||
transclusionId,
|
||||
});
|
||||
const pos = getPos();
|
||||
if (typeof pos !== "number") return;
|
||||
const from = pos;
|
||||
const to = pos + node.nodeSize;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt({ from, to }, content as any)
|
||||
.run();
|
||||
} catch {
|
||||
// mutation surfaces errors via React Query; node stays as-is
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditable && (
|
||||
<div
|
||||
className={classes.includeControls}
|
||||
contentEditable={false}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{sourcePageId && transclusionId && hostPageId && (
|
||||
<SyncBlockReferencesDropdown
|
||||
sourcePageId={sourcePageId}
|
||||
transclusionId={transclusionId}
|
||||
currentPageId={hostPageId}
|
||||
mode="reference"
|
||||
onOpenChange={trackOpen}
|
||||
/>
|
||||
)}
|
||||
<span className={classes.controlsDivider} />
|
||||
<Tooltip label={t("Refresh")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
disabled={!sourcePageId || !transclusionId}
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{sourcePageHref && (
|
||||
<Tooltip label={t("Edit source")}>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to={sourcePageHref}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
borderBottom: "none",
|
||||
}}
|
||||
>
|
||||
<IconPencil 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
|
||||
leftSection={<IconLinkOff size={14} />}
|
||||
onClick={handleUnsync}
|
||||
disabled={
|
||||
unsyncMutation.isPending ||
|
||||
!hostPageId ||
|
||||
!sourcePageId ||
|
||||
!transclusionId
|
||||
}
|
||||
>
|
||||
{t("Unsync")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => deleteNode()}
|
||||
>
|
||||
{t("Remove from page")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sourcePageId || !transclusionId ? (
|
||||
<NotFoundPlaceholder />
|
||||
) : !result ? (
|
||||
<div style={{ minHeight: 24 }} />
|
||||
) : !("status" in result) ? (
|
||||
<TransclusionContent content={result.content} />
|
||||
) : result.status === "no_access" ? (
|
||||
<NoAccessPlaceholder />
|
||||
) : (
|
||||
<NotFoundPlaceholder />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
NodeViewContent,
|
||||
NodeViewProps,
|
||||
NodeViewWrapper,
|
||||
} from "@tiptap/react";
|
||||
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconLinkOff,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "./transclusion.module.css";
|
||||
import SyncBlockReferencesDropdown from "@/features/transclusion/components/sync-block-references-dropdown";
|
||||
|
||||
export default function TransclusionView(props: NodeViewProps) {
|
||||
const { editor, node, deleteNode } = props;
|
||||
const { t } = useTranslation();
|
||||
const [openMenus, setOpenMenus] = useState(0);
|
||||
const trackOpen = (open: boolean) =>
|
||||
setOpenMenus((n) => Math.max(0, n + (open ? 1 : -1)));
|
||||
|
||||
const isEditable = editor.isEditable;
|
||||
// @ts-ignore - editor.storage.pageId is set by the host editor (page-editor.tsx onCreate)
|
||||
const sourcePageId: string | undefined = editor.storage?.pageId;
|
||||
const transclusionId: string | null = node.attrs.id ?? null;
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = async () => {
|
||||
if (!sourcePageId || !transclusionId) return;
|
||||
const html = `<div data-type="transclusionReference" data-source-page-id="${sourcePageId}" data-transclusion-id="${transclusionId}"></div>`;
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/html": new Blob([html], { type: "text/html" }),
|
||||
"text/plain": new Blob([html], { type: "text/plain" }),
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// Fallback for browsers without ClipboardItem write support
|
||||
try {
|
||||
await navigator.clipboard.writeText(html);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
notifications.show({
|
||||
message: t("Copied. Paste on any page to embed this synced block."),
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnsync = () => {
|
||||
editor.chain().focus().unsyncTransclusionSource().run();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={classes.transclusionWrap}
|
||||
data-menu-open={openMenus > 0 ? "true" : "false"}
|
||||
data-id={transclusionId ?? undefined}
|
||||
>
|
||||
{isEditable && (
|
||||
<div
|
||||
className={classes.transclusionControls}
|
||||
contentEditable={false}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{sourcePageId && transclusionId && (
|
||||
<SyncBlockReferencesDropdown
|
||||
sourcePageId={sourcePageId}
|
||||
transclusionId={transclusionId}
|
||||
currentPageId={sourcePageId}
|
||||
mode="source"
|
||||
onOpenChange={trackOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className={classes.controlsDivider} />
|
||||
|
||||
<Tooltip label={copied ? t("Copied") : t("Copy synced block")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={copied ? "teal" : "gray"}
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
disabled={!sourcePageId || !transclusionId}
|
||||
>
|
||||
{copied ? <IconCheck size={14} /> : <IconCopy 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
|
||||
leftSection={<IconLinkOff size={14} />}
|
||||
onClick={handleUnsync}
|
||||
>
|
||||
{t("Unsync")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => deleteNode()}
|
||||
>
|
||||
{t("Delete synced block")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NodeViewContent />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.placeholderIcon {
|
||||
flex: none;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.transclusionBadge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.transclusionWrap {
|
||||
position: relative;
|
||||
margin-left: -3rem;
|
||||
margin-right: -3rem;
|
||||
width: calc(100% + 6rem);
|
||||
padding: 0.5em 3rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
transition: border 0.3s;
|
||||
}
|
||||
|
||||
.transclusionWrap:hover,
|
||||
.transclusionWrap:focus-within {
|
||||
border: 2px solid
|
||||
light-dark(
|
||||
var(--mantine-color-orange-2),
|
||||
color-mix(in srgb, var(--mantine-color-orange-9), transparent 55%)
|
||||
);
|
||||
}
|
||||
|
||||
.transclusionControls {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--mantine-color-body);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Hover bridge: keeps :hover on the wrap while the cursor crosses the
|
||||
8px gap between wrap and floating chrome, so the menu doesn't fade out
|
||||
on the way to it. */
|
||||
.transclusionControls::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.transclusionWrap:hover .transclusionControls,
|
||||
.transclusionWrap:focus-within .transclusionControls,
|
||||
.transclusionWrap[data-menu-open="true"] .transclusionControls {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.controlsDivider {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.transclusionControls a[href],
|
||||
.includeControls a[href] {
|
||||
color: var(--ai-color);
|
||||
border-bottom: none;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.includeWrap {
|
||||
position: relative;
|
||||
margin-left: -3rem;
|
||||
margin-right: -3rem;
|
||||
width: calc(100% + 6rem);
|
||||
padding: 0.5em 0;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
transition: border 0.3s;
|
||||
}
|
||||
|
||||
.includeWrap:hover,
|
||||
.includeWrap[data-focused="true"],
|
||||
.includeWrap[data-menu-open="true"] {
|
||||
border: 2px solid
|
||||
light-dark(
|
||||
var(--mantine-color-orange-2),
|
||||
color-mix(in srgb, var(--mantine-color-orange-9), transparent 55%)
|
||||
);
|
||||
}
|
||||
|
||||
.includeControls {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--mantine-color-body);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Hover bridge: keeps :hover on the wrap while the cursor crosses the
|
||||
8px gap between wrap and floating chrome, so the menu doesn't fade out
|
||||
on the way to it. */
|
||||
.includeControls::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.includeWrap:hover .includeControls,
|
||||
.includeWrap:focus-within .includeControls,
|
||||
.includeWrap[data-focused="true"] .includeControls,
|
||||
.includeWrap[data-menu-open="true"] .includeControls {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
:global(.react-renderer.node-transclusionSource.ProseMirror-selectednode),
|
||||
:global(.react-renderer.node-transclusionReference.ProseMirror-selectednode) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.transclusionWrap,
|
||||
.includeWrap {
|
||||
margin-left: -1rem;
|
||||
margin-right: -1rem;
|
||||
width: calc(100% + 2rem);
|
||||
}
|
||||
|
||||
.transclusionWrap {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.transclusionControls,
|
||||
.includeControls {
|
||||
display: none !important;
|
||||
}
|
||||
.transclusionWrap,
|
||||
.includeWrap {
|
||||
border: none !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.editingOriginalTag {
|
||||
display: inline-block;
|
||||
padding: 0 6px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-blue-7);
|
||||
background: light-dark(
|
||||
var(--mantine-color-blue-0),
|
||||
var(--mantine-color-blue-9)
|
||||
);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import {
|
||||
NodeSelection,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
TextSelection,
|
||||
} from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
|
||||
export interface GlobalDragHandleOptions {
|
||||
/**
|
||||
* The width of the drag handle
|
||||
*/
|
||||
dragHandleWidth: number;
|
||||
|
||||
/**
|
||||
* The treshold for scrolling
|
||||
*/
|
||||
scrollThreshold: number;
|
||||
|
||||
/*
|
||||
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
||||
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
||||
*/
|
||||
dragHandleSelector?: string;
|
||||
|
||||
/**
|
||||
* Tags to be excluded for drag handle
|
||||
*/
|
||||
excludedTags: string[];
|
||||
|
||||
/**
|
||||
* Custom nodes to be included for drag handle
|
||||
*/
|
||||
customNodes: string[];
|
||||
}
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node.getBoundingClientRect();
|
||||
const modal = node.closest('[role="dialog"]');
|
||||
|
||||
if (modal && window.getComputedStyle(modal).transform !== "none") {
|
||||
const modalRect = modal.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top - modalRect.top,
|
||||
left: data.left - modalRect.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: data.top,
|
||||
left: data.left,
|
||||
width: data.width,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeDOMAtCoords(
|
||||
coords: { x: number; y: number },
|
||||
options: GlobalDragHandleOptions,
|
||||
view: EditorView,
|
||||
) {
|
||||
const selectors = [
|
||||
"li",
|
||||
"p:not(:first-child)",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
...options.customNodes.map((node) => `[data-type=${node}]`),
|
||||
].join(", ");
|
||||
return document
|
||||
.elementsFromPoint(coords.x, coords.y)
|
||||
.find((elem: Element) => {
|
||||
// Skip elements that belong to a nested editor (e.g. transclusion
|
||||
// references render their own ProseMirror instance). Only consider
|
||||
// elements whose closest editor is this host view.
|
||||
if (elem.closest(".ProseMirror") !== view.dom) return false;
|
||||
return (
|
||||
elem.parentElement?.matches?.(".ProseMirror") ||
|
||||
elem.matches(selectors)
|
||||
);
|
||||
});
|
||||
}
|
||||
function nodePosAtDOM(
|
||||
node: Element,
|
||||
view: EditorView,
|
||||
options: GlobalDragHandleOptions,
|
||||
) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||
top: boundingRect.top + 1,
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function calcNodePos(pos: number, view: EditorView) {
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
||||
return pos;
|
||||
}
|
||||
|
||||
export function DragHandlePlugin(
|
||||
options: GlobalDragHandleOptions & { pluginKey: string },
|
||||
) {
|
||||
let listType = "";
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const node = nodeDOMAtCoords(
|
||||
{
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
},
|
||||
options,
|
||||
view,
|
||||
);
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
||||
|
||||
const { from, to } = view.state.selection;
|
||||
const diff = from - to;
|
||||
|
||||
const fromSelectionPos = calcNodePos(from, view);
|
||||
let differentNodeSelected = false;
|
||||
|
||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||
|
||||
// Check if nodePos points to the top level node
|
||||
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
||||
else {
|
||||
const nodeSelection = NodeSelection.create(
|
||||
view.state.doc,
|
||||
nodePos.before(),
|
||||
);
|
||||
|
||||
// Check if the node where the drag event started is part of the current selection
|
||||
differentNodeSelected = !(
|
||||
draggedNodePos + 1 >= nodeSelection.$from.pos &&
|
||||
draggedNodePos <= nodeSelection.$to.pos
|
||||
);
|
||||
}
|
||||
let selection = view.state.selection;
|
||||
if (
|
||||
!differentNodeSelected &&
|
||||
diff !== 0 &&
|
||||
!(view.state.selection instanceof NodeSelection)
|
||||
) {
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||
selection = TextSelection.create(
|
||||
view.state.doc,
|
||||
draggedNodePos,
|
||||
endSelection.$to.pos,
|
||||
);
|
||||
} else {
|
||||
selection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||
|
||||
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
|
||||
// if table row is selected, go to the parent node to select the whole node
|
||||
if (
|
||||
(selection as NodeSelection).node.type.isInline ||
|
||||
(selection as NodeSelection).node.type.name === "tableRow"
|
||||
) {
|
||||
let $pos = view.state.doc.resolve(selection.from);
|
||||
selection = NodeSelection.create(view.state.doc, $pos.before());
|
||||
}
|
||||
}
|
||||
view.dispatch(view.state.tr.setSelection(selection));
|
||||
|
||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem"
|
||||
) {
|
||||
listType = node.parentElement!.tagName;
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = view.serializeForClipboard(slice);
|
||||
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData("text/html", dom.innerHTML);
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
|
||||
function hideDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.add("hide");
|
||||
}
|
||||
}
|
||||
|
||||
function showDragHandle() {
|
||||
if (dragHandleElement) {
|
||||
dragHandleElement.classList.remove("hide");
|
||||
}
|
||||
}
|
||||
|
||||
function hideHandleOnEditorOut(event: MouseEvent) {
|
||||
if (event.target instanceof Element) {
|
||||
// Check if the relatedTarget class is still inside the editor
|
||||
const relatedTarget = event.relatedTarget as HTMLElement;
|
||||
const isInsideEditor =
|
||||
relatedTarget?.classList.contains("tiptap") ||
|
||||
relatedTarget?.classList.contains("drag-handle");
|
||||
|
||||
if (isInsideEditor) return;
|
||||
}
|
||||
hideDragHandle();
|
||||
}
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey(options.pluginKey),
|
||||
view: (view) => {
|
||||
const handleBySelector = options.dragHandleSelector
|
||||
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
||||
: null;
|
||||
dragHandleElement = handleBySelector ?? document.createElement("div");
|
||||
dragHandleElement.draggable = true;
|
||||
dragHandleElement.dataset.dragHandle = "";
|
||||
dragHandleElement.classList.add("drag-handle");
|
||||
|
||||
function onDragHandleDragStart(e: DragEvent) {
|
||||
handleDragStart(e, view);
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
|
||||
|
||||
function onDragHandleDrag(e: DragEvent) {
|
||||
hideDragHandle();
|
||||
let scrollY = window.scrollY;
|
||||
if (e.clientY < options.scrollThreshold) {
|
||||
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollThreshold) {
|
||||
window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
dragHandleElement.addEventListener("drag", onDragHandleDrag);
|
||||
|
||||
hideDragHandle();
|
||||
|
||||
if (!handleBySelector) {
|
||||
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||
}
|
||||
view?.dom?.parentElement?.addEventListener(
|
||||
"mouseout",
|
||||
hideHandleOnEditorOut,
|
||||
);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
if (!handleBySelector) {
|
||||
dragHandleElement?.remove?.();
|
||||
}
|
||||
dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
|
||||
dragHandleElement?.removeEventListener(
|
||||
"dragstart",
|
||||
onDragHandleDragStart,
|
||||
);
|
||||
dragHandleElement = null;
|
||||
view?.dom?.parentElement?.removeEventListener(
|
||||
"mouseout",
|
||||
hideHandleOnEditorOut,
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords(
|
||||
{
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
},
|
||||
options,
|
||||
view,
|
||||
);
|
||||
|
||||
const notDragging = node?.closest(".not-draggable");
|
||||
const excludedTagList = options.excludedTags
|
||||
.concat(["ol", "ul"])
|
||||
.join(", ");
|
||||
|
||||
if (
|
||||
!(node instanceof Element) ||
|
||||
node.matches(excludedTagList) ||
|
||||
notDragging
|
||||
) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
}
|
||||
|
||||
const compStyle = window.getComputedStyle(node);
|
||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
||||
const lineHeight = isNaN(parsedLineHeight)
|
||||
? parseInt(compStyle.fontSize) * 1.2
|
||||
: parsedLineHeight;
|
||||
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
|
||||
rect.top += (lineHeight - 24) / 2;
|
||||
rect.top += paddingTop;
|
||||
// Li markers
|
||||
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
||||
rect.left -= options.dragHandleWidth;
|
||||
}
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
// dragging class is used for CSS
|
||||
dragstart: (view) => {
|
||||
view.dom.classList.add("dragging");
|
||||
},
|
||||
drop: (view, event) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!dropPos) return;
|
||||
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
if (!droppedNode) return;
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
|
||||
const isDroppedInsideList =
|
||||
resolvedPos.parent.type.name === "listItem";
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem" &&
|
||||
!isDroppedInsideList &&
|
||||
listType == "OL"
|
||||
) {
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(
|
||||
null,
|
||||
droppedNode,
|
||||
);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const GlobalDragHandle = Extension.create({
|
||||
name: "globalDragHandle",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
dragHandleWidth: 20,
|
||||
scrollThreshold: 100,
|
||||
excludedTags: [],
|
||||
customNodes: [],
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
DragHandlePlugin({
|
||||
pluginKey: "globalDragHandle",
|
||||
dragHandleWidth: this.options.dragHandleWidth,
|
||||
scrollThreshold: this.options.scrollThreshold,
|
||||
dragHandleSelector: this.options.dragHandleSelector,
|
||||
excludedTags: this.options.excludedTags,
|
||||
customNodes: this.options.customNodes,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default GlobalDragHandle;
|
||||
@@ -9,7 +9,6 @@ import SubScript from "@tiptap/extension-subscript";
|
||||
import { Typography } from "@tiptap/extension-typography";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
||||
import { Youtube } from "@tiptap/extension-youtube";
|
||||
import SlashCommand, { SlashCommandExtension as Command } from "@/features/editor/extensions/slash-command";
|
||||
import renderItems from "@/features/editor/components/slash-menu/render-items";
|
||||
@@ -52,6 +51,8 @@ import {
|
||||
Columns,
|
||||
Column,
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -80,6 +81,8 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
||||
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
@@ -100,6 +103,7 @@ import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboa
|
||||
import EmojiCommand from "./emoji-command";
|
||||
import { countWords } from "alfaaz";
|
||||
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
||||
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -167,7 +171,7 @@ export const mainExtensions = [
|
||||
SharedStorage,
|
||||
Heading,
|
||||
UniqueID.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
types: ["heading", "paragraph", "transclusionSource"],
|
||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
||||
}),
|
||||
Placeholder.configure({
|
||||
@@ -215,7 +219,9 @@ export const mainExtensions = [
|
||||
}),
|
||||
Typography,
|
||||
TrailingNode,
|
||||
GlobalDragHandle,
|
||||
GlobalDragHandle.configure({
|
||||
customNodes: ["transclusionSource", "transclusionReference"],
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
SlashCommand,
|
||||
@@ -351,6 +357,12 @@ export const mainExtensions = [
|
||||
Status.configure({
|
||||
view: StatusView,
|
||||
}),
|
||||
TransclusionSource.configure({
|
||||
view: TransclusionView,
|
||||
}),
|
||||
TransclusionReference.configure({
|
||||
view: TransclusionReferenceView,
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
|
||||
@@ -71,6 +71,7 @@ import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -399,55 +400,60 @@ export default function PageEditor({
|
||||
}
|
||||
}, [yjsConnectionStatus, isSynced]);
|
||||
|
||||
if (showStatic) {
|
||||
return (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
<TransclusionLookupProvider>
|
||||
{showStatic ? (
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
/>
|
||||
) : (
|
||||
<div className="editor-container" style={{ position: "relative" }}>
|
||||
<div ref={menuContainerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && (
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
)}
|
||||
{editor && (
|
||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||
)}
|
||||
|
||||
{editor && editorIsEditable && (
|
||||
<div>
|
||||
<EditorAiMenu editor={editor} />
|
||||
<EditorLinkMenu editor={editor} />
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
<TableMenu editor={editor} />
|
||||
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<PdfMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<SubpagesMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<DrawioMenu editor={editor} />
|
||||
<ColumnsMenu editor={editor} />
|
||||
{editor && editorIsEditable && (
|
||||
<div>
|
||||
<EditorAiMenu editor={editor} />
|
||||
<EditorLinkMenu editor={editor} />
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
<TableMenu editor={editor} />
|
||||
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<PdfMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<SubpagesMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<DrawioMenu editor={editor} />
|
||||
<ColumnsMenu editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
{editor &&
|
||||
!editorIsEditable &&
|
||||
(editable || canComment) &&
|
||||
providersRef.current && (
|
||||
<ReadonlyBubbleMenu editor={editor} />
|
||||
)}
|
||||
{showCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} />
|
||||
)}
|
||||
{showReadOnlyCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{editor && !editorIsEditable && (editable || canComment) && providersRef.current && (
|
||||
<ReadonlyBubbleMenu editor={editor} />
|
||||
)}
|
||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||
{showReadOnlyCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
style={{ paddingBottom: "20vh" }}
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
style={{ paddingBottom: "20vh" }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</TransclusionLookupProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,17 +9,26 @@ import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { useAtom } from "jotai";
|
||||
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||
|
||||
interface PageEditorProps {
|
||||
title: string;
|
||||
content: any;
|
||||
pageId?: string;
|
||||
/**
|
||||
* When rendering inside a public share, pass the share's id (or key). Lookups
|
||||
* for transclusion content then resolve against the share graph instead of
|
||||
* the viewer's personal permissions, so a share never leaks source content
|
||||
* that isn't itself shared.
|
||||
*/
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
export default function ReadonlyPageEditor({
|
||||
title,
|
||||
content,
|
||||
pageId,
|
||||
shareId,
|
||||
}: PageEditorProps) {
|
||||
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
||||
const isComponentMounted = useRef(false);
|
||||
@@ -65,7 +74,7 @@ export default function ReadonlyPageEditor({
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<TransclusionLookupProvider shareId={shareId}>
|
||||
<div className="page-title">
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
@@ -95,6 +104,6 @@ export default function ReadonlyPageEditor({
|
||||
}}
|
||||
></EditorProvider>
|
||||
<div style={{ paddingBottom: "20vh" }}></div>
|
||||
</>
|
||||
</TransclusionLookupProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px 3px 6px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-7),
|
||||
var(--mantine-color-dark-1)
|
||||
);
|
||||
transition: background 120ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
}
|
||||
|
||||
.triggerIcon {
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-5),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.triggerChev {
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-5),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
display: inline-flex;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
border-bottom: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-7),
|
||||
var(--mantine-color-dark-1)
|
||||
);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bannerIcon {
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.bannerLink {
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-9),
|
||||
var(--mantine-color-dark-0)
|
||||
);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bannerLink:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 500;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
margin: 0 0 6px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-9),
|
||||
var(--mantine-color-dark-0)
|
||||
);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
text-decoration: none;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
}
|
||||
|
||||
.rowIcon {
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-5),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.rowEmoji {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rowTitle {
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-7),
|
||||
var(--mantine-color-dark-1)
|
||||
);
|
||||
text-transform: uppercase;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.badgeAccent {
|
||||
background: light-dark(
|
||||
var(--mantine-color-blue-0),
|
||||
var(--mantine-color-blue-9)
|
||||
);
|
||||
color: light-dark(
|
||||
var(--mantine-color-blue-7),
|
||||
var(--mantine-color-blue-2)
|
||||
);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 18px 14px;
|
||||
text-align: center;
|
||||
color: light-dark(
|
||||
var(--mantine-color-gray-6),
|
||||
var(--mantine-color-dark-2)
|
||||
);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState } from "react";
|
||||
import { Loader, Popover } from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconCornerDownLeft,
|
||||
IconFile,
|
||||
IconInfoCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useReferencesQuery } from "@/features/transclusion/queries/transclusion-query";
|
||||
import type { ReferencingPage } from "@/features/transclusion/types/transclusion.types";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import classes from "./sync-block-references-dropdown.module.css";
|
||||
|
||||
type Props = {
|
||||
sourcePageId: string | null;
|
||||
transclusionId: string | null;
|
||||
/** The page currently being viewed - used to mark the "THIS PAGE" badge. */
|
||||
currentPageId: string;
|
||||
/**
|
||||
* Source: trigger reads "Editing original".
|
||||
* Reference: trigger reads "Synced to N other pages".
|
||||
*/
|
||||
mode: "source" | "reference";
|
||||
/** Notified whenever the dropdown opens/closes (for keep-chrome-visible). */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export default function SyncBlockReferencesDropdown({
|
||||
sourcePageId,
|
||||
transclusionId,
|
||||
currentPageId,
|
||||
mode,
|
||||
onOpenChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpened(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
// Fetch eagerly so the "Synced to N other pages" count is correct even
|
||||
// before the dropdown is opened. The cache is keyed on (sourcePageId,
|
||||
// transclusionId), so two views (source + reference) share one fetch.
|
||||
const enabled = !!sourcePageId && !!transclusionId;
|
||||
const { data, isLoading } = useReferencesQuery(
|
||||
sourcePageId,
|
||||
transclusionId,
|
||||
enabled,
|
||||
);
|
||||
|
||||
const allPages: Array<{ page: ReferencingPage; isOriginal: boolean }> = [];
|
||||
if (data?.source) {
|
||||
allPages.push({ page: data.source, isOriginal: true });
|
||||
}
|
||||
for (const ref of data?.references ?? []) {
|
||||
allPages.push({ page: ref, isOriginal: false });
|
||||
}
|
||||
|
||||
const otherCount = allPages.filter((p) => p.page.id !== currentPageId).length;
|
||||
const label =
|
||||
mode === "source"
|
||||
? t("Editing original")
|
||||
: t("Synced to {{count}} other page", {
|
||||
count: otherCount,
|
||||
defaultValue_one: "Synced to {{count}} other page",
|
||||
defaultValue_other: "Synced to {{count}} other pages",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover
|
||||
position="bottom-start"
|
||||
shadow="lg"
|
||||
opened={opened}
|
||||
onChange={handleOpenChange}
|
||||
width={340}
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.trigger}
|
||||
onClick={() => handleOpenChange(!opened)}
|
||||
aria-expanded={opened}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span className={classes.triggerIcon}>
|
||||
<IconCornerDownLeft size={14} stroke={1.8} />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
<span className={classes.triggerChev}>
|
||||
<IconChevronDown size={12} stroke={2} />
|
||||
</span>
|
||||
</button>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown className={classes.dropdown}>
|
||||
{mode === "reference" && data?.source && (
|
||||
<div className={classes.banner}>
|
||||
<span className={classes.bannerIcon}>
|
||||
<IconInfoCircle size={16} stroke={1.6} />
|
||||
</span>
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey="sourceReadOnlyHint"
|
||||
defaults="This section is read-only here. Edit it on the <link>original source page</link>."
|
||||
components={{
|
||||
link: (
|
||||
<Link
|
||||
to={
|
||||
data.source.spaceSlug
|
||||
? buildPageUrl(
|
||||
data.source.spaceSlug,
|
||||
data.source.slugId,
|
||||
data.source.title,
|
||||
)
|
||||
: `/p/${data.source.id}`
|
||||
}
|
||||
className={classes.bannerLink}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className={classes.loading}>
|
||||
<Loader size="xs" />
|
||||
</div>
|
||||
) : allPages.length === 0 ? (
|
||||
<div className={classes.empty}>{t("No pages")}</div>
|
||||
) : (
|
||||
<div className={classes.section}>
|
||||
<div className={classes.sectionLabel}>{t("Synced to")}</div>
|
||||
<ul className={classes.list}>
|
||||
{allPages.map(({ page, isOriginal }) => {
|
||||
const isCurrent = page.id === currentPageId;
|
||||
const href = page.spaceSlug
|
||||
? buildPageUrl(page.spaceSlug, page.slugId, page.title)
|
||||
: `/p/${page.id}`;
|
||||
const title = page.title?.length ? page.title : t("Untitled");
|
||||
return (
|
||||
<li key={page.id}>
|
||||
<Link
|
||||
to={href}
|
||||
className={classes.row}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
{page.icon ? (
|
||||
<span className={classes.rowEmoji}>{page.icon}</span>
|
||||
) : (
|
||||
<span className={classes.rowIcon}>
|
||||
<IconFile size={16} stroke={1.6} />
|
||||
</span>
|
||||
)}
|
||||
<span className={classes.rowTitle} title={title}>
|
||||
{title}
|
||||
</span>
|
||||
{isCurrent ? (
|
||||
<span
|
||||
className={`${classes.badge} ${classes.badgeAccent}`}
|
||||
>
|
||||
{t("THIS PAGE")}
|
||||
</span>
|
||||
) : isOriginal ? (
|
||||
<span className={classes.badge}>{t("ORIGINAL")}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
listReferences,
|
||||
unsyncReference,
|
||||
} from "../services/transclusion-api";
|
||||
|
||||
export function useReferencesQuery(
|
||||
sourcePageId: string | null,
|
||||
transclusionId: string | null,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["transclusion-references", sourcePageId, transclusionId],
|
||||
queryFn: () =>
|
||||
listReferences({
|
||||
sourcePageId: sourcePageId!,
|
||||
transclusionId: transclusionId!,
|
||||
}),
|
||||
enabled: enabled && !!sourcePageId && !!transclusionId,
|
||||
staleTime: 10 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnsyncReferenceMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (params: {
|
||||
referencePageId: string;
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
}) => unsyncReference(params),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import api from "@/lib/api-client";
|
||||
import type {
|
||||
ReferencingPagesResponse,
|
||||
TransclusionLookup,
|
||||
} from "../types/transclusion.types";
|
||||
|
||||
export async function lookupTransclusion(params: {
|
||||
references: Array<{ sourcePageId: string; transclusionId: string }>;
|
||||
}): Promise<{ items: TransclusionLookup[] }> {
|
||||
const r = await api.post("/pages/transclusion/lookup", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function lookupTransclusionForShare(params: {
|
||||
shareId: string;
|
||||
references: Array<{ sourcePageId: string; transclusionId: string }>;
|
||||
}): Promise<{ items: TransclusionLookup[] }> {
|
||||
const r = await api.post("/shares/transclusion/lookup", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function listReferences(params: {
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
}): Promise<ReferencingPagesResponse> {
|
||||
const r = await api.post("/pages/transclusion/references", params);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function unsyncReference(params: {
|
||||
referencePageId: string;
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
}): Promise<{ content: unknown }> {
|
||||
const r = await api.post("/pages/transclusion/unsync-reference", params);
|
||||
return r.data;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type TransclusionLookup =
|
||||
| {
|
||||
sourcePageId: string;
|
||||
transclusionId: string;
|
||||
content: unknown;
|
||||
sourceUpdatedAt: string;
|
||||
}
|
||||
| { sourcePageId: string; transclusionId: string; status: "not_found" }
|
||||
| { sourcePageId: string; transclusionId: string; status: "no_access" };
|
||||
|
||||
export type ReferencingPage = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
spaceSlug: string | null;
|
||||
};
|
||||
|
||||
export type ReferencingPagesResponse = {
|
||||
source: ReferencingPage | null;
|
||||
references: ReferencingPage[];
|
||||
};
|
||||
@@ -65,6 +65,7 @@ export default function SharedPage() {
|
||||
title={data.page.title}
|
||||
content={data.page.content}
|
||||
pageId={data.page.id}
|
||||
shareId={data.share.id}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user