fix: page title editor bugs (#892)

* Fix page title

* compare empty page title

* Properly handle null tree node name and icon
This commit is contained in:
Philip Okugbe
2025-03-14 22:41:34 +00:00
committed by GitHub
parent 598361992e
commit 96dfe9f817
5 changed files with 98 additions and 52 deletions

View File

@ -37,6 +37,7 @@
"katex": "0.16.21", "katex": "0.16.21",
"lowlight": "^3.2.0", "lowlight": "^3.2.0",
"mermaid": "^11.4.1", "mermaid": "^11.4.1",
"mitt": "^3.0.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.11", "react-clear-modal": "^2.0.11",

View File

@ -1,5 +1,5 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { EditorContent, useEditor } from "@tiptap/react"; import { EditorContent, useEditor } from "@tiptap/react";
import { Document } from "@tiptap/extension-document"; import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading"; import { Heading } from "@tiptap/extension-heading";
@ -11,16 +11,16 @@ import {
titleEditorAtom, titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
import { useUpdatePageMutation } from "@/features/page/queries/page-query"; import { useUpdatePageMutation } from "@/features/page/queries/page-query";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedCallback } from "@mantine/hooks";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { updateTreeNodeName } from "@/features/page/tree/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history"; import { History } from "@tiptap/extension-history";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import EmojiCommand from '@/features/editor/extensions/emoji-command.ts'; import EmojiCommand from "@/features/editor/extensions/emoji-command.ts";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
export interface TitleEditorProps { export interface TitleEditorProps {
pageId: string; pageId: string;
@ -38,16 +38,9 @@ export function TitleEditor({
editable, editable,
}: TitleEditorProps) { }: TitleEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [debouncedTitleState, setDebouncedTitleState] = useState(null); const { mutateAsync: updatePageMutationAsync } = useUpdatePageMutation();
const [debouncedTitle] = useDebouncedValue(debouncedTitleState, 700);
const {
data: updatedPageData,
mutate: updatePageMutation,
status,
} = useUpdatePageMutation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom);
const [treeData, setTreeData] = useAtom(treeDataAtom);
const emit = useQueryEmit(); const emit = useQueryEmit();
const navigate = useNavigate(); const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId); const [activePageId, setActivePageId] = useState(pageId);
@ -68,18 +61,17 @@ export function TitleEditor({
History.configure({ History.configure({
depth: 20, depth: 20,
}), }),
EmojiCommand EmojiCommand,
], ],
onCreate({ editor }) { onCreate({ editor }) {
if (editor) { if (editor) {
// @ts-ignore // @ts-ignore
setTitleEditor(editor); setTitleEditor(editor);
setActivePageId(pageId);
} }
}, },
onUpdate({ editor }) { onUpdate({ editor }) {
const currentTitle = editor.getText(); debounceUpdate();
setDebouncedTitleState(currentTitle);
setActivePageId(pageId);
}, },
editable: editable, editable: editable,
content: title, content: title,
@ -92,31 +84,34 @@ export function TitleEditor({
navigate(pageSlug, { replace: true }); navigate(pageSlug, { replace: true });
}, [title]); }, [title]);
useEffect(() => { const saveTitle = useCallback(() => {
if (debouncedTitle !== null && activePageId === pageId) { if (!titleEditor || activePageId !== pageId) return;
updatePageMutation({
if (
titleEditor.getText() === title ||
(titleEditor.getText() === "" && title === null)
) {
return;
}
updatePageMutationAsync({
pageId: pageId, pageId: pageId,
title: debouncedTitle, title: titleEditor.getText(),
}); }).then((page) => {
} const event: UpdateEvent = {
}, [debouncedTitle]);
useEffect(() => {
if (status === "success" && updatedPageData) {
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
setTreeData(newTreeData);
setTimeout(() => {
emit({
operation: "updateOne", operation: "updateOne",
spaceId: updatedPageData.spaceId, spaceId: page.spaceId,
entity: ["pages"], entity: ["pages"],
id: pageId, id: page.id,
payload: { title: debouncedTitle, slugId: slugId }, payload: { title: page.title, slugId: page.slugId },
};
localEmitter.emit("message", event);
emit(event);
}); });
}, 50); }, [pageId, title, titleEditor]);
}
}, [updatedPageData, status]); const debounceUpdate = useDebouncedCallback(saveTitle, 500);
useEffect(() => { useEffect(() => {
if (titleEditor && title !== titleEditor.getText()) { if (titleEditor && title !== titleEditor.getText()) {
@ -130,6 +125,13 @@ export function TitleEditor({
}, 500); }, 500);
}, [titleEditor]); }, [titleEditor]);
useEffect(() => {
return () => {
// force-save title on navigation
saveTitle();
};
}, [pageId]);
function handleTitleKeyDown(event) { function handleTitleKeyDown(event) {
if (!titleEditor || !pageEditor || event.shiftKey) return; if (!titleEditor || !pageEditor || event.shiftKey) return;

View File

@ -6,6 +6,7 @@ import { WebSocketEvent } from "@/features/websocket/types";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { SimpleTree } from "react-arborist"; import { SimpleTree } from "react-arborist";
import localEmitter from "@/lib/local-emitter.ts";
export const useTreeSocket = () => { export const useTreeSocket = () => {
const [socket] = useAtom(socketAtom); const [socket] = useAtom(socketAtom);
@ -18,8 +19,29 @@ export const useTreeSocket = () => {
}, [treeData]); }, [treeData]);
useEffect(() => { useEffect(() => {
socket?.on("message", (event: WebSocketEvent) => { const updateNodeName = (event) => {
const initialData = initialTreeData.current;
const treeApi = new SimpleTree<SpaceTreeNode>(initialData);
if (treeApi.find(event?.id)) {
if (event.payload?.title !== undefined) {
treeApi.update({
id: event.id,
changes: { name: event.payload.title },
});
setTreeData(treeApi.data);
}
}
};
localEmitter.on("message", updateNodeName);
return () => {
localEmitter.off("message", updateNodeName);
};
}, []);
useEffect(() => {
socket?.on("message", (event: WebSocketEvent) => {
const initialData = initialTreeData.current; const initialData = initialTreeData.current;
const treeApi = new SimpleTree<SpaceTreeNode>(initialData); const treeApi = new SimpleTree<SpaceTreeNode>(initialData);
@ -27,30 +49,40 @@ export const useTreeSocket = () => {
case "updateOne": case "updateOne":
if (event.entity[0] === "pages") { if (event.entity[0] === "pages") {
if (treeApi.find(event.id)) { if (treeApi.find(event.id)) {
if (event.payload?.title) { if (event.payload?.title !== undefined) {
treeApi.update({ id: event.id, changes: { name: event.payload.title } }); treeApi.update({
id: event.id,
changes: { name: event.payload.title },
});
} }
if (event.payload?.icon) { if (event.payload?.icon !== undefined) {
treeApi.update({ id: event.id, changes: { icon: event.payload.icon } }); treeApi.update({
id: event.id,
changes: { icon: event.payload.icon },
});
} }
setTreeData(treeApi.data); setTreeData(treeApi.data);
} }
} }
break; break;
case 'addTreeNode': case "addTreeNode":
if (treeApi.find(event.payload.data.id)) return; if (treeApi.find(event.payload.data.id)) return;
treeApi.create({ parentId: event.payload.parentId, index: event.payload.index, data: event.payload.data }); treeApi.create({
parentId: event.payload.parentId,
index: event.payload.index,
data: event.payload.data,
});
setTreeData(treeApi.data); setTreeData(treeApi.data);
break; break;
case 'moveTreeNode': case "moveTreeNode":
// move node // move node
if (treeApi.find(event.payload.id)) { if (treeApi.find(event.payload.id)) {
treeApi.move({ treeApi.move({
id: event.payload.id, id: event.payload.id,
parentId: event.payload.parentId, parentId: event.payload.parentId,
index: event.payload.index index: event.payload.index,
}); });
// update node position // update node position
@ -58,7 +90,7 @@ export const useTreeSocket = () => {
id: event.payload.id, id: event.payload.id,
changes: { changes: {
position: event.payload.position, position: event.payload.position,
} },
}); });
setTreeData(treeApi.data); setTreeData(treeApi.data);
@ -66,12 +98,12 @@ export const useTreeSocket = () => {
break; break;
case "deleteTreeNode": case "deleteTreeNode":
if (treeApi.find(event.payload.node.id)){ if (treeApi.find(event.payload.node.id)) {
treeApi.drop({ id: event.payload.node.id }); treeApi.drop({ id: event.payload.node.id });
setTreeData(treeApi.data); setTreeData(treeApi.data);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['pages', event.payload.node.slugId].filter(Boolean), queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
}); });
} }
break; break;

View File

@ -0,0 +1,3 @@
import mitt from "mitt";
const localEmitter = mitt();
export default localEmitter;

8
pnpm-lock.yaml generated
View File

@ -281,6 +281,9 @@ importers:
mermaid: mermaid:
specifier: ^11.4.1 specifier: ^11.4.1
version: 11.4.1 version: 11.4.1
mitt:
specifier: ^3.0.1
version: 3.0.1
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
@ -6957,6 +6960,9 @@ packages:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp@1.0.4: mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -16819,6 +16825,8 @@ snapshots:
minipass: 3.3.6 minipass: 3.3.6
yallist: 4.0.0 yallist: 4.0.0
mitt@3.0.1: {}
mkdirp@1.0.4: {} mkdirp@1.0.4: {}
mlly@1.7.3: mlly@1.7.3: