From 3cb954db693a7ca8522a0a8987c19b9f850826bf Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Thu, 16 Jan 2025 12:48:35 +0000 Subject: [PATCH] fix: editor improvements (#583) * delete unused component * return page prosemirror content * prefetch pages * use prosemirro json content on editor * cache page query with id and slug as key * Show notice on collaboration disconnection * enable scroll while typing * enable immediatelyRender * avoid image break in PDF print * Comment editor rendering props --- .../comment/components/comment-editor.tsx | 2 + .../src/features/editor/atoms/editor-atoms.ts | 6 +- .../src/features/editor/full-editor.tsx | 4 +- .../src/features/editor/page-editor.tsx | 101 ++++++++++++------ .../src/features/editor/styles/media.css | 4 + .../src/features/editor/title-editor.tsx | 4 +- .../components/header/page-header-menu.tsx | 16 ++- .../src/features/page/queries/page-query.ts | 16 ++- .../page/tree/components/space-tree.tsx | 21 ++++ .../tree/components/tree-collapse.module.css | 27 ----- .../page/tree/components/tree-collapse.tsx | 59 ---------- apps/client/src/pages/page/page.tsx | 4 +- apps/server/src/core/page/dto/page.dto.ts | 4 + apps/server/src/core/page/page.controller.ts | 1 + package.json | 1 - pnpm-lock.yaml | 16 --- 16 files changed, 144 insertions(+), 142 deletions(-) delete mode 100644 apps/client/src/features/page/tree/components/tree-collapse.module.css delete mode 100644 apps/client/src/features/page/tree/components/tree-collapse.tsx diff --git a/apps/client/src/features/comment/components/comment-editor.tsx b/apps/client/src/features/comment/components/comment-editor.tsx index 850d6b92..09116f1a 100644 --- a/apps/client/src/features/comment/components/comment-editor.tsx +++ b/apps/client/src/features/comment/components/comment-editor.tsx @@ -48,6 +48,8 @@ const CommentEditor = forwardRef( }, content: defaultContent, editable, + immediatelyRender: true, + shouldRerenderOnTransaction: false, autofocus: (autofocus && "end") || false, }); diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index e15b9ff2..6f54c057 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -1,6 +1,8 @@ -import { atom } from 'jotai'; -import { Editor } from '@tiptap/core'; +import { atom } from "jotai"; +import { Editor } from "@tiptap/core"; export const pageEditorAtom = atom(null); export const titleEditorAtom = atom(null); + +export const yjsConnectionStatusAtom = atom(""); diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index bed83a62..3731e12c 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -13,6 +13,7 @@ export interface FullEditorProps { pageId: string; slugId: string; title: string; + content: string; spaceSlug: string; editable: boolean; } @@ -21,6 +22,7 @@ export function FullEditor({ pageId, title, slugId, + content, spaceSlug, editable, }: FullEditorProps) { @@ -40,7 +42,7 @@ export function FullEditor({ spaceSlug={spaceSlug} editable={editable} /> - + ); } diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 2349e4f1..a896a63a 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -8,8 +8,8 @@ import React, { } from "react"; import { IndexeddbPersistence } from "y-indexeddb"; import * as Y from "yjs"; -import { HocuspocusProvider } from "@hocuspocus/provider"; -import { EditorContent, useEditor } from "@tiptap/react"; +import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider"; +import { EditorContent, EditorProvider, useEditor } from "@tiptap/react"; import { collabExtensions, mainExtensions, @@ -18,14 +18,16 @@ import { useAtom } from "jotai"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; -import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; +import { + pageEditorAtom, + yjsConnectionStatusAtom, +} from "@/features/editor/atoms/editor-atoms"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; import { activeCommentIdAtom, showCommentPopupAtom, } from "@/features/comment/atoms/comment-atom"; import CommentDialog from "@/features/comment/components/comment-dialog"; -import EditorSkeleton from "@/features/editor/components/editor-skeleton"; import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu"; import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx"; import TableMenu from "@/features/editor/components/table/table-menu.tsx"; @@ -43,9 +45,14 @@ import DrawioMenu from "./components/drawio/drawio-menu"; interface PageEditorProps { pageId: string; editable: boolean; + content: any; } -export default function PageEditor({ pageId, editable }: PageEditorProps) { +export default function PageEditor({ + pageId, + editable, + content, +}: PageEditorProps) { const [token] = useAtom(authTokensAtom); const collaborationURL = useCollaborationUrl(); const [currentUser] = useAtom(currentUserAtom); @@ -56,8 +63,11 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) { const ydoc = useMemo(() => new Y.Doc(), [pageId]); const [isLocalSynced, setLocalSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false); - const documentName = `page.${pageId}`; + const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( + yjsConnectionStatusAtom, + ); const menuContainerRef = useRef(null); + const documentName = `page.${pageId}`; const localProvider = useMemo(() => { const provider = new IndexeddbPersistence(documentName, ydoc); @@ -76,12 +86,21 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) { document: ydoc, token: token?.accessToken, connect: false, + onStatus: (status) => { + if (status.status === "connected") { + setYjsConnectionStatus(status.status); + } + }, }); provider.on("synced", () => { setRemoteSynced(true); }); + provider.on("disconnect", () => { + setYjsConnectionStatus(WebSocketStatus.Disconnected); + }); + return provider; }, [ydoc, pageId, token?.accessToken]); @@ -97,15 +116,18 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) { }, [remoteProvider, localProvider]); const extensions = [ - ... mainExtensions, - ... collabExtensions(remoteProvider, currentUser.user), + ...mainExtensions, + ...collabExtensions(remoteProvider, currentUser.user), ]; const editor = useEditor( { extensions, editable, + immediatelyRender: true, editorProps: { + scrollThreshold: 80, + scrollMargin: 80, handleDOMEvents: { keydown: (_view, event) => { if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { @@ -157,36 +179,51 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) { setAsideState({ tab: "", isAsideOpen: false }); }, [pageId]); - const isSynced = isLocalSynced || isRemoteSynced; + useEffect(() => { + if (editable) { + if (yjsConnectionStatus === WebSocketStatus.Connected) { + editor.setEditable(true); + } else { + // disable edits if connection fails + editor.setEditable(false); + } + } + }, [yjsConnectionStatus]); + + const isSynced = isLocalSynced && isRemoteSynced; return isSynced ? (
- {isSynced && ( -
- +
+ - {editor && editor.isEditable && ( -
- - - - - - - - - -
- )} + {editor && editor.isEditable && ( +
+ + + + + + + + + +
+ )} - {showCommentPopup && ( - - )} -
- )} -
editor.commands.focus('end')} style={{ paddingBottom: '20vh' }}>
+ {showCommentPopup && } +
+ +
editor.commands.focus("end")} + style={{ paddingBottom: "20vh" }} + >
) : ( - + ); } diff --git a/apps/client/src/features/editor/styles/media.css b/apps/client/src/features/editor/styles/media.css index 714822e8..ac9cf3f7 100644 --- a/apps/client/src/features/editor/styles/media.css +++ b/apps/client/src/features/editor/styles/media.css @@ -2,6 +2,10 @@ img { max-width: 100%; height: auto; + + @media print { + break-inside: avoid; + } } .node-image, .node-video, .node-excalidraw, .node-drawio { diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 5d0a54f7..d88ebf31 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -38,7 +38,7 @@ export function TitleEditor({ }: TitleEditorProps) { const { t } = useTranslation(); const [debouncedTitleState, setDebouncedTitleState] = useState(null); - const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500); + const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 700); const { data: updatedPageData, mutate: updatePageMutation, @@ -81,6 +81,8 @@ export function TitleEditor({ }, editable: editable, content: title, + immediatelyRender: true, + shouldRerenderOnTransaction: false, }); useEffect(() => { diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 8a0822c0..5531b217 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -8,6 +8,7 @@ import { IconMessage, IconPrinter, IconTrash, + IconWifiOff, } from "@tabler/icons-react"; import React from "react"; import useToggleAside from "@/hooks/use-toggle-aside.tsx"; @@ -23,18 +24,31 @@ import { extractPageSlugId } from "@/lib"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; -import PageExportModal from "@/features/page/components/page-export-modal.tsx"; import { useTranslation } from "react-i18next"; import ExportModal from "@/components/common/export-modal"; +import { yjsConnectionStatusAtom } from "@/features/editor/atoms/editor-atoms.ts"; interface PageHeaderMenuProps { readOnly?: boolean; } export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { const toggleAside = useToggleAside(); + const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom); return ( <> + {yjsConnectionStatus === "disconnected" && ( + + + + + + )} + , ): UseQueryResult { - return useQuery({ + const query = useQuery({ queryKey: ["pages", pageInput.pageId], queryFn: () => getPageById(pageInput), enabled: !!pageInput.pageId, staleTime: 5 * 60 * 1000, }); + + useEffect(() => { + if (query.data) { + if (isValidUuid(pageInput.pageId)) { + queryClient.setQueryData(["pages", query.data.slugId], query.data); + } else { + queryClient.setQueryData(["pages", query.data.id], query.data); + } + } + }, [query.data]); + + return query; } export function useCreatePageMutation() { diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index b9e55377..03a0685f 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -35,6 +35,7 @@ import { import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { getPageBreadcrumbs, + getPageById, getSidebarPages, } from "@/features/page/services/page-service.ts"; import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts"; @@ -232,6 +233,24 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { const [treeData, setTreeData] = useAtom(treeDataAtom); const emit = useQueryEmit(); const { spaceSlug } = useParams(); + const timerRef = useRef(null); + + const prefetchPage = () => { + timerRef.current = setTimeout(() => { + queryClient.prefetchQuery({ + queryKey: ["pages", node.data.slugId], + queryFn: () => getPageById({ pageId: node.data.slugId }), + staleTime: 5 * 60 * 1000, + }); + }, 150); + }; + + const cancelPagePrefetch = () => { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }; async function handleLoadChildren(node: NodeApi) { if (!node.data.hasChildren) return; @@ -330,6 +349,8 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { className={clsx(classes.node, node.state)} ref={dragHandle} onClick={handleClick} + onMouseEnter={prefetchPage} + onMouseLeave={cancelPagePrefetch} > handleLoadChildren(node)} /> diff --git a/apps/client/src/features/page/tree/components/tree-collapse.module.css b/apps/client/src/features/page/tree/components/tree-collapse.module.css deleted file mode 100644 index 9decc50a..00000000 --- a/apps/client/src/features/page/tree/components/tree-collapse.module.css +++ /dev/null @@ -1,27 +0,0 @@ -.control { - font-weight: 500; - display: block; - width: 100%; - padding: var(--mantine-spacing-xs) var(--mantine-spacing-xs); - color: var(--mantine-color-text); - font-size: var(--mantine-font-size-sm); - - @mixin hover { - background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7)); - color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); - } -} - -.item { - display: block; - text-decoration: none; - padding: var(--mantine-spacing-xs) var(--mantine-spacing-md); - padding-left: 4px; - margin-left: var(--mantine-spacing-sm); - font-size: var(--mantine-font-size-sm); - color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); -} - -.chevron { - transition: transform 200ms ease; -} diff --git a/apps/client/src/features/page/tree/components/tree-collapse.tsx b/apps/client/src/features/page/tree/components/tree-collapse.tsx deleted file mode 100644 index c1cf7e59..00000000 --- a/apps/client/src/features/page/tree/components/tree-collapse.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { ReactNode, useState } from "react"; -import { - Group, - Box, - Collapse, - ThemeIcon, - UnstyledButton, - rem, -} from "@mantine/core"; -import { IconChevronRight } from "@tabler/icons-react"; -import classes from "./tree-collapse.module.css"; - -interface TreeCollapseProps { - icon?: React.FC; - label: string; - initiallyOpened?: boolean; - children: ReactNode; -} - -export function TreeCollapse({ - icon: Icon, - label, - initiallyOpened, - children, -}: TreeCollapseProps) { - const [opened, setOpened] = useState(initiallyOpened || false); - - return ( - <> - setOpened((o) => !o)} - className={classes.control} - > - - - - - - {label} - - - - - - - -
{children}
-
- - ); -} diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index edbda600..cc6a80b7 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -25,7 +25,7 @@ export default function Page() { const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); const spaceRules = space?.membership?.permissions; - const spaceAbility = useSpaceAbility(spaceRules); + const spaceAbility = useSpaceAbility(spaceRules); if (isLoading) { return <>; @@ -55,8 +55,10 @@ export default function Page() { />