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
This commit is contained in:
Philip Okugbe
2025-01-16 12:48:35 +00:00
committed by GitHub
parent 71cfe3cd8e
commit 3cb954db69
16 changed files with 144 additions and 142 deletions

View File

@ -48,6 +48,8 @@ const CommentEditor = forwardRef(
}, },
content: defaultContent, content: defaultContent,
editable, editable,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: (autofocus && "end") || false, autofocus: (autofocus && "end") || false,
}); });

View File

@ -1,6 +1,8 @@
import { atom } from 'jotai'; import { atom } from "jotai";
import { Editor } from '@tiptap/core'; import { Editor } from "@tiptap/core";
export const pageEditorAtom = atom<Editor | null>(null); export const pageEditorAtom = atom<Editor | null>(null);
export const titleEditorAtom = atom<Editor | null>(null); export const titleEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");

View File

@ -13,6 +13,7 @@ export interface FullEditorProps {
pageId: string; pageId: string;
slugId: string; slugId: string;
title: string; title: string;
content: string;
spaceSlug: string; spaceSlug: string;
editable: boolean; editable: boolean;
} }
@ -21,6 +22,7 @@ export function FullEditor({
pageId, pageId,
title, title,
slugId, slugId,
content,
spaceSlug, spaceSlug,
editable, editable,
}: FullEditorProps) { }: FullEditorProps) {
@ -40,7 +42,7 @@ export function FullEditor({
spaceSlug={spaceSlug} spaceSlug={spaceSlug}
editable={editable} editable={editable}
/> />
<MemoizedPageEditor pageId={pageId} editable={editable} /> <MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
</Container> </Container>
); );
} }

View File

@ -8,8 +8,8 @@ import React, {
} from "react"; } from "react";
import { IndexeddbPersistence } from "y-indexeddb"; import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs"; import * as Y from "yjs";
import { HocuspocusProvider } from "@hocuspocus/provider"; import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
import { EditorContent, useEditor } from "@tiptap/react"; import { EditorContent, EditorProvider, useEditor } from "@tiptap/react";
import { import {
collabExtensions, collabExtensions,
mainExtensions, mainExtensions,
@ -18,14 +18,16 @@ import { useAtom } from "jotai";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url"; import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; 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 { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { import {
activeCommentIdAtom, activeCommentIdAtom,
showCommentPopupAtom, showCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom"; } from "@/features/comment/atoms/comment-atom";
import CommentDialog from "@/features/comment/components/comment-dialog"; 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 { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx"; import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-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 { interface PageEditorProps {
pageId: string; pageId: string;
editable: boolean; editable: boolean;
content: any;
} }
export default function PageEditor({ pageId, editable }: PageEditorProps) { export default function PageEditor({
pageId,
editable,
content,
}: PageEditorProps) {
const [token] = useAtom(authTokensAtom); const [token] = useAtom(authTokensAtom);
const collaborationURL = useCollaborationUrl(); const collaborationURL = useCollaborationUrl();
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
@ -56,8 +63,11 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
const ydoc = useMemo(() => new Y.Doc(), [pageId]); const ydoc = useMemo(() => new Y.Doc(), [pageId]);
const [isLocalSynced, setLocalSynced] = useState(false); const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false);
const documentName = `page.${pageId}`; const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
const localProvider = useMemo(() => { const localProvider = useMemo(() => {
const provider = new IndexeddbPersistence(documentName, ydoc); const provider = new IndexeddbPersistence(documentName, ydoc);
@ -76,12 +86,21 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
document: ydoc, document: ydoc,
token: token?.accessToken, token: token?.accessToken,
connect: false, connect: false,
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
}); });
provider.on("synced", () => { provider.on("synced", () => {
setRemoteSynced(true); setRemoteSynced(true);
}); });
provider.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
return provider; return provider;
}, [ydoc, pageId, token?.accessToken]); }, [ydoc, pageId, token?.accessToken]);
@ -97,15 +116,18 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
}, [remoteProvider, localProvider]); }, [remoteProvider, localProvider]);
const extensions = [ const extensions = [
... mainExtensions, ...mainExtensions,
... collabExtensions(remoteProvider, currentUser.user), ...collabExtensions(remoteProvider, currentUser.user),
]; ];
const editor = useEditor( const editor = useEditor(
{ {
extensions, extensions,
editable, editable,
immediatelyRender: true,
editorProps: { editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
@ -157,11 +179,21 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
setAsideState({ tab: "", isAsideOpen: false }); setAsideState({ tab: "", isAsideOpen: false });
}, [pageId]); }, [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 ? ( return isSynced ? (
<div> <div>
{isSynced && (
<div ref={menuContainerRef}> <div ref={menuContainerRef}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
@ -179,14 +211,19 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
</div> </div>
)} )}
{showCommentPopup && ( {showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
<CommentDialog editor={editor} pageId={pageId} />
)}
</div> </div>
)}
<div onClick={() => editor.commands.focus('end')} style={{ paddingBottom: '20vh' }}></div> <div
onClick={() => editor.commands.focus("end")}
style={{ paddingBottom: "20vh" }}
></div>
</div> </div>
) : ( ) : (
<EditorSkeleton /> <EditorProvider
editable={false}
extensions={mainExtensions}
content={content}
></EditorProvider>
); );
} }

View File

@ -2,6 +2,10 @@
img { img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
@media print {
break-inside: avoid;
}
} }
.node-image, .node-video, .node-excalidraw, .node-drawio { .node-image, .node-video, .node-excalidraw, .node-drawio {

View File

@ -38,7 +38,7 @@ export function TitleEditor({
}: TitleEditorProps) { }: TitleEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [debouncedTitleState, setDebouncedTitleState] = useState(null); const [debouncedTitleState, setDebouncedTitleState] = useState(null);
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 500); const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 700);
const { const {
data: updatedPageData, data: updatedPageData,
mutate: updatePageMutation, mutate: updatePageMutation,
@ -81,6 +81,8 @@ export function TitleEditor({
}, },
editable: editable, editable: editable,
content: title, content: title,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
}); });
useEffect(() => { useEffect(() => {

View File

@ -8,6 +8,7 @@ import {
IconMessage, IconMessage,
IconPrinter, IconPrinter,
IconTrash, IconTrash,
IconWifiOff,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import React from "react"; import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; 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 { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.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 { useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal"; import ExportModal from "@/components/common/export-modal";
import { yjsConnectionStatusAtom } from "@/features/editor/atoms/editor-atoms.ts";
interface PageHeaderMenuProps { interface PageHeaderMenuProps {
readOnly?: boolean; readOnly?: boolean;
} }
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const toggleAside = useToggleAside(); const toggleAside = useToggleAside();
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
return ( return (
<> <>
{yjsConnectionStatus === "disconnected" && (
<Tooltip
label="Real-time editor connection lost. Retrying..."
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Comments" openDelay={250} withArrow> <Tooltip label="Comments" openDelay={250} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="default"

View File

@ -25,17 +25,31 @@ import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils"; import { buildTree } from "@/features/page/tree/utils";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export function usePageQuery( export function usePageQuery(
pageInput: Partial<IPageInput>, pageInput: Partial<IPageInput>,
): UseQueryResult<IPage, Error> { ): UseQueryResult<IPage, Error> {
return useQuery({ const query = useQuery({
queryKey: ["pages", pageInput.pageId], queryKey: ["pages", pageInput.pageId],
queryFn: () => getPageById(pageInput), queryFn: () => getPageById(pageInput),
enabled: !!pageInput.pageId, enabled: !!pageInput.pageId,
staleTime: 5 * 60 * 1000, 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() { export function useCreatePageMutation() {

View File

@ -35,6 +35,7 @@ import {
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { import {
getPageBreadcrumbs, getPageBreadcrumbs,
getPageById,
getSidebarPages, getSidebarPages,
} from "@/features/page/services/page-service.ts"; } from "@/features/page/services/page-service.ts";
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts"; import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
@ -232,6 +233,24 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const [treeData, setTreeData] = useAtom(treeDataAtom); const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
const { spaceSlug } = useParams(); 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<SpaceTreeNode>) { async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
if (!node.data.hasChildren) return; if (!node.data.hasChildren) return;
@ -330,6 +349,8 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
className={clsx(classes.node, node.state)} className={clsx(classes.node, node.state)}
ref={dragHandle} ref={dragHandle}
onClick={handleClick} onClick={handleClick}
onMouseEnter={prefetchPage}
onMouseLeave={cancelPagePrefetch}
> >
<PageArrow node={node} onExpandTree={() => handleLoadChildren(node)} /> <PageArrow node={node} onExpandTree={() => handleLoadChildren(node)} />

View File

@ -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;
}

View File

@ -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<any>;
label: string;
initiallyOpened?: boolean;
children: ReactNode;
}
export function TreeCollapse({
icon: Icon,
label,
initiallyOpened,
children,
}: TreeCollapseProps) {
const [opened, setOpened] = useState(initiallyOpened || false);
return (
<>
<UnstyledButton
onClick={() => setOpened((o) => !o)}
className={classes.control}
>
<Group justify="space-between" gap={0}>
<Box style={{ display: "flex", alignItems: "center" }}>
<ThemeIcon variant="light" size={20}>
<Icon style={{ width: rem(18), height: rem(18) }} />
</ThemeIcon>
<Box ml="md">{label}</Box>
</Box>
<IconChevronRight
className={classes.chevron}
stroke={1.5}
style={{
width: rem(16),
height: rem(16),
transform: opened ? "rotate(90deg)" : "none",
}}
/>
</Group>
</UnstyledButton>
<Collapse in={opened}>
<div className={classes.item}>{children}</div>
</Collapse>
</>
);
}

View File

@ -55,8 +55,10 @@ export default function Page() {
/> />
<FullEditor <FullEditor
key={page.id}
pageId={page.id} pageId={page.id}
title={page.title} title={page.title}
content={page.content}
slugId={page.slugId} slugId={page.slugId}
spaceSlug={page?.space?.slug} spaceSlug={page?.space?.slug}
editable={spaceAbility.can( editable={spaceAbility.can(

View File

@ -26,4 +26,8 @@ export class PageInfoDto extends PageIdDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
includeSpace: boolean; includeSpace: boolean;
@IsOptional()
@IsBoolean()
includeContent: boolean;
} }

View File

@ -43,6 +43,7 @@ export class PageController {
async getPage(@Body() dto: PageInfoDto, @AuthUser() user: User) { async getPage(@Body() dto: PageInfoDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId, { const page = await this.pageRepo.findById(dto.pageId, {
includeSpace: true, includeSpace: true,
includeContent: true,
}); });
if (!page) { if (!page) {

View File

@ -38,7 +38,6 @@
"@tiptap/extension-link": "^2.10.3", "@tiptap/extension-link": "^2.10.3",
"@tiptap/extension-list-item": "^2.10.3", "@tiptap/extension-list-item": "^2.10.3",
"@tiptap/extension-list-keymap": "^2.10.3", "@tiptap/extension-list-keymap": "^2.10.3",
"@tiptap/extension-mention": "^2.10.3",
"@tiptap/extension-placeholder": "^2.10.3", "@tiptap/extension-placeholder": "^2.10.3",
"@tiptap/extension-subscript": "^2.10.3", "@tiptap/extension-subscript": "^2.10.3",
"@tiptap/extension-superscript": "^2.10.3", "@tiptap/extension-superscript": "^2.10.3",

16
pnpm-lock.yaml generated
View File

@ -79,9 +79,6 @@ importers:
'@tiptap/extension-list-keymap': '@tiptap/extension-list-keymap':
specifier: ^2.10.3 specifier: ^2.10.3
version: 2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3)) version: 2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))
'@tiptap/extension-mention':
specifier: ^2.10.3
version: 2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))(@tiptap/pm@2.10.3)(@tiptap/suggestion@2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))(@tiptap/pm@2.10.3))
'@tiptap/extension-placeholder': '@tiptap/extension-placeholder':
specifier: ^2.10.3 specifier: ^2.10.3
version: 2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))(@tiptap/pm@2.10.3) version: 2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))(@tiptap/pm@2.10.3)
@ -3590,13 +3587,6 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': ^2.7.0 '@tiptap/core': ^2.7.0
'@tiptap/extension-mention@2.10.3':
resolution: {integrity: sha512-h0+BrTS2HdjMfsuy6zkFIqmVGYL8w3jIG0gYaDHjWwwe/Lf2BDgOu3bZWcSr/3bKiJIwwzpOJrXssqta4TZ0yQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/suggestion': ^2.7.0
'@tiptap/extension-ordered-list@2.10.3': '@tiptap/extension-ordered-list@2.10.3':
resolution: {integrity: sha512-/SFuEDnbJxy3jvi72LeyiPHWkV+uFc0LUHTUHSh20vwyy+tLrzncJfXohGbTIv5YxYhzExQYZDRD4VbSghKdlw==} resolution: {integrity: sha512-/SFuEDnbJxy3jvi72LeyiPHWkV+uFc0LUHTUHSh20vwyy+tLrzncJfXohGbTIv5YxYhzExQYZDRD4VbSghKdlw==}
peerDependencies: peerDependencies:
@ -12362,12 +12352,6 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 2.10.3(@tiptap/pm@2.10.3) '@tiptap/core': 2.10.3(@tiptap/pm@2.10.3)
'@tiptap/extension-mention@2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))(@tiptap/pm@2.10.3)(@tiptap/suggestion@2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))(@tiptap/pm@2.10.3))':
dependencies:
'@tiptap/core': 2.10.3(@tiptap/pm@2.10.3)
'@tiptap/pm': 2.10.3
'@tiptap/suggestion': 2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))(@tiptap/pm@2.10.3)
'@tiptap/extension-ordered-list@2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))': '@tiptap/extension-ordered-list@2.10.3(@tiptap/core@2.10.3(@tiptap/pm@2.10.3))':
dependencies: dependencies:
'@tiptap/core': 2.10.3(@tiptap/pm@2.10.3) '@tiptap/core': 2.10.3(@tiptap/pm@2.10.3)