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

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