mirror of
https://github.com/makeplane/plane.git
synced 2025-12-16 11:57:56 +01:00
[WIKI-181] refactor: file plugins and types (#7074)
* refactor: file plugins and types * refactor: image extension storage types * chore: update meta tag name * chore: extension fileset storage key * fix: build errors * refactor: utility extension * refactor: file plugins * chore: remove standalone plugin extensions * chore: refactoring out onCreate into a common utility * refactor: work item embed extension * chore: use extension enums * fix: errors and warnings * refactor: rename extension files * fix: tsup reloading issue * fix: image upload types and heading types * fix: file plugin object reference * fix: iseditable is hard coded * fix: image extension names * fix: collaborative editor editable value * chore: add constants for editor meta as well --------- Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
This commit is contained in:
committed by
GitHub
parent
a3a580923c
commit
e388a9a279
@@ -57,7 +57,7 @@
|
||||
"concurrently": "^9.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsup": "^8.4.0",
|
||||
"tsup": "8.3.0",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/reflect-metadata": "^0.1.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"tsup": "8.4.0",
|
||||
"tsup": "8.3.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"postcss": "^8.4.38",
|
||||
"tsup": "^8.4.0",
|
||||
"tsup": "8.3.0",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
14
packages/editor/src/ce/constants/utility.ts
Normal file
14
packages/editor/src/ce/constants/utility.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ExtensionFileSetStorageKey } from "@/plane-editor/types/storage";
|
||||
|
||||
export const NODE_FILE_MAP: {
|
||||
[key: string]: {
|
||||
fileSetName: ExtensionFileSetStorageKey;
|
||||
};
|
||||
} = {
|
||||
image: {
|
||||
fileSetName: "deletedImageSet",
|
||||
},
|
||||
imageComponent: {
|
||||
fileSetName: "deletedImageSet",
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { AnyExtension } from "@tiptap/core";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { HeadingExtensionStorage } from "@/extensions";
|
||||
import { CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
import { CustomLinkStorage } from "@/extensions/custom-link";
|
||||
import { MentionExtensionStorage } from "@/extensions/mentions";
|
||||
import { ImageExtensionStorage } from "@/plugins/image";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { type HeadingExtensionStorage } from "@/extensions";
|
||||
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
import { type CustomLinkStorage } from "@/extensions/custom-link";
|
||||
import { type ImageExtensionStorage } from "@/extensions/image";
|
||||
import { type MentionExtensionStorage } from "@/extensions/mentions";
|
||||
import { type UtilityExtensionStorage } from "@/extensions/utility";
|
||||
|
||||
export type ExtensionStorageMap = {
|
||||
imageComponent: CustomImageExtensionStorage;
|
||||
image: ImageExtensionStorage;
|
||||
link: CustomLinkStorage;
|
||||
headingList: HeadingExtensionStorage;
|
||||
mention: MentionExtensionStorage;
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: CustomImageExtensionStorage;
|
||||
[CORE_EXTENSIONS.IMAGE]: ImageExtensionStorage;
|
||||
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
|
||||
[CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage;
|
||||
[CORE_EXTENSIONS.MENTION]: MentionExtensionStorage;
|
||||
[CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage;
|
||||
};
|
||||
|
||||
export type ExtensionFileSetStorageKey = Extract<keyof ImageExtensionStorage, "deletedImageSet">;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DocumentContentLoader, PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { IssueWidget } from "@/extensions";
|
||||
import { WorkItemEmbedExtension } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
@@ -39,9 +39,10 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
} = props;
|
||||
|
||||
const extensions: Extensions = [];
|
||||
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
WorkItemEmbedExtension({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { IssueWidget } from "@/extensions";
|
||||
import { WorkItemEmbedExtension } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
@@ -53,7 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const extensions: Extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
WorkItemEmbedExtension({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FC, ReactNode, useRef } from "react";
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// types
|
||||
import { TDisplayConfig } from "@/types";
|
||||
// components
|
||||
@@ -36,12 +37,12 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
if (
|
||||
currentNode.content.size === 0 && // Check if the current node is empty
|
||||
!(
|
||||
editor.isActive("orderedList") ||
|
||||
editor.isActive("bulletList") ||
|
||||
editor.isActive("taskItem") ||
|
||||
editor.isActive("table") ||
|
||||
editor.isActive("blockquote") ||
|
||||
editor.isActive("codeBlock")
|
||||
editor.isActive(CORE_EXTENSIONS.ORDERED_LIST) ||
|
||||
editor.isActive(CORE_EXTENSIONS.BULLET_LIST) ||
|
||||
editor.isActive(CORE_EXTENSIONS.TASK_ITEM) ||
|
||||
editor.isActive(CORE_EXTENSIONS.TABLE) ||
|
||||
editor.isActive(CORE_EXTENSIONS.BLOCKQUOTE) ||
|
||||
editor.isActive(CORE_EXTENSIONS.CODE_BLOCK)
|
||||
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
|
||||
) {
|
||||
return;
|
||||
@@ -53,10 +54,10 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const lastNode = lastNodePos.node();
|
||||
|
||||
// Check if the last node is a not paragraph
|
||||
if (lastNode && lastNode.type.name !== "paragraph") {
|
||||
if (lastNode && lastNode.type.name !== CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If last node is not a paragraph, insert a new paragraph at the end
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
|
||||
editor?.chain().insertContentAt(endPosition, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
|
||||
|
||||
// Focus the newly added paragraph for immediate editing
|
||||
editor
|
||||
|
||||
@@ -12,7 +12,7 @@ interface LinkViewContainerProps {
|
||||
export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containerRef }) => {
|
||||
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [virtualElement, setVirtualElement] = useState<any>(null);
|
||||
const [virtualElement, setVirtualElement] = useState<Element | null>(null);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
|
||||
@@ -51,7 +51,9 @@ export const LinkEditView = ({ viewProps }: LinkEditViewProps) => {
|
||||
if (!hasSubmitted.current && !linkRemoved && initialUrl === "") {
|
||||
try {
|
||||
removeLink();
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
console.error("Error removing link", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
[linkRemoved, initialUrl]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
import { Copy, LucideIcon, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
interface BlockMenuProps {
|
||||
editor: Editor;
|
||||
@@ -102,7 +104,8 @@ export const BlockMenu = (props: BlockMenuProps) => {
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
isDisabled:
|
||||
editor.state.selection.content().content.firstChild?.type.name === "image" || editor.isActive("imageComponent"),
|
||||
editor.state.selection.content().content.firstChild?.type.name === CORE_EXTENSIONS.IMAGE ||
|
||||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE),
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Check, Link, Trash2 } from "lucide-react";
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
||||
@@ -43,7 +45,7 @@ export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
||||
"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",
|
||||
{
|
||||
"bg-custom-background-80": isOpen,
|
||||
"text-custom-text-100": editor.isActive("link"),
|
||||
"text-custom-text-100": editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK),
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/components/menus";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
// local components
|
||||
@@ -90,8 +91,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
if (
|
||||
empty ||
|
||||
!editor.isEditable ||
|
||||
editor.isActive("image") ||
|
||||
editor.isActive("imageComponent") ||
|
||||
editor.isActive(CORE_EXTENSIONS.IMAGE) ||
|
||||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE) ||
|
||||
isNodeSelection(selection) ||
|
||||
isCellSelection(selection) ||
|
||||
isSelecting
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
Palette,
|
||||
AlignCenter,
|
||||
} from "lucide-react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import {
|
||||
insertHorizontalRule,
|
||||
@@ -35,12 +37,7 @@ import {
|
||||
toggleBold,
|
||||
toggleBulletList,
|
||||
toggleCodeBlock,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingSix,
|
||||
toggleHeadingThree,
|
||||
toggleHeadingTwo,
|
||||
toggleHeading,
|
||||
toggleItalic,
|
||||
toggleOrderedList,
|
||||
toggleStrike,
|
||||
@@ -65,63 +62,49 @@ export type EditorMenuItem<T extends TEditorCommands> = {
|
||||
export const TextItem = (editor: Editor): EditorMenuItem<"text"> => ({
|
||||
key: "text",
|
||||
name: "Text",
|
||||
isActive: () => editor.isActive("paragraph"),
|
||||
isActive: () => editor.isActive(CORE_EXTENSIONS.PARAGRAPH),
|
||||
command: () => setText(editor),
|
||||
icon: CaseSensitive,
|
||||
});
|
||||
|
||||
export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> => ({
|
||||
key: "h1",
|
||||
name: "Heading 1",
|
||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||
command: () => toggleHeadingOne(editor),
|
||||
icon: Heading1,
|
||||
type SupportedHeadingLevels = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
|
||||
const HeadingItem = <T extends SupportedHeadingLevels>(
|
||||
editor: Editor,
|
||||
level: 1 | 2 | 3 | 4 | 5 | 6,
|
||||
key: T,
|
||||
name: string,
|
||||
icon: LucideIcon
|
||||
): EditorMenuItem<T> => ({
|
||||
key,
|
||||
name,
|
||||
isActive: () => editor.isActive(CORE_EXTENSIONS.HEADING, { level }),
|
||||
command: () => toggleHeading(editor, level),
|
||||
icon,
|
||||
});
|
||||
|
||||
export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> => ({
|
||||
key: "h2",
|
||||
name: "Heading 2",
|
||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||
command: () => toggleHeadingTwo(editor),
|
||||
icon: Heading2,
|
||||
});
|
||||
export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> =>
|
||||
HeadingItem(editor, 1, "h1", "Heading 1", Heading1);
|
||||
|
||||
export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> => ({
|
||||
key: "h3",
|
||||
name: "Heading 3",
|
||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||
command: () => toggleHeadingThree(editor),
|
||||
icon: Heading3,
|
||||
});
|
||||
export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> =>
|
||||
HeadingItem(editor, 2, "h2", "Heading 2", Heading2);
|
||||
|
||||
export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> => ({
|
||||
key: "h4",
|
||||
name: "Heading 4",
|
||||
isActive: () => editor.isActive("heading", { level: 4 }),
|
||||
command: () => toggleHeadingFour(editor),
|
||||
icon: Heading4,
|
||||
});
|
||||
export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> =>
|
||||
HeadingItem(editor, 3, "h3", "Heading 3", Heading3);
|
||||
|
||||
export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> => ({
|
||||
key: "h5",
|
||||
name: "Heading 5",
|
||||
isActive: () => editor.isActive("heading", { level: 5 }),
|
||||
command: () => toggleHeadingFive(editor),
|
||||
icon: Heading5,
|
||||
});
|
||||
export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> =>
|
||||
HeadingItem(editor, 4, "h4", "Heading 4", Heading4);
|
||||
|
||||
export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> => ({
|
||||
key: "h6",
|
||||
name: "Heading 6",
|
||||
isActive: () => editor.isActive("heading", { level: 6 }),
|
||||
command: () => toggleHeadingSix(editor),
|
||||
icon: Heading6,
|
||||
});
|
||||
export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> =>
|
||||
HeadingItem(editor, 5, "h5", "Heading 5", Heading5);
|
||||
|
||||
export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> =>
|
||||
HeadingItem(editor, 6, "h6", "Heading 6", Heading6);
|
||||
|
||||
export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({
|
||||
key: "bold",
|
||||
name: "Bold",
|
||||
isActive: () => editor?.isActive("bold"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.BOLD),
|
||||
command: () => toggleBold(editor),
|
||||
icon: BoldIcon,
|
||||
});
|
||||
@@ -129,7 +112,7 @@ export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({
|
||||
export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({
|
||||
key: "italic",
|
||||
name: "Italic",
|
||||
isActive: () => editor?.isActive("italic"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.ITALIC),
|
||||
command: () => toggleItalic(editor),
|
||||
icon: ItalicIcon,
|
||||
});
|
||||
@@ -137,7 +120,7 @@ export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({
|
||||
export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
|
||||
key: "underline",
|
||||
name: "Underline",
|
||||
isActive: () => editor?.isActive("underline"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.UNDERLINE),
|
||||
command: () => toggleUnderline(editor),
|
||||
icon: UnderlineIcon,
|
||||
});
|
||||
@@ -145,7 +128,7 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
|
||||
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({
|
||||
key: "strikethrough",
|
||||
name: "Strikethrough",
|
||||
isActive: () => editor?.isActive("strike"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.STRIKETHROUGH),
|
||||
command: () => toggleStrike(editor),
|
||||
icon: StrikethroughIcon,
|
||||
});
|
||||
@@ -153,7 +136,7 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
|
||||
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
|
||||
key: "bulleted-list",
|
||||
name: "Bulleted list",
|
||||
isActive: () => editor?.isActive("bulletList"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.BULLET_LIST),
|
||||
command: () => toggleBulletList(editor),
|
||||
icon: ListIcon,
|
||||
});
|
||||
@@ -161,7 +144,7 @@ export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list">
|
||||
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
|
||||
key: "numbered-list",
|
||||
name: "Numbered list",
|
||||
isActive: () => editor?.isActive("orderedList"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.ORDERED_LIST),
|
||||
command: () => toggleOrderedList(editor),
|
||||
icon: ListOrderedIcon,
|
||||
});
|
||||
@@ -169,7 +152,7 @@ export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"
|
||||
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
|
||||
key: "to-do-list",
|
||||
name: "To-do list",
|
||||
isActive: () => editor.isActive("taskItem"),
|
||||
isActive: () => editor.isActive(CORE_EXTENSIONS.TASK_ITEM),
|
||||
command: () => toggleTaskList(editor),
|
||||
icon: CheckSquare,
|
||||
});
|
||||
@@ -177,7 +160,7 @@ export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
|
||||
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
|
||||
key: "quote",
|
||||
name: "Quote",
|
||||
isActive: () => editor?.isActive("blockquote"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.BLOCKQUOTE),
|
||||
command: () => toggleBlockquote(editor),
|
||||
icon: TextQuote,
|
||||
});
|
||||
@@ -185,7 +168,7 @@ export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
|
||||
export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({
|
||||
key: "code",
|
||||
name: "Code",
|
||||
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.CODE_INLINE) || editor?.isActive(CORE_EXTENSIONS.CODE_BLOCK),
|
||||
command: () => toggleCodeBlock(editor),
|
||||
icon: CodeIcon,
|
||||
});
|
||||
@@ -193,7 +176,7 @@ export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({
|
||||
export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({
|
||||
key: "table",
|
||||
name: "Table",
|
||||
isActive: () => editor?.isActive("table"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.TABLE),
|
||||
command: () => insertTableCommand(editor),
|
||||
icon: TableIcon,
|
||||
});
|
||||
@@ -201,7 +184,7 @@ export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({
|
||||
export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
|
||||
key: "image",
|
||||
name: "Image",
|
||||
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.IMAGE) || editor?.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE),
|
||||
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
|
||||
icon: ImageIcon,
|
||||
});
|
||||
@@ -210,7 +193,7 @@ export const HorizontalRuleItem = (editor: Editor) =>
|
||||
({
|
||||
key: "divider",
|
||||
name: "Divider",
|
||||
isActive: () => editor?.isActive("horizontalRule"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.HORIZONTAL_RULE),
|
||||
command: () => insertHorizontalRule(editor),
|
||||
icon: MinusSquare,
|
||||
}) as const;
|
||||
@@ -218,7 +201,7 @@ export const HorizontalRuleItem = (editor: Editor) =>
|
||||
export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({
|
||||
key: "text-color",
|
||||
name: "Color",
|
||||
isActive: (props) => editor.isActive("customColor", { color: props?.color }),
|
||||
isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { color: props?.color }),
|
||||
command: (props) => {
|
||||
if (!props) return;
|
||||
toggleTextColor(props.color, editor);
|
||||
@@ -229,7 +212,7 @@ export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => (
|
||||
export const BackgroundColorItem = (editor: Editor): EditorMenuItem<"background-color"> => ({
|
||||
key: "background-color",
|
||||
name: "Background color",
|
||||
isActive: (props) => editor.isActive("customColor", { backgroundColor: props?.color }),
|
||||
isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { backgroundColor: props?.color }),
|
||||
command: (props) => {
|
||||
if (!props) return;
|
||||
toggleBackgroundColor(props.color, editor);
|
||||
|
||||
44
packages/editor/src/core/constants/extension.ts
Normal file
44
packages/editor/src/core/constants/extension.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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",
|
||||
}
|
||||
3
packages/editor/src/core/constants/meta.ts
Normal file
3
packages/editor/src/core/constants/meta.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export enum CORE_EDITOR_META {
|
||||
SKIP_FILE_DELETION = "skipFileDeletion",
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import React, { useState } from "react";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// local components
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { Node as NodeType } from "@tiptap/pm/model";
|
||||
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
|
||||
import { Node as NodeType } from "@tiptap/pm/model";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// types
|
||||
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
|
||||
// utils
|
||||
@@ -9,14 +11,14 @@ import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
|
||||
// Extend Tiptap's Commands interface
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
calloutComponent: {
|
||||
[CORE_EXTENSIONS.CALLOUT]: {
|
||||
insertCallout: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomCalloutExtensionConfig = Node.create({
|
||||
name: "calloutComponent",
|
||||
name: CORE_EXTENSIONS.CALLOUT,
|
||||
group: "block",
|
||||
content: "block+",
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// plane helpers
|
||||
import { convertHexEmojiToDecimal } from "@plane/utils";
|
||||
// plane ui
|
||||
// plane imports
|
||||
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
import { cn, convertHexEmojiToDecimal } from "@plane/utils";
|
||||
// types
|
||||
import { TCalloutBlockAttributes } from "./types";
|
||||
// utils
|
||||
|
||||
@@ -20,7 +20,7 @@ export type TCalloutBlockEmojiAttributes = {
|
||||
|
||||
export type TCalloutBlockAttributes = {
|
||||
[EAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
|
||||
[EAttributeNames.BACKGROUND]: string;
|
||||
[EAttributeNames.BACKGROUND]: string | undefined;
|
||||
[EAttributeNames.BLOCK_TYPE]: "callout-component";
|
||||
} & TCalloutBlockIconAttributes &
|
||||
TCalloutBlockEmojiAttributes;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// plane helpers
|
||||
import { sanitizeHTML } from "@plane/utils";
|
||||
// plane ui
|
||||
// plane imports
|
||||
import { TEmojiLogoProps } from "@plane/ui";
|
||||
import { sanitizeHTML } from "@plane/utils";
|
||||
// types
|
||||
import {
|
||||
EAttributeNames,
|
||||
@@ -12,11 +11,11 @@ import {
|
||||
|
||||
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
|
||||
"data-logo-in-use": "emoji",
|
||||
"data-icon-color": null,
|
||||
"data-icon-name": null,
|
||||
"data-icon-color": undefined,
|
||||
"data-icon-name": undefined,
|
||||
"data-emoji-unicode": "128161",
|
||||
"data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
|
||||
"data-background": null,
|
||||
"data-background": undefined,
|
||||
"data-block-type": "callout-component",
|
||||
};
|
||||
|
||||
@@ -32,7 +31,7 @@ export const getStoredLogo = (): TStoredLogoValue => {
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo"));
|
||||
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo") ?? "");
|
||||
if (storedData) {
|
||||
let parsedData: TEmojiLogoProps;
|
||||
try {
|
||||
@@ -69,7 +68,7 @@ export const updateStoredLogo = (value: TEmojiLogoProps): void => {
|
||||
// function to get the stored background color from local storage
|
||||
export const getStoredBackgroundColor = (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background"));
|
||||
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background") ?? "");
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Fragment, Node } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
export const MarkdownClipboard = Extension.create({
|
||||
name: "markdownClipboard",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownClipboard"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const markdownSerializer = this.editor.storage.markdown.serializer;
|
||||
const isTableRow = slice.content.firstChild?.type?.name === "tableRow";
|
||||
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
|
||||
|
||||
if (nodeSelect) {
|
||||
return markdownSerializer.serialize(slice.content);
|
||||
}
|
||||
|
||||
const processTableContent = (tableNode: Node | Fragment) => {
|
||||
let result = "";
|
||||
tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => {
|
||||
tableRowNode.content?.forEach?.((cell: Node) => {
|
||||
const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : "";
|
||||
result += cellContent + "\n";
|
||||
});
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
if (isTableRow) {
|
||||
const rowsCount = slice.content?.childCount || 0;
|
||||
const cellsCount = slice.content?.firstChild?.content?.childCount || 0;
|
||||
if (rowsCount === 1 || cellsCount === 1) {
|
||||
return processTableContent(slice.content);
|
||||
} else {
|
||||
return markdownSerializer.serialize(slice.content);
|
||||
}
|
||||
}
|
||||
|
||||
const traverseToParentOfLeaf = (
|
||||
node: Node | null,
|
||||
parent: Fragment | Node,
|
||||
depth: number
|
||||
): Node | Fragment => {
|
||||
let currentNode = node;
|
||||
let currentParent = parent;
|
||||
let currentDepth = depth;
|
||||
|
||||
while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) {
|
||||
if (currentNode.content?.childCount > 1) {
|
||||
if (currentNode.content.firstChild?.type?.name === "listItem") {
|
||||
return currentParent;
|
||||
} else {
|
||||
return currentNode.content;
|
||||
}
|
||||
}
|
||||
|
||||
currentParent = currentNode;
|
||||
currentNode = currentNode.content?.firstChild || null;
|
||||
currentDepth--;
|
||||
}
|
||||
|
||||
return currentParent;
|
||||
};
|
||||
|
||||
if (slice.content.childCount > 1) {
|
||||
return markdownSerializer.serialize(slice.content);
|
||||
} else {
|
||||
const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart);
|
||||
|
||||
let currentNode = targetNode;
|
||||
while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) {
|
||||
currentNode = currentNode.firstChild;
|
||||
}
|
||||
if (currentNode instanceof Node && currentNode.isText) {
|
||||
return currentNode.text;
|
||||
}
|
||||
|
||||
return markdownSerializer.serialize(targetNode);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export interface CodeOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -6,7 +8,7 @@ export interface CodeOptions {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
code: {
|
||||
[CORE_EXTENSIONS.CODE_INLINE]: {
|
||||
/**
|
||||
* Set a code mark
|
||||
*/
|
||||
@@ -27,7 +29,7 @@ export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/;
|
||||
const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g;
|
||||
|
||||
export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
|
||||
name: "code",
|
||||
name: CORE_EXTENSIONS.CODE_INLINE,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import { CopyIcon, CheckIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// plane utils
|
||||
@@ -27,7 +27,7 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
|
||||
await navigator.clipboard.writeText(node.textContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export interface CodeBlockOptions {
|
||||
/**
|
||||
@@ -25,7 +27,7 @@ export interface CodeBlockOptions {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
codeBlock: {
|
||||
[CORE_EXTENSIONS.CODE_BLOCK]: {
|
||||
/**
|
||||
* Set a code block
|
||||
*/
|
||||
@@ -42,7 +44,7 @@ export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
|
||||
export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
|
||||
|
||||
export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
name: "codeBlock",
|
||||
name: CORE_EXTENSIONS.CODE_BLOCK,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
@@ -118,7 +120,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
toggleCodeBlock:
|
||||
(attributes) =>
|
||||
({ commands }) =>
|
||||
commands.toggleNode(this.name, "paragraph", attributes),
|
||||
commands.toggleNode(this.name, CORE_EXTENSIONS.PARAGRAPH, attributes),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -126,7 +128,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return {
|
||||
"Mod-Alt-c": () => this.editor.commands.toggleCodeBlock(),
|
||||
|
||||
// remove code block when at start of document or code block is empty
|
||||
// remove codeBlock when at start of document or codeBlock is empty
|
||||
Backspace: () => {
|
||||
try {
|
||||
const { empty, $anchor } = this.editor.state.selection;
|
||||
@@ -259,7 +261,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.editor.isActive("code")) {
|
||||
if (this.editor.isActive(CORE_EXTENSIONS.CODE_INLINE)) {
|
||||
// Check if it's an inline code block
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
|
||||
@@ -88,7 +88,7 @@ export function LowlightPlugin({
|
||||
throw Error("You should provide an instance of lowlight to use the code-block-lowlight extension");
|
||||
}
|
||||
|
||||
const lowlightPlugin: Plugin<any> = new Plugin({
|
||||
const lowlightPlugin: Plugin = new Plugin({
|
||||
key: new PluginKey("lowlight"),
|
||||
|
||||
state: {
|
||||
|
||||
@@ -3,24 +3,24 @@ import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
// extensions
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// plane editor imports
|
||||
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
|
||||
// extensions
|
||||
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
|
||||
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
|
||||
import { CustomCodeInlineExtension } from "./code-inline";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
import { CustomLinkExtension } from "./custom-link";
|
||||
import { CustomHorizontalRule } from "./horizontal-rule";
|
||||
import { ImageExtensionWithoutProps } from "./image";
|
||||
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
|
||||
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
|
||||
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
import { CustomTextAlignExtension } from "./text-align";
|
||||
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
|
||||
import { WorkItemEmbedExtensionConfig } from "./work-item-embed/extension-config";
|
||||
|
||||
export const CoreEditorExtensionsWithoutProps = [
|
||||
StarterKit.configure({
|
||||
@@ -72,12 +72,12 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
ImageExtensionWithoutProps().configure({
|
||||
ImageExtensionWithoutProps.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageComponentWithoutProps(),
|
||||
CustomImageComponentWithoutProps,
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
@@ -104,4 +104,4 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
...CoreEditorAdditionalExtensionsWithoutProps,
|
||||
];
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
||||
export const DocumentEditorExtensionsWithoutProps = [WorkItemEmbedExtensionConfig];
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import codemark from "prosemirror-codemark";
|
||||
|
||||
export const CustomCodeMarkPlugin = Extension.create({
|
||||
name: "codemarkPlugin",
|
||||
addProseMirrorPlugins() {
|
||||
return codemark({ markType: this.editor.schema.marks.code });
|
||||
},
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
color: {
|
||||
[CORE_EXTENSIONS.CUSTOM_COLOR]: {
|
||||
/**
|
||||
* Set the text color
|
||||
* @param {string} color The color to set
|
||||
@@ -34,7 +35,7 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
|
||||
export const CustomColorExtension = Mark.create({
|
||||
name: "customColor",
|
||||
name: CORE_EXTENSIONS.CUSTOM_COLOR,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
export type CustoBaseImageNodeViewProps = {
|
||||
@@ -77,7 +80,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
failedToLoadImage={failedToLoadImage}
|
||||
getPos={getPos}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
maxFileSize={getExtensionStorage(editor, "imageComponent").maxFileSize}
|
||||
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
|
||||
node={node}
|
||||
setIsUploaded={setIsUploaded}
|
||||
selected={selected}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||
// hooks
|
||||
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
|
||||
maxFileSize: number;
|
||||
@@ -57,7 +59,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
// control cursor position after upload
|
||||
const nextNode = editor.state.doc.nodeAt(pos + 1);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If there is a paragraph node after the image component, move the focus to the next node
|
||||
editor.commands.setTextSelection(pos + 1);
|
||||
} else {
|
||||
@@ -75,7 +77,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
|
||||
editorCommand: async (file) => await editor?.commands.uploadImage(imageEntityId, file),
|
||||
handleProgressStatus: (isUploading) => {
|
||||
editor.storage.imageComponent.uploadInProgress = isUploading;
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading;
|
||||
},
|
||||
loadFileFromFileSystem: loadImageFromFileSystem,
|
||||
maxFileSize,
|
||||
@@ -85,6 +87,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
editor,
|
||||
maxFileSize,
|
||||
onInvalidFile: (_error, message) => alert(message),
|
||||
pos: getPos(),
|
||||
type: "image",
|
||||
uploader: uploadFile,
|
||||
@@ -123,6 +126,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
editor,
|
||||
filesList,
|
||||
maxFileSize,
|
||||
onInvalidFile: (_error, message) => alert(message),
|
||||
pos: getPos(),
|
||||
type: "image",
|
||||
uploader: uploadFile,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
@@ -16,7 +20,7 @@ export const ImageUploadStatus: React.FC<Props> = (props) => {
|
||||
// subscribe to image upload status
|
||||
const uploadStatus: number | undefined = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => editor.storage.imageComponent?.assetsUploadStatus[nodeId],
|
||||
selector: ({ editor }) => getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsUploadStatus?.[nodeId],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// plugins
|
||||
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
@@ -23,23 +22,21 @@ export type InsertImageComponentProps = {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
imageComponent: {
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
|
||||
updateAssetsUploadStatus?: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
|
||||
getImageSource?: (path: string) => () => Promise<string>;
|
||||
restoreImage: (src: string) => () => Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) => getExtensionStorage(editor, "imageComponent")?.fileMap;
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
|
||||
|
||||
export interface CustomImageExtensionStorage {
|
||||
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
uploadInProgress: boolean;
|
||||
maxFileSize: number;
|
||||
}
|
||||
|
||||
@@ -47,16 +44,14 @@ export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File })
|
||||
|
||||
export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const {
|
||||
assetsUploadStatus,
|
||||
getAssetSrc,
|
||||
upload,
|
||||
delete: deleteImageFn,
|
||||
restore: restoreImageFn,
|
||||
validation: { maxFileSize },
|
||||
} = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
@@ -102,41 +97,15 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name),
|
||||
];
|
||||
},
|
||||
|
||||
onCreate(this) {
|
||||
const imageSources = new Set<string>();
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === this.name) {
|
||||
if (!node.attrs.src?.startsWith("http")) return;
|
||||
imageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
imageSources.forEach(async (src) => {
|
||||
try {
|
||||
await restoreImageFn(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
assetsUploadStatus,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -152,6 +121,7 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
file: props.file,
|
||||
maxFileSize,
|
||||
onError: (_error, message) => alert(message),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
@@ -196,9 +166,6 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const fileUrl = await upload(blockId, file);
|
||||
return fileUrl;
|
||||
},
|
||||
updateAssetsUploadStatus: (updatedStatus) => () => {
|
||||
this.storage.assetsUploadStatus = updatedStatus;
|
||||
},
|
||||
getImageSource: (path) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// components
|
||||
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
// types
|
||||
@@ -9,8 +11,8 @@ import { TReadOnlyFileHandler } from "@/types";
|
||||
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc, restore: restoreImageFn } = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: false,
|
||||
group: "block",
|
||||
atom: true,
|
||||
@@ -53,13 +55,11 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize: 0,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
assetsUploadStatus: {},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Mark, markPasteRule, mergeAttributes, PasteRuleMatch } from "@tiptap/core";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
import { find, registerCustomProtocol, reset } from "linkifyjs";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { autolink } from "./helpers/autolink";
|
||||
import { clickHandler } from "./helpers/clickHandler";
|
||||
import { pasteHandler } from "./helpers/pasteHandler";
|
||||
@@ -46,7 +49,7 @@ export interface LinkOptions {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
link: {
|
||||
[CORE_EXTENSIONS.CUSTOM_LINK]: {
|
||||
/**
|
||||
* Set a link mark
|
||||
*/
|
||||
@@ -79,7 +82,7 @@ export type CustomLinkStorage = {
|
||||
};
|
||||
|
||||
export const CustomLinkExtension = Mark.create<LinkOptions, CustomLinkStorage>({
|
||||
name: "link",
|
||||
name: CORE_EXTENSIONS.CUSTOM_LINK,
|
||||
|
||||
priority: 1000,
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export function clickHandler(options: ClickHandlerOptions): Plugin {
|
||||
}
|
||||
|
||||
let a = event.target as HTMLElement;
|
||||
const els = [];
|
||||
const els: HTMLElement[] = [];
|
||||
|
||||
while (a?.nodeName !== "DIV") {
|
||||
els.push(a);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core";
|
||||
import { Node, NodeType } from "@tiptap/pm/model";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
|
||||
const { $from } = state.selection;
|
||||
const nodeType = getNodeType(typeOrName, state.schema);
|
||||
|
||||
let currentNode = null;
|
||||
let currentNode: Node | null = null;
|
||||
let currentDepth = $from.depth;
|
||||
let currentPos = $from.pos;
|
||||
let targetDepth: number | null = null;
|
||||
@@ -72,7 +74,11 @@ const getPrevListDepth = (typeOrName: string, state: EditorState) => {
|
||||
// Traverse up the document structure from the adjusted position
|
||||
for (let d = resolvedPos.depth; d > 0; d--) {
|
||||
const node = resolvedPos.node(d);
|
||||
if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") {
|
||||
if (
|
||||
[CORE_EXTENSIONS.BULLET_LIST, CORE_EXTENSIONS.ORDERED_LIST, CORE_EXTENSIONS.TASK_LIST].includes(
|
||||
node.type.name as CORE_EXTENSIONS
|
||||
)
|
||||
) {
|
||||
// Increment depth for each list ancestor found
|
||||
depth++;
|
||||
}
|
||||
@@ -309,12 +315,12 @@ const isCurrentParagraphASibling = (state: EditorState): boolean => {
|
||||
|
||||
// Ensure we're in a paragraph and the parent is a list item.
|
||||
if (
|
||||
currentParagraphNode.type.name === "paragraph" &&
|
||||
(listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem")
|
||||
currentParagraphNode.type.name === CORE_EXTENSIONS.PARAGRAPH &&
|
||||
[CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(listItemNode.type.name as CORE_EXTENSIONS)
|
||||
) {
|
||||
let paragraphNodesCount = 0;
|
||||
listItemNode.forEach((child) => {
|
||||
if (child.type.name === "paragraph") {
|
||||
if (child.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
paragraphNodesCount++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { handleBackspace, handleDelete } from "@/extensions/custom-list-keymap/list-helpers";
|
||||
|
||||
@@ -31,10 +33,10 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) {
|
||||
if (this.editor.commands.sinkListItem("listItem")) {
|
||||
if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.LIST_ITEM)) {
|
||||
return true;
|
||||
} else if (this.editor.commands.sinkListItem("taskItem")) {
|
||||
} else if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
@@ -46,9 +48,9 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
return true;
|
||||
},
|
||||
"Shift-Tab": () => {
|
||||
if (this.editor.commands.liftListItem("listItem")) {
|
||||
if (this.editor.commands.liftListItem(CORE_EXTENSIONS.LIST_ITEM)) {
|
||||
return true;
|
||||
} else if (this.editor.commands.liftListItem("taskItem")) {
|
||||
} else if (this.editor.commands.liftListItem(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
return true;
|
||||
}
|
||||
// if tabIndex is set, we don't want to handle Tab key
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Extension, Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
// types
|
||||
import { TEditorCommands } from "@/types";
|
||||
|
||||
export const DropHandlerExtension = Extension.create({
|
||||
name: "dropHandler",
|
||||
priority: 1000,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("drop-handler-plugin"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
if (
|
||||
editor.isEditable &&
|
||||
event.clipboardData &&
|
||||
event.clipboardData.files &&
|
||||
event.clipboardData.files.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
const acceptedFiles = files.filter(
|
||||
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
|
||||
);
|
||||
|
||||
if (acceptedFiles.length) {
|
||||
const pos = view.state.selection.from;
|
||||
insertFilesSafely({
|
||||
editor,
|
||||
files: acceptedFiles,
|
||||
initialPos: pos,
|
||||
event: "drop",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view, event, _slice, moved) => {
|
||||
if (
|
||||
editor.isEditable &&
|
||||
!moved &&
|
||||
event.dataTransfer &&
|
||||
event.dataTransfer.files &&
|
||||
event.dataTransfer.files.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
const acceptedFiles = files.filter(
|
||||
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
|
||||
);
|
||||
|
||||
if (acceptedFiles.length) {
|
||||
const coordinates = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (coordinates) {
|
||||
const pos = coordinates.pos;
|
||||
insertFilesSafely({
|
||||
editor,
|
||||
files: acceptedFiles,
|
||||
initialPos: pos,
|
||||
event: "drop",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
type InsertFilesSafelyArgs = {
|
||||
editor: Editor;
|
||||
event: "insert" | "drop";
|
||||
files: File[];
|
||||
initialPos: number;
|
||||
type?: Extract<TEditorCommands, "attachment" | "image">;
|
||||
};
|
||||
|
||||
export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
|
||||
const { editor, event, files, initialPos, type } = args;
|
||||
let pos = initialPos;
|
||||
|
||||
for (const file of files) {
|
||||
// safe insertion
|
||||
const docSize = editor.state.doc.content.size;
|
||||
pos = Math.min(pos, docSize);
|
||||
|
||||
let fileType: "image" | "attachment" | null = null;
|
||||
|
||||
try {
|
||||
if (type) {
|
||||
if (["image", "attachment"].includes(type)) fileType = type;
|
||||
else throw new Error("Wrong file type passed");
|
||||
} else {
|
||||
if (ACCEPTED_IMAGE_MIME_TYPES.includes(file.type)) fileType = "image";
|
||||
else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment";
|
||||
}
|
||||
// insert file depending on the type at the current position
|
||||
if (fileType === "image") {
|
||||
editor.commands.insertImageComponent({
|
||||
file,
|
||||
pos,
|
||||
event,
|
||||
});
|
||||
} else if (fileType === "attachment") {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error while ${event}ing file:`, error);
|
||||
}
|
||||
|
||||
// Move to the next position
|
||||
pos += 1;
|
||||
}
|
||||
};
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
||||
Extension.create({
|
||||
name: "enterKey",
|
||||
name: CORE_EXTENSIONS.ENTER_KEY,
|
||||
|
||||
addKeyboardShortcuts(this) {
|
||||
return {
|
||||
Enter: () => {
|
||||
if (!this.editor.storage.mentionsOpen) {
|
||||
if (onEnterKeyPress) {
|
||||
onEnterKeyPress();
|
||||
}
|
||||
const isMentionOpen = getExtensionStorage(this.editor, CORE_EXTENSIONS.MENTION)?.mentionsOpen;
|
||||
if (!isMentionOpen) {
|
||||
onEnterKeyPress?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -18,8 +21,8 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
||||
"Shift-Enter": ({ editor }) =>
|
||||
editor.commands.first(({ commands }) => [
|
||||
() => commands.newlineInCode(),
|
||||
() => commands.splitListItem("listItem"),
|
||||
() => commands.splitListItem("taskItem"),
|
||||
() => commands.splitListItem(CORE_EXTENSIONS.LIST_ITEM),
|
||||
() => commands.splitListItem(CORE_EXTENSIONS.TASK_ITEM),
|
||||
() => commands.createParagraphNear(),
|
||||
() => commands.liftEmptyBlock(),
|
||||
() => commands.splitBlock(),
|
||||
@@ -7,12 +7,13 @@ import TextStyle from "@tiptap/extension-text-style";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import {
|
||||
CustomCalloutExtension,
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
CustomCodeMarkPlugin,
|
||||
CustomColorExtension,
|
||||
CustomHorizontalRule,
|
||||
CustomImageExtension,
|
||||
@@ -22,17 +23,17 @@ import {
|
||||
CustomQuoteExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomTypographyExtension,
|
||||
DropHandlerExtension,
|
||||
ImageExtension,
|
||||
ListKeymap,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
MarkdownClipboard,
|
||||
UtilityExtension,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
@@ -49,7 +50,7 @@ type TArguments = {
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args;
|
||||
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex, editable } = args;
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -89,7 +90,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
...(enableHistory ? {} : { history: false }),
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
DropHandlerExtension,
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: {
|
||||
class: "py-4 border-custom-border-400",
|
||||
@@ -127,7 +127,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
class: "",
|
||||
},
|
||||
}),
|
||||
CustomCodeMarkPlugin,
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
@@ -135,7 +134,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
transformPastedText: true,
|
||||
breaks: true,
|
||||
}),
|
||||
MarkdownClipboard,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
@@ -145,15 +143,17 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
placeholder: ({ editor, node }) => {
|
||||
if (!editor.isEditable) return "";
|
||||
|
||||
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
||||
if (node.type.name === CORE_EXTENSIONS.HEADING) return `Heading ${node.attrs.level}`;
|
||||
|
||||
if (editor.storage.imageComponent?.uploadInProgress) return "";
|
||||
const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress;
|
||||
|
||||
if (isUploadInProgress) return "";
|
||||
|
||||
const shouldHidePlaceholder =
|
||||
editor.isActive("table") ||
|
||||
editor.isActive("codeBlock") ||
|
||||
editor.isActive("image") ||
|
||||
editor.isActive("imageComponent");
|
||||
editor.isActive(CORE_EXTENSIONS.TABLE) ||
|
||||
editor.isActive(CORE_EXTENSIONS.CODE_BLOCK) ||
|
||||
editor.isActive(CORE_EXTENSIONS.IMAGE) ||
|
||||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE);
|
||||
|
||||
if (shouldHidePlaceholder) return "";
|
||||
|
||||
@@ -169,6 +169,10 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
CharacterCount,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutExtension,
|
||||
UtilityExtension({
|
||||
isEditable: editable,
|
||||
fileHandler,
|
||||
}),
|
||||
CustomColorExtension,
|
||||
...CoreEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export interface IMarking {
|
||||
type: "heading";
|
||||
@@ -12,8 +14,8 @@ export type HeadingExtensionStorage = {
|
||||
headings: IMarking[];
|
||||
};
|
||||
|
||||
export const HeadingListExtension = Extension.create<any, HeadingExtensionStorage>({
|
||||
name: "headingList",
|
||||
export const HeadingListExtension = Extension.create<unknown, HeadingExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.HEADINGS_LIST,
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isNodeSelection, mergeAttributes, Node, nodeInputRule } from "@tiptap/core";
|
||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export interface HorizontalRuleOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -7,7 +9,7 @@ export interface HorizontalRuleOptions {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
horizontalRule: {
|
||||
[CORE_EXTENSIONS.HORIZONTAL_RULE]: {
|
||||
/**
|
||||
* Add a horizontal rule
|
||||
*/
|
||||
@@ -17,7 +19,7 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
|
||||
export const CustomHorizontalRule = Node.create<HorizontalRuleOptions>({
|
||||
name: "horizontalRule",
|
||||
name: CORE_EXTENSIONS.HORIZONTAL_RULE,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import ImageExt from "@tiptap/extension-image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// plugins
|
||||
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
export type ImageExtensionStorage = {
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
};
|
||||
|
||||
export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||
const {
|
||||
getAssetSrc,
|
||||
delete: deleteImageFn,
|
||||
restore: restoreImageFn,
|
||||
validation: { maxFileSize },
|
||||
} = fileHandler;
|
||||
|
||||
return ImageExt.extend<any, ImageExtensionStorage>({
|
||||
return BaseImageExtension.extend<unknown, ImageExtensionStorage>({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
@@ -25,36 +25,10 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name),
|
||||
];
|
||||
},
|
||||
|
||||
onCreate(this) {
|
||||
const imageSources = new Set<string>();
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === this.name) {
|
||||
if (!node.attrs.src?.startsWith("http")) return;
|
||||
|
||||
imageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
imageSources.forEach(async (src) => {
|
||||
try {
|
||||
await restoreImageFn(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// storage to keep track of image states Map<src, isDeleted>
|
||||
addStorage() {
|
||||
return {
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,58 +1,56 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
// extensions
|
||||
import { ImageExtensionStorage } from "@/plugins/image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
// local imports
|
||||
import { ImageExtensionStorage } from "./extension";
|
||||
|
||||
export const CustomImageComponentWithoutProps = () =>
|
||||
Image.extend<Record<string, unknown>, ImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
export const CustomImageComponentWithoutProps = BaseImageExtension.extend<
|
||||
Record<string, unknown>,
|
||||
ImageExtensionStorage
|
||||
>({
|
||||
name: "imageComponent",
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize: 0,
|
||||
assetsUploadStatus: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default CustomImageComponentWithoutProps;
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import ImageExt from "@tiptap/extension-image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
|
||||
export const ImageExtensionWithoutProps = () =>
|
||||
ImageExt.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
export const ImageExtensionWithoutProps = BaseImageExtension.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
@@ -8,7 +8,7 @@ import { TReadOnlyFileHandler } from "@/types";
|
||||
export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc } = props;
|
||||
|
||||
return Image.extend({
|
||||
return BaseImageExtension.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
|
||||
@@ -5,22 +5,20 @@ export * from "./custom-image";
|
||||
export * from "./custom-link";
|
||||
export * from "./custom-list-keymap";
|
||||
export * from "./image";
|
||||
export * from "./issue-embed";
|
||||
export * from "./mentions";
|
||||
export * from "./slash-commands";
|
||||
export * from "./table";
|
||||
export * from "./typography";
|
||||
export * from "./work-item-embed";
|
||||
export * from "./core-without-props";
|
||||
export * from "./custom-code-inline";
|
||||
export * from "./custom-color";
|
||||
export * from "./drop";
|
||||
export * from "./enter-key-extension";
|
||||
export * from "./enter-key";
|
||||
export * from "./extensions";
|
||||
export * from "./headers";
|
||||
export * from "./headings-list";
|
||||
export * from "./horizontal-rule";
|
||||
export * from "./keymap";
|
||||
export * from "./quote";
|
||||
export * from "./read-only-extensions";
|
||||
export * from "./side-menu";
|
||||
export * from "./text-align";
|
||||
export * from "./clipboard";
|
||||
export * from "./utility";
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./widget-node";
|
||||
export * from "./issue-embed-without-props";
|
||||
@@ -1,41 +0,0 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
export const IssueWidgetWithoutProps = () =>
|
||||
Node.create({
|
||||
name: "issue-embed-component",
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
entity_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
project_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
workspace_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
id: {
|
||||
default: undefined,
|
||||
},
|
||||
entity_name: {
|
||||
default: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "issue-embed-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
|
||||
type Props = {
|
||||
widgetCallback: ({
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
issueId: string;
|
||||
projectId: string | undefined;
|
||||
workspaceSlug: string | undefined;
|
||||
}) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const IssueWidget = (props: Props) =>
|
||||
Node.create({
|
||||
name: "issue-embed-component",
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
entity_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
project_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
workspace_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
id: {
|
||||
default: undefined,
|
||||
},
|
||||
entity_name: {
|
||||
default: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((issueProps: any) => (
|
||||
<NodeViewWrapper>
|
||||
{props.widgetCallback({
|
||||
issueId: issueProps.node.attrs.entity_identifier,
|
||||
projectId: issueProps.node.attrs.project_identifier,
|
||||
workspaceSlug: issueProps.node.attrs.workspace_identifier,
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
));
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "issue-embed-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
@@ -2,11 +2,13 @@ import { Extension } from "@tiptap/core";
|
||||
import { NodeType } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
import { canJoin } from "@tiptap/pm/transform";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
interface Commands<ReturnType> {
|
||||
customkeymap: {
|
||||
customKeymap: {
|
||||
/**
|
||||
* Select text between node boundaries
|
||||
*/
|
||||
@@ -59,7 +61,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) {
|
||||
}
|
||||
|
||||
export const CustomKeymap = Extension.create({
|
||||
name: "CustomKeymap",
|
||||
name: "customKeymap",
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
@@ -87,9 +89,9 @@ export const CustomKeymap = Extension.create({
|
||||
const newTr = newState.tr;
|
||||
|
||||
const joinableNodes = [
|
||||
newState.schema.nodes["orderedList"],
|
||||
newState.schema.nodes["taskList"],
|
||||
newState.schema.nodes["bulletList"],
|
||||
newState.schema.nodes[CORE_EXTENSIONS.ORDERED_LIST],
|
||||
newState.schema.nodes[CORE_EXTENSIONS.TASK_LIST],
|
||||
newState.schema.nodes[CORE_EXTENSIONS.BULLET_LIST],
|
||||
];
|
||||
|
||||
let joined = false;
|
||||
@@ -18,7 +18,7 @@ export const MentionNodeView = (props: Props) => {
|
||||
return (
|
||||
<NodeViewWrapper className="mention-component inline w-fit">
|
||||
{(extension.options as TMentionExtensionOptions).renderComponent({
|
||||
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER],
|
||||
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER] ?? "",
|
||||
entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention",
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
@@ -61,7 +61,9 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps
|
||||
sections,
|
||||
selectedIndex,
|
||||
});
|
||||
setSelectedIndex(newIndex);
|
||||
if (newIndex) {
|
||||
setSelectedIndex(newIndex);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -79,7 +81,9 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const sectionsResponse = await searchCallback?.(query);
|
||||
setSections(sectionsResponse);
|
||||
if (sectionsResponse) {
|
||||
setSections(sectionsResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch suggestions:", error);
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import tippy from "tippy.js";
|
||||
import { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
// helpers
|
||||
import { CommandListInstance } from "@/helpers/tippy";
|
||||
// types
|
||||
@@ -15,7 +15,7 @@ export const renderMentionsDropdown =
|
||||
() => {
|
||||
const { searchCallback } = props;
|
||||
let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | null = null;
|
||||
let popup: any | null = null;
|
||||
let popup: Instance | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Blockquote from "@tiptap/extension-blockquote";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export const CustomQuoteExtension = Blockquote.extend({
|
||||
addKeyboardShortcuts() {
|
||||
@@ -10,7 +12,7 @@ export const CustomQuoteExtension = Blockquote.extend({
|
||||
|
||||
if (!parent) return false;
|
||||
|
||||
if (parent.type.name !== "blockquote") {
|
||||
if (parent.type.name !== CORE_EXTENSIONS.BLOCKQUOTE) {
|
||||
return false;
|
||||
}
|
||||
if ($from.pos !== $to.pos) return false;
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutReadOnlyExtension,
|
||||
CustomColorExtension,
|
||||
MarkdownClipboard,
|
||||
UtilityExtension,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
@@ -117,7 +117,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
html: true,
|
||||
transformCopiedText: false,
|
||||
}),
|
||||
MarkdownClipboard,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
@@ -127,6 +126,10 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
CustomColorExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutReadOnlyExtension,
|
||||
UtilityExtension({
|
||||
isEditable: false,
|
||||
fileHandler,
|
||||
}),
|
||||
...CoreReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
}),
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// plugins
|
||||
import { AIHandlePlugin } from "@/plugins/ai-handle";
|
||||
import { DragHandlePlugin, nodeDOMAtCoords } from "@/plugins/drag-handle";
|
||||
@@ -33,7 +35,7 @@ export const SideMenuExtension = (props: Props) => {
|
||||
const { aiEnabled, dragDropEnabled } = props;
|
||||
|
||||
return Extension.create({
|
||||
name: "editorSideMenu",
|
||||
name: CORE_EXTENSIONS.SIDE_MENU,
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
SideMenu({
|
||||
@@ -26,22 +26,17 @@ import {
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleTaskList,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingTwo,
|
||||
toggleHeadingThree,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingSix,
|
||||
toggleHeading,
|
||||
toggleTextColor,
|
||||
toggleBackgroundColor,
|
||||
insertImage,
|
||||
insertCallout,
|
||||
setText,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
|
||||
// plane editor extensions
|
||||
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
|
||||
// local types
|
||||
import { TExtensionProps, TSlashCommandAdditionalOption } from "./root";
|
||||
|
||||
@@ -75,7 +70,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingOne(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 1, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h2",
|
||||
@@ -84,7 +79,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingTwo(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 2, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h3",
|
||||
@@ -93,7 +88,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingThree(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 3, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h4",
|
||||
@@ -102,7 +97,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading4 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingFour(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 4, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h5",
|
||||
@@ -111,7 +106,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading5 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingFive(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 5, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h6",
|
||||
@@ -120,7 +115,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading6 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingSix(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 6, range),
|
||||
},
|
||||
{
|
||||
commandKey: "to-do-list",
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
// helpers
|
||||
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
|
||||
// components
|
||||
import { ISlashCommandItem } from "@/types";
|
||||
import { TSlashCommandSection } from "./command-items-list";
|
||||
import { CommandMenuItem } from "./command-menu-item";
|
||||
|
||||
export type SlashCommandsMenuProps = {
|
||||
editor: Editor;
|
||||
items: TSlashCommandSection[];
|
||||
command: any;
|
||||
command: (item: ISlashCommandItem) => void;
|
||||
};
|
||||
|
||||
export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => {
|
||||
@@ -103,7 +104,9 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref)
|
||||
sections,
|
||||
selectedIndex,
|
||||
});
|
||||
setSelectedIndex(newIndex);
|
||||
if (newIndex) {
|
||||
setSelectedIndex(newIndex);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Editor, Range, Extension } from "@tiptap/core";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { CommandListInstance } from "@/helpers/tippy";
|
||||
// types
|
||||
@@ -20,7 +22,7 @@ export type TSlashCommandAdditionalOption = ISlashCommandItem & {
|
||||
};
|
||||
|
||||
const Command = Extension.create<SlashCommandOptions>({
|
||||
name: "slash-command",
|
||||
name: CORE_EXTENSIONS.SLASH_COMMANDS,
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
@@ -34,11 +36,11 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
const parentNode = selection.$from.node(selection.$from.depth);
|
||||
const blockType = parentNode.type.name;
|
||||
|
||||
if (blockType === "codeBlock") {
|
||||
if (blockType === CORE_EXTENSIONS.CODE_BLOCK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editor.isActive("table")) {
|
||||
if (editor.isActive(CORE_EXTENSIONS.TABLE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -59,7 +61,7 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
|
||||
let popup: any | null = null;
|
||||
let popup: Instance | null = null;
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
export interface TableCellOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const TableCell = Node.create<TableCellOptions>({
|
||||
name: "tableCell",
|
||||
name: CORE_EXTENSIONS.TABLE_CELL,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
@@ -1 +0,0 @@
|
||||
export { TableCell } from "./table-cell";
|
||||
@@ -1,11 +1,12 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
export interface TableHeaderOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
name: "tableHeader",
|
||||
name: CORE_EXTENSIONS.TABLE_HEADER,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
@@ -1 +0,0 @@
|
||||
export { TableHeader } from "./table-header";
|
||||
@@ -1,11 +1,13 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export interface TableRowOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const TableRow = Node.create<TableRowOptions>({
|
||||
name: "tableRow",
|
||||
name: CORE_EXTENSIONS.TABLE_ROW,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
@@ -1 +0,0 @@
|
||||
export { TableRow } from "./table-row";
|
||||
@@ -1,6 +1,8 @@
|
||||
import { findParentNode } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { Plugin, PluginKey, TextSelection, Transaction } from "@tiptap/pm/state";
|
||||
import { DecorationSet, Decoration } from "@tiptap/pm/view";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
const key = new PluginKey("tableControls");
|
||||
|
||||
@@ -17,16 +19,14 @@ export function tableControls() {
|
||||
},
|
||||
props: {
|
||||
handleTripleClickOn(view, pos, node, nodePos, event, direct) {
|
||||
if (node.type.name === 'tableCell') {
|
||||
if (node.type.name === CORE_EXTENSIONS.TABLE_CELL) {
|
||||
event.preventDefault();
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
const line = $pos.parent;
|
||||
const linePos = $pos.start();
|
||||
const start = linePos;
|
||||
const end = linePos + line.nodeSize - 1;
|
||||
const tr = view.state.tr.setSelection(
|
||||
TextSelection.create(view.state.doc, start, end)
|
||||
);
|
||||
const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, start, end));
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
@@ -52,12 +52,12 @@ export function tableControls() {
|
||||
|
||||
if (!pos || pos.pos < 0 || pos.pos > view.state.doc.content.size) return;
|
||||
|
||||
const table = findParentNode((node) => node.type.name === "table")(
|
||||
TextSelection.create(view.state.doc, pos.pos)
|
||||
);
|
||||
const cell = findParentNode((node) => node.type.name === "tableCell" || node.type.name === "tableHeader")(
|
||||
const table = findParentNode((node) => node.type.name === CORE_EXTENSIONS.TABLE)(
|
||||
TextSelection.create(view.state.doc, pos.pos)
|
||||
);
|
||||
const cell = findParentNode((node) =>
|
||||
[CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS)
|
||||
)(TextSelection.create(view.state.doc, pos.pos));
|
||||
|
||||
if (!table || !cell) return;
|
||||
|
||||
@@ -112,7 +112,7 @@ class TableControlsState {
|
||||
};
|
||||
}
|
||||
|
||||
apply(tr: any) {
|
||||
apply(tr: Transaction) {
|
||||
const actions = tr.getMeta(key);
|
||||
|
||||
if (actions?.setHoveredTable !== undefined) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { h } from "jsx-dom-cjs";
|
||||
import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model";
|
||||
import { Decoration, NodeView } from "@tiptap/pm/view";
|
||||
import tippy, { Instance, Props } from "tippy.js";
|
||||
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model";
|
||||
import { CellSelection, TableMap, updateColumnsOnResize } from "@tiptap/pm/tables";
|
||||
|
||||
import { Decoration, NodeView } from "@tiptap/pm/view";
|
||||
import { h } from "jsx-dom-cjs";
|
||||
import { icons } from "src/core/extensions/table/table/icons";
|
||||
import tippy, { Instance, Props } from "tippy.js";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
type ToolboxItem = {
|
||||
label: string;
|
||||
@@ -30,10 +30,10 @@ export function updateColumns(
|
||||
if (!row) return;
|
||||
|
||||
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
||||
const { colspan, colwidth } = row.child(i).attrs;
|
||||
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 hasWidth = overrideCol === col ? overrideValue : colWidth && colWidth[j];
|
||||
const cssWidth = hasWidth ? `${hasWidth}px` : "";
|
||||
|
||||
totalWidth += hasWidth || cellMinWidth;
|
||||
@@ -85,7 +85,7 @@ function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: strin
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
|
||||
background: color.backgroundColor,
|
||||
textColor: color.textColor,
|
||||
})
|
||||
@@ -104,12 +104,12 @@ function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: st
|
||||
|
||||
// Find the depth of the table row node
|
||||
let rowDepth = hoveredCell.depth;
|
||||
while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||
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 !== "tableRow") {
|
||||
if (hoveredCell.node(rowDepth).type.name !== CORE_EXTENSIONS.TABLE_ROW) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,14 @@ import {
|
||||
toggleHeader,
|
||||
toggleHeaderCell,
|
||||
} from "@tiptap/pm/tables";
|
||||
|
||||
import { tableControls } from "@/extensions/table/table/table-controls";
|
||||
import { TableView } from "@/extensions/table/table/table-view";
|
||||
import { createTable } from "@/extensions/table/table/utilities/create-table";
|
||||
import { deleteTableWhenAllCellsSelected } from "@/extensions/table/table/utilities/delete-table-when-all-cells-selected";
|
||||
import { Decoration } from "@tiptap/pm/view";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { tableControls } from "./table-controls";
|
||||
import { TableView } from "./table-view";
|
||||
import { createTable } from "./utilities/create-table";
|
||||
import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected";
|
||||
import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action";
|
||||
import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action";
|
||||
|
||||
@@ -38,7 +41,7 @@ export interface TableOptions {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
table: {
|
||||
[CORE_EXTENSIONS.TABLE]: {
|
||||
insertTable: (options?: {
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
@@ -79,7 +82,7 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
|
||||
export const Table = Node.create({
|
||||
name: "table",
|
||||
name: CORE_EXTENSIONS.TABLE,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
@@ -219,8 +222,8 @@ export const Table = Node.create({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.isActive("table")) {
|
||||
if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) {
|
||||
if (this.editor.isActive(CORE_EXTENSIONS.TABLE)) {
|
||||
if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
return false;
|
||||
}
|
||||
if (this.editor.commands.goToNextCell()) {
|
||||
@@ -249,7 +252,7 @@ export const Table = Node.create({
|
||||
return ({ editor, getPos, node, decorations }) => {
|
||||
const { cellMinWidth } = this.options;
|
||||
|
||||
return new TableView(node, cellMinWidth, decorations as any, editor, getPos as () => number);
|
||||
return new TableView(node, cellMinWidth, decorations as Decoration[], editor, getPos as () => number);
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
|
||||
@@ -10,14 +12,17 @@ export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ edito
|
||||
}
|
||||
|
||||
let cellCount = 0;
|
||||
const table = findParentNodeClosestToPos(selection.ranges[0].$from, (node) => node.type.name === "table");
|
||||
const table = findParentNodeClosestToPos(
|
||||
selection.ranges[0].$from,
|
||||
(node) => node.type.name === CORE_EXTENSIONS.TABLE
|
||||
);
|
||||
|
||||
table?.node.descendants((node) => {
|
||||
if (node.type.name === "table") {
|
||||
if (node.type.name === CORE_EXTENSIONS.TABLE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (["tableCell", "tableHeader"].includes(node.type.name)) {
|
||||
if ([CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS)) {
|
||||
cellCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { findParentNodeOfType } from "@/helpers/common";
|
||||
|
||||
export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
// Check if the current selection or the closest node is a table
|
||||
if (!editor.isActive("table")) return false;
|
||||
if (!editor.isActive(CORE_EXTENSIONS.TABLE)) return false;
|
||||
|
||||
try {
|
||||
// Get the current selection
|
||||
const { selection } = editor.state;
|
||||
|
||||
// Find the table node and its position
|
||||
const tableNode = findParentNodeOfType(selection, "table");
|
||||
const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE);
|
||||
if (!tableNode) return false;
|
||||
|
||||
const tablePos = tableNode.pos;
|
||||
@@ -39,7 +41,7 @@ export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor })
|
||||
|
||||
const prevNode = editor.state.doc.nodeAt(prevNodePos - 1);
|
||||
|
||||
if (prevNode && prevNode.type.name === "paragraph") {
|
||||
if (prevNode && prevNode.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If there's a paragraph before the table, move the cursor to the end of that paragraph
|
||||
const endOfParagraphPos = tablePos - prevNode.nodeSize;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { findParentNodeOfType } from "@/helpers/common";
|
||||
|
||||
export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
// Check if the current selection or the closest node is a table
|
||||
if (!editor.isActive("table")) return false;
|
||||
if (!editor.isActive(CORE_EXTENSIONS.TABLE)) return false;
|
||||
|
||||
try {
|
||||
// Get the current selection
|
||||
const { selection } = editor.state;
|
||||
|
||||
// Find the table node and its position
|
||||
const tableNode = findParentNodeOfType(selection, "table");
|
||||
const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE);
|
||||
if (!tableNode) return false;
|
||||
|
||||
const tablePos = tableNode.pos;
|
||||
@@ -31,13 +33,13 @@ export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor })
|
||||
// Check for an existing node immediately after the table
|
||||
const nextNode = editor.state.doc.nodeAt(nextNodePos);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If the next node is an paragraph, move the cursor there
|
||||
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
} else if (!nextNode) {
|
||||
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
|
||||
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
|
||||
editor.chain().insertContentAt(nextNodePos, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(nextNodePos + 1)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Extension, InputRule } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import {
|
||||
TypographyOptions,
|
||||
emDash,
|
||||
@@ -23,7 +25,7 @@ import {
|
||||
} from "./inputRules";
|
||||
|
||||
export const CustomTypographyExtension = Extension.create<TypographyOptions>({
|
||||
name: "typography",
|
||||
name: CORE_EXTENSIONS.TYPOGRAPHY,
|
||||
|
||||
addInputRules() {
|
||||
const rules: InputRule[] = [];
|
||||
|
||||
71
packages/editor/src/core/extensions/utility.ts
Normal file
71
packages/editor/src/core/extensions/utility.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
// prosemirror plugins
|
||||
import codemark from "prosemirror-codemark";
|
||||
// helpers
|
||||
import { restorePublicImages } from "@/helpers/image-helpers";
|
||||
// plugins
|
||||
import { DropHandlerPlugin } from "@/plugins/drop";
|
||||
import { FilePlugins } from "@/plugins/file/root";
|
||||
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
|
||||
// types
|
||||
import { TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands {
|
||||
utility: {
|
||||
updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface UtilityExtensionStorage {
|
||||
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
|
||||
uploadInProgress: boolean;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
fileHandler: TFileHandler | TReadOnlyFileHandler;
|
||||
isEditable: boolean;
|
||||
};
|
||||
|
||||
export const UtilityExtension = (props: Props) => {
|
||||
const { fileHandler, isEditable } = props;
|
||||
const { restore: restoreImageFn } = fileHandler;
|
||||
|
||||
return Extension.create<Record<string, unknown>, UtilityExtensionStorage>({
|
||||
name: "utility",
|
||||
priority: 1000,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...FilePlugins({
|
||||
editor: this.editor,
|
||||
isEditable,
|
||||
fileHandler,
|
||||
}),
|
||||
...codemark({ markType: this.editor.schema.marks.code }),
|
||||
MarkdownClipboardPlugin(this.editor),
|
||||
DropHandlerPlugin(this.editor),
|
||||
];
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
restorePublicImages(this.editor, restoreImageFn);
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
assetsUploadStatus: isEditable && "assetsUploadStatus" in fileHandler ? fileHandler.assetsUploadStatus : {},
|
||||
uploadInProgress: false,
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
updateAssetsUploadStatus: (updatedStatus) => () => {
|
||||
this.storage.assetsUploadStatus = updatedStatus;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export const WorkItemEmbedExtensionConfig = Node.create({
|
||||
name: CORE_EXTENSIONS.WORK_ITEM_EMBED,
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
entity_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
project_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
workspace_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
id: {
|
||||
default: undefined,
|
||||
},
|
||||
entity_name: {
|
||||
default: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "issue-embed-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
// local imports
|
||||
import { WorkItemEmbedExtensionConfig } from "./extension-config";
|
||||
|
||||
type Props = {
|
||||
widgetCallback: ({
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
issueId: string;
|
||||
projectId: string | undefined;
|
||||
workspaceSlug: string | undefined;
|
||||
}) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const WorkItemEmbedExtension = (props: Props) =>
|
||||
WorkItemEmbedExtensionConfig.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((issueProps: any) => (
|
||||
<NodeViewWrapper>
|
||||
{props.widgetCallback({
|
||||
issueId: issueProps.node.attrs.entity_identifier,
|
||||
projectId: issueProps.node.attrs.project_identifier,
|
||||
workspaceSlug: issueProps.node.attrs.workspace_identifier,
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./extension";
|
||||
@@ -1,6 +1,8 @@
|
||||
import { EditorState, Selection } from "@tiptap/pm/state";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
interface EditorClassNames {
|
||||
noBorder?: boolean;
|
||||
@@ -67,7 +69,7 @@ export const isValidHttpUrl = (string: string): { isValid: boolean; url: string
|
||||
url: string,
|
||||
};
|
||||
}
|
||||
} catch (_) {
|
||||
} catch {
|
||||
// Original string wasn't a valid URL - that's okay, we'll try with https
|
||||
}
|
||||
|
||||
@@ -79,7 +81,7 @@ export const isValidHttpUrl = (string: string): { isValid: boolean; url: string
|
||||
isValid: true,
|
||||
url: urlWithHttps,
|
||||
};
|
||||
} catch (_) {
|
||||
} catch {
|
||||
return {
|
||||
isValid: false,
|
||||
url: string,
|
||||
@@ -91,7 +93,7 @@ export const getParagraphCount = (editorState: EditorState | undefined) => {
|
||||
if (!editorState) return 0;
|
||||
let paragraphCount = 0;
|
||||
editorState.doc.descendants((node) => {
|
||||
if (node.type.name === "paragraph" && node.content.size > 0) paragraphCount++;
|
||||
if (node.type.name === CORE_EXTENSIONS.PARAGRAPH && node.content.size > 0) paragraphCount++;
|
||||
});
|
||||
return paragraphCount;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Editor, Range } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { InsertImageComponentProps } from "@/extensions";
|
||||
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
|
||||
@@ -6,44 +8,14 @@ import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-
|
||||
import { findTableAncestor } from "@/helpers/common";
|
||||
|
||||
export const setText = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("paragraph").run();
|
||||
else editor.chain().focus().setNode("paragraph").run();
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode(CORE_EXTENSIONS.PARAGRAPH).run();
|
||||
else editor.chain().focus().setNode(CORE_EXTENSIONS.PARAGRAPH).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||
export const toggleHeading = (editor: Editor, level: 1 | 2 | 3 | 4 | 5 | 6, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode(CORE_EXTENSIONS.HEADING, { level }).run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleHeading({ level: 3 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingFour = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 4 }).run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleHeading({ level: 4 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingFive = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 5 }).run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleHeading({ level: 5 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingSix = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 6 }).run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleHeading({ level: 6 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level }).run();
|
||||
};
|
||||
|
||||
export const toggleBold = (editor: Editor, range?: Range) => {
|
||||
@@ -68,7 +40,7 @@ export const toggleUnderline = (editor: Editor, range?: Range) => {
|
||||
export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
try {
|
||||
// if it's a code block, replace it with the code with paragraphs
|
||||
if (editor.isActive("codeBlock")) {
|
||||
if (editor.isActive(CORE_EXTENSIONS.CODE_BLOCK)) {
|
||||
replaceCodeWithText(editor);
|
||||
return;
|
||||
}
|
||||
@@ -77,12 +49,12 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
const text = editor.state.doc.textBetween(from, to, "\n");
|
||||
const isMultiline = text.includes("\n");
|
||||
|
||||
// if the selection is not a range i.e. empty, then simply convert it into a code block
|
||||
// if the selection is not a range i.e. empty, then simply convert it into a codeBlock
|
||||
if (editor.state.selection.empty) {
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
} else if (isMultiline) {
|
||||
// if the selection is multiline, then also replace the text content with
|
||||
// a code block
|
||||
// a codeBlock
|
||||
editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run();
|
||||
} else {
|
||||
// if the selection is single line, then simply convert it into inline
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
export enum EFileError {
|
||||
INVALID_FILE_TYPE = "INVALID_FILE_TYPE",
|
||||
FILE_SIZE_TOO_LARGE = "FILE_SIZE_TOO_LARGE",
|
||||
NO_FILE_SELECTED = "NO_FILE_SELECTED",
|
||||
}
|
||||
|
||||
type TArgs = {
|
||||
acceptedMimeTypes: string[];
|
||||
file: File;
|
||||
maxFileSize: number;
|
||||
onError: (error: EFileError, message: string) => void;
|
||||
};
|
||||
|
||||
export const isFileValid = (args: TArgs): boolean => {
|
||||
const { acceptedMimeTypes, file, maxFileSize } = args;
|
||||
const { acceptedMimeTypes, file, maxFileSize, onError } = args;
|
||||
|
||||
if (!file) {
|
||||
alert("No file selected. Please select a file to upload.");
|
||||
onError(EFileError.NO_FILE_SELECTED, "No file selected. Please select a file to upload.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!acceptedMimeTypes.includes(file.type)) {
|
||||
alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP or GIF file.");
|
||||
onError(EFileError.INVALID_FILE_TYPE, "Invalid file type.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > maxFileSize) {
|
||||
alert(`File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.`);
|
||||
onError(
|
||||
EFileError.FILE_SIZE_TOO_LARGE,
|
||||
`File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
32
packages/editor/src/core/helpers/image-helpers.ts
Normal file
32
packages/editor/src/core/helpers/image-helpers.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
/**
|
||||
* Finds all public image nodes in the document and restores them using the provided restore function
|
||||
*
|
||||
* Never remove this onCreate hook, it's a hack to restore old public
|
||||
* images, since they don't give error if they've been deleted as they are
|
||||
* rendered directly from image source instead of going through the
|
||||
* apiserver
|
||||
*/
|
||||
export const restorePublicImages = (editor: Editor, restoreImageFn: TFileHandler["restore"]) => {
|
||||
const imageSources = new Set<string>();
|
||||
editor.state.doc.descendants((node) => {
|
||||
if ([CORE_EXTENSIONS.IMAGE, CORE_EXTENSIONS.CUSTOM_IMAGE].includes(node.type.name as CORE_EXTENSIONS)) {
|
||||
if (!node.attrs.src?.startsWith("http")) return;
|
||||
|
||||
imageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
|
||||
imageSources.forEach(async (src) => {
|
||||
try {
|
||||
await restoreImageFn(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
type Direction = "up" | "down";
|
||||
|
||||
@@ -39,13 +41,13 @@ export const insertEmptyParagraphAtNodeBoundaries: (
|
||||
|
||||
if (insertPosUp === 0) {
|
||||
// If at the very start of the document, insert a new paragraph at the start
|
||||
editor.chain().insertContentAt(insertPosUp, { type: "paragraph" }).run();
|
||||
editor.chain().insertContentAt(insertPosUp, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
|
||||
editor.chain().setTextSelection(insertPosUp).run(); // Set the cursor to the new paragraph
|
||||
} else {
|
||||
// Otherwise, check the node immediately before the target node
|
||||
const prevNode = doc.nodeAt(insertPosUp - 1);
|
||||
|
||||
if (prevNode && prevNode.type.name === "paragraph") {
|
||||
if (prevNode && prevNode.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If the previous node is a paragraph, move the cursor there
|
||||
editor
|
||||
.chain()
|
||||
@@ -67,13 +69,13 @@ export const insertEmptyParagraphAtNodeBoundaries: (
|
||||
// Check the node immediately after the target node
|
||||
const nextNode = doc.nodeAt(insertPosDown);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If the next node is a paragraph, move the cursor to the end of it
|
||||
const endOfParagraphPos = insertPosDown + nextNode.nodeSize - 1;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
} else if (!nextNode) {
|
||||
// If there is no next node (end of document), insert a new paragraph
|
||||
editor.chain().insertContentAt(insertPosDown, { type: "paragraph" }).run();
|
||||
editor.chain().insertContentAt(insertPosDown, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(insertPosDown + 1)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
// extensions
|
||||
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
|
||||
|
||||
@@ -6,10 +6,13 @@ import { useImperativeHandle, MutableRefObject, useEffect } from "react";
|
||||
import * as Y from "yjs";
|
||||
// components
|
||||
import { getEditorMenuItems } from "@/components/menus";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CoreEditorExtensions } from "@/extensions";
|
||||
// helpers
|
||||
import { getParagraphCount } from "@/helpers/common";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position";
|
||||
import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helpers/scroll-to-node";
|
||||
// props
|
||||
@@ -23,6 +26,7 @@ import type {
|
||||
TExtensions,
|
||||
TMentionHandler,
|
||||
} from "@/types";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
|
||||
export interface CustomEditorProps {
|
||||
editable: boolean;
|
||||
@@ -111,16 +115,19 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
// value is null when intentionally passed where syncing is not yet
|
||||
// supported and value is undefined when the data from swr is not populated
|
||||
if (value == null) return;
|
||||
if (editor && !editor.isDestroyed && !editor.storage.imageComponent?.uploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||
if (editor.state.selection) {
|
||||
const docLength = editor.state.doc.content.size;
|
||||
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
|
||||
editor.commands.setTextSelection(relativePosition);
|
||||
if (editor) {
|
||||
const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress;
|
||||
if (!editor.isDestroyed && !isUploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||
if (editor.state.selection) {
|
||||
const docLength = editor.state.doc.content.size;
|
||||
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
|
||||
editor.commands.setTextSelection(relativePosition);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error syncing editor content with external value:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error syncing editor content with external value:", error);
|
||||
}
|
||||
}
|
||||
}, [editor, value, id]);
|
||||
@@ -143,7 +150,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
},
|
||||
getCurrentCursorPosition: () => editor?.state.selection.from,
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string, emitUpdate = false) => {
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
|
||||
@@ -179,7 +186,10 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||
// Subscribe to update event emitted from headers extension
|
||||
editor?.on("update", () => {
|
||||
callback(editor?.storage.headingList.headings);
|
||||
const headings = getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings;
|
||||
if (headings) {
|
||||
callback(headings);
|
||||
}
|
||||
});
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
@@ -188,7 +198,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
editor?.off("update");
|
||||
};
|
||||
},
|
||||
getHeadings: () => editor?.storage.headingList.headings,
|
||||
getHeadings: () => (editor ? getExtensionStorage(editor, CORE_EXTENSIONS.HEADINGS_LIST)?.headings : []),
|
||||
onStateChange: (callback: () => void) => {
|
||||
// Subscribe to editor state changes
|
||||
editor?.on("transaction", () => {
|
||||
@@ -221,7 +231,8 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
if (!editor) return;
|
||||
scrollSummary(editor, marking);
|
||||
},
|
||||
isEditorReadyToDiscard: () => editor?.storage.imageComponent?.uploadInProgress === false,
|
||||
isEditorReadyToDiscard: () =>
|
||||
!!editor && getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress === false,
|
||||
setFocusAtPosition: (position: number) => {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
console.error("Editor reference is not available or has been destroyed.");
|
||||
@@ -232,7 +243,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
const safePosition = Math.max(0, Math.min(position, docSize));
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(safePosition, [{ type: "paragraph" }])
|
||||
.insertContentAt(safePosition, [{ type: CORE_EXTENSIONS.PARAGRAPH }])
|
||||
.focus()
|
||||
.run();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { DragEvent, useCallback, useEffect, useState } from "react";
|
||||
// extensions
|
||||
import { insertFilesSafely } from "@/extensions/drop";
|
||||
// helpers
|
||||
import { EFileError, isFileValid } from "@/helpers/file";
|
||||
// plugins
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { insertFilesSafely } from "@/plugins/drop";
|
||||
// types
|
||||
import { TEditorCommands } from "@/types";
|
||||
|
||||
@@ -13,12 +13,20 @@ type TUploaderArgs = {
|
||||
handleProgressStatus?: (isUploading: boolean) => void;
|
||||
loadFileFromFileSystem?: (file: string) => void;
|
||||
maxFileSize: number;
|
||||
onInvalidFile: (error: EFileError, message: string) => void;
|
||||
onUpload: (url: string, file: File) => void;
|
||||
};
|
||||
|
||||
export const useUploader = (args: TUploaderArgs) => {
|
||||
const { acceptedMimeTypes, editorCommand, handleProgressStatus, loadFileFromFileSystem, maxFileSize, onUpload } =
|
||||
args;
|
||||
const {
|
||||
acceptedMimeTypes,
|
||||
editorCommand,
|
||||
handleProgressStatus,
|
||||
loadFileFromFileSystem,
|
||||
maxFileSize,
|
||||
onInvalidFile,
|
||||
onUpload,
|
||||
} = args;
|
||||
// states
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
@@ -30,6 +38,7 @@ export const useUploader = (args: TUploaderArgs) => {
|
||||
acceptedMimeTypes,
|
||||
file,
|
||||
maxFileSize,
|
||||
onError: onInvalidFile,
|
||||
});
|
||||
if (!isValid) {
|
||||
handleProgressStatus?.(false);
|
||||
@@ -75,13 +84,14 @@ type TDropzoneArgs = {
|
||||
acceptedMimeTypes: string[];
|
||||
editor: Editor;
|
||||
maxFileSize: number;
|
||||
onInvalidFile: (error: EFileError, message: string) => void;
|
||||
pos: number;
|
||||
type: Extract<TEditorCommands, "attachment" | "image">;
|
||||
uploader: (file: File) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useDropZone = (args: TDropzoneArgs) => {
|
||||
const { acceptedMimeTypes, editor, maxFileSize, pos, type, uploader } = args;
|
||||
const { acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader } = args;
|
||||
// states
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
||||
@@ -117,12 +127,13 @@ export const useDropZone = (args: TDropzoneArgs) => {
|
||||
editor,
|
||||
filesList,
|
||||
maxFileSize,
|
||||
onInvalidFile,
|
||||
pos,
|
||||
type,
|
||||
uploader,
|
||||
});
|
||||
},
|
||||
[acceptedMimeTypes, editor, maxFileSize, pos, type, uploader]
|
||||
[acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader]
|
||||
);
|
||||
const onDragEnter = useCallback(() => setDraggedInside(true), []);
|
||||
const onDragLeave = useCallback(() => setDraggedInside(false), []);
|
||||
@@ -141,6 +152,7 @@ type TMultipleFileArgs = {
|
||||
editor: Editor;
|
||||
filesList: FileList;
|
||||
maxFileSize: number;
|
||||
onInvalidFile: (error: EFileError, message: string) => void;
|
||||
pos: number;
|
||||
type: Extract<TEditorCommands, "attachment" | "image">;
|
||||
uploader: (file: File) => Promise<void>;
|
||||
@@ -148,7 +160,7 @@ type TMultipleFileArgs = {
|
||||
|
||||
// Upload the first file and insert the remaining ones for uploading multiple files
|
||||
export const uploadFirstFileAndInsertRemaining = async (args: TMultipleFileArgs) => {
|
||||
const { acceptedMimeTypes, editor, filesList, maxFileSize, pos, type, uploader } = args;
|
||||
const { acceptedMimeTypes, editor, filesList, maxFileSize, onInvalidFile, pos, type, uploader } = args;
|
||||
const filteredFiles: File[] = [];
|
||||
for (let i = 0; i < filesList.length; i += 1) {
|
||||
const file = filesList.item(i);
|
||||
@@ -158,6 +170,7 @@ export const uploadFirstFileAndInsertRemaining = async (args: TMultipleFileArgs)
|
||||
acceptedMimeTypes,
|
||||
file,
|
||||
maxFileSize,
|
||||
onError: onInvalidFile,
|
||||
})
|
||||
) {
|
||||
filteredFiles.push(file);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||
import { CoreReadOnlyEditorProps } from "@/props";
|
||||
// types
|
||||
import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
disabledExtensions: TExtensions[];
|
||||
@@ -75,7 +76,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: (emitUpdate = false) => {
|
||||
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
|
||||
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string, emitUpdate = false) => {
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Fragment, Slice, Node, Schema } from "@tiptap/pm/model";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
// @ts-expect-error __serializeForClipboard's is not exported
|
||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
|
||||
|
||||
@@ -132,7 +134,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
let listType = "";
|
||||
let isDragging = false;
|
||||
let lastClientY = 0;
|
||||
let scrollAnimationFrame = null;
|
||||
let scrollAnimationFrame: number | null = null;
|
||||
let isDraggedOutsideWindow: "top" | "bottom" | boolean = false;
|
||||
let isMouseInsideWhileDragging = false;
|
||||
let currentScrollSpeed = 0;
|
||||
@@ -142,8 +144,10 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragEvent, view: EditorView) => {
|
||||
const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options);
|
||||
listType = listTypeFromDragStart;
|
||||
const { listType: listTypeFromDragStart } = handleNodeSelection(event, view, true, options) ?? {};
|
||||
if (listTypeFromDragStart) {
|
||||
listType = listTypeFromDragStart;
|
||||
}
|
||||
isDragging = true;
|
||||
lastClientY = event.clientY;
|
||||
scroll();
|
||||
@@ -297,7 +301,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
|
||||
// Traverse up the document tree to find if we're inside a list item
|
||||
for (let i = resolvedPos.depth; i > 0; i--) {
|
||||
if (resolvedPos.node(i).type.name === "listItem") {
|
||||
if (resolvedPos.node(i).type.name === CORE_EXTENSIONS.LIST_ITEM) {
|
||||
isDroppedInsideList = true;
|
||||
dropDepth = i;
|
||||
break;
|
||||
@@ -305,7 +309,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
}
|
||||
|
||||
// Handle nested list items and task items
|
||||
if (droppedNode.type.name === "listItem") {
|
||||
if (droppedNode.type.name === CORE_EXTENSIONS.LIST_ITEM) {
|
||||
let slice = view.state.selection.content();
|
||||
let newFragment = slice.content;
|
||||
|
||||
@@ -348,8 +352,8 @@ function flattenListStructure(fragment: Fragment, schema: Schema): Fragment {
|
||||
(node.content.firstChild.type === schema.nodes.bulletList ||
|
||||
node.content.firstChild.type === schema.nodes.orderedList)
|
||||
) {
|
||||
const sublist = node.content.firstChild;
|
||||
const flattened = flattenListStructure(sublist.content, schema);
|
||||
const subList = node.content.firstChild;
|
||||
const flattened = flattenListStructure(subList.content, schema);
|
||||
flattened.forEach((subNode) => result.push(subNode));
|
||||
}
|
||||
}
|
||||
@@ -376,7 +380,7 @@ const handleNodeSelection = (
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
|
||||
// Handle blockquotes separately
|
||||
// Handle blockquote separately
|
||||
if (node.matches("blockquote")) {
|
||||
draggedNodePos = nodePosAtDOMForBlockQuotes(node, view);
|
||||
if (draggedNodePos === null || draggedNodePos === undefined) return;
|
||||
@@ -385,7 +389,10 @@ const handleNodeSelection = (
|
||||
const $pos = view.state.doc.resolve(draggedNodePos);
|
||||
|
||||
// If it's a nested list item or task item, move up to the item level
|
||||
if (($pos.parent.type.name === "listItem" || $pos.parent.type.name === "taskItem") && $pos.depth > 1) {
|
||||
if (
|
||||
[CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes($pos.parent.type.name as CORE_EXTENSIONS) &&
|
||||
$pos.depth > 1
|
||||
) {
|
||||
draggedNodePos = $pos.before($pos.depth);
|
||||
}
|
||||
}
|
||||
@@ -403,14 +410,16 @@ const handleNodeSelection = (
|
||||
// Additional logic for drag start
|
||||
if (event instanceof DragEvent && !event.dataTransfer) return;
|
||||
|
||||
if (nodeSelection.node.type.name === "listItem" || nodeSelection.node.type.name === "taskItem") {
|
||||
if (
|
||||
[CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(nodeSelection.node.type.name as CORE_EXTENSIONS)
|
||||
) {
|
||||
listType = node.closest("ol, ul")?.tagName || "";
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = __serializeForClipboard(view, slice);
|
||||
|
||||
if (event instanceof DragEvent) {
|
||||
if (event instanceof DragEvent && event.dataTransfer) {
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData("text/html", dom.innerHTML);
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
|
||||
118
packages/editor/src/core/plugins/drop.ts
Normal file
118
packages/editor/src/core/plugins/drop.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
// types
|
||||
import { TEditorCommands } from "@/types";
|
||||
|
||||
export const DropHandlerPlugin = (editor: Editor): Plugin =>
|
||||
new Plugin({
|
||||
key: new PluginKey("drop-handler-plugin"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
if (
|
||||
editor.isEditable &&
|
||||
event.clipboardData &&
|
||||
event.clipboardData.files &&
|
||||
event.clipboardData.files.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
const acceptedFiles = files.filter(
|
||||
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
|
||||
);
|
||||
|
||||
if (acceptedFiles.length) {
|
||||
const pos = view.state.selection.from;
|
||||
insertFilesSafely({
|
||||
editor,
|
||||
files: acceptedFiles,
|
||||
initialPos: pos,
|
||||
event: "drop",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view, event, _slice, moved) => {
|
||||
if (
|
||||
editor.isEditable &&
|
||||
!moved &&
|
||||
event.dataTransfer &&
|
||||
event.dataTransfer.files &&
|
||||
event.dataTransfer.files.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
const acceptedFiles = files.filter(
|
||||
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
|
||||
);
|
||||
|
||||
if (acceptedFiles.length) {
|
||||
const coordinates = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (coordinates) {
|
||||
const pos = coordinates.pos;
|
||||
insertFilesSafely({
|
||||
editor,
|
||||
files: acceptedFiles,
|
||||
initialPos: pos,
|
||||
event: "drop",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type InsertFilesSafelyArgs = {
|
||||
editor: Editor;
|
||||
event: "insert" | "drop";
|
||||
files: File[];
|
||||
initialPos: number;
|
||||
type?: Extract<TEditorCommands, "attachment" | "image">;
|
||||
};
|
||||
|
||||
export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
|
||||
const { editor, event, files, initialPos, type } = args;
|
||||
let pos = initialPos;
|
||||
|
||||
for (const file of files) {
|
||||
// safe insertion
|
||||
const docSize = editor.state.doc.content.size;
|
||||
pos = Math.min(pos, docSize);
|
||||
|
||||
let fileType: "image" | "attachment" | null = null;
|
||||
|
||||
try {
|
||||
if (type) {
|
||||
if (["image", "attachment"].includes(type)) fileType = type;
|
||||
else throw new Error("Wrong file type passed");
|
||||
} else {
|
||||
if (ACCEPTED_IMAGE_MIME_TYPES.includes(file.type)) fileType = "image";
|
||||
else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment";
|
||||
}
|
||||
// insert file depending on the type at the current position
|
||||
if (fileType === "image") {
|
||||
editor.commands.insertImageComponent({
|
||||
file,
|
||||
pos,
|
||||
event,
|
||||
});
|
||||
} else if (fileType === "attachment") {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error while ${event}ing file:`, error);
|
||||
}
|
||||
|
||||
// Move to the next position
|
||||
pos += 1;
|
||||
}
|
||||
};
|
||||
67
packages/editor/src/core/plugins/file/delete.ts
Normal file
67
packages/editor/src/core/plugins/file/delete.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
// plane editor imports
|
||||
import { NODE_FILE_MAP } from "@/plane-editor/constants/utility";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
// local imports
|
||||
import { TFileNode } from "./types";
|
||||
|
||||
const DELETE_PLUGIN_KEY = new PluginKey("delete-utility");
|
||||
|
||||
export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHandler["delete"]): Plugin =>
|
||||
new Plugin({
|
||||
key: DELETE_PLUGIN_KEY,
|
||||
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
||||
const newFileSources: {
|
||||
[nodeType: string]: Set<string> | undefined;
|
||||
} = {};
|
||||
if (!transactions.some((tr) => tr.docChanged)) return null;
|
||||
|
||||
newState.doc.descendants((node) => {
|
||||
const nodeType = node.type.name;
|
||||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
||||
if (nodeFileSetDetails) {
|
||||
if (newFileSources[nodeType]) {
|
||||
newFileSources[nodeType].add(node.attrs.src);
|
||||
} else {
|
||||
newFileSources[nodeType] = new Set([node.attrs.src]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
// if the transaction has meta of skipFileDeletion set to true, then return (like while clearing the editor content programmatically)
|
||||
if (transaction.getMeta("skipFileDeletion")) return;
|
||||
|
||||
const removedFiles: TFileNode[] = [];
|
||||
|
||||
// iterate through all the nodes in the old state
|
||||
oldState.doc.descendants((node) => {
|
||||
const nodeType = node.type.name;
|
||||
const isAValidNode = NODE_FILE_MAP[nodeType];
|
||||
// if the node doesn't match, then return as no point in checking
|
||||
if (!isAValidNode) return;
|
||||
// Check if the node has been deleted or replaced
|
||||
if (!newFileSources[nodeType]?.has(node.attrs.src)) {
|
||||
removedFiles.push(node as TFileNode);
|
||||
}
|
||||
});
|
||||
|
||||
removedFiles.forEach(async (node) => {
|
||||
const nodeType = node.type.name;
|
||||
const src = node.attrs.src;
|
||||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
||||
if (!nodeFileSetDetails || !src) return;
|
||||
try {
|
||||
editor.storage[nodeType][nodeFileSetDetails.fileSetName]?.set(src, true);
|
||||
await deleteHandler(src);
|
||||
} catch (error) {
|
||||
console.error("Error deleting file via delete utility plugin:", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
72
packages/editor/src/core/plugins/file/restore.ts
Normal file
72
packages/editor/src/core/plugins/file/restore.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// plane editor imports
|
||||
import { NODE_FILE_MAP } from "@/plane-editor/constants/utility";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
// local imports
|
||||
import { TFileNode } from "./types";
|
||||
|
||||
const RESTORE_PLUGIN_KEY = new PluginKey("restore-utility");
|
||||
|
||||
export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFileHandler["restore"]): Plugin =>
|
||||
new Plugin({
|
||||
key: RESTORE_PLUGIN_KEY,
|
||||
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
||||
if (!transactions.some((tr) => tr.docChanged)) return null;
|
||||
|
||||
const oldFileSources: {
|
||||
[key: string]: Set<string> | undefined;
|
||||
} = {};
|
||||
oldState.doc.descendants((node) => {
|
||||
const nodeType = node.type.name;
|
||||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
||||
if (nodeFileSetDetails) {
|
||||
if (oldFileSources[nodeType]) {
|
||||
oldFileSources[nodeType].add(node.attrs.src);
|
||||
} else {
|
||||
oldFileSources[nodeType] = new Set([node.attrs.src]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
transactions.forEach(() => {
|
||||
const addedFiles: TFileNode[] = [];
|
||||
|
||||
newState.doc.descendants((node, pos) => {
|
||||
const nodeType = node.type.name;
|
||||
const isAValidNode = NODE_FILE_MAP[nodeType];
|
||||
// if the node doesn't match, then return as no point in checking
|
||||
if (!isAValidNode) return;
|
||||
if (pos < 0 || pos > newState.doc.content.size) return;
|
||||
if (oldFileSources[nodeType]?.has(node.attrs.src)) return;
|
||||
// if the src is just a id (private bucket), then we don't need to handle restore from here but
|
||||
// only while it fails to load
|
||||
if (nodeType === CORE_EXTENSIONS.CUSTOM_IMAGE && !node.attrs.src?.startsWith("http")) return;
|
||||
addedFiles.push(node as TFileNode);
|
||||
});
|
||||
|
||||
addedFiles.forEach(async (node) => {
|
||||
const nodeType = node.type.name;
|
||||
const src = node.attrs.src;
|
||||
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
|
||||
const extensionFileSetStorage = editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName];
|
||||
const wasDeleted = extensionFileSetStorage?.get(src);
|
||||
if (!nodeFileSetDetails || !src) return;
|
||||
if (wasDeleted === undefined) {
|
||||
extensionFileSetStorage?.set(src, false);
|
||||
} else if (wasDeleted === true) {
|
||||
try {
|
||||
await restoreHandler(src);
|
||||
extensionFileSetStorage?.set(src, false);
|
||||
} catch (error) {
|
||||
console.error("Error restoring file via restore utility plugin:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
22
packages/editor/src/core/plugins/file/root.ts
Normal file
22
packages/editor/src/core/plugins/file/root.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
// types
|
||||
import { TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
// local imports
|
||||
import { TrackFileDeletionPlugin } from "./delete";
|
||||
import { TrackFileRestorationPlugin } from "./restore";
|
||||
|
||||
type TArgs = {
|
||||
editor: Editor;
|
||||
fileHandler: TFileHandler | TReadOnlyFileHandler;
|
||||
isEditable: boolean;
|
||||
};
|
||||
|
||||
export const FilePlugins = (args: TArgs): Plugin[] => {
|
||||
const { editor, fileHandler, isEditable } = args;
|
||||
|
||||
return [
|
||||
...(isEditable && "delete" in fileHandler ? [TrackFileDeletionPlugin(editor, fileHandler.delete)] : []),
|
||||
TrackFileRestorationPlugin(editor, fileHandler.restore),
|
||||
];
|
||||
};
|
||||
8
packages/editor/src/core/plugins/file/types.ts
Normal file
8
packages/editor/src/core/plugins/file/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
|
||||
export type TFileNode = ProseMirrorNode & {
|
||||
attrs: {
|
||||
src: string;
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
// plugins
|
||||
import { type ImageNode } from "@/plugins/image";
|
||||
// types
|
||||
import { DeleteImage } from "@/types";
|
||||
|
||||
export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage, nodeType: string): Plugin =>
|
||||
new Plugin({
|
||||
key: new PluginKey(`delete-${nodeType}`),
|
||||
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
||||
const newImageSources = new Set<string>();
|
||||
newState.doc.descendants((node) => {
|
||||
if (node.type.name === nodeType) {
|
||||
newImageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
// if the transaction has meta of skipImageDeletion get to true, then return (like while clearing the editor content programatically)
|
||||
if (transaction.getMeta("skipImageDeletion")) return;
|
||||
// transaction could be a selection
|
||||
if (!transaction.docChanged) return;
|
||||
|
||||
const removedImages: ImageNode[] = [];
|
||||
|
||||
// iterate through all the nodes in the old state
|
||||
oldState.doc.descendants((oldNode) => {
|
||||
// if the node is not an image, then return as no point in checking
|
||||
if (oldNode.type.name !== nodeType) return;
|
||||
|
||||
// Check if the node has been deleted or replaced
|
||||
if (!newImageSources.has(oldNode.attrs.src)) {
|
||||
removedImages.push(oldNode as ImageNode);
|
||||
}
|
||||
});
|
||||
|
||||
removedImages.forEach(async (node) => {
|
||||
const src = node.attrs.src;
|
||||
editor.storage[nodeType].deletedImageSet?.set(src, true);
|
||||
if (!src) return;
|
||||
try {
|
||||
await deleteImage(src);
|
||||
} catch (error) {
|
||||
console.error("Error deleting image:", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./types";
|
||||
export * from "./delete-image";
|
||||
export * from "./restore-image";
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
// plugins
|
||||
import { ImageNode } from "@/plugins/image";
|
||||
// types
|
||||
import { RestoreImage } from "@/types";
|
||||
|
||||
export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage, nodeType: string): Plugin =>
|
||||
new Plugin({
|
||||
key: new PluginKey(`restore-${nodeType}`),
|
||||
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
||||
const oldImageSources = new Set<string>();
|
||||
oldState.doc.descendants((node) => {
|
||||
if (node.type.name === nodeType) {
|
||||
oldImageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
if (!transaction.docChanged) return;
|
||||
|
||||
const addedImages: ImageNode[] = [];
|
||||
|
||||
newState.doc.descendants((node, pos) => {
|
||||
if (node.type.name !== nodeType) return;
|
||||
if (pos < 0 || pos > newState.doc.content.size) return;
|
||||
if (oldImageSources.has(node.attrs.src)) return;
|
||||
// if the src is just a id (private bucket), then we don't need to handle restore from here but
|
||||
// only while it fails to load
|
||||
if (!node.attrs.src?.startsWith("http")) return;
|
||||
addedImages.push(node as ImageNode);
|
||||
});
|
||||
|
||||
addedImages.forEach(async (image) => {
|
||||
const src = image.attrs.src;
|
||||
const wasDeleted = editor.storage[nodeType].deletedImageSet.get(src);
|
||||
if (wasDeleted === undefined) {
|
||||
editor.storage[nodeType].deletedImageSet.set(src, false);
|
||||
} else if (wasDeleted === true) {
|
||||
try {
|
||||
await onNodeRestored(src, restoreImage);
|
||||
editor.storage[nodeType].deletedImageSet.set(src, false);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
|
||||
if (!src) return;
|
||||
try {
|
||||
await restoreImage(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
|
||||
export interface ImageNode extends ProseMirrorNode {
|
||||
attrs: {
|
||||
src: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ImageExtensionStorage = {
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
uploadInProgress: boolean;
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./image-node";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user