From e209aaa27227ea18599bd897acf6e5a0ec1a9931 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 14 Feb 2025 15:36:44 +0000 Subject: [PATCH] feat: internal page links and mentions (#604) * Work on mentions * fix: properly parse page slug * fix editor suggestion bugs * mentions must start with whitespace * add icon to page mention render * feat: backlinks - WIP * UI - WIP * permissions check * use FTS for page suggestion * cleanup * WIP * page title fallback * feat: handle internal link paste * link styling * WIP * Switch back to LIKE operator for search suggestion * WIP * scope to workspaceId * still create link for pages not found * select necessary columns * cleanups --- ...d-handler.tsx => editor-paste-handler.tsx} | 32 +- .../components/link/internal-link-paste.ts | 74 ++++ .../editor/components/link/link-preview.tsx | 8 +- .../editor/components/link/link.module.css | 6 + .../components/mention/mention-list.tsx | 273 ++++++++++++++ .../components/mention/mention-suggestion.ts | 113 ++++++ .../components/mention/mention-view.tsx | 56 +++ .../components/mention/mention.module.css | 58 +++ .../editor/components/mention/mention.type.ts | 28 ++ .../features/editor/extensions/extensions.ts | 23 +- .../src/features/editor/page-editor.tsx | 7 +- .../src/features/editor/styles/core.css | 10 +- .../src/features/editor/styles/index.css | 2 + .../src/features/editor/styles/mention.css | 5 + .../features/search/queries/search-query.ts | 3 +- .../src/features/search/search-spotlight.tsx | 11 +- .../src/features/search/types/search.types.ts | 5 + apps/client/src/lib/constants.ts | 2 + apps/client/src/lib/{utils.ts => utils.tsx} | 25 +- apps/client/src/pages/page/page.tsx | 5 +- .../src/collaboration/collaboration.util.ts | 2 + .../extensions/persistence.extension.ts | 49 ++- .../src/common/helpers/prosemirror/utils.ts | 58 +++ apps/server/src/common/helpers/utils.ts | 11 +- .../processors/attachment.processor.ts | 2 +- apps/server/src/core/search/dto/search.dto.ts | 16 +- .../src/core/search/search.controller.ts | 4 +- apps/server/src/core/search/search.service.ts | 68 ++-- .../src/core/space/services/space.service.ts | 2 +- apps/server/src/database/database.module.ts | 3 + .../migrations/20241218T223249-backlinks.ts | 33 ++ .../database/repos/backlink/backlink.repo.ts | 72 ++++ .../src/database/repos/page/page.repo.ts | 12 +- apps/server/src/database/types/db.d.ts | 19 +- .../server/src/database/types/entity.types.ts | 8 +- .../integrations/export/export.controller.ts | 6 +- .../src/integrations/export/export.service.ts | 133 ++++++- apps/server/src/integrations/export/utils.ts | 7 +- .../queue/constants/queue.constants.ts | 8 +- .../queue/constants/queue.interface.ts | 8 + .../queue/processors/backlinks.processor.ts | 129 +++++++ .../src/integrations/queue/queue.module.ts | 7 +- package.json | 2 +- packages/editor-ext/src/index.ts | 3 +- packages/editor-ext/src/lib/mention.ts | 334 ++++++++++++++++++ pnpm-lock.yaml | 38 +- 46 files changed, 1679 insertions(+), 101 deletions(-) rename apps/client/src/features/editor/components/common/{file-upload-handler.tsx => editor-paste-handler.tsx} (55%) create mode 100644 apps/client/src/features/editor/components/link/internal-link-paste.ts create mode 100644 apps/client/src/features/editor/components/link/link.module.css create mode 100644 apps/client/src/features/editor/components/mention/mention-list.tsx create mode 100644 apps/client/src/features/editor/components/mention/mention-suggestion.ts create mode 100644 apps/client/src/features/editor/components/mention/mention-view.tsx create mode 100644 apps/client/src/features/editor/components/mention/mention.module.css create mode 100644 apps/client/src/features/editor/components/mention/mention.type.ts create mode 100644 apps/client/src/features/editor/styles/mention.css create mode 100644 apps/client/src/lib/constants.ts rename apps/client/src/lib/{utils.ts => utils.tsx} (77%) create mode 100644 apps/server/src/common/helpers/prosemirror/utils.ts create mode 100644 apps/server/src/database/migrations/20241218T223249-backlinks.ts create mode 100644 apps/server/src/database/repos/backlink/backlink.repo.ts create mode 100644 apps/server/src/integrations/queue/constants/queue.interface.ts create mode 100644 apps/server/src/integrations/queue/processors/backlinks.processor.ts create mode 100644 packages/editor-ext/src/lib/mention.ts diff --git a/apps/client/src/features/editor/components/common/file-upload-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx similarity index 55% rename from apps/client/src/features/editor/components/common/file-upload-handler.tsx rename to apps/client/src/features/editor/components/common/editor-paste-handler.tsx index 0486286a..c0a9c6d8 100644 --- a/apps/client/src/features/editor/components/common/file-upload-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -2,12 +2,42 @@ import type { EditorView } from "@tiptap/pm/view"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; +import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; +import { Slice } from "@tiptap/pm/model"; +import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; -export const handleFilePaste = ( +export const handlePaste = ( view: EditorView, event: ClipboardEvent, pageId: string, + creatorId?: string, ) => { + const clipboardData = event.clipboardData.getData("text/plain"); + + if (INTERNAL_LINK_REGEX.test(clipboardData)) { + // we have to do this validation here to allow the default link extension to takeover if needs be + event.preventDefault(); + const url = clipboardData.trim(); + const { from: pos, empty } = view.state.selection; + const match = INTERNAL_LINK_REGEX.exec(url); + const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href); + + // pasted link must be from the same workspace/domain and must not be on a selection + if (!empty || match[2] !== window.location.host) { + // allow the default link extension to handle this + return false; + } + + // for now, we only support internal links from the same space + // compare space name + if (currentPageMatch[4].toLowerCase() !== match[4].toLowerCase()) { + return false; + } + + createMentionAction(url, view, pos, creatorId); + return true; + } + if (event.clipboardData?.files.length) { event.preventDefault(); const [file] = Array.from(event.clipboardData.files); diff --git a/apps/client/src/features/editor/components/link/internal-link-paste.ts b/apps/client/src/features/editor/components/link/internal-link-paste.ts new file mode 100644 index 00000000..ac3fc55a --- /dev/null +++ b/apps/client/src/features/editor/components/link/internal-link-paste.ts @@ -0,0 +1,74 @@ +import { EditorView } from "@tiptap/pm/view"; +import { getPageById } from "@/features/page/services/page-service.ts"; +import { IPage } from "@/features/page/types/page.types.ts"; +import { v7 } from "uuid"; +import { extractPageSlugId } from "@/lib"; + +export type LinkFn = ( + url: string, + view: EditorView, + pos: number, + creatorId: string, +) => void; + +export interface InternalLinkOptions { + validateFn: (url: string, view: EditorView) => boolean; + onResolveLink: (linkedPageId: string, creatorId: string) => Promise; +} + +export const handleInternalLink = + ({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn => + async (url: string, view, pos, creatorId) => { + const validated = validateFn(url, view); + if (!validated) return; + + const linkedPageId = extractPageSlugId(url); + + await onResolveLink(linkedPageId, creatorId).then( + (page: IPage) => { + const { schema } = view.state; + + const node = schema.nodes.mention.create({ + id: v7(), + label: page.title || "Untitled", + entityType: "page", + entityId: page.id, + slugId: page.slugId, + creatorId: creatorId, + }); + + if (!node) return; + + const transaction = view.state.tr.replaceWith(pos, pos, node); + view.dispatch(transaction); + }, + () => { + // on failure, insert as normal link + const { schema } = view.state; + + const transaction = view.state.tr.insertText(url, pos); + transaction.addMark( + pos, + pos + url.length, + schema.marks.link.create({ href: url }), + ); + + view.dispatch(transaction); + }, + ); + }; + +export const createMentionAction = handleInternalLink({ + onResolveLink: async (linkedPageId: string): Promise => { + // eslint-disable-next-line no-useless-catch + try { + return await getPageById({ pageId: linkedPageId }); + } catch (err) { + throw err; + } + }, + validateFn: (url: string, view: EditorView) => { + // validation is already done on the paste handler + return true; + }, +}); diff --git a/apps/client/src/features/editor/components/link/link-preview.tsx b/apps/client/src/features/editor/components/link/link-preview.tsx index fecd1727..8b0de952 100644 --- a/apps/client/src/features/editor/components/link/link-preview.tsx +++ b/apps/client/src/features/editor/components/link/link-preview.tsx @@ -8,6 +8,7 @@ import { } from "@mantine/core"; import { IconLinkOff, IconPencil } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; +import classes from "./link.module.css"; export type LinkPreviewPanelProps = { url: string; @@ -31,12 +32,7 @@ export const LinkPreviewPanel = ({ href={url} target="_blank" rel="noopener noreferrer" - inherit - style={{ - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }} + className={classes.link} > {url} diff --git a/apps/client/src/features/editor/components/link/link.module.css b/apps/client/src/features/editor/components/link/link.module.css new file mode 100644 index 00000000..2168997f --- /dev/null +++ b/apps/client/src/features/editor/components/link/link.module.css @@ -0,0 +1,6 @@ +.link { + color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} \ No newline at end of file diff --git a/apps/client/src/features/editor/components/mention/mention-list.tsx b/apps/client/src/features/editor/components/mention/mention-list.tsx new file mode 100644 index 00000000..4a4ae74a --- /dev/null +++ b/apps/client/src/features/editor/components/mention/mention-list.tsx @@ -0,0 +1,273 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts"; +import { + ActionIcon, + Group, + Paper, + ScrollArea, + Text, + UnstyledButton, +} from "@mantine/core"; +import clsx from "clsx"; +import classes from "./mention.module.css"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { IconFileDescription } from "@tabler/icons-react"; +import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; +import { useParams } from "react-router-dom"; +import { v7 as uuid7 } from "uuid"; +import { useAtom } from "jotai"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { + MentionListProps, + MentionSuggestionItem, +} from "@/features/editor/components/mention/mention.type.ts"; + +const MentionList = forwardRef((props, ref) => { + const [selectedIndex, setSelectedIndex] = useState(1); + const viewportRef = useRef(null); + const { spaceSlug } = useParams(); + const { data: space } = useSpaceQuery(spaceSlug); + const [currentUser] = useAtom(currentUserAtom); + const [renderItems, setRenderItems] = useState([]); + + const { data: suggestion, isLoading } = useSearchSuggestionsQuery({ + query: props.query, + includeUsers: true, + includePages: true, + spaceId: space.id, + limit: 10, + }); + + useEffect(() => { + if (suggestion && !isLoading) { + let items: MentionSuggestionItem[] = []; + + if (suggestion?.users?.length > 0) { + items.push({ entityType: "header", label: "Users" }); + + items = items.concat( + suggestion.users.map((user) => ({ + id: uuid7(), + label: user.name, + entityType: "user", + entityId: user.id, + avatarUrl: user.avatarUrl, + })), + ); + } + + if (suggestion?.pages?.length > 0) { + items.push({ entityType: "header", label: "Pages" }); + items = items.concat( + suggestion.pages.map((page) => ({ + id: uuid7(), + label: page.title || "Untitled", + entityType: "page", + entityId: page.id, + slugId: page.slugId, + icon: page.icon, + })), + ); + } + + setRenderItems(items); + // update editor storage + props.editor.storage.mentionItems = items; + } + }, [suggestion, isLoading]); + + const selectItem = useCallback( + (index: number) => { + const item = renderItems?.[index]; + if (item) { + if (item.entityType === "user") { + props.command({ + id: item.id, + label: item.label, + entityType: "user", + entityId: item.entityId, + creatorId: currentUser?.user.id, + }); + } + if (item.entityType === "page") { + props.command({ + id: item.id, + label: item.label || "Untitled", + entityType: "page", + entityId: item.entityId, + slugId: item.slugId, + creatorId: currentUser?.user.id, + }); + } + } + }, + [renderItems], + ); + + const upHandler = () => { + if (!renderItems.length) return; + + let newIndex = selectedIndex; + + do { + newIndex = (newIndex + renderItems.length - 1) % renderItems.length; + } while (renderItems[newIndex].entityType === "header"); + setSelectedIndex(newIndex); + }; + + const downHandler = () => { + if (!renderItems.length) return; + let newIndex = selectedIndex; + do { + newIndex = (newIndex + 1) % renderItems.length; + } while (renderItems[newIndex].entityType === "header"); + setSelectedIndex(newIndex); + }; + + const enterHandler = () => { + if (!renderItems.length) return; + if (renderItems[selectedIndex].entityType !== "header") { + selectItem(selectedIndex); + } + }; + + useEffect(() => { + setSelectedIndex(1); + }, [suggestion]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + if (event.key === "ArrowUp") { + upHandler(); + return true; + } + + if (event.key === "ArrowDown") { + downHandler(); + return true; + } + + if (event.key === "Enter") { + // don't trap the enter button if there are no items to render + if (renderItems.length === 0) { + return false; + } + enterHandler(); + return true; + } + + return false; + }, + })); + + // if no results and enter what to do? + + useEffect(() => { + viewportRef.current + ?.querySelector(`[data-item-index="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + if (renderItems.length === 0) { + return ( + + No results + + ); + } + + return ( + + + {renderItems?.map((item, index) => { + if (item.entityType === "header") { + return ( +
+ + {item.label} + +
+ ); + } else if (item.entityType === "user") { + return ( + selectItem(index)} + className={clsx(classes.menuBtn, { + [classes.selectedItem]: index === selectedIndex, + })} + > + + + +
+ + {item.label} + +
+
+
+ ); + } else if (item.entityType === "page") { + return ( + selectItem(index)} + className={clsx(classes.menuBtn, { + [classes.selectedItem]: index === selectedIndex, + })} + > + + + {item.icon || ( + + + + )} + + +
+ + {item.label} + +
+
+
+ ); + } else { + return null; + } + })} +
+
+ ); +}); + +export default MentionList; diff --git a/apps/client/src/features/editor/components/mention/mention-suggestion.ts b/apps/client/src/features/editor/components/mention/mention-suggestion.ts new file mode 100644 index 00000000..11710639 --- /dev/null +++ b/apps/client/src/features/editor/components/mention/mention-suggestion.ts @@ -0,0 +1,113 @@ +import { ReactRenderer, useEditor } from "@tiptap/react"; +import tippy from "tippy.js"; +import MentionList from "@/features/editor/components/mention/mention-list.tsx"; + +function getWhitespaceCount(query: string) { + const matches = query?.match(/([\s]+)/g); + return matches?.length || 0; +} + +const mentionRenderItems = () => { + let component: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { + editor: ReturnType; + clientRect: DOMRect; + query: string; + }) => { + // query must not start with a whitespace + if (props.query.charAt(0) === ' '){ + return; + } + + // don't render component if space between the search query words is greater than 4 + const whitespaceCount = getWhitespaceCount(props.query); + if (whitespaceCount > 4) { + return; + } + + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + // @ts-ignore + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: { + editor: ReturnType; + clientRect: DOMRect; + query: string; + }) => { + // query must not start with a whitespace + if (props.query.charAt(0) === ' '){ + component?.destroy(); + return; + } + + // only update component if popup is not destroyed + if (!popup?.[0].state.isDestroyed) { + component?.updateProps(props); + } + + if (!props || !props.clientRect) { + return; + } + + const whitespaceCount = getWhitespaceCount(props.query); + + // destroy component if space is greater 3 without a match + if ( + whitespaceCount > 3 && + props.editor.storage.mentionItems.length === 0 + ) { + popup?.[0]?.destroy(); + component?.destroy(); + return; + } + + popup && + !popup?.[0].state.isDestroyed && + popup?.[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key) + if ( + props.event.key === "Escape" || + (props.event.key === "Enter" && !popup?.[0].state.isShown) + ) { + popup?.[0].destroy(); + component?.destroy(); + return false; + } + return (component?.ref as any)?.onKeyDown(props); + }, + onExit: () => { + if (popup && !popup?.[0].state.isDestroyed) { + popup[0].destroy(); + } + + if (component) { + component.destroy(); + } + }, + }; +}; + +export default mentionRenderItems; diff --git a/apps/client/src/features/editor/components/mention/mention-view.tsx b/apps/client/src/features/editor/components/mention/mention-view.tsx new file mode 100644 index 00000000..fa23237f --- /dev/null +++ b/apps/client/src/features/editor/components/mention/mention-view.tsx @@ -0,0 +1,56 @@ +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { ActionIcon, Anchor, Text } from "@mantine/core"; +import { IconFileDescription } from "@tabler/icons-react"; +import { Link, useParams } from "react-router-dom"; +import { usePageQuery } from "@/features/page/queries/page-query.ts"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import classes from "./mention.module.css"; + +export default function MentionView(props: NodeViewProps) { + const { node } = props; + const { label, entityType, entityId, slugId } = node.attrs; + const { spaceSlug } = useParams(); + const { + data: page, + isLoading, + isError, + } = usePageQuery({ pageId: entityType === "page" ? slugId : null }); + + return ( + + {entityType === "user" && ( + + @{label} + + )} + + {entityType === "page" && ( + + {page?.icon ? ( + {page.icon} + ) : ( + + + + )} + + + {page?.title || label} + + + )} + + ); +} diff --git a/apps/client/src/features/editor/components/mention/mention.module.css b/apps/client/src/features/editor/components/mention/mention.module.css new file mode 100644 index 00000000..691fc71a --- /dev/null +++ b/apps/client/src/features/editor/components/mention/mention.module.css @@ -0,0 +1,58 @@ +.pageMentionLink { + color: light-dark( + var(--mantine-color-dark-4), + var(--mantine-color-dark-1) + ) !important; +} +.pageMentionText { + @mixin light { + border-bottom: 0.05em solid var(--mantine-color-dark-0); + } + @mixin dark { + border-bottom: 0.05em solid var(--mantine-color-dark-2); + } +} + +.userMention { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-6) + ); + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-1)); + font-weight: 500; + border-radius: 0.4rem; + box-decoration-break: clone; + padding: 0.1rem 0.3rem; + cursor: pointer; + &::after { + content: "\200B"; + } +} + +.menuBtn { + width: 100%; + padding: 4px; + margin-bottom: 2px; + color: var(--mantine-color-text); + border-radius: var(--mantine-radius-sm); + + &:hover { + @mixin light { + background: var(--mantine-color-gray-2); + } + + @mixin dark { + background: var(--mantine-color-gray-light); + } + } +} + +.selectedItem { + @mixin light { + background: var(--mantine-color-gray-2); + } + + @mixin dark { + background: var(--mantine-color-gray-light); + } +} diff --git a/apps/client/src/features/editor/components/mention/mention.type.ts b/apps/client/src/features/editor/components/mention/mention.type.ts new file mode 100644 index 00000000..e837629a --- /dev/null +++ b/apps/client/src/features/editor/components/mention/mention.type.ts @@ -0,0 +1,28 @@ +import { Editor, Range } from "@tiptap/core"; + +export interface MentionListProps { + query: string; + command: any; + items: []; + range: Range; + text: string; + editor: Editor; +} + +export type MentionSuggestionItem = + | { entityType: "header"; label: string } + | { + id: string; + label: string; + entityType: "user"; + entityId: string; + avatarUrl: string; +} + | { + id: string; + label: string; + entityType: "page"; + entityId: string; + slugId: string; + icon: string; +}; \ No newline at end of file diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 891f1182..9dcd288b 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, + Mention, } from "@docmost/editor-ext"; import { randomElement, @@ -64,8 +65,11 @@ import clojure from "highlight.js/lib/languages/clojure"; import fortran from "highlight.js/lib/languages/fortran"; import haskell from "highlight.js/lib/languages/haskell"; import scala from "highlight.js/lib/languages/scala"; +import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import MentionView from "@/features/editor/components/mention/mention-view.tsx"; +import i18n from "@/i18n.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; -import i18n from "i18next"; const lowlight = createLowlight(common); lowlight.register("mermaid", plaintext); @@ -133,6 +137,23 @@ export const mainExtensions = [ class: "comment-mark", }, }), + Mention.configure({ + suggestion: { + allowSpaces: true, + items: () => { + return []; + }, + // @ts-ignore + render: mentionRenderItems, + }, + HTMLAttributes: { + class: "mention", + }, + }).extend({ + addNodeView() { + return ReactNodeViewRenderer(MentionView); + }, + }), Table.configure({ resizable: true, lastColumnResizable: false, diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 79dbecad..75a14eb0 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -35,8 +35,8 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx"; import VideoMenu from "@/features/editor/components/video/video-menu.tsx"; import { handleFileDrop, - handleFilePaste, -} from "@/features/editor/components/common/file-upload-handler.tsx"; + handlePaste, +} from "@/features/editor/components/common/editor-paste-handler.tsx"; import LinkMenu from "@/features/editor/components/link/link-menu.tsx"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import DrawioMenu from "./components/drawio/drawio-menu"; @@ -138,7 +138,8 @@ export default function PageEditor({ } }, }, - handlePaste: (view, event) => handleFilePaste(view, event, pageId), + handlePaste: (view, event, slice) => + handlePaste(view, event, pageId, currentUser?.user.id), handleDrop: (view, event, _slice, moved) => handleFileDrop(view, event, moved, pageId), }, diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index 1709b42e..962a07ec 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -56,8 +56,14 @@ } a { - color: light-dark(#207af1, #587da9); - /*font-weight: bold;*/ + color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); + @mixin light { + border-bottom: 0.05em solid var(--mantine-color-dark-0); + } + @mixin dark { + border-bottom: 0.05em solid var(--mantine-color-dark-2); + } + /*font-weight: 500; */ text-decoration: none; cursor: pointer; } diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index 90b9b7c2..cf979957 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -9,3 +9,5 @@ @import "./media.css"; @import "./code.css"; @import "./print.css"; +@import "./mention.css"; + diff --git a/apps/client/src/features/editor/styles/mention.css b/apps/client/src/features/editor/styles/mention.css new file mode 100644 index 00000000..89674500 --- /dev/null +++ b/apps/client/src/features/editor/styles/mention.css @@ -0,0 +1,5 @@ +.node-mention { + &.ProseMirror-selectednode { + outline: none; + } +} \ No newline at end of file diff --git a/apps/client/src/features/search/queries/search-query.ts b/apps/client/src/features/search/queries/search-query.ts index 81f5bcb0..2505e7a2 100644 --- a/apps/client/src/features/search/queries/search-query.ts +++ b/apps/client/src/features/search/queries/search-query.ts @@ -24,7 +24,8 @@ export function useSearchSuggestionsQuery( params: SearchSuggestionParams, ): UseQueryResult { return useQuery({ - queryKey: ["search-suggestion", params], + queryKey: ["search-suggestion", params.query], + staleTime: 60 * 1000, // 1min queryFn: () => searchSuggestions(params), enabled: !!params.query, }); diff --git a/apps/client/src/features/search/search-spotlight.tsx b/apps/client/src/features/search/search-spotlight.tsx index b1e44e25..52e24557 100644 --- a/apps/client/src/features/search/search-spotlight.tsx +++ b/apps/client/src/features/search/search-spotlight.tsx @@ -1,11 +1,12 @@ import { Group, Center, Text } from "@mantine/core"; import { Spotlight } from "@mantine/spotlight"; -import { IconFileDescription, IconSearch } from "@tabler/icons-react"; +import { IconSearch } from "@tabler/icons-react"; import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useDebouncedValue } from "@mantine/hooks"; import { usePageSearchQuery } from "@/features/search/queries/search-query"; import { buildPageUrl } from "@/features/page/page.utils.ts"; +import { getPageIcon } from "@/lib"; import { useTranslation } from "react-i18next"; interface SearchSpotlightProps { @@ -33,13 +34,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { } > -
- {page?.icon ? ( - {page.icon} - ) : ( - - )} -
+
{getPageIcon(page?.icon)}
{page.title} diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts index dc94576c..5a346f6b 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -1,6 +1,7 @@ import { IUser } from "@/features/user/types/user.types.ts"; import { IGroup } from "@/features/group/types/group.types.ts"; import { ISpace } from "@/features/space/types/space.types.ts"; +import { IPage } from "@/features/page/types/page.types.ts"; export interface IPageSearch { id: string; @@ -20,11 +21,15 @@ export interface SearchSuggestionParams { query: string; includeUsers?: boolean; includeGroups?: boolean; + includePages?: boolean; + spaceId?: string; + limit?: number; } export interface ISuggestionResult { users?: Partial; groups?: Partial; + pages?: Partial; } export interface IPageSearchParams { diff --git a/apps/client/src/lib/constants.ts b/apps/client/src/lib/constants.ts new file mode 100644 index 00000000..7685837f --- /dev/null +++ b/apps/client/src/lib/constants.ts @@ -0,0 +1,2 @@ +export const INTERNAL_LINK_REGEX = + /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/; diff --git a/apps/client/src/lib/utils.ts b/apps/client/src/lib/utils.tsx similarity index 77% rename from apps/client/src/lib/utils.ts rename to apps/client/src/lib/utils.tsx index 4ea1ef8d..6914053a 100644 --- a/apps/client/src/lib/utils.ts +++ b/apps/client/src/lib/utils.tsx @@ -1,3 +1,7 @@ +import { validate as isValidUUID } from "uuid"; +import { ActionIcon } from "@mantine/core"; +import { IconFileDescription } from "@tabler/icons-react"; +import { ReactNode } from "react"; import { TFunction } from "i18next"; export function formatMemberCount(memberCount: number, t: TFunction): string { @@ -8,12 +12,15 @@ export function formatMemberCount(memberCount: number, t: TFunction): string { } } -export function extractPageSlugId(input: string): string { - if (!input) { +export function extractPageSlugId(slug: string): string { + if (!slug) { return undefined; } - const parts = input.split("-"); - return parts.length > 1 ? parts[parts.length - 1] : input; + if (isValidUUID(slug)) { + return slug; + } + const parts = slug.split("-"); + return parts.length > 1 ? parts[parts.length - 1] : slug; } export const computeSpaceSlug = (name: string) => { @@ -76,3 +83,13 @@ export function decodeBase64ToSvgString(base64Data: string): string { export function capitalizeFirstChar(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); } + +export function getPageIcon(icon: string, size = 18): string | ReactNode { + return ( + icon || ( + + + + ) + ); +} diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index ce41816e..43d0fe59 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -20,6 +20,7 @@ export default function Page() { data: page, isLoading, isError, + error, } = usePageQuery({ pageId: extractPageSlugId(pageSlug) }); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); @@ -31,7 +32,9 @@ export default function Page() { } if (isError || !page) { - // TODO: fix this + if ([401, 403, 404].includes(error?.["status"])) { + return
{t("Page not found")}
; + } return
{t("Error fetching page data.")}
; } diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 41701ae7..f91d5722 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -31,6 +31,7 @@ import { Drawio, Excalidraw, Embed, + Mention } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML } from '../common/helpers/prosemirror/html'; @@ -75,6 +76,7 @@ export const tiptapExtensions = [ Drawio, Excalidraw, Embed, + Mention ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 3907d35b..49278172 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -12,6 +12,16 @@ import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { executeTx } from '@docmost/db/utils'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { InjectQueue } from '@nestjs/bullmq'; +import { QueueJob, QueueName } from '../../integrations/queue/constants'; +import { Queue } from 'bullmq'; +import { + extractMentions, + extractPageMentions, +} from '../../common/helpers/prosemirror/utils'; +import { isDeepStrictEqual } from 'node:util'; +import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface'; +import { Page } from '@docmost/db/types/entity.types'; @Injectable() export class PersistenceExtension implements Extension { @@ -21,6 +31,7 @@ export class PersistenceExtension implements Extension { private readonly pageRepo: PageRepo, @InjectKysely() private readonly db: KyselyDB, private eventEmitter: EventEmitter2, + @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue, ) {} async onLoadDocument(data: onLoadDocumentPayload) { @@ -85,12 +96,13 @@ export class PersistenceExtension implements Extension { this.logger.warn('jsonToText' + err?.['message']); } - try { - let page = null; + let page: Page = null; + try { await executeTx(this.db, async (trx) => { page = await this.pageRepo.findById(pageId, { withLock: true, + includeContent: true, trx, }); @@ -99,6 +111,11 @@ export class PersistenceExtension implements Extension { return; } + if (isDeepStrictEqual(tiptapJson, page.content)) { + page = null; + return; + } + await this.pageRepo.updatePage( { content: tiptapJson, @@ -109,18 +126,30 @@ export class PersistenceExtension implements Extension { pageId, trx, ); - }); - this.eventEmitter.emit('collab.page.updated', { - page: { - ...page, - lastUpdatedById: context.user.id, - content: tiptapJson, - textContent: textContent, - }, + this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`); }); } catch (err) { this.logger.error(`Failed to update page ${pageId}`, err); } + + if (page) { + this.eventEmitter.emit('collab.page.updated', { + page: { + ...page, + content: tiptapJson, + lastUpdatedById: context.user.id, + }, + }); + + const mentions = extractMentions(tiptapJson); + const pageMentions = extractPageMentions(mentions); + + await this.generalQueue.add(QueueJob.PAGE_BACKLINKS, { + pageId: pageId, + workspaceId: page.workspaceId, + mentions: pageMentions, + } as IPageBacklinkJob); + } } } diff --git a/apps/server/src/common/helpers/prosemirror/utils.ts b/apps/server/src/common/helpers/prosemirror/utils.ts new file mode 100644 index 00000000..9d9b5ebe --- /dev/null +++ b/apps/server/src/common/helpers/prosemirror/utils.ts @@ -0,0 +1,58 @@ +import { Node } from '@tiptap/pm/model'; +import { jsonToNode } from '../../../collaboration/collaboration.util'; + +export interface MentionNode { + id: string; + label: string; + entityType: 'user' | 'page'; + entityId: string; + creatorId: string; +} + +export function extractMentions(prosemirrorJson: any) { + const mentionList: MentionNode[] = []; + const doc = jsonToNode(prosemirrorJson); + + doc.descendants((node: Node) => { + if (node.type.name === 'mention') { + if ( + node.attrs.id && + !mentionList.some((mention) => mention.id === node.attrs.id) + ) { + mentionList.push({ + id: node.attrs.id, + label: node.attrs.label, + entityType: node.attrs.entityType, + entityId: node.attrs.entityId, + creatorId: node.attrs.creatorId, + }); + } + } + }); + return mentionList; +} + +export function extractUserMentions(mentionList: MentionNode[]): MentionNode[] { + const userList = []; + for (const mention of mentionList) { + if (mention.entityType === 'user') { + userList.push(mention); + } + } + return userList as MentionNode[]; +} + +export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] { + const pageMentionList = []; + for (const mention of mentionList) { + if ( + mention.entityType === 'page' && + !pageMentionList.some( + (pageMention) => pageMention.entityId === mention.entityId, + ) + ) { + pageMentionList.push(mention); + } + } + return pageMentionList as MentionNode[]; +} diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index 07053d77..0cf4f170 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -31,7 +31,7 @@ export function parseRedisUrl(redisUrl: string): RedisConfig { // extract db value if present if (pathname.length > 1) { const value = pathname.slice(1); - if (!isNaN(parseInt(value))){ + if (!isNaN(parseInt(value))) { db = parseInt(value, 10); } } @@ -44,3 +44,12 @@ export function createRetryStrategy() { return Math.max(Math.min(Math.exp(times), 20000), 3000); }; } + +export function extractDateFromUuid7(uuid7: string) { + //https://park.is/blog_posts/20240803_extracting_timestamp_from_uuid_v7/ + const parts = uuid7.split('-'); + const highBitsHex = parts[0] + parts[1].slice(0, 4); + const timestamp = parseInt(highBitsHex, 16); + + return new Date(timestamp); +} diff --git a/apps/server/src/core/attachment/processors/attachment.processor.ts b/apps/server/src/core/attachment/processors/attachment.processor.ts index 0d5fbeac..feba813b 100644 --- a/apps/server/src/core/attachment/processors/attachment.processor.ts +++ b/apps/server/src/core/attachment/processors/attachment.processor.ts @@ -5,7 +5,7 @@ import { AttachmentService } from '../services/attachment.service'; import { QueueJob, QueueName } from 'src/integrations/queue/constants'; import { Space } from '@docmost/db/types/entity.types'; -@Processor(QueueName.ATTACHEMENT_QUEUE) +@Processor(QueueName.ATTACHMENT_QUEUE) export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy { private readonly logger = new Logger(AttachmentProcessor.name); constructor(private readonly attachmentService: AttachmentService) { diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index 505fc3c5..2948b6f2 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -33,9 +33,21 @@ export class SearchSuggestionDTO { @IsOptional() @IsBoolean() - includeUsers?: string; + includeUsers?: boolean; @IsOptional() @IsBoolean() - includeGroups?: number; + includeGroups?: boolean; + + @IsOptional() + @IsBoolean() + includePages?: boolean; + + @IsOptional() + @IsString() + spaceId?: string; + + @IsOptional() + @IsNumber() + limit?: number; } diff --git a/apps/server/src/core/search/search.controller.ts b/apps/server/src/core/search/search.controller.ts index 631d1654..5354ff03 100644 --- a/apps/server/src/core/search/search.controller.ts +++ b/apps/server/src/core/search/search.controller.ts @@ -48,11 +48,13 @@ export class SearchController { throw new NotImplementedException(); } + @HttpCode(HttpStatus.OK) @Post('suggest') async searchSuggestions( @Body() dto: SearchSuggestionDTO, + @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { - return this.searchService.searchSuggestions(dto, workspace.id); + return this.searchService.searchSuggestions(dto, user.id, workspace.id); } } diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index a0ede5c0..c1339f7e 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -5,6 +5,7 @@ import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { sql } from 'kysely'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; // eslint-disable-next-line @typescript-eslint/no-require-imports const tsquery = require('pg-tsquery')(); @@ -14,6 +15,7 @@ export class SearchService { constructor( @InjectKysely() private readonly db: KyselyDB, private pageRepo: PageRepo, + private spaceMemberRepo: SpaceMemberRepo, ) {} async searchPage( @@ -29,15 +31,15 @@ export class SearchService { .selectFrom('pages') .select([ 'id', + 'slugId', 'title', 'icon', 'parentPageId', - 'slugId', 'creatorId', 'createdAt', 'updatedAt', sql`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('rank'), - sql`ts_headline('english', text_content, to_tsquery(${searchQuery}), 'MinWords=9, MaxWords=10, MaxFragments=10')`.as( + sql`ts_headline('english', text_content, to_tsquery(${searchQuery}),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( 'highlight', ), ]) @@ -66,35 +68,59 @@ export class SearchService { async searchSuggestions( suggestion: SearchSuggestionDTO, + userId: string, workspaceId: string, ) { - const limit = 25; - - const userSearch = this.db - .selectFrom('users') - .select(['id', 'name', 'avatarUrl']) - .where((eb) => eb('users.name', 'ilike', `%${suggestion.query}%`)) - .where('workspaceId', '=', workspaceId) - .limit(limit); - - const groupSearch = this.db - .selectFrom('groups') - .select(['id', 'name', 'description']) - .where((eb) => eb('groups.name', 'ilike', `%${suggestion.query}%`)) - .where('workspaceId', '=', workspaceId) - .limit(limit); - let users = []; let groups = []; + let pages = []; + + const limit = suggestion?.limit || 10; + const query = suggestion.query.toLowerCase().trim(); if (suggestion.includeUsers) { - users = await userSearch.execute(); + users = await this.db + .selectFrom('users') + .select(['id', 'name', 'avatarUrl']) + .where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`)) + .where('workspaceId', '=', workspaceId) + .limit(limit) + .execute(); } if (suggestion.includeGroups) { - groups = await groupSearch.execute(); + groups = await this.db + .selectFrom('groups') + .select(['id', 'name', 'description']) + .where((eb) => eb(sql`LOWER(groups.name)`, 'like', `%${query}%`)) + .where('workspaceId', '=', workspaceId) + .limit(limit) + .execute(); } - return { users, groups }; + if (suggestion.includePages) { + let pageSearch = this.db + .selectFrom('pages') + .select(['id', 'slugId', 'title', 'icon', 'spaceId']) + .where((eb) => eb(sql`LOWER(pages.title)`, 'like', `%${query}%`)) + .where('workspaceId', '=', workspaceId) + .limit(limit); + + // only search spaces the user has access to + const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); + + if (suggestion?.spaceId) { + if (userSpaceIds.includes(suggestion.spaceId)) { + pageSearch = pageSearch.where('spaceId', '=', suggestion.spaceId); + pages = await pageSearch.execute(); + } + } else if (userSpaceIds?.length > 0) { + // we need this check or the query will throw an error if the userSpaceIds array is empty + pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds); + pages = await pageSearch.execute(); + } + } + + return { users, groups, pages }; } } diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts index 2eebdf40..0f3c123a 100644 --- a/apps/server/src/core/space/services/space.service.ts +++ b/apps/server/src/core/space/services/space.service.ts @@ -24,7 +24,7 @@ export class SpaceService { private spaceRepo: SpaceRepo, private spaceMemberService: SpaceMemberService, @InjectKysely() private readonly db: KyselyDB, - @InjectQueue(QueueName.ATTACHEMENT_QUEUE) private attachmentQueue: Queue, + @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, ) {} async createSpace( diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 4799ae85..cfce123a 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -23,6 +23,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types'; import * as process from 'node:process'; import { MigrationService } from '@docmost/db/services/migration.service'; import { UserTokenRepo } from './repos/user-token/user-token.repo'; +import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; // https://github.com/brianc/node-postgres/issues/811 types.setTypeParser(types.builtins.INT8, (val) => Number(val)); @@ -68,6 +69,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); CommentRepo, AttachmentRepo, UserTokenRepo, + BacklinkRepo, ], exports: [ WorkspaceRepo, @@ -81,6 +83,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); CommentRepo, AttachmentRepo, UserTokenRepo, + BacklinkRepo, ], }) export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap { diff --git a/apps/server/src/database/migrations/20241218T223249-backlinks.ts b/apps/server/src/database/migrations/20241218T223249-backlinks.ts new file mode 100644 index 00000000..fae69e77 --- /dev/null +++ b/apps/server/src/database/migrations/20241218T223249-backlinks.ts @@ -0,0 +1,33 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('backlinks') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('source_page_id', 'uuid', (col) => + col.references('pages.id').onDelete('cascade').notNull(), + ) + .addColumn('target_page_id', 'uuid', (col) => + col.references('pages.id').onDelete('cascade').notNull(), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addUniqueConstraint('backlinks_source_page_id_target_page_id_unique', [ + 'source_page_id', + 'target_page_id', + ]) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('backlinks').execute(); +} diff --git a/apps/server/src/database/repos/backlink/backlink.repo.ts b/apps/server/src/database/repos/backlink/backlink.repo.ts new file mode 100644 index 00000000..43c01065 --- /dev/null +++ b/apps/server/src/database/repos/backlink/backlink.repo.ts @@ -0,0 +1,72 @@ +import { + Backlink, + InsertableBacklink, + UpdatableBacklink, +} from '@docmost/db/types/entity.types'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { dbOrTx } from '@docmost/db/utils'; +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; + +@Injectable() +export class BacklinkRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById( + backlinkId: string, + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + + return db + .selectFrom('backlinks') + .select([ + 'id', + 'sourcePageId', + 'targetPageId', + 'workspaceId', + 'createdAt', + 'updatedAt', + ]) + .where('id', '=', backlinkId) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + } + + async insertBacklink( + insertableBacklink: InsertableBacklink, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .insertInto('backlinks') + .values(insertableBacklink) + .onConflict((oc) => + oc.columns(['sourcePageId', 'targetPageId']).doNothing(), + ) + .returningAll() + .executeTakeFirst(); + } + + async updateBacklink( + updatableBacklink: UpdatableBacklink, + backlinkId: string, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .updateTable('userTokens') + .set(updatableBacklink) + .where('id', '=', backlinkId) + .execute(); + } + + async deleteBacklink( + backlinkId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db.deleteFrom('backlinks').where('id', '=', backlinkId).execute(); + } +} diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index a888442e..e02a3b2f 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -166,7 +166,16 @@ export class PageRepo { .withRecursive('page_hierarchy', (db) => db .selectFrom('pages') - .select(['id', 'slugId', 'title', 'icon', 'content', 'parentPageId', 'spaceId']) + .select([ + 'id', + 'slugId', + 'title', + 'icon', + 'content', + 'parentPageId', + 'spaceId', + 'workspaceId', + ]) .where('id', '=', parentPageId) .unionAll((exp) => exp @@ -179,6 +188,7 @@ export class PageRepo { 'p.content', 'p.parentPageId', 'p.spaceId', + 'p.workspaceId', ]) .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'), ), diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 792dae9a..c2596c91 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -42,6 +42,15 @@ export interface Attachments { workspaceId: string; } +export interface Backlinks { + createdAt: Generated; + id: Generated; + sourcePageId: string; + targetPageId: string; + updatedAt: Generated; + workspaceId: string; +} + export interface Comments { content: Json | null; createdAt: Generated; @@ -51,6 +60,7 @@ export interface Comments { id: Generated; pageId: string; parentCommentId: string | null; + resolvedAt: Timestamp | null; selection: string | null; type: string | null; workspaceId: string; @@ -59,6 +69,7 @@ export interface Comments { export interface Groups { createdAt: Generated; creatorId: string | null; + deletedAt: Timestamp | null; description: string | null; id: Generated; isDefault: boolean; @@ -118,6 +129,7 @@ export interface Pages { export interface SpaceMembers { addedById: string | null; createdAt: Generated; + deletedAt: Timestamp | null; groupId: string | null; id: Generated; role: string; @@ -135,7 +147,7 @@ export interface Spaces { id: Generated; logo: string | null; name: string | null; - slug: string | null; + slug: string; updatedAt: Generated; visibility: Generated; workspaceId: string; @@ -155,7 +167,7 @@ export interface Users { locale: string | null; name: string | null; password: string | null; - role: string; + role: string | null; settings: Json | null; timezone: string | null; updatedAt: Generated; @@ -186,13 +198,13 @@ export interface WorkspaceInvitations { } export interface Workspaces { - allowedEmailDomains: Generated; createdAt: Generated; customDomain: string | null; defaultRole: Generated; defaultSpaceId: string | null; deletedAt: Timestamp | null; description: string | null; + emailDomains: Generated; hostname: string | null; id: Generated; logo: string | null; @@ -203,6 +215,7 @@ export interface Workspaces { export interface DB { attachments: Attachments; + backlinks: Backlinks; comments: Comments; groups: Groups; groupUsers: GroupUsers; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 8145bc9d..b852459c 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -12,6 +12,7 @@ import { SpaceMembers, WorkspaceInvitations, UserTokens, + Backlinks, } from './db'; // Workspace @@ -76,4 +77,9 @@ export type UpdatableAttachment = Updateable>; // User Token export type UserToken = Selectable; export type InsertableUserToken = Insertable; -export type UpdatableUserToken = Updateable>; \ No newline at end of file +export type UpdatableUserToken = Updateable>; + +// Backlink +export type Backlink = Selectable; +export type InsertableBacklink = Insertable; +export type UpdatableBacklink = Updateable>; diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts index 83626f99..f67905da 100644 --- a/apps/server/src/integrations/export/export.controller.ts +++ b/apps/server/src/integrations/export/export.controller.ts @@ -76,7 +76,11 @@ export class ExportController { return; } - const rawContent = await this.exportService.exportPage(dto.format, page); + const rawContent = await this.exportService.exportPage( + dto.format, + page, + true, + ); res.headers({ 'Content-Type': getMimeType(fileExt), diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index 70eb98f0..22237ff6 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -4,7 +4,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { jsonToHtml } from '../../collaboration/collaboration.util'; +import { jsonToHtml, jsonToNode } from '../../collaboration/collaboration.util'; import { turndown } from './turndown-utils'; import { ExportFormat } from './dto/export-dto'; import { Page } from '@docmost/db/types/entity.types'; @@ -24,6 +24,11 @@ import { updateAttachmentUrls, } from './utils'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { Node } from '@tiptap/pm/model'; +import { EditorState } from '@tiptap/pm/state'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import slugify = require('@sindresorhus/slugify'); +import { EnvironmentService } from '../environment/environment.service'; @Injectable() export class ExportService { @@ -33,16 +38,27 @@ export class ExportService { private readonly pageRepo: PageRepo, @InjectKysely() private readonly db: KyselyDB, private readonly storageService: StorageService, + private readonly environmentService: EnvironmentService, ) {} - async exportPage(format: string, page: Page) { + async exportPage(format: string, page: Page, singlePage?: boolean) { const titleNode = { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: getPageTitle(page.title) }], }; - const prosemirrorJson: any = getProsemirrorContent(page.content); + let prosemirrorJson: any; + + if (singlePage) { + prosemirrorJson = await this.turnPageMentionsToLinks( + getProsemirrorContent(page.content), + page.workspaceId, + ); + } else { + // mentions is already turned to links during the zip process + prosemirrorJson = getProsemirrorContent(page.content); + } if (page.title) { prosemirrorJson.content.unshift(titleNode); @@ -115,7 +131,8 @@ export class ExportService { 'pages.title', 'pages.content', 'pages.parentPageId', - 'pages.spaceId' + 'pages.spaceId', + 'pages.workspaceId', ]) .where('spaceId', '=', spaceId) .execute(); @@ -160,7 +177,10 @@ export class ExportService { for (const page of children) { const childPages = tree[page.id] || []; - const prosemirrorJson = getProsemirrorContent(page.content); + const prosemirrorJson = await this.turnPageMentionsToLinks( + getProsemirrorContent(page.content), + page.workspaceId, + ); const currentPagePath = slugIdToPath[page.slugId]; @@ -219,4 +239,107 @@ export class ExportService { ); } } + + async turnPageMentionsToLinks(prosemirrorJson: any, workspaceId: string) { + const doc = jsonToNode(prosemirrorJson); + + const pageMentionIds = []; + + doc.descendants((node: Node) => { + if (node.type.name === 'mention' && node.attrs.entityType === 'page') { + if (node.attrs.entityId) { + pageMentionIds.push(node.attrs.entityId); + } + } + }); + + if (pageMentionIds.length < 1) { + return prosemirrorJson; + } + + const pages = await this.db + .selectFrom('pages') + .select([ + 'id', + 'slugId', + 'title', + 'creatorId', + 'spaceId', + 'workspaceId', + ]) + .select((eb) => this.pageRepo.withSpace(eb)) + .where('id', 'in', pageMentionIds) + .where('workspaceId', '=', workspaceId) + .execute(); + + const pageMap = new Map(pages.map((page) => [page.id, page])); + + let editorState = EditorState.create({ + doc: doc, + }); + + const transaction = editorState.tr; + + let offset = 0; + + /** + * Helper function to replace a mention node with a link node. + */ + const replaceMentionWithLink = ( + node: Node, + pos: number, + title: string, + slugId: string, + spaceSlug: string, + ) => { + const linkTitle = title || 'untitled'; + const truncatedTitle = linkTitle?.substring(0, 70); + const pageSlug = `${slugify(truncatedTitle)}-${slugId}`; + + // Create the link URL + const link = `${this.environmentService.getAppUrl()}/s/${spaceSlug}/p/${pageSlug}`; + + // Create a link mark and a text node with that mark + const linkMark = editorState.schema.marks.link.create({ href: link }); + const linkTextNode = editorState.schema.text(linkTitle, [linkMark]); + + // Calculate positions (adjusted by the current offset) + const from = pos + offset; + const to = pos + offset + node.nodeSize; + + // Replace the node in the transaction and update the offset + transaction.replaceWith(from, to, linkTextNode); + offset += linkTextNode.nodeSize - node.nodeSize; + }; + + // find and convert page mentions to links + editorState.doc.descendants((node: Node, pos: number) => { + // Check if the node is a page mention + if (node.type.name === 'mention' && node.attrs.entityType === 'page') { + const { entityId: pageId, slugId, label } = node.attrs; + const page = pageMap.get(pageId); + + if (page) { + replaceMentionWithLink( + node, + pos, + page.title, + page.slugId, + page.space.slug, + ); + } else { + // if page is not found, default to the node label and slugId + replaceMentionWithLink(node, pos, label, slugId, 'undefined'); + } + } + }); + + if (transaction.docChanged) { + editorState = editorState.apply(transaction); + } + + const updatedDoc = editorState.doc; + + return updatedDoc.toJSON(); + } } diff --git a/apps/server/src/integrations/export/utils.ts b/apps/server/src/integrations/export/utils.ts index 1c8f5e1a..e296f194 100644 --- a/apps/server/src/integrations/export/utils.ts +++ b/apps/server/src/integrations/export/utils.ts @@ -7,6 +7,9 @@ import { Page } from '@docmost/db/types/entity.types'; export type PageExportTree = Record; +export const INTERNAL_LINK_REGEX = + /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/; + export function getExportExtension(format: string) { if (format === ExportFormat.HTML) { return '.html'; @@ -83,13 +86,11 @@ export function replaceInternalLinks( currentPagePath: string, ) { const doc = jsonToNode(prosemirrorJson); - const internalLinkRegex = - /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/; doc.descendants((node: Node) => { for (const mark of node.marks) { if (mark.type.name === 'link' && mark.attrs.href) { - const match = mark.attrs.href.match(internalLinkRegex); + const match = mark.attrs.href.match(INTERNAL_LINK_REGEX); if (match) { const markLink = mark.attrs.href; diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index 40a0ab8b..57219b78 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -1,10 +1,16 @@ export enum QueueName { EMAIL_QUEUE = '{email-queue}', - ATTACHEMENT_QUEUE = '{attachment-queue}', + ATTACHMENT_QUEUE = '{attachment-queue}', + GENERAL_QUEUE = '{general-queue}', } export enum QueueJob { SEND_EMAIL = 'send-email', DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments', DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments', + PAGE_CONTENT_UPDATE = 'page-content-update', + + PAGE_BACKLINKS = 'page-backlinks', } + + diff --git a/apps/server/src/integrations/queue/constants/queue.interface.ts b/apps/server/src/integrations/queue/constants/queue.interface.ts new file mode 100644 index 00000000..d16061b1 --- /dev/null +++ b/apps/server/src/integrations/queue/constants/queue.interface.ts @@ -0,0 +1,8 @@ +import { MentionNode } from "../../../common/helpers/prosemirror/utils"; + + +export interface IPageBacklinkJob { + pageId: string; + workspaceId: string; + mentions: MentionNode[]; +} \ No newline at end of file diff --git a/apps/server/src/integrations/queue/processors/backlinks.processor.ts b/apps/server/src/integrations/queue/processors/backlinks.processor.ts new file mode 100644 index 00000000..5d633582 --- /dev/null +++ b/apps/server/src/integrations/queue/processors/backlinks.processor.ts @@ -0,0 +1,129 @@ +import { Logger, OnModuleDestroy } from '@nestjs/common'; +import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { QueueJob, QueueName } from '../constants'; +import { IPageBacklinkJob } from '../constants/queue.interface'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; +import { executeTx } from '@docmost/db/utils'; + +@Processor(QueueName.GENERAL_QUEUE) +export class BacklinksProcessor extends WorkerHost implements OnModuleDestroy { + private readonly logger = new Logger(BacklinksProcessor.name); + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly backlinkRepo: BacklinkRepo, + ) { + super(); + } + + async process(job: Job): Promise { + try { + const { pageId, mentions, workspaceId } = job.data; + + switch (job.name) { + case QueueJob.PAGE_BACKLINKS: + { + await executeTx(this.db, async (trx) => { + const existingBacklinks = await trx + .selectFrom('backlinks') + .select('targetPageId') + .where('sourcePageId', '=', pageId) + .execute(); + + if (existingBacklinks.length === 0 && mentions.length === 0) { + return; + } + + const existingTargetPageIds = existingBacklinks.map( + (backlink) => backlink.targetPageId, + ); + + const targetPageIds = mentions + .filter((mention) => mention.entityId !== pageId) + .map((mention) => mention.entityId); + + // make sure target pages belong to the same workspace + let validTargetPages = []; + if (targetPageIds.length > 0) { + validTargetPages = await trx + .selectFrom('pages') + .select('id') + .where('id', 'in', targetPageIds) + .where('workspaceId', '=', workspaceId) + .execute(); + } + + const validTargetPageIds = validTargetPages.map( + (page) => page.id, + ); + + // new backlinks + const backlinksToAdd = validTargetPageIds.filter( + (id) => !existingTargetPageIds.includes(id), + ); + + // stale backlinks + const backlinksToRemove = existingTargetPageIds.filter( + (existingId) => !validTargetPageIds.includes(existingId), + ); + + // add new backlinks + if (backlinksToAdd.length > 0) { + const newBacklinks = backlinksToAdd.map((targetPageId) => ({ + sourcePageId: pageId, + targetPageId: targetPageId, + workspaceId: workspaceId, + })); + + await this.backlinkRepo.insertBacklink(newBacklinks, trx); + this.logger.debug( + `Added ${newBacklinks.length} new backlinks to ${pageId}`, + ); + } + + // remove stale backlinks + if (backlinksToRemove.length > 0) { + await this.db + .deleteFrom('backlinks') + .where('sourcePageId', '=', pageId) + .where('targetPageId', 'in', backlinksToRemove) + .execute(); + + this.logger.debug( + `Removed ${backlinksToRemove.length} outdated backlinks from ${pageId}.`, + ); + } + }); + } + break; + } + } catch (err) { + throw err; + } + } + + @OnWorkerEvent('active') + onActive(job: Job) { + this.logger.debug(`Processing ${job.name} job`); + } + + @OnWorkerEvent('failed') + onError(job: Job) { + this.logger.error( + `Error processing ${job.name} job. Reason: ${job.failedReason}`, + ); + } + + @OnWorkerEvent('completed') + onCompleted(job: Job) { + this.logger.debug(`Completed ${job.name} job`); + } + + async onModuleDestroy(): Promise { + if (this.worker) { + await this.worker.close(); + } + } +} diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index 8ba26f6d..0e8834a1 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -3,6 +3,7 @@ import { BullModule } from '@nestjs/bullmq'; import { EnvironmentService } from '../environment/environment.service'; import { createRetryStrategy, parseRedisUrl } from '../../common/helpers'; import { QueueName } from './constants'; +import { BacklinksProcessor } from "./processors/backlinks.processor"; @Global() @Module({ @@ -33,9 +34,13 @@ import { QueueName } from './constants'; name: QueueName.EMAIL_QUEUE, }), BullModule.registerQueue({ - name: QueueName.ATTACHEMENT_QUEUE, + name: QueueName.ATTACHMENT_QUEUE, + }), + BullModule.registerQueue({ + name: QueueName.GENERAL_QUEUE, }), ], exports: [BullModule], + providers: [BacklinksProcessor] }) export class QueueModule {} diff --git a/package.json b/package.json index 2a73172b..69423d18 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@hocuspocus/transformer": "^2.14.0", "@joplin/turndown": "^4.0.74", "@joplin/turndown-plugin-gfm": "^1.0.56", - "@sindresorhus/slugify": "^2.2.1", + "@sindresorhus/slugify": "1.1.0", "@tiptap/core": "^2.10.3", "@tiptap/extension-code-block": "^2.10.3", "@tiptap/extension-code-block-lowlight": "^2.10.3", diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 49f0fdd1..9e211778 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -11,8 +11,9 @@ export * from "./lib/media-utils"; export * from "./lib/link"; export * from "./lib/selection"; export * from "./lib/attachment"; -export * from "./lib/custom-code-block" +export * from "./lib/custom-code-block"; export * from "./lib/drawio"; export * from "./lib/excalidraw"; export * from "./lib/embed"; +export * from "./lib/mention"; export * from "./lib/markdown"; diff --git a/packages/editor-ext/src/lib/mention.ts b/packages/editor-ext/src/lib/mention.ts new file mode 100644 index 00000000..edbf9c11 --- /dev/null +++ b/packages/editor-ext/src/lib/mention.ts @@ -0,0 +1,334 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { DOMOutputSpec, Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { PluginKey } from "@tiptap/pm/state"; +import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; + +export interface MentionNodeAttrs { + /** + * unique mention node id (uuidv7) + */ + id: string | null; + /** + * The label to be rendered by the editor as the displayed text for this mentioned + * item, if provided. + */ + label?: string | null; + + /** + * the entity type - user or page + */ + entityType: "user" | "page"; + + /** + * the entity id - userId or pageId + */ + entityId?: string | null; + + /** + * page slugId + */ + slugId?: string | null; + + /** + * the id of the user who initiated the mention + */ + creatorId?: string; +} + +export type MentionOptions< + SuggestionItem = any, + Attrs extends Record = MentionNodeAttrs, +> = { + /** + * The HTML attributes for a mention node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record; + + /** + * A function to render the text of a mention. + * @param props The render props + * @returns The text + * @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + */ + renderText: (props: { + options: MentionOptions; + node: ProseMirrorNode; + }) => string; + + /** + * A function to render the HTML of a mention. + * @param props The render props + * @returns The HTML as a ProseMirror DOM Output Spec + * @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`] + */ + renderHTML: (props: { + options: MentionOptions; + node: ProseMirrorNode; + }) => DOMOutputSpec; + + /** + * Whether to delete the trigger character with backspace. + * @default false + */ + deleteTriggerWithBackspace: boolean; + + /** + * The suggestion options. + * @default {} + * @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } } + */ + suggestion: Omit, "editor">; +}; + +/** + * The plugin key for the mention plugin. + * @default 'mention' + */ +export const MentionPluginKey = new PluginKey("mention"); + +/** + * This extension allows you to insert mentions into the editor. + * @see https://www.tiptap.dev/api/extensions/mention + */ +export const Mention = Node.create({ + name: "mention", + + priority: 101, + + addOptions() { + return { + HTMLAttributes: {}, + renderText({ options, node }) { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`; + }, + deleteTriggerWithBackspace: false, + renderHTML({ options, node }) { + const isUserMention = node.attrs.entityType === "user"; + return [ + "span", + mergeAttributes(this.HTMLAttributes, options.HTMLAttributes), + `${isUserMention ? options.suggestion.char : ""}${node.attrs.label ?? node.attrs.entityId}`, + ]; + }, + suggestion: { + char: "@", + pluginKey: MentionPluginKey, + command: ({ editor, range, props }) => { + // increase range.to by one when the next node is of type "text" + // and starts with a space character + const nodeAfter = editor.view.state.selection.$to.nodeAfter; + const overrideSpace = nodeAfter?.text?.startsWith(" "); + + if (overrideSpace) { + range.to += 1; + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: this.name, + attrs: props, + }, + { + type: "text", + text: " ", + }, + ]) + .run(); + + // get reference to `window` object from editor element, to support cross-frame JS usage + editor.view.dom.ownerDocument.defaultView + ?.getSelection() + ?.collapseToEnd(); + }, + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from); + const type = state.schema.nodes[this.name]; + const allow = !!$from.parent.type.contentMatch.matchType(type); + + return allow; + }, + }, + }; + }, + + group: "inline", + inline: true, + selectable: true, + atom: true, + + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute("data-id"), + renderHTML: (attributes) => { + if (!attributes.id) { + return {}; + } + + return { + "data-id": attributes.id, + }; + }, + }, + + label: { + default: null, + parseHTML: (element) => element.getAttribute("data-label"), + renderHTML: (attributes) => { + if (!attributes.label) { + return {}; + } + + return { + "data-label": attributes.label, + }; + }, + }, + + entityType: { + default: null, + parseHTML: (element) => element.getAttribute("data-entity-type"), + renderHTML: (attributes) => { + if (!attributes.entityType) { + return {}; + } + + return { + "data-entity-type": attributes.entityType, + }; + }, + }, + + entityId: { + default: null, + parseHTML: (element) => element.getAttribute("data-entity-id"), + renderHTML: (attributes) => { + if (!attributes.entityId) { + return {}; + } + + return { + "data-entity-id": attributes.entityId, + }; + }, + }, + + slugId: { + default: null, + parseHTML: (element) => element.getAttribute("data-slug-id"), + renderHTML: (attributes) => { + if (!attributes.slugId) { + return {}; + } + + return { + "data-slug-id": attributes.slugId, + }; + }, + }, + + creatorId: { + default: null, + parseHTML: (element) => element.getAttribute("data-creator-id"), + renderHTML: (attributes) => { + if (!attributes.creatorId) { + return {}; + } + + return { + "data-creator-id": attributes.creatorId, + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: `span[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ node, HTMLAttributes }) { + const mergedOptions = { ...this.options }; + + mergedOptions.HTMLAttributes = mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ); + const html = this.options.renderHTML({ + options: mergedOptions, + node, + }); + + if (typeof html === "string") { + return [ + "span", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + html, + ]; + } + return html; + }, + + renderText({ node }) { + return this.options.renderText({ + options: this.options, + node, + }); + }, + + addKeyboardShortcuts() { + return { + Backspace: () => + this.editor.commands.command(({ tr, state }) => { + let isMention = false; + const { selection } = state; + const { empty, anchor } = selection; + + if (!empty) { + return false; + } + + state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { + if (node.type.name === this.name) { + isMention = true; + tr.insertText( + this.options.deleteTriggerWithBackspace + ? "" + : this.options.suggestion.char || "", + pos, + pos + node.nodeSize, + ); + + return false; + } + }); + + return isMention; + }), + }; + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ]; + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 459c311f..db5c9a84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,8 +38,8 @@ importers: specifier: ^1.0.56 version: 1.0.56 '@sindresorhus/slugify': - specifier: ^2.2.1 - version: 2.2.1 + specifier: 1.1.0 + version: 1.1.0 '@tiptap/core': specifier: ^2.10.3 version: 2.10.3(@tiptap/pm@2.10.3) @@ -3155,13 +3155,13 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@sindresorhus/slugify@2.2.1': - resolution: {integrity: sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==} - engines: {node: '>=12'} + '@sindresorhus/slugify@1.1.0': + resolution: {integrity: sha512-ujZRbmmizX26yS/HnB3P9QNlNa4+UvHh+rIse3RbOXLp8yl6n1TxB4t7NHggtVgS8QmmOtzXo48kCxZGACpkPw==} + engines: {node: '>=10'} - '@sindresorhus/transliterate@1.6.0': - resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==} - engines: {node: '>=12'} + '@sindresorhus/transliterate@0.1.2': + resolution: {integrity: sha512-5/kmIOY9FF32nicXH+5yLNTX4NJ4atl7jRgqAJuIn/iyDFXBktOKDxCvyGE/EzmF4ngSUvjXxQUQlQiZ5lfw+w==} + engines: {node: '>=10'} '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -5327,10 +5327,6 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - escape-string-regexp@5.0.0: - resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} - engines: {node: '>=12'} - eslint-config-prettier@9.1.0: resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} hasBin: true @@ -6507,6 +6503,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.deburr@4.1.0: + resolution: {integrity: sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -11847,14 +11846,15 @@ snapshots: '@sinclair/typebox@0.27.8': {} - '@sindresorhus/slugify@2.2.1': + '@sindresorhus/slugify@1.1.0': dependencies: - '@sindresorhus/transliterate': 1.6.0 - escape-string-regexp: 5.0.0 + '@sindresorhus/transliterate': 0.1.2 + escape-string-regexp: 4.0.0 - '@sindresorhus/transliterate@1.6.0': + '@sindresorhus/transliterate@0.1.2': dependencies: - escape-string-regexp: 5.0.0 + escape-string-regexp: 2.0.0 + lodash.deburr: 4.1.0 '@sinonjs/commons@3.0.1': dependencies: @@ -14484,8 +14484,6 @@ snapshots: escape-string-regexp@4.0.0: {} - escape-string-regexp@5.0.0: {} - eslint-config-prettier@9.1.0(eslint@9.15.0(jiti@1.21.0)): dependencies: eslint: 9.15.0(jiti@1.21.0) @@ -15984,6 +15982,8 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.deburr@4.1.0: {} + lodash.defaults@4.2.0: {} lodash.flatten@4.4.0: {}