mirror of
https://github.com/docmost/docmost.git
synced 2026-05-17 23:14:07 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4573dc1249 | |||
| f7a9004c73 |
@@ -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 { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
List,
|
List,
|
||||||
Code,
|
Code,
|
||||||
CopyButton,
|
|
||||||
Alert,
|
Alert,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { CopyButton } from "@/components/common/copy-button";
|
||||||
import {
|
import {
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
PinInput,
|
PinInput,
|
||||||
Alert,
|
Alert,
|
||||||
List,
|
List,
|
||||||
CopyButton,
|
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
Collapse,
|
Collapse,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { CopyButton } from "@/components/common/copy-button";
|
||||||
import {
|
import {
|
||||||
IconQrcode,
|
IconQrcode,
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
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 { useEffect, useState } from "react";
|
||||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||||
import classes from "./code-block.module.css";
|
import classes from "./code-block.module.css";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
import { Text, Group, UnstyledButton } from "@mantine/core";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
import classes from "./css/history.module.css";
|
import classes from "./css/history.module.css";
|
||||||
@@ -6,8 +6,6 @@ import clsx from "clsx";
|
|||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback } from "react";
|
||||||
|
|
||||||
const MAX_VISIBLE_AVATARS = 5;
|
|
||||||
|
|
||||||
interface HistoryItemProps {
|
interface HistoryItemProps {
|
||||||
historyItem: IPageHistory;
|
historyItem: IPageHistory;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -33,9 +31,6 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
onHover?.(historyItem.id, index);
|
onHover?.(historyItem.id, index);
|
||||||
}, [onHover, historyItem.id, index]);
|
}, [onHover, historyItem.id, index]);
|
||||||
|
|
||||||
const contributors = historyItem.contributors;
|
|
||||||
const hasContributors = contributors && contributors.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
p="xs"
|
p="xs"
|
||||||
@@ -44,54 +39,25 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
onMouseLeave={onHoverEnd}
|
onMouseLeave={onHoverEnd}
|
||||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||||
>
|
>
|
||||||
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
<Group wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="sm">
|
||||||
|
{formattedDate(new Date(historyItem.createdAt))}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Group gap={6} wrap="nowrap" mt={4}>
|
<div style={{ flex: 1 }}>
|
||||||
{hasContributors ? (
|
<Group gap={4} wrap="nowrap">
|
||||||
<>
|
<CustomAvatar
|
||||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
size="sm"
|
||||||
<Avatar.Group spacing={8}>
|
avatarUrl={historyItem.lastUpdatedBy?.avatarUrl}
|
||||||
{contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => (
|
name={historyItem.lastUpdatedBy?.name}
|
||||||
<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}>
|
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||||
{contributors[0].name}
|
{historyItem.lastUpdatedBy?.name}
|
||||||
</Text>
|
</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>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,18 +62,11 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) {
|
|||||||
|
|
||||||
const selectData = useMemo(
|
const selectData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
historyItems.map((item) => {
|
historyItems.map((item) => ({
|
||||||
const contributors = item.contributors;
|
value: item.id,
|
||||||
const hasContributors = contributors && contributors.length > 0;
|
label: formattedDate(new Date(item.createdAt)),
|
||||||
const names = hasContributors
|
userName: item.lastUpdatedBy?.name,
|
||||||
? contributors.map((c) => c.name).join(", ")
|
})),
|
||||||
: item.lastUpdatedBy?.name;
|
|
||||||
return {
|
|
||||||
value: item.id,
|
|
||||||
label: formattedDate(new Date(item.createdAt)),
|
|
||||||
userName: names,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
[historyItems],
|
[historyItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -18,5 +18,4 @@ export interface IPageHistory {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastUpdatedBy: IPageHistoryUser;
|
lastUpdatedBy: IPageHistoryUser;
|
||||||
contributors?: IPageHistoryUser[];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ 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, useAtomValue } 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 { 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 { 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";
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
|||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||||
import {
|
import {
|
||||||
useClipboard,
|
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
useElementSize,
|
useElementSize,
|
||||||
useMergedRef,
|
useMergedRef,
|
||||||
} from "@mantine/hooks";
|
} from "@mantine/hooks";
|
||||||
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { dfs } from "react-arborist/dist/module/utils";
|
import { dfs } from "react-arborist/dist/module/utils";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
buildPageUrl,
|
buildPageUrl,
|
||||||
buildSharedPageUrl,
|
buildSharedPageUrl,
|
||||||
} from "@/features/page/page.utils.ts";
|
} from "@/features/page/page.utils.ts";
|
||||||
import { useClipboard } from "@mantine/hooks";
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
|
import { useDeleteShareMutation } from "@/features/share/queries/share-query.ts";
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@/features/workspace/queries/workspace-query.ts";
|
} from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { notifications } from "@mantine/notifications";
|
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 { getInviteLink } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useEffect, useState } from "react";
|
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";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function WorkspaceInviteSection() {
|
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 };
|
||||||
|
}
|
||||||
@@ -7,10 +7,9 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
|||||||
import { IncomingMessage } from 'http';
|
import { IncomingMessage } from 'http';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { TokenModule } from '../core/auth/token.module';
|
import { TokenModule } from '../core/auth/token.module';
|
||||||
import { HistoryProcessor } from './processors/history.processor';
|
import { HistoryListener } from './listeners/history.listener';
|
||||||
import { LoggerExtension } from './extensions/logger.extension';
|
import { LoggerExtension } from './extensions/logger.extension';
|
||||||
import { CollaborationHandler } from './collaboration.handler';
|
import { CollaborationHandler } from './collaboration.handler';
|
||||||
import { CollabHistoryService } from './services/collab-history.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -18,8 +17,7 @@ import { CollabHistoryService } from './services/collab-history.service';
|
|||||||
AuthenticationExtension,
|
AuthenticationExtension,
|
||||||
PersistenceExtension,
|
PersistenceExtension,
|
||||||
LoggerExtension,
|
LoggerExtension,
|
||||||
HistoryProcessor,
|
HistoryListener,
|
||||||
CollabHistoryService,
|
|
||||||
CollaborationHandler,
|
CollaborationHandler,
|
||||||
],
|
],
|
||||||
exports: [CollaborationGateway],
|
exports: [CollaborationGateway],
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
|
||||||
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
|
||||||
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
|
||||||
@@ -13,6 +13,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|||||||
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 { executeTx } from '@docmost/db/utils';
|
import { executeTx } from '@docmost/db/utils';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
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';
|
||||||
@@ -21,17 +22,8 @@ import {
|
|||||||
extractPageMentions,
|
extractPageMentions,
|
||||||
} from '../../common/helpers/prosemirror/utils';
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import {
|
import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface';
|
||||||
IPageBacklinkJob,
|
|
||||||
IPageHistoryJob,
|
|
||||||
} from '../../integrations/queue/constants/queue.interface';
|
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
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()
|
@Injectable()
|
||||||
export class PersistenceExtension implements Extension {
|
export class PersistenceExtension implements Extension {
|
||||||
@@ -41,10 +33,9 @@ export class PersistenceExtension implements Extension {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
private eventEmitter: EventEmitter2,
|
||||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
|
|
||||||
private readonly collabHistory: CollabHistoryService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||||
@@ -110,7 +101,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let page: Page = null;
|
let page: Page = null;
|
||||||
const editingUserIds = this.consumeContributors(documentName);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await executeTx(this.db, async (trx) => {
|
await executeTx(this.db, async (trx) => {
|
||||||
@@ -133,9 +123,13 @@ export class PersistenceExtension implements Extension {
|
|||||||
let contributorIds = undefined;
|
let contributorIds = undefined;
|
||||||
try {
|
try {
|
||||||
const existingContributors = page.contributorIds || [];
|
const existingContributors = page.contributorIds || [];
|
||||||
|
const contributorSet = this.contributors.get(documentName);
|
||||||
|
contributorSet.add(page.creatorId);
|
||||||
|
const newContributors = [...contributorSet];
|
||||||
contributorIds = Array.from(
|
contributorIds = Array.from(
|
||||||
new Set([...existingContributors, ...editingUserIds, page.creatorId]),
|
new Set([...existingContributors, ...newContributors]),
|
||||||
);
|
);
|
||||||
|
this.contributors.delete(documentName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||||
}
|
}
|
||||||
@@ -159,7 +153,13 @@ export class PersistenceExtension implements Extension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (page) {
|
if (page) {
|
||||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
this.eventEmitter.emit('collab.page.updated', {
|
||||||
|
page: {
|
||||||
|
...page,
|
||||||
|
content: tiptapJson,
|
||||||
|
lastUpdatedById: context.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const mentions = extractMentions(tiptapJson);
|
const mentions = extractMentions(tiptapJson);
|
||||||
const pageMentions = extractPageMentions(mentions);
|
const pageMentions = extractPageMentions(mentions);
|
||||||
@@ -174,8 +174,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
pageIds: [pageId],
|
pageIds: [pageId],
|
||||||
workspaceId: page.workspaceId,
|
workspaceId: page.workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.enqueuePageHistory(page);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,26 +193,4 @@ export class PersistenceExtension implements Extension {
|
|||||||
const documentName = data.documentName;
|
const documentName = data.documentName;
|
||||||
this.contributors.delete(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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
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';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder } from 'kysely';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -25,7 +25,6 @@ export class PageHistoryRepo {
|
|||||||
'icon',
|
'icon',
|
||||||
'coverPhoto',
|
'coverPhoto',
|
||||||
'lastUpdatedById',
|
'lastUpdatedById',
|
||||||
'contributorIds',
|
|
||||||
'spaceId',
|
'spaceId',
|
||||||
'workspaceId',
|
'workspaceId',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
@@ -45,7 +44,6 @@ export class PageHistoryRepo {
|
|||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.select((eb) => this.withLastUpdatedBy(eb))
|
.select((eb) => this.withLastUpdatedBy(eb))
|
||||||
.select((eb) => this.withContributors(eb))
|
|
||||||
.where('id', '=', pageHistoryId)
|
.where('id', '=', pageHistoryId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
@@ -62,10 +60,7 @@ export class PageHistoryRepo {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveHistory(
|
async saveHistory(page: Page, trx?: KyselyTransaction): Promise<void> {
|
||||||
page: Page,
|
|
||||||
opts?: { contributorIds?: string[]; trx?: KyselyTransaction },
|
|
||||||
): Promise<void> {
|
|
||||||
await this.insertPageHistory(
|
await this.insertPageHistory(
|
||||||
{
|
{
|
||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
@@ -75,11 +70,10 @@ export class PageHistoryRepo {
|
|||||||
icon: page.icon,
|
icon: page.icon,
|
||||||
coverPhoto: page.coverPhoto,
|
coverPhoto: page.coverPhoto,
|
||||||
lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
|
lastUpdatedById: page.lastUpdatedById ?? page.creatorId,
|
||||||
contributorIds: opts?.contributorIds,
|
|
||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
workspaceId: page.workspaceId,
|
workspaceId: page.workspaceId,
|
||||||
},
|
},
|
||||||
opts?.trx,
|
trx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +82,6 @@ export class PageHistoryRepo {
|
|||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.select((eb) => this.withLastUpdatedBy(eb))
|
.select((eb) => this.withLastUpdatedBy(eb))
|
||||||
.select((eb) => this.withContributors(eb))
|
|
||||||
.where('pageId', '=', pageId);
|
.where('pageId', '=', pageId);
|
||||||
|
|
||||||
return executeWithCursorPagination(query, {
|
return executeWithCursorPagination(query, {
|
||||||
@@ -127,17 +120,4 @@ export class PageHistoryRepo {
|
|||||||
.whereRef('users.id', '=', 'pageHistory.lastUpdatedById'),
|
.whereRef('users.id', '=', 'pageHistory.lastUpdatedById'),
|
||||||
).as('lastUpdatedBy');
|
).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,7 +199,6 @@ export interface GroupUsers {
|
|||||||
|
|
||||||
export interface PageHistory {
|
export interface PageHistory {
|
||||||
content: Json | null;
|
content: Json | null;
|
||||||
contributorIds: Generated<string[] | null>;
|
|
||||||
coverPhoto: string | null;
|
coverPhoto: string | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export enum QueueName {
|
|||||||
FILE_TASK_QUEUE = '{file-task-queue}',
|
FILE_TASK_QUEUE = '{file-task-queue}',
|
||||||
SEARCH_QUEUE = '{search-queue}',
|
SEARCH_QUEUE = '{search-queue}',
|
||||||
AI_QUEUE = '{ai-queue}',
|
AI_QUEUE = '{ai-queue}',
|
||||||
HISTORY_QUEUE = '{history-queue}',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueueJob {
|
export enum QueueJob {
|
||||||
@@ -59,6 +58,4 @@ export enum QueueJob {
|
|||||||
|
|
||||||
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
|
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
|
||||||
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
|
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
|
||||||
|
|
||||||
PAGE_HISTORY = 'page-history',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,4 @@ export interface IPageBacklinkJob {
|
|||||||
|
|
||||||
export interface IStripeSeatsSyncJob {
|
export interface IStripeSeatsSyncJob {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface IPageHistoryJob {
|
|
||||||
pageId: string;
|
|
||||||
}
|
}
|
||||||
@@ -73,14 +73,6 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
|
|||||||
attempts: 1,
|
attempts: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.HISTORY_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
attempts: 2,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
exports: [BullModule],
|
exports: [BullModule],
|
||||||
providers: [BacklinksProcessor],
|
providers: [BacklinksProcessor],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import TiptapHeading, {
|
|||||||
import { mergeAttributes } from "@tiptap/react";
|
import { mergeAttributes } from "@tiptap/react";
|
||||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||||
import { Plugin } from "prosemirror-state";
|
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 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>`;
|
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>`;
|
||||||
@@ -41,7 +42,7 @@ export const Heading = TiptapHeading.extend<TiptapHeadingOptions>({
|
|||||||
const id = node.attrs.id;
|
const id = node.attrs.id;
|
||||||
const baseUrl = window.location.href.split('#')[0];
|
const baseUrl = window.location.href.split('#')[0];
|
||||||
const url = `${baseUrl}#${id}`;
|
const url = `${baseUrl}#${id}`;
|
||||||
navigator.clipboard.writeText(url);
|
copyToClipboard(url);
|
||||||
linkBtnContent.innerHTML = successIcon;
|
linkBtnContent.innerHTML = successIcon;
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => (linkBtnContent.innerHTML = copyIcon),
|
() => (linkBtnContent.innerHTML = copyIcon),
|
||||||
|
|||||||
@@ -384,3 +384,25 @@ export function sanitizeUrl(url: string | undefined): string {
|
|||||||
|
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyz";
|
const alphabet = "abcdefghijklmnopqrstuvwxyz";
|
||||||
export const generateNodeId = customAlphabet(alphabet, 12);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user