Files
docmost/apps/client/src/features/editor/title-editor.tsx
T
Philip Okugbe 96dfe9f817 fix: page title editor bugs (#892)
* Fix page title

* compare empty page title

* Properly handle null tree node name and icon
2025-03-14 22:41:34 +00:00

153 lines
4.1 KiB
TypeScript

import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useState } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtomValue } from "jotai";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useUpdatePageMutation } from "@/features/page/queries/page-query";
import { useDebouncedCallback } from "@mantine/hooks";
import { useAtom } from "jotai";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
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 {
pageId: string;
slugId: string;
title: string;
spaceSlug: string;
editable: boolean;
}
export function TitleEditor({
pageId,
slugId,
title,
spaceSlug,
editable,
}: TitleEditorProps) {
const { t } = useTranslation();
const { mutateAsync: updatePageMutationAsync } = useUpdatePageMutation();
const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const titleEditor = useEditor({
extensions: [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: t("Untitled"),
showOnlyWhenEditable: false,
}),
History.configure({
depth: 20,
}),
EmojiCommand,
],
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setTitleEditor(editor);
setActivePageId(pageId);
}
},
onUpdate({ editor }) {
debounceUpdate();
},
editable: editable,
content: title,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
});
useEffect(() => {
const pageSlug = buildPageUrl(spaceSlug, slugId, title);
navigate(pageSlug, { replace: true });
}, [title]);
const saveTitle = useCallback(() => {
if (!titleEditor || activePageId !== pageId) return;
if (
titleEditor.getText() === title ||
(titleEditor.getText() === "" && title === null)
) {
return;
}
updatePageMutationAsync({
pageId: pageId,
title: titleEditor.getText(),
}).then((page) => {
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: { title: page.title, slugId: page.slugId },
};
localEmitter.emit("message", event);
emit(event);
});
}, [pageId, title, titleEditor]);
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
useEffect(() => {
if (titleEditor && title !== titleEditor.getText()) {
titleEditor.commands.setContent(title);
}
}, [pageId, title, titleEditor]);
useEffect(() => {
setTimeout(() => {
titleEditor?.commands.focus("end");
}, 500);
}, [titleEditor]);
useEffect(() => {
return () => {
// force-save title on navigation
saveTitle();
};
}, [pageId]);
function handleTitleKeyDown(event) {
if (!titleEditor || !pageEditor || event.shiftKey) return;
const { key } = event;
const { $head } = titleEditor.state.selection;
const shouldFocusEditor =
key === "Enter" ||
key === "ArrowDown" ||
(key === "ArrowRight" && !$head.nodeAfter);
if (shouldFocusEditor) {
pageEditor.commands.focus("start");
}
}
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
}