mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
d2629afff2
* feat: add heading extension with unique ID support and scroll functionality * Added unique id for heading * remove baseUrl heading storage * move heading to extensions package * WIP * support anchors in mentions * enhance scrolling functionality * nodeId function * fix nanoid import * Bring unique-id extension local * fixes * fix internal link scroll in public pages * add unique id server side * rename mention anchor to anchorId * capture first anchorId on paste --------- Co-authored-by: Romik <40670677+RomikMakavana@users.noreply.github.com>
254 lines
7.2 KiB
TypeScript
254 lines
7.2 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 {
|
|
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 { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
|
import { searchSpotlight } from "@/features/search/constants.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: updateTitlePageMutationAsync } =
|
|
useUpdateTitlePageMutation();
|
|
const pageEditor = useAtomValue(pageEditorAtom);
|
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
|
const emit = useQueryEmit();
|
|
const navigate = useNavigate();
|
|
const [activePageId, setActivePageId] = useState(pageId);
|
|
const [currentUser] = useAtom(currentUserAtom);
|
|
const userPageEditMode =
|
|
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
|
|
|
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 ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
if ((event.ctrlKey || event.metaKey) && 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(() => {
|
|
titleEditor?.commands.focus("end");
|
|
}, 500);
|
|
}, [titleEditor]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
// force-save title on navigation
|
|
saveTitle();
|
|
};
|
|
}, [pageId]);
|
|
|
|
useEffect(() => {
|
|
// honor user default page edit mode preference
|
|
if (userPageEditMode && titleEditor && editable) {
|
|
if (userPageEditMode === PageEditMode.Edit) {
|
|
titleEditor.setEditable(true);
|
|
} else if (userPageEditMode === PageEditMode.Read) {
|
|
titleEditor.setEditable(false);
|
|
}
|
|
}
|
|
}, [userPageEditMode, 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 (
|
|
<EditorContent
|
|
editor={titleEditor}
|
|
onKeyDown={(event) => {
|
|
// First handle the search hotkey
|
|
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
|
|
|
// Then handle other key events
|
|
handleTitleKeyDown(event);
|
|
}}
|
|
/>
|
|
);
|
|
}
|