Compare commits

..

7 Commits

Author SHA1 Message Date
Philipinho 5eb3416b5c namespace 2026-01-17 14:51:56 +00:00
Philipinho c1cfe158cd smart scene sync 2026-01-17 14:39:27 +00:00
Philipinho ab81903299 ignore ts error 2026-01-17 13:44:04 +00:00
Philipinho 14698ebb05 fix room exit 2026-01-17 13:42:58 +00:00
Philipinho efa52ea4c8 Live cursor 2026-01-17 13:36:35 +00:00
Philipinho a4750bff56 WIP - POC 2026-01-17 13:24:54 +00:00
Philipinho 5c9eed53c0 websocket - WIP 2026-01-17 03:15:58 +00:00
113 changed files with 4500 additions and 3144 deletions
+1 -7
View File
@@ -46,10 +46,4 @@ DRAWIO_URL=
DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false)
DEBUG_MODE=false
# Log database queries
DEBUG_DB=false
# Log http requests
LOG_HTTP=false
DEBUG_MODE=false
+2
View File
@@ -24,6 +24,7 @@
"@mantine/spotlight": "^8.3.12",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17",
"@tiptap/extension-character-count": "^2.27.1",
"alfaaz": "^1.1.0",
"axios": "^1.13.2",
"clsx": "^2.1.1",
@@ -53,6 +54,7 @@
"react-router-dom": "^7.12.0",
"semver": "^7.7.3",
"socket.io-client": "^4.8.3",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76"
},
@@ -328,8 +328,6 @@
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any file from your device.": "Upload any file from your device.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Table",
"Insert a table.": "Insert a table.",
"Insert collapsible block.": "Insert collapsible block.",
@@ -5,27 +5,26 @@ import {
Badge,
Table,
ActionIcon,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription } from "@tabler/icons-react";
import { getSpaceUrl } from "@/lib/config.ts";
} from '@mantine/core';
import {Link} from 'react-router-dom';
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
import { buildPageUrl } from '@/features/page/page.utils.ts';
import { formattedDate } from '@/lib/time.ts';
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
import { IconFileDescription } from '@tabler/icons-react';
import { getSpaceUrl } from '@/lib/config.ts';
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
interface Props {
spaceId?: string;
}
export default function RecentChanges({ spaceId }: Props) {
export default function RecentChanges({spaceId}: Props) {
const { t } = useTranslation();
const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId);
const {data: pages, isLoading, isError} = useRecentChangesQuery(spaceId);
if (isLoading) {
return <PageListSkeleton />;
return <PageListSkeleton/>;
}
if (isError) {
@@ -45,8 +44,8 @@ export default function RecentChanges({ spaceId }: Props) {
>
<Group wrap="nowrap">
{page.icon || (
<ActionIcon variant="transparent" color="gray" size={18}>
<IconFileDescription size={18} />
<ActionIcon variant='transparent' color='gray' size={18}>
<IconFileDescription size={18}/>
</ActionIcon>
)}
@@ -59,23 +58,18 @@ export default function RecentChanges({ spaceId }: Props) {
{!spaceId && (
<Table.Td>
<Badge
color={getInitialsColor(page?.space.name)}
color="blue"
variant="light"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}
style={{cursor: 'pointer'}}
>
{page?.space.name}
</Badge>
</Table.Td>
)}
<Table.Td>
<Text
c="dimmed"
style={{ whiteSpace: "nowrap" }}
size="xs"
fw={500}
>
<Text c="dimmed" style={{whiteSpace: 'nowrap'}} size="xs" fw={500}>
{formattedDate(page.updatedAt)}
</Text>
</Table.Td>
@@ -1,49 +0,0 @@
import { useRef, useState, ReactNode } from "react";
import { Text, TextProps, Tooltip } from "@mantine/core";
type AutoTooltipTextProps = TextProps & {
children: ReactNode;
tooltipLabel?: string;
tooltipProps?: Omit<
React.ComponentProps<typeof Tooltip>,
"children" | "label"
>;
};
export function AutoTooltipText({
children,
tooltipLabel,
tooltipProps,
...textProps
}: AutoTooltipTextProps) {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const handleMouseEnter = () => {
const element = textRef.current;
if (element) {
setIsTruncated(element.scrollWidth > element.clientWidth);
}
};
const label = tooltipLabel ?? (typeof children === "string" ? children : "");
return (
<Tooltip
label={label}
disabled={!isTruncated || !label}
multiline
withArrow
{...tooltipProps}
>
<Text
ref={textRef}
truncate
onMouseEnter={handleMouseEnter}
{...textProps}
>
{children}
</Text>
</Tooltip>
);
}
@@ -1,13 +1,11 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib";
import { useTranslation } from "react-i18next";
export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected } = props;
const { url, name, size } = node.attrs;
const { hovered, ref } = useHover();
@@ -22,28 +20,26 @@ export default function AttachmentView(props: NodeViewProps) {
wrap="nowrap"
h={25}
>
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
{url ? (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
<Loader size={20} style={{ flexShrink: 0 }} />
)}
<Group justify="space-between" wrap="nowrap">
<IconPaperclip size={20} />
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
{url ? name : t("Uploading {{name}}", { name })}
<Text component="span" size="md" truncate="end">
{name}
</Text>
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
<Text component="span" size="sm" c="dimmed" inline>
{formatBytes(size)}
</Text>
</Group>
{url && (selected || hovered) && (
{selected || hovered ? (
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
) : (
""
)}
</Group>
</Paper>
@@ -1,6 +1,10 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
import { isNodeSelection, useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import {
BubbleMenu,
BubbleMenuProps,
isNodeSelection,
useEditor,
useEditorState,
} from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react";
import {
IconBold,
@@ -34,7 +38,7 @@ export interface BubbleMenuItem {
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
editor: Editor | null;
editor: ReturnType<typeof useEditor>;
};
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
@@ -129,9 +133,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
}
return isTextSelected(editor);
},
options: {
placement: "top",
offset: 8,
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onCreate: (instance) => {
instance.popper.firstChild?.addEventListener("blur", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
});
},
onHide: () => {
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
@@ -147,7 +156,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
return (
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
<BubbleMenu {...bubbleMenuProps}>
<div className={classes.bubbleMenu}>
<NodeSelector
editor={props.editor}
@@ -1,5 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import {
@@ -49,26 +53,17 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
},
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const setCalloutType = useCallback(
@@ -117,12 +112,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`callout-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
tippyOptions={{
getReferenceClientRect,
offset: [0, 10],
placement: "bottom",
// offset: 233, // // offset: [0, 10],
// zIndex: 99,
flip: false,
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
}}
shouldShow={shouldShow}
>
@@ -90,7 +90,6 @@ export default function CodeBlockView(props: NodeViewProps) {
node.textContent.length > 0
}
>
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} />
</pre>
@@ -1,12 +1,13 @@
import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
export const handlePaste = (
editor: Editor,
view: EditorView,
event: ClipboardEvent,
pageId: string,
creatorId?: string,
@@ -17,7 +18,7 @@ export const handlePaste = (
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();
const url = clipboardData.trim();
const { from: pos, empty } = editor.state.selection;
const { from: pos, empty } = view.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
@@ -33,27 +34,19 @@ export const handlePaste = (
return false;
}
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
const urlWithoutAnchor = anchorId
? url.substring(0, url.indexOf("#"))
: url;
createMentionAction(
urlWithoutAnchor,
editor.view,
pos,
creatorId,
anchorId,
);
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
return true;
}
if (event.clipboardData?.files.length) {
event.preventDefault();
for (const file of event.clipboardData.files) {
const pos = editor.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
uploadAttachmentAction(file, editor, pos, pageId);
const pos = view.state.selection.from;
uploadImageAction(file, view, pos, pageId);
uploadVideoAction(file, view, pos, pageId);
uploadAttachmentAction(file, view, pos, pageId);
}
return true;
}
@@ -61,7 +54,7 @@ export const handlePaste = (
};
export const handleFileDrop = (
editor: Editor,
view: EditorView,
event: DragEvent,
moved: boolean,
pageId: string,
@@ -70,14 +63,14 @@ export const handleFileDrop = (
event.preventDefault();
for (const file of event.dataTransfer.files) {
const coordinates = editor.view.posAtCoords({
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
}
return true;
}
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -35,26 +40,17 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
},
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const onWidthChange = useCallback(
@@ -69,11 +65,15 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`drawio-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -66,7 +66,6 @@ export default function DrawioView(props: NodeViewProps) {
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
@@ -1,41 +1,16 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list";
import tippy from "tippy.js";
import { init } from "emoji-mart";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderEmojiItems = () => {
let component: ReactRenderer | null = null;
let popup: HTMLDivElement | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const destroy = () => {
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
}
if (component) {
component.destroy();
component = null;
}
};
let popup: any | null = null;
return {
onBeforeStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
}) => {
init({
data: async () => (await import("@emoji-mart/data")).default,
@@ -50,61 +25,51 @@ const renderEmojiItems = () => {
return;
}
getReferenceClientRect = props.clientRect;
popup = document.createElement("div");
popup.style.zIndex = "9999";
popup.style.position = "absolute";
popup.style.top = "0";
popup.style.left = "0";
popup.appendChild(component.element);
document.body.appendChild(popup);
const virtualElement = {
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect(0, 0, 0, 0);
},
};
cleanup = autoUpdate(virtualElement, popup, () => {
if (!popup) return;
computePosition(virtualElement, popup, {
placement: "bottom-start",
middleware: [offset(10), flip(), shift()],
}).then(({ x, y }) => {
if (!popup) return;
Object.assign(popup.style, {
transform: `translate(${x}px, ${y}px)`,
});
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom",
});
},
onStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
}) => {
component?.updateProps({ ...props, isLoading: false });
component?.updateProps({...props, isLoading: false});
if (props.clientRect) {
getReferenceClientRect = props.clientRect;
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
}) => {
component?.updateProps(props);
if (props.clientRect) {
getReferenceClientRect = props.clientRect;
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
destroy();
popup?.[0].hide();
component?.destroy()
return true;
}
@@ -113,7 +78,13 @@ const renderEmojiItems = () => {
return component?.ref?.onKeyDown(props);
},
onExit: () => {
destroy();
if (popup && !popup[0]?.state.isDestroyed) {
popup[0]?.destroy();
}
if (component) {
component?.destroy();
}
},
};
};
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -37,26 +42,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
},
});
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const onWidthChange = useCallback(
@@ -69,13 +65,17 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`excalidraw-menu`}
pluginKey={`excalidraw-menu}`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -1,3 +1,5 @@
import { ENCRYPTION_KEY_BITS } from "@excalidraw/common";
type LibraryItems = any;
type LibraryPersistedData = {
@@ -8,8 +10,8 @@ export interface LibraryPersistenceAdapter {
load(metadata: { source: "load" | "save" }):
| Promise<{ libraryItems: LibraryItems } | null>
| {
libraryItems: LibraryItems;
}
libraryItems: LibraryItems;
}
| null;
save(libraryData: LibraryPersistedData): Promise<void> | void;
@@ -25,7 +27,10 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
return JSON.parse(data);
}
} catch (e) {
console.error("Error downloading Excalidraw library from localStorage", e);
console.error(
"Error downloading Excalidraw library from localStorage",
e,
);
}
return null;
},
@@ -40,3 +45,124 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = {
}
},
};
export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
if ("arrayBuffer" in blob) {
return blob.arrayBuffer();
}
// Safari
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
if (!event.target?.result) {
return reject(new Error("Couldn't convert blob to ArrayBuffer"));
}
resolve(event.target.result as ArrayBuffer);
};
reader.readAsArrayBuffer(blob);
});
};
export const IV_LENGTH_BYTES = 12;
// Pre-transform error: No known conditions for "./data/encryption" specifier in "@excalidraw/excalidraw" package
// Plugin: vite:import-analysis
// File: /Users/lite/WebstormProjects/docmost-ee/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts:11:7
// 7 | decryptData,
// 8 | encryptData
// 9 | } from "@excalidraw/excalidraw/data/encryption";
//@ts-ignore
export const createIV = (): Uint8Array<ArrayBuffer> => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr);
};
export const generateEncryptionKey = async <
T extends "string" | "cryptoKey" = "string",
>(
returnAs?: T,
): Promise<T extends "cryptoKey" ? CryptoKey : string> => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: ENCRYPTION_KEY_BITS,
},
true, // extractable
["encrypt", "decrypt"],
);
return (
returnAs === "cryptoKey"
? key
: (await window.crypto.subtle.exportKey("jwk", key)).k
) as T extends "cryptoKey" ? CryptoKey : string;
};
export const getCryptoKey = (key: string, usage: KeyUsage) =>
window.crypto.subtle.importKey(
"jwk",
{
alg: "A128GCM",
ext: true,
k: key,
key_ops: ["encrypt", "decrypt"],
kty: "oct",
},
{
name: "AES-GCM",
length: ENCRYPTION_KEY_BITS,
},
false, // extractable
[usage],
);
export const encryptData = async (
key: string | CryptoKey,
//@ts-ignore
data: Uint8Array<ArrayBuffer> | ArrayBuffer | Blob | File | string,
//@ts-ignore
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array<ArrayBuffer> }> => {
const importedKey =
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
const iv = createIV();
//@ts-ignore
const buffer: ArrayBuffer | Uint8Array<ArrayBuffer> =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
? data
: data instanceof Blob
? await blobToArrayBuffer(data)
: data;
// We use symmetric encryption. AES-GCM is the recommended algorithm and
// includes checks that the ciphertext has not been modified by an attacker.
const encryptedBuffer = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
},
importedKey,
buffer,
);
return { encryptedBuffer, iv };
};
export const decryptData = async (
//@ts-ignore
iv: Uint8Array<ArrayBuffer>,
//@ts-ignore
encrypted: Uint8Array<ArrayBuffer> | ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getCryptoKey(privateKey, "decrypt");
return window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv,
},
key,
encrypted,
);
};
@@ -8,13 +8,14 @@ import {
Text,
useComputedColorScheme,
} from "@mantine/core";
import { useState } from "react";
import { useState, useCallback } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import type { ExcalidrawImperativeAPI, Gesture } from "@excalidraw/excalidraw/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
@@ -22,8 +23,9 @@ import { IconEdit } from "@tabler/icons-react";
import { lazy } from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { useHandleLibrary, LiveCollaborationTrigger } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { useExcalidrawCollab } from "./use-excalidraw-collab";
const Excalidraw = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({
@@ -46,6 +48,16 @@ export default function ExcalidrawView(props: NodeViewProps) {
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
const pageId = editor.storage?.pageId;
const { broadcastScene, broadcastPointer, isCollaborating } = useExcalidrawCollab(excalidrawAPI, pageId, opened);
const handleChange = useCallback(
(elements: readonly ExcalidrawElement[]) => {
broadcastScene(elements);
},
[broadcastScene],
);
const handleOpen = async () => {
if (!editor.isEditable) {
return;
@@ -98,7 +110,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
@@ -158,6 +169,14 @@ export default function ExcalidrawView(props: NodeViewProps) {
scrollToContent: true,
}}
theme={computedColorScheme}
onChange={handleChange}
onPointerUpdate={broadcastPointer}
renderTopRightUI={() => (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => {}}
/>
)}
/>
</Suspense>
</div>
@@ -0,0 +1,257 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import { newElementWith } from "@excalidraw/element";
import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common";
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
import type {
OnUserFollowedPayload,
SocketId,
} from "@excalidraw/excalidraw/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import { isSyncableElement } from "../data";
import type {
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import type { TCollabClass } from "./Collab";
import type { Socket } from "socket.io-client";
class Portal {
collab: TCollabClass;
socket: Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null;
roomKey: string | null = null;
broadcastedElementVersions: Map<string, number> = new Map();
constructor(collab: TCollabClass) {
this.collab = collab;
}
open(socket: Socket, id: string, key: string) {
this.socket = socket;
this.roomId = id;
this.roomKey = key;
// Initialize socket listeners
this.socket.on("init-room", () => {
if (this.socket) {
this.socket.emit("join-room", this.roomId);
trackEvent("share", "room joined");
}
});
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
WS_SUBTYPES.INIT,
this.collab.getSceneElementsIncludingDeleted(),
/* syncAll */ true,
);
});
this.socket.on("room-user-change", (clients: SocketId[]) => {
this.collab.setCollaborators(clients);
});
return socket;
}
close() {
if (!this.socket) {
return;
}
this.queueFileUpload.flush();
this.socket.close();
this.socket = null;
this.roomId = null;
this.roomKey = null;
this.socketInitialized = false;
this.broadcastedElementVersions = new Map();
}
isOpen() {
return !!(
this.socketInitialized &&
this.socket &&
this.roomId &&
this.roomKey
);
}
async _broadcastSocketData(
data: SocketUpdateData,
volatile: boolean = false,
roomId?: string,
) {
if (this.isOpen()) {
const json = JSON.stringify(data);
const encoded = new TextEncoder().encode(json);
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
this.socket?.emit(
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
roomId ?? this.roomId,
encryptedBuffer,
iv,
);
}
}
queueFileUpload = throttle(async () => {
try {
await this.collab.fileManager.saveFiles({
elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
files: this.collab.excalidrawAPI.getFiles(),
});
} catch (error: any) {
if (error.name !== "AbortError") {
this.collab.excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
}
}
let isChanged = false;
const newElements = this.collab.excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
isChanged = true;
// this will signal collaborators to pull image data from server
// (using mutation instead of newElementWith otherwise it'd break
// in-progress dragging)
return newElementWith(element, { status: "saved" });
}
return element;
});
if (isChanged) {
this.collab.excalidrawAPI.updateScene({
elements: newElements,
captureUpdate: CaptureUpdateAction.NEVER,
});
}
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
elements: readonly OrderedExcalidrawElement[],
syncAll: boolean,
) => {
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT");
}
// sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc).
const syncableElements = elements.reduce((acc, element) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
element.version > this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push(element);
}
return acc;
}, [] as SyncableExcalidrawElement[]);
const data: SocketUpdateDataSource[typeof updateType] = {
type: updateType,
payload: {
elements: syncableElements,
},
};
for (const syncableElement of syncableElements) {
this.broadcastedElementVersions.set(
syncableElement.id,
syncableElement.version,
);
}
this.queueFileUpload();
await this._broadcastSocketData(data as SocketUpdateData);
};
broadcastIdleChange = (userState: UserIdleState) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
type: WS_SUBTYPES.IDLE_STATUS,
payload: {
socketId: this.socket.id as SocketId,
userState,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
broadcastMouseLocation = (payload: {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
}) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
type: WS_SUBTYPES.MOUSE_LOCATION,
payload: {
socketId: this.socket.id as SocketId,
pointer: payload.pointer,
button: payload.button || "up",
selectedElementIds:
this.collab.excalidrawAPI.getAppState().selectedElementIds,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
broadcastVisibleSceneBounds = (
payload: {
sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"];
},
roomId: string,
) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = {
type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS,
payload: {
socketId: this.socket.id as SocketId,
username: this.collab.state.username,
sceneBounds: payload.sceneBounds,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
roomId,
);
}
};
broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
if (this.socket?.id) {
this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
}
};
}
export default Portal;
@@ -0,0 +1,266 @@
import { useEffect, useRef, useCallback, useMemo, useState } from "react";
import { useAtom } from "jotai";
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import type {
ExcalidrawImperativeAPI,
Collaborator,
Gesture,
} from "@excalidraw/excalidraw/types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { reconcileElements, getSceneVersion } from "@excalidraw/excalidraw";
import throttle from "lodash.throttle";
// Message types for collaboration
type SceneUpdateMessage = {
type: "SCENE_UPDATE";
payload: { elements: readonly ExcalidrawElement[] };
};
type PointerUpdateMessage = {
type: "POINTER_UPDATE";
payload: {
socketId: string;
pointer: { x: number; y: number };
button: "down" | "up";
username: string;
selectedElementIds: Record<string, boolean>;
};
};
type CollabMessage = SceneUpdateMessage | PointerUpdateMessage;
export function useExcalidrawCollab(
excalidrawAPI: ExcalidrawImperativeAPI | null,
pageId: string | undefined,
isOpen: boolean,
) {
const [socket] = useAtom(socketAtom);
const [currentUser] = useAtom(currentUserAtom);
const lastBroadcastedVersion = useRef(-1);
const isInitialized = useRef(false);
const collaboratorsRef = useRef<Map<string, Collaborator>>(new Map());
const [isCollaborating, setIsCollaborating] = useState(false);
// Track broadcasted element versions for bandwidth optimization
const broadcastedElementVersions = useRef<Map<string, number>>(new Map());
const roomId = pageId ? `excalidraw-${pageId}` : null;
const username = currentUser?.user?.name || "Anonymous";
// Broadcast pointer/cursor updates (volatile - can be dropped)
const broadcastPointer = useMemo(
() =>
throttle(
(payload: {
pointer: { x: number; y: number };
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => {
if (!socket || !roomId || !isInitialized.current) return;
if (payload.pointersMap.size >= 2) return; // Skip multi-touch
const data: PointerUpdateMessage = {
type: "POINTER_UPDATE",
payload: {
socketId: socket.id!,
pointer: payload.pointer,
button: payload.button,
username,
selectedElementIds:
excalidrawAPI?.getAppState().selectedElementIds || {},
},
};
const json = JSON.stringify(data);
socket.emit("ex-server-volatile-broadcast", [roomId, json, null]);
},
50,
),
[socket, roomId, username, excalidrawAPI],
);
// Broadcast scene changes with bandwidth optimization
const broadcastScene = useCallback(
(elements: readonly ExcalidrawElement[], syncAll = false) => {
if (!socket || !roomId || !isInitialized.current) {
return;
}
const sceneVersion = getSceneVersion(elements);
if (sceneVersion <= lastBroadcastedVersion.current) {
return;
}
// Filter to only send elements that changed since last broadcast
const changedElements = elements.filter((element) => {
const lastVersion = broadcastedElementVersions.current.get(element.id);
return syncAll || lastVersion === undefined || element.version > lastVersion;
});
if (changedElements.length === 0) {
return;
}
const data: SceneUpdateMessage = {
type: "SCENE_UPDATE",
payload: { elements: changedElements },
};
// Update tracking map
for (const element of changedElements) {
broadcastedElementVersions.current.set(element.id, element.version);
}
const json = JSON.stringify(data);
socket.emit("ex-server-broadcast", [roomId, json, null]);
lastBroadcastedVersion.current = sceneVersion;
},
[socket, roomId],
);
// Throttled version for onChange handler
const throttledBroadcastScene = useMemo(
() => throttle((elements: readonly ExcalidrawElement[]) => broadcastScene(elements, false), 100),
[broadcastScene],
);
// Handle incoming broadcasts
const handleClientBroadcast = useCallback(
(jsonData: string, _iv: Uint8Array | null) => {
if (!excalidrawAPI || !socket) return;
try {
const data: CollabMessage = JSON.parse(jsonData);
if (data.type === "SCENE_UPDATE" && data.payload?.elements) {
const remoteElements = data.payload.elements;
const localElements =
excalidrawAPI.getSceneElementsIncludingDeleted();
const reconciledElements = reconcileElements(
localElements,
// @ts-ignore
remoteElements,
excalidrawAPI.getAppState(),
);
excalidrawAPI.updateScene({
elements: reconciledElements,
});
lastBroadcastedVersion.current = getSceneVersion(reconciledElements);
} else if (data.type === "POINTER_UPDATE") {
const { socketId, pointer, button, username, selectedElementIds } =
data.payload;
// Don't update our own cursor
if (socketId === socket.id) return;
// Update collaborator with pointer info
const collaborator = collaboratorsRef.current.get(socketId) || {};
collaboratorsRef.current.set(socketId, {
...collaborator,
// @ts-ignore
pointer,
button,
username,
// @ts-ignore
selectedElementIds,
isCurrentUser: false,
});
excalidrawAPI.updateScene({
// @ts-ignore
collaborators: collaboratorsRef.current,
});
}
} catch (err) {
console.error("Failed to process broadcast:", err);
}
},
[excalidrawAPI, socket],
);
// Handle room user changes
const handleRoomUserChange = useCallback(
(socketIds: string[]) => {
if (!excalidrawAPI || !socket) return;
// Update collaborators map, preserving existing data
const newCollaborators = new Map<string, Collaborator>();
for (const id of socketIds) {
const existing = collaboratorsRef.current.get(id);
newCollaborators.set(id, {
...existing,
isCurrentUser: id === socket.id,
username:
existing?.username || (id === socket.id ? username : "User"),
});
}
collaboratorsRef.current = newCollaborators;
// @ts-ignore
excalidrawAPI.updateScene({ collaborators: newCollaborators });
// We're collaborating if there are other users
setIsCollaborating(socketIds.length > 1);
},
[excalidrawAPI, socket, username],
);
// Join/leave room based on modal state
useEffect(() => {
if (!socket || !roomId || !isOpen) {
setIsCollaborating(false);
return;
}
console.log("Joining room:", roomId);
socket.emit("ex-join-room", roomId);
isInitialized.current = true;
// Set up listeners
socket.on("ex-client-broadcast", handleClientBroadcast);
socket.on("ex-room-user-change", handleRoomUserChange);
socket.on("ex-first-in-room", () => {
console.log("First in excalidraw room");
});
socket.on("ex-new-user", (socketId: string) => {
console.log("New user joined:", socketId);
if (excalidrawAPI) {
// Send full scene to new user (syncAll = true)
broadcastScene(excalidrawAPI.getSceneElements(), true);
}
});
return () => {
console.log("Leaving room:", roomId);
socket.emit("ex-leave-room", roomId);
socket.off("ex-client-broadcast", handleClientBroadcast);
socket.off("ex-room-user-change", handleRoomUserChange);
socket.off("ex-first-in-room");
socket.off("ex-new-user");
isInitialized.current = false;
lastBroadcastedVersion.current = -1;
broadcastedElementVersions.current = new Map();
collaboratorsRef.current = new Map();
setIsCollaborating(false);
};
}, [
socket,
roomId,
isOpen,
handleClientBroadcast,
handleRoomUserChange,
broadcastScene,
excalidrawAPI,
]);
return {
broadcastScene: throttledBroadcastScene,
broadcastPointer,
isCollaborating,
};
}
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image");
},
[editor],
);
const editorState = useEditorState({
editor,
@@ -37,37 +52,17 @@ export function ImageMenu({ editor }: EditorMenuProps) {
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image") && editor.getAttributes("image").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const alignImageLeft = useCallback(() => {
@@ -110,11 +105,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`image-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -1,27 +0,0 @@
.imageWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
@@ -1,70 +1,30 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Image, Loader, Text } from "@mantine/core";
import { useMemo } from "react";
import { Image } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx";
import classes from "./image-view.module.css";
import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node, selected } = props;
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
const { node, selected } = props;
const { src, width, align, title } = node.attrs;
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
if (align === "center") return "alignCenter";
return "alignCenter";
}, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.imagePreviews =
editor.storage.shared.imagePreviews || {};
if (placeholder?.id) {
return editor.storage.shared.imagePreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return (
<NodeViewWrapper data-drag-handle>
<div
className={clsx(
selected && "ProseMirror-selectednode",
classes.imageWrapper,
alignClass,
)}
style={{
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<Image
radius="md"
fit="contain"
src={previewSrc}
alt={placeholder?.name}
/>
<Loader size={20} pos="absolute" bottom={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
<Image
radius="md"
fit="contain"
w={width}
src={getFileUrl(src)}
alt={title}
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
/>
</NodeViewWrapper>
);
}
@@ -1,10 +1,9 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
const [showEdit, setShowEdit] = useState(false);
@@ -60,15 +59,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`link-menu`}
pluginKey={`link-menu}`}
updateDelay={0}
options={{
onHide: () => {
tippyOptions={{
appendTo: () => {
return appendTo?.current;
},
onHidden: () => {
setShowEdit(false);
},
placement: "bottom",
offset: 5,
// zIndex: 101,
offset: [0, 5],
zIndex: 101,
}}
shouldShow={shouldShow}
>
@@ -106,7 +106,6 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
setRenderItems(items);
// update editor storage
//@ts-ignore
props.editor.storage.mentionItems = items;
}
}, [suggestion, isLoading]);
@@ -164,7 +163,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const enterHandler = () => {
if (!renderItems.length) return;
if (renderItems[selectedIndex]?.entityType !== "header") {
if (renderItems[selectedIndex].entityType !== "header") {
selectItem(selectedIndex);
}
};
@@ -204,7 +203,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
parentPageId: page.id || null,
title: title
};
let createdPage: IPage;
try {
createdPage = await createPageMutation.mutateAsync(payload);
@@ -1,11 +1,5 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import tippy from "tippy.js";
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
function getWhitespaceCount(query: string) {
@@ -15,27 +9,16 @@ function getWhitespaceCount(query: string) {
const mentionRenderItems = () => {
let component: ReactRenderer | null = null;
let activeClientRect: (() => DOMRect) | null = null;
let updatePositionCleanup: (() => void) | null = null;
const destroy = () => {
updatePositionCleanup?.();
updatePositionCleanup = null;
component?.destroy();
if (component?.element?.parentNode) {
component.element.parentNode.removeChild(component.element);
}
component = null;
};
let popup: any | null = null;
return {
onStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === " ") {
if (props.query.charAt(0) === ' '){
return;
}
@@ -54,95 +37,75 @@ const mentionRenderItems = () => {
return;
}
activeClientRect = props.clientRect;
const { element } = component;
document.body.appendChild(element);
updatePositionCleanup = autoUpdate(
{
getBoundingClientRect: () =>
activeClientRect ? activeClientRect() : new DOMRect(),
},
element,
() => {
if (!component?.element) return;
computePosition(
{
getBoundingClientRect: () => {
return activeClientRect ? activeClientRect() : new DOMRect();
},
},
element,
{
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
},
).then(({ x, y }) => {
Object.assign(element.style, {
left: `${x}px`,
top: `${y}px`,
position: "absolute",
zIndex: "9999",
});
});
},
);
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
clientRect: DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === " ") {
destroy();
if (props.query.charAt(0) === ' '){
component?.destroy();
return;
}
// only update component if popup is not destroyed
if (component) {
component.updateProps(props);
if (!popup?.[0].state.isDestroyed) {
component?.updateProps(props);
}
if (!props || !props.clientRect) {
return;
}
activeClientRect = props.clientRect;
const whitespaceCount = getWhitespaceCount(props.query);
// destroy component if space is greater 3 without a match
if (
whitespaceCount > 4 &&
//@ts-ignore
props.editor.storage.mentionItems.length === 1
whitespaceCount > 3 &&
props.editor.storage.mentionItems.length === 0
) {
destroy();
return;
}
// fallback exit
if (whitespaceCount > 7) {
destroy();
popup?.[0]?.destroy();
component?.destroy();
return;
}
popup &&
!popup?.[0].state.isDestroyed &&
popup?.[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
destroy();
return true;
}
if (props.event.key === "Enter" && !component) {
destroy();
return false;
}
if (props.event.key)
if (
props.event.key === "Escape" ||
(props.event.key === "Enter" && !popup?.[0].state.isShown)
) {
popup?.[0].destroy();
component?.destroy();
return false;
}
return (component?.ref as any)?.onKeyDown(props);
},
onExit: () => {
destroy();
if (popup && !popup?.[0].state.isDestroyed) {
popup[0].destroy();
}
if (component) {
component.destroy();
}
},
};
};
@@ -73,8 +73,6 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
if (!editor) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
//TODO: check type error
//@ts-ignore
const position: Range = results[resultIndex];
if (!position) return;
@@ -161,7 +161,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -174,13 +173,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
uploadImageAction(file, editor.view, pos, pageId);
}
}
// Reset the input value to allow uploading the same file again if needed
input.value = "";
};
input.click();
},
@@ -193,7 +188,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -201,18 +195,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*";
input.multiple = true;
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor, pos, pageId);
}
const file = input.files[0];
const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor.view, pos, pageId);
}
// Reset the input value to allow uploading the same file again if needed
input.value = "";
};
input.click();
},
@@ -225,7 +213,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -233,18 +220,12 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input");
input.type = "file";
input.accept = "";
input.multiple = true;
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor, pos, pageId, true);
}
const file = input.files[0];
const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor.view, pos, pageId, true);
}
// Reset the input value to allow uploading the same file again if needed
input.value = "";
};
input.click();
},
@@ -1,35 +1,10 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import CommandList from "@/features/editor/components/slash-menu/command-list";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import tippy from "tippy.js";
const renderItems = () => {
let component: ReactRenderer | null = null;
let popup: HTMLElement | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const updatePosition = () => {
if (!popup || !getReferenceClientRect) return;
// @ts-ignore
const rect = getReferenceClientRect();
computePosition({ getBoundingClientRect: () => rect }, popup, {
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
}).then(({ x, y }) => {
if (popup) {
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
}
});
};
let popup: any | null = null;
return {
onStart: (props: {
@@ -46,29 +21,15 @@ const renderItems = () => {
}
// @ts-ignore
getReferenceClientRect = props.clientRect;
popup = document.createElement("div");
popup.style.zIndex = "9999";
popup.style.position = "absolute";
popup.style.top = "0";
popup.style.left = "0";
document.body.appendChild(popup);
popup.appendChild(component.element);
cleanup = autoUpdate(
// @ts-ignore
{
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect();
},
},
popup,
updatePosition
);
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
@@ -80,15 +41,14 @@ const renderItems = () => {
return;
}
// @ts-ignore
getReferenceClientRect = props.clientRect;
updatePosition();
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
if (popup) {
popup.style.display = "none";
}
popup?.[0].hide();
return true;
}
@@ -97,19 +57,12 @@ const renderItems = () => {
return component?.ref?.onKeyDown(props);
},
onExit: () => {
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
if (popup && !popup[0].state.isDestroyed) {
popup[0].destroy();
}
if (component) {
component.destroy();
component = null;
}
},
};
@@ -1,11 +1,15 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, findParentNode } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { sticky } from "tippy.js";
interface SubpagesMenuProps {
editor: Editor;
@@ -29,7 +33,7 @@ export const SubpagesMenu = React.memo(
return editor.isActive("subpages");
},
[editor]
[editor],
);
const getReferenceClientRect = useCallback(() => {
@@ -58,8 +62,18 @@ export const SubpagesMenu = React.memo(
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`subpages-menu`}
pluginKey={`subpages-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
@@ -75,7 +89,7 @@ export const SubpagesMenu = React.memo(
</Tooltip>
</BaseBubbleMenu>
);
}
},
);
export default SubpagesMenu;
@@ -19,7 +19,6 @@ export default function SubpagesView(props: NodeViewProps) {
const { spaceSlug, shareId } = useParams();
const { t } = useTranslation();
//@ts-ignore
const currentPageId = editor.storage.pageId;
// Get subpages from shared tree if we're in a shared context
@@ -1,4 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -15,7 +17,6 @@ import {
import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus";
export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -28,7 +29,7 @@ export const TableCellMenu = React.memo(
return isCellSelection(state.selection);
},
[editor]
[editor],
);
const mergeCells = useCallback(() => {
@@ -52,27 +53,23 @@ export const TableCellMenu = React.memo(
}, [editor]);
return (
<BubbleMenu
<BaseBubbleMenu
editor={editor}
pluginKey="table-cell-menu"
updateDelay={0}
appendTo={() => {
return appendTo?.current;
}}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
offset: {
mainAxis: 15,
tippyOptions={{
appendTo: () => {
return appendTo?.current;
},
offset: [0, 15],
zIndex: 99,
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} />
<Tooltip position="top" label={t("Merge cells")}>
<ActionIcon
onClick={mergeCells}
@@ -128,9 +125,9 @@ export const TableCellMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BubbleMenu>
</BaseBubbleMenu>
);
}
},
);
export default TableCellMenu;
@@ -1,6 +1,11 @@
import { posToDOMRect, findParentNode } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -12,12 +17,9 @@ import {
IconColumnRemove,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove,
IconTableColumn,
IconTableRow,
IconRowRemove, IconTableColumn, IconTableRow,
IconTrashX,
} from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus";
} from '@tabler/icons-react';
import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
@@ -32,28 +34,20 @@ export const TableMenu = React.memo(
return editor.isActive("table") && !isCellSelection(state.selection);
},
[editor]
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const rect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
return dom.getBoundingClientRect();
}
const rect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const toggleHeaderColumn = useCallback(() => {
@@ -93,33 +87,42 @@ export const TableMenu = React.memo(
}, [editor]);
return (
<BubbleMenu
<BaseBubbleMenu
editor={editor}
pluginKey="table-menu"
resizeDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
placement: "top",
offset: {
mainAxis: 15,
},
flip: {
fallbackPlacements: ["top", "bottom"],
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
boundary: editor.options.element as HTMLElement,
},
shift: {
padding: 8 + 15,
crossAxis: true,
updateDelay={0}
tippyOptions={{
getReferenceClientRect: getReferenceClientRect,
offset: [0, 15],
zIndex: 99,
popperOptions: {
modifiers: [
{
name: "preventOverflow",
enabled: true,
options: {
altAxis: true,
boundary: "clippingParents",
padding: 8,
},
},
{
name: "flip",
enabled: true,
options: {
boundary: editor.options.element,
fallbackPlacements: ["top", "bottom"],
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
},
},
],
},
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<Tooltip position="top" label={t("Add left column")}>
<Tooltip position="top" label={t("Add left column")}
>
<ActionIcon
onClick={addColumnLeft}
variant="default"
@@ -185,7 +188,8 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header row")}>
<Tooltip position="top" label={t("Toggle header row")}
>
<ActionIcon
onClick={toggleHeaderRow}
variant="default"
@@ -196,7 +200,8 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header column")}>
<Tooltip position="top" label={t("Toggle header column")}
>
<ActionIcon
onClick={toggleHeaderColumn}
variant="default"
@@ -219,9 +224,9 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BubbleMenu>
</BaseBubbleMenu>
);
}
},
);
export default TableMenu;
@@ -1,6 +1,11 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -17,6 +22,16 @@ import { useTranslation } from "react-i18next";
export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video");
},
[editor],
);
const editorState = useEditorState({
editor,
@@ -37,37 +52,17 @@ export function VideoMenu({ editor }: EditorMenuProps) {
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video") && editor.getAttributes("video").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return dom.getBoundingClientRect();
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const alignVideoLeft = useCallback(() => {
@@ -110,11 +105,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`video-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
@@ -1,33 +0,0 @@
.videoWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.video {
display: block;
width: 100%;
height: 100%;
border-radius: 8px;
}
@@ -1,75 +1,29 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx";
import classes from "./video-view.module.css";
import { useTranslation } from "react-i18next";
export default function VideoView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node, selected } = props;
const { src, width, align, aspectRatio, placeholder } = node.attrs;
const { node, selected } = props;
const { src, width, align } = node.attrs;
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
if (align === "center") return "alignCenter";
return "alignCenter";
}, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.videoPreviews =
editor.storage.shared.videoPreviews || {};
if (placeholder?.id) {
return editor.storage.shared.videoPreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return (
<NodeViewWrapper data-drag-handle>
<div
className={clsx(
selected && "ProseMirror-selectednode",
classes.videoWrapper,
alignClass,
)}
style={{
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<video
className={classes.video}
preload="metadata"
controls
src={getFileUrl(src)}
/>
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<video
className={classes.video}
preload="metadata"
controls
src={previewSrc}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
<video
preload="metadata"
width={width}
controls
src={getFileUrl(src)}
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
style={{ display: "block" }}
/>
</NodeViewWrapper>
);
}
@@ -1,7 +1,11 @@
import { StarterKit } from "@tiptap/starter-kit";
import { Placeholder } from "@tiptap/extension-placeholder";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount } from "@tiptap/extensions";
import { CharacterCount } from "@tiptap/extension-character-count";
import { TaskList } from "@tiptap/extension-task-list";
import { ListKeymap } from "@tiptap/extension-list-keymap";
import { TaskItem } from "@tiptap/extension-task-item";
import { Underline } from "@tiptap/extension-underline";
import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography";
@@ -11,7 +15,7 @@ import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider";
import {
Comment,
@@ -37,12 +41,11 @@ import {
Embed,
SearchAndReplace,
Mention,
TableDndExtension,
Subpages,
TableDndExtension,
Heading,
Highlight,
UniqueID,
SharedStorage,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -94,9 +97,7 @@ lowlight.register("scala", scala);
export const mainExtensions = [
StarterKit.configure({
heading: false,
undoRedo: false,
link: false,
trailingNode: false,
history: false,
dropcursor: {
width: 3,
color: "#70CFF8",
@@ -108,7 +109,6 @@ export const mainExtensions = [
},
},
}),
SharedStorage,
Heading,
UniqueID.configure({
types: ["heading", "paragraph"],
@@ -134,6 +134,8 @@ export const mainExtensions = [
TaskItem.configure({
nested: true,
}),
ListKeymap,
Underline,
LinkExtension.configure({
openOnClick: false,
}),
@@ -168,9 +170,6 @@ export const mainExtensions = [
},
}).extend({
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView);
},
}),
@@ -209,7 +208,6 @@ export const mainExtensions = [
}),
CustomCodeBlock.configure({
view: CodeBlockView,
//@ts-ignore
lowlight,
HTMLAttributes: {
spellcheck: false,
@@ -248,7 +246,7 @@ export const mainExtensions = [
Escape: () => {
const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event);
return false;
return true;
},
};
},
@@ -260,9 +258,8 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
export const collabExtensions: CollabExtensions = (provider, user) => [
Collaboration.configure({
document: provider.document,
provider,
}),
CollaborationCaret.configure({
CollaborationCursor.configure({
provider,
user: {
name: user.name,
+113 -101
View File
@@ -1,22 +1,13 @@
import "@/features/editor/styles/index.css";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
onAuthenticationFailedParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
} from "@hocuspocus/provider";
import {
Editor,
EditorContent,
EditorProvider,
useEditor,
@@ -78,140 +69,161 @@ export default function PageEditor({
editable,
content,
}: PageEditorProps) {
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
const editorCreated = useRef(false);
useEffect(() => {
isComponentMounted.current = true;
}, []);
const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const ydocRef = useRef<Y.Doc | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
);
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
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);
const localProvider = providersRef.current?.local;
const remoteProvider = providersRef.current?.remote;
// Track when collaborative provider is ready and synced
const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
}
};
local.on("synced", () => setLocalSynced(true));
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
connect: true,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
remote.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
remote.connect();
}, 100);
}
});
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
providersRef.current = { local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
};
}, [pageId]);
/*
useEffect(() => {
// Handle token updates by reconnecting with new token
if (providersRef.current?.remote && collabQuery?.token) {
const currentToken = providersRef.current.remote.configuration.token;
if (currentToken !== collabQuery.token) {
// Token has changed, need to reconnect with new token
providersRef.current.remote.disconnect();
providersRef.current.remote.configuration.token = collabQuery.token;
providersRef.current.remote.connect();
}
}
}, [collabQuery?.token]);
*/
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
const remoteProvider = providersRef.current.remote;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
remoteProvider.status === WebSocketStatus.Connected
) {
socket.disconnect();
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
remoteProvider.status === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => {
if (!providersReady || !providersRef.current || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
if (!remoteProvider || !currentUser?.user) return mainExtensions;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [providersReady, currentUser?.user]);
}, [remoteProvider, currentUser?.user]);
const editor = useEditor(
{
@@ -254,30 +266,18 @@ export default function PageEditor({
}
},
},
handlePaste: (_view, event) => {
if (!editorRef.current) return false;
return handlePaste(
editorRef.current,
event,
pageId,
currentUser?.user.id,
);
},
handleDrop: (_view, event, _slice, moved) => {
if (!editorRef.current) return false;
return handleFileDrop(editorRef.current, event, moved, pageId);
},
handlePaste: (view, event, slice) =>
handlePaste(view, event, pageId, currentUser?.user.id),
handleDrop: (view, event, _slice, moved) =>
handleFileDrop(view, event, moved, pageId),
},
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setEditor(editor);
// @ts-ignore
editor.storage.pageId = pageId;
handleScrollTo(editor);
editorRef.current = editor;
editorCreated.current = true;
}
},
onUpdate({ editor }) {
@@ -287,7 +287,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, extensions],
[pageId, editable, remoteProvider],
);
const editorIsEditable = useEditorState({
@@ -343,17 +343,30 @@ export default function PageEditor({
setAsideState({ tab: "", isAsideOpen: false });
}, [pageId]);
useEffect(() => {
if (remoteProvider?.status === WebSocketStatus.Connecting) {
const timeout = setTimeout(() => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
}, 5000);
return () => clearTimeout(timeout);
}
}, [remoteProvider?.status]);
const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => {
const timeout = setTimeout(() => {
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
const collabReadyTimeout = setTimeout(() => {
if (
!isCollabReady &&
isSynced &&
remoteProvider?.status === WebSocketStatus.Connected
) {
setIsCollabReady(true);
}
}, 7500);
}, 500);
return () => clearTimeout(collabReadyTimeout);
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]);
useEffect(() => {
// Only honor user default page edit mode preference and permissions
if (editor) {
@@ -375,13 +388,12 @@ export default function PageEditor({
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
yjsConnectionStatus === WebSocketStatus.Connected &&
isSynced
remoteProvider?.status === WebSocketStatus.Connected
) {
hasConnectedOnceRef.current = true;
setShowStatic(false);
}
}, [yjsConnectionStatus, isSynced]);
}, [remoteProvider?.status]);
if (showStatic) {
return (
@@ -81,7 +81,6 @@ export default function ReadonlyPageEditor({
onCreate={({ editor }) => {
if (editor) {
if (pageId) {
// @ts-ignore
editor.storage.pageId = pageId;
}
// @ts-ignore
@@ -1,5 +1,5 @@
/* Give a remote user a caret */
.collaboration-carets__caret {
.collaboration-cursor__caret {
border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d;
margin-left: -1px;
@@ -10,7 +10,7 @@
}
/* Render the username above the caret */
.collaboration-carets__label {
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0;
color: #0d0d0d;
font-size: 0.75rem;
@@ -10,7 +10,6 @@ import Paginate from "@/components/common/paginate.tsx";
import { queryClient } from "@/main.tsx";
import { getSpaces } from "@/features/space/services/space-service.ts";
import { getGroupMembers } from "@/features/group/services/group-service.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function GroupList() {
const { t } = useTranslation();
@@ -52,9 +51,9 @@ export default function GroupList() {
<Group gap="sm" wrap="nowrap">
<IconGroupCircle />
<div style={{ minWidth: 0, overflow: "hidden" }}>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
</AutoTooltipText>
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{group.description}
</Text>
@@ -1,9 +1,8 @@
import "@/features/editor/styles/index.css";
import React, { useEffect } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Title } from "@mantine/core";
import classes from "./history.module.css";
import '@/features/editor/styles/index.css';
import React, { useEffect } from 'react';
import { EditorContent, useEditor } from '@tiptap/react';
import { mainExtensions } from '@/features/editor/extensions/extensions';
import { Title } from '@mantine/core';
export interface HistoryEditorProps {
title: string;
@@ -27,9 +26,7 @@ export function HistoryEditor({ title, content }: HistoryEditorProps) {
<div>
<Title order={1}>{title}</Title>
{editor && (
<EditorContent editor={editor} className={classes.historyEditor} />
)}
{editor && <EditorContent editor={editor} />}
</div>
</>
);
@@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) {
mainEditorTitle
.chain()
.clearContent()
.setContent(activeHistoryData.title, { emitUpdate: true })
.setContent(activeHistoryData.title, true)
.run();
mainEditor
.chain()
@@ -1,49 +1,37 @@
.history {
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
display: block;
width: 100%;
padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
}
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
}
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
}
}
.active {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-8)
);
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8));
}
.sidebar {
max-height: rem(700px);
width: rem(250px);
padding: var(--mantine-spacing-sm);
display: flex;
flex-direction: column;
border-right: rem(1px) solid
max-height: rem(700px);
width: rem(250px);
padding: var(--mantine-spacing-sm);
display: flex;
flex-direction: column;
border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.sidebarFlex {
display: flex;
display: flex;
}
.sidebarMain {
flex: 1;
flex: 1;
}
.sidebarRightSection {
flex: 1;
padding: rem(16px) rem(40px);
flex: 1;
padding: rem(16px) rem(40px);
}
@@ -9,14 +9,20 @@ import {
IconList,
IconMessage,
IconPrinter,
IconSearch,
IconTrash,
IconWifiOff,
} from "@tabler/icons-react";
import React, { useEffect, useRef, useState } from "react";
import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom, useAtomValue } from "jotai";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
import {
getHotkeyHandler,
useClipboard,
useDisclosure,
useHotkeys,
} from "@mantine/hooks";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -32,7 +38,8 @@ import {
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate } from "@/lib/time.ts";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
@@ -44,6 +51,7 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const toggleAside = useToggleAside();
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
useHotkeys(
[
@@ -60,7 +68,6 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event);
},
{ preventDefault: false },
],
],
[],
@@ -68,7 +75,17 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
return (
<>
<ConnectionWarning />
{yjsConnectionStatus === "disconnected" && (
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
{!readOnly && <PageStateSegmentedControl size="xs" />}
@@ -273,51 +290,3 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</>
);
}
function ConnectionWarning() {
const { t } = useTranslation();
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
const [showWarning, setShowWarning] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
if (isDisconnected) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
}
} else {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setShowWarning(false);
}
}, [yjsConnectionStatus]);
// Cleanup only on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
if (!showWarning) return null;
return (
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
);
}
@@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
export default function SpaceList() {
const { t } = useTranslation();
@@ -50,9 +49,9 @@ export default function SpaceList() {
name={space.name}
/>
<div style={{ minWidth: 0, overflow: "hidden" }}>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
<Text fz="sm" fw={500} lineClamp={1}>
{space.name}
</AutoTooltipText>
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{space.description}
</Text>
@@ -27,7 +27,6 @@ import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { SearchInput } from "@/components/common/search-input.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
type MemberType = "user" | "group";
@@ -139,10 +138,10 @@ export default function SpaceMembersList({
{member.type === "group" && <IconGroupCircle />}
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 260 }}>
<AutoTooltipText fz="sm" fw={500}>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{member?.name}
</AutoTooltipText>
</Text>
<Text fz="xs" c="dimmed">
{member.type == "user" && member?.email}
@@ -23,7 +23,6 @@ import SpaceSettingsModal from "@/features/space/components/settings-modal";
import classes from "./all-spaces-list.module.css";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
interface AllSpacesListProps {
spaces: any[];
@@ -97,10 +96,10 @@ export default function AllSpacesList({
variant="filled"
size="md"
/>
<div style={{ minWidth: 0, overflow: "hidden", maxWidth: 350 }}>
<AutoTooltipText fz="sm" fw={500} lineClamp={1}>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{space.name}
</AutoTooltipText>
</Text>
{space.description && (
<Text fz="xs" c="dimmed" lineClamp={2}>
{space.description}
-34
View File
@@ -1,34 +0,0 @@
import { MantineColor } from "@mantine/core";
function hashCode(input: string) {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
const char = input.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return hash;
}
const defaultColors: MantineColor[] = [
"blue",
"cyan",
"grape",
"green",
"indigo",
"lime",
"orange",
"pink",
"red",
"teal",
"violet",
];
export function getInitialsColor(
name: string,
colors: MantineColor[] = defaultColors,
) {
const hash = hashCode(name);
const index = Math.abs(hash) % colors.length;
return colors[index];
}
+2 -5
View File
@@ -74,13 +74,11 @@
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0",
"ldapts": "^7.4.0",
"mammoth": "^1.11.0",
"mime-types": "^2.1.35",
"nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0",
"nestjs-pino": "^4.5.0",
"nodemailer": "^7.0.12",
"openid-client": "^5.7.1",
"otpauth": "^9.4.1",
@@ -88,11 +86,9 @@
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.4.394",
"pg": "^8.16.3",
"pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1",
"postgres": "^3.4.8",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"postmark": "^4.0.5",
"react": "^18.3.1",
"reflect-metadata": "^0.2.2",
@@ -120,6 +116,7 @@
"@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.11.11",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.14",
"@types/yauzl": "^2.10.3",
-2
View File
@@ -18,7 +18,6 @@ import { SecurityModule } from './integrations/security/security.module';
import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
import { LoggerModule } from './common/logger/logger.module';
const enterpriseModules = [];
try {
@@ -36,7 +35,6 @@ try {
@Module({
imports: [
LoggerModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -26,7 +26,7 @@ export class CollaborationGateway {
) {
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
this.hocuspocus = new Hocuspocus({
this.hocuspocus = HocuspocusServer.configure({
debounce: 10000,
maxDebounce: 45000,
unloadImmediately: false,
@@ -65,6 +65,6 @@ export class CollaborationGateway {
}
async destroy(): Promise<void> {
//await this.hocuspocus.destroy();
await this.hocuspocus.destroy();
}
}
@@ -1,12 +1,14 @@
import { StarterKit } from '@tiptap/starter-kit';
import { TextAlign } from '@tiptap/extension-text-align';
import { TaskList } from '@tiptap/extension-task-list';
import { TaskItem } from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline';
import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript';
import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube';
import { TaskList, TaskItem } from '@tiptap/extension-list';
import {
Heading,
Callout,
@@ -40,14 +42,11 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [
StarterKit.configure({
codeBlock: false,
link: false,
trailingNode: false,
heading: false,
}),
Heading,
@@ -60,6 +59,7 @@ export const tiptapExtensions = [
TaskItem.configure({
nested: true,
}),
Underline,
LinkExtension,
Superscript,
SubScript,
@@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension {
}
if (userSpaceRole === SpaceRole.READER) {
data.connectionConfig.readOnly = true;
data.connection.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`);
}
@@ -8,11 +8,9 @@ import { QueueModule } from '../../integrations/queue/queue.module';
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';
@Module({
imports: [
LoggerModule,
DatabaseModule,
EnvironmentModule,
CollaborationModule,
@@ -5,8 +5,8 @@ import {
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
import { Logger } from '@nestjs/common';
import { Logger as PinoLogger } from 'nestjs-pino';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -17,12 +17,10 @@ async function bootstrap() {
maxParamLength: 500,
}),
{
bufferLogs: true,
logger: new InternalLogFilter(),
},
);
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', { exclude: ['/'] });
app.enableCors();
@@ -1,71 +0,0 @@
// https://github.com/WebReflection/html-escaper
/**
* Copyright (C) 2017-present by Andrea Giammarchi - @WebReflection
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const { replace } = '';
// escape
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g;
const ca = /[&<>'"]/g;
const esca = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
};
const pe = (m) => esca[m];
/**
* Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
* @param {string} es the input to safely escape
* @returns {string} the escaped input, and it **throws** an error if
* the input type is unexpected, except for boolean and numbers,
* converted as string.
*/
export const htmlEscape = (es) => replace.call(es, ca, pe);
// unescape
const unes = {
'&amp;': '&',
'&#38;': '&',
'&lt;': '<',
'&#60;': '<',
'&gt;': '>',
'&#62;': '>',
'&apos;': "'",
'&#39;': "'",
'&quot;': '"',
'&#34;': '"',
};
const cape = (m) => unes[m];
/**
* Safely unescape previously escaped entities such as `&`, `<`, `>`, `"`,
* and `'`.
* @param {string} un a previously escaped string
* @returns {string} the unescaped input, and it **throws** an error if
* the input type is unexpected, except for boolean and numbers,
* converted as string.
*/
export const htmlUnescape = (un) => replace.call(un, es, cape);
-36
View File
@@ -2,7 +2,6 @@ import * as path from 'path';
import * as bcrypt from 'bcrypt';
import { sanitize } from 'sanitize-filename-ts';
import { FastifyRequest } from 'fastify';
import { Readable, Transform } from 'stream';
export const envPath = path.resolve(process.cwd(), '..', '..', '.env');
@@ -99,38 +98,3 @@ export function hasLicenseOrEE(opts: {
const { licenseKey, plan, isCloud } = opts;
return Boolean(licenseKey) || (isCloud && plan === 'business');
}
/**
* Normalizes a database URL for postgres.js compatibility.
* - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values
* - Removes `schema` parameter (has no effect via connection string)
* Note: If we don't strip them, the connection will fail
*/
export function normalizePostgresUrl(url: string): string {
const parsed = new URL(url);
const newParams = new URLSearchParams();
for (const [key, value] of parsed.searchParams) {
if (key === 'sslmode' && value === 'no-verify') continue;
if (key === 'schema') continue;
newParams.append(key, value);
}
parsed.search = newParams.toString();
return parsed.toString();
}
export function createByteCountingStream(source: Readable) {
let bytesRead = 0;
const stream = new Transform({
transform(chunk, encoding, callback) {
bytesRead += chunk.length;
callback(null, chunk);
},
});
source.pipe(stream);
source.on('error', (err) => stream.emit('error', err));
return { stream, getBytesRead: () => bytesRead };
}
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { LoggerModule as PinoLoggerModule } from 'nestjs-pino';
import { createPinoConfig } from './pino.config';
@Module({
imports: [PinoLoggerModule.forRoot(createPinoConfig())],
exports: [PinoLoggerModule],
})
export class LoggerModule {}
@@ -1,77 +0,0 @@
import { Params } from 'nestjs-pino';
import { stdTimeFunctions } from 'pino';
const CONTEXTS_TO_IGNORE = [
'InstanceLoader',
'RoutesResolver',
'RouterExplorer',
'WebSocketsController',
];
export function createPinoConfig(): Params {
const isProduction = process.env.NODE_ENV === 'production';
const isDebugMode = process.env.DEBUG_MODE === 'true';
const logHttp = process.env.LOG_HTTP === 'true';
const level = isProduction && !isDebugMode ? 'info' : 'debug';
return {
pinoHttp: {
level,
timestamp: stdTimeFunctions.isoTime,
transport: !isProduction
? {
target: 'pino-pretty',
options: {
colorize: true,
singleLine: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
}
: undefined,
formatters: {
level: (label) => ({ level: label }),
log: (object: Record<string, unknown>) => {
if (isProduction && !isDebugMode) {
const context = object['context'] as string | undefined;
if (context && CONTEXTS_TO_IGNORE.includes(context)) {
return { filtered: true };
}
}
return object;
},
},
serializers: {
req: (req) => {
const forwardedFor = req.headers?.['x-forwarded-for'];
const ip =
req.headers?.['cf-connecting-ip'] ||
(typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() : undefined) ||
req.remoteAddress;
return {
method: req.method,
url: req.url,
ip,
userAgent: req.headers?.['user-agent'],
};
},
res: (res) => ({
statusCode: res.statusCode,
}),
},
customLogLevel: (_req, res, err) => {
if (res.statusCode >= 500 || err) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
autoLogging: logHttp
? {
ignore: (req) =>
req.url === '/api/health' || req.url === '/api/health/live',
}
: false,
},
};
}
@@ -181,9 +181,7 @@ export class AttachmentController {
}
try {
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600',
@@ -243,9 +241,7 @@ export class AttachmentController {
}
try {
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
@@ -371,14 +367,14 @@ export class AttachmentController {
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try {
const fileStream = await this.storageService.readStream(filePath);
const fileStream = await this.storageService.read(filePath);
res.headers({
'Content-Type': getMimeType(filePath),
'Cache-Control': 'private, max-age=86400',
});
return res.send(fileStream);
} catch (err) {
// this.logger.error(err);
// this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@@ -5,17 +5,15 @@ import { sanitizeFileName } from '../../common/helpers';
import * as sharp from 'sharp';
export interface PreparedFile {
buffer?: Buffer;
buffer: Buffer;
fileName: string;
fileSize: number;
fileExtension: string;
mimeType: string;
multiPartFile?: MultipartFile;
}
export async function prepareFile(
filePromise: Promise<MultipartFile>,
options: { skipBuffer?: boolean } = {},
): Promise<PreparedFile> {
const file = await filePromise;
@@ -24,16 +22,10 @@ export async function prepareFile(
}
try {
let buffer: Buffer | undefined;
let fileSize = 0;
if (!options.skipBuffer) {
buffer = await file.toBuffer();
fileSize = buffer.length;
}
const buffer = await file.toBuffer();
const sanitizedFilename = sanitizeFileName(file.filename);
const fileName = sanitizedFilename.slice(0, 255);
const fileSize = buffer.length;
const fileExtension = path.extname(file.filename).toLowerCase();
return {
@@ -42,7 +34,6 @@ export async function prepareFile(
fileSize,
fileExtension,
mimeType: file.mimetype,
multiPartFile: file,
};
} catch (error) {
throw error;
@@ -4,7 +4,6 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { Readable } from 'stream';
import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart';
import {
@@ -27,7 +26,6 @@ import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { createByteCountingStream } from '../../../common/helpers/utils';
@Injectable()
export class AttachmentService {
@@ -51,9 +49,7 @@ export class AttachmentService {
attachmentId?: string;
}) {
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
const preparedFile: PreparedFile = await prepareFile(filePromise, {
skipBuffer: true,
});
const preparedFile: PreparedFile = await prepareFile(filePromise);
let isUpdate = false;
let attachmentId = null;
@@ -85,14 +81,7 @@ export class AttachmentService {
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
const { stream, getBytesRead } = createByteCountingStream(
preparedFile.multiPartFile.file,
);
await this.uploadToDrive(filePath, stream);
// Update fileSize from the consumed stream
preparedFile.fileSize = getBytesRead();
await this.uploadToDrive(filePath, preparedFile.buffer);
let attachment: Attachment = null;
try {
@@ -153,10 +142,7 @@ export class AttachmentService {
const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions);
const processedBuffer = await compressAndResizeIcon(
preparedFile.buffer,
type,
);
const processedBuffer = await compressAndResizeIcon(preparedFile.buffer, type);
preparedFile.buffer = processedBuffer;
preparedFile.fileSize = processedBuffer.length;
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
@@ -246,9 +232,9 @@ export class AttachmentService {
}
}
async uploadToDrive(filePath: string, fileContent: Buffer | Readable) {
async uploadToDrive(filePath: string, fileBuffer: any) {
try {
await this.storageService.upload(filePath, fileContent);
await this.storageService.upload(filePath, fileBuffer);
} catch (err) {
this.logger.error('Error uploading file to drive:', err);
throw new BadRequestException('Error uploading file to drive');
@@ -7,7 +7,6 @@ import { validate as isValidUUID } from 'uuid';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Workspace } from '@docmost/db/types/entity.types';
import { htmlEscape } from '../../common/helpers/html-escaper';
@Controller('share')
export class ShareSeoController {
@@ -69,7 +68,7 @@ export class ShareSeoController {
return this.sendIndex(indexFilePath, res);
}
const rawTitle = htmlEscape(share?.sharedPage.title ?? 'untitled');
const rawTitle = share.sharedPage.title ?? 'untitled';
const metaTitle =
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}` : rawTitle;
+20 -23
View File
@@ -7,7 +7,8 @@ import {
} from '@nestjs/common';
import { InjectKysely, KyselyModule } from 'nestjs-kysely';
import { EnvironmentService } from '../integrations/environment/environment.service';
import { CamelCasePlugin, LogEvent, sql } from 'kysely';
import { CamelCasePlugin, LogEvent, PostgresDialect, sql } from 'kysely';
import { Pool, types } from 'pg';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@@ -25,9 +26,9 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PageListener } from '@docmost/db/listeners/page.listener';
import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres';
import { normalizePostgresUrl } from '../common/helpers';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@Global()
@Module({
@@ -36,30 +37,26 @@ import { normalizePostgresUrl } from '../common/helpers';
imports: [],
inject: [EnvironmentService],
useFactory: (environmentService: EnvironmentService) => ({
dialect: new PostgresJSDialect({
postgres: postgres(
normalizePostgresUrl(environmentService.getDatabaseURL()),
{
max: environmentService.getDatabaseMaxPool(),
onnotice: () => {},
types: {
bigint: {
to: 20,
from: [20, 1700],
serialize: (value: number) => value.toString(),
parse: (value: string) => Number.parseInt(value),
},
},
},
),
dialect: new PostgresDialect({
pool: new Pool({
connectionString: environmentService.getDatabaseURL(),
max: environmentService.getDatabaseMaxPool(),
}).on('error', (err) => {
console.error('Database error:', err.message);
}),
}),
plugins: [new CamelCasePlugin()],
log: (event: LogEvent) => {
if (environmentService.getNodeEnv() !== 'development') return;
const logger = new Logger(DatabaseModule.name);
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
logger.debug(event.query.sql);
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
if (event.level) {
if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
logger.debug(event.query.sql);
logger.debug('query time: ' + event.queryDurationMillis + ' ms');
//if (event.query.parameters.length > 0) {
// logger.debug('parameters: ' + event.query.parameters);
//}
}
}
},
}),
+12 -6
View File
@@ -1,19 +1,25 @@
import * as path from 'path';
import { promises as fs } from 'fs';
import { Kysely, Migrator, FileMigrationProvider } from 'kysely';
import pg from 'pg';
import {
Kysely,
Migrator,
PostgresDialect,
FileMigrationProvider,
} from 'kysely';
import { run } from 'kysely-migration-cli';
import * as dotenv from 'dotenv';
import { envPath, normalizePostgresUrl } from '../common/helpers';
import { PostgresJSDialect } from 'kysely-postgres-js';
import postgres from 'postgres';
import { envPath } from '../common/helpers/utils';
dotenv.config({ path: envPath });
const migrationFolder = path.join(__dirname, './migrations');
const db = new Kysely<any>({
dialect: new PostgresJSDialect({
postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL)),
dialect: new PostgresDialect({
pool: new pg.Pool({
connectionString: process.env.DATABASE_URL,
}) as any,
}),
});
@@ -55,7 +55,7 @@ export class ExportController {
throw new ForbiddenException();
}
const zipFileStream = await this.exportService.exportPages(
const zipFileBuffer = await this.exportService.exportPages(
dto.pageId,
dto.format,
dto.includeAttachments,
@@ -70,7 +70,7 @@ export class ExportController {
'attachment; filename="' + encodeURIComponent(fileName) + '"',
});
res.send(zipFileStream);
res.send(zipFileBuffer);
}
@UseGuards(JwtAuthGuard)
@@ -100,6 +100,6 @@ export class ExportController {
'"',
});
res.send(exportFile.fileStream);
res.send(exportFile.fileBuffer);
}
}
@@ -177,7 +177,7 @@ export class ExportService {
const fileName = `${space.name}-space-export.zip`;
return {
fileStream: zipFile,
fileBuffer: zipFile,
fileName,
};
}
@@ -10,11 +10,7 @@ import {
} from '../../../collaboration/collaboration.util';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
generateSlugId,
sanitizeFileName,
createByteCountingStream,
} from '../../../common/helpers';
import { generateSlugId, sanitizeFileName } from '../../../common/helpers';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
@@ -177,24 +173,15 @@ export class ImportService {
};
}
async getNewPagePosition(
spaceId: string,
parentPageId?: string,
): Promise<string> {
let query = this.db
async getNewPagePosition(spaceId: string): Promise<string> {
const lastPage = await this.db
.selectFrom('pages')
.select(['id', 'position'])
.where('spaceId', '=', spaceId)
.orderBy('position', (ob) => ob.collate('C').desc())
.limit(1);
if (parentPageId) {
query = query.where('parentPageId', '=', parentPageId);
} else {
query = query.where('parentPageId', 'is', null);
}
const lastPage = await query.executeTakeFirst();
.limit(1)
.where('parentPageId', 'is', null)
.executeTakeFirst();
if (lastPage) {
return generateJitteredKeyBetween(lastPage.position, null);
@@ -211,21 +198,20 @@ export class ImportService {
workspaceId: string,
) {
const file = await filePromise;
const fileBuffer = await file.toBuffer();
const fileExtension = path.extname(file.filename).toLowerCase();
const fileName = sanitizeFileName(
path.basename(file.filename, fileExtension),
);
const fileSize = fileBuffer.length;
const fileNameWithExt = fileName + fileExtension;
const fileTaskId = uuid7();
const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`;
// upload file
const { stream, getBytesRead } = createByteCountingStream(file.file);
await this.storageService.upload(filePath, stream);
const fileSize = getBytesRead();
await this.storageService.upload(filePath, fileBuffer);
const fileTask = await this.db
.insertInto('fileTasks')
@@ -20,15 +20,9 @@ export class LocalDriver implements StorageDriver {
return join(this.config.storagePath, filePath);
}
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
async upload(filePath: string, file: Buffer): Promise<void> {
try {
const fullPath = this._fullPath(filePath);
if (file instanceof Buffer) {
await fs.outputFile(fullPath, file);
} else {
await fs.mkdir(dirname(fullPath), { recursive: true });
await pipeline(file, createWriteStream(fullPath));
}
await fs.outputFile(this._fullPath(filePath), file);
} catch (err) {
throw new Error(`Failed to upload file: ${(err as Error).message}`);
}
@@ -48,7 +42,7 @@ export class LocalDriver implements StorageDriver {
try {
const fromFullPath = this._fullPath(fromFilePath);
const toFullPath = this._fullPath(toFilePath);
if (await this.exists(fromFilePath)) {
await fs.copy(fromFullPath, toFullPath);
}
@@ -23,21 +23,19 @@ export class S3Driver implements StorageDriver {
this.s3Client = new S3Client(config as any);
}
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
async upload(filePath: string, file: Buffer): Promise<void> {
try {
const contentType = getMimeType(filePath);
const upload = new Upload({
client: this.s3Client,
params: {
Bucket: this.config.bucket,
Key: filePath,
Body: file,
ContentType: contentType,
},
const command = new PutObjectCommand({
Bucket: this.config.bucket,
Key: filePath,
Body: file,
ContentType: contentType,
// ACL: "public-read",
});
await upload.done();
await this.s3Client.send(command);
} catch (err) {
throw new Error(`Failed to upload file: ${(err as Error).message}`);
}
@@ -1,7 +1,7 @@
import { Readable } from 'stream';
export interface StorageDriver {
upload(filePath: string, file: Buffer | Readable): Promise<void>;
upload(filePath: string, file: Buffer): Promise<void>;
uploadStream(filePath: string, file: Readable, options?: { recreateClient?: boolean }): Promise<void>;
@@ -8,9 +8,9 @@ export class StorageService {
private readonly logger = new Logger(StorageService.name);
constructor(
@Inject(STORAGE_DRIVER_TOKEN) private storageDriver: StorageDriver,
) { }
) {}
async upload(filePath: string, fileContent: Buffer | Readable) {
async upload(filePath: string, fileContent: Buffer | any) {
await this.storageDriver.upload(filePath, fileContent);
this.logger.debug(`File uploaded successfully. Path: ${filePath}`);
}
+5 -5
View File
@@ -5,9 +5,9 @@ import {
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
import { Logger as PinoLogger } from 'nestjs-pino';
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import { InternalLogFilter } from './common/logger/internal-log-filter';
import fastifyMultipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
@@ -24,12 +24,10 @@ async function bootstrap() {
}),
{
rawBody: true,
bufferLogs: true,
logger: new InternalLogFilter(),
},
);
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', {
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'],
});
@@ -101,7 +99,9 @@ async function bootstrap() {
const port = process.env.PORT || 3000;
await app.listen(port, '0.0.0.0', () => {
logger.log(`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`);
logger.log(
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
);
});
}
@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { ExcalidrawFollowPayload } from '../types/excalidraw.types';
@Injectable()
export class ExcalidrawCollabService {
// Track socket -> rooms mapping for disconnect handling
// (Socket.IO clears client.rooms before handleDisconnect runs)
private socketRooms = new Map<string, Set<string>>();
async handleJoinRoom(
client: Socket,
server: Server,
roomId: string,
): Promise<void> {
await client.join(roomId);
// Track room membership
if (!this.socketRooms.has(client.id)) {
this.socketRooms.set(client.id, new Set());
}
this.socketRooms.get(client.id).add(roomId);
const sockets = await server.in(roomId).fetchSockets();
if (sockets.length <= 1) {
server.to(client.id).emit('ex-first-in-room');
} else {
client.broadcast.to(roomId).emit('ex-new-user', client.id);
}
server.in(roomId).emit(
'ex-room-user-change',
sockets.map((socket) => socket.id),
);
}
async handleLeaveRoom(
client: Socket,
server: Server,
roomId: string,
): Promise<void> {
await client.leave(roomId);
// Remove from tracking
this.socketRooms.get(client.id)?.delete(roomId);
// Notify remaining users
const sockets = await server.in(roomId).fetchSockets();
if (sockets.length > 0) {
server.in(roomId).emit(
'ex-room-user-change',
sockets.map((socket) => socket.id),
);
}
}
handleServerBroadcast(
client: Socket,
roomId: string,
encryptedData: ArrayBuffer,
iv: Uint8Array,
): void {
client.broadcast.to(roomId).emit('ex-client-broadcast', encryptedData, iv);
}
handleServerVolatileBroadcast(
client: Socket,
roomId: string,
encryptedData: ArrayBuffer,
iv: Uint8Array,
): void {
client.volatile.broadcast
.to(roomId)
.emit('ex-client-broadcast', encryptedData, iv);
}
async handleUserFollow(
client: Socket,
server: Server,
payload: ExcalidrawFollowPayload,
): Promise<void> {
const roomId = `follow@${payload.userToFollow.socketId}`;
if (payload.action === 'FOLLOW') {
await client.join(roomId);
} else {
await client.leave(roomId);
}
const sockets = await server.in(roomId).fetchSockets();
const followedBy = sockets.map((socket) => socket.id);
server.to(payload.userToFollow.socketId).emit(
'ex-user-follow-room-change',
followedBy,
);
}
async handleDisconnecting(client: Socket, server: Server): Promise<void> {
// Use tracked rooms since client.rooms is empty by this point
const rooms = this.socketRooms.get(client.id) || new Set();
for (const roomId of rooms) {
const otherClients = (await server.in(roomId).fetchSockets()).filter(
(socket) => socket.id !== client.id,
);
const isFollowRoom = roomId.startsWith('follow@');
if (!isFollowRoom && otherClients.length > 0) {
server.to(roomId).emit(
'ex-room-user-change',
otherClients.map((socket) => socket.id),
);
}
if (isFollowRoom && otherClients.length === 0) {
const socketId = roomId.replace('follow@', '');
server.to(socketId).emit('ex-broadcast-unfollow');
}
}
// Clean up tracking
this.socketRooms.delete(client.id);
}
}
@@ -0,0 +1,9 @@
export type ExcalidrawUserToFollow = {
socketId: string;
username: string;
};
export type ExcalidrawFollowPayload = {
userToFollow: ExcalidrawUserToFollow;
action: 'FOLLOW' | 'UNFOLLOW';
};
+80 -1
View File
@@ -1,6 +1,8 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
@@ -11,17 +13,23 @@ import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
import { OnModuleDestroy } from '@nestjs/common';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import * as cookie from 'cookie';
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
import { ExcalidrawFollowPayload } from './types/excalidraw.types';
@WebSocketGateway({
cors: { origin: '*' },
transports: ['websocket'],
})
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
export class WsGateway
implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy
{
@WebSocketServer()
server: Server;
constructor(
private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo,
private excalidrawCollabService: ExcalidrawCollabService,
) {}
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
@@ -41,6 +49,8 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
client.join([workspaceRoom, ...spaceRooms]);
this.server.to(client.id).emit('init-room');
} catch (err) {
client.emit('Unauthorized');
client.disconnect();
@@ -76,6 +86,75 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
client.leave(roomName);
}
// Excalidraw Sync
@SubscribeMessage('ex-join-room')
async handleExJoinRoom(
@ConnectedSocket() client: Socket,
@MessageBody() roomId: string,
): Promise<void> {
await this.excalidrawCollabService.handleJoinRoom(
client,
this.server,
roomId,
);
}
@SubscribeMessage('ex-leave-room')
async handleExLeaveRoom(
@ConnectedSocket() client: Socket,
@MessageBody() roomId: string,
): Promise<void> {
await this.excalidrawCollabService.handleLeaveRoom(
client,
this.server,
roomId,
);
}
@SubscribeMessage('ex-server-broadcast')
handleServerBroadcast(
@ConnectedSocket() client: Socket,
@MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array],
): void {
this.excalidrawCollabService.handleServerBroadcast(
client,
roomId,
encryptedData,
iv,
);
}
@SubscribeMessage('ex-server-volatile-broadcast')
handleServerVolatileBroadcast(
@ConnectedSocket() client: Socket,
@MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array],
): void {
this.excalidrawCollabService.handleServerVolatileBroadcast(
client,
roomId,
encryptedData,
iv,
);
}
@SubscribeMessage('ex-user-follow')
async handleUserFollow(
@ConnectedSocket() client: Socket,
@MessageBody() payload: ExcalidrawFollowPayload,
): Promise<void> {
await this.excalidrawCollabService.handleUserFollow(
client,
this.server,
payload,
);
}
async handleDisconnect(client: Socket): Promise<void> {
await this.excalidrawCollabService.handleDisconnecting(client, this.server);
}
onModuleDestroy() {
if (this.server) {
this.server.close();
+2 -1
View File
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { WsGateway } from './ws.gateway';
import { TokenModule } from '../core/auth/token.module';
import { ExcalidrawCollabService } from './services/excalidraw-collab.service';
@Module({
imports: [TokenModule],
providers: [WsGateway],
providers: [WsGateway, ExcalidrawCollabService],
})
export class WsModule {}
+41 -37
View File
@@ -15,56 +15,60 @@
"server:dev": "nx run server:start:dev",
"server:start": "nx run server:start:prod",
"email:dev": "nx run server:email:dev",
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\"",
"clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite"
"dev": "pnpm concurrently -n \"frontend,backend\" -c \"cyan,green\" \"pnpm run client:dev\" \"pnpm run server:dev\""
},
"dependencies": {
"@braintree/sanitize-url": "^7.1.0",
"@casl/ability": "6.8.0",
"@casl/ability": "^6.7.5",
"@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3",
"@hocuspocus/extension-redis": "3.4.3",
"@hocuspocus/provider": "3.4.3",
"@hocuspocus/server": "3.4.3",
"@hocuspocus/transformer": "3.4.3",
"@hocuspocus/extension-redis": "^2.15.3",
"@hocuspocus/provider": "^2.15.3",
"@hocuspocus/server": "^2.15.3",
"@hocuspocus/transformer": "^2.15.3",
"@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "1.1.0",
"@tiptap/core": "3.17.1",
"@tiptap/extension-code-block": "3.17.1",
"@tiptap/extension-collaboration": "3.17.1",
"@tiptap/extension-collaboration-caret": "3.17.1",
"@tiptap/extension-color": "3.17.1",
"@tiptap/extension-document": "3.17.1",
"@tiptap/extension-heading": "3.17.1",
"@tiptap/extension-highlight": "3.17.1",
"@tiptap/extension-history": "3.17.1",
"@tiptap/extension-image": "3.17.1",
"@tiptap/extension-link": "3.17.1",
"@tiptap/extension-list": "3.17.1",
"@tiptap/extension-placeholder": "3.17.1",
"@tiptap/extension-subscript": "3.17.1",
"@tiptap/extension-superscript": "3.17.1",
"@tiptap/extension-table": "3.17.1",
"@tiptap/extension-text": "3.17.1",
"@tiptap/extension-text-align": "3.17.1",
"@tiptap/extension-text-style": "3.17.1",
"@tiptap/extension-typography": "3.17.1",
"@tiptap/extension-unique-id": "^3.17.1",
"@tiptap/extension-youtube": "3.17.1",
"@tiptap/html": "3.17.1",
"@tiptap/pm": "3.17.1",
"@tiptap/react": "3.17.1",
"@tiptap/starter-kit": "3.17.1",
"@tiptap/suggestion": "3.17.1",
"@tiptap/core": "2.27.1",
"@tiptap/extension-code-block": "2.27.1",
"@tiptap/extension-code-block-lowlight": "2.27.1",
"@tiptap/extension-collaboration": "2.27.1",
"@tiptap/extension-collaboration-cursor": "2.27.1",
"@tiptap/extension-color": "2.27.1",
"@tiptap/extension-document": "2.27.1",
"@tiptap/extension-heading": "2.27.1",
"@tiptap/extension-highlight": "2.27.1",
"@tiptap/extension-history": "2.27.1",
"@tiptap/extension-image": "2.27.1",
"@tiptap/extension-link": "2.27.1",
"@tiptap/extension-list-item": "2.27.1",
"@tiptap/extension-list-keymap": "2.27.1",
"@tiptap/extension-placeholder": "2.27.1",
"@tiptap/extension-subscript": "2.27.1",
"@tiptap/extension-superscript": "2.27.1",
"@tiptap/extension-table": "2.27.1",
"@tiptap/extension-table-cell": "2.27.1",
"@tiptap/extension-table-header": "2.27.1",
"@tiptap/extension-table-row": "2.27.1",
"@tiptap/extension-task-item": "2.27.1",
"@tiptap/extension-task-list": "2.27.1",
"@tiptap/extension-text": "2.27.1",
"@tiptap/extension-text-align": "2.27.1",
"@tiptap/extension-text-style": "2.27.1",
"@tiptap/extension-typography": "2.27.1",
"@tiptap/extension-underline": "2.27.1",
"@tiptap/extension-youtube": "2.27.1",
"@tiptap/html": "2.27.1",
"@tiptap/pm": "2.27.1",
"@tiptap/react": "2.27.1",
"@tiptap/starter-kit": "2.27.1",
"@tiptap/suggestion": "2.27.1",
"@types/qrcode": "^1.5.5",
"bytes": "^3.1.2",
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
"dompurify": "^3.2.6",
"fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1",
"image-dimensions": "^2.5.0",
"ioredis": "^5.4.1",
"jszip": "^3.10.1",
"linkifyjs": "^4.3.2",
@@ -74,7 +78,7 @@
"uuid": "^11.1.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "1.3.7",
"yjs": "^13.6.29"
"yjs": "^13.6.27"
},
"devDependencies": {
"@nx/js": "20.4.5",
-1
View File
@@ -23,4 +23,3 @@ export * from "./lib/subpages";
export * from "./lib/highlight";
export * from "./lib/heading/heading";
export * from "./lib/unique-id";
export * from "./lib/shared-storage";
@@ -1,125 +1,126 @@
import { Node } from "@tiptap/pm/model";
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import {
insertTrailingNode,
MediaUploadOptions,
UploadFn,
} from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Command } from "@tiptap/core";
const findAttachmentNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
const uploadKey = new PluginKey("attachment-upload");
doc.descendants((node, pos) => {
if (result) return false;
if (
node.type.name === "attachment" &&
node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
}
return true;
export const AttachmentUploadPlugin = ({
placeholderClass,
}: {
placeholderClass: string;
}) =>
new Plugin({
key: uploadKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
//@-ts-expect-error - not yet sure what the type I need here
const action = tr.getMeta(this);
if (action?.add) {
const { id, pos, fileName } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", placeholderClass);
const uploadingText = document.createElement("span");
uploadingText.setAttribute("class", "uploading-text");
uploadingText.textContent = `Uploading ${fileName}`;
placeholder.appendChild(uploadingText);
const realPos = pos + 1;
const deco = Decoration.widget(realPos, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action?.remove) {
set = set.remove(
set.find(
undefined,
undefined,
(spec) => spec.id == action.remove.id,
),
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
return result;
};
const handleAttachmentUpload =
function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state) as DecorationSet;
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
return found.length ? found[0]?.from : null;
}
export const handleAttachmentUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, editor, pos, pageId, allowMedia) => {
async (file, view, pos, pageId, allowMedia) => {
const validated = validateFn?.(file, allowMedia);
// @ts-ignore
if (!validated) return;
// A fresh object to act as the ID for this upload
const id = {};
const placeholderId = generateNodeId();
// Replace the selection with a placeholder
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
let placeholderInserted = false;
tr.setMeta(uploadKey, {
add: {
id,
pos,
fileName: file.name,
},
});
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.attachment?.create({
placeholder: {
id: placeholderId,
},
name: file.name,
size: file.size,
});
insertTrailingNode(tr, pos, view);
view.dispatch(tr);
if (!initialPlaceholderNode) return false;
await onUpload(file, pageId).then(
(attachment: IAttachment) => {
const { schema } = view.state;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
const pos = findPlaceholder(view.state, id);
if (isEmptyTextBlock) {
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
if (pos == null) return;
return true;
};
};
const replacePlaceholderWithAttachment = (
attachment: IAttachment,
): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (!attachment) return;
// If the placeholder is not found or attachment is missing, abort the process
if (currentPos === null || !attachment) return false;
// Update the placeholder node with the actual attachment data
tr.setNodeMarkup(currentPos, undefined, {
const node = schema.nodes.attachment?.create({
url: `/api/files/${attachment.id}/${attachment.fileName}`,
name: attachment.fileName,
mime: attachment.mimeType,
size: attachment.fileSize,
attachmentId: attachment.id,
});
if (!node) return;
return true;
};
};
const removePlaceholder = (): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null) return false;
tr.delete(currentPos, currentPos + 2);
return true;
};
};
// Only show the placeholder if the upload takes more than 250ms
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithAttachment(attachment));
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithAttachment(attachment))
.run();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
}
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
},
() => {
// Deletes the placeholder on error
const transaction = view.state.tr
.delete(pos, pos)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
},
);
};
export { handleAttachmentUpload };
@@ -1,5 +1,6 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { AttachmentUploadPlugin } from "./attachment-upload";
export interface AttachmentOptions {
HTMLAttributes: Record<string, any>;
@@ -12,7 +13,6 @@ export interface AttachmentAttributes {
mime?: string; // e.g. application/zip
size?: number;
attachmentId?: string;
placeholder?: string;
}
declare module "@tiptap/core" {
@@ -75,10 +75,6 @@ export const Attachment = Node.create<AttachmentOptions>({
"data-attachment-id": attributes.attachmentId,
}),
},
placeholder: {
default: null,
rendered: false,
},
};
},
@@ -124,9 +120,14 @@ export const Attachment = Node.create<AttachmentOptions>({
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
addProseMirrorPlugins() {
return [
AttachmentUploadPlugin({
placeholderClass: "attachment-placeholder",
}),
];
},
});
@@ -87,7 +87,7 @@ export const Callout = Node.create<CalloutOptions>({
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes
HTMLAttributes,
),
0,
];
@@ -130,9 +130,6 @@ export const Callout = Node.create<CalloutOptions>({
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
@@ -196,7 +193,7 @@ export const Callout = Node.create<CalloutOptions>({
tr.delete(pos, pos + nodeSize);
tr.setSelection(
TextSelection.near(tr.doc.resolve(previousPosition - 1))
TextSelection.near(tr.doc.resolve(previousPosition - 1)),
);
tr.insert(previousPosition - 1, content);
@@ -0,0 +1,81 @@
import CodeBlockLowlight, {
CodeBlockLowlightOptions,
} from "@tiptap/extension-code-block-lowlight";
import { ReactNodeViewRenderer } from "@tiptap/react";
export interface CustomCodeBlockOptions extends CodeBlockLowlightOptions {
view: any;
}
const TAB_CHAR = "\u00A0\u00A0";
export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
{
selectable: true,
addOptions() {
return {
...this.parent?.(),
view: null,
};
},
addKeyboardShortcuts() {
return {
...this.parent?.(),
Tab: () => {
if (this.editor.isActive("codeBlock")) {
this.editor
.chain()
.command(({ tr }) => {
tr.insertText(TAB_CHAR);
return true;
})
.run();
return true;
}
},
"Mod-a": () => {
if (this.editor.isActive("codeBlock")) {
const { state } = this.editor;
const { $from } = state.selection;
let codeBlockNode = null;
let codeBlockPos = null;
let depth = 0;
for (depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type.name === "codeBlock") {
codeBlockNode = node;
codeBlockPos = $from.start(depth) - 1;
break;
}
}
if (codeBlockNode && codeBlockPos !== null) {
const codeBlockStart = codeBlockPos;
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
const contentStart = codeBlockStart + 1;
const contentEnd = codeBlockEnd - 1;
this.editor.commands.setTextSelection({
from: contentStart,
to: contentEnd,
});
return true;
}
}
return false;
},
};
},
addNodeView() {
return ReactNodeViewRenderer(this.options.view);
},
}
);
@@ -1,108 +0,0 @@
import type { CodeBlockOptions } from "@tiptap/extension-code-block";
import CodeBlock from "@tiptap/extension-code-block";
import { LowlightPlugin } from "./lowlight-plugin.js";
import { ReactNodeViewRenderer } from "@tiptap/react";
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
/**
* The lowlight instance.
*/
lowlight: any;
view: any;
}
const TAB_CHAR = "\u00A0\u00A0";
/**
* This extension allows you to highlight code blocks with lowlight.
* @see https://tiptap.dev/api/nodes/code-block-lowlight
*/
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
selectable: true,
addOptions() {
return {
...this.parent?.(),
lowlight: {},
languageClassPrefix: "language-",
exitOnTripleEnter: true,
exitOnArrowDown: true,
defaultLanguage: null,
HTMLAttributes: {},
view: null,
};
},
addKeyboardShortcuts() {
return {
...this.parent?.(),
Tab: () => {
if (this.editor.isActive("codeBlock")) {
this.editor
.chain()
.command(({ tr }) => {
tr.insertText(TAB_CHAR);
return true;
})
.run();
return true;
}
},
"Mod-a": () => {
if (this.editor.isActive("codeBlock")) {
const { state } = this.editor;
const { $from } = state.selection;
let codeBlockNode = null;
let codeBlockPos = null;
let depth = 0;
for (depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type.name === "codeBlock") {
codeBlockNode = node;
codeBlockPos = $from.start(depth) - 1;
break;
}
}
if (codeBlockNode && codeBlockPos !== null) {
const codeBlockStart = codeBlockPos;
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
const contentStart = codeBlockStart + 1;
const contentEnd = codeBlockEnd - 1;
this.editor.commands.setTextSelection({
from: contentStart,
to: contentEnd,
});
return true;
}
}
return false;
},
};
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
addProseMirrorPlugins() {
return [
...(this.parent?.() || []),
LowlightPlugin({
name: this.name,
lowlight: this.options.lowlight,
defaultLanguage: this.options.defaultLanguage,
}),
];
},
});
@@ -1 +0,0 @@
export { CustomCodeBlock } from "./custom-code-block";
@@ -1,159 +0,0 @@
import { findChildren } from '@tiptap/core'
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
// @ts-ignore
import highlight from 'highlight.js/lib/core'
function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
return nodes
.map(node => {
const classes = [...className, ...(node.properties ? node.properties.className : [])]
if (node.children) {
return parseNodes(node.children, classes)
}
return {
text: node.value,
classes,
}
})
.flat()
}
function getHighlightNodes(result: any) {
// `.value` for lowlight v1, `.children` for lowlight v2
return result.value || result.children || []
}
function registered(aliasOrLanguage: string) {
return Boolean(highlight.getLanguage(aliasOrLanguage))
}
function getDecorations({
doc,
name,
lowlight,
defaultLanguage,
}: {
doc: ProsemirrorNode
name: string
lowlight: any
defaultLanguage: string | null | undefined
}) {
const decorations: Decoration[] = []
findChildren(doc, node => node.type.name === name).forEach(block => {
let from = block.pos + 1
const language = block.node.attrs.language || defaultLanguage
const languages = lowlight.listLanguages()
const nodes =
language && (languages.includes(language) || registered(language) || lowlight.registered?.(language))
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent))
parseNodes(nodes).forEach(node => {
const to = from + node.text.length
if (node.classes.length) {
const decoration = Decoration.inline(from, to, {
class: node.classes.join(' '),
})
decorations.push(decoration)
}
from = to
})
})
return DecorationSet.create(doc, decorations)
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
function isFunction(param: any): param is Function {
return typeof param === 'function'
}
export function LowlightPlugin({
name,
lowlight,
defaultLanguage,
}: {
name: string
lowlight: any
defaultLanguage: string | null | undefined
}) {
if (!['highlight', 'highlightAuto', 'listLanguages'].every(api => isFunction(lowlight[api]))) {
throw Error('You should provide an instance of lowlight to use the code-block-lowlight extension')
}
const lowlightPlugin: Plugin<any> = new Plugin({
key: new PluginKey('lowlight'),
state: {
init: (_, { doc }) =>
getDecorations({
doc,
name,
lowlight,
defaultLanguage,
}),
apply: (transaction, decorationSet, oldState, newState) => {
const oldNodeName = oldState.selection.$head.parent.type.name
const newNodeName = newState.selection.$head.parent.type.name
const oldNodes = findChildren(oldState.doc, node => node.type.name === name)
const newNodes = findChildren(newState.doc, node => node.type.name === name)
if (
transaction.docChanged &&
// Apply decorations if:
// selection includes named node,
([oldNodeName, newNodeName].includes(name) ||
// OR transaction adds/removes named node,
newNodes.length !== oldNodes.length ||
// OR transaction has changes that completely encapsulte a node
// (for example, a transaction that affects the entire document).
// Such transactions can happen during collab syncing via y-prosemirror, for example.
transaction.steps.some(step => {
// @ts-ignore
return (
// @ts-ignore
step.from !== undefined &&
// @ts-ignore
step.to !== undefined &&
oldNodes.some(node => {
// @ts-ignore
return (
// @ts-ignore
node.pos >= step.from &&
// @ts-ignore
node.pos + node.node.nodeSize <= step.to
)
})
)
}))
) {
return getDecorations({
doc: transaction.doc,
name,
lowlight,
defaultLanguage,
})
}
return decorationSet.map(transaction.mapping, transaction.doc)
},
},
props: {
decorations(state) {
return lowlightPlugin.getState(state)
},
},
})
return lowlightPlugin
}
@@ -27,7 +27,6 @@ export const Details = Node.create<DetailsOptions>({
content: "detailsSummary detailsContent",
defining: true,
isolating: true,
// @ts-ignore
allowGapCursor: false,
addOptions() {
return {
+18 -28
View File
@@ -41,45 +41,45 @@ export const Drawio = Node.create<DrawioOptions>({
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => element.getAttribute("data-src"),
default: '',
parseHTML: (element) => element.getAttribute('data-src'),
renderHTML: (attributes) => ({
"data-src": attributes.src,
'data-src': attributes.src,
}),
},
title: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-title"),
parseHTML: (element) => element.getAttribute('data-title'),
renderHTML: (attributes: DrawioAttributes) => ({
"data-title": attributes.title,
'data-title': attributes.title,
}),
},
width: {
default: "100%",
parseHTML: (element) => element.getAttribute("data-width"),
default: '100%',
parseHTML: (element) => element.getAttribute('data-width'),
renderHTML: (attributes: DrawioAttributes) => ({
"data-width": attributes.width,
'data-width': attributes.width,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
parseHTML: (element) => element.getAttribute('data-size'),
renderHTML: (attributes: DrawioAttributes) => ({
"data-size": attributes.size,
'data-size': attributes.size,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align"),
default: 'center',
parseHTML: (element) => element.getAttribute('data-align'),
renderHTML: (attributes: DrawioAttributes) => ({
"data-align": attributes.align,
'data-align': attributes.align,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
parseHTML: (element) => element.getAttribute('data-attachment-id'),
renderHTML: (attributes: DrawioAttributes) => ({
"data-attachment-id": attributes.attachmentId,
'data-attachment-id': attributes.attachmentId,
}),
},
};
@@ -95,20 +95,13 @@ export const Drawio = Node.create<DrawioOptions>({
renderHTML({ HTMLAttributes }) {
return [
"div",
'div',
mergeAttributes(
{ "data-type": this.name },
{ 'data-type': this.name },
this.options.HTMLAttributes,
HTMLAttributes
),
[
"img",
{
src: HTMLAttributes["data-src"],
alt: HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"],
},
],
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }],
];
},
@@ -126,9 +119,6 @@ export const Drawio = Node.create<DrawioOptions>({
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});
+23 -26
View File
@@ -1,6 +1,6 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { sanitizeUrl } from "./utils";
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { sanitizeUrl } from './utils';
export interface EmbedOptions {
HTMLAttributes: Record<string, any>;
@@ -14,7 +14,7 @@ export interface EmbedAttributes {
height?: number;
}
declare module "@tiptap/core" {
declare module '@tiptap/core' {
interface Commands<ReturnType> {
embeds: {
setEmbed: (attributes?: EmbedAttributes) => ReturnType;
@@ -23,9 +23,9 @@ declare module "@tiptap/core" {
}
export const Embed = Node.create<EmbedOptions>({
name: "embed",
name: 'embed',
inline: false,
group: "block",
group: 'block',
isolating: true,
atom: true,
defining: true,
@@ -40,41 +40,41 @@ export const Embed = Node.create<EmbedOptions>({
addAttributes() {
return {
src: {
default: "",
default: '',
parseHTML: (element) => {
const src = element.getAttribute("data-src");
const src = element.getAttribute('data-src');
return sanitizeUrl(src);
},
renderHTML: (attributes: EmbedAttributes) => ({
"data-src": sanitizeUrl(attributes.src),
'data-src': sanitizeUrl(attributes.src),
}),
},
provider: {
default: "",
parseHTML: (element) => element.getAttribute("data-provider"),
default: '',
parseHTML: (element) => element.getAttribute('data-provider'),
renderHTML: (attributes: EmbedAttributes) => ({
"data-provider": attributes.provider,
'data-provider': attributes.provider,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align"),
default: 'center',
parseHTML: (element) => element.getAttribute('data-align'),
renderHTML: (attributes: EmbedAttributes) => ({
"data-align": attributes.align,
'data-align': attributes.align,
}),
},
width: {
default: 640,
parseHTML: (element) => element.getAttribute("data-width"),
parseHTML: (element) => element.getAttribute('data-width'),
renderHTML: (attributes: EmbedAttributes) => ({
"data-width": attributes.width,
'data-width': attributes.width,
}),
},
height: {
default: 480,
parseHTML: (element) => element.getAttribute("data-height"),
parseHTML: (element) => element.getAttribute('data-height'),
renderHTML: (attributes: EmbedAttributes) => ({
"data-height": attributes.height,
'data-height': attributes.height,
}),
},
};
@@ -91,13 +91,13 @@ export const Embed = Node.create<EmbedOptions>({
renderHTML({ HTMLAttributes }) {
const src = HTMLAttributes["data-src"];
const safeHref = sanitizeUrl(src);
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes
HTMLAttributes,
),
[
"a",
@@ -120,9 +120,9 @@ export const Embed = Node.create<EmbedOptions>({
...attrs,
src: sanitizeUrl(attrs.src),
};
return commands.insertContent({
type: "embed",
type: 'embed',
attrs: validatedAttrs,
});
},
@@ -130,9 +130,6 @@ export const Embed = Node.create<EmbedOptions>({
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});
+24 -34
View File
@@ -1,5 +1,5 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
export interface ExcalidrawOptions {
HTMLAttributes: Record<string, any>;
@@ -14,7 +14,7 @@ export interface ExcalidrawAttributes {
attachmentId?: string;
}
declare module "@tiptap/core" {
declare module '@tiptap/core' {
interface Commands<ReturnType> {
excalidraw: {
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
@@ -23,9 +23,9 @@ declare module "@tiptap/core" {
}
export const Excalidraw = Node.create<ExcalidrawOptions>({
name: "excalidraw",
name: 'excalidraw',
inline: false,
group: "block",
group: 'block',
isolating: true,
atom: true,
defining: true,
@@ -40,45 +40,45 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => element.getAttribute("data-src"),
default: '',
parseHTML: (element) => element.getAttribute('data-src'),
renderHTML: (attributes) => ({
"data-src": attributes.src,
'data-src': attributes.src,
}),
},
title: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-title"),
parseHTML: (element) => element.getAttribute('data-title'),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-title": attributes.title,
'data-title': attributes.title,
}),
},
width: {
default: "100%",
parseHTML: (element) => element.getAttribute("data-width"),
default: '100%',
parseHTML: (element) => element.getAttribute('data-width'),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-width": attributes.width,
'data-width': attributes.width,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
parseHTML: (element) => element.getAttribute('data-size'),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-size": attributes.size,
'data-size': attributes.size,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align"),
default: 'center',
parseHTML: (element) => element.getAttribute('data-align'),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-align": attributes.align,
'data-align': attributes.align,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
parseHTML: (element) => element.getAttribute('data-attachment-id'),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-attachment-id": attributes.attachmentId,
'data-attachment-id': attributes.attachmentId,
}),
},
};
@@ -94,20 +94,13 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
renderHTML({ HTMLAttributes }) {
return [
"div",
'div',
mergeAttributes(
{ "data-type": this.name },
{ 'data-type': this.name },
this.options.HTMLAttributes,
HTMLAttributes
),
[
"img",
{
src: HTMLAttributes["data-src"],
alt: HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"],
},
],
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }],
];
},
@@ -117,7 +110,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
(attrs: ExcalidrawAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "excalidraw",
type: 'excalidraw',
attrs: attrs,
});
},
@@ -125,9 +118,6 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});
+105 -123
View File
@@ -1,145 +1,127 @@
import { imageDimensionsFromStream } from "image-dimensions";
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const findImageNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
const uploadKey = new PluginKey("image-upload");
doc.descendants((node, pos) => {
if (result) return false;
if (
node.type.name === "image" &&
node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
}
return true;
export const ImageUploadPlugin = ({
placeholderClass,
}: {
placeholderClass: string;
}) =>
new Plugin({
key: uploadKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
//@-ts-expect-error - not yet sure what the type I need here
const action = tr.getMeta(this);
if (action?.add) {
const { id, pos, src } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute("class", placeholderClass);
image.src = src;
placeholder.appendChild(image);
const deco = Decoration.widget(pos + 1, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action?.remove) {
set = set.remove(
set.find(
undefined,
undefined,
(spec) => spec.id == action.remove.id,
),
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
return result;
};
const handleImageUpload =
function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state) as DecorationSet;
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
return found.length ? found[0]?.from : null;
}
export const handleImageUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, editor, pos, pageId) => {
async (file, view, pos, pageId) => {
// check if the file is an image
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
// A fresh object to act as the ID for this upload
const id = {};
const objectUrl = URL.createObjectURL(file);
const imageDimensions = await imageDimensionsFromStream(file.stream());
const placeholderId = generateNodeId();
const aspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height
: undefined;
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const tr = view.state.tr;
// Replace the selection with a placeholder
if (!tr.selection.empty) tr.deleteSelection();
let placeholderInserted = false;
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
editor.storage.shared.imagePreviews =
editor.storage.shared.imagePreviews || {};
editor.storage.shared.imagePreviews[placeholderId] = objectUrl;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.image?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
aspectRatio,
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
// Replace e.g. empty paragraph with the image
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
insertTrailingNode(tr, pos, view);
view.dispatch(tr);
};
const replacePlaceholderWithImage = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
// If the placeholder is not found or attachment is missing, abort the process
if (currentPos === null || !attachment) return false;
await onUpload(file, pageId).then(
(attachment: IAttachment) => {
const { schema } = view.state;
// Update the placeholder node with the actual image data
tr.setNodeMarkup(currentPos, undefined, {
const pos = findPlaceholder(view.state, id);
// If the content around the placeholder has been deleted, drop
// the image
if (pos == null) return;
// Otherwise, insert it at the placeholder's position, and remove
// the placeholder
if (!attachment) return;
const node = schema.nodes.image?.create({
src: `/api/files/${attachment.id}/${attachment.fileName}`,
attachmentId: attachment.id,
title: attachment.fileName,
size: attachment.fileSize,
aspectRatio,
});
if (!node) return;
return true;
};
};
const removePlaceholder = (): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null) return false;
// Remove the placeholder node
tr.delete(currentPos, currentPos + 2);
return true;
};
};
// Only show the placeholder if the upload takes more than 250ms
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.imagePreviews) {
delete editor.storage.shared.imagePreviews[placeholderId];
}
};
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithImage(attachment));
disposePreviewFile();
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithImage(attachment))
.run();
disposePreviewFile();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
}
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
},
() => {
// Deletes the image placeholder on error
const transaction = view.state.tr
.delete(pos, pos)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
},
);
};
export { handleImageUpload };
+10 -19
View File
@@ -1,6 +1,7 @@
import Image from "@tiptap/extension-image";
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { ImageUploadPlugin } from "./image-upload";
import { mergeAttributes, Range } from "@tiptap/core";
export interface ImageOptions extends DefaultImageOptions {
@@ -9,15 +10,11 @@ export interface ImageOptions extends DefaultImageOptions {
export interface ImageAttributes {
src?: string;
alt?: string;
title?: string;
align?: string;
attachmentId?: string;
size?: number;
width?: number;
aspectRatio?: number;
placeholder?: {
id: string;
name: string;
};
}
declare module "@tiptap/core" {
@@ -93,17 +90,6 @@ export const TiptapImage = Image.extend<ImageOptions>({
"data-size": attributes.size,
}),
},
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: ImageAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
placeholder: {
default: null,
rendered: false,
},
};
},
@@ -149,9 +135,14 @@ export const TiptapImage = Image.extend<ImageOptions>({
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
addProseMirrorPlugins() {
return [
ImageUploadPlugin({
placeholderClass: "image-upload",
}),
];
},
});
@@ -63,9 +63,6 @@ export const MathBlock = Node.create({
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
@@ -64,9 +64,6 @@ export const MathInline = Node.create<MathInlineOption>({
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
+16 -2
View File
@@ -1,8 +1,9 @@
import { Editor } from "@tiptap/core";
import type { EditorView } from "@tiptap/pm/view";
import { Transaction } from "@tiptap/pm/state";
export type UploadFn = (
file: File,
editor: Editor,
view: EditorView,
pos: number,
pageId: string,
// only applicable to file attachments
@@ -13,3 +14,16 @@ export interface MediaUploadOptions {
validateFn?: (file: File, allowMedia?: boolean) => void;
onUpload: (file: File, pageId: string) => Promise<any>;
}
export function insertTrailingNode(
tr: Transaction,
pos: number,
view: EditorView,
) {
// create trailing node after decoration
// if decoration is at the last node
const currentDocSize = view.state.doc.content.size;
if (pos + 1 === currentDocSize) {
tr.insert(currentDocSize, view.state.schema.nodes.paragraph.create());
}
}
@@ -31,9 +31,6 @@ import {
import { Node as PMNode, Mark } from "@tiptap/pm/model";
declare module "@tiptap/core" {
interface Storage {
searchAndReplace: SearchAndReplaceStorage;
}
interface Commands<ReturnType> {
search: {
/**
@@ -187,21 +184,21 @@ const replace = (
if (dispatch) {
const tr = state.tr;
// Get all marks that span the text being replaced
const marksSet = new Set<Mark>();
state.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach((mark) => marksSet.add(mark));
node.marks.forEach(mark => marksSet.add(mark));
}
});
const marks = Array.from(marksSet);
// Delete the old text and insert new text with preserved marks
tr.delete(from, to);
tr.insert(from, state.schema.text(replaceTerm, marks));
dispatch(tr);
}
};
@@ -218,17 +215,17 @@ const replaceAll = (
// Process replacements in reverse order to avoid position shifting issues
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
const { from, to } = resultsCopy[i];
// Get all marks that span the text being replaced
const marksSet = new Set<Mark>();
tr.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach((mark) => marksSet.add(mark));
node.marks.forEach(mark => marksSet.add(mark));
}
});
const marks = Array.from(marksSet);
// Delete and insert with preserved marks
tr.delete(from, to);
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
@@ -355,17 +352,10 @@ export const SearchAndReplace = Extension.create<
// The results will be recalculated by the plugin, but we need to ensure
// the index doesn't exceed the new bounds
setTimeout(() => {
const newResultsLength =
editor.storage.searchAndReplace.results.length;
if (
newResultsLength > 0 &&
editor.storage.searchAndReplace.resultIndex >= newResultsLength
) {
const newResultsLength = editor.storage.searchAndReplace.results.length;
if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) {
// Keep the same position if possible, otherwise go to the last result
editor.storage.searchAndReplace.resultIndex = Math.min(
resultIndex,
newResultsLength - 1,
);
editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1);
}
}, 0);
@@ -1 +0,0 @@
export { SharedStorage } from "./shared-storage";
@@ -1,17 +0,0 @@
import { Extension } from "@tiptap/core";
declare module "@tiptap/core" {
interface Storage {
shared: Record<string, any>;
}
}
const SharedStorage = Extension.create({
name: "shared",
addStorage() {
return {};
},
});
export { SharedStorage };
@@ -44,7 +44,7 @@ export const Subpages = Node.create<SubpagesOptions>({
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes
HTMLAttributes,
),
];
},
@@ -63,9 +63,6 @@ export const Subpages = Node.create<SubpagesOptions>({
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});

Some files were not shown because too many files have changed in this diff Show More