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
@@ -1,11 +1,13 @@
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 { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib";
import { useTranslation } from "react-i18next";
export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected } = props;
const { url, name, size } = node.attrs;
const { hovered, ref } = useHover();
@@ -20,26 +22,28 @@ export default function AttachmentView(props: NodeViewProps) {
wrap="nowrap"
h={25}
>
<Group justify="space-between" wrap="nowrap">
<IconPaperclip size={20} />
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
{url ? (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
<Loader size={20} style={{ flexShrink: 0 }} />
)}
<Text component="span" size="md" truncate="end">
{name}
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
{url ? name : t("Uploading {{name}}", { name })}
</Text>
<Text component="span" size="sm" c="dimmed" inline>
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{formatBytes(size)}
</Text>
</Group>
{selected || hovered ? (
{url && (selected || hovered) && (
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
) : (
""
)}
</Group>
</Paper>
@@ -1,10 +1,6 @@
import {
BubbleMenu,
BubbleMenuProps,
isNodeSelection,
useEditor,
useEditorState,
} from "@tiptap/react";
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
import { isNodeSelection, useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react";
import {
IconBold,
@@ -38,7 +34,7 @@ export interface BubbleMenuItem {
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
editor: ReturnType<typeof useEditor>;
editor: Editor | null;
};
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
@@ -133,14 +129,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
}
return isTextSelected(editor);
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onCreate: (instance) => {
instance.popper.firstChild?.addEventListener("blur", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
});
},
options: {
placement: "top",
offset: 8,
onHide: () => {
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
@@ -156,7 +147,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
return (
<BubbleMenu {...bubbleMenuProps}>
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
<div className={classes.bubbleMenu}>
<NodeSelector
editor={props.editor}
@@ -1,9 +1,5 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import {
@@ -53,17 +49,26 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
},
});
const getReferenceClientRect = useCallback(() => {
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
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]);
const setCalloutType = useCallback(
@@ -112,14 +117,12 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`callout-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 10],
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "bottom",
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
// offset: 233, // // offset: [0, 10],
// zIndex: 99,
flip: false,
}}
shouldShow={shouldShow}
>
@@ -90,6 +90,7 @@ export default function CodeBlockView(props: NodeViewProps) {
node.textContent.length > 0
}
>
{/* @ts-ignore */}
<NodeViewContent as="code" className={`language-${language}`} />
</pre>
@@ -1,13 +1,12 @@
import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
export const handlePaste = (
view: EditorView,
editor: Editor,
event: ClipboardEvent,
pageId: 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
event.preventDefault();
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 currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
@@ -34,19 +33,27 @@ export const handlePaste = (
return false;
}
const anchorId = match[6] ? match[6].split('#')[0] : undefined;
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
const urlWithoutAnchor = anchorId
? url.substring(0, url.indexOf("#"))
: url;
createMentionAction(
urlWithoutAnchor,
editor.view,
pos,
creatorId,
anchorId,
);
return true;
}
if (event.clipboardData?.files.length) {
event.preventDefault();
for (const file of event.clipboardData.files) {
const pos = view.state.selection.from;
uploadImageAction(file, view, pos, pageId);
uploadVideoAction(file, view, pos, pageId);
uploadAttachmentAction(file, view, pos, pageId);
const pos = editor.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
uploadAttachmentAction(file, editor, pos, pageId);
}
return true;
}
@@ -54,7 +61,7 @@ export const handlePaste = (
};
export const handleFileDrop = (
view: EditorView,
editor: Editor,
event: DragEvent,
moved: boolean,
pageId: string,
@@ -63,14 +70,14 @@ export const handleFileDrop = (
event.preventDefault();
for (const file of event.dataTransfer.files) {
const coordinates = view.posAtCoords({
const coordinates = editor.view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
}
return true;
}
@@ -1,11 +1,6 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -40,17 +35,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
},
});
const getReferenceClientRect = useCallback(() => {
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
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]);
const onWidthChange = useCallback(
@@ -65,15 +69,11 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`drawio-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
@@ -66,6 +66,7 @@ export default function DrawioView(props: NodeViewProps) {
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
@@ -1,16 +1,41 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
import EmojiList from "./emoji-list";
import tippy from "tippy.js";
import { init } from "emoji-mart";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
const renderEmojiItems = () => {
let component: ReactRenderer | null = null;
let popup: 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 {
onBeforeStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
clientRect: () => DOMRect;
}) => {
init({
data: async () => (await import("@emoji-mart/data")).default,
@@ -25,51 +50,61 @@ const renderEmojiItems = () => {
return;
}
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom",
getReferenceClientRect = props.clientRect;
popup = document.createElement("div");
popup.style.zIndex = "9999";
popup.style.position = "absolute";
popup.style.top = "0";
popup.style.left = "0";
popup.appendChild(component.element);
document.body.appendChild(popup);
const virtualElement = {
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect(0, 0, 0, 0);
},
};
cleanup = autoUpdate(virtualElement, popup, () => {
if (!popup) return;
computePosition(virtualElement, popup, {
placement: "bottom-start",
middleware: [offset(10), flip(), shift()],
}).then(({ x, y }) => {
if (!popup) return;
Object.assign(popup.style, {
transform: `translate(${x}px, ${y}px)`,
});
});
});
},
onStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
clientRect: () => DOMRect;
}) => {
component?.updateProps({...props, isLoading: false});
component?.updateProps({ ...props, isLoading: false });
if (!props.clientRect) {
return;
if (props.clientRect) {
getReferenceClientRect = props.clientRect;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
clientRect: () => DOMRect;
}) => {
component?.updateProps(props);
if (!props.clientRect) {
return;
if (props.clientRect) {
getReferenceClientRect = props.clientRect;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
component?.destroy()
destroy();
return true;
}
@@ -78,13 +113,7 @@ const renderEmojiItems = () => {
return component?.ref?.onKeyDown(props);
},
onExit: () => {
if (popup && !popup[0]?.state.isDestroyed) {
popup[0]?.destroy();
}
if (component) {
component?.destroy();
}
destroy();
},
};
};
@@ -1,11 +1,6 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -42,17 +37,26 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
},
});
const getReferenceClientRect = useCallback(() => {
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
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]);
const onWidthChange = useCallback(
@@ -65,17 +69,13 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`excalidraw-menu}`}
pluginKey={`excalidraw-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
@@ -98,6 +98,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
@@ -1,11 +1,6 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next";
export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image");
},
[editor],
);
const editorState = useEditorState({
editor,
@@ -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 predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection);
if (parent) {
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]);
const alignImageLeft = useCallback(() => {
@@ -105,15 +110,11 @@ export function ImageMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`image-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
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 { Group, Image, Loader, Text } from "@mantine/core";
import { useMemo } from "react";
import { Image } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx";
import classes from "./image-view.module.css";
import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) {
const { node, selected } = props;
const { src, width, align, title } = node.attrs;
const { t } = useTranslation();
const { editor, node, selected } = props;
const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
if (align === "center") return "alignCenter";
return "alignCenter";
}, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.imagePreviews =
editor.storage.shared.imagePreviews || {};
if (placeholder?.id) {
return editor.storage.shared.imagePreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return (
<NodeViewWrapper data-drag-handle>
<Image
radius="md"
fit="contain"
w={width}
src={getFileUrl(src)}
alt={title}
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
/>
<div
className={clsx(
selected && "ProseMirror-selectednode",
classes.imageWrapper,
alignClass,
)}
style={{
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<Image
radius="md"
fit="contain"
src={previewSrc}
alt={placeholder?.name}
/>
<Loader size={20} pos="absolute" bottom={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</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 { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
const [showEdit, setShowEdit] = useState(false);
@@ -59,18 +60,15 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`link-menu}`}
pluginKey={`link-menu`}
updateDelay={0}
tippyOptions={{
appendTo: () => {
return appendTo?.current;
},
onHidden: () => {
options={{
onHide: () => {
setShowEdit(false);
},
placement: "bottom",
offset: [0, 5],
zIndex: 101,
offset: 5,
// zIndex: 101,
}}
shouldShow={shouldShow}
>
@@ -106,6 +106,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
setRenderItems(items);
// update editor storage
//@ts-ignore
props.editor.storage.mentionItems = items;
}
}, [suggestion, isLoading]);
@@ -163,7 +164,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const enterHandler = () => {
if (!renderItems.length) return;
if (renderItems[selectedIndex].entityType !== "header") {
if (renderItems[selectedIndex]?.entityType !== "header") {
selectItem(selectedIndex);
}
};
@@ -203,7 +204,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
parentPageId: page.id || null,
title: title
};
let createdPage: IPage;
try {
createdPage = await createPageMutation.mutateAsync(payload);
@@ -1,5 +1,11 @@
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";
function getWhitespaceCount(query: string) {
@@ -9,16 +15,27 @@ function getWhitespaceCount(query: string) {
const mentionRenderItems = () => {
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 {
onStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
clientRect: () => DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === ' '){
if (props.query.charAt(0) === " ") {
return;
}
@@ -37,75 +54,95 @@ const mentionRenderItems = () => {
return;
}
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
activeClientRect = props.clientRect;
const { element } = component;
document.body.appendChild(element);
updatePositionCleanup = autoUpdate(
{
getBoundingClientRect: () =>
activeClientRect ? activeClientRect() : new DOMRect(),
},
element,
() => {
if (!component?.element) return;
computePosition(
{
getBoundingClientRect: () => {
return activeClientRect ? activeClientRect() : new DOMRect();
},
},
element,
{
placement: "bottom-start",
middleware: [offset(0), flip(), shift()],
},
).then(({ x, y }) => {
Object.assign(element.style, {
left: `${x}px`,
top: `${y}px`,
position: "absolute",
zIndex: "9999",
});
});
},
);
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: DOMRect;
clientRect: () => DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === ' '){
component?.destroy();
if (props.query.charAt(0) === " ") {
destroy();
return;
}
// only update component if popup is not destroyed
if (!popup?.[0].state.isDestroyed) {
component?.updateProps(props);
if (component) {
component.updateProps(props);
}
if (!props || !props.clientRect) {
return;
}
activeClientRect = props.clientRect;
const whitespaceCount = getWhitespaceCount(props.query);
// destroy component if space is greater 3 without a match
if (
whitespaceCount > 3 &&
props.editor.storage.mentionItems.length === 0
whitespaceCount > 4 &&
//@ts-ignore
props.editor.storage.mentionItems.length === 1
) {
popup?.[0]?.destroy();
component?.destroy();
destroy();
return;
}
// fallback exit
if (whitespaceCount > 7) {
destroy();
return;
}
popup &&
!popup?.[0].state.isDestroyed &&
popup?.[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key)
if (
props.event.key === "Escape" ||
(props.event.key === "Enter" && !popup?.[0].state.isShown)
) {
popup?.[0].destroy();
component?.destroy();
return false;
}
if (props.event.key === "Escape") {
destroy();
return true;
}
if (props.event.key === "Enter" && !component) {
destroy();
return false;
}
return (component?.ref as any)?.onKeyDown(props);
},
onExit: () => {
if (popup && !popup?.[0].state.isDestroyed) {
popup[0].destroy();
}
if (component) {
component.destroy();
}
destroy();
},
};
};
@@ -73,6 +73,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
if (!editor) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
//TODO: check type error
//@ts-ignore
const position: Range = results[resultIndex];
if (!position) return;
@@ -161,6 +161,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -173,9 +174,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (input.files?.length) {
for (const file of input.files) {
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();
},
@@ -188,6 +193,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -195,12 +201,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*";
input.multiple = true;
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor.view, pos, pageId);
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor, pos, pageId);
}
}
// Reset the input value to allow uploading the same file again if needed
input.value = "";
};
input.click();
},
@@ -213,6 +225,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
@@ -220,12 +233,18 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input");
input.type = "file";
input.accept = "";
input.multiple = true;
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor.view, pos, pageId, true);
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor, pos, pageId, true);
}
}
// Reset the input value to allow uploading the same file again if needed
input.value = "";
};
input.click();
},
@@ -1,10 +1,35 @@
import { ReactRenderer, useEditor } from "@tiptap/react";
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 = () => {
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 {
onStart: (props: {
@@ -21,15 +46,29 @@ const renderItems = () => {
}
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
getReferenceClientRect = props.clientRect;
popup = document.createElement("div");
popup.style.zIndex = "9999";
popup.style.position = "absolute";
popup.style.top = "0";
popup.style.left = "0";
document.body.appendChild(popup);
popup.appendChild(component.element);
cleanup = autoUpdate(
// @ts-ignore
{
getBoundingClientRect: () => {
return getReferenceClientRect
? getReferenceClientRect()
: new DOMRect();
},
},
popup,
updatePosition
);
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
@@ -41,14 +80,15 @@ const renderItems = () => {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
// @ts-ignore
getReferenceClientRect = props.clientRect;
updatePosition();
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
if (popup) {
popup.style.display = "none";
}
return true;
}
@@ -57,12 +97,19 @@ const renderItems = () => {
return component?.ref?.onKeyDown(props);
},
onExit: () => {
if (popup && !popup[0].state.isDestroyed) {
popup[0].destroy();
if (cleanup) {
cleanup();
cleanup = null;
}
if (popup) {
popup.remove();
popup = null;
}
if (component) {
component.destroy();
component = null;
}
},
};
@@ -1,15 +1,11 @@
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, findParentNode } from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { sticky } from "tippy.js";
interface SubpagesMenuProps {
editor: Editor;
@@ -33,7 +29,7 @@ export const SubpagesMenu = React.memo(
return editor.isActive("subpages");
},
[editor],
[editor]
);
const getReferenceClientRect = useCallback(() => {
@@ -62,18 +58,8 @@ export const SubpagesMenu = React.memo(
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`subpages-menu}`}
pluginKey={`subpages-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
@@ -89,7 +75,7 @@ export const SubpagesMenu = React.memo(
</Tooltip>
</BaseBubbleMenu>
);
},
}
);
export default SubpagesMenu;
@@ -19,6 +19,7 @@ export default function SubpagesView(props: NodeViewProps) {
const { spaceSlug, shareId } = useParams();
const { t } = useTranslation();
//@ts-ignore
const currentPageId = editor.storage.pageId;
// Get subpages from shared tree if we're in a shared context
@@ -1,6 +1,4 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -17,6 +15,7 @@ import {
import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus";
export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -29,7 +28,7 @@ export const TableCellMenu = React.memo(
return isCellSelection(state.selection);
},
[editor],
[editor]
);
const mergeCells = useCallback(() => {
@@ -53,23 +52,27 @@ export const TableCellMenu = React.memo(
}, [editor]);
return (
<BaseBubbleMenu
<BubbleMenu
editor={editor}
pluginKey="table-cell-menu"
updateDelay={0}
tippyOptions={{
appendTo: () => {
return appendTo?.current;
appendTo={() => {
return appendTo?.current;
}}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
offset: {
mainAxis: 15,
},
offset: [0, 15],
zIndex: 99,
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} />
<Tooltip position="top" label={t("Merge cells")}>
<ActionIcon
onClick={mergeCells}
@@ -125,9 +128,9 @@ export const TableCellMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BaseBubbleMenu>
</BubbleMenu>
);
},
}
);
export default TableCellMenu;
@@ -1,11 +1,6 @@
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { posToDOMRect, findParentNode } from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
@@ -17,9 +12,12 @@ import {
IconColumnRemove,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove, IconTableColumn, IconTableRow,
IconRowRemove,
IconTableColumn,
IconTableRow,
IconTrashX,
} from '@tabler/icons-react';
} from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus";
import { isCellSelection } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
@@ -34,20 +32,28 @@ export const TableMenu = React.memo(
return editor.isActive("table") && !isCellSelection(state.selection);
},
[editor],
[editor]
);
const getReferenceClientRect = useCallback(() => {
const getReferencedVirtualElement = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
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]);
const toggleHeaderColumn = useCallback(() => {
@@ -87,42 +93,33 @@ export const TableMenu = React.memo(
}, [editor]);
return (
<BaseBubbleMenu
<BubbleMenu
editor={editor}
pluginKey="table-menu"
updateDelay={0}
tippyOptions={{
getReferenceClientRect: getReferenceClientRect,
offset: [0, 15],
zIndex: 99,
popperOptions: {
modifiers: [
{
name: "preventOverflow",
enabled: true,
options: {
altAxis: true,
boundary: "clippingParents",
padding: 8,
},
},
{
name: "flip",
enabled: true,
options: {
boundary: editor.options.element,
fallbackPlacements: ["top", "bottom"],
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
},
},
],
resizeDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
ref={(element) => {
element.style.zIndex = "99";
}}
options={{
placement: "top",
offset: {
mainAxis: 15,
},
flip: {
fallbackPlacements: ["top", "bottom"],
padding: { top: 35 + 15, left: 8, right: 8, bottom: -Infinity },
boundary: editor.options.element as HTMLElement,
},
shift: {
padding: 8 + 15,
crossAxis: true,
},
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<Tooltip position="top" label={t("Add left column")}
>
<Tooltip position="top" label={t("Add left column")}>
<ActionIcon
onClick={addColumnLeft}
variant="default"
@@ -188,8 +185,7 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header row")}
>
<Tooltip position="top" label={t("Toggle header row")}>
<ActionIcon
onClick={toggleHeaderRow}
variant="default"
@@ -200,8 +196,7 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header column")}
>
<Tooltip position="top" label={t("Toggle header column")}>
<ActionIcon
onClick={toggleHeaderColumn}
variant="default"
@@ -224,9 +219,9 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BaseBubbleMenu>
</BubbleMenu>
);
},
}
);
export default TableMenu;
@@ -1,11 +1,6 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
@@ -22,16 +17,6 @@ import { useTranslation } from "react-i18next";
export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video");
},
[editor],
);
const editorState = useEditorState({
editor,
@@ -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 predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection);
if (parent) {
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]);
const alignVideoLeft = useCallback(() => {
@@ -105,15 +110,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
editor={editor}
pluginKey={`video-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
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 { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx";
import classes from "./video-view.module.css";
import { useTranslation } from "react-i18next";
export default function VideoView(props: NodeViewProps) {
const { node, selected } = props;
const { src, width, align } = node.attrs;
const { t } = useTranslation();
const { editor, node, selected } = props;
const { src, width, align, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
if (align === "center") return "alignCenter";
return "alignCenter";
}, [align]);
const previewSrc = useMemo(() => {
editor.storage.shared.videoPreviews =
editor.storage.shared.videoPreviews || {};
if (placeholder?.id) {
return editor.storage.shared.videoPreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return (
<NodeViewWrapper data-drag-handle>
<video
preload="metadata"
width={width}
controls
src={getFileUrl(src)}
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
style={{ display: "block" }}
/>
<div
className={clsx(
selected && "ProseMirror-selectednode",
classes.videoWrapper,
alignClass,
)}
style={{
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
width,
}}
>
{src && (
<video
className={classes.video}
preload="metadata"
controls
src={getFileUrl(src)}
/>
)}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<video
className={classes.video}
preload="metadata"
controls
src={previewSrc}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper>
);
}