[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:
Aaryan Khandelwal
2025-05-28 01:43:01 +05:30
committed by GitHub
parent a3a580923c
commit e388a9a279
110 changed files with 1344 additions and 1138 deletions

View File

@@ -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"
}
}

View File

@@ -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": {

View File

@@ -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": [

View 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",
},
};

View File

@@ -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

View File

@@ -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">;

View File

@@ -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,
})
);

View File

@@ -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,
})
);

View File

@@ -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

View File

@@ -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,

View File

@@ -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]

View File

@@ -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();

View File

@@ -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) => {

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View 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",
}

View File

@@ -0,0 +1,3 @@
export enum CORE_EDITOR_META {
SKIP_FILE_DELETION = "skipFileDeletion",
}

View File

@@ -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

View File

@@ -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+",

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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);
}
},
},
}),
];
},
});

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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");

View File

@@ -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: {

View File

@@ -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];

View File

@@ -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 });
},
});

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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,

View File

@@ -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(() => {

View File

@@ -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);

View File

@@ -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: {},
};
},

View File

@@ -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,

View File

@@ -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);

View File

@@ -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++;
}
});

View File

@@ -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

View File

@@ -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;
}
};

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,
};
},

View File

@@ -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,
};
},
});

View File

@@ -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,
},
};
},
});

View File

@@ -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?.(),

View File

@@ -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";

View File

@@ -1,2 +0,0 @@
export * from "./widget-node";
export * from "./issue-embed-without-props";

View File

@@ -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)];
},
});

View File

@@ -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)];
},
});

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 }) => {

View File

@@ -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;

View File

@@ -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,
}),

View File

@@ -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({

View File

@@ -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",

View File

@@ -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);
}
},
}));

View File

@@ -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, {

View File

@@ -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 {

View File

@@ -1 +0,0 @@
export { TableCell } from "./table-cell";

View File

@@ -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 {

View File

@@ -1 +0,0 @@
export { TableHeader } from "./table-header";

View File

@@ -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 {

View File

@@ -1 +0,0 @@
export { TableRow } from "./table-row";

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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);
};
},

View File

@@ -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;
}
});

View File

@@ -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();

View File

@@ -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)

View File

@@ -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[] = [];

View 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;
},
};
},
});
};

View File

@@ -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)];
},
});

View File

@@ -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>
));
},
});

View File

@@ -0,0 +1 @@
export * from "./extension";

View File

@@ -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;
};

View File

@@ -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

View File

@@ -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;
}

View 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);
}
});
};

View File

@@ -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)

View File

@@ -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";

View File

@@ -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) {

View File

@@ -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);

View 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" });

View File

@@ -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);

View 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;
}
};

View 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;
},
});

View 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;
},
});

View 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),
];
};

View File

@@ -0,0 +1,8 @@
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
export type TFileNode = ProseMirrorNode & {
attrs: {
src: string;
id: string;
};
};

View File

@@ -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;
},
});

View File

@@ -1,3 +0,0 @@
export * from "./types";
export * from "./delete-image";
export * from "./restore-image";

View File

@@ -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;
}
}

View File

@@ -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;
};

View File

@@ -1 +0,0 @@
export * from "./image-node";

Some files were not shown because too many files have changed in this diff Show More