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
This commit is contained in:
Philip Okugbe
2025-02-14 15:36:44 +00:00
committed by GitHub
parent 0ef6b1978a
commit e209aaa272
46 changed files with 1679 additions and 101 deletions

View File

@ -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);

View File

@ -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<any>;
}
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<any> => {
// 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;
},
});

View File

@ -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}
</Anchor>

View File

@ -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;
}

View File

@ -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<any, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(1);
const viewportRef = useRef<HTMLDivElement>(null);
const { spaceSlug } = useParams();
const { data: space } = useSpaceQuery(spaceSlug);
const [currentUser] = useAtom(currentUserAtom);
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
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 (
<Paper shadow="md" p="xs" withBorder>
No results
</Paper>
);
}
return (
<Paper id="mention" shadow="md" p="xs" withBorder>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={350}
w={320}
scrollbarSize={8}
>
{renderItems?.map((item, index) => {
if (item.entityType === "header") {
return (
<div key={`${item.label}-${index}`}>
<Text c="dimmed" mb={4} tt="uppercase">
{item.label}
</Text>
</div>
);
} else if (item.entityType === "user") {
return (
<UnstyledButton
data-item-index={index}
key={index}
onClick={() => selectItem(index)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
})}
>
<Group>
<CustomAvatar
size={"sm"}
avatarUrl={item.avatarUrl}
name={item.label}
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{item.label}
</Text>
</div>
</Group>
</UnstyledButton>
);
} else if (item.entityType === "page") {
return (
<UnstyledButton
data-item-index={index}
key={index}
onClick={() => selectItem(index)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
})}
>
<Group>
<ActionIcon
variant="default"
component="div"
aria-label={item.label}
>
{item.icon || (
<ActionIcon
component="span"
variant="transparent"
color="gray"
size={18}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
</ActionIcon>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{item.label}
</Text>
</div>
</Group>
</UnstyledButton>
);
} else {
return null;
}
})}
</ScrollArea.Autosize>
</Paper>
);
});
export default MentionList;

View File

@ -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<typeof useEditor>;
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<typeof useEditor>;
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;

View File

@ -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 (
<NodeViewWrapper style={{ display: "inline" }}>
{entityType === "user" && (
<Text className={classes.userMention} component="span">
@{label}
</Text>
)}
{entityType === "page" && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(spaceSlug, slugId, label)}
underline="never"
className={classes.pageMentionLink}
>
{page?.icon ? (
<span style={{ marginRight: "4px" }}>{page.icon}</span>
) : (
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<span className={classes.pageMentionText}>
{page?.title || label}
</span>
</Anchor>
)}
</NodeViewWrapper>
);
}

View File

@ -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);
}
}

View File

@ -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;
};

View File

@ -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,

View File

@ -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),
},

View File

@ -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;
}

View File

@ -9,3 +9,5 @@
@import "./media.css";
@import "./code.css";
@import "./print.css";
@import "./mention.css";

View File

@ -0,0 +1,5 @@
.node-mention {
&.ProseMirror-selectednode {
outline: none;
}
}

View File

@ -24,7 +24,8 @@ export function useSearchSuggestionsQuery(
params: SearchSuggestionParams,
): UseQueryResult<ISuggestionResult, Error> {
return useQuery({
queryKey: ["search-suggestion", params],
queryKey: ["search-suggestion", params.query],
staleTime: 60 * 1000, // 1min
queryFn: () => searchSuggestions(params),
enabled: !!params.query,
});

View File

@ -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) {
}
>
<Group wrap="nowrap" w="100%">
<Center>
{page?.icon ? (
<span style={{ fontSize: "20px" }}>{page.icon}</span>
) : (
<IconFileDescription size={20} />
)}
</Center>
<Center>{getPageIcon(page?.icon)}</Center>
<div style={{ flex: 1 }}>
<Text>{page.title}</Text>

View File

@ -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<IUser[]>;
groups?: Partial<IGroup[]>;
pages?: Partial<IPage[]>;
}
export interface IPageSearchParams {

View File

@ -0,0 +1,2 @@
export const INTERNAL_LINK_REGEX =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;

View File

@ -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 || (
<ActionIcon variant="transparent" color="gray" size={size}>
<IconFileDescription size={size} />
</ActionIcon>
)
);
}

View File

@ -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 <div>{t("Page not found")}</div>;
}
return <div>{t("Error fetching page data.")}</div>;
}