mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 22:53:08 +08:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3d50558db | |||
| 605bf760be | |||
| c5c9237b00 | |||
| efb0a9317b | |||
| 063ea99b66 | |||
| aa143ad79c | |||
| 918f4508d2 | |||
| 5cd0ba6902 | |||
| 511c822a90 | |||
| 7af31fe228 | |||
| dd460713ce | |||
| b0f3bec4d1 | |||
| 8ea92668b2 | |||
| dcf9228344 | |||
| dbd1308c72 | |||
| 03ae58253a | |||
| a1260188ae | |||
| b99d803b81 | |||
| b9b5ddb848 | |||
| dd8c42e1f3 | |||
| bdf02f593d | |||
| e24bf5ed57 | |||
| f3f74c591f | |||
| 13c29545a2 | |||
| 0c5c83a17a | |||
| 5bda5623f2 | |||
| 5f966a2d89 | |||
| 051bc80ab7 | |||
| 78d363febb | |||
| 8681d9a8c4 | |||
| b9543b01bd | |||
| 5510434221 | |||
| f671e7a3b9 | |||
| 974bcea690 | |||
| 601ed88931 | |||
| cfbaedcd63 | |||
| 5fc04aa7df | |||
| c357f169e1 | |||
| 1cbd2854bb | |||
| 3af1482a31 | |||
| d31d1f7bbd | |||
| cc0146d0cd | |||
| 83ce9cf240 | |||
| e7e85e9fdd | |||
| 2d710612b1 | |||
| a0814ef49a | |||
| bf17289ab2 | |||
| c2cd412ac7 | |||
| 71dfcf6bce | |||
| 23e8ab032e | |||
| 0e1d4e5eee | |||
| 6f83f32d5c | |||
| 63ea2f7663 | |||
| 66a3dad632 | |||
| 2adc6a60d2 |
+7
-1
@@ -46,4 +46,10 @@ DRAWIO_URL=
|
|||||||
DISABLE_TELEMETRY=false
|
DISABLE_TELEMETRY=false
|
||||||
|
|
||||||
# Enable debug logging in production (default: false)
|
# Enable debug logging in production (default: false)
|
||||||
DEBUG_MODE=false
|
DEBUG_MODE=false
|
||||||
|
|
||||||
|
# Log database queries
|
||||||
|
DEBUG_DB=false
|
||||||
|
|
||||||
|
# Log http requests
|
||||||
|
LOG_HTTP=false
|
||||||
|
|||||||
+10
-12
@@ -24,7 +24,6 @@
|
|||||||
"@mantine/spotlight": "^8.3.12",
|
"@mantine/spotlight": "^8.3.12",
|
||||||
"@tabler/icons-react": "^3.36.1",
|
"@tabler/icons-react": "^3.36.1",
|
||||||
"@tanstack/react-query": "^5.90.17",
|
"@tanstack/react-query": "^5.90.17",
|
||||||
"@tiptap/extension-character-count": "^2.27.1",
|
|
||||||
"alfaaz": "^1.1.0",
|
"alfaaz": "^1.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -43,18 +42,17 @@
|
|||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"posthog-js": "^1.255.1",
|
"posthog-js": "^1.255.1",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.3",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.17",
|
"react-clear-modal": "^2.0.17",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.3",
|
||||||
"react-drawio": "^1.0.7",
|
"react-drawio": "^1.0.7",
|
||||||
"react-error-boundary": "^4.1.2",
|
"react-error-boundary": "^6.1.0",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-i18next": "^15.0.1",
|
"react-i18next": "^15.0.1",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"tippy.js": "^6.3.7",
|
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
@@ -65,13 +63,13 @@
|
|||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/katex": "^0.16.7",
|
"@types/katex": "^0.16.7",
|
||||||
"@types/node": "22.19.1",
|
"@types/node": "22.19.1",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
"eslint-plugin-react": "^7.37.2",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"globals": "^15.13.0",
|
"globals": "^15.13.0",
|
||||||
"optics-ts": "^2.4.1",
|
"optics-ts": "^2.4.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
|
|||||||
@@ -328,6 +328,8 @@
|
|||||||
"Upload any image from your device.": "Upload any image from your device.",
|
"Upload any image from your device.": "Upload any image from your device.",
|
||||||
"Upload any video from your device.": "Upload any video from your device.",
|
"Upload any video from your device.": "Upload any video from your device.",
|
||||||
"Upload any file from your device.": "Upload any file from your device.",
|
"Upload any file from your device.": "Upload any file from your device.",
|
||||||
|
"Uploading {{name}}": "Uploading {{name}}",
|
||||||
|
"Uploading file": "Uploading file",
|
||||||
"Table": "Table",
|
"Table": "Table",
|
||||||
"Insert a table.": "Insert a table.",
|
"Insert a table.": "Insert a table.",
|
||||||
"Insert collapsible block.": "Insert collapsible block.",
|
"Insert collapsible block.": "Insert collapsible block.",
|
||||||
|
|||||||
@@ -5,26 +5,27 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Table,
|
Table,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
} from '@mantine/core';
|
} from "@mantine/core";
|
||||||
import {Link} from 'react-router-dom';
|
import { Link } from "react-router-dom";
|
||||||
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
|
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
|
||||||
import { buildPageUrl } from '@/features/page/page.utils.ts';
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { formattedDate } from '@/lib/time.ts';
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
|
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { IconFileDescription } from '@tabler/icons-react';
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { getSpaceUrl } from '@/lib/config.ts';
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getInitialsColor } from "@/lib/get-initials-color.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecentChanges({spaceId}: Props) {
|
export default function RecentChanges({ spaceId }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
|
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <PageListSkeleton/>;
|
return <PageListSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
@@ -44,8 +45,8 @@ export default function RecentChanges({spaceId}: Props) {
|
|||||||
>
|
>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{page.icon || (
|
{page.icon || (
|
||||||
<ActionIcon variant='transparent' color='gray' size={18}>
|
<ActionIcon variant="transparent" color="gray" size={18}>
|
||||||
<IconFileDescription size={18}/>
|
<IconFileDescription size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -58,18 +59,23 @@ export default function RecentChanges({spaceId}: Props) {
|
|||||||
{!spaceId && (
|
{!spaceId && (
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge
|
||||||
color="blue"
|
color={getInitialsColor(page?.space.name)}
|
||||||
variant="light"
|
variant="light"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={getSpaceUrl(page?.space.slug)}
|
to={getSpaceUrl(page?.space.slug)}
|
||||||
style={{cursor: 'pointer'}}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
{page?.space.name}
|
{page?.space.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
)}
|
)}
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
size="xs"
|
||||||
|
fw={500}
|
||||||
|
>
|
||||||
{formattedDate(page.updatedAt)}
|
{formattedDate(page.updatedAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useRef, useState, ReactNode } from "react";
|
||||||
|
import { Text, TextProps, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
type AutoTooltipTextProps = TextProps & {
|
||||||
|
children: ReactNode;
|
||||||
|
tooltipLabel?: string;
|
||||||
|
tooltipProps?: Omit<
|
||||||
|
React.ComponentProps<typeof Tooltip>,
|
||||||
|
"children" | "label"
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AutoTooltipText({
|
||||||
|
children,
|
||||||
|
tooltipLabel,
|
||||||
|
tooltipProps,
|
||||||
|
...textProps
|
||||||
|
}: AutoTooltipTextProps) {
|
||||||
|
const textRef = useRef<HTMLParagraphElement>(null);
|
||||||
|
const [isTruncated, setIsTruncated] = useState(false);
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
const element = textRef.current;
|
||||||
|
if (element) {
|
||||||
|
setIsTruncated(element.scrollWidth > element.clientWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={label}
|
||||||
|
disabled={!isTruncated || !label}
|
||||||
|
multiline
|
||||||
|
withArrow
|
||||||
|
{...tooltipProps}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
ref={textRef}
|
||||||
|
truncate
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
{...textProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,15 +3,3 @@ import { atom } from 'jotai';
|
|||||||
export const showCommentPopupAtom = atom<boolean>(false);
|
export const showCommentPopupAtom = atom<boolean>(false);
|
||||||
export const activeCommentIdAtom = atom<string>('');
|
export const activeCommentIdAtom = atom<string>('');
|
||||||
export const draftCommentIdAtom = atom<string>('');
|
export const draftCommentIdAtom = atom<string>('');
|
||||||
|
|
||||||
// Read-only comment state
|
|
||||||
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
|
|
||||||
export type YjsSelection = {
|
|
||||||
anchor: any;
|
|
||||||
head: any;
|
|
||||||
};
|
|
||||||
export type ReadOnlyCommentData = {
|
|
||||||
yjsSelection: YjsSelection;
|
|
||||||
selectedText: string;
|
|
||||||
};
|
|
||||||
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createComment,
|
createComment,
|
||||||
createReadOnlyComment,
|
|
||||||
CreateReadOnlyCommentData,
|
|
||||||
deleteComment,
|
deleteComment,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
updateComment,
|
updateComment,
|
||||||
@@ -108,23 +106,4 @@ export function useDeleteCommentMutation(pageId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateReadOnlyCommentMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return useMutation<IComment, Error, CreateReadOnlyCommentData>({
|
|
||||||
mutationFn: (data) => createReadOnlyComment(data),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
|
|
||||||
notifications.show({ message: t("Comment created successfully") });
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
notifications.show({
|
|
||||||
message: t("Error creating comment"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
// EE: useResolveCommentMutation has been moved to @/ee/comment/queries/comment-query
|
||||||
|
|||||||
@@ -40,20 +40,3 @@ export async function getPageComments(
|
|||||||
export async function deleteComment(commentId: string): Promise<void> {
|
export async function deleteComment(commentId: string): Promise<void> {
|
||||||
await api.post("/comments/delete", { commentId });
|
await api.post("/comments/delete", { commentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateReadOnlyCommentData = {
|
|
||||||
pageId: string;
|
|
||||||
content: string;
|
|
||||||
selection?: string;
|
|
||||||
yjsSelection: {
|
|
||||||
anchor: any;
|
|
||||||
head: any;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createReadOnlyComment(
|
|
||||||
data: CreateReadOnlyCommentData,
|
|
||||||
): Promise<IComment> {
|
|
||||||
const req = await api.post<IComment>("/comments/create-readonly", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
|
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
||||||
import { useHover } from "@mantine/hooks";
|
import { useHover } from "@mantine/hooks";
|
||||||
import { formatBytes } from "@/lib";
|
import { formatBytes } from "@/lib";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function AttachmentView(props: NodeViewProps) {
|
export default function AttachmentView(props: NodeViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { node, selected } = props;
|
const { node, selected } = props;
|
||||||
const { url, name, size } = node.attrs;
|
const { url, name, size } = node.attrs;
|
||||||
const { hovered, ref } = useHover();
|
const { hovered, ref } = useHover();
|
||||||
@@ -20,26 +22,28 @@ export default function AttachmentView(props: NodeViewProps) {
|
|||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
h={25}
|
h={25}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
|
||||||
<IconPaperclip size={20} />
|
{url ? (
|
||||||
|
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Text component="span" size="md" truncate="end">
|
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
|
||||||
{name}
|
{url ? name : t("Uploading {{name}}", { name })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text component="span" size="sm" c="dimmed" inline>
|
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
||||||
{formatBytes(size)}
|
{formatBytes(size)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{selected || hovered ? (
|
{url && (selected || hovered) && (
|
||||||
<a href={getFileUrl(url)} target="_blank">
|
<a href={getFileUrl(url)} target="_blank">
|
||||||
<ActionIcon variant="default" aria-label="download file">
|
<ActionIcon variant="default" aria-label="download file">
|
||||||
<IconDownload size={18} />
|
<IconDownload size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</a>
|
</a>
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import {
|
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
||||||
BubbleMenu,
|
import { isNodeSelection, useEditorState } from "@tiptap/react";
|
||||||
BubbleMenuProps,
|
import type { Editor } from "@tiptap/react";
|
||||||
isNodeSelection,
|
|
||||||
useEditor,
|
|
||||||
useEditorState,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import { FC, useEffect, useRef, useState } from "react";
|
import { FC, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
IconBold,
|
IconBold,
|
||||||
@@ -38,7 +34,7 @@ export interface BubbleMenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: Editor | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||||
@@ -133,14 +129,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
}
|
}
|
||||||
return isTextSelected(editor);
|
return isTextSelected(editor);
|
||||||
},
|
},
|
||||||
tippyOptions: {
|
options: {
|
||||||
moveTransition: "transform 0.15s ease-out",
|
placement: "top",
|
||||||
onCreate: (instance) => {
|
offset: 8,
|
||||||
instance.popper.firstChild?.addEventListener("blur", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onHide: () => {
|
onHide: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
@@ -156,7 +147,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu {...bubbleMenuProps}>
|
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
||||||
<div className={classes.bubbleMenu}>
|
<div className={classes.bubbleMenu}>
|
||||||
<NodeSelector
|
<NodeSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import {
|
|
||||||
BubbleMenu,
|
|
||||||
isNodeSelection,
|
|
||||||
isTextSelection,
|
|
||||||
useEditor,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import { FC, useEffect, useRef } from "react";
|
|
||||||
import { IconMessage } from "@tabler/icons-react";
|
|
||||||
import classes from "./bubble-menu.module.css";
|
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
readOnlyCommentDataAtom,
|
|
||||||
showReadOnlyCommentPopupAtom,
|
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ySyncPluginKey } from "y-prosemirror";
|
|
||||||
import { getRelativeSelection } from "y-prosemirror";
|
|
||||||
|
|
||||||
type ReadOnlyBubbleMenuProps = {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ReadOnlyBubbleMenu: FC<ReadOnlyBubbleMenuProps> = ({ editor }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
|
|
||||||
showReadOnlyCommentPopupAtom,
|
|
||||||
);
|
|
||||||
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
|
|
||||||
const showPopupRef = useRef(showReadOnlyCommentPopup);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
showPopupRef.current = showReadOnlyCommentPopup;
|
|
||||||
}, [showReadOnlyCommentPopup]);
|
|
||||||
|
|
||||||
const handleCommentClick = () => {
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
const view = editor.view;
|
|
||||||
const ystate = ySyncPluginKey.getState(view.state);
|
|
||||||
|
|
||||||
if (ystate?.binding) {
|
|
||||||
const selection = getRelativeSelection(ystate.binding, view.state);
|
|
||||||
const { from, to } = editor.state.selection;
|
|
||||||
const selectedText = editor.state.doc.textBetween(from, to);
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
setReadOnlyCommentData({
|
|
||||||
yjsSelection: {
|
|
||||||
anchor: selection.anchor,
|
|
||||||
head: selection.head,
|
|
||||||
},
|
|
||||||
selectedText,
|
|
||||||
});
|
|
||||||
setShowReadOnlyCommentPopup(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't render if editor is not available or is editable
|
|
||||||
if (!editor || editor.isEditable) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BubbleMenu
|
|
||||||
editor={editor}
|
|
||||||
pluginKey="readonly"
|
|
||||||
shouldShow={({ state, editor }) => {
|
|
||||||
// Safety check - don't show if editor became editable
|
|
||||||
if (!editor || editor.isEditable || editor.isDestroyed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { selection } = state;
|
|
||||||
const { empty, from, to } = selection;
|
|
||||||
|
|
||||||
if (
|
|
||||||
editor.isActive("image") ||
|
|
||||||
empty ||
|
|
||||||
isNodeSelection(selection) ||
|
|
||||||
isCellSelection(selection) ||
|
|
||||||
showPopupRef?.current
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if actual text is selected (not just empty block)
|
|
||||||
const hasText = state.doc.textBetween(from, to).length > 0;
|
|
||||||
return isTextSelection(selection) && hasText;
|
|
||||||
}}
|
|
||||||
tippyOptions={{
|
|
||||||
moveTransition: "transform 0.15s ease-out",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={classes.bubbleMenu}>
|
|
||||||
<Tooltip label={t("Comment")} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
radius="0"
|
|
||||||
aria-label={t("Comment")}
|
|
||||||
style={{ border: "none" }}
|
|
||||||
onClick={handleCommentClick}
|
|
||||||
>
|
|
||||||
<IconMessage size={16} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</BubbleMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Dialog, Group, Stack, Text } from "@mantine/core";
|
|
||||||
import { useClickOutside } from "@mantine/hooks";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
|
||||||
activeCommentIdAtom,
|
|
||||||
readOnlyCommentDataAtom,
|
|
||||||
showReadOnlyCommentPopupAtom,
|
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
|
||||||
import { useCreateReadOnlyCommentMutation } from "@/features/comment/queries/comment-query";
|
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
|
||||||
import { useEditor } from "@tiptap/react";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
|
||||||
|
|
||||||
type ReadOnlyCommentDialogProps = {
|
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
pageId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ReadOnlyCommentDialog({ editor, pageId }: ReadOnlyCommentDialogProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [comment, setComment] = useState("");
|
|
||||||
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
|
||||||
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(
|
|
||||||
readOnlyCommentDataAtom,
|
|
||||||
);
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
|
||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
|
||||||
const useClickOutsideRef = useClickOutside(() => {
|
|
||||||
handleDialogClose();
|
|
||||||
});
|
|
||||||
const createCommentMutation = useCreateReadOnlyCommentMutation();
|
|
||||||
const { isPending } = createCommentMutation;
|
|
||||||
|
|
||||||
const emit = useQueryEmit();
|
|
||||||
|
|
||||||
const handleDialogClose = () => {
|
|
||||||
setShowReadOnlyCommentPopup(false);
|
|
||||||
setReadOnlyCommentData(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddComment = async () => {
|
|
||||||
if (!readOnlyCommentData) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const commentData = {
|
|
||||||
pageId: pageId,
|
|
||||||
content: JSON.stringify(comment),
|
|
||||||
selection: readOnlyCommentData.selectedText,
|
|
||||||
yjsSelection: readOnlyCommentData.yjsSelection,
|
|
||||||
};
|
|
||||||
|
|
||||||
const createdComment =
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
|
||||||
|
|
||||||
setActiveCommentId(createdComment.id);
|
|
||||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
|
||||||
const commentElement = document.querySelector(selector);
|
|
||||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}, 400);
|
|
||||||
|
|
||||||
emit({
|
|
||||||
operation: "invalidateComment",
|
|
||||||
pageId: pageId,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setShowReadOnlyCommentPopup(false);
|
|
||||||
setReadOnlyCommentData(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommentEditorChange = (newContent: any) => {
|
|
||||||
setComment(newContent);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
opened={true}
|
|
||||||
onClose={handleDialogClose}
|
|
||||||
ref={useClickOutsideRef}
|
|
||||||
size="lg"
|
|
||||||
radius="md"
|
|
||||||
w={300}
|
|
||||||
position={{ bottom: 500, right: 50 }}
|
|
||||||
withCloseButton
|
|
||||||
withBorder
|
|
||||||
>
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Group>
|
|
||||||
<CustomAvatar
|
|
||||||
size="sm"
|
|
||||||
avatarUrl={currentUser.user.avatarUrl}
|
|
||||||
name={currentUser.user.name}
|
|
||||||
/>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Text size="sm" fw={500} lineClamp={1}>
|
|
||||||
{currentUser.user.name}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<CommentEditor
|
|
||||||
onUpdate={handleCommentEditorChange}
|
|
||||||
onSave={handleAddComment}
|
|
||||||
placeholder={t("Write a comment")}
|
|
||||||
editable={true}
|
|
||||||
autofocus={true}
|
|
||||||
/>
|
|
||||||
<CommentActions onSave={handleAddComment} isLoading={isPending} />
|
|
||||||
</Stack>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReadOnlyCommentDialog;
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import {
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
BubbleMenu as BaseBubbleMenu,
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
findParentNode,
|
|
||||||
posToDOMRect,
|
|
||||||
useEditorState,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
@@ -53,17 +49,26 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getReferenceClientRect = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "callout";
|
const predicate = (node: PMNode) => node.type.name === "callout";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
return dom.getBoundingClientRect();
|
const domRect = dom.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const setCalloutType = useCallback(
|
const setCalloutType = useCallback(
|
||||||
@@ -112,14 +117,12 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`callout-menu`}
|
pluginKey={`callout-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
getReferenceClientRect,
|
options={{
|
||||||
offset: [0, 10],
|
|
||||||
placement: "bottom",
|
placement: "bottom",
|
||||||
zIndex: 99,
|
// offset: 233, // // offset: [0, 10],
|
||||||
popperOptions: {
|
// zIndex: 99,
|
||||||
modifiers: [{ name: "flip", enabled: false }],
|
flip: false,
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export default function CodeBlockView(props: NodeViewProps) {
|
|||||||
node.textContent.length > 0
|
node.textContent.length > 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* @ts-ignore */}
|
||||||
<NodeViewContent as="code" className={`language-${language}`} />
|
<NodeViewContent as="code" className={`language-${language}`} />
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import type { EditorView } from "@tiptap/pm/view";
|
|
||||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
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";
|
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
|
||||||
export const handlePaste = (
|
export const handlePaste = (
|
||||||
view: EditorView,
|
editor: Editor,
|
||||||
event: ClipboardEvent,
|
event: ClipboardEvent,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
creatorId?: string,
|
creatorId?: string,
|
||||||
@@ -18,7 +17,7 @@ export const handlePaste = (
|
|||||||
// we have to do this validation here to allow the default link extension to takeover if needs be
|
// we have to do this validation here to allow the default link extension to takeover if needs be
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const url = clipboardData.trim();
|
const url = clipboardData.trim();
|
||||||
const { from: pos, empty } = view.state.selection;
|
const { from: pos, empty } = editor.state.selection;
|
||||||
const match = INTERNAL_LINK_REGEX.exec(url);
|
const match = INTERNAL_LINK_REGEX.exec(url);
|
||||||
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
||||||
|
|
||||||
@@ -34,19 +33,27 @@ export const handlePaste = (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
|
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
|
||||||
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
|
const urlWithoutAnchor = anchorId
|
||||||
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
|
? url.substring(0, url.indexOf("#"))
|
||||||
|
: url;
|
||||||
|
createMentionAction(
|
||||||
|
urlWithoutAnchor,
|
||||||
|
editor.view,
|
||||||
|
pos,
|
||||||
|
creatorId,
|
||||||
|
anchorId,
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.clipboardData?.files.length) {
|
if (event.clipboardData?.files.length) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
for (const file of event.clipboardData.files) {
|
for (const file of event.clipboardData.files) {
|
||||||
const pos = view.state.selection.from;
|
const pos = editor.state.selection.from;
|
||||||
uploadImageAction(file, view, pos, pageId);
|
uploadImageAction(file, editor, pos, pageId);
|
||||||
uploadVideoAction(file, view, pos, pageId);
|
uploadVideoAction(file, editor, pos, pageId);
|
||||||
uploadAttachmentAction(file, view, pos, pageId);
|
uploadAttachmentAction(file, editor, pos, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -54,7 +61,7 @@ export const handlePaste = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleFileDrop = (
|
export const handleFileDrop = (
|
||||||
view: EditorView,
|
editor: Editor,
|
||||||
event: DragEvent,
|
event: DragEvent,
|
||||||
moved: boolean,
|
moved: boolean,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
@@ -63,14 +70,14 @@ export const handleFileDrop = (
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
for (const file of event.dataTransfer.files) {
|
for (const file of event.dataTransfer.files) {
|
||||||
const coordinates = view.posAtCoords({
|
const coordinates = editor.view.posAtCoords({
|
||||||
left: event.clientX,
|
left: event.clientX,
|
||||||
top: event.clientY,
|
top: event.clientY,
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import {
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
BubbleMenu as BaseBubbleMenu,
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
findParentNode,
|
|
||||||
posToDOMRect,
|
|
||||||
useEditorState,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { sticky } from "tippy.js";
|
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -40,17 +35,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getReferenceClientRect = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "drawio";
|
const predicate = (node: PMNode) => node.type.name === "drawio";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
return dom.getBoundingClientRect();
|
const domRect = dom.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const onWidthChange = useCallback(
|
||||||
@@ -65,15 +69,11 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`drawio-menu`}
|
pluginKey={`drawio-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
getReferenceClientRect,
|
options={{
|
||||||
offset: [0, 8],
|
placement: "top",
|
||||||
zIndex: 99,
|
offset: 8,
|
||||||
popperOptions: {
|
flip: false,
|
||||||
modifiers: [{ name: "flip", enabled: false }],
|
|
||||||
},
|
|
||||||
plugins: [sticky],
|
|
||||||
sticky: "popper",
|
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
const fileName = "diagram.drawio.svg";
|
const fileName = "diagram.drawio.svg";
|
||||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
let attachment: IAttachment = null;
|
||||||
|
|||||||
@@ -1,16 +1,41 @@
|
|||||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||||
import EmojiList from "./emoji-list";
|
import EmojiList from "./emoji-list";
|
||||||
import tippy from "tippy.js";
|
|
||||||
import { init } from "emoji-mart";
|
import { init } from "emoji-mart";
|
||||||
|
import {
|
||||||
|
autoUpdate,
|
||||||
|
computePosition,
|
||||||
|
flip,
|
||||||
|
offset,
|
||||||
|
shift,
|
||||||
|
} from "@floating-ui/dom";
|
||||||
|
|
||||||
const renderEmojiItems = () => {
|
const renderEmojiItems = () => {
|
||||||
let component: ReactRenderer | null = null;
|
let component: ReactRenderer | null = null;
|
||||||
let popup: any | null = null;
|
let popup: HTMLDivElement | null = null;
|
||||||
|
let cleanup: (() => void) | null = null;
|
||||||
|
let getReferenceClientRect: (() => DOMRect) | null = null;
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
cleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
popup.remove();
|
||||||
|
popup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component) {
|
||||||
|
component.destroy();
|
||||||
|
component = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onBeforeStart: (props: {
|
onBeforeStart: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: DOMRect;
|
clientRect: () => DOMRect;
|
||||||
}) => {
|
}) => {
|
||||||
init({
|
init({
|
||||||
data: async () => (await import("@emoji-mart/data")).default,
|
data: async () => (await import("@emoji-mart/data")).default,
|
||||||
@@ -25,51 +50,61 @@ const renderEmojiItems = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
getReferenceClientRect = props.clientRect;
|
||||||
popup = tippy("body", {
|
popup = document.createElement("div");
|
||||||
getReferenceClientRect: props.clientRect,
|
popup.style.zIndex = "9999";
|
||||||
appendTo: () => document.body,
|
popup.style.position = "absolute";
|
||||||
content: component.element,
|
popup.style.top = "0";
|
||||||
showOnCreate: true,
|
popup.style.left = "0";
|
||||||
interactive: true,
|
popup.appendChild(component.element);
|
||||||
trigger: "manual",
|
document.body.appendChild(popup);
|
||||||
placement: "bottom",
|
|
||||||
|
const virtualElement = {
|
||||||
|
getBoundingClientRect: () => {
|
||||||
|
return getReferenceClientRect
|
||||||
|
? getReferenceClientRect()
|
||||||
|
: new DOMRect(0, 0, 0, 0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
cleanup = autoUpdate(virtualElement, popup, () => {
|
||||||
|
if (!popup) return;
|
||||||
|
|
||||||
|
computePosition(virtualElement, popup, {
|
||||||
|
placement: "bottom-start",
|
||||||
|
middleware: [offset(10), flip(), shift()],
|
||||||
|
}).then(({ x, y }) => {
|
||||||
|
if (!popup) return;
|
||||||
|
|
||||||
|
Object.assign(popup.style, {
|
||||||
|
transform: `translate(${x}px, ${y}px)`,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: DOMRect;
|
clientRect: () => DOMRect;
|
||||||
}) => {
|
}) => {
|
||||||
component?.updateProps({...props, isLoading: false});
|
component?.updateProps({ ...props, isLoading: false });
|
||||||
|
|
||||||
if (!props.clientRect) {
|
if (props.clientRect) {
|
||||||
return;
|
getReferenceClientRect = props.clientRect;
|
||||||
}
|
}
|
||||||
|
|
||||||
popup &&
|
|
||||||
popup[0].setProps({
|
|
||||||
getReferenceClientRect: props.clientRect,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onUpdate: (props: {
|
onUpdate: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: DOMRect;
|
clientRect: () => DOMRect;
|
||||||
}) => {
|
}) => {
|
||||||
component?.updateProps(props);
|
component?.updateProps(props);
|
||||||
|
|
||||||
if (!props.clientRect) {
|
if (props.clientRect) {
|
||||||
return;
|
getReferenceClientRect = props.clientRect;
|
||||||
}
|
}
|
||||||
|
|
||||||
popup &&
|
|
||||||
popup[0].setProps({
|
|
||||||
getReferenceClientRect: props.clientRect,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key === "Escape") {
|
||||||
popup?.[0].hide();
|
destroy();
|
||||||
component?.destroy()
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -78,13 +113,7 @@ const renderEmojiItems = () => {
|
|||||||
return component?.ref?.onKeyDown(props);
|
return component?.ref?.onKeyDown(props);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
if (popup && !popup[0]?.state.isDestroyed) {
|
destroy();
|
||||||
popup[0]?.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (component) {
|
|
||||||
component?.destroy();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import {
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
BubbleMenu as BaseBubbleMenu,
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
findParentNode,
|
|
||||||
posToDOMRect,
|
|
||||||
useEditorState,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { sticky } from "tippy.js";
|
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -42,17 +37,26 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getReferenceClientRect = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
return dom.getBoundingClientRect();
|
const domRect = dom.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const onWidthChange = useCallback(
|
||||||
@@ -65,17 +69,13 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`excalidraw-menu}`}
|
pluginKey={`excalidraw-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
getReferenceClientRect,
|
options={{
|
||||||
offset: [0, 8],
|
placement: "top",
|
||||||
zIndex: 99,
|
offset: 8,
|
||||||
popperOptions: {
|
flip: false,
|
||||||
modifiers: [{ name: "flip", enabled: false }],
|
|
||||||
},
|
|
||||||
plugins: [sticky],
|
|
||||||
sticky: "popper",
|
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
const fileName = "diagram.excalidraw.svg";
|
const fileName = "diagram.excalidraw.svg";
|
||||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
let attachment: IAttachment = null;
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import {
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
BubbleMenu as BaseBubbleMenu,
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
findParentNode,
|
|
||||||
posToDOMRect,
|
|
||||||
useEditorState,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { sticky } from "tippy.js";
|
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const shouldShow = useCallback(
|
|
||||||
({ state }: ShouldShowProps) => {
|
|
||||||
if (!state) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return editor.isActive("image");
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -52,17 +37,37 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getReferenceClientRect = useCallback(() => {
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive("image") && editor.getAttributes("image").src;
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "image";
|
const predicate = (node: PMNode) => node.type.name === "image";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
return dom.getBoundingClientRect();
|
const domRect = dom.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignImageLeft = useCallback(() => {
|
const alignImageLeft = useCallback(() => {
|
||||||
@@ -105,15 +110,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`image-menu`}
|
pluginKey={`image-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
getReferenceClientRect,
|
options={{
|
||||||
offset: [0, 8],
|
placement: "top",
|
||||||
zIndex: 99,
|
offset: 8,
|
||||||
popperOptions: {
|
flip: false,
|
||||||
modifiers: [{ name: "flip", enabled: false }],
|
|
||||||
},
|
|
||||||
plugins: [sticky],
|
|
||||||
sticky: "popper",
|
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
.imageWrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 0%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -135% 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,70 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
import { Group, Image, Loader, Text } from "@mantine/core";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Image } from "@mantine/core";
|
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import classes from "./image-view.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function ImageView(props: NodeViewProps) {
|
export default function ImageView(props: NodeViewProps) {
|
||||||
const { node, selected } = props;
|
const { t } = useTranslation();
|
||||||
const { src, width, align, title } = node.attrs;
|
const { editor, node, selected } = props;
|
||||||
|
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
|
||||||
const alignClass = useMemo(() => {
|
const alignClass = useMemo(() => {
|
||||||
if (align === "left") return "alignLeft";
|
if (align === "left") return "alignLeft";
|
||||||
if (align === "right") return "alignRight";
|
if (align === "right") return "alignRight";
|
||||||
if (align === "center") return "alignCenter";
|
if (align === "center") return "alignCenter";
|
||||||
return "alignCenter";
|
return "alignCenter";
|
||||||
}, [align]);
|
}, [align]);
|
||||||
|
const previewSrc = useMemo(() => {
|
||||||
|
editor.storage.shared.imagePreviews =
|
||||||
|
editor.storage.shared.imagePreviews || {};
|
||||||
|
|
||||||
|
if (placeholder?.id) {
|
||||||
|
return editor.storage.shared.imagePreviews[placeholder.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [placeholder, editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle>
|
||||||
<Image
|
<div
|
||||||
radius="md"
|
className={clsx(
|
||||||
fit="contain"
|
selected && "ProseMirror-selectednode",
|
||||||
w={width}
|
classes.imageWrapper,
|
||||||
src={getFileUrl(src)}
|
alignClass,
|
||||||
alt={title}
|
)}
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
style={{
|
||||||
/>
|
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
||||||
|
width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{src && (
|
||||||
|
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
|
||||||
|
)}
|
||||||
|
{!src && previewSrc && (
|
||||||
|
<Group pos="relative" h="100%" w="100%">
|
||||||
|
<Image
|
||||||
|
radius="md"
|
||||||
|
fit="contain"
|
||||||
|
src={previewSrc}
|
||||||
|
alt={placeholder?.name}
|
||||||
|
/>
|
||||||
|
<Loader size={20} pos="absolute" bottom={6} right={6} />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{!src && !previewSrc && (
|
||||||
|
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||||
|
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||||
|
<Text component="span" size="sm" truncate="end">
|
||||||
|
{placeholder?.name
|
||||||
|
? t("Uploading {{name}}", { name: placeholder.name })
|
||||||
|
: t("Uploading file")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||||
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
|
||||||
import { Card } from "@mantine/core";
|
import { Card } from "@mantine/core";
|
||||||
|
import { useEditorState } from "@tiptap/react";
|
||||||
|
|
||||||
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
||||||
const [showEdit, setShowEdit] = useState(false);
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
@@ -59,18 +60,15 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`link-menu}`}
|
pluginKey={`link-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
options={{
|
||||||
appendTo: () => {
|
onHide: () => {
|
||||||
return appendTo?.current;
|
|
||||||
},
|
|
||||||
onHidden: () => {
|
|
||||||
setShowEdit(false);
|
setShowEdit(false);
|
||||||
},
|
},
|
||||||
placement: "bottom",
|
placement: "bottom",
|
||||||
offset: [0, 5],
|
offset: 5,
|
||||||
zIndex: 101,
|
// zIndex: 101,
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
|||||||
|
|
||||||
setRenderItems(items);
|
setRenderItems(items);
|
||||||
// update editor storage
|
// update editor storage
|
||||||
|
//@ts-ignore
|
||||||
props.editor.storage.mentionItems = items;
|
props.editor.storage.mentionItems = items;
|
||||||
}
|
}
|
||||||
}, [suggestion, isLoading]);
|
}, [suggestion, isLoading]);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||||
import tippy from "tippy.js";
|
import {
|
||||||
|
autoUpdate,
|
||||||
|
computePosition,
|
||||||
|
flip,
|
||||||
|
offset,
|
||||||
|
shift,
|
||||||
|
} from "@floating-ui/dom";
|
||||||
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
|
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
|
||||||
|
|
||||||
function getWhitespaceCount(query: string) {
|
function getWhitespaceCount(query: string) {
|
||||||
@@ -9,16 +15,27 @@ function getWhitespaceCount(query: string) {
|
|||||||
|
|
||||||
const mentionRenderItems = () => {
|
const mentionRenderItems = () => {
|
||||||
let component: ReactRenderer | null = null;
|
let component: ReactRenderer | null = null;
|
||||||
let popup: any | null = null;
|
let activeClientRect: (() => DOMRect) | null = null;
|
||||||
|
let updatePositionCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
updatePositionCleanup?.();
|
||||||
|
updatePositionCleanup = null;
|
||||||
|
component?.destroy();
|
||||||
|
if (component?.element?.parentNode) {
|
||||||
|
component.element.parentNode.removeChild(component.element);
|
||||||
|
}
|
||||||
|
component = null;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: DOMRect;
|
clientRect: () => DOMRect;
|
||||||
query: string;
|
query: string;
|
||||||
}) => {
|
}) => {
|
||||||
// query must not start with a whitespace
|
// query must not start with a whitespace
|
||||||
if (props.query.charAt(0) === ' '){
|
if (props.query.charAt(0) === " ") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,75 +54,88 @@ const mentionRenderItems = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
activeClientRect = props.clientRect;
|
||||||
popup = tippy("body", {
|
|
||||||
getReferenceClientRect: props.clientRect,
|
const { element } = component;
|
||||||
appendTo: () => document.body,
|
document.body.appendChild(element);
|
||||||
content: component.element,
|
|
||||||
showOnCreate: true,
|
updatePositionCleanup = autoUpdate(
|
||||||
interactive: true,
|
{
|
||||||
trigger: "manual",
|
getBoundingClientRect: () =>
|
||||||
placement: "bottom-start",
|
activeClientRect ? activeClientRect() : new DOMRect(),
|
||||||
});
|
},
|
||||||
|
element,
|
||||||
|
() => {
|
||||||
|
if (!component?.element) return;
|
||||||
|
computePosition(
|
||||||
|
{
|
||||||
|
getBoundingClientRect: () => {
|
||||||
|
return activeClientRect ? activeClientRect() : new DOMRect();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
element,
|
||||||
|
{
|
||||||
|
placement: "bottom-start",
|
||||||
|
middleware: [offset(0), flip(), shift()],
|
||||||
|
}
|
||||||
|
).then(({ x, y }) => {
|
||||||
|
Object.assign(element.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: "9999",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onUpdate: (props: {
|
onUpdate: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
clientRect: DOMRect;
|
clientRect: () => DOMRect;
|
||||||
query: string;
|
query: string;
|
||||||
}) => {
|
}) => {
|
||||||
// query must not start with a whitespace
|
// query must not start with a whitespace
|
||||||
if (props.query.charAt(0) === ' '){
|
if (props.query.charAt(0) === " ") {
|
||||||
component?.destroy();
|
destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// only update component if popup is not destroyed
|
// only update component if popup is not destroyed
|
||||||
if (!popup?.[0].state.isDestroyed) {
|
if (component) {
|
||||||
component?.updateProps(props);
|
component.updateProps(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props || !props.clientRect) {
|
if (!props || !props.clientRect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activeClientRect = props.clientRect;
|
||||||
|
|
||||||
const whitespaceCount = getWhitespaceCount(props.query);
|
const whitespaceCount = getWhitespaceCount(props.query);
|
||||||
|
|
||||||
// destroy component if space is greater 3 without a match
|
// destroy component if space is greater 3 without a match
|
||||||
if (
|
if (
|
||||||
whitespaceCount > 3 &&
|
whitespaceCount > 3 &&
|
||||||
|
//@ts-ignore
|
||||||
props.editor.storage.mentionItems.length === 0
|
props.editor.storage.mentionItems.length === 0
|
||||||
) {
|
) {
|
||||||
popup?.[0]?.destroy();
|
destroy();
|
||||||
component?.destroy();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
popup &&
|
|
||||||
!popup?.[0].state.isDestroyed &&
|
|
||||||
popup?.[0].setProps({
|
|
||||||
getReferenceClientRect: props.clientRect,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key)
|
if (props.event.key)
|
||||||
if (
|
if (
|
||||||
props.event.key === "Escape" ||
|
props.event.key === "Escape" ||
|
||||||
(props.event.key === "Enter" && !popup?.[0].state.isShown)
|
(props.event.key === "Enter" && !component)
|
||||||
) {
|
) {
|
||||||
popup?.[0].destroy();
|
destroy();
|
||||||
component?.destroy();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (component?.ref as any)?.onKeyDown(props);
|
return (component?.ref as any)?.onKeyDown(props);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
if (popup && !popup?.[0].state.isDestroyed) {
|
destroy();
|
||||||
popup[0].destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (component) {
|
|
||||||
component.destroy();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+2
@@ -73,6 +73,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
|||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
|
||||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||||
|
//TODO: check type error
|
||||||
|
//@ts-ignore
|
||||||
const position: Range = results[resultIndex];
|
const position: Range = results[resultIndex];
|
||||||
|
|
||||||
if (!position) return;
|
if (!position) return;
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
if (!pageId) return;
|
if (!pageId) return;
|
||||||
|
|
||||||
@@ -173,9 +174,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
for (const file of input.files) {
|
for (const file of input.files) {
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
uploadImageAction(file, editor.view, pos, pageId);
|
|
||||||
|
uploadImageAction(file, editor, pos, pageId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset the input value to allow uploading the same file again if needed
|
||||||
|
input.value = "";
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -188,6 +193,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
if (!pageId) return;
|
if (!pageId) return;
|
||||||
|
|
||||||
@@ -195,12 +201,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "video/*";
|
input.accept = "video/*";
|
||||||
|
input.multiple = true;
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
const file = input.files[0];
|
for (const file of input.files) {
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
uploadVideoAction(file, editor.view, pos, pageId);
|
|
||||||
|
uploadVideoAction(file, editor, pos, pageId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset the input value to allow uploading the same file again if needed
|
||||||
|
input.value = "";
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
@@ -213,6 +225,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
if (!pageId) return;
|
if (!pageId) return;
|
||||||
|
|
||||||
@@ -220,12 +233,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "";
|
input.accept = "";
|
||||||
|
input.multiple = true;
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
const file = input.files[0];
|
for (const file of input.files) {
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = editor.view.state.selection.from;
|
||||||
uploadAttachmentAction(file, editor.view, pos, pageId, true);
|
|
||||||
|
uploadAttachmentAction(file, editor, pos, pageId, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset the input value to allow uploading the same file again if needed
|
||||||
|
input.value = "";
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
import { ReactRenderer, useEditor } from "@tiptap/react";
|
import { ReactRenderer, useEditor } from "@tiptap/react";
|
||||||
import CommandList from "@/features/editor/components/slash-menu/command-list";
|
import CommandList from "@/features/editor/components/slash-menu/command-list";
|
||||||
import tippy from "tippy.js";
|
import {
|
||||||
|
autoUpdate,
|
||||||
|
computePosition,
|
||||||
|
flip,
|
||||||
|
offset,
|
||||||
|
shift,
|
||||||
|
} from "@floating-ui/dom";
|
||||||
|
|
||||||
const renderItems = () => {
|
const renderItems = () => {
|
||||||
let component: ReactRenderer | null = null;
|
let component: ReactRenderer | null = null;
|
||||||
let popup: any | null = null;
|
let popup: HTMLElement | null = null;
|
||||||
|
let cleanup: (() => void) | null = null;
|
||||||
|
let getReferenceClientRect: (() => DOMRect) | null = null;
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!popup || !getReferenceClientRect) return;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const rect = getReferenceClientRect();
|
||||||
|
|
||||||
|
computePosition({ getBoundingClientRect: () => rect }, popup, {
|
||||||
|
placement: "bottom-start",
|
||||||
|
middleware: [offset(0), flip(), shift()],
|
||||||
|
}).then(({ x, y }) => {
|
||||||
|
if (popup) {
|
||||||
|
popup.style.left = `${x}px`;
|
||||||
|
popup.style.top = `${y}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: (props: {
|
onStart: (props: {
|
||||||
@@ -21,15 +46,29 @@ const renderItems = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
popup = tippy("body", {
|
getReferenceClientRect = props.clientRect;
|
||||||
getReferenceClientRect: props.clientRect,
|
|
||||||
appendTo: () => document.body,
|
popup = document.createElement("div");
|
||||||
content: component.element,
|
popup.style.zIndex = "9999";
|
||||||
showOnCreate: true,
|
popup.style.position = "absolute";
|
||||||
interactive: true,
|
popup.style.top = "0";
|
||||||
trigger: "manual",
|
popup.style.left = "0";
|
||||||
placement: "bottom-start",
|
|
||||||
});
|
document.body.appendChild(popup);
|
||||||
|
popup.appendChild(component.element);
|
||||||
|
|
||||||
|
cleanup = autoUpdate(
|
||||||
|
// @ts-ignore
|
||||||
|
{
|
||||||
|
getBoundingClientRect: () => {
|
||||||
|
return getReferenceClientRect
|
||||||
|
? getReferenceClientRect()
|
||||||
|
: new DOMRect();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
popup,
|
||||||
|
updatePosition
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onUpdate: (props: {
|
onUpdate: (props: {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
@@ -41,14 +80,15 @@ const renderItems = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
popup &&
|
// @ts-ignore
|
||||||
popup[0].setProps({
|
getReferenceClientRect = props.clientRect;
|
||||||
getReferenceClientRect: props.clientRect,
|
updatePosition();
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key === "Escape") {
|
||||||
popup?.[0].hide();
|
if (popup) {
|
||||||
|
popup.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -57,12 +97,19 @@ const renderItems = () => {
|
|||||||
return component?.ref?.onKeyDown(props);
|
return component?.ref?.onKeyDown(props);
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
if (popup && !popup[0].state.isDestroyed) {
|
if (cleanup) {
|
||||||
popup[0].destroy();
|
cleanup();
|
||||||
|
cleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
popup.remove();
|
||||||
|
popup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (component) {
|
if (component) {
|
||||||
component.destroy();
|
component.destroy();
|
||||||
|
component = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import {
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
BubbleMenu as BaseBubbleMenu,
|
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||||
posToDOMRect,
|
|
||||||
findParentNode,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import React, { useCallback } from "react";
|
import React, { JSX, useCallback } from "react";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { sticky } from "tippy.js";
|
|
||||||
|
|
||||||
interface SubpagesMenuProps {
|
interface SubpagesMenuProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -33,7 +29,7 @@ export const SubpagesMenu = React.memo(
|
|||||||
|
|
||||||
return editor.isActive("subpages");
|
return editor.isActive("subpages");
|
||||||
},
|
},
|
||||||
[editor],
|
[editor]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getReferenceClientRect = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
@@ -62,18 +58,8 @@ export const SubpagesMenu = React.memo(
|
|||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`subpages-menu}`}
|
pluginKey={`subpages-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
|
||||||
getReferenceClientRect,
|
|
||||||
offset: [0, 8],
|
|
||||||
zIndex: 99,
|
|
||||||
popperOptions: {
|
|
||||||
modifiers: [{ name: "flip", enabled: false }],
|
|
||||||
},
|
|
||||||
plugins: [sticky],
|
|
||||||
sticky: "popper",
|
|
||||||
}}
|
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<Tooltip position="top" label={t("Delete")}>
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
@@ -89,7 +75,7 @@ export const SubpagesMenu = React.memo(
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export default SubpagesMenu;
|
export default SubpagesMenu;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function SubpagesView(props: NodeViewProps) {
|
|||||||
const { spaceSlug, shareId } = useParams();
|
const { spaceSlug, shareId } = useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
const currentPageId = editor.storage.pageId;
|
const currentPageId = editor.storage.pageId;
|
||||||
|
|
||||||
// Get subpages from shared tree if we're in a shared context
|
// Get subpages from shared tree if we're in a shared context
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
|
import React, { JSX, useCallback } from "react";
|
||||||
import React, { useCallback } from "react";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -17,6 +15,7 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TableBackgroundColor } from "./table-background-color";
|
import { TableBackgroundColor } from "./table-background-color";
|
||||||
import { TableTextAlignment } from "./table-text-alignment";
|
import { TableTextAlignment } from "./table-text-alignment";
|
||||||
|
import { BubbleMenu } from "@tiptap/react/menus";
|
||||||
|
|
||||||
export const TableCellMenu = React.memo(
|
export const TableCellMenu = React.memo(
|
||||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||||
@@ -29,7 +28,7 @@ export const TableCellMenu = React.memo(
|
|||||||
|
|
||||||
return isCellSelection(state.selection);
|
return isCellSelection(state.selection);
|
||||||
},
|
},
|
||||||
[editor],
|
[editor]
|
||||||
);
|
);
|
||||||
|
|
||||||
const mergeCells = useCallback(() => {
|
const mergeCells = useCallback(() => {
|
||||||
@@ -53,23 +52,27 @@ export const TableCellMenu = React.memo(
|
|||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey="table-cell-menu"
|
pluginKey="table-cell-menu"
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
appendTo={() => {
|
||||||
appendTo: () => {
|
return appendTo?.current;
|
||||||
return appendTo?.current;
|
}}
|
||||||
|
ref={(element) => {
|
||||||
|
element.style.zIndex = "99";
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
offset: {
|
||||||
|
mainAxis: 15,
|
||||||
},
|
},
|
||||||
offset: [0, 15],
|
|
||||||
zIndex: 99,
|
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<TableBackgroundColor editor={editor} />
|
<TableBackgroundColor editor={editor} />
|
||||||
<TableTextAlignment editor={editor} />
|
<TableTextAlignment editor={editor} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Merge cells")}>
|
<Tooltip position="top" label={t("Merge cells")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={mergeCells}
|
onClick={mergeCells}
|
||||||
@@ -125,9 +128,9 @@ export const TableCellMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
</BaseBubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export default TableCellMenu;
|
export default TableCellMenu;
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import {
|
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||||
BubbleMenu as BaseBubbleMenu,
|
|
||||||
posToDOMRect,
|
|
||||||
findParentNode,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import React, { useCallback } from "react";
|
import React, { JSX, useCallback } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -17,9 +12,12 @@ import {
|
|||||||
IconColumnRemove,
|
IconColumnRemove,
|
||||||
IconRowInsertBottom,
|
IconRowInsertBottom,
|
||||||
IconRowInsertTop,
|
IconRowInsertTop,
|
||||||
IconRowRemove, IconTableColumn, IconTableRow,
|
IconRowRemove,
|
||||||
|
IconTableColumn,
|
||||||
|
IconTableRow,
|
||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
|
import { BubbleMenu } from "@tiptap/react/menus";
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
import { isCellSelection } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -34,20 +32,28 @@ export const TableMenu = React.memo(
|
|||||||
|
|
||||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||||
},
|
},
|
||||||
[editor],
|
[editor]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getReferenceClientRect = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "table";
|
const predicate = (node: PMNode) => node.type.name === "table";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
return dom.getBoundingClientRect();
|
const rect = dom.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => rect,
|
||||||
|
getClientRects: () => [rect],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
const rect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => rect,
|
||||||
|
getClientRects: () => [rect],
|
||||||
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const toggleHeaderColumn = useCallback(() => {
|
const toggleHeaderColumn = useCallback(() => {
|
||||||
@@ -87,42 +93,33 @@ export const TableMenu = React.memo(
|
|||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey="table-menu"
|
pluginKey="table-menu"
|
||||||
updateDelay={0}
|
resizeDelay={0}
|
||||||
tippyOptions={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
getReferenceClientRect: getReferenceClientRect,
|
ref={(element) => {
|
||||||
offset: [0, 15],
|
element.style.zIndex = "99";
|
||||||
zIndex: 99,
|
}}
|
||||||
popperOptions: {
|
options={{
|
||||||
modifiers: [
|
placement: "top",
|
||||||
{
|
offset: {
|
||||||
name: "preventOverflow",
|
mainAxis: 15,
|
||||||
enabled: true,
|
},
|
||||||
options: {
|
flip: {
|
||||||
altAxis: true,
|
fallbackPlacements: ["top", "bottom"],
|
||||||
boundary: "clippingParents",
|
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
|
||||||
padding: 8,
|
boundary: editor.options.element as HTMLElement,
|
||||||
},
|
},
|
||||||
},
|
shift: {
|
||||||
{
|
padding: 8 + 15,
|
||||||
name: "flip",
|
crossAxis: true,
|
||||||
enabled: true,
|
|
||||||
options: {
|
|
||||||
boundary: editor.options.element,
|
|
||||||
fallbackPlacements: ["top", "bottom"],
|
|
||||||
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<ActionIcon.Group>
|
||||||
<Tooltip position="top" label={t("Add left column")}
|
<Tooltip position="top" label={t("Add left column")}>
|
||||||
>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addColumnLeft}
|
onClick={addColumnLeft}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -188,8 +185,7 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header row")}
|
<Tooltip position="top" label={t("Toggle header row")}>
|
||||||
>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderRow}
|
onClick={toggleHeaderRow}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -200,8 +196,7 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header column")}
|
<Tooltip position="top" label={t("Toggle header column")}>
|
||||||
>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderColumn}
|
onClick={toggleHeaderColumn}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -224,9 +219,9 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
</BaseBubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export default TableMenu;
|
export default TableMenu;
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import {
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
BubbleMenu as BaseBubbleMenu,
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
findParentNode,
|
|
||||||
posToDOMRect,
|
|
||||||
useEditorState,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { sticky } from "tippy.js";
|
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
@@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const shouldShow = useCallback(
|
|
||||||
({ state }: ShouldShowProps) => {
|
|
||||||
if (!state) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return editor.isActive("video");
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -52,17 +37,37 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getReferenceClientRect = useCallback(() => {
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive("video") && editor.getAttributes("video").src;
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "video";
|
const predicate = (node: PMNode) => node.type.name === "video";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
return dom.getBoundingClientRect();
|
const domRect = dom.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignVideoLeft = useCallback(() => {
|
const alignVideoLeft = useCallback(() => {
|
||||||
@@ -105,15 +110,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
pluginKey={`video-menu`}
|
pluginKey={`video-menu`}
|
||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
tippyOptions={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
getReferenceClientRect,
|
options={{
|
||||||
offset: [0, 8],
|
placement: "top",
|
||||||
zIndex: 99,
|
offset: 8,
|
||||||
popperOptions: {
|
flip: false,
|
||||||
modifiers: [{ name: "flip", enabled: false }],
|
|
||||||
},
|
|
||||||
plugins: [sticky],
|
|
||||||
sticky: "popper",
|
|
||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.videoWrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 0%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -135% 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
@@ -1,29 +1,75 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
import { Group, Loader, Text } from "@mantine/core";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import classes from "./video-view.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function VideoView(props: NodeViewProps) {
|
export default function VideoView(props: NodeViewProps) {
|
||||||
const { node, selected } = props;
|
const { t } = useTranslation();
|
||||||
const { src, width, align } = node.attrs;
|
const { editor, node, selected } = props;
|
||||||
|
const { src, width, align, aspectRatio, placeholder } = node.attrs;
|
||||||
const alignClass = useMemo(() => {
|
const alignClass = useMemo(() => {
|
||||||
if (align === "left") return "alignLeft";
|
if (align === "left") return "alignLeft";
|
||||||
if (align === "right") return "alignRight";
|
if (align === "right") return "alignRight";
|
||||||
if (align === "center") return "alignCenter";
|
if (align === "center") return "alignCenter";
|
||||||
return "alignCenter";
|
return "alignCenter";
|
||||||
}, [align]);
|
}, [align]);
|
||||||
|
const previewSrc = useMemo(() => {
|
||||||
|
editor.storage.shared.videoPreviews =
|
||||||
|
editor.storage.shared.videoPreviews || {};
|
||||||
|
|
||||||
|
if (placeholder?.id) {
|
||||||
|
return editor.storage.shared.videoPreviews[placeholder.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [placeholder, editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle>
|
||||||
<video
|
<div
|
||||||
preload="metadata"
|
className={clsx(
|
||||||
width={width}
|
selected && "ProseMirror-selectednode",
|
||||||
controls
|
classes.videoWrapper,
|
||||||
src={getFileUrl(src)}
|
alignClass,
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
)}
|
||||||
style={{ display: "block" }}
|
style={{
|
||||||
/>
|
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
||||||
|
width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{src && (
|
||||||
|
<video
|
||||||
|
className={classes.video}
|
||||||
|
preload="metadata"
|
||||||
|
controls
|
||||||
|
src={getFileUrl(src)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!src && previewSrc && (
|
||||||
|
<Group pos="relative" h="100%" w="100%">
|
||||||
|
<video
|
||||||
|
className={classes.video}
|
||||||
|
preload="metadata"
|
||||||
|
controls
|
||||||
|
src={previewSrc}
|
||||||
|
/>
|
||||||
|
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{!src && !previewSrc && (
|
||||||
|
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||||
|
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||||
|
<Text component="span" size="sm" truncate="end">
|
||||||
|
{placeholder?.name
|
||||||
|
? t("Uploading {{name}}", { name: placeholder.name })
|
||||||
|
: t("Uploading file")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
|
||||||
import { TextAlign } from "@tiptap/extension-text-align";
|
import { TextAlign } from "@tiptap/extension-text-align";
|
||||||
import { CharacterCount } from "@tiptap/extension-character-count";
|
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||||
import { TaskList } from "@tiptap/extension-task-list";
|
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
||||||
import { ListKeymap } from "@tiptap/extension-list-keymap";
|
|
||||||
import { TaskItem } from "@tiptap/extension-task-item";
|
|
||||||
import { Underline } from "@tiptap/extension-underline";
|
|
||||||
import { Superscript } from "@tiptap/extension-superscript";
|
import { Superscript } from "@tiptap/extension-superscript";
|
||||||
import SubScript from "@tiptap/extension-subscript";
|
import SubScript from "@tiptap/extension-subscript";
|
||||||
import { Typography } from "@tiptap/extension-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
@@ -15,7 +11,7 @@ import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
|||||||
import { Youtube } from "@tiptap/extension-youtube";
|
import { Youtube } from "@tiptap/extension-youtube";
|
||||||
import SlashCommand from "@/features/editor/extensions/slash-command";
|
import SlashCommand from "@/features/editor/extensions/slash-command";
|
||||||
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||||
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
|
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import {
|
import {
|
||||||
Comment,
|
Comment,
|
||||||
@@ -41,11 +37,12 @@ import {
|
|||||||
Embed,
|
Embed,
|
||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
Mention,
|
Mention,
|
||||||
Subpages,
|
|
||||||
TableDndExtension,
|
TableDndExtension,
|
||||||
|
Subpages,
|
||||||
Heading,
|
Heading,
|
||||||
Highlight,
|
Highlight,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
|
SharedStorage,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -97,7 +94,9 @@ lowlight.register("scala", scala);
|
|||||||
export const mainExtensions = [
|
export const mainExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: false,
|
heading: false,
|
||||||
history: false,
|
undoRedo: false,
|
||||||
|
link: false,
|
||||||
|
trailingNode: false,
|
||||||
dropcursor: {
|
dropcursor: {
|
||||||
width: 3,
|
width: 3,
|
||||||
color: "#70CFF8",
|
color: "#70CFF8",
|
||||||
@@ -109,6 +108,7 @@ export const mainExtensions = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
SharedStorage,
|
||||||
Heading,
|
Heading,
|
||||||
UniqueID.configure({
|
UniqueID.configure({
|
||||||
types: ["heading", "paragraph"],
|
types: ["heading", "paragraph"],
|
||||||
@@ -134,8 +134,6 @@ export const mainExtensions = [
|
|||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
ListKeymap,
|
|
||||||
Underline,
|
|
||||||
LinkExtension.configure({
|
LinkExtension.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
}),
|
}),
|
||||||
@@ -170,6 +168,9 @@ export const mainExtensions = [
|
|||||||
},
|
},
|
||||||
}).extend({
|
}).extend({
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
|
||||||
return ReactNodeViewRenderer(MentionView);
|
return ReactNodeViewRenderer(MentionView);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -208,6 +209,7 @@ export const mainExtensions = [
|
|||||||
}),
|
}),
|
||||||
CustomCodeBlock.configure({
|
CustomCodeBlock.configure({
|
||||||
view: CodeBlockView,
|
view: CodeBlockView,
|
||||||
|
//@ts-ignore
|
||||||
lowlight,
|
lowlight,
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
spellcheck: false,
|
spellcheck: false,
|
||||||
@@ -258,8 +260,9 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
|||||||
export const collabExtensions: CollabExtensions = (provider, user) => [
|
export const collabExtensions: CollabExtensions = (provider, user) => [
|
||||||
Collaboration.configure({
|
Collaboration.configure({
|
||||||
document: provider.document,
|
document: provider.document,
|
||||||
|
provider,
|
||||||
}),
|
}),
|
||||||
CollaborationCursor.configure({
|
CollaborationCaret.configure({
|
||||||
provider,
|
provider,
|
||||||
user: {
|
user: {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import {
|
import {
|
||||||
HocuspocusProvider,
|
HocuspocusProvider,
|
||||||
onAuthenticationFailedParameters,
|
onStatusParameters,
|
||||||
WebSocketStatus,
|
WebSocketStatus,
|
||||||
|
HocuspocusProviderWebsocket,
|
||||||
|
onSyncedParameters,
|
||||||
} from "@hocuspocus/provider";
|
} from "@hocuspocus/provider";
|
||||||
import {
|
import {
|
||||||
|
Editor,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
EditorProvider,
|
EditorProvider,
|
||||||
useEditor,
|
useEditor,
|
||||||
@@ -28,12 +37,9 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
|||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
showCommentPopupAtom,
|
showCommentPopupAtom,
|
||||||
showReadOnlyCommentPopupAtom,
|
|
||||||
} from "@/features/comment/atoms/comment-atom";
|
} from "@/features/comment/atoms/comment-atom";
|
||||||
import CommentDialog from "@/features/comment/components/comment-dialog";
|
import CommentDialog from "@/features/comment/components/comment-dialog";
|
||||||
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
||||||
import { ReadOnlyBubbleMenu } from "@/features/editor/components/bubble-menu/read-only-bubble-menu";
|
|
||||||
import ReadOnlyCommentDialog from "@/features/editor/components/bubble-menu/read-only-comment-dialog";
|
|
||||||
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
||||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||||
@@ -72,11 +78,9 @@ export default function PageEditor({
|
|||||||
editable,
|
editable,
|
||||||
content,
|
content,
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
|
|
||||||
|
|
||||||
const collaborationURL = useCollaborationUrl();
|
const collaborationURL = useCollaborationUrl();
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
const editorCreated = useRef(false);
|
const editorRef = useRef<Editor | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isComponentMounted.current = true;
|
isComponentMounted.current = true;
|
||||||
@@ -87,147 +91,127 @@ export default function PageEditor({
|
|||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||||
const ydocRef = useRef<Y.Doc | null>(null);
|
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||||
if (!ydocRef.current) {
|
|
||||||
ydocRef.current = new Y.Doc();
|
|
||||||
}
|
|
||||||
const ydoc = ydocRef.current;
|
|
||||||
const [isLocalSynced, setLocalSynced] = useState(false);
|
|
||||||
const [isRemoteSynced, setRemoteSynced] = useState(false);
|
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const documentName = `page.${pageId}`;
|
|
||||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||||
const documentState = useDocumentVisibility();
|
const documentState = useDocumentVisibility();
|
||||||
const [isCollabReady, setIsCollabReady] = useState(false);
|
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const userPageEditMode =
|
const userPageEditMode =
|
||||||
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
|
const canScroll = useCallback(
|
||||||
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
|
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||||
|
[isComponentMounted],
|
||||||
|
);
|
||||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||||
// Providers only created once per pageId
|
// Providers only created once per pageId
|
||||||
const providersRef = useRef<{
|
const providersRef = useRef<{
|
||||||
local: IndexeddbPersistence;
|
local: IndexeddbPersistence;
|
||||||
remote: HocuspocusProvider;
|
remote: HocuspocusProvider;
|
||||||
|
socket: HocuspocusProviderWebsocket;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [providersReady, setProvidersReady] = useState(false);
|
const [providersReady, setProvidersReady] = useState(false);
|
||||||
|
|
||||||
const localProvider = providersRef.current?.local;
|
|
||||||
const remoteProvider = providersRef.current?.remote;
|
|
||||||
|
|
||||||
// Track when collaborative provider is ready and synced
|
|
||||||
const [collabReady, setCollabReady] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
remoteProvider?.status === WebSocketStatus.Connected &&
|
|
||||||
isLocalSynced &&
|
|
||||||
isRemoteSynced
|
|
||||||
) {
|
|
||||||
setCollabReady(true);
|
|
||||||
}
|
|
||||||
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!providersRef.current) {
|
if (!providersRef.current) {
|
||||||
|
const documentName = `page.${pageId}`;
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||||
local.on("synced", () => setLocalSynced(true));
|
const socket = new HocuspocusProviderWebsocket({
|
||||||
const remote = new HocuspocusProvider({
|
|
||||||
name: documentName,
|
|
||||||
url: collaborationURL,
|
url: collaborationURL,
|
||||||
|
});
|
||||||
|
const onLocalSyncedHandler = () => {
|
||||||
|
setIsLocalSynced(true);
|
||||||
|
};
|
||||||
|
const onStatusHandler = (event: onStatusParameters) => {
|
||||||
|
setYjsConnectionStatus(event.status);
|
||||||
|
};
|
||||||
|
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||||
|
setIsRemoteSynced(event.state);
|
||||||
|
};
|
||||||
|
const onAuthenticationFailedHandler = () => {
|
||||||
|
const payload = jwtDecode(collabQuery?.token);
|
||||||
|
const now = Date.now().valueOf() / 1000;
|
||||||
|
const isTokenExpired = now >= payload.exp;
|
||||||
|
if (isTokenExpired) {
|
||||||
|
refetchCollabToken().then((result) => {
|
||||||
|
if (result.data?.token) {
|
||||||
|
socket.disconnect();
|
||||||
|
setTimeout(() => {
|
||||||
|
remote.configuration.token = result.data.token;
|
||||||
|
socket.connect();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const remote = new HocuspocusProvider({
|
||||||
|
websocketProvider: socket,
|
||||||
|
name: documentName,
|
||||||
document: ydoc,
|
document: ydoc,
|
||||||
token: collabQuery?.token,
|
token: collabQuery?.token,
|
||||||
connect: true,
|
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||||
preserveConnection: false,
|
onStatus: onStatusHandler,
|
||||||
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
|
onSynced: onSyncedHandler,
|
||||||
const payload = jwtDecode(collabQuery?.token);
|
|
||||||
const now = Date.now().valueOf() / 1000;
|
|
||||||
const isTokenExpired = now >= payload.exp;
|
|
||||||
if (isTokenExpired) {
|
|
||||||
refetchCollabToken().then((result) => {
|
|
||||||
if (result.data?.token) {
|
|
||||||
remote.disconnect();
|
|
||||||
setTimeout(() => {
|
|
||||||
remote.configuration.token = result.data.token;
|
|
||||||
remote.connect();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onStatus: (status) => {
|
|
||||||
if (status.status === "connected") {
|
|
||||||
setYjsConnectionStatus(status.status);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
remote.on("synced", () => setRemoteSynced(true));
|
|
||||||
remote.on("disconnect", () => {
|
local.on("synced", onLocalSyncedHandler);
|
||||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
providersRef.current = { socket, local, remote };
|
||||||
});
|
|
||||||
providersRef.current = { local, remote };
|
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
} else {
|
} else {
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
}
|
}
|
||||||
// Only destroy on final unmount
|
// Only destroy on final unmount
|
||||||
return () => {
|
return () => {
|
||||||
|
providersRef.current?.socket.destroy();
|
||||||
providersRef.current?.remote.destroy();
|
providersRef.current?.remote.destroy();
|
||||||
providersRef.current?.local.destroy();
|
providersRef.current?.local.destroy();
|
||||||
providersRef.current = null;
|
providersRef.current = null;
|
||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
/*
|
|
||||||
useEffect(() => {
|
|
||||||
// Handle token updates by reconnecting with new token
|
|
||||||
if (providersRef.current?.remote && collabQuery?.token) {
|
|
||||||
const currentToken = providersRef.current.remote.configuration.token;
|
|
||||||
if (currentToken !== collabQuery.token) {
|
|
||||||
// Token has changed, need to reconnect with new token
|
|
||||||
providersRef.current.remote.disconnect();
|
|
||||||
providersRef.current.remote.configuration.token = collabQuery.token;
|
|
||||||
providersRef.current.remote.connect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [collabQuery?.token]);
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Only connect/disconnect on tab/idle, not destroy
|
// Only connect/disconnect on tab/idle, not destroy
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!providersReady || !providersRef.current) return;
|
if (!providersReady || !providersRef.current) return;
|
||||||
const remoteProvider = providersRef.current.remote;
|
const socket = providersRef.current.socket;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isIdle &&
|
isIdle &&
|
||||||
documentState === "hidden" &&
|
documentState === "hidden" &&
|
||||||
remoteProvider.status === WebSocketStatus.Connected
|
yjsConnectionStatus === WebSocketStatus.Connected
|
||||||
) {
|
) {
|
||||||
remoteProvider.disconnect();
|
socket.disconnect();
|
||||||
setIsCollabReady(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
documentState === "visible" &&
|
documentState === "visible" &&
|
||||||
remoteProvider.status === WebSocketStatus.Disconnected
|
yjsConnectionStatus === WebSocketStatus.Disconnected
|
||||||
) {
|
) {
|
||||||
resetIdle();
|
resetIdle();
|
||||||
remoteProvider.connect();
|
socket.connect();
|
||||||
setTimeout(() => setIsCollabReady(true), 500);
|
|
||||||
}
|
}
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||||
|
|
||||||
|
// Attach here, to make sure the connection gets properly established
|
||||||
|
providersRef.current?.remote.attach();
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
if (!remoteProvider || !currentUser?.user) return mainExtensions;
|
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
||||||
|
return mainExtensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteProvider = providersRef.current.remote;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...mainExtensions,
|
...mainExtensions,
|
||||||
...collabExtensions(remoteProvider, currentUser?.user),
|
...collabExtensions(remoteProvider, currentUser?.user),
|
||||||
];
|
];
|
||||||
}, [remoteProvider, currentUser?.user]);
|
}, [providersReady, currentUser?.user]);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@@ -270,18 +254,30 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
handlePaste: (view, event, slice) =>
|
handlePaste: (_view, event) => {
|
||||||
handlePaste(view, event, pageId, currentUser?.user.id),
|
if (!editorRef.current) return false;
|
||||||
handleDrop: (view, event, _slice, moved) =>
|
|
||||||
handleFileDrop(view, event, moved, pageId),
|
return handlePaste(
|
||||||
|
editorRef.current,
|
||||||
|
event,
|
||||||
|
pageId,
|
||||||
|
currentUser?.user.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
handleDrop: (_view, event, _slice, moved) => {
|
||||||
|
if (!editorRef.current) return false;
|
||||||
|
|
||||||
|
return handleFileDrop(editorRef.current, event, moved, pageId);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onCreate({ editor }) {
|
onCreate({ editor }) {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setEditor(editor);
|
setEditor(editor);
|
||||||
|
// @ts-ignore
|
||||||
editor.storage.pageId = pageId;
|
editor.storage.pageId = pageId;
|
||||||
handleScrollTo(editor);
|
handleScrollTo(editor);
|
||||||
editorCreated.current = true;
|
editorRef.current = editor;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
@@ -291,7 +287,7 @@ export default function PageEditor({
|
|||||||
debouncedUpdateContent(editorJson);
|
debouncedUpdateContent(editorJson);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[pageId, editable, remoteProvider],
|
[pageId, editable, extensions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editorIsEditable = useEditorState({
|
const editorIsEditable = useEditorState({
|
||||||
@@ -347,30 +343,17 @@ export default function PageEditor({
|
|||||||
setAsideState({ tab: "", isAsideOpen: false });
|
setAsideState({ tab: "", isAsideOpen: false });
|
||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (remoteProvider?.status === WebSocketStatus.Connecting) {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
|
||||||
}, 5000);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}, [remoteProvider?.status]);
|
|
||||||
|
|
||||||
const isSynced = isLocalSynced && isRemoteSynced;
|
const isSynced = isLocalSynced && isRemoteSynced;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const collabReadyTimeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (
|
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
|
||||||
!isCollabReady &&
|
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||||
isSynced &&
|
|
||||||
remoteProvider?.status === WebSocketStatus.Connected
|
|
||||||
) {
|
|
||||||
setIsCollabReady(true);
|
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 7500);
|
||||||
return () => clearTimeout(collabReadyTimeout);
|
|
||||||
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
|
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [yjsConnectionStatus, isSynced]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only honor user default page edit mode preference and permissions
|
// Only honor user default page edit mode preference and permissions
|
||||||
if (editor) {
|
if (editor) {
|
||||||
@@ -392,12 +375,13 @@ export default function PageEditor({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!hasConnectedOnceRef.current &&
|
!hasConnectedOnceRef.current &&
|
||||||
remoteProvider?.status === WebSocketStatus.Connected
|
yjsConnectionStatus === WebSocketStatus.Connected &&
|
||||||
|
isSynced
|
||||||
) {
|
) {
|
||||||
hasConnectedOnceRef.current = true;
|
hasConnectedOnceRef.current = true;
|
||||||
setShowStatic(false);
|
setShowStatic(false);
|
||||||
}
|
}
|
||||||
}, [remoteProvider?.status]);
|
}, [yjsConnectionStatus, isSynced]);
|
||||||
|
|
||||||
if (showStatic) {
|
if (showStatic) {
|
||||||
return (
|
return (
|
||||||
@@ -433,13 +417,7 @@ export default function PageEditor({
|
|||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{editor && !editorIsEditable && (
|
|
||||||
<ReadOnlyBubbleMenu key="readonly-bubble" editor={editor} />
|
|
||||||
)}
|
|
||||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||||
{showReadOnlyCommentPopup && (
|
|
||||||
<ReadOnlyCommentDialog editor={editor} pageId={pageId} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={() => editor.commands.focus("end")}
|
onClick={() => editor.commands.focus("end")}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export default function ReadonlyPageEditor({
|
|||||||
onCreate={({ editor }) => {
|
onCreate={({ editor }) => {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
if (pageId) {
|
if (pageId) {
|
||||||
|
// @ts-ignore
|
||||||
editor.storage.pageId = pageId;
|
editor.storage.pageId = pageId;
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Give a remote user a caret */
|
/* Give a remote user a caret */
|
||||||
.collaboration-cursor__caret {
|
.collaboration-carets__caret {
|
||||||
border-left: 1px solid #0d0d0d;
|
border-left: 1px solid #0d0d0d;
|
||||||
border-right: 1px solid #0d0d0d;
|
border-right: 1px solid #0d0d0d;
|
||||||
margin-left: -1px;
|
margin-left: -1px;
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Render the username above the caret */
|
/* Render the username above the caret */
|
||||||
.collaboration-cursor__label {
|
.collaboration-carets__label {
|
||||||
border-radius: 3px 3px 3px 0;
|
border-radius: 3px 3px 3px 0;
|
||||||
color: #0d0d0d;
|
color: #0d0d0d;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Paginate from "@/components/common/paginate.tsx";
|
|||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { getSpaces } from "@/features/space/services/space-service.ts";
|
import { getSpaces } from "@/features/space/services/space-service.ts";
|
||||||
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
import { getGroupMembers } from "@/features/group/services/group-service.ts";
|
||||||
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||||
|
|
||||||
export default function GroupList() {
|
export default function GroupList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -51,9 +52,9 @@ export default function GroupList() {
|
|||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<IconGroupCircle />
|
<IconGroupCircle />
|
||||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||||
{group.name}
|
{group.name}
|
||||||
</Text>
|
</AutoTooltipText>
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{group.description}
|
{group.description}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) {
|
|||||||
mainEditorTitle
|
mainEditorTitle
|
||||||
.chain()
|
.chain()
|
||||||
.clearContent()
|
.clearContent()
|
||||||
.setContent(activeHistoryData.title, true)
|
.setContent(activeHistoryData.title, { emitUpdate: true })
|
||||||
.run();
|
.run();
|
||||||
mainEditor
|
mainEditor
|
||||||
.chain()
|
.chain()
|
||||||
|
|||||||
@@ -9,20 +9,14 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
IconSearch,
|
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
import {
|
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||||
getHotkeyHandler,
|
|
||||||
useClipboard,
|
|
||||||
useDisclosure,
|
|
||||||
useHotkeys,
|
|
||||||
} from "@mantine/hooks";
|
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@@ -38,8 +32,7 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
|
||||||
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
@@ -51,7 +44,6 @@ interface PageHeaderMenuProps {
|
|||||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toggleAside = useToggleAside();
|
const toggleAside = useToggleAside();
|
||||||
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
[
|
[
|
||||||
@@ -75,17 +67,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{yjsConnectionStatus === "disconnected" && (
|
<ConnectionWarning />
|
||||||
<Tooltip
|
|
||||||
label={t("Real-time editor connection lost. Retrying...")}
|
|
||||||
openDelay={250}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
|
|
||||||
<IconWifiOff size={20} stroke={2} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
{!readOnly && <PageStateSegmentedControl size="xs" />}
|
||||||
|
|
||||||
@@ -290,3 +272,49 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConnectionWarning() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
||||||
|
const [showWarning, setShowWarning] = useState(false);
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isDisconnected = ["disconnected", "connecting"].includes(yjsConnectionStatus);
|
||||||
|
|
||||||
|
if (isDisconnected) {
|
||||||
|
if (!timeoutRef.current) {
|
||||||
|
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setShowWarning(false);
|
||||||
|
}
|
||||||
|
}, [yjsConnectionStatus]);
|
||||||
|
|
||||||
|
// Cleanup only on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!showWarning) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label={t("Real-time editor connection lost. Retrying...")}
|
||||||
|
openDelay={250}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
|
||||||
|
<IconWifiOff size={20} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,9 +94,9 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
|||||||
spaceId,
|
spaceId,
|
||||||
});
|
});
|
||||||
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
||||||
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
|
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>(null);
|
||||||
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
const rootElement = useRef<HTMLDivElement>(null);
|
||||||
const [isRootReady, setIsRootReady] = useState(false);
|
const [isRootReady, setIsRootReady] = useState(false);
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
const mergedRef = useMergedRef((element) => {
|
const mergedRef = useMergedRef((element) => {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function SharedTree({ sharedPageTree }: SharedTree) {
|
|||||||
const [tree, setTree] = useState<
|
const [tree, setTree] = useState<
|
||||||
TreeApi<SharedPageTreeNode> | null | undefined
|
TreeApi<SharedPageTreeNode> | null | undefined
|
||||||
>(null);
|
>(null);
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
const rootElement = useRef<HTMLDivElement>(null);
|
||||||
const { ref: sizeRef, width, height } = useElementSize();
|
const { ref: sizeRef, width, height } = useElementSize();
|
||||||
const mergedRef = useMergedRef(rootElement, sizeRef);
|
const mergedRef = useMergedRef(rootElement, sizeRef);
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||||
|
|
||||||
export default function SpaceList() {
|
export default function SpaceList() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -49,9 +50,9 @@ export default function SpaceList() {
|
|||||||
name={space.name}
|
name={space.name}
|
||||||
/>
|
/>
|
||||||
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
<div style={{ minWidth: 0, overflow: "hidden" }}>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||||
{space.name}
|
{space.name}
|
||||||
</Text>
|
</AutoTooltipText>
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{space.description}
|
{space.description}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Paginate from "@/components/common/paginate.tsx";
|
import Paginate from "@/components/common/paginate.tsx";
|
||||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||||
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||||
|
|
||||||
type MemberType = "user" | "group";
|
type MemberType = "user" | "group";
|
||||||
|
|
||||||
@@ -138,10 +139,10 @@ export default function SpaceMembersList({
|
|||||||
|
|
||||||
{member.type === "group" && <IconGroupCircle />}
|
{member.type === "group" && <IconGroupCircle />}
|
||||||
|
|
||||||
<div>
|
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 260 }}>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<AutoTooltipText fz="sm" fw={500}>
|
||||||
{member?.name}
|
{member?.name}
|
||||||
</Text>
|
</AutoTooltipText>
|
||||||
<Text fz="xs" c="dimmed">
|
<Text fz="xs" c="dimmed">
|
||||||
{member.type == "user" && member?.email}
|
{member.type == "user" && member?.email}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import SpaceSettingsModal from "@/features/space/components/settings-modal";
|
|||||||
import classes from "./all-spaces-list.module.css";
|
import classes from "./all-spaces-list.module.css";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
|
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
|
||||||
|
|
||||||
interface AllSpacesListProps {
|
interface AllSpacesListProps {
|
||||||
spaces: any[];
|
spaces: any[];
|
||||||
@@ -96,10 +97,10 @@ export default function AllSpacesList({
|
|||||||
variant="filled"
|
variant="filled"
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 350 }}>
|
||||||
<Text fz="sm" fw={500} lineClamp={1}>
|
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
|
||||||
{space.name}
|
{space.name}
|
||||||
</Text>
|
</AutoTooltipText>
|
||||||
{space.description && (
|
{space.description && (
|
||||||
<Text fz="xs" c="dimmed" lineClamp={2}>
|
<Text fz="xs" c="dimmed" lineClamp={2}>
|
||||||
{space.description}
|
{space.description}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MantineColor } from "@mantine/core";
|
||||||
|
|
||||||
|
function hashCode(input: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
const char = input.charCodeAt(i);
|
||||||
|
hash = (hash << 5) - hash + char;
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultColors: MantineColor[] = [
|
||||||
|
"blue",
|
||||||
|
"cyan",
|
||||||
|
"grape",
|
||||||
|
"green",
|
||||||
|
"indigo",
|
||||||
|
"lime",
|
||||||
|
"orange",
|
||||||
|
"pink",
|
||||||
|
"red",
|
||||||
|
"teal",
|
||||||
|
"violet",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getInitialsColor(
|
||||||
|
name: string,
|
||||||
|
colors: MantineColor[] = defaultColors,
|
||||||
|
) {
|
||||||
|
const hash = hashCode(name);
|
||||||
|
const index = Math.abs(hash) % colors.length;
|
||||||
|
return colors[index];
|
||||||
|
}
|
||||||
@@ -74,11 +74,13 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"kysely": "^0.28.2",
|
"kysely": "^0.28.2",
|
||||||
"kysely-migration-cli": "^0.4.2",
|
"kysely-migration-cli": "^0.4.2",
|
||||||
|
"kysely-postgres-js": "^3.0.0",
|
||||||
"ldapts": "^7.4.0",
|
"ldapts": "^7.4.0",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"nestjs-kysely": "^1.2.0",
|
"nestjs-kysely": "^1.2.0",
|
||||||
|
"nestjs-pino": "^4.5.0",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"openid-client": "^5.7.1",
|
"openid-client": "^5.7.1",
|
||||||
"otpauth": "^9.4.1",
|
"otpauth": "^9.4.1",
|
||||||
@@ -86,9 +88,11 @@
|
|||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfjs-dist": "^5.4.394",
|
"pdfjs-dist": "^5.4.394",
|
||||||
"pg": "^8.16.3",
|
|
||||||
"pg-tsquery": "^8.4.2",
|
"pg-tsquery": "^8.4.2",
|
||||||
"pgvector": "^0.2.1",
|
"pgvector": "^0.2.1",
|
||||||
|
"postgres": "^3.4.8",
|
||||||
|
"pino-http": "^11.0.0",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"postmark": "^4.0.5",
|
"postmark": "^4.0.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -116,7 +120,6 @@
|
|||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/passport-google-oauth20": "^2.0.16",
|
"@types/passport-google-oauth20": "^2.0.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pg": "^8.11.11",
|
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"@types/yauzl": "^2.10.3",
|
"@types/yauzl": "^2.10.3",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { SecurityModule } from './integrations/security/security.module';
|
|||||||
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
|
||||||
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
||||||
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
import { RedisConfigService } from './integrations/redis/redis-config.service';
|
||||||
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
|
|
||||||
const enterpriseModules = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -35,6 +36,7 @@ try {
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
LoggerModule,
|
||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class CollaborationGateway {
|
|||||||
) {
|
) {
|
||||||
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
|
||||||
|
|
||||||
this.hocuspocus = HocuspocusServer.configure({
|
this.hocuspocus = new Hocuspocus({
|
||||||
debounce: 10000,
|
debounce: 10000,
|
||||||
maxDebounce: 45000,
|
maxDebounce: 45000,
|
||||||
unloadImmediately: false,
|
unloadImmediately: false,
|
||||||
@@ -65,10 +65,6 @@ export class CollaborationGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
await this.hocuspocus.destroy();
|
//await this.hocuspocus.destroy();
|
||||||
}
|
|
||||||
|
|
||||||
async openDirectConnection(documentName: string) {
|
|
||||||
return this.hocuspocus.openDirectConnection(documentName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import { StarterKit } from '@tiptap/starter-kit';
|
import { StarterKit } from '@tiptap/starter-kit';
|
||||||
import {
|
|
||||||
initProseMirrorDoc,
|
|
||||||
relativePositionToAbsolutePosition,
|
|
||||||
} from 'y-prosemirror';
|
|
||||||
import * as Y from 'yjs';
|
|
||||||
import { Document } from '@hocuspocus/server';
|
|
||||||
import { TextAlign } from '@tiptap/extension-text-align';
|
import { TextAlign } from '@tiptap/extension-text-align';
|
||||||
import { TaskList } from '@tiptap/extension-task-list';
|
|
||||||
import { TaskItem } from '@tiptap/extension-task-item';
|
|
||||||
import { Underline } from '@tiptap/extension-underline';
|
|
||||||
import { Superscript } from '@tiptap/extension-superscript';
|
import { Superscript } from '@tiptap/extension-superscript';
|
||||||
import SubScript from '@tiptap/extension-subscript';
|
import SubScript from '@tiptap/extension-subscript';
|
||||||
import { Typography } from '@tiptap/extension-typography';
|
import { Typography } from '@tiptap/extension-typography';
|
||||||
import { TextStyle } from '@tiptap/extension-text-style';
|
import { TextStyle } from '@tiptap/extension-text-style';
|
||||||
import { Color } from '@tiptap/extension-color';
|
import { Color } from '@tiptap/extension-color';
|
||||||
import { Youtube } from '@tiptap/extension-youtube';
|
import { Youtube } from '@tiptap/extension-youtube';
|
||||||
|
import { TaskList, TaskItem } from '@tiptap/extension-list';
|
||||||
import {
|
import {
|
||||||
Heading,
|
Heading,
|
||||||
Callout,
|
Callout,
|
||||||
@@ -48,11 +40,14 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
|||||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||||
|
//import { generateJSON } from '@tiptap/html';
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
|
link: false,
|
||||||
|
trailingNode: false,
|
||||||
heading: false,
|
heading: false,
|
||||||
}),
|
}),
|
||||||
Heading,
|
Heading,
|
||||||
@@ -65,7 +60,6 @@ export const tiptapExtensions = [
|
|||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
Underline,
|
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
Superscript,
|
Superscript,
|
||||||
SubScript,
|
SubScript,
|
||||||
@@ -122,169 +116,3 @@ export function jsonToNode(tiptapJson: JSONContent) {
|
|||||||
export function getPageId(documentName: string) {
|
export function getPageId(documentName: string) {
|
||||||
return documentName.split('.')[1];
|
return documentName.split('.')[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type YjsSelection = {
|
|
||||||
anchor: any;
|
|
||||||
head: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function setYjsMark(
|
|
||||||
doc: Document,
|
|
||||||
fragment: Y.XmlFragment,
|
|
||||||
yjsSelection: YjsSelection,
|
|
||||||
markName: string,
|
|
||||||
markAttributes: Record<string, any>,
|
|
||||||
) {
|
|
||||||
const schema = getSchema(tiptapExtensions);
|
|
||||||
const { mapping } = initProseMirrorDoc(fragment, schema);
|
|
||||||
|
|
||||||
// Convert JSON positions to Y.js RelativePosition objects
|
|
||||||
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
|
|
||||||
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
|
|
||||||
|
|
||||||
const anchor = relativePositionToAbsolutePosition(
|
|
||||||
doc,
|
|
||||||
fragment,
|
|
||||||
anchorRelPos,
|
|
||||||
mapping,
|
|
||||||
);
|
|
||||||
const head = relativePositionToAbsolutePosition(
|
|
||||||
doc,
|
|
||||||
fragment,
|
|
||||||
headRelPos,
|
|
||||||
mapping,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (anchor === null || head === null) {
|
|
||||||
throw new Error(
|
|
||||||
'Could not resolve Y.js relative positions to absolute positions',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const from = Math.min(anchor, head);
|
|
||||||
const to = Math.max(anchor, head);
|
|
||||||
|
|
||||||
// Apply mark directly to Y.js XmlText nodes
|
|
||||||
// This bypasses updateYFragment which has compatibility issues
|
|
||||||
applyMarkToYFragment(fragment, from, to, markName, markAttributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMarkToYFragment(
|
|
||||||
fragment: Y.XmlFragment,
|
|
||||||
from: number,
|
|
||||||
to: number,
|
|
||||||
markName: string,
|
|
||||||
markAttributes: Record<string, any>,
|
|
||||||
) {
|
|
||||||
let pos = 0;
|
|
||||||
|
|
||||||
const processItem = (item: any): boolean => {
|
|
||||||
if (pos >= to) return false;
|
|
||||||
|
|
||||||
if (item instanceof Y.XmlText) {
|
|
||||||
const textLength = item.length;
|
|
||||||
const itemEnd = pos + textLength;
|
|
||||||
|
|
||||||
if (itemEnd > from && pos < to) {
|
|
||||||
const formatFrom = Math.max(0, from - pos);
|
|
||||||
const formatTo = Math.min(textLength, to - pos);
|
|
||||||
const formatLength = formatTo - formatFrom;
|
|
||||||
|
|
||||||
if (formatLength > 0) {
|
|
||||||
item.format(formatFrom, formatLength, { [markName]: markAttributes });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pos = itemEnd;
|
|
||||||
} else if (item instanceof Y.XmlElement) {
|
|
||||||
pos++; // Opening tag
|
|
||||||
for (let i = 0; i < item.length; i++) {
|
|
||||||
if (!processItem(item.get(i))) return false;
|
|
||||||
}
|
|
||||||
pos++; // Closing tag
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < fragment.length; i++) {
|
|
||||||
if (!processItem(fragment.get(i))) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a mark from all text in the fragment that has the specified attribute value.
|
|
||||||
* Useful for deleting comments by commentId.
|
|
||||||
*/
|
|
||||||
export function removeYjsMarkByAttribute(
|
|
||||||
fragment: Y.XmlFragment,
|
|
||||||
markName: string,
|
|
||||||
attributeName: string,
|
|
||||||
attributeValue: string,
|
|
||||||
) {
|
|
||||||
const processItem = (item: any) => {
|
|
||||||
if (item instanceof Y.XmlText) {
|
|
||||||
// Get all formatting deltas to find ranges with this mark
|
|
||||||
const deltas = item.toDelta();
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
for (const delta of deltas) {
|
|
||||||
const length = delta.insert?.length ?? 0;
|
|
||||||
const attributes = delta.attributes ?? {};
|
|
||||||
const markAttr = attributes[markName];
|
|
||||||
|
|
||||||
if (markAttr && markAttr[attributeName] === attributeValue) {
|
|
||||||
// Remove the mark by setting it to null
|
|
||||||
item.format(offset, length, { [markName]: null });
|
|
||||||
}
|
|
||||||
offset += length;
|
|
||||||
}
|
|
||||||
} else if (item instanceof Y.XmlElement) {
|
|
||||||
for (let i = 0; i < item.length; i++) {
|
|
||||||
processItem(item.get(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < fragment.length; i++) {
|
|
||||||
processItem(fragment.get(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a mark's attributes for all text that has the specified attribute value.
|
|
||||||
* Useful for resolving/unresolving comments by commentId.
|
|
||||||
*/
|
|
||||||
export function updateYjsMarkAttribute(
|
|
||||||
fragment: Y.XmlFragment,
|
|
||||||
markName: string,
|
|
||||||
findByAttribute: { name: string; value: string },
|
|
||||||
newAttributes: Record<string, any>,
|
|
||||||
) {
|
|
||||||
const processItem = (item: any) => {
|
|
||||||
if (item instanceof Y.XmlText) {
|
|
||||||
const deltas = item.toDelta();
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
for (const delta of deltas) {
|
|
||||||
const length = delta.insert?.length ?? 0;
|
|
||||||
const attributes = delta.attributes ?? {};
|
|
||||||
const markAttr = attributes[markName];
|
|
||||||
|
|
||||||
if (markAttr && markAttr[findByAttribute.name] === findByAttribute.value) {
|
|
||||||
// Update the mark with new attributes (merge with existing)
|
|
||||||
item.format(offset, length, {
|
|
||||||
[markName]: { ...markAttr, ...newAttributes },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
offset += length;
|
|
||||||
}
|
|
||||||
} else if (item instanceof Y.XmlElement) {
|
|
||||||
for (let i = 0; i < item.length; i++) {
|
|
||||||
processItem(item.get(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < fragment.length; i++) {
|
|
||||||
processItem(fragment.get(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (userSpaceRole === SpaceRole.READER) {
|
if (userSpaceRole === SpaceRole.READER) {
|
||||||
data.connection.readOnly = true;
|
data.connectionConfig.readOnly = true;
|
||||||
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
content: tiptapJson,
|
content: tiptapJson,
|
||||||
textContent: textContent,
|
textContent: textContent,
|
||||||
ydoc: ydocState,
|
ydoc: ydocState,
|
||||||
lastUpdatedById: context?.user?.id,
|
lastUpdatedById: context.user.id,
|
||||||
contributorIds: contributorIds,
|
contributorIds: contributorIds,
|
||||||
},
|
},
|
||||||
pageId,
|
pageId,
|
||||||
@@ -157,7 +157,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
page: {
|
page: {
|
||||||
...page,
|
...page,
|
||||||
content: tiptapJson,
|
content: tiptapJson,
|
||||||
lastUpdatedById: context?.user?.id,
|
lastUpdatedById: context.user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export class PersistenceExtension implements Extension {
|
|||||||
|
|
||||||
async onChange(data: onChangePayload) {
|
async onChange(data: onChangePayload) {
|
||||||
const documentName = data.documentName;
|
const documentName = data.documentName;
|
||||||
const userId = data.context?.user?.id;
|
const userId = data.context?.user.id;
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
if (!this.contributors.has(documentName)) {
|
if (!this.contributors.has(documentName)) {
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { QueueModule } from '../../integrations/queue/queue.module';
|
|||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { HealthModule } from '../../integrations/health/health.module';
|
import { HealthModule } from '../../integrations/health/health.module';
|
||||||
import { CollaborationController } from './collaboration.controller';
|
import { CollaborationController } from './collaboration.controller';
|
||||||
|
import { LoggerModule } from '../../common/logger/logger.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
LoggerModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
CollaborationModule,
|
CollaborationModule,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
NestFastifyApplication,
|
NestFastifyApplication,
|
||||||
} from '@nestjs/platform-fastify';
|
} from '@nestjs/platform-fastify';
|
||||||
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
|
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
|
||||||
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
|
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Logger as PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
@@ -17,10 +17,12 @@ async function bootstrap() {
|
|||||||
maxParamLength: 500,
|
maxParamLength: 500,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
logger: new InternalLogFilter(),
|
bufferLogs: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.useLogger(app.get(PinoLogger));
|
||||||
|
|
||||||
app.setGlobalPrefix('api', { exclude: ['/'] });
|
app.setGlobalPrefix('api', { exclude: ['/'] });
|
||||||
|
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// https://github.com/WebReflection/html-escaper
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2017-present by Andrea Giammarchi - @WebReflection
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { replace } = '';
|
||||||
|
|
||||||
|
// escape
|
||||||
|
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g;
|
||||||
|
const ca = /[&<>'"]/g;
|
||||||
|
|
||||||
|
const esca = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
"'": ''',
|
||||||
|
'"': '"',
|
||||||
|
};
|
||||||
|
const pe = (m) => esca[m];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
|
||||||
|
* @param {string} es the input to safely escape
|
||||||
|
* @returns {string} the escaped input, and it **throws** an error if
|
||||||
|
* the input type is unexpected, except for boolean and numbers,
|
||||||
|
* converted as string.
|
||||||
|
*/
|
||||||
|
export const htmlEscape = (es) => replace.call(es, ca, pe);
|
||||||
|
|
||||||
|
// unescape
|
||||||
|
const unes = {
|
||||||
|
'&': '&',
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'>': '>',
|
||||||
|
''': "'",
|
||||||
|
''': "'",
|
||||||
|
'"': '"',
|
||||||
|
'"': '"',
|
||||||
|
};
|
||||||
|
const cape = (m) => unes[m];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely unescape previously escaped entities such as `&`, `<`, `>`, `"`,
|
||||||
|
* and `'`.
|
||||||
|
* @param {string} un a previously escaped string
|
||||||
|
* @returns {string} the unescaped input, and it **throws** an error if
|
||||||
|
* the input type is unexpected, except for boolean and numbers,
|
||||||
|
* converted as string.
|
||||||
|
*/
|
||||||
|
export const htmlUnescape = (un) => replace.call(un, es, cape);
|
||||||
@@ -2,6 +2,7 @@ import * as path from 'path';
|
|||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { sanitize } from 'sanitize-filename-ts';
|
import { sanitize } from 'sanitize-filename-ts';
|
||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
|
import { Readable, Transform } from 'stream';
|
||||||
|
|
||||||
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
|
||||||
|
|
||||||
@@ -98,3 +99,38 @@ export function hasLicenseOrEE(opts: {
|
|||||||
const { licenseKey, plan, isCloud } = opts;
|
const { licenseKey, plan, isCloud } = opts;
|
||||||
return Boolean(licenseKey) || (isCloud && plan === 'business');
|
return Boolean(licenseKey) || (isCloud && plan === 'business');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a database URL for postgres.js compatibility.
|
||||||
|
* - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values
|
||||||
|
* - Removes `schema` parameter (has no effect via connection string)
|
||||||
|
* Note: If we don't strip them, the connection will fail
|
||||||
|
*/
|
||||||
|
export function normalizePostgresUrl(url: string): string {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const newParams = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of parsed.searchParams) {
|
||||||
|
if (key === 'sslmode' && value === 'no-verify') continue;
|
||||||
|
if (key === 'schema') continue;
|
||||||
|
newParams.append(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.search = newParams.toString();
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createByteCountingStream(source: Readable) {
|
||||||
|
let bytesRead = 0;
|
||||||
|
const stream = new Transform({
|
||||||
|
transform(chunk, encoding, callback) {
|
||||||
|
bytesRead += chunk.length;
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
source.pipe(stream);
|
||||||
|
source.on('error', (err) => stream.emit('error', err));
|
||||||
|
|
||||||
|
return { stream, getBytesRead: () => bytesRead };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LoggerModule as PinoLoggerModule } from 'nestjs-pino';
|
||||||
|
import { createPinoConfig } from './pino.config';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PinoLoggerModule.forRoot(createPinoConfig())],
|
||||||
|
exports: [PinoLoggerModule],
|
||||||
|
})
|
||||||
|
export class LoggerModule {}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Params } from 'nestjs-pino';
|
||||||
|
import { stdTimeFunctions } from 'pino';
|
||||||
|
|
||||||
|
const CONTEXTS_TO_IGNORE = [
|
||||||
|
'InstanceLoader',
|
||||||
|
'RoutesResolver',
|
||||||
|
'RouterExplorer',
|
||||||
|
'WebSocketsController',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createPinoConfig(): Params {
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
||||||
|
const logHttp = process.env.LOG_HTTP === 'true';
|
||||||
|
|
||||||
|
const level = isProduction && !isDebugMode ? 'info' : 'debug';
|
||||||
|
|
||||||
|
return {
|
||||||
|
pinoHttp: {
|
||||||
|
level,
|
||||||
|
timestamp: stdTimeFunctions.isoTime,
|
||||||
|
transport: !isProduction
|
||||||
|
? {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
singleLine: true,
|
||||||
|
translateTime: 'SYS:standard',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
formatters: {
|
||||||
|
level: (label) => ({ level: label }),
|
||||||
|
log: (object: Record<string, unknown>) => {
|
||||||
|
if (isProduction && !isDebugMode) {
|
||||||
|
const context = object['context'] as string | undefined;
|
||||||
|
if (context && CONTEXTS_TO_IGNORE.includes(context)) {
|
||||||
|
return { filtered: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return object;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serializers: {
|
||||||
|
req: (req) => {
|
||||||
|
const forwardedFor = req.headers?.['x-forwarded-for'];
|
||||||
|
const ip =
|
||||||
|
req.headers?.['cf-connecting-ip'] ||
|
||||||
|
(typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() : undefined) ||
|
||||||
|
req.remoteAddress;
|
||||||
|
|
||||||
|
return {
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
ip,
|
||||||
|
userAgent: req.headers?.['user-agent'],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
res: (res) => ({
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
customLogLevel: (_req, res, err) => {
|
||||||
|
if (res.statusCode >= 500 || err) return 'error';
|
||||||
|
if (res.statusCode >= 400) return 'warn';
|
||||||
|
return 'info';
|
||||||
|
},
|
||||||
|
autoLogging: logHttp
|
||||||
|
? {
|
||||||
|
ignore: (req) =>
|
||||||
|
req.url === '/api/health' || req.url === '/api/health/live',
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,15 +5,17 @@ import { sanitizeFileName } from '../../common/helpers';
|
|||||||
import * as sharp from 'sharp';
|
import * as sharp from 'sharp';
|
||||||
|
|
||||||
export interface PreparedFile {
|
export interface PreparedFile {
|
||||||
buffer: Buffer;
|
buffer?: Buffer;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
fileExtension: string;
|
fileExtension: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
multiPartFile?: MultipartFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prepareFile(
|
export async function prepareFile(
|
||||||
filePromise: Promise<MultipartFile>,
|
filePromise: Promise<MultipartFile>,
|
||||||
|
options: { skipBuffer?: boolean } = {},
|
||||||
): Promise<PreparedFile> {
|
): Promise<PreparedFile> {
|
||||||
const file = await filePromise;
|
const file = await filePromise;
|
||||||
|
|
||||||
@@ -22,10 +24,16 @@ export async function prepareFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await file.toBuffer();
|
let buffer: Buffer | undefined;
|
||||||
|
let fileSize = 0;
|
||||||
|
|
||||||
|
if (!options.skipBuffer) {
|
||||||
|
buffer = await file.toBuffer();
|
||||||
|
fileSize = buffer.length;
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizedFilename = sanitizeFileName(file.filename);
|
const sanitizedFilename = sanitizeFileName(file.filename);
|
||||||
const fileName = sanitizedFilename.slice(0, 255);
|
const fileName = sanitizedFilename.slice(0, 255);
|
||||||
const fileSize = buffer.length;
|
|
||||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -34,6 +42,7 @@ export async function prepareFile(
|
|||||||
fileSize,
|
fileSize,
|
||||||
fileExtension,
|
fileExtension,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
|
multiPartFile: file,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Readable } from 'stream';
|
||||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||||
import { MultipartFile } from '@fastify/multipart';
|
import { MultipartFile } from '@fastify/multipart';
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +27,7 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
|
import { createByteCountingStream } from '../../../common/helpers/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AttachmentService {
|
export class AttachmentService {
|
||||||
@@ -49,7 +51,9 @@ export class AttachmentService {
|
|||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
|
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
|
||||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
const preparedFile: PreparedFile = await prepareFile(filePromise, {
|
||||||
|
skipBuffer: true,
|
||||||
|
});
|
||||||
|
|
||||||
let isUpdate = false;
|
let isUpdate = false;
|
||||||
let attachmentId = null;
|
let attachmentId = null;
|
||||||
@@ -81,7 +85,14 @@ export class AttachmentService {
|
|||||||
|
|
||||||
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
|
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
|
||||||
|
|
||||||
await this.uploadToDrive(filePath, preparedFile.buffer);
|
const { stream, getBytesRead } = createByteCountingStream(
|
||||||
|
preparedFile.multiPartFile.file,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.uploadToDrive(filePath, stream);
|
||||||
|
|
||||||
|
// Update fileSize from the consumed stream
|
||||||
|
preparedFile.fileSize = getBytesRead();
|
||||||
|
|
||||||
let attachment: Attachment = null;
|
let attachment: Attachment = null;
|
||||||
try {
|
try {
|
||||||
@@ -142,7 +153,10 @@ export class AttachmentService {
|
|||||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||||
validateFileType(preparedFile.fileExtension, validImageExtensions);
|
validateFileType(preparedFile.fileExtension, validImageExtensions);
|
||||||
|
|
||||||
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
|
const processedBuffer = await compressAndResizeIcon(
|
||||||
|
preparedFile.buffer,
|
||||||
|
type,
|
||||||
|
);
|
||||||
preparedFile.buffer = processedBuffer;
|
preparedFile.buffer = processedBuffer;
|
||||||
preparedFile.fileSize = processedBuffer.length;
|
preparedFile.fileSize = processedBuffer.length;
|
||||||
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
||||||
@@ -232,9 +246,9 @@ export class AttachmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadToDrive(filePath: string, fileBuffer: any) {
|
async uploadToDrive(filePath: string, fileContent: Buffer | Readable) {
|
||||||
try {
|
try {
|
||||||
await this.storageService.upload(filePath, fileBuffer);
|
await this.storageService.upload(filePath, fileContent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Error uploading file to drive:', err);
|
this.logger.error('Error uploading file to drive:', err);
|
||||||
throw new BadRequestException('Error uploading file to drive');
|
throw new BadRequestException('Error uploading file to drive');
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CommentService } from './comment.service';
|
import { CommentService } from './comment.service';
|
||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
import { CreateReadOnlyCommentDto } from './dto/create-readonly-comment.dto';
|
|
||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
@@ -63,28 +62,6 @@ export class CommentController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('create-readonly')
|
|
||||||
async createReadOnly(
|
|
||||||
@Body() createCommentDto: CreateReadOnlyCommentDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const page = await this.pageRepo.findById(createCommentDto.pageId);
|
|
||||||
if (!page || page.deletedAt) {
|
|
||||||
throw new NotFoundException('Page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.commentService.createReadOnlyComment(
|
|
||||||
{
|
|
||||||
userId: user.id,
|
|
||||||
page,
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
},
|
|
||||||
createCommentDto,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/')
|
@Post('/')
|
||||||
async findPageComments(
|
async findPageComments(
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CommentService } from './comment.service';
|
import { CommentService } from './comment.service';
|
||||||
import { CommentController } from './comment.controller';
|
import { CommentController } from './comment.controller';
|
||||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CollaborationModule],
|
imports: [],
|
||||||
controllers: [CommentController],
|
controllers: [CommentController],
|
||||||
providers: [CommentService],
|
providers: [CommentService],
|
||||||
exports: [CommentService],
|
exports: [CommentService],
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
import { CreateReadOnlyCommentDto } from './dto/create-readonly-comment.dto';
|
|
||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||||
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||||
@@ -14,19 +12,13 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
|||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { CollaborationGateway } from '../../collaboration/collaboration.gateway';
|
|
||||||
import { setYjsMark } from '../../collaboration/collaboration.util';
|
|
||||||
import * as Y from 'yjs';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
private readonly logger = new Logger(CommentService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private commentRepo: CommentRepo,
|
private commentRepo: CommentRepo,
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
private spaceMemberRepo: SpaceMemberRepo,
|
private spaceMemberRepo: SpaceMemberRepo,
|
||||||
private collaborationGateway: CollaborationGateway,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(commentId: string) {
|
async findById(commentId: string) {
|
||||||
@@ -113,49 +105,4 @@ export class CommentService {
|
|||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createReadOnlyComment(
|
|
||||||
opts: { userId: string; page: Page; workspaceId: string },
|
|
||||||
createCommentDto: CreateReadOnlyCommentDto,
|
|
||||||
): Promise<Comment> {
|
|
||||||
const { userId, page, workspaceId } = opts;
|
|
||||||
const commentContent = JSON.parse(createCommentDto.content);
|
|
||||||
|
|
||||||
const comment = await this.commentRepo.insertComment({
|
|
||||||
pageId: page.id,
|
|
||||||
content: commentContent,
|
|
||||||
selection: createCommentDto?.selection?.substring(0, 250),
|
|
||||||
type: 'inline',
|
|
||||||
creatorId: userId,
|
|
||||||
workspaceId: workspaceId,
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentName = `page.${page.id}`;
|
|
||||||
const directConnection =
|
|
||||||
await this.collaborationGateway.openDirectConnection(documentName);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await directConnection.transact((doc) => {
|
|
||||||
const fragment = doc.getXmlFragment('default');
|
|
||||||
setYjsMark(doc, fragment, createCommentDto.yjsSelection, 'comment', {
|
|
||||||
commentId: comment.id,
|
|
||||||
resolved: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to apply comment mark for comment ${comment.id}`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
await this.commentRepo.deleteComment(comment.id);
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Failed to apply comment mark. Selection may have changed.',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await directConnection.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return comment;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { IsJSON, IsObject, IsOptional, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateReadOnlyCommentDto {
|
|
||||||
@IsString()
|
|
||||||
pageId: string;
|
|
||||||
|
|
||||||
@IsJSON()
|
|
||||||
content: any;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
selection: string;
|
|
||||||
|
|
||||||
@IsObject()
|
|
||||||
yjsSelection: {
|
|
||||||
anchor: any;
|
|
||||||
head: any;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import { validate as isValidUUID } from 'uuid';
|
|||||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
import { Workspace } from '@docmost/db/types/entity.types';
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { htmlEscape } from '../../common/helpers/html-escaper';
|
||||||
|
|
||||||
@Controller('share')
|
@Controller('share')
|
||||||
export class ShareSeoController {
|
export class ShareSeoController {
|
||||||
@@ -68,7 +69,7 @@ export class ShareSeoController {
|
|||||||
return this.sendIndex(indexFilePath, res);
|
return this.sendIndex(indexFilePath, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawTitle = share.sharedPage.title ?? 'untitled';
|
const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled');
|
||||||
const metaTitle =
|
const metaTitle =
|
||||||
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle;
|
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle;
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
|
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
|
||||||
import { EnvironmentService } from '../integrations/environment/environment.service';
|
import { EnvironmentService } from '../integrations/environment/environment.service';
|
||||||
import { CamelCasePlugin, LogEvent, PostgresDialect, sql } from 'kysely';
|
import { CamelCasePlugin, LogEvent, sql } from 'kysely';
|
||||||
import { Pool, types } from 'pg';
|
|
||||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
@@ -26,9 +25,9 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
|||||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||||
|
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||||
// https://github.com/brianc/node-postgres/issues/811
|
import * as postgres from 'postgres';
|
||||||
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
import { normalizePostgresUrl } from '../common/helpers';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -37,26 +36,30 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
imports: [],
|
imports: [],
|
||||||
inject: [EnvironmentService],
|
inject: [EnvironmentService],
|
||||||
useFactory: (environmentService: EnvironmentService) => ({
|
useFactory: (environmentService: EnvironmentService) => ({
|
||||||
dialect: new PostgresDialect({
|
dialect: new PostgresJSDialect({
|
||||||
pool: new Pool({
|
postgres: postgres(
|
||||||
connectionString: environmentService.getDatabaseURL(),
|
normalizePostgresUrl(environmentService.getDatabaseURL()),
|
||||||
max: environmentService.getDatabaseMaxPool(),
|
{
|
||||||
}).on('error', (err) => {
|
max: environmentService.getDatabaseMaxPool(),
|
||||||
console.error('Database error:', err.message);
|
onnotice: () => {},
|
||||||
}),
|
types: {
|
||||||
|
bigint: {
|
||||||
|
to: 20,
|
||||||
|
from: [20, 1700],
|
||||||
|
serialize: (value: number) => value.toString(),
|
||||||
|
parse: (value: string) => Number.parseInt(value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
plugins: [new CamelCasePlugin()],
|
plugins: [new CamelCasePlugin()],
|
||||||
log: (event: LogEvent) => {
|
log: (event: LogEvent) => {
|
||||||
if (environmentService.getNodeEnv() !== 'development') return;
|
if (environmentService.getNodeEnv() !== 'development') return;
|
||||||
const logger = new Logger(DatabaseModule.name);
|
const logger = new Logger(DatabaseModule.name);
|
||||||
if (event.level) {
|
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
|
||||||
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
|
logger.debug(event.query.sql);
|
||||||
logger.debug(event.query.sql);
|
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
|
||||||
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
|
|
||||||
//if (event.query.parameters.length > 0) {
|
|
||||||
// logger.debug('parameters: ' + event.query.parameters);
|
|
||||||
//}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,25 +1,19 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import pg from 'pg';
|
import { Kysely, Migrator, FileMigrationProvider } from 'kysely';
|
||||||
import {
|
|
||||||
Kysely,
|
|
||||||
Migrator,
|
|
||||||
PostgresDialect,
|
|
||||||
FileMigrationProvider,
|
|
||||||
} from 'kysely';
|
|
||||||
import { run } from 'kysely-migration-cli';
|
import { run } from 'kysely-migration-cli';
|
||||||
import * as dotenv from 'dotenv';
|
import * as dotenv from 'dotenv';
|
||||||
import { envPath } from '../common/helpers/utils';
|
import { envPath, normalizePostgresUrl } from '../common/helpers';
|
||||||
|
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
dotenv.config({ path: envPath });
|
dotenv.config({ path: envPath });
|
||||||
|
|
||||||
const migrationFolder = path.join(__dirname, './migrations');
|
const migrationFolder = path.join(__dirname, './migrations');
|
||||||
|
|
||||||
const db = new Kysely<any>({
|
const db = new Kysely<any>({
|
||||||
dialect: new PostgresDialect({
|
dialect: new PostgresJSDialect({
|
||||||
pool: new pg.Pool({
|
postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL)),
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
}) as any,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 1d1ab6cf81...b6844b019c
@@ -10,7 +10,11 @@ import {
|
|||||||
} from '../../../collaboration/collaboration.util';
|
} from '../../../collaboration/collaboration.util';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { generateSlugId, sanitizeFileName } from '../../../common/helpers';
|
import {
|
||||||
|
generateSlugId,
|
||||||
|
sanitizeFileName,
|
||||||
|
createByteCountingStream,
|
||||||
|
} from '../../../common/helpers';
|
||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
@@ -173,15 +177,24 @@ export class ImportService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNewPagePosition(spaceId: string): Promise<string> {
|
async getNewPagePosition(
|
||||||
const lastPage = await this.db
|
spaceId: string,
|
||||||
|
parentPageId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
let query = this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(['id', 'position'])
|
.select(['id', 'position'])
|
||||||
.where('spaceId', '=', spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
.orderBy('position', (ob) => ob.collate('C').desc())
|
.orderBy('position', (ob) => ob.collate('C').desc())
|
||||||
.limit(1)
|
.limit(1);
|
||||||
.where('parentPageId', 'is', null)
|
|
||||||
.executeTakeFirst();
|
if (parentPageId) {
|
||||||
|
query = query.where('parentPageId', '=', parentPageId);
|
||||||
|
} else {
|
||||||
|
query = query.where('parentPageId', 'is', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastPage = await query.executeTakeFirst();
|
||||||
|
|
||||||
if (lastPage) {
|
if (lastPage) {
|
||||||
return generateJitteredKeyBetween(lastPage.position, null);
|
return generateJitteredKeyBetween(lastPage.position, null);
|
||||||
@@ -198,20 +211,21 @@ export class ImportService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
) {
|
) {
|
||||||
const file = await filePromise;
|
const file = await filePromise;
|
||||||
const fileBuffer = await file.toBuffer();
|
|
||||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||||
const fileName = sanitizeFileName(
|
const fileName = sanitizeFileName(
|
||||||
path.basename(file.filename, fileExtension),
|
path.basename(file.filename, fileExtension),
|
||||||
);
|
);
|
||||||
const fileSize = fileBuffer.length;
|
|
||||||
|
|
||||||
const fileNameWithExt = fileName + fileExtension;
|
const fileNameWithExt = fileName + fileExtension;
|
||||||
|
|
||||||
const fileTaskId = uuid7();
|
const fileTaskId = uuid7();
|
||||||
const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`;
|
const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`;
|
||||||
|
|
||||||
// upload file
|
// upload file
|
||||||
await this.storageService.upload(filePath, fileBuffer);
|
const { stream, getBytesRead } = createByteCountingStream(file.file);
|
||||||
|
|
||||||
|
await this.storageService.upload(filePath, stream);
|
||||||
|
|
||||||
|
const fileSize = getBytesRead();
|
||||||
|
|
||||||
const fileTask = await this.db
|
const fileTask = await this.db
|
||||||
.insertInto('fileTasks')
|
.insertInto('fileTasks')
|
||||||
|
|||||||
@@ -20,9 +20,15 @@ export class LocalDriver implements StorageDriver {
|
|||||||
return join(this.config.storagePath, filePath);
|
return join(this.config.storagePath, filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload(filePath: string, file: Buffer): Promise<void> {
|
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fs.outputFile(this._fullPath(filePath), file);
|
const fullPath = this._fullPath(filePath);
|
||||||
|
if (file instanceof Buffer) {
|
||||||
|
await fs.outputFile(fullPath, file);
|
||||||
|
} else {
|
||||||
|
await fs.mkdir(dirname(fullPath), { recursive: true });
|
||||||
|
await pipeline(file, createWriteStream(fullPath));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -42,7 +48,7 @@ export class LocalDriver implements StorageDriver {
|
|||||||
try {
|
try {
|
||||||
const fromFullPath = this._fullPath(fromFilePath);
|
const fromFullPath = this._fullPath(fromFilePath);
|
||||||
const toFullPath = this._fullPath(toFilePath);
|
const toFullPath = this._fullPath(toFilePath);
|
||||||
|
|
||||||
if (await this.exists(fromFilePath)) {
|
if (await this.exists(fromFilePath)) {
|
||||||
await fs.copy(fromFullPath, toFullPath);
|
await fs.copy(fromFullPath, toFullPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,19 +23,21 @@ export class S3Driver implements StorageDriver {
|
|||||||
this.s3Client = new S3Client(config as any);
|
this.s3Client = new S3Client(config as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload(filePath: string, file: Buffer): Promise<void> {
|
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contentType = getMimeType(filePath);
|
const contentType = getMimeType(filePath);
|
||||||
|
|
||||||
const command = new PutObjectCommand({
|
const upload = new Upload({
|
||||||
Bucket: this.config.bucket,
|
client: this.s3Client,
|
||||||
Key: filePath,
|
params: {
|
||||||
Body: file,
|
Bucket: this.config.bucket,
|
||||||
ContentType: contentType,
|
Key: filePath,
|
||||||
// ACL: "public-read",
|
Body: file,
|
||||||
|
ContentType: contentType,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.s3Client.send(command);
|
await upload.done();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
throw new Error(`Failed to upload file: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
export interface StorageDriver {
|
export interface StorageDriver {
|
||||||
upload(filePath: string, file: Buffer): Promise<void>;
|
upload(filePath: string, file: Buffer | Readable): Promise<void>;
|
||||||
|
|
||||||
uploadStream(filePath: string, file: Readable, options?: { recreateClient?: boolean }): Promise<void>;
|
uploadStream(filePath: string, file: Readable, options?: { recreateClient?: boolean }): Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export class StorageService {
|
|||||||
private readonly logger = new Logger(StorageService.name);
|
private readonly logger = new Logger(StorageService.name);
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver,
|
@Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async upload(filePath: string, fileContent: Buffer | any) {
|
async upload(filePath: string, fileContent: Buffer | Readable) {
|
||||||
await this.storageDriver.upload(filePath, fileContent);
|
await this.storageDriver.upload(filePath, fileContent);
|
||||||
this.logger.debug(`File uploaded successfully. Path: ${filePath}`);
|
this.logger.debug(`File uploaded successfully. Path: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
NestFastifyApplication,
|
NestFastifyApplication,
|
||||||
} from '@nestjs/platform-fastify';
|
} from '@nestjs/platform-fastify';
|
||||||
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
|
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { Logger as PinoLogger } from 'nestjs-pino';
|
||||||
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
|
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
|
||||||
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
||||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
|
||||||
import fastifyMultipart from '@fastify/multipart';
|
import fastifyMultipart from '@fastify/multipart';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
|
||||||
@@ -24,10 +24,12 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
rawBody: true,
|
rawBody: true,
|
||||||
logger: new InternalLogFilter(),
|
bufferLogs: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.useLogger(app.get(PinoLogger));
|
||||||
|
|
||||||
app.setGlobalPrefix('api', {
|
app.setGlobalPrefix('api', {
|
||||||
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'],
|
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'],
|
||||||
});
|
});
|
||||||
@@ -99,9 +101,7 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
await app.listen(port, '0.0.0.0', () => {
|
await app.listen(port, '0.0.0.0', () => {
|
||||||
logger.log(
|
logger.log(`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`);
|
||||||
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-41
@@ -15,60 +15,56 @@
|
|||||||
"server:dev": "nx run server:start:dev",
|
"server:dev": "nx run server:start:dev",
|
||||||
"server:start": "nx run server:start:prod",
|
"server:start": "nx run server:start:prod",
|
||||||
"email:dev": "nx run server:email:dev",
|
"email:dev": "nx run server:email:dev",
|
||||||
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\""
|
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\"",
|
||||||
|
"clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "^7.1.0",
|
"@braintree/sanitize-url": "^7.1.0",
|
||||||
"@casl/ability": "^6.7.5",
|
"@casl/ability": "6.8.0",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.7.3",
|
"@floating-ui/dom": "^1.7.3",
|
||||||
"@hocuspocus/extension-redis": "^2.15.3",
|
"@hocuspocus/extension-redis": "3.4.3",
|
||||||
"@hocuspocus/provider": "^2.15.3",
|
"@hocuspocus/provider": "3.4.3",
|
||||||
"@hocuspocus/server": "^2.15.3",
|
"@hocuspocus/server": "3.4.3",
|
||||||
"@hocuspocus/transformer": "^2.15.3",
|
"@hocuspocus/transformer": "3.4.3",
|
||||||
"@joplin/turndown": "^4.0.74",
|
"@joplin/turndown": "^4.0.74",
|
||||||
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
||||||
"@sindresorhus/slugify": "1.1.0",
|
"@sindresorhus/slugify": "1.1.0",
|
||||||
"@tiptap/core": "2.27.1",
|
"@tiptap/core": "3.17.0",
|
||||||
"@tiptap/extension-code-block": "2.27.1",
|
"@tiptap/extension-code-block": "3.17.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "2.27.1",
|
"@tiptap/extension-collaboration": "3.17.0",
|
||||||
"@tiptap/extension-collaboration": "2.27.1",
|
"@tiptap/extension-collaboration-caret": "3.17.0",
|
||||||
"@tiptap/extension-collaboration-cursor": "2.27.1",
|
"@tiptap/extension-color": "3.17.0",
|
||||||
"@tiptap/extension-color": "2.27.1",
|
"@tiptap/extension-document": "3.17.0",
|
||||||
"@tiptap/extension-document": "2.27.1",
|
"@tiptap/extension-heading": "3.17.0",
|
||||||
"@tiptap/extension-heading": "2.27.1",
|
"@tiptap/extension-highlight": "3.17.0",
|
||||||
"@tiptap/extension-highlight": "2.27.1",
|
"@tiptap/extension-history": "3.17.0",
|
||||||
"@tiptap/extension-history": "2.27.1",
|
"@tiptap/extension-image": "3.17.0",
|
||||||
"@tiptap/extension-image": "2.27.1",
|
"@tiptap/extension-link": "3.17.0",
|
||||||
"@tiptap/extension-link": "2.27.1",
|
"@tiptap/extension-list": "3.17.0",
|
||||||
"@tiptap/extension-list-item": "2.27.1",
|
"@tiptap/extension-placeholder": "3.17.0",
|
||||||
"@tiptap/extension-list-keymap": "2.27.1",
|
"@tiptap/extension-subscript": "3.17.0",
|
||||||
"@tiptap/extension-placeholder": "2.27.1",
|
"@tiptap/extension-superscript": "3.17.0",
|
||||||
"@tiptap/extension-subscript": "2.27.1",
|
"@tiptap/extension-table": "3.17.0",
|
||||||
"@tiptap/extension-superscript": "2.27.1",
|
"@tiptap/extension-text": "3.17.0",
|
||||||
"@tiptap/extension-table": "2.27.1",
|
"@tiptap/extension-text-align": "3.17.0",
|
||||||
"@tiptap/extension-table-cell": "2.27.1",
|
"@tiptap/extension-text-style": "3.17.0",
|
||||||
"@tiptap/extension-table-header": "2.27.1",
|
"@tiptap/extension-typography": "3.17.0",
|
||||||
"@tiptap/extension-table-row": "2.27.1",
|
"@tiptap/extension-unique-id": "^3.17.0",
|
||||||
"@tiptap/extension-task-item": "2.27.1",
|
"@tiptap/extension-youtube": "3.17.0",
|
||||||
"@tiptap/extension-task-list": "2.27.1",
|
"@tiptap/html": "3.17.0",
|
||||||
"@tiptap/extension-text": "2.27.1",
|
"@tiptap/pm": "3.17.0",
|
||||||
"@tiptap/extension-text-align": "2.27.1",
|
"@tiptap/react": "3.17.0",
|
||||||
"@tiptap/extension-text-style": "2.27.1",
|
"@tiptap/starter-kit": "3.17.0",
|
||||||
"@tiptap/extension-typography": "2.27.1",
|
"@tiptap/suggestion": "3.17.0",
|
||||||
"@tiptap/extension-underline": "2.27.1",
|
|
||||||
"@tiptap/extension-youtube": "2.27.1",
|
|
||||||
"@tiptap/html": "2.27.1",
|
|
||||||
"@tiptap/pm": "2.27.1",
|
|
||||||
"@tiptap/react": "2.27.1",
|
|
||||||
"@tiptap/starter-kit": "2.27.1",
|
|
||||||
"@tiptap/suggestion": "2.27.1",
|
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"fractional-indexing-jittered": "^1.0.0",
|
"fractional-indexing-jittered": "^1.0.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"image-dimensions": "^2.5.0",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"linkifyjs": "^4.3.2",
|
"linkifyjs": "^4.3.2",
|
||||||
@@ -78,7 +74,7 @@
|
|||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"y-prosemirror": "1.3.7",
|
"y-prosemirror": "1.3.7",
|
||||||
"yjs": "^13.6.27"
|
"yjs": "^13.6.29"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nx/js": "20.4.5",
|
"@nx/js": "20.4.5",
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ export * from "./lib/subpages";
|
|||||||
export * from "./lib/highlight";
|
export * from "./lib/highlight";
|
||||||
export * from "./lib/heading/heading";
|
export * from "./lib/heading/heading";
|
||||||
export * from "./lib/unique-id";
|
export * from "./lib/unique-id";
|
||||||
|
export * from "./lib/shared-storage";
|
||||||
|
|||||||
@@ -1,126 +1,125 @@
|
|||||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
import { Node } from "@tiptap/pm/model";
|
||||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||||
import {
|
|
||||||
insertTrailingNode,
|
|
||||||
MediaUploadOptions,
|
|
||||||
UploadFn,
|
|
||||||
} from "../media-utils";
|
|
||||||
import { IAttachment } from "../types";
|
import { IAttachment } from "../types";
|
||||||
|
import { generateNodeId } from "../utils";
|
||||||
|
import { Command } from "@tiptap/core";
|
||||||
|
|
||||||
const uploadKey = new PluginKey("attachment-upload");
|
const findAttachmentNodeByPlaceholderId = (
|
||||||
|
doc: Node,
|
||||||
|
placeholderId: string,
|
||||||
|
): { node: Node; pos: number } | null => {
|
||||||
|
let result: { node: Node; pos: number } | null = null;
|
||||||
|
|
||||||
export const AttachmentUploadPlugin = ({
|
doc.descendants((node, pos) => {
|
||||||
placeholderClass,
|
if (result) return false;
|
||||||
}: {
|
if (
|
||||||
placeholderClass: string;
|
node.type.name === "attachment" &&
|
||||||
}) =>
|
node.attrs.placeholder?.id === placeholderId
|
||||||
new Plugin({
|
) {
|
||||||
key: uploadKey,
|
result = { node, pos };
|
||||||
state: {
|
return false;
|
||||||
init() {
|
}
|
||||||
return DecorationSet.empty;
|
return true;
|
||||||
},
|
|
||||||
apply(tr, set) {
|
|
||||||
set = set.map(tr.mapping, tr.doc);
|
|
||||||
// See if the transaction adds or removes any placeholders
|
|
||||||
//@-ts-expect-error - not yet sure what the type I need here
|
|
||||||
const action = tr.getMeta(this);
|
|
||||||
if (action?.add) {
|
|
||||||
const { id, pos, fileName } = action.add;
|
|
||||||
|
|
||||||
const placeholder = document.createElement("div");
|
|
||||||
placeholder.setAttribute("class", placeholderClass);
|
|
||||||
|
|
||||||
const uploadingText = document.createElement("span");
|
|
||||||
uploadingText.setAttribute("class", "uploading-text");
|
|
||||||
uploadingText.textContent = `Uploading ${fileName}`;
|
|
||||||
|
|
||||||
placeholder.appendChild(uploadingText);
|
|
||||||
|
|
||||||
const realPos = pos + 1;
|
|
||||||
const deco = Decoration.widget(realPos, placeholder, {
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
set = set.add(tr.doc, [deco]);
|
|
||||||
} else if (action?.remove) {
|
|
||||||
set = set.remove(
|
|
||||||
set.find(
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
(spec) => spec.id == action.remove.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return set;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decorations(state) {
|
|
||||||
return this.getState(state);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function findPlaceholder(state: EditorState, id: {}) {
|
return result;
|
||||||
const decos = uploadKey.getState(state) as DecorationSet;
|
};
|
||||||
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
|
const handleAttachmentUpload =
|
||||||
return found.length ? found[0]?.from : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleAttachmentUpload =
|
|
||||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||||
async (file, view, pos, pageId, allowMedia) => {
|
async (file, editor, pos, pageId, allowMedia) => {
|
||||||
const validated = validateFn?.(file, allowMedia);
|
const validated = validateFn?.(file, allowMedia);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (!validated) return;
|
if (!validated) return;
|
||||||
// A fresh object to act as the ID for this upload
|
|
||||||
const id = {};
|
|
||||||
|
|
||||||
// Replace the selection with a placeholder
|
const placeholderId = generateNodeId();
|
||||||
const tr = view.state.tr;
|
|
||||||
if (!tr.selection.empty) tr.deleteSelection();
|
|
||||||
|
|
||||||
tr.setMeta(uploadKey, {
|
let placeholderInserted = false;
|
||||||
add: {
|
|
||||||
id,
|
|
||||||
pos,
|
|
||||||
fileName: file.name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
insertTrailingNode(tr, pos, view);
|
const insertPlaceholder = (): Command => {
|
||||||
view.dispatch(tr);
|
return ({ tr, state }) => {
|
||||||
|
const initialPlaceholderNode = state.schema.nodes.attachment?.create({
|
||||||
|
placeholder: {
|
||||||
|
id: placeholderId,
|
||||||
|
},
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
|
||||||
await onUpload(file, pageId).then(
|
if (!initialPlaceholderNode) return false;
|
||||||
(attachment: IAttachment) => {
|
|
||||||
const { schema } = view.state;
|
|
||||||
|
|
||||||
const pos = findPlaceholder(view.state, id);
|
const { parent } = tr.doc.resolve(pos);
|
||||||
|
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
|
||||||
|
|
||||||
if (pos == null) return;
|
if (isEmptyTextBlock) {
|
||||||
|
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
|
||||||
|
} else {
|
||||||
|
tr.insert(pos, initialPlaceholderNode);
|
||||||
|
}
|
||||||
|
|
||||||
if (!attachment) return;
|
return true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const replacePlaceholderWithAttachment = (
|
||||||
|
attachment: IAttachment,
|
||||||
|
): Command => {
|
||||||
|
return ({ tr }) => {
|
||||||
|
const { pos: currentPos = null } =
|
||||||
|
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||||
|
|
||||||
const node = schema.nodes.attachment?.create({
|
// If the placeholder is not found or attachment is missing, abort the process
|
||||||
|
if (currentPos === null || !attachment) return false;
|
||||||
|
|
||||||
|
// Update the placeholder node with the actual attachment data
|
||||||
|
tr.setNodeMarkup(currentPos, undefined, {
|
||||||
url: `/api/files/${attachment.id}/${attachment.fileName}`,
|
url: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||||
name: attachment.fileName,
|
name: attachment.fileName,
|
||||||
mime: attachment.mimeType,
|
mime: attachment.mimeType,
|
||||||
size: attachment.fileSize,
|
size: attachment.fileSize,
|
||||||
attachmentId: attachment.id,
|
attachmentId: attachment.id,
|
||||||
});
|
});
|
||||||
if (!node) return;
|
|
||||||
|
|
||||||
const transaction = view.state.tr
|
return true;
|
||||||
.replaceWith(pos, pos, node)
|
};
|
||||||
.setMeta(uploadKey, { remove: { id } });
|
};
|
||||||
view.dispatch(transaction);
|
const removePlaceholder = (): Command => {
|
||||||
},
|
return ({ tr }) => {
|
||||||
() => {
|
const { pos: currentPos = null } =
|
||||||
// Deletes the placeholder on error
|
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||||
const transaction = view.state.tr
|
|
||||||
.delete(pos, pos)
|
if (currentPos === null) return false;
|
||||||
.setMeta(uploadKey, { remove: { id } });
|
|
||||||
view.dispatch(transaction);
|
tr.delete(currentPos, currentPos + 2);
|
||||||
},
|
|
||||||
);
|
return true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// Only show the placeholder if the upload takes more than 250ms
|
||||||
|
const insertPlaceholderTimeout = setTimeout(() => {
|
||||||
|
editor.commands.command(insertPlaceholder());
|
||||||
|
placeholderInserted = true;
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attachment: IAttachment = await onUpload(file, pageId);
|
||||||
|
|
||||||
|
clearTimeout(insertPlaceholderTimeout);
|
||||||
|
|
||||||
|
if (placeholderInserted) {
|
||||||
|
setTimeout(() => {
|
||||||
|
editor.commands.command(replacePlaceholderWithAttachment(attachment));
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.command(insertPlaceholder())
|
||||||
|
.command(replacePlaceholderWithAttachment(attachment))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(insertPlaceholderTimeout);
|
||||||
|
|
||||||
|
editor.commands.command(removePlaceholder());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { handleAttachmentUpload };
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import { AttachmentUploadPlugin } from "./attachment-upload";
|
|
||||||
|
|
||||||
export interface AttachmentOptions {
|
export interface AttachmentOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
@@ -13,6 +12,7 @@ export interface AttachmentAttributes {
|
|||||||
mime?: string; // e.g. application/zip
|
mime?: string; // e.g. application/zip
|
||||||
size?: number;
|
size?: number;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
@@ -75,6 +75,10 @@ export const Attachment = Node.create<AttachmentOptions>({
|
|||||||
"data-attachment-id": attributes.attachmentId,
|
"data-attachment-id": attributes.attachmentId,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
placeholder: {
|
||||||
|
default: null,
|
||||||
|
rendered: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -120,14 +124,9 @@ export const Attachment = Node.create<AttachmentOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
AttachmentUploadPlugin({
|
|
||||||
placeholderClass: "attachment-placeholder",
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const Callout = Node.create<CalloutOptions>({
|
|||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ "data-type": this.name },
|
{ "data-type": this.name },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes,
|
HTMLAttributes
|
||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
];
|
];
|
||||||
@@ -130,6 +130,9 @@ export const Callout = Node.create<CalloutOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -193,7 +196,7 @@ export const Callout = Node.create<CalloutOptions>({
|
|||||||
|
|
||||||
tr.delete(pos, pos + nodeSize);
|
tr.delete(pos, pos + nodeSize);
|
||||||
tr.setSelection(
|
tr.setSelection(
|
||||||
TextSelection.near(tr.doc.resolve(previousPosition - 1)),
|
TextSelection.near(tr.doc.resolve(previousPosition - 1))
|
||||||
);
|
);
|
||||||
tr.insert(previousPosition - 1, content);
|
tr.insert(previousPosition - 1, content);
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import CodeBlockLowlight, {
|
|
||||||
CodeBlockLowlightOptions,
|
|
||||||
} from "@tiptap/extension-code-block-lowlight";
|
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
||||||
|
|
||||||
export interface CustomCodeBlockOptions extends CodeBlockLowlightOptions {
|
|
||||||
view: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_CHAR = "\u00A0\u00A0";
|
|
||||||
|
|
||||||
export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
|
|
||||||
{
|
|
||||||
selectable: true,
|
|
||||||
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(),
|
|
||||||
view: null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(),
|
|
||||||
Tab: () => {
|
|
||||||
if (this.editor.isActive("codeBlock")) {
|
|
||||||
this.editor
|
|
||||||
.chain()
|
|
||||||
.command(({ tr }) => {
|
|
||||||
tr.insertText(TAB_CHAR);
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Mod-a": () => {
|
|
||||||
if (this.editor.isActive("codeBlock")) {
|
|
||||||
const { state } = this.editor;
|
|
||||||
const { $from } = state.selection;
|
|
||||||
|
|
||||||
let codeBlockNode = null;
|
|
||||||
let codeBlockPos = null;
|
|
||||||
let depth = 0;
|
|
||||||
|
|
||||||
for (depth = $from.depth; depth > 0; depth--) {
|
|
||||||
const node = $from.node(depth);
|
|
||||||
if (node.type.name === "codeBlock") {
|
|
||||||
codeBlockNode = node;
|
|
||||||
codeBlockPos = $from.start(depth) - 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (codeBlockNode && codeBlockPos !== null) {
|
|
||||||
const codeBlockStart = codeBlockPos;
|
|
||||||
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
|
|
||||||
|
|
||||||
const contentStart = codeBlockStart + 1;
|
|
||||||
const contentEnd = codeBlockEnd - 1;
|
|
||||||
|
|
||||||
this.editor.commands.setTextSelection({
|
|
||||||
from: contentStart,
|
|
||||||
to: contentEnd,
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addNodeView() {
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { CodeBlockOptions } from "@tiptap/extension-code-block";
|
||||||
|
import CodeBlock from "@tiptap/extension-code-block";
|
||||||
|
|
||||||
|
import { LowlightPlugin } from "./lowlight-plugin.js";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
|
||||||
|
/**
|
||||||
|
* The lowlight instance.
|
||||||
|
*/
|
||||||
|
lowlight: any;
|
||||||
|
view: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAB_CHAR = "\u00A0\u00A0";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This extension allows you to highlight code blocks with lowlight.
|
||||||
|
* @see https://tiptap.dev/api/nodes/code-block-lowlight
|
||||||
|
*/
|
||||||
|
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
lowlight: {},
|
||||||
|
languageClassPrefix: "language-",
|
||||||
|
exitOnTripleEnter: true,
|
||||||
|
exitOnArrowDown: true,
|
||||||
|
defaultLanguage: null,
|
||||||
|
HTMLAttributes: {},
|
||||||
|
view: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
Tab: () => {
|
||||||
|
if (this.editor.isActive("codeBlock")) {
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.command(({ tr }) => {
|
||||||
|
tr.insertText(TAB_CHAR);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Mod-a": () => {
|
||||||
|
if (this.editor.isActive("codeBlock")) {
|
||||||
|
const { state } = this.editor;
|
||||||
|
const { $from } = state.selection;
|
||||||
|
|
||||||
|
let codeBlockNode = null;
|
||||||
|
let codeBlockPos = null;
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
for (depth = $from.depth; depth > 0; depth--) {
|
||||||
|
const node = $from.node(depth);
|
||||||
|
if (node.type.name === "codeBlock") {
|
||||||
|
codeBlockNode = node;
|
||||||
|
codeBlockPos = $from.start(depth) - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codeBlockNode && codeBlockPos !== null) {
|
||||||
|
const codeBlockStart = codeBlockPos;
|
||||||
|
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
|
||||||
|
|
||||||
|
const contentStart = codeBlockStart + 1;
|
||||||
|
const contentEnd = codeBlockEnd - 1;
|
||||||
|
|
||||||
|
this.editor.commands.setTextSelection({
|
||||||
|
from: contentStart,
|
||||||
|
to: contentEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
...(this.parent?.() || []),
|
||||||
|
LowlightPlugin({
|
||||||
|
name: this.name,
|
||||||
|
lowlight: this.options.lowlight,
|
||||||
|
defaultLanguage: this.options.defaultLanguage,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { CustomCodeBlock } from "./custom-code-block";
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { findChildren } from '@tiptap/core'
|
||||||
|
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
|
// @ts-ignore
|
||||||
|
import highlight from 'highlight.js/lib/core'
|
||||||
|
|
||||||
|
function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
|
||||||
|
return nodes
|
||||||
|
.map(node => {
|
||||||
|
const classes = [...className, ...(node.properties ? node.properties.className : [])]
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
return parseNodes(node.children, classes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: node.value,
|
||||||
|
classes,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHighlightNodes(result: any) {
|
||||||
|
// `.value` for lowlight v1, `.children` for lowlight v2
|
||||||
|
return result.value || result.children || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function registered(aliasOrLanguage: string) {
|
||||||
|
return Boolean(highlight.getLanguage(aliasOrLanguage))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDecorations({
|
||||||
|
doc,
|
||||||
|
name,
|
||||||
|
lowlight,
|
||||||
|
defaultLanguage,
|
||||||
|
}: {
|
||||||
|
doc: ProsemirrorNode
|
||||||
|
name: string
|
||||||
|
lowlight: any
|
||||||
|
defaultLanguage: string | null | undefined
|
||||||
|
}) {
|
||||||
|
const decorations: Decoration[] = []
|
||||||
|
|
||||||
|
findChildren(doc, node => node.type.name === name).forEach(block => {
|
||||||
|
let from = block.pos + 1
|
||||||
|
const language = block.node.attrs.language || defaultLanguage
|
||||||
|
const languages = lowlight.listLanguages()
|
||||||
|
|
||||||
|
const nodes =
|
||||||
|
language && (languages.includes(language) || registered(language) || lowlight.registered?.(language))
|
||||||
|
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
|
||||||
|
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent))
|
||||||
|
|
||||||
|
parseNodes(nodes).forEach(node => {
|
||||||
|
const to = from + node.text.length
|
||||||
|
|
||||||
|
if (node.classes.length) {
|
||||||
|
const decoration = Decoration.inline(from, to, {
|
||||||
|
class: node.classes.join(' '),
|
||||||
|
})
|
||||||
|
|
||||||
|
decorations.push(decoration)
|
||||||
|
}
|
||||||
|
|
||||||
|
from = to
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||||
|
function isFunction(param: any): param is Function {
|
||||||
|
return typeof param === 'function'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LowlightPlugin({
|
||||||
|
name,
|
||||||
|
lowlight,
|
||||||
|
defaultLanguage,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
lowlight: any
|
||||||
|
defaultLanguage: string | null | undefined
|
||||||
|
}) {
|
||||||
|
if (!['highlight', 'highlightAuto', 'listLanguages'].every(api => isFunction(lowlight[api]))) {
|
||||||
|
throw Error('You should provide an instance of lowlight to use the code-block-lowlight extension')
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowlightPlugin: Plugin<any> = new Plugin({
|
||||||
|
key: new PluginKey('lowlight'),
|
||||||
|
|
||||||
|
state: {
|
||||||
|
init: (_, { doc }) =>
|
||||||
|
getDecorations({
|
||||||
|
doc,
|
||||||
|
name,
|
||||||
|
lowlight,
|
||||||
|
defaultLanguage,
|
||||||
|
}),
|
||||||
|
apply: (transaction, decorationSet, oldState, newState) => {
|
||||||
|
const oldNodeName = oldState.selection.$head.parent.type.name
|
||||||
|
const newNodeName = newState.selection.$head.parent.type.name
|
||||||
|
const oldNodes = findChildren(oldState.doc, node => node.type.name === name)
|
||||||
|
const newNodes = findChildren(newState.doc, node => node.type.name === name)
|
||||||
|
|
||||||
|
if (
|
||||||
|
transaction.docChanged &&
|
||||||
|
// Apply decorations if:
|
||||||
|
// selection includes named node,
|
||||||
|
([oldNodeName, newNodeName].includes(name) ||
|
||||||
|
// OR transaction adds/removes named node,
|
||||||
|
newNodes.length !== oldNodes.length ||
|
||||||
|
// OR transaction has changes that completely encapsulte a node
|
||||||
|
// (for example, a transaction that affects the entire document).
|
||||||
|
// Such transactions can happen during collab syncing via y-prosemirror, for example.
|
||||||
|
transaction.steps.some(step => {
|
||||||
|
// @ts-ignore
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
step.from !== undefined &&
|
||||||
|
// @ts-ignore
|
||||||
|
step.to !== undefined &&
|
||||||
|
oldNodes.some(node => {
|
||||||
|
// @ts-ignore
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
node.pos >= step.from &&
|
||||||
|
// @ts-ignore
|
||||||
|
node.pos + node.node.nodeSize <= step.to
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
return getDecorations({
|
||||||
|
doc: transaction.doc,
|
||||||
|
name,
|
||||||
|
lowlight,
|
||||||
|
defaultLanguage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return decorationSet.map(transaction.mapping, transaction.doc)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return lowlightPlugin.getState(state)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return lowlightPlugin
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export const Details = Node.create<DetailsOptions>({
|
|||||||
content: "detailsSummary detailsContent",
|
content: "detailsSummary detailsContent",
|
||||||
defining: true,
|
defining: true,
|
||||||
isolating: true,
|
isolating: true,
|
||||||
|
// @ts-ignore
|
||||||
allowGapCursor: false,
|
allowGapCursor: false,
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -41,45 +41,45 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
src: {
|
src: {
|
||||||
default: '',
|
default: "",
|
||||||
parseHTML: (element) => element.getAttribute('data-src'),
|
parseHTML: (element) => element.getAttribute("data-src"),
|
||||||
renderHTML: (attributes) => ({
|
renderHTML: (attributes) => ({
|
||||||
'data-src': attributes.src,
|
"data-src": attributes.src,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
default: undefined,
|
default: undefined,
|
||||||
parseHTML: (element) => element.getAttribute('data-title'),
|
parseHTML: (element) => element.getAttribute("data-title"),
|
||||||
renderHTML: (attributes: DrawioAttributes) => ({
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
'data-title': attributes.title,
|
"data-title": attributes.title,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: '100%',
|
default: "100%",
|
||||||
parseHTML: (element) => element.getAttribute('data-width'),
|
parseHTML: (element) => element.getAttribute("data-width"),
|
||||||
renderHTML: (attributes: DrawioAttributes) => ({
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
'data-width': attributes.width,
|
"data-width": attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute('data-size'),
|
parseHTML: (element) => element.getAttribute("data-size"),
|
||||||
renderHTML: (attributes: DrawioAttributes) => ({
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
'data-size': attributes.size,
|
"data-size": attributes.size,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
align: {
|
align: {
|
||||||
default: 'center',
|
default: "center",
|
||||||
parseHTML: (element) => element.getAttribute('data-align'),
|
parseHTML: (element) => element.getAttribute("data-align"),
|
||||||
renderHTML: (attributes: DrawioAttributes) => ({
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
'data-align': attributes.align,
|
"data-align": attributes.align,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
attachmentId: {
|
attachmentId: {
|
||||||
default: undefined,
|
default: undefined,
|
||||||
parseHTML: (element) => element.getAttribute('data-attachment-id'),
|
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||||
renderHTML: (attributes: DrawioAttributes) => ({
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
'data-attachment-id': attributes.attachmentId,
|
"data-attachment-id": attributes.attachmentId,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -95,13 +95,20 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return [
|
return [
|
||||||
'div',
|
"div",
|
||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ 'data-type': this.name },
|
{ "data-type": this.name },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes
|
HTMLAttributes
|
||||||
),
|
),
|
||||||
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }],
|
[
|
||||||
|
"img",
|
||||||
|
{
|
||||||
|
src: HTMLAttributes["data-src"],
|
||||||
|
alt: HTMLAttributes["data-title"],
|
||||||
|
width: HTMLAttributes["data-width"],
|
||||||
|
},
|
||||||
|
],
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -119,6 +126,9 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Node, mergeAttributes } from '@tiptap/core';
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import { sanitizeUrl } from './utils';
|
import { sanitizeUrl } from "./utils";
|
||||||
|
|
||||||
export interface EmbedOptions {
|
export interface EmbedOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
@@ -14,7 +14,7 @@ export interface EmbedAttributes {
|
|||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
embeds: {
|
embeds: {
|
||||||
setEmbed: (attributes?: EmbedAttributes) => ReturnType;
|
setEmbed: (attributes?: EmbedAttributes) => ReturnType;
|
||||||
@@ -23,9 +23,9 @@ declare module '@tiptap/core' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Embed = Node.create<EmbedOptions>({
|
export const Embed = Node.create<EmbedOptions>({
|
||||||
name: 'embed',
|
name: "embed",
|
||||||
inline: false,
|
inline: false,
|
||||||
group: 'block',
|
group: "block",
|
||||||
isolating: true,
|
isolating: true,
|
||||||
atom: true,
|
atom: true,
|
||||||
defining: true,
|
defining: true,
|
||||||
@@ -40,41 +40,41 @@ export const Embed = Node.create<EmbedOptions>({
|
|||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
src: {
|
src: {
|
||||||
default: '',
|
default: "",
|
||||||
parseHTML: (element) => {
|
parseHTML: (element) => {
|
||||||
const src = element.getAttribute('data-src');
|
const src = element.getAttribute("data-src");
|
||||||
return sanitizeUrl(src);
|
return sanitizeUrl(src);
|
||||||
},
|
},
|
||||||
renderHTML: (attributes: EmbedAttributes) => ({
|
renderHTML: (attributes: EmbedAttributes) => ({
|
||||||
'data-src': sanitizeUrl(attributes.src),
|
"data-src": sanitizeUrl(attributes.src),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
provider: {
|
provider: {
|
||||||
default: '',
|
default: "",
|
||||||
parseHTML: (element) => element.getAttribute('data-provider'),
|
parseHTML: (element) => element.getAttribute("data-provider"),
|
||||||
renderHTML: (attributes: EmbedAttributes) => ({
|
renderHTML: (attributes: EmbedAttributes) => ({
|
||||||
'data-provider': attributes.provider,
|
"data-provider": attributes.provider,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
align: {
|
align: {
|
||||||
default: 'center',
|
default: "center",
|
||||||
parseHTML: (element) => element.getAttribute('data-align'),
|
parseHTML: (element) => element.getAttribute("data-align"),
|
||||||
renderHTML: (attributes: EmbedAttributes) => ({
|
renderHTML: (attributes: EmbedAttributes) => ({
|
||||||
'data-align': attributes.align,
|
"data-align": attributes.align,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: 640,
|
default: 640,
|
||||||
parseHTML: (element) => element.getAttribute('data-width'),
|
parseHTML: (element) => element.getAttribute("data-width"),
|
||||||
renderHTML: (attributes: EmbedAttributes) => ({
|
renderHTML: (attributes: EmbedAttributes) => ({
|
||||||
'data-width': attributes.width,
|
"data-width": attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
default: 480,
|
default: 480,
|
||||||
parseHTML: (element) => element.getAttribute('data-height'),
|
parseHTML: (element) => element.getAttribute("data-height"),
|
||||||
renderHTML: (attributes: EmbedAttributes) => ({
|
renderHTML: (attributes: EmbedAttributes) => ({
|
||||||
'data-height': attributes.height,
|
"data-height": attributes.height,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -91,13 +91,13 @@ export const Embed = Node.create<EmbedOptions>({
|
|||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
const src = HTMLAttributes["data-src"];
|
const src = HTMLAttributes["data-src"];
|
||||||
const safeHref = sanitizeUrl(src);
|
const safeHref = sanitizeUrl(src);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"div",
|
"div",
|
||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ "data-type": this.name },
|
{ "data-type": this.name },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes,
|
HTMLAttributes
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
"a",
|
"a",
|
||||||
@@ -120,9 +120,9 @@ export const Embed = Node.create<EmbedOptions>({
|
|||||||
...attrs,
|
...attrs,
|
||||||
src: sanitizeUrl(attrs.src),
|
src: sanitizeUrl(attrs.src),
|
||||||
};
|
};
|
||||||
|
|
||||||
return commands.insertContent({
|
return commands.insertContent({
|
||||||
type: 'embed',
|
type: "embed",
|
||||||
attrs: validatedAttrs,
|
attrs: validatedAttrs,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -130,6 +130,9 @@ export const Embed = Node.create<EmbedOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Node, mergeAttributes } from '@tiptap/core';
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
export interface ExcalidrawOptions {
|
export interface ExcalidrawOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
@@ -14,7 +14,7 @@ export interface ExcalidrawAttributes {
|
|||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tiptap/core' {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
excalidraw: {
|
excalidraw: {
|
||||||
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
||||||
@@ -23,9 +23,9 @@ declare module '@tiptap/core' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Excalidraw = Node.create<ExcalidrawOptions>({
|
export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||||
name: 'excalidraw',
|
name: "excalidraw",
|
||||||
inline: false,
|
inline: false,
|
||||||
group: 'block',
|
group: "block",
|
||||||
isolating: true,
|
isolating: true,
|
||||||
atom: true,
|
atom: true,
|
||||||
defining: true,
|
defining: true,
|
||||||
@@ -40,45 +40,45 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
src: {
|
src: {
|
||||||
default: '',
|
default: "",
|
||||||
parseHTML: (element) => element.getAttribute('data-src'),
|
parseHTML: (element) => element.getAttribute("data-src"),
|
||||||
renderHTML: (attributes) => ({
|
renderHTML: (attributes) => ({
|
||||||
'data-src': attributes.src,
|
"data-src": attributes.src,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
default: undefined,
|
default: undefined,
|
||||||
parseHTML: (element) => element.getAttribute('data-title'),
|
parseHTML: (element) => element.getAttribute("data-title"),
|
||||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
'data-title': attributes.title,
|
"data-title": attributes.title,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: '100%',
|
default: "100%",
|
||||||
parseHTML: (element) => element.getAttribute('data-width'),
|
parseHTML: (element) => element.getAttribute("data-width"),
|
||||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
'data-width': attributes.width,
|
"data-width": attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute('data-size'),
|
parseHTML: (element) => element.getAttribute("data-size"),
|
||||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
'data-size': attributes.size,
|
"data-size": attributes.size,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
align: {
|
align: {
|
||||||
default: 'center',
|
default: "center",
|
||||||
parseHTML: (element) => element.getAttribute('data-align'),
|
parseHTML: (element) => element.getAttribute("data-align"),
|
||||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
'data-align': attributes.align,
|
"data-align": attributes.align,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
attachmentId: {
|
attachmentId: {
|
||||||
default: undefined,
|
default: undefined,
|
||||||
parseHTML: (element) => element.getAttribute('data-attachment-id'),
|
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
'data-attachment-id': attributes.attachmentId,
|
"data-attachment-id": attributes.attachmentId,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -94,13 +94,20 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return [
|
return [
|
||||||
'div',
|
"div",
|
||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ 'data-type': this.name },
|
{ "data-type": this.name },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes
|
HTMLAttributes
|
||||||
),
|
),
|
||||||
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }],
|
[
|
||||||
|
"img",
|
||||||
|
{
|
||||||
|
src: HTMLAttributes["data-src"],
|
||||||
|
alt: HTMLAttributes["data-title"],
|
||||||
|
width: HTMLAttributes["data-width"],
|
||||||
|
},
|
||||||
|
],
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -110,7 +117,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
(attrs: ExcalidrawAttributes) =>
|
(attrs: ExcalidrawAttributes) =>
|
||||||
({ commands }) => {
|
({ commands }) => {
|
||||||
return commands.insertContent({
|
return commands.insertContent({
|
||||||
type: 'excalidraw',
|
type: "excalidraw",
|
||||||
attrs: attrs,
|
attrs: attrs,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -118,6 +125,9 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,127 +1,145 @@
|
|||||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
import { imageDimensionsFromStream } from "image-dimensions";
|
||||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||||
import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils";
|
|
||||||
import { IAttachment } from "../types";
|
import { IAttachment } from "../types";
|
||||||
|
import { generateNodeId } from "../utils";
|
||||||
|
import { Node } from "@tiptap/pm/model";
|
||||||
|
import { Command } from "@tiptap/core";
|
||||||
|
|
||||||
const uploadKey = new PluginKey("image-upload");
|
const findImageNodeByPlaceholderId = (
|
||||||
|
doc: Node,
|
||||||
|
placeholderId: string,
|
||||||
|
): { node: Node; pos: number } | null => {
|
||||||
|
let result: { node: Node; pos: number } | null = null;
|
||||||
|
|
||||||
export const ImageUploadPlugin = ({
|
doc.descendants((node, pos) => {
|
||||||
placeholderClass,
|
if (result) return false;
|
||||||
}: {
|
if (
|
||||||
placeholderClass: string;
|
node.type.name === "image" &&
|
||||||
}) =>
|
node.attrs.placeholder?.id === placeholderId
|
||||||
new Plugin({
|
) {
|
||||||
key: uploadKey,
|
result = { node, pos };
|
||||||
state: {
|
return false;
|
||||||
init() {
|
}
|
||||||
return DecorationSet.empty;
|
return true;
|
||||||
},
|
|
||||||
apply(tr, set) {
|
|
||||||
set = set.map(tr.mapping, tr.doc);
|
|
||||||
// See if the transaction adds or removes any placeholders
|
|
||||||
//@-ts-expect-error - not yet sure what the type I need here
|
|
||||||
const action = tr.getMeta(this);
|
|
||||||
if (action?.add) {
|
|
||||||
const { id, pos, src } = action.add;
|
|
||||||
|
|
||||||
const placeholder = document.createElement("div");
|
|
||||||
placeholder.setAttribute("class", "img-placeholder");
|
|
||||||
const image = document.createElement("img");
|
|
||||||
image.setAttribute("class", placeholderClass);
|
|
||||||
image.src = src;
|
|
||||||
placeholder.appendChild(image);
|
|
||||||
const deco = Decoration.widget(pos + 1, placeholder, {
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
set = set.add(tr.doc, [deco]);
|
|
||||||
} else if (action?.remove) {
|
|
||||||
set = set.remove(
|
|
||||||
set.find(
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
(spec) => spec.id == action.remove.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return set;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decorations(state) {
|
|
||||||
return this.getState(state);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function findPlaceholder(state: EditorState, id: {}) {
|
return result;
|
||||||
const decos = uploadKey.getState(state) as DecorationSet;
|
};
|
||||||
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
|
const handleImageUpload =
|
||||||
return found.length ? found[0]?.from : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleImageUpload =
|
|
||||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||||
async (file, view, pos, pageId) => {
|
async (file, editor, pos, pageId) => {
|
||||||
// check if the file is an image
|
// check if the file is an image
|
||||||
const validated = validateFn?.(file);
|
const validated = validateFn?.(file);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (!validated) return;
|
if (!validated) return;
|
||||||
// A fresh object to act as the ID for this upload
|
|
||||||
const id = {};
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
const objectUrl = URL.createObjectURL(file);
|
||||||
reader.readAsDataURL(file);
|
const imageDimensions = await imageDimensionsFromStream(file.stream());
|
||||||
reader.onload = () => {
|
const placeholderId = generateNodeId();
|
||||||
const tr = view.state.tr;
|
const aspectRatio = imageDimensions
|
||||||
// Replace the selection with a placeholder
|
? imageDimensions.width / imageDimensions.height
|
||||||
if (!tr.selection.empty) tr.deleteSelection();
|
: undefined;
|
||||||
|
|
||||||
tr.setMeta(uploadKey, {
|
let placeholderInserted = false;
|
||||||
add: {
|
|
||||||
id,
|
|
||||||
pos,
|
|
||||||
src: reader.result,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
insertTrailingNode(tr, pos, view);
|
editor.storage.shared.imagePreviews =
|
||||||
view.dispatch(tr);
|
editor.storage.shared.imagePreviews || {};
|
||||||
|
editor.storage.shared.imagePreviews[placeholderId] = objectUrl;
|
||||||
|
|
||||||
|
const insertPlaceholder = (): Command => {
|
||||||
|
return ({ tr, state }) => {
|
||||||
|
const initialPlaceholderNode = state.schema.nodes.image?.create({
|
||||||
|
placeholder: {
|
||||||
|
id: placeholderId,
|
||||||
|
name: file.name,
|
||||||
|
},
|
||||||
|
aspectRatio,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!initialPlaceholderNode) return false;
|
||||||
|
|
||||||
|
const { parent } = tr.doc.resolve(pos);
|
||||||
|
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
|
||||||
|
|
||||||
|
if (isEmptyTextBlock) {
|
||||||
|
// Replace e.g. empty paragraph with the image
|
||||||
|
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
|
||||||
|
} else {
|
||||||
|
tr.insert(pos, initialPlaceholderNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
const replacePlaceholderWithImage = (attachment: IAttachment): Command => {
|
||||||
|
return ({ tr }) => {
|
||||||
|
const { pos: currentPos = null } =
|
||||||
|
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||||
|
|
||||||
await onUpload(file, pageId).then(
|
// If the placeholder is not found or attachment is missing, abort the process
|
||||||
(attachment: IAttachment) => {
|
if (currentPos === null || !attachment) return false;
|
||||||
const { schema } = view.state;
|
|
||||||
|
|
||||||
const pos = findPlaceholder(view.state, id);
|
// Update the placeholder node with the actual image data
|
||||||
|
tr.setNodeMarkup(currentPos, undefined, {
|
||||||
// If the content around the placeholder has been deleted, drop
|
|
||||||
// the image
|
|
||||||
if (pos == null) return;
|
|
||||||
|
|
||||||
// Otherwise, insert it at the placeholder's position, and remove
|
|
||||||
// the placeholder
|
|
||||||
|
|
||||||
if (!attachment) return;
|
|
||||||
|
|
||||||
const node = schema.nodes.image?.create({
|
|
||||||
src: `/api/files/${attachment.id}/${attachment.fileName}`,
|
src: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||||
attachmentId: attachment.id,
|
attachmentId: attachment.id,
|
||||||
title: attachment.fileName,
|
|
||||||
size: attachment.fileSize,
|
size: attachment.fileSize,
|
||||||
|
aspectRatio,
|
||||||
});
|
});
|
||||||
if (!node) return;
|
|
||||||
|
|
||||||
const transaction = view.state.tr
|
return true;
|
||||||
.replaceWith(pos, pos, node)
|
};
|
||||||
.setMeta(uploadKey, { remove: { id } });
|
};
|
||||||
view.dispatch(transaction);
|
const removePlaceholder = (): Command => {
|
||||||
},
|
return ({ tr }) => {
|
||||||
() => {
|
const { pos: currentPos = null } =
|
||||||
// Deletes the image placeholder on error
|
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||||
const transaction = view.state.tr
|
|
||||||
.delete(pos, pos)
|
if (currentPos === null) return false;
|
||||||
.setMeta(uploadKey, { remove: { id } });
|
|
||||||
view.dispatch(transaction);
|
// Remove the placeholder node
|
||||||
},
|
tr.delete(currentPos, currentPos + 2);
|
||||||
);
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// Only show the placeholder if the upload takes more than 250ms
|
||||||
|
const insertPlaceholderTimeout = setTimeout(() => {
|
||||||
|
editor.commands.command(insertPlaceholder());
|
||||||
|
placeholderInserted = true;
|
||||||
|
}, 250);
|
||||||
|
const disposePreviewFile = () => {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
|
||||||
|
if (editor.storage.shared.imagePreviews) {
|
||||||
|
delete editor.storage.shared.imagePreviews[placeholderId];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attachment: IAttachment = await onUpload(file, pageId);
|
||||||
|
|
||||||
|
clearTimeout(insertPlaceholderTimeout);
|
||||||
|
|
||||||
|
if (placeholderInserted) {
|
||||||
|
setTimeout(() => {
|
||||||
|
editor.commands.command(replacePlaceholderWithImage(attachment));
|
||||||
|
disposePreviewFile();
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.command(insertPlaceholder())
|
||||||
|
.command(replacePlaceholderWithImage(attachment))
|
||||||
|
.run();
|
||||||
|
disposePreviewFile();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(insertPlaceholderTimeout);
|
||||||
|
|
||||||
|
editor.commands.command(removePlaceholder());
|
||||||
|
disposePreviewFile();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { handleImageUpload };
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Image from "@tiptap/extension-image";
|
import Image from "@tiptap/extension-image";
|
||||||
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
|
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import { ImageUploadPlugin } from "./image-upload";
|
|
||||||
import { mergeAttributes, Range } from "@tiptap/core";
|
import { mergeAttributes, Range } from "@tiptap/core";
|
||||||
|
|
||||||
export interface ImageOptions extends DefaultImageOptions {
|
export interface ImageOptions extends DefaultImageOptions {
|
||||||
@@ -10,11 +9,15 @@ export interface ImageOptions extends DefaultImageOptions {
|
|||||||
export interface ImageAttributes {
|
export interface ImageAttributes {
|
||||||
src?: string;
|
src?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
title?: string;
|
|
||||||
align?: string;
|
align?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
aspectRatio?: number;
|
||||||
|
placeholder?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
@@ -90,6 +93,17 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
"data-size": attributes.size,
|
"data-size": attributes.size,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
aspectRatio: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||||
|
renderHTML: (attributes: ImageAttributes) => ({
|
||||||
|
"data-aspect-ratio": attributes.aspectRatio,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
default: null,
|
||||||
|
rendered: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -135,14 +149,9 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
ImageUploadPlugin({
|
|
||||||
placeholderClass: "image-upload",
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export const MathBlock = Node.create({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const MathInline = Node.create<MathInlineOption>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { EditorView } from "@tiptap/pm/view";
|
import { Editor } from "@tiptap/core";
|
||||||
import { Transaction } from "@tiptap/pm/state";
|
|
||||||
|
|
||||||
export type UploadFn = (
|
export type UploadFn = (
|
||||||
file: File,
|
file: File,
|
||||||
view: EditorView,
|
editor: Editor,
|
||||||
pos: number,
|
pos: number,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
// only applicable to file attachments
|
// only applicable to file attachments
|
||||||
@@ -14,16 +13,3 @@ export interface MediaUploadOptions {
|
|||||||
validateFn?: (file: File, allowMedia?: boolean) => void;
|
validateFn?: (file: File, allowMedia?: boolean) => void;
|
||||||
onUpload: (file: File, pageId: string) => Promise<any>;
|
onUpload: (file: File, pageId: string) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function insertTrailingNode(
|
|
||||||
tr: Transaction,
|
|
||||||
pos: number,
|
|
||||||
view: EditorView,
|
|
||||||
) {
|
|
||||||
// create trailing node after decoration
|
|
||||||
// if decoration is at the last node
|
|
||||||
const currentDocSize = view.state.doc.content.size;
|
|
||||||
if (pos + 1 === currentDocSize) {
|
|
||||||
tr.insert(currentDocSize, view.state.schema.nodes.paragraph.create());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ import {
|
|||||||
import { Node as PMNode, Mark } from "@tiptap/pm/model";
|
import { Node as PMNode, Mark } from "@tiptap/pm/model";
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
|
interface Storage {
|
||||||
|
searchAndReplace: SearchAndReplaceStorage;
|
||||||
|
}
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
search: {
|
search: {
|
||||||
/**
|
/**
|
||||||
@@ -184,21 +187,21 @@ const replace = (
|
|||||||
|
|
||||||
if (dispatch) {
|
if (dispatch) {
|
||||||
const tr = state.tr;
|
const tr = state.tr;
|
||||||
|
|
||||||
// Get all marks that span the text being replaced
|
// Get all marks that span the text being replaced
|
||||||
const marksSet = new Set<Mark>();
|
const marksSet = new Set<Mark>();
|
||||||
state.doc.nodesBetween(from, to, (node) => {
|
state.doc.nodesBetween(from, to, (node) => {
|
||||||
if (node.isText && node.marks) {
|
if (node.isText && node.marks) {
|
||||||
node.marks.forEach(mark => marksSet.add(mark));
|
node.marks.forEach((mark) => marksSet.add(mark));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const marks = Array.from(marksSet);
|
const marks = Array.from(marksSet);
|
||||||
|
|
||||||
// Delete the old text and insert new text with preserved marks
|
// Delete the old text and insert new text with preserved marks
|
||||||
tr.delete(from, to);
|
tr.delete(from, to);
|
||||||
tr.insert(from, state.schema.text(replaceTerm, marks));
|
tr.insert(from, state.schema.text(replaceTerm, marks));
|
||||||
|
|
||||||
dispatch(tr);
|
dispatch(tr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -215,17 +218,17 @@ const replaceAll = (
|
|||||||
// Process replacements in reverse order to avoid position shifting issues
|
// Process replacements in reverse order to avoid position shifting issues
|
||||||
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
|
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
|
||||||
const { from, to } = resultsCopy[i];
|
const { from, to } = resultsCopy[i];
|
||||||
|
|
||||||
// Get all marks that span the text being replaced
|
// Get all marks that span the text being replaced
|
||||||
const marksSet = new Set<Mark>();
|
const marksSet = new Set<Mark>();
|
||||||
tr.doc.nodesBetween(from, to, (node) => {
|
tr.doc.nodesBetween(from, to, (node) => {
|
||||||
if (node.isText && node.marks) {
|
if (node.isText && node.marks) {
|
||||||
node.marks.forEach(mark => marksSet.add(mark));
|
node.marks.forEach((mark) => marksSet.add(mark));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const marks = Array.from(marksSet);
|
const marks = Array.from(marksSet);
|
||||||
|
|
||||||
// Delete and insert with preserved marks
|
// Delete and insert with preserved marks
|
||||||
tr.delete(from, to);
|
tr.delete(from, to);
|
||||||
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
|
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
|
||||||
@@ -352,10 +355,17 @@ export const SearchAndReplace = Extension.create<
|
|||||||
// The results will be recalculated by the plugin, but we need to ensure
|
// The results will be recalculated by the plugin, but we need to ensure
|
||||||
// the index doesn't exceed the new bounds
|
// the index doesn't exceed the new bounds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const newResultsLength = editor.storage.searchAndReplace.results.length;
|
const newResultsLength =
|
||||||
if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) {
|
editor.storage.searchAndReplace.results.length;
|
||||||
|
if (
|
||||||
|
newResultsLength > 0 &&
|
||||||
|
editor.storage.searchAndReplace.resultIndex >= newResultsLength
|
||||||
|
) {
|
||||||
// Keep the same position if possible, otherwise go to the last result
|
// Keep the same position if possible, otherwise go to the last result
|
||||||
editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1);
|
editor.storage.searchAndReplace.resultIndex = Math.min(
|
||||||
|
resultIndex,
|
||||||
|
newResultsLength - 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SharedStorage } from "./shared-storage";
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Storage {
|
||||||
|
shared: Record<string, any>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SharedStorage = Extension.create({
|
||||||
|
name: "shared",
|
||||||
|
|
||||||
|
addStorage() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { SharedStorage };
|
||||||
@@ -44,7 +44,7 @@ export const Subpages = Node.create<SubpagesOptions>({
|
|||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ "data-type": this.name },
|
{ "data-type": this.name },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes,
|
HTMLAttributes
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
@@ -63,6 +63,9 @@ export const Subpages = Node.create<SubpagesOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell";
|
import { TableCell as TiptapTableCell } from "@tiptap/extension-table";
|
||||||
|
|
||||||
export const TableCell = TiptapTableCell.extend({
|
export const TableCell = TiptapTableCell.extend({
|
||||||
name: "tableCell",
|
name: "tableCell",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user