From d3a0c9bfdf3710161fe66e8de28a8d23104d54f5 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:18:58 +0100 Subject: [PATCH 1/5] fix(base): export CSV with the active view's filter (#2541) The export button only sent the page id, so filtered views still downloaded every row. Send the view's current (including unsaved draft) filter, and bump the ee submodule for server-side support. --- apps/client/src/ee/base/components/base-toolbar.tsx | 7 +++++-- apps/client/src/ee/base/services/base-service.ts | 7 +++++-- apps/server/src/ee | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/client/src/ee/base/components/base-toolbar.tsx b/apps/client/src/ee/base/components/base-toolbar.tsx index 213ce587..c10fe108 100644 --- a/apps/client/src/ee/base/components/base-toolbar.tsx +++ b/apps/client/src/ee/base/components/base-toolbar.tsx @@ -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); diff --git a/apps/client/src/ee/base/services/base-service.ts b/apps/client/src/ee/base/services/base-service.ts index dfe99833..6dc7e1ac 100644 --- a/apps/client/src/ee/base/services/base-service.ts +++ b/apps/client/src/ee/base/services/base-service.ts @@ -57,10 +57,13 @@ export async function convertPageToBase( return req.data; } -export async function exportBaseToCsv(pageId: string): Promise { +export async function exportBaseToCsv( + pageId: string, + filter?: FilterNode, +): Promise { const req = await api.post( "/bases/export-csv", - { pageId }, + { pageId, filter }, { responseType: "blob" }, ); diff --git a/apps/server/src/ee b/apps/server/src/ee index 8190e218..d6260785 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 8190e2182cbbcc5e291217a13147fcd246ede3fe +Subproject commit d6260785de583ddb85109802c92499eb9c21b8f0 From 42e07d8019fdb6a1aadf6e96d131ad60d497073f Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:07:01 +0100 Subject: [PATCH 2/5] feat: comments deep link (#2550) * feat: comments deeplinks * cleanup * feat: add support to in-app notification --- .../features/comment/atoms/comment-atom.ts | 1 + .../comment/components/comment-dialog.tsx | 6 +- .../comment/components/comment-list-item.tsx | 23 +-- .../components/comment-list-with-tabs.tsx | 7 +- .../comment/components/comment-menu.tsx | 30 +++- .../comment/components/comment.module.css | 13 ++ .../comment/hooks/use-comment-deep-link.ts | 153 ++++++++++++++++++ .../src/features/editor/page-editor.tsx | 6 +- .../components/notification-item.tsx | 5 +- apps/client/src/lib/api-client.ts | 8 +- apps/client/src/pages/page/page.tsx | 13 +- .../services/comment.notification.ts | 13 +- apps/server/src/ee | 2 +- .../emails/comment-created-email.tsx | 6 +- .../emails/comment-mention-email.tsx | 6 +- .../emails/comment-resolved-email.tsx | 6 +- 16 files changed, 259 insertions(+), 39 deletions(-) create mode 100644 apps/client/src/features/comment/hooks/use-comment-deep-link.ts diff --git a/apps/client/src/features/comment/atoms/comment-atom.ts b/apps/client/src/features/comment/atoms/comment-atom.ts index 374e7f7f..13694ad5 100644 --- a/apps/client/src/features/comment/atoms/comment-atom.ts +++ b/apps/client/src/features/comment/atoms/comment-atom.ts @@ -3,6 +3,7 @@ import { atom } from 'jotai'; export const showCommentPopupAtom = atom(false); export const activeCommentIdAtom = atom(''); export const draftCommentIdAtom = atom(''); +export const commentPanelTabAtom = atom<'open' | 'resolved'>('open'); // Read-only comment state export const showReadOnlyCommentPopupAtom = atom(false); diff --git a/apps/client/src/features/comment/components/comment-dialog.tsx b/apps/client/src/features/comment/components/comment-dialog.tsx index 6eadc86a..8587850e 100644 --- a/apps/client/src/features/comment/components/comment-dialog.tsx +++ b/apps/client/src/features/comment/components/comment-dialog.tsx @@ -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(() => { diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index 7cedcd84..484ef063 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -150,16 +150,19 @@ function CommentListItem({ /> )} - {(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && ( - - )} + diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx index a3f348b8..cc34564a 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -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() { }} > + setActiveTab(value === "resolved" ? "resolved" : "open") + } variant="default" style={{ flex: "1 1 auto", diff --git a/apps/client/src/features/comment/components/comment-menu.tsx b/apps/client/src/features/comment/components/comment-menu.tsx index 690815e6..eac2c361 100644 --- a/apps/client/src/features/comment/components/comment-menu.tsx +++ b/apps/client/src/features/comment/components/comment-menu.tsx @@ -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({ )} {isParentComment && + canManage && (canResolve ? ( ))} } - onClick={openDeleteModal} + leftSection={} + onClick={handleCopyLink} > - {t("Delete comment")} + {t("Copy link")} + {canManage && ( + } + onClick={openDeleteModal} + > + {t("Delete comment")} + + )} ); diff --git a/apps/client/src/features/comment/components/comment.module.css b/apps/client/src/features/comment/components/comment.module.css index dfa61b79..1f58287a 100644 --- a/apps/client/src/features/comment/components/comment.module.css +++ b/apps/client/src/features/comment/components/comment.module.css @@ -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); diff --git a/apps/client/src/features/comment/hooks/use-comment-deep-link.ts b/apps/client/src/features/comment/hooks/use-comment-deep-link.ts new file mode 100644 index 00000000..4d630241 --- /dev/null +++ b/apps/client/src/features/comment/hooks/use-comment-deep-link.ts @@ -0,0 +1,153 @@ +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"; + +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(null); + const timersRef = useRef>>([]); + + // 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(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) => { + 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, + ]); +} diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 8e6e1036..7c6ace15 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -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]); diff --git a/apps/client/src/features/notification/components/notification-item.tsx b/apps/client/src/features/notification/components/notification-item.tsx index 12b9f242..3dbd6b60 100644 --- a/apps/client/src/features/notification/components/notification-item.tsx +++ b/apps/client/src/features/notification/components/notification-item.tsx @@ -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."); diff --git a/apps/client/src/lib/api-client.ts b/apps/client/src/lib/api-client.ts index cde9317a..805cb7fa 100644 --- a/apps/client/src/lib/api-client.ts +++ b/apps/client/src/lib/api-client.ts @@ -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()}`; } } diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index ea109488..b809c9b0 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -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); diff --git a/apps/server/src/core/notification/services/comment.notification.ts b/apps/server/src/core/notification/services/comment.notification.ts index c79c2895..74109a17 100644 --- a/apps/server/src/core/notification/services/comment.notification.ts +++ b/apps/server/src/core/notification/services/comment.notification.ts @@ -48,7 +48,7 @@ export class CommentNotificationService { ); if (!context) return; - const { actor, pageTitle, pageUrl } = context; + const { actor, pageTitle, commentUrl } = context; const notifiedUserIds = new Set(); 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 }; } } diff --git a/apps/server/src/ee b/apps/server/src/ee index d6260785..cc28defe 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit d6260785de583ddb85109802c92499eb9c21b8f0 +Subproject commit cc28defeec44c8f71e686cbec6e4298bb447e0e7 diff --git a/apps/server/src/integrations/transactional/emails/comment-created-email.tsx b/apps/server/src/integrations/transactional/emails/comment-created-email.tsx index ddf70963..de5cadaa 100644 --- a/apps/server/src/integrations/transactional/emails/comment-created-email.tsx +++ b/apps/server/src/integrations/transactional/emails/comment-created-email.tsx @@ -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 ( @@ -23,7 +23,7 @@ export const CommentCreateEmail = ({ {pageTitle}. - View + View ); }; diff --git a/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx b/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx index 72a1d4a7..09ec1193 100644 --- a/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx +++ b/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx @@ -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 ( @@ -23,7 +23,7 @@ export const CommentMentionEmail = ({ {pageTitle}. - View + View ); }; diff --git a/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx b/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx index 303375cd..5f09b794 100644 --- a/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx +++ b/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx @@ -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 ( @@ -23,7 +23,7 @@ export const CommentResolvedEmail = ({ {pageTitle}. - View + View ); }; From 2e3fdbbff453fbf9265123ef75ea8db42d692287 Mon Sep 17 00:00:00 2001 From: Salihu <91833785+salihudickson@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:49:47 +0100 Subject: [PATCH 3/5] feat: subpages component improvements (#2488) * expand subpages node functionality * minor fixes * minor fix * refactor: redesign subpages view * fix link color --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com> --- .../public/locales/en-US/translation.json | 6 + .../components/subpages/subpage-item.tsx | 138 ++++++++++++ .../components/subpages/subpages-menu.tsx | 2 +- .../components/subpages/subpages-view.tsx | 196 ++++++------------ .../components/subpages/subpages.module.css | 174 +++++++++++++++- .../subpages/subpages.utils.test.ts | 80 +++++++ .../components/subpages/subpages.utils.ts | 38 ++++ .../components/subpages/use-subpages.test.tsx | 181 ++++++++++++++++ .../components/subpages/use-subpages.ts | 55 +++++ .../components/docs/docs.module.css | 2 +- .../share/hooks/use-shared-page-subpages.ts | 13 -- apps/client/src/features/share/utils.ts | 2 + apps/client/src/lib/time.ts | 25 +++ .../src/core/page/services/page.service.ts | 1 + .../src/database/repos/page/page.repo.ts | 6 + packages/editor-ext/src/lib/subpages/index.ts | 2 +- .../editor-ext/src/lib/subpages/subpages.ts | 19 +- 17 files changed, 786 insertions(+), 154 deletions(-) create mode 100644 apps/client/src/features/editor/components/subpages/subpage-item.tsx create mode 100644 apps/client/src/features/editor/components/subpages/subpages.utils.test.ts create mode 100644 apps/client/src/features/editor/components/subpages/subpages.utils.ts create mode 100644 apps/client/src/features/editor/components/subpages/use-subpages.test.tsx create mode 100644 apps/client/src/features/editor/components/subpages/use-subpages.ts delete mode 100644 apps/client/src/features/share/hooks/use-shared-page-subpages.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 3dc11c1b..d270c877 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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", diff --git a/apps/client/src/features/editor/components/subpages/subpage-item.tsx b/apps/client/src/features/editor/components/subpages/subpage-item.tsx new file mode 100644 index 00000000..d5353689 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpage-item.tsx @@ -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 ( +
+
+ {page.hasChildren ? ( + setOpened((value) => !value)} + aria-label={ + opened + ? t("Collapse {{name}}", { name: title }) + : t("Expand {{name}}", { name: title }) + } + aria-expanded={opened} + > + + + ) : ( + + )} + + + + {page.icon || } + + {title} + {updatedAt && ( + + )} + +
+ + {page.hasChildren && ( + + {isLoading ? ( +
+ {t("Loading…")} +
+ ) : ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx index 19089360..2a29a321 100644 --- a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx +++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx @@ -67,7 +67,7 @@ export const SubpagesMenu = React.memo( updateDelay={0} shouldShow={shouldShow} > - + 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 ( - - - {t("Failed to load subpages")} - - - ); - } - - if (subpages.length === 0) { - return ( - -
- - {t("No subpages")} - -
-
- ); - } - return ( -
- - {subpages.map((page) => ( - - {page?.icon ? ( - {page.icon} - ) : ( - - - - )} +
+
+
+ {t("Subpages")} + {subpages.length > 0 && ( + {subpages.length} + )} +
- - {page?.title || t("untitled")} - - - ))} - + {isEditable && subpages.length > 1 && ( + + 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")} + /> + )} +
+ + {subpages.length > 0 ? ( +
+ {subpages.map((page) => ( + + ))} +
+ ) : ( +
+ {error ? t("Failed to load subpages") : t("No subpages")} +
+ )}
); diff --git a/apps/client/src/features/editor/components/subpages/subpages.module.css b/apps/client/src/features/editor/components/subpages/subpages.module.css index fb43eaf6..f3e4f4f8 100644 --- a/apps/client/src/features/editor/components/subpages/subpages.module.css +++ b/apps/client/src/features/editor/components/subpages/subpages.module.css @@ -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); +} diff --git a/apps/client/src/features/editor/components/subpages/subpages.utils.test.ts b/apps/client/src/features/editor/components/subpages/subpages.utils.test.ts new file mode 100644 index 00000000..36690168 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpages.utils.test.ts @@ -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"]); + }); +}); diff --git a/apps/client/src/features/editor/components/subpages/subpages.utils.ts b/apps/client/src/features/editor/components/subpages/subpages.utils.ts new file mode 100644 index 00000000..cd94e558 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/subpages.utils.ts @@ -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); +} diff --git a/apps/client/src/features/editor/components/subpages/use-subpages.test.tsx b/apps/client/src/features/editor/components/subpages/use-subpages.test.tsx new file mode 100644 index 00000000..930cab45 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/use-subpages.test.tsx @@ -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 }) => ( + + + + + + + + + + + + + ); + + 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(); + }); +}); diff --git a/apps/client/src/features/editor/components/subpages/use-subpages.ts b/apps/client/src/features/editor/components/subpages/use-subpages.ts new file mode 100644 index 00000000..4ba4ba13 --- /dev/null +++ b/apps/client/src/features/editor/components/subpages/use-subpages.ts @@ -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 }; +} diff --git a/apps/client/src/features/public-space/components/docs/docs.module.css b/apps/client/src/features/public-space/components/docs/docs.module.css index d9582464..d8fada6b 100644 --- a/apps/client/src/features/public-space/components/docs/docs.module.css +++ b/apps/client/src/features/public-space/components/docs/docs.module.css @@ -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"] { diff --git a/apps/client/src/features/share/hooks/use-shared-page-subpages.ts b/apps/client/src/features/share/hooks/use-shared-page-subpages.ts deleted file mode 100644 index 76b17912..00000000 --- a/apps/client/src/features/share/hooks/use-shared-page-subpages.ts +++ /dev/null @@ -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], - ); -} diff --git a/apps/client/src/features/share/utils.ts b/apps/client/src/features/share/utils.ts index 334cc786..4b9bd8c4 100644 --- a/apps/client/src/features/share/utils.ts +++ b/apps/client/src/features/share/utils.ts @@ -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: [], diff --git a/apps/client/src/lib/time.ts b/apps/client/src/lib/time.ts index a6056dd3..aa12ab34 100644 --- a/apps/client/src/lib/time.ts +++ b/apps/client/src/lib/time.ts @@ -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)) { diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index fbe8a4d5..6fe9ef5c 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -312,6 +312,7 @@ export class PageService { 'spaceId', 'creatorId', 'isBase', + 'updatedAt', 'deletedAt', ]) .select((eb) => this.pageRepo.withHasChildren(eb)) diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index bd6f1ee8..1ce93701 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -607,6 +607,7 @@ export class PageRepo { 'pages.parentPageId', 'pages.spaceId', 'pages.workspaceId', + 'pages.updatedAt', sql`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`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`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`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(); diff --git a/packages/editor-ext/src/lib/subpages/index.ts b/packages/editor-ext/src/lib/subpages/index.ts index 27a974a6..55f9390e 100644 --- a/packages/editor-ext/src/lib/subpages/index.ts +++ b/packages/editor-ext/src/lib/subpages/index.ts @@ -1,2 +1,2 @@ export { Subpages } from "./subpages"; -export type { SubpagesAttributes, SubpagesOptions } from "./subpages"; +export type { SubpagesAttributes, SubpagesOptions, SubpagesSortBy } from "./subpages"; diff --git a/packages/editor-ext/src/lib/subpages/subpages.ts b/packages/editor-ext/src/lib/subpages/subpages.ts index 6f5c1062..d21afb0b 100644 --- a/packages/editor-ext/src/lib/subpages/subpages.ts +++ b/packages/editor-ext/src/lib/subpages/subpages.ts @@ -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 { @@ -26,6 +30,19 @@ export const Subpages = Node.create({ }; }, + 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, From c08d1d3539e1393c6e2a10995d5f19e67edff515 Mon Sep 17 00:00:00 2001 From: Salihu <91833785+salihudickson@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:50:44 +0100 Subject: [PATCH 4/5] feat: multi tab node (#2381) * init multi-tab-node * dragging logic * tabs menu * tabs menu * markdown import * markdown export * improve markdown import and export * fix exponential backtracking in regex * fix exponential backtracking in regex * WIP * fix: add docx schema * feat(editor): rework tabs block state, controls and editing - Keep the active tab in per-viewer plugin state, not in the document - Update hidden-tab decorations incrementally; diff whole-doc remote updates - Replace the tabs bubble menu with an in-block toolbar and per-tab actions menu - Add rename, duplicate, move left/right and delete tab actions - Rename a tab by double-clicking it; add tabs from a strip button - Hide inactive tabs with hidden="until-found" and open them on beforematch - Open the containing tab for search, TOCs, comment links and hash scrolls - Disallow tabs inside tabs: hide slash item, flatten pastes, refuse drops - Stop content copied or dragged out of a tab from creating a new tabs block - Keep the selection anchor and land at the panel end when arrowing into tabs - Let Enter add a line at the end of a tab instead of leaving the block - Fix drag handle moves of custom nodes inside tab panels - Keep editor focus on toolbar clicks so copy and delete work - Export tabs to Markdown as level 4 headings; drop the tabs Markdown syntax - Share node copy-to-clipboard between the columns and tabs toolbars - Raise inactive tab label contrast * fix(editor): show a compact drag preview for the tabs block - Add a hidden data-drag-preview chip with the tab labels to the tabs view - The global drag handle uses it instead of snapshotting the block and its neighbours - Style the chip outside the editor scope since the handle clones it into the body * fix(editor): hide the drop cursor while reordering tabs in the tab strip * fix: better tabs selection, rendering and ids - Keep selections out of hidden tabs and map chained tab updates - Drive the tab strip from decorations instead of per-keystroke lookups - Repair empty and repeated tab ids --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com> --- .../public/locales/en-US/translation.json | 11 +- .../comment/components/comment-list-item.tsx | 2 + .../comment/hooks/use-comment-deep-link.ts | 2 + .../components/columns/columns-menu.tsx | 48 +- .../search-and-replace-dialog.tsx | 1 + .../search-navigation-dialog.tsx | 1 + .../components/slash-menu/menu-items.test.ts | 18 + .../components/slash-menu/menu-items.ts | 14 + .../table-of-contents/table-of-contents.tsx | 1 + .../components/tabs/tab-actions-menu.tsx | 62 + .../components/tabs/tab-rename-input.tsx | 48 + .../editor/components/tabs/tab-slot.tsx | 133 ++ .../editor/components/tabs/tabs-toolbar.tsx | 69 + .../editor/components/tabs/tabs-view.test.tsx | 654 ++++++++++ .../editor/components/tabs/tabs-view.tsx | 238 ++++ .../editor/components/tabs/tabs.types.ts | 7 + .../editor/extensions/drag-handle.test.ts | 296 +++++ .../features/editor/extensions/drag-handle.ts | 186 ++- .../features/editor/extensions/extensions.ts | 18 +- .../features/editor/extensions/tabs.test.ts | 1151 +++++++++++++++++ .../editor/hooks/use-editor-scroll.ts | 2 + .../src/features/editor/styles/index.css | 1 + .../src/features/editor/styles/tabs.css | 204 +++ .../editor/utils/copy-node-to-clipboard.ts | 43 + .../client/src/features/editor/utils/index.ts | 2 + .../editor/utils/show-tab-at-element.ts | 6 + .../components/docs/docs-toc.test.tsx | 93 ++ .../public-space/components/docs/docs-toc.tsx | 1 + apps/client/vitest.config.ts | 4 + .../src/collaboration/collaboration.util.ts | 8 + apps/server/src/ee | 2 +- .../export/html-to-markdown.spec.ts | 15 + packages/editor-ext/src/index.ts | 1 + .../src/lib/markdown/utils/turndown.utils.ts | 39 +- .../src/lib/prosemirror-docx/schema.ts | 13 + .../src/lib/prosemirror-docx/serializer.ts | 14 +- packages/editor-ext/src/lib/tabs/index.ts | 10 + packages/editor-ext/src/lib/tabs/tab-label.ts | 42 + packages/editor-ext/src/lib/tabs/tab-panel.ts | 43 + packages/editor-ext/src/lib/tabs/tab.ts | 52 + .../editor-ext/src/lib/tabs/tabs-state.ts | 441 +++++++ packages/editor-ext/src/lib/tabs/tabs.ts | 397 ++++++ .../editor-ext/src/lib/tabs/tabs.utils.ts | 157 +++ 43 files changed, 4478 insertions(+), 72 deletions(-) create mode 100644 apps/client/src/features/editor/components/slash-menu/menu-items.test.ts create mode 100644 apps/client/src/features/editor/components/tabs/tab-actions-menu.tsx create mode 100644 apps/client/src/features/editor/components/tabs/tab-rename-input.tsx create mode 100644 apps/client/src/features/editor/components/tabs/tab-slot.tsx create mode 100644 apps/client/src/features/editor/components/tabs/tabs-toolbar.tsx create mode 100644 apps/client/src/features/editor/components/tabs/tabs-view.test.tsx create mode 100644 apps/client/src/features/editor/components/tabs/tabs-view.tsx create mode 100644 apps/client/src/features/editor/components/tabs/tabs.types.ts create mode 100644 apps/client/src/features/editor/extensions/drag-handle.test.ts create mode 100644 apps/client/src/features/editor/extensions/tabs.test.ts create mode 100644 apps/client/src/features/editor/styles/tabs.css create mode 100644 apps/client/src/features/editor/utils/copy-node-to-clipboard.ts create mode 100644 apps/client/src/features/editor/utils/show-tab-at-element.ts create mode 100644 apps/client/src/features/public-space/components/docs/docs-toc.test.tsx create mode 100644 packages/editor-ext/src/lib/tabs/index.ts create mode 100644 packages/editor-ext/src/lib/tabs/tab-label.ts create mode 100644 packages/editor-ext/src/lib/tabs/tab-panel.ts create mode 100644 packages/editor-ext/src/lib/tabs/tab.ts create mode 100644 packages/editor-ext/src/lib/tabs/tabs-state.ts create mode 100644 packages/editor-ext/src/lib/tabs/tabs.ts create mode 100644 packages/editor-ext/src/lib/tabs/tabs.utils.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index d270c877..5fad7e0d 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1482,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}}" } diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index 484ef063..6e535555 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -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(() => { diff --git a/apps/client/src/features/comment/hooks/use-comment-deep-link.ts b/apps/client/src/features/comment/hooks/use-comment-deep-link.ts index 4d630241..bfbd3b4b 100644 --- a/apps/client/src/features/comment/hooks/use-comment-deep-link.ts +++ b/apps/client/src/features/comment/hooks/use-comment-deep-link.ts @@ -8,6 +8,7 @@ import { 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; @@ -129,6 +130,7 @@ export function useCommentDeepLink(pageId: string | undefined) { // 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( diff --git a/apps/client/src/features/editor/components/columns/columns-menu.tsx b/apps/client/src/features/editor/components/columns/columns-menu.tsx index d88d9f6a..71b11bd6 100644 --- a/apps/client/src/features/editor/components/columns/columns-menu.tsx +++ b/apps/client/src/features/editor/components/columns/columns-menu.tsx @@ -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(() => { diff --git a/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx index f473f8ff..989fc9b8 100644 --- a/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx +++ b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx @@ -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); diff --git a/apps/client/src/features/editor/components/search-and-replace/search-navigation-dialog.tsx b/apps/client/src/features/editor/components/search-and-replace/search-navigation-dialog.tsx index d4a072d9..b7585a8a 100644 --- a/apps/client/src/features/editor/components/search-and-replace/search-navigation-dialog.tsx +++ b/apps/client/src/features/editor/components/search-and-replace/search-navigation-dialog.tsx @@ -45,6 +45,7 @@ function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) { }); if (!position) return; + editor.commands.showTabAt(position.from); requestAnimationFrame(() => { document .querySelector(".search-result-current") diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.test.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.test.ts new file mode 100644 index 00000000..ecaadd9e --- /dev/null +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.test.ts @@ -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"); + }); +}); diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index d8598c5e..b75a75a3 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -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; }): 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 ( diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx index c4a082db..dfe1abf5 100644 --- a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx @@ -51,6 +51,7 @@ export const TableOfContents: FC = (props) => { const handleScrollToHeading = (position: number) => { if (!props.editor || props.editor.isDestroyed) return; + props.editor.commands.showTabAt(position); const { view } = props.editor; const headerOffset = parseInt( diff --git a/apps/client/src/features/editor/components/tabs/tab-actions-menu.tsx b/apps/client/src/features/editor/components/tabs/tab-actions-menu.tsx new file mode 100644 index 00000000..02a89e8c --- /dev/null +++ b/apps/client/src/features/editor/components/tabs/tab-actions-menu.tsx @@ -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 ( + + + + + + + + + + actions.rename(index)}> + {t("Rename")} + + actions.duplicate(index)}> + {t("Duplicate")} + + actions.move(index, index - 1)} + > + {t("Move left")} + + actions.move(index, index + 1)} + > + {t("Move right")} + + actions.remove(index)}> + {t("Delete tab")} + + + + ); +} diff --git a/apps/client/src/features/editor/components/tabs/tab-rename-input.tsx b/apps/client/src/features/editor/components/tabs/tab-rename-input.tsx new file mode 100644 index 00000000..53dd2c10 --- /dev/null +++ b/apps/client/src/features/editor/components/tabs/tab-rename-input.tsx @@ -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 ( + + 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)} + /> + + ); +} diff --git a/apps/client/src/features/editor/components/tabs/tab-slot.tsx b/apps/client/src/features/editor/components/tabs/tab-slot.tsx new file mode 100644 index 00000000..dccf1ce7 --- /dev/null +++ b/apps/client/src/features/editor/components/tabs/tab-slot.tsx @@ -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(null); + const [isDragging, setIsDragging] = useState(false); + const [closestEdge, setClosestEdge] = useState(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 ( +
+ {isRenaming ? ( + actions.finishRename(index, value)} + /> + ) : ( + actions.rename(index) : undefined} + > + {label || t("Untitled")} + + )} + {isActive && isEditable && !isRenaming && ( + + )} +
+ ); +} diff --git a/apps/client/src/features/editor/components/tabs/tabs-toolbar.tsx b/apps/client/src/features/editor/components/tabs/tabs-toolbar.tsx new file mode 100644 index 00000000..4ad52981 --- /dev/null +++ b/apps/client/src/features/editor/components/tabs/tabs-toolbar.tsx @@ -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; + +export default function TabsToolbar({ editor, getPos }: TabsToolbarProps) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef>(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 ( +
e.preventDefault()} + > + + + {copied ? ( + + ) : ( + + )} + + + + + + + +
+ ); +} diff --git a/apps/client/src/features/editor/components/tabs/tabs-view.test.tsx b/apps/client/src/features/editor/components/tabs/tabs-view.test.tsx new file mode 100644 index 00000000..51faa0a6 --- /dev/null +++ b/apps/client/src/features/editor/components/tabs/tabs-view.test.tsx @@ -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) => + 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((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 ; +} + +async function mountTabs( + editable: boolean, + extensions?: AnyExtension[], + view?: typeof TabsView, +) { + let editor!: Editor; + const utils = render( + + (editor = instance)} + /> + , + ); + 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) => ( + { + 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(".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(":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" ? "

dropped

" : ""), + }, + }); + 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) {} + }; + + 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 }[]; + 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", + ); + }); +}); diff --git a/apps/client/src/features/editor/components/tabs/tabs-view.tsx b/apps/client/src/features/editor/components/tabs/tabs-view.tsx new file mode 100644 index 00000000..cc09e9db --- /dev/null +++ b/apps/client/src/features/editor/components/tabs/tabs-view.tsx @@ -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(null); + const [renamingKey, setRenamingKey] = useState(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(".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(".dm-tabs__scroller"); + const slot = scroller?.querySelector( + `[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( + ":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 ( + setHasFocusWithin(true)} + onBlur={(event: FocusEvent) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + setHasFocusWithin(false); + } + }} + > + {showToolbar && (isEditorFocused || hasFocusWithin) && ( + + )} + {/* the drag handle shows this instead of a snapshot of the block */} + +
+ { + if (value !== null) activate(Number(value)); + }} + > + + + {tabs.map((tab, index) => ( + + ))} + + + + {isEditable && ( +
+ + + + + +
+ )} +
+ +
+ ); +} diff --git a/apps/client/src/features/editor/components/tabs/tabs.types.ts b/apps/client/src/features/editor/components/tabs/tabs.types.ts new file mode 100644 index 00000000..6b140b03 --- /dev/null +++ b/apps/client/src/features/editor/components/tabs/tabs.types.ts @@ -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; +}; diff --git a/apps/client/src/features/editor/extensions/drag-handle.test.ts b/apps/client/src/features/editor/extensions/drag-handle.test.ts new file mode 100644 index 00000000..8d25ff15 --- /dev/null +++ b/apps/client/src/features/editor/extensions/drag-handle.test.ts @@ -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(".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); + }); +}); diff --git a/apps/client/src/features/editor/extensions/drag-handle.ts b/apps/client/src/features/editor/extensions/drag-handle.ts index 8794b585..aa231cd8 100644 --- a/apps/client/src/features/editor/extensions/drag-handle.ts +++ b/apps/client/src/features/editor/extensions/drag-handle.ts @@ -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
    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: [], diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 76a4cf88..6543f777 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -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, diff --git a/apps/client/src/features/editor/extensions/tabs.test.ts b/apps/client/src/features/editor/extensions/tabs.test.ts new file mode 100644 index 00000000..4ce603c8 --- /dev/null +++ b/apps/client/src/features/editor/extensions/tabs.test.ts @@ -0,0 +1,1151 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import * as Y from "yjs"; +import { Editor, getSchema, type JSONContent } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { Collaboration } from "@tiptap/extension-collaboration"; +import { undoDepth } from "@tiptap/pm/history"; +import { DOMSerializer } from "@tiptap/pm/model"; +import { NodeSelection } from "@tiptap/pm/state"; +import { prosemirrorJSONToYDoc } from "@tiptap/y-tiptap"; +import { + Tab, + TabLabel, + TabPanel, + Tabs, + getActiveTabIndex, +} from "@docmost/editor-ext"; +import { showTabAtElement } from "@/features/editor/utils"; + +const paragraph = (text = ""): JSONContent => + text + ? { type: "paragraph", content: [{ type: "text", text }] } + : { type: "paragraph" }; + +const tab = (id: string, label: string, ...blocks: JSONContent[]) => ({ + type: "tab", + attrs: { id }, + content: [ + { type: "tabLabel", content: [{ type: "text", text: label }] }, + { type: "tabPanel", content: blocks.length ? blocks : [paragraph()] }, + ], +}); + +const tabsBlock = (...tabs: JSONContent[]): JSONContent => ({ + type: "tabs", + content: tabs, +}); + +let editor: Editor | 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; +}); + +function createEditor(...content: JSONContent[]) { + const element = document.createElement("div"); + document.body.appendChild(element); + editor = new Editor({ + element, + extensions: [StarterKit, Tabs, Tab, TabLabel, TabPanel], + content: { type: "doc", content }, + }); + return editor; +} + +function textPos(target: Editor, text: string, from = 0) { + let found = -1; + target.state.doc.descendants((node, pos) => { + if (found !== -1) return false; + if (node.isTextblock && node.textContent === text && pos >= from) { + found = pos + 1; + } + return true; + }); + return found; +} + +function tabIds(target: Editor) { + const ids: string[] = []; + target.state.doc.descendants((node) => { + if (node.type.name === "tab") ids.push(node.attrs.id); + return true; + }); + return ids; +} + +function blockHTML(target: Editor, pos: number) { + const wrapper = document.createElement("div"); + wrapper.appendChild( + DOMSerializer.fromSchema(target.schema).serializeNode( + target.state.doc.nodeAt(pos)!, + ), + ); + return wrapper.innerHTML; +} + +// pastes into the empty paragraph that ends the document +function pasteAtEnd(target: Editor, html: string) { + target.commands.setTextSelection(target.state.doc.content.size - 1); + // jsdom has no ClipboardEvent, which pasteHTML would create without one + target.view.pasteHTML(html, new Event("paste") as ClipboardEvent); +} + +function tabsPositions(target: Editor) { + const positions: number[] = []; + target.state.doc.descendants((node, pos) => { + if (node.type.name === "tabs") positions.push(pos); + return true; + }); + return positions; +} + +function labels(target: Editor, tabsPos = tabsPositions(target)[0]) { + const tabs = target.state.doc.nodeAt(tabsPos)!; + return Array.from( + { length: tabs.childCount }, + (_, index) => tabs.child(index).firstChild!.textContent, + ); +} + +function hiddenTabIds(target: Editor) { + return Array.from( + target.view.dom.querySelectorAll('[data-type="tab"].dm-tab-hidden'), + ).map((element) => element.getAttribute("data-tab-id")); +} + +function selectionAncestors(target: Editor) { + const { $head } = target.state.selection; + return Array.from( + { length: $head.depth + 1 }, + (_, depth) => $head.node(depth).type.name, + ); +} + +describe("tabs: per-viewer active tab", () => { + it("opens on the first tab and hides the others", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + ); + expect(getActiveTabIndex(ed.state, 0)).toBe(0); + expect(hiddenTabIds(ed)).toEqual(["b"]); + }); + + it("switches tabs without changing the document or the undo history", () => { + // ends in a paragraph so the trailing-node extension has nothing to append + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + paragraph(), + ); + const before = ed.state.doc; + ed.commands.setActiveTab(1, 0); + expect(ed.state.doc).toBe(before); + expect(undoDepth(ed.state)).toBe(0); + expect(getActiveTabIndex(ed.state, 0)).toBe(1); + expect(hiddenTabIds(ed)).toEqual(["a"]); + }); + + it("keeps the tab elements in place when switching", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + ); + const firstTab = ed.view.dom.querySelector('[data-tab-id="a"]'); + ed.commands.setActiveTab(1, 0); + expect(ed.view.dom.querySelector('[data-tab-id="a"]')).toBe(firstTab); + }); + + it("keeps the active tab when content is added above the block", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + ); + ed.commands.setActiveTab(1, 0); + ed.commands.insertContentAt(0, paragraph("above")); + const [tabsPos] = tabsPositions(ed); + expect(tabsPos).toBeGreaterThan(0); + expect(getActiveTabIndex(ed.state, tabsPos)).toBe(1); + expect(hiddenTabIds(ed)).toEqual(["a"]); + }); + + it("keeps nested blocks' hidden tabs when the outer block switches", () => { + const inner = tabsBlock(tab("c", "Gamma"), tab("d", "Delta")); + const ed = createEditor( + tabsBlock(tab("a", "Alpha", inner), tab("b", "Beta")), + ); + const [outerPos, innerPos] = tabsPositions(ed); + ed.commands.setActiveTab(1, innerPos); + expect(hiddenTabIds(ed).sort()).toEqual(["b", "c"]); + ed.commands.setActiveTab(1, outerPos); + expect(hiddenTabIds(ed).sort()).toEqual(["a", "c"]); + ed.commands.setActiveTab(0, outerPos); + expect(hiddenTabIds(ed).sort()).toEqual(["b", "c"]); + }); + + it("hides the other tabs of a pasted block", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + paragraph(), + ); + pasteAtEnd(ed, blockHTML(ed, 0)); + const [, copyPos] = tabsPositions(ed); + const copyIds = [0, 1].map( + (index) => ed.state.doc.nodeAt(copyPos)!.child(index).attrs.id, + ); + expect(hiddenTabIds(ed).sort()).toEqual(["b", copyIds[1]].sort()); + }); + + it("hides the right tabs after a tab is moved or deleted", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta"), tab("c", "Gamma")), + ); + ed.commands.setActiveTab(1, 0); + ed.commands.moveTab(1, 2, 0); + expect(hiddenTabIds(ed)).toEqual(["a", "c"]); + ed.commands.deleteTab(0, 0); + expect(hiddenTabIds(ed)).toEqual(["c"]); + }); + + it("keeps a tab opened by a command that later steps in the chain move", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + paragraph(), + ); + ed.chain().setActiveTab(1, 0).insertContentAt(0, paragraph("above")).run(); + const [tabsPos] = tabsPositions(ed); + expect(tabsPos).toBeGreaterThan(0); + expect(getActiveTabIndex(ed.state, tabsPos)).toBe(1); + expect(hiddenTabIds(ed)).toEqual(["a"]); + }); + + it("keeps the active tab when the block is wrapped and lifted", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + paragraph("tail"), + ); + ed.commands.setActiveTab(1, 0); + selectNode(ed, "tabs"); + ed.commands.wrapIn("blockquote"); + let [tabsPos] = tabsPositions(ed); + expect(ed.state.doc.firstChild!.type.name).toBe("blockquote"); + expect(getActiveTabIndex(ed.state, tabsPos)).toBe(1); + expect(hiddenTabIds(ed)).toEqual(["a"]); + + selectNode(ed, "tabs"); + ed.commands.lift("blockquote"); + [tabsPos] = tabsPositions(ed); + expect(tabsPos).toBe(0); + expect(getActiveTabIndex(ed.state, tabsPos)).toBe(1); + expect(hiddenTabIds(ed)).toEqual(["a"]); + }); + + it("showTabAt opens every tab that contains the position", () => { + const inner = tabsBlock( + tab("c", "Gamma"), + tab("d", "Delta", paragraph("deep")), + ); + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta", inner)), + ); + ed.commands.showTabAt(textPos(ed, "deep")); + const [outerPos, innerPos] = tabsPositions(ed); + expect(getActiveTabIndex(ed.state, outerPos)).toBe(1); + expect(getActiveTabIndex(ed.state, innerPos)).toBe(1); + }); +}); + +describe("tabs: revealing hidden content", () => { + it("leaves hidden tabs findable so find in page can reach them", () => { + const ed = createEditor(tabsBlock(tab("a", "Alpha"), tab("b", "Beta"))); + const hidden = ed.view.dom.querySelector('[data-tab-id="b"]')!; + expect(hidden.getAttribute("hidden")).toBe("until-found"); + expect( + ed.view.dom.querySelector('[data-tab-id="a"]')!.hasAttribute("hidden"), + ).toBe(false); + }); + + it("opens a hidden tab when the browser reveals a match in it", () => { + const ed = createEditor(tabsBlock(tab("a", "Alpha"), tab("b", "Beta"))); + ed.view.dom + .querySelector('[data-tab-id="b"]')! + .dispatchEvent(new Event("beforematch", { bubbles: true })); + expect(getActiveTabIndex(ed.state, 0)).toBe(1); + expect(hiddenTabIds(ed)).toEqual(["a"]); + }); + + it("showTabAtElement opens every hidden tab around an element", () => { + const inner = tabsBlock( + tab("c", "Gamma"), + tab("d", "Delta", paragraph("deep")), + ); + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta", inner)), + ); + showTabAtElement(ed.view.domAtPos(textPos(ed, "deep")).node as Element); + const [outerPos, innerPos] = tabsPositions(ed); + expect(getActiveTabIndex(ed.state, outerPos)).toBe(1); + expect(getActiveTabIndex(ed.state, innerPos)).toBe(1); + expect(hiddenTabIds(ed).sort()).toEqual(["a", "c"]); + }); + + it("showTabAtElement leaves visible content alone", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha", paragraph("shown")), tab("b", "Beta")), + ); + const before = ed.state; + showTabAtElement(ed.view.domAtPos(textPos(ed, "shown")).node as Element); + expect(ed.state).toBe(before); + }); +}); + +function panelOutline(target: Editor, tabsPos: number, index = 0) { + const out: string[] = []; + target.state.doc + .nodeAt(tabsPos)! + .child(index) + .lastChild!.forEach((node) => { + const level = node.type.name === "heading" ? node.attrs.level : ""; + out.push(`${node.type.name}${level}: ${node.textContent}`); + }); + return out; +} + +describe("tabs: no tabs inside tabs", () => { + it("insertTabs refuses inside a tab, including inside a block in it", () => { + const ed = createEditor( + tabsBlock( + tab( + "a", + "Alpha", + paragraph("inside"), + { type: "blockquote", content: [paragraph("quoted")] }, + ), + ), + paragraph("outside"), + ); + ed.commands.setTextSelection(textPos(ed, "inside")); + expect(ed.commands.insertTabs()).toBe(false); + ed.commands.setTextSelection(textPos(ed, "quoted")); + expect(ed.can().insertTabs()).toBe(false); + ed.commands.setTextSelection(textPos(ed, "outside")); + expect(ed.can().insertTabs()).toBe(true); + expect(tabsPositions(ed)).toHaveLength(1); + }); + + it("flattens a tabs block pasted into a tab into headings and content", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha", paragraph("inside"), paragraph())), + tabsBlock( + tab("x", "X", paragraph("x body")), + tab("y", "Y", paragraph("y body")), + ), + paragraph(), + ); + const [targetPos, copyPos] = tabsPositions(ed); + ed.commands.setTextSelection(emptyPanelLinePos(ed)); + ed.view.pasteHTML( + blockHTML(ed, copyPos), + new Event("paste") as ClipboardEvent, + ); + expect(tabsPositions(ed)).toEqual([targetPos, expect.any(Number)]); + expect(panelOutline(ed, targetPos)).toEqual([ + "paragraph: inside", + "heading4: X", + "paragraph: x body", + "heading4: Y", + "paragraph: y body", + ]); + }); + + it("pastes text copied from one tab into another without flattening anything", () => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", paragraph("alpha one"), paragraph("alpha two")), + tab("b", "Beta", paragraph("beta")), + ), + paragraph(), + ); + ed.commands.setTextSelection({ + from: textPos(ed, "alpha one") + 6, + to: textPos(ed, "alpha two") + 5, + }); + const { dom } = ed.view.serializeForClipboard(ed.state.selection.content()); + ed.commands.setActiveTab(1, 0); + ed.commands.setTextSelection(textPos(ed, "beta") + "beta".length); + ed.view.pasteHTML(dom.innerHTML, new Event("paste") as ClipboardEvent); + expect(tabsPositions(ed)).toEqual([0]); + expect(labels(ed)).toEqual(["Alpha", "Beta"]); + expect(panelOutline(ed, 0, 1)).toEqual([ + "paragraph: betaone", + "paragraph: alpha", + ]); + }); + + function dropTabsBlock(target: Editor, sourcePos: number, dropPos: number) { + const dragged = NodeSelection.create(target.state.doc, sourcePos); + target.view.dispatch(target.state.tr.setSelection(dragged)); + target.view.dragging = { + slice: dragged.content(), + move: true, + node: dragged, + } as typeof target.view.dragging; + target.view.posAtCoords = () => ({ pos: dropPos, inside: -1 }); + const drop = new Event("drop", { bubbles: true, cancelable: true }); + Object.assign(drop, { + clientX: 10, + clientY: 10, + dataTransfer: { files: [], types: [], getData: () => "" }, + }); + target.view.dom.dispatchEvent(drop); + return drop; + } + + it("refuses to drop a tabs block into a tab", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha", paragraph("inside"))), + tabsBlock(tab("x", "X", paragraph("x body"))), + paragraph("tail"), + ); + const before = ed.state.doc; + const [, sourcePos] = tabsPositions(ed); + const drop = dropTabsBlock(ed, sourcePos, textPos(ed, "inside") + 2); + expect(ed.state.doc).toBe(before); + expect(drop.defaultPrevented).toBe(true); + }); + + it("still drops a tabs block outside tabs", () => { + const ed = createEditor( + paragraph("lead"), + tabsBlock(tab("x", "X", paragraph("x body"))), + paragraph("tail"), + ); + const [sourcePos] = tabsPositions(ed); + dropTabsBlock(ed, sourcePos, ed.state.doc.content.size); + expect(ed.state.doc.firstChild!.textContent).toBe("lead"); + expect(ed.state.doc.child(1).textContent).toBe("tail"); + expect(tabsPositions(ed)).toHaveLength(1); + }); +}); + +describe("tabs: content taken out of a tab", () => { + function copyFromTab(target: Editor) { + target.commands.setTextSelection({ + from: textPos(target, "alpha one") + 6, + to: textPos(target, "alpha two") + 5, + }); + return target.view.serializeForClipboard(target.state.selection.content()) + .dom.innerHTML; + } + + it.each([ + ["the end of a line", "outside", "outsideone"], + ["an empty line", "", "one"], + ])( + "pastes text copied from a tab onto %s as plain content, not a new tabs block", + (_, line, pasted) => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", paragraph("alpha one"), paragraph("alpha two")), + ), + paragraph(line), + paragraph("tail"), + ); + const html = copyFromTab(ed); + const lineStart = ed.state.doc.firstChild!.nodeSize + 1; + ed.commands.setTextSelection(lineStart + line.length); + ed.view.pasteHTML(html, new Event("paste") as ClipboardEvent); + expect(tabsPositions(ed)).toEqual([0]); + expect(ed.state.doc.child(1).textContent).toBe(pasted); + expect(ed.state.doc.child(2).textContent).toBe("alpha"); + }, + ); + + it("keeps the tab when everything in it is replaced by a paste", () => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", paragraph("alpha one"), paragraph("alpha two")), + ), + paragraph("tail"), + ); + ed.commands.setTextSelection({ + from: textPos(ed, "alpha one"), + to: textPos(ed, "alpha two") + "alpha two".length, + }); + ed.view.pasteHTML("

    pasted

    ", new Event("paste") as ClipboardEvent); + expect(tabsPositions(ed)).toEqual([0]); + expect(labels(ed)).toEqual(["Alpha"]); + expect(panelOutline(ed, 0)).toEqual(["paragraph: pasted"]); + }); + + it("drops a selection dragged out of a tab as plain content, not a new tabs block", () => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", paragraph("alpha one"), paragraph("alpha two")), + ), + paragraph("outside"), + paragraph(), + ); + ed.commands.setTextSelection({ + from: textPos(ed, "alpha one"), + to: textPos(ed, "alpha two") + "alpha two".length, + }); + ed.view.dragging = { + slice: ed.state.selection.content(), + move: true, + } as typeof ed.view.dragging; + const emptyLine = ed.state.doc.content.size - 1; + ed.view.posAtCoords = () => ({ pos: emptyLine, inside: -1 }); + const drop = new Event("drop", { bubbles: true, cancelable: true }); + Object.assign(drop, { + clientX: 10, + clientY: 10, + dataTransfer: { files: [], types: [], getData: () => "" }, + }); + ed.view.dom.dispatchEvent(drop); + expect(tabsPositions(ed)).toEqual([0]); + expect(ed.state.doc.child(2).textContent).toBe("alpha one"); + expect(ed.state.doc.child(3).textContent).toBe("alpha two"); + }); +}); + +describe("tabs: commands", () => { + it("insertTabs adds two tabs and puts the cursor in the first panel", () => { + const ed = createEditor(paragraph()); + ed.commands.setTextSelection(1); + ed.commands.insertTabs(); + expect(labels(ed)).toEqual(["Tab 1", "Tab 2"]); + expect(selectionAncestors(ed)).toEqual([ + "doc", + "tabs", + "tab", + "tabPanel", + "paragraph", + ]); + expect(ed.state.selection.$head.index(1)).toBe(0); + }); + + it("addTab appends a numbered tab and switches to it", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + ); + ed.commands.addTab(0); + expect(labels(ed)).toEqual(["Alpha", "Beta", "Tab 3"]); + expect(getActiveTabIndex(ed.state, 0)).toBe(2); + }); + + it("duplicateTab copies the tab next to it with a new id and switches to it", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha", paragraph("body")), tab("b", "Beta")), + ); + ed.commands.duplicateTab(0, 0); + const tabs = ed.state.doc.nodeAt(0)!; + expect(labels(ed)).toEqual(["Alpha", "Alpha", "Beta"]); + expect(tabs.child(1).lastChild!.textContent).toBe("body"); + expect(tabs.child(1).attrs.id).not.toBe("a"); + expect(getActiveTabIndex(ed.state, 0)).toBe(1); + }); + + it("moveTab keeps the active tab selected", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta"), tab("c", "Gamma")), + ); + ed.commands.moveTab(0, 2, 0); + expect(labels(ed)).toEqual(["Beta", "Gamma", "Alpha"]); + expect(getActiveTabIndex(ed.state, 0)).toBe(2); + ed.commands.moveTab(0, 1, 0); + expect(labels(ed)).toEqual(["Gamma", "Beta", "Alpha"]); + expect(getActiveTabIndex(ed.state, 0)).toBe(2); + }); + + it("renameTab replaces the label and clears it without inserting a space", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + ); + ed.commands.renameTab(1, "Second", 0); + expect(labels(ed)).toEqual(["Alpha", "Second"]); + ed.commands.renameTab(1, "", 0); + expect(labels(ed)).toEqual(["Alpha", ""]); + }); + + it("deleteTab on the active tab switches to its neighbour", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta"), tab("c", "Gamma")), + ); + ed.commands.setActiveTab(1, 0); + ed.commands.deleteTab(1, 0); + expect(labels(ed)).toEqual(["Alpha", "Gamma"]); + expect(getActiveTabIndex(ed.state, 0)).toBe(1); + }); + + it("deleteTab on another tab keeps the active tab", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta"), tab("c", "Gamma")), + ); + ed.commands.setActiveTab(2, 0); + ed.commands.deleteTab(0, 0); + expect(labels(ed)).toEqual(["Beta", "Gamma"]); + expect(getActiveTabIndex(ed.state, 0)).toBe(1); + }); + + it("deleteTab on the last tab removes the block without throwing", () => { + const ed = createEditor(paragraph("before"), tabsBlock(tab("a", "Only"))); + const [tabsPos] = tabsPositions(ed); + expect(() => + ed.chain().focus().deleteTab(0, tabsPos).run(), + ).not.toThrow(); + expect(tabsPositions(ed)).toEqual([]); + }); +}); + +describe("tabs: unique tab ids", () => { + it("gives pasted tabs, nested ones included, new ids", () => { + const inner = tabsBlock(tab("c", "Gamma"), tab("d", "Delta")); + const ed = createEditor( + tabsBlock(tab("a", "Alpha", inner), tab("b", "Beta")), + paragraph(), + ); + pasteAtEnd(ed, blockHTML(ed, 0)); + expect(tabsPositions(ed)).toHaveLength(4); + const ids = tabIds(ed); + expect(ids).toHaveLength(8); + expect(new Set(ids).size).toBe(8); + expect(ids.every(Boolean)).toBe(true); + }); + + it("duplicateTab gives the copy's nested tabs new ids", () => { + const inner = tabsBlock(tab("c", "Gamma"), tab("d", "Delta")); + const ed = createEditor( + tabsBlock(tab("a", "Alpha", inner), tab("b", "Beta")), + ); + ed.commands.duplicateTab(0, 0); + const ids = tabIds(ed); + expect(ids).toHaveLength(7); + expect(new Set(ids).size).toBe(7); + }); + + it("generates an id for a tab whose HTML id is empty", () => { + const ed = createEditor(paragraph()); + ed.commands.setContent( + '
    L

    p

    ', + ); + expect(ed.state.doc.firstChild!.firstChild!.attrs.id).toBeTruthy(); + }); +}); + +describe("tabs: unique tab ids repair", () => { + it("renames repeated and empty ids on load so every tab can be opened", async () => { + const ed = createEditor( + tabsBlock( + tab("x", "First", paragraph("first")), + tab("x", "Second", paragraph("second")), + tab("", "Third"), + ), + paragraph(), + ); + // tiptap runs onCreate a task after mounting + await new Promise((resolve) => setTimeout(resolve, 0)); + const ids = tabIds(ed); + expect(ids[0]).toBe("x"); + expect(new Set(ids).size).toBe(3); + expect(ids.every(Boolean)).toBe(true); + expect(undoDepth(ed.state)).toBe(0); + + ed.commands.setActiveTab(1, 0); + expect(getActiveTabIndex(ed.state, 0)).toBe(1); + expect(hiddenTabIds(ed)).not.toContain(ids[1]); + }); + + it("renames the ids of an inserted block that repeat existing ones", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + paragraph(), + ); + ed.commands.insertContentAt( + ed.state.doc.content.size - 1, + tabsBlock(tab("a", "Copy A"), tab("b", "Copy B")), + ); + const ids = tabIds(ed); + expect(ids.slice(0, 2)).toEqual(["a", "b"]); + expect(new Set(ids).size).toBe(4); + }); +}); + +describe("tabs: collaboration", () => { + const extensions = [ + StarterKit.configure({ undoRedo: false }), + Tabs, + Tab, + TabLabel, + TabPanel, + ]; + const mounted: Editor[] = []; + + afterEach(() => { + mounted.splice(0).forEach((instance) => instance.destroy()); + }); + + function startSession(...content: JSONContent[]) { + const mount = (ydoc: Y.Doc) => { + const element = document.createElement("div"); + document.body.appendChild(element); + const instance = new Editor({ + element, + extensions: [...extensions, Collaboration.configure({ document: ydoc })], + }); + mounted.push(instance); + return instance; + }; + + const viewerDoc = prosemirrorJSONToYDoc( + getSchema(extensions), + { type: "doc", content }, + "default", + ); + const viewer = mount(viewerDoc); + + const typeRemotely = (pos: number, content: string | JSONContent) => { + const remoteDoc = new Y.Doc(); + Y.applyUpdate(remoteDoc, Y.encodeStateAsUpdate(viewerDoc)); + mount(remoteDoc).commands.insertContentAt(pos, content); + Y.applyUpdate( + viewerDoc, + Y.encodeStateAsUpdate(remoteDoc, Y.encodeStateVector(viewerDoc)), + "remote", + ); + }; + + return { viewer, typeRemotely }; + } + + it("keeps the active tab when a remote update replaces the document", () => { + const { viewer, typeRemotely } = startSession( + paragraph("lead"), + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + paragraph("tail"), + ); + const [tabsPos] = tabsPositions(viewer); + viewer.commands.setActiveTab(1, tabsPos); + + typeRemotely(1, "!"); + + expect(viewer.state.doc.firstChild!.textContent).toBe("!lead"); + const [movedPos] = tabsPositions(viewer); + expect(movedPos).toBe(tabsPos + 1); + expect(getActiveTabIndex(viewer.state, movedPos)).toBe(1); + }); + + it("keeps the active tab of a pasted copy when a remote update replaces the document", () => { + const { viewer, typeRemotely } = startSession( + paragraph("lead"), + tabsBlock( + tab("a", "Alpha", paragraph("a1")), + tab("b", "Beta", paragraph("b1")), + ), + paragraph(), + ); + pasteAtEnd(viewer, blockHTML(viewer, tabsPositions(viewer)[0])); + const [, copyPos] = tabsPositions(viewer); + viewer.commands.setActiveTab(1, copyPos); + viewer.commands.setTextSelection(textPos(viewer, "b1", copyPos)); + + typeRemotely(1, "!"); + + const [, movedCopyPos] = tabsPositions(viewer); + expect(movedCopyPos).toBe(copyPos + 1); + expect(getActiveTabIndex(viewer.state, movedCopyPos)).toBe(1); + expect(viewer.state.selection.from).toBeGreaterThan(movedCopyPos); + expect(viewer.state.selection.$head.parent.textContent).toBe("b1"); + }); + + it("keeps the active tab when a remote edit repeats the text next to it", () => { + const { viewer, typeRemotely } = startSession( + paragraph("aa"), + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + paragraph("tail"), + ); + const [tabsPos] = tabsPositions(viewer); + viewer.commands.setActiveTab(1, tabsPos); + + typeRemotely(2, "a"); + + expect(viewer.state.doc.firstChild!.textContent).toBe("aaa"); + expect(getActiveTabIndex(viewer.state, tabsPos + 1)).toBe(1); + expect(hiddenTabIds(viewer)).toEqual(["a"]); + }); + + it("keeps the active tab when a collaborator types inside it", () => { + const { viewer, typeRemotely } = startSession( + tabsBlock( + tab("a", "Alpha", paragraph("a1")), + tab("b", "Beta", paragraph("b1")), + ), + paragraph("tail"), + ); + viewer.commands.setActiveTab(1, 0); + + typeRemotely(textPos(viewer, "b1"), "!"); + + expect(textPos(viewer, "!b1")).toBeGreaterThan(0); + expect(getActiveTabIndex(viewer.state, 0)).toBe(1); + expect(hiddenTabIds(viewer)).toEqual(["a"]); + }); + + it("keeps the active tab of tabs without ids when a remote update replaces the document", () => { + const { viewer, typeRemotely } = startSession( + paragraph("lead"), + tabsBlock(tab("", "Alpha"), tab("", "Beta")), + paragraph("tail"), + ); + const [tabsPos] = tabsPositions(viewer); + viewer.commands.setActiveTab(1, tabsPos); + + typeRemotely(1, "!"); + + expect(getActiveTabIndex(viewer.state, tabsPos + 1)).toBe(1); + }); + + it("hides all but the first tab of a block a collaborator adds", () => { + const { viewer, typeRemotely } = startSession( + paragraph("lead"), + paragraph("tail"), + ); + + typeRemotely( + viewer.state.doc.firstChild!.nodeSize, + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + ); + + expect(tabsPositions(viewer)).toHaveLength(1); + expect(hiddenTabIds(viewer)).toEqual(["b"]); + }); + + it("repairs repeated ids in a block a collaborator adds", () => { + const { viewer, typeRemotely } = startSession( + paragraph("lead"), + paragraph("tail"), + ); + + typeRemotely( + viewer.state.doc.firstChild!.nodeSize, + tabsBlock(tab("x", "First"), tab("x", "Second")), + ); + + const ids = tabIds(viewer); + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); + }); + + it("keeps each block's active tab when blocks share tab ids", () => { + const { viewer, typeRemotely } = startSession( + paragraph("lead"), + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + paragraph("between"), + tabsBlock(tab("a", "Alpha"), tab("b", "Beta")), + paragraph("tail"), + ); + const [firstPos] = tabsPositions(viewer); + viewer.commands.setActiveTab(1, firstPos); + + typeRemotely(1, "!"); + + const indexes = tabsPositions(viewer).map((pos) => + getActiveTabIndex(viewer.state, pos), + ); + expect(indexes).toEqual([1, 0]); + }); +}); + +function emptyPanelLinePos(target: Editor) { + let found = -1; + target.state.doc.descendants((node, pos, parent) => { + if (found !== -1) return false; + if ( + node.type.name === "paragraph" && + node.content.size === 0 && + parent?.type.name === "tabPanel" + ) { + found = pos + 1; + } + return true; + }); + return found; +} + +function pressKey(target: Editor, key: string, keyCode: number) { + target.view.dom.dispatchEvent( + new KeyboardEvent("keydown", { + key, + keyCode, + bubbles: true, + cancelable: true, + }), + ); +} + +function selectNode(target: Editor, typeName: string) { + let found = -1; + target.state.doc.descendants((node, pos) => { + if (found !== -1) return false; + if (node.type.name === typeName) found = pos; + return true; + }); + target.view.dispatch( + target.state.tr.setSelection(NodeSelection.create(target.state.doc, found)), + ); +} + +describe("tabs: cursor placement", () => { + it("focus at the start of a page that begins with tabs lands in the first panel", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha", paragraph("first panel")), tab("b", "Beta")), + ); + ed.commands.focus("start"); + expect(selectionAncestors(ed)).toEqual([ + "doc", + "tabs", + "tab", + "tabPanel", + "paragraph", + ]); + expect(ed.state.selection.$head.parent.textContent).toBe("first panel"); + }); + + it("focus at the start lands in the first panel when the page ends in a paragraph", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha", paragraph("first panel")), tab("b", "Beta")), + paragraph("tail"), + ); + ed.commands.focus("start"); + expect(selectionAncestors(ed)).toEqual([ + "doc", + "tabs", + "tab", + "tabPanel", + "paragraph", + ]); + expect(ed.state.selection.$head.parent.textContent).toBe("first panel"); + }); + + it("moves the cursor into the new tab when its tab gets hidden", () => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", paragraph("first panel")), + tab("b", "Beta", paragraph("second panel")), + ), + ); + ed.commands.setTextSelection(textPos(ed, "first panel")); + ed.commands.setActiveTab(1, 0); + expect(ed.state.selection.$head.parent.textContent).toBe("second panel"); + }); + + it("keeps a cursor placed in a hidden tab inside the visible one", () => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", paragraph("first panel")), + tab("b", "Beta", paragraph("second panel")), + ), + ); + ed.commands.setTextSelection(textPos(ed, "second panel")); + expect(getActiveTabIndex(ed.state, 0)).toBe(0); + expect(ed.state.selection.$head.parent.textContent).toBe("first panel"); + }); + + it("moves a selection resting directly in the tabs block into the visible tab", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha", paragraph("first panel")), tab("b", "Beta")), + paragraph("after"), + ); + const secondTabPos = 1 + ed.state.doc.firstChild!.firstChild!.nodeSize; + ed.view.dispatch( + ed.state.tr.setSelection(NodeSelection.create(ed.state.doc, secondTabPos)), + ); + expect(ed.state.selection.$head.parent.textContent).toBe("first panel"); + }); + + it("does not switch back when find and replace resets the selection", () => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", paragraph("first panel")), + tab("b", "Beta", paragraph("second panel")), + ), + ); + const match = textPos(ed, "second panel"); + ed.commands.showTabAt(match); + ed.commands.setTextSelection(match); + ed.commands.setTextSelection(0); + expect(getActiveTabIndex(ed.state, 0)).toBe(1); + expect(ed.state.selection.$head.parent.textContent).toBe("second panel"); + }); + + it("drops a selection inside a tab that gets hidden instead of stretching it into the new tab", () => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", paragraph("alpha text")), + tab("b", "Beta", paragraph("beta text")), + ), + paragraph("tail"), + ); + ed.commands.setTextSelection({ + from: textPos(ed, "alpha text"), + to: textPos(ed, "alpha text") + 5, + }); + ed.commands.setActiveTab(1, 0); + expect(ed.state.selection.empty).toBe(true); + expect(ed.state.selection.$head.parent.textContent).toBe("beta text"); + }); + + it("settles inside the inner visible tab when an outer panel opens with nested tabs", () => { + const inner = tabsBlock( + tab("c", "Gamma", paragraph("inner one")), + tab("d", "Delta", paragraph("inner two")), + ); + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", inner), + tab("b", "Beta", paragraph("outer two")), + ), + paragraph("tail"), + ); + ed.commands.setActiveTab(1, 0); + ed.commands.setTextSelection(textPos(ed, "outer two")); + ed.commands.setActiveTab(0, 0); + expect(ed.state.selection.$head.parent.textContent).toBe("inner one"); + expect(selectionAncestors(ed)).not.toContain("tabLabel"); + }); +}); + +describe("tabs: arrow keys at the edges of a tab", () => { + const divider: JSONContent = { type: "horizontalRule" }; + + it("leaves the block downward past a divider that ends the tab", () => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", paragraph("first line"), divider), + tab("b", "Beta", paragraph("beta")), + ), + paragraph("after the block"), + ); + selectNode(ed, "horizontalRule"); + pressKey(ed, "ArrowDown", 40); + expect(ed.state.selection.$head.parent.type.name).toBe("tabPanel"); + pressKey(ed, "ArrowDown", 40); + expect(ed.state.selection.$head.parent.textContent).toBe("after the block"); + expect(getActiveTabIndex(ed.state, 0)).toBe(0); + }); + + it("leaves the block upward past a divider that starts the tab", () => { + const ed = createEditor( + paragraph("before the block"), + tabsBlock( + tab("a", "Alpha", divider, paragraph("first line")), + tab("b", "Beta", paragraph("beta")), + ), + paragraph("after"), + ); + selectNode(ed, "horizontalRule"); + pressKey(ed, "ArrowUp", 38); + expect(ed.state.selection.$head.parent.textContent).toBe("before the block"); + }); + + // jsdom has no layout for vertical arrows, so these move the selection the way + // ProseMirror resolves them: into the nearest text, hidden or not + it("moving up into the block from below lands at the end of the visible tab", () => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", paragraph("a first"), paragraph("a last")), + tab("b", "Beta", paragraph("b last")), + ), + paragraph("after"), + ); + ed.commands.setTextSelection(textPos(ed, "after")); + ed.commands.setTextSelection(textPos(ed, "b last") + "b last".length); + expect(ed.state.selection.head).toBe( + textPos(ed, "a last") + "a last".length, + ); + }); + + it("keeps the anchor when a selection is extended into the block", () => { + const ed = createEditor( + paragraph("before"), + tabsBlock( + tab("a", "Alpha", paragraph("a first")), + tab("b", "Beta", paragraph("b first")), + ), + paragraph("after"), + ); + const [tabsPos] = tabsPositions(ed); + ed.commands.setActiveTab(1, tabsPos); + const anchor = textPos(ed, "before") + 2; + ed.commands.setTextSelection(anchor); + ed.commands.setTextSelection({ from: anchor, to: textPos(ed, "Alpha") }); + expect(ed.state.selection.anchor).toBe(anchor); + expect(ed.state.selection.head).toBe(textPos(ed, "b first")); + }); + + it("keeps the anchor when a selection is extended out of the block", () => { + const ed = createEditor( + paragraph("before"), + tabsBlock( + tab("a", "Alpha", paragraph("a first")), + tab("b", "Beta", paragraph("b first")), + ), + paragraph("after"), + ); + const anchor = textPos(ed, "a first") + 3; + ed.commands.setTextSelection(anchor); + ed.commands.setTextSelection({ from: anchor, to: textPos(ed, "Alpha") }); + expect(ed.state.selection.anchor).toBe(anchor); + expect(ed.state.selection.head).toBe( + textPos(ed, "before") + "before".length, + ); + }); + + it("stays in the visible tab when nothing comes before the block", () => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", divider, paragraph("first line")), + tab("b", "Beta", paragraph("beta")), + ), + paragraph("after"), + ); + selectNode(ed, "horizontalRule"); + pressKey(ed, "ArrowUp", 38); + expect(selectionAncestors(ed)).toEqual(["doc", "tabs", "tab", "tabPanel"]); + expect(ed.state.selection.$head.index(1)).toBe(0); + }); +}); + +describe("tabs: Enter", () => { + it("adds a line on an empty line in the middle of a tab", () => { + const ed = createEditor( + tabsBlock( + tab("a", "Alpha", paragraph("A"), paragraph(), paragraph("B")), + ), + ); + ed.commands.setTextSelection(emptyPanelLinePos(ed)); + pressKey(ed, "Enter", 13); + const panel = ed.state.doc.firstChild!.firstChild!.lastChild!; + expect(panel.childCount).toBe(4); + expect(selectionAncestors(ed)).toContain("tabPanel"); + }); + + it("adds a line on the empty last line of a tab instead of leaving it", () => { + const ed = createEditor( + tabsBlock(tab("a", "Alpha", paragraph("A"), paragraph())), + ); + ed.commands.setTextSelection(emptyPanelLinePos(ed)); + pressKey(ed, "Enter", 13); + const panel = ed.state.doc.firstChild!.firstChild!.lastChild!; + expect(panel.childCount).toBe(3); + expect(selectionAncestors(ed)).toContain("tabPanel"); + }); +}); diff --git a/apps/client/src/features/editor/hooks/use-editor-scroll.ts b/apps/client/src/features/editor/hooks/use-editor-scroll.ts index 0f1759cf..dbbbe211 100644 --- a/apps/client/src/features/editor/hooks/use-editor-scroll.ts +++ b/apps/client/src/features/editor/hooks/use-editor-scroll.ts @@ -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 { 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 { diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index cb49785a..1b2271ac 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -17,5 +17,6 @@ @import "./indent.css"; @import "./columns.css"; @import "./status.css"; +@import "./tabs.css"; @import "./base-embed.css"; @import "./footnotes.css"; diff --git a/apps/client/src/features/editor/styles/tabs.css b/apps/client/src/features/editor/styles/tabs.css new file mode 100644 index 00000000..3e460f37 --- /dev/null +++ b/apps/client/src/features/editor/styles/tabs.css @@ -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; + } + } +} diff --git a/apps/client/src/features/editor/utils/copy-node-to-clipboard.ts b/apps/client/src/features/editor/utils/copy-node-to-clipboard.ts new file mode 100644 index 00000000..4af09d91 --- /dev/null +++ b/apps/client/src/features/editor/utils/copy-node-to-clipboard.ts @@ -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 { + 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); +} diff --git a/apps/client/src/features/editor/utils/index.ts b/apps/client/src/features/editor/utils/index.ts index a9f6bd3b..2b345554 100644 --- a/apps/client/src/features/editor/utils/index.ts +++ b/apps/client/src/features/editor/utils/index.ts @@ -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"; diff --git a/apps/client/src/features/editor/utils/show-tab-at-element.ts b/apps/client/src/features/editor/utils/show-tab-at-element.ts new file mode 100644 index 00000000..a01f41d4 --- /dev/null +++ b/apps/client/src/features/editor/utils/show-tab-at-element.ts @@ -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 })); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-toc.test.tsx b/apps/client/src/features/public-space/components/docs/docs-toc.test.tsx new file mode 100644 index 00000000..cf511e01 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-toc.test.tsx @@ -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 type reads as read-only + store.set(readOnlyEditorAtom as PrimitiveAtom, editor); + render( + + + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Hidden heading" })); + }); + + expect(activeWhenScrolled).toBe(1); + expect(getActiveTabIndex(editor.state, 0)).toBe(1); + }); +}); diff --git a/apps/client/src/features/public-space/components/docs/docs-toc.tsx b/apps/client/src/features/public-space/components/docs/docs-toc.tsx index ceb43641..7e2ae544 100644 --- a/apps/client/src/features/public-space/components/docs/docs-toc.tsx +++ b/apps/client/src/features/public-space/components/docs/docs-toc.tsx @@ -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); diff --git a/apps/client/vitest.config.ts b/apps/client/vitest.config.ts index 5cde717a..b2e75956 100644 --- a/apps/client/vitest.config.ts +++ b/apps/client/vitest.config.ts @@ -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'), }, }, diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 1c527ea2..312d7762 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -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, diff --git a/apps/server/src/ee b/apps/server/src/ee index cc28defe..f0086cfe 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit cc28defeec44c8f71e686cbec6e4298bb447e0e7 +Subproject commit f0086cfe7705b8aa05ccb385c1c690426f48dc5f diff --git a/apps/server/src/integrations/export/html-to-markdown.spec.ts b/apps/server/src/integrations/export/html-to-markdown.spec.ts index 8e4e43d0..340323c2 100644 --- a/apps/server/src/integrations/export/html-to-markdown.spec.ts +++ b/apps/server/src/integrations/export/html-to-markdown.spec.ts @@ -51,4 +51,19 @@ describe('htmlToMarkdown', () => { '', ]); }); + + it('exports each tab as a level 4 heading followed by its content', () => { + const markdown = htmlToMarkdown( + '
    ' + + '
    Windows
    ' + + '

    Run setup.exe

    ' + + '
    Linux *beta*
    ' + + '

    Run setup.sh

    ' + + '
    ', + ); + + expect(markdown.trim()).toBe( + '#### Windows\n\nRun setup.exe\n\n#### Linux \\*beta\\*\n\nRun setup.sh', + ); + }); }); diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 80a67f44..cc1631b1 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -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"; diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index 3914d792..bb48c095 100644 --- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -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('
    ', ' '); } +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 + ')'; }, }); diff --git a/packages/editor-ext/src/lib/prosemirror-docx/schema.ts b/packages/editor-ext/src/lib/prosemirror-docx/schema.ts index 235d796b..c7b2d140 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/schema.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/schema.ts @@ -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); }, diff --git a/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts b/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts index b349ece3..739f6f51 100644 --- a/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts +++ b/packages/editor-ext/src/lib/prosemirror-docx/serializer.ts @@ -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 { diff --git a/packages/editor-ext/src/lib/tabs/index.ts b/packages/editor-ext/src/lib/tabs/index.ts new file mode 100644 index 00000000..5ac76253 --- /dev/null +++ b/packages/editor-ext/src/lib/tabs/index.ts @@ -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"; diff --git a/packages/editor-ext/src/lib/tabs/tab-label.ts b/packages/editor-ext/src/lib/tabs/tab-label.ts new file mode 100644 index 00000000..dbbcb641 --- /dev/null +++ b/packages/editor-ext/src/lib/tabs/tab-label.ts @@ -0,0 +1,42 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export interface TabLabelOptions { + HTMLAttributes: Record; +} + +export const TabLabel = Node.create({ + 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, + ]; + }, +}); diff --git a/packages/editor-ext/src/lib/tabs/tab-panel.ts b/packages/editor-ext/src/lib/tabs/tab-panel.ts new file mode 100644 index 00000000..69e87aaa --- /dev/null +++ b/packages/editor-ext/src/lib/tabs/tab-panel.ts @@ -0,0 +1,43 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export interface TabPanelOptions { + HTMLAttributes: Record; +} + +export const TabPanel = Node.create({ + 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, + ]; + }, +}); diff --git a/packages/editor-ext/src/lib/tabs/tab.ts b/packages/editor-ext/src/lib/tabs/tab.ts new file mode 100644 index 00000000..9fe50765 --- /dev/null +++ b/packages/editor-ext/src/lib/tabs/tab.ts @@ -0,0 +1,52 @@ +import { mergeAttributes, Node } from '@tiptap/core'; +import { generateNodeId } from "../utils"; + +export interface TabOptions { + HTMLAttributes: Record; +} + +export const Tab = Node.create({ + 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, + ]; + }, +}); diff --git a/packages/editor-ext/src/lib/tabs/tabs-state.ts b/packages/editor-ext/src/lib/tabs/tabs-state.ts new file mode 100644 index 00000000..6426e390 --- /dev/null +++ b/packages/editor-ext/src/lib/tabs/tabs-state.ts @@ -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; + decorations: DecorationSet; + // bumped whenever tabs are added, removed or split + structureVersion: number; +}; + +const tabsViewPluginKey = new PluginKey('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(); + 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, + mapping: Mapping, + doc: PMNode, + changedBlocks: number[] | null, +) { + const mapped = new Map(); + 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(); + 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, +) { + 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) { + 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, +) { + const tabs = doc.nodeAt(tabsPos); + if (!isTabsNode(tabs)) return decorations; + + const tabEnds = new Map(); + 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({ + 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); + }, + }); +} diff --git a/packages/editor-ext/src/lib/tabs/tabs.ts b/packages/editor-ext/src/lib/tabs/tabs.ts new file mode 100644 index 00000000..a6d3e715 --- /dev/null +++ b/packages/editor-ext/src/lib/tabs/tabs.ts @@ -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; + view: ComponentType> | null; +} + +declare module '@tiptap/core' { + interface Commands { + 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({ + 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(), + ]; + }, +}); diff --git a/packages/editor-ext/src/lib/tabs/tabs.utils.ts b/packages/editor-ext/src/lib/tabs/tabs.utils.ts new file mode 100644 index 00000000..57b106f1 --- /dev/null +++ b/packages/editor-ext/src/lib/tabs/tabs.utils.ts @@ -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(); + 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; +} From 83c70e4d00ebb2c9cd648d69621571f435c976b8 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:10:29 +0100 Subject: [PATCH 5/5] fix: crash when previewing pages in trash (#2559) --- apps/client/src/features/editor/readonly-page-editor.tsx | 2 +- apps/server/src/ee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index f99bfc04..a1032862 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -143,7 +143,7 @@ export default function ReadonlyPageEditor({ return true; }, } - : undefined + : {} } onCreate={({ editor }) => { if (editor) { diff --git a/apps/server/src/ee b/apps/server/src/ee index f0086cfe..71625f8e 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit f0086cfe7705b8aa05ccb385c1c690426f48dc5f +Subproject commit 71625f8e246a7680991302fede87ba999c9db9da