feat: Tiptap V3 migration (#1854)

* Tiptap3 migration - WIP

* fix collaboration

* remove unused code

* fix flicker

* disable duplicate extensions

* update tiptap version

* Switch to useEditorState
- Set shouldRerenderOnTransaction to false

* fix editable state

* add tippyoptions for reference

* merge main

* tiptap 3.6.1

* fix bubble menu

* fix converter

* fix menus

* fix collaboration caret css

* fix: Set `isInitialized` to force immediate react node view rendering

* feat: Migrate tippy.js menus to Floating UI

* feat: Update collaboration connection for HocusPocus v3

* fix: Connect/disconnect websocketProvider

* cleanup

* cleanup

* feat: Improved placeholder and upload handling for images

* feat: Improved placeholder and upload handling for videos

* refactor: Image node and view clean-up

* feat: Improved placeholder and upload handling for attachments

* fix: Video view styles

* fix: Transaction handling on asset upload

* fix: Use imageDimensionsFromStream

* feat: Multiple file upload, improved placeholders, local previews

* fix: Drag & drop, paste upload

* fix: Allow media as attachment

* * add skeleton pulse animation
* add translation strings
* fix attachment view responsiveness

* fix collab connection status display

* Tiptap v3.17.0

* fix suggestion menu exit bug

* fix search shortcut

* fix history editor css

* tiptap 3.17.1

---------

Co-authored-by: Arek Nawo <areknawo@areknawo.com>
This commit is contained in:
Philip Okugbe
2026-01-24 20:41:08 +00:00
committed by GitHub
parent 98f71c95fe
commit 657fdf8cb7
74 changed files with 2532 additions and 2370 deletions
-2
View File
@@ -24,7 +24,6 @@
"@mantine/spotlight": "^8.3.12", "@mantine/spotlight": "^8.3.12",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "^5.90.17",
"@tiptap/extension-character-count": "^2.27.1",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -54,7 +53,6 @@
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"semver": "^7.7.3", "semver": "^7.7.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
@@ -328,6 +328,8 @@
"Upload any image from your device.": "Upload any image from your device.", "Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.", "Upload any video from your device.": "Upload any video from your device.",
"Upload any file from your device.": "Upload any file from your device.", "Upload any file from your device.": "Upload any file from your device.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
"Table": "Table", "Table": "Table",
"Insert a table.": "Insert a table.", "Insert a table.": "Insert a table.",
"Insert collapsible block.": "Insert collapsible block.", "Insert collapsible block.": "Insert collapsible block.",
@@ -1,11 +1,13 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon } from "@mantine/core"; import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react"; import { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks"; import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib"; import { formatBytes } from "@/lib";
import { useTranslation } from "react-i18next";
export default function AttachmentView(props: NodeViewProps) { export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected } = props; const { node, selected } = props;
const { url, name, size } = node.attrs; const { url, name, size } = node.attrs;
const { hovered, ref } = useHover(); const { hovered, ref } = useHover();
@@ -20,26 +22,28 @@ export default function AttachmentView(props: NodeViewProps) {
wrap="nowrap" wrap="nowrap"
h={25} h={25}
> >
<Group justify="space-between" wrap="nowrap"> <Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
<IconPaperclip size={20} /> {url ? (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
<Loader size={20} style={{ flexShrink: 0 }} />
)}
<Text component="span" size="md" truncate="end"> <Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
{name} {url ? name : t("Uploading {{name}}", { name })}
</Text> </Text>
<Text component="span" size="sm" c="dimmed" inline> <Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{formatBytes(size)} {formatBytes(size)}
</Text> </Text>
</Group> </Group>
{selected || hovered ? ( {url && (selected || hovered) && (
<a href={getFileUrl(url)} target="_blank"> <a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file"> <ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} /> <IconDownload size={18} />
</ActionIcon> </ActionIcon>
</a> </a>
) : (
""
)} )}
</Group> </Group>
</Paper> </Paper>
@@ -1,10 +1,6 @@
import { import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
BubbleMenu, import { isNodeSelection, useEditorState } from "@tiptap/react";
BubbleMenuProps, import type { Editor } from "@tiptap/react";
isNodeSelection,
useEditor,
useEditorState,
} from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from "react";
import { import {
IconBold, IconBold,
@@ -38,7 +34,7 @@ export interface BubbleMenuItem {
} }
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & { type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
}; };
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => { export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
@@ -133,14 +129,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
} }
return isTextSelected(editor); return isTextSelected(editor);
}, },
tippyOptions: { options: {
moveTransition: "transform 0.15s ease-out", placement: "top",
onCreate: (instance) => { offset: 8,
instance.popper.firstChild?.addEventListener("blur", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
});
},
onHide: () => { onHide: () => {
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
@@ -156,7 +147,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
return ( return (
<BubbleMenu {...bubbleMenuProps}> <BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
<div className={classes.bubbleMenu}> <div className={classes.bubbleMenu}>
<NodeSelector <NodeSelector
editor={props.editor} editor={props.editor}
@@ -1,9 +1,5 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
@@ -53,17 +49,26 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferenceClientRect = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout"; const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const setCalloutType = useCallback( const setCalloutType = useCallback(
@@ -112,14 +117,12 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`callout-menu`} pluginKey={`callout-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect, options={{
offset: [0, 10],
placement: "bottom", placement: "bottom",
zIndex: 99, // offset: 233, // // offset: [0, 10],
popperOptions: { // zIndex: 99,
modifiers: [{ name: "flip", enabled: false }], flip: false,
},
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -90,6 +90,7 @@ export default function CodeBlockView(props: NodeViewProps) {
node.textContent.length > 0 node.textContent.length > 0
} }
> >
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} /> <NodeViewContent as="code" className={`language-${language}`} />
</pre> </pre>
@@ -1,13 +1,12 @@
import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
export const handlePaste = ( export const handlePaste = (
view: EditorView, editor: Editor,
event: ClipboardEvent, event: ClipboardEvent,
pageId: string, pageId: string,
creatorId?: string, creatorId?: string,
@@ -18,7 +17,7 @@ export const handlePaste = (
// we have to do this validation here to allow the default link extension to takeover if needs be // we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault(); event.preventDefault();
const url = clipboardData.trim(); const url = clipboardData.trim();
const { from: pos, empty } = view.state.selection; const { from: pos, empty } = editor.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url); const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href); const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
@@ -34,19 +33,27 @@ export const handlePaste = (
return false; return false;
} }
const anchorId = match[6] ? match[6].split('#')[0] : undefined; const anchorId = match[6] ? match[6].split("#")[0] : undefined;
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url; const urlWithoutAnchor = anchorId
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId); ? url.substring(0, url.indexOf("#"))
: url;
createMentionAction(
urlWithoutAnchor,
editor.view,
pos,
creatorId,
anchorId,
);
return true; return true;
} }
if (event.clipboardData?.files.length) { if (event.clipboardData?.files.length) {
event.preventDefault(); event.preventDefault();
for (const file of event.clipboardData.files) { for (const file of event.clipboardData.files) {
const pos = view.state.selection.from; const pos = editor.state.selection.from;
uploadImageAction(file, view, pos, pageId); uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, view, pos, pageId); uploadVideoAction(file, editor, pos, pageId);
uploadAttachmentAction(file, view, pos, pageId); uploadAttachmentAction(file, editor, pos, pageId);
} }
return true; return true;
} }
@@ -54,7 +61,7 @@ export const handlePaste = (
}; };
export const handleFileDrop = ( export const handleFileDrop = (
view: EditorView, editor: Editor,
event: DragEvent, event: DragEvent,
moved: boolean, moved: boolean,
pageId: string, pageId: string,
@@ -63,14 +70,14 @@ export const handleFileDrop = (
event.preventDefault(); event.preventDefault();
for (const file of event.dataTransfer.files) { for (const file of event.dataTransfer.files) {
const coordinates = view.posAtCoords({ const coordinates = editor.view.posAtCoords({
left: event.clientX, left: event.clientX,
top: event.clientY, top: event.clientY,
}); });
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
} }
return true; return true;
} }
@@ -1,11 +1,6 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -40,17 +35,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferenceClientRect = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio"; const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const onWidthChange = useCallback(
@@ -65,15 +69,11 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`drawio-menu`} pluginKey={`drawio-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect, options={{
offset: [0, 8], placement: "top",
zIndex: 99, offset: 8,
popperOptions: { flip: false,
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -66,6 +66,7 @@ export default function DrawioView(props: NodeViewProps) {
const fileName = "diagram.drawio.svg"; const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName); const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; let attachment: IAttachment = null;
@@ -1,16 +1,41 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list"; import EmojiList from "./emoji-list";
import tippy from "tippy.js";
import { init } from "emoji-mart"; import { init } from "emoji-mart";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderEmojiItems = () => { const renderEmojiItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let popup: any | null = null; let popup: HTMLDivElement | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const destroy = () => {
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
}
if (component) {
component.destroy();
component = null;
}
};
return { return {
onBeforeStart: (props: { onBeforeStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
}) => { }) => {
init({ init({
data: async () => (await import("@emoji-mart/data")).default, data: async () => (await import("@emoji-mart/data")).default,
@@ -25,51 +50,61 @@ const renderEmojiItems = () => {
return; return;
} }
// @ts-ignore getReferenceClientRect = props.clientRect;
popup = tippy("body", { popup = document.createElement("div");
getReferenceClientRect: props.clientRect, popup.style.zIndex = "9999";
appendTo: () => document.body, popup.style.position = "absolute";
content: component.element, popup.style.top = "0";
showOnCreate: true, popup.style.left = "0";
interactive: true, popup.appendChild(component.element);
trigger: "manual", document.body.appendChild(popup);
placement: "bottom",
const virtualElement = {
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect(0, 0, 0, 0);
},
};
cleanup = autoUpdate(virtualElement, popup, () => {
if (!popup) return;
computePosition(virtualElement, popup, {
placement: "bottom-start",
middleware: [offset(10), flip(), shift()],
}).then(({ x, y }) => {
if (!popup) return;
Object.assign(popup.style, {
transform: `translate(${x}px, ${y}px)`,
});
});
}); });
}, },
onStart: (props: { onStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
}) => { }) => {
component?.updateProps({...props, isLoading: false}); component?.updateProps({ ...props, isLoading: false });
if (!props.clientRect) { if (props.clientRect) {
return; getReferenceClientRect = props.clientRect;
} }
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
}) => { }) => {
component?.updateProps(props); component?.updateProps(props);
if (!props.clientRect) { if (props.clientRect) {
return; getReferenceClientRect = props.clientRect;
} }
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key === "Escape") {
popup?.[0].hide(); destroy();
component?.destroy()
return true; return true;
} }
@@ -78,13 +113,7 @@ const renderEmojiItems = () => {
return component?.ref?.onKeyDown(props); return component?.ref?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
if (popup && !popup[0]?.state.isDestroyed) { destroy();
popup[0]?.destroy();
}
if (component) {
component?.destroy();
}
}, },
}; };
}; };
@@ -1,11 +1,6 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react"; import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -42,17 +37,26 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferenceClientRect = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw"; const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const onWidthChange = useCallback(
@@ -65,17 +69,13 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`excalidraw-menu}`} pluginKey={`excalidraw-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect, options={{
offset: [0, 8], placement: "top",
zIndex: 99, offset: 8,
popperOptions: { flip: false,
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -98,6 +98,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
const fileName = "diagram.excalidraw.svg"; const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; let attachment: IAttachment = null;
@@ -1,11 +1,6 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next";
export function ImageMenu({ editor }: EditorMenuProps) { export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image");
},
[editor],
);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -52,17 +37,37 @@ export function ImageMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferenceClientRect = useCallback(() => { const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image") && editor.getAttributes("image").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image"; const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const alignImageLeft = useCallback(() => { const alignImageLeft = useCallback(() => {
@@ -105,15 +110,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`image-menu`} pluginKey={`image-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect, options={{
offset: [0, 8], placement: "top",
zIndex: 99, offset: 8,
popperOptions: { flip: false,
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -0,0 +1,27 @@
.imageWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
@@ -1,30 +1,70 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Image, Loader, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { Image } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./image-view.module.css";
import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) { export default function ImageView(props: NodeViewProps) {
const { node, selected } = props; const { t } = useTranslation();
const { src, width, align, title } = node.attrs; const { editor, node, selected } = props;
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
if (align === "center") return "alignCenter"; if (align === "center") return "alignCenter";
return "alignCenter"; return "alignCenter";
}, [align]); }, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.imagePreviews =
editor.storage.shared.imagePreviews || {};
if (placeholder?.id) {
return editor.storage.shared.imagePreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<Image <div
radius="md" className={clsx(
fit="contain" selected && "ProseMirror-selectednode",
w={width} classes.imageWrapper,
src={getFileUrl(src)} alignClass,
alt={title} )}
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)} style={{
/> aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<Image
radius="md"
fit="contain"
src={previewSrc}
alt={placeholder?.name}
/>
<Loader size={20} pos="absolute" bottom={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
@@ -1,9 +1,10 @@
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx"; import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
import { Card } from "@mantine/core"; import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
export function LinkMenu({ editor, appendTo }: EditorMenuProps) { export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
@@ -59,18 +60,15 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`link-menu}`} pluginKey={`link-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ options={{
appendTo: () => { onHide: () => {
return appendTo?.current;
},
onHidden: () => {
setShowEdit(false); setShowEdit(false);
}, },
placement: "bottom", placement: "bottom",
offset: [0, 5], offset: 5,
zIndex: 101, // zIndex: 101,
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -106,6 +106,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
setRenderItems(items); setRenderItems(items);
// update editor storage // update editor storage
//@ts-ignore
props.editor.storage.mentionItems = items; props.editor.storage.mentionItems = items;
} }
}, [suggestion, isLoading]); }, [suggestion, isLoading]);
@@ -163,7 +164,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const enterHandler = () => { const enterHandler = () => {
if (!renderItems.length) return; if (!renderItems.length) return;
if (renderItems[selectedIndex].entityType !== "header") { if (renderItems[selectedIndex]?.entityType !== "header") {
selectItem(selectedIndex); selectItem(selectedIndex);
} }
}; };
@@ -1,5 +1,11 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import tippy from "tippy.js"; import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import MentionList from "@/features/editor/components/mention/mention-list.tsx"; import MentionList from "@/features/editor/components/mention/mention-list.tsx";
function getWhitespaceCount(query: string) { function getWhitespaceCount(query: string) {
@@ -9,16 +15,27 @@ function getWhitespaceCount(query: string) {
const mentionRenderItems = () => { const mentionRenderItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let popup: any | null = null; let activeClientRect: (() => DOMRect) | null = null;
let updatePositionCleanup: (() => void) | null = null;
const destroy = () => {
updatePositionCleanup?.();
updatePositionCleanup = null;
component?.destroy();
if (component?.element?.parentNode) {
component.element.parentNode.removeChild(component.element);
}
component = null;
};
return { return {
onStart: (props: { onStart: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
query: string; query: string;
}) => { }) => {
// query must not start with a whitespace // query must not start with a whitespace
if (props.query.charAt(0) === ' '){ if (props.query.charAt(0) === " ") {
return; return;
} }
@@ -37,75 +54,95 @@ const mentionRenderItems = () => {
return; return;
} }
// @ts-ignore activeClientRect = props.clientRect;
popup = tippy("body", {
getReferenceClientRect: props.clientRect, const { element } = component;
appendTo: () => document.body, document.body.appendChild(element);
content: component.element,
showOnCreate: true, updatePositionCleanup = autoUpdate(
interactive: true, {
trigger: "manual", getBoundingClientRect: () =>
placement: "bottom-start", activeClientRect ? activeClientRect() : new DOMRect(),
}); },
element,
() => {
if (!component?.element) return;
computePosition(
{
getBoundingClientRect: () => {
return activeClientRect ? activeClientRect() : new DOMRect();
},
},
element,
{
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
},
).then(({ x, y }) => {
Object.assign(element.style, {
left: `${x}px`,
top: `${y}px`,
position: "absolute",
zIndex: "9999",
});
});
},
);
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
clientRect: DOMRect; clientRect: () => DOMRect;
query: string; query: string;
}) => { }) => {
// query must not start with a whitespace // query must not start with a whitespace
if (props.query.charAt(0) === ' '){ if (props.query.charAt(0) === " ") {
component?.destroy(); destroy();
return; return;
} }
// only update component if popup is not destroyed // only update component if popup is not destroyed
if (!popup?.[0].state.isDestroyed) { if (component) {
component?.updateProps(props); component.updateProps(props);
} }
if (!props || !props.clientRect) { if (!props || !props.clientRect) {
return; return;
} }
activeClientRect = props.clientRect;
const whitespaceCount = getWhitespaceCount(props.query); const whitespaceCount = getWhitespaceCount(props.query);
// destroy component if space is greater 3 without a match // destroy component if space is greater 3 without a match
if ( if (
whitespaceCount > 3 && whitespaceCount > 4 &&
props.editor.storage.mentionItems.length === 0 //@ts-ignore
props.editor.storage.mentionItems.length === 1
) { ) {
popup?.[0]?.destroy(); destroy();
component?.destroy(); return;
}
// fallback exit
if (whitespaceCount > 7) {
destroy();
return; return;
} }
popup &&
!popup?.[0].state.isDestroyed &&
popup?.[0].setProps({
getReferenceClientRect: props.clientRect,
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key) if (props.event.key === "Escape") {
if ( destroy();
props.event.key === "Escape" || return true;
(props.event.key === "Enter" && !popup?.[0].state.isShown) }
) {
popup?.[0].destroy(); if (props.event.key === "Enter" && !component) {
component?.destroy(); destroy();
return false; return false;
} }
return (component?.ref as any)?.onKeyDown(props); return (component?.ref as any)?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
if (popup && !popup?.[0].state.isDestroyed) { destroy();
popup[0].destroy();
}
if (component) {
component.destroy();
}
}, },
}; };
}; };
@@ -73,6 +73,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
if (!editor) return; if (!editor) return;
const { results, resultIndex } = editor.storage.searchAndReplace; const { results, resultIndex } = editor.storage.searchAndReplace;
//TODO: check type error
//@ts-ignore
const position: Range = results[resultIndex]; const position: Range = results[resultIndex];
if (!position) return; if (!position) return;
@@ -161,6 +161,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -173,9 +174,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadImageAction(file, editor.view, pos, pageId);
uploadImageAction(file, editor, pos, pageId);
} }
} }
// Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -188,6 +193,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -195,12 +201,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "video/*"; input.accept = "video/*";
input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor.view, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
}
} }
// Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -213,6 +225,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
if (!pageId) return; if (!pageId) return;
@@ -220,12 +233,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = ""; input.accept = "";
input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor.view, pos, pageId, true);
uploadAttachmentAction(file, editor, pos, pageId, true);
}
} }
// Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -1,10 +1,35 @@
import { ReactRenderer, useEditor } from "@tiptap/react"; import { ReactRenderer, useEditor } from "@tiptap/react";
import CommandList from "@/features/editor/components/slash-menu/command-list"; import CommandList from "@/features/editor/components/slash-menu/command-list";
import tippy from "tippy.js"; import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderItems = () => { const renderItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let popup: any | null = null; let popup: HTMLElement | null = null;
let cleanup: (() => void) | null = null;
let getReferenceClientRect: (() => DOMRect) | null = null;
const updatePosition = () => {
if (!popup || !getReferenceClientRect) return;
// @ts-ignore
const rect = getReferenceClientRect();
computePosition({ getBoundingClientRect: () => rect }, popup, {
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
}).then(({ x, y }) => {
if (popup) {
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
}
});
};
return { return {
onStart: (props: { onStart: (props: {
@@ -21,15 +46,29 @@ const renderItems = () => {
} }
// @ts-ignore // @ts-ignore
popup = tippy("body", { getReferenceClientRect = props.clientRect;
getReferenceClientRect: props.clientRect,
appendTo: () => document.body, popup = document.createElement("div");
content: component.element, popup.style.zIndex = "9999";
showOnCreate: true, popup.style.position = "absolute";
interactive: true, popup.style.top = "0";
trigger: "manual", popup.style.left = "0";
placement: "bottom-start",
}); document.body.appendChild(popup);
popup.appendChild(component.element);
cleanup = autoUpdate(
// @ts-ignore
{
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect();
},
},
popup,
updatePosition
);
}, },
onUpdate: (props: { onUpdate: (props: {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
@@ -41,14 +80,15 @@ const renderItems = () => {
return; return;
} }
popup && // @ts-ignore
popup[0].setProps({ getReferenceClientRect = props.clientRect;
getReferenceClientRect: props.clientRect, updatePosition();
});
}, },
onKeyDown: (props: { event: KeyboardEvent }) => { onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") { if (props.event.key === "Escape") {
popup?.[0].hide(); if (popup) {
popup.style.display = "none";
}
return true; return true;
} }
@@ -57,12 +97,19 @@ const renderItems = () => {
return component?.ref?.onKeyDown(props); return component?.ref?.onKeyDown(props);
}, },
onExit: () => { onExit: () => {
if (popup && !popup[0].state.isDestroyed) { if (cleanup) {
popup[0].destroy(); cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
} }
if (component) { if (component) {
component.destroy(); component.destroy();
component = null;
} }
}, },
}; };
@@ -1,15 +1,11 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { posToDOMRect, findParentNode } from "@tiptap/react";
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { sticky } from "tippy.js";
interface SubpagesMenuProps { interface SubpagesMenuProps {
editor: Editor; editor: Editor;
@@ -33,7 +29,7 @@ export const SubpagesMenu = React.memo(
return editor.isActive("subpages"); return editor.isActive("subpages");
}, },
[editor], [editor]
); );
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
@@ -62,18 +58,8 @@ export const SubpagesMenu = React.memo(
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`subpages-menu}`} pluginKey={`subpages-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<Tooltip position="top" label={t("Delete")}> <Tooltip position="top" label={t("Delete")}>
@@ -89,7 +75,7 @@ export const SubpagesMenu = React.memo(
</Tooltip> </Tooltip>
</BaseBubbleMenu> </BaseBubbleMenu>
); );
}, }
); );
export default SubpagesMenu; export default SubpagesMenu;
@@ -19,6 +19,7 @@ export default function SubpagesView(props: NodeViewProps) {
const { spaceSlug, shareId } = useParams(); const { spaceSlug, shareId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
//@ts-ignore
const currentPageId = editor.storage.pageId; const currentPageId = editor.storage.pageId;
// Get subpages from shared tree if we're in a shared context // Get subpages from shared tree if we're in a shared context
@@ -1,6 +1,4 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -17,6 +15,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color"; import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment"; import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus";
export const TableCellMenu = React.memo( export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => { ({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -29,7 +28,7 @@ export const TableCellMenu = React.memo(
return isCellSelection(state.selection); return isCellSelection(state.selection);
}, },
[editor], [editor]
); );
const mergeCells = useCallback(() => { const mergeCells = useCallback(() => {
@@ -53,16 +52,20 @@ export const TableCellMenu = React.memo(
}, [editor]); }, [editor]);
return ( return (
<BaseBubbleMenu <BubbleMenu
editor={editor} editor={editor}
pluginKey="table-cell-menu" pluginKey="table-cell-menu"
updateDelay={0} updateDelay={0}
tippyOptions={{ appendTo={() => {
appendTo: () => { return appendTo?.current;
return appendTo?.current; }}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
offset: {
mainAxis: 15,
}, },
offset: [0, 15],
zIndex: 99,
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -125,9 +128,9 @@ export const TableCellMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</BaseBubbleMenu> </BubbleMenu>
); );
}, }
); );
export default TableCellMenu; export default TableCellMenu;
@@ -1,11 +1,6 @@
import { import { posToDOMRect, findParentNode } from "@tiptap/react";
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -17,9 +12,12 @@ import {
IconColumnRemove, IconColumnRemove,
IconRowInsertBottom, IconRowInsertBottom,
IconRowInsertTop, IconRowInsertTop,
IconRowRemove, IconTableColumn, IconTableRow, IconRowRemove,
IconTableColumn,
IconTableRow,
IconTrashX, IconTrashX,
} from '@tabler/icons-react'; } from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus";
import { isCellSelection } from "@docmost/editor-ext"; import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -34,20 +32,28 @@ export const TableMenu = React.memo(
return editor.isActive("table") && !isCellSelection(state.selection); return editor.isActive("table") && !isCellSelection(state.selection);
}, },
[editor], [editor]
); );
const getReferenceClientRect = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table"; const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const rect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const rect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => rect,
getClientRects: () => [rect],
};
}, [editor]); }, [editor]);
const toggleHeaderColumn = useCallback(() => { const toggleHeaderColumn = useCallback(() => {
@@ -87,42 +93,33 @@ export const TableMenu = React.memo(
}, [editor]); }, [editor]);
return ( return (
<BaseBubbleMenu <BubbleMenu
editor={editor} editor={editor}
pluginKey="table-menu" pluginKey="table-menu"
updateDelay={0} resizeDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect: getReferenceClientRect, ref={(element) => {
offset: [0, 15], element.style.zIndex = "99";
zIndex: 99, }}
popperOptions: { options={{
modifiers: [ placement: "top",
{ offset: {
name: "preventOverflow", mainAxis: 15,
enabled: true, },
options: { flip: {
altAxis: true, fallbackPlacements: ["top", "bottom"],
boundary: "clippingParents", padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
padding: 8, boundary: editor.options.element as HTMLElement,
}, },
}, shift: {
{ padding: 8 + 15,
name: "flip", crossAxis: true,
enabled: true,
options: {
boundary: editor.options.element,
fallbackPlacements: ["top", "bottom"],
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
},
},
],
}, },
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group> <ActionIcon.Group>
<Tooltip position="top" label={t("Add left column")} <Tooltip position="top" label={t("Add left column")}>
>
<ActionIcon <ActionIcon
onClick={addColumnLeft} onClick={addColumnLeft}
variant="default" variant="default"
@@ -188,8 +185,7 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Toggle header row")} <Tooltip position="top" label={t("Toggle header row")}>
>
<ActionIcon <ActionIcon
onClick={toggleHeaderRow} onClick={toggleHeaderRow}
variant="default" variant="default"
@@ -200,8 +196,7 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip position="top" label={t("Toggle header column")} <Tooltip position="top" label={t("Toggle header column")}>
>
<ActionIcon <ActionIcon
onClick={toggleHeaderColumn} onClick={toggleHeaderColumn}
variant="default" variant="default"
@@ -224,9 +219,9 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
</BaseBubbleMenu> </BubbleMenu>
); );
}, }
); );
export default TableMenu; export default TableMenu;
@@ -1,11 +1,6 @@
import { import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
BubbleMenu as BaseBubbleMenu, import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next";
export function VideoMenu({ editor }: EditorMenuProps) { export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video");
},
[editor],
);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -52,17 +37,37 @@ export function VideoMenu({ editor }: EditorMenuProps) {
}, },
}); });
const getReferenceClientRect = useCallback(() => { const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video") && editor.getAttributes("video").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video"; const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect(); const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
} }
return posToDOMRect(editor.view, selection.from, selection.to); const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]); }, [editor]);
const alignVideoLeft = useCallback(() => { const alignVideoLeft = useCallback(() => {
@@ -105,15 +110,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
editor={editor} editor={editor}
pluginKey={`video-menu`} pluginKey={`video-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ getReferencedVirtualElement={getReferencedVirtualElement}
getReferenceClientRect, options={{
offset: [0, 8], placement: "top",
zIndex: 99, offset: 8,
popperOptions: { flip: false,
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
@@ -0,0 +1,33 @@
.videoWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
overflow: hidden;
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.video {
display: block;
width: 100%;
height: 100%;
border-radius: 8px;
}
@@ -1,29 +1,75 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./video-view.module.css";
import { useTranslation } from "react-i18next";
export default function VideoView(props: NodeViewProps) { export default function VideoView(props: NodeViewProps) {
const { node, selected } = props; const { t } = useTranslation();
const { src, width, align } = node.attrs; const { editor, node, selected } = props;
const { src, width, align, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
if (align === "center") return "alignCenter"; if (align === "center") return "alignCenter";
return "alignCenter"; return "alignCenter";
}, [align]); }, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.videoPreviews =
editor.storage.shared.videoPreviews || {};
if (placeholder?.id) {
return editor.storage.shared.videoPreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<video <div
preload="metadata" className={clsx(
width={width} selected && "ProseMirror-selectednode",
controls classes.videoWrapper,
src={getFileUrl(src)} alignClass,
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)} )}
style={{ display: "block" }} style={{
/> aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<video
className={classes.video}
preload="metadata"
controls
src={getFileUrl(src)}
/>
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<video
className={classes.video}
preload="metadata"
controls
src={previewSrc}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
@@ -1,11 +1,7 @@
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Placeholder } from "@tiptap/extension-placeholder";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { CharacterCount } from "@tiptap/extension-character-count"; import { TaskList, TaskItem } from "@tiptap/extension-list";
import { TaskList } from "@tiptap/extension-task-list"; import { Placeholder, CharacterCount } from "@tiptap/extensions";
import { ListKeymap } from "@tiptap/extension-list-keymap";
import { TaskItem } from "@tiptap/extension-task-item";
import { Underline } from "@tiptap/extension-underline";
import { Superscript } from "@tiptap/extension-superscript"; import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript"; import SubScript from "@tiptap/extension-subscript";
import { Typography } from "@tiptap/extension-typography"; import { Typography } from "@tiptap/extension-typography";
@@ -15,7 +11,7 @@ import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube"; import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand from "@/features/editor/extensions/slash-command"; import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration"; import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor"; import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
import { HocuspocusProvider } from "@hocuspocus/provider"; import { HocuspocusProvider } from "@hocuspocus/provider";
import { import {
Comment, Comment,
@@ -41,11 +37,12 @@ import {
Embed, Embed,
SearchAndReplace, SearchAndReplace,
Mention, Mention,
Subpages,
TableDndExtension, TableDndExtension,
Subpages,
Heading, Heading,
Highlight, Highlight,
UniqueID, UniqueID,
SharedStorage,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -97,7 +94,9 @@ lowlight.register("scala", scala);
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
heading: false, heading: false,
history: false, undoRedo: false,
link: false,
trailingNode: false,
dropcursor: { dropcursor: {
width: 3, width: 3,
color: "#70CFF8", color: "#70CFF8",
@@ -109,6 +108,7 @@ export const mainExtensions = [
}, },
}, },
}), }),
SharedStorage,
Heading, Heading,
UniqueID.configure({ UniqueID.configure({
types: ["heading", "paragraph"], types: ["heading", "paragraph"],
@@ -134,8 +134,6 @@ export const mainExtensions = [
TaskItem.configure({ TaskItem.configure({
nested: true, nested: true,
}), }),
ListKeymap,
Underline,
LinkExtension.configure({ LinkExtension.configure({
openOnClick: false, openOnClick: false,
}), }),
@@ -170,6 +168,9 @@ export const mainExtensions = [
}, },
}).extend({ }).extend({
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView); return ReactNodeViewRenderer(MentionView);
}, },
}), }),
@@ -208,6 +209,7 @@ export const mainExtensions = [
}), }),
CustomCodeBlock.configure({ CustomCodeBlock.configure({
view: CodeBlockView, view: CodeBlockView,
//@ts-ignore
lowlight, lowlight,
HTMLAttributes: { HTMLAttributes: {
spellcheck: false, spellcheck: false,
@@ -246,7 +248,7 @@ export const mainExtensions = [
Escape: () => { Escape: () => {
const event = new CustomEvent("closeFindDialogFromEditor", {}); const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
return true; return false;
}, },
}; };
}, },
@@ -258,8 +260,9 @@ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
export const collabExtensions: CollabExtensions = (provider, user) => [ export const collabExtensions: CollabExtensions = (provider, user) => [
Collaboration.configure({ Collaboration.configure({
document: provider.document, document: provider.document,
provider,
}), }),
CollaborationCursor.configure({ CollaborationCaret.configure({
provider, provider,
user: { user: {
name: user.name, name: user.name,
+101 -113
View File
@@ -1,13 +1,22 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { IndexeddbPersistence } from "y-indexeddb"; import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs"; import * as Y from "yjs";
import { import {
HocuspocusProvider, HocuspocusProvider,
onAuthenticationFailedParameters, onStatusParameters,
WebSocketStatus, WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import { import {
Editor,
EditorContent, EditorContent,
EditorProvider, EditorProvider,
useEditor, useEditor,
@@ -69,11 +78,9 @@ export default function PageEditor({
editable, editable,
content, content,
}: PageEditorProps) { }: PageEditorProps) {
const collaborationURL = useCollaborationUrl(); const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const editorCreated = useRef(false); const editorRef = useRef<Editor | null>(null);
useEffect(() => { useEffect(() => {
isComponentMounted.current = true; isComponentMounted.current = true;
@@ -84,146 +91,127 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const ydocRef = useRef<Y.Doc | null>(null); const [isLocalSynced, setIsLocalSynced] = useState(false);
if (!ydocRef.current) { const [isRemoteSynced, setIsRemoteSynced] = useState(false);
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility(); const documentState = useDocumentVisibility();
const [isCollabReady, setIsCollabReady] = useState(false);
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const userPageEditMode = const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]); () => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll }); const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId // Providers only created once per pageId
const providersRef = useRef<{ const providersRef = useRef<{
local: IndexeddbPersistence; local: IndexeddbPersistence;
remote: HocuspocusProvider; remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null); } | null>(null);
const [providersReady, setProvidersReady] = useState(false); const [providersReady, setProvidersReady] = useState(false);
const localProvider = providersRef.current?.local;
const remoteProvider = providersRef.current?.remote;
// Track when collaborative provider is ready and synced
const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
useEffect(() => { useEffect(() => {
if (!providersRef.current) { if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc); const local = new IndexeddbPersistence(documentName, ydoc);
local.on("synced", () => setLocalSynced(true)); const socket = new HocuspocusProviderWebsocket({
const remote = new HocuspocusProvider({
name: documentName,
url: collaborationURL, url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
}
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc, document: ydoc,
token: collabQuery?.token, token: collabQuery?.token,
connect: true, onAuthenticationFailed: onAuthenticationFailedHandler,
preserveConnection: false, onStatus: onStatusHandler,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => { onSynced: onSyncedHandler,
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken().then((result) => {
if (result.data?.token) {
remote.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
remote.connect();
}, 100);
}
});
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
}); });
remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => { local.on("synced", onLocalSyncedHandler);
setYjsConnectionStatus(WebSocketStatus.Disconnected); providersRef.current = { socket, local, remote };
});
providersRef.current = { local, remote };
setProvidersReady(true); setProvidersReady(true);
} else { } else {
setProvidersReady(true); setProvidersReady(true);
} }
// Only destroy on final unmount // Only destroy on final unmount
return () => { return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy(); providersRef.current?.remote.destroy();
providersRef.current?.local.destroy(); providersRef.current?.local.destroy();
providersRef.current = null; providersRef.current = null;
}; };
}, [pageId]); }, [pageId]);
/*
useEffect(() => {
// Handle token updates by reconnecting with new token
if (providersRef.current?.remote && collabQuery?.token) {
const currentToken = providersRef.current.remote.configuration.token;
if (currentToken !== collabQuery.token) {
// Token has changed, need to reconnect with new token
providersRef.current.remote.disconnect();
providersRef.current.remote.configuration.token = collabQuery.token;
providersRef.current.remote.connect();
}
}
}, [collabQuery?.token]);
*/
// Only connect/disconnect on tab/idle, not destroy // Only connect/disconnect on tab/idle, not destroy
useEffect(() => { useEffect(() => {
if (!providersReady || !providersRef.current) return; if (!providersReady || !providersRef.current) return;
const remoteProvider = providersRef.current.remote; const socket = providersRef.current.socket;
if ( if (
isIdle && isIdle &&
documentState === "hidden" && documentState === "hidden" &&
remoteProvider.status === WebSocketStatus.Connected yjsConnectionStatus === WebSocketStatus.Connected
) { ) {
remoteProvider.disconnect(); socket.disconnect();
setIsCollabReady(false);
return; return;
} }
if ( if (
documentState === "visible" && documentState === "visible" &&
remoteProvider.status === WebSocketStatus.Disconnected yjsConnectionStatus === WebSocketStatus.Disconnected
) { ) {
resetIdle(); resetIdle();
remoteProvider.connect(); socket.connect();
setTimeout(() => setIsCollabReady(true), 500);
} }
}, [isIdle, documentState, providersReady, resetIdle]); }, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => { const extensions = useMemo(() => {
if (!remoteProvider || !currentUser?.user) return mainExtensions; if (!providersReady || !providersRef.current || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [ return [
...mainExtensions, ...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user), ...collabExtensions(remoteProvider, currentUser?.user),
]; ];
}, [remoteProvider, currentUser?.user]); }, [providersReady, currentUser?.user]);
const editor = useEditor( const editor = useEditor(
{ {
@@ -266,18 +254,30 @@ export default function PageEditor({
} }
}, },
}, },
handlePaste: (view, event, slice) => handlePaste: (_view, event) => {
handlePaste(view, event, pageId, currentUser?.user.id), if (!editorRef.current) return false;
handleDrop: (view, event, _slice, moved) =>
handleFileDrop(view, event, moved, pageId), return handlePaste(
editorRef.current,
event,
pageId,
currentUser?.user.id,
);
},
handleDrop: (_view, event, _slice, moved) => {
if (!editorRef.current) return false;
return handleFileDrop(editorRef.current, event, moved, pageId);
},
}, },
onCreate({ editor }) { onCreate({ editor }) {
if (editor) { if (editor) {
// @ts-ignore // @ts-ignore
setEditor(editor); setEditor(editor);
// @ts-ignore
editor.storage.pageId = pageId; editor.storage.pageId = pageId;
handleScrollTo(editor); handleScrollTo(editor);
editorCreated.current = true; editorRef.current = editor;
} }
}, },
onUpdate({ editor }) { onUpdate({ editor }) {
@@ -287,7 +287,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, remoteProvider], [pageId, editable, extensions],
); );
const editorIsEditable = useEditorState({ const editorIsEditable = useEditorState({
@@ -343,30 +343,17 @@ export default function PageEditor({
setAsideState({ tab: "", isAsideOpen: false }); setAsideState({ tab: "", isAsideOpen: false });
}, [pageId]); }, [pageId]);
useEffect(() => {
if (remoteProvider?.status === WebSocketStatus.Connecting) {
const timeout = setTimeout(() => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
}, 5000);
return () => clearTimeout(timeout);
}
}, [remoteProvider?.status]);
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => { useEffect(() => {
const collabReadyTimeout = setTimeout(() => { const timeout = setTimeout(() => {
if ( if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
!isCollabReady && setYjsConnectionStatus(WebSocketStatus.Disconnected);
isSynced &&
remoteProvider?.status === WebSocketStatus.Connected
) {
setIsCollabReady(true);
} }
}, 500); }, 7500);
return () => clearTimeout(collabReadyTimeout);
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
return () => clearTimeout(timeout);
}, [yjsConnectionStatus, isSynced]);
useEffect(() => { useEffect(() => {
// Only honor user default page edit mode preference and permissions // Only honor user default page edit mode preference and permissions
if (editor) { if (editor) {
@@ -388,12 +375,13 @@ export default function PageEditor({
useEffect(() => { useEffect(() => {
if ( if (
!hasConnectedOnceRef.current && !hasConnectedOnceRef.current &&
remoteProvider?.status === WebSocketStatus.Connected yjsConnectionStatus === WebSocketStatus.Connected &&
isSynced
) { ) {
hasConnectedOnceRef.current = true; hasConnectedOnceRef.current = true;
setShowStatic(false); setShowStatic(false);
} }
}, [remoteProvider?.status]); }, [yjsConnectionStatus, isSynced]);
if (showStatic) { if (showStatic) {
return ( return (
@@ -81,6 +81,7 @@ export default function ReadonlyPageEditor({
onCreate={({ editor }) => { onCreate={({ editor }) => {
if (editor) { if (editor) {
if (pageId) { if (pageId) {
// @ts-ignore
editor.storage.pageId = pageId; editor.storage.pageId = pageId;
} }
// @ts-ignore // @ts-ignore
@@ -1,5 +1,5 @@
/* Give a remote user a caret */ /* Give a remote user a caret */
.collaboration-cursor__caret { .collaboration-carets__caret {
border-left: 1px solid #0d0d0d; border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d; border-right: 1px solid #0d0d0d;
margin-left: -1px; margin-left: -1px;
@@ -10,7 +10,7 @@
} }
/* Render the username above the caret */ /* Render the username above the caret */
.collaboration-cursor__label { .collaboration-carets__label {
border-radius: 3px 3px 3px 0; border-radius: 3px 3px 3px 0;
color: #0d0d0d; color: #0d0d0d;
font-size: 0.75rem; font-size: 0.75rem;
@@ -1,8 +1,9 @@
import '@/features/editor/styles/index.css'; import "@/features/editor/styles/index.css";
import React, { useEffect } from 'react'; import React, { useEffect } from "react";
import { EditorContent, useEditor } from '@tiptap/react'; import { EditorContent, useEditor } from "@tiptap/react";
import { mainExtensions } from '@/features/editor/extensions/extensions'; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Title } from '@mantine/core'; import { Title } from "@mantine/core";
import classes from "./history.module.css";
export interface HistoryEditorProps { export interface HistoryEditorProps {
title: string; title: string;
@@ -26,7 +27,9 @@ export function HistoryEditor({ title, content }: HistoryEditorProps) {
<div> <div>
<Title order={1}>{title}</Title> <Title order={1}>{title}</Title>
{editor && <EditorContent editor={editor} />} {editor && (
<EditorContent editor={editor} className={classes.historyEditor} />
)}
</div> </div>
</> </>
); );
@@ -67,7 +67,7 @@ function HistoryList({ pageId }: Props) {
mainEditorTitle mainEditorTitle
.chain() .chain()
.clearContent() .clearContent()
.setContent(activeHistoryData.title, true) .setContent(activeHistoryData.title, { emitUpdate: true })
.run(); .run();
mainEditor mainEditor
.chain() .chain()
@@ -1,37 +1,49 @@
.history { .history {
display: block; display: block;
width: 100%; width: 100%;
padding: var(--mantine-spacing-md); padding: var(--mantine-spacing-md);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
@mixin hover { @mixin hover {
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)
);
}
}
.historyEditor {
:global(.ProseMirror) {
padding: 0 !important;
}
} }
.active { .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 { .sidebar {
max-height: rem(700px); max-height: rem(700px);
width: rem(250px); width: rem(250px);
padding: var(--mantine-spacing-sm); padding: var(--mantine-spacing-sm);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: rem(1px) solid border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
} }
.sidebarFlex { .sidebarFlex {
display: flex; display: flex;
} }
.sidebarMain { .sidebarMain {
flex: 1; flex: 1;
} }
.sidebarRightSection { .sidebarRightSection {
flex: 1; flex: 1;
padding: rem(16px) rem(40px); padding: rem(16px) rem(40px);
} }
@@ -9,20 +9,14 @@ import {
IconList, IconList,
IconMessage, IconMessage,
IconPrinter, IconPrinter,
IconSearch,
IconTrash, IconTrash,
IconWifiOff, IconWifiOff,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import React from "react"; import React, { useEffect, useRef, useState } from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { import { useClipboard, useDisclosure, useHotkeys } from "@mantine/hooks";
getHotkeyHandler,
useClipboard,
useDisclosure,
useHotkeys,
} from "@mantine/hooks";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -38,8 +32,7 @@ import {
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts"; } from "@/features/editor/atoms/editor-atoms.ts";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts"; import { formattedDate } from "@/lib/time.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx"; import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
@@ -51,7 +44,6 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const toggleAside = useToggleAside(); const toggleAside = useToggleAside();
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
useHotkeys( useHotkeys(
[ [
@@ -68,6 +60,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const event = new CustomEvent("closeFindDialogFromEditor", {}); const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event); document.dispatchEvent(event);
}, },
{ preventDefault: false },
], ],
], ],
[], [],
@@ -75,17 +68,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
return ( return (
<> <>
{yjsConnectionStatus === "disconnected" && ( <ConnectionWarning />
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ActionIcon variant="default" c="red" style={{ border: "none" }}>
<IconWifiOff size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
{!readOnly && <PageStateSegmentedControl size="xs" />} {!readOnly && <PageStateSegmentedControl size="xs" />}
@@ -290,3 +273,51 @@ 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>
);
}
@@ -26,7 +26,7 @@ export class CollaborationGateway {
) { ) {
this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl()); this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl());
this.hocuspocus = HocuspocusServer.configure({ this.hocuspocus = new Hocuspocus({
debounce: 10000, debounce: 10000,
maxDebounce: 45000, maxDebounce: 45000,
unloadImmediately: false, unloadImmediately: false,
@@ -65,6 +65,6 @@ export class CollaborationGateway {
} }
async destroy(): Promise<void> { async destroy(): Promise<void> {
await this.hocuspocus.destroy(); //await this.hocuspocus.destroy();
} }
} }
@@ -1,14 +1,12 @@
import { StarterKit } from '@tiptap/starter-kit'; import { StarterKit } from '@tiptap/starter-kit';
import { TextAlign } from '@tiptap/extension-text-align'; import { TextAlign } from '@tiptap/extension-text-align';
import { TaskList } from '@tiptap/extension-task-list';
import { TaskItem } from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline';
import { Superscript } from '@tiptap/extension-superscript'; import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
import { Typography } from '@tiptap/extension-typography'; import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style'; import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color'; import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube'; import { Youtube } from '@tiptap/extension-youtube';
import { TaskList, TaskItem } from '@tiptap/extension-list';
import { import {
Heading, Heading,
Callout, Callout,
@@ -42,11 +40,14 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML // @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352 // see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html';
import { Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
codeBlock: false, codeBlock: false,
link: false,
trailingNode: false,
heading: false, heading: false,
}), }),
Heading, Heading,
@@ -59,7 +60,6 @@ export const tiptapExtensions = [
TaskItem.configure({ TaskItem.configure({
nested: true, nested: true,
}), }),
Underline,
LinkExtension, LinkExtension,
Superscript, Superscript,
SubScript, SubScript,
@@ -69,7 +69,7 @@ export class AuthenticationExtension implements Extension {
} }
if (userSpaceRole === SpaceRole.READER) { if (userSpaceRole === SpaceRole.READER) {
data.connection.readOnly = true; data.connectionConfig.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`); this.logger.debug(`User granted readonly access to page: ${pageId}`);
} }
+35 -40
View File
@@ -20,56 +20,51 @@
}, },
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "^7.1.0", "@braintree/sanitize-url": "^7.1.0",
"@casl/ability": "^6.7.5", "@casl/ability": "6.8.0",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "^1.7.3", "@floating-ui/dom": "^1.7.3",
"@hocuspocus/extension-redis": "^2.15.3", "@hocuspocus/extension-redis": "3.4.3",
"@hocuspocus/provider": "^2.15.3", "@hocuspocus/provider": "3.4.3",
"@hocuspocus/server": "^2.15.3", "@hocuspocus/server": "3.4.3",
"@hocuspocus/transformer": "^2.15.3", "@hocuspocus/transformer": "3.4.3",
"@joplin/turndown": "^4.0.74", "@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56", "@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "1.1.0", "@sindresorhus/slugify": "1.1.0",
"@tiptap/core": "2.27.1", "@tiptap/core": "3.17.1",
"@tiptap/extension-code-block": "2.27.1", "@tiptap/extension-code-block": "3.17.1",
"@tiptap/extension-code-block-lowlight": "2.27.1", "@tiptap/extension-collaboration": "3.17.1",
"@tiptap/extension-collaboration": "2.27.1", "@tiptap/extension-collaboration-caret": "3.17.1",
"@tiptap/extension-collaboration-cursor": "2.27.1", "@tiptap/extension-color": "3.17.1",
"@tiptap/extension-color": "2.27.1", "@tiptap/extension-document": "3.17.1",
"@tiptap/extension-document": "2.27.1", "@tiptap/extension-heading": "3.17.1",
"@tiptap/extension-heading": "2.27.1", "@tiptap/extension-highlight": "3.17.1",
"@tiptap/extension-highlight": "2.27.1", "@tiptap/extension-history": "3.17.1",
"@tiptap/extension-history": "2.27.1", "@tiptap/extension-image": "3.17.1",
"@tiptap/extension-image": "2.27.1", "@tiptap/extension-link": "3.17.1",
"@tiptap/extension-link": "2.27.1", "@tiptap/extension-list": "3.17.1",
"@tiptap/extension-list-item": "2.27.1", "@tiptap/extension-placeholder": "3.17.1",
"@tiptap/extension-list-keymap": "2.27.1", "@tiptap/extension-subscript": "3.17.1",
"@tiptap/extension-placeholder": "2.27.1", "@tiptap/extension-superscript": "3.17.1",
"@tiptap/extension-subscript": "2.27.1", "@tiptap/extension-table": "3.17.1",
"@tiptap/extension-superscript": "2.27.1", "@tiptap/extension-text": "3.17.1",
"@tiptap/extension-table": "2.27.1", "@tiptap/extension-text-align": "3.17.1",
"@tiptap/extension-table-cell": "2.27.1", "@tiptap/extension-text-style": "3.17.1",
"@tiptap/extension-table-header": "2.27.1", "@tiptap/extension-typography": "3.17.1",
"@tiptap/extension-table-row": "2.27.1", "@tiptap/extension-unique-id": "^3.17.1",
"@tiptap/extension-task-item": "2.27.1", "@tiptap/extension-youtube": "3.17.1",
"@tiptap/extension-task-list": "2.27.1", "@tiptap/html": "3.17.1",
"@tiptap/extension-text": "2.27.1", "@tiptap/pm": "3.17.1",
"@tiptap/extension-text-align": "2.27.1", "@tiptap/react": "3.17.1",
"@tiptap/extension-text-style": "2.27.1", "@tiptap/starter-kit": "3.17.1",
"@tiptap/extension-typography": "2.27.1", "@tiptap/suggestion": "3.17.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", "@types/qrcode": "^1.5.5",
"bytes": "^3.1.2", "bytes": "^3.1.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"fractional-indexing-jittered": "^1.0.0", "fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1",
"image-dimensions": "^2.5.0",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"linkifyjs": "^4.3.2", "linkifyjs": "^4.3.2",
@@ -79,7 +74,7 @@
"uuid": "^11.1.0", "uuid": "^11.1.0",
"y-indexeddb": "^9.0.12", "y-indexeddb": "^9.0.12",
"y-prosemirror": "1.3.7", "y-prosemirror": "1.3.7",
"yjs": "^13.6.27" "yjs": "^13.6.29"
}, },
"devDependencies": { "devDependencies": {
"@nx/js": "20.4.5", "@nx/js": "20.4.5",
+1
View File
@@ -23,3 +23,4 @@ export * from "./lib/subpages";
export * from "./lib/highlight"; export * from "./lib/highlight";
export * from "./lib/heading/heading"; export * from "./lib/heading/heading";
export * from "./lib/unique-id"; export * from "./lib/unique-id";
export * from "./lib/shared-storage";
@@ -1,126 +1,125 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { Node } from "@tiptap/pm/model";
import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import {
insertTrailingNode,
MediaUploadOptions,
UploadFn,
} from "../media-utils";
import { IAttachment } from "../types"; import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Command } from "@tiptap/core";
const uploadKey = new PluginKey("attachment-upload"); const findAttachmentNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
export const AttachmentUploadPlugin = ({ doc.descendants((node, pos) => {
placeholderClass, if (result) return false;
}: { if (
placeholderClass: string; node.type.name === "attachment" &&
}) => node.attrs.placeholder?.id === placeholderId
new Plugin({ ) {
key: uploadKey, result = { node, pos };
state: { return false;
init() { }
return DecorationSet.empty; return true;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
//@-ts-expect-error - not yet sure what the type I need here
const action = tr.getMeta(this);
if (action?.add) {
const { id, pos, fileName } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", placeholderClass);
const uploadingText = document.createElement("span");
uploadingText.setAttribute("class", "uploading-text");
uploadingText.textContent = `Uploading ${fileName}`;
placeholder.appendChild(uploadingText);
const realPos = pos + 1;
const deco = Decoration.widget(realPos, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action?.remove) {
set = set.remove(
set.find(
undefined,
undefined,
(spec) => spec.id == action.remove.id,
),
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}); });
function findPlaceholder(state: EditorState, id: {}) { return result;
const decos = uploadKey.getState(state) as DecorationSet; };
const found = decos.find(undefined, undefined, (spec) => spec.id == id); const handleAttachmentUpload =
return found.length ? found[0]?.from : null;
}
export const handleAttachmentUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn => ({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId, allowMedia) => { async (file, editor, pos, pageId, allowMedia) => {
const validated = validateFn?.(file, allowMedia); const validated = validateFn?.(file, allowMedia);
// @ts-ignore // @ts-ignore
if (!validated) return; if (!validated) return;
// A fresh object to act as the ID for this upload
const id = {};
// Replace the selection with a placeholder const placeholderId = generateNodeId();
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
tr.setMeta(uploadKey, { let placeholderInserted = false;
add: {
id,
pos,
fileName: file.name,
},
});
insertTrailingNode(tr, pos, view); const insertPlaceholder = (): Command => {
view.dispatch(tr); return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.attachment?.create({
placeholder: {
id: placeholderId,
},
name: file.name,
size: file.size,
});
await onUpload(file, pageId).then( if (!initialPlaceholderNode) return false;
(attachment: IAttachment) => {
const { schema } = view.state;
const pos = findPlaceholder(view.state, id); const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (pos == null) return; if (isEmptyTextBlock) {
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
if (!attachment) return; return true;
};
};
const replacePlaceholderWithAttachment = (
attachment: IAttachment,
): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
const node = schema.nodes.attachment?.create({ // If the placeholder is not found or attachment is missing, abort the process
if (currentPos === null || !attachment) return false;
// Update the placeholder node with the actual attachment data
tr.setNodeMarkup(currentPos, undefined, {
url: `/api/files/${attachment.id}/${attachment.fileName}`, url: `/api/files/${attachment.id}/${attachment.fileName}`,
name: attachment.fileName, name: attachment.fileName,
mime: attachment.mimeType, mime: attachment.mimeType,
size: attachment.fileSize, size: attachment.fileSize,
attachmentId: attachment.id, attachmentId: attachment.id,
}); });
if (!node) return;
const transaction = view.state.tr return true;
.replaceWith(pos, pos, node) };
.setMeta(uploadKey, { remove: { id } }); };
view.dispatch(transaction); const removePlaceholder = (): Command => {
}, return ({ tr }) => {
() => { const { pos: currentPos = null } =
// Deletes the placeholder on error findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
const transaction = view.state.tr
.delete(pos, pos) if (currentPos === null) return false;
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction); tr.delete(currentPos, currentPos + 2);
},
); return true;
};
};
// Only show the placeholder if the upload takes more than 250ms
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithAttachment(attachment));
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithAttachment(attachment))
.run();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
}
}; };
export { handleAttachmentUpload };
@@ -1,6 +1,5 @@
import { Node, mergeAttributes } from "@tiptap/core"; import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { AttachmentUploadPlugin } from "./attachment-upload";
export interface AttachmentOptions { export interface AttachmentOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@@ -13,6 +12,7 @@ export interface AttachmentAttributes {
mime?: string; // e.g. application/zip mime?: string; // e.g. application/zip
size?: number; size?: number;
attachmentId?: string; attachmentId?: string;
placeholder?: string;
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@@ -75,6 +75,10 @@ export const Attachment = Node.create<AttachmentOptions>({
"data-attachment-id": attributes.attachmentId, "data-attachment-id": attributes.attachmentId,
}), }),
}, },
placeholder: {
default: null,
rendered: false,
},
}; };
}, },
@@ -120,14 +124,9 @@ export const Attachment = Node.create<AttachmentOptions>({
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
addProseMirrorPlugins() {
return [
AttachmentUploadPlugin({
placeholderClass: "attachment-placeholder",
}),
];
},
}); });
@@ -87,7 +87,7 @@ export const Callout = Node.create<CalloutOptions>({
mergeAttributes( mergeAttributes(
{ "data-type": this.name }, { "data-type": this.name },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes, HTMLAttributes
), ),
0, 0,
]; ];
@@ -130,6 +130,9 @@ export const Callout = Node.create<CalloutOptions>({
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
@@ -193,7 +196,7 @@ export const Callout = Node.create<CalloutOptions>({
tr.delete(pos, pos + nodeSize); tr.delete(pos, pos + nodeSize);
tr.setSelection( tr.setSelection(
TextSelection.near(tr.doc.resolve(previousPosition - 1)), TextSelection.near(tr.doc.resolve(previousPosition - 1))
); );
tr.insert(previousPosition - 1, content); tr.insert(previousPosition - 1, content);
@@ -1,81 +0,0 @@
import CodeBlockLowlight, {
CodeBlockLowlightOptions,
} from "@tiptap/extension-code-block-lowlight";
import { ReactNodeViewRenderer } from "@tiptap/react";
export interface CustomCodeBlockOptions extends CodeBlockLowlightOptions {
view: any;
}
const TAB_CHAR = "\u00A0\u00A0";
export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
{
selectable: true,
addOptions() {
return {
...this.parent?.(),
view: null,
};
},
addKeyboardShortcuts() {
return {
...this.parent?.(),
Tab: () => {
if (this.editor.isActive("codeBlock")) {
this.editor
.chain()
.command(({ tr }) => {
tr.insertText(TAB_CHAR);
return true;
})
.run();
return true;
}
},
"Mod-a": () => {
if (this.editor.isActive("codeBlock")) {
const { state } = this.editor;
const { $from } = state.selection;
let codeBlockNode = null;
let codeBlockPos = null;
let depth = 0;
for (depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type.name === "codeBlock") {
codeBlockNode = node;
codeBlockPos = $from.start(depth) - 1;
break;
}
}
if (codeBlockNode && codeBlockPos !== null) {
const codeBlockStart = codeBlockPos;
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
const contentStart = codeBlockStart + 1;
const contentEnd = codeBlockEnd - 1;
this.editor.commands.setTextSelection({
from: contentStart,
to: contentEnd,
});
return true;
}
}
return false;
},
};
},
addNodeView() {
return ReactNodeViewRenderer(this.options.view);
},
}
);
@@ -0,0 +1,108 @@
import type { CodeBlockOptions } from "@tiptap/extension-code-block";
import CodeBlock from "@tiptap/extension-code-block";
import { LowlightPlugin } from "./lowlight-plugin.js";
import { ReactNodeViewRenderer } from "@tiptap/react";
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
/**
* The lowlight instance.
*/
lowlight: any;
view: any;
}
const TAB_CHAR = "\u00A0\u00A0";
/**
* This extension allows you to highlight code blocks with lowlight.
* @see https://tiptap.dev/api/nodes/code-block-lowlight
*/
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
selectable: true,
addOptions() {
return {
...this.parent?.(),
lowlight: {},
languageClassPrefix: "language-",
exitOnTripleEnter: true,
exitOnArrowDown: true,
defaultLanguage: null,
HTMLAttributes: {},
view: null,
};
},
addKeyboardShortcuts() {
return {
...this.parent?.(),
Tab: () => {
if (this.editor.isActive("codeBlock")) {
this.editor
.chain()
.command(({ tr }) => {
tr.insertText(TAB_CHAR);
return true;
})
.run();
return true;
}
},
"Mod-a": () => {
if (this.editor.isActive("codeBlock")) {
const { state } = this.editor;
const { $from } = state.selection;
let codeBlockNode = null;
let codeBlockPos = null;
let depth = 0;
for (depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type.name === "codeBlock") {
codeBlockNode = node;
codeBlockPos = $from.start(depth) - 1;
break;
}
}
if (codeBlockNode && codeBlockPos !== null) {
const codeBlockStart = codeBlockPos;
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
const contentStart = codeBlockStart + 1;
const contentEnd = codeBlockEnd - 1;
this.editor.commands.setTextSelection({
from: contentStart,
to: contentEnd,
});
return true;
}
}
return false;
},
};
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
addProseMirrorPlugins() {
return [
...(this.parent?.() || []),
LowlightPlugin({
name: this.name,
lowlight: this.options.lowlight,
defaultLanguage: this.options.defaultLanguage,
}),
];
},
});
@@ -0,0 +1 @@
export { CustomCodeBlock } from "./custom-code-block";
@@ -0,0 +1,159 @@
import { findChildren } from '@tiptap/core'
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
// @ts-ignore
import highlight from 'highlight.js/lib/core'
function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
return nodes
.map(node => {
const classes = [...className, ...(node.properties ? node.properties.className : [])]
if (node.children) {
return parseNodes(node.children, classes)
}
return {
text: node.value,
classes,
}
})
.flat()
}
function getHighlightNodes(result: any) {
// `.value` for lowlight v1, `.children` for lowlight v2
return result.value || result.children || []
}
function registered(aliasOrLanguage: string) {
return Boolean(highlight.getLanguage(aliasOrLanguage))
}
function getDecorations({
doc,
name,
lowlight,
defaultLanguage,
}: {
doc: ProsemirrorNode
name: string
lowlight: any
defaultLanguage: string | null | undefined
}) {
const decorations: Decoration[] = []
findChildren(doc, node => node.type.name === name).forEach(block => {
let from = block.pos + 1
const language = block.node.attrs.language || defaultLanguage
const languages = lowlight.listLanguages()
const nodes =
language && (languages.includes(language) || registered(language) || lowlight.registered?.(language))
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent))
parseNodes(nodes).forEach(node => {
const to = from + node.text.length
if (node.classes.length) {
const decoration = Decoration.inline(from, to, {
class: node.classes.join(' '),
})
decorations.push(decoration)
}
from = to
})
})
return DecorationSet.create(doc, decorations)
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
function isFunction(param: any): param is Function {
return typeof param === 'function'
}
export function LowlightPlugin({
name,
lowlight,
defaultLanguage,
}: {
name: string
lowlight: any
defaultLanguage: string | null | undefined
}) {
if (!['highlight', 'highlightAuto', 'listLanguages'].every(api => isFunction(lowlight[api]))) {
throw Error('You should provide an instance of lowlight to use the code-block-lowlight extension')
}
const lowlightPlugin: Plugin<any> = new Plugin({
key: new PluginKey('lowlight'),
state: {
init: (_, { doc }) =>
getDecorations({
doc,
name,
lowlight,
defaultLanguage,
}),
apply: (transaction, decorationSet, oldState, newState) => {
const oldNodeName = oldState.selection.$head.parent.type.name
const newNodeName = newState.selection.$head.parent.type.name
const oldNodes = findChildren(oldState.doc, node => node.type.name === name)
const newNodes = findChildren(newState.doc, node => node.type.name === name)
if (
transaction.docChanged &&
// Apply decorations if:
// selection includes named node,
([oldNodeName, newNodeName].includes(name) ||
// OR transaction adds/removes named node,
newNodes.length !== oldNodes.length ||
// OR transaction has changes that completely encapsulte a node
// (for example, a transaction that affects the entire document).
// Such transactions can happen during collab syncing via y-prosemirror, for example.
transaction.steps.some(step => {
// @ts-ignore
return (
// @ts-ignore
step.from !== undefined &&
// @ts-ignore
step.to !== undefined &&
oldNodes.some(node => {
// @ts-ignore
return (
// @ts-ignore
node.pos >= step.from &&
// @ts-ignore
node.pos + node.node.nodeSize <= step.to
)
})
)
}))
) {
return getDecorations({
doc: transaction.doc,
name,
lowlight,
defaultLanguage,
})
}
return decorationSet.map(transaction.mapping, transaction.doc)
},
},
props: {
decorations(state) {
return lowlightPlugin.getState(state)
},
},
})
return lowlightPlugin
}
@@ -27,6 +27,7 @@ export const Details = Node.create<DetailsOptions>({
content: "detailsSummary detailsContent", content: "detailsSummary detailsContent",
defining: true, defining: true,
isolating: true, isolating: true,
// @ts-ignore
allowGapCursor: false, allowGapCursor: false,
addOptions() { addOptions() {
return { return {
+28 -18
View File
@@ -41,45 +41,45 @@ export const Drawio = Node.create<DrawioOptions>({
addAttributes() { addAttributes() {
return { return {
src: { src: {
default: '', default: "",
parseHTML: (element) => element.getAttribute('data-src'), parseHTML: (element) => element.getAttribute("data-src"),
renderHTML: (attributes) => ({ renderHTML: (attributes) => ({
'data-src': attributes.src, "data-src": attributes.src,
}), }),
}, },
title: { title: {
default: undefined, default: undefined,
parseHTML: (element) => element.getAttribute('data-title'), parseHTML: (element) => element.getAttribute("data-title"),
renderHTML: (attributes: DrawioAttributes) => ({ renderHTML: (attributes: DrawioAttributes) => ({
'data-title': attributes.title, "data-title": attributes.title,
}), }),
}, },
width: { width: {
default: '100%', default: "100%",
parseHTML: (element) => element.getAttribute('data-width'), parseHTML: (element) => element.getAttribute("data-width"),
renderHTML: (attributes: DrawioAttributes) => ({ renderHTML: (attributes: DrawioAttributes) => ({
'data-width': attributes.width, "data-width": attributes.width,
}), }),
}, },
size: { size: {
default: null, default: null,
parseHTML: (element) => element.getAttribute('data-size'), parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: DrawioAttributes) => ({ renderHTML: (attributes: DrawioAttributes) => ({
'data-size': attributes.size, "data-size": attributes.size,
}), }),
}, },
align: { align: {
default: 'center', default: "center",
parseHTML: (element) => element.getAttribute('data-align'), parseHTML: (element) => element.getAttribute("data-align"),
renderHTML: (attributes: DrawioAttributes) => ({ renderHTML: (attributes: DrawioAttributes) => ({
'data-align': attributes.align, "data-align": attributes.align,
}), }),
}, },
attachmentId: { attachmentId: {
default: undefined, default: undefined,
parseHTML: (element) => element.getAttribute('data-attachment-id'), parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: DrawioAttributes) => ({ renderHTML: (attributes: DrawioAttributes) => ({
'data-attachment-id': attributes.attachmentId, "data-attachment-id": attributes.attachmentId,
}), }),
}, },
}; };
@@ -95,13 +95,20 @@ export const Drawio = Node.create<DrawioOptions>({
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return [ return [
'div', "div",
mergeAttributes( mergeAttributes(
{ 'data-type': this.name }, { "data-type": this.name },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes HTMLAttributes
), ),
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }], [
"img",
{
src: HTMLAttributes["data-src"],
alt: HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"],
},
],
]; ];
}, },
@@ -119,6 +126,9 @@ export const Drawio = Node.create<DrawioOptions>({
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
}); });
+24 -21
View File
@@ -1,6 +1,6 @@
import { Node, mergeAttributes } from '@tiptap/core'; import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from '@tiptap/react'; import { ReactNodeViewRenderer } from "@tiptap/react";
import { sanitizeUrl } from './utils'; import { sanitizeUrl } from "./utils";
export interface EmbedOptions { export interface EmbedOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@@ -14,7 +14,7 @@ export interface EmbedAttributes {
height?: number; height?: number;
} }
declare module '@tiptap/core' { declare module "@tiptap/core" {
interface Commands<ReturnType> { interface Commands<ReturnType> {
embeds: { embeds: {
setEmbed: (attributes?: EmbedAttributes) => ReturnType; setEmbed: (attributes?: EmbedAttributes) => ReturnType;
@@ -23,9 +23,9 @@ declare module '@tiptap/core' {
} }
export const Embed = Node.create<EmbedOptions>({ export const Embed = Node.create<EmbedOptions>({
name: 'embed', name: "embed",
inline: false, inline: false,
group: 'block', group: "block",
isolating: true, isolating: true,
atom: true, atom: true,
defining: true, defining: true,
@@ -40,41 +40,41 @@ export const Embed = Node.create<EmbedOptions>({
addAttributes() { addAttributes() {
return { return {
src: { src: {
default: '', default: "",
parseHTML: (element) => { parseHTML: (element) => {
const src = element.getAttribute('data-src'); const src = element.getAttribute("data-src");
return sanitizeUrl(src); return sanitizeUrl(src);
}, },
renderHTML: (attributes: EmbedAttributes) => ({ renderHTML: (attributes: EmbedAttributes) => ({
'data-src': sanitizeUrl(attributes.src), "data-src": sanitizeUrl(attributes.src),
}), }),
}, },
provider: { provider: {
default: '', default: "",
parseHTML: (element) => element.getAttribute('data-provider'), parseHTML: (element) => element.getAttribute("data-provider"),
renderHTML: (attributes: EmbedAttributes) => ({ renderHTML: (attributes: EmbedAttributes) => ({
'data-provider': attributes.provider, "data-provider": attributes.provider,
}), }),
}, },
align: { align: {
default: 'center', default: "center",
parseHTML: (element) => element.getAttribute('data-align'), parseHTML: (element) => element.getAttribute("data-align"),
renderHTML: (attributes: EmbedAttributes) => ({ renderHTML: (attributes: EmbedAttributes) => ({
'data-align': attributes.align, "data-align": attributes.align,
}), }),
}, },
width: { width: {
default: 640, default: 640,
parseHTML: (element) => element.getAttribute('data-width'), parseHTML: (element) => element.getAttribute("data-width"),
renderHTML: (attributes: EmbedAttributes) => ({ renderHTML: (attributes: EmbedAttributes) => ({
'data-width': attributes.width, "data-width": attributes.width,
}), }),
}, },
height: { height: {
default: 480, default: 480,
parseHTML: (element) => element.getAttribute('data-height'), parseHTML: (element) => element.getAttribute("data-height"),
renderHTML: (attributes: EmbedAttributes) => ({ renderHTML: (attributes: EmbedAttributes) => ({
'data-height': attributes.height, "data-height": attributes.height,
}), }),
}, },
}; };
@@ -97,7 +97,7 @@ export const Embed = Node.create<EmbedOptions>({
mergeAttributes( mergeAttributes(
{ "data-type": this.name }, { "data-type": this.name },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes, HTMLAttributes
), ),
[ [
"a", "a",
@@ -122,7 +122,7 @@ export const Embed = Node.create<EmbedOptions>({
}; };
return commands.insertContent({ return commands.insertContent({
type: 'embed', type: "embed",
attrs: validatedAttrs, attrs: validatedAttrs,
}); });
}, },
@@ -130,6 +130,9 @@ export const Embed = Node.create<EmbedOptions>({
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
}); });
+34 -24
View File
@@ -1,5 +1,5 @@
import { Node, mergeAttributes } from '@tiptap/core'; import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from '@tiptap/react'; import { ReactNodeViewRenderer } from "@tiptap/react";
export interface ExcalidrawOptions { export interface ExcalidrawOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@@ -14,7 +14,7 @@ export interface ExcalidrawAttributes {
attachmentId?: string; attachmentId?: string;
} }
declare module '@tiptap/core' { declare module "@tiptap/core" {
interface Commands<ReturnType> { interface Commands<ReturnType> {
excalidraw: { excalidraw: {
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType; setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
@@ -23,9 +23,9 @@ declare module '@tiptap/core' {
} }
export const Excalidraw = Node.create<ExcalidrawOptions>({ export const Excalidraw = Node.create<ExcalidrawOptions>({
name: 'excalidraw', name: "excalidraw",
inline: false, inline: false,
group: 'block', group: "block",
isolating: true, isolating: true,
atom: true, atom: true,
defining: true, defining: true,
@@ -40,45 +40,45 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
addAttributes() { addAttributes() {
return { return {
src: { src: {
default: '', default: "",
parseHTML: (element) => element.getAttribute('data-src'), parseHTML: (element) => element.getAttribute("data-src"),
renderHTML: (attributes) => ({ renderHTML: (attributes) => ({
'data-src': attributes.src, "data-src": attributes.src,
}), }),
}, },
title: { title: {
default: undefined, default: undefined,
parseHTML: (element) => element.getAttribute('data-title'), parseHTML: (element) => element.getAttribute("data-title"),
renderHTML: (attributes: ExcalidrawAttributes) => ({ renderHTML: (attributes: ExcalidrawAttributes) => ({
'data-title': attributes.title, "data-title": attributes.title,
}), }),
}, },
width: { width: {
default: '100%', default: "100%",
parseHTML: (element) => element.getAttribute('data-width'), parseHTML: (element) => element.getAttribute("data-width"),
renderHTML: (attributes: ExcalidrawAttributes) => ({ renderHTML: (attributes: ExcalidrawAttributes) => ({
'data-width': attributes.width, "data-width": attributes.width,
}), }),
}, },
size: { size: {
default: null, default: null,
parseHTML: (element) => element.getAttribute('data-size'), parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: ExcalidrawAttributes) => ({ renderHTML: (attributes: ExcalidrawAttributes) => ({
'data-size': attributes.size, "data-size": attributes.size,
}), }),
}, },
align: { align: {
default: 'center', default: "center",
parseHTML: (element) => element.getAttribute('data-align'), parseHTML: (element) => element.getAttribute("data-align"),
renderHTML: (attributes: ExcalidrawAttributes) => ({ renderHTML: (attributes: ExcalidrawAttributes) => ({
'data-align': attributes.align, "data-align": attributes.align,
}), }),
}, },
attachmentId: { attachmentId: {
default: undefined, default: undefined,
parseHTML: (element) => element.getAttribute('data-attachment-id'), parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: ExcalidrawAttributes) => ({ renderHTML: (attributes: ExcalidrawAttributes) => ({
'data-attachment-id': attributes.attachmentId, "data-attachment-id": attributes.attachmentId,
}), }),
}, },
}; };
@@ -94,13 +94,20 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return [ return [
'div', "div",
mergeAttributes( mergeAttributes(
{ 'data-type': this.name }, { "data-type": this.name },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes HTMLAttributes
), ),
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }], [
"img",
{
src: HTMLAttributes["data-src"],
alt: HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"],
},
],
]; ];
}, },
@@ -110,7 +117,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
(attrs: ExcalidrawAttributes) => (attrs: ExcalidrawAttributes) =>
({ commands }) => { ({ commands }) => {
return commands.insertContent({ return commands.insertContent({
type: 'excalidraw', type: "excalidraw",
attrs: attrs, attrs: attrs,
}); });
}, },
@@ -118,6 +125,9 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
}); });
+123 -105
View File
@@ -1,127 +1,145 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { imageDimensionsFromStream } from "image-dimensions";
import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types"; import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const uploadKey = new PluginKey("image-upload"); const findImageNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
export const ImageUploadPlugin = ({ doc.descendants((node, pos) => {
placeholderClass, if (result) return false;
}: { if (
placeholderClass: string; node.type.name === "image" &&
}) => node.attrs.placeholder?.id === placeholderId
new Plugin({ ) {
key: uploadKey, result = { node, pos };
state: { return false;
init() { }
return DecorationSet.empty; return true;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
//@-ts-expect-error - not yet sure what the type I need here
const action = tr.getMeta(this);
if (action?.add) {
const { id, pos, src } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute("class", placeholderClass);
image.src = src;
placeholder.appendChild(image);
const deco = Decoration.widget(pos + 1, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action?.remove) {
set = set.remove(
set.find(
undefined,
undefined,
(spec) => spec.id == action.remove.id,
),
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}); });
function findPlaceholder(state: EditorState, id: {}) { return result;
const decos = uploadKey.getState(state) as DecorationSet; };
const found = decos.find(undefined, undefined, (spec) => spec.id == id); const handleImageUpload =
return found.length ? found[0]?.from : null;
}
export const handleImageUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn => ({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId) => { async (file, editor, pos, pageId) => {
// check if the file is an image // check if the file is an image
const validated = validateFn?.(file); const validated = validateFn?.(file);
// @ts-ignore // @ts-ignore
if (!validated) return; if (!validated) return;
// A fresh object to act as the ID for this upload
const id = {};
const reader = new FileReader(); const objectUrl = URL.createObjectURL(file);
reader.readAsDataURL(file); const imageDimensions = await imageDimensionsFromStream(file.stream());
reader.onload = () => { const placeholderId = generateNodeId();
const tr = view.state.tr; const aspectRatio = imageDimensions
// Replace the selection with a placeholder ? imageDimensions.width / imageDimensions.height
if (!tr.selection.empty) tr.deleteSelection(); : undefined;
tr.setMeta(uploadKey, { let placeholderInserted = false;
add: {
id,
pos,
src: reader.result,
},
});
insertTrailingNode(tr, pos, view); editor.storage.shared.imagePreviews =
view.dispatch(tr); editor.storage.shared.imagePreviews || {};
editor.storage.shared.imagePreviews[placeholderId] = objectUrl;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.image?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
aspectRatio,
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
// Replace e.g. empty paragraph with the image
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
}; };
const replacePlaceholderWithImage = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
await onUpload(file, pageId).then( // If the placeholder is not found or attachment is missing, abort the process
(attachment: IAttachment) => { if (currentPos === null || !attachment) return false;
const { schema } = view.state;
const pos = findPlaceholder(view.state, id); // Update the placeholder node with the actual image data
tr.setNodeMarkup(currentPos, undefined, {
// If the content around the placeholder has been deleted, drop
// the image
if (pos == null) return;
// Otherwise, insert it at the placeholder's position, and remove
// the placeholder
if (!attachment) return;
const node = schema.nodes.image?.create({
src: `/api/files/${attachment.id}/${attachment.fileName}`, src: `/api/files/${attachment.id}/${attachment.fileName}`,
attachmentId: attachment.id, attachmentId: attachment.id,
title: attachment.fileName,
size: attachment.fileSize, size: attachment.fileSize,
aspectRatio,
}); });
if (!node) return;
const transaction = view.state.tr return true;
.replaceWith(pos, pos, node) };
.setMeta(uploadKey, { remove: { id } }); };
view.dispatch(transaction); const removePlaceholder = (): Command => {
}, return ({ tr }) => {
() => { const { pos: currentPos = null } =
// Deletes the image placeholder on error findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
const transaction = view.state.tr
.delete(pos, pos) if (currentPos === null) return false;
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction); // Remove the placeholder node
}, tr.delete(currentPos, currentPos + 2);
);
return true;
};
};
// Only show the placeholder if the upload takes more than 250ms
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.imagePreviews) {
delete editor.storage.shared.imagePreviews[placeholderId];
}
};
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithImage(attachment));
disposePreviewFile();
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithImage(attachment))
.run();
disposePreviewFile();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
}
}; };
export { handleImageUpload };
+19 -10
View File
@@ -1,7 +1,6 @@
import Image from "@tiptap/extension-image"; import Image from "@tiptap/extension-image";
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image"; import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { ImageUploadPlugin } from "./image-upload";
import { mergeAttributes, Range } from "@tiptap/core"; import { mergeAttributes, Range } from "@tiptap/core";
export interface ImageOptions extends DefaultImageOptions { export interface ImageOptions extends DefaultImageOptions {
@@ -10,11 +9,15 @@ export interface ImageOptions extends DefaultImageOptions {
export interface ImageAttributes { export interface ImageAttributes {
src?: string; src?: string;
alt?: string; alt?: string;
title?: string;
align?: string; align?: string;
attachmentId?: string; attachmentId?: string;
size?: number; size?: number;
width?: number; width?: number;
aspectRatio?: number;
placeholder?: {
id: string;
name: string;
};
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@@ -90,6 +93,17 @@ export const TiptapImage = Image.extend<ImageOptions>({
"data-size": attributes.size, "data-size": attributes.size,
}), }),
}, },
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: ImageAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
placeholder: {
default: null,
rendered: false,
},
}; };
}, },
@@ -135,14 +149,9 @@ export const TiptapImage = Image.extend<ImageOptions>({
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
addProseMirrorPlugins() {
return [
ImageUploadPlugin({
placeholderClass: "image-upload",
}),
];
},
}); });
@@ -63,6 +63,9 @@ export const MathBlock = Node.create({
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
@@ -64,6 +64,9 @@ export const MathInline = Node.create<MathInlineOption>({
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
+2 -16
View File
@@ -1,9 +1,8 @@
import type { EditorView } from "@tiptap/pm/view"; import { Editor } from "@tiptap/core";
import { Transaction } from "@tiptap/pm/state";
export type UploadFn = ( export type UploadFn = (
file: File, file: File,
view: EditorView, editor: Editor,
pos: number, pos: number,
pageId: string, pageId: string,
// only applicable to file attachments // only applicable to file attachments
@@ -14,16 +13,3 @@ export interface MediaUploadOptions {
validateFn?: (file: File, allowMedia?: boolean) => void; validateFn?: (file: File, allowMedia?: boolean) => void;
onUpload: (file: File, pageId: string) => Promise<any>; onUpload: (file: File, pageId: string) => Promise<any>;
} }
export function insertTrailingNode(
tr: Transaction,
pos: number,
view: EditorView,
) {
// create trailing node after decoration
// if decoration is at the last node
const currentDocSize = view.state.doc.content.size;
if (pos + 1 === currentDocSize) {
tr.insert(currentDocSize, view.state.schema.nodes.paragraph.create());
}
}
@@ -31,6 +31,9 @@ import {
import { Node as PMNode, Mark } from "@tiptap/pm/model"; import { Node as PMNode, Mark } from "@tiptap/pm/model";
declare module "@tiptap/core" { declare module "@tiptap/core" {
interface Storage {
searchAndReplace: SearchAndReplaceStorage;
}
interface Commands<ReturnType> { interface Commands<ReturnType> {
search: { search: {
/** /**
@@ -189,7 +192,7 @@ const replace = (
const marksSet = new Set<Mark>(); const marksSet = new Set<Mark>();
state.doc.nodesBetween(from, to, (node) => { state.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) { if (node.isText && node.marks) {
node.marks.forEach(mark => marksSet.add(mark)); node.marks.forEach((mark) => marksSet.add(mark));
} }
}); });
@@ -220,7 +223,7 @@ const replaceAll = (
const marksSet = new Set<Mark>(); const marksSet = new Set<Mark>();
tr.doc.nodesBetween(from, to, (node) => { tr.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) { if (node.isText && node.marks) {
node.marks.forEach(mark => marksSet.add(mark)); node.marks.forEach((mark) => marksSet.add(mark));
} }
}); });
@@ -352,10 +355,17 @@ export const SearchAndReplace = Extension.create<
// The results will be recalculated by the plugin, but we need to ensure // The results will be recalculated by the plugin, but we need to ensure
// the index doesn't exceed the new bounds // the index doesn't exceed the new bounds
setTimeout(() => { setTimeout(() => {
const newResultsLength = editor.storage.searchAndReplace.results.length; const newResultsLength =
if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) { editor.storage.searchAndReplace.results.length;
if (
newResultsLength > 0 &&
editor.storage.searchAndReplace.resultIndex >= newResultsLength
) {
// Keep the same position if possible, otherwise go to the last result // Keep the same position if possible, otherwise go to the last result
editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1); editor.storage.searchAndReplace.resultIndex = Math.min(
resultIndex,
newResultsLength - 1,
);
} }
}, 0); }, 0);
@@ -0,0 +1 @@
export { SharedStorage } from "./shared-storage";
@@ -0,0 +1,17 @@
import { Extension } from "@tiptap/core";
declare module "@tiptap/core" {
interface Storage {
shared: Record<string, any>;
}
}
const SharedStorage = Extension.create({
name: "shared",
addStorage() {
return {};
},
});
export { SharedStorage };
@@ -44,7 +44,7 @@ export const Subpages = Node.create<SubpagesOptions>({
mergeAttributes( mergeAttributes(
{ "data-type": this.name }, { "data-type": this.name },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes, HTMLAttributes
), ),
]; ];
}, },
@@ -63,6 +63,9 @@ export const Subpages = Node.create<SubpagesOptions>({
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell"; import { TableCell as TiptapTableCell } from "@tiptap/extension-table";
export const TableCell = TiptapTableCell.extend({ export const TableCell = TiptapTableCell.extend({
name: "tableCell", name: "tableCell",
@@ -1,7 +1,12 @@
import { Editor, Extension } from "@tiptap/core"; import { Editor, Extension } from "@tiptap/core";
import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state"; import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state";
import { EditorProps, EditorView } from "@tiptap/pm/view"; import { EditorProps, EditorView } from "@tiptap/pm/view";
import { DraggingDOMs, getDndRelatedDOMs, getHoveringCell, HoveringCellInfo } from "./utils"; import {
DraggingDOMs,
getDndRelatedDOMs,
getHoveringCell,
HoveringCellInfo,
} from "./utils";
import { getDragOverColumn, getDragOverRow } from "./calc-drag-over"; import { getDragOverColumn, getDragOverRow } from "./calc-drag-over";
import { moveColumn, moveRow } from "../utils"; import { moveColumn, moveRow } from "../utils";
import { PreviewController } from "./preview/preview-controller"; import { PreviewController } from "./preview/preview-controller";
@@ -10,268 +15,302 @@ import { DragHandleController } from "./handle/drag-handle-controller";
import { EmptyImageController } from "./handle/empty-image-controller"; import { EmptyImageController } from "./handle/empty-image-controller";
import { AutoScrollController } from "./auto-scroll-controller"; import { AutoScrollController } from "./auto-scroll-controller";
export const TableDndKey = new PluginKey('table-drag-and-drop') export const TableDndKey = new PluginKey("table-drag-and-drop");
class TableDragHandlePluginSpec implements PluginSpec<void> { class TableDragHandlePluginSpec implements PluginSpec<void> {
key = TableDndKey key = TableDndKey;
props: EditorProps<Plugin<void>> props: EditorProps<Plugin<void>>;
private _colDragHandle: HTMLElement; private _colDragHandle: HTMLElement;
private _rowDragHandle: HTMLElement; private _rowDragHandle: HTMLElement;
private _hoveringCell?: HoveringCellInfo; private _hoveringCell?: HoveringCellInfo;
private _disposables: (() => void)[] = []; private _disposables: (() => void)[] = [];
private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 }; private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 };
private _dragging = false; private _dragging = false;
private _draggingDirection: 'col' | 'row' = 'col'; private _draggingDirection: "col" | "row" = "col";
private _draggingIndex = -1; private _draggingIndex = -1;
private _droppingIndex = -1; private _droppingIndex = -1;
private _draggingDOMs?: DraggingDOMs | undefined private _draggingDOMs?: DraggingDOMs | undefined;
private _startCoords: { x: number; y: number } = { x: 0, y: 0 }; private _startCoords: { x: number; y: number } = { x: 0, y: 0 };
private _previewController: PreviewController; private _previewController: PreviewController;
private _dropIndicatorController: DropIndicatorController; private _dropIndicatorController: DropIndicatorController;
private _dragHandleController: DragHandleController; private _dragHandleController: DragHandleController;
private _emptyImageController: EmptyImageController; private _emptyImageController: EmptyImageController;
private _autoScrollController: AutoScrollController; private _autoScrollController: AutoScrollController;
constructor(public editor: Editor) { constructor(public editor: Editor) {
this.props = { this.props = {
handleDOMEvents: { handleDOMEvents: {
pointerover: this._pointerOver, pointerover: this._pointerOver,
} },
} };
this._dragHandleController = new DragHandleController(); this._dragHandleController = new DragHandleController();
this._colDragHandle = this._dragHandleController.colDragHandle; this._colDragHandle = this._dragHandleController.colDragHandle;
this._rowDragHandle = this._dragHandleController.rowDragHandle; this._rowDragHandle = this._dragHandleController.rowDragHandle;
this._previewController = new PreviewController(); this._previewController = new PreviewController();
this._dropIndicatorController = new DropIndicatorController(); this._dropIndicatorController = new DropIndicatorController();
this._emptyImageController = new EmptyImageController(); this._emptyImageController = new EmptyImageController();
this._autoScrollController = new AutoScrollController(); this._autoScrollController = new AutoScrollController();
this._bindDragEvents(); this._bindDragEvents();
}
view = () => {
const wrapper = this.editor.options.element;
//@ts-ignore
wrapper.appendChild(this._colDragHandle);
//@ts-ignore
wrapper.appendChild(this._rowDragHandle);
//@ts-ignore
wrapper.appendChild(this._previewController.previewRoot);
//@ts-ignore
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot);
return {
update: this.update,
destroy: this.destroy,
};
};
update = () => {};
destroy = () => {
if (!this.editor.isDestroyed) return;
this._dragHandleController.destroy();
this._emptyImageController.destroy();
this._previewController.destroy();
this._dropIndicatorController.destroy();
this._autoScrollController.stop();
this._disposables.forEach((disposable) => disposable());
};
private _pointerOver = (view: EditorView, event: PointerEvent) => {
if (this._dragging) return;
// Don't show drag handles in readonly mode
if (!this.editor.isEditable) {
this._dragHandleController.hide();
return;
} }
view = () => { const hoveringCell = getHoveringCell(view, event);
const wrapper = this.editor.options.element; this._hoveringCell = hoveringCell;
wrapper.appendChild(this._colDragHandle) if (!hoveringCell) {
wrapper.appendChild(this._rowDragHandle) this._dragHandleController.hide();
wrapper.appendChild(this._previewController.previewRoot) } else {
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot) this._dragHandleController.show(this.editor, hoveringCell);
}
};
return { private _onDragColStart = (event: DragEvent) => {
update: this.update, this._onDragStart(event, "col");
destroy: this.destroy, };
}
private _onDraggingCol = (event: DragEvent) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs) return;
this._draggingCoords = { x: event.clientX, y: event.clientY };
this._previewController.onDragging(
draggingDOMs,
this._draggingCoords.x,
this._draggingCoords.y,
"col",
);
this._autoScrollController.checkXAutoScroll(event.clientX, draggingDOMs);
const direction =
this._startCoords.x > this._draggingCoords.x ? "left" : "right";
const dragOverColumn = getDragOverColumn(
draggingDOMs.table,
this._draggingCoords.x,
);
if (!dragOverColumn) return;
const [col, index] = dragOverColumn;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(col, direction, "col");
};
private _onDragRowStart = (event: DragEvent) => {
this._onDragStart(event, "row");
};
private _onDraggingRow = (event: DragEvent) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs) return;
this._draggingCoords = { x: event.clientX, y: event.clientY };
this._previewController.onDragging(
draggingDOMs,
this._draggingCoords.x,
this._draggingCoords.y,
"row",
);
this._autoScrollController.checkYAutoScroll(event.clientY);
const direction =
this._startCoords.y > this._draggingCoords.y ? "up" : "down";
const dragOverRow = getDragOverRow(
draggingDOMs.table,
this._draggingCoords.y,
);
if (!dragOverRow) return;
const [row, index] = dragOverRow;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(row, direction, "row");
};
private _onDragEnd = () => {
this._dragging = false;
this._draggingIndex = -1;
this._droppingIndex = -1;
this._startCoords = { x: 0, y: 0 };
this._autoScrollController.stop();
this._dropIndicatorController.onDragEnd();
this._previewController.onDragEnd();
};
private _bindDragEvents = () => {
this._colDragHandle.addEventListener("dragstart", this._onDragColStart);
this._disposables.push(() => {
this._colDragHandle.removeEventListener(
"dragstart",
this._onDragColStart,
);
});
this._colDragHandle.addEventListener("dragend", this._onDragEnd);
this._disposables.push(() => {
this._colDragHandle.removeEventListener("dragend", this._onDragEnd);
});
this._rowDragHandle.addEventListener("dragstart", this._onDragRowStart);
this._disposables.push(() => {
this._rowDragHandle.removeEventListener(
"dragstart",
this._onDragRowStart,
);
});
this._rowDragHandle.addEventListener("dragend", this._onDragEnd);
this._disposables.push(() => {
this._rowDragHandle.removeEventListener("dragend", this._onDragEnd);
});
const ownerDocument = this.editor.view.dom?.ownerDocument;
if (ownerDocument) {
// To make `drop` event work, we need to prevent the default behavior of the
// `dragover` event for drop zone. Here we set the whole document as the
// drop zone so that even the mouse moves outside the editor, the `drop`
// event will still be triggered.
ownerDocument.addEventListener("drop", this._onDrop);
ownerDocument.addEventListener("dragover", this._onDrag);
this._disposables.push(() => {
ownerDocument.removeEventListener("drop", this._onDrop);
ownerDocument.removeEventListener("dragover", this._onDrag);
});
}
};
private _onDragStart = (event: DragEvent, type: "col" | "row") => {
const dataTransfer = event.dataTransfer;
if (dataTransfer) {
dataTransfer.effectAllowed = "move";
this._emptyImageController.hideDragImage(dataTransfer);
}
this._dragging = true;
this._draggingDirection = type;
this._startCoords = { x: event.clientX, y: event.clientY };
const draggingIndex =
(type === "col"
? this._hoveringCell?.colIndex
: this._hoveringCell?.rowIndex) ?? 0;
this._draggingIndex = draggingIndex;
const relatedDoms = getDndRelatedDOMs(
this.editor.view,
this._hoveringCell?.cellPos,
draggingIndex,
type,
);
this._draggingDOMs = relatedDoms;
const index =
type === "col"
? this._hoveringCell?.colIndex
: this._hoveringCell?.rowIndex;
this._previewController.onDragStart(relatedDoms, index, type);
this._dropIndicatorController.onDragStart(relatedDoms, type);
};
private _onDrag = (event: DragEvent) => {
event.preventDefault();
if (!this._dragging) return;
if (this._draggingDirection === "col") {
this._onDraggingCol(event);
} else {
this._onDraggingRow(event);
}
};
private _onDrop = () => {
if (!this._dragging) return;
const direction = this._draggingDirection;
const from = this._draggingIndex;
const to = this._droppingIndex;
const tr = this.editor.state.tr;
const pos = this.editor.state.selection.from;
if (direction === "col") {
const canMove = moveColumn({
tr,
originIndex: from,
targetIndex: to,
select: true,
pos,
});
if (canMove) {
this.editor.view.dispatch(tr);
}
return;
} }
update = () => {} if (direction === "row") {
const canMove = moveRow({
tr,
originIndex: from,
targetIndex: to,
select: true,
pos,
});
if (canMove) {
this.editor.view.dispatch(tr);
}
destroy = () => { return;
if (!this.editor.isDestroyed) return;
this._dragHandleController.destroy();
this._emptyImageController.destroy();
this._previewController.destroy();
this._dropIndicatorController.destroy();
this._autoScrollController.stop();
this._disposables.forEach(disposable => disposable());
}
private _pointerOver = (view: EditorView, event: PointerEvent) => {
if (this._dragging) return;
// Don't show drag handles in readonly mode
if (!this.editor.isEditable) {
this._dragHandleController.hide();
return;
}
const hoveringCell = getHoveringCell(view, event)
this._hoveringCell = hoveringCell;
if (!hoveringCell) {
this._dragHandleController.hide();
} else {
this._dragHandleController.show(this.editor, hoveringCell);
}
}
private _onDragColStart = (event: DragEvent) => {
this._onDragStart(event, 'col');
}
private _onDraggingCol = (event: DragEvent) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs) return;
this._draggingCoords = { x: event.clientX, y: event.clientY };
this._previewController.onDragging(draggingDOMs, this._draggingCoords.x, this._draggingCoords.y, 'col');
this._autoScrollController.checkXAutoScroll(event.clientX, draggingDOMs);
const direction = this._startCoords.x > this._draggingCoords.x ? 'left' : 'right';
const dragOverColumn = getDragOverColumn(draggingDOMs.table, this._draggingCoords.x);
if (!dragOverColumn) return;
const [col, index] = dragOverColumn;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(col, direction, 'col');
}
private _onDragRowStart = (event: DragEvent) => {
this._onDragStart(event, 'row');
}
private _onDraggingRow = (event: DragEvent) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs) return;
this._draggingCoords = { x: event.clientX, y: event.clientY };
this._previewController.onDragging(draggingDOMs, this._draggingCoords.x, this._draggingCoords.y, 'row');
this._autoScrollController.checkYAutoScroll(event.clientY);
const direction = this._startCoords.y > this._draggingCoords.y ? 'up' : 'down';
const dragOverRow = getDragOverRow(draggingDOMs.table, this._draggingCoords.y);
if (!dragOverRow) return;
const [row, index] = dragOverRow;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(row, direction, 'row');
}
private _onDragEnd = () => {
this._dragging = false;
this._draggingIndex = -1;
this._droppingIndex = -1;
this._startCoords = { x: 0, y: 0 };
this._autoScrollController.stop();
this._dropIndicatorController.onDragEnd();
this._previewController.onDragEnd();
}
private _bindDragEvents = () => {
this._colDragHandle.addEventListener('dragstart', this._onDragColStart);
this._disposables.push(() => {
this._colDragHandle.removeEventListener('dragstart', this._onDragColStart);
})
this._colDragHandle.addEventListener('dragend', this._onDragEnd);
this._disposables.push(() => {
this._colDragHandle.removeEventListener('dragend', this._onDragEnd);
})
this._rowDragHandle.addEventListener('dragstart', this._onDragRowStart);
this._disposables.push(() => {
this._rowDragHandle.removeEventListener('dragstart', this._onDragRowStart);
})
this._rowDragHandle.addEventListener('dragend', this._onDragEnd);
this._disposables.push(() => {
this._rowDragHandle.removeEventListener('dragend', this._onDragEnd);
})
const ownerDocument = this.editor.view.dom?.ownerDocument
if (ownerDocument) {
// To make `drop` event work, we need to prevent the default behavior of the
// `dragover` event for drop zone. Here we set the whole document as the
// drop zone so that even the mouse moves outside the editor, the `drop`
// event will still be triggered.
ownerDocument.addEventListener('drop', this._onDrop);
ownerDocument.addEventListener('dragover', this._onDrag);
this._disposables.push(() => {
ownerDocument.removeEventListener('drop', this._onDrop);
ownerDocument.removeEventListener('dragover', this._onDrag);
});
}
}
private _onDragStart = (event: DragEvent, type: 'col' | 'row') => {
const dataTransfer = event.dataTransfer;
if (dataTransfer) {
dataTransfer.effectAllowed = 'move';
this._emptyImageController.hideDragImage(dataTransfer);
}
this._dragging = true;
this._draggingDirection = type;
this._startCoords = { x: event.clientX, y: event.clientY };
const draggingIndex = (type === 'col' ? this._hoveringCell?.colIndex : this._hoveringCell?.rowIndex) ?? 0;
this._draggingIndex = draggingIndex;
const relatedDoms = getDndRelatedDOMs(
this.editor.view,
this._hoveringCell?.cellPos,
draggingIndex,
type
)
this._draggingDOMs = relatedDoms;
const index = type === 'col' ? this._hoveringCell?.colIndex : this._hoveringCell?.rowIndex;
this._previewController.onDragStart(relatedDoms, index, type);
this._dropIndicatorController.onDragStart(relatedDoms, type);
}
private _onDrag = (event: DragEvent) => {
event.preventDefault()
if (!this._dragging) return;
if (this._draggingDirection === 'col') {
this._onDraggingCol(event);
} else {
this._onDraggingRow(event);
}
}
private _onDrop = () => {
if (!this._dragging) return;
const direction = this._draggingDirection;
const from = this._draggingIndex;
const to = this._droppingIndex;
const tr = this.editor.state.tr;
const pos = this.editor.state.selection.from;
if (direction === 'col') {
const canMove = moveColumn({
tr,
originIndex: from,
targetIndex: to,
select: true,
pos,
})
if (canMove) {
this.editor.view.dispatch(tr);
}
return;
}
if (direction === 'row') {
const canMove = moveRow({
tr,
originIndex: from,
targetIndex: to,
select: true,
pos,
})
if (canMove) {
this.editor.view.dispatch(tr);
}
return;
}
} }
};
} }
export const TableDndExtension = Extension.create({ export const TableDndExtension = Extension.create({
name: 'table-drag-and-drop', name: "table-drag-and-drop",
addProseMirrorPlugins() { addProseMirrorPlugins() {
const editor = this.editor const editor = this.editor;
const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor) const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor);
const dragHandlePlugin = new Plugin(dragHandlePluginSpec) const dragHandlePlugin = new Plugin(dragHandlePluginSpec);
return [dragHandlePlugin] return [dragHandlePlugin];
} },
}) });
+1 -1
View File
@@ -1,4 +1,4 @@
import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header"; import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table";
export const TableHeader = TiptapTableHeader.extend({ export const TableHeader = TiptapTableHeader.extend({
name: "tableHeader", name: "tableHeader",
+1 -2
View File
@@ -1,6 +1,5 @@
import TiptapTableRow from "@tiptap/extension-table-row"; import { TableRow as TiptapTableRow } from "@tiptap/extension-table";
export const TableRow = TiptapTableRow.extend({ export const TableRow = TiptapTableRow.extend({
allowGapCursor: false,
content: "(tableCell | tableHeader)*", content: "(tableCell | tableHeader)*",
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
import Table from "@tiptap/extension-table"; import { Table } from "@tiptap/extension-table";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { DOMOutputSpec } from "@tiptap/pm/model"; import { DOMOutputSpec } from "@tiptap/pm/model";
@@ -1,11 +0,0 @@
import { removeDuplicates } from './removeDuplicates.js'
/**
* Returns a list of duplicated items within an array.
*/
export function findDuplicates(items: any[]): any[] {
const filtered = items.filter((el, index) => items.indexOf(el) !== index)
const duplicates = removeDuplicates(filtered)
return duplicates
}
@@ -1,15 +0,0 @@
/**
* Removes duplicated values within an array.
* Supports numbers, strings and objects.
*/
export function removeDuplicates<T>(array: T[], by = JSON.stringify): T[] {
const seen: Record<any, any> = {}
return array.filter(item => {
const key = by(item)
return Object.prototype.hasOwnProperty.call(seen, key)
? false
: (seen[key] = true)
})
}
@@ -1,386 +1,11 @@
import {
combineTransactionSteps,
Extension,
findChildren,
findChildrenInRange,
getChangedRanges,
} from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Fragment, Slice } from "@tiptap/pm/model";
import type { Transaction } from "@tiptap/pm/state";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { findDuplicates } from "./helpers/findDuplicates.js";
import { generateNodeId } from "../utils"; import { generateNodeId } from "../utils";
import { UniqueID as TiptapUniqueID } from "@tiptap/extension-unique-id";
export type UniqueIDGenerationContext = { export const UniqueID = TiptapUniqueID.extend({
node: ProseMirrorNode;
pos: number;
};
export interface UniqueIDOptions {
/**
* The name of the attribute to add the unique ID to.
* @default "id"
*/
attributeName: string;
/**
* The types of nodes to add unique IDs to.
* @default []
*/
types: string[];
/**
* The function that generates the unique ID. By default, a UUID v4 is
* generated. However, you can provide your own function to generate the
* unique ID based on the node type and the position.
*/
generateID: (ctx: UniqueIDGenerationContext) => any;
/**
* Ignore some mutations, for example applied from other users through the collaboration plugin.
*
* @default null
*/
filterTransaction: ((transaction: Transaction) => boolean) | null;
/**
* Whether to update the document by adding unique IDs to the nodes. Set this
* property to `false` if the document is in `readonly` mode, is immutable, or
* you don't want it to be modified.
*
* @default true
*/
updateDocument: boolean;
}
export const UniqueID = Extension.create<UniqueIDOptions>({
name: "uniqueID",
// well set a very high priority to make sure this runs first
// and is compatible with `appendTransaction` hooks of other extensions
priority: 10000,
addOptions() { addOptions() {
return { return {
attributeName: "id", ...this.parent?.(),
types: [],
generateID: () => generateNodeId(), generateID: () => generateNodeId(),
filterTransaction: null,
updateDocument: true,
}; };
}, },
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
[this.options.attributeName]: {
default: null,
parseHTML: (element) =>
element.getAttribute(`data-${this.options.attributeName}`),
renderHTML: (attributes) => {
if (!attributes[this.options.attributeName]) {
return {};
}
return {
[`data-${this.options.attributeName}`]:
attributes[this.options.attributeName],
};
},
},
},
},
];
},
// check initial content for missing ids
onCreate() {
if (!this.options.updateDocument) {
return;
}
const collaboration = this.editor.extensionManager.extensions.find(
(ext) => ext.name === "collaboration",
);
const collaborationCursor = this.editor.extensionManager.extensions.find(
(ext) => ext.name === "collaborationCursor",
);
const collabExtensions = [collaboration, collaborationCursor].filter(
Boolean,
);
const collab = collabExtensions.find((ext) => ext?.options?.provider);
const provider = collab?.options?.provider;
const createIds = () => {
const { view, state } = this.editor;
const { tr, doc } = state;
const { types, attributeName, generateID } = this.options;
const nodesWithoutId = findChildren(doc, (node) => {
return (
types.includes(node.type.name) && node.attrs[attributeName] === null
);
});
nodesWithoutId.forEach(({ node, pos }) => {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
[attributeName]: generateID({ node, pos }),
});
});
tr.setMeta("addToHistory", false);
view.dispatch(tr);
if (provider) {
provider.off("synced", createIds);
}
};
/**
* We need to handle collaboration a bit different here
* because we can't automatically add IDs when the provider is not yet synced
* otherwise we end up with empty paragraphs
*/
if (collab) {
if (!provider) {
return createIds();
}
provider.on("synced", createIds);
} else {
return createIds();
}
},
addProseMirrorPlugins() {
if (!this.options.updateDocument) {
return [];
}
let dragSourceElement: Element | null = null;
let transformPasted = false;
return [
new Plugin({
key: new PluginKey("uniqueID"),
appendTransaction: (transactions, oldState, newState) => {
const hasDocChanges =
transactions.some((transaction) => transaction.docChanged) &&
!oldState.doc.eq(newState.doc);
const filterTransactions =
this.options.filterTransaction &&
transactions.some((tr) => !this.options.filterTransaction?.(tr));
const isCollabTransaction = transactions.find((tr) =>
tr.getMeta("y-sync$"),
);
if (isCollabTransaction) {
return;
}
if (!hasDocChanges || filterTransactions) {
return;
}
const { tr } = newState;
const { types, attributeName, generateID } = this.options;
const transform = combineTransactionSteps(
oldState.doc,
transactions as Transaction[],
);
const { mapping } = transform;
// get changed ranges based on the old state
const changes = getChangedRanges(transform);
changes.forEach(({ newRange }) => {
const newNodes = findChildrenInRange(
newState.doc,
newRange,
(node) => {
return types.includes(node.type.name);
},
);
const newIds = newNodes
.map(({ node }) => node.attrs[attributeName])
.filter((id) => id !== null);
newNodes.forEach(({ node, pos }, i) => {
// instead of checking `node.attrs[attributeName]` directly
// we look at the current state of the node within `tr.doc`.
// this helps to prevent adding new ids to the same node
// if the node changed multiple times within one transaction
const id = tr.doc.nodeAt(pos)?.attrs[attributeName];
if (id === null) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
[attributeName]: generateID({ node, pos }),
});
return;
}
const nextNode = newNodes[i + 1];
if (nextNode && node.content.size === 0) {
tr.setNodeMarkup(nextNode.pos, undefined, {
...nextNode.node.attrs,
[attributeName]: id,
});
newIds[i + 1] = id;
if (nextNode.node.attrs[attributeName]) {
return;
}
const generatedId = generateID({ node, pos });
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
[attributeName]: generatedId,
});
newIds[i] = generatedId;
return tr;
}
const duplicatedNewIds = findDuplicates(newIds);
// check if the node doesnt exist in the old state
const { deleted } = mapping.invert().mapResult(pos);
const newNode = deleted && duplicatedNewIds.includes(id);
if (newNode) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
[attributeName]: generateID({ node, pos }),
});
}
});
});
if (!tr.steps.length) {
return;
}
// `tr.setNodeMarkup` resets the stored marks
// so we'll restore them if they exist
tr.setStoredMarks(newState.tr.storedMarks);
// Mark this transaction as coming from UniqueID
// to prevent infinite loops with other extensions (e.g., TrailingNode)
tr.setMeta("__uniqueIDTransaction", true);
return tr;
},
// we register a global drag handler to track the current drag source element
view(view) {
const handleDragstart = (event: DragEvent) => {
dragSourceElement = view.dom.parentElement?.contains(
event.target as Element,
)
? view.dom.parentElement
: null;
};
window.addEventListener("dragstart", handleDragstart);
return {
destroy() {
window.removeEventListener("dragstart", handleDragstart);
},
};
},
props: {
// `handleDOMEvents` is called before `transformPasted`
// so we can do some checks before
handleDOMEvents: {
// only create new ids for dropped content
// or dropped content while holding `alt`
// or content is dragged from another editor
drop: (view, event) => {
if (
dragSourceElement !== view.dom.parentElement ||
event.dataTransfer?.effectAllowed === "copyMove" ||
event.dataTransfer?.effectAllowed === "copy"
) {
dragSourceElement = null;
transformPasted = true;
}
return false;
},
// always create new ids on pasted content
paste: () => {
transformPasted = true;
return false;
},
},
// well remove ids for every pasted node
// so we can create a new one within `appendTransaction`
transformPasted: (slice) => {
if (!transformPasted) {
return slice;
}
const { types, attributeName } = this.options;
const removeId = (fragment: Fragment): Fragment => {
const list: ProseMirrorNode[] = [];
fragment.forEach((node) => {
// dont touch text nodes
if (node.isText) {
list.push(node);
return;
}
// check for any other child nodes
if (!types.includes(node.type.name)) {
list.push(node.copy(removeId(node.content)));
return;
}
// remove id
const nodeWithoutId = node.type.create(
{
...node.attrs,
[attributeName]: null,
},
removeId(node.content),
node.marks,
);
list.push(nodeWithoutId);
});
return Fragment.from(list);
};
// reset check
transformPasted = false;
return new Slice(
removeId(slice.content),
slice.openStart,
slice.openEnd,
);
},
},
}),
];
},
}); });
+4 -12
View File
@@ -1,6 +1,6 @@
// @ts-nocheck
import { Editor, findParentNode, isTextSelection } from "@tiptap/core"; import { Editor, findParentNode, isTextSelection } from "@tiptap/core";
import { Selection, Transaction } from "@tiptap/pm/state"; import { EditorState, Selection, Transaction } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
import { CellSelection, TableMap } from "@tiptap/pm/tables"; import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { Node, ResolvedPos } from "@tiptap/pm/model"; import { Node, ResolvedPos } from "@tiptap/pm/model";
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url"; import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
@@ -287,11 +287,7 @@ export const isColumnGripSelected = ({
const nodeDOM = view.nodeDOM(from) as HTMLElement; const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos; const node = nodeDOM || domAtPos;
if ( if (!editor.isActive("table") || !node || isTableSelected(state.selection)) {
!editor.isActive("table") ||
!node ||
isTableSelected(state.selection)
) {
return false; return false;
} }
@@ -324,11 +320,7 @@ export const isRowGripSelected = ({
const nodeDOM = view.nodeDOM(from) as HTMLElement; const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos; const node = nodeDOM || domAtPos;
if ( if (!editor.isActive("table") || !node || isTableSelected(state.selection)) {
!editor.isActive(Table.name) ||
!node ||
isTableSelected(state.selection)
) {
return false; return false;
} }
+144 -107
View File
@@ -1,132 +1,169 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import {
insertTrailingNode,
MediaUploadOptions,
UploadFn,
} from "../media-utils";
import { IAttachment } from "../types"; import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const uploadKey = new PluginKey("video-upload"); const findVideoNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
export const VideoUploadPlugin = ({ doc.descendants((node, pos) => {
placeholderClass, if (result) return false;
}: {
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"); if (
placeholder.setAttribute("class", "video-placeholder"); node.type.name === "video" &&
const video = document.createElement("video"); node.attrs.placeholder?.id === placeholderId
video.setAttribute("class", placeholderClass); ) {
video.src = src; result = { node, pos };
placeholder.appendChild(video); return false;
const deco = Decoration.widget(pos + 1, placeholder, { }
id,
}); return true;
set = set.add(tr.doc, [deco]);
} else if (action?.remove) {
set = set.remove(
set.find(
undefined,
undefined,
(spec) => spec.id == action.remove.id,
),
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}); });
function findPlaceholder(state: EditorState, id: {}) { return result;
const decos = uploadKey.getState(state) as DecorationSet; };
const found = decos.find(undefined, undefined, (spec) => spec.id == id); const getVideoDimensions = (
return found.length ? found[0]?.from : null; url: string,
} ): Promise<
{ width: number; height: number; aspectRatio: number } | undefined
> => {
return new Promise<
{ width: number; height: number; aspectRatio: number } | undefined
>((resolve) => {
const video = document.createElement("video");
export const handleVideoUpload = video.preload = "metadata";
video.onloadedmetadata = () => {
const width = video.videoWidth;
const height = video.videoHeight;
const aspectRatio = height > 0 ? width / height : 1;
resolve({ width, height, aspectRatio });
};
video.onerror = () => {
resolve(undefined);
};
video.src = url;
});
};
const handleVideoUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn => ({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId) => { async (file, editor, pos, pageId) => {
// check if the file is an image // check if the file is valid
const validated = validateFn?.(file); const validated = validateFn?.(file);
// @ts-ignore // @ts-ignore
if (!validated) return; if (!validated) return;
// A fresh object to act as the ID for this upload
const id = {};
// Replace the selection with a placeholder const objectUrl = URL.createObjectURL(file);
const videoDimensions = await getVideoDimensions(objectUrl);
const placeholderId = generateNodeId();
const aspectRatio = videoDimensions.aspectRatio;
const reader = new FileReader(); let placeholderInserted = false;
reader.readAsDataURL(file);
reader.onload = () => {
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
tr.setMeta(uploadKey, { editor.storage.shared.videoPreviews =
add: { editor.storage.shared.videoPreviews || {};
id, editor.storage.shared.videoPreviews[placeholderId] = objectUrl;
pos,
src: reader.result,
},
});
insertTrailingNode(tr, pos, view); const insertPlaceholder = (): Command => {
view.dispatch(tr); return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.video?.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 video
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
}; };
const replacePlaceholderWithVideo = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
await onUpload(file, pageId).then( // If the placeholder is not found or attachment is missing, abort the process
(attachment: IAttachment) => { if (currentPos === null || !attachment) return;
const { schema } = view.state;
const pos = findPlaceholder(view.state, id); // Update the placeholder node with the actual video data
tr.setNodeMarkup(currentPos, undefined, {
// If the content around the placeholder has been deleted, drop
// the image
if (pos == null) return;
// Otherwise, insert it at the placeholder's position, and remove
// the placeholder
if (!attachment) return;
const node = schema.nodes.video?.create({
src: `/api/files/${attachment.id}/${attachment.fileName}`, src: `/api/files/${attachment.id}/${attachment.fileName}`,
attachmentId: attachment.id, attachmentId: attachment.id,
title: attachment.fileName, title: attachment.fileName,
size: attachment.fileSize, size: attachment.fileSize,
aspectRatio,
}); });
if (!node) return;
const transaction = view.state.tr return true;
.replaceWith(pos, pos, node) };
.setMeta(uploadKey, { remove: { id } }); };
view.dispatch(transaction); const removePlaceholder = (): Command => {
}, return ({ tr }) => {
() => { const { pos: currentPos = null } =
// Deletes the image placeholder on error findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
const transaction = view.state.tr
.delete(pos, pos) if (currentPos === null) return false;
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction); tr.delete(currentPos, currentPos + 2);
},
); return true;
};
};
// Only show the placeholder if the upload takes more than 250ms
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.videoPreviews) {
delete editor.storage.shared.videoPreviews[placeholderId];
}
};
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithVideo(attachment));
disposePreviewFile();
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithVideo(attachment))
.run();
disposePreviewFile();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
}
}; };
export { handleVideoUpload };
+22 -13
View File
@@ -1,6 +1,5 @@
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { VideoUploadPlugin } from "./video-upload"; import { Range, Node } from "@tiptap/core";
import { mergeAttributes, Range, Node, nodeInputRule } from "@tiptap/core";
export interface VideoOptions { export interface VideoOptions {
view: any; view: any;
@@ -8,11 +7,15 @@ export interface VideoOptions {
} }
export interface VideoAttributes { export interface VideoAttributes {
src?: string; src?: string;
title?: string;
align?: string; align?: string;
attachmentId?: string; attachmentId?: string;
size?: number; size?: number;
width?: number; width?: number;
aspectRatio?: number;
placeholder?: {
id: string;
name: string;
};
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@@ -81,15 +84,26 @@ export const TiptapVideo = Node.create<VideoOptions>({
"data-align": attributes.align, "data-align": attributes.align,
}), }),
}, },
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: VideoAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
placeholder: {
default: null,
rendered: false,
},
}; };
}, },
parseHTML() { parseHTML() {
return [ return [
{ {
tag: 'video', tag: "video",
}, },
] ];
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
@@ -126,14 +140,9 @@ export const TiptapVideo = Node.create<VideoOptions>({
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
addProseMirrorPlugins() {
return [
VideoUploadPlugin({
placeholderClass: "video-upload",
}),
];
},
}); });
+1
View File
@@ -8,6 +8,7 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"target": "ES2022", "target": "ES2022",
"jsx": "react-jsx",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./", "baseUrl": "./",
+496 -580
View File
File diff suppressed because it is too large Load Diff