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/editor": "workspace:*",
|
||||||
"@plane/logger": "workspace:*",
|
"@plane/logger": "workspace:*",
|
||||||
"@plane/types": "workspace:*",
|
"@plane/types": "workspace:*",
|
||||||
"@tiptap/core": "^2.22.3",
|
"@tiptap/core": "catalog:",
|
||||||
"@tiptap/html": "^2.22.3",
|
"@tiptap/html": "catalog:",
|
||||||
"axios": "catalog:",
|
"axios": "catalog:",
|
||||||
"compression": "1.8.1",
|
"compression": "1.8.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@ -43,7 +43,8 @@
|
|||||||
"pino-http": "^10.3.0",
|
"pino-http": "^10.3.0",
|
||||||
"pino-pretty": "^11.2.2",
|
"pino-pretty": "^11.2.2",
|
||||||
"uuid": "catalog:",
|
"uuid": "catalog:",
|
||||||
"y-prosemirror": "^1.2.15",
|
"ws": "^8.18.3",
|
||||||
|
"y-prosemirror": "^1.3.7",
|
||||||
"y-protocols": "^1.0.6",
|
"y-protocols": "^1.0.6",
|
||||||
"yjs": "^13.6.20",
|
"yjs": "^13.6.20",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
@ -59,11 +60,7 @@
|
|||||||
"@types/pino-http": "^5.8.4",
|
"@types/pino-http": "^5.8.4",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"concurrently": "^9.0.1",
|
|
||||||
"nodemon": "^3.1.7",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"tsdown": "catalog:",
|
"tsdown": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:"
|
||||||
"ws": "^8.18.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export type TEditorFlaggingHookProps = {
|
|||||||
/**
|
/**
|
||||||
* @description extensions disabled in various editors
|
* @description extensions disabled in various editors
|
||||||
*/
|
*/
|
||||||
export const useEditorFlagging = (props: TEditorFlaggingHookProps): TEditorFlaggingHookReturnType => ({
|
export const useEditorFlagging = (_props: TEditorFlaggingHookProps): TEditorFlaggingHookReturnType => ({
|
||||||
document: {
|
document: {
|
||||||
disabled: ["ai", "collaboration-cursor"],
|
disabled: ["ai", "collaboration-cursor"],
|
||||||
flagged: [],
|
flagged: [],
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
"./styles": "./dist/styles/index.css"
|
"./styles": "./dist/styles/index.css"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsdown",
|
"build": "tsc && tsdown",
|
||||||
"dev": "tsdown --watch",
|
"dev": "tsdown --watch",
|
||||||
"check:lint": "eslint . --max-warnings 30",
|
"check:lint": "eslint . --max-warnings 30",
|
||||||
"check:types": "tsc --noEmit",
|
"check:types": "tsc --noEmit",
|
||||||
@ -46,25 +46,23 @@
|
|||||||
"@plane/types": "workspace:*",
|
"@plane/types": "workspace:*",
|
||||||
"@plane/ui": "workspace:*",
|
"@plane/ui": "workspace:*",
|
||||||
"@plane/utils": "workspace:*",
|
"@plane/utils": "workspace:*",
|
||||||
"@tiptap/core": "^2.22.3",
|
"@tiptap/core": "catalog:",
|
||||||
"@tiptap/extension-blockquote": "^2.22.3",
|
"@tiptap/extension-blockquote": "^3.5.3",
|
||||||
"@tiptap/extension-character-count": "^2.22.3",
|
"@tiptap/extension-collaboration": "^3.5.3",
|
||||||
"@tiptap/extension-collaboration": "^2.22.3",
|
"@tiptap/extension-emoji": "^3.5.3",
|
||||||
"@tiptap/extension-emoji": "^2.22.3",
|
"@tiptap/extension-image": "^3.5.3",
|
||||||
"@tiptap/extension-image": "^2.22.3",
|
"@tiptap/extension-list-item": "^3.5.3",
|
||||||
"@tiptap/extension-list-item": "^2.22.3",
|
"@tiptap/extension-mention": "^3.5.3",
|
||||||
"@tiptap/extension-mention": "^2.22.3",
|
"@tiptap/extension-task-item": "^3.5.3",
|
||||||
"@tiptap/extension-placeholder": "^2.22.3",
|
"@tiptap/extension-task-list": "^3.5.3",
|
||||||
"@tiptap/extension-task-item": "^2.22.3",
|
"@tiptap/extension-text-align": "^3.5.3",
|
||||||
"@tiptap/extension-task-list": "^2.22.3",
|
"@tiptap/extension-text-style": "^3.5.3",
|
||||||
"@tiptap/extension-text-align": "^2.22.3",
|
"@tiptap/extensions": "^3.5.3",
|
||||||
"@tiptap/extension-text-style": "^2.22.3",
|
"@tiptap/html": "catalog:",
|
||||||
"@tiptap/extension-underline": "^2.22.3",
|
"@tiptap/pm": "^3.5.3",
|
||||||
"@tiptap/html": "^2.22.3",
|
"@tiptap/react": "^3.5.3",
|
||||||
"@tiptap/pm": "^2.22.3",
|
"@tiptap/starter-kit": "^3.5.3",
|
||||||
"@tiptap/react": "^2.22.3",
|
"@tiptap/suggestion": "^3.5.3",
|
||||||
"@tiptap/starter-kit": "^2.22.3",
|
|
||||||
"@tiptap/suggestion": "^2.22.3",
|
|
||||||
"emoji-regex": "^10.3.0",
|
"emoji-regex": "^10.3.0",
|
||||||
"highlight.js": "^11.8.0",
|
"highlight.js": "^11.8.0",
|
||||||
"is-emoji-supported": "^0.0.5",
|
"is-emoji-supported": "^0.0.5",
|
||||||
@ -74,7 +72,7 @@
|
|||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
"prosemirror-codemark": "^0.4.2",
|
"prosemirror-codemark": "^0.4.2",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-markdown": "^0.8.10",
|
"tiptap-markdown": "^0.9.0",
|
||||||
"uuid": "catalog:",
|
"uuid": "catalog:",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"y-prosemirror": "^1.2.15",
|
"y-prosemirror": "^1.2.15",
|
||||||
@ -88,6 +86,7 @@
|
|||||||
"@types/node": "18.15.3",
|
"@types/node": "18.15.3",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
|
"@types/uuid": "^8.3.4",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tsdown": "catalog:",
|
"tsdown": "catalog:",
|
||||||
"typescript": "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: {
|
export type NodeFileMapType = Partial<
|
||||||
[key: string]: {
|
Record<
|
||||||
fileSetName: ExtensionFileSetStorageKey;
|
CORE_EXTENSIONS | ADDITIONAL_EXTENSIONS,
|
||||||
};
|
{
|
||||||
} = {
|
fileSetName: ExtensionFileSetStorageKey;
|
||||||
image: {
|
}
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const NODE_FILE_MAP: NodeFileMapType = {
|
||||||
|
[CORE_EXTENSIONS.IMAGE]: {
|
||||||
fileSetName: "deletedImageSet",
|
fileSetName: "deletedImageSet",
|
||||||
},
|
},
|
||||||
imageComponent: {
|
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
|
||||||
fileSetName: "deletedImageSet",
|
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
|
// 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 { 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">;
|
export type ExtensionFileSetStorageKey = Extract<keyof ImageExtensionStorage, "deletedImageSet">;
|
||||||
|
|||||||
@ -4,9 +4,6 @@ import { FC, useCallback, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
// components
|
// components
|
||||||
import { LinkView, LinkViewProps } from "@/components/links";
|
import { LinkView, LinkViewProps } from "@/components/links";
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
|
||||||
// components
|
|
||||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@ -22,7 +19,7 @@ export const LinkViewContainer: FC<Props> = ({ editor, containerRef }) => {
|
|||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
selector: ({ editor }: { editor: 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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import tippy, { Instance } from "tippy.js";
|
import tippy, { type Instance } from "tippy.js";
|
||||||
// plane utils
|
// plane utils
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// types
|
// types
|
||||||
import { TAIHandler } from "@/types";
|
import type { TAIHandler } from "@/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
menu: TAIHandler["menu"];
|
menu: TAIHandler["menu"];
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
useFloating,
|
useFloating,
|
||||||
|
autoUpdate,
|
||||||
offset,
|
offset,
|
||||||
flip,
|
flip,
|
||||||
shift,
|
shift,
|
||||||
autoUpdate,
|
|
||||||
useDismiss,
|
useDismiss,
|
||||||
useInteractions,
|
useInteractions,
|
||||||
FloatingPortal,
|
FloatingPortal,
|
||||||
@ -11,15 +11,16 @@ import {
|
|||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import { Copy, LucideIcon, Trash2 } from "lucide-react";
|
import { Copy, LucideIcon, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
// constants
|
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
|
// constants
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
import { IEditorProps } from "@/types";
|
// types
|
||||||
|
import type { IEditorProps } from "@/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
disabledExtensions?: IEditorProps["disabledExtensions"];
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
flaggedExtensions?: IEditorProps["flaggedExtensions"];
|
flaggedExtensions?: IEditorProps["flaggedExtensions"];
|
||||||
disabledExtensions?: IEditorProps["disabledExtensions"];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BlockMenu = (props: Props) => {
|
export const BlockMenu = (props: Props) => {
|
||||||
@ -74,15 +75,6 @@ export const BlockMenu = (props: Props) => {
|
|||||||
// Set the virtual reference as the reference element
|
// Set the virtual reference as the reference element
|
||||||
refs.setReference(virtualReferenceRef.current);
|
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
|
// Show the menu
|
||||||
openBlockMenu();
|
openBlockMenu();
|
||||||
return;
|
return;
|
||||||
@ -93,9 +85,10 @@ export const BlockMenu = (props: Props) => {
|
|||||||
closeBlockMenu();
|
closeBlockMenu();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editor, refs, openBlockMenu, closeBlockMenu]
|
[refs, openBlockMenu, closeBlockMenu]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
@ -106,10 +99,11 @@ export const BlockMenu = (props: Props) => {
|
|||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
closeBlockMenu();
|
closeBlockMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("click", handleClickDragHandle);
|
document.addEventListener("click", handleClickDragHandle);
|
||||||
document.addEventListener("contextmenu", handleClickDragHandle);
|
document.addEventListener("contextmenu", handleClickDragHandle);
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
document.addEventListener("scroll", handleScroll, true); // Using capture phase
|
document.addEventListener("scroll", handleScroll, true);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("click", handleClickDragHandle);
|
document.removeEventListener("click", handleClickDragHandle);
|
||||||
@ -200,6 +194,7 @@ export const BlockMenu = (props: Props) => {
|
|||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FloatingPortal>
|
<FloatingPortal>
|
||||||
<div
|
<div
|
||||||
@ -209,7 +204,6 @@ export const BlockMenu = (props: Props) => {
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
...floatingStyles,
|
...floatingStyles,
|
||||||
zIndex: 99,
|
|
||||||
animationFillMode: "forwards",
|
animationFillMode: "forwards",
|
||||||
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", // Expo ease out
|
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",
|
"transition-all duration-300 transform origin-top-right",
|
||||||
isAnimatedIn ? "opacity-100 scale-100" : "opacity-0 scale-75"
|
isAnimatedIn ? "opacity-100 scale-100" : "opacity-0 scale-75"
|
||||||
)}
|
)}
|
||||||
data-prevent-outside-click
|
|
||||||
{...getFloatingProps()}
|
{...getFloatingProps()}
|
||||||
>
|
>
|
||||||
{MENU_ITEMS.map((item) => {
|
{MENU_ITEMS.map((item) => {
|
||||||
if (item.isDisabled) {
|
if (item.isDisabled) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
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 { ALargeSmall, Ban } from "lucide-react";
|
||||||
import { Dispatch, FC, SetStateAction } from "react";
|
import { useMemo, type FC } from "react";
|
||||||
// plane utils
|
// plane utils
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// constants
|
// constants
|
||||||
import { COLORS_LIST } from "@/constants/common";
|
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 { BackgroundColorItem, TextColorItem } from "../menu-items";
|
||||||
import { EditorStateType } from "./root";
|
import { EditorStateType } from "./root";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
|
||||||
editorState: EditorStateType;
|
editorState: EditorStateType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BubbleMenuColorSelector: FC<Props> = (props) => {
|
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 activeTextColor = useMemo(() => editorState.color, [editorState.color]);
|
||||||
const activeBackgroundColor = editorState.backgroundColor;
|
const activeBackgroundColor = useMemo(() => editorState.backgroundColor, [editorState.backgroundColor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full">
|
<FloatingMenuRoot
|
||||||
<button
|
classNames={{
|
||||||
type="button"
|
buttonContainer: "h-full",
|
||||||
onClick={(e) => {
|
button:
|
||||||
setIsOpen(!isOpen);
|
"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",
|
||||||
e.stopPropagation();
|
}}
|
||||||
}}
|
menuButton={
|
||||||
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>Color</span>
|
<span
|
||||||
<span
|
className={cn(
|
||||||
className={cn(
|
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
|
||||||
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
|
{
|
||||||
{
|
"bg-custom-background-100": !activeBackgroundColor,
|
||||||
"bg-custom-background-100": !activeBackgroundColor,
|
}
|
||||||
}
|
)}
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ALargeSmall
|
|
||||||
className={cn("size-3.5", {
|
|
||||||
"text-custom-text-100": !activeTextColor,
|
|
||||||
})}
|
|
||||||
style={{
|
style={{
|
||||||
color: activeTextColor ? activeTextColor.textColor : "inherit",
|
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</span>
|
<ALargeSmall
|
||||||
</button>
|
className={cn("size-3.5", {
|
||||||
{isOpen && (
|
"text-custom-text-100": !activeTextColor,
|
||||||
<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">
|
style={{
|
||||||
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
|
color: activeTextColor ? activeTextColor.textColor : "inherit",
|
||||||
<div className="flex items-center gap-2">
|
}}
|
||||||
{COLORS_LIST.map((color) => (
|
/>
|
||||||
<button
|
</span>
|
||||||
key={color.key}
|
</>
|
||||||
type="button"
|
}
|
||||||
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
options={options}
|
||||||
style={{
|
getFloatingProps={getFloatingProps}
|
||||||
backgroundColor: color.textColor,
|
getReferenceProps={getReferenceProps}
|
||||||
}}
|
>
|
||||||
onClick={() => TextColorItem(editor).command({ color: color.key })}
|
<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
|
<button
|
||||||
|
key={color.key}
|
||||||
type="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"
|
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||||
onClick={() => TextColorItem(editor).command({ color: undefined })}
|
style={{
|
||||||
>
|
backgroundColor: color.textColor,
|
||||||
<Ban className="size-4" />
|
}}
|
||||||
</button>
|
onClick={() => TextColorItem(editor).command({ color: color.key })}
|
||||||
</div>
|
/>
|
||||||
|
))}
|
||||||
|
<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>
|
||||||
<div className="space-y-1.5">
|
</div>
|
||||||
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
|
||||||
{COLORS_LIST.map((color) => (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{COLORS_LIST.map((color) => (
|
||||||
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 })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<button
|
<button
|
||||||
|
key={color.key}
|
||||||
type="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"
|
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||||
onClick={() => BackgroundColorItem(editor).command({ color: undefined })}
|
style={{
|
||||||
>
|
backgroundColor: color.backgroundColor,
|
||||||
<Ban className="size-4" />
|
}}
|
||||||
</button>
|
onClick={() => BackgroundColorItem(editor).command({ color: color.key })}
|
||||||
</div>
|
/>
|
||||||
|
))}
|
||||||
|
<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>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</div>
|
</FloatingMenuRoot>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
export * from "./color-selector";
|
export * from "./color-selector";
|
||||||
export * from "./link-selector";
|
|
||||||
export * from "./node-selector";
|
export * from "./node-selector";
|
||||||
export * from "./root";
|
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 { Check, Link, Trash2 } from "lucide-react";
|
||||||
import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react";
|
import { FC, useCallback, useRef, useState } from "react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// constants
|
// constants
|
||||||
@ -8,17 +8,20 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
|
|||||||
// helpers
|
// helpers
|
||||||
import { isValidHttpUrl } from "@/helpers/common";
|
import { isValidHttpUrl } from "@/helpers/common";
|
||||||
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
||||||
|
import { FloatingMenuRoot } from "../floating-menu/root";
|
||||||
|
import { useFloatingMenu } from "../floating-menu/use-floating-menu";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
||||||
const { editor, isOpen, setIsOpen } = props;
|
const { editor } = props;
|
||||||
// states
|
// states
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
// floating ui
|
||||||
|
const { options, getReferenceProps, getFloatingProps } = useFloatingMenu({});
|
||||||
|
const { context } = options;
|
||||||
// refs
|
// refs
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@ -30,88 +33,89 @@ export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
|||||||
const { isValid, url: validatedUrl } = isValidHttpUrl(url);
|
const { isValid, url: validatedUrl } = isValidHttpUrl(url);
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
setLinkEditor(editor, validatedUrl);
|
setLinkEditor(editor, validatedUrl);
|
||||||
setIsOpen(false);
|
context.onOpenChange(false);
|
||||||
setError(false);
|
setError(false);
|
||||||
} else {
|
} else {
|
||||||
setError(true);
|
setError(true);
|
||||||
}
|
}
|
||||||
}, [editor, inputRef, setIsOpen]);
|
}, [editor, inputRef, context]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full">
|
<FloatingMenuRoot
|
||||||
<button
|
classNames={{
|
||||||
type="button"
|
buttonContainer: "h-full",
|
||||||
className={cn(
|
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 transition-colors",
|
"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),
|
"text-custom-text-100": editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK),
|
||||||
}
|
}
|
||||||
)}
|
),
|
||||||
onClick={(e) => {
|
}}
|
||||||
setIsOpen(!isOpen);
|
getFloatingProps={getFloatingProps}
|
||||||
e.stopPropagation();
|
getReferenceProps={getReferenceProps}
|
||||||
}}
|
menuButton={
|
||||||
>
|
<>
|
||||||
Link
|
Link
|
||||||
<Link className="flex-shrink-0 size-3" />
|
<Link className="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">
|
options={options}
|
||||||
<div
|
>
|
||||||
className={cn("flex rounded border border-custom-border-300 transition-colors", {
|
<div className="w-60 mt-1 rounded-md bg-custom-background-100 shadow-custom-shadow-rg">
|
||||||
"border-red-500": error,
|
<div
|
||||||
})}
|
className={cn("flex rounded border-[0.5px] border-custom-border-300 transition-colors", {
|
||||||
>
|
"border-red-500": error,
|
||||||
<input
|
})}
|
||||||
ref={inputRef}
|
>
|
||||||
type="url"
|
<input
|
||||||
placeholder="Enter or paste a link"
|
ref={inputRef}
|
||||||
onClick={(e) => e.stopPropagation()}
|
type="url"
|
||||||
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"
|
placeholder="Enter or paste a link"
|
||||||
defaultValue={editor.getAttributes("link").href || ""}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => {
|
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"
|
||||||
setError(false);
|
defaultValue={editor.getAttributes("link").href || ""}
|
||||||
if (e.key === "Enter") {
|
onKeyDown={(e) => {
|
||||||
e.preventDefault();
|
setError(false);
|
||||||
handleLinkSubmit();
|
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
|
<Trash2 className="size-4" />
|
||||||
/>
|
</button>
|
||||||
{editor.getAttributes("link").href ? (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="grid place-items-center rounded-sm p-1 text-red-500 hover:bg-red-500/20 transition-all"
|
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) => {
|
onClick={(e) => {
|
||||||
unsetLinkEditor(editor);
|
e.stopPropagation();
|
||||||
setIsOpen(false);
|
handleLinkSubmit();
|
||||||
e.stopPropagation();
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Check className="size-4" />
|
||||||
<Trash2 className="size-4" />
|
</button>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{error && (
|
||||||
</div>
|
<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 { Editor } from "@tiptap/react";
|
||||||
import { Check, ChevronDown } from "lucide-react";
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
import { Dispatch, FC, SetStateAction } from "react";
|
import { FC } from "react";
|
||||||
// plane utils
|
// plane utils
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// components
|
// components
|
||||||
@ -20,17 +20,20 @@ import {
|
|||||||
EditorMenuItem,
|
EditorMenuItem,
|
||||||
} from "@/components/menus";
|
} from "@/components/menus";
|
||||||
// types
|
// 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 = {
|
type Props = {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
|
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>[] = [
|
const items: EditorMenuItem<TEditorCommands>[] = [
|
||||||
TextItem(editor),
|
TextItem(editor),
|
||||||
HeadingOneItem(editor),
|
HeadingOneItem(editor),
|
||||||
@ -44,52 +47,58 @@ export const BubbleMenuNodeSelector: FC<Props> = (props) => {
|
|||||||
TodoListItem(editor),
|
TodoListItem(editor),
|
||||||
QuoteItem(editor),
|
QuoteItem(editor),
|
||||||
CodeItem(editor),
|
CodeItem(editor),
|
||||||
];
|
] as EditorMenuItem<TEditorCommands>[];
|
||||||
|
|
||||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||||
name: "Multiple",
|
name: "Multiple",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full">
|
<FloatingMenuRoot
|
||||||
<button
|
classNames={{
|
||||||
type="button"
|
buttonContainer: "h-full",
|
||||||
onClick={(e) => {
|
button: cn(
|
||||||
setIsOpen(!isOpen);
|
"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",
|
||||||
e.stopPropagation();
|
{
|
||||||
}}
|
"bg-custom-background-80": context.open,
|
||||||
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" />
|
menuButton={
|
||||||
</button>
|
<>
|
||||||
{isOpen && (
|
<span>{activeItem?.name}</span>
|
||||||
<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">
|
<ChevronDown className="shrink-0 size-3" />
|
||||||
{items.map((item) => (
|
</>
|
||||||
<button
|
}
|
||||||
key={item.name}
|
options={options}
|
||||||
type="button"
|
getFloatingProps={getFloatingProps}
|
||||||
onClick={(e) => {
|
getReferenceProps={getReferenceProps}
|
||||||
item.command();
|
>
|
||||||
setIsOpen(false);
|
<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">
|
||||||
e.stopPropagation();
|
{items.map((item) => (
|
||||||
}}
|
<button
|
||||||
className={cn(
|
key={item.name}
|
||||||
"flex items-center justify-between rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80",
|
type="button"
|
||||||
{
|
onClick={(e) => {
|
||||||
"bg-custom-background-80": activeItem.name === item.name,
|
item.command();
|
||||||
}
|
context.onOpenChange(false);
|
||||||
)}
|
e.stopPropagation();
|
||||||
>
|
}}
|
||||||
<div className="flex items-center space-x-2">
|
className={cn(
|
||||||
<item.icon className="size-3 flex-shrink-0" />
|
"flex items-center justify-between rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80",
|
||||||
<span>{item.name}</span>
|
{
|
||||||
</div>
|
"bg-custom-background-80": activeItem.name === item.name,
|
||||||
{activeItem.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
|
}
|
||||||
</button>
|
)}
|
||||||
))}
|
>
|
||||||
</section>
|
<div className="flex items-center space-x-2">
|
||||||
)}
|
<item.icon className="size-3 flex-shrink-0" />
|
||||||
</div>
|
<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";
|
import { FC, useEffect, useState, useRef } from "react";
|
||||||
// plane utils
|
// plane utils
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
@ -7,7 +9,6 @@ import {
|
|||||||
BackgroundColorItem,
|
BackgroundColorItem,
|
||||||
BoldItem,
|
BoldItem,
|
||||||
BubbleMenuColorSelector,
|
BubbleMenuColorSelector,
|
||||||
BubbleMenuLinkSelector,
|
|
||||||
BubbleMenuNodeSelector,
|
BubbleMenuNodeSelector,
|
||||||
CodeItem,
|
CodeItem,
|
||||||
EditorMenuItem,
|
EditorMenuItem,
|
||||||
@ -23,9 +24,10 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
|
|||||||
// extensions
|
// extensions
|
||||||
import { isCellSelection } from "@/extensions/table/table/utilities/helpers";
|
import { isCellSelection } from "@/extensions/table/table/utilities/helpers";
|
||||||
// types
|
// types
|
||||||
import { TEditorCommands } from "@/types";
|
import type { TEditorCommands } from "@/types";
|
||||||
// local imports
|
// local imports
|
||||||
import { TextAlignmentSelector } from "./alignment-selector";
|
import { TextAlignmentSelector } from "./alignment-selector";
|
||||||
|
import { BubbleMenuLinkSelector } from "./link-selector";
|
||||||
|
|
||||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||||
|
|
||||||
@ -38,7 +40,14 @@ export type EditorStateType = {
|
|||||||
left: boolean;
|
left: boolean;
|
||||||
right: boolean;
|
right: boolean;
|
||||||
center: boolean;
|
center: boolean;
|
||||||
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
|
color:
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
textColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
| {
|
| {
|
||||||
key: string;
|
key: string;
|
||||||
@ -49,27 +58,31 @@ export type EditorStateType = {
|
|||||||
| undefined;
|
| undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
|
type Props = {
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
editor: Editor;
|
||||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
};
|
||||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
|
||||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
export const EditorBubbleMenu: FC<Props> = (props) => {
|
||||||
|
const { editor } = props;
|
||||||
|
// states
|
||||||
const [isSelecting, setIsSelecting] = useState(false);
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
|
// refs
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const formattingItems = {
|
const formattingItems = {
|
||||||
code: CodeItem(props.editor),
|
code: CodeItem(editor),
|
||||||
bold: BoldItem(props.editor),
|
bold: BoldItem(editor),
|
||||||
italic: ItalicItem(props.editor),
|
italic: ItalicItem(editor),
|
||||||
underline: UnderLineItem(props.editor),
|
underline: UnderLineItem(editor),
|
||||||
strikethrough: StrikeThroughItem(props.editor),
|
strikethrough: StrikeThroughItem(editor),
|
||||||
"text-align": TextAlignItem(props.editor),
|
"text-align": TextAlignItem(editor),
|
||||||
} satisfies {
|
} satisfies {
|
||||||
[K in TEditorCommands]?: EditorMenuItem<K>;
|
[K in TEditorCommands]?: EditorMenuItem<K>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const editorState: EditorStateType = useEditorState({
|
const editorState: EditorStateType = useEditorState({
|
||||||
editor: props.editor,
|
editor,
|
||||||
selector: ({ editor }: { editor: Editor }) => ({
|
selector: ({ editor }) => ({
|
||||||
code: formattingItems.code.isActive(),
|
code: formattingItems.code.isActive(),
|
||||||
bold: formattingItems.bold.isActive(),
|
bold: formattingItems.bold.isActive(),
|
||||||
italic: formattingItems.italic.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];
|
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
|
||||||
|
|
||||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||||
...props,
|
editor,
|
||||||
shouldShow: ({ state, editor }) => {
|
shouldShow: ({ state, editor }) => {
|
||||||
const { selection } = state;
|
const { selection } = state;
|
||||||
const { empty } = selection;
|
const { empty } = selection;
|
||||||
@ -106,20 +119,28 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
tippyOptions: {
|
options: {
|
||||||
moveTransition: "transform 0.15s ease-out",
|
|
||||||
duration: [300, 0],
|
|
||||||
zIndex: 9,
|
|
||||||
onShow: () => {
|
onShow: () => {
|
||||||
props.editor.storage.link.isBubbleMenuOpen = true;
|
if (editor.storage.link) {
|
||||||
|
editor.storage.link.isBubbleMenuOpen = true;
|
||||||
|
}
|
||||||
|
editor.commands.addActiveDropbarExtension("bubble-menu");
|
||||||
},
|
},
|
||||||
onHidden: () => {
|
onHide: () => {
|
||||||
props.editor.storage.link.isBubbleMenuOpen = false;
|
if (editor.storage.link) {
|
||||||
setIsNodeSelectorOpen(false);
|
editor.storage.link.isBubbleMenuOpen = false;
|
||||||
setIsLinkSelectorOpen(false);
|
}
|
||||||
setIsColorSelectorOpen(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(() => {
|
useEffect(() => {
|
||||||
@ -127,7 +148,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
|||||||
if (menuRef.current?.contains(e.target as Node)) return;
|
if (menuRef.current?.contains(e.target as Node)) return;
|
||||||
|
|
||||||
function handleMouseMove() {
|
function handleMouseMove() {
|
||||||
if (!props.editor.state.selection.empty) {
|
if (!editor.state.selection.empty) {
|
||||||
setIsSelecting(true);
|
setIsSelecting(true);
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
}
|
}
|
||||||
@ -148,7 +169,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
document.removeEventListener("mousedown", handleMouseDown);
|
||||||
};
|
};
|
||||||
}, [props.editor]);
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu {...bubbleMenuProps}>
|
<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"
|
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">
|
<div className="px-2">
|
||||||
<BubbleMenuNodeSelector
|
<BubbleMenuNodeSelector editor={editor} />
|
||||||
editor={props.editor!}
|
|
||||||
isOpen={isNodeSelectorOpen}
|
|
||||||
setIsOpen={() => {
|
|
||||||
setIsNodeSelectorOpen((prev) => !prev);
|
|
||||||
setIsLinkSelectorOpen(false);
|
|
||||||
setIsColorSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{!editorState.code && (
|
{!editorState.code && (
|
||||||
<div className="px-2">
|
<div className="px-2">
|
||||||
<BubbleMenuLinkSelector
|
<BubbleMenuLinkSelector editor={editor} />
|
||||||
editor={props.editor}
|
|
||||||
isOpen={isLinkSelectorOpen}
|
|
||||||
setIsOpen={() => {
|
|
||||||
setIsLinkSelectorOpen((prev) => !prev);
|
|
||||||
setIsNodeSelectorOpen(false);
|
|
||||||
setIsColorSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!editorState.code && (
|
{!editorState.code && (
|
||||||
<div className="px-2">
|
<div className="px-2">
|
||||||
<BubbleMenuColorSelector
|
<BubbleMenuColorSelector editor={editor} editorState={editorState} />
|
||||||
editor={props.editor}
|
|
||||||
isOpen={isColorSelectorOpen}
|
|
||||||
editorState={editorState}
|
|
||||||
setIsOpen={() => {
|
|
||||||
setIsColorSelectorOpen((prev) => !prev);
|
|
||||||
setIsNodeSelectorOpen(false);
|
|
||||||
setIsLinkSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-0.5 px-2">
|
<div className="flex gap-0.5 px-2">
|
||||||
@ -215,7 +211,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<TextAlignmentSelector editor={props.editor} editorState={editorState} />
|
<TextAlignmentSelector editor={editor} editorState={editorState} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</BubbleMenu>
|
</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 {
|
import {
|
||||||
BoldIcon,
|
BoldIcon,
|
||||||
Heading1,
|
Heading1,
|
||||||
@ -18,7 +18,7 @@ import {
|
|||||||
Heading5,
|
Heading5,
|
||||||
Heading6,
|
Heading6,
|
||||||
CaseSensitive,
|
CaseSensitive,
|
||||||
LucideIcon,
|
type LucideIcon,
|
||||||
MinusSquare,
|
MinusSquare,
|
||||||
Palette,
|
Palette,
|
||||||
AlignCenter,
|
AlignCenter,
|
||||||
@ -70,7 +70,7 @@ export const TextItem = (editor: Editor): EditorMenuItem<"text"> => ({
|
|||||||
icon: CaseSensitive,
|
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>(
|
const HeadingItem = <T extends SupportedHeadingLevels>(
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
@ -274,5 +274,5 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
|
|||||||
TextColorItem(editor),
|
TextColorItem(editor),
|
||||||
BackgroundColorItem(editor),
|
BackgroundColorItem(editor),
|
||||||
TextAlignItem(editor),
|
TextAlignItem(editor),
|
||||||
];
|
] as EditorMenuItem<TEditorCommands>[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { COLORS_LIST } from "@/constants/common";
|
|||||||
import { CalloutBlockColorSelector } from "./color-selector";
|
import { CalloutBlockColorSelector } from "./color-selector";
|
||||||
import { CalloutBlockLogoSelector } from "./logo-selector";
|
import { CalloutBlockLogoSelector } from "./logo-selector";
|
||||||
// types
|
// types
|
||||||
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
|
import { ECalloutAttributeNames, TCalloutBlockAttributes } from "./types";
|
||||||
// utils
|
// utils
|
||||||
import { updateStoredBackgroundColor } from "./utils";
|
import { updateStoredBackgroundColor } from "./utils";
|
||||||
|
|
||||||
@ -45,7 +45,7 @@ export const CustomCalloutBlock: React.FC<CustomCalloutNodeViewProps> = (props)
|
|||||||
toggleDropdown={() => setIsColorPickerOpen((prev) => !prev)}
|
toggleDropdown={() => setIsColorPickerOpen((prev) => !prev)}
|
||||||
onSelect={(val) => {
|
onSelect={(val) => {
|
||||||
updateAttributes({
|
updateAttributes({
|
||||||
[EAttributeNames.BACKGROUND]: val,
|
[ECalloutAttributeNames.BACKGROUND]: val,
|
||||||
});
|
});
|
||||||
updateStoredBackgroundColor(val);
|
updateStoredBackgroundColor(val);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
|
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
|
||||||
import { Node as NodeType } from "@tiptap/pm/model";
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
// constants
|
// constants
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
// types
|
// types
|
||||||
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
|
import { type CustomCalloutExtensionType, ECalloutAttributeNames, type TCalloutBlockAttributes } from "./types";
|
||||||
// utils
|
// utils
|
||||||
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
|
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
|
||||||
|
|
||||||
// Extend Tiptap's Commands interface
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
[CORE_EXTENSIONS.CALLOUT]: {
|
[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,
|
name: CORE_EXTENSIONS.CALLOUT,
|
||||||
group: "block",
|
group: "block",
|
||||||
content: "block+",
|
content: "block+",
|
||||||
@ -25,20 +24,24 @@ export const CustomCalloutExtensionConfig = Node.create({
|
|||||||
addAttributes() {
|
addAttributes() {
|
||||||
const attributes = {
|
const attributes = {
|
||||||
// Reduce instead of map to accumulate the attributes directly into an object
|
// Reduce instead of map to accumulate the attributes directly into an object
|
||||||
...Object.values(EAttributeNames).reduce((acc, value) => {
|
...Object.values(ECalloutAttributeNames).reduce(
|
||||||
acc[value] = {
|
(acc, value) => {
|
||||||
default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value],
|
acc[value] = {
|
||||||
};
|
default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value],
|
||||||
return acc;
|
};
|
||||||
}, {}),
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<ECalloutAttributeNames, { default: TCalloutBlockAttributes[ECalloutAttributeNames] }>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return attributes;
|
return attributes;
|
||||||
},
|
},
|
||||||
|
|
||||||
addStorage() {
|
addStorage() {
|
||||||
return {
|
return {
|
||||||
markdown: {
|
markdown: {
|
||||||
serialize(state: MarkdownSerializerState, node: NodeType) {
|
serialize(state: MarkdownSerializerState, node: ProseMirrorNode) {
|
||||||
const attrs = node.attrs as TCalloutBlockAttributes;
|
const attrs = node.attrs as TCalloutBlockAttributes;
|
||||||
const logoInUse = attrs["data-logo-in-use"];
|
const logoInUse = attrs["data-logo-in-use"];
|
||||||
// add callout logo
|
// add callout logo
|
||||||
@ -62,7 +65,7 @@ export const CustomCalloutExtensionConfig = Node.create({
|
|||||||
parseHTML() {
|
parseHTML() {
|
||||||
return [
|
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";
|
import { findParentNodeClosestToPos, type Predicate, ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
// extensions
|
// constants
|
||||||
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout/block";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
// helpers
|
// helpers
|
||||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||||
// config
|
// local imports
|
||||||
|
import { CustomCalloutBlock, type CustomCalloutNodeViewProps } from "./block";
|
||||||
import { CustomCalloutExtensionConfig } from "./extension-config";
|
import { CustomCalloutExtensionConfig } from "./extension-config";
|
||||||
// utils
|
import type { CustomCalloutExtensionOptions, CustomCalloutExtensionStorage } from "./types";
|
||||||
import { getStoredBackgroundColor, getStoredLogo } from "./utils";
|
import { getStoredBackgroundColor, getStoredLogo } from "./utils";
|
||||||
|
|
||||||
export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
|
export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend<
|
||||||
|
CustomCalloutExtensionOptions,
|
||||||
|
CustomCalloutExtensionStorage
|
||||||
|
>({
|
||||||
selectable: true,
|
selectable: true,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
|
|
||||||
@ -25,7 +29,7 @@ export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
|
|||||||
type: this.name,
|
type: this.name,
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "paragraph",
|
type: CORE_EXTENSIONS.PARAGRAPH,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
attrs: {
|
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_COLOR = "data-icon-color",
|
||||||
ICON_NAME = "data-icon-name",
|
ICON_NAME = "data-icon-name",
|
||||||
EMOJI_UNICODE = "data-emoji-unicode",
|
EMOJI_UNICODE = "data-emoji-unicode",
|
||||||
@ -9,18 +11,23 @@ export enum EAttributeNames {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TCalloutBlockIconAttributes = {
|
export type TCalloutBlockIconAttributes = {
|
||||||
[EAttributeNames.ICON_COLOR]: string | undefined;
|
[ECalloutAttributeNames.ICON_COLOR]: string | undefined;
|
||||||
[EAttributeNames.ICON_NAME]: string | undefined;
|
[ECalloutAttributeNames.ICON_NAME]: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCalloutBlockEmojiAttributes = {
|
export type TCalloutBlockEmojiAttributes = {
|
||||||
[EAttributeNames.EMOJI_UNICODE]: string | undefined;
|
[ECalloutAttributeNames.EMOJI_UNICODE]: string | undefined;
|
||||||
[EAttributeNames.EMOJI_URL]: string | undefined;
|
[ECalloutAttributeNames.EMOJI_URL]: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCalloutBlockAttributes = {
|
export type TCalloutBlockAttributes = {
|
||||||
[EAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
|
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
|
||||||
[EAttributeNames.BACKGROUND]: string | undefined;
|
[ECalloutAttributeNames.BACKGROUND]: string | undefined;
|
||||||
[EAttributeNames.BLOCK_TYPE]: "callout-component";
|
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component";
|
||||||
} & TCalloutBlockIconAttributes &
|
} & TCalloutBlockIconAttributes &
|
||||||
TCalloutBlockEmojiAttributes;
|
TCalloutBlockEmojiAttributes;
|
||||||
|
|
||||||
|
export type CustomCalloutExtensionOptions = unknown;
|
||||||
|
export type CustomCalloutExtensionStorage = unknown;
|
||||||
|
|
||||||
|
export type CustomCalloutExtensionType = ProseMirrorNode<CustomCalloutExtensionOptions, CustomCalloutExtensionStorage>;
|
||||||
|
|||||||
@ -1,33 +1,33 @@
|
|||||||
// plane imports
|
// plane imports
|
||||||
import { TEmojiLogoProps } from "@plane/ui";
|
import type { TEmojiLogoProps } from "@plane/ui";
|
||||||
import { sanitizeHTML } from "@plane/utils";
|
import { sanitizeHTML } from "@plane/utils";
|
||||||
// types
|
// types
|
||||||
import {
|
import {
|
||||||
EAttributeNames,
|
ECalloutAttributeNames,
|
||||||
TCalloutBlockAttributes,
|
TCalloutBlockAttributes,
|
||||||
TCalloutBlockEmojiAttributes,
|
TCalloutBlockEmojiAttributes,
|
||||||
TCalloutBlockIconAttributes,
|
TCalloutBlockIconAttributes,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
|
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
|
||||||
"data-logo-in-use": "emoji",
|
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji",
|
||||||
"data-icon-color": undefined,
|
[ECalloutAttributeNames.ICON_COLOR]: undefined,
|
||||||
"data-icon-name": undefined,
|
[ECalloutAttributeNames.ICON_NAME]: undefined,
|
||||||
"data-emoji-unicode": "128161",
|
[ECalloutAttributeNames.EMOJI_UNICODE]: "128161",
|
||||||
"data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
|
[ECalloutAttributeNames.EMOJI_URL]: "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
|
||||||
"data-background": undefined,
|
[ECalloutAttributeNames.BACKGROUND]: undefined,
|
||||||
"data-block-type": "callout-component",
|
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component",
|
||||||
};
|
};
|
||||||
|
|
||||||
type TStoredLogoValue = Pick<TCalloutBlockAttributes, EAttributeNames.LOGO_IN_USE> &
|
type TStoredLogoValue = Pick<TCalloutBlockAttributes, ECalloutAttributeNames.LOGO_IN_USE> &
|
||||||
(TCalloutBlockEmojiAttributes | TCalloutBlockIconAttributes);
|
(TCalloutBlockEmojiAttributes | TCalloutBlockIconAttributes);
|
||||||
|
|
||||||
// function to get the stored logo from local storage
|
// function to get the stored logo from local storage
|
||||||
export const getStoredLogo = (): TStoredLogoValue => {
|
export const getStoredLogo = (): TStoredLogoValue => {
|
||||||
const fallBackValues: TStoredLogoValue = {
|
const fallBackValues: TStoredLogoValue = {
|
||||||
"data-logo-in-use": "emoji",
|
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji",
|
||||||
"data-emoji-unicode": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
|
[ECalloutAttributeNames.EMOJI_UNICODE]: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.EMOJI_UNICODE],
|
||||||
"data-emoji-url": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
|
[ECalloutAttributeNames.EMOJI_URL]: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[ECalloutAttributeNames.EMOJI_URL],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@ -43,16 +43,20 @@ export const getStoredLogo = (): TStoredLogoValue => {
|
|||||||
}
|
}
|
||||||
if (parsedData.in_use === "emoji" && parsedData.emoji?.value) {
|
if (parsedData.in_use === "emoji" && parsedData.emoji?.value) {
|
||||||
return {
|
return {
|
||||||
"data-logo-in-use": "emoji",
|
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji",
|
||||||
"data-emoji-unicode": parsedData.emoji.value || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
|
[ECalloutAttributeNames.EMOJI_UNICODE]:
|
||||||
"data-emoji-url": parsedData.emoji.url || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
|
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) {
|
if (parsedData.in_use === "icon" && parsedData.icon?.name) {
|
||||||
return {
|
return {
|
||||||
"data-logo-in-use": "icon",
|
[ECalloutAttributeNames.LOGO_IN_USE]: "icon",
|
||||||
"data-icon-name": parsedData.icon.name || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-name"],
|
[ECalloutAttributeNames.ICON_NAME]:
|
||||||
"data-icon-color": parsedData.icon.color || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-color"],
|
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>({
|
export const CodeBlockLowlight = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...(this.parent?.() ?? {
|
||||||
|
languageClassPrefix: "language-",
|
||||||
|
exitOnTripleEnter: true,
|
||||||
|
exitOnArrowDown: true,
|
||||||
|
HTMLAttributes: {},
|
||||||
|
}),
|
||||||
lowlight: {},
|
lowlight: {},
|
||||||
defaultLanguage: null,
|
defaultLanguage: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export const CodeBlockComponent: React.FC<Props> = ({ node }) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
|
<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>
|
</pre>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import TextStyle from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import TiptapUnderline from "@tiptap/extension-underline";
|
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
// helpers
|
// helpers
|
||||||
import { isValidHttpUrl } from "@/helpers/common";
|
import { isValidHttpUrl } from "@/helpers/common";
|
||||||
@ -76,7 +75,6 @@ export const CoreEditorExtensionsWithoutProps = [
|
|||||||
}),
|
}),
|
||||||
ImageExtensionConfig,
|
ImageExtensionConfig,
|
||||||
CustomImageExtensionConfig,
|
CustomImageExtensionConfig,
|
||||||
TiptapUnderline,
|
|
||||||
TextStyle,
|
TextStyle,
|
||||||
TaskList.configure({
|
TaskList.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
|
|||||||
@ -2,10 +2,6 @@ import { NodeSelection } from "@tiptap/pm/state";
|
|||||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// constants
|
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
|
||||||
// helpers
|
|
||||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
|
||||||
// local imports
|
// local imports
|
||||||
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
|
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
|
||||||
import { ensurePixelString, getImageBlockId } from "../utils";
|
import { ensurePixelString, getImageBlockId } from "../utils";
|
||||||
@ -62,7 +58,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
|||||||
const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);
|
const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false);
|
||||||
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
|
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
|
||||||
// extension options
|
// extension options
|
||||||
const isTouchDevice = !!getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;
|
const isTouchDevice = !!editor.storage.utility.isTouchDevice;
|
||||||
|
|
||||||
const updateAttributesSafely = useCallback(
|
const updateAttributesSafely = useCallback(
|
||||||
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
|
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
|
||||||
@ -199,6 +195,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
|||||||
editor.commands.blur();
|
editor.commands.blur();
|
||||||
}
|
}
|
||||||
const pos = getPos();
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
|
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
|
||||||
editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));
|
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";
|
import { useEffect, useRef, useState } from "react";
|
||||||
// constants
|
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
|
||||||
// helpers
|
|
||||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
|
||||||
// local imports
|
// local imports
|
||||||
import type { CustomImageExtension, TCustomImageAttributes } from "../types";
|
import type { CustomImageExtensionType, TCustomImageAttributes } from "../types";
|
||||||
import { CustomImageBlock } from "./block";
|
import { CustomImageBlock } from "./block";
|
||||||
import { CustomImageUploader } from "./uploader";
|
import { CustomImageUploader } from "./uploader";
|
||||||
|
|
||||||
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
|
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
|
||||||
extension: CustomImageExtension;
|
extension: CustomImageExtensionType;
|
||||||
getPos: () => number;
|
|
||||||
editor: Editor;
|
|
||||||
node: NodeViewProps["node"] & {
|
node: NodeViewProps["node"] & {
|
||||||
attrs: TCustomImageAttributes;
|
attrs: TCustomImageAttributes;
|
||||||
};
|
};
|
||||||
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
|
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
|
||||||
selected: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
|
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||||
@ -84,7 +77,7 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
|
|||||||
<CustomImageUploader
|
<CustomImageUploader
|
||||||
failedToLoadImage={failedToLoadImage}
|
failedToLoadImage={failedToLoadImage}
|
||||||
loadImageFromFileSystem={setImageFromFileSystem}
|
loadImageFromFileSystem={setImageFromFileSystem}
|
||||||
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
|
maxFileSize={editor.storage.imageComponent?.maxFileSize}
|
||||||
setIsUploaded={setIsUploaded}
|
setIsUploaded={setIsUploaded}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { useEditorState } from "@tiptap/react";
|
import { useEditorState } from "@tiptap/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
// constants
|
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
|
||||||
// helpers
|
|
||||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@ -20,7 +16,7 @@ export const ImageUploadStatus: React.FC<Props> = (props) => {
|
|||||||
// subscribe to image upload status
|
// subscribe to image upload status
|
||||||
const uploadStatus: number | undefined = useEditorState({
|
const uploadStatus: number | undefined = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
selector: ({ editor }) => getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsUploadStatus?.[nodeId],
|
selector: ({ editor }) => editor.storage.utility?.assetsUploadStatus?.[nodeId],
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
|||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
// helpers
|
// helpers
|
||||||
import { EFileError } from "@/helpers/file";
|
import { EFileError } from "@/helpers/file";
|
||||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
|
||||||
// hooks
|
// hooks
|
||||||
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||||
// local imports
|
// local imports
|
||||||
@ -40,7 +39,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
const { id: imageEntityId } = node.attrs;
|
const { id: imageEntityId } = node.attrs;
|
||||||
// derived values
|
// derived values
|
||||||
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
|
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
|
||||||
const isTouchDevice = !!getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;
|
const isTouchDevice = !!editor.storage.utility.isTouchDevice;
|
||||||
|
|
||||||
const onUpload = useCallback(
|
const onUpload = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
@ -60,7 +59,12 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
|
|
||||||
// only if the cursor is at the current image component, manipulate
|
// only if the cursor is at the current image component, manipulate
|
||||||
// the cursor position
|
// 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
|
// control cursor position after upload
|
||||||
const nextNode = editor.state.doc.nodeAt(pos + 1);
|
const nextNode = editor.state.doc.nodeAt(pos + 1);
|
||||||
|
|
||||||
@ -85,7 +89,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
|
|
||||||
const handleProgressStatus = useCallback(
|
const handleProgressStatus = useCallback(
|
||||||
(isUploading: boolean) => {
|
(isUploading: boolean) => {
|
||||||
getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading;
|
editor.storage.utility.uploadInProgress = isUploading;
|
||||||
},
|
},
|
||||||
[editor]
|
[editor]
|
||||||
);
|
);
|
||||||
@ -107,7 +111,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
|
|
||||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
|
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
|
||||||
editor,
|
editor,
|
||||||
pos: getPos(),
|
getPos,
|
||||||
type: "image",
|
type: "image",
|
||||||
uploader: uploadFile,
|
uploader: uploadFile,
|
||||||
});
|
});
|
||||||
@ -139,13 +143,14 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
|||||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const filesList = e.target.files;
|
const filesList = e.target.files;
|
||||||
if (!filesList) {
|
const pos = getPos();
|
||||||
|
if (!filesList || pos === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await uploadFirstFileAndInsertRemaining({
|
await uploadFirstFileAndInsertRemaining({
|
||||||
editor,
|
editor,
|
||||||
filesList,
|
filesList,
|
||||||
pos: getPos(),
|
pos,
|
||||||
type: "image",
|
type: "image",
|
||||||
uploader: uploadFile,
|
uploader: uploadFile,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,7 +3,14 @@ import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
|||||||
// constants
|
// constants
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
// local imports
|
// 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";
|
import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils";
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
@ -12,9 +19,15 @@ declare module "@tiptap/core" {
|
|||||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
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,
|
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||||
group: "block",
|
group: "block",
|
||||||
atom: true,
|
atom: true,
|
||||||
@ -22,12 +35,15 @@ export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtensi
|
|||||||
addAttributes() {
|
addAttributes() {
|
||||||
const attributes = {
|
const attributes = {
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
...Object.values(ECustomImageAttributeNames).reduce((acc, value) => {
|
...Object.values(ECustomImageAttributeNames).reduce(
|
||||||
acc[value] = {
|
(acc, value) => {
|
||||||
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
|
acc[value] = {
|
||||||
};
|
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
|
||||||
return acc;
|
};
|
||||||
}, {}),
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<ECustomImageAttributeNames, { default: TCustomImageAttributes[ECustomImageAttributeNames] }>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return attributes;
|
return attributes;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { TFileHandler } from "@/types";
|
|||||||
// local imports
|
// local imports
|
||||||
import { CustomImageNodeView, CustomImageNodeViewProps } from "./components/node-view";
|
import { CustomImageNodeView, CustomImageNodeViewProps } from "./components/node-view";
|
||||||
import { CustomImageExtensionConfig } from "./extension-config";
|
import { CustomImageExtensionConfig } from "./extension-config";
|
||||||
|
import type { CustomImageExtensionOptions, CustomImageExtensionStorage } from "./types";
|
||||||
import { getImageComponentImageFileMap } from "./utils";
|
import { getImageComponentImageFileMap } from "./utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -22,7 +23,7 @@ export const CustomImageExtension = (props: Props) => {
|
|||||||
// derived values
|
// derived values
|
||||||
const { getAssetSrc, getAssetDownloadSrc, restore: restoreImageFn } = fileHandler;
|
const { getAssetSrc, getAssetDownloadSrc, restore: restoreImageFn } = fileHandler;
|
||||||
|
|
||||||
return CustomImageExtensionConfig.extend({
|
return CustomImageExtensionConfig.extend<CustomImageExtensionOptions, CustomImageExtensionStorage>({
|
||||||
selectable: isEditable,
|
selectable: isEditable,
|
||||||
draggable: isEditable,
|
draggable: isEditable,
|
||||||
|
|
||||||
|
|||||||
@ -53,4 +53,4 @@ export type CustomImageExtensionStorage = {
|
|||||||
maxFileSize: number;
|
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 type { Editor } from "@tiptap/core";
|
||||||
import { AlignCenter, AlignLeft, AlignRight, type LucideIcon } from "lucide-react";
|
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
|
// local imports
|
||||||
import { ECustomImageAttributeNames, TCustomImageAlignment, type Pixel, type TCustomImageAttributes } from "./types";
|
import { ECustomImageAttributeNames, TCustomImageAlignment, type Pixel, type TCustomImageAttributes } from "./types";
|
||||||
|
|
||||||
@ -16,8 +12,7 @@ export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
|
|||||||
[ECustomImageAttributeNames.ALIGNMENT]: "left",
|
[ECustomImageAttributeNames.ALIGNMENT]: "left",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
export const getImageComponentImageFileMap = (editor: Editor) => editor.storage.imageComponent?.fileMap;
|
||||||
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
|
|
||||||
|
|
||||||
export const ensurePixelString = <TDefault>(
|
export const ensurePixelString = <TDefault>(
|
||||||
value: Pixel | TDefault | number | undefined | null,
|
value: Pixel | TDefault | number | undefined | null,
|
||||||
|
|||||||
@ -50,31 +50,8 @@ type LinkOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Storage {
|
||||||
[CORE_EXTENSIONS.CUSTOM_LINK]: {
|
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,15 +10,13 @@ import {
|
|||||||
PasteRule,
|
PasteRule,
|
||||||
removeDuplicates,
|
removeDuplicates,
|
||||||
} from "@tiptap/core";
|
} 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 { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||||
import emojiRegex from "emoji-regex";
|
import emojiRegex from "emoji-regex";
|
||||||
import { isEmojiSupported } from "is-emoji-supported";
|
import { isEmojiSupported } from "is-emoji-supported";
|
||||||
// helpers
|
// helpers
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
|
||||||
import { customFindSuggestionMatch } from "@/helpers/find-suggestion-match";
|
import { customFindSuggestionMatch } from "@/helpers/find-suggestion-match";
|
||||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@ -78,11 +76,6 @@ export type EmojiOptions = {
|
|||||||
suggestion: Omit<SuggestionOptions, "editor">;
|
suggestion: Omit<SuggestionOptions, "editor">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmojiStorage = {
|
|
||||||
emojis: EmojiItem[];
|
|
||||||
isSupported: (item: EmojiItem) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EmojiSuggestionPluginKey = new PluginKey("emojiSuggestion");
|
export const EmojiSuggestionPluginKey = new PluginKey("emojiSuggestion");
|
||||||
|
|
||||||
export const inputRegex = /:([a-zA-Z0-9_+-]+):$/;
|
export const inputRegex = /:([a-zA-Z0-9_+-]+):$/;
|
||||||
@ -344,7 +337,7 @@ export const Emoji = Node.create<EmojiOptions, EmojiStorage>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
const isTouchDevice = !!getExtensionStorage(this.editor, CORE_EXTENSIONS.UTILITY).isTouchDevice;
|
const isTouchDevice = !!this.editor.storage.utility.isTouchDevice;
|
||||||
if (isTouchDevice) {
|
if (isTouchDevice) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,11 +7,13 @@ import { emojiSuggestion } from "./suggestion";
|
|||||||
|
|
||||||
export const EmojiExtension = Emoji.extend({
|
export const EmojiExtension = Emoji.extend({
|
||||||
addStorage() {
|
addStorage() {
|
||||||
|
const extensionOptions = this.options;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
markdown: {
|
markdown: {
|
||||||
serialize(state: MarkdownSerializerState, node: ProseMirrorNode) {
|
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) {
|
if (emojiItem?.emoji) {
|
||||||
state.write(emojiItem?.emoji);
|
state.write(emojiItem?.emoji);
|
||||||
} else if (emojiItem?.fallbackImage) {
|
} else if (emojiItem?.fallbackImage) {
|
||||||
|
|||||||
@ -1,19 +1,16 @@
|
|||||||
import type { EmojiOptions } from "@tiptap/extension-emoji";
|
import type { EmojiOptions } from "@tiptap/extension-emoji";
|
||||||
import { ReactRenderer, Editor } from "@tiptap/react";
|
import { ReactRenderer, type Editor } from "@tiptap/react";
|
||||||
import { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
|
import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
|
||||||
// constants
|
// constants
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
// helpers
|
|
||||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
|
||||||
// local imports
|
// 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"];
|
const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
|
||||||
|
|
||||||
export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
||||||
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
|
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
|
||||||
const { emojis } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);
|
const { emojis, isSupported } = editor.storage.emoji;
|
||||||
const { isSupported } = getExtensionStorage(editor, CORE_EXTENSIONS.EMOJI);
|
|
||||||
const filteredEmojis = emojis.filter((emoji) => {
|
const filteredEmojis = emojis.filter((emoji) => {
|
||||||
const hasEmoji = !!emoji?.emoji;
|
const hasEmoji = !!emoji?.emoji;
|
||||||
const hasFallbackImage = !!emoji?.fallbackImage;
|
const hasFallbackImage = !!emoji?.fallbackImage;
|
||||||
@ -27,7 +24,7 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
|||||||
|
|
||||||
if (query.trim() === "") {
|
if (query.trim() === "") {
|
||||||
const defaultEmojis = DEFAULT_EMOJIS.map((name) =>
|
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)
|
.filter(Boolean)
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
@ -57,7 +54,7 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
|||||||
editor = props.editor;
|
editor = props.editor;
|
||||||
|
|
||||||
// Track active dropdown
|
// 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, {
|
component = new ReactRenderer(EmojiList, {
|
||||||
props: {
|
props: {
|
||||||
@ -101,10 +98,10 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
|
|||||||
onExit: (): void => {
|
onExit: (): void => {
|
||||||
// Remove from active dropdowns
|
// Remove from active dropdowns
|
||||||
if (editor) {
|
if (editor) {
|
||||||
const utilityStorage = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY);
|
const { activeDropbarExtensions } = editor.storage.utility;
|
||||||
const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI);
|
const index = activeDropbarExtensions.indexOf(CORE_EXTENSIONS.EMOJI);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
utilityStorage.activeDropbarExtensions.splice(index, 1);
|
activeDropbarExtensions.splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { Extension } from "@tiptap/core";
|
import { Extension } from "@tiptap/core";
|
||||||
// constants
|
// constants
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
// helpers
|
|
||||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
|
||||||
|
|
||||||
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
||||||
Extension.create({
|
Extension.create({
|
||||||
@ -11,7 +9,7 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
|||||||
addKeyboardShortcuts(this) {
|
addKeyboardShortcuts(this) {
|
||||||
return {
|
return {
|
||||||
Enter: () => {
|
Enter: () => {
|
||||||
const { activeDropbarExtensions } = getExtensionStorage(this.editor, CORE_EXTENSIONS.UTILITY);
|
const { activeDropbarExtensions } = this.editor.storage.utility;
|
||||||
|
|
||||||
if (activeDropbarExtensions.length === 0) {
|
if (activeDropbarExtensions.length === 0) {
|
||||||
onEnterKeyPress?.();
|
onEnterKeyPress?.();
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { Extensions } from "@tiptap/core";
|
import { Extensions } from "@tiptap/core";
|
||||||
import CharacterCount from "@tiptap/extension-character-count";
|
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import TextStyle from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import TiptapUnderline from "@tiptap/extension-underline";
|
import { CharacterCount } from "@tiptap/extensions";
|
||||||
import { Markdown } from "tiptap-markdown";
|
import { Markdown } from "tiptap-markdown";
|
||||||
// extensions
|
// extensions
|
||||||
import {
|
import {
|
||||||
@ -76,7 +75,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
|||||||
ListKeymap({ tabIndex }),
|
ListKeymap({ tabIndex }),
|
||||||
CustomLinkExtension,
|
CustomLinkExtension,
|
||||||
CustomTypographyExtension,
|
CustomTypographyExtension,
|
||||||
TiptapUnderline,
|
|
||||||
TextStyle,
|
TextStyle,
|
||||||
TaskList.configure({
|
TaskList.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
|
|||||||
@ -9,6 +9,12 @@ export type HeadingExtensionStorage = {
|
|||||||
headings: IMarking[];
|
headings: IMarking[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Storage {
|
||||||
|
[CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const HeadingListExtension = Extension.create<unknown, HeadingExtensionStorage>({
|
export const HeadingListExtension = Extension.create<unknown, HeadingExtensionStorage>({
|
||||||
name: CORE_EXTENSIONS.HEADINGS_LIST,
|
name: CORE_EXTENSIONS.HEADINGS_LIST,
|
||||||
|
|
||||||
@ -43,7 +49,11 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
|
|||||||
|
|
||||||
this.storage.headings = headings;
|
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;
|
return null;
|
||||||
},
|
},
|
||||||
@ -51,8 +61,4 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
|
|||||||
|
|
||||||
return [plugin];
|
return [plugin];
|
||||||
},
|
},
|
||||||
|
|
||||||
getHeadings() {
|
|
||||||
return this.storage.headings;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
// constants
|
||||||
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
// helpers
|
// helpers
|
||||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||||
// types
|
// types
|
||||||
@ -7,6 +9,12 @@ import type { TFileHandler } from "@/types";
|
|||||||
import { CustomImageNodeView, CustomImageNodeViewProps } from "../custom-image/components/node-view";
|
import { CustomImageNodeView, CustomImageNodeViewProps } from "../custom-image/components/node-view";
|
||||||
import { ImageExtensionConfig } from "./extension-config";
|
import { ImageExtensionConfig } from "./extension-config";
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Storage {
|
||||||
|
[CORE_EXTENSIONS.IMAGE]: ImageExtensionStorage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ImageExtensionStorage = {
|
export type ImageExtensionStorage = {
|
||||||
deletedImageSet: Map<string, boolean>;
|
deletedImageSet: Map<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -39,10 +39,6 @@ export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOpti
|
|||||||
return ["mention-component", mergeAttributes(HTMLAttributes)];
|
return ["mention-component", mergeAttributes(HTMLAttributes)];
|
||||||
},
|
},
|
||||||
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "mention",
|
|
||||||
},
|
|
||||||
|
|
||||||
renderText({ node }) {
|
renderText({ node }) {
|
||||||
return getMentionDisplayText(this.options, node);
|
return getMentionDisplayText(this.options, node);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import { ReactRenderer } from "@tiptap/react";
|
import { ReactRenderer } from "@tiptap/react";
|
||||||
import { SuggestionOptions } from "@tiptap/suggestion";
|
import type { SuggestionOptions } from "@tiptap/suggestion";
|
||||||
import tippy, { Instance } from "tippy.js";
|
// constants
|
||||||
// helpers
|
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
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";
|
import { CommandListInstance } from "@/helpers/tippy";
|
||||||
// types
|
// types
|
||||||
import { TMentionHandler } from "@/types";
|
import { TMentionHandler } from "@/types";
|
||||||
@ -17,63 +16,48 @@ export const renderMentionsDropdown =
|
|||||||
() => {
|
() => {
|
||||||
const { searchCallback } = props;
|
const { searchCallback } = props;
|
||||||
let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | null = null;
|
let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | null = null;
|
||||||
let popup: Instance | null = null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
onStart: ({ clientRect, editor }) => {
|
||||||
if (!searchCallback) return;
|
if (!searchCallback) return;
|
||||||
if (!props.clientRect) return;
|
if (!clientRect) return;
|
||||||
component = new ReactRenderer<CommandListInstance, MentionsListDropdownProps>(MentionsListDropdown, {
|
component = new ReactRenderer<CommandListInstance, MentionsListDropdownProps>(MentionsListDropdown, {
|
||||||
props: {
|
props: {
|
||||||
...props,
|
...props,
|
||||||
searchCallback,
|
searchCallback,
|
||||||
},
|
},
|
||||||
editor: props.editor,
|
editor: 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.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);
|
component?.updateProps(props);
|
||||||
popup?.[0]?.setProps({
|
if (!clientRect) return;
|
||||||
getReferenceClientRect: props.clientRect,
|
if (component?.element) {
|
||||||
});
|
updateFloatingUIFloaterPosition(editor, component?.element as HTMLElement);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: ({ event }) => {
|
||||||
if (props.event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
popup?.[0]?.hide();
|
component?.destroy();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||||
|
|
||||||
if (navigationKeys.includes(props.event.key)) {
|
if (navigationKeys.includes(event.key)) {
|
||||||
props.event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
if (component?.ref?.onKeyDown(props)) {
|
return component?.ref?.onKeyDown({ event });
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
return component?.ref?.onKeyDown({ event });
|
||||||
},
|
},
|
||||||
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
|
onExit: ({ editor }) => {
|
||||||
const utilityStorage = getExtensionStorage(props.editor, CORE_EXTENSIONS.UTILITY);
|
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.MENTION);
|
||||||
const index = utilityStorage.activeDropbarExtensions.indexOf(CORE_EXTENSIONS.MENTION);
|
component?.element.remove();
|
||||||
if (index > -1) {
|
|
||||||
utilityStorage.activeDropbarExtensions.splice(index, 1);
|
|
||||||
}
|
|
||||||
popup?.[0]?.destroy();
|
|
||||||
component?.destroy();
|
component?.destroy();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import { Placeholder } from "@tiptap/extensions";
|
||||||
// constants
|
// constants
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
// helpers
|
|
||||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
|
||||||
// types
|
// types
|
||||||
import type { IEditorProps } from "@/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}`;
|
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 "";
|
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 { ReactRenderer } from "@tiptap/react";
|
||||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
|
||||||
import tippy, { Instance } from "tippy.js";
|
|
||||||
// constants
|
// constants
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
// helpers
|
// helpers
|
||||||
|
import { updateFloatingUIFloaterPosition } from "@/helpers/floating-ui";
|
||||||
import { CommandListInstance } from "@/helpers/tippy";
|
import { CommandListInstance } from "@/helpers/tippy";
|
||||||
// types
|
// types
|
||||||
import { IEditorProps, ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
|
import { IEditorProps, ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
|
||||||
@ -32,7 +32,6 @@ const Command = Extension.create<SlashCommandOptions>({
|
|||||||
},
|
},
|
||||||
allow({ editor }: { editor: Editor }) {
|
allow({ editor }: { editor: Editor }) {
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
|
|
||||||
const parentNode = selection.$from.node(selection.$from.depth);
|
const parentNode = selection.$from.node(selection.$from.depth);
|
||||||
const blockType = parentNode.type.name;
|
const blockType = parentNode.type.name;
|
||||||
|
|
||||||
@ -49,64 +48,63 @@ const Command = Extension.create<SlashCommandOptions>({
|
|||||||
return [
|
return [
|
||||||
Suggestion({
|
Suggestion({
|
||||||
editor: this.editor,
|
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,
|
...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"> & {
|
export type TExtensionProps = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions"> & {
|
||||||
additionalOptions?: TSlashCommandAdditionalOption[];
|
additionalOptions?: TSlashCommandAdditionalOption[];
|
||||||
};
|
};
|
||||||
@ -115,6 +113,5 @@ export const SlashCommands = (props: TExtensionProps) =>
|
|||||||
Command.configure({
|
Command.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: getSlashCommandFilteredSections(props),
|
items: getSlashCommandFilteredSections(props),
|
||||||
render: renderItems,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -27,6 +27,8 @@ export const CustomStarterKitExtension = (args: TArgs) => {
|
|||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
horizontalRule: false,
|
horizontalRule: false,
|
||||||
blockquote: false,
|
blockquote: false,
|
||||||
|
link: false,
|
||||||
|
listKeymap: false,
|
||||||
paragraph: {
|
paragraph: {
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "editor-paragraph-block",
|
class: "editor-paragraph-block",
|
||||||
@ -41,6 +43,6 @@ export const CustomStarterKitExtension = (args: TArgs) => {
|
|||||||
class:
|
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)]",
|
"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 type { Editor, NodeViewProps } from "@tiptap/core";
|
||||||
import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model";
|
import type { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model";
|
||||||
import { CellSelection, TableMap, updateColumnsOnResize } from "@tiptap/pm/tables";
|
import { TableMap, updateColumnsOnResize } from "@tiptap/pm/tables";
|
||||||
import { Decoration, NodeView } from "@tiptap/pm/view";
|
import type { Decoration, NodeView } from "@tiptap/pm/view";
|
||||||
import { h } from "jsx-dom-cjs";
|
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 {
|
export class TableView implements NodeView {
|
||||||
node: ProseMirrorNode;
|
node: ProseMirrorNode;
|
||||||
cellMinWidth: number;
|
cellMinWidth: number;
|
||||||
decorations: Decoration[];
|
decorations: readonly Decoration[];
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
getPos: () => number;
|
getPos: NodeViewProps["getPos"];
|
||||||
hoveredCell: ResolvedPos | null = null;
|
hoveredCell: ResolvedPos | null = null;
|
||||||
map: TableMap;
|
map: TableMap;
|
||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
table: HTMLTableElement;
|
table: HTMLTableElement;
|
||||||
colgroup: HTMLTableColElement;
|
colgroup: HTMLTableColElement;
|
||||||
tbody: HTMLElement;
|
tbody: HTMLElement;
|
||||||
rowsControl?: HTMLElement | null;
|
|
||||||
columnsControl?: HTMLElement | null;
|
|
||||||
columnsToolbox?: Instance<Props>;
|
|
||||||
rowsToolbox?: Instance<Props>;
|
|
||||||
controls?: HTMLElement;
|
controls?: HTMLElement;
|
||||||
|
|
||||||
get dom() {
|
get dom() {
|
||||||
@ -282,9 +29,9 @@ export class TableView implements NodeView {
|
|||||||
constructor(
|
constructor(
|
||||||
node: ProseMirrorNode,
|
node: ProseMirrorNode,
|
||||||
cellMinWidth: number,
|
cellMinWidth: number,
|
||||||
decorations: Decoration[],
|
decorations: readonly Decoration[],
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
getPos: () => number
|
getPos: NodeViewProps["getPos"]
|
||||||
) {
|
) {
|
||||||
this.node = node;
|
this.node = node;
|
||||||
this.cellMinWidth = cellMinWidth;
|
this.cellMinWidth = cellMinWidth;
|
||||||
@ -294,88 +41,6 @@ export class TableView implements NodeView {
|
|||||||
this.hoveredCell = null;
|
this.hoveredCell = null;
|
||||||
this.map = TableMap.get(node);
|
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(
|
this.colgroup = h(
|
||||||
"colgroup",
|
"colgroup",
|
||||||
null,
|
null,
|
||||||
@ -387,9 +52,8 @@ export class TableView implements NodeView {
|
|||||||
this.root = h(
|
this.root = h(
|
||||||
"div",
|
"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
|
this.table
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -405,10 +69,6 @@ export class TableView implements NodeView {
|
|||||||
this.decorations = [...decorations];
|
this.decorations = [...decorations];
|
||||||
this.map = TableMap.get(this.node);
|
this.map = TableMap.get(this.node);
|
||||||
|
|
||||||
if (this.editor.isEditable) {
|
|
||||||
this.updateControls();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -426,67 +86,4 @@ export class TableView implements NodeView {
|
|||||||
ignoreMutation() {
|
ignoreMutation() {
|
||||||
return true;
|
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,
|
toggleHeader,
|
||||||
toggleHeaderCell,
|
toggleHeaderCell,
|
||||||
} from "@tiptap/pm/tables";
|
} from "@tiptap/pm/tables";
|
||||||
import type { Decoration } from "@tiptap/pm/view";
|
|
||||||
// constants
|
// constants
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||||
// local imports
|
// local imports
|
||||||
@ -264,7 +263,7 @@ export const Table = Node.create<TableOptions>({
|
|||||||
return ({ editor, node, decorations, getPos }) => {
|
return ({ editor, node, decorations, getPos }) => {
|
||||||
const { cellMinWidth } = this.options;
|
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.EMOJI
|
||||||
| CORE_EXTENSIONS.SLASH_COMMANDS
|
| CORE_EXTENSIONS.SLASH_COMMANDS
|
||||||
| CORE_EXTENSIONS.TABLE
|
| CORE_EXTENSIONS.TABLE
|
||||||
|
| "bubble-menu"
|
||||||
| CORE_EXTENSIONS.SIDE_MENU
|
| CORE_EXTENSIONS.SIDE_MENU
|
||||||
| TAdditionalActiveDropbarExtensions;
|
| TAdditionalActiveDropbarExtensions;
|
||||||
|
|
||||||
@ -36,6 +37,9 @@ declare module "@tiptap/core" {
|
|||||||
removeActiveDropbarExtension: (extension: TActiveDropbarExtensions) => () => void;
|
removeActiveDropbarExtension: (extension: TActiveDropbarExtensions) => () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
interface Storage {
|
||||||
|
[CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UtilityExtensionStorage = {
|
export type UtilityExtensionStorage = {
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
// constants
|
// plane imports
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { ADDITIONAL_EXTENSIONS, CORE_EXTENSIONS } from "@plane/utils";
|
||||||
// extensions
|
// extensions
|
||||||
import { getImageBlockId } from "@/extensions/custom-image/utils";
|
import { getImageBlockId } from "@/extensions/custom-image/utils";
|
||||||
// plane editor imports
|
// plane editor imports
|
||||||
import { ADDITIONAL_ASSETS_META_DATA_RECORD } from "@/plane-editor/constants/assets";
|
import { ADDITIONAL_ASSETS_META_DATA_RECORD } from "@/plane-editor/constants/assets";
|
||||||
// types
|
// types
|
||||||
import { TEditorAsset } from "@/types";
|
import type { TEditorAsset } from "@/types";
|
||||||
|
|
||||||
export type TAssetMetaDataRecord = (attrs: ProseMirrorNode["attrs"]) => TEditorAsset | undefined;
|
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) => {
|
[CORE_EXTENSIONS.IMAGE]: (attrs) => {
|
||||||
if (!attrs?.src) return;
|
if (!attrs?.src) return;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import { Editor } from "@tiptap/core";
|
import type { Editor } from "@tiptap/core";
|
||||||
import { DOMSerializer } from "@tiptap/pm/model";
|
import { DOMSerializer } from "@tiptap/pm/model";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
// components
|
// components
|
||||||
@ -11,7 +11,6 @@ import { CORE_EDITOR_META } from "@/constants/meta";
|
|||||||
import type { EditorRefApi, TEditorCommands } from "@/types";
|
import type { EditorRefApi, TEditorCommands } from "@/types";
|
||||||
// local imports
|
// local imports
|
||||||
import { getParagraphCount } from "./common";
|
import { getParagraphCount } from "./common";
|
||||||
import { getExtensionStorage } from "./get-extension-storage";
|
|
||||||
import { insertContentAtSavedSelection } from "./insert-content-at-cursor-position";
|
import { insertContentAtSavedSelection } from "./insert-content-at-cursor-position";
|
||||||
import { scrollSummary, scrollToNodeViaDOMCoordinates } from "./scroll-to-node";
|
import { scrollSummary, scrollToNodeViaDOMCoordinates } from "./scroll-to-node";
|
||||||
|
|
||||||
@ -72,18 +71,18 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getDocumentInfo: () => ({
|
getDocumentInfo: () => ({
|
||||||
characters: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.characters?.() : 0,
|
characters: editor?.storage.characterCount?.characters?.() ?? 0,
|
||||||
paragraphs: getParagraphCount(editor?.state),
|
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: () => {
|
getMarkDown: () => {
|
||||||
const markdownOutput = editor?.storage?.markdown?.getMarkdown?.();
|
const markdownOutput = editor?.storage?.markdown?.getMarkdown?.() ?? "";
|
||||||
return markdownOutput;
|
return markdownOutput;
|
||||||
},
|
},
|
||||||
isAnyDropbarOpen: () => {
|
isAnyDropbarOpen: () => {
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
const utilityStorage = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY);
|
const utilityStorage = editor.storage.utility;
|
||||||
return utilityStorage.activeDropbarExtensions.length > 0;
|
return utilityStorage.activeDropbarExtensions.length > 0;
|
||||||
},
|
},
|
||||||
scrollSummary: (marking) => {
|
scrollSummary: (marking) => {
|
||||||
@ -91,7 +90,17 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
|||||||
scrollSummary(editor, marking);
|
scrollSummary(editor, marking);
|
||||||
},
|
},
|
||||||
setEditorValue: (content, emitUpdate = false) => {
|
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),
|
emitRealTimeUpdate: (message) => provider?.sendStateless(message),
|
||||||
executeMenuItemCommand: (props) => {
|
executeMenuItemCommand: (props) => {
|
||||||
@ -148,8 +157,7 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
|||||||
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
|
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isEditorReadyToDiscard: () =>
|
isEditorReadyToDiscard: () => editor?.storage?.utility?.uploadInProgress === false,
|
||||||
!!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false,
|
|
||||||
isMenuItemActive: (props) => {
|
isMenuItemActive: (props) => {
|
||||||
const { itemKey } = props;
|
const { itemKey } = props;
|
||||||
const editorItems = getEditorMenuItems(editor);
|
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) },
|
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||||
onDocumentInfoChange: (callback) => {
|
onDocumentInfoChange: (callback) => {
|
||||||
const handleDocumentInfoChange = () => {
|
const handleDocumentInfoChange = () => {
|
||||||
if (!editor) return;
|
if (!editor?.storage) return;
|
||||||
callback({
|
callback({
|
||||||
characters: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.CHARACTER_COUNT)?.characters?.() : 0,
|
characters: editor.storage.characterCount?.characters?.() ?? 0,
|
||||||
paragraphs: getParagraphCount(editor?.state),
|
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) => {
|
onHeadingChange: (callback) => {
|
||||||
const handleHeadingChange = () => {
|
const handleHeadingChange = () => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const headings = getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings;
|
const headings = editor.storage.headingsList?.headings;
|
||||||
if (headings) {
|
if (headings) {
|
||||||
callback(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 { useEditorState, useEditor as useTiptapEditor } from "@tiptap/react";
|
||||||
import { useImperativeHandle, useEffect } from "react";
|
import { useImperativeHandle, useEffect } from "react";
|
||||||
// constants
|
import type { MarkdownStorage } from "tiptap-markdown";
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
|
||||||
// extensions
|
// extensions
|
||||||
import { CoreEditorExtensions } from "@/extensions";
|
import { CoreEditorExtensions } from "@/extensions";
|
||||||
// helpers
|
// helpers
|
||||||
import { getEditorRefHelpers } from "@/helpers/editor-ref";
|
import { getEditorRefHelpers } from "@/helpers/editor-ref";
|
||||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
|
||||||
// props
|
// props
|
||||||
import { CoreEditorProps } from "@/props";
|
import { CoreEditorProps } from "@/props";
|
||||||
// types
|
// types
|
||||||
import type { TEditorHookProps } from "@/types";
|
import type { TEditorHookProps } from "@/types";
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Storage {
|
||||||
|
markdown: MarkdownStorage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useEditor = (props: TEditorHookProps) => {
|
export const useEditor = (props: TEditorHookProps) => {
|
||||||
const {
|
const {
|
||||||
autofocus = false,
|
autofocus = false,
|
||||||
@ -86,10 +90,15 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||||||
// supported and value is undefined when the data from swr is not populated
|
// supported and value is undefined when the data from swr is not populated
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
if (editor) {
|
if (editor) {
|
||||||
const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress;
|
const { uploadInProgress: isUploadInProgress } = editor.storage.utility;
|
||||||
if (!editor.isDestroyed && !isUploadInProgress) {
|
if (!editor.isDestroyed && !isUploadInProgress) {
|
||||||
try {
|
try {
|
||||||
editor.commands.setContent(value, false, { preserveWhitespace: true });
|
editor.commands.setContent(value, {
|
||||||
|
emitUpdate: false,
|
||||||
|
parseOptions: {
|
||||||
|
preserveWhitespace: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (editor.state.selection) {
|
if (editor.state.selection) {
|
||||||
const docLength = editor.state.doc.content.size;
|
const docLength = editor.state.doc.content.size;
|
||||||
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
|
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
|
||||||
@ -113,7 +122,7 @@ export const useEditor = (props: TEditorHookProps) => {
|
|||||||
const assetsList = useEditorState({
|
const assetsList = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
selector: ({ editor }) => ({
|
selector: ({ editor }) => ({
|
||||||
assets: editor ? getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsList : [],
|
assets: editor?.storage.utility?.assetsList ?? [],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
// trigger callback when assets list changes
|
// 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";
|
import { DragEvent, useCallback, useEffect, useState } from "react";
|
||||||
// helpers
|
// helpers
|
||||||
import { EFileError, isFileValid } from "@/helpers/file";
|
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.");
|
throw new Error("Something went wrong while uploading the file.");
|
||||||
}
|
}
|
||||||
onUpload(url, file);
|
onUpload(url, file);
|
||||||
} catch (errPayload) {
|
} catch {
|
||||||
const error = errPayload?.response?.data?.error || "Something went wrong";
|
console.error("useFileUpload: Error in uploading file");
|
||||||
console.error(error);
|
|
||||||
} finally {
|
} finally {
|
||||||
handleProgressStatus?.(false);
|
handleProgressStatus?.(false);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
@ -90,13 +89,13 @@ export const useUploader = (args: TUploaderArgs) => {
|
|||||||
|
|
||||||
type TDropzoneArgs = {
|
type TDropzoneArgs = {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
pos: number;
|
getPos: NodeViewProps["getPos"];
|
||||||
type: Extract<TEditorCommands, "attachment" | "image">;
|
type: Extract<TEditorCommands, "attachment" | "image">;
|
||||||
uploader: (file: File) => Promise<void>;
|
uploader: (file: File) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDropZone = (args: TDropzoneArgs) => {
|
export const useDropZone = (args: TDropzoneArgs) => {
|
||||||
const { editor, pos, type, uploader } = args;
|
const { editor, getPos, type, uploader } = args;
|
||||||
// states
|
// states
|
||||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
||||||
@ -124,8 +123,9 @@ export const useDropZone = (args: TDropzoneArgs) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDraggedInside(false);
|
setDraggedInside(false);
|
||||||
const filesList = e.dataTransfer.files;
|
const filesList = e.dataTransfer.files;
|
||||||
|
const pos = getPos();
|
||||||
|
|
||||||
if (filesList.length === 0 || !editor.isEditable) {
|
if (filesList.length === 0 || !editor.isEditable || pos === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,7 +137,7 @@ export const useDropZone = (args: TDropzoneArgs) => {
|
|||||||
uploader,
|
uploader,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[editor, pos, type, uploader]
|
[editor, type, uploader, getPos]
|
||||||
);
|
);
|
||||||
const onDragEnter = useCallback(() => setDraggedInside(true), []);
|
const onDragEnter = useCallback(() => setDraggedInside(true), []);
|
||||||
const onDragLeave = useCallback(() => setDraggedInside(false), []);
|
const onDragLeave = useCallback(() => setDraggedInside(false), []);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Editor } from "@tiptap/core";
|
import type { Editor } from "@tiptap/core";
|
||||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
import { type EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||||
// constants
|
// constants
|
||||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||||
// plane editor imports
|
// plane editor imports
|
||||||
@ -7,6 +7,7 @@ import { NODE_FILE_MAP } from "@/plane-editor/constants/utility";
|
|||||||
// types
|
// types
|
||||||
import { TFileHandler } from "@/types";
|
import { TFileHandler } from "@/types";
|
||||||
// local imports
|
// local imports
|
||||||
|
import type { NodeFileMapType } from "../../../ce/constants/utility";
|
||||||
import { TFileNode } from "./types";
|
import { TFileNode } from "./types";
|
||||||
|
|
||||||
const DELETE_PLUGIN_KEY = new PluginKey("delete-utility");
|
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;
|
if (!transactions.some((tr) => tr.docChanged)) return null;
|
||||||
|
|
||||||
newState.doc.descendants((node) => {
|
newState.doc.descendants((node) => {
|
||||||
const nodeType = node.type.name;
|
const nodeType = node.type.name as keyof NodeFileMapType;
|
||||||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
||||||
if (nodeFileSetDetails) {
|
if (nodeFileSetDetails) {
|
||||||
if (newFileSources[nodeType]) {
|
if (newFileSources[nodeType]) {
|
||||||
@ -40,7 +41,7 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
|
|||||||
|
|
||||||
// iterate through all the nodes in the old state
|
// iterate through all the nodes in the old state
|
||||||
oldState.doc.descendants((node) => {
|
oldState.doc.descendants((node) => {
|
||||||
const nodeType = node.type.name;
|
const nodeType = node.type.name as keyof NodeFileMapType;
|
||||||
const isAValidNode = NODE_FILE_MAP[nodeType];
|
const isAValidNode = NODE_FILE_MAP[nodeType];
|
||||||
// if the node doesn't match, then return as no point in checking
|
// if the node doesn't match, then return as no point in checking
|
||||||
if (!isAValidNode) return;
|
if (!isAValidNode) return;
|
||||||
@ -51,12 +52,13 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
|
|||||||
});
|
});
|
||||||
|
|
||||||
removedFiles.forEach(async (node) => {
|
removedFiles.forEach(async (node) => {
|
||||||
const nodeType = node.type.name;
|
const nodeType = node.type.name as keyof NodeFileMapType;
|
||||||
const src = node.attrs.src;
|
const src = node.attrs.src;
|
||||||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
||||||
if (!nodeFileSetDetails || !src) return;
|
if (!nodeFileSetDetails || !src) return;
|
||||||
try {
|
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
|
// update assets list storage value
|
||||||
editor.commands.updateAssetsList?.({
|
editor.commands.updateAssetsList?.({
|
||||||
idToRemove: node.attrs.id,
|
idToRemove: node.attrs.id,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Editor } from "@tiptap/core";
|
import type { Editor } from "@tiptap/core";
|
||||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
import { type EditorState, Plugin, PluginKey, type Transaction } from "@tiptap/pm/state";
|
||||||
// constants
|
// plane imports
|
||||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
import { CORE_EXTENSIONS } from "@plane/utils";
|
||||||
// helpers
|
// helpers
|
||||||
import { CORE_ASSETS_META_DATA_RECORD } from "@/helpers/assets";
|
import { CORE_ASSETS_META_DATA_RECORD } from "@/helpers/assets";
|
||||||
// plane editor imports
|
// plane editor imports
|
||||||
@ -9,6 +9,7 @@ import { NODE_FILE_MAP } from "@/plane-editor/constants/utility";
|
|||||||
// types
|
// types
|
||||||
import { TFileHandler } from "@/types";
|
import { TFileHandler } from "@/types";
|
||||||
// local imports
|
// local imports
|
||||||
|
import type { NodeFileMapType } from "../../../ce/constants/utility";
|
||||||
import { TFileNode } from "./types";
|
import { TFileNode } from "./types";
|
||||||
|
|
||||||
const RESTORE_PLUGIN_KEY = new PluginKey("restore-utility");
|
const RESTORE_PLUGIN_KEY = new PluginKey("restore-utility");
|
||||||
@ -23,7 +24,7 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
|
|||||||
[key: string]: Set<string> | undefined;
|
[key: string]: Set<string> | undefined;
|
||||||
} = {};
|
} = {};
|
||||||
oldState.doc.descendants((node) => {
|
oldState.doc.descendants((node) => {
|
||||||
const nodeType = node.type.name;
|
const nodeType = node.type.name as keyof NodeFileMapType;
|
||||||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
||||||
if (nodeFileSetDetails) {
|
if (nodeFileSetDetails) {
|
||||||
if (oldFileSources[nodeType]) {
|
if (oldFileSources[nodeType]) {
|
||||||
@ -38,7 +39,7 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
|
|||||||
const addedFiles: TFileNode[] = [];
|
const addedFiles: TFileNode[] = [];
|
||||||
|
|
||||||
newState.doc.descendants((node, pos) => {
|
newState.doc.descendants((node, pos) => {
|
||||||
const nodeType = node.type.name;
|
const nodeType = node.type.name as keyof NodeFileMapType;
|
||||||
const isAValidNode = NODE_FILE_MAP[nodeType];
|
const isAValidNode = NODE_FILE_MAP[nodeType];
|
||||||
// if the node doesn't match, then return as no point in checking
|
// if the node doesn't match, then return as no point in checking
|
||||||
if (!isAValidNode) return;
|
if (!isAValidNode) return;
|
||||||
@ -58,9 +59,11 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
|
|||||||
});
|
});
|
||||||
|
|
||||||
addedFiles.forEach(async (node) => {
|
addedFiles.forEach(async (node) => {
|
||||||
const nodeType = node.type.name;
|
const nodeType = node.type.name as keyof NodeFileMapType;
|
||||||
const src = node.attrs.src;
|
const src = node.attrs.src;
|
||||||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
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 extensionFileSetStorage = editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName];
|
||||||
const wasDeleted = extensionFileSetStorage?.get(src);
|
const wasDeleted = extensionFileSetStorage?.get(src);
|
||||||
if (!nodeFileSetDetails || !src) return;
|
if (!nodeFileSetDetails || !src) return;
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
|
|||||||
key: new PluginKey("markdownClipboard"),
|
key: new PluginKey("markdownClipboard"),
|
||||||
props: {
|
props: {
|
||||||
clipboardTextSerializer: (slice) => {
|
clipboardTextSerializer: (slice) => {
|
||||||
|
// @ts-expect-error tiptap-markdown types are not updated
|
||||||
const markdownSerializer = editor.storage.markdown.serializer;
|
const markdownSerializer = editor.storage.markdown.serializer;
|
||||||
const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW;
|
const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW;
|
||||||
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
|
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
|
||||||
|
|||||||
@ -143,61 +143,6 @@
|
|||||||
}
|
}
|
||||||
/* End column resizer */
|
/* 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,
|
.resize-cursor .table-wrapper .table-controls .rows-control,
|
||||||
.table-wrapper.controls--disabled .table-controls .rows-control,
|
.table-wrapper.controls--disabled .table-controls .rows-control,
|
||||||
.resize-cursor .table-wrapper .table-controls .columns-control,
|
.resize-cursor .table-wrapper .table-controls .columns-control,
|
||||||
|
|||||||
@ -5,10 +5,10 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
|
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/core/*"],
|
"@/*": ["./src/core/*"],
|
||||||
|
|||||||
@ -7,5 +7,6 @@ export default defineConfig({
|
|||||||
dts: true,
|
dts: true,
|
||||||
clean: false,
|
clean: false,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
minify: true,
|
||||||
copy: ["src/styles"],
|
copy: ["src/styles"],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -50,3 +50,51 @@ export const isEditorEmpty = (description: string | undefined): boolean =>
|
|||||||
description === "<p></p>" ||
|
description === "<p></p>" ||
|
||||||
description === `<p class="editor-paragraph-block"></p>` ||
|
description === `<p class="editor-paragraph-block"></p>` ||
|
||||||
description.trim() === "";
|
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
|
typescript: 5.8.3
|
||||||
tsdown: 0.14.2
|
tsdown: 0.14.2
|
||||||
uuid: 10.0.0
|
uuid: 10.0.0
|
||||||
|
"@tiptap/core": ^3.5.3
|
||||||
|
"@tiptap/html": ^3.5.3
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- turbo
|
- turbo
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user