diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx index bf2937fcd63..93900700b27 100644 --- a/packages/editor/src/ce/extensions/document-extensions.tsx +++ b/packages/editor/src/ce/extensions/document-extensions.tsx @@ -4,20 +4,17 @@ import { SlashCommand } from "@/extensions"; // plane editor types import { TIssueEmbedConfig } from "@/plane-editor/types"; // types -import { TExtensions, TFileHandler, TUserDetails } from "@/types"; +import { TExtensions, TUserDetails } from "@/types"; type Props = { disabledExtensions?: TExtensions[]; - fileHandler: TFileHandler; issueEmbedConfig: TIssueEmbedConfig | undefined; provider: HocuspocusProvider; userDetails: TUserDetails; }; -export const DocumentEditorAdditionalExtensions = (props: Props) => { - const { fileHandler } = props; - - const extensions: Extensions = [SlashCommand(fileHandler.upload)]; +export const DocumentEditorAdditionalExtensions = (_props: Props) => { + const extensions: Extensions = [SlashCommand()]; return extensions; }; diff --git a/packages/editor/src/core/components/editors/editor-content.tsx b/packages/editor/src/core/components/editors/editor-content.tsx index 691bc1002b7..b05457f2e63 100644 --- a/packages/editor/src/core/components/editors/editor-content.tsx +++ b/packages/editor/src/core/components/editors/editor-content.tsx @@ -1,7 +1,5 @@ import { FC, ReactNode } from "react"; import { Editor, EditorContent } from "@tiptap/react"; -// extensions -import { ImageResizer } from "@/extensions/image"; interface EditorContentProps { children?: ReactNode; @@ -16,7 +14,6 @@ export const EditorContentWrapper: FC = (props) => { return (
editor?.chain().focus(undefined, { scrollIntoView: false }).run()}> - {editor?.isActive("image") && editor?.isEditable && } {children}
); diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx index 28204237275..fe4d2d51373 100644 --- a/packages/editor/src/core/components/editors/rich-text/editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -8,10 +8,10 @@ import { SideMenuExtension, SlashCommand } from "@/extensions"; import { EditorRefApi, IRichTextEditor } from "@/types"; const RichTextEditor = (props: IRichTextEditor) => { - const { dragDropEnabled, fileHandler } = props; + const { dragDropEnabled } = props; const getExtensions = useCallback(() => { - const extensions = [SlashCommand(fileHandler.upload)]; + const extensions = [SlashCommand()]; extensions.push( SideMenuExtension({ @@ -21,7 +21,7 @@ const RichTextEditor = (props: IRichTextEditor) => { ); return extensions; - }, [dragDropEnabled, fileHandler.upload]); + }, [dragDropEnabled]); return ( diff --git a/packages/editor/src/core/components/menus/block-menu.tsx b/packages/editor/src/core/components/menus/block-menu.tsx index a90e39ca22b..e1715b5f783 100644 --- a/packages/editor/src/core/components/menus/block-menu.tsx +++ b/packages/editor/src/core/components/menus/block-menu.tsx @@ -101,7 +101,8 @@ export const BlockMenu = (props: BlockMenuProps) => { icon: Copy, key: "duplicate", label: "Duplicate", - isDisabled: editor.state.selection.content().content.firstChild?.type.name === "image", + isDisabled: + editor.state.selection.content().content.firstChild?.type.name === "image" || editor.isActive("imageComponent"), onClick: (e) => { e.preventDefault(); e.stopPropagation(); diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index 60db11704d4..b60196beacd 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -23,7 +23,6 @@ import { } from "lucide-react"; // helpers import { - insertImageCommand, insertTableCommand, setText, toggleBlockquote, @@ -43,7 +42,7 @@ import { toggleUnderline, } from "@/helpers/editor-commands"; // types -import { TEditorCommands, UploadImage } from "@/types"; +import { TEditorCommands } from "@/types"; export interface EditorMenuItem { key: TEditorCommands; @@ -189,16 +188,17 @@ export const TableItem = (editor: Editor): EditorMenuItem => ({ icon: TableIcon, }); -export const ImageItem = (editor: Editor, uploadFile: UploadImage) => +export const ImageItem = (editor: Editor) => ({ key: "image", name: "Image", isActive: () => editor?.isActive("image"), - command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection), + command: (savedSelection: Selection | null) => + editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }), icon: ImageIcon, }) as const; -export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImage) { +export function getEditorMenuItems(editor: Editor | null) { if (!editor) { return []; } @@ -220,6 +220,6 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag NumberedListItem(editor), QuoteItem(editor), TableItem(editor), - ImageItem(editor, uploadFile), + ImageItem(editor), ]; } diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index c0f066c3ff9..1cedd513966 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -11,6 +11,7 @@ import { CustomCodeInlineExtension } from "./code-inline"; 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 { CustomMentionWithoutProps } from "./mentions/mentions-without-props"; import { CustomQuoteExtension } from "./quote"; @@ -61,6 +62,7 @@ export const CoreEditorExtensionsWithoutProps = [ class: "rounded-md", }, }), + CustomImageComponentWithoutProps(), TiptapUnderline, TextStyle, TaskList.configure({ diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx new file mode 100644 index 00000000000..22a793a9580 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -0,0 +1,128 @@ +import React, { useRef, useState, useCallback, useLayoutEffect } from "react"; +import { NodeSelection } from "@tiptap/pm/state"; +// extensions +import { CustomImageNodeViewProps } from "@/extensions/custom-image"; +// helpers +import { cn } from "@/helpers/common"; + +const MIN_SIZE = 100; + +export const CustomImageBlock: React.FC = (props) => { + const { node, updateAttributes, selected, getPos, editor } = props; + const { src, width, height } = node.attrs; + + const [size, setSize] = useState({ width: width || "35%", height: height || "auto" }); + const [isLoading, setIsLoading] = useState(true); + + const containerRef = useRef(null); + const containerRect = useRef(null); + const imageRef = useRef(null); + const isResizing = useRef(false); + const aspectRatio = useRef(1); + + useLayoutEffect(() => { + if (imageRef.current) { + const img = imageRef.current; + img.onload = () => { + if (node.attrs.width === "35%" && node.attrs.height === "auto") { + aspectRatio.current = img.naturalWidth / img.naturalHeight; + const initialWidth = Math.max(img.naturalWidth * 0.35, MIN_SIZE); + const initialHeight = initialWidth / aspectRatio.current; + setSize({ width: `${initialWidth}px`, height: `${initialHeight}px` }); + } + setIsLoading(false); + }; + } + }, [src]); + + const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + isResizing.current = true; + if (containerRef.current) { + containerRect.current = containerRef.current.getBoundingClientRect(); + } + }, []); + + useLayoutEffect(() => { + // for realtime resizing and undo/redo + setSize({ width, height }); + }, [width, height]); + + const handleResize = useCallback((e: MouseEvent | TouchEvent) => { + if (!isResizing.current || !containerRef.current || !containerRect.current) return; + + const clientX = "touches" in e ? e.touches[0].clientX : e.clientX; + + const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE); + const newHeight = newWidth / aspectRatio.current; + + setSize({ width: `${newWidth}px`, height: `${newHeight}px` }); + }, []); + + const handleResizeEnd = useCallback(() => { + if (isResizing.current) { + isResizing.current = false; + updateAttributes(size); + } + }, [size, updateAttributes]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + const pos = getPos(); + const nodeSelection = NodeSelection.create(editor.state.doc, pos); + editor.view.dispatch(editor.state.tr.setSelection(nodeSelection)); + }, + [editor, getPos] + ); + + useLayoutEffect(() => { + const handleGlobalMouseMove = (e: MouseEvent) => handleResize(e); + const handleGlobalMouseUp = () => handleResizeEnd(); + + document.addEventListener("mousemove", handleGlobalMouseMove); + document.addEventListener("mouseup", handleGlobalMouseUp); + + return () => { + document.removeEventListener("mousemove", handleGlobalMouseMove); + document.removeEventListener("mouseup", handleGlobalMouseUp); + }; + }, [handleResize, handleResizeEnd]); + + return ( +
+ {isLoading &&
} + + {editor.isEditable && selected &&
} + {editor.isEditable && ( + <> +
+
+ + )} +
+ ); +}; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx new file mode 100644 index 00000000000..4786d8f997a --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -0,0 +1,122 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Node as ProsemirrorNode } from "@tiptap/pm/model"; +import { Editor, NodeViewWrapper } from "@tiptap/react"; +// extensions +import { + CustomImageBlock, + CustomImageUploader, + UploadEntity, + UploadImageExtensionStorage, +} from "@/extensions/custom-image"; + +export type CustomImageNodeViewProps = { + getPos: () => number; + editor: Editor; + node: ProsemirrorNode & { + attrs: { + src: string; + width: string; + height: string; + }; + }; + updateAttributes: (attrs: Record) => void; + selected: boolean; +}; + +export const CustomImageNode = (props: CustomImageNodeViewProps) => { + const { getPos, editor, node, updateAttributes, selected } = props; + + const fileInputRef = useRef(null); + const hasTriggeredFilePickerRef = useRef(false); + const [isUploaded, setIsUploaded] = useState(!!node.attrs.src); + + const id = node.attrs.id as string; + const editorStorage = editor.storage.imageComponent as UploadImageExtensionStorage | undefined; + + const getUploadEntity = useCallback( + (): UploadEntity | undefined => editorStorage?.fileMap.get(id), + [editorStorage, id] + ); + + const onUpload = useCallback( + (url: string) => { + if (url) { + setIsUploaded(true); + // Update the node view's src attribute + updateAttributes({ src: url }); + editorStorage?.fileMap.delete(id); + } + }, + [editorStorage?.fileMap, id, updateAttributes] + ); + + const uploadFile = useCallback( + async (file: File) => { + try { + // @ts-expect-error - TODO: fix typings, and don't remove await from + // here for now + const url: string = await editor?.commands.uploadImage(file); + + if (!url) { + throw new Error("Something went wrong while uploading the image"); + } + onUpload(url); + } catch (error) { + console.error("Error uploading file:", error); + } + }, + [editor.commands, onUpload] + ); + + useEffect(() => { + const uploadEntity = getUploadEntity(); + + if (uploadEntity) { + if (uploadEntity.event === "drop" && "file" in uploadEntity) { + uploadFile(uploadEntity.file); + } else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) { + const entity = editorStorage?.fileMap.get(id); + if (entity && entity.hasOpenedFileInputOnce) return; + fileInputRef.current.click(); + hasTriggeredFilePickerRef.current = true; + if (!entity) return; + editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true }); + } + } + }, [getUploadEntity, uploadFile]); + + useEffect(() => { + if (node.attrs.src) { + setIsUploaded(true); + } + }, [node.attrs.src]); + + const existingFile = React.useMemo(() => { + const entity = getUploadEntity(); + return entity && entity.event === "drop" ? entity.file : undefined; + }, [getUploadEntity]); + + return ( + +
+ {isUploaded ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx new file mode 100644 index 00000000000..d288630c637 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx @@ -0,0 +1,90 @@ +import { ChangeEvent, useCallback, useEffect, useRef } from "react"; +import { Editor } from "@tiptap/core"; +import { ImageIcon } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common"; +// hooks +import { useUploader, useFileUpload, useDropZone } from "@/hooks/use-file-upload"; +// plugins +import { isFileValid } from "@/plugins/image"; + +type RefType = React.RefObject | ((instance: HTMLInputElement | null) => void); + +const assignRef = (ref: RefType, value: HTMLInputElement | null) => { + if (typeof ref === "function") { + ref(value); + } else if (ref && typeof ref === "object") { + (ref as React.MutableRefObject).current = value; + } +}; + +export const CustomImageUploader = (props: { + onUpload: (url: string) => void; + editor: Editor; + fileInputRef: RefType; + existingFile?: File; + selected: boolean; +}) => { + const { selected, onUpload, editor, fileInputRef, existingFile } = props; + const { loading, uploadFile } = useUploader({ onUpload, editor }); + const { handleUploadClick, ref: internalRef } = useFileUpload(); + const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile }); + + const localRef = useRef(null); + + const onFileChange = useCallback( + (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + if (isFileValid(file)) { + uploadFile(file); + } + } + }, + [uploadFile] + ); + + useEffect(() => { + // no need to validate as the file is already validated before the drop onto + // the editor + if (existingFile) { + uploadFile(existingFile); + } + }, [existingFile, uploadFile]); + + return ( +
+ +
+ {loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"} +
+ { + localRef.current = element; + assignRef(fileInputRef, element); + assignRef(internalRef as RefType, element); + }} + hidden + type="file" + accept=".jpg,.jpeg,.png,.webp" + onChange={onFileChange} + /> +
+ ); +}; diff --git a/packages/editor/src/core/extensions/custom-image/components/index.ts b/packages/editor/src/core/extensions/custom-image/components/index.ts new file mode 100644 index 00000000000..d16be13c869 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/components/index.ts @@ -0,0 +1,3 @@ +export * from "./image-block"; +export * from "./image-node"; +export * from "./image-uploader"; diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts new file mode 100644 index 00000000000..73786e621f0 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -0,0 +1,157 @@ +import { mergeAttributes } from "@tiptap/core"; +import { Image } from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { v4 as uuidv4 } from "uuid"; +// extensions +import { CustomImageNode } from "@/extensions/custom-image"; +// plugins +import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image"; +// types +import { TFileHandler } from "@/types"; +// helpers +import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; + +declare module "@tiptap/core" { + interface Commands { + imageComponent: { + setImageUpload: ({ file, pos, event }: { file?: File; pos?: number; event: "insert" | "drop" }) => ReturnType; + uploadImage: (file: File) => () => Promise | undefined; + }; + } +} + +export interface UploadImageExtensionStorage { + fileMap: Map; +} + +export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; + +export const CustomImageExtension = (props: TFileHandler) => { + const { upload, delete: deleteImage, restore: restoreImage } = props; + + return Image.extend, UploadImageExtensionStorage>({ + 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, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "image-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["image-component", mergeAttributes(HTMLAttributes)]; + }, + + onCreate(this) { + const imageSources = new Set(); + this.editor.state.doc.descendants((node) => { + if (node.type.name === this.name) { + imageSources.add(node.attrs.src); + } + }); + imageSources.forEach(async (src) => { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + await restoreImage(assetUrlWithWorkspaceId); + } catch (error) { + console.error("Error restoring image: ", error); + } + }); + }, + + addKeyboardShortcuts() { + return { + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), + }; + }, + + addProseMirrorPlugins() { + return [ + TrackImageDeletionPlugin(this.editor, deleteImage, this.name), + TrackImageRestorationPlugin(this.editor, restoreImage, this.name), + ]; + }, + + addStorage() { + return { + fileMap: new Map(), + deletedImageSet: new Map(), + }; + }, + + addCommands() { + return { + setImageUpload: + (props: { file?: File; pos?: number; event: "insert" | "drop" }) => + ({ commands }) => { + // Early return if there's an invalid file being dropped + if (props?.file && !isFileValid(props.file)) { + return false; + } + + // generate a unique id for the image to keep track of dropped + // files' file data + const fileId = uuidv4(); + if (props?.event === "drop" && props.file) { + (this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, { + file: props.file, + event: props.event, + }); + } else if (props.event === "insert") { + (this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, { + event: props.event, + }); + } + + const attributes = { + id: fileId, + }; + + if (props.pos) { + return commands.insertContentAt(props.pos, { + type: this.name, + attrs: attributes, + }); + } + return commands.insertContent({ + type: this.name, + attrs: attributes, + }); + }, + uploadImage: (file: File) => async () => { + const fileUrl = await upload(file); + return fileUrl; + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, + }); +}; diff --git a/packages/editor/src/core/extensions/custom-image/index.ts b/packages/editor/src/core/extensions/custom-image/index.ts new file mode 100644 index 00000000000..de2bb38789d --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/index.ts @@ -0,0 +1,3 @@ +export * from "./components"; +export * from "./custom-image"; +export * from "./read-only-custom-image"; diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts new file mode 100644 index 00000000000..4f9c7d3cd48 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts @@ -0,0 +1,54 @@ +import { mergeAttributes } from "@tiptap/core"; +import { Image } from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +// components +import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image"; + +export const CustomReadOnlyImageExtension = () => + Image.extend, UploadImageExtensionStorage>({ + name: "imageComponent", + selectable: false, + group: "block", + atom: true, + draggable: false, + + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + src: { + default: null, + }, + height: { + default: "auto", + }, + ["id"]: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "image-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["image-component", mergeAttributes(HTMLAttributes)]; + }, + + addStorage() { + return { + fileMap: new Map(), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, + }); diff --git a/packages/editor/src/core/extensions/drop.tsx b/packages/editor/src/core/extensions/drop.tsx index d56f802d97a..943ab60d46f 100644 --- a/packages/editor/src/core/extensions/drop.tsx +++ b/packages/editor/src/core/extensions/drop.tsx @@ -1,11 +1,8 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "prosemirror-state"; -// plugins -import { startImageUpload } from "@/plugins/image"; -// types -import { UploadImage } from "@/types"; +import { EditorView } from "prosemirror-view"; -export const DropHandlerExtension = (uploadFile: UploadImage) => +export const DropHandlerExtension = () => Extension.create({ name: "dropHandler", priority: 1000, @@ -15,28 +12,51 @@ export const DropHandlerExtension = (uploadFile: UploadImage) => new Plugin({ key: new PluginKey("drop-handler-plugin"), props: { - handlePaste: (view, event) => { - if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) { + handlePaste: (view: EditorView, event: ClipboardEvent) => { + if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) { event.preventDefault(); - const file = event.clipboardData.files[0]; - const pos = view.state.selection.from; - startImageUpload(this.editor, file, view, pos, uploadFile); - return true; + const files = Array.from(event.clipboardData.files); + const imageFiles = files.filter((file) => file.type.startsWith("image")); + + if (imageFiles.length > 0) { + const pos = view.state.selection.from; + imageFiles.forEach((file, index) => { + this.editor + .chain() + .focus() + .setImageUpload({ file, pos: pos + index, event: "drop" }) + .run(); + }); + return true; + } } return false; }, - handleDrop: (view, event, _slice, moved) => { - if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { + handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => { + if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) { event.preventDefault(); - const file = event.dataTransfer.files[0]; - const coordinates = view.posAtCoords({ - left: event.clientX, - top: event.clientY, - }); - if (coordinates) { - startImageUpload(this.editor, file, view, coordinates.pos - 1, uploadFile); + const files = Array.from(event.dataTransfer.files); + const imageFiles = files.filter((file) => file.type.startsWith("image")); + + if (imageFiles.length > 0) { + const coordinates = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + + if (coordinates) { + imageFiles.forEach((file, index) => { + setTimeout(() => { + this.editor + .chain() + .focus() + .setImageUpload({ file, pos: coordinates.pos + index, event: "drop" }) + .run(); + }, index * 100); // Slight delay between insertions + }); + } + return true; } - return true; } return false; }, diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 823754a9317..1c2e1889112 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -12,6 +12,7 @@ import { CustomCodeInlineExtension, CustomCodeMarkPlugin, CustomHorizontalRule, + CustomImageExtension, CustomKeymap, CustomLinkExtension, CustomMention, @@ -79,7 +80,7 @@ export const CoreEditorExtensions = ({ ...(enableHistory ? {} : { history: false }), }), CustomQuoteExtension, - DropHandlerExtension(uploadFile), + DropHandlerExtension(), CustomHorizontalRule.configure({ HTMLAttributes: { class: "my-4 border-custom-border-400", @@ -104,6 +105,12 @@ export const CoreEditorExtensions = ({ class: "rounded-md", }, }), + CustomImageExtension({ + delete: deleteFile, + restore: restoreFile, + upload: uploadFile, + cancel: cancelUploadImage ?? (() => {}), + }), TiptapUnderline, TextStyle, TaskList.configure({ @@ -142,7 +149,7 @@ export const CoreEditorExtensions = ({ placeholder: ({ editor, node }) => { if (node.type.name === "heading") return `Heading ${node.attrs.level}`; - if (editor.storage.image.uploadInProgress) return ""; + // if (editor.storage.image.uploadInProgress) return ""; const shouldHidePlaceholder = editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index 98961b7f0f1..1f15846a1a1 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -1,37 +1,33 @@ import ImageExt from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; // helpers import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; // plugins -import { - IMAGE_NODE_TYPE, - ImageExtensionStorage, - TrackImageDeletionPlugin, - TrackImageRestorationPlugin, - UploadImagesPlugin, -} from "@/plugins/image"; +import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image"; // types import { DeleteImage, RestoreImage } from "@/types"; +// extensions +import { CustomImageNode } from "@/extensions"; export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) => ImageExt.extend({ addKeyboardShortcuts() { return { - ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"), - ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"), + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), }; }, addProseMirrorPlugins() { return [ - UploadImagesPlugin(this.editor, cancelUploadImage), - TrackImageDeletionPlugin(this.editor, deleteImage), - TrackImageRestorationPlugin(this.editor, restoreImage), + TrackImageDeletionPlugin(this.editor, deleteImage, this.name), + TrackImageRestorationPlugin(this.editor, restoreImage, this.name), ]; }, onCreate(this) { const imageSources = new Set(); this.editor.state.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { + if (node.type.name === this.name) { imageSources.add(node.attrs.src); } }); @@ -64,4 +60,9 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm }, }; }, + + // render custom image node + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, }); diff --git a/packages/editor/src/core/extensions/image/image-component-without-props.tsx b/packages/editor/src/core/extensions/image/image-component-without-props.tsx new file mode 100644 index 00000000000..2141f6445d3 --- /dev/null +++ b/packages/editor/src/core/extensions/image/image-component-without-props.tsx @@ -0,0 +1,57 @@ +import { mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { Image } from "@tiptap/extension-image"; +// extensions +import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions"; + +export const CustomImageComponentWithoutProps = () => + Image.extend, UploadImageExtensionStorage>({ + 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, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "image-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["image-component", mergeAttributes(HTMLAttributes)]; + }, + + addStorage() { + return { + fileMap: new Map(), + deletedImageSet: new Map(), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, + }); + +export default CustomImageComponentWithoutProps; diff --git a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx index 0d505000c7e..bd9ca3c820b 100644 --- a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx @@ -1,4 +1,7 @@ import ImageExt from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +// extensions +import { CustomImageNode } from "@/extensions"; export const ImageExtensionWithoutProps = () => ImageExt.extend({ @@ -13,4 +16,8 @@ export const ImageExtensionWithoutProps = () => }, }; }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, }); diff --git a/packages/editor/src/core/extensions/image/image-resize.tsx b/packages/editor/src/core/extensions/image/image-resize.tsx deleted file mode 100644 index c50e3189660..00000000000 --- a/packages/editor/src/core/extensions/image/image-resize.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useState } from "react"; -import { Editor } from "@tiptap/react"; -import Moveable from "react-moveable"; - -type Props = { - editor: Editor; - id: string; -}; - -const getImageElement = (editorId: string): HTMLImageElement | null => - document.querySelector(`#editor-container-${editorId}.active-editor .ProseMirror-selectednode`); - -export const ImageResizer = (props: Props) => { - const { editor, id } = props; - // states - const [aspectRatio, setAspectRatio] = useState(1); - - const updateMediaSize = () => { - const imageElement = getImageElement(id); - - if (!imageElement) return; - - const selection = editor.state.selection; - - // Use the style width/height if available, otherwise fall back to the element's natural width/height - const width = imageElement.style.width - ? Number(imageElement.style.width.replace("px", "")) - : imageElement.getAttribute("width"); - const height = imageElement.style.height - ? Number(imageElement.style.height.replace("px", "")) - : imageElement.getAttribute("height"); - - editor.commands.setImage({ - src: imageElement.src, - width: width, - height: height, - } as any); - editor.commands.setNodeSelection(selection.from); - }; - - return ( - { - const imageElement = getImageElement(id); - if (imageElement) { - const originalWidth = Number(imageElement.width); - const originalHeight = Number(imageElement.height); - setAspectRatio(originalWidth / originalHeight); - } - }} - onResize={({ target, width, height, delta }) => { - if (delta[0] || delta[1]) { - let newWidth, newHeight; - if (delta[0]) { - // Width change detected - newWidth = Math.max(width, 100); - newHeight = newWidth / aspectRatio; - } else if (delta[1]) { - // Height change detected - newHeight = Math.max(height, 100); - newWidth = newHeight * aspectRatio; - } - target.style.width = `${newWidth}px`; - target.style.height = `${newHeight}px`; - } - }} - onResizeEnd={() => { - updateMediaSize(); - }} - scalable - renderDirections={["se"]} - onScale={({ target, transform }) => { - target.style.transform = transform; - }} - /> - ); -}; diff --git a/packages/editor/src/core/extensions/image/index.ts b/packages/editor/src/core/extensions/image/index.ts index 3e2f7518dd3..9c7dc65d783 100644 --- a/packages/editor/src/core/extensions/image/index.ts +++ b/packages/editor/src/core/extensions/image/index.ts @@ -1,4 +1,3 @@ export * from "./extension"; export * from "./image-extension-without-props"; -export * from "./image-resize"; export * from "./read-only-image"; diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx index 8112eba4ec5..1605174b325 100644 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ b/packages/editor/src/core/extensions/image/read-only-image.tsx @@ -1,4 +1,7 @@ import Image from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +// extensions +import { CustomImageNode } from "@/extensions"; export const ReadOnlyImageExtension = Image.extend({ addAttributes() { @@ -12,4 +15,7 @@ export const ReadOnlyImageExtension = Image.extend({ }, }; }, + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, }); diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index 41f8189b0b7..658dd2f7997 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -1,5 +1,6 @@ export * from "./code"; export * from "./code-inline"; +export * from "./custom-image"; export * from "./custom-link"; export * from "./custom-list-keymap"; export * from "./image"; diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index 68e3f7d6888..2898b6cdc03 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -19,6 +19,7 @@ import { TableRow, Table, CustomMention, + CustomReadOnlyImageExtension, } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; @@ -74,6 +75,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { class: "rounded-md", }, }), + CustomReadOnlyImageExtension(), TiptapUnderline, TextStyle, TaskList.configure({ diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.tsx index 75577c74fc6..038d679b185 100644 --- a/packages/editor/src/core/extensions/side-menu.tsx +++ b/packages/editor/src/core/extensions/side-menu.tsx @@ -3,7 +3,7 @@ import { Plugin, PluginKey } from "@tiptap/pm/state"; import { EditorView } from "@tiptap/pm/view"; // plugins import { AIHandlePlugin } from "@/plugins/ai-handle"; -import { DragHandlePlugin } from "@/plugins/drag-handle"; +import { DragHandlePlugin, nodeDOMAtCoords } from "@/plugins/drag-handle"; type Props = { aiEnabled: boolean; @@ -59,41 +59,6 @@ const absoluteRect = (node: Element) => { }; }; -const nodeDOMAtCoords = (coords: { x: number; y: number }) => { - const elements = document.elementsFromPoint(coords.x, coords.y); - const generalSelectors = [ - "li", - "p:not(:first-child)", - ".code-block", - "blockquote", - "img", - "h1, h2, h3, h4, h5, h6", - "[data-type=horizontalRule]", - ".table-wrapper", - ".issue-embed", - ].join(", "); - - for (const elem of elements) { - if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) { - return elem; - } - - // if the element is a

tag that is the first child of a td or th - if ( - (elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) && - elem?.textContent?.trim() !== "" - ) { - return elem; // Return only if p tag is not empty in td or th - } - - // apply general selector - if (elem.matches(generalSelectors)) { - return elem; - } - } - return null; -}; - const SideMenu = (options: SideMenuPluginProps) => { const { handlesConfig } = options; const editorSideMenu: HTMLDivElement | null = document.createElement("div"); diff --git a/packages/editor/src/core/extensions/slash-commands.tsx b/packages/editor/src/core/extensions/slash-commands.tsx index 78aa379576b..3b1789781cf 100644 --- a/packages/editor/src/core/extensions/slash-commands.tsx +++ b/packages/editor/src/core/extensions/slash-commands.tsx @@ -28,7 +28,6 @@ import { toggleBulletList, toggleOrderedList, toggleTaskList, - insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree, @@ -37,7 +36,7 @@ import { toggleHeadingSix, } from "@/helpers/editor-commands"; // types -import { CommandProps, ISlashCommandItem, UploadImage } from "@/types"; +import { CommandProps, ISlashCommandItem } from "@/types"; interface CommandItemProps { key: string; @@ -63,7 +62,7 @@ const Command = Extension.create({ const { selection } = editor.state; const parentNode = selection.$from.node(selection.$from.depth); - const blockType = parentNode?.type?.name; + const blockType = parentNode.type.name; if (blockType === "codeBlock") { return false; @@ -89,7 +88,7 @@ const Command = Extension.create({ }); const getSuggestionItems = - (uploadFile: UploadImage, additionalOptions?: Array) => + (additionalOptions?: Array) => ({ query }: { query: string }) => { let slashCommands: ISlashCommandItem[] = [ { @@ -224,11 +223,11 @@ const getSuggestionItems = { key: "image", title: "Image", - description: "Upload an image from your computer.", - searchTerms: ["img", "photo", "picture", "media"], icon: , + description: "Insert an image", + searchTerms: ["img", "photo", "picture", "media", "upload"], command: ({ editor, range }: CommandProps) => { - insertImageCommand(editor, uploadFile, null, range); + editor.chain().focus().deleteRange(range).setImageUpload({ event: "insert" }).run(); }, }, { @@ -415,10 +414,10 @@ const renderItems = () => { }; }; -export const SlashCommand = (uploadFile: UploadImage, additionalOptions?: Array) => +export const SlashCommand = (additionalOptions?: Array) => Command.configure({ suggestion: { - items: getSuggestionItems(uploadFile, additionalOptions), + items: getSuggestionItems(additionalOptions), render: renderItems, }, }); diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index eba56b099b8..35456068301 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -83,7 +83,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { ...(extensions ?? []), ...DocumentEditorAdditionalExtensions({ disabledExtensions, - fileHandler, issueEmbedConfig: embedHandler?.issue, provider, userDetails: user, diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index c12e612fe17..0542418e5d5 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -91,6 +91,7 @@ export const useEditor = (props: CustomEditorProps) => { onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()), onDestroy: () => handleEditorReady?.(false), }); + // Update the ref whenever savedSelection changes useEffect(() => { savedSelectionRef.current = savedSelection; @@ -123,7 +124,7 @@ export const useEditor = (props: CustomEditorProps) => { editorRef.current?.commands.clearContent(emitUpdate); }, setEditorValue: (content: string) => { - editorRef.current?.commands.setContent(content); + editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" }); }, setEditorValueAtCursorPosition: (content: string) => { if (savedSelection) { @@ -131,7 +132,7 @@ export const useEditor = (props: CustomEditorProps) => { } }, executeMenuItemCommand: (itemKey: TEditorCommands) => { - const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload); + const editorItems = getEditorMenuItems(editorRef.current); const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); @@ -147,7 +148,7 @@ export const useEditor = (props: CustomEditorProps) => { } }, isMenuItemActive: (itemName: TEditorCommands): boolean => { - const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload); + const editorItems = getEditorMenuItems(editorRef.current); const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName); const item = getEditorMenuItem(itemName); @@ -214,20 +215,25 @@ export const useEditor = (props: CustomEditorProps) => { } }); const selection = nodesArray.join(""); - console.log(selection); return selection; }, insertText: (contentHTML, insertOnNextLine) => { - if (!editor) return; + if (!editorRef.current) return; // get selection - const { from, to, empty } = editor.state.selection; + const { from, to, empty } = editorRef.current.state.selection; if (empty) return; if (insertOnNextLine) { // move cursor to the end of the selection and insert a new line - editor.chain().focus().setTextSelection(to).insertContent("
").insertContent(contentHTML).run(); + editorRef.current + .chain() + .focus() + .setTextSelection(to) + .insertContent("
") + .insertContent(contentHTML) + .run(); } else { // replace selected text with the content provided - editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run(); + editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run(); } }, getDocumentInfo: () => { @@ -238,7 +244,7 @@ export const useEditor = (props: CustomEditorProps) => { }; }, }), - [editorRef, savedSelection, fileHandler.upload] + [editorRef, savedSelection] ); if (!editor) { diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts new file mode 100644 index 00000000000..3aea2d155cb --- /dev/null +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -0,0 +1,111 @@ +import { DragEvent, useCallback, useEffect, useRef, useState } from "react"; +import { Editor } from "@tiptap/core"; +import { isFileValid } from "@/plugins/image"; + +export const useUploader = ({ onUpload, editor }: { onUpload: (url: string) => void; editor: Editor }) => { + const [loading, setLoading] = useState(false); + + const uploadFile = useCallback( + async (file: File) => { + setLoading(true); + try { + // @ts-expect-error - TODO: fix typings, and don't remove await from + // here for now + const url: string = await editor?.commands.uploadImage(file); + + if (!url) { + throw new Error("Something went wrong while uploading the image"); + } + onUpload(url); + } catch (errPayload: any) { + console.log(errPayload); + const error = errPayload?.response?.data?.error || "Something went wrong"; + console.error(error); + } + setLoading(false); + }, + [onUpload, editor] + ); + + return { loading, uploadFile }; +}; + +export const useFileUpload = () => { + const fileInput = useRef(null); + + const handleUploadClick = useCallback(() => { + fileInput.current?.click(); + }, []); + + return { ref: fileInput, handleUploadClick }; +}; +export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => { + const [isDragging, setIsDragging] = useState(false); + const [draggedInside, setDraggedInside] = useState(false); + + useEffect(() => { + const dragStartHandler = () => { + setIsDragging(true); + }; + + const dragEndHandler = () => { + setIsDragging(false); + }; + + document.body.addEventListener("dragstart", dragStartHandler); + document.body.addEventListener("dragend", dragEndHandler); + + return () => { + document.body.removeEventListener("dragstart", dragStartHandler); + document.body.removeEventListener("dragend", dragEndHandler); + }; + }, []); + + const onDrop = useCallback( + (e: DragEvent) => { + setDraggedInside(false); + if (e.dataTransfer.files.length === 0) { + return; + } + + const fileList = e.dataTransfer.files; + + const files: File[] = []; + + for (let i = 0; i < fileList.length; i += 1) { + const item = fileList.item(i); + if (item) { + files.push(item); + } + } + + if (files.some((file) => file.type.indexOf("image") === -1)) { + return; + } + + e.preventDefault(); + + const filteredFiles = files.filter((f) => f.type.indexOf("image") !== -1); + + const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined; + + if (file) { + const isValid = isFileValid(file); + if (isValid) { + uploader(file); + } + } + }, + [uploader] + ); + + const onDragEnter = () => { + setDraggedInside(true); + }; + + const onDragLeave = () => { + setDraggedInside(false); + }; + + return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop }; +}; diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index b6081a51032..3ee7f8e9075 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -58,7 +58,7 @@ export const useReadOnlyEditor = ({ // for syncing swr data on tab refocus etc useEffect(() => { if (initialValue === null || initialValue === undefined) return; - if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue); + if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" }); }, [editor, initialValue]); const editorRef: MutableRefObject = useRef(null); @@ -68,7 +68,7 @@ export const useReadOnlyEditor = ({ editorRef.current?.commands.clearContent(); }, setEditorValue: (content: string) => { - editorRef.current?.commands.setContent(content); + editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" }); }, getMarkDown: (): string => { const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index f8b258e8ad1..d260b284968 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -30,7 +30,7 @@ const createDragHandleElement = (): HTMLElement => { return dragHandleElement; }; -const nodeDOMAtCoords = (coords: { x: number; y: number }) => { +export const nodeDOMAtCoords = (coords: { x: number; y: number }) => { const elements = document.elementsFromPoint(coords.x, coords.y); const generalSelectors = [ "li", @@ -42,13 +42,34 @@ const nodeDOMAtCoords = (coords: { x: number; y: number }) => { "[data-type=horizontalRule]", ".table-wrapper", ".issue-embed", + ".image-upload-component", ].join(", "); + const hasNestedImg = (el: Element): boolean => { + if (el.tagName.toLowerCase() === "img") return true; + // @ts-expect-error todo + for (const child of el.children) { + if (hasNestedImg(child)) return true; + } + return false; + }; + for (const elem of elements) { + const elemHasNestedImg = hasNestedImg(elem); if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) { return elem; } + // if the element is a

tag and has a nested img i.e. the new image + // component + if (elem.matches("p") && elemHasNestedImg) { + return null; + } + + if (elem.matches("div") && elemHasNestedImg) { + return elem; + } + // if the element is a

tag that is the first child of a td or th if ( (elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) && diff --git a/packages/editor/src/core/plugins/image/delete-image.ts b/packages/editor/src/core/plugins/image/delete-image.ts index 8dc1bf07229..769b6f24588 100644 --- a/packages/editor/src/core/plugins/image/delete-image.ts +++ b/packages/editor/src/core/plugins/image/delete-image.ts @@ -1,17 +1,17 @@ import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; // plugins -import { IMAGE_NODE_TYPE, deleteKey, type ImageNode } from "@/plugins/image"; +import { type ImageNode } from "@/plugins/image"; // types import { DeleteImage } from "@/types"; -export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage): Plugin => +export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage, nodeType: string): Plugin => new Plugin({ - key: deleteKey, + key: new PluginKey(`delete-${nodeType}`), appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { const newImageSources = new Set(); newState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { + if (node.type.name === nodeType) { newImageSources.add(node.attrs.src); } }); @@ -25,7 +25,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag // 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 !== IMAGE_NODE_TYPE) return; + if (oldNode.type.name !== nodeType) return; // Check if the node has been deleted or replaced if (!newImageSources.has(oldNode.attrs.src)) { @@ -35,7 +35,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag removedImages.forEach(async (node) => { const src = node.attrs.src; - editor.storage.image.deletedImageSet.set(src, true); + editor.storage[nodeType].deletedImageSet.set(src, true); await onNodeDeleted(src, deleteImage); }); }); diff --git a/packages/editor/src/core/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts index 036df9b8870..cbcd3353225 100644 --- a/packages/editor/src/core/plugins/image/restore-image.ts +++ b/packages/editor/src/core/plugins/image/restore-image.ts @@ -1,17 +1,17 @@ import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; // plugins -import { IMAGE_NODE_TYPE, ImageNode, restoreKey } from "@/plugins/image"; +import { ImageNode } from "@/plugins/image"; // types import { RestoreImage } from "@/types"; -export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage): Plugin => +export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage, nodeType: string): Plugin => new Plugin({ - key: restoreKey, + key: new PluginKey(`restore-${nodeType}`), appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { const oldImageSources = new Set(); oldState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { + if (node.type.name === nodeType) { oldImageSources.add(node.attrs.src); } }); @@ -22,20 +22,21 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor const addedImages: ImageNode[] = []; newState.doc.descendants((node, pos) => { - if (node.type.name !== IMAGE_NODE_TYPE) return; + if (node.type.name !== nodeType) return; if (pos < 0 || pos > newState.doc.content.size) return; if (oldImageSources.has(node.attrs.src)) return; addedImages.push(node as ImageNode); }); addedImages.forEach(async (image) => { - const wasDeleted = editor.storage.image.deletedImageSet.get(image.attrs.src); + const src = image.attrs.src; + const wasDeleted = editor.storage[nodeType].deletedImageSet.get(src); if (wasDeleted === undefined) { - editor.storage.image.deletedImageSet.set(image.attrs.src, false); + editor.storage[nodeType].deletedImageSet.set(src, false); } else if (wasDeleted === true) { try { - await onNodeRestored(image.attrs.src, restoreImage); - editor.storage.image.deletedImageSet.set(image.attrs.src, false); + await onNodeRestored(src, restoreImage); + editor.storage[nodeType].deletedImageSet.set(src, false); } catch (error) { console.error("Error restoring image: ", error); } diff --git a/packages/editor/src/core/plugins/image/utils/validate-file.ts b/packages/editor/src/core/plugins/image/utils/validate-file.ts index b79ca6683e2..c86e99335fe 100644 --- a/packages/editor/src/core/plugins/image/utils/validate-file.ts +++ b/packages/editor/src/core/plugins/image/utils/validate-file.ts @@ -1,17 +1,23 @@ -export function isFileValid(file: File): boolean { +export function isFileValid(file: File, showAlert = true): boolean { if (!file) { - alert("No file selected. Please select a file to upload."); + if (showAlert) { + alert("No file selected. Please select a file to upload."); + } return false; } const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; if (!allowedTypes.includes(file.type)) { - alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file."); + if (showAlert) { + alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file."); + } return false; } if (file.size > 5 * 1024 * 1024) { - alert("File size too large. Please select a file smaller than 5MB."); + if (showAlert) { + alert("File size too large. Please select a file smaller than 5MB."); + } return false; } diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 15ec7913752..fc9fe1ac603 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -23,7 +23,6 @@ export * from "@/helpers/common"; export * from "@/helpers/editor-commands"; export * from "@/helpers/yjs"; export * from "@/extensions/table/table"; -export { startImageUpload } from "@/plugins/image"; // components export * from "@/components/menus"; diff --git a/packages/editor/src/styles/drag-drop.css b/packages/editor/src/styles/drag-drop.css index 3bea5dcf256..9fb8a2c36aa 100644 --- a/packages/editor/src/styles/drag-drop.css +++ b/packages/editor/src/styles/drag-drop.css @@ -39,7 +39,7 @@ } /* end ai handle */ -.ProseMirror:not(.dragging) .ProseMirror-selectednode { +.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image) { position: relative; cursor: grab; outline: none !important; @@ -63,6 +63,15 @@ border-radius: 4px; pointer-events: none; } + + &.node-imageComponent, + &.node-image { + --horizontal-offset: 0px; + + &::after { + background-color: rgba(var(--color-background-100), 0.2); + } + } } /* for targeting the task list items */ @@ -96,7 +105,8 @@ ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after { margin-left: -35px; } -.ProseMirror img { +.ProseMirror node-image, +.ProseMirror node-imageComponent { transition: filter 0.1s ease-in-out; cursor: pointer; diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index b27db3c6a41..0c79fdfc60c 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -122,18 +122,21 @@ /* Custom image styles */ .ProseMirror img { - transition: filter 0.1s ease-in-out; - margin-top: 8px; + margin-top: 0 !important; margin-bottom: 0; - &:hover { - cursor: pointer; - filter: brightness(90%); - } + &:not(.read-only-image) { + transition: filter 0.1s ease-in-out; + + &:hover { + cursor: pointer; + filter: brightness(90%); + } - &.ProseMirror-selectednode { - outline: 3px solid rgba(var(--color-primary-100)); - filter: brightness(90%); + &.ProseMirror-selectednode { + outline: 3px solid rgba(var(--color-primary-100)); + filter: brightness(90%); + } } } @@ -261,26 +264,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { transition: opacity 0.2s ease-out; } -.img-placeholder { - position: relative; - width: 35%; - margin-top: 0 !important; - margin-bottom: 0 !important; - - &::before { - content: ""; - box-sizing: border-box; - position: absolute; - top: 50%; - left: 45%; - width: 20px; - height: 20px; - border-radius: 50%; - border: 3px solid rgba(var(--color-text-200)); - border-top-color: rgba(var(--color-text-800)); - animation: spinning 0.6s linear infinite; - } -} @keyframes spinning { to { diff --git a/packages/editor/tsup.config.ts b/packages/editor/tsup.config.ts index 98a37e6705e..c378c0b2b2d 100644 --- a/packages/editor/tsup.config.ts +++ b/packages/editor/tsup.config.ts @@ -4,7 +4,7 @@ export default defineConfig((options: Options) => ({ entry: ["src/index.ts", "src/lib.ts"], format: ["cjs", "esm"], dts: true, - clean: true, + clean: false, external: ["react"], injectStyle: true, ...options, diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 736459f9824..9cc5c44c249 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -2,12 +2,21 @@ "name": "@plane/helpers", "version": "0.22.0", "description": "Helper functions shared across multiple apps internally", - "main": "index.ts", "private": true, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**" + ], + "scripts": { + "build": "tsup ./index.ts --format esm,cjs --dts --external react --minify" + }, "devDependencies": { "@types/node": "^22.5.4", "@types/react": "^18.3.5", - "typescript": "^5.6.2" + "typescript": "^5.6.2", + "tsup": "^7.2.0" }, "dependencies": { "react": "^18.3.1" diff --git a/packages/helpers/tsconfig.json b/packages/helpers/tsconfig.json new file mode 100644 index 00000000000..08a82e90799 --- /dev/null +++ b/packages/helpers/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "tsconfig/react-library.json", + "compilerOptions": { + "jsx": "react" + }, + "include": ["."], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/space/helpers/string.helper.ts b/space/helpers/string.helper.ts index 6c60e4bb9f0..5c704c44c36 100644 --- a/space/helpers/string.helper.ts +++ b/space/helpers/string.helper.ts @@ -69,7 +69,9 @@ export const isCommentEmpty = (comment: string | undefined): boolean => { // return true if comment is undefined if (!comment) return true; return ( - comment?.trim() === "" || comment === "

" || isEmptyHtmlString(comment ?? "", ["img", "mention-component"]) + comment?.trim() === "" || + comment === "

" || + isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"]) ); }; diff --git a/web/helpers/string.helper.ts b/web/helpers/string.helper.ts index 00d601c74e0..1182feeb0a7 100644 --- a/web/helpers/string.helper.ts +++ b/web/helpers/string.helper.ts @@ -249,7 +249,9 @@ export const isCommentEmpty = (comment: string | undefined): boolean => { // return true if comment is undefined if (!comment) return true; return ( - comment?.trim() === "" || comment === "

" || isEmptyHtmlString(comment ?? "", ["img", "mention-component"]) + comment?.trim() === "" || + comment === "

" || + isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"]) ); }; diff --git a/yarn.lock b/yarn.lock index c3bec8f79e0..694c95d6c17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4020,6 +4020,13 @@ dependencies: undici-types "~6.19.2" +"@types/node@^22.5.4": + version "22.5.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.5.5.tgz#52f939dd0f65fc552a4ad0b392f3c466cc5d7a44" + integrity sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA== + dependencies: + undici-types "~6.19.2" + "@types/nprogress@^0.2.0": version "0.2.3" resolved "https://registry.yarnpkg.com/@types/nprogress/-/nprogress-0.2.3.tgz#b2150b054a13622fabcba12cf6f0b54c48b14287" @@ -4092,7 +4099,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@18.2.48", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react@^18.2.42", "@types/react@^18.2.48": +"@types/react@*", "@types/react@18.2.48", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react@^18.2.42", "@types/react@^18.2.48", "@types/react@^18.3.5": version "18.2.48" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.48.tgz#11df5664642d0bd879c1f58bc1d37205b064e8f1" integrity sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w== @@ -12445,6 +12452,11 @@ typescript@5.4.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== +typescript@^5.6.2: + version "5.6.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" + integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== + uc.micro@^2.0.0, uc.micro@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee" @@ -13167,4 +13179,4 @@ zeed-dom@^0.10.9: zxcvbn@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/zxcvbn/-/zxcvbn-4.4.2.tgz#28ec17cf09743edcab056ddd8b1b06262cc73c30" - integrity sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ== \ No newline at end of file + integrity sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==