chore(sync): sync updates from docmost/docmost

This commit is contained in:
Forgejo Actions 2026-10-11 04:00:28 +00:00
commit fec7ca32ac
75 changed files with 5533 additions and 269 deletions

View file

@ -626,6 +626,10 @@
"Restore this base to view its contents.": "Restore this base to view its contents.",
"Subpages": "Subpages",
"Failed to load subpages": "Failed to load subpages",
"Sort subpages": "Sort subpages",
"Manual": "Manual",
"A–Z": "A–Z",
"Recent": "Recent",
"No subpages": "No subpages",
"Subpages (Child pages)": "Subpages (Child pages)",
"List all subpages of the current page": "List all subpages of the current page",
@ -978,6 +982,8 @@
"Page menu": "Page menu",
"Expand": "Expand",
"Collapse": "Collapse",
"Expand {{name}}": "Expand {{name}}",
"Collapse {{name}}": "Collapse {{name}}",
"Comment menu": "Comment menu",
"Group menu": "Group menu",
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
@ -1476,5 +1482,14 @@
"Test event delivered to {{name}}": "Test event delivered to {{name}}",
"Updated SIEM destination": "Updated SIEM destination",
"Verify TLS certificate": "Verify TLS certificate",
"e.g. Splunk prod": "e.g. Splunk prod"
"e.g. Splunk prod": "e.g. Splunk prod",
"Tabs": "Tabs",
"Insert a multi-tab content block.": "Insert a multi-tab content block.",
"Add tab": "Add tab",
"Tab actions": "Tab actions",
"Move left": "Move left",
"Move right": "Move right",
"Delete tab": "Delete tab",
"Tab label": "Tab label",
"Tab actions for {{name}}": "Tab actions for {{name}}"
}

View file

@ -20,6 +20,7 @@ import {
FilterGroup,
} from "@/ee/base/types/base.types";
import { exportBaseToCsv } from "@/ee/base/services/base-service";
import { normalizeFilter } from "@/ee/base/queries/base-row-query";
import { getApiErrorMessage } from "@/lib/api-error";
import { ViewTabs } from "@/ee/base/components/views/view-tabs";
import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config";
@ -69,11 +70,13 @@ export function BaseToolbar({
const isKanban = activeView?.type === "kanban";
const viewFilter = activeView?.config?.filter;
const handleExport = useCallback(async () => {
if (exporting) return;
setExporting(true);
try {
await exportBaseToCsv(base.id);
await exportBaseToCsv(base.id, normalizeFilter(viewFilter));
} catch (err) {
notifications.show({
color: "red",
@ -82,7 +85,7 @@ export function BaseToolbar({
} finally {
setExporting(false);
}
}, [base.id, exporting, t]);
}, [base.id, exporting, t, viewFilter]);
const openToolbar = useCallback((panel: "sort" | "filter" | "properties") => {
setSortOpened(panel === "sort" ? (v) => !v : false);

View file

@ -57,10 +57,13 @@ export async function convertPageToBase(
return req.data;
}
export async function exportBaseToCsv(pageId: string): Promise<void> {
export async function exportBaseToCsv(
pageId: string,
filter?: FilterNode,
): Promise<void> {
const req = await api.post(
"/bases/export-csv",
{ pageId },
{ pageId, filter },
{ responseType: "blob" },
);

View file

@ -3,6 +3,7 @@ import { atom } from 'jotai';
export const showCommentPopupAtom = atom<boolean>(false);
export const activeCommentIdAtom = atom<string>('');
export const draftCommentIdAtom = atom<string>('');
export const commentPanelTabAtom = atom<'open' | 'resolved'>('open');
// Read-only comment state
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);

View file

@ -1,9 +1,10 @@
import React, { useState } from "react";
import { Dialog, Group, Stack, Text } from "@mantine/core";
import { useClickOutside } from "@mantine/hooks";
import { useAtom } from "jotai";
import { useAtom, useSetAtom } from "jotai";
import {
activeCommentIdAtom,
commentPanelTabAtom,
draftCommentIdAtom,
showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
@ -32,6 +33,7 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const setCommentPanelTab = useSetAtom(commentPanelTabAtom);
const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom);
const [currentUser] = useAtom(currentUserAtom);
const [, setAsideState] = useAtom(asideStateAtom);
@ -90,6 +92,7 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
});
}
setActiveCommentId(createdComment.id);
setCommentPanelTab("open");
setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => {
@ -121,6 +124,7 @@ function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
});
setActiveCommentId(createdComment.id);
setCommentPanelTab("open");
setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => {

View file

@ -5,6 +5,7 @@ import { useAtom, useAtomValue } from "jotai";
import { useTimeAgo } from "@/hooks/use-time-ago";
import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import { showTabAtElement } from "@/features/editor/utils";
import { isEditorReady } from "@docmost/editor-ext";
import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu";
@ -109,6 +110,7 @@ function CommentListItem({
`.comment-mark[data-comment-id="${comment.id}"]`,
);
if (el) {
showTabAtElement(el);
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("comment-highlight");
setTimeout(() => {
@ -150,16 +152,19 @@ function CommentListItem({
/>
)}
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
<CommentMenu
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
onResolveComment={handleResolveComment}
canEdit={currentUser?.user?.id === comment.creatorId}
isResolved={comment.resolvedAt != null}
isParentComment={!comment.parentCommentId}
/>
)}
<CommentMenu
commentId={comment.id}
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
onResolveComment={handleResolveComment}
canEdit={currentUser?.user?.id === comment.creatorId}
canManage={
currentUser?.user?.id === comment.creatorId ||
userSpaceRole === "admin"
}
isResolved={comment.resolvedAt != null}
isParentComment={!comment.parentCommentId}
/>
</div>
</Group>

View file

@ -29,11 +29,13 @@ import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { commentPanelTabAtom } from "@/features/comment/atoms/comment-atom";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
function CommentListWithTabs() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const [activeTab, setActiveTab] = useAtom(commentPanelTabAtom);
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const {
data: comments,
@ -185,7 +187,10 @@ function CommentListWithTabs() {
}}
>
<Tabs
defaultValue="open"
value={activeTab}
onChange={(value) =>
setActiveTab(value === "resolved" ? "resolved" : "open")
}
variant="default"
style={{
flex: "1 1 auto",

View file

@ -2,36 +2,51 @@ import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import {
IconDots,
IconEdit,
IconLink,
IconTrash,
IconCircleCheck,
IconCircleCheckFilled,
} from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { useClipboard } from "@/hooks/use-clipboard";
import { getAppUrl } from "@/lib/config";
type CommentMenuProps = {
commentId: string;
onEditComment: () => void;
onDeleteComment: () => void;
onResolveComment?: () => void;
canEdit?: boolean;
canManage?: boolean;
isResolved?: boolean;
isParentComment?: boolean;
};
function CommentMenu({
commentId,
onEditComment,
onDeleteComment,
onResolveComment,
canEdit = true,
canManage = false,
isResolved = false,
isParentComment = false,
}: CommentMenuProps) {
const { t } = useTranslation();
const canResolve = useHasFeature(Feature.COMMENT_RESOLUTION);
const upgradeLabel = useUpgradeLabel();
const clipboard = useClipboard({ timeout: 500 });
const handleCopyLink = () => {
const link = `${getAppUrl()}${window.location.pathname}?commentId=${commentId}`;
clipboard.copy(link);
notifications.show({ message: t("Link copied") });
};
//@ts-ignore
const openDeleteModal = () =>
@ -65,6 +80,7 @@ function CommentMenu({
</Menu.Item>
)}
{isParentComment &&
canManage &&
(canResolve ? (
<Menu.Item
onClick={onResolveComment}
@ -86,11 +102,19 @@ function CommentMenu({
</Tooltip>
))}
<Menu.Item
leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}
leftSection={<IconLink size={14} />}
onClick={handleCopyLink}
>
{t("Delete comment")}
{t("Copy link")}
</Menu.Item>
{canManage && (
<Menu.Item
leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}
>
{t("Delete comment")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
);

View file

@ -6,6 +6,19 @@
border: 2px solid #8d7249;
}
.deepLinkHighlight {
animation: comment-deep-link-flash 3s ease-out;
}
@keyframes comment-deep-link-flash {
0% {
background-color: var(--mantine-color-yellow-light);
}
100% {
background-color: transparent;
}
}
.textSelection {
margin-top: 4px;
border-left: 2px solid var(--mantine-color-gray-6);

View file

@ -0,0 +1,155 @@
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { useSetAtom } from "jotai";
import { useCommentsQuery } from "@/features/comment/queries/comment-query";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
activeCommentIdAtom,
commentPanelTabAtom,
} from "@/features/comment/atoms/comment-atom";
import classes from "@/features/comment/components/comment.module.css";
import { showTabAtElement } from "@/features/editor/utils";
const COMMENT_ID_PARAM = "commentId";
const HIGHLIGHT_DURATION = 3000;
const RETRY_INTERVAL = 100;
const MAX_RETRIES = 40; // ~4s for the panel/editor to mount and render
/**
* Reveals a comment referenced by a `?commentId=` query param (e.g. from a
* notification email): opens the comments panel on the right tab, then scrolls
* to and briefly highlights the comment in the panel and, for inline comments,
* its mark in the document.
*/
export function useCommentDeepLink(pageId: string | undefined) {
const [searchParams, setSearchParams] = useSearchParams();
const commentId = searchParams.get(COMMENT_ID_PARAM);
const setAsideState = useSetAtom(asideStateAtom);
const setActiveCommentId = useSetAtom(activeCommentIdAtom);
const setCommentPanelTab = useSetAtom(commentPanelTabAtom);
// Only fetch comments when a deep link is present so normal page loads
// don't pay for an eager comments request.
const { data: comments, isLoading } = useCommentsQuery({
pageId: (commentId ? pageId : undefined) as string,
});
const handledRef = useRef<string | null>(null);
const timersRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
// Cancel pending retries on unmount only, so clearing the URL param (which
// re-runs the activation effect) cannot abort them.
useEffect(
() => () => {
timersRef.current.forEach(clearTimeout);
timersRef.current = [];
},
[],
);
// Reset per page so a deep-link tab override never outlives its page.
useEffect(() => {
setCommentPanelTab("open");
}, [pageId, setCommentPanelTab]);
useEffect(() => {
if (!commentId) {
handledRef.current = null;
return;
}
if (!pageId) return;
if (isLoading || !comments?.items) return;
if (handledRef.current === commentId) return;
handledRef.current = commentId;
// Normalize the URL once handled, whatever the outcome.
const nextParams = new URLSearchParams(searchParams);
nextParams.delete(COMMENT_ID_PARAM);
setSearchParams(nextParams, { replace: true });
const target = comments.items.find((c) => c.id === commentId);
if (!target) return; // unknown, deleted, or inaccessible comment
// Replies have no mark or panel container of their own; surface the thread.
const threadId = target.parentCommentId ?? target.id;
const thread = comments.items.find((c) => c.id === threadId);
setCommentPanelTab(thread?.resolvedAt ? "resolved" : "open");
setActiveCommentId(threadId);
setAsideState({ tab: "comments", isAsideOpen: true });
// Act once the element exists AND its position has settled, so the scroll
// lands correctly even while the page is still laying out (static -> live
// editor swap, images, fonts). Falls back to the last attempt if layout
// never fully settles within the window.
const onElementReady = (
selector: string,
onFound: (el: HTMLElement) => void,
) => {
let attempts = 0;
let lastTop: number | null = null;
let stableTicks = 0;
const tick = () => {
let el: HTMLElement | null = null;
try {
el = document.querySelector<HTMLElement>(selector);
} catch {
return;
}
attempts += 1;
const lastAttempt = attempts >= MAX_RETRIES;
if (el) {
const top = Math.round(el.getBoundingClientRect().top + window.scrollY);
stableTicks =
lastTop !== null && Math.abs(top - lastTop) <= 1 ? stableTicks + 1 : 0;
lastTop = top;
if (stableTicks >= 2 || lastAttempt) {
onFound(el);
return;
}
}
if (!lastAttempt) {
timersRef.current.push(setTimeout(tick, RETRY_INTERVAL));
}
};
tick();
};
onElementReady(`div[data-comment-id="${threadId}"]`, (el) => {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add(classes.deepLinkHighlight);
timersRef.current.push(
setTimeout(
() => el.classList.remove(classes.deepLinkHighlight),
HIGHLIGHT_DURATION,
),
);
});
// Inline marks render only for unresolved selection comments.
if (thread?.selection && !thread.resolvedAt) {
onElementReady(`.comment-mark[data-comment-id="${threadId}"]`, (el) => {
showTabAtElement(el);
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("comment-highlight");
timersRef.current.push(
setTimeout(
() => el.classList.remove("comment-highlight"),
HIGHLIGHT_DURATION,
),
);
});
}
}, [
commentId,
pageId,
isLoading,
comments,
searchParams,
setSearchParams,
setAsideState,
setActiveCommentId,
setCommentPanelTab,
]);
}

View file

@ -1,7 +1,7 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef, useState } from "react";
import { DOMSerializer, Node as PMNode } from "@tiptap/pm/model";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
@ -23,6 +23,7 @@ import { isEditorReady, isTextSelected } from "@docmost/editor-ext";
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
import { copyNodeToClipboard } from "@/features/editor/utils";
type LayoutPreset = {
layout: ColumnsLayout;
@ -78,6 +79,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
"drawio",
"excalidraw",
"table",
"tabs",
];
const shouldShow = useCallback(
@ -191,54 +193,16 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
);
const handleCopy = useCallback(() => {
const { state } = editor;
const parent = findParentNode(
(node: PMNode) => node.type.name === "columns",
)(state.selection);
)(editor.state.selection);
if (!parent) return;
const serializer = DOMSerializer.fromSchema(state.schema);
const dom = serializer.serializeNode(parent.node);
const wrapper = document.createElement("div");
wrapper.appendChild(dom);
const onSuccess = () => {
copyNodeToClipboard(editor, parent.node).then(() => {
clearTimeout(copyTimerRef.current);
setCopied(true);
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
};
if (navigator.clipboard?.write) {
navigator.clipboard
.write([
new ClipboardItem({
"text/html": new Blob([wrapper.innerHTML], { type: "text/html" }),
"text/plain": new Blob([parent.node.textContent], {
type: "text/plain",
}),
}),
])
.then(onSuccess)
.catch(execCommandFallback);
} else {
execCommandFallback();
}
function execCommandFallback() {
wrapper.style.position = "fixed";
wrapper.style.left = "-9999px";
document.body.appendChild(wrapper);
const range = document.createRange();
range.selectNodeContents(wrapper);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand("copy");
sel?.removeAllRanges();
document.body.removeChild(wrapper);
editor.view.focus();
onSuccess();
}
});
}, [editor]);
const handleDelete = useCallback(() => {

View file

@ -79,6 +79,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
const position: Range = results[resultIndex];
if (!position) return;
editor.commands.showTabAt(results[resultIndex].from);
// @ts-ignore
editor.commands.setTextSelection(position);

View file

@ -45,6 +45,7 @@ function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) {
});
if (!position) return;
editor.commands.showTabAt(position.from);
requestAnimationFrame(() => {
document
.querySelector(".search-result-current")

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import type { Editor } from "@tiptap/core";
import { getSuggestionItems } from "./menu-items";
const editorWhere = (canInsertTabs: boolean) =>
({ can: () => ({ insertTabs: () => canInsertTabs }) }) as unknown as Editor;
const titles = (editor?: Editor) =>
Object.values(getSuggestionItems({ query: "tabs", editor }))
.flat()
.map((item) => item.title);
describe("slash menu", () => {
it("hides Tabs where tabs can't be inserted, such as inside a tab", () => {
expect(titles(editorWhere(true))).toContain("Tabs");
expect(titles(editorWhere(false))).not.toContain("Tabs");
});
});

View file

@ -8,6 +8,7 @@ import {
IconH3,
IconInfoCircle,
IconLayoutKanban,
IconLayoutNavbar,
IconList,
IconListNumbers,
IconMath,
@ -36,6 +37,7 @@ import {
CommandProps,
SlashMenuGroupedItemsType,
} from "@/features/editor/components/slash-menu/types";
import type { Editor } from "@tiptap/core";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
@ -400,6 +402,15 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setDetails().run(),
},
{
title: "Tabs",
description: "Insert a multi-tab content block.",
searchTerms: ["tabs", "tabbed", "multi", "panel"],
icon: IconLayoutNavbar,
disable: (editor) => !editor.can().insertTabs(),
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).insertTabs().run(),
},
{
title: "Callout",
description: "Insert callout notice.",
@ -799,9 +810,11 @@ const CommandGroups: SlashMenuGroupedItemsType = {
export const getSuggestionItems = ({
query,
editor,
excludeItems,
}: {
query: string;
editor?: Editor;
excludeItems?: Set<string>;
}): SlashMenuGroupedItemsType => {
const search = query.toLowerCase();
@ -820,6 +833,7 @@ export const getSuggestionItems = ({
for (const [group, items] of Object.entries(CommandGroups)) {
const filteredItems = items.filter((item) => {
if (excludeItems?.has(item.title)) return false;
if (editor && item.disable?.(editor)) return false;
const translatedTitle = i18n.t(item.title);
const translatedDescription = i18n.t(item.description);
return (

View file

@ -0,0 +1,138 @@
import { ActionIcon, Collapse } from "@mantine/core";
import { IconChevronRight, IconFileDescription } from "@tabler/icons-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import type { SubpagesSortBy } from "@docmost/editor-ext";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils";
import { formattedDate, shortTimeAgo } from "@/lib/time";
import { useSubpages } from "./use-subpages";
import type { SubpageListItem } from "./subpages.utils";
import classes from "./subpages.module.css";
type SubpageItemProps = {
page: SubpageListItem;
depth: number;
sortBy: SubpagesSortBy;
showUpdatedAt: boolean;
isPublicSpaceRoute: boolean;
shareId?: string;
spaceSlug?: string;
};
export default function SubpageItem({
page,
depth,
sortBy,
showUpdatedAt,
isPublicSpaceRoute,
shareId,
spaceSlug,
}: SubpageItemProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const { subpages: children, isLoading } = useSubpages(
page.id,
sortBy,
opened,
);
const title = page.title || t("untitled");
const updatedAt =
showUpdatedAt && page.updatedAt ? new Date(page.updatedAt) : null;
const pageUrl = shareId
? buildSharedPageUrl({
shareId,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: isPublicSpaceRoute
? buildPublicSpaceUrl({
spaceSlug,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title);
return (
<div role="listitem">
<div
className={classes.row}
style={{ paddingInlineStart: 8 + depth * 24 }}
>
{page.hasChildren ? (
<ActionIcon
variant="subtle"
color="gray"
size={22}
className={classes.toggle}
onClick={() => setOpened((value) => !value)}
aria-label={
opened
? t("Collapse {{name}}", { name: title })
: t("Expand {{name}}", { name: title })
}
aria-expanded={opened}
>
<IconChevronRight
size={16}
className={clsx(classes.chevron, opened && classes.chevronOpened)}
/>
</ActionIcon>
) : (
<span className={classes.togglePlaceholder} />
)}
<Link to={pageUrl} className={classes.link} draggable={false}>
<span className={classes.icon} aria-hidden>
{page.icon || <IconFileDescription size={18} stroke={1.5} />}
</span>
<span className={classes.title}>{title}</span>
{updatedAt && (
<time
className={classes.updatedAt}
dateTime={updatedAt.toISOString()}
title={formattedDate(updatedAt)}
>
{shortTimeAgo(updatedAt)}
</time>
)}
</Link>
</div>
{page.hasChildren && (
<Collapse expanded={opened} keepMounted={false}>
{isLoading ? (
<div
className={classes.loading}
style={{ paddingInlineStart: 8 + (depth + 1) * 24 }}
>
{t("Loading…")}
</div>
) : (
<div role="list">
{children.map((child) => (
<SubpageItem
key={child.id}
page={child}
depth={depth + 1}
sortBy={sortBy}
showUpdatedAt={showUpdatedAt}
isPublicSpaceRoute={isPublicSpaceRoute}
shareId={shareId}
spaceSlug={spaceSlug}
/>
))}
</div>
)}
</Collapse>
)}
</div>
);
}

View file

@ -67,7 +67,7 @@ export const SubpagesMenu = React.memo(
updateDelay={0}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={deleteNode}
variant="default"

View file

@ -1,30 +1,29 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import { useMemo } from "react";
import { Link, useLocation, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
import styles from "../mention/mention.module.css";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { NodeViewProps, NodeViewWrapper, useEditorState } from "@tiptap/react";
import { SegmentedControl } from "@mantine/core";
import { useId } from "react";
import { useLocation, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
import { useAtomValue } from "jotai";
import type { SubpagesSortBy } from "@docmost/editor-ext";
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
import { findSubpagesInTree } from "@/features/share/utils";
import { extractPageSlugId } from "@/lib";
import SubpageItem from "./subpage-item";
import { useSubpages } from "./use-subpages";
import classes from "./subpages.module.css";
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { editor, node, updateAttributes } = props;
const { spaceSlug, shareId, pageSlug } = useParams();
const { t } = useTranslation();
const location = useLocation();
const headingId = useId();
const isShareRoute = location.pathname.startsWith("/share/");
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const isPublicView = isShareRoute || isPublicSpaceRoute;
const isEditable = useEditorState({
editor,
selector: (ctx) => ctx.editor.isEditable,
});
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
@ -33,7 +32,7 @@ export default function SubpagesView(props: NodeViewProps) {
const routePageId = extractPageSlugId(pageSlug);
let currentPageId = storagePageId;
if (shareId){
if (isShareRoute){
currentPageId = routePageId;
}
@ -45,127 +44,60 @@ export default function SubpagesView(props: NodeViewProps) {
currentPageId = routePageId ?? publicSpaceTreeData?.[0]?.slugId;
}
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
const publicSpaceSubpages = useMemo(
() => findSubpagesInTree(publicSpaceTreeData, currentPageId),
[publicSpaceTreeData, currentPageId],
);
const sortBy: SubpagesSortBy = node.attrs.sortBy || "default";
const { subpages, isLoading, error } = useSubpages(currentPageId, sortBy);
const isPublicView = Boolean(shareId) || isPublicSpaceRoute;
const { data, isLoading, error } = useGetSidebarPagesQuery(
isPublicView ? null : { pageId: currentPageId },
);
const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages
if (shareId && sharedSubpages) {
return sharedSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
}));
}
if (isPublicSpaceRoute) {
return publicSpaceSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
}));
}
// Otherwise use the API data
if (!data?.pages) return [];
const allPages = data.pages.flatMap((page) => page.items);
return sortPositionKeys(allPages);
}, [
data,
shareId,
sharedSubpages,
isPublicSpaceRoute,
publicSpaceSubpages,
]);
if (isLoading && !isPublicView) {
if (isLoading) {
return null;
}
if (error && !isPublicView) {
return (
<NodeViewWrapper data-drag-handle>
<Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")}
</Text>
</NodeViewWrapper>
);
}
if (subpages.length === 0) {
return (
<NodeViewWrapper data-drag-handle>
<div className={classes.container}>
<Text c="dimmed" size="md" py="md">
{t("No subpages")}
</Text>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle>
<div className={classes.container}>
<Stack gap={5}>
{subpages.map((page) => (
<Anchor
key={page.id}
component={Link}
fw={500}
to={
shareId
? buildSharedPageUrl({
shareId,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: isPublicSpaceRoute
? buildPublicSpaceUrl({
spaceSlug,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
}
underline="never"
className={styles.pageMentionLink}
draggable={false}
>
{page?.icon ? (
<span style={{ marginRight: "4px" }}>{page.icon}</span>
) : (
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<div className={classes.card}>
<div className={classes.header}>
<div id={headingId} className={classes.heading}>
{t("Subpages")}
{subpages.length > 0 && (
<span className={classes.count}>{subpages.length}</span>
)}
</div>
<span className={styles.pageMentionText}>
{page?.title || t("untitled")}
</span>
</Anchor>
))}
</Stack>
{isEditable && subpages.length > 1 && (
<SegmentedControl<SubpagesSortBy>
size="xs"
className={classes.sortControl}
value={sortBy}
onChange={(value) => updateAttributes({ sortBy: value })}
data={[
{ label: t("Manual"), value: "default" },
{ label: t("A–Z"), value: "title-asc" },
{ label: t("Recent"), value: "recent" },
]}
aria-label={t("Sort subpages")}
/>
)}
</div>
{subpages.length > 0 ? (
<div role="list" aria-labelledby={headingId} className={classes.list}>
{subpages.map((page) => (
<SubpageItem
key={page.id}
page={page}
depth={0}
sortBy={sortBy}
showUpdatedAt={!isPublicView}
isPublicSpaceRoute={isPublicSpaceRoute}
shareId={shareId}
spaceSlug={spaceSlug}
/>
))}
</div>
) : (
<div className={classes.empty}>
{error ? t("Failed to load subpages") : t("No subpages")}
</div>
)}
</div>
</NodeViewWrapper>
);

View file

@ -1,9 +1,173 @@
.container {
margin: 0;
padding-left: 4px;
.card {
/* lighter than dimmed but still AA on the header */
--subpages-dimmed: light-dark(
#6b7280,
color-mix(in srgb, var(--mantine-color-dark-1), var(--mantine-color-dark-2))
);
--subpages-icon: light-dark(
var(--mantine-color-gray-6),
var(--mantine-color-dark-2)
);
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-md);
overflow: hidden;
user-select: none;
a {
border: none !important;
&:hover .sortControl,
&:focus-within .sortControl {
opacity: 1;
}
}
/* round the selection outline to match the card */
:global(.ProseMirror .node-subpages) {
border-radius: var(--mantine-radius-md);
}
:global(.ProseMirror-selectednode) .sortControl {
opacity: 1;
}
.header {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--mantine-spacing-sm);
min-height: rem(44px);
padding-block: rem(6px);
padding-inline: rem(16px) rem(8px);
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.heading {
display: flex;
align-items: baseline;
gap: rem(8px);
font-size: var(--mantine-font-size-sm);
font-weight: 600;
line-height: 1.4;
}
.count {
font-weight: 500;
color: var(--subpages-dimmed);
}
.sortControl {
margin-inline-start: auto;
opacity: 0;
transition: opacity 150ms ease;
@media (hover: none) {
opacity: 1;
}
}
.empty {
padding: rem(12px) rem(16px);
font-size: var(--mantine-font-size-sm);
color: var(--subpages-dimmed);
}
.list {
padding: rem(4px);
}
.row {
display: flex;
align-items: center;
gap: rem(4px);
min-height: rem(36px);
padding-inline-end: rem(12px);
border-radius: var(--mantine-radius-sm);
&:hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
}
.toggle {
flex: none;
}
.togglePlaceholder {
flex: none;
width: rem(22px);
}
.chevron {
color: var(--subpages-icon);
transition: transform 150ms ease;
}
.toggle:hover .chevron {
color: inherit;
}
.chevronOpened {
transform: rotate(90deg);
}
.link {
display: flex;
align-items: center;
align-self: stretch;
gap: rem(8px);
flex: 1;
min-width: 0;
padding-inline-start: rem(4px);
}
/* beat the editor's link styles */
.card .row .link {
font-weight: inherit;
border-bottom: none;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: rem(20px);
font-size: rem(17px);
line-height: 1;
color: var(--subpages-icon);
}
.title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: rem(15px);
line-height: 1.5;
}
.updatedAt {
flex: none;
padding-inline-start: rem(12px);
font-size: var(--mantine-font-size-xs);
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--subpages-dimmed);
}
.loading {
display: flex;
align-items: center;
min-height: rem(36px);
font-size: var(--mantine-font-size-sm);
color: var(--subpages-dimmed);
}

View file

@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { sortSubpages, type SubpageListItem } from "./subpages.utils";
// input order differs from every expected order
const pages: SubpageListItem[] = [
{
id: "a",
slugId: "a",
title: "Alpha",
position: "a3",
hasChildren: false,
updatedAt: "2026-10-02T09:00:00.000Z",
},
{ id: "c", slugId: "c", title: "charlie", position: "a2", hasChildren: false },
{
id: "d",
slugId: "d",
title: "Delta",
position: "a1",
hasChildren: true,
updatedAt: "2026-10-01T09:00:00.000Z",
},
{
id: "b",
slugId: "b",
title: "beta",
position: "a0",
hasChildren: false,
updatedAt: "2026-10-03T09:00:00.000Z",
},
];
const ids = (items: SubpageListItem[]) => items.map((item) => item.id);
describe("sortSubpages", () => {
it("keeps the manual position order by default", () => {
expect(ids(sortSubpages(pages, "default"))).toEqual(["b", "d", "c", "a"]);
});
it("sorts titles A to Z ignoring case", () => {
expect(ids(sortSubpages(pages, "title-asc"))).toEqual(["a", "b", "c", "d"]);
});
it("sorts numbers inside titles by value", () => {
const chapters = ["Chapter 10", "Chapter 2", "Chapter 1"].map(
(title, index) => ({
id: title,
slugId: title,
title,
position: `a${index}`,
hasChildren: false,
}),
);
expect(ids(sortSubpages(chapters, "title-asc"))).toEqual([
"Chapter 1",
"Chapter 2",
"Chapter 10",
]);
});
it("puts the most recently updated first and undated pages last", () => {
expect(ids(sortSubpages(pages, "recent"))).toEqual(["b", "a", "d", "c"]);
});
it("falls back to the manual order for an unknown sort value", () => {
expect(ids(sortSubpages(pages, "title-desc" as never))).toEqual([
"b",
"d",
"c",
"a",
]);
});
it("does not reorder the input array", () => {
sortSubpages(pages, "recent");
sortSubpages(pages, "default");
expect(ids(pages)).toEqual(["a", "c", "d", "b"]);
});
});

View file

@ -0,0 +1,38 @@
import type { SubpagesSortBy } from "@docmost/editor-ext";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
export type SubpageListItem = {
id: string;
slugId: string;
title: string;
icon?: string;
position: string;
hasChildren: boolean;
updatedAt?: Date | string;
};
export function sortSubpages(
items: SubpageListItem[],
sortBy: SubpagesSortBy,
): SubpageListItem[] {
const sortedItems = [...items];
if (sortBy === "title-asc") {
return sortedItems.sort((a, b) =>
(a.title || "").localeCompare(b.title || "", undefined, {
sensitivity: "base",
numeric: true,
}),
);
}
if (sortBy === "recent") {
return sortedItems.sort(
(a, b) =>
new Date(b.updatedAt ?? 0).getTime() -
new Date(a.updatedAt ?? 0).getTime(),
);
}
return sortPositionKeys(sortedItems);
}

View file

@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createStore, Provider as JotaiProvider } from "jotai";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { ReactNode } from "react";
import type { SubpagesSortBy } from "@docmost/editor-ext";
import { getSidebarPages } from "@/features/page/services/page-service";
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
import { buildSharedPageTree } from "@/features/share/utils";
import { useSubpages } from "./use-subpages";
vi.mock("@/main.tsx", async () => ({
queryClient: new (await import("@tanstack/react-query")).QueryClient(),
}));
vi.mock("@/features/page/services/page-service", () => ({
getSidebarPages: vi.fn(),
}));
const sidebarPage = (
id: string,
title: string,
position: string,
updatedAt: string,
hasChildren: boolean,
) => ({
id,
slugId: `slug-${id}`,
title,
icon: null,
position,
parentPageId: "p1",
spaceId: "space-1",
creatorId: "user-1",
isBase: false,
deletedAt: null,
hasChildren,
canEdit: true,
updatedAt,
});
const treePage = (
id: string,
title: string,
position: string,
parentPageId: string | null,
updatedAt: string,
) => ({
id,
slugId: `slug-${id}`,
title,
icon: null,
position,
parentPageId,
spaceId: "space-1",
workspaceId: "workspace-1",
isRestricted: false,
updatedAt,
});
const treePages = [
treePage("p1", "Parent", "a0", null, "2026-10-01T00:00:00.000Z"),
treePage("c1", "Older child", "a0", "p1", "2026-10-02T00:00:00.000Z"),
treePage("c2", "Newer child", "a1", "p1", "2026-10-05T00:00:00.000Z"),
treePage("g1", "Grandchild", "a0", "c1", "2026-10-03T00:00:00.000Z"),
];
function renderSubpages(
path: string,
pageId: string,
sortBy: SubpagesSortBy,
{ enabled = true, store = createStore() } = {},
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<JotaiProvider store={store}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/s/:spaceSlug/p/:pageSlug" element={children} />
<Route path="/share/:shareId/p/:pageSlug" element={children} />
<Route path="/share/p/:pageSlug" element={children} />
<Route path="/docs/:spaceSlug/:pageSlug" element={children} />
</Routes>
</MemoryRouter>
</JotaiProvider>
</QueryClientProvider>
);
return renderHook(() => useSubpages(pageId, sortBy, enabled), { wrapper });
}
const ids = (items: { id: string }[]) => items.map((item) => item.id);
describe("useSubpages", () => {
beforeEach(() => {
vi.mocked(getSidebarPages).mockReset();
});
it("loads children of the page from the sidebar API in the app", async () => {
vi.mocked(getSidebarPages).mockResolvedValue({
items: [
sidebarPage("c1", "Older child", "a0", "2026-10-02T00:00:00.000Z", true),
sidebarPage("c2", "Newer child", "a1", "2026-10-05T00:00:00.000Z", false),
],
meta: {
limit: 100,
hasNextPage: false,
hasPrevPage: false,
nextCursor: null,
prevCursor: null,
},
} as never);
const { result } = renderSubpages("/s/general/p/parent-p1", "p1", "recent");
await waitFor(() => expect(ids(result.current.subpages)).toEqual(["c2", "c1"]));
expect(getSidebarPages).toHaveBeenCalledWith(
expect.objectContaining({ pageId: "p1" }),
);
});
it("does not request children until enabled", () => {
const { result } = renderSubpages("/s/general/p/parent-p1", "p1", "default", {
enabled: false,
});
expect(result.current.subpages).toEqual([]);
expect(getSidebarPages).not.toHaveBeenCalled();
});
it("reads children from the share tree without calling the API", () => {
const store = createStore();
store.set(sharedTreeDataAtom, buildSharedPageTree(treePages as never));
const { result } = renderSubpages("/share/abc/p/parent-slug-p1", "slug-p1", "recent", {
store,
});
expect(ids(result.current.subpages)).toEqual(["c2", "c1"]);
expect(getSidebarPages).not.toHaveBeenCalled();
});
it("treats a share link without a share key as a share", () => {
const store = createStore();
store.set(sharedTreeDataAtom, buildSharedPageTree(treePages as never));
const { result } = renderSubpages("/share/p/parent-slug-p1", "slug-p1", "default", {
store,
});
expect(ids(result.current.subpages)).toEqual(["c1", "c2"]);
expect(getSidebarPages).not.toHaveBeenCalled();
});
it("reads children from the public docs tree without calling the API", () => {
const store = createStore();
store.set(publicSpaceTreeDataAtom, buildSharedPageTree(treePages as never));
const { result } = renderSubpages("/docs/handbook/parent-slug-p1", "p1", "default", {
store,
});
expect(
result.current.subpages.map(({ id, slugId, title, hasChildren }) => ({
id,
slugId,
title,
hasChildren,
})),
).toEqual([
{ id: "c1", slugId: "slug-c1", title: "Older child", hasChildren: true },
{ id: "c2", slugId: "slug-c2", title: "Newer child", hasChildren: false },
]);
expect(getSidebarPages).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,55 @@
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { useAtomValue } from "jotai";
import type { SubpagesSortBy } from "@docmost/editor-ext";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
import { findSubpagesInTree } from "@/features/share/utils";
import { sortSubpages } from "./subpages.utils";
export function useSubpages(
pageId: string | undefined,
sortBy: SubpagesSortBy,
enabled = true,
) {
const { pathname } = useLocation();
const isShareRoute = pathname.startsWith("/share/");
const isPublicView = isShareRoute || pathname.startsWith("/docs/");
const sharedTreeData = useAtomValue(sharedTreeDataAtom);
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
const { data, isLoading, error } = useGetSidebarPagesQuery(
!isPublicView && enabled ? { pageId } : null,
);
const subpages = useMemo(() => {
if (isPublicView) {
const tree = isShareRoute ? sharedTreeData : publicSpaceTreeData;
const nodes = findSubpagesInTree(tree, pageId).map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
hasChildren: node.hasChildren,
updatedAt: node.updatedAt,
}));
return sortSubpages(nodes, sortBy);
}
const pages = data?.pages.flatMap((page) => page.items) ?? [];
return sortSubpages(pages, sortBy);
}, [
isPublicView,
isShareRoute,
sharedTreeData,
publicSpaceTreeData,
pageId,
data,
sortBy,
]);
return { subpages, isLoading, error };
}

View file

@ -51,6 +51,7 @@ export const TableOfContents: FC<TableOfContentsProps> = (props) => {
const handleScrollToHeading = (position: number) => {
if (!props.editor || props.editor.isDestroyed) return;
props.editor.commands.showTabAt(position);
const { view } = props.editor;
const headerOffset = parseInt(

View file

@ -0,0 +1,62 @@
import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { TabActions } from "./tabs.types";
type TabActionsMenuProps = {
index: number;
tabCount: number;
label: string;
actions: TabActions;
};
export default function TabActionsMenu({
index,
tabCount,
label,
actions,
}: TabActionsMenuProps) {
const { t } = useTranslation();
return (
<Menu position="bottom-start" shadow="md" width={200}>
<Menu.Target>
<Tooltip label={t("Tab actions")}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("Tab actions for {{name}}", {
name: label || t("Untitled"),
})}
>
<IconChevronDown size={14} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => actions.rename(index)}>
{t("Rename")}
</Menu.Item>
<Menu.Item onClick={() => actions.duplicate(index)}>
{t("Duplicate")}
</Menu.Item>
<Menu.Item
disabled={index === 0}
onClick={() => actions.move(index, index - 1)}
>
{t("Move left")}
</Menu.Item>
<Menu.Item
disabled={index === tabCount - 1}
onClick={() => actions.move(index, index + 1)}
>
{t("Move right")}
</Menu.Item>
<Menu.Item color="red" onClick={() => actions.remove(index)}>
{t("Delete tab")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}

View file

@ -0,0 +1,48 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
type TabRenameInputProps = {
initialValue: string;
onDone: (value: string | null) => void;
};
export default function TabRenameInput({
initialValue,
onDone,
}: TabRenameInputProps) {
const { t } = useTranslation();
const [value, setValue] = useState(initialValue);
const doneRef = useRef(false);
const finish = (result: string | null) => {
if (doneRef.current) return;
doneRef.current = true;
onDone(result);
};
return (
<span className="dm-tabs__rename" data-value={value}>
<input
aria-label={t("Tab label")}
size={1}
value={value}
autoFocus
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => setValue(event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Enter") {
event.preventDefault();
finish(value);
}
if (event.key === "Escape") {
event.preventDefault();
finish(null);
}
}}
onBlur={() => finish(value)}
/>
</span>
);
}

View file

@ -0,0 +1,133 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Tabs } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import TabActionsMenu from "./tab-actions-menu";
import TabRenameInput from "./tab-rename-input";
import type { TabActions } from "./tabs.types";
export const TAB_DRAG_TYPE = "editor-tab";
type TabSlotProps = {
index: number;
tabCount: number;
label: string;
isActive: boolean;
isEditable: boolean;
isRenaming: boolean;
instanceId: string;
contentId: string;
actions: TabActions;
};
export default function TabSlot({
index,
tabCount,
label,
isActive,
isEditable,
isRenaming,
instanceId,
contentId,
actions,
}: TabSlotProps) {
const { t } = useTranslation();
const slotRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const reorderEnabled = isEditable && tabCount > 1 && !isRenaming;
const moveRef = useRef(actions.move);
useLayoutEffect(() => {
moveRef.current = actions.move;
});
useEffect(() => {
const element = slotRef.current;
if (!element || !reorderEnabled) return;
return combine(
draggable({
element,
getInitialData: () => ({ type: TAB_DRAG_TYPE, instanceId, index }),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element,
canDrop: ({ source }) =>
source.data.type === TAB_DRAG_TYPE &&
source.data.instanceId === instanceId &&
source.data.index !== index,
getData: ({ input, element: target }) =>
attachClosestEdge(
{ index },
{ input, element: target, allowedEdges: ["left", "right"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
const startIndex = source.data.index as number;
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget: index,
closestEdgeOfTarget: edge,
axis: "horizontal",
});
if (finishIndex !== startIndex) {
moveRef.current(startIndex, finishIndex);
}
},
}),
);
}, [index, instanceId, reorderEnabled]);
return (
<div
ref={slotRef}
className="dm-tabs__slot"
data-tab-index={index}
data-active={isActive || undefined}
data-dragging={isDragging || undefined}
data-closest-edge={closestEdge ?? undefined}
>
{isRenaming ? (
<TabRenameInput
initialValue={label}
onDone={(value) => actions.finishRename(index, value)}
/>
) : (
<Tabs.Tab
value={String(index)}
className="dm-tabs__tab"
aria-controls={contentId}
onDoubleClick={isEditable ? () => actions.rename(index) : undefined}
>
{label || t("Untitled")}
</Tabs.Tab>
)}
{isActive && isEditable && !isRenaming && (
<TabActionsMenu
index={index}
tabCount={tabCount}
label={label}
actions={actions}
/>
)}
</div>
);
}

View file

@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconCheck, IconCopy, IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { NodeViewProps } from "@tiptap/react";
import { copyNodeToClipboard } from "@/features/editor/utils";
import classes from "../common/toolbar-menu.module.css";
type TabsToolbarProps = Pick<NodeViewProps, "editor" | "getPos">;
export default function TabsToolbar({ editor, getPos }: TabsToolbarProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(copiedTimerRef.current), []);
const handleCopy = () => {
const pos = getPos();
const node = typeof pos === "number" ? editor.state.doc.nodeAt(pos) : null;
if (!node) return;
copyNodeToClipboard(editor, node).then(() => {
clearTimeout(copiedTimerRef.current);
setCopied(true);
copiedTimerRef.current = setTimeout(() => setCopied(false), 1500);
});
};
const handleDelete = () => {
const pos = getPos();
if (typeof pos !== "number") return;
editor.chain().focus().setNodeSelection(pos).deleteSelection().run();
};
return (
<div
className={clsx(classes.toolbar, "dm-tabs__toolbar")}
contentEditable={false}
onMouseDown={(e) => e.preventDefault()}
>
<Tooltip label={copied ? t("Copied") : t("Copy")}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t("Copy")}
onClick={handleCopy}
>
{copied ? (
<IconCheck size={18} color="var(--mantine-color-green-6)" />
) : (
<IconCopy size={18} />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("Delete")}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t("Delete")}
onClick={handleDelete}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
);
}

View file

@ -0,0 +1,654 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { useEffect } from "react";
import { MantineProvider } from "@mantine/core";
import {
EditorContent,
useEditor,
type Editor,
type NodeViewProps,
} from "@tiptap/react";
import type { AnyExtension } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import {
Tab,
TabLabel,
TabPanel,
Tabs,
getActiveTabIndex,
} from "@docmost/editor-ext";
import TabsView from "./tabs-view";
import GlobalDragHandle from "@/features/editor/extensions/drag-handle";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) =>
key.replace(/\{\{(\w+)\}\}/g, (match, name) =>
options && name in options ? String(options[name]) : match,
),
}),
}));
const tab = (id: string, label: string, text: string) => ({
type: "tab",
attrs: { id },
content: [
{ type: "tabLabel", content: [{ type: "text", text: label }] },
{
type: "tabPanel",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
},
],
});
const content = {
type: "doc",
content: [
{
type: "tabs",
content: [tab("a", "Alpha", "first panel"), tab("b", "Beta", "second panel")],
},
{ type: "paragraph", content: [{ type: "text", text: "after" }] },
],
};
beforeAll(() => {
window.matchMedia ??= ((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
Range.prototype.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0);
Range.prototype.getClientRects = () =>
({
length: 0,
item: () => null,
[Symbol.iterator]: function* () {},
}) as unknown as DOMRectList;
document.elementFromPoint = () => null;
Element.prototype.scrollIntoView = () => {};
// jsdom's Blob has no text()
Blob.prototype.text ??= function (this: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsText(this);
});
};
// jsdom loads no Mantine CSS, and the autoscroll dev check reads this overflow
const style = document.createElement("style");
style.textContent = ".dm-tabs__scroller { overflow-x: auto; }";
document.head.appendChild(style);
});
afterEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
});
function Harness({
editable,
onEditor,
extensions = [],
view = TabsView,
}: {
editable: boolean;
onEditor: (editor: Editor) => void;
extensions?: AnyExtension[];
view?: typeof TabsView;
}) {
const editor = useEditor({
extensions: [
StarterKit,
Tabs.configure({ view }),
Tab,
TabLabel,
TabPanel,
...extensions,
],
content,
editable,
immediatelyRender: true,
});
useEffect(() => {
if (editor) onEditor(editor);
}, [editor, onEditor]);
return <EditorContent editor={editor} />;
}
async function mountTabs(
editable: boolean,
extensions?: AnyExtension[],
view?: typeof TabsView,
) {
let editor!: Editor;
const utils = render(
<MantineProvider env="test">
<Harness
editable={editable}
extensions={extensions}
view={view}
onEditor={(instance) => (editor = instance)}
/>
</MantineProvider>,
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return { editor, container: utils.container };
}
const tabTexts = () => screen.getAllByRole("tab").map((el) => el.textContent);
const selectedTab = () =>
screen
.getAllByRole("tab")
.find((el) => el.getAttribute("aria-selected") === "true")?.textContent;
function docLabels(editor: Editor) {
const tabs = editor.state.doc.firstChild!;
return Array.from(
{ length: tabs.childCount },
(_, index) => tabs.child(index).firstChild!.textContent,
);
}
async function click(element: Element) {
await act(async () => {
fireEvent.click(element);
});
}
async function chooseTabAction(name: string) {
await click(screen.getByRole("button", { name: /^Tab actions for / }));
await click(screen.getByRole("menuitem", { name }));
}
async function flushMicrotasks() {
for (let i = 0; i < 30; i += 1) await Promise.resolve();
}
describe("TabsView", () => {
it("shows labels from the document and only navigation in read mode", async () => {
const { container } = await mountTabs(false);
expect(tabTexts()).toEqual(["Alpha", "Beta"]);
expect(selectedTab()).toBe("Alpha");
expect(
screen.queryByRole("button", { name: /^Tab actions for / }),
).toBeNull();
expect(screen.queryByRole("button", { name: "Add tab" })).toBeNull();
expect(container.querySelector(".dm-tabs__actions")).toBeNull();
for (const tabElement of screen.getAllByRole("tab")) {
const controls = tabElement.getAttribute("aria-controls");
expect(controls && document.getElementById(controls)).toBeTruthy();
}
});
it("moves between tabs with the arrow keys and keeps focus on the tab", async () => {
await mountTabs(false);
await act(async () => {
fireEvent.keyDown(screen.getByRole("tab", { name: "Alpha" }), {
key: "ArrowRight",
});
});
expect(selectedTab()).toBe("Beta");
expect(document.activeElement).toBe(
screen.getByRole("tab", { name: "Beta" }),
);
});
it("switches tabs locally when a tab is clicked", async () => {
const { editor, container } = await mountTabs(true);
const before = editor.state.doc;
await click(screen.getByRole("tab", { name: "Beta" }));
expect(selectedTab()).toBe("Beta");
expect(editor.state.doc).toBe(before);
const hidden = Array.from(
container.querySelectorAll('[data-type="tab"].dm-tab-hidden'),
).map((el) => el.getAttribute("data-tab-id"));
expect(hidden).toEqual(["a"]);
});
it("does no position lookups while the page is edited outside the block", async () => {
let lookups = 0;
const CountingView = (props: NodeViewProps) => (
<TabsView
{...props}
getPos={() => {
lookups += 1;
return props.getPos();
}}
/>
);
const { editor } = await mountTabs(true, [], CountingView);
lookups = 0;
await act(async () => {
editor.commands.insertContentAt(textPos(editor, "after"), "x");
});
expect(editor.state.doc.lastChild!.textContent).toBe("xafter");
expect(lookups).toBe(0);
});
it("updates the strip within the same task as the switch", async () => {
const { editor } = await mountTabs(true);
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
false;
editor.commands.setActiveTab(1, 0);
await flushMicrotasks();
expect(tabTexts()).toEqual(["Alpha", "Beta"]);
expect(selectedTab()).toBe("Beta");
});
it("shows edit controls as soon as the editor becomes editable", async () => {
const { editor } = await mountTabs(false);
await act(async () => {
editor.setEditable(true);
});
expect(
screen.getByRole("button", { name: "Tab actions for Alpha" }),
).toBeTruthy();
expect(screen.getByRole("button", { name: "Add tab" })).toBeTruthy();
});
it("scrolls the strip so the active tab clears the scroller's edge controls", async () => {
const { container } = await mountTabs(false);
const scroller = container.querySelector<HTMLElement>(".dm-tabs__scroller")!;
const scrollBy = vi.fn();
scroller.scrollBy = scrollBy;
const place = (element: Element, left: number, right: number) => {
element.getBoundingClientRect = () =>
new DOMRect(left, 0, right - left, 40);
};
place(scroller, 100, 400);
scroller
.parentElement!.querySelectorAll<HTMLElement>(":scope > [data-position]")
.forEach((control) => {
Object.defineProperty(control, "offsetWidth", { value: 50 });
});
const slot = (index: number) =>
scroller.querySelector(`[data-tab-index="${index}"]`)!;
place(slot(1), 330, 430);
await click(screen.getByRole("tab", { name: "Beta" }));
expect(scrollBy).toHaveBeenLastCalledWith({ left: 80, behavior: "smooth" });
place(slot(0), 120, 220);
await click(screen.getByRole("tab", { name: "Alpha" }));
expect(scrollBy).toHaveBeenLastCalledWith({ left: -30, behavior: "smooth" });
place(slot(1), 200, 300);
await click(screen.getByRole("tab", { name: "Beta" }));
expect(scrollBy).toHaveBeenCalledTimes(2);
});
it("renames the active tab from the tab actions menu", async () => {
const { editor } = await mountTabs(true);
await chooseTabAction("Rename");
const input = screen.getByRole("textbox", {
name: "Tab label",
}) as HTMLInputElement;
expect(input.value).toBe("Alpha");
await act(async () => {
fireEvent.change(input, { target: { value: "First" } });
fireEvent.keyDown(input, { key: "Enter" });
});
expect(docLabels(editor)).toEqual(["First", "Beta"]);
expect(tabTexts()).toEqual(["First", "Beta"]);
});
it("ignores double-click in read mode", async () => {
await mountTabs(false);
await act(async () => {
fireEvent.doubleClick(screen.getByRole("tab", { name: "Beta" }));
});
expect(screen.queryByRole("textbox", { name: "Tab label" })).toBeNull();
});
it("renames a tab on double-click", async () => {
const { editor } = await mountTabs(true);
await act(async () => {
fireEvent.doubleClick(screen.getByRole("tab", { name: "Beta" }));
});
const input = screen.getByRole("textbox", {
name: "Tab label",
}) as HTMLInputElement;
expect(input.value).toBe("Beta");
await act(async () => {
fireEvent.change(input, { target: { value: "Second" } });
fireEvent.keyDown(input, { key: "Enter" });
});
expect(docLabels(editor)).toEqual(["Alpha", "Second"]);
});
it("keeps the label when rename is cancelled or left empty", async () => {
const { editor } = await mountTabs(true);
await chooseTabAction("Rename");
let input = screen.getByRole("textbox", { name: "Tab label" });
await act(async () => {
fireEvent.change(input, { target: { value: "Changed" } });
fireEvent.keyDown(input, { key: "Escape" });
});
expect(docLabels(editor)).toEqual(["Alpha", "Beta"]);
await chooseTabAction("Rename");
input = screen.getByRole("textbox", { name: "Tab label" });
await act(async () => {
fireEvent.change(input, { target: { value: " " } });
fireEvent.keyDown(input, { key: "Enter" });
});
expect(docLabels(editor)).toEqual(["Alpha", "Beta"]);
});
it.each([
["the event reports a composition", { isComposing: true }],
["the key code is 229, as Safari reports it", { keyCode: 229 }],
])(
"ignores Enter while an IME composition is in progress: %s",
async (_, init) => {
const { editor } = await mountTabs(true);
await chooseTabAction("Rename");
const input = screen.getByRole("textbox", { name: "Tab label" });
await act(async () => {
fireEvent.change(input, { target: { value: "Changed" } });
fireEvent.keyDown(input, { key: "Enter", ...init });
});
expect(screen.getByRole("textbox", { name: "Tab label" })).toBe(input);
expect(docLabels(editor)).toEqual(["Alpha", "Beta"]);
},
);
it("duplicates the active tab next to it and switches to the copy", async () => {
const { editor } = await mountTabs(true);
await chooseTabAction("Duplicate");
expect(docLabels(editor)).toEqual(["Alpha", "Alpha", "Beta"]);
expect(getActiveTabIndex(editor.state, 0)).toBe(1);
});
it("moves the active tab and disables moves past the edges", async () => {
const { editor } = await mountTabs(true);
await click(screen.getByRole("button", { name: "Tab actions for Alpha" }));
expect(
(screen.getByRole("menuitem", { name: "Move left" }) as HTMLButtonElement)
.disabled,
).toBe(true);
await click(screen.getByRole("menuitem", { name: "Move right" }));
expect(docLabels(editor)).toEqual(["Beta", "Alpha"]);
expect(selectedTab()).toBe("Alpha");
});
it("deletes tabs and removes the block with the last one", async () => {
const { editor } = await mountTabs(true);
await chooseTabAction("Delete tab");
expect(docLabels(editor)).toEqual(["Beta"]);
expect(selectedTab()).toBe("Beta");
await chooseTabAction("Delete tab");
expect(editor.state.doc.firstChild!.type.name).toBe("paragraph");
});
it("adds a numbered tab and opens rename with the label selected", async () => {
const { editor } = await mountTabs(true);
await click(screen.getByRole("button", { name: "Add tab" }));
expect(docLabels(editor)).toEqual(["Alpha", "Beta", "Tab 3"]);
expect(getActiveTabIndex(editor.state, 0)).toBe(2);
const input = screen.getByRole("textbox", {
name: "Tab label",
}) as HTMLInputElement;
expect(input.value).toBe("Tab 3");
expect([input.selectionStart, input.selectionEnd]).toEqual([0, 5]);
});
});
function textPos(editor: Editor, text: string) {
let found = -1;
editor.state.doc.descendants((node, pos) => {
if (found !== -1) return false;
if (node.isTextblock && node.textContent === text) found = pos + 1;
return true;
});
return found;
}
async function focusEditorAt(editor: Editor, text: string) {
// commands.focus() defers a frame and dom.focus() leaves the DOM selection displaced
await act(async () => {
editor.commands.setTextSelection(textPos(editor, text));
editor.view.focus();
});
expect(editor.view.hasFocus()).toBe(true);
}
async function moveFocus(element: HTMLElement, action: "focus" | "blur") {
await act(async () => {
element[action]();
});
}
describe("TabsView block drag", () => {
it("drags the block by its handle with a preview of its tabs, not a snapshot of the page", async () => {
const { editor } = await mountTabs(true, [GlobalDragHandle]);
const blockDom = editor.view.nodeDOM(0) as HTMLElement;
const elementsFromPoint = document.elementsFromPoint;
document.elementsFromPoint = () => [blockDom];
editor.view.posAtCoords = () => ({ pos: 1, inside: 0 });
const setDragImage = vi.fn();
const dragStart = new Event("dragstart", { bubbles: true, cancelable: true });
Object.assign(dragStart, {
clientX: 0,
clientY: 0,
ctrlKey: false,
dataTransfer: {
clearData() {},
setData() {},
setDragImage,
effectAllowed: "",
},
});
await act(async () => {
document.querySelector(".drag-handle")!.dispatchEvent(dragStart);
});
document.elementsFromPoint = elementsFromPoint;
expect(editor.state.selection.from).toBe(0);
const [image] = setDragImage.mock.calls[0] as [HTMLElement];
expect(image).not.toBe(blockDom);
expect(image.hasAttribute("hidden")).toBe(false);
expect(image.textContent).toBe("Alpha · Beta");
image.dispatchEvent(new Event("dragend", { bubbles: true }));
});
});
describe("TabsView tab reorder", () => {
// inside is the node whose DOM holds the pointer, as ProseMirror reports it
function dragOver(
editor: Editor,
target: Element,
pos: number,
inside: number,
) {
editor.view.posAtCoords = () => ({ pos, inside });
const event = new Event("dragover", { bubbles: true, cancelable: true });
Object.assign(event, { clientX: 5, clientY: 5 });
target.dispatchEvent(event);
}
const dropCursor = () =>
document.querySelector(
".prosemirror-dropcursor-block, .prosemirror-dropcursor-inline",
);
it("shows no editor drop cursor while a tab is dragged over the strip", async () => {
const { editor } = await mountTabs(true);
Object.defineProperty(editor.view.dom, "offsetParent", {
configurable: true,
get: () => document.body,
});
const panelStart = textPos(editor, "first panel") - 1;
dragOver(editor, screen.getByRole("tab", { name: "Beta" }), panelStart, 0);
expect(dropCursor()).toBeNull();
// the same drag over the content does get one, so the check above can fail
dragOver(
editor,
editor.view.domAtPos(textPos(editor, "first panel")).node as Element,
panelStart,
panelStart,
);
expect(dropCursor()).not.toBeNull();
dropCursor()!.remove();
});
});
describe("TabsView strip events", () => {
function fire(target: Element, type: string) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, {
clientX: 0,
clientY: 0,
dataTransfer: {
files: [],
types: ["text/html"],
getData: (kind: string) => (kind === "text/html" ? "<p>dropped</p>" : ""),
},
});
target.dispatchEvent(event);
return event;
}
it("leaves the strip's tab reorder drags to the strip", async () => {
const { editor } = await mountTabs(true);
editor.view.posAtCoords = () => null;
const before = editor.state.doc;
const tabElement = screen.getByRole("tab", { name: "Beta" });
expect(fire(tabElement, "dragover").defaultPrevented).toBe(false);
fire(tabElement, "drop");
expect(editor.state.doc).toBe(before);
// over the content the editor does accept the drag
const content = editor.view.domAtPos(textPos(editor, "first panel"))
.node as Element;
expect(fire(content, "dragover").defaultPrevented).toBe(true);
});
});
describe("TabsView toolbar", () => {
it("shows Copy and Delete only while the cursor is in the block in edit mode", async () => {
const { editor } = await mountTabs(true);
await focusEditorAt(editor, "first panel");
expect(screen.getByRole("button", { name: "Copy" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Delete" })).toBeTruthy();
await act(async () => {
editor.commands.setTextSelection(textPos(editor, "after"));
});
expect(screen.queryByRole("button", { name: "Copy" })).toBeNull();
await act(async () => {
editor.commands.setTextSelection(textPos(editor, "first panel"));
editor.setEditable(false);
});
expect(screen.queryByRole("button", { name: "Copy" })).toBeNull();
});
it("shows no toolbar on load before the editor is focused", async () => {
const { editor } = await mountTabs(true);
expect(editor.state.selection.$head.node(1).type.name).toBe("tabs");
expect(editor.isFocused).toBe(false);
expect(screen.queryByRole("button", { name: "Copy" })).toBeNull();
});
it("keeps the toolbar when focus moves from the editor to a tab", async () => {
const { editor } = await mountTabs(true);
await focusEditorAt(editor, "first panel");
const copyButton = screen.getByRole("button", { name: "Copy" });
await moveFocus(screen.getByRole("tab", { name: "Alpha" }), "focus");
expect(editor.view.hasFocus()).toBe(false);
expect(screen.getByRole("button", { name: "Copy" })).toBe(copyButton);
});
it("keeps the toolbar while focus is in the tab actions menu", async () => {
const { editor } = await mountTabs(true);
await focusEditorAt(editor, "first panel");
await click(screen.getByRole("button", { name: "Tab actions for Alpha" }));
await moveFocus(screen.getByRole("menuitem", { name: "Rename" }), "focus");
expect(editor.view.hasFocus()).toBe(false);
expect(screen.getByRole("button", { name: "Copy" })).toBeTruthy();
});
it("hides the toolbar once focus leaves both the editor and the block", async () => {
const { editor } = await mountTabs(true);
await focusEditorAt(editor, "first panel");
const tabButton = screen.getByRole("tab", { name: "Alpha" });
await moveFocus(tabButton, "focus");
expect(screen.getByRole("button", { name: "Copy" })).toBeTruthy();
await moveFocus(tabButton, "blur");
expect(document.activeElement).toBe(document.body);
expect(screen.queryByRole("button", { name: "Copy" })).toBeNull();
await focusEditorAt(editor, "first panel");
expect(screen.getByRole("button", { name: "Copy" })).toBeTruthy();
await moveFocus(editor.view.dom as HTMLElement, "blur");
expect(screen.queryByRole("button", { name: "Copy" })).toBeNull();
});
// a browser re-renders between the editor's blur and the button's focus, so
// the toolbar must keep focus in the editor or it unmounts before the click
it.each(["Copy", "Delete"])(
"keeps focus in the editor when %s is pressed",
async (name) => {
const { editor } = await mountTabs(true);
await focusEditorAt(editor, "first panel");
const icon = screen
.getByRole("button", { name })
.querySelector("svg")!;
expect(fireEvent.mouseDown(icon)).toBe(false);
expect(editor.view.hasFocus()).toBe(true);
},
);
it("deletes the whole block", async () => {
const { editor } = await mountTabs(true);
await focusEditorAt(editor, "first panel");
await click(screen.getByRole("button", { name: "Delete" }));
expect(editor.state.doc.firstChild!.type.name).toBe("paragraph");
});
it("copies the block as HTML", async () => {
const write = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { write },
configurable: true,
});
(globalThis as { ClipboardItem?: unknown }).ClipboardItem = class {
constructor(public items: Record<string, Blob>) {}
};
const { editor } = await mountTabs(true);
await focusEditorAt(editor, "first panel");
await click(screen.getByRole("button", { name: "Copy" }));
const [item] = write.mock.calls[0][0] as { items: Record<string, Blob> }[];
const html = await item.items["text/html"].text();
expect(html).toContain('data-type="tabs"');
expect(html).toContain("Alpha");
expect(html).toContain("Beta");
expect(await item.items["text/plain"].text()).toBe(
"Alpha\n\nfirst panel\n\nBeta\n\nsecond panel",
);
});
});

View file

@ -0,0 +1,238 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type FocusEvent,
} from "react";
import {
NodeViewContent,
NodeViewWrapper,
useEditorState,
type NodeViewProps,
} from "@tiptap/react";
import { ActionIcon, Scroller, Tabs, Tooltip } from "@mantine/core";
import { IconLayoutNavbar, IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { findParentNode } from "@tiptap/core";
import { NodeSelection } from "@tiptap/pm/state";
import { getShownTabIndex, isTextSelected } from "@docmost/editor-ext";
import TabSlot, { TAB_DRAG_TYPE } from "./tab-slot";
import TabsToolbar from "./tabs-toolbar";
import type { TabActions } from "./tabs.types";
export default function TabsView({
node,
editor,
getPos,
innerDecorations,
}: NodeViewProps) {
const { t } = useTranslation();
const instanceId = useId();
const contentId = `${instanceId}-content`;
const headerRef = useRef<HTMLDivElement>(null);
const [renamingKey, setRenamingKey] = useState<string | null>(null);
const [hasFocusWithin, setHasFocusWithin] = useState(false);
const isEditable = useEditorState({
editor,
selector: (ctx) => ctx.editor?.isEditable ?? false,
});
const isEditorFocused = useEditorState({
editor,
selector: (ctx) => ctx.editor?.isFocused ?? false,
});
// the innermost tabs block around the cursor shows the toolbar. selectors
// run on every transaction, so match the node rather than look up positions
const showToolbar = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor?.isEditable) return false;
const { selection } = ctx.editor.state;
if (selection instanceof NodeSelection) return selection.node === node;
const parent = findParentNode((n) => n.type.name === "tabs")(selection);
return parent?.node === node && !isTextSelected(ctx.editor);
},
});
const tabs = Array.from({ length: node.childCount }, (_, index) => {
const tab = node.child(index);
return {
key: tab.attrs.id || String(index),
label: tab.firstChild?.textContent ?? "",
};
});
const activeIndex = getShownTabIndex(node, innerDecorations);
const runAtTabsPos = useCallback(
(run: (tabsPos: number) => void) => {
const pos = getPos();
if (typeof pos === "number") run(pos);
},
[getPos],
);
const activate = useCallback(
(index: number) =>
runAtTabsPos((pos) => editor.commands.setActiveTab(index, pos)),
[editor, runAtTabsPos],
);
const addTab = () =>
runAtTabsPos((pos) => {
if (!editor.commands.addTab(pos)) return;
const tabsNode = editor.state.doc.nodeAt(pos);
const added = tabsNode?.lastChild;
if (tabsNode && added) {
setRenamingKey(added.attrs.id || String(tabsNode.childCount - 1));
}
});
const actions: TabActions = {
rename: (index) => setRenamingKey(tabs[index]?.key ?? null),
finishRename: (index, label) => {
setRenamingKey(null);
if (label === null) return;
runAtTabsPos((pos) => {
const next = label.trim();
const current =
editor.state.doc.nodeAt(pos)?.maybeChild(index)?.firstChild
?.textContent ?? "";
if (next && next !== current) {
editor.commands.renameTab(index, next, pos);
}
});
},
duplicate: (index) =>
runAtTabsPos((pos) => editor.commands.duplicateTab(index, pos)),
move: (from, to) =>
runAtTabsPos((pos) => editor.commands.moveTab(from, to, pos)),
remove: (index) =>
runAtTabsPos((pos) => editor.commands.deleteTab(index, pos)),
};
// pan the strip when a dragged tab nears its edges
useEffect(() => {
const scroller =
headerRef.current?.querySelector<HTMLElement>(".dm-tabs__scroller");
if (!scroller) return;
return autoScrollForElements({
element: scroller,
canScroll: ({ source }) =>
source.data.type === TAB_DRAG_TYPE &&
source.data.instanceId === instanceId,
getAllowedAxis: () => "horizontal",
});
}, [instanceId]);
// reveal the active tab inside the strip without scrolling the page
useEffect(() => {
const scroller =
headerRef.current?.querySelector<HTMLElement>(".dm-tabs__scroller");
const slot = scroller?.querySelector<HTMLElement>(
`[data-tab-index="${activeIndex}"]`,
);
if (!scroller || !slot) return;
// keep the active tab clear of the scroller's overlaid edge controls
const inset =
scroller.parentElement?.querySelector<HTMLElement>(
":scope > [data-position]",
)?.offsetWidth ?? 0;
const bounds = scroller.getBoundingClientRect();
const slotBounds = slot.getBoundingClientRect();
const visibleLeft = bounds.left + inset;
const visibleRight = bounds.right - inset;
if (slotBounds.left < visibleLeft) {
scroller.scrollBy({
left: slotBounds.left - visibleLeft,
behavior: "smooth",
});
} else if (slotBounds.right > visibleRight) {
scroller.scrollBy({
left: slotBounds.right - visibleRight,
behavior: "smooth",
});
}
}, [activeIndex, tabs.length]);
return (
<NodeViewWrapper
data-type="tabs"
className="dm-tabs"
onFocus={() => setHasFocusWithin(true)}
onBlur={(event: FocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setHasFocusWithin(false);
}
}}
>
{showToolbar && (isEditorFocused || hasFocusWithin) && (
<TabsToolbar editor={editor} getPos={getPos} />
)}
{/* the drag handle shows this instead of a snapshot of the block */}
<div data-drag-preview hidden className="dm-tabs-drag-preview">
<IconLayoutNavbar size={16} />
<span>{tabs.map((tab) => tab.label || t("Untitled")).join(" · ")}</span>
</div>
<div
ref={headerRef}
className="dm-tabs__header"
contentEditable={false}
data-tab-strip
>
<Tabs
unstyled
className="dm-tabs__tabs"
value={String(activeIndex)}
onChange={(value) => {
if (value !== null) activate(Number(value));
}}
>
<Scroller
draggable={false}
classNames={{ container: "dm-tabs__scroller" }}
>
<Tabs.List className="dm-tabs__list">
{tabs.map((tab, index) => (
<TabSlot
key={tab.key}
index={index}
tabCount={tabs.length}
label={tab.label}
isActive={index === activeIndex}
isEditable={isEditable}
isRenaming={renamingKey === tab.key}
instanceId={instanceId}
contentId={contentId}
actions={actions}
/>
))}
</Tabs.List>
</Scroller>
</Tabs>
{isEditable && (
<div className="dm-tabs__actions">
<Tooltip label={t("Add tab")}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Add tab")}
onClick={addTab}
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
</div>
)}
</div>
<NodeViewContent id={contentId} className="dm-tabs__content" />
</NodeViewWrapper>
);
}

View file

@ -0,0 +1,7 @@
export type TabActions = {
rename: (index: number) => void;
finishRename: (index: number, label: string | null) => void;
duplicate: (index: number) => void;
move: (from: number, to: number) => void;
remove: (index: number) => void;
};

View file

@ -0,0 +1,296 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { Editor, Node, type JSONContent } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { NodeSelection } from "@tiptap/pm/state";
import { Tab, TabLabel, TabPanel, Tabs } from "@docmost/editor-ext";
import GlobalDragHandle from "./drag-handle";
let editor: Editor | null = null;
let element: HTMLElement | null = null;
beforeAll(() => {
// jsdom has no layout, and scrolling the selection into view measures ranges
Range.prototype.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0);
Range.prototype.getClientRects = () =>
({
length: 0,
item: () => null,
[Symbol.iterator]: function* () {},
}) as unknown as DOMRectList;
});
afterEach(() => {
editor?.destroy();
editor = null;
element?.remove();
element = null;
});
const paragraph = (text: string): JSONContent => ({
type: "paragraph",
content: [{ type: "text", text }],
});
// stand-ins for custom nodes the handle drags whole: an atom like a synced
// block reference, and a container like a synced block
const Card = Node.create({
name: "card",
group: "block",
atom: true,
parseHTML: () => [{ tag: 'div[data-type="card"]' }],
renderHTML: () => ["div", { "data-type": "card" }],
});
const Box = Node.create({
name: "box",
group: "block",
content: "block+",
parseHTML: () => [{ tag: 'div[data-type="box"]' }],
renderHTML: () => ["div", { "data-type": "box" }, 0],
});
// X at 0 and Y at 3, then a one-tab block and a tail paragraph
function setup(
panel: JSONContent[] = [paragraph("panel")],
after: JSONContent[] = [],
) {
element = document.createElement("div");
document.body.appendChild(element);
editor = new Editor({
element,
extensions: [
StarterKit,
Tabs,
Tab,
TabLabel,
TabPanel,
Card,
Box,
GlobalDragHandle.configure({
customNodes: ["box", "tabPanel"],
atomNodes: ["card"],
}),
],
content: {
type: "doc",
content: [
paragraph("X"),
paragraph("Y"),
{
type: "tabs",
content: [
{
type: "tab",
attrs: { id: "a" },
content: [
{ type: "tabLabel", content: [{ type: "text", text: "Alpha" }] },
{ type: "tabPanel", content: panel },
],
},
],
},
paragraph("tail"),
...after,
],
},
});
return editor;
}
function panelStart(ed: Editor) {
let found = -1;
ed.state.doc.descendants((node, pos) => {
if (found === -1 && node.type.name === "tabPanel") found = pos + 1;
return found === -1;
});
return found;
}
function panelChildren(ed: Editor) {
const panel = ed.state.doc.nodeAt(panelStart(ed) - 1)!;
const out: string[] = [];
panel.forEach((node) => out.push(node.textContent || node.type.name));
return out;
}
function childPos(ed: Editor, typeName: string) {
let found = -1;
ed.state.doc.descendants((node, pos) => {
if (found === -1 && node.type.name === typeName) found = pos;
return found === -1;
});
return found;
}
const texts = (ed: Editor) => {
const out: string[] = [];
ed.state.doc.descendants((node) => {
if (node.isTextblock) out.push(node.textContent);
return true;
});
return out;
};
const dragData = () => ({
files: [],
clearData() {},
setData() {},
setDragImage() {},
effectAllowed: "",
});
function dragEvent(type: string, dataTransfer: unknown) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, { clientX: 0, clientY: 0, ctrlKey: false, dataTransfer });
return event;
}
function startHandleDrag(ed: Editor, blockPos: number) {
const handle = element!.querySelector<HTMLElement>(".drag-handle")!;
const blockDom = ed.view.nodeDOM(blockPos) as HTMLElement;
const elementsFromPoint = document.elementsFromPoint;
document.elementsFromPoint = () => [blockDom];
ed.view.posAtCoords = () => ({ pos: blockPos + 1, inside: blockPos });
handle.dispatchEvent(dragEvent("dragstart", dragData()));
document.elementsFromPoint = elementsFromPoint;
return handle;
}
function startNativeDrag(ed: Editor, blockPos: number) {
ed.view.dispatch(
ed.state.tr.setSelection(NodeSelection.create(ed.state.doc, blockPos)),
);
ed.view.posAtCoords = () => ({ pos: blockPos + 1, inside: blockPos });
(ed.view.nodeDOM(blockPos) as HTMLElement).dispatchEvent(
dragEvent("dragstart", dragData()),
);
}
function dropAt(ed: Editor, pos: number, dataTransfer?: unknown) {
ed.view.posAtCoords = () => ({ pos, inside: -1 });
const drop = new Event("drop", { bubbles: true, cancelable: true });
Object.assign(drop, { clientX: 10, clientY: 10, dataTransfer });
ed.view.dom.dispatchEvent(drop);
return drop;
}
const endDrag = (source: HTMLElement) =>
source.dispatchEvent(new Event("dragend", { bubbles: true }));
describe("drag handle drops into a tab panel", () => {
it("leaves drops that did not start in the editor to ProseMirror", () => {
const ed = setup();
ed.view.dispatch(
ed.state.tr.setSelection(NodeSelection.create(ed.state.doc, 0)),
);
const before = ed.state.doc;
const drop = dropAt(ed, panelStart(ed));
expect(ed.state.doc).toBe(before);
expect(drop.defaultPrevented).toBe(false);
});
it("moves a block dragged by its handle into the panel", () => {
const ed = setup();
const handle = startHandleDrag(ed, 0);
const drop = dropAt(ed, panelStart(ed), dragData());
endDrag(handle);
expect(texts(ed)).toEqual(["Y", "Alpha", "X", "panel", "tail"]);
expect(drop.defaultPrevented).toBe(true);
});
it("leaves a file dropped in the panel alone after a cancelled handle drag", () => {
const ed = setup();
endDrag(startHandleDrag(ed, 0));
const before = ed.state.doc;
const drop = dropAt(ed, panelStart(ed), {
files: [new File(["x"], "photo.png", { type: "image/png" })],
types: ["Files"],
getData: () => "",
});
expect(ed.state.doc).toBe(before);
expect(drop.defaultPrevented).toBe(false);
});
it.each([
["after the handle drag ended", true],
["when the handle drag never reported its end", false],
])(
"moves a natively dragged block, not the last handle-dragged one, %s",
(_, handleDragEnded) => {
const ed = setup();
const handle = startHandleDrag(ed, 0);
if (handleDragEnded) endDrag(handle);
startNativeDrag(ed, 3);
const drop = dropAt(ed, panelStart(ed), dragData());
endDrag(ed.view.dom);
expect(texts(ed)).toEqual(["X", "Alpha", "Y", "panel", "tail"]);
expect(drop.defaultPrevented).toBe(true);
},
);
it("moves the node ProseMirror reports as dragged over the selected one", () => {
const ed = setup();
endDrag(startHandleDrag(ed, 0));
expect(ed.state.selection.from).toBe(0);
const dragged = NodeSelection.create(ed.state.doc, 3);
ed.view.dragging = {
slice: dragged.content(),
move: true,
node: dragged,
} as typeof ed.view.dragging;
const drop = dropAt(ed, panelStart(ed), dragData());
expect(texts(ed)).toEqual(["X", "Alpha", "Y", "panel", "tail"]);
expect(drop.defaultPrevented).toBe(true);
});
it.each([
["an atom", "card", [paragraph("first"), { type: "card" }, paragraph("last")]],
[
"a container",
"box",
[paragraph("first"), { type: "box", content: [paragraph("boxed")] }, paragraph("last")],
],
])(
"moves %s dragged by its handle within the panel, not the whole panel",
(_, typeName, panel) => {
const ed = setup(panel as JSONContent[]);
const handle = startHandleDrag(ed, childPos(ed, typeName));
expect((ed.state.selection as NodeSelection).node.type.name).toBe(typeName);
const panelEnd = panelStart(ed) + ed.state.doc.nodeAt(panelStart(ed) - 1)!.content.size;
const drop = dropAt(ed, panelEnd, dragData());
endDrag(handle);
const moved = typeName === "box" ? "boxed" : "card";
expect(panelChildren(ed)).toEqual(["first", "last", moved]);
expect(drop.defaultPrevented).toBe(true);
},
);
it("refuses to move a tabs block into a tab", () => {
const ed = setup(undefined, [
{
type: "tabs",
content: [
{
type: "tab",
attrs: { id: "x" },
content: [
{ type: "tabLabel", content: [{ type: "text", text: "X" }] },
{ type: "tabPanel", content: [paragraph("x body")] },
],
},
],
},
paragraph("end"),
]);
const secondTabsPos =
ed.state.doc.content.size -
ed.state.doc.lastChild!.nodeSize -
ed.state.doc.child(ed.state.doc.childCount - 2).nodeSize;
const before = ed.state.doc;
const handle = startHandleDrag(ed, secondTabsPos);
expect((ed.state.selection as NodeSelection).node.type.name).toBe("tabs");
const drop = dropAt(ed, panelStart(ed), dragData());
endDrag(handle);
expect(ed.state.doc).toBe(before);
expect(drop.defaultPrevented).toBe(true);
});
});

View file

@ -3,10 +3,13 @@ import {
NodeSelection,
Plugin,
PluginKey,
Selection,
TextSelection,
} from "@tiptap/pm/state";
import { Fragment, Slice, Node } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { Fragment, Slice } from "@tiptap/pm/model";
import type { Node, ResolvedPos } from "@tiptap/pm/model";
import type { EditorView } from "@tiptap/pm/view";
import { hasTabsBlock } from "@docmost/editor-ext";
export interface GlobalDragHandleOptions {
/**
@ -150,9 +153,109 @@ function isCustomNodeDOM(
return false;
}
function getDirectTarget($pos: ResolvedPos, ancestorDepth: number) {
const ancestor = $pos.node(ancestorDepth);
if (ancestor.childCount === 0) return null;
if ($pos.depth > ancestorDepth) {
const childDepth = ancestorDepth + 1;
return {
pos: $pos.before(childDepth),
node: $pos.node(childDepth),
};
}
const index = $pos.index(ancestorDepth);
const childIndex = Math.min(index, ancestor.childCount - 1);
if (childIndex < 0) return null;
return {
pos: $pos.posAtIndex(childIndex, ancestorDepth),
node: ancestor.child(childIndex),
};
}
type DragSource = { from: number; to: number; node: Node };
function resolveMoveSource(
view: EditorView,
hint: DragSource | null,
): DragSource | null {
const { state } = view;
if (hint) {
const current = state.doc.nodeAt(hint.from);
if (current?.eq(hint.node)) return hint;
}
if (state.selection instanceof NodeSelection) {
return {
from: state.selection.from,
to: state.selection.from + state.selection.node.nodeSize,
node: state.selection.node,
};
}
return null;
}
function moveNodeWithinTabPanel(
view: EditorView,
dropPos: number,
source: DragSource,
): boolean {
const { state } = view;
const { from: sourceFrom, to: sourceTo, node: sourceNode } = source;
const $drop = state.doc.resolve(dropPos);
const panelDepth = $drop.depth;
if (dropPos > sourceFrom && dropPos < sourceTo) return false;
let insertPos: number;
const target = getDirectTarget($drop, panelDepth);
if (!target) {
insertPos = $drop.start(panelDepth);
} else {
const targetStart = target.pos;
const targetEnd = targetStart + target.node.nodeSize;
const midpoint = (targetStart + targetEnd) / 2;
insertPos = dropPos >= midpoint ? targetEnd : targetStart;
}
// Already in place.
if (insertPos === sourceFrom || insertPos === sourceTo) return false;
const tr = state.tr;
tr.delete(sourceFrom, sourceTo);
const insertAt = tr.mapping.map(insertPos, -1);
const $insert = tr.doc.resolve(insertAt);
const index = $insert.index();
if (!$insert.parent.canReplace(index, index, Fragment.from(sourceNode))) {
return false;
}
tr.insert(insertAt, sourceNode);
const $placed = tr.doc.resolve(
Math.min(insertAt, tr.doc.content.size),
);
tr.setSelection(Selection.near($placed));
tr.scrollIntoView();
view.dispatch(tr);
return true;
}
const NON_PROMOTABLE_PARENTS = new Set(["tabs", "tab", "tabPanel"]);
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
if ($pos.depth > 1 && !NON_PROMOTABLE_PARENTS.has($pos.node($pos.depth).type.name))
return $pos.before($pos.depth);
return pos;
}
@ -160,6 +263,8 @@ export function DragHandlePlugin(
options: GlobalDragHandleOptions & { pluginKey: string },
) {
let listType = "";
let dragSource: DragSource | null = null;
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
@ -176,6 +281,16 @@ export function DragHandlePlugin(
if (!(node instanceof Element)) return;
// ProseMirror never sees the end of a drag that starts on the handle
document.addEventListener(
"dragend",
() => {
dragSource = null;
view.dragging = null;
},
{ once: true },
);
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view);
@ -222,17 +337,21 @@ export function DragHandlePlugin(
// The drag landed on a custom-node container (transclusion etc.).
// Walk up to the matching node so the drag moves the whole
// container, not whatever inner element the click landed on.
const customTypes = new Set([
...options.customNodes,
...options.atomNodes,
]);
for (let d = $sel.depth; d > 0; d--) {
if (customTypes.has($sel.node(d).type.name)) {
selection = NodeSelection.create(
view.state.doc,
$sel.before(d),
);
break;
// tabs structure is listed only so panel paragraphs get a handle
const customTypes = new Set(
[...options.customNodes, ...options.atomNodes].filter(
(name) => !NON_PROMOTABLE_PARENTS.has(name),
),
);
if (!customTypes.has((selection as NodeSelection).node.type.name)) {
for (let d = $sel.depth; d > 0; d--) {
if (customTypes.has($sel.node(d).type.name)) {
selection = NodeSelection.create(
view.state.doc,
$sel.before(d),
);
break;
}
}
}
} else {
@ -262,6 +381,14 @@ export function DragHandlePlugin(
}
}
view.dispatch(view.state.tr.setSelection(selection));
dragSource =
view.state.selection instanceof NodeSelection
? {
from: view.state.selection.from,
to: view.state.selection.to,
node: view.state.selection.node,
}
: null;
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (
@ -401,6 +528,7 @@ export function DragHandlePlugin(
);
const notDragging = node?.closest(".not-draggable");
const notDraggingMatch = node?.matches(".not-draggable-match");
const excludedTagList = options.excludedTags
.concat(["ol", "ul"])
.join(", ");
@ -408,7 +536,8 @@ export function DragHandlePlugin(
if (
!(node instanceof Element) ||
node.matches(excludedTagList) ||
notDragging
notDragging ||
notDraggingMatch
) {
hideDragHandle();
return;
@ -474,6 +603,7 @@ export function DragHandlePlugin(
// dragging class is used for CSS
dragstart: (view) => {
view.dom.classList.add("dragging");
dragSource = null;
},
drop: (view, event) => {
view.dom.classList.remove("dragging");
@ -496,6 +626,30 @@ export function DragHandlePlugin(
const isDroppedInsideList =
resolvedPos.parent.type.name === "listItem";
const isDroppedInsideTabPanel =
resolvedPos.parent.type.name === "tabPanel";
if (isDroppedInsideTabPanel && view.dragging) {
// view.dragging.node is set at runtime but missing from its type
const { node: dragged } = view.dragging as { node?: NodeSelection };
const source = resolveMoveSource(
view,
dragged
? { from: dragged.from, to: dragged.to, node: dragged.node }
: dragSource,
);
// tabs don't nest, so a dragged tabs block stays where it was
const moved =
source && !hasTabsBlock(Fragment.from(source.node))
? moveNodeWithinTabPanel(view, dropPos.pos, source)
: false;
// even when not moved swallow the drop so ProseMirror doesn't insert a copy.
event.preventDefault();
view.dragging = null;
dragSource = null;
return moved;
}
// 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 &&
@ -513,6 +667,7 @@ export function DragHandlePlugin(
},
dragend: (view) => {
view.dom.classList.remove("dragging");
dragSource = null;
},
},
},
@ -526,6 +681,7 @@ const GlobalDragHandle = Extension.create({
return {
dragHandleWidth: 20,
scrollThreshold: 100,
dragHandleSelector: undefined,
excludedTags: [],
customNodes: [],
atomNodes: [],

View file

@ -1,4 +1,4 @@
import { markInputRule } from "@tiptap/core";
import { markInputRule, type Editor } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { TiptapDocument } from "@/features/editor/extensions/document";
import { Code } from "@tiptap/extension-code";
@ -67,6 +67,10 @@ import {
Footnotes,
Footnote,
FootnoteReference,
Tabs,
Tab,
TabLabel,
TabPanel,
} from "@docmost/editor-ext";
import {
randomElement,
@ -95,6 +99,7 @@ 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 TabsView from "@/features/editor/components/tabs/tabs-view.tsx";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx";
@ -242,7 +247,7 @@ export const mainExtensions = [
Typography,
TrailingNode,
GlobalDragHandle.configure({
customNodes: ["transclusionSource", "transclusionReference"],
customNodes: ["transclusionSource", "transclusionReference", "tabPanel"],
atomNodes: ["base"],
}),
TextStyle,
@ -297,6 +302,12 @@ export const mainExtensions = [
Details,
DetailsSummary,
DetailsContent,
Tabs.configure({
view: TabsView,
}),
Tab,
TabLabel,
TabPanel,
Youtube.configure({
addPasteHandler: false,
controls: true,
@ -448,9 +459,10 @@ const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([
const TemplateSlashCommand = Command.configure({
suggestion: {
items: ({ query }: { query: string }) =>
items: ({ query, editor }: { query: string; editor: Editor }) =>
getSuggestionItems({
query,
editor,
excludeItems: TEMPLATE_EXCLUDED_SLASH_ITEMS,
}),
render: renderItems,

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import { Editor } from "@tiptap/react";
import { useCallback, useEffect, useState } from "react";
import { showTabAtElement } from "@/features/editor/utils";
function waitForState(checkFn: () => boolean): Promise<void> {
return new Promise((resolve) => {
@ -48,6 +49,7 @@ export const useEditorScroll = ({
}
const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`);
if (dom) {
showTabAtElement(dom);
dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
resolve(true);
} else {

View file

@ -29,7 +29,7 @@ import {
collabExtensions,
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
@ -41,6 +41,7 @@ import {
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
activeCommentIdAtom,
commentPanelTabAtom,
showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
@ -188,6 +189,7 @@ function CollabPageEditor({
const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const setCommentPanelTab = useSetAtom(commentPanelTabAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
@ -386,6 +388,7 @@ function CollabPageEditor({
}
setActiveCommentId(commentId);
setCommentPanelTab("open");
setAsideState({ tab: "comments", isAsideOpen: true });
//wait if aside is closed
@ -409,7 +412,6 @@ function CollabPageEditor({
useEffect(() => {
setActiveCommentId(null);
setShowCommentPopup(false);
setAsideState({ tab: "", isAsideOpen: false });
setLightboxRequest(null);
}, [pageId]);

View file

@ -143,7 +143,7 @@ export default function ReadonlyPageEditor({
return true;
},
}
: undefined
: {}
}
onCreate={({ editor }) => {
if (editor) {

View file

@ -17,5 +17,6 @@
@import "./indent.css";
@import "./columns.css";
@import "./status.css";
@import "./tabs.css";
@import "./base-embed.css";
@import "./footnotes.css";

View file

@ -0,0 +1,204 @@
.ProseMirror {
.dm-tabs {
position: relative;
margin: 0.5em 0;
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-md);
}
.dm-tabs__toolbar {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
z-index: 99;
transform: translateX(-50%);
}
.dm-tabs__header {
display: flex;
align-items: stretch;
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
user-select: none;
}
.dm-tabs__tabs {
flex: 1;
min-width: 0;
}
.dm-tabs__list {
display: flex;
gap: 4px;
padding: 0 8px;
}
.dm-tabs__slot {
position: relative;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
padding: 0 8px;
}
.dm-tabs__slot[data-active]::before {
content: "";
position: absolute;
inset-inline: 0;
bottom: 0;
height: 2px;
background: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-2));
}
.dm-tabs__slot[data-dragging] {
opacity: 0.4;
}
.dm-tabs__slot[data-closest-edge]::after {
content: "";
position: absolute;
top: 8px;
bottom: 8px;
width: 2px;
border-radius: 1px;
background: var(--mantine-primary-color-filled);
pointer-events: none;
}
.dm-tabs__slot[data-closest-edge="left"]::after {
inset-inline-start: -3px;
}
.dm-tabs__slot[data-closest-edge="right"]::after {
inset-inline-end: -3px;
}
.dm-tabs__tab {
padding: 12px 0;
border: 0;
background: none;
font: inherit;
white-space: nowrap;
cursor: pointer;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.dm-tabs__tab:hover,
.dm-tabs__tab[data-active] {
color: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-0));
}
.dm-tabs__tab:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
border-radius: var(--mantine-radius-xs);
}
/* the mirrored text sizes the input; size="1" stops its default 20-character width */
.dm-tabs__rename {
display: inline-grid;
min-width: 4ch;
margin: 8px 0;
padding: 2px 6px;
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
.dm-tabs__rename::after,
.dm-tabs__rename input {
grid-area: 1 / 1;
font: inherit;
}
.dm-tabs__rename::after {
content: attr(data-value) " ";
white-space: pre;
visibility: hidden;
}
.dm-tabs__rename input {
width: 100%;
padding: 0;
border: none;
outline: none;
background: none;
color: inherit;
}
.dm-tabs__actions {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-inline-start: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.dm-tabs__content {
padding: 8px 16px;
}
[data-type="tabLabel"] {
display: none;
}
[data-type="tabPanel"] {
outline: none;
}
}
/* the drag handle clones the preview into the body, outside the editor */
.dm-tabs-drag-preview {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 260px;
padding: 4px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18);
}
.dm-tabs-drag-preview[hidden] {
display: none;
}
.dm-tabs-drag-preview svg {
flex-shrink: 0;
}
.dm-tabs-drag-preview span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media print {
.ProseMirror {
.dm-tabs__header,
.dm-tabs__toolbar {
display: none;
}
.dm-tab-hidden {
display: block;
content-visibility: visible;
}
[data-type="tabLabel"] {
display: block;
margin: 0.75em 0 0.25em;
font-weight: 600;
}
}
}

View file

@ -0,0 +1,43 @@
import type { Editor } from "@tiptap/core";
import { DOMSerializer, type Node as PMNode } from "@tiptap/pm/model";
export function copyNodeToClipboard(
editor: Editor,
node: PMNode,
): Promise<void> {
const serializer = DOMSerializer.fromSchema(editor.state.schema);
const wrapper = document.createElement("div");
wrapper.appendChild(serializer.serializeNode(node));
const copyWithExecCommand = () => {
wrapper.style.position = "fixed";
wrapper.style.left = "-9999px";
document.body.appendChild(wrapper);
const range = document.createRange();
range.selectNodeContents(wrapper);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
document.execCommand("copy");
selection?.removeAllRanges();
document.body.removeChild(wrapper);
editor.view.focus();
};
if (!navigator.clipboard?.write) {
copyWithExecCommand();
return Promise.resolve();
}
return navigator.clipboard
.write([
new ClipboardItem({
"text/html": new Blob([wrapper.innerHTML], { type: "text/html" }),
"text/plain": new Blob(
[node.textBetween(0, node.content.size, "\n\n")],
{ type: "text/plain" },
),
}),
])
.catch(copyWithExecCommand);
}

View file

@ -1,2 +1,4 @@
export * from "./is-custom-node-selected";
export * from "./is-text-selected";
export * from "./copy-node-to-clipboard";
export * from "./show-tab-at-element";

View file

@ -0,0 +1,6 @@
// opens the hidden tab around an element the way find in page does, so it can be scrolled to
export function showTabAtElement(element: Element) {
element
.closest(".dm-tab-hidden")
?.dispatchEvent(new Event("beforematch", { bubbles: true }));
}

View file

@ -74,13 +74,16 @@ export function NotificationItem({
}
};
const commentParam = notification.commentId
? `?commentId=${notification.commentId}`
: "";
const pageUrl =
notification.page && notification.space
? buildPageUrl(
notification.space.slug,
notification.page.slugId,
notification.page.title,
)
) + commentParam
: undefined;
const isSiemDestination = notification.type.startsWith("siem_destination.");

View file

@ -0,0 +1,93 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { Provider, createStore, type PrimitiveAtom } from "jotai";
import { Editor, type JSONContent } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import {
Tab,
TabLabel,
TabPanel,
Tabs,
getActiveTabIndex,
} from "@docmost/editor-ext";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import DocsToc from "./docs-toc";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
let editor: Editor | null = null;
beforeAll(() => {
globalThis.IntersectionObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof IntersectionObserver;
});
afterEach(() => {
vi.restoreAllMocks();
editor?.destroy();
editor = null;
});
const tab = (id: string, label: string, block: JSONContent) => ({
type: "tab",
attrs: { id },
content: [
{ type: "tabLabel", content: [{ type: "text", text: label }] },
{ type: "tabPanel", content: [block] },
],
});
describe("DocsToc", () => {
it("opens the tab holding a heading before scrolling to it", async () => {
const element = document.createElement("div");
document.body.appendChild(element);
editor = new Editor({
element,
editable: false,
extensions: [StarterKit, Tabs, Tab, TabLabel, TabPanel],
content: {
type: "doc",
content: [
{
type: "tabs",
content: [
tab("a", "Alpha", {
type: "paragraph",
content: [{ type: "text", text: "first" }],
}),
tab("b", "Beta", {
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Hidden heading" }],
}),
],
},
],
},
});
let activeWhenScrolled = -1;
vi.spyOn(window, "scrollTo").mockImplementation(() => {
activeWhenScrolled = getActiveTabIndex(editor!.state, 0);
});
const store = createStore();
// without strictNullChecks the atom<Editor | null> type reads as read-only
store.set(readOnlyEditorAtom as PrimitiveAtom<Editor | null>, editor);
render(
<Provider store={store}>
<DocsToc />
</Provider>,
);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Hidden heading" }));
});
expect(activeWhenScrolled).toBe(1);
expect(getActiveTabIndex(editor.state, 0)).toBe(1);
});
});

View file

@ -67,6 +67,7 @@ export default function DocsToc() {
const handleScrollToHeading = (position: number) => {
if (!editor || editor.isDestroyed) return;
editor.commands.showTabAt(position);
const { view } = editor;
const { node } = view.domAtPos(position);

View file

@ -813,7 +813,7 @@
color: var(--docs-accent);
}
/* Internal page links (mentions, subpages lists) follow the accent like any
/* Internal page links (mentions) follow the accent like any
* other link; the app skin pins them to ink with !important, hence the
* counter-!important, and the underline tint softens to match. */
.root :global(.ProseMirror) a[class*="pageMentionLink"] {

View file

@ -1,13 +0,0 @@
import { useMemo } from "react";
import { useAtomValue } from "jotai";
import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom";
import { findSubpagesInTree } from "@/features/share/utils";
export function useSharedPageSubpages(pageId: string | undefined) {
const treeData = useAtomValue(sharedTreeDataAtom);
return useMemo(
() => findSubpagesInTree(treeData, pageId),
[treeData, pageId],
);
}

View file

@ -9,6 +9,7 @@ export type SharedPageTreeNode = {
position: string;
spaceId: string;
parentPageId: string;
updatedAt?: Date;
hasChildren: boolean;
children: SharedPageTreeNode[];
label: string;
@ -32,6 +33,7 @@ export function buildSharedPageTree(
hasChildren: false,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
updatedAt: page.updatedAt,
label: page.title || "untitled",
value: page.id,
children: [],

View file

@ -86,12 +86,12 @@ function redirectToLogin() {
// the oauth consent page redirects to login itself, preserving its query string
"/oauth/consent",
];
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
const redirectTo = window.location.pathname;
if (redirectTo === APP_ROUTE.HOME) {
const { pathname, search, hash } = window.location;
if (!exemptPaths.some((path) => pathname.startsWith(path))) {
if (pathname === APP_ROUTE.HOME) {
window.location.href = APP_ROUTE.AUTH.LOGIN;
} else {
const params = new URLSearchParams({ redirect: redirectTo });
const params = new URLSearchParams({ redirect: pathname + search + hash });
window.location.href = `${APP_ROUTE.AUTH.LOGIN}?${params.toString()}`;
}
}

View file

@ -9,6 +9,31 @@ export function timeAgo(date: Date) {
});
}
const RELATIVE_TIME_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 365 * 86400],
["month", 30 * 86400],
["week", 7 * 86400],
["day", 86400],
["hour", 3600],
["minute", 60],
];
export function shortTimeAgo(date: Date) {
const seconds = (Date.now() - new Date(date).getTime()) / 1000;
const unit = RELATIVE_TIME_UNITS.find(([, unitSeconds]) => seconds >= unitSeconds);
if (!unit) {
return new Intl.RelativeTimeFormat(i18n.language, {
numeric: "auto",
}).format(0, "second");
}
const [name, unitSeconds] = unit;
return new Intl.RelativeTimeFormat(i18n.language, {
style: "narrow",
}).format(-Math.floor(seconds / unitSeconds), name);
}
export function formattedDate(date: Date) {
const locale = getDateFnsLocale();
if (isToday(date)) {

View file

@ -6,8 +6,11 @@ import HistoryModal from "@/features/page-history/components/history-modal";
import PageHeader from "@/features/page/components/header/page-header.tsx";
import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useCommentDeepLink } from "@/features/comment/hooks/use-comment-deep-link";
import { useTranslation } from "react-i18next";
import React from "react";
import React, { useEffect } from "react";
import { useSetAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
import { Button } from "@mantine/core";
@ -57,6 +60,14 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
error,
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const setAsideState = useSetAtom(asideStateAtom);
// Keep above useCommentDeepLink so a deep link can reopen the aside.
useEffect(() => {
setAsideState({ tab: "", isAsideOpen: false });
}, [page?.id, setAsideState]);
useCommentDeepLink(page?.id);
const hasBases = useHasFeature(Feature.BASES);
const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);

View file

@ -6,6 +6,10 @@ export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@docmost/editor-ext': path.resolve(
__dirname,
'../../packages/editor-ext/src/index.ts',
),
'@': path.resolve(__dirname, './src'),
},
},

View file

@ -49,6 +49,10 @@ import {
Footnotes,
Footnote,
FootnoteReference,
Tabs,
Tab,
TabLabel,
TabPanel,
} from '@docmost/editor-ext';
import {
extensions as coreExtensions,
@ -102,6 +106,10 @@ export const tiptapExtensions = [
Details,
DetailsContent,
DetailsSummary,
Tabs,
Tab,
TabLabel,
TabPanel,
CustomTable,
TableCell,
TableRow,

View file

@ -48,7 +48,7 @@ export class CommentNotificationService {
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const { actor, pageTitle, commentUrl } = context;
const notifiedUserIds = new Set<string>();
notifiedUserIds.add(actorId);
@ -92,7 +92,7 @@ export class CommentNotificationService {
userId,
notification.id,
`${actor.name} mentioned you in a comment`,
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
CommentMentionEmail({ actorName: actor.name, pageTitle, commentUrl }),
NotificationType.COMMENT_USER_MENTION,
);
@ -118,7 +118,7 @@ export class CommentNotificationService {
recipientId,
notification.id,
`${actor.name} commented on ${pageTitle}`,
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
CommentCreateEmail({ actorName: actor.name, pageTitle, commentUrl }),
NotificationType.COMMENT_CREATED,
);
}
@ -145,7 +145,7 @@ export class CommentNotificationService {
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const { actor, pageTitle, commentUrl } = context;
const roles = await this.spaceMemberRepo.getUserSpaceRoles(
commentCreatorId,
@ -183,7 +183,7 @@ export class CommentNotificationService {
commentCreatorId,
notification.id,
subject,
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
CommentResolvedEmail({ actorName: actor.name, pageTitle, commentUrl }),
NotificationType.COMMENT_RESOLVED,
);
}
@ -235,7 +235,8 @@ export class CommentNotificationService {
}
const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
const commentUrl = `${pageUrl}?commentId=${commentId}`;
return { actor, pageTitle: getPageTitle(page.title), pageUrl };
return { actor, pageTitle: getPageTitle(page.title), commentUrl };
}
}

View file

@ -312,6 +312,7 @@ export class PageService {
'spaceId',
'creatorId',
'isBase',
'updatedAt',
'deletedAt',
])
.select((eb) => this.pageRepo.withHasChildren(eb))

View file

@ -607,6 +607,7 @@ export class PageRepo {
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
'pages.updatedAt',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
@ -626,6 +627,7 @@ export class PageRepo {
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
'p.updatedAt',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
@ -644,6 +646,7 @@ export class PageRepo {
'parentPageId',
'spaceId',
'workspaceId',
'updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
// Filter out restricted pages from the result
@ -671,6 +674,7 @@ export class PageRepo {
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
'pages.updatedAt',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.where('pages.spaceId', '=', spaceId)
@ -690,6 +694,7 @@ export class PageRepo {
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
'p.updatedAt',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.where('p.deletedAt', 'is', null)
@ -706,6 +711,7 @@ export class PageRepo {
'parentPageId',
'spaceId',
'workspaceId',
'updatedAt',
])
.where('isRestricted', '=', false)
.execute();

@ -1 +1 @@
Subproject commit 8190e2182cbbcc5e291217a13147fcd246ede3fe
Subproject commit 71625f8e246a7680991302fede87ba999c9db9da

View file

@ -51,4 +51,19 @@ describe('htmlToMarkdown', () => {
'</details>',
]);
});
it('exports each tab as a level 4 heading followed by its content', () => {
const markdown = htmlToMarkdown(
'<div data-type="tabs">' +
'<div data-type="tab" data-tab-id="a"><div data-type="tabLabel">Windows</div>' +
'<div data-type="tabPanel"><p>Run setup.exe</p></div></div>' +
'<div data-type="tab" data-tab-id="b"><div data-type="tabLabel">Linux *beta*</div>' +
'<div data-type="tabPanel"><p>Run setup.sh</p></div></div>' +
'</div>',
);
expect(markdown.trim()).toBe(
'#### Windows\n\nRun setup.exe\n\n#### Linux \\*beta\\*\n\nRun setup.sh',
);
});
});

View file

@ -6,13 +6,13 @@ import { EmailButton, MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
commentUrl: string;
}
export const CommentCreateEmail = ({
actorName,
pageTitle,
pageUrl,
commentUrl,
}: Props) => {
return (
<MailBody>
@ -23,7 +23,7 @@ export const CommentCreateEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<EmailButton href={commentUrl}>View</EmailButton>
</MailBody>
);
};

View file

@ -6,13 +6,13 @@ import { EmailButton, MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
commentUrl: string;
}
export const CommentMentionEmail = ({
actorName,
pageTitle,
pageUrl,
commentUrl,
}: Props) => {
return (
<MailBody>
@ -23,7 +23,7 @@ export const CommentMentionEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<EmailButton href={commentUrl}>View</EmailButton>
</MailBody>
);
};

View file

@ -6,13 +6,13 @@ import { EmailButton, MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
commentUrl: string;
}
export const CommentResolvedEmail = ({
actorName,
pageTitle,
pageUrl,
commentUrl,
}: Props) => {
return (
<MailBody>
@ -23,7 +23,7 @@ export const CommentResolvedEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<EmailButton href={commentUrl}>View</EmailButton>
</MailBody>
);
};

View file

@ -30,6 +30,7 @@ export * from "./lib/shared-storage";
export * from "./lib/recreate-transform";
export * from "./lib/columns";
export * from "./lib/status";
export * from "./lib/tabs";
export * from "./lib/pdf";
export * from "./lib/page-break";
export * from "./lib/footnotes";

View file

@ -24,6 +24,7 @@ export function htmlToMarkdown(html: string): string {
TurndownPluginGfm.tables,
TurndownPluginGfm.strikethrough,
TurndownPluginGfm.highlightedCodeBlock,
tabs,
taskList,
callout,
preserveDetail,
@ -44,6 +45,32 @@ export function htmlToMarkdown(html: string): string {
return turndownService.turndown(htmlWithoutColgroups).replaceAll('<br>', ' ');
}
function tabs(turndownService: _TurndownService) {
turndownService.addRule('tabs', {
filter: (node: HTMLInputElement) =>
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'tabs',
replacement: (_content: string, node: HTMLInputElement) => {
const sections = Array.from(
node.querySelectorAll(':scope > div[data-type="tab"]'),
).map((tabNode) => {
const label =
tabNode.querySelector(':scope > div[data-type="tabLabel"]')
?.textContent ?? '';
const panel = tabNode.querySelector(
':scope > div[data-type="tabPanel"]',
);
const heading = `#### ${turndownService.escape(label.replace(/\s+/g, ' ').trim())}`;
const body = panel
? turndownService.turndown(panel.innerHTML).trim()
: '';
return body ? `${heading}\n\n${body}` : heading;
});
return `\n\n${sections.join('\n\n')}\n\n`;
},
});
}
function listParagraph(turndownService: _TurndownService) {
turndownService.addRule('paragraph', {
filter: ['p'],
@ -59,7 +86,9 @@ function listParagraph(turndownService: _TurndownService) {
function orderedListItem(turndownService: _TurndownService) {
turndownService.addRule('orderedListItem', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem';
return (
node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem'
);
},
replacement: (content: string, node: HTMLInputElement, options: any) => {
const parent = node.parentNode as HTMLElement;
@ -120,9 +149,7 @@ function taskList(turndownService: _TurndownService) {
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
return (
prefix +
text +
(node.nextSibling && !/\n$/.test(text) ? '\n' : '')
prefix + text + (node.nextSibling && !/\n$/.test(text) ? '\n' : '')
);
},
});
@ -267,9 +294,7 @@ function video(turndownService: _TurndownService) {
replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src') || '';
const ariaLabel = node.getAttribute('aria-label');
const name = sanitizeMdLinkText(
ariaLabel || getBasename(src) || src,
);
const name = sanitizeMdLinkText(ariaLabel || getBasename(src) || src);
return '[' + name + '](' + src + ')';
},
});

View file

@ -150,6 +150,19 @@ export const defaultAsyncNodes: NodeSerializerAsync = {
async detailsContent(state, node) {
await state.renderContent(node);
},
async tabs(state, node) {
await state.renderContent(node);
},
async tab(state, node) {
await state.renderContent(node);
},
async tabLabel(state, node) {
await state.renderInline(node);
state.closeBlock(node, { heading: HeadingLevel.HEADING_4 });
},
async tabPanel(state, node) {
await state.renderContent(node);
},
async columns(state, node) {
await state.renderContent(node);
},

View file

@ -556,9 +556,17 @@ export class DocxSerializerStateAsync {
async render(node: Node, parent: Node, index: number) {
if (typeof parent === 'number') throw new Error('!');
if (!this.nodes[node.type.name])
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
await Promise.resolve(this.nodes[node.type.name](this, node, parent, index));
if (this.nodes[node.type.name]) {
await Promise.resolve(this.nodes[node.type.name](this, node, parent, index));
return;
}
// node types without a serializer export their content instead of failing the export
if (node.isTextblock) {
await this.renderInline(node);
this.closeBlock(node);
return;
}
await this.renderContent(node);
}
renderMarks(node: Node, marks: Mark[]): IRunOptions {

View file

@ -1,2 +1,2 @@
export { Subpages } from "./subpages";
export type { SubpagesAttributes, SubpagesOptions } from "./subpages";
export type { SubpagesAttributes, SubpagesOptions, SubpagesSortBy } from "./subpages";

View file

@ -6,7 +6,11 @@ export interface SubpagesOptions {
view: any;
}
export interface SubpagesAttributes {}
export type SubpagesSortBy = "default" | "title-asc" | "recent";
export interface SubpagesAttributes {
sortBy?: SubpagesSortBy;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@ -26,6 +30,19 @@ export const Subpages = Node.create<SubpagesOptions>({
};
},
addAttributes() {
return {
sortBy: {
default: "default",
parseHTML: (element: HTMLElement) =>
element.getAttribute("data-sort-by") || "default",
renderHTML: (attributes: SubpagesAttributes) => ({
"data-sort-by": attributes.sortBy || "default",
}),
},
};
},
group: "block",
atom: true,
draggable: true,

View file

@ -0,0 +1,10 @@
export { Tabs } from "./tabs";
export { Tab } from "./tab";
export { TabLabel } from "./tab-label";
export { TabPanel } from "./tab-panel";
export { getActiveTabIndex, getShownTabIndex } from "./tabs-state";
export { hasTabsBlock } from "./tabs.utils";
export type { TabsOptions } from "./tabs";
export type { TabOptions } from "./tab";
export type { TabLabelOptions } from "./tab-label";
export type { TabPanelOptions } from "./tab-panel";

View file

@ -0,0 +1,42 @@
import { mergeAttributes, Node } from "@tiptap/core";
export interface TabLabelOptions {
HTMLAttributes: Record<string, unknown>;
}
export const TabLabel = Node.create<TabLabelOptions>({
name: "tabLabel",
content: "inline*",
defining: true,
selectable: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{
"data-type": this.name,
"aria-hidden": "true",
class: "not-draggable-match"
},
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
});

View file

@ -0,0 +1,43 @@
import { mergeAttributes, Node } from "@tiptap/core";
export interface TabPanelOptions {
HTMLAttributes: Record<string, unknown>;
}
export const TabPanel = Node.create<TabPanelOptions>({
name: "tabPanel",
content: "block+",
// keep the panel when all of its content is replaced, but don't count it as
// defining for copied content: that wraps content dragged or pasted out of a
// tab in a new tabs block with an empty tab
extendNodeSchema(extension) {
return extension.name === "tabPanel" ? { definingAsContext: true } : {};
},
addOptions() {
return {
HTMLAttributes: {},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name, role: "tabpanel", class: "not-draggable-match" },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
});

View file

@ -0,0 +1,52 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { generateNodeId } from "../utils";
export interface TabOptions {
HTMLAttributes: Record<string, unknown>;
}
export const Tab = Node.create<TabOptions>({
name: 'tab',
content: 'tabLabel tabPanel',
defining: true,
isolating: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
id: {
default: '',
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-tab-id') || generateNodeId(),
renderHTML: (attributes: { id?: string }) => ({
'data-tab-id': attributes.id ?? '',
}),
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(
{ 'data-type': this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
});

View file

@ -0,0 +1,441 @@
import type {
Fragment,
Node as PMNode,
ResolvedPos,
} from '@tiptap/pm/model';
import {
Mapping,
ReplaceAroundStep,
ReplaceStep,
StepMap,
type Step,
} from '@tiptap/pm/transform';
import {
Plugin,
PluginKey,
Selection,
TextSelection,
type EditorState,
type Transaction,
} from '@tiptap/pm/state';
import {
Decoration,
DecorationSet,
type DecorationSource,
} from '@tiptap/pm/view';
import { fixTabIds, getPanelContentPos, isTabsNode } from './tabs.utils';
type ActiveTab = { tabId: string; index: number };
type ActiveTabUpdate = ActiveTab & { tabsPos: number };
// steps is how many steps the transaction had when the update was made
type RecordedUpdate = ActiveTabUpdate & { steps: number };
type TabsViewState = {
active: Map<number, ActiveTab>;
decorations: DecorationSet;
// bumped whenever tabs are added, removed or split
structureVersion: number;
};
const tabsViewPluginKey = new PluginKey<TabsViewState>('tabsView');
function resolveActiveIndex(tabs: PMNode, entry: ActiveTab | undefined) {
if (!entry) return 0;
if (entry.tabId) {
for (let i = 0; i < tabs.childCount; i += 1) {
if (tabs.child(i).attrs.id === entry.tabId) return i;
}
}
return Math.min(entry.index, tabs.childCount - 1);
}
export function getActiveTabIndex(state: EditorState, tabsPos: number) {
const tabs = state.doc.nodeAt(tabsPos);
if (!isTabsNode(tabs)) return 0;
const active = tabsViewPluginKey.getState(state)?.active;
return resolveActiveIndex(tabs, active?.get(tabsPos));
}
// the tab a block's content shows, read from the decorations its node view gets
export function getShownTabIndex(
tabs: PMNode,
innerDecorations: DecorationSource,
) {
const hidden = new Set<number>();
innerDecorations.forEachSet((set) =>
set
.find(undefined, undefined, (spec) => spec.hiddenTab === true)
.forEach((decoration) => hidden.add(decoration.from)),
);
let shown = -1;
tabs.forEach((_, offset, index) => {
if (shown === -1 && !hidden.has(offset)) shown = index;
});
return Math.max(shown, 0);
}
export function setActiveTabMeta(tr: Transaction, update: ActiveTabUpdate) {
const updates: RecordedUpdate[] = tr.getMeta(tabsViewPluginKey) ?? [];
return tr.setMeta(tabsViewPluginKey, [
...updates,
{ ...update, steps: tr.steps.length },
]);
}
type StepChange = {
map: StepMap;
// where in the doc after the step tabs were added, removed or split
tabsChange: { from: number; to: number } | null;
};
const isTabOrTabs = (node: PMNode) =>
node.type.name === 'tabs' || node.type.name === 'tab';
function containsTabs(fragment: Fragment) {
let found = false;
fragment.descendants((node) => {
if (found || node.isTextblock) return false;
found = isTabOrTabs(node);
return !found;
});
return found;
}
// a tab that starts or ends inside the range, rather than enclosing it
function hasTabBoundary(doc: PMNode, from: number, to: number) {
let found = false;
doc.nodesBetween(from, to, (node, pos) => {
if (found || node.isTextblock) return false;
found = isTabOrTabs(node) && (pos >= from || pos + node.nodeSize <= to);
return !found;
});
return found;
}
// remote updates and undo replace the whole doc, so diff it to find the real change
function diffChange(before: PMNode, after: PMNode): StepChange {
const from = before.content.findDiffStart(after.content);
if (from == null) return { map: StepMap.empty, tabsChange: null };
let { a: toA, b: toB } = before.content.findDiffEnd(after.content)!;
// the scan from the end can run past the start when the edit repeats nearby content
const overlap = from - Math.min(toA, toB);
if (overlap > 0) {
toA += overlap;
toB += overlap;
}
const changesTabs =
hasTabBoundary(before, from, toA) || hasTabBoundary(after, from, toB);
return {
map: new StepMap([from, toA - from, toB - from]),
tabsChange: changesTabs ? { from, to: toB } : null,
};
}
function describeStep(step: Step, before: PMNode, after: PMNode): StepChange {
if (
step instanceof ReplaceStep &&
step.from === 0 &&
step.to === before.content.size
) {
return diffChange(before, after);
}
const map = step.getMap();
if (step instanceof ReplaceStep) {
const changesTabs =
containsTabs(step.slice.content) ||
(step.from < step.to && hasTabBoundary(before, step.from, step.to));
return {
map,
tabsChange: changesTabs
? { from: step.from, to: step.from + step.slice.size }
: null,
};
}
if (step instanceof ReplaceAroundStep) {
return {
map,
tabsChange: { from: map.map(step.from, -1), to: map.map(step.to, 1) },
};
}
return { map, tabsChange: null };
}
function findTabsBlocks(doc: PMNode, from: number, to: number) {
const positions: number[] = [];
doc.nodesBetween(from, to, (node, pos) => {
if (isTabsNode(node)) positions.push(pos);
return !node.isTextblock;
});
return positions;
}
function mapActiveTabs(
active: Map<number, ActiveTab>,
mapping: Mapping,
doc: PMNode,
changedBlocks: number[] | null,
) {
const mapped = new Map<number, ActiveTab>();
const lost: ActiveTab[] = [];
active.forEach((entry, pos) => {
const result = mapping.mapResult(pos, 1);
// only a change to the tabs themselves can leave something else at the position
const isBlock =
!result.deleted &&
(!changedBlocks || isTabsNode(doc.nodeAt(result.pos)));
if (isBlock) mapped.set(result.pos, entry);
else lost.push(entry);
});
if (!lost.length || !changedBlocks) return mapped;
// a block rebuilt by the change is found again by its tab ids
const blockByTabId = new Map<string, number | null>();
changedBlocks.forEach((pos) => {
doc.nodeAt(pos)!.forEach((tab) => {
const known = blockByTabId.get(tab.attrs.id);
// an id shared by two blocks (pasted copies) can't be told apart
blockByTabId.set(
tab.attrs.id,
known === undefined || known === pos ? pos : null,
);
});
});
lost.forEach((entry) => {
const pos = entry.tabId ? blockByTabId.get(entry.tabId) : null;
if (pos != null && !mapped.has(pos)) mapped.set(pos, entry);
});
return mapped;
}
function hiddenTabDecorations(
tabs: PMNode,
tabsPos: number,
active: Map<number, ActiveTab>,
) {
const activeIndex = resolveActiveIndex(tabs, active.get(tabsPos));
const decorations: Decoration[] = [];
tabs.forEach((tab, offset, index) => {
if (index === activeIndex) return;
const from = tabsPos + 1 + offset;
// until-found lets find in page reach the tab and fire beforematch on it
decorations.push(
Decoration.node(
from,
from + tab.nodeSize,
{ class: 'dm-tab-hidden', hidden: 'until-found' },
{ hiddenTab: true },
),
);
});
return decorations;
}
function buildDecorations(doc: PMNode, active: Map<number, ActiveTab>) {
const decorations: Decoration[] = [];
doc.descendants((node, pos) => {
if (isTabsNode(node)) {
decorations.push(...hiddenTabDecorations(node, pos, active));
}
return !node.isTextblock;
});
return DecorationSet.create(doc, decorations);
}
// swaps only this block's own decorations, leaving those of nested blocks alone
function redecorateBlock(
decorations: DecorationSet,
doc: PMNode,
tabsPos: number,
active: Map<number, ActiveTab>,
) {
const tabs = doc.nodeAt(tabsPos);
if (!isTabsNode(tabs)) return decorations;
const tabEnds = new Map<number, number>();
tabs.forEach((tab, offset) => {
const from = tabsPos + 1 + offset;
tabEnds.set(from, from + tab.nodeSize);
});
const stale = decorations
.find(tabsPos + 1, tabsPos + tabs.nodeSize - 1)
.filter((decoration) => tabEnds.get(decoration.from) === decoration.to);
return decorations
.remove(stale)
.add(doc, hiddenTabDecorations(tabs, tabsPos, active));
}
function findVisibleHead(
doc: PMNode,
tabs: PMNode,
tabsPos: number,
activeIndex: number,
head: number,
previousHead: number | null,
) {
const panelPos = getPanelContentPos(tabs, tabsPos, activeIndex);
const panelEnd = panelPos + tabs.child(activeIndex).lastChild!.content.size;
if (previousHead !== null) {
// moving off either end of the visible panel leaves the block that way
if (previousHead >= panelPos && previousHead <= panelEnd) {
const forward = head > previousHead;
const exit = Selection.findFrom(
doc.resolve(forward ? tabsPos + tabs.nodeSize : tabsPos),
forward ? 1 : -1,
);
if (exit) return exit;
}
// moving up into the block from below lands at the end of the visible panel
if (previousHead >= tabsPos + tabs.nodeSize) {
return Selection.near(doc.resolve(panelEnd), -1);
}
}
return Selection.near(doc.resolve(panelPos));
}
// the outermost tabs block that hides the position: in a label, between tabs,
// or in an inactive tab
function findHidingTabs(state: EditorState, $pos: ResolvedPos) {
for (let depth = 1; depth <= $pos.depth; depth += 1) {
const tabs = $pos.node(depth);
if (!isTabsNode(tabs)) continue;
const tabsPos = $pos.before(depth);
const activeIndex = resolveActiveIndex(
tabs,
tabsViewPluginKey.getState(state)?.active.get(tabsPos),
);
const betweenTabs = depth === $pos.depth;
const inLabel =
depth + 2 <= $pos.depth && $pos.node(depth + 2).type.name === 'tabLabel';
if (betweenTabs || inLabel || $pos.index(depth) !== activeIndex) {
return { tabs, tabsPos, activeIndex };
}
}
return null;
}
// the cursor never rests in a hidden label, an inactive tab, or between tabs
function findVisibleSelection(
state: EditorState,
selection: Selection,
previousHead: number | null,
) {
const hiding = findHidingTabs(state, selection.$head);
if (!hiding) return null;
const visible = findVisibleHead(
state.doc,
hiding.tabs,
hiding.tabsPos,
hiding.activeIndex,
selection.head,
previousHead,
);
// a shift-extended selection keeps its anchor, unless that is hidden too
const keepAnchor =
selection instanceof TextSelection &&
!selection.empty &&
!findHidingTabs(state, selection.$anchor);
return keepAnchor
? TextSelection.between(selection.$anchor, visible.$head)
: visible;
}
// tab ids must be unique for the active tab to be found again, so repair
// any that new tabs from another editor, the API or raw content duplicate
export function tabIdsPlugin() {
return new Plugin({
key: new PluginKey('tabIds'),
appendTransaction(_transactions, oldState, newState) {
const before = tabsViewPluginKey.getState(oldState)?.structureVersion;
const after = tabsViewPluginKey.getState(newState)?.structureVersion;
if (before === after) return null;
const { tr } = newState;
return fixTabIds(tr) ? tr.setMeta('addToHistory', false) : null;
},
});
}
export function tabsViewPlugin() {
return new Plugin<TabsViewState>({
key: tabsViewPluginKey,
state: {
init: (_, state) => ({
active: new Map(),
decorations: buildDecorations(state.doc, new Map()),
structureVersion: 0,
}),
apply(tr, value) {
const updates: RecordedUpdate[] | undefined =
tr.getMeta(tabsViewPluginKey);
if (!tr.docChanged && !updates) return value;
let { active, decorations, structureVersion } = value;
const changedBlocks: number[] = [];
let mapping: Mapping | null = null;
if (tr.docChanged) {
const changes = tr.steps.map((step, index) =>
describeStep(step, tr.docs[index], tr.docs[index + 1] ?? tr.doc),
);
mapping = new Mapping(changes.map((change) => change.map));
let tabsChanged = false;
changes.forEach(({ tabsChange }, index) => {
if (!tabsChange) return;
tabsChanged = true;
const rest = mapping.slice(index + 1);
changedBlocks.push(
...findTabsBlocks(
tr.doc,
rest.map(tabsChange.from, -1),
rest.map(tabsChange.to, 1),
),
);
});
active = mapActiveTabs(
active,
mapping,
tr.doc,
tabsChanged ? changedBlocks : null,
);
if (tabsChanged) structureVersion += 1;
decorations = decorations.map(mapping, tr.doc);
}
if (updates) {
active = new Map(active);
updates.forEach(({ tabsPos, tabId, index, steps }) => {
// steps added later in the same transaction can move the block
const result = mapping?.slice(steps).mapResult(tabsPos, 1);
if (result?.deleted) return;
const pos = result?.pos ?? tabsPos;
active.set(pos, { tabId, index });
changedBlocks.push(pos);
});
}
new Set(changedBlocks).forEach((tabsPos) => {
decorations = redecorateBlock(decorations, tr.doc, tabsPos, active);
});
return { active, decorations, structureVersion };
},
},
props: {
decorations: (state) => tabsViewPluginKey.getState(state)?.decorations,
},
appendTransaction(_transactions, oldState, newState) {
// only a pure selection move, such as an arrow key, leaves the panel
const previousHead =
oldState.doc === newState.doc ? oldState.selection.head : null;
let selection = newState.selection;
let next = findVisibleSelection(newState, selection, previousHead);
if (next === null) return null;
// the plugin never sees its own transaction, so settle nested tabs here
for (let pass = 0; next !== null && pass < 8; pass += 1) {
selection = next;
next = findVisibleSelection(newState, selection, previousHead);
}
return newState.tr.setSelection(selection);
},
});
}

View file

@ -0,0 +1,397 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { Slice } from '@tiptap/pm/model';
import {
Plugin,
PluginKey,
Selection,
type EditorState,
} from '@tiptap/pm/state';
import { ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react';
import type { ComponentType } from 'react';
import { generateNodeId } from '../utils';
import {
getActiveTabIndex,
setActiveTabMeta,
tabIdsPlugin,
tabsViewPlugin,
} from './tabs-state';
import {
clampIndex,
fixTabIds,
getPanelContentPos,
getTabPos,
flattenTabsBlocks,
hasTabsBlock,
indexAfterMove,
isInsideTabs,
isTabsNode,
tabLabelAt,
withFreshTabIds,
} from './tabs.utils';
export interface TabsOptions {
HTMLAttributes: Record<string, unknown>;
view: ComponentType<ReactNodeViewProps<HTMLElement>> | null;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
tabs: {
insertTabs: () => ReturnType;
addTab: (tabsPos: number) => ReturnType;
setActiveTab: (index: number, tabsPos: number) => ReturnType;
showTabAt: (pos: number) => ReturnType;
duplicateTab: (index: number, tabsPos: number) => ReturnType;
moveTab: (from: number, to: number, tabsPos: number) => ReturnType;
renameTab: (index: number, label: string, tabsPos: number) => ReturnType;
deleteTab: (index: number, tabsPos: number) => ReturnType;
};
}
}
const isInTabStrip = (event: Event) =>
Boolean((event.target as HTMLElement | null)?.closest?.('[data-tab-strip]'));
export const Tabs = Node.create<TabsOptions>({
name: 'tabs',
group: 'block',
content: 'tab+',
defining: true,
isolating: true,
// the drop cursor listens on the editor directly, so it also sees the strip's
// tab reorder drags; content is never dropped on the strip
extendNodeSchema(extension) {
return extension.name === 'tabs'
? {
disableDropCursor: (_view: unknown, _pos: unknown, event: Event) =>
isInTabStrip(event),
}
: {};
},
addOptions() {
return { HTMLAttributes: {}, view: null };
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(
{ 'data-type': this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
addNodeView() {
if (!this.options.view) return undefined;
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view, {
// a tab switch only changes the decorations, which the view isn't
// re-rendered for by default, and the tab strip reads them
update: ({ updateProps }) => {
updateProps();
return true;
},
});
},
addCommands() {
const createTab = (schema: EditorState['schema'], label: string) => {
const { tab, tabLabel, tabPanel, paragraph } = schema.nodes;
return tab.create({ id: generateNodeId() }, [
tabLabel.create(null, schema.text(label)),
tabPanel.create(null, paragraph.create()),
]);
};
return {
insertTabs:
() =>
({ tr, state, dispatch }) => {
// tabs don't nest, for now
if (isInsideTabs(state.selection.$from)) return false;
const tabsNode = this.type.create(null, [
createTab(state.schema, tabLabelAt(0)),
createTab(state.schema, tabLabelAt(1)),
]);
if (!dispatch) return true;
tr.replaceSelectionWith(tabsNode).scrollIntoView();
const firstTabId = tabsNode.child(0).attrs.id;
let tabsPos = -1;
tr.doc.descendants((node, pos) => {
if (tabsPos !== -1) return false;
if (
node.type === this.type &&
node.child(0).attrs.id === firstTabId
) {
tabsPos = pos;
return false;
}
return !node.isTextblock;
});
if (tabsPos !== -1) {
const panelPos = getPanelContentPos(tabsNode, tabsPos, 0);
tr.setSelection(Selection.near(tr.doc.resolve(panelPos)));
}
return true;
},
addTab:
(tabsPos) =>
({ tr, state, dispatch }) => {
const tabs = tr.doc.nodeAt(tabsPos);
if (!isTabsNode(tabs)) return false;
if (!dispatch) return true;
const tab = createTab(state.schema, tabLabelAt(tabs.childCount));
const insertPos = tabsPos + tabs.nodeSize - 1;
tr.insert(insertPos, tab);
setActiveTabMeta(tr, {
tabsPos,
tabId: tab.attrs.id,
index: tabs.childCount,
});
const panelPos = insertPos + 1 + tab.child(0).nodeSize + 1;
tr.setSelection(Selection.near(tr.doc.resolve(panelPos)));
return true;
},
setActiveTab:
(index, tabsPos) =>
({ tr, dispatch }) => {
const tabs = tr.doc.nodeAt(tabsPos);
if (!isTabsNode(tabs)) return false;
const next = clampIndex(index, tabs.childCount);
if (dispatch) {
setActiveTabMeta(tr, {
tabsPos,
tabId: tabs.child(next).attrs.id,
index: next,
});
}
return true;
},
showTabAt:
(pos) =>
({ tr, dispatch }) => {
const $pos = tr.doc.resolve(pos);
let found = false;
for (let depth = 1; depth < $pos.depth; depth += 1) {
const tabs = $pos.node(depth);
if (!isTabsNode(tabs)) continue;
found = true;
const index = $pos.index(depth);
if (dispatch) {
setActiveTabMeta(tr, {
tabsPos: $pos.before(depth),
tabId: tabs.child(index).attrs.id,
index,
});
}
}
return found;
},
duplicateTab:
(index, tabsPos) =>
({ tr, dispatch }) => {
const tabs = tr.doc.nodeAt(tabsPos);
if (!isTabsNode(tabs)) return false;
if (!dispatch) return true;
const sourceIndex = clampIndex(index, tabs.childCount);
const source = tabs.child(sourceIndex);
const copy = source.type.create(
{ ...source.attrs, id: generateNodeId() },
withFreshTabIds(source.content),
);
tr.insert(
getTabPos(tabs, tabsPos, sourceIndex) + source.nodeSize,
copy,
);
setActiveTabMeta(tr, {
tabsPos,
tabId: copy.attrs.id,
index: sourceIndex + 1,
});
return true;
},
moveTab:
(from, to, tabsPos) =>
({ state, tr, dispatch }) => {
const tabs = tr.doc.nodeAt(tabsPos);
if (!isTabsNode(tabs) || tabs.childCount < 2) return false;
const fromIndex = clampIndex(from, tabs.childCount);
const toIndex = clampIndex(to, tabs.childCount);
if (fromIndex === toIndex) return false;
if (!dispatch) return true;
const activeIndex = getActiveTabIndex(state, tabsPos);
const activeTabId = tabs.child(activeIndex).attrs.id;
const moved = tabs.child(fromIndex);
const fromPos = getTabPos(tabs, tabsPos, fromIndex);
tr.delete(fromPos, fromPos + moved.nodeSize);
const remaining = tr.doc.nodeAt(tabsPos)!;
tr.insert(getTabPos(remaining, tabsPos, toIndex), moved);
setActiveTabMeta(tr, {
tabsPos,
tabId: activeTabId,
index: indexAfterMove(activeIndex, fromIndex, toIndex),
});
return true;
},
renameTab:
(index, label, tabsPos) =>
({ tr, state, dispatch }) => {
const tabs = tr.doc.nodeAt(tabsPos);
if (!isTabsNode(tabs)) return false;
const tabIndex = clampIndex(index, tabs.childCount);
const labelPos = getTabPos(tabs, tabsPos, tabIndex) + 1;
const labelNode = tr.doc.nodeAt(labelPos);
if (labelNode?.type.name !== 'tabLabel') return false;
if (!dispatch) return true;
const from = labelPos + 1;
const to = from + labelNode.content.size;
if (label) tr.replaceWith(from, to, state.schema.text(label));
else tr.delete(from, to);
return true;
},
deleteTab:
(index, tabsPos) =>
({ state, tr, dispatch }) => {
const tabs = tr.doc.nodeAt(tabsPos);
if (!isTabsNode(tabs)) return false;
if (!dispatch) return true;
if (tabs.childCount === 1) {
tr.delete(tabsPos, tabsPos + tabs.nodeSize);
return true;
}
const target = clampIndex(index, tabs.childCount);
const activeIndex = getActiveTabIndex(state, tabsPos);
const tabPos = getTabPos(tabs, tabsPos, target);
tr.delete(tabPos, tabPos + tabs.child(target).nodeSize);
if (target === activeIndex) {
const next = Math.min(target, tabs.childCount - 2);
const remaining = tr.doc.nodeAt(tabsPos)!;
setActiveTabMeta(tr, {
tabsPos,
tabId: remaining.child(next).attrs.id,
index: next,
});
}
return true;
},
};
},
// content loaded straight into the editor never passes through a transaction
onCreate() {
const { tr } = this.editor.state;
if (fixTabIds(tr)) {
this.editor.view.dispatch(tr.setMeta('addToHistory', false));
}
},
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey('tabsDragGuard'),
props: {
// the strip's tab reorder is its own drag and drop. the node view
// already stops its dragstart; claim the rest so the editor doesn't
// take them as a content drag. they still reach the dnd library
handleDOMEvents: {
dragenter: (_view, event) => isInTabStrip(event),
dragover: (_view, event) => isInTabStrip(event),
drop: (_view, event) => isInTabStrip(event),
},
},
}),
new Plugin({
key: new PluginKey('tabsPaste'),
props: {
transformPasted: (slice) =>
new Slice(
withFreshTabIds(slice.content),
slice.openStart,
slice.openEnd,
),
handlePaste: (view, _event, slice) => {
if (!isInsideTabs(view.state.selection.$from)) return false;
const content = flattenTabsBlocks(
slice.content,
slice.openStart,
slice.openEnd,
);
if (!content) return false;
view.dispatch(
view.state.tr
.replaceSelection(
new Slice(content, slice.openStart, slice.openEnd),
)
.scrollIntoView()
.setMeta('paste', true)
.setMeta('uiEvent', 'paste'),
);
return true;
},
// a dragged tabs block can't land inside another, so the drop does nothing
handleDrop: (view, event, slice) => {
if (!hasTabsBlock(slice.content, slice.openStart, slice.openEnd)) {
return false;
}
const target = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
return Boolean(
target && isInsideTabs(view.state.doc.resolve(target.pos)),
);
},
},
}),
new Plugin({
key: new PluginKey('tabsReveal'),
props: {
handleDOMEvents: {
// find in page, an anchor link, or a scroll to an element reaching into a hidden tab
beforematch: (view, event) => {
if (event.target instanceof Element) {
editor.commands.showTabAt(view.posAtDOM(event.target, 0));
}
return false;
},
},
},
}),
tabsViewPlugin(),
tabIdsPlugin(),
];
},
});

View file

@ -0,0 +1,157 @@
import { findParentNodeClosestToPos } from '@tiptap/core';
import {
Fragment,
type Node as PMNode,
type ResolvedPos,
} from '@tiptap/pm/model';
import type { Transaction } from '@tiptap/pm/state';
import { generateNodeId } from '../utils';
export const tabLabelAt = (index: number) => `Tab ${index + 1}`;
export const clampIndex = (value: unknown, length: number) => {
const parsed = Number(value ?? 0);
if (!Number.isFinite(parsed) || length <= 0) return 0;
return Math.max(0, Math.min(Math.trunc(parsed), length - 1));
};
export const indexAfterMove = (index: number, from: number, to: number) => {
if (index === from) return to;
if (from < index && index <= to) return index - 1;
if (to <= index && index < from) return index + 1;
return index;
};
export function isTabsNode(node: PMNode | null | undefined): node is PMNode {
return node?.type.name === 'tabs' && node.childCount > 0;
}
export function getTabPos(tabs: PMNode, tabsPos: number, index: number) {
let pos = tabsPos + 1;
for (let i = 0; i < index; i += 1) pos += tabs.child(i).nodeSize;
return pos;
}
export function getPanelContentPos(
tabs: PMNode,
tabsPos: number,
index: number,
) {
const tabPos = getTabPos(tabs, tabsPos, index);
return tabPos + 1 + tabs.child(index).child(0).nodeSize + 1;
}
export function withFreshTabIds(fragment: Fragment): Fragment {
const nodes: PMNode[] = [];
fragment.forEach((node) => {
if (node.isTextblock || node.isLeaf) {
nodes.push(node);
return;
}
const content = withFreshTabIds(node.content);
nodes.push(
node.type.name === 'tab'
? node.type.create(
{ ...node.attrs, id: generateNodeId() },
content,
node.marks,
)
: node.copy(content),
);
});
return Fragment.from(nodes);
}
// keeps the first tab with each id and gives empty or repeated ones a fresh id
export function fixTabIds(tr: Transaction) {
const seen = new Set<string>();
const repairs: number[] = [];
tr.doc.descendants((node, pos) => {
if (node.type.name === 'tab') {
const { id } = node.attrs;
if (!id || seen.has(id)) repairs.push(pos);
else seen.add(id);
}
return !node.isTextblock;
});
repairs.forEach((pos) => {
let id = generateNodeId();
while (seen.has(id)) id = generateNodeId();
seen.add(id);
tr.setNodeAttribute(pos, 'id', id);
});
return repairs.length > 0;
}
export const isInsideTabs = ($pos: ResolvedPos) =>
Boolean(findParentNodeClosestToPos($pos, (node) => node.type.name === 'tabs'));
function tabsBlockContent(tabs: PMNode) {
const { heading } = tabs.type.schema.nodes;
const nodes: PMNode[] = [];
tabs.forEach((tab) => {
const [label, panel] = [tab.child(0), tab.child(1)];
if (label.content.size) {
nodes.push(heading.create({ level: 4 }, label.content));
}
panel.forEach((node) => nodes.push(node));
});
return Fragment.from(nodes);
}
// tabs can't nest, so a whole tabs block becomes its labels as headings, as in
// the markdown export, followed by their content. the open edges of a copied
// selection are context rather than blocks. null when there is nothing to flatten
export function flattenTabsBlocks(
fragment: Fragment,
openStart = 0,
openEnd = 0,
): Fragment | null {
let changed = false;
const nodes: PMNode[] = [];
fragment.forEach((node, _, index) => {
const openLeft = index === 0 ? openStart : 0;
const openRight = index === fragment.childCount - 1 ? openEnd : 0;
if (node.type.name === 'tabs' && !openLeft && !openRight) {
changed = true;
const content = tabsBlockContent(node);
(flattenTabsBlocks(content) ?? content).forEach((child) =>
nodes.push(child),
);
return;
}
const content =
node.isTextblock || node.isLeaf
? null
: flattenTabsBlocks(
node.content,
Math.max(openLeft - 1, 0),
Math.max(openRight - 1, 0),
);
if (content) changed = true;
nodes.push(content ? node.copy(content) : node);
});
return changed ? Fragment.from(nodes) : null;
}
// a whole tabs block anywhere in the fragment, with the same open edges rule
export function hasTabsBlock(
fragment: Fragment,
openStart = 0,
openEnd = 0,
): boolean {
let found = false;
fragment.forEach((node, _, index) => {
if (found || node.isTextblock || node.isLeaf) return;
const openLeft = index === 0 ? openStart : 0;
const openRight = index === fragment.childCount - 1 ? openEnd : 0;
found =
(node.type.name === 'tabs' && !openLeft && !openRight) ||
hasTabsBlock(
node.content,
Math.max(openLeft - 1, 0),
Math.max(openRight - 1, 0),
);
});
return found;
}