mirror of
https://github.com/gosticks/plane.git
synced 2025-10-16 12:45:33 +00:00
[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:
parent
5951372555
commit
e110ef55b8
@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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:"
|
||||
|
||||
@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@ -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">;
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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"];
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
export * from "./color-selector";
|
||||
export * from "./link-selector";
|
||||
export * from "./node-selector";
|
||||
export * from "./root";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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>[];
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
|
||||
@ -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]}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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));
|
||||
},
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -53,4 +53,4 @@ export type CustomImageExtensionStorage = {
|
||||
maxFileSize: number;
|
||||
};
|
||||
|
||||
export type CustomImageExtension = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;
|
||||
export type CustomImageExtensionType = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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?.();
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@ -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 "";
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 }),
|
||||
});
|
||||
};
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
33
packages/editor/src/core/helpers/floating-ui.ts
Normal file
33
packages/editor/src/core/helpers/floating-ui.ts
Normal 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));
|
||||
};
|
||||
@ -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];
|
||||
@ -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
|
||||
|
||||
@ -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), []);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"target": "ESNext",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/core/*"],
|
||||
|
||||
@ -7,5 +7,6 @@ export default defineConfig({
|
||||
dts: true,
|
||||
clean: false,
|
||||
sourcemap: true,
|
||||
minify: true,
|
||||
copy: ["src/styles"],
|
||||
});
|
||||
|
||||
@ -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 {}
|
||||
|
||||
890
pnpm-lock.yaml
890
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user