mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 17:27:06 +08:00
feat: multiplexing
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
HocuspocusProviderWebsocket,
|
||||||
|
WebSocketStatus,
|
||||||
|
} from "@hocuspocus/provider";
|
||||||
|
import { getCollaborationUrl } from "@/lib/config.ts";
|
||||||
|
|
||||||
|
const RELEASE_GRACE_MS = 5000;
|
||||||
|
|
||||||
|
let socket: HocuspocusProviderWebsocket | null = null;
|
||||||
|
let editorCount = 0;
|
||||||
|
let releaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function getCollabSocket(): HocuspocusProviderWebsocket {
|
||||||
|
if (!socket) {
|
||||||
|
socket = new HocuspocusProviderWebsocket({ url: getCollaborationUrl() });
|
||||||
|
}
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acquireCollabSocket(): void {
|
||||||
|
editorCount++;
|
||||||
|
if (releaseTimer) {
|
||||||
|
clearTimeout(releaseTimer);
|
||||||
|
releaseTimer = null;
|
||||||
|
}
|
||||||
|
const collabSocket = getCollabSocket();
|
||||||
|
if (collabSocket.status === WebSocketStatus.Disconnected) {
|
||||||
|
collabSocket.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseCollabSocket(): void {
|
||||||
|
editorCount--;
|
||||||
|
if (editorCount > 0) return;
|
||||||
|
if (releaseTimer) clearTimeout(releaseTimer);
|
||||||
|
releaseTimer = setTimeout(() => {
|
||||||
|
releaseTimer = null;
|
||||||
|
if (editorCount === 0) {
|
||||||
|
socket?.disconnect();
|
||||||
|
}
|
||||||
|
}, RELEASE_GRACE_MS);
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { getCollaborationUrl } from "@/lib/config.ts";
|
|
||||||
|
|
||||||
const useCollaborationURL = (): string => {
|
|
||||||
return getCollaborationUrl();
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useCollaborationURL;
|
|
||||||
@@ -7,15 +7,16 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
import * as Y from "yjs";
|
|
||||||
import {
|
import {
|
||||||
HocuspocusProvider,
|
|
||||||
onStatusParameters,
|
|
||||||
WebSocketStatus,
|
WebSocketStatus,
|
||||||
HocuspocusProviderWebsocket,
|
|
||||||
onSyncedParameters,
|
|
||||||
onStatelessParameters,
|
onStatelessParameters,
|
||||||
} from "@hocuspocus/provider";
|
} from "@hocuspocus/provider";
|
||||||
|
import {
|
||||||
|
HocuspocusProviderWebsocketComponent,
|
||||||
|
HocuspocusRoom,
|
||||||
|
useHocuspocusEvent,
|
||||||
|
useHocuspocusProvider,
|
||||||
|
} from "@hocuspocus/provider-react";
|
||||||
import {
|
import {
|
||||||
Editor,
|
Editor,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
@@ -28,7 +29,6 @@ import {
|
|||||||
mainExtensions,
|
mainExtensions,
|
||||||
} from "@/features/editor/extensions/extensions";
|
} from "@/features/editor/extensions/extensions";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
@@ -76,6 +76,11 @@ import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
|||||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
acquireCollabSocket,
|
||||||
|
getCollabSocket,
|
||||||
|
releaseCollabSocket,
|
||||||
|
} from "@/features/editor/collab-socket";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -91,7 +96,78 @@ export default function PageEditor({
|
|||||||
canComment,
|
canComment,
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const collaborationURL = useCollaborationUrl();
|
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||||
|
const { pageSlug } = useParams();
|
||||||
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
|
const [socket] = useState(getCollabSocket);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
acquireCollabSocket();
|
||||||
|
return () => releaseCollabSocket();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStateless = ({ payload }: onStatelessParameters) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(payload);
|
||||||
|
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||||
|
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||||
|
if (pageData) {
|
||||||
|
queryClient.setQueryData(["pages", slugId], {
|
||||||
|
...pageData,
|
||||||
|
updatedAt: message.updatedAt,
|
||||||
|
...(message.lastUpdatedBy && {
|
||||||
|
lastUpdatedBy: message.lastUpdatedBy,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore unrelated stateless messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthenticationFailed = () => {
|
||||||
|
const payload = jwtDecode(collabQuery?.token);
|
||||||
|
const now = Date.now().valueOf() / 1000;
|
||||||
|
const isTokenExpired = now >= payload.exp;
|
||||||
|
if (isTokenExpired) {
|
||||||
|
refetchCollabToken();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TransclusionLookupProvider>
|
||||||
|
{collabQuery?.token ? (
|
||||||
|
<HocuspocusProviderWebsocketComponent websocketProvider={socket}>
|
||||||
|
<HocuspocusRoom
|
||||||
|
name={`page.${pageId}`}
|
||||||
|
token={collabQuery.token}
|
||||||
|
flushDelay={500}
|
||||||
|
onStateless={handleStateless}
|
||||||
|
onAuthenticationFailed={handleAuthenticationFailed}
|
||||||
|
>
|
||||||
|
<CollabPageEditor
|
||||||
|
pageId={pageId}
|
||||||
|
editable={editable}
|
||||||
|
content={content}
|
||||||
|
canComment={canComment}
|
||||||
|
/>
|
||||||
|
</HocuspocusRoom>
|
||||||
|
</HocuspocusProviderWebsocketComponent>
|
||||||
|
) : (
|
||||||
|
<StaticPageEditor content={content} ariaLabel={t("Page content")} />
|
||||||
|
)}
|
||||||
|
</TransclusionLookupProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollabPageEditor({
|
||||||
|
pageId,
|
||||||
|
editable,
|
||||||
|
content,
|
||||||
|
canComment,
|
||||||
|
}: PageEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const provider = useHocuspocusProvider();
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
const editorRef = useRef<Editor | null>(null);
|
const editorRef = useRef<Editor | null>(null);
|
||||||
|
|
||||||
@@ -112,7 +188,6 @@ export default function PageEditor({
|
|||||||
);
|
);
|
||||||
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
|
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
|
||||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||||
const documentState = useDocumentVisibility();
|
const documentState = useDocumentVisibility();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
@@ -123,96 +198,24 @@ export default function PageEditor({
|
|||||||
[isComponentMounted],
|
[isComponentMounted],
|
||||||
);
|
);
|
||||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||||
// Providers only created once per pageId
|
|
||||||
const providersRef = useRef<{
|
|
||||||
local: IndexeddbPersistence;
|
|
||||||
remote: HocuspocusProvider;
|
|
||||||
socket: HocuspocusProviderWebsocket;
|
|
||||||
} | null>(null);
|
|
||||||
const [providersReady, setProvidersReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!providersRef.current) {
|
const local = new IndexeddbPersistence(
|
||||||
const documentName = `page.${pageId}`;
|
provider.configuration.name,
|
||||||
const ydoc = new Y.Doc();
|
provider.document,
|
||||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
);
|
||||||
const socket = new HocuspocusProviderWebsocket({
|
local.on("synced", () => setIsLocalSynced(true));
|
||||||
url: collaborationURL,
|
|
||||||
});
|
|
||||||
const onLocalSyncedHandler = () => {
|
|
||||||
setIsLocalSynced(true);
|
|
||||||
};
|
|
||||||
const onStatusHandler = (event: onStatusParameters) => {
|
|
||||||
setYjsConnectionStatus(event.status);
|
|
||||||
};
|
|
||||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
|
||||||
setIsRemoteSynced(event.state);
|
|
||||||
};
|
|
||||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(payload);
|
|
||||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
|
||||||
if (pageData) {
|
|
||||||
queryClient.setQueryData(["pages", slugId], {
|
|
||||||
...pageData,
|
|
||||||
updatedAt: message.updatedAt,
|
|
||||||
...(message.lastUpdatedBy && {
|
|
||||||
lastUpdatedBy: message.lastUpdatedBy,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore unrelated stateless messages
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onAuthenticationFailedHandler = () => {
|
|
||||||
const payload = jwtDecode(collabQuery?.token);
|
|
||||||
const now = Date.now().valueOf() / 1000;
|
|
||||||
const isTokenExpired = now >= payload.exp;
|
|
||||||
if (isTokenExpired) {
|
|
||||||
refetchCollabToken().then((result) => {
|
|
||||||
if (result.data?.token) {
|
|
||||||
socket.disconnect();
|
|
||||||
setTimeout(() => {
|
|
||||||
remote.configuration.token = result.data.token;
|
|
||||||
socket.connect();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const remote = new HocuspocusProvider({
|
|
||||||
websocketProvider: socket,
|
|
||||||
name: documentName,
|
|
||||||
document: ydoc,
|
|
||||||
token: collabQuery?.token,
|
|
||||||
flushDelay: 500,
|
|
||||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
|
||||||
onStatus: onStatusHandler,
|
|
||||||
onSynced: onSyncedHandler,
|
|
||||||
onStateless: onStatelessHandler,
|
|
||||||
});
|
|
||||||
|
|
||||||
local.on("synced", onLocalSyncedHandler);
|
|
||||||
providersRef.current = { socket, local, remote };
|
|
||||||
setProvidersReady(true);
|
|
||||||
} else {
|
|
||||||
setProvidersReady(true);
|
|
||||||
}
|
|
||||||
// Only destroy on final unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
providersRef.current?.socket.destroy();
|
local.destroy();
|
||||||
providersRef.current?.remote.destroy();
|
|
||||||
providersRef.current?.local.destroy();
|
|
||||||
providersRef.current = null;
|
|
||||||
};
|
};
|
||||||
}, [pageId]);
|
}, [provider]);
|
||||||
|
|
||||||
|
useHocuspocusEvent("synced", ({ state }) => setIsRemoteSynced(state));
|
||||||
|
useHocuspocusEvent("status", ({ status }) => setYjsConnectionStatus(status));
|
||||||
|
|
||||||
// Only connect/disconnect on tab/idle, not destroy
|
// Only connect/disconnect on tab/idle, not destroy
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!providersReady || !providersRef.current) return;
|
const socket = provider.configuration.websocketProvider;
|
||||||
const socket = providersRef.current.socket;
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isIdle &&
|
isIdle &&
|
||||||
@@ -229,23 +232,15 @@ export default function PageEditor({
|
|||||||
resetIdle();
|
resetIdle();
|
||||||
socket.connect();
|
socket.connect();
|
||||||
}
|
}
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
}, [isIdle, documentState, provider, resetIdle]);
|
||||||
|
|
||||||
// Attach here, to make sure the connection gets properly established
|
|
||||||
providersRef.current?.remote.attach();
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
if (!currentUser?.user) {
|
||||||
return mainExtensions;
|
return mainExtensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteProvider = providersRef.current.remote;
|
return [...mainExtensions, ...collabExtensions(provider, currentUser.user)];
|
||||||
|
}, [provider, currentUser?.user]);
|
||||||
return [
|
|
||||||
...mainExtensions,
|
|
||||||
...collabExtensions(remoteProvider, currentUser?.user),
|
|
||||||
];
|
|
||||||
}, [providersReady, currentUser?.user]);
|
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@@ -417,65 +412,72 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
}, [yjsConnectionStatus, isSynced]);
|
}, [yjsConnectionStatus, isSynced]);
|
||||||
|
|
||||||
|
if (showStatic) {
|
||||||
|
return <StaticPageEditor content={content} ariaLabel={t("Page content")} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TransclusionLookupProvider>
|
<div className="editor-container" style={{ position: "relative" }}>
|
||||||
{showStatic ? (
|
<div ref={menuContainerRef}>
|
||||||
<EditorProvider
|
<EditorContent editor={editor} />
|
||||||
editable={false}
|
|
||||||
immediatelyRender={true}
|
|
||||||
extensions={mainExtensions}
|
|
||||||
content={content}
|
|
||||||
editorProps={{
|
|
||||||
attributes: {
|
|
||||||
"aria-label": t("Page content"),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="editor-container" style={{ position: "relative" }}>
|
|
||||||
<div ref={menuContainerRef}>
|
|
||||||
<EditorContent editor={editor} />
|
|
||||||
|
|
||||||
{editor && (
|
{editor && (
|
||||||
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
<SearchAndReplaceDialog editor={editor} editable={editable} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editor && editorIsEditable && (
|
{editor && editorIsEditable && (
|
||||||
<div>
|
<div>
|
||||||
<EditorAiMenu editor={editor} />
|
<EditorAiMenu editor={editor} />
|
||||||
<EditorLinkMenu editor={editor} />
|
<EditorLinkMenu editor={editor} />
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
<TableMenu editor={editor} />
|
<TableMenu editor={editor} />
|
||||||
<TableHandlesLayer editor={editor} />
|
<TableHandlesLayer editor={editor} />
|
||||||
<ImageMenu editor={editor} />
|
<ImageMenu editor={editor} />
|
||||||
<VideoMenu editor={editor} />
|
<VideoMenu editor={editor} />
|
||||||
<PdfMenu editor={editor} />
|
<PdfMenu editor={editor} />
|
||||||
<CalloutMenu editor={editor} />
|
<CalloutMenu editor={editor} />
|
||||||
<SubpagesMenu editor={editor} />
|
<SubpagesMenu editor={editor} />
|
||||||
<ExcalidrawMenu editor={editor} />
|
<ExcalidrawMenu editor={editor} />
|
||||||
<DrawioMenu editor={editor} />
|
<DrawioMenu editor={editor} />
|
||||||
<ColumnsMenu editor={editor} />
|
<ColumnsMenu editor={editor} />
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{editor &&
|
|
||||||
!editorIsEditable &&
|
|
||||||
(editable || canComment) &&
|
|
||||||
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
|
|
||||||
{showCommentPopup && (
|
|
||||||
<CommentDialog editor={editor} pageId={pageId} />
|
|
||||||
)}
|
|
||||||
{showReadOnlyCommentPopup && (
|
|
||||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
)}
|
||||||
onClick={() => {
|
{editor && !editorIsEditable && (editable || canComment) && (
|
||||||
if (editor && !editor.isDestroyed) editor.commands.focus("end");
|
<ReadonlyBubbleMenu editor={editor} />
|
||||||
}}
|
)}
|
||||||
style={{ paddingBottom: "20vh" }}
|
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||||
></div>
|
{showReadOnlyCommentPopup && (
|
||||||
</div>
|
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||||
)}
|
)}
|
||||||
</TransclusionLookupProvider>
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
if (editor && !editor.isDestroyed) editor.commands.focus("end");
|
||||||
|
}}
|
||||||
|
style={{ paddingBottom: "20vh" }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StaticPageEditor({
|
||||||
|
content,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
content: any;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<EditorProvider
|
||||||
|
editable={false}
|
||||||
|
immediatelyRender={true}
|
||||||
|
extensions={mainExtensions}
|
||||||
|
content={content}
|
||||||
|
editorProps={{
|
||||||
|
attributes: {
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,6 @@
|
|||||||
"kysely-migration-cli": "0.4.2",
|
"kysely-migration-cli": "0.4.2",
|
||||||
"kysely-postgres-js": "3.0.0",
|
"kysely-postgres-js": "3.0.0",
|
||||||
"ldapts": "8.1.7",
|
"ldapts": "8.1.7",
|
||||||
"lib0": "0.2.117",
|
|
||||||
"mammoth": "1.12.0",
|
"mammoth": "1.12.0",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"msgpackr": "^1.11.9",
|
"msgpackr": "^1.11.9",
|
||||||
@@ -118,7 +117,6 @@
|
|||||||
"stripe": "^17.7.0",
|
"stripe": "^17.7.0",
|
||||||
"tlds": "1.261.0",
|
"tlds": "1.261.0",
|
||||||
"tmp-promise": "3.0.3",
|
"tmp-promise": "3.0.3",
|
||||||
"tseep": "1.3.1",
|
|
||||||
"typesense": "3.0.5",
|
"typesense": "3.0.5",
|
||||||
"undici": "7.28.0",
|
"undici": "7.28.0",
|
||||||
"ws": "8.21.0",
|
"ws": "8.21.0",
|
||||||
|
|||||||
+30
-29
@@ -24,40 +24,41 @@
|
|||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@floating-ui/dom": "1.7.3",
|
"@floating-ui/dom": "1.7.3",
|
||||||
"@hocuspocus/provider": "4.4.0",
|
"@hocuspocus/provider": "4.4.0",
|
||||||
|
"@hocuspocus/provider-react": "4.4.0",
|
||||||
"@hocuspocus/server": "4.4.0",
|
"@hocuspocus/server": "4.4.0",
|
||||||
"@hocuspocus/transformer": "4.4.0",
|
"@hocuspocus/transformer": "4.4.0",
|
||||||
"@joplin/turndown": "4.0.82",
|
"@joplin/turndown": "4.0.82",
|
||||||
"@joplin/turndown-plugin-gfm": "1.0.64",
|
"@joplin/turndown-plugin-gfm": "1.0.64",
|
||||||
"@sindresorhus/slugify": "3.0.0",
|
"@sindresorhus/slugify": "3.0.0",
|
||||||
"@tiptap/core": "3.27.1",
|
"@tiptap/core": "3.28.0",
|
||||||
"@tiptap/extension-audio": "3.27.1",
|
"@tiptap/extension-audio": "3.28.0",
|
||||||
"@tiptap/extension-code-block": "3.27.1",
|
"@tiptap/extension-code-block": "3.28.0",
|
||||||
"@tiptap/extension-collaboration": "3.27.1",
|
"@tiptap/extension-collaboration": "3.28.0",
|
||||||
"@tiptap/extension-collaboration-caret": "3.27.1",
|
"@tiptap/extension-collaboration-caret": "3.28.0",
|
||||||
"@tiptap/extension-color": "3.27.1",
|
"@tiptap/extension-color": "3.28.0",
|
||||||
"@tiptap/extension-document": "3.27.1",
|
"@tiptap/extension-document": "3.28.0",
|
||||||
"@tiptap/extension-heading": "3.27.1",
|
"@tiptap/extension-heading": "3.28.0",
|
||||||
"@tiptap/extension-highlight": "3.27.1",
|
"@tiptap/extension-highlight": "3.28.0",
|
||||||
"@tiptap/extension-history": "3.27.1",
|
"@tiptap/extension-history": "3.28.0",
|
||||||
"@tiptap/extension-image": "3.27.1",
|
"@tiptap/extension-image": "3.28.0",
|
||||||
"@tiptap/extension-link": "3.27.1",
|
"@tiptap/extension-link": "3.28.0",
|
||||||
"@tiptap/extension-list": "3.27.1",
|
"@tiptap/extension-list": "3.28.0",
|
||||||
"@tiptap/extension-placeholder": "3.27.1",
|
"@tiptap/extension-placeholder": "3.28.0",
|
||||||
"@tiptap/extension-subscript": "3.27.1",
|
"@tiptap/extension-subscript": "3.28.0",
|
||||||
"@tiptap/extension-superscript": "3.27.1",
|
"@tiptap/extension-superscript": "3.28.0",
|
||||||
"@tiptap/extension-table": "3.27.1",
|
"@tiptap/extension-table": "3.28.0",
|
||||||
"@tiptap/extension-text": "3.27.1",
|
"@tiptap/extension-text": "3.28.0",
|
||||||
"@tiptap/extension-text-align": "3.27.1",
|
"@tiptap/extension-text-align": "3.28.0",
|
||||||
"@tiptap/extension-text-style": "3.27.1",
|
"@tiptap/extension-text-style": "3.28.0",
|
||||||
"@tiptap/extension-typography": "3.27.1",
|
"@tiptap/extension-typography": "3.28.0",
|
||||||
"@tiptap/extension-unique-id": "3.27.1",
|
"@tiptap/extension-unique-id": "3.28.0",
|
||||||
"@tiptap/extension-youtube": "3.27.1",
|
"@tiptap/extension-youtube": "3.28.0",
|
||||||
"@tiptap/html": "3.27.1",
|
"@tiptap/html": "3.28.0",
|
||||||
"@tiptap/pm": "3.27.1",
|
"@tiptap/pm": "3.28.0",
|
||||||
"@tiptap/react": "3.27.1",
|
"@tiptap/react": "3.28.0",
|
||||||
"@tiptap/starter-kit": "3.27.1",
|
"@tiptap/starter-kit": "3.28.0",
|
||||||
"@tiptap/suggestion": "3.27.1",
|
"@tiptap/suggestion": "3.28.0",
|
||||||
"@tiptap/y-tiptap": "3.0.5",
|
"@tiptap/y-tiptap": "3.0.7",
|
||||||
"bytes": "3.1.2",
|
"bytes": "3.1.2",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
|
|||||||
Generated
+392
-385
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user