chore(sync): sync updates from docmost/docmost
This commit is contained in:
commit
fec7ca32ac
75 changed files with 5533 additions and 269 deletions
|
|
@ -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}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
155
apps/client/src/features/comment/hooks/use-comment-deep-link.ts
Normal file
155
apps/client/src/features/comment/hooks/use-comment-deep-link.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) {
|
|||
});
|
||||
|
||||
if (!position) return;
|
||||
editor.commands.showTabAt(position.from);
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(".search-result-current")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
133
apps/client/src/features/editor/components/tabs/tab-slot.tsx
Normal file
133
apps/client/src/features/editor/components/tabs/tab-slot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
238
apps/client/src/features/editor/components/tabs/tabs-view.tsx
Normal file
238
apps/client/src/features/editor/components/tabs/tabs-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
296
apps/client/src/features/editor/extensions/drag-handle.test.ts
Normal file
296
apps/client/src/features/editor/extensions/drag-handle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1151
apps/client/src/features/editor/extensions/tabs.test.ts
Normal file
1151
apps/client/src/features/editor/extensions/tabs.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export default function ReadonlyPageEditor({
|
|||
return true;
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
: {}
|
||||
}
|
||||
onCreate={({ editor }) => {
|
||||
if (editor) {
|
||||
|
|
|
|||
|
|
@ -17,5 +17,6 @@
|
|||
@import "./indent.css";
|
||||
@import "./columns.css";
|
||||
@import "./status.css";
|
||||
@import "./tabs.css";
|
||||
@import "./base-embed.css";
|
||||
@import "./footnotes.css";
|
||||
|
|
|
|||
204
apps/client/src/features/editor/styles/tabs.css
Normal file
204
apps/client/src/features/editor/styles/tabs.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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()}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ export class PageService {
|
|||
'spaceId',
|
||||
'creatorId',
|
||||
'isBase',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 + ')';
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { Subpages } from "./subpages";
|
||||
export type { SubpagesAttributes, SubpagesOptions } from "./subpages";
|
||||
export type { SubpagesAttributes, SubpagesOptions, SubpagesSortBy } from "./subpages";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
10
packages/editor-ext/src/lib/tabs/index.ts
Normal file
10
packages/editor-ext/src/lib/tabs/index.ts
Normal 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";
|
||||
42
packages/editor-ext/src/lib/tabs/tab-label.ts
Normal file
42
packages/editor-ext/src/lib/tabs/tab-label.ts
Normal 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,
|
||||
];
|
||||
},
|
||||
});
|
||||
43
packages/editor-ext/src/lib/tabs/tab-panel.ts
Normal file
43
packages/editor-ext/src/lib/tabs/tab-panel.ts
Normal 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,
|
||||
];
|
||||
},
|
||||
});
|
||||
52
packages/editor-ext/src/lib/tabs/tab.ts
Normal file
52
packages/editor-ext/src/lib/tabs/tab.ts
Normal 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,
|
||||
];
|
||||
},
|
||||
});
|
||||
441
packages/editor-ext/src/lib/tabs/tabs-state.ts
Normal file
441
packages/editor-ext/src/lib/tabs/tabs-state.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
397
packages/editor-ext/src/lib/tabs/tabs.ts
Normal file
397
packages/editor-ext/src/lib/tabs/tabs.ts
Normal 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(),
|
||||
];
|
||||
},
|
||||
});
|
||||
157
packages/editor-ext/src/lib/tabs/tabs.utils.ts
Normal file
157
packages/editor-ext/src/lib/tabs/tabs.utils.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue