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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 144 additions and 142 deletions

View File

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

View File

@ -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<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;
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}
/>
<MemoizedPageEditor pageId={pageId} editable={editable} />
<MemoizedPageEditor pageId={pageId} editable={editable} content={content} />
</Container>
);
}

View File

@ -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]);
@ -105,7 +124,10 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
{
extensions,
editable,
immediatelyRender: true,
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
handleDOMEvents: {
keydown: (_view, event) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
@ -157,11 +179,21 @@ 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 ? (
<div>
{isSynced && (
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
@ -179,14 +211,19 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
</div>
)}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
</div>
)}
<div onClick={() => editor.commands.focus('end')} style={{ paddingBottom: '20vh' }}></div>
<div
onClick={() => editor.commands.focus("end")}
style={{ paddingBottom: "20vh" }}
></div>
</div>
) : (
<EditorSkeleton />
<EditorProvider
editable={false}
extensions={mainExtensions}
content={content}
></EditorProvider>
);
}

View File

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

View File

@ -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(() => {

View File

@ -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" && (
<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>
<ActionIcon
variant="default"

View File

@ -25,17 +25,31 @@ import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next";
export function usePageQuery(
pageInput: Partial<IPageInput>,
): UseQueryResult<IPage, Error> {
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() {

View File

@ -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<any>) {
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<SpaceTreeNode>) {
if (!node.data.hasChildren) return;
@ -330,6 +349,8 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
className={clsx(classes.node, node.state)}
ref={dragHandle}
onClick={handleClick}
onMouseEnter={prefetchPage}
onMouseLeave={cancelPagePrefetch}
>
<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
key={page.id}
pageId={page.id}
title={page.title}
content={page.content}
slugId={page.slugId}
spaceSlug={page?.space?.slug}
editable={spaceAbility.can(

View File

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

View File

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

View File

@ -38,7 +38,6 @@
"@tiptap/extension-link": "^2.10.3",
"@tiptap/extension-list-item": "^2.10.3",
"@tiptap/extension-list-keymap": "^2.10.3",
"@tiptap/extension-mention": "^2.10.3",
"@tiptap/extension-placeholder": "^2.10.3",
"@tiptap/extension-subscript": "^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':
specifier: ^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':
specifier: ^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:
'@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':
resolution: {integrity: sha512-/SFuEDnbJxy3jvi72LeyiPHWkV+uFc0LUHTUHSh20vwyy+tLrzncJfXohGbTIv5YxYhzExQYZDRD4VbSghKdlw==}
peerDependencies:
@ -12362,12 +12352,6 @@ snapshots:
dependencies:
'@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))':
dependencies:
'@tiptap/core': 2.10.3(@tiptap/pm@2.10.3)