[WIKI-569] chore: migrate to tiptap v3 (#7526)

* chore: upgrade to tiptap v3

* chore: update starter kit

* chore: tippy to floating-ui migration for mentions

* chore: update remaining floating menus

* chore: update setEditorValue function

* fix: potential bugs

* chore: extract out common floating ui positioning logic

* fix: storage api

* fix: bubble menu

* fix: type errors

* fix: type errors

* chore: upgrade tiptap-markdown package

* fix: mentions close callback

* chore: update bubbling sequence

* chore: update package.json

* chore: update tiptap catalogs

* fix: add error handling

* fix: file plugin types

* chore: update live package.json

* fix: broken lock file

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2025-09-30 19:56:12 +05:30 committed by GitHub
parent 5951372555
commit e110ef55b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 1301 additions and 1599 deletions

View File

@ -29,8 +29,8 @@
"@plane/editor": "workspace:*",
"@plane/logger": "workspace:*",
"@plane/types": "workspace:*",
"@tiptap/core": "^2.22.3",
"@tiptap/html": "^2.22.3",
"@tiptap/core": "catalog:",
"@tiptap/html": "catalog:",
"axios": "catalog:",
"compression": "1.8.1",
"cors": "^2.8.5",
@ -43,7 +43,8 @@
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "catalog:",
"y-prosemirror": "^1.2.15",
"ws": "^8.18.3",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20",
"zod": "^3.25.76"
@ -59,11 +60,7 @@
"@types/pino-http": "^5.8.4",
"@types/uuid": "^9.0.1",
"@types/ws": "^8.18.1",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsdown": "catalog:",
"typescript": "catalog:",
"ws": "^8.18.3"
"typescript": "catalog:"
}
}

View File

@ -25,7 +25,7 @@ export type TEditorFlaggingHookProps = {
/**
* @description extensions disabled in various editors
*/
export const useEditorFlagging = (props: TEditorFlaggingHookProps): TEditorFlaggingHookReturnType => ({
export const useEditorFlagging = (_props: TEditorFlaggingHookProps): TEditorFlaggingHookReturnType => ({
document: {
disabled: ["ai", "collaboration-cursor"],
flagged: [],

View File

@ -23,7 +23,7 @@
"./styles": "./dist/styles/index.css"
},
"scripts": {
"build": "tsdown",
"build": "tsc && tsdown",
"dev": "tsdown --watch",
"check:lint": "eslint . --max-warnings 30",
"check:types": "tsc --noEmit",
@ -46,25 +46,23 @@
"@plane/types": "workspace:*",
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"@tiptap/core": "^2.22.3",
"@tiptap/extension-blockquote": "^2.22.3",
"@tiptap/extension-character-count": "^2.22.3",
"@tiptap/extension-collaboration": "^2.22.3",
"@tiptap/extension-emoji": "^2.22.3",
"@tiptap/extension-image": "^2.22.3",
"@tiptap/extension-list-item": "^2.22.3",
"@tiptap/extension-mention": "^2.22.3",
"@tiptap/extension-placeholder": "^2.22.3",
"@tiptap/extension-task-item": "^2.22.3",
"@tiptap/extension-task-list": "^2.22.3",
"@tiptap/extension-text-align": "^2.22.3",
"@tiptap/extension-text-style": "^2.22.3",
"@tiptap/extension-underline": "^2.22.3",
"@tiptap/html": "^2.22.3",
"@tiptap/pm": "^2.22.3",
"@tiptap/react": "^2.22.3",
"@tiptap/starter-kit": "^2.22.3",
"@tiptap/suggestion": "^2.22.3",
"@tiptap/core": "catalog:",
"@tiptap/extension-blockquote": "^3.5.3",
"@tiptap/extension-collaboration": "^3.5.3",
"@tiptap/extension-emoji": "^3.5.3",
"@tiptap/extension-image": "^3.5.3",
"@tiptap/extension-list-item": "^3.5.3",
"@tiptap/extension-mention": "^3.5.3",
"@tiptap/extension-task-item": "^3.5.3",
"@tiptap/extension-task-list": "^3.5.3",
"@tiptap/extension-text-align": "^3.5.3",
"@tiptap/extension-text-style": "^3.5.3",
"@tiptap/extensions": "^3.5.3",
"@tiptap/html": "catalog:",
"@tiptap/pm": "^3.5.3",
"@tiptap/react": "^3.5.3",
"@tiptap/starter-kit": "^3.5.3",
"@tiptap/suggestion": "^3.5.3",
"emoji-regex": "^10.3.0",
"highlight.js": "^11.8.0",
"is-emoji-supported": "^0.0.5",
@ -74,7 +72,7 @@
"lucide-react": "catalog:",
"prosemirror-codemark": "^0.4.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"tiptap-markdown": "^0.9.0",
"uuid": "catalog:",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.15",
@ -88,6 +86,7 @@
"@types/node": "18.15.3",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/uuid": "^8.3.4",
"postcss": "^8.4.38",
"tsdown": "catalog:",
"typescript": "catalog:"

View File

@ -1,14 +1,22 @@
import { ExtensionFileSetStorageKey } from "@/plane-editor/types/storage";
// plane imports
import { ADDITIONAL_EXTENSIONS, CORE_EXTENSIONS } from "@plane/utils";
// plane editor imports
import type { ExtensionFileSetStorageKey } from "@/plane-editor/types/storage";
export const NODE_FILE_MAP: {
[key: string]: {
fileSetName: ExtensionFileSetStorageKey;
};
} = {
image: {
export type NodeFileMapType = Partial<
Record<
CORE_EXTENSIONS | ADDITIONAL_EXTENSIONS,
{
fileSetName: ExtensionFileSetStorageKey;
}
>
>;
export const NODE_FILE_MAP: NodeFileMapType = {
[CORE_EXTENSIONS.IMAGE]: {
fileSetName: "deletedImageSet",
},
imageComponent: {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
fileSetName: "deletedImageSet",
},
};

View File

@ -1,22 +1,4 @@
import { CharacterCountStorage } from "@tiptap/extension-character-count";
// constants
import type { EmojiStorage } from "@tiptap/extension-emoji";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import type { HeadingExtensionStorage } from "@/extensions";
import type { CustomImageExtensionStorage } from "@/extensions/custom-image/types";
import type { CustomLinkStorage } from "@/extensions/custom-link";
import type { ImageExtensionStorage } from "@/extensions/image";
import type { UtilityExtensionStorage } from "@/extensions/utility";
export type ExtensionStorageMap = {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: CustomImageExtensionStorage;
[CORE_EXTENSIONS.IMAGE]: ImageExtensionStorage;
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
[CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage;
[CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage;
[CORE_EXTENSIONS.EMOJI]: EmojiStorage;
[CORE_EXTENSIONS.CHARACTER_COUNT]: CharacterCountStorage;
};
export type ExtensionFileSetStorageKey = Extract<keyof ImageExtensionStorage, "deletedImageSet">;

View File

@ -4,9 +4,6 @@ import { FC, useCallback, useEffect, useRef, useState } from "react";
// components
import { LinkView, LinkViewProps } from "@/components/links";
import { CORE_EXTENSIONS } from "@/constants/extension";
// components
import { getExtensionStorage } from "@/helpers/get-extension-storage";
type Props = {
editor: Editor;
@ -22,7 +19,7 @@ export const LinkViewContainer: FC<Props> = ({ editor, containerRef }) => {
const editorState = useEditorState({
editor,
selector: ({ editor }: { editor: Editor }) => ({
linkExtensionStorage: getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_LINK),
linkExtensionStorage: editor.storage.link,
}),
});

View File

@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import tippy, { Instance } from "tippy.js";
import tippy, { type Instance } from "tippy.js";
// plane utils
import { cn } from "@plane/utils";
// types
import { TAIHandler } from "@/types";
import type { TAIHandler } from "@/types";
type Props = {
menu: TAIHandler["menu"];

View File

@ -1,9 +1,9 @@
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
autoUpdate,
useDismiss,
useInteractions,
FloatingPortal,
@ -11,15 +11,16 @@ import {
import type { Editor } from "@tiptap/react";
import { Copy, LucideIcon, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
// constants
import { cn } from "@plane/utils";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { IEditorProps } from "@/types";
// types
import type { IEditorProps } from "@/types";
type Props = {
disabledExtensions?: IEditorProps["disabledExtensions"];
editor: Editor;
flaggedExtensions?: IEditorProps["flaggedExtensions"];
disabledExtensions?: IEditorProps["disabledExtensions"];
};
export const BlockMenu = (props: Props) => {
@ -74,15 +75,6 @@ export const BlockMenu = (props: Props) => {
// Set the virtual reference as the reference element
refs.setReference(virtualReferenceRef.current);
// Ensure the targeted block is selected
const rect = dragHandle.getBoundingClientRect();
const coords = { left: rect.left + rect.width / 2, top: rect.top + rect.height / 2 };
const posAtCoords = editor.view.posAtCoords(coords);
if (posAtCoords) {
const $pos = editor.state.doc.resolve(posAtCoords.pos);
const nodePos = $pos.before($pos.depth);
editor.chain().setNodeSelection(nodePos).run();
}
// Show the menu
openBlockMenu();
return;
@ -93,9 +85,10 @@ export const BlockMenu = (props: Props) => {
closeBlockMenu();
}
},
[editor, refs, openBlockMenu, closeBlockMenu]
[refs, openBlockMenu, closeBlockMenu]
);
// Set up event listeners
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
@ -106,10 +99,11 @@ export const BlockMenu = (props: Props) => {
const handleScroll = () => {
closeBlockMenu();
};
document.addEventListener("click", handleClickDragHandle);
document.addEventListener("contextmenu", handleClickDragHandle);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("scroll", handleScroll, true); // Using capture phase
document.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("click", handleClickDragHandle);
@ -200,6 +194,7 @@ export const BlockMenu = (props: Props) => {
if (!isOpen) {
return null;
}
return (
<FloatingPortal>
<div
@ -209,7 +204,6 @@ export const BlockMenu = (props: Props) => {
}}
style={{
...floatingStyles,
zIndex: 99,
animationFillMode: "forwards",
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", // Expo ease out
}}
@ -218,13 +212,11 @@ export const BlockMenu = (props: Props) => {
"transition-all duration-300 transform origin-top-right",
isAnimatedIn ? "opacity-100 scale-100" : "opacity-0 scale-75"
)}
data-prevent-outside-click
{...getFloatingProps()}
>
{MENU_ITEMS.map((item) => {
if (item.isDisabled) {
return null;
}
if (item.isDisabled) return null;
return (
<button
key={item.key}

View File

@ -1,109 +1,113 @@
import { Editor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { ALargeSmall, Ban } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import { useMemo, type FC } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { COLORS_LIST } from "@/constants/common";
// helpers
// local imports
import { FloatingMenuRoot } from "../floating-menu/root";
import { useFloatingMenu } from "../floating-menu/use-floating-menu";
import { BackgroundColorItem, TextColorItem } from "../menu-items";
import { EditorStateType } from "./root";
type Props = {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
editorState: EditorStateType;
};
export const BubbleMenuColorSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen, editorState } = props;
const { editor, editorState } = props;
// floating ui
const { options, getReferenceProps, getFloatingProps } = useFloatingMenu({});
const activeTextColor = editorState.color;
const activeBackgroundColor = editorState.backgroundColor;
const activeTextColor = useMemo(() => editorState.color, [editorState.color]);
const activeBackgroundColor = useMemo(() => editorState.backgroundColor, [editorState.backgroundColor]);
return (
<div className="relative h-full">
<button
type="button"
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
>
<span>Color</span>
<span
className={cn(
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
{
"bg-custom-background-100": !activeBackgroundColor,
}
)}
style={{
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
}}
>
<ALargeSmall
className={cn("size-3.5", {
"text-custom-text-100": !activeTextColor,
})}
<FloatingMenuRoot
classNames={{
buttonContainer: "h-full",
button:
"flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors",
}}
menuButton={
<>
<span>Color</span>
<span
className={cn(
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
{
"bg-custom-background-100": !activeBackgroundColor,
}
)}
style={{
color: activeTextColor ? activeTextColor.textColor : "inherit",
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
}}
/>
</span>
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 space-y-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.textColor,
}}
onClick={() => TextColorItem(editor).command({ color: color.key })}
/>
))}
>
<ALargeSmall
className={cn("size-3.5", {
"text-custom-text-100": !activeTextColor,
})}
style={{
color: activeTextColor ? activeTextColor.textColor : "inherit",
}}
/>
</span>
</>
}
options={options}
getFloatingProps={getFloatingProps}
getReferenceProps={getReferenceProps}
>
<section className="mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 space-y-2 shadow-custom-shadow-rg">
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => TextColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.textColor,
}}
onClick={() => TextColorItem(editor).command({ color: color.key })}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => TextColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => BackgroundColorItem(editor).command({ color: color.key })}
/>
))}
</div>
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => BackgroundColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => BackgroundColorItem(editor).command({ color: color.key })}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => BackgroundColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
</section>
)}
</div>
</div>
</section>
</FloatingMenuRoot>
);
};

View File

@ -1,4 +1,3 @@
export * from "./color-selector";
export * from "./link-selector";
export * from "./node-selector";
export * from "./root";

View File

@ -1,6 +1,6 @@
import { Editor } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import { Check, Link, Trash2 } from "lucide-react";
import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react";
import { FC, useCallback, useRef, useState } from "react";
// plane imports
import { cn } from "@plane/utils";
// constants
@ -8,17 +8,20 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
import { FloatingMenuRoot } from "../floating-menu/root";
import { useFloatingMenu } from "../floating-menu/use-floating-menu";
type Props = {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
};
export const BubbleMenuLinkSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen } = props;
const { editor } = props;
// states
const [error, setError] = useState(false);
// floating ui
const { options, getReferenceProps, getFloatingProps } = useFloatingMenu({});
const { context } = options;
// refs
const inputRef = useRef<HTMLInputElement>(null);
@ -30,88 +33,89 @@ export const BubbleMenuLinkSelector: FC<Props> = (props) => {
const { isValid, url: validatedUrl } = isValidHttpUrl(url);
if (isValid) {
setLinkEditor(editor, validatedUrl);
setIsOpen(false);
context.onOpenChange(false);
setError(false);
} else {
setError(true);
}
}, [editor, inputRef, setIsOpen]);
}, [editor, inputRef, context]);
return (
<div className="relative h-full">
<button
type="button"
className={cn(
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors",
<FloatingMenuRoot
classNames={{
buttonContainer: "h-full",
button: cn(
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded whitespace-nowrap transition-colors",
{
"bg-custom-background-80": isOpen,
"bg-custom-background-80": context.open,
"text-custom-text-100": editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK),
}
)}
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
>
Link
<Link className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<div className="fixed top-full z-[99999] mt-1 w-60 animate-in fade-in slide-in-from-top-1 rounded bg-custom-background-100 shadow-custom-shadow-rg">
<div
className={cn("flex rounded border border-custom-border-300 transition-colors", {
"border-red-500": error,
})}
>
<input
ref={inputRef}
type="url"
placeholder="Enter or paste a link"
onClick={(e) => e.stopPropagation()}
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 py-2 px-1.5 text-xs outline-none placeholder:text-custom-text-400 rounded"
defaultValue={editor.getAttributes("link").href || ""}
onKeyDown={(e) => {
setError(false);
if (e.key === "Enter") {
e.preventDefault();
handleLinkSubmit();
}
),
}}
getFloatingProps={getFloatingProps}
getReferenceProps={getReferenceProps}
menuButton={
<>
Link
<Link className="shrink-0 size-3" />
</>
}
options={options}
>
<div className="w-60 mt-1 rounded-md bg-custom-background-100 shadow-custom-shadow-rg">
<div
className={cn("flex rounded border-[0.5px] border-custom-border-300 transition-colors", {
"border-red-500": error,
})}
>
<input
ref={inputRef}
type="url"
placeholder="Enter or paste a link"
onClick={(e) => e.stopPropagation()}
className="flex-1 border-r-[0.5px] border-custom-border-300 bg-custom-background-100 py-2 px-1.5 text-xs outline-none placeholder:text-custom-text-400 rounded"
defaultValue={editor.getAttributes("link").href || ""}
onKeyDown={(e) => {
setError(false);
if (e.key === "Enter") {
e.preventDefault();
handleLinkSubmit();
}
}}
onFocus={() => setError(false)}
autoFocus
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="grid place-items-center rounded-sm p-1 text-red-500 hover:bg-red-500/20 transition-all"
onClick={(e) => {
unsetLinkEditor(editor);
e.stopPropagation();
context.onOpenChange(false);
}}
onFocus={() => setError(false)}
autoFocus
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="grid place-items-center rounded-sm p-1 text-red-500 hover:bg-red-500/20 transition-all"
onClick={(e) => {
unsetLinkEditor(editor);
setIsOpen(false);
e.stopPropagation();
}}
>
<Trash2 className="size-4" />
</button>
) : (
<button
type="button"
className="h-full aspect-square grid place-items-center p-1 rounded-sm text-custom-text-300 hover:bg-custom-background-80 transition-all"
onClick={(e) => {
e.stopPropagation();
handleLinkSubmit();
}}
>
<Check className="size-4" />
</button>
)}
</div>
{error && (
<p className="text-xs text-red-500 my-1 px-2 pointer-events-none animate-in fade-in slide-in-from-top-0">
Please enter a valid URL
</p>
>
<Trash2 className="size-4" />
</button>
) : (
<button
type="button"
className="h-full aspect-square grid place-items-center p-1 rounded-sm text-custom-text-300 hover:bg-custom-background-80 transition-all"
onClick={(e) => {
e.stopPropagation();
handleLinkSubmit();
}}
>
<Check className="size-4" />
</button>
)}
</div>
)}
</div>
{error && (
<p className="text-xs text-red-500 my-1 px-2 pointer-events-none animate-in fade-in slide-in-from-top-0">
Please enter a valid URL
</p>
)}
</div>
</FloatingMenuRoot>
);
};

View File

@ -1,6 +1,6 @@
import { Editor } from "@tiptap/react";
import { Check, ChevronDown } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import { FC } from "react";
// plane utils
import { cn } from "@plane/utils";
// components
@ -20,17 +20,20 @@ import {
EditorMenuItem,
} from "@/components/menus";
// types
import { TEditorCommands } from "@/types";
import type { TEditorCommands } from "@/types";
// local imports
import { FloatingMenuRoot } from "../floating-menu/root";
import { useFloatingMenu } from "../floating-menu/use-floating-menu";
type Props = {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
};
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen } = props;
const { editor } = props;
// floating ui
const { options, getReferenceProps, getFloatingProps } = useFloatingMenu({});
const { context } = options;
const items: EditorMenuItem<TEditorCommands>[] = [
TextItem(editor),
HeadingOneItem(editor),
@ -44,52 +47,58 @@ export const BubbleMenuNodeSelector: FC<Props> = (props) => {
TodoListItem(editor),
QuoteItem(editor),
CodeItem(editor),
];
] as EditorMenuItem<TEditorCommands>[];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
return (
<div className="relative h-full">
<button
type="button"
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
>
<span>{activeItem?.name}</span>
<ChevronDown className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
{items.map((item) => (
<button
key={item.name}
type="button"
onClick={(e) => {
item.command();
setIsOpen(false);
e.stopPropagation();
}}
className={cn(
"flex items-center justify-between rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80",
{
"bg-custom-background-80": activeItem.name === item.name,
}
)}
>
<div className="flex items-center space-x-2">
<item.icon className="size-3 flex-shrink-0" />
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
</button>
))}
</section>
)}
</div>
<FloatingMenuRoot
classNames={{
buttonContainer: "h-full",
button: cn(
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded whitespace-nowrap transition-colors",
{
"bg-custom-background-80": context.open,
}
),
}}
menuButton={
<>
<span>{activeItem?.name}</span>
<ChevronDown className="shrink-0 size-3" />
</>
}
options={options}
getFloatingProps={getFloatingProps}
getReferenceProps={getReferenceProps}
>
<section className="w-48 max-h-[90vh] mt-1 flex flex-col overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg">
{items.map((item) => (
<button
key={item.name}
type="button"
onClick={(e) => {
item.command();
context.onOpenChange(false);
e.stopPropagation();
}}
className={cn(
"flex items-center justify-between rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80",
{
"bg-custom-background-80": activeItem.name === item.name,
}
)}
>
<div className="flex items-center space-x-2">
<item.icon className="size-3 flex-shrink-0" />
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
</button>
))}
</section>
</FloatingMenuRoot>
);
};

View File

@ -1,4 +1,6 @@
import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection, useEditorState } from "@tiptap/react";
import { type Editor, isNodeSelection } from "@tiptap/core";
import { useEditorState } from "@tiptap/react";
import { BubbleMenu, type BubbleMenuProps } from "@tiptap/react/menus";
import { FC, useEffect, useState, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
@ -7,7 +9,6 @@ import {
BackgroundColorItem,
BoldItem,
BubbleMenuColorSelector,
BubbleMenuLinkSelector,
BubbleMenuNodeSelector,
CodeItem,
EditorMenuItem,
@ -23,9 +24,10 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { isCellSelection } from "@/extensions/table/table/utilities/helpers";
// types
import { TEditorCommands } from "@/types";
import type { TEditorCommands } from "@/types";
// local imports
import { TextAlignmentSelector } from "./alignment-selector";
import { BubbleMenuLinkSelector } from "./link-selector";
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
@ -38,7 +40,14 @@ export type EditorStateType = {
left: boolean;
right: boolean;
center: boolean;
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
color:
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
backgroundColor:
| {
key: string;
@ -49,27 +58,31 @@ export type EditorStateType = {
| undefined;
};
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
const menuRef = useRef<HTMLDivElement>(null);
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
type Props = {
editor: Editor;
};
export const EditorBubbleMenu: FC<Props> = (props) => {
const { editor } = props;
// states
const [isSelecting, setIsSelecting] = useState(false);
// refs
const menuRef = useRef<HTMLDivElement>(null);
const formattingItems = {
code: CodeItem(props.editor),
bold: BoldItem(props.editor),
italic: ItalicItem(props.editor),
underline: UnderLineItem(props.editor),
strikethrough: StrikeThroughItem(props.editor),
"text-align": TextAlignItem(props.editor),
code: CodeItem(editor),
bold: BoldItem(editor),
italic: ItalicItem(editor),
underline: UnderLineItem(editor),
strikethrough: StrikeThroughItem(editor),
"text-align": TextAlignItem(editor),
} satisfies {
[K in TEditorCommands]?: EditorMenuItem<K>;
};
const editorState: EditorStateType = useEditorState({
editor: props.editor,
selector: ({ editor }: { editor: Editor }) => ({
editor,
selector: ({ editor }) => ({
code: formattingItems.code.isActive(),
bold: formattingItems.bold.isActive(),
italic: formattingItems.italic.isActive(),
@ -88,7 +101,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
editor,
shouldShow: ({ state, editor }) => {
const { selection } = state;
const { empty } = selection;
@ -106,20 +119,28 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
}
return true;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
duration: [300, 0],
zIndex: 9,
options: {
onShow: () => {
props.editor.storage.link.isBubbleMenuOpen = true;
if (editor.storage.link) {
editor.storage.link.isBubbleMenuOpen = true;
}
editor.commands.addActiveDropbarExtension("bubble-menu");
},
onHidden: () => {
props.editor.storage.link.isBubbleMenuOpen = false;
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
onHide: () => {
if (editor.storage.link) {
editor.storage.link.isBubbleMenuOpen = false;
}
setTimeout(() => {
editor.commands.removeActiveDropbarExtension("bubble-menu");
}, 0);
},
},
// TODO: Migrate these to floating UI options
// tippyOptions: {
// moveTransition: "transform 0.15s ease-out",
// duration: [300, 0],
// zIndex: 9,
// },
};
useEffect(() => {
@ -127,7 +148,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
if (menuRef.current?.contains(e.target as Node)) return;
function handleMouseMove() {
if (!props.editor.state.selection.empty) {
if (!editor.state.selection.empty) {
setIsSelecting(true);
document.removeEventListener("mousemove", handleMouseMove);
}
@ -148,7 +169,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, [props.editor]);
}, [editor]);
return (
<BubbleMenu {...bubbleMenuProps}>
@ -158,41 +179,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg overflow-x-scroll horizontal-scrollbar scrollbar-xs"
>
<div className="px-2">
<BubbleMenuNodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen((prev) => !prev);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
<BubbleMenuNodeSelector editor={editor} />
</div>
{!editorState.code && (
<div className="px-2">
<BubbleMenuLinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
<BubbleMenuLinkSelector editor={editor} />
</div>
)}
{!editorState.code && (
<div className="px-2">
<BubbleMenuColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
editorState={editorState}
setIsOpen={() => {
setIsColorSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
<BubbleMenuColorSelector editor={editor} editorState={editorState} />
</div>
)}
<div className="flex gap-0.5 px-2">
@ -215,7 +211,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
</button>
))}
</div>
<TextAlignmentSelector editor={props.editor} editorState={editorState} />
<TextAlignmentSelector editor={editor} editorState={editorState} />
</div>
)}
</BubbleMenu>

View File

@ -0,0 +1,65 @@
import {
FloatingOverlay,
FloatingPortal,
type UseInteractionsReturn,
type UseFloatingReturn,
} from "@floating-ui/react";
type Props = {
children: React.ReactNode;
classNames?: {
buttonContainer?: string;
button?: string;
};
getFloatingProps: UseInteractionsReturn["getFloatingProps"];
getReferenceProps: UseInteractionsReturn["getReferenceProps"];
menuButton: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
options: UseFloatingReturn;
};
export const FloatingMenuRoot: React.FC<Props> = (props) => {
const { children, classNames, getFloatingProps, getReferenceProps, menuButton, onClick, options } = props;
// derived values
const { refs, floatingStyles, context } = options;
return (
<>
<div className={classNames?.buttonContainer}>
<button
ref={refs.setReference}
{...getReferenceProps()}
type="button"
className={classNames?.button}
onClick={(e) => {
context.onOpenChange(!context.open);
onClick?.(e);
}}
>
{menuButton}
</button>
</div>
{context.open && (
<FloatingPortal>
{/* Backdrop */}
<FloatingOverlay
style={{
zIndex: 99,
}}
lockScroll
/>
<div
ref={refs.setFloating}
{...getFloatingProps()}
style={{
...floatingStyles,
zIndex: 100,
}}
>
{children}
</div>
</FloatingPortal>
)}
</>
);
};

View File

@ -0,0 +1,50 @@
import {
shift,
flip,
useDismiss,
useFloating,
useInteractions,
autoUpdate,
useClick,
useRole,
} from "@floating-ui/react";
import { useState } from "react";
type TArgs = {
handleOpenChange?: (open: boolean) => void;
};
export const useFloatingMenu = (args: TArgs) => {
const { handleOpenChange } = args;
// states
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// floating ui
const options = useFloating({
placement: "bottom-start",
middleware: [
flip({
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
}),
shift({
padding: 8,
}),
],
open: isDropdownOpen,
onOpenChange: (open) => {
setIsDropdownOpen(open);
handleOpenChange?.(open);
},
whileElementsMounted: autoUpdate,
});
const { context } = options;
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]);
return {
options,
getReferenceProps,
getFloatingProps,
};
};

View File

@ -1,4 +1,4 @@
import { Editor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import {
BoldIcon,
Heading1,
@ -18,7 +18,7 @@ import {
Heading5,
Heading6,
CaseSensitive,
LucideIcon,
type LucideIcon,
MinusSquare,
Palette,
AlignCenter,
@ -70,7 +70,7 @@ export const TextItem = (editor: Editor): EditorMenuItem<"text"> => ({
icon: CaseSensitive,
});
type SupportedHeadingLevels = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
type SupportedHeadingLevels = Extract<TEditorCommands, "h1" | "h2" | "h3" | "h4" | "h5" | "h6">;
const HeadingItem = <T extends SupportedHeadingLevels>(
editor: Editor,
@ -274,5 +274,5 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
TextColorItem(editor),
BackgroundColorItem(editor),
TextAlignItem(editor),
];
] as EditorMenuItem<TEditorCommands>[];
};

View File

@ -6,7 +6,7 @@ import { COLORS_LIST } from "@/constants/common";
import { CalloutBlockColorSelector } from "./color-selector";
import { CalloutBlockLogoSelector } from "./logo-selector";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
import { ECalloutAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { updateStoredBackgroundColor } from "./utils";
@ -45,7 +45,7 @@ export const CustomCalloutBlock: React.FC<CustomCalloutNodeViewProps> = (props)
toggleDropdown={() => setIsColorPickerOpen((prev) => !prev)}
onSelect={(val) => {
updateAttributes({
[EAttributeNames.BACKGROUND]: val,
[ECalloutAttributeNames.BACKGROUND]: val,
});
updateStoredBackgroundColor(val);
}}

View File

@ -1,14 +1,13 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node as NodeType } from "@tiptap/pm/model";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
import { type CustomCalloutExtensionType, ECalloutAttributeNames, type TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
// Extend Tiptap's Commands interface
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CALLOUT]: {
@ -17,7 +16,7 @@ declare module "@tiptap/core" {
}
}
export const CustomCalloutExtensionConfig = Node.create({
export const CustomCalloutExtensionConfig: CustomCalloutExtensionType = Node.create({
name: CORE_EXTENSIONS.CALLOUT,
group: "block",
content: "block+",
@ -25,20 +24,24 @@ export const CustomCalloutExtensionConfig = Node.create({
addAttributes() {
const attributes = {
// Reduce instead of map to accumulate the attributes directly into an object
...Object.values(EAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value],
};
return acc;
}, {}),
...Object.values(ECalloutAttributeNames).reduce(
(acc, value) => {
acc[value] = {
default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value],
};
return acc;
},
{} as Record<ECalloutAttributeNames, { default: TCalloutBlockAttributes[ECalloutAttributeNames] }>
),
};
return attributes;
},
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {
serialize(state: MarkdownSerializerState, node: ProseMirrorNode) {
const attrs = node.attrs as TCalloutBlockAttributes;
const logoInUse = attrs["data-logo-in-use"];
// add callout logo
@ -62,7 +65,7 @@ export const CustomCalloutExtensionConfig = Node.create({
parseHTML() {
return [
{
tag: `div[${EAttributeNames.BLOCK_TYPE}="${DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[EAttributeNames.BLOCK_TYPE]}"]`,
tag: `div[${ECalloutAttributeNames.BLOCK_TYPE}="${DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.BLOCK_TYPE]}"]`,
},
];
},

View File

@ -1,14 +1,18 @@
import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout/block";
import { findParentNodeClosestToPos, type Predicate, ReactNodeViewRenderer } from "@tiptap/react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// config
// local imports
import { CustomCalloutBlock, type CustomCalloutNodeViewProps } from "./block";
import { CustomCalloutExtensionConfig } from "./extension-config";
// utils
import type { CustomCalloutExtensionOptions, CustomCalloutExtensionStorage } from "./types";
import { getStoredBackgroundColor, getStoredLogo } from "./utils";
export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend<
CustomCalloutExtensionOptions,
CustomCalloutExtensionStorage
>({
selectable: true,
draggable: true,
@ -25,7 +29,7 @@ export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
type: this.name,
content: [
{
type: "paragraph",
type: CORE_EXTENSIONS.PARAGRAPH,
},
],
attrs: {

View File

@ -1,4 +1,6 @@
export enum EAttributeNames {
import type { Node as ProseMirrorNode } from "@tiptap/core";
export enum ECalloutAttributeNames {
ICON_COLOR = "data-icon-color",
ICON_NAME = "data-icon-name",
EMOJI_UNICODE = "data-emoji-unicode",
@ -9,18 +11,23 @@ export enum EAttributeNames {
}
export type TCalloutBlockIconAttributes = {
[EAttributeNames.ICON_COLOR]: string | undefined;
[EAttributeNames.ICON_NAME]: string | undefined;
[ECalloutAttributeNames.ICON_COLOR]: string | undefined;
[ECalloutAttributeNames.ICON_NAME]: string | undefined;
};
export type TCalloutBlockEmojiAttributes = {
[EAttributeNames.EMOJI_UNICODE]: string | undefined;
[EAttributeNames.EMOJI_URL]: string | undefined;
[ECalloutAttributeNames.EMOJI_UNICODE]: string | undefined;
[ECalloutAttributeNames.EMOJI_URL]: string | undefined;
};
export type TCalloutBlockAttributes = {
[EAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[EAttributeNames.BACKGROUND]: string | undefined;
[EAttributeNames.BLOCK_TYPE]: "callout-component";
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[ECalloutAttributeNames.BACKGROUND]: string | undefined;
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component";
} & TCalloutBlockIconAttributes &
TCalloutBlockEmojiAttributes;
export type CustomCalloutExtensionOptions = unknown;
export type CustomCalloutExtensionStorage = unknown;
export type CustomCalloutExtensionType = ProseMirrorNode<CustomCalloutExtensionOptions, CustomCalloutExtensionStorage>;

View File

@ -1,33 +1,33 @@
// plane imports
import { TEmojiLogoProps } from "@plane/ui";
import type { TEmojiLogoProps } from "@plane/ui";
import { sanitizeHTML } from "@plane/utils";
// types
import {
EAttributeNames,
ECalloutAttributeNames,
TCalloutBlockAttributes,
TCalloutBlockEmojiAttributes,
TCalloutBlockIconAttributes,
} from "./types";
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
"data-logo-in-use": "emoji",
"data-icon-color": undefined,
"data-icon-name": undefined,
"data-emoji-unicode": "128161",
"data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
"data-background": undefined,
"data-block-type": "callout-component",
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji",
[ECalloutAttributeNames.ICON_COLOR]: undefined,
[ECalloutAttributeNames.ICON_NAME]: undefined,
[ECalloutAttributeNames.EMOJI_UNICODE]: "128161",
[ECalloutAttributeNames.EMOJI_URL]: "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
[ECalloutAttributeNames.BACKGROUND]: undefined,
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component",
};
type TStoredLogoValue = Pick<TCalloutBlockAttributes, EAttributeNames.LOGO_IN_USE> &
type TStoredLogoValue = Pick<TCalloutBlockAttributes, ECalloutAttributeNames.LOGO_IN_USE> &
(TCalloutBlockEmojiAttributes | TCalloutBlockIconAttributes);
// function to get the stored logo from local storage
export const getStoredLogo = (): TStoredLogoValue => {
const fallBackValues: TStoredLogoValue = {
"data-logo-in-use": "emoji",
"data-emoji-unicode": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
"data-emoji-url": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji",
[ECalloutAttributeNames.EMOJI_UNICODE]: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.EMOJI_UNICODE],
[ECalloutAttributeNames.EMOJI_URL]: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.EMOJI_URL],
};
if (typeof window !== "undefined") {
@ -43,16 +43,20 @@ export const getStoredLogo = (): TStoredLogoValue => {
}
if (parsedData.in_use === "emoji" && parsedData.emoji?.value) {
return {
"data-logo-in-use": "emoji",
"data-emoji-unicode": parsedData.emoji.value || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
"data-emoji-url": parsedData.emoji.url || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji",
[ECalloutAttributeNames.EMOJI_UNICODE]:
parsedData.emoji.value || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.EMOJI_UNICODE],
[ECalloutAttributeNames.EMOJI_URL]:
parsedData.emoji.url || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.EMOJI_URL],
};
}
if (parsedData.in_use === "icon" && parsedData.icon?.name) {
return {
"data-logo-in-use": "icon",
"data-icon-name": parsedData.icon.name || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-name"],
"data-icon-color": parsedData.icon.color || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-color"],
[ECalloutAttributeNames.LOGO_IN_USE]: "icon",
[ECalloutAttributeNames.ICON_NAME]:
parsedData.icon.name || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.ICON_NAME],
[ECalloutAttributeNames.ICON_COLOR]:
parsedData.icon.color || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.ICON_COLOR],
};
}
}

View File

@ -11,7 +11,12 @@ type CodeBlockLowlightOptions = CodeBlockOptions & {
export const CodeBlockLowlight = CodeBlock.extend<CodeBlockLowlightOptions>({
addOptions() {
return {
...this.parent?.(),
...(this.parent?.() ?? {
languageClassPrefix: "language-",
exitOnTripleEnter: true,
exitOnArrowDown: true,
HTMLAttributes: {},
}),
lowlight: {},
defaultLanguage: null,
};

View File

@ -56,7 +56,7 @@ export const CodeBlockComponent: React.FC<Props> = ({ node }) => {
</Tooltip>
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
<NodeViewContent as="code" className="whitespace-pre-wrap" />
<NodeViewContent<"code"> as="code" className="whitespace-pre-wrap" />
</pre>
</NodeViewWrapper>
);

View File

@ -1,7 +1,6 @@
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
import { TextStyle } from "@tiptap/extension-text-style";
import StarterKit from "@tiptap/starter-kit";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@ -76,7 +75,6 @@ export const CoreEditorExtensionsWithoutProps = [
}),
ImageExtensionConfig,
CustomImageExtensionConfig,
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {

View File

@ -2,10 +2,6 @@ import { NodeSelection } from "@tiptap/pm/state";
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
// plane imports
import { cn } from "@plane/utils";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString, getImageBlockId } from "../utils";
@ -62,7 +58,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
// extension options
const isTouchDevice = !!getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;
const isTouchDevice = !!editor.storage.utility.isTouchDevice;
const updateAttributesSafely = useCallback(
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
@ -199,6 +195,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
editor.commands.blur();
}
const pos = getPos();
if (pos === undefined) return;
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));
},

View File

@ -1,23 +1,16 @@
import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import type { CustomImageExtension, TCustomImageAttributes } from "../types";
import type { CustomImageExtensionType, TCustomImageAttributes } from "../types";
import { CustomImageBlock } from "./block";
import { CustomImageUploader } from "./uploader";
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
extension: CustomImageExtension;
getPos: () => number;
editor: Editor;
extension: CustomImageExtensionType;
node: NodeViewProps["node"] & {
attrs: TCustomImageAttributes;
};
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
selected: boolean;
};
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
@ -84,7 +77,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
<CustomImageUploader
failedToLoadImage={failedToLoadImage}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
maxFileSize={editor.storage.imageComponent?.maxFileSize}
setIsUploaded={setIsUploaded}
{...props}
/>

View File

@ -1,10 +1,6 @@
import { Editor } from "@tiptap/core";
import { useEditorState } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
type Props = {
editor: Editor;
@ -20,7 +16,7 @@ export const ImageUploadStatus: React.FC<Props> = (props) => {
// subscribe to image upload status
const uploadStatus: number | undefined = useEditorState({
editor,
selector: ({ editor }) => getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsUploadStatus?.[nodeId],
selector: ({ editor }) => editor.storage.utility?.assetsUploadStatus?.[nodeId],
});
useEffect(() => {

View File

@ -7,7 +7,6 @@ import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { EFileError } from "@/helpers/file";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// hooks
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
// local imports
@ -40,7 +39,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const { id: imageEntityId } = node.attrs;
// derived values
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
const isTouchDevice = !!getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;
const isTouchDevice = !!editor.storage.utility.isTouchDevice;
const onUpload = useCallback(
(url: string) => {
@ -60,7 +59,12 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
// only if the cursor is at the current image component, manipulate
// the cursor position
if (currentNode && currentNode.type.name === node.type.name && currentNode.attrs.src === url) {
if (
currentNode &&
currentNode.type.name === node.type.name &&
currentNode.attrs.src === url &&
pos !== undefined
) {
// control cursor position after upload
const nextNode = editor.state.doc.nodeAt(pos + 1);
@ -85,7 +89,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const handleProgressStatus = useCallback(
(isUploading: boolean) => {
getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading;
editor.storage.utility.uploadInProgress = isUploading;
},
[editor]
);
@ -107,7 +111,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
editor,
pos: getPos(),
getPos,
type: "image",
uploader: uploadFile,
});
@ -139,13 +143,14 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
async (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const filesList = e.target.files;
if (!filesList) {
const pos = getPos();
if (!filesList || pos === undefined) {
return;
}
await uploadFirstFileAndInsertRemaining({
editor,
filesList,
pos: getPos(),
pos,
type: "image",
uploader: uploadFile,
});

View File

@ -3,7 +3,14 @@ import { Image as BaseImageExtension } from "@tiptap/extension-image";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { type CustomImageExtension, ECustomImageAttributeNames, type InsertImageComponentProps } from "./types";
import {
type CustomImageExtensionType,
type CustomImageExtensionStorage,
ECustomImageAttributeNames,
type InsertImageComponentProps,
CustomImageExtensionOptions,
TCustomImageAttributes,
} from "./types";
import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils";
declare module "@tiptap/core" {
@ -12,9 +19,15 @@ declare module "@tiptap/core" {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
};
}
interface Storage {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: CustomImageExtensionStorage;
}
}
export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtension.extend({
export const CustomImageExtensionConfig: CustomImageExtensionType = BaseImageExtension.extend<
CustomImageExtensionOptions,
CustomImageExtensionStorage
>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
group: "block",
atom: true,
@ -22,12 +35,15 @@ export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtensi
addAttributes() {
const attributes = {
...this.parent?.(),
...Object.values(ECustomImageAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
};
return acc;
}, {}),
...Object.values(ECustomImageAttributeNames).reduce(
(acc, value) => {
acc[value] = {
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
};
return acc;
},
{} as Record<ECustomImageAttributeNames, { default: TCustomImageAttributes[ECustomImageAttributeNames] }>
),
};
return attributes;

View File

@ -10,6 +10,7 @@ import type { TFileHandler } from "@/types";
// local imports
import { CustomImageNodeView, CustomImageNodeViewProps } from "./components/node-view";
import { CustomImageExtensionConfig } from "./extension-config";
import type { CustomImageExtensionOptions, CustomImageExtensionStorage } from "./types";
import { getImageComponentImageFileMap } from "./utils";
type Props = {
@ -22,7 +23,7 @@ export const CustomImageExtension = (props: Props) => {
// derived values
const { getAssetSrc, getAssetDownloadSrc, restore: restoreImageFn } = fileHandler;
return CustomImageExtensionConfig.extend({
return CustomImageExtensionConfig.extend<CustomImageExtensionOptions, CustomImageExtensionStorage>({
selectable: isEditable,
draggable: isEditable,

View File

@ -53,4 +53,4 @@ export type CustomImageExtensionStorage = {
maxFileSize: number;
};
export type CustomImageExtension = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;
export type CustomImageExtensionType = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;

View File

@ -1,9 +1,5 @@
import type { Editor } from "@tiptap/core";
import { AlignCenter, AlignLeft, AlignRight, type LucideIcon } from "lucide-react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import { ECustomImageAttributeNames, TCustomImageAlignment, type Pixel, type TCustomImageAttributes } from "./types";
@ -16,8 +12,7 @@ export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.ALIGNMENT]: "left",
};
export const getImageComponentImageFileMap = (editor: Editor) =>
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
export const getImageComponentImageFileMap = (editor: Editor) => editor.storage.imageComponent?.fileMap;
export const ensurePixelString = <TDefault>(
value: Pixel | TDefault | number | undefined | null,

View File

@ -50,31 +50,8 @@ type LinkOptions = {
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_LINK]: {
/**
* Set a link mark
*/
setLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/**
* Toggle a link mark
*/
toggleLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/**
* Unset a link mark
*/
unsetLink: () => ReturnType;
};
interface Storage {
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
}
}

View File

@ -10,15 +10,13 @@ import {
PasteRule,
removeDuplicates,
} from "@tiptap/core";
import { emojis, emojiToShortcode, shortcodeToEmoji } from "@tiptap/extension-emoji";
import { EmojiStorage, emojis, emojiToShortcode, shortcodeToEmoji } from "@tiptap/extension-emoji";
import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
import emojiRegex from "emoji-regex";
import { isEmojiSupported } from "is-emoji-supported";
// helpers
import { CORE_EXTENSIONS } from "@/constants/extension";
import { customFindSuggestionMatch } from "@/helpers/find-suggestion-match";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@ -78,11 +76,6 @@ export type EmojiOptions = {
suggestion: Omit<SuggestionOptions, "editor">;
};
export type EmojiStorage = {
emojis: EmojiItem[];
isSupported: (item: EmojiItem) => boolean;
};
export const EmojiSuggestionPluginKey = new PluginKey("emojiSuggestion");
export const inputRegex = /:([a-zA-Z0-9_+-]+):$/;
@ -344,7 +337,7 @@ export const Emoji = Node.create<EmojiOptions, EmojiStorage>({
},
addProseMirrorPlugins() {
const isTouchDevice = !!getExtensionStorage(this.editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;
const isTouchDevice = !!this.editor.storage.utility.isTouchDevice;
if (isTouchDevice) {
return [];
}

View File

@ -7,11 +7,13 @@ import { emojiSuggestion } from "./suggestion";
export const EmojiExtension = Emoji.extend({
addStorage() {
const extensionOptions = this.options;
return {
...this.parent?.(),
markdown: {
serialize(state: MarkdownSerializerState, node: ProseMirrorNode) {
const emojiItem = shortcodeToEmoji(node.attrs.name, this.options.emojis);
const emojiItem = shortcodeToEmoji(node.attrs.name, extensionOptions.emojis);
if (emojiItem?.emoji) {
state.write(emojiItem?.emoji);
} else if (emojiItem?.fallbackImage) {

View File

@ -1,19 +1,16 @@
import type { EmojiOptions } from "@tiptap/extension-emoji";
import { ReactRenderer, Editor } from "@tiptap/react";
import { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
import { ReactRenderer, type Editor } from "@tiptap/react";
import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import { EmojiItem, EmojiList, EmojiListRef } from "./components/emojis-list";
import { type EmojiItem, EmojiList, type EmojiListRef } from "./components/emojis-list";
const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
export const emojiSuggestion: EmojiOptions["suggestion"] = {
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
const { emojis } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);
const { isSupported } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);
const { emojis, isSupported } = editor.storage.emoji;
const filteredEmojis = emojis.filter((emoji) => {
const hasEmoji = !!emoji?.emoji;
const hasFallbackImage = !!emoji?.fallbackImage;
@ -27,7 +24,7 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
if (query.trim() === "") {
const defaultEmojis = DEFAULT_EMOJIS.map((name) =>
filteredEmojis.find((emoji: EmojiItem) => emoji.shortcodes.includes(name) || emoji.name === name)
filteredEmojis.find((emoji) => emoji.shortcodes.includes(name) || emoji.name === name)
)
.filter(Boolean)
.slice(0, 5);
@ -57,7 +54,7 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
editor = props.editor;
// Track active dropdown
getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);
editor.storage.utility.activeDropbarExtensions.push(CORE_EXTENSIONS.EMOJI);
component = new ReactRenderer(EmojiList, {
props: {
@ -101,10 +98,10 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
onExit: (): void => {
// Remove from active dropdowns
if (editor) {
const utilityStorage = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY);
const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI);
const { activeDropbarExtensions } = editor.storage.utility;
const index = activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI);
if (index > -1) {
utilityStorage.activeDropbarExtensions.splice(index, 1);
activeDropbarExtensions.splice(index, 1);
}
}

View File

@ -1,8 +1,6 @@
import { Extension } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
Extension.create({
@ -11,7 +9,7 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
addKeyboardShortcuts(this) {
return {
Enter: () => {
const { activeDropbarExtensions } = getExtensionStorage(this.editor, CORE_EXTENSIONS.UTILITY);
const { activeDropbarExtensions } = this.editor.storage.utility;
if (activeDropbarExtensions.length === 0) {
onEnterKeyPress?.();

View File

@ -1,9 +1,8 @@
import { Extensions } from "@tiptap/core";
import CharacterCount from "@tiptap/extension-character-count";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
import { TextStyle } from "@tiptap/extension-text-style";
import { CharacterCount } from "@tiptap/extensions";
import { Markdown } from "tiptap-markdown";
// extensions
import {
@ -76,7 +75,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
ListKeymap({ tabIndex }),
CustomLinkExtension,
CustomTypographyExtension,
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {

View File

@ -9,6 +9,12 @@ export type HeadingExtensionStorage = {
headings: IMarking[];
};
declare module "@tiptap/core" {
interface Storage {
[CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage;
}
}
export const HeadingListExtension = Extension.create<unknown, HeadingExtensionStorage>({
name: CORE_EXTENSIONS.HEADINGS_LIST,
@ -43,7 +49,11 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
this.storage.headings = headings;
this.editor.emit("update", { editor: this.editor, transaction: newState.tr });
this.editor.emit("update", {
editor: this.editor,
transaction: newState.tr,
appendedTransactions: [],
});
return null;
},
@ -51,8 +61,4 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
return [plugin];
},
getHeadings() {
return this.storage.headings;
},
});

View File

@ -1,4 +1,6 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
@ -7,6 +9,12 @@ import type { TFileHandler } from "@/types";
import { CustomImageNodeView, CustomImageNodeViewProps } from "../custom-image/components/node-view";
import { ImageExtensionConfig } from "./extension-config";
declare module "@tiptap/core" {
interface Storage {
[CORE_EXTENSIONS.IMAGE]: ImageExtensionStorage;
}
}
export type ImageExtensionStorage = {
deletedImageSet: Map<string, boolean>;
};

View File

@ -39,10 +39,6 @@ export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOpti
return ["mention-component", mergeAttributes(HTMLAttributes)];
},
HTMLAttributes: {
class: "mention",
},
renderText({ node }) {
return getMentionDisplayText(this.options, node);
},

View File

@ -1,10 +1,9 @@
import { Editor } from "@tiptap/core";
import { ReactRenderer } from "@tiptap/react";
import { SuggestionOptions } from "@tiptap/suggestion";
import tippy, { Instance } from "tippy.js";
// helpers
import type { SuggestionOptions } from "@tiptap/suggestion";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// helpers
import { updateFloatingUIFloaterPosition } from "@/helpers/floating-ui";
import { CommandListInstance } from "@/helpers/tippy";
// types
import { TMentionHandler } from "@/types";
@ -17,63 +16,48 @@ export const renderMentionsDropdown =
() => {
const { searchCallback } = props;
let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | null = null;
let popup: Instance | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
onStart: ({ clientRect, editor }) => {
if (!searchCallback) return;
if (!props.clientRect) return;
if (!clientRect) return;
component = new ReactRenderer<CommandListInstance, MentionsListDropdownProps>(MentionsListDropdown, {
props: {
...props,
searchCallback,
},
editor: props.editor,
});
getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY).activeDropbarExtensions.push(
CORE_EXTENSIONS.MENTION
);
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () =>
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
editor: editor,
});
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.MENTION);
const element = component.element as HTMLElement;
element.style.position = "absolute";
document.body.appendChild(element);
updateFloatingUIFloaterPosition(editor, element);
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
onUpdate: ({ clientRect, editor }) => {
component?.updateProps(props);
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
if (!clientRect) return;
if (component?.element) {
updateFloatingUIFloaterPosition(editor, component?.element as HTMLElement);
}
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0]?.hide();
onKeyDown: ({ event }) => {
if (event.key === "Escape") {
component?.destroy();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
props.event?.stopPropagation();
if (component?.ref?.onKeyDown(props)) {
return true;
}
if (navigationKeys.includes(event.key)) {
event?.stopPropagation();
return component?.ref?.onKeyDown({ event });
}
return false;
return component?.ref?.onKeyDown({ event });
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
const utilityStorage = getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY);
const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.MENTION);
if (index > -1) {
utilityStorage.activeDropbarExtensions.splice(index, 1);
}
popup?.[0]?.destroy();
onExit: ({ editor }) => {
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.MENTION);
component?.element.remove();
component?.destroy();
},
};

View File

@ -1,8 +1,6 @@
import Placeholder from "@tiptap/extension-placeholder";
import { Placeholder } from "@tiptap/extensions";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// types
import type { IEditorProps } from "@/types";
@ -19,7 +17,7 @@ export const CustomPlaceholderExtension = (args: TArgs) => {
if (node.type.name === CORE_EXTENSIONS.HEADING) return `Heading ${node.attrs.level}`;
const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress;
const isUploadInProgress = editor.storage.utility?.uploadInProgress;
if (isUploadInProgress) return "";

View File

@ -1,10 +1,10 @@
import { Editor, Range, Extension } from "@tiptap/core";
import { type Editor, type Range, Extension } from "@tiptap/core";
import { ReactRenderer } from "@tiptap/react";
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
import tippy, { Instance } from "tippy.js";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { updateFloatingUIFloaterPosition } from "@/helpers/floating-ui";
import { CommandListInstance } from "@/helpers/tippy";
// types
import { IEditorProps, ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
@ -32,7 +32,6 @@ const Command = Extension.create<SlashCommandOptions>({
},
allow({ editor }: { editor: Editor }) {
const { selection } = editor.state;
const parentNode = selection.$from.node(selection.$from.depth);
const blockType = parentNode.type.name;
@ -49,64 +48,63 @@ const Command = Extension.create<SlashCommandOptions>({
return [
Suggestion({
editor: this.editor,
render: () => {
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
return {
onStart: (props) => {
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
const element = component.element as HTMLElement;
element.style.position = "absolute";
element.style.zIndex = "100";
(props.editor.options.element || document.body).appendChild(element);
updateFloatingUIFloaterPosition(props.editor, element);
},
onUpdate: (props) => {
if (!component || !component.element) return;
component.updateProps(props);
if (!props.clientRect) {
return;
}
const element = component.element as HTMLElement;
updateFloatingUIFloaterPosition(props.editor, element);
},
onKeyDown: (props) => {
if (props.event.key === "Escape") {
component?.destroy();
component = null;
return true;
}
return component?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
component?.destroy();
component = null;
},
};
},
...this.options.suggestion,
}),
];
},
});
const renderItems: SuggestionOptions["render"] = () => {
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
let popup: Instance | null = null;
return {
onStart: (props) => {
// Track active dropdown
props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS);
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
props,
editor: props.editor,
});
const tippyContainer =
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]');
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: tippyContainer,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props) => {
component?.updateProps(props);
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
if (component?.ref?.onKeyDown(props)) {
return true;
}
return false;
},
onExit: ({ editor }) => {
// Remove from active dropdowns
editor?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS);
popup?.[0].destroy();
component?.destroy();
},
};
};
export type TExtensionProps = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions"> & {
additionalOptions?: TSlashCommandAdditionalOption[];
};
@ -115,6 +113,5 @@ export const SlashCommands = (props: TExtensionProps) =>
Command.configure({
suggestion: {
items: getSlashCommandFilteredSections(props),
render: renderItems,
},
});

View File

@ -27,6 +27,8 @@ export const CustomStarterKitExtension = (args: TArgs) => {
codeBlock: false,
horizontalRule: false,
blockquote: false,
link: false,
listKeymap: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
@ -41,6 +43,6 @@ export const CustomStarterKitExtension = (args: TArgs) => {
class:
"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
},
...(enableHistory ? {} : { history: false }),
...(enableHistory ? {} : { undoRedo: false }),
});
};

View File

@ -1,274 +1,21 @@
import { Editor } from "@tiptap/core";
import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model";
import { CellSelection, TableMap, updateColumnsOnResize } from "@tiptap/pm/tables";
import { Decoration, NodeView } from "@tiptap/pm/view";
import type { Editor, NodeViewProps } from "@tiptap/core";
import type { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model";
import { TableMap, updateColumnsOnResize } from "@tiptap/pm/tables";
import type { Decoration, NodeView } from "@tiptap/pm/view";
import { h } from "jsx-dom-cjs";
import tippy, { Instance, Props } from "tippy.js";
import { CORE_EXTENSIONS } from "@/constants/extension";
import { icons } from "./icons";
import { isCellSelection } from "./utilities/helpers";
type ToolboxItem = {
label: string;
icon: string;
action: (args: any) => void;
};
export function updateColumns(
node: ProseMirrorNode,
colgroup: HTMLElement,
table: HTMLElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: any
) {
let totalWidth = 0;
let fixedWidth = true;
let nextDOM = colgroup.firstChild as HTMLElement;
const row = node.firstChild;
if (!row) return;
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colWidth } = row.child(i).attrs;
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth = overrideCol === col ? overrideValue : colWidth && colWidth[j];
const cssWidth = hasWidth ? `${hasWidth}px` : "";
totalWidth += hasWidth || cellMinWidth;
if (!hasWidth) {
fixedWidth = false;
}
if (!nextDOM) {
colgroup.appendChild(document.createElement("col")).style.width = cssWidth;
} else {
if (nextDOM.style.width !== cssWidth) {
nextDOM.style.width = cssWidth;
}
nextDOM = nextDOM.nextSibling as HTMLElement;
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling;
nextDOM.parentNode?.removeChild(nextDOM);
nextDOM = after as HTMLElement;
}
if (fixedWidth) {
table.style.width = `${totalWidth}px`;
table.style.minWidth = "";
} else {
table.style.width = "";
table.style.minWidth = `${totalWidth}px`;
}
}
const defaultTippyOptions: Partial<Props> = {
allowHTML: true,
arrow: false,
trigger: "click",
animation: "scale-subtle",
theme: "light-border no-padding",
interactive: true,
hideOnClick: true,
placement: "right",
};
function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
return editor
.chain()
.focus()
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
background: color.backgroundColor,
textColor: color.textColor,
})
.run();
}
function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) {
const { state, dispatch } = editor.view;
const { selection } = state;
if (!isCellSelection(selection)) {
return false;
}
// Get the position of the hovered cell in the selection to determine the row.
const hoveredCell = selection.$headCell || selection.$anchorCell;
// Find the depth of the table row node
let rowDepth = hoveredCell.depth;
while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== CORE_EXTENSIONS.TABLE_ROW) {
rowDepth--;
}
// If we couldn't find a tableRow node, we can't set the background color
if (hoveredCell.node(rowDepth).type.name !== CORE_EXTENSIONS.TABLE_ROW) {
return false;
}
// Get the position where the table row starts
const rowStartPos = hoveredCell.start(rowDepth);
// Create a transaction that sets the background color on the tableRow node.
const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, {
...hoveredCell.node(rowDepth).attrs,
background: color.backgroundColor,
textColor: color.textColor,
});
dispatch(tr);
return true;
}
const columnsToolboxItems: ToolboxItem[] = [
{
label: "Toggle column header",
icon: icons.toggleColumnHeader,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(),
},
{
label: "Add column before",
icon: icons.insertLeftTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(),
},
{
label: "Add column after",
icon: icons.insertRightTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(),
},
{
label: "Pick color",
icon: "", // No icon needed for color picker
action: (_args: unknown) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
},
{
label: "Delete column",
icon: icons.deleteColumn,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(),
},
];
const rowsToolboxItems: ToolboxItem[] = [
{
label: "Toggle row header",
icon: icons.toggleRowHeader,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(),
},
{
label: "Add row above",
icon: icons.insertTopTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(),
},
{
label: "Add row below",
icon: icons.insertBottomTableIcon,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(),
},
{
label: "Pick color",
icon: "",
action: (_args: unknown) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
},
{
label: "Delete row",
icon: icons.deleteRow,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteRow().run(),
},
];
function createToolbox({
triggerButton,
items,
tippyOptions,
onSelectColor,
onClickItem,
colors,
}: {
triggerButton: Element | null;
items: ToolboxItem[];
tippyOptions: any;
onClickItem: (item: ToolboxItem) => void;
onSelectColor: (color: { backgroundColor: string; textColor: string }) => void;
colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } };
}): Instance<Props> {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const toolbox = tippy(triggerButton, {
content: h(
"div",
{
className:
"rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg min-w-[12rem] whitespace-nowrap",
},
items.map((item) => {
if (item.label === "Pick color") {
return h("div", { className: "flex flex-col" }, [
h("hr", { className: "my-2 border-custom-border-200" }),
h("div", { className: "text-custom-text-200 text-sm" }, item.label),
h(
"div",
{ className: "grid grid-cols-6 gap-x-1 gap-y-2.5 mt-2" },
Object.entries(colors).map(([_, colorValue]) =>
h("div", {
className: "grid place-items-center size-6 rounded cursor-pointer",
style: `background-color: ${colorValue.backgroundColor};color: ${colorValue.textColor || "inherit"};`,
innerHTML:
colorValue.icon ?? `<span class="text-md" style:"color: ${colorValue.backgroundColor}>A</span>`,
onClick: () => onSelectColor(colorValue),
})
)
),
h("hr", { className: "my-2 border-custom-border-200" }),
]);
} else {
return h(
"div",
{
className:
"flex items-center gap-2 px-1 py-1.5 bg-custom-background-100 hover:bg-custom-background-80 text-sm text-custom-text-200 rounded cursor-pointer",
itemType: "div",
onClick: () => onClickItem(item),
},
[
h("span", {
className: "h-3 w-3 flex-shrink-0",
innerHTML: item.icon,
}),
h("div", { className: "label" }, item.label),
]
);
}
})
),
...tippyOptions,
});
return Array.isArray(toolbox) ? toolbox[0] : toolbox;
}
export class TableView implements NodeView {
node: ProseMirrorNode;
cellMinWidth: number;
decorations: Decoration[];
decorations: readonly Decoration[];
editor: Editor;
getPos: () => number;
getPos: NodeViewProps["getPos"];
hoveredCell: ResolvedPos | null = null;
map: TableMap;
root: HTMLElement;
table: HTMLTableElement;
colgroup: HTMLTableColElement;
tbody: HTMLElement;
rowsControl?: HTMLElement | null;
columnsControl?: HTMLElement | null;
columnsToolbox?: Instance<Props>;
rowsToolbox?: Instance<Props>;
controls?: HTMLElement;
get dom() {
@ -282,9 +29,9 @@ export class TableView implements NodeView {
constructor(
node: ProseMirrorNode,
cellMinWidth: number,
decorations: Decoration[],
decorations: readonly Decoration[],
editor: Editor,
getPos: () => number
getPos: NodeViewProps["getPos"]
) {
this.node = node;
this.cellMinWidth = cellMinWidth;
@ -294,88 +41,6 @@ export class TableView implements NodeView {
this.hoveredCell = null;
this.map = TableMap.get(node);
if (editor.isEditable) {
this.rowsControl = h(
"div",
{ className: "rows-control" },
h("div", {
itemType: "button",
className: "rows-control-div",
onClick: () => this.selectRow(),
})
);
this.columnsControl = h(
"div",
{ className: "columns-control" },
h("div", {
itemType: "button",
className: "columns-control-div",
onClick: () => this.selectColumn(),
})
);
this.controls = h(
"div",
{ className: "table-controls", contentEditable: "false" },
this.rowsControl,
this.columnsControl
);
const columnColors = {
Blue: { backgroundColor: "#D9E4FF", textColor: "#171717" },
Orange: { backgroundColor: "#FFEDD5", textColor: "#171717" },
Grey: { backgroundColor: "#F1F1F1", textColor: "#171717" },
Yellow: { backgroundColor: "#FEF3C7", textColor: "#171717" },
Green: { backgroundColor: "#DCFCE7", textColor: "#171717" },
Red: { backgroundColor: "#FFDDDD", textColor: "#171717" },
Pink: { backgroundColor: "#FFE8FA", textColor: "#171717" },
Purple: { backgroundColor: "#E8DAFB", textColor: "#171717" },
None: {
backgroundColor: "none",
textColor: "none",
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>`,
},
};
this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector(".columns-control-div"),
items: columnsToolboxItems,
colors: columnColors,
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls,
},
onClickItem: (item) => {
item.action({
editor: this.editor,
triggerButton: this.columnsControl?.firstElementChild,
controlsContainer: this.controls,
});
this.columnsToolbox?.hide();
},
});
this.rowsToolbox = createToolbox({
triggerButton: this.rowsControl.firstElementChild,
items: rowsToolboxItems,
colors: columnColors,
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls,
},
onSelectColor: (color) => setTableRowBackgroundColor(editor, color),
onClickItem: (item) => {
item.action({
editor: this.editor,
triggerButton: this.rowsControl?.firstElementChild,
controlsContainer: this.controls,
});
this.rowsToolbox?.hide();
},
});
}
this.colgroup = h(
"colgroup",
null,
@ -387,9 +52,8 @@ export class TableView implements NodeView {
this.root = h(
"div",
{
className: "table-wrapper editor-full-width-block horizontal-scrollbar scrollbar-sm controls--disabled",
className: "table-wrapper editor-full-width-block horizontal-scrollbar scrollbar-sm",
},
this.controls,
this.table
);
@ -405,10 +69,6 @@ export class TableView implements NodeView {
this.decorations = [...decorations];
this.map = TableMap.get(this.node);
if (this.editor.isEditable) {
this.updateControls();
}
this.render();
return true;
@ -426,67 +86,4 @@ export class TableView implements NodeView {
ignoreMutation() {
return true;
}
updateControls() {
const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce(
(acc, curr) => {
if (curr.spec.hoveredCell !== undefined) {
acc["hoveredCell"] = curr.spec.hoveredCell;
}
if (curr.spec.hoveredTable !== undefined) {
acc["hoveredTable"] = curr.spec.hoveredTable;
}
return acc;
},
{} as Record<string, HTMLElement>
) as any;
if (table === undefined || cell === undefined) {
return this.root.classList.add("controls--disabled");
}
this.root.classList.remove("controls--disabled");
this.hoveredCell = cell;
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
if (!this.table || !cellDom) {
return;
}
const tableRect = this.table?.getBoundingClientRect();
const cellRect = cellDom?.getBoundingClientRect();
if (this.columnsControl) {
this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`;
this.columnsControl.style.width = `${cellRect.width}px`;
}
if (this.rowsControl) {
this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`;
this.rowsControl.style.height = `${cellRect.height}px`;
}
}
selectColumn() {
if (!this.hoveredCell) return;
const colIndex = this.map.colCount(this.hoveredCell.pos - (this.getPos() + 1));
const anchorCellPos = this.hoveredCell.pos;
const headCellPos = this.map.map[colIndex + this.map.width * (this.map.height - 1)] + (this.getPos() + 1);
const cellSelection = CellSelection.create(this.editor.view.state.doc, anchorCellPos, headCellPos);
this.editor.view.dispatch(this.editor.state.tr.setSelection(cellSelection));
}
selectRow() {
if (!this.hoveredCell) return;
const anchorCellPos = this.hoveredCell.pos;
const anchorCellIndex = this.map.map.indexOf(anchorCellPos - (this.getPos() + 1));
const headCellPos = this.map.map[anchorCellIndex + (this.map.width - 1)] + (this.getPos() + 1);
const cellSelection = CellSelection.create(this.editor.state.doc, anchorCellPos, headCellPos);
this.editor.view.dispatch(this.editor.view.state.tr.setSelection(cellSelection));
}
}

View File

@ -18,7 +18,6 @@ import {
toggleHeader,
toggleHeaderCell,
} from "@tiptap/pm/tables";
import type { Decoration } from "@tiptap/pm/view";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
@ -264,7 +263,7 @@ export const Table = Node.create<TableOptions>({
return ({ editor, node, decorations, getPos }) => {
const { cellMinWidth } = this.options;
return new TableView(node, cellMinWidth, decorations as Decoration[], editor, getPos);
return new TableView(node, cellMinWidth, decorations, editor, getPos);
};
},

View File

@ -16,6 +16,7 @@ type TActiveDropbarExtensions =
| CORE_EXTENSIONS.EMOJI
| CORE_EXTENSIONS.SLASH_COMMANDS
| CORE_EXTENSIONS.TABLE
| "bubble-menu"
| CORE_EXTENSIONS.SIDE_MENU
| TAdditionalActiveDropbarExtensions;
@ -36,6 +37,9 @@ declare module "@tiptap/core" {
removeActiveDropbarExtension: (extension: TActiveDropbarExtensions) => () => void;
};
}
interface Storage {
[CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage;
}
}
export type UtilityExtensionStorage = {

View File

@ -1,16 +1,18 @@
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
// plane imports
import { ADDITIONAL_EXTENSIONS, CORE_EXTENSIONS } from "@plane/utils";
// extensions
import { getImageBlockId } from "@/extensions/custom-image/utils";
// plane editor imports
import { ADDITIONAL_ASSETS_META_DATA_RECORD } from "@/plane-editor/constants/assets";
// types
import { TEditorAsset } from "@/types";
import type { TEditorAsset } from "@/types";
export type TAssetMetaDataRecord = (attrs: ProseMirrorNode["attrs"]) => TEditorAsset | undefined;
export const CORE_ASSETS_META_DATA_RECORD: Partial<Record<CORE_EXTENSIONS, TAssetMetaDataRecord>> = {
export const CORE_ASSETS_META_DATA_RECORD: Partial<
Record<CORE_EXTENSIONS | ADDITIONAL_EXTENSIONS, TAssetMetaDataRecord>
> = {
[CORE_EXTENSIONS.IMAGE]: (attrs) => {
if (!attrs?.src) return;
return {

View File

@ -1,5 +1,5 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Editor } from "@tiptap/core";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { Editor } from "@tiptap/core";
import { DOMSerializer } from "@tiptap/pm/model";
import * as Y from "yjs";
// components
@ -11,7 +11,6 @@ import { CORE_EDITOR_META } from "@/constants/meta";
import type { EditorRefApi, TEditorCommands } from "@/types";
// local imports
import { getParagraphCount } from "./common";
import { getExtensionStorage } from "./get-extension-storage";
import { insertContentAtSavedSelection } from "./insert-content-at-cursor-position";
import { scrollSummary, scrollToNodeViaDOMCoordinates } from "./scroll-to-node";
@ -72,18 +71,18 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
};
},
getDocumentInfo: () => ({
characters: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.characters?.() : 0,
characters: editor?.storage.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editor?.state),
words: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.words?.() : 0,
words: editor?.storage.characterCount?.words?.() ?? 0,
}),
getHeadings: () => (editor ? getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings : []),
getHeadings: () => (editor ? editor.storage.headingsList?.headings : []),
getMarkDown: () => {
const markdownOutput = editor?.storage?.markdown?.getMarkdown?.();
const markdownOutput = editor?.storage?.markdown?.getMarkdown?.() ?? "";
return markdownOutput;
},
isAnyDropbarOpen: () => {
if (!editor) return false;
const utilityStorage = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY);
const utilityStorage = editor.storage.utility;
return utilityStorage.activeDropbarExtensions.length > 0;
},
scrollSummary: (marking) => {
@ -91,7 +90,17 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
scrollSummary(editor, marking);
},
setEditorValue: (content, emitUpdate = false) => {
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
editor
?.chain()
.setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true)
.setMeta(CORE_EDITOR_META.INTENTIONAL_DELETION, true)
.setContent(content, {
emitUpdate,
parseOptions: {
preserveWhitespace: true,
},
})
.run();
},
emitRealTimeUpdate: (message) => provider?.sendStateless(message),
executeMenuItemCommand: (props) => {
@ -148,8 +157,7 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
isEditorReadyToDiscard: () =>
!!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false,
isEditorReadyToDiscard: () => editor?.storage?.utility?.uploadInProgress === false,
isMenuItemActive: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editor);
@ -163,11 +171,11 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
onDocumentInfoChange: (callback) => {
const handleDocumentInfoChange = () => {
if (!editor) return;
if (!editor?.storage) return;
callback({
characters: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.characters?.() : 0,
characters: editor.storage.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editor?.state),
words: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.words?.() : 0,
words: editor.storage.characterCount?.words?.() ?? 0,
});
};
@ -183,7 +191,7 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
onHeadingChange: (callback) => {
const handleHeadingChange = () => {
if (!editor) return;
const headings = getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings;
const headings = editor.storage.headingsList?.headings;
if (headings) {
callback(headings);
}

View File

@ -0,0 +1,33 @@
import { computePosition, flip, type Middleware, type Strategy, type Placement, shift } from "@floating-ui/dom";
import { type Editor, posToDOMRect } from "@tiptap/core";
export const updateFloatingUIFloaterPosition = (
editor: Editor,
element: HTMLElement,
options?: {
elementStyle?: Partial<CSSStyleDeclaration>;
middleware?: Middleware[];
placement?: Placement;
strategy?: Strategy;
}
) => {
const virtualElement = {
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
};
computePosition(virtualElement, element, {
placement: options?.placement ?? "bottom-start",
strategy: options?.strategy ?? "absolute",
middleware: options?.middleware ?? [shift(), flip()],
})
.then(({ x, y, strategy }) => {
Object.assign(element.style, {
width: "max-content",
position: strategy,
left: `${x}px`,
top: `${y}px`,
...options?.elementStyle,
});
})
.catch((error) => console.error("An error occurred while updating floating UI floter position:", error));
};

View File

@ -1,8 +0,0 @@
import { Editor } from "@tiptap/core";
// plane editor types
import { ExtensionStorageMap } from "@/plane-editor/types/storage";
export const getExtensionStorage = <K extends keyof ExtensionStorageMap>(
editor: Editor,
extensionName: K
): ExtensionStorageMap[K] => editor.storage[extensionName];

View File

@ -1,17 +1,21 @@
import { useEditorState, useEditor as useTiptapEditor } from "@tiptap/react";
import { useImperativeHandle, useEffect } from "react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import type { MarkdownStorage } from "tiptap-markdown";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
import { getEditorRefHelpers } from "@/helpers/editor-ref";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// props
import { CoreEditorProps } from "@/props";
// types
import type { TEditorHookProps } from "@/types";
declare module "@tiptap/core" {
interface Storage {
markdown: MarkdownStorage;
}
}
export const useEditor = (props: TEditorHookProps) => {
const {
autofocus = false,
@ -86,10 +90,15 @@ export const useEditor = (props: TEditorHookProps) => {
// supported and value is undefined when the data from swr is not populated
if (value == null) return;
if (editor) {
const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress;
const { uploadInProgress: isUploadInProgress } = editor.storage.utility;
if (!editor.isDestroyed && !isUploadInProgress) {
try {
editor.commands.setContent(value, false, { preserveWhitespace: true });
editor.commands.setContent(value, {
emitUpdate: false,
parseOptions: {
preserveWhitespace: true,
},
});
if (editor.state.selection) {
const docLength = editor.state.doc.content.size;
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
@ -113,7 +122,7 @@ export const useEditor = (props: TEditorHookProps) => {
const assetsList = useEditorState({
editor,
selector: ({ editor }) => ({
assets: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsList : [],
assets: editor?.storage.utility?.assetsList ?? [],
}),
});
// trigger callback when assets list changes

View File

@ -1,4 +1,4 @@
import { Editor } from "@tiptap/core";
import type { Editor, NodeViewProps } from "@tiptap/core";
import { DragEvent, useCallback, useEffect, useState } from "react";
// helpers
import { EFileError, isFileValid } from "@/helpers/file";
@ -66,9 +66,8 @@ export const useUploader = (args: TUploaderArgs) => {
throw new Error("Something went wrong while uploading the file.");
}
onUpload(url, file);
} catch (errPayload) {
const error = errPayload?.response?.data?.error || "Something went wrong";
console.error(error);
} catch {
console.error("useFileUpload: Error in uploading file");
} finally {
handleProgressStatus?.(false);
setIsUploading(false);
@ -90,13 +89,13 @@ export const useUploader = (args: TUploaderArgs) => {
type TDropzoneArgs = {
editor: Editor;
pos: number;
getPos: NodeViewProps["getPos"];
type: Extract<TEditorCommands, "attachment" | "image">;
uploader: (file: File) => Promise<void>;
};
export const useDropZone = (args: TDropzoneArgs) => {
const { editor, pos, type, uploader } = args;
const { editor, getPos, type, uploader } = args;
// states
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
@ -124,8 +123,9 @@ export const useDropZone = (args: TDropzoneArgs) => {
e.preventDefault();
setDraggedInside(false);
const filesList = e.dataTransfer.files;
const pos = getPos();
if (filesList.length === 0 || !editor.isEditable) {
if (filesList.length === 0 || !editor.isEditable || pos === undefined) {
return;
}
@ -137,7 +137,7 @@ export const useDropZone = (args: TDropzoneArgs) => {
uploader,
});
},
[editor, pos, type, uploader]
[editor, type, uploader, getPos]
);
const onDragEnter = useCallback(() => setDraggedInside(true), []);
const onDragLeave = useCallback(() => setDraggedInside(false), []);

View File

@ -1,5 +1,5 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import type { Editor } from "@tiptap/core";
import { type EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
// constants
import { CORE_EDITOR_META } from "@/constants/meta";
// plane editor imports
@ -7,6 +7,7 @@ import { NODE_FILE_MAP } from "@/plane-editor/constants/utility";
// types
import { TFileHandler } from "@/types";
// local imports
import type { NodeFileMapType } from "../../../ce/constants/utility";
import { TFileNode } from "./types";
const DELETE_PLUGIN_KEY = new PluginKey("delete-utility");
@ -21,7 +22,7 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
if (!transactions.some((tr) => tr.docChanged)) return null;
newState.doc.descendants((node) => {
const nodeType = node.type.name;
const nodeType = node.type.name as keyof NodeFileMapType;
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
if (nodeFileSetDetails) {
if (newFileSources[nodeType]) {
@ -40,7 +41,7 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
// iterate through all the nodes in the old state
oldState.doc.descendants((node) => {
const nodeType = node.type.name;
const nodeType = node.type.name as keyof NodeFileMapType;
const isAValidNode = NODE_FILE_MAP[nodeType];
// if the node doesn't match, then return as no point in checking
if (!isAValidNode) return;
@ -51,12 +52,13 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
});
removedFiles.forEach(async (node) => {
const nodeType = node.type.name;
const nodeType = node.type.name as keyof NodeFileMapType;
const src = node.attrs.src;
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
if (!nodeFileSetDetails || !src) return;
try {
editor.storage[nodeType][nodeFileSetDetails.fileSetName]?.set(src, true);
// @ts-expect-error add proper types for storage
editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName]?.set(src, true);
// update assets list storage value
editor.commands.updateAssetsList?.({
idToRemove: node.attrs.id,

View File

@ -1,7 +1,7 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import type { Editor } from "@tiptap/core";
import { type EditorState, Plugin, PluginKey, type Transaction } from "@tiptap/pm/state";
// plane imports
import { CORE_EXTENSIONS } from "@plane/utils";
// helpers
import { CORE_ASSETS_META_DATA_RECORD } from "@/helpers/assets";
// plane editor imports
@ -9,6 +9,7 @@ import { NODE_FILE_MAP } from "@/plane-editor/constants/utility";
// types
import { TFileHandler } from "@/types";
// local imports
import type { NodeFileMapType } from "../../../ce/constants/utility";
import { TFileNode } from "./types";
const RESTORE_PLUGIN_KEY = new PluginKey("restore-utility");
@ -23,7 +24,7 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
[key: string]: Set<string> | undefined;
} = {};
oldState.doc.descendants((node) => {
const nodeType = node.type.name;
const nodeType = node.type.name as keyof NodeFileMapType;
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
if (nodeFileSetDetails) {
if (oldFileSources[nodeType]) {
@ -38,7 +39,7 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
const addedFiles: TFileNode[] = [];
newState.doc.descendants((node, pos) => {
const nodeType = node.type.name;
const nodeType = node.type.name as keyof NodeFileMapType;
const isAValidNode = NODE_FILE_MAP[nodeType];
// if the node doesn't match, then return as no point in checking
if (!isAValidNode) return;
@ -58,9 +59,11 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
});
addedFiles.forEach(async (node) => {
const nodeType = node.type.name;
const nodeType = node.type.name as keyof NodeFileMapType;
const src = node.attrs.src;
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
if (!nodeFileSetDetails) return;
// @ts-expect-error add proper types for storage
const extensionFileSetStorage = editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName];
const wasDeleted = extensionFileSetStorage?.get(src);
if (!nodeFileSetDetails || !src) return;

View File

@ -9,6 +9,7 @@ export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
// @ts-expect-error tiptap-markdown types are not updated
const markdownSerializer = editor.storage.markdown.serializer;
const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW;
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;

View File

@ -143,61 +143,6 @@
}
/* End column resizer */
.table-wrapper .table-controls {
position: absolute;
.columns-control,
.rows-control {
transition: opacity ease-in 100ms;
position: absolute;
z-index: 5;
display: flex;
justify-content: center;
align-items: center;
}
.columns-control {
height: 20px;
transform: translateY(-50%);
.columns-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
width: 30px;
height: 15px;
}
}
.rows-control {
width: 20px;
transform: translateX(-50%);
left: -8px;
.rows-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
height: 30px;
width: 15px;
}
}
.columns-control-div,
.rows-control-div {
background-color: rgba(var(--color-background-80));
border: 0.5px solid rgba(var(--color-border-200));
border-radius: 4px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: rgba(var(--color-shadow-2xs));
cursor: pointer;
}
}
.resize-cursor .table-wrapper .table-controls .rows-control,
.table-wrapper.controls--disabled .table-controls .rows-control,
.resize-cursor .table-wrapper .table-controls .columns-control,

View File

@ -5,10 +5,10 @@
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"sourceMap": true,
"target": "ESNext",
"baseUrl": ".",
"paths": {
"@/*": ["./src/core/*"],

View File

@ -7,5 +7,6 @@ export default defineConfig({
dts: true,
clean: false,
sourcemap: true,
minify: true,
copy: ["src/styles"],
});

View File

@ -50,3 +50,51 @@ export const isEditorEmpty = (description: string | undefined): boolean =>
description === "<p></p>" ||
description === `<p class="editor-paragraph-block"></p>` ||
description.trim() === "";
export enum CORE_EXTENSIONS {
BLOCKQUOTE = "blockquote",
BOLD = "bold",
BULLET_LIST = "bulletList",
CALLOUT = "calloutComponent",
CHARACTER_COUNT = "characterCount",
CODE_BLOCK = "codeBlock",
CODE_INLINE = "code",
CUSTOM_COLOR = "customColor",
CUSTOM_IMAGE = "imageComponent",
CUSTOM_LINK = "link",
DOCUMENT = "doc",
DROP_CURSOR = "dropCursor",
ENTER_KEY = "enterKey",
GAP_CURSOR = "gapCursor",
HARD_BREAK = "hardBreak",
HEADING = "heading",
HEADINGS_LIST = "headingsList",
HISTORY = "history",
HORIZONTAL_RULE = "horizontalRule",
IMAGE = "image",
ITALIC = "italic",
LIST_ITEM = "listItem",
MARKDOWN_CLIPBOARD = "markdownClipboard",
MENTION = "mention",
ORDERED_LIST = "orderedList",
PARAGRAPH = "paragraph",
PLACEHOLDER = "placeholder",
SIDE_MENU = "editorSideMenu",
SLASH_COMMANDS = "slash-command",
STRIKETHROUGH = "strike",
TABLE = "table",
TABLE_CELL = "tableCell",
TABLE_HEADER = "tableHeader",
TABLE_ROW = "tableRow",
TASK_ITEM = "taskItem",
TASK_LIST = "taskList",
TEXT_ALIGN = "textAlign",
TEXT_STYLE = "textStyle",
TYPOGRAPHY = "typography",
UNDERLINE = "underline",
UTILITY = "utility",
WORK_ITEM_EMBED = "issue-embed-component",
EMOJI = "emoji",
}
export enum ADDITIONAL_EXTENSIONS {}

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,8 @@ catalog:
typescript: 5.8.3
tsdown: 0.14.2
uuid: 10.0.0
"@tiptap/core": ^3.5.3
"@tiptap/html": ^3.5.3
onlyBuiltDependencies:
- turbo