mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 23:02:36 +10:00
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:
@ -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);
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -9,3 +9,5 @@
|
||||
@import "./media.css";
|
||||
@import "./code.css";
|
||||
@import "./print.css";
|
||||
@import "./mention.css";
|
||||
|
||||
|
||||
5
apps/client/src/features/editor/styles/mention.css
Normal file
5
apps/client/src/features/editor/styles/mention.css
Normal file
@ -0,0 +1,5 @@
|
||||
.node-mention {
|
||||
&.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
2
apps/client/src/lib/constants.ts
Normal file
2
apps/client/src/lib/constants.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const INTERNAL_LINK_REGEX =
|
||||
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user