+
{
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleSelect();
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ aria-label={t("Failed to load PDF")}
+ >
{t("Failed to load PDF")}
diff --git a/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx
index f5c176613..a6054d222 100644
--- a/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx
+++ b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx
@@ -187,12 +187,14 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
position={{ top: 90, right: 50 }}
withBorder
transitionProps={{ transition: "slide-down" }}
+ aria-label={t("Find and replace")}
>
}
rightSection={
@@ -217,7 +219,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
-
+
-
+
caseSensitiveToggle()}
+ aria-label={t("Match case (Alt+C)")}
+ aria-pressed={caseSensitive.isCaseSensitive}
>
replaceButtonToggle()}
+ aria-label={t("Replace")}
+ aria-pressed={replaceButton.isReplaceShow}
>
)}
-
+
@@ -269,6 +290,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
}
rightSection={}
rightSectionPointerEvents="all"
diff --git a/apps/client/src/features/editor/components/slash-menu/command-list.tsx b/apps/client/src/features/editor/components/slash-menu/command-list.tsx
index 54d6cd17a..ebc06660f 100644
--- a/apps/client/src/features/editor/components/slash-menu/command-list.tsx
+++ b/apps/client/src/features/editor/components/slash-menu/command-list.tsx
@@ -86,7 +86,15 @@ const CommandList = ({
}, [selectedIndex]);
return flatItems.length > 0 ? (
-
+
- {Object.entries(items).map(([category, categoryItems]) => (
-
+ {(() => {
+ let flatIndex = -1;
+ return Object.entries(items).map(([category, categoryItems]) => (
+
{category}
- {categoryItems.map((item: SlashMenuItemType, index: number) => (
+ {categoryItems.map((item: SlashMenuItemType) => {
+ flatIndex += 1;
+ const itemIndex = flatIndex;
+ return (
selectItem(index)}
+ data-item-index={itemIndex}
+ key={itemIndex}
+ id={`slash-command-option-${itemIndex}`}
+ role="option"
+ aria-selected={itemIndex === selectedIndex}
+ onClick={() => selectItem(itemIndex)}
className={clsx(classes.menuBtn, {
- [classes.selectedItem]: index === selectedIndex,
+ [classes.selectedItem]: itemIndex === selectedIndex,
})}
>
-
+
@@ -124,9 +140,11 @@ const CommandList = ({
- ))}
+ );
+ })}
- ))}
+ ));
+ })()}
) : null;
diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
index 875e2efdd..4a0532fe3 100644
--- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts
+++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
@@ -25,6 +25,8 @@ import {
IconColumns3,
IconColumns2,
IconTag,
+ IconMoodSmile,
+ IconRotate2,
} from "@tabler/icons-react";
import {
CommandProps,
@@ -132,7 +134,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Numbered list",
description: "Create a list with numbering.",
- searchTerms: ["numbered", "ordered", "list"],
+ searchTerms: ["numbered", "ordered", "list", "ol"],
icon: IconListNumbers,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
@@ -231,7 +233,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();
@@ -468,15 +478,53 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
+ {
+ title: "Emoji",
+ description: "Insert emoji.",
+ searchTerms: ["emoji", "icon", "smiley", "emoticon", "symbol", "reaction"],
+ icon: IconMoodSmile,
+ command: ({ editor, range }: CommandProps) => {
+ editor.chain().focus().deleteRange(range).insertContent(":").run();
+ },
+ },
{
title: "Subpages (Child pages)",
description: "List all subpages of the current page",
- searchTerms: ["subpages", "child", "children", "nested", "hierarchy"],
+ searchTerms: [
+ "subpages",
+ "child",
+ "children",
+ "nested",
+ "hierarchy",
+ "toc",
+ ],
icon: IconSitemap,
command: ({ editor, range }: CommandProps) => {
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.",
diff --git a/apps/client/src/features/editor/components/status/status-view.tsx b/apps/client/src/features/editor/components/status/status-view.tsx
index d8f10d794..3cca4b597 100644
--- a/apps/client/src/features/editor/components/status/status-view.tsx
+++ b/apps/client/src/features/editor/components/status/status-view.tsx
@@ -92,8 +92,17 @@ export default function StatusView(props: NodeViewProps) {
colorClassMap[color],
)}
onClick={() => isEditable && setOpened(true)}
+ onKeyDown={(e) => {
+ if (isEditable && (e.key === "Enter" || e.key === " ")) {
+ e.preventDefault();
+ setOpened(true);
+ }
+ }}
role="button"
tabIndex={0}
+ aria-label={text || "SET STATUS"}
+ aria-haspopup="dialog"
+ aria-expanded={opened}
>
{text || "SET STATUS"}
@@ -127,6 +136,16 @@ export default function StatusView(props: NodeViewProps) {
)}
style={{ backgroundColor: bg }}
onClick={() => handleColorChange(name)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleColorChange(name);
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ aria-label={name}
+ aria-pressed={color === name}
>
{color === name && }
diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx
index 123889f32..804a274a9 100644
--- a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx
+++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx
@@ -25,7 +25,7 @@ const recalculateLinks = (nodePos: NodePos[]) => {
(acc, item) => {
const label = item.node.textContent;
const level = Number(item.node.attrs.level);
- if (label.length && level <= 3) {
+ if (label.length && level <= 4) {
acc.push({
label,
level,
diff --git a/apps/client/src/features/editor/components/transclusion/error-placeholder.tsx b/apps/client/src/features/editor/components/transclusion/error-placeholder.tsx
new file mode 100644
index 000000000..a7f99321c
--- /dev/null
+++ b/apps/client/src/features/editor/components/transclusion/error-placeholder.tsx
@@ -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 (
+
+
+ {t("Failed to load this synced block")}
+
+ );
+}
diff --git a/apps/client/src/features/editor/components/transclusion/no-access-placeholder.tsx b/apps/client/src/features/editor/components/transclusion/no-access-placeholder.tsx
new file mode 100644
index 000000000..5212e169c
--- /dev/null
+++ b/apps/client/src/features/editor/components/transclusion/no-access-placeholder.tsx
@@ -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 (
+
+
+ {t("You don't have access to this synced block")}
+
+ );
+}
diff --git a/apps/client/src/features/editor/components/transclusion/not-found-placeholder.tsx b/apps/client/src/features/editor/components/transclusion/not-found-placeholder.tsx
new file mode 100644
index 000000000..db18d998c
--- /dev/null
+++ b/apps/client/src/features/editor/components/transclusion/not-found-placeholder.tsx
@@ -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 (
+
+
+ {t("The original synced block no longer exists")}
+
+ );
+}
diff --git a/apps/client/src/features/editor/components/transclusion/transclusion-content.tsx b/apps/client/src/features/editor/components/transclusion/transclusion-content.tsx
new file mode 100644
index 000000000..946291225
--- /dev/null
+++ b/apps/client/src/features/editor/components/transclusion/transclusion-content.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx b/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx
new file mode 100644
index 000000000..ec44a5f20
--- /dev/null
+++ b/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx
@@ -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;
+};
+
+const TransclusionLookupContext = createContext(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());
+ const queueRef = useRef(new Set());
+ const tickRef = useRef | 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(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());
+ // 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());
+ // 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 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(
+ (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(
+ (key) =>
+ new Promise((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(
+ () => ({ subscribe, refresh }),
+ [subscribe, refresh],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTransclusionLookup(
+ sourcePageId: string | null | undefined,
+ transclusionId: string | null | undefined,
+): {
+ result: TransclusionLookup | null;
+ refresh: () => Promise;
+} {
+ const ctx = useContext(TransclusionLookupContext);
+ const [result, setResult] = useState(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 };
+}
diff --git a/apps/client/src/features/editor/components/transclusion/transclusion-reference-view.tsx b/apps/client/src/features/editor/components/transclusion/transclusion-reference-view.tsx
new file mode 100644
index 000000000..490e179b2
--- /dev/null
+++ b/apps/client/src/features/editor/components/transclusion/transclusion-reference-view.tsx
@@ -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 (
+ 0 ? "true" : "false"}
+ contentEditable={false}
+ >
+ }
+ >
+
+
+
+ );
+}
+
+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 && (
+ e.preventDefault()}
+ >
+ {sourcePageId && transclusionId && hostPageId && (
+
+ )}
+
+
+
+
+
+
+ {sourcePageHref && (
+
+
+
+
+
+ )}
+
+
+ )}
+
+ {!sourcePageId || !transclusionId ? (
+
+ ) : !result ? (
+
+ ) : !("status" in result) ? (
+
+ ) : result.status === "no_access" ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/apps/client/src/features/editor/components/transclusion/transclusion-view.tsx b/apps/client/src/features/editor/components/transclusion/transclusion-view.tsx
new file mode 100644
index 000000000..c27024472
--- /dev/null
+++ b/apps/client/src/features/editor/components/transclusion/transclusion-view.tsx
@@ -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 = ``;
+ 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 (
+ 0 ? "true" : "false"}
+ data-id={transclusionId ?? undefined}
+ >
+ {isEditable && (
+ e.preventDefault()}
+ >
+ {sourcePageId && transclusionId && (
+
+ )}
+
+
+
+
+
+ {copied ? : }
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/client/src/features/editor/components/transclusion/transclusion.module.css b/apps/client/src/features/editor/components/transclusion/transclusion.module.css
new file mode 100644
index 000000000..2fb5f7547
--- /dev/null
+++ b/apps/client/src/features/editor/components/transclusion/transclusion.module.css
@@ -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);
+}
diff --git a/apps/client/src/features/editor/components/video/video-view.tsx b/apps/client/src/features/editor/components/video/video-view.tsx
index 46ff79084..9a67533b1 100644
--- a/apps/client/src/features/editor/components/video/video-view.tsx
+++ b/apps/client/src/features/editor/components/video/video-view.tsx
@@ -47,6 +47,7 @@ export default function VideoView(props: NodeViewProps) {
preload="metadata"
controls
src={getFileUrl(src)}
+ aria-label={placeholder?.name || t("Video")}
/>
)}
{!src && previewSrc && (
@@ -56,6 +57,7 @@ export default function VideoView(props: NodeViewProps) {
preload="metadata"
controls
src={previewSrc}
+ aria-label={placeholder?.name || t("Video")}
/>
@@ -71,7 +73,7 @@ export default function VideoView(props: NodeViewProps) {
)}
{!src && !previewSrc && !placeholder && (
-
+
)}
diff --git a/apps/client/src/features/editor/extensions/drag-handle.ts b/apps/client/src/features/editor/extensions/drag-handle.ts
new file mode 100644
index 000000000..a4843ed67
--- /dev/null
+++ b/apps/client/src/features/editor/extensions/drag-handle.ts
@@ -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
(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 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;
diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts
index 1ad93308c..23be85aa1 100644
--- a/apps/client/src/features/editor/extensions/extensions.ts
+++ b/apps/client/src/features/editor/extensions/extensions.ts
@@ -9,9 +9,10 @@ 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 SlashCommand, {
+ SlashCommandExtension as Command,
+} from "@/features/editor/extensions/slash-command";
import renderItems from "@/features/editor/components/slash-menu/render-items";
import getSuggestionItems from "@/features/editor/components/slash-menu/menu-items";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
@@ -47,11 +48,14 @@ import {
Subpages,
Heading,
Highlight,
+ Indent,
UniqueID,
SharedStorage,
Columns,
Column,
Status,
+ TransclusionSource,
+ TransclusionReference,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -80,6 +84,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 +106,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 +174,7 @@ export const mainExtensions = [
SharedStorage,
Heading,
UniqueID.configure({
- types: ["heading", "paragraph"],
+ types: ["heading", "paragraph", "transclusionSource"],
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
Placeholder.configure({
@@ -197,6 +204,7 @@ export const mainExtensions = [
showOnlyWhenEditable: true,
}),
TextAlign.configure({ types: ["heading", "paragraph"] }),
+ Indent,
TaskList,
TaskItem.configure({
nested: true,
@@ -215,7 +223,9 @@ export const mainExtensions = [
}),
Typography,
TrailingNode,
- GlobalDragHandle,
+ GlobalDragHandle.configure({
+ customNodes: ["transclusionSource", "transclusionReference"],
+ }),
TextStyle,
Color,
SlashCommand,
@@ -305,6 +315,8 @@ export const mainExtensions = [
view: CodeBlockView,
//@ts-ignore
lowlight,
+ enableTabIndentation: true,
+ tabSize: 2,
HTMLAttributes: {
spellcheck: false,
},
@@ -351,6 +363,12 @@ export const mainExtensions = [
Status.configure({
view: StatusView,
}),
+ TransclusionSource.configure({
+ view: TransclusionView,
+ }),
+ TransclusionReference.configure({
+ view: TransclusionReferenceView,
+ }),
MarkdownClipboard.configure({
transformPastedText: true,
}),
@@ -393,7 +411,10 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
const TemplateSlashCommand = Command.configure({
suggestion: {
items: ({ query }: { query: string }) =>
- getSuggestionItems({ query, excludeItems: TEMPLATE_EXCLUDED_SLASH_ITEMS }),
+ getSuggestionItems({
+ query,
+ excludeItems: TEMPLATE_EXCLUDED_SLASH_ITEMS,
+ }),
render: renderItems,
},
});
diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.ts
index 230798c50..bebb567ab 100644
--- a/apps/client/src/features/editor/extensions/markdown-clipboard.ts
+++ b/apps/client/src/features/editor/extensions/markdown-clipboard.ts
@@ -80,10 +80,12 @@ export const MarkdownClipboard = Extension.create({
const { from, to } = view.state.selection;
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
+ const body = elementFromString(parsed);
+ normalizeTableColumnWidths(body);
const contentNodes = DOMParser.fromSchema(
this.editor.schema,
- ).parseSlice(elementFromString(parsed), {
+ ).parseSlice(body, {
preserveWhitespace: true,
});
@@ -137,3 +139,92 @@ function elementFromString(value) {
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
}
+
+const DEFAULT_PASTE_COL_WIDTH_PX = 150;
+
+function parsePixelWidth(el: Element): number | null {
+ const attr = el.getAttribute("width");
+ if (attr) {
+ const n = parseInt(attr, 10);
+ if (Number.isFinite(n) && n > 0) return n;
+ }
+ const style = el.getAttribute("style") || "";
+ const m = style.match(/(?:^|;)\s*width\s*:\s*([\d.]+)\s*px/i);
+ if (m) {
+ const n = parseInt(m[1], 10);
+ if (Number.isFinite(n) && n > 0) return n;
+ }
+ return null;
+}
+
+function getFirstRow(table: Element): Element | null {
+ const tbodyRow = table.querySelector(":scope > tbody > tr");
+ if (tbodyRow) return tbodyRow;
+ const theadRow = table.querySelector(":scope > thead > tr");
+ if (theadRow) return theadRow;
+ return table.querySelector(":scope > tr");
+}
+
+function deriveColumnWidths(table: Element): (number | null)[] | null {
+ const cols = table.querySelectorAll(":scope > colgroup > col");
+ if (cols.length > 0) {
+ const widths: (number | null)[] = [];
+ cols.forEach((col) => widths.push(parsePixelWidth(col)));
+ if (widths.some((w) => w !== null)) return widths;
+ }
+
+ const firstRow = getFirstRow(table);
+ if (!firstRow) return null;
+
+ const widths: (number | null)[] = [];
+ Array.from(firstRow.children)
+ .filter((c) => c.tagName === "TD" || c.tagName === "TH")
+ .forEach((cell) => {
+ const colspan = parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
+ const w = parsePixelWidth(cell);
+ for (let i = 0; i < colspan; i++) {
+ widths.push(w !== null ? Math.round(w / colspan) : null);
+ }
+ });
+ if (widths.length === 0 || widths.every((w) => w === null)) return null;
+ return widths;
+}
+
+// Mirror of server normalizeTableColumnWidths (see import/utils/table-utils.ts):
+// markdown source has no widths, so without this every pasted table renders
+// at table-layout:fixed/100% and squashes columns to fit the editor instead of
+// letting .tableWrapper's overflow-x: auto scroll.
+export function normalizeTableColumnWidths(root: Element): void {
+ root.querySelectorAll("table").forEach((table) => {
+ const firstRow = getFirstRow(table);
+ if (!firstRow) return;
+
+ let colWidths = deriveColumnWidths(table);
+ if (!colWidths) {
+ let count = 0;
+ Array.from(firstRow.children)
+ .filter((c) => c.tagName === "TD" || c.tagName === "TH")
+ .forEach((cell) => {
+ count += parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
+ });
+ if (count === 0) return;
+ colWidths = new Array(count).fill(DEFAULT_PASTE_COL_WIDTH_PX);
+ }
+
+ let col = 0;
+ Array.from(firstRow.children)
+ .filter((c) => c.tagName === "TD" || c.tagName === "TH")
+ .forEach((cell) => {
+ if (cell.getAttribute("colwidth")) {
+ col += parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
+ return;
+ }
+ const colspan = parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
+ const slice = colWidths!.slice(col, col + colspan);
+ col += colspan;
+ if (slice.length === 0 || slice.every((w) => w === null)) return;
+ const values = slice.map((w) => (w == null ? 100 : w));
+ cell.setAttribute("colwidth", values.join(","));
+ });
+ });
+}
diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx
index 6ebb8669c..69bf2628f 100644
--- a/apps/client/src/features/editor/full-editor.tsx
+++ b/apps/client/src/features/editor/full-editor.tsx
@@ -3,20 +3,27 @@ import React from "react";
import { TitleEditor } from "@/features/editor/title-editor";
import PageEditor from "@/features/editor/page-editor";
import {
+ ActionIcon,
Container,
Divider,
Group,
Popover,
Stack,
Text,
+ Tooltip,
UnstyledButton,
} from "@mantine/core";
+import { IconInfoCircle } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { PageVerificationBadge } from "@/ee/page-verification";
import { useTranslation } from "react-i18next";
import { IContributor } from "@/features/page/types/page.types.ts";
+import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
+import { PageEditMode } from "@/features/user/types/user.types.ts";
+import useToggleAside from "@/hooks/use-toggle-aside.tsx";
+import clsx from "clsx";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
@@ -52,6 +59,11 @@ export function FullEditor({
}: FullEditorProps) {
const [user] = useAtom(userAtom);
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
+ const editorToolbarEnabled =
+ user.settings?.preferences?.editorToolbar ?? false;
+ const userPageEditMode =
+ user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
+ const isEditMode = userPageEditMode === PageEditMode.Edit;
return (
+ {editorToolbarEnabled && editable && isEditMode && }
c.id !== creator?.id,
@@ -102,8 +116,8 @@ function PageByline({
{creator && (
@@ -165,6 +179,17 @@ function PageByline({
)}
+
+ toggleAside("details")}
+ >
+
+
+
+
);
diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx
index 7cc4723ac..57aab5bb0 100644
--- a/apps/client/src/features/editor/page-editor.tsx
+++ b/apps/client/src/features/editor/page-editor.tsx
@@ -62,7 +62,7 @@ import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
-import { extractPageSlugId } from "@/lib";
+import { extractPageSlugId, platformModifierKey } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
@@ -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;
@@ -232,11 +233,19 @@ export default function PageEditor({
scrollMargin: 80,
handleDOMEvents: {
keydown: (_view, event) => {
- if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
+ if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
- if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
+ if (event.key === "Tab") {
+ const editor = editorRef.current;
+ if (!editor) return false;
+ event.preventDefault();
+ return editor.view.someProp("handleKeyDown", (f) =>
+ f(editor.view, event)
+ );
+ }
+ if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
@@ -391,55 +400,60 @@ export default function PageEditor({
}
}, [yjsConnectionStatus, isSynced]);
- if (showStatic) {
- return (
-
- );
- }
-
return (
-
-
-
+
+ {showStatic ? (
+
+ ) : (
+
+
+
- {editor && (
-
- )}
+ {editor && (
+
+ )}
- {editor && editorIsEditable && (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {editor && editorIsEditable && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+ {editor &&
+ !editorIsEditable &&
+ (editable || canComment) &&
+ providersRef.current && (
+
+ )}
+ {showCommentPopup && (
+
+ )}
+ {showReadOnlyCommentPopup && (
+
+ )}
- )}
- {editor && !editorIsEditable && (editable || canComment) && providersRef.current && (
-
- )}
- {showCommentPopup &&
}
- {showReadOnlyCommentPopup && (
-
- )}
-
-
editor.commands.focus("end")}
- style={{ paddingBottom: "20vh" }}
- >
-
+ editor.commands.focus("end")}
+ style={{ paddingBottom: "20vh" }}
+ >
+
+ )}
+
);
}
diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx
index a27c7bfb3..cd4878a9b 100644
--- a/apps/client/src/features/editor/readonly-page-editor.tsx
+++ b/apps/client/src/features/editor/readonly-page-editor.tsx
@@ -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 (
- <>
+
- >
+
);
}
diff --git a/apps/client/src/features/editor/styles/editor.module.css b/apps/client/src/features/editor/styles/editor.module.css
index dfe7393f8..ed5f86432 100644
--- a/apps/client/src/features/editor/styles/editor.module.css
+++ b/apps/client/src/features/editor/styles/editor.module.css
@@ -9,3 +9,15 @@
}
}
+.byline {
+ padding-left: 3rem;
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ padding-left: 1rem;
+ }
+
+ @media print {
+ padding-left: 0;
+ }
+}
+
diff --git a/apps/client/src/features/editor/styles/indent.css b/apps/client/src/features/editor/styles/indent.css
new file mode 100644
index 000000000..cd2bd5857
--- /dev/null
+++ b/apps/client/src/features/editor/styles/indent.css
@@ -0,0 +1,14 @@
+.ProseMirror {
+ --indent-step: 2rem;
+}
+
+.ProseMirror [data-indent="1"] { padding-inline-start: calc(var(--indent-step) * 1); }
+.ProseMirror [data-indent="2"] { padding-inline-start: calc(var(--indent-step) * 2); }
+.ProseMirror [data-indent="3"] { padding-inline-start: calc(var(--indent-step) * 3); }
+.ProseMirror [data-indent="4"] { padding-inline-start: calc(var(--indent-step) * 4); }
+.ProseMirror [data-indent="5"] { padding-inline-start: calc(var(--indent-step) * 5); }
+.ProseMirror [data-indent="6"] { padding-inline-start: calc(var(--indent-step) * 6); }
+.ProseMirror [data-indent="7"] { padding-inline-start: calc(var(--indent-step) * 7); }
+.ProseMirror [data-indent="8"] { padding-inline-start: calc(var(--indent-step) * 8); }
+.ProseMirror [data-indent="9"] { padding-inline-start: calc(var(--indent-step) * 9); }
+.ProseMirror [data-indent="10"] { padding-inline-start: calc(var(--indent-step) * 10); }
diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css
index 7ec4be9b6..7abfe1086 100644
--- a/apps/client/src/features/editor/styles/index.css
+++ b/apps/client/src/features/editor/styles/index.css
@@ -13,5 +13,6 @@
@import "./mention.css";
@import "./ordered-list.css";
@import "./highlight.css";
+@import "./indent.css";
@import "./columns.css";
@import "./status.css";
diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx
index 15c3ff028..e61d8c042 100644
--- a/apps/client/src/features/editor/title-editor.tsx
+++ b/apps/client/src/features/editor/title-editor.tsx
@@ -27,6 +27,7 @@ import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts";
+import { platformModifierKey } from "@/lib";
export interface TitleEditorProps {
pageId: string;
@@ -90,11 +91,11 @@ export function TitleEditor({
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
- if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
+ if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
- if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
+ if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
diff --git a/apps/client/src/features/favorite/components/star-button.tsx b/apps/client/src/features/favorite/components/star-button.tsx
index 6a99e01e9..7ff8ff77d 100644
--- a/apps/client/src/features/favorite/components/star-button.tsx
+++ b/apps/client/src/features/favorite/components/star-button.tsx
@@ -53,15 +53,17 @@ export default function StarButton(props: StarButtonProps) {
}
};
+ const label = isFavorited
+ ? t("Remove from favorites")
+ : t("Add to favorites");
+
return (
-
+
diff --git a/apps/client/src/features/group/components/group-action-menu.tsx b/apps/client/src/features/group/components/group-action-menu.tsx
index 331ea3dea..8c3dccb05 100644
--- a/apps/client/src/features/group/components/group-action-menu.tsx
+++ b/apps/client/src/features/group/components/group-action-menu.tsx
@@ -53,7 +53,7 @@ export default function GroupActionMenu() {
arrowPosition="center"
>
-
+
diff --git a/apps/client/src/features/group/components/group-members.tsx b/apps/client/src/features/group/components/group-members.tsx
index 9b5bbd7b2..56807bf8d 100644
--- a/apps/client/src/features/group/components/group-members.tsx
+++ b/apps/client/src/features/group/components/group-members.tsx
@@ -54,7 +54,7 @@ export default function GroupMembersList() {
{t("User")}
{t("Status")}
-
+
diff --git a/apps/client/src/features/home/components/created-by-me.tsx b/apps/client/src/features/home/components/created-by-me.tsx
index 70137b105..99051357e 100644
--- a/apps/client/src/features/home/components/created-by-me.tsx
+++ b/apps/client/src/features/home/components/created-by-me.tsx
@@ -4,7 +4,7 @@ import {
UnstyledButton,
Badge,
Table,
- ActionIcon,
+ ThemeIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
@@ -61,13 +61,13 @@ export default function CreatedByMe({ spaceId }: Props) {
>
{page.icon || (
-
-
+
)}
{page.title || t("Untitled")}
diff --git a/apps/client/src/features/home/components/favorites-pages.tsx b/apps/client/src/features/home/components/favorites-pages.tsx
index eb87216e0..aed8e653a 100644
--- a/apps/client/src/features/home/components/favorites-pages.tsx
+++ b/apps/client/src/features/home/components/favorites-pages.tsx
@@ -4,7 +4,7 @@ import {
UnstyledButton,
Badge,
Table,
- ActionIcon,
+ ThemeIcon,
Button,
} from "@mantine/core";
import { Link } from "react-router-dom";
@@ -62,13 +62,13 @@ export default function FavoritesPages({ spaceId }: Props) {
>
{fav.page.icon || (
-
-
+
)}
{fav.page.title || t("Untitled")}
diff --git a/apps/client/src/features/home/components/home-ai-prompt.module.css b/apps/client/src/features/home/components/home-ai-prompt.module.css
index e6d816067..8a6d57e11 100644
--- a/apps/client/src/features/home/components/home-ai-prompt.module.css
+++ b/apps/client/src/features/home/components/home-ai-prompt.module.css
@@ -16,7 +16,7 @@
.subtitle {
font-size: var(--mantine-font-size-sm);
- color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+ color: var(--mantine-color-dimmed);
text-align: center;
margin-top: 6px;
margin-bottom: var(--mantine-spacing-lg);
diff --git a/apps/client/src/features/notification/components/notification-popover.tsx b/apps/client/src/features/notification/components/notification-popover.tsx
index 161ac1e6c..751b9edf1 100644
--- a/apps/client/src/features/notification/components/notification-popover.tsx
+++ b/apps/client/src/features/notification/components/notification-popover.tsx
@@ -58,6 +58,9 @@ export function NotificationPopover() {
variant="subtle"
color="dark"
size="sm"
+ aria-label={t("Notifications")}
+ aria-haspopup="dialog"
+ aria-expanded={opened}
onClick={() => setOpened((o) => !o)}
>
void;
+}
+
+export function BacklinksList({
+ pageId,
+ direction,
+ enabled,
+ onItemClick,
+}: BacklinksListProps) {
+ const { t } = useTranslation();
+ const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ useBacklinksQuery(pageId, direction, enabled);
+
+ if (!enabled) return null;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const items: IBacklinkPageItem[] =
+ data?.pages.flatMap((page) => page.items) ?? [];
+
+ if (items.length === 0) {
+ return (
+
+ {direction === "incoming"
+ ? t("No pages link here yet.")
+ : t("This page doesn't link to other pages yet.")}
+
+ );
+ }
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
+ return;
+ }
+ onItemClick();
+ };
+
+ return (
+
+ {items.map((item) => (
+
+
+ {getPageIcon(item.icon ?? "")}
+
+
+ {item.title || t("Untitled")}
+
+ {item.space?.name && (
+
+ {item.space.name}
+
+ )}
+
+
+
+ ))}
+ {hasNextPage && (
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/features/page-details/components/backlinks-modal.tsx b/apps/client/src/features/page-details/components/backlinks-modal.tsx
new file mode 100644
index 000000000..83fc31147
--- /dev/null
+++ b/apps/client/src/features/page-details/components/backlinks-modal.tsx
@@ -0,0 +1,62 @@
+import { Modal, Stack, Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
+import { BacklinksList } from "./backlinks-list";
+
+interface BacklinksModalProps {
+ pageId: string;
+ opened: boolean;
+ onClose: () => void;
+}
+
+export function BacklinksModal({
+ pageId,
+ opened,
+ onClose,
+}: BacklinksModalProps) {
+ const { t } = useTranslation();
+ const { data: counts } = useBacklinksCountQuery(pageId);
+
+ return (
+
+
+
+
+ {t("Backlinks")}
+
+
+
+
+
+
+ {t("Incoming links ({{count}})", {
+ count: counts?.incoming ?? 0,
+ })}
+
+
+
+
+
+
+ {t("Outgoing links ({{count}})", {
+ count: counts?.outgoing ?? 0,
+ })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/page-details/components/page-details-aside.tsx b/apps/client/src/features/page-details/components/page-details-aside.tsx
new file mode 100644
index 000000000..7435f9e63
--- /dev/null
+++ b/apps/client/src/features/page-details/components/page-details-aside.tsx
@@ -0,0 +1,233 @@
+import {
+ Divider,
+ Group,
+ Skeleton,
+ Stack,
+ Text,
+ UnstyledButton,
+} from "@mantine/core";
+import { IconChevronRight } from "@tabler/icons-react";
+import { useDisclosure } from "@mantine/hooks";
+import { useAtomValue } from "jotai";
+import { useParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { extractPageSlugId } from "@/lib";
+import { usePageQuery } from "@/features/page/queries/page-query.ts";
+import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
+import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
+import { BacklinksModal } from "./backlinks-modal";
+import { formattedDate, timeAgo } from "@/lib/time.ts";
+import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
+
+export function PageDetailsAside() {
+ const { pageSlug } = useParams();
+ const { data: page } = usePageQuery({
+ pageId: extractPageSlugId(pageSlug),
+ });
+ const pageEditor = useAtomValue(pageEditorAtom);
+ const { data: counts, isLoading: countsLoading } = useBacklinksCountQuery(page?.id);
+ const [modalOpened, { open: openModal, close: closeModal }] =
+ useDisclosure(false);
+
+ if (!page) return null;
+
+ const wordCount: number =
+ pageEditor?.storage?.characterCount?.words?.() ?? 0;
+ const characterCount: number =
+ pageEditor?.storage?.characterCount?.characters?.() ?? 0;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function PeopleSection({
+ creator,
+ lastUpdatedBy,
+}: {
+ creator: { id: string; name: string; avatarUrl: string } | null;
+ lastUpdatedBy: { id: string; name: string; avatarUrl: string } | null;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+ );
+}
+
+function PersonRow({
+ label,
+ person,
+}: {
+ label: string;
+ person: { id: string; name: string; avatarUrl: string } | null;
+}) {
+ return (
+
+
+ {label}
+
+ {person ? (
+
+
+
+ {person.name}
+
+
+ ) : (
+
+ —
+
+ )}
+
+ );
+}
+
+function StatsSection({
+ wordCount,
+ characterCount,
+ createdAt,
+ updatedAt,
+}: {
+ wordCount: number;
+ characterCount: number;
+ createdAt: Date | string;
+ updatedAt: Date | string;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("Stats")}
+
+
+
+
+
+
+ );
+}
+
+function StatRow({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ );
+}
+
+function BacklinksSection({
+ incomingCount,
+ outgoingCount,
+ isLoading,
+ onClick,
+}: {
+ incomingCount: number;
+ outgoingCount: number;
+ isLoading: boolean;
+ onClick: () => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("Backlinks")}
+
+
+
+
+ );
+}
+
+function BacklinksRow({
+ label,
+ count,
+ isLoading,
+ onClick,
+}: {
+ label: string;
+ count: number;
+ isLoading: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {isLoading ? (
+
+ ) : (
+ {count}
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/page-details/queries/backlinks-query.ts b/apps/client/src/features/page-details/queries/backlinks-query.ts
new file mode 100644
index 000000000..a5e4619ee
--- /dev/null
+++ b/apps/client/src/features/page-details/queries/backlinks-query.ts
@@ -0,0 +1,45 @@
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import {
+ getBacklinks,
+ getBacklinksCount,
+} from "@/features/page-details/services/backlinks-service.ts";
+import {
+ BacklinkDirection,
+ IBacklinkCount,
+} from "@/features/page-details/types/backlink.types.ts";
+
+const BACKLINKS_STALE_TIME = 30 * 1000;
+const BACKLINKS_PAGE_LIMIT = 100;
+
+export function useBacklinksCountQuery(pageId: string | undefined) {
+ return useQuery({
+ queryKey: ["backlinks-count", pageId],
+ queryFn: () => getBacklinksCount(pageId as string),
+ enabled: !!pageId,
+ staleTime: BACKLINKS_STALE_TIME,
+ });
+}
+
+export function useBacklinksQuery(
+ pageId: string | undefined,
+ direction: BacklinkDirection,
+ enabled: boolean,
+) {
+ return useInfiniteQuery({
+ queryKey: ["backlinks", pageId, direction],
+ queryFn: ({ pageParam }) =>
+ getBacklinks({
+ pageId: pageId as string,
+ direction,
+ cursor: pageParam,
+ limit: BACKLINKS_PAGE_LIMIT,
+ }),
+ enabled: enabled && !!pageId,
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) =>
+ lastPage.meta.hasNextPage
+ ? (lastPage.meta.nextCursor ?? undefined)
+ : undefined,
+ staleTime: BACKLINKS_STALE_TIME,
+ });
+}
diff --git a/apps/client/src/features/page-details/services/backlinks-service.ts b/apps/client/src/features/page-details/services/backlinks-service.ts
new file mode 100644
index 000000000..779911eec
--- /dev/null
+++ b/apps/client/src/features/page-details/services/backlinks-service.ts
@@ -0,0 +1,26 @@
+import api from "@/lib/api-client";
+import { IPagination } from "@/lib/types.ts";
+import {
+ IBacklinkCount,
+ IBacklinkPageItem,
+ IBacklinksListParams,
+} from "@/features/page-details/types/backlink.types.ts";
+
+export async function getBacklinksCount(
+ pageId: string,
+): Promise {
+ const req = await api.post("/pages/backlinks-count", {
+ pageId,
+ });
+ return req.data;
+}
+
+export async function getBacklinks(
+ params: IBacklinksListParams,
+): Promise> {
+ const req = await api.post>(
+ "/pages/backlinks",
+ params,
+ );
+ return req.data;
+}
diff --git a/apps/client/src/features/page-details/types/backlink.types.ts b/apps/client/src/features/page-details/types/backlink.types.ts
new file mode 100644
index 000000000..f45874e5d
--- /dev/null
+++ b/apps/client/src/features/page-details/types/backlink.types.ts
@@ -0,0 +1,23 @@
+export type BacklinkDirection = "incoming" | "outgoing";
+
+export interface IBacklinkCount {
+ incoming: number;
+ outgoing: number;
+}
+
+export interface IBacklinkPageItem {
+ id: string;
+ slugId: string;
+ title: string | null;
+ icon: string | null;
+ spaceId: string;
+ space: { id: string; slug: string; name: string } | null;
+ updatedAt: string;
+}
+
+export interface IBacklinksListParams {
+ pageId: string;
+ direction: BacklinkDirection;
+ cursor?: string;
+ limit?: number;
+}
diff --git a/apps/client/src/features/page-history/components/history-modal.tsx b/apps/client/src/features/page-history/components/history-modal.tsx
index dfd43cf1f..08f05c9e9 100644
--- a/apps/client/src/features/page-history/components/history-modal.tsx
+++ b/apps/client/src/features/page-history/components/history-modal.tsx
@@ -22,6 +22,7 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
opened={isModalOpen}
onClose={() => setModalOpen(false)}
fullScreen
+ aria-label={t("Page history")}
>
@@ -49,6 +50,7 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
size={1400}
opened={isModalOpen}
onClose={() => setModalOpen(false)}
+ aria-label={t("Page history")}
>
diff --git a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx
index 11507e404..d02ba6e91 100644
--- a/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx
+++ b/apps/client/src/features/page/components/breadcrumbs/breadcrumb.tsx
@@ -19,6 +19,7 @@ import { buildPageUrl } from "@/features/page/page.utils.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import { useMediaQuery } from "@mantine/hooks";
+import { useTranslation } from "react-i18next";
function getTitle(name: string, icon: string) {
if (icon) {
@@ -28,6 +29,7 @@ function getTitle(name: string, icon: string) {
}
export default function Breadcrumb() {
+ const { t } = useTranslation();
const treeData = useAtomValue(treeDataAtom);
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
SpaceTreeNode[] | null
@@ -80,7 +82,7 @@ export default function Breadcrumb() {
));
const renderAnchor = useCallback(
- (node: SpaceTreeNode) => (
+ (node: SpaceTreeNode, isCurrent = false) => (
{getTitle(node.name, node.icon)}
@@ -115,7 +118,11 @@ export default function Breadcrumb() {
key="hidden-nodes"
>
-
+
@@ -124,11 +131,13 @@ export default function Breadcrumb() {
,
//renderAnchor(secondLastNode),
- renderAnchor(lastNode),
+ renderAnchor(lastNode, true),
];
}
- return breadcrumbNodes.map(renderAnchor);
+ return breadcrumbNodes.map((node, i) =>
+ renderAnchor(node, i === breadcrumbNodes.length - 1),
+ );
};
const getMobileBreadcrumbItems = () => {
@@ -144,8 +153,12 @@ export default function Breadcrumb() {
key="mobile-hidden-nodes"
>
-
-
+
+
@@ -157,16 +170,18 @@ export default function Breadcrumb() {
];
}
- return breadcrumbNodes.map(renderAnchor);
+ return breadcrumbNodes.map((node, i) =>
+ renderAnchor(node, i === breadcrumbNodes.length - 1),
+ );
};
return (
-
+
+
);
}
diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx
index 9e4b72096..81c25e825 100644
--- a/apps/client/src/features/page/components/header/page-header-menu.tsx
+++ b/apps/client/src/features/page/components/header/page-header-menu.tsx
@@ -1,4 +1,4 @@
-import { ActionIcon, Group, Menu, Text, Tooltip } from "@mantine/core";
+import { ActionIcon, Group, Menu, Text, ThemeIcon, Tooltip } from "@mantine/core";
import {
IconArrowRight,
IconArrowsHorizontal,
@@ -99,6 +99,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
toggleAside("comments")}
>
@@ -109,6 +110,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
toggleAside("toc")}
>
@@ -205,7 +207,11 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
arrowPosition="center"
>
-
+
@@ -416,9 +422,15 @@ function ConnectionWarning() {
openDelay={250}
withArrow
>
-
+
-
+
);
}
diff --git a/apps/client/src/features/page/components/page-import-modal.tsx b/apps/client/src/features/page/components/page-import-modal.tsx
index df6691d55..3283ff794 100644
--- a/apps/client/src/features/page/components/page-import-modal.tsx
+++ b/apps/client/src/features/page/components/page-import-modal.tsx
@@ -12,6 +12,7 @@ import {
IconCheck,
IconFileCode,
IconFileTypeDocx,
+ IconFileTypePdf,
IconFileTypeZip,
IconMarkdown,
IconX,
@@ -66,7 +67,7 @@ export default function PageImportModal({
{t("Import pages")}
-
+
@@ -90,12 +91,14 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const markdownFileRef = useRef<() => void>(null);
const htmlFileRef = useRef<() => void>(null);
const docxFileRef = useRef<() => void>(null);
+ const pdfFileRef = useRef<() => void>(null);
const notionFileRef = useRef<() => void>(null);
const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null);
const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT);
const canUseDocx = useHasFeature(Feature.DOCX_IMPORT);
+ const canUsePdf = useHasFeature(Feature.PDF_IMPORT);
const upgradeLabel = useUpgradeLabel();
const handleZipUpload = async (selectedFile: File, source: string) => {
@@ -244,7 +247,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
}, 3000);
}, [fileTaskId]);
- const maxSingleFileSize = bytes("20mb");
+ const maxSingleFileSize = bytes("30mb");
const handleFileUpload = async (selectedFiles: File[]) => {
if (!selectedFiles) {
@@ -298,6 +301,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
if (markdownFileRef.current) markdownFileRef.current();
if (htmlFileRef.current) htmlFileRef.current();
if (docxFileRef.current) docxFileRef.current();
+ if (pdfFileRef.current) pdfFileRef.current();
const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
@@ -328,7 +332,15 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
return (
<>
-
+
{(props) => (
+ )}
+
+
handleZipUpload(file, "notion")}
accept="application/zip"
resetRef={notionFileRef}
+ inputProps={{
+ "aria-label": t("Choose {{format}} file", { format: "Notion" }),
+ }}
>
{(props) => (
handleZipUpload(file, "confluence")}
accept="application/zip"
resetRef={confluenceFileRef}
+ inputProps={{
+ "aria-label": t("Choose {{format}} file", { format: "Confluence" }),
+ }}
>
{(props) => (
handleZipUpload(file, "generic")}
accept="application/zip"
resetRef={zipFileRef}
+ inputProps={{
+ "aria-label": t("Choose {{format}} file", { format: "ZIP" }),
+ }}
>
{(props) => (
diff --git a/apps/client/src/features/page/trash/components/trash-page-content-modal.tsx b/apps/client/src/features/page/trash/components/trash-page-content-modal.tsx
index ef35b02db..c9aad622c 100644
--- a/apps/client/src/features/page/trash/components/trash-page-content-modal.tsx
+++ b/apps/client/src/features/page/trash/components/trash-page-content-modal.tsx
@@ -19,7 +19,7 @@ export default function TrashPageContentModal({
const title = pageTitle || t("Untitled");
return (
-
+
diff --git a/apps/client/src/features/page/trash/components/trash.tsx b/apps/client/src/features/page/trash/components/trash.tsx
index ad2ba5002..de7e7ca4a 100644
--- a/apps/client/src/features/page/trash/components/trash.tsx
+++ b/apps/client/src/features/page/trash/components/trash.tsx
@@ -129,7 +129,7 @@ export default function Trash() {
{t("Deleted at")}
-
+
diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx
index ee4d77458..df5b9a429 100644
--- a/apps/client/src/features/page/tree/components/space-tree.tsx
+++ b/apps/client/src/features/page/tree/components/space-tree.tsx
@@ -458,6 +458,8 @@ interface CreateNodeProps {
}
function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
+ const { t } = useTranslation();
+
function handleCreate() {
if (node.data.hasChildren && node.children.length === 0) {
node.toggle();
@@ -475,6 +477,7 @@ function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
{
e.preventDefault();
e.stopPropagation();
@@ -591,6 +594,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
{
e.preventDefault();
e.stopPropagation();
@@ -725,6 +729,8 @@ interface PageArrowProps {
}
function PageArrow({ node, onExpandTree }: PageArrowProps) {
+ const { t } = useTranslation();
+
useEffect(() => {
if (node.isOpen) {
onExpandTree();
@@ -736,6 +742,8 @@ function PageArrow({ node, onExpandTree }: PageArrowProps) {
size={20}
variant="subtle"
c="gray"
+ aria-label={node.isOpen ? t("Collapse") : t("Expand")}
+ aria-expanded={node.isInternal ? node.isOpen : undefined}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
diff --git a/apps/client/src/features/search/components/search-control.tsx b/apps/client/src/features/search/components/search-control.tsx
index a98909b7b..518128d40 100644
--- a/apps/client/src/features/search/components/search-control.tsx
+++ b/apps/client/src/features/search/components/search-control.tsx
@@ -13,6 +13,7 @@ import {
import classes from "./search-control.module.css";
import React from "react";
import { useTranslation } from "react-i18next";
+import { platformModifierLabel } from "@/lib";
interface SearchControlProps extends BoxProps, ElementProps<"button"> {}
@@ -27,7 +28,7 @@ export function SearchControl({ className, ...others }: SearchControlProps) {
{t("Search")}
- Ctrl + K
+ {platformModifierLabel} + K
@@ -46,6 +47,7 @@ export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
diff --git a/apps/client/src/features/session/components/session-list.tsx b/apps/client/src/features/session/components/session-list.tsx
index 18a304586..6549a6e5f 100644
--- a/apps/client/src/features/session/components/session-list.tsx
+++ b/apps/client/src/features/session/components/session-list.tsx
@@ -37,7 +37,7 @@ export default function SessionList() {
{t("Device Name")}
{t("Last Active")}
-
+
@@ -94,7 +94,7 @@ export default function SessionList() {
{t("Device Name")}
{t("Last Active")}
- {otherSessions.length > 0 && }
+ {otherSessions.length > 0 && }
diff --git a/apps/client/src/features/share/components/share-action-menu.tsx b/apps/client/src/features/share/components/share-action-menu.tsx
index 52dad0dab..059951289 100644
--- a/apps/client/src/features/share/components/share-action-menu.tsx
+++ b/apps/client/src/features/share/components/share-action-menu.tsx
@@ -75,7 +75,7 @@ export default function ShareActionMenu({ share }: Props) {
arrowPosition="center"
>
-
+
diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx
index 64881ca25..bba226fe7 100644
--- a/apps/client/src/features/share/components/share-shell.tsx
+++ b/apps/client/src/features/share/components/share-shell.tsx
@@ -148,6 +148,7 @@ export default function ShareShell({
onClick={toggleTocMobile}
hiddenFrom="sm"
size="sm"
+ aria-label={t("Table of contents")}
>
@@ -157,6 +158,7 @@ export default function ShareShell({
{t("Member")}
{t("Role")}
-
+
diff --git a/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx b/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx
index f8ca2bf79..f0dc9b3aa 100644
--- a/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx
+++ b/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx
@@ -49,15 +49,15 @@ function WatchButton({ spaceId, watchedIds, size = 16 }: { spaceId: string; watc
}
};
+ const label = isWatching ? t("Stop watching space") : t("Watch space");
+
return (
-
+
@@ -111,7 +111,7 @@ export default function AllSpacesList({
{t("Space")}
{t("Members")}
-
+
@@ -168,7 +168,11 @@ export default function AllSpacesList({