mirror of
https://github.com/docmost/docmost.git
synced 2026-06-22 09:01:37 +10:00
82d065669d
The header edit/read toggle now controls only the current session's mode without saving it as the user's preference. The saved preference (set in profile settings) is applied once on initial load and sticks across page navigations within the session, so navigating to a new page no longer resets the mode mid-session. Fixes #1693
251 lines
7.0 KiB
TypeScript
251 lines
7.0 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 {
|
|
currentPageEditModeAtom,
|
|
pageEditorAtom,
|
|
titleEditorAtom,
|
|
} from "@/features/editor/atoms/editor-atoms";
|
|
import {
|
|
updatePageData,
|
|
useUpdateTitlePageMutation,
|
|
} from "@/features/page/queries/page-query";
|
|
import { useDebouncedCallback, getHotkeyHandler } 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";
|
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
|
import { platformModifierKey } from "@/lib";
|
|
|
|
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: updateTitlePageMutationAsync } =
|
|
useUpdateTitlePageMutation();
|
|
const pageEditor = useAtomValue(pageEditorAtom);
|
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
|
const emit = useQueryEmit();
|
|
const navigate = useNavigate();
|
|
const [activePageId, setActivePageId] = useState(pageId);
|
|
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
|
|
|
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,
|
|
editorProps: {
|
|
handleDOMEvents: {
|
|
keydown: (_view, event) => {
|
|
if (platformModifierKey(event) && event.code === "KeyS") {
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
if (platformModifierKey(event) && event.code === "KeyK") {
|
|
searchSpotlight.open();
|
|
return true;
|
|
}
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
const anchorId = window.location.hash
|
|
? window.location.hash.substring(1)
|
|
: undefined;
|
|
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
|
|
navigate(pageSlug, { replace: true });
|
|
}, [title]);
|
|
|
|
const saveTitle = useCallback(() => {
|
|
if (!titleEditor || activePageId !== pageId) return;
|
|
|
|
if (
|
|
titleEditor.getText() === title ||
|
|
(titleEditor.getText() === "" && title === null)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
updateTitlePageMutationAsync({
|
|
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,
|
|
parentPageId: page.parentPageId,
|
|
icon: page.icon,
|
|
},
|
|
};
|
|
|
|
if (page.title !== titleEditor.getText()) return;
|
|
|
|
updatePageData(page);
|
|
|
|
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(() => {
|
|
// guard against Cannot access view['hasFocus'] error
|
|
if (!titleEditor?.isInitialized) return;
|
|
titleEditor?.commands?.focus("end");
|
|
}, 300);
|
|
}, [titleEditor]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
// force-save title on navigation
|
|
saveTitle();
|
|
};
|
|
}, [pageId]);
|
|
|
|
useEffect(() => {
|
|
if (!titleEditor) return;
|
|
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
|
}, [currentPageEditMode, titleEditor, editable]);
|
|
|
|
const openSearchDialog = () => {
|
|
const event = new CustomEvent("openFindDialogFromEditor", {});
|
|
document.dispatchEvent(event);
|
|
};
|
|
|
|
function handleTitleKeyDown(event: any) {
|
|
if (!titleEditor || !pageEditor || event.shiftKey) return;
|
|
|
|
// Prevent focus shift when IME composition is active
|
|
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
|
|
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
|
|
return;
|
|
|
|
const { key } = event;
|
|
const { $head } = titleEditor.state.selection;
|
|
|
|
if (key === "Enter") {
|
|
event.preventDefault();
|
|
|
|
const { $from } = titleEditor.state.selection;
|
|
const titleText = titleEditor.getText();
|
|
|
|
// Get the text offset within the heading node (not document position)
|
|
const textOffset = $from.parentOffset;
|
|
|
|
const textAfterCursor = titleText.slice(textOffset);
|
|
|
|
// Delete text after cursor from title (this will be in undo history)
|
|
const endPos = titleEditor.state.doc.content.size;
|
|
if (textAfterCursor) {
|
|
titleEditor.commands.deleteRange({ from: $from.pos, to: endPos });
|
|
}
|
|
|
|
// Don't add to history so undo in page editor won't remove this split
|
|
pageEditor
|
|
.chain()
|
|
.command(({ tr }) => {
|
|
tr.setMeta("addToHistory", false);
|
|
return true;
|
|
})
|
|
.insertContentAt(0, {
|
|
type: "paragraph",
|
|
content: textAfterCursor
|
|
? [{ type: "text", text: textAfterCursor }]
|
|
: undefined,
|
|
})
|
|
.focus("start")
|
|
.run();
|
|
return;
|
|
}
|
|
|
|
const shouldFocusEditor =
|
|
key === "ArrowDown" || (key === "ArrowRight" && !$head.nodeAfter);
|
|
|
|
if (shouldFocusEditor) {
|
|
pageEditor.commands.focus("start");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="page-title">
|
|
<EditorContent
|
|
editor={titleEditor}
|
|
onKeyDown={(event) => {
|
|
// First handle the search hotkey
|
|
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
|
|
|
// Then handle other key events
|
|
handleTitleKeyDown(event);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|