diff --git a/apps/client/src/features/editor/styles/details.css b/apps/client/src/features/editor/styles/details.css
index 567118b8..5c5d151b 100644
--- a/apps/client/src/features/editor/styles/details.css
+++ b/apps/client/src/features/editor/styles/details.css
@@ -71,4 +71,12 @@
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
transform: rotateZ(90deg);
}
-}
\ No newline at end of file
+
+ [data-type="details"]:has(.search-result) > [data-type="detailsContainer"] > [data-type="detailsContent"]{
+ display: block;
+ }
+
+ [data-type="details"]:has(.search-result) > [data-type="detailsButton"] .ProseMirror-icon{
+ transform: rotateZ(90deg);
+ }
+}
diff --git a/apps/client/src/features/editor/styles/find.css b/apps/client/src/features/editor/styles/find.css
new file mode 100644
index 00000000..77b72f25
--- /dev/null
+++ b/apps/client/src/features/editor/styles/find.css
@@ -0,0 +1,9 @@
+.search-result{
+ background: #ffff65;
+ color: #212529;
+}
+
+.search-result-current{
+ background: #ffc266 !important;
+ color: #212529;
+}
diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css
index cf979957..44793724 100644
--- a/apps/client/src/features/editor/styles/index.css
+++ b/apps/client/src/features/editor/styles/index.css
@@ -9,5 +9,5 @@
@import "./media.css";
@import "./code.css";
@import "./print.css";
+@import "./find.css";
@import "./mention.css";
-
diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx
index e695867f..937ae374 100644
--- a/apps/client/src/features/editor/title-editor.tsx
+++ b/apps/client/src/features/editor/title-editor.tsx
@@ -10,8 +10,11 @@ import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
-import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
-import { useDebouncedCallback } from "@mantine/hooks";
+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";
@@ -40,7 +43,8 @@ export function TitleEditor({
editable,
}: TitleEditorProps) {
const { t } = useTranslation();
- const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
+ const { mutateAsync: updateTitlePageMutationAsync } =
+ useUpdateTitlePageMutation();
const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit();
@@ -108,7 +112,12 @@ export function TitleEditor({
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
- payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
+ payload: {
+ title: page.title,
+ slugId: page.slugId,
+ parentPageId: page.parentPageId,
+ icon: page.icon,
+ },
};
if (page.title !== titleEditor.getText()) return;
@@ -152,13 +161,19 @@ export function TitleEditor({
}
}, [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
+
+ // 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;
-
+ if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
+ return;
+
const { key } = event;
const { $head } = titleEditor.state.selection;
@@ -172,5 +187,16 @@ export function TitleEditor({
}
}
- return
;
+ return (
+
{
+ // First handle the search hotkey
+ getHotkeyHandler([["mod+F", openSearchDialog]])(event);
+
+ // Then handle other key events
+ handleTitleKeyDown(event);
+ }}
+ />
+ );
}
diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx
index 816cc502..934be3af 100644
--- a/apps/client/src/features/page/components/header/page-header-menu.tsx
+++ b/apps/client/src/features/page/components/header/page-header-menu.tsx
@@ -9,6 +9,7 @@ import {
IconList,
IconMessage,
IconPrinter,
+ IconSearch,
IconTrash,
IconWifiOff,
} from "@tabler/icons-react";
@@ -16,7 +17,12 @@ import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
-import { useClipboard, useDisclosure } from "@mantine/hooks";
+import {
+ getHotkeyHandler,
+ useClipboard,
+ useDisclosure,
+ useHotkeys,
+} from "@mantine/hooks";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -32,6 +38,7 @@ import {
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
+import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
@@ -46,6 +53,26 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const toggleAside = useToggleAside();
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
+ useHotkeys(
+ [
+ [
+ "mod+F",
+ () => {
+ const event = new CustomEvent("openFindDialogFromEditor", {});
+ document.dispatchEvent(event);
+ },
+ ],
+ [
+ "Escape",
+ () => {
+ const event = new CustomEvent("closeFindDialogFromEditor", {});
+ document.dispatchEvent(event);
+ },
+ ],
+ ],
+ [],
+ );
+
return (
<>
{yjsConnectionStatus === "disconnected" && (
diff --git a/apps/client/src/features/space/components/multi-member-select.tsx b/apps/client/src/features/space/components/multi-member-select.tsx
index efa2142f..602a6232 100644
--- a/apps/client/src/features/space/components/multi-member-select.tsx
+++ b/apps/client/src/features/space/components/multi-member-select.tsx
@@ -26,6 +26,9 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
{option["type"] === "group" && }
{option.label}
+ {option["type"] === "user" && option["email"] && (
+ {option["email"]}
+ )}
);
@@ -47,6 +50,7 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const userItems = suggestion?.users.map((user: IUser) => ({
value: `user-${user.id}`,
label: user.name,
+ email: user.email,
avatarUrl: user.avatarUrl,
type: "user",
}));
diff --git a/apps/server/src/collaboration/extensions/authentication.extension.ts b/apps/server/src/collaboration/extensions/authentication.extension.ts
index b7925619..1a42bd97 100644
--- a/apps/server/src/collaboration/extensions/authentication.extension.ts
+++ b/apps/server/src/collaboration/extensions/authentication.extension.ts
@@ -46,6 +46,10 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException();
}
+ if (user.deactivatedAt || user.deletedAt) {
+ throw new UnauthorizedException();
+ }
+
const page = await this.pageRepo.findById(pageId);
if (!page) {
this.logger.warn(`Page not found: ${pageId}`);
diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts
index fb98ed7f..dc1235ec 100644
--- a/apps/server/src/core/auth/auth.controller.ts
+++ b/apps/server/src/core/auth/auth.controller.ts
@@ -108,7 +108,7 @@ export class AuthController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
- return this.authService.getCollabToken(user.id, workspace.id);
+ return this.authService.getCollabToken(user, workspace.id);
}
@UseGuards(JwtAuthGuard)
diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts
index 9c761ef3..c71bc3bc 100644
--- a/apps/server/src/core/auth/services/auth.service.ts
+++ b/apps/server/src/core/auth/services/auth.service.ts
@@ -22,7 +22,7 @@ import { ForgotPasswordDto } from '../dto/forgot-password.dto';
import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-email';
import { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo';
import { PasswordResetDto } from '../dto/password-reset.dto';
-import { UserToken, Workspace } from '@docmost/db/types/entity.types';
+import { User, UserToken, Workspace } from '@docmost/db/types/entity.types';
import { UserTokenType } from '../auth.constants';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { InjectKysely } from 'nestjs-kysely';
@@ -222,9 +222,9 @@ export class AuthService {
}
}
- async getCollabToken(userId: string, workspaceId: string) {
+ async getCollabToken(user: User, workspaceId: string) {
const token = await this.tokenService.generateCollabToken(
- userId,
+ user,
workspaceId,
);
return { token };
diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts
index 963e8e65..c0e64e25 100644
--- a/apps/server/src/core/auth/services/token.service.ts
+++ b/apps/server/src/core/auth/services/token.service.ts
@@ -22,7 +22,7 @@ export class TokenService {
) {}
async generateAccessToken(user: User): Promise {
- if (user.deletedAt) {
+ if (user.deactivatedAt || user.deletedAt) {
throw new ForbiddenException();
}
@@ -35,12 +35,13 @@ export class TokenService {
return this.jwtService.sign(payload);
}
- async generateCollabToken(
- userId: string,
- workspaceId: string,
- ): Promise {
+ async generateCollabToken(user: User, workspaceId: string): Promise {
+ if (user.deactivatedAt || user.deletedAt) {
+ throw new ForbiddenException();
+ }
+
const payload: JwtCollabPayload = {
- sub: userId,
+ sub: user.id,
workspaceId,
type: JwtType.COLLAB,
};
diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts
index 083444f2..fae56b7c 100644
--- a/apps/server/src/core/auth/strategies/jwt.strategy.ts
+++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts
@@ -42,7 +42,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
- if (!user || user.deletedAt) {
+ if (!user || user.deactivatedAt || user.deletedAt) {
throw new UnauthorizedException();
}
diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts
index f8caeb55..145c5313 100644
--- a/apps/server/src/core/page/page.controller.ts
+++ b/apps/server/src/core/page/page.controller.ts
@@ -146,7 +146,6 @@ export class PageController {
return this.pageService.getRecentPages(user.id, pagination);
}
- // TODO: scope to workspaces
@HttpCode(HttpStatus.OK)
@Post('/history')
async getPageHistory(
@@ -155,6 +154,10 @@ export class PageController {
@AuthUser() user: User,
) {
const page = await this.pageRepo.findById(dto.pageId);
+ if (!page) {
+ throw new NotFoundException('Page not found');
+ }
+
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts
index b8a62170..3ea1e535 100644
--- a/apps/server/src/core/search/search.service.ts
+++ b/apps/server/src/core/search/search.service.ts
@@ -140,7 +140,7 @@ export class SearchService {
if (suggestion.includeUsers) {
users = await this.db
.selectFrom('users')
- .select(['id', 'name', 'avatarUrl'])
+ .select(['id', 'name', 'email', 'avatarUrl'])
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 4c252d1e..49a16ab3 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 4c252d1ec35a3fb13c8eaf19509de83cf5fe2779
+Subproject commit 49a16ab3e03971a375bcbfac60c3c1150d19059b
diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts
index f2cb776b..d3e1d53d 100644
--- a/packages/editor-ext/src/index.ts
+++ b/packages/editor-ext/src/index.ts
@@ -17,4 +17,5 @@ export * from "./lib/excalidraw";
export * from "./lib/embed";
export * from "./lib/mention";
export * from "./lib/markdown";
+export * from "./lib/search-and-replace";
export * from "./lib/embed-provider";
diff --git a/packages/editor-ext/src/lib/search-and-replace/index.ts b/packages/editor-ext/src/lib/search-and-replace/index.ts
new file mode 100644
index 00000000..d082e4f8
--- /dev/null
+++ b/packages/editor-ext/src/lib/search-and-replace/index.ts
@@ -0,0 +1,3 @@
+import { SearchAndReplace } from './search-and-replace'
+export * from './search-and-replace'
+export default SearchAndReplace
\ No newline at end of file
diff --git a/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts
new file mode 100644
index 00000000..ca66958f
--- /dev/null
+++ b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts
@@ -0,0 +1,455 @@
+/***
+ MIT License
+ Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
+ ***/
+
+import { Extension, Range, type Dispatch } from "@tiptap/core";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
+import {
+ Plugin,
+ PluginKey,
+ type EditorState,
+ type Transaction,
+} from "@tiptap/pm/state";
+import { Node as PMNode, Mark } from "@tiptap/pm/model";
+
+declare module "@tiptap/core" {
+ interface Commands {
+ search: {
+ /**
+ * @description Set search term in extension.
+ */
+ setSearchTerm: (searchTerm: string) => ReturnType;
+ /**
+ * @description Set replace term in extension.
+ */
+ setReplaceTerm: (replaceTerm: string) => ReturnType;
+ /**
+ * @description Set case sensitivity in extension.
+ */
+ setCaseSensitive: (caseSensitive: boolean) => ReturnType;
+ /**
+ * @description Reset current search result to first instance.
+ */
+ resetIndex: () => ReturnType;
+ /**
+ * @description Find next instance of search result.
+ */
+ nextSearchResult: () => ReturnType;
+ /**
+ * @description Find previous instance of search result.
+ */
+ previousSearchResult: () => ReturnType;
+ /**
+ * @description Replace first instance of search result with given replace term.
+ */
+ replace: () => ReturnType;
+ /**
+ * @description Replace all instances of search result with given replace term.
+ */
+ replaceAll: () => ReturnType;
+ /**
+ * @description Find selected instance of search result.
+ */
+ selectCurrentItem: () => ReturnType;
+ };
+ }
+}
+
+interface TextNodesWithPosition {
+ text: string;
+ pos: number;
+}
+
+const getRegex = (
+ s: string,
+ disableRegex: boolean,
+ caseSensitive: boolean,
+): RegExp => {
+ return RegExp(
+ disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s,
+ caseSensitive ? "gu" : "gui",
+ );
+};
+
+interface ProcessedSearches {
+ decorationsToReturn: DecorationSet;
+ results: Range[];
+}
+
+function processSearches(
+ doc: PMNode,
+ searchTerm: RegExp,
+ searchResultClass: string,
+ resultIndex: number,
+): ProcessedSearches {
+ const decorations: Decoration[] = [];
+ const results: Range[] = [];
+
+ let textNodesWithPosition: TextNodesWithPosition[] = [];
+ let index = 0;
+
+ if (!searchTerm) {
+ return {
+ decorationsToReturn: DecorationSet.empty,
+ results: [],
+ };
+ }
+
+ doc?.descendants((node, pos) => {
+ if (node.isText) {
+ if (textNodesWithPosition[index]) {
+ textNodesWithPosition[index] = {
+ text: textNodesWithPosition[index].text + node.text,
+ pos: textNodesWithPosition[index].pos,
+ };
+ } else {
+ textNodesWithPosition[index] = {
+ text: `${node.text}`,
+ pos,
+ };
+ }
+ } else {
+ index += 1;
+ }
+ });
+
+ textNodesWithPosition = textNodesWithPosition.filter(Boolean);
+
+ for (const element of textNodesWithPosition) {
+ const { text, pos } = element;
+ const matches = Array.from(text.matchAll(searchTerm)).filter(
+ ([matchText]) => matchText.trim(),
+ );
+
+ for (const m of matches) {
+ if (m[0] === "") break;
+
+ if (m.index !== undefined) {
+ results.push({
+ from: pos + m.index,
+ to: pos + m.index + m[0].length,
+ });
+ }
+ }
+ }
+
+ for (let i = 0; i < results.length; i += 1) {
+ const r = results[i];
+ const className =
+ i === resultIndex
+ ? `${searchResultClass} ${searchResultClass}-current`
+ : searchResultClass;
+ const decoration: Decoration = Decoration.inline(r.from, r.to, {
+ class: className,
+ });
+
+ decorations.push(decoration);
+ }
+
+ return {
+ decorationsToReturn: DecorationSet.create(doc, decorations),
+ results,
+ };
+}
+
+const replace = (
+ replaceTerm: string,
+ results: Range[],
+ resultIndex: number,
+ { state, dispatch }: { state: EditorState; dispatch: Dispatch },
+) => {
+ const firstResult = results[resultIndex];
+
+ if (!firstResult) return;
+
+ const { from, to } = results[resultIndex];
+
+ if (dispatch) {
+ const tr = state.tr;
+
+ // Get all marks that span the text being replaced
+ const marksSet = new Set();
+ state.doc.nodesBetween(from, to, (node) => {
+ if (node.isText && node.marks) {
+ node.marks.forEach(mark => marksSet.add(mark));
+ }
+ });
+
+ const marks = Array.from(marksSet);
+
+ // Delete the old text and insert new text with preserved marks
+ tr.delete(from, to);
+ tr.insert(from, state.schema.text(replaceTerm, marks));
+
+ dispatch(tr);
+ }
+};
+
+const replaceAll = (
+ replaceTerm: string,
+ results: Range[],
+ { tr, dispatch }: { tr: Transaction; dispatch: Dispatch },
+) => {
+ const resultsCopy = results.slice();
+
+ if (!resultsCopy.length) return;
+
+ // Process replacements in reverse order to avoid position shifting issues
+ for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
+ const { from, to } = resultsCopy[i];
+
+ // Get all marks that span the text being replaced
+ const marksSet = new Set();
+ tr.doc.nodesBetween(from, to, (node) => {
+ if (node.isText && node.marks) {
+ node.marks.forEach(mark => marksSet.add(mark));
+ }
+ });
+
+ const marks = Array.from(marksSet);
+
+ // Delete and insert with preserved marks
+ tr.delete(from, to);
+ tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
+ }
+
+ dispatch(tr);
+};
+
+export const searchAndReplacePluginKey = new PluginKey(
+ "searchAndReplacePlugin",
+);
+
+export interface SearchAndReplaceOptions {
+ searchResultClass: string;
+ disableRegex: boolean;
+}
+
+export interface SearchAndReplaceStorage {
+ searchTerm: string;
+ replaceTerm: string;
+ results: Range[];
+ lastSearchTerm: string;
+ caseSensitive: boolean;
+ lastCaseSensitive: boolean;
+ resultIndex: number;
+ lastResultIndex: number;
+}
+
+export const SearchAndReplace = Extension.create<
+ SearchAndReplaceOptions,
+ SearchAndReplaceStorage
+>({
+ name: "searchAndReplace",
+
+ addOptions() {
+ return {
+ searchResultClass: "search-result",
+ disableRegex: true,
+ };
+ },
+
+ addStorage() {
+ return {
+ searchTerm: "",
+ replaceTerm: "",
+ results: [],
+ lastSearchTerm: "",
+ caseSensitive: false,
+ lastCaseSensitive: false,
+ resultIndex: 0,
+ lastResultIndex: 0,
+ };
+ },
+
+ addCommands() {
+ return {
+ setSearchTerm:
+ (searchTerm: string) =>
+ ({ editor }) => {
+ editor.storage.searchAndReplace.searchTerm = searchTerm;
+
+ return false;
+ },
+ setReplaceTerm:
+ (replaceTerm: string) =>
+ ({ editor }) => {
+ editor.storage.searchAndReplace.replaceTerm = replaceTerm;
+
+ return false;
+ },
+ setCaseSensitive:
+ (caseSensitive: boolean) =>
+ ({ editor }) => {
+ editor.storage.searchAndReplace.caseSensitive = caseSensitive;
+
+ return false;
+ },
+ resetIndex:
+ () =>
+ ({ editor }) => {
+ editor.storage.searchAndReplace.resultIndex = 0;
+
+ return false;
+ },
+ nextSearchResult:
+ () =>
+ ({ editor }) => {
+ const { results, resultIndex } = editor.storage.searchAndReplace;
+
+ const nextIndex = resultIndex + 1;
+
+ if (results[nextIndex]) {
+ editor.storage.searchAndReplace.resultIndex = nextIndex;
+ } else {
+ editor.storage.searchAndReplace.resultIndex = 0;
+ }
+
+ return false;
+ },
+ previousSearchResult:
+ () =>
+ ({ editor }) => {
+ const { results, resultIndex } = editor.storage.searchAndReplace;
+
+ const prevIndex = resultIndex - 1;
+
+ if (results[prevIndex]) {
+ editor.storage.searchAndReplace.resultIndex = prevIndex;
+ } else {
+ editor.storage.searchAndReplace.resultIndex = results.length - 1;
+ }
+
+ return false;
+ },
+ replace:
+ () =>
+ ({ editor, state, dispatch }) => {
+ const { replaceTerm, results, resultIndex } =
+ editor.storage.searchAndReplace;
+
+ replace(replaceTerm, results, resultIndex, { state, dispatch });
+
+ // After replace, adjust index if needed
+ // The results will be recalculated by the plugin, but we need to ensure
+ // the index doesn't exceed the new bounds
+ setTimeout(() => {
+ const newResultsLength = editor.storage.searchAndReplace.results.length;
+ if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) {
+ // Keep the same position if possible, otherwise go to the last result
+ editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1);
+ }
+ }, 0);
+
+ return false;
+ },
+ replaceAll:
+ () =>
+ ({ editor, tr, dispatch }) => {
+ const { replaceTerm, results } = editor.storage.searchAndReplace;
+
+ replaceAll(replaceTerm, results, { tr, dispatch });
+
+ return false;
+ },
+ selectCurrentItem:
+ () =>
+ ({ editor }) => {
+ const { results } = editor.storage.searchAndReplace;
+ for (let i = 0; i < results.length; i++) {
+ if (
+ results[i].from == editor.state.selection.from &&
+ results[i].to == editor.state.selection.to
+ ) {
+ editor.storage.searchAndReplace.resultIndex = i;
+ }
+ }
+ return false;
+ },
+ };
+ },
+
+ addProseMirrorPlugins() {
+ const editor = this.editor;
+ const { searchResultClass, disableRegex } = this.options;
+
+ const setLastSearchTerm = (t: string) =>
+ (editor.storage.searchAndReplace.lastSearchTerm = t);
+ const setLastCaseSensitive = (t: boolean) =>
+ (editor.storage.searchAndReplace.lastCaseSensitive = t);
+ const setLastResultIndex = (t: number) =>
+ (editor.storage.searchAndReplace.lastResultIndex = t);
+
+ return [
+ new Plugin({
+ key: searchAndReplacePluginKey,
+ state: {
+ init: () => DecorationSet.empty,
+ apply({ doc, docChanged }, oldState) {
+ const {
+ searchTerm,
+ lastSearchTerm,
+ caseSensitive,
+ lastCaseSensitive,
+ resultIndex,
+ lastResultIndex,
+ } = editor.storage.searchAndReplace;
+
+ if (
+ !docChanged &&
+ lastSearchTerm === searchTerm &&
+ lastCaseSensitive === caseSensitive &&
+ lastResultIndex === resultIndex
+ )
+ return oldState;
+
+ setLastSearchTerm(searchTerm);
+ setLastCaseSensitive(caseSensitive);
+ setLastResultIndex(resultIndex);
+
+ if (!searchTerm) {
+ editor.storage.searchAndReplace.results = [];
+ return DecorationSet.empty;
+ }
+
+ const { decorationsToReturn, results } = processSearches(
+ doc,
+ getRegex(searchTerm, disableRegex, caseSensitive),
+ searchResultClass,
+ resultIndex,
+ );
+
+ editor.storage.searchAndReplace.results = results;
+
+ return decorationsToReturn;
+ },
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state);
+ },
+ },
+ }),
+ ];
+ },
+});
+
+export default SearchAndReplace;