mirror of
https://github.com/docmost/docmost.git
synced 2025-11-15 15:51:15 +10:00
* feat: page find and replace * * Refactor search and replace directory * bugfix scroll * Fix search and replace functionality for macOS and improve UX - Fixed cmd+f shortcut to work on macOS (using 'Mod' key instead of 'Control') - Added search functionality to title editor - Fixed "Not found" message showing when search term is empty - Fixed tooltip error when clicking replace button - Changed replace button from icon to text for consistency - Reduced width of search input fields for better UI - Fixed result index after replace operation to prevent out-of-bounds error - Added missing translation strings for search and replace dialog - Updated tooltip to show platform-specific shortcuts (⌘F on Mac, Ctrl-F on others) * Hide replace functionality for users with view-only permissions - Added editable prop to SearchAndReplaceDialog component - Pass editable state from PageEditor to SearchAndReplaceDialog - Conditionally render replace button based on edit permissions - Hide replace input section for view-only users - Disable Alt+R shortcut when user lacks edit permissions * Fix search dialog not closing properly when navigating away - Clear all state (search text, replace text) when closing dialog - Reset replace button visibility state on close - Clear editor search term to remove highlights - Ensure dialog closes properly when route changes * fix: preserve text marks (comments, etc.) when replacing text in search and replace - Collect all marks that span the text being replaced using nodesBetween - Apply collected marks to the replacement text to maintain formatting - Fixes issue where comment marks were being removed during text replacement * ignore type error --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
203 lines
5.6 KiB
TypeScript
203 lines
5.6 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";
|
|
|
|
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,
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
|
|
const shouldFocusEditor =
|
|
key === "Enter" ||
|
|
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);
|
|
}}
|
|
/>
|
|
);
|
|
}
|