mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f92e1fecf | |||
| e709e34f1f | |||
| ae9484e274 | |||
| 3c81441ddb | |||
| 152702ebe0 | |||
| 10b0ac06dd | |||
| 49ab9875ba | |||
| 25f4b8c2b4 | |||
| 4d43f86c51 | |||
| f170ede8da | |||
| 7861b5b186 | |||
| 3a9bdfbb06 | |||
| ab7999a946 | |||
| 0f02261ee6 | |||
| aff8dba2cb | |||
| f6a8247c48 | |||
| 7879e1f600 |
+12
-12
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.25.2",
|
||||
"version": "0.25.3",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -14,18 +14,18 @@
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "0.18.0-c158187",
|
||||
"@mantine/core": "^8.3.12",
|
||||
"@mantine/dates": "^8.3.12",
|
||||
"@mantine/form": "^8.3.12",
|
||||
"@mantine/hooks": "^8.3.12",
|
||||
"@mantine/modals": "^8.3.12",
|
||||
"@mantine/notifications": "^8.3.12",
|
||||
"@mantine/spotlight": "^8.3.12",
|
||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||
"@mantine/core": "^8.3.14",
|
||||
"@mantine/dates": "^8.3.14",
|
||||
"@mantine/form": "^8.3.14",
|
||||
"@mantine/hooks": "^8.3.14",
|
||||
"@mantine/modals": "^8.3.14",
|
||||
"@mantine/notifications": "^8.3.14",
|
||||
"@mantine/spotlight": "^8.3.14",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tanstack/react-query": "^5.90.17",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
@@ -41,7 +41,7 @@
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"posthog-js": "1.345.5",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.17",
|
||||
@@ -66,7 +66,7 @@
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
|
||||
// modified to use the polyfilled clipboard api
|
||||
import React from "react";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { useProps } from "@mantine/core";
|
||||
|
||||
interface CopyButtonProps {
|
||||
/** Children callback, provides current status and copy function as an argument */
|
||||
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
|
||||
|
||||
/** Value that is copied to the clipboard when the button is clicked */
|
||||
value: string;
|
||||
|
||||
/** Copied status timeout in ms @default `1000` */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
timeout: 1000,
|
||||
} satisfies Partial<CopyButtonProps>;
|
||||
|
||||
export function CopyButton(props: CopyButtonProps) {
|
||||
const { children, timeout, value, ...others } = useProps(
|
||||
"CopyButton",
|
||||
defaultProps,
|
||||
props,
|
||||
);
|
||||
const clipboard = useClipboard({ timeout });
|
||||
const copy = () => clipboard.copy(value);
|
||||
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
|
||||
}
|
||||
|
||||
CopyButton.displayName = "@mantine/core/CopyButton";
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
Group,
|
||||
List,
|
||||
Code,
|
||||
CopyButton,
|
||||
Alert,
|
||||
PasswordInput,
|
||||
} from "@mantine/core";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import {
|
||||
IconRefresh,
|
||||
IconCopy,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
PinInput,
|
||||
Alert,
|
||||
List,
|
||||
CopyButton,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Paper,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
Collapse,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import {
|
||||
IconQrcode,
|
||||
IconShieldCheck,
|
||||
|
||||
@@ -84,9 +84,14 @@ const CommentEditor = forwardRef(
|
||||
autofocus: (autofocus && "end") || false,
|
||||
});
|
||||
|
||||
// Sync content from props for read-only editors (e.g. when updated via
|
||||
// websocket on another browser). Skip for editable editors to avoid
|
||||
// resetting the cursor position on every keystroke.
|
||||
useEffect(() => {
|
||||
commentEditor.commands.setContent(defaultContent);
|
||||
}, [defaultContent]);
|
||||
if (!editable && commentEditor && defaultContent) {
|
||||
commentEditor.commands.setContent(defaultContent);
|
||||
}
|
||||
}, [defaultContent, editable, commentEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Group, Text, Box, Badge } from "@mantine/core";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
@@ -40,6 +40,7 @@ function CommentListItem({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const [content, setContent] = useState<string>(comment.content);
|
||||
const editContentRef = useRef<any>(null);
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
@@ -56,9 +57,13 @@ function CommentListItem({
|
||||
setIsLoading(true);
|
||||
const commentToUpdate = {
|
||||
commentId: comment.id,
|
||||
content: JSON.stringify(content),
|
||||
content: JSON.stringify(editContentRef.current ?? content),
|
||||
};
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
if (editContentRef.current) {
|
||||
setContent(editContentRef.current);
|
||||
editContentRef.current = null;
|
||||
}
|
||||
setIsEditing(false);
|
||||
|
||||
emit({
|
||||
@@ -128,6 +133,7 @@ function CommentListItem({
|
||||
setIsEditing(true);
|
||||
}
|
||||
function cancelEdit() {
|
||||
editContentRef.current = null;
|
||||
setIsEditing(false);
|
||||
}
|
||||
|
||||
@@ -194,7 +200,7 @@ function CommentListItem({
|
||||
<CommentEditor
|
||||
defaultContent={content}
|
||||
editable={true}
|
||||
onUpdate={(newContent: any) => setContent(newContent)}
|
||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||
onSave={handleUpdateComment}
|
||||
autofocus={true}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, CopyButton, Group, Select, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Group, Select, Tooltip } from "@mantine/core";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import classes from "./code-block.module.css";
|
||||
|
||||
@@ -160,7 +160,7 @@ export function TitleEditor({
|
||||
// guard against Cannot access view['hasFocus'] error
|
||||
if (!titleEditor?.isInitialized) return;
|
||||
titleEditor?.commands?.focus("end");
|
||||
}, 500);
|
||||
}, 300);
|
||||
}, [titleEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Text, Group, UnstyledButton } from "@mantine/core";
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./css/history.module.css";
|
||||
@@ -6,6 +6,8 @@ import clsx from "clsx";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import { memo, useCallback } from "react";
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 5;
|
||||
|
||||
interface HistoryItemProps {
|
||||
historyItem: IPageHistory;
|
||||
index: number;
|
||||
@@ -31,6 +33,9 @@ const HistoryItem = memo(function HistoryItem({
|
||||
onHover?.(historyItem.id, index);
|
||||
}, [onHover, historyItem.id, index]);
|
||||
|
||||
const contributors = historyItem.contributors;
|
||||
const hasContributors = contributors && contributors.length > 0;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
p="xs"
|
||||
@@ -39,25 +44,54 @@ const HistoryItem = memo(function HistoryItem({
|
||||
onMouseLeave={onHoverEnd}
|
||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{formattedDate(new Date(historyItem.createdAt))}
|
||||
</Text>
|
||||
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
|
||||
name={historyItem.lastUpdatedBy?.name}
|
||||
/>
|
||||
<Group gap={6} wrap="nowrap" mt={4}>
|
||||
{hasContributors ? (
|
||||
<>
|
||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
||||
<Avatar.Group spacing={8}>
|
||||
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
|
||||
<Tooltip key={contributor.id} label={contributor.name} withArrow>
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={contributor.avatarUrl}
|
||||
name={contributor.name}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
{contributors.length > MAX_VISIBLE_AVATARS && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={contributors.slice(MAX_VISIBLE_AVATARS).map((c) => (
|
||||
<div key={c.id}>{c.name}</div>
|
||||
))}
|
||||
>
|
||||
<Avatar size="sm" color="gray">
|
||||
+{contributors.length - MAX_VISIBLE_AVATARS}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Avatar.Group>
|
||||
</Tooltip.Group>
|
||||
{contributors.length === 1 && (
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{historyItem.lastUpdatedBy?.name}
|
||||
{contributors[0].name}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
|
||||
name={historyItem.lastUpdatedBy?.name}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{historyItem.lastUpdatedBy?.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
@@ -62,11 +62,18 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
||||
|
||||
const selectData = useMemo(
|
||||
() =>
|
||||
historyItems.map((item) => ({
|
||||
value: item.id,
|
||||
label: formattedDate(new Date(item.createdAt)),
|
||||
userName: item.lastUpdatedBy?.name,
|
||||
})),
|
||||
historyItems.map((item) => {
|
||||
const contributors = item.contributors;
|
||||
const hasContributors = contributors && contributors.length > 0;
|
||||
const names = hasContributors
|
||||
? contributors.map((c) => c.name).join(", ")
|
||||
: item.lastUpdatedBy?.name;
|
||||
return {
|
||||
value: item.id,
|
||||
label: formattedDate(new Date(item.createdAt)),
|
||||
userName: names,
|
||||
};
|
||||
}),
|
||||
[historyItems],
|
||||
);
|
||||
|
||||
|
||||
@@ -18,4 +18,5 @@ export interface IPageHistory {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUpdatedBy: IPageHistoryUser;
|
||||
contributors?: IPageHistoryUser[];
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
|
||||
@@ -54,11 +54,11 @@ import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||
import {
|
||||
useClipboard,
|
||||
useDisclosure,
|
||||
useElementSize,
|
||||
useMergedRef,
|
||||
} from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { dfs } from "react-arborist/dist/module/utils";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
buildPageUrl,
|
||||
buildSharedPageUrl,
|
||||
} from "@/features/page/page.utils.ts";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, CopyButton, Group, Text, TextInput } from "@mantine/core";
|
||||
import { Button, Group, Text, TextInput } from "@mantine/core";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function WorkspaceInviteSection() {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
||||
// polyfilled to support execCommand fallback
|
||||
import { useState } from "react";
|
||||
import { execCommandCopy } from "@docmost/editor-ext";
|
||||
|
||||
export type UseClipboardOptions = {
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export type UseClipboardReturnValue = {
|
||||
copy: (value: string) => void;
|
||||
reset: () => void;
|
||||
error: Error | null;
|
||||
copied: boolean;
|
||||
};
|
||||
|
||||
export function useClipboard(
|
||||
options: UseClipboardOptions = { timeout: 2000 },
|
||||
): UseClipboardReturnValue {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyTimeout, setCopyTimeout] = useState<number | null>(null);
|
||||
|
||||
const handleCopyResult = (value: boolean) => {
|
||||
window.clearTimeout(copyTimeout!);
|
||||
setCopyTimeout(window.setTimeout(() => setCopied(false), options.timeout));
|
||||
setCopied(value);
|
||||
};
|
||||
|
||||
const copy = (value: string) => {
|
||||
if ("clipboard" in navigator) {
|
||||
navigator.clipboard
|
||||
.writeText(value)
|
||||
.then(() => handleCopyResult(true))
|
||||
.catch(() => {
|
||||
try {
|
||||
execCommandCopy(value);
|
||||
handleCopyResult(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("Failed to copy"));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
execCommandCopy(value);
|
||||
handleCopyResult(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("Failed to copy"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setCopied(false);
|
||||
setError(null);
|
||||
window.clearTimeout(copyTimeout!);
|
||||
};
|
||||
|
||||
return { copy, reset, error, copied };
|
||||
}
|
||||
+12
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.25.2",
|
||||
"version": "0.25.3",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -56,8 +56,8 @@
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.1.13",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@react-email/components": "0.0.28",
|
||||
"@react-email/render": "1.0.2",
|
||||
"@react-email/components": "1.0.7",
|
||||
"@react-email/render": "2.0.4",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"ai": "^6.0.37",
|
||||
"ai-sdk-ollama": "^3.1.1",
|
||||
@@ -111,32 +111,32 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@nestjs/cli": "^11.0.4",
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.1",
|
||||
"@nestjs/testing": "^11.0.10",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/debounce": "^1.2.4",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-google-oauth20": "^2.0.16",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"globals": "^15.15.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest": "^30.2.0",
|
||||
"kysely-codegen": "^0.19.0",
|
||||
"prettier": "^3.5.1",
|
||||
"react-email": "3.0.2",
|
||||
"react-email": "5.2.8",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-loader": "^9.5.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Hocuspocus, Document } from '@hocuspocus/server';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
prosemirrorNodeToYElement,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
import * as Y from 'yjs';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
|
||||
export type CollabEventHandlers = ReturnType<
|
||||
CollaborationHandler['getHandlers']
|
||||
@@ -20,6 +27,44 @@ export class CollaborationHandler {
|
||||
// const fragment = doc.getXmlFragment('default');
|
||||
//});
|
||||
},
|
||||
updatePageContent: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
prosemirrorJson: any;
|
||||
operation: string;
|
||||
user: User;
|
||||
},
|
||||
) => {
|
||||
const { prosemirrorJson, operation, user } = payload;
|
||||
this.logger.debug('Updating page content via yjs', documentName);
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
{ user },
|
||||
(doc) => {
|
||||
const fragment = doc.getXmlFragment('default');
|
||||
|
||||
if (operation === 'replace') {
|
||||
if (fragment.length > 0) {
|
||||
fragment.delete(0, fragment.length);
|
||||
}
|
||||
|
||||
const newDoc = TiptapTransformer.toYdoc(
|
||||
prosemirrorJson,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newDoc));
|
||||
} else {
|
||||
const newContent = prosemirrorJson.content || [];
|
||||
const yElements = newContent.map(prosemirrorNodeToYElement);
|
||||
const position =
|
||||
operation === 'prepend' ? 0 : fragment.length;
|
||||
fragment.insert(position, yElements);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import {
|
||||
Global,
|
||||
Logger,
|
||||
Module,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
@@ -7,9 +13,10 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { WebSocket } from 'ws';
|
||||
import { TokenModule } from '../core/auth/token.module';
|
||||
import { HistoryListener } from './listeners/history.listener';
|
||||
import { HistoryProcessor } from './processors/history.processor';
|
||||
import { LoggerExtension } from './extensions/logger.extension';
|
||||
import { CollaborationHandler } from './collaboration.handler';
|
||||
import { CollabHistoryService } from './services/collab-history.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
@@ -17,7 +24,8 @@ import { CollaborationHandler } from './collaboration.handler';
|
||||
AuthenticationExtension,
|
||||
PersistenceExtension,
|
||||
LoggerExtension,
|
||||
HistoryListener,
|
||||
HistoryProcessor,
|
||||
CollabHistoryService,
|
||||
CollaborationHandler,
|
||||
],
|
||||
exports: [CollaborationGateway],
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Highlight,
|
||||
UniqueID,
|
||||
addUniqueIdsToDoc,
|
||||
htmlToMarkdown,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
@@ -42,6 +43,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||
//import { generateJSON } from '@tiptap/html';
|
||||
import { Node, Schema } from '@tiptap/pm/model';
|
||||
import * as Y from 'yjs';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
@@ -161,3 +163,37 @@ function stripUnknownNodes(
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
|
||||
if (node.type === 'text') {
|
||||
const ytext = new Y.XmlText();
|
||||
ytext.insert(0, node.text || '');
|
||||
if (node.marks?.length > 0) {
|
||||
const attrs: Record<string, any> = {};
|
||||
for (const mark of node.marks) {
|
||||
attrs[mark.type] = mark.attrs || true;
|
||||
}
|
||||
ytext.format(0, node.text?.length || 0, attrs);
|
||||
}
|
||||
return ytext;
|
||||
}
|
||||
|
||||
const element = new Y.XmlElement(node.type);
|
||||
if (node.attrs) {
|
||||
for (const [key, value] of Object.entries(node.attrs)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
element.setAttribute(key, value as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.content?.length > 0) {
|
||||
const children = node.content.map(prosemirrorNodeToYElement);
|
||||
element.insert(0, children);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export function jsonToMarkdown(tiptapJson: any): string {
|
||||
const html = jsonToHtml(tiptapJson);
|
||||
return htmlToMarkdown(html);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
||||
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
||||
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
||||
@@ -13,7 +13,6 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
@@ -22,8 +21,17 @@ import {
|
||||
extractPageMentions,
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface';
|
||||
import {
|
||||
IPageBacklinkJob,
|
||||
IPageHistoryJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { CollabHistoryService } from '../services/collab-history.service';
|
||||
import {
|
||||
HISTORY_FAST_INTERVAL,
|
||||
HISTORY_FAST_THRESHOLD,
|
||||
HISTORY_INTERVAL,
|
||||
} from '../constants';
|
||||
|
||||
@Injectable()
|
||||
export class PersistenceExtension implements Extension {
|
||||
@@ -33,9 +41,10 @@ export class PersistenceExtension implements Extension {
|
||||
constructor(
|
||||
private readonly pageRepo: PageRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private eventEmitter: EventEmitter2,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
|
||||
private readonly collabHistory: CollabHistoryService,
|
||||
) {}
|
||||
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
@@ -101,6 +110,7 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
|
||||
let page: Page = null;
|
||||
const editingUserIds = this.consumeContributors(documentName);
|
||||
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
@@ -123,13 +133,9 @@ export class PersistenceExtension implements Extension {
|
||||
let contributorIds = undefined;
|
||||
try {
|
||||
const existingContributors = page.contributorIds || [];
|
||||
const contributorSet = this.contributors.get(documentName);
|
||||
contributorSet.add(page.creatorId);
|
||||
const newContributors = [...contributorSet];
|
||||
contributorIds = Array.from(
|
||||
new Set([...existingContributors, ...newContributors]),
|
||||
new Set([...existingContributors, ...editingUserIds, page.creatorId]),
|
||||
);
|
||||
this.contributors.delete(documentName);
|
||||
} catch (err) {
|
||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||
}
|
||||
@@ -153,13 +159,7 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
|
||||
if (page) {
|
||||
this.eventEmitter.emit('collab.page.updated', {
|
||||
page: {
|
||||
...page,
|
||||
content: tiptapJson,
|
||||
lastUpdatedById: context.user.id,
|
||||
},
|
||||
});
|
||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||
|
||||
const mentions = extractMentions(tiptapJson);
|
||||
const pageMentions = extractPageMentions(mentions);
|
||||
@@ -174,12 +174,15 @@ export class PersistenceExtension implements Extension {
|
||||
pageIds: [pageId],
|
||||
workspaceId: page.workspaceId,
|
||||
});
|
||||
|
||||
await this.enqueuePageHistory(page);
|
||||
}
|
||||
}
|
||||
|
||||
async onChange(data: onChangePayload) {
|
||||
const documentName = data.documentName;
|
||||
const userId = data.context?.user.id;
|
||||
const userId = data.context?.user?.id;
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
if (!this.contributors.has(documentName)) {
|
||||
@@ -193,4 +196,26 @@ export class PersistenceExtension implements Extension {
|
||||
const documentName = data.documentName;
|
||||
this.contributors.delete(documentName);
|
||||
}
|
||||
|
||||
private consumeContributors(documentName: string): string[] {
|
||||
const contributorSet = this.contributors.get(documentName);
|
||||
if (!contributorSet) return [];
|
||||
const userIds = [...contributorSet];
|
||||
this.contributors.delete(documentName);
|
||||
return userIds;
|
||||
}
|
||||
|
||||
private async enqueuePageHistory(page: Page): Promise<void> {
|
||||
const pageAge = Date.now() - new Date(page.createdAt).getTime();
|
||||
const delay =
|
||||
pageAge < HISTORY_FAST_THRESHOLD
|
||||
? HISTORY_FAST_INTERVAL
|
||||
: HISTORY_INTERVAL;
|
||||
|
||||
await this.historyQueue.add(
|
||||
QueueJob.PAGE_HISTORY,
|
||||
{ pageId: page.id } as IPageHistoryJob,
|
||||
{ jobId: page.id, delay },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
|
||||
export class UpdatedPageEvent {
|
||||
page: Page;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HistoryListener {
|
||||
private readonly logger = new Logger(HistoryListener.name);
|
||||
|
||||
constructor(
|
||||
private readonly pageHistoryRepo: PageHistoryRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
@OnEvent('collab.page.updated')
|
||||
async handleCreatePageHistory(event: UpdatedPageEvent) {
|
||||
const { page } = event;
|
||||
|
||||
const pageCreationTime = new Date(page.createdAt).getTime();
|
||||
const currentTime = Date.now();
|
||||
const FIVE_MINUTES = this.environmentService.isDevelopment()
|
||||
? 60 * 1000
|
||||
: 5 * 60 * 1000;
|
||||
|
||||
if (currentTime - pageCreationTime < FIVE_MINUTES) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id, {
|
||||
includeContent: true,
|
||||
});
|
||||
|
||||
if (
|
||||
!lastHistory ||
|
||||
(!isDeepStrictEqual(lastHistory.content, page.content) &&
|
||||
currentTime - new Date(lastHistory.createdAt).getTime() >= FIVE_MINUTES)
|
||||
) {
|
||||
try {
|
||||
await this.pageHistoryRepo.saveHistory(page);
|
||||
this.logger.debug(`New history created for: ${page.id}`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to create history for page: ${page.id}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
|
||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { CollabHistoryService } from '../services/collab-history.service';
|
||||
|
||||
@Processor(QueueName.HISTORY_QUEUE)
|
||||
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(HistoryProcessor.name);
|
||||
|
||||
constructor(
|
||||
private readonly pageHistoryRepo: PageHistoryRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly collabHistory: CollabHistoryService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<IPageHistoryJob, void>): Promise<void> {
|
||||
if (job.name !== QueueJob.PAGE_HISTORY) return;
|
||||
|
||||
try {
|
||||
const { pageId } = job.data;
|
||||
|
||||
const page = await this.pageRepo.findById(pageId, {
|
||||
includeContent: true,
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
this.logger.warn(`Page ${pageId} not found, skipping history`);
|
||||
await this.collabHistory.clearContributors(pageId);
|
||||
return;
|
||||
}
|
||||
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
pageId,
|
||||
{ includeContent: true },
|
||||
);
|
||||
|
||||
if (
|
||||
!lastHistory ||
|
||||
!isDeepStrictEqual(lastHistory.content, page.content)
|
||||
) {
|
||||
const contributorIds =
|
||||
await this.collabHistory.popContributors(pageId);
|
||||
|
||||
try {
|
||||
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
|
||||
this.logger.debug(`History created for page: ${pageId}`);
|
||||
} catch (err) {
|
||||
await this.collabHistory.addContributors(
|
||||
pageId,
|
||||
contributorIds,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('active')
|
||||
onActive(job: Job) {
|
||||
this.logger.debug(`Processing ${job.name} for page: ${job.data.pageId}`);
|
||||
}
|
||||
|
||||
@OnWorkerEvent('failed')
|
||||
onError(job: Job) {
|
||||
this.logger.error(
|
||||
`Failed ${job.name} for page: ${job.data.pageId}. Reason: ${job.failedReason}`,
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { HealthModule } from '../../integrations/health/health.module';
|
||||
import { CollaborationController } from './collaboration.controller';
|
||||
import { LoggerModule } from '../../common/logger/logger.module';
|
||||
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
|
||||
import { RedisConfigService } from '../../integrations/redis/redis-config.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -19,6 +21,9 @@ import { LoggerModule } from '../../common/logger/logger.module';
|
||||
QueueModule,
|
||||
HealthModule,
|
||||
EventEmitterModule.forRoot(),
|
||||
RedisModule.forRootAsync({
|
||||
useClass: RedisConfigService,
|
||||
}),
|
||||
],
|
||||
controllers: [
|
||||
AppController,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
const REDIS_KEY_PREFIX = 'history:contributors:';
|
||||
|
||||
@Injectable()
|
||||
export class CollabHistoryService {
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(private readonly redisService: RedisService) {
|
||||
this.redis = this.redisService.getOrThrow();
|
||||
}
|
||||
|
||||
async addContributors(pageId: string, userIds: string[]): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
await this.redis.sadd(REDIS_KEY_PREFIX + pageId, ...userIds);
|
||||
}
|
||||
|
||||
async popContributors(pageId: string): Promise<string[]> {
|
||||
const key = REDIS_KEY_PREFIX + pageId;
|
||||
const count = await this.redis.scard(key);
|
||||
if (count === 0) return [];
|
||||
return await this.redis.spop(key, count);
|
||||
}
|
||||
|
||||
async clearContributors(pageId: string): Promise<void> {
|
||||
await this.redis.del(REDIS_KEY_PREFIX + pageId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
initProseMirrorDoc,
|
||||
relativePositionToAbsolutePosition,
|
||||
} from 'y-prosemirror';
|
||||
import * as Y from 'yjs';
|
||||
import { Document } from '@hocuspocus/server';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import { tiptapExtensions } from './collaboration.util';
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -464,7 +464,8 @@ export class AttachmentController {
|
||||
'Cache-Control': `${cacheScope}, max-age=3600`,
|
||||
});
|
||||
|
||||
if (fileSize) {
|
||||
const isSvg = attachment.fileExt === '.svg';
|
||||
if (fileSize && !isSvg) {
|
||||
res.header('Content-Length', fileSize);
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export class AttachmentService {
|
||||
if (isUpdate) {
|
||||
attachment = await this.attachmentRepo.updateAttachment(
|
||||
{
|
||||
fileSize: preparedFile.fileSize,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
attachmentId,
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import {
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export type ContentFormat = 'json' | 'markdown' | 'html';
|
||||
|
||||
export class CreatePageDto {
|
||||
@IsOptional()
|
||||
@@ -15,4 +24,12 @@ export class CreatePageDto {
|
||||
|
||||
@IsUUID()
|
||||
spaceId: string;
|
||||
|
||||
@IsOptional()
|
||||
content?: string | object;
|
||||
|
||||
@ValidateIf((o) => o.content !== undefined)
|
||||
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
||||
@IsIn(['json', 'markdown', 'html'])
|
||||
format?: ContentFormat;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
import { ContentFormat } from './create-page.dto';
|
||||
|
||||
export class PageIdDto {
|
||||
@IsString()
|
||||
@@ -30,6 +34,11 @@ export class PageInfoDto extends PageIdDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeContent: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value?.toLowerCase())
|
||||
@IsIn(['json', 'markdown', 'html'])
|
||||
format?: ContentFormat;
|
||||
}
|
||||
|
||||
export class DeletePageDto extends PageIdDto {
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreatePageDto } from './create-page.dto';
|
||||
import { IsString } from 'class-validator';
|
||||
import { CreatePageDto, ContentFormat } from './create-page.dto';
|
||||
import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export type ContentOperation = 'append' | 'prepend' | 'replace';
|
||||
|
||||
export class UpdatePageDto extends PartialType(CreatePageDto) {
|
||||
@IsString()
|
||||
pageId: string;
|
||||
|
||||
@IsOptional()
|
||||
content?: string | object;
|
||||
|
||||
@ValidateIf((o) => o.content !== undefined)
|
||||
@Transform(({ value }) => value?.toLowerCase())
|
||||
@IsIn(['append', 'prepend', 'replace'])
|
||||
operation?: ContentOperation;
|
||||
|
||||
@ValidateIf((o) => o.content !== undefined)
|
||||
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
|
||||
@IsIn(['json', 'markdown', 'html'])
|
||||
format?: ContentFormat;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
import {
|
||||
jsonToHtml,
|
||||
jsonToMarkdown,
|
||||
} from '../../collaboration/collaboration.util';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@@ -66,6 +70,17 @@ export class PageController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
if (dto.format && dto.format !== 'json' && page.content) {
|
||||
const contentOutput =
|
||||
dto.format === 'markdown'
|
||||
? jsonToMarkdown(page.content)
|
||||
: jsonToHtml(page.content);
|
||||
return {
|
||||
...page,
|
||||
content: contentOutput,
|
||||
};
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -84,7 +99,25 @@ export class PageController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.create(user.id, workspace.id, createPageDto);
|
||||
const page = await this.pageService.create(
|
||||
user.id,
|
||||
workspace.id,
|
||||
createPageDto,
|
||||
);
|
||||
|
||||
if (
|
||||
createPageDto.format &&
|
||||
createPageDto.format !== 'json' &&
|
||||
page.content
|
||||
) {
|
||||
const contentOutput =
|
||||
createPageDto.format === 'markdown'
|
||||
? jsonToMarkdown(page.content)
|
||||
: jsonToHtml(page.content);
|
||||
return { ...page, content: contentOutput };
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -101,7 +134,25 @@ export class PageController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.update(page, updatePageDto, user.id);
|
||||
const updatedPage = await this.pageService.update(
|
||||
page,
|
||||
updatePageDto,
|
||||
user,
|
||||
);
|
||||
|
||||
if (
|
||||
updatePageDto.format &&
|
||||
updatePageDto.format !== 'json' &&
|
||||
updatedPage.content
|
||||
) {
|
||||
const contentOutput =
|
||||
updatePageDto.format === 'markdown'
|
||||
? jsonToMarkdown(updatedPage.content)
|
||||
: jsonToHtml(updatedPage.content);
|
||||
return { ...updatedPage, content: contentOutput };
|
||||
}
|
||||
|
||||
return updatedPage;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -4,11 +4,12 @@ import { PageController } from './page.controller';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||
exports: [PageService, PageHistoryService],
|
||||
imports: [StorageModule],
|
||||
imports: [StorageModule, CollaborationModule],
|
||||
})
|
||||
export class PageModule {}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CreatePageDto } from '../dto/create-page.dto';
|
||||
import { UpdatePageDto } from '../dto/update-page.dto';
|
||||
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
|
||||
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
isAttachmentNode,
|
||||
removeMarkTypeFromDoc,
|
||||
} from '../../../common/helpers/prosemirror/utils';
|
||||
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
||||
import {
|
||||
htmlToJson,
|
||||
jsonToNode,
|
||||
jsonToText,
|
||||
} from 'src/collaboration/collaboration.util';
|
||||
import {
|
||||
CopyPageMapEntry,
|
||||
ICopyPageAttachment,
|
||||
@@ -40,6 +44,8 @@ import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import { EventName } from '../../../common/events/event.contants';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
||||
import { markdownToHtml } from '@docmost/editor-ext';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -53,6 +59,7 @@ export class PageService {
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
private eventEmitter: EventEmitter2,
|
||||
private collaborationGateway: CollaborationGateway,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
@@ -88,7 +95,22 @@ export class PageService {
|
||||
parentPageId = parentPage.id;
|
||||
}
|
||||
|
||||
const createdPage = await this.pageRepo.insertPage({
|
||||
let content = undefined;
|
||||
let textContent = undefined;
|
||||
let ydoc = undefined;
|
||||
|
||||
if (createPageDto?.content && createPageDto?.format) {
|
||||
const prosemirrorJson = await this.parseProsemirrorContent(
|
||||
createPageDto.content,
|
||||
createPageDto.format,
|
||||
);
|
||||
|
||||
content = prosemirrorJson;
|
||||
textContent = jsonToText(prosemirrorJson);
|
||||
ydoc = createYdocFromJson(prosemirrorJson);
|
||||
}
|
||||
|
||||
return this.pageRepo.insertPage({
|
||||
slugId: generateSlugId(),
|
||||
title: createPageDto.title,
|
||||
position: await this.nextPagePosition(
|
||||
@@ -101,9 +123,10 @@ export class PageService {
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
lastUpdatedById: userId,
|
||||
content,
|
||||
textContent,
|
||||
ydoc,
|
||||
});
|
||||
|
||||
return createdPage;
|
||||
}
|
||||
|
||||
async nextPagePosition(spaceId: string, parentPageId?: string) {
|
||||
@@ -150,23 +173,37 @@ export class PageService {
|
||||
async update(
|
||||
page: Page,
|
||||
updatePageDto: UpdatePageDto,
|
||||
userId: string,
|
||||
user: User,
|
||||
): Promise<Page> {
|
||||
const contributors = new Set<string>(page.contributorIds);
|
||||
contributors.add(userId);
|
||||
contributors.add(user.id);
|
||||
const contributorIds = Array.from(contributors);
|
||||
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
title: updatePageDto.title,
|
||||
icon: updatePageDto.icon,
|
||||
lastUpdatedById: userId,
|
||||
lastUpdatedById: user.id,
|
||||
updatedAt: new Date(),
|
||||
contributorIds: contributorIds,
|
||||
},
|
||||
page.id,
|
||||
);
|
||||
|
||||
if (
|
||||
updatePageDto.content &&
|
||||
updatePageDto.operation &&
|
||||
updatePageDto.format
|
||||
) {
|
||||
await this.updatePageContent(
|
||||
page.id,
|
||||
updatePageDto.content,
|
||||
updatePageDto.operation,
|
||||
updatePageDto.format,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.pageRepo.findById(page.id, {
|
||||
includeSpace: true,
|
||||
includeContent: true,
|
||||
@@ -176,6 +213,23 @@ export class PageService {
|
||||
});
|
||||
}
|
||||
|
||||
async updatePageContent(
|
||||
pageId: string,
|
||||
content: string | object,
|
||||
operation: ContentOperation,
|
||||
format: ContentFormat,
|
||||
user: User,
|
||||
): Promise<void> {
|
||||
const prosemirrorJson = await this.parseProsemirrorContent(content, format);
|
||||
|
||||
const documentName = `page.${pageId}`;
|
||||
await this.collaborationGateway.handleYjsEvent(
|
||||
'updatePageContent',
|
||||
documentName,
|
||||
{ operation, prosemirrorJson, user },
|
||||
);
|
||||
}
|
||||
|
||||
async getSidebarPages(
|
||||
spaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
@@ -209,7 +263,11 @@ export class PageService {
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'position', direction: 'asc', orderModifier: (ob) => ob.collate('C').asc() },
|
||||
{
|
||||
expression: 'position',
|
||||
direction: 'asc',
|
||||
orderModifier: (ob) => ob.collate('C').asc(),
|
||||
},
|
||||
{ expression: 'id', direction: 'asc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
@@ -653,4 +711,36 @@ export class PageService {
|
||||
): Promise<void> {
|
||||
await this.pageRepo.removePage(pageId, userId, workspaceId);
|
||||
}
|
||||
|
||||
private async parseProsemirrorContent(
|
||||
content: string | object,
|
||||
format: ContentFormat,
|
||||
): Promise<any> {
|
||||
let prosemirrorJson: any;
|
||||
|
||||
switch (format) {
|
||||
case 'markdown': {
|
||||
const html = await markdownToHtml(content as string);
|
||||
prosemirrorJson = htmlToJson(html as string);
|
||||
break;
|
||||
}
|
||||
case 'html': {
|
||||
prosemirrorJson = htmlToJson(content as string);
|
||||
break;
|
||||
}
|
||||
case 'json':
|
||||
default: {
|
||||
prosemirrorJson = content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
jsonToNode(prosemirrorJson);
|
||||
} catch (err) {
|
||||
throw new BadRequestException('Invalid content format');
|
||||
}
|
||||
|
||||
return prosemirrorJson;
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('page_history')
|
||||
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}'))
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('page_history')
|
||||
.dropColumn('contributor_ids')
|
||||
.execute();
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { ExpressionBuilder } from 'kysely';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
|
||||
@Injectable()
|
||||
@@ -25,6 +25,7 @@ export class PageHistoryRepo {
|
||||
'icon',
|
||||
'coverPhoto',
|
||||
'lastUpdatedById',
|
||||
'contributorIds',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
@@ -44,6 +45,7 @@ export class PageHistoryRepo {
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.select((eb) => this.withLastUpdatedBy(eb))
|
||||
.select((eb) => this.withContributors(eb))
|
||||
.where('id', '=', pageHistoryId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
@@ -60,7 +62,10 @@ export class PageHistoryRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async saveHistory(page: Page, trx?: KyselyTransaction): Promise<void> {
|
||||
async saveHistory(
|
||||
page: Page,
|
||||
opts?: { contributorIds?: string[]; trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
await this.insertPageHistory(
|
||||
{
|
||||
pageId: page.id,
|
||||
@@ -70,10 +75,11 @@ export class PageHistoryRepo {
|
||||
icon: page.icon,
|
||||
coverPhoto: page.coverPhoto,
|
||||
lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
|
||||
contributorIds: opts?.contributorIds,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId: page.workspaceId,
|
||||
},
|
||||
trx,
|
||||
opts?.trx,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +88,7 @@ export class PageHistoryRepo {
|
||||
.selectFrom('pageHistory')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withLastUpdatedBy(eb))
|
||||
.select((eb) => this.withContributors(eb))
|
||||
.where('pageId', '=', pageId);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
@@ -120,4 +127,17 @@ export class PageHistoryRepo {
|
||||
.whereRef('users.id', '=', 'pageHistory.lastUpdatedById'),
|
||||
).as('lastUpdatedBy');
|
||||
}
|
||||
|
||||
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef(
|
||||
'users.id',
|
||||
'=',
|
||||
sql`ANY(${eb.ref('pageHistory.contributorIds')})`,
|
||||
),
|
||||
).as('contributors');
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -199,6 +199,7 @@ export interface GroupUsers {
|
||||
|
||||
export interface PageHistory {
|
||||
content: Json | null;
|
||||
contributorIds: Generated<string[] | null>;
|
||||
coverPhoto: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
icon: string | null;
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum QueueName {
|
||||
FILE_TASK_QUEUE = '{file-task-queue}',
|
||||
SEARCH_QUEUE = '{search-queue}',
|
||||
AI_QUEUE = '{ai-queue}',
|
||||
HISTORY_QUEUE = '{history-queue}',
|
||||
}
|
||||
|
||||
export enum QueueJob {
|
||||
@@ -58,4 +59,6 @@ export enum QueueJob {
|
||||
|
||||
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
|
||||
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
|
||||
|
||||
PAGE_HISTORY = 'page-history',
|
||||
}
|
||||
|
||||
@@ -9,4 +9,8 @@ export interface IPageBacklinkJob {
|
||||
|
||||
export interface IStripeSeatsSyncJob {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface IPageHistoryJob {
|
||||
pageId: string;
|
||||
}
|
||||
@@ -73,6 +73,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
|
||||
attempts: 1,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.HISTORY_QUEUE,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
attempts: 2,
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [BullModule],
|
||||
providers: [BacklinksProcessor],
|
||||
|
||||
+22
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "docmost",
|
||||
"homepage": "https://docmost.com",
|
||||
"version": "0.25.2",
|
||||
"version": "0.25.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nx run-many -t build",
|
||||
@@ -56,12 +56,13 @@
|
||||
"@tiptap/react": "3.17.1",
|
||||
"@tiptap/starter-kit": "3.17.1",
|
||||
"@tiptap/suggestion": "3.17.1",
|
||||
"@tiptap/y-tiptap": "^3.0.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"bytes": "^3.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"diff": "8.0.3",
|
||||
"dompurify": "^3.2.6",
|
||||
"dompurify": "^3.3.1",
|
||||
"fractional-indexing-jittered": "^1.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"image-dimensions": "^2.5.0",
|
||||
@@ -78,12 +79,12 @@
|
||||
"yjs": "^13.6.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nx/js": "20.4.5",
|
||||
"@nx/js": "22.5.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"nx": "20.4.5",
|
||||
"nx": "22.5.0",
|
||||
"tsx": "^4.19.3"
|
||||
},
|
||||
"workspaces": {
|
||||
@@ -101,7 +102,23 @@
|
||||
"jsdom": "25.0.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"prosemirror-changeset": "2.3.1",
|
||||
"y-prosemirror": "1.3.7"
|
||||
"y-prosemirror": "1.3.7",
|
||||
"glob": "10.5.0",
|
||||
"lodash": "4.17.23",
|
||||
"ws": "8.19.0",
|
||||
"cross-spawn": "7.0.5",
|
||||
"dompurify": "3.3.1",
|
||||
"tmp": "0.2.5",
|
||||
"lodash-es": "4.17.23",
|
||||
"@tiptap/core": "3.17.1",
|
||||
"@tiptap/pm": "3.17.1",
|
||||
"@tiptap/starter-kit": "3.17.1",
|
||||
"@tiptap/extension-blockquote": "3.17.1",
|
||||
"@tiptap/extension-bold": "3.17.0",
|
||||
"@tiptap/extension-bubble-menu": "3.17.1",
|
||||
"@tiptap/extension-bullet-list": "3.17.1",
|
||||
"@tiptap/extension-list": "3.17.1",
|
||||
"@tiptap/extension-code": "3.17.1"
|
||||
},
|
||||
"neverBuiltDependencies": []
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import TiptapHeading, {
|
||||
import { mergeAttributes } from "@tiptap/react";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { copyToClipboard } from "../utils";
|
||||
|
||||
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
|
||||
const successIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="color: forestgreen;" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="m10.6 16.6l7.05-7.05l-1.4-1.4l-5.65 5.65l-2.85-2.85l-1.4 1.4zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>`;
|
||||
|
||||
export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
|
||||
// @ts-ignore
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
@@ -41,7 +43,7 @@ export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
|
||||
const id = node.attrs.id;
|
||||
const baseUrl = window.location.href.split('#')[0];
|
||||
const url = `${baseUrl}#${id}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
copyToClipboard(url);
|
||||
linkBtnContent.innerHTML = successIcon;
|
||||
setTimeout(
|
||||
() => (linkBtnContent.innerHTML = copyIcon),
|
||||
|
||||
@@ -384,3 +384,25 @@ export function sanitizeUrl(url: string | undefined): string {
|
||||
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz";
|
||||
export const generateNodeId = customAlphabet(alphabet, 12);
|
||||
|
||||
export function copyToClipboard(text: string): void {
|
||||
if ("clipboard" in navigator) {
|
||||
navigator.clipboard.writeText(text).catch(() => {
|
||||
execCommandCopy(text);
|
||||
});
|
||||
} else {
|
||||
execCommandCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
export function execCommandCopy(text: string): void {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.top = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
Generated
+4352
-4051
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user