diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index b9287dc9..769ff61a 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -389,5 +389,15 @@ "Failed to share page": "Failed to share page", "Copy page": "Copy page", "Copy page to a different space.": "Copy page to a different space.", - "Page copied successfully": "Page copied successfully" + "Page copied successfully": "Page copied successfully", + "Find": "Find", + "Not found": "Not found", + "Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)", + "Next match (Enter)": "Next match (Enter)", + "Match case (Alt+C)": "Match case (Alt+C)", + "Replace": "Replace", + "Close (Escape)": "Close (Escape)", + "Replace (Enter)": "Replace (Enter)", + "Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)", + "Replace all": "Replace all" } diff --git a/apps/client/src/ee/billing/components/billing-details.tsx b/apps/client/src/ee/billing/components/billing-details.tsx index a4ea9547..0fb06147 100644 --- a/apps/client/src/ee/billing/components/billing-details.tsx +++ b/apps/client/src/ee/billing/components/billing-details.tsx @@ -117,7 +117,8 @@ export default function BillingDetails() { {billing.billingScheme === "tiered" && ( <> - ${billing.amount / 100} {billing.currency.toUpperCase()} + ${billing.amount / 100} {billing.currency.toUpperCase()} /{" "} + {billing.interval} per {billing.interval} @@ -129,7 +130,7 @@ export default function BillingDetails() { <> {(billing.amount / 100) * billing.quantity}{" "} - {billing.currency.toUpperCase()} + {billing.currency.toUpperCase()} / {billing.interval} ${billing.amount / 100} /user/{billing.interval} diff --git a/apps/client/src/ee/billing/components/billing-plans.tsx b/apps/client/src/ee/billing/components/billing-plans.tsx index 8d5f28d3..b57643b2 100644 --- a/apps/client/src/ee/billing/components/billing-plans.tsx +++ b/apps/client/src/ee/billing/components/billing-plans.tsx @@ -12,14 +12,18 @@ import { Badge, Flex, Switch, + Alert, } from "@mantine/core"; import { useState } from "react"; -import { IconCheck } from "@tabler/icons-react"; +import { IconCheck, IconInfoCircle } from "@tabler/icons-react"; import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts"; import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts"; +import { useAtomValue } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; export default function BillingPlans() { const { data: plans } = useBillingPlans(); + const workspace = useAtomValue(workspaceAtom); const [isAnnual, setIsAnnual] = useState(true); const [selectedTierValue, setSelectedTierValue] = useState( null, @@ -36,49 +40,76 @@ export default function BillingPlans() { } }; + // TODO: remove by July 30. + // Check if workspace was created between June 28 and July 14, 2025 + const showTieredPricingNotice = (() => { + if (!workspace?.createdAt) return false; + const createdDate = new Date(workspace.createdAt); + const startDate = new Date('2025-06-20'); + const endDate = new Date('2025-07-14'); + return createdDate >= startDate && createdDate <= endDate; + })(); + if (!plans || plans.length === 0) { return null; } - const firstPlan = plans[0]; + // Check if any plan is tiered + const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0); + const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0); - // Set initial tier value if not set - if (!selectedTierValue && firstPlan.pricingTiers.length > 0) { - setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString()); + // Set initial tier value if not set and we have tiered plans + if (hasTieredPlans && !selectedTierValue && firstTieredPlan) { + setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString()); return null; } - if (!selectedTierValue) { + // For tiered plans, ensure we have a selected tier + if (hasTieredPlans && !selectedTierValue) { return null; } - const selectData = firstPlan.pricingTiers - .filter((tier) => !tier.custom) + const selectData = firstTieredPlan?.pricingTiers + ?.filter((tier) => !tier.custom) .map((tier, index) => { const prevMaxUsers = - index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0; + index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0; return { value: tier.upTo.toString(), label: `${prevMaxUsers + 1}-${tier.upTo} users`, }; - }); + }) || []; return ( + {/* Tiered pricing notice for eligible workspaces */} + {showTieredPricingNotice && !hasTieredPlans && ( + } + title="Want the old tiered pricing?" + color="blue" + mb="lg" + > + Contact support to switch back to our tiered pricing model. + + )} + {/* Controls Section */} {/* Team Size and Billing Controls */} - + )} @@ -102,17 +133,29 @@ export default function BillingPlans() { {/* Plans Grid */} {plans.map((plan, index) => { - const tieredPlan = plan; - const planSelectedTier = - tieredPlan.pricingTiers.find( - (tier) => tier.upTo.toString() === selectedTierValue, - ) || tieredPlan.pricingTiers[0]; - - const price = isAnnual - ? planSelectedTier.yearly - : planSelectedTier.monthly; + let price; + let displayPrice; const priceId = isAnnual ? plan.yearlyId : plan.monthlyId; + if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) { + // Tiered billing logic + const planSelectedTier = + plan.pricingTiers.find( + (tier) => tier.upTo.toString() === selectedTierValue, + ) || plan.pricingTiers[0]; + + price = isAnnual + ? planSelectedTier.yearly + : planSelectedTier.monthly; + displayPrice = isAnnual ? (price / 12).toFixed(0) : price; + } else { + // Per-unit billing logic + const monthlyPrice = parseFloat(plan.price?.monthly || '0'); + const yearlyPrice = parseFloat(plan.price?.yearly || '0'); + price = isAnnual ? yearlyPrice : monthlyPrice; + displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice; + } + return ( - ${isAnnual ? (price / 12).toFixed(0) : price} + ${displayPrice} - per {isAnnual ? "month" : "month"} + {plan.billingScheme === 'per_unit' + ? `per user/month` + : `per month`} - {isAnnual && ( - - Billed annually + + {isAnnual ? "Billed annually" : "Billed monthly"} + + {plan.billingScheme === 'tiered' && plan.pricingTiers && ( + + For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users )} - - For {planSelectedTier.upTo} users - {/* CTA Button */} {/* Features */} diff --git a/apps/client/src/ee/billing/types/billing.types.ts b/apps/client/src/ee/billing/types/billing.types.ts index dfa1a60b..58225519 100644 --- a/apps/client/src/ee/billing/types/billing.types.ts +++ b/apps/client/src/ee/billing/types/billing.types.ts @@ -53,7 +53,7 @@ export interface IBillingPlan { }; features: string[]; billingScheme: string | null; - pricingTiers: PricingTier[]; + pricingTiers?: PricingTier[]; } interface PricingTier { diff --git a/apps/client/src/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts b/apps/client/src/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts new file mode 100644 index 00000000..e9760ef3 --- /dev/null +++ b/apps/client/src/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts @@ -0,0 +1,9 @@ +import { atom } from "jotai"; + +type SearchAndReplaceAtomType = { + isOpen: boolean; +}; + +export const searchAndReplaceStateAtom = atom({ + isOpen: false, +}); diff --git a/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx new file mode 100644 index 00000000..df6f0031 --- /dev/null +++ b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx @@ -0,0 +1,312 @@ +import { + ActionIcon, + Button, + Dialog, + Flex, + Input, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { + IconArrowNarrowDown, + IconArrowNarrowUp, + IconLetterCase, + IconReplace, + IconSearch, + IconX, +} from "@tabler/icons-react"; +import { useEditor } from "@tiptap/react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts"; +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { getHotkeyHandler, useToggle } from "@mantine/hooks"; +import { useLocation } from "react-router-dom"; +import classes from "./search-replace.module.css"; + +interface PageFindDialogDialogProps { + editor: ReturnType; + editable?: boolean; +} + +function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) { + const { t } = useTranslation(); + const [searchText, setSearchText] = useState(""); + const [replaceText, setReplaceText] = useState(""); + const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom); + const inputRef = useRef(null); + + const [replaceButton, replaceButtonToggle] = useToggle([ + { isReplaceShow: false, color: "gray" }, + { isReplaceShow: true, color: "blue" }, + ]); + + const [caseSensitive, caseSensitiveToggle] = useToggle([ + { isCaseSensitive: false, color: "gray" }, + { isCaseSensitive: true, color: "blue" }, + ]); + + const searchInputEvent = (event: React.ChangeEvent) => { + setSearchText(event.target.value); + }; + + const replaceInputEvent = (event: React.ChangeEvent) => { + setReplaceText(event.target.value); + }; + + const closeDialog = () => { + setSearchText(""); + setReplaceText(""); + setPageFindState({ isOpen: false }); + // Reset replace button state when closing + if (replaceButton.isReplaceShow) { + replaceButtonToggle(); + } + // Clear search term in editor + if (editor) { + editor.commands.setSearchTerm(""); + } + }; + + const goToSelection = () => { + if (!editor) return; + + const { results, resultIndex } = editor.storage.searchAndReplace; + const position: Range = results[resultIndex]; + + if (!position) return; + + // @ts-ignore + editor.commands.setTextSelection(position); + + const element = document.querySelector(".search-result-current"); + if (element) + element.scrollIntoView({ behavior: "smooth", block: "center" }); + + editor.commands.setTextSelection(0); + }; + + const next = () => { + editor.commands.nextSearchResult(); + goToSelection(); + }; + + const previous = () => { + editor.commands.previousSearchResult(); + goToSelection(); + }; + + const replace = () => { + editor.commands.setReplaceTerm(replaceText); + editor.commands.replace(); + goToSelection(); + }; + + const replaceAll = () => { + editor.commands.setReplaceTerm(replaceText); + editor.commands.replaceAll(); + }; + + useEffect(() => { + editor.commands.setSearchTerm(searchText); + editor.commands.resetIndex(); + editor.commands.selectCurrentItem(); + }, [searchText]); + + const handleOpenEvent = (e) => { + setPageFindState({ isOpen: true }); + const selectedText = editor.state.doc.textBetween( + editor.state.selection.from, + editor.state.selection.to, + ); + if (selectedText !== "") { + setSearchText(selectedText); + } + inputRef.current?.focus(); + inputRef.current?.select(); + }; + + const handleCloseEvent = (e) => { + closeDialog(); + }; + + useEffect(() => { + !pageFindState.isOpen && closeDialog(); + + document.addEventListener("openFindDialogFromEditor", handleOpenEvent); + document.addEventListener("closeFindDialogFromEditor", handleCloseEvent); + + return () => { + document.removeEventListener("openFindDialogFromEditor", handleOpenEvent); + document.removeEventListener( + "closeFindDialogFromEditor", + handleCloseEvent, + ); + }; + }, [pageFindState.isOpen]); + + useEffect(() => { + editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive); + editor.commands.resetIndex(); + goToSelection(); + }, [caseSensitive]); + + const resultsCount = useMemo( + () => + searchText.trim() === "" + ? "" + : editor?.storage?.searchAndReplace?.results.length > 0 + ? editor?.storage?.searchAndReplace?.resultIndex + + 1 + + "/" + + editor?.storage?.searchAndReplace?.results.length + : t("Not found"), + [ + searchText, + editor?.storage?.searchAndReplace?.resultIndex, + editor?.storage?.searchAndReplace?.results.length, + ], + ); + + const location = useLocation(); + useEffect(() => { + closeDialog(); + }, [location]); + + return ( + + + + } + rightSection={ + + {resultsCount} + + } + rightSectionWidth="70" + rightSectionPointerEvents="all" + size="xs" + w={220} + onChange={searchInputEvent} + value={searchText} + autoFocus + onKeyDown={getHotkeyHandler([ + ["Enter", next], + ["shift+Enter", previous], + ["alt+C", caseSensitiveToggle], + //@ts-ignore + ...(editable ? [["alt+R", replaceButtonToggle]] : []), + ])} + /> + + + + + + + + + + + + + + caseSensitiveToggle()} + > + + + + {editable && ( + + replaceButtonToggle()} + > + + + + )} + + + + + + + + {replaceButton.isReplaceShow && editable && ( + + } + rightSection={
} + rightSectionPointerEvents="all" + size="xs" + w={180} + autoFocus + onChange={replaceInputEvent} + value={replaceText} + onKeyDown={getHotkeyHandler([ + ["Enter", replace], + ["ctrl+alt+Enter", replaceAll], + ])} + /> + + + + + + + + +
+ )} +
+
+ ); +} + +export default SearchAndReplaceDialog; diff --git a/apps/client/src/features/editor/components/search-and-replace/search-replace.module.css b/apps/client/src/features/editor/components/search-and-replace/search-replace.module.css new file mode 100644 index 00000000..f864991c --- /dev/null +++ b/apps/client/src/features/editor/components/search-and-replace/search-replace.module.css @@ -0,0 +1,10 @@ +.findDialog{ + @media print { + display: none; + } +} + +.findDialog div[data-position="right"].mantine-Input-section { + justify-content: right; + padding-right: 8px; +} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index dc27f198..4558151d 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -36,6 +36,7 @@ import { Drawio, Excalidraw, Embed, + SearchAndReplace, Mention, } from "@docmost/editor-ext"; import { @@ -217,6 +218,22 @@ export const mainExtensions = [ CharacterCount.configure({ wordCounter: (text) => countWords(text), }), + SearchAndReplace.extend({ + addKeyboardShortcuts() { + return { + 'Mod-f': () => { + const event = new CustomEvent("openFindDialogFromEditor", {}); + document.dispatchEvent(event); + return true; + }, + 'Escape': () => { + const event = new CustomEvent("closeFindDialogFromEditor", {}); + document.dispatchEvent(event); + return true; + }, + } + }, + }).configure(), ] as any; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index ec937ff9..01f6dc55 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -1,10 +1,5 @@ import "@/features/editor/styles/index.css"; -import React, { - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { IndexeddbPersistence } from "y-indexeddb"; import * as Y from "yjs"; import { @@ -44,6 +39,7 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import DrawioMenu from "./components/drawio/drawio-menu"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; +import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx"; import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks"; import { useIdle } from "@/hooks/use-idle.ts"; import { queryClient } from "@/main.tsx"; @@ -130,7 +126,15 @@ export default function PageEditor({ const now = Date.now().valueOf() / 1000; const isTokenExpired = now >= payload.exp; if (isTokenExpired) { - refetchCollabToken(); + refetchCollabToken().then((result) => { + if (result.data?.token) { + remote.disconnect(); + setTimeout(() => { + remote.configuration.token = result.data.token; + remote.connect(); + }, 100); + } + }); } }, onStatus: (status) => { @@ -156,6 +160,21 @@ export default function PageEditor({ }; }, [pageId]); + /* + useEffect(() => { + // Handle token updates by reconnecting with new token + if (providersRef.current?.remote && collabQuery?.token) { + const currentToken = providersRef.current.remote.configuration.token; + if (currentToken !== collabQuery.token) { + // Token has changed, need to reconnect with new token + providersRef.current.remote.disconnect(); + providersRef.current.remote.configuration.token = collabQuery.token; + providersRef.current.remote.connect(); + } + } + }, [collabQuery?.token]); + */ + // Only connect/disconnect on tab/idle, not destroy useEffect(() => { if (!providersReady || !providersRef.current) return; @@ -198,6 +217,10 @@ export default function PageEditor({ scrollMargin: 80, handleDOMEvents: { keydown: (_view, event) => { + if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { + event.preventDefault(); + return true; + } if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { const slashCommand = document.querySelector("#slash-command"); if (slashCommand) { @@ -350,6 +373,11 @@ export default function PageEditor({
+ + {editor && ( + + )} + {editor && editor.isEditable && (
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;