diff --git a/packages/editor/src/ce/types/storage.ts b/packages/editor/src/ce/types/storage.ts index 5f576df5090..84eee65f982 100644 --- a/packages/editor/src/ce/types/storage.ts +++ b/packages/editor/src/ce/types/storage.ts @@ -2,7 +2,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension"; // extensions import { type HeadingExtensionStorage } from "@/extensions"; -import { type CustomImageExtensionStorage } from "@/extensions/custom-image"; +import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types"; import { type CustomLinkStorage } from "@/extensions/custom-link"; import { type ImageExtensionStorage } from "@/extensions/image"; import { type MentionExtensionStorage } from "@/extensions/mentions"; diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index a309c2013af..d66cae7bde9 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -12,10 +12,10 @@ import { CustomCalloutExtensionConfig } from "./callout/extension-config"; import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props"; import { CustomCodeInlineExtension } from "./code-inline"; import { CustomColorExtension } from "./custom-color"; +import { CustomImageExtensionConfig } from "./custom-image/extension-config"; import { CustomLinkExtension } from "./custom-link"; import { CustomHorizontalRule } from "./horizontal-rule"; -import { ImageExtensionWithoutProps } from "./image"; -import { CustomImageComponentWithoutProps } from "./image/image-component-without-props"; +import { ImageExtensionConfig } from "./image"; import { CustomMentionExtensionConfig } from "./mentions/extension-config"; import { CustomQuoteExtension } from "./quote"; import { TableHeader, TableCell, TableRow, Table } from "./table"; @@ -72,12 +72,8 @@ export const CoreEditorExtensionsWithoutProps = [ "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), - ImageExtensionWithoutProps.configure({ - HTMLAttributes: { - class: "rounded-md", - }, - }), - CustomImageComponentWithoutProps, + ImageExtensionConfig, + CustomImageExtensionConfig, 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/block.tsx similarity index 89% rename from packages/editor/src/core/extensions/custom-image/components/image-block.tsx rename to packages/editor/src/core/extensions/custom-image/components/block.tsx index 5dfbad01294..1ff36abca77 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/block.tsx @@ -1,68 +1,42 @@ import { NodeSelection } from "@tiptap/pm/state"; import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react"; -// plane utils +// plane imports import { cn } from "@plane/utils"; -// extensions -import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image"; +// local imports +import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types"; +import { ensurePixelString } from "../utils"; +import type { CustomImageNodeViewProps } from "./node-view"; +import { ImageToolbarRoot } from "./toolbar"; import { ImageUploadStatus } from "./upload-status"; const MIN_SIZE = 100; -type Pixel = `${number}px`; - -type PixelAttribute = Pixel | TDefault; - -export type ImageAttributes = { - src: string | null; - width: PixelAttribute<"35%" | number>; - height: PixelAttribute<"auto" | number>; - aspectRatio: number | null; - id: string | null; -}; - -type Size = { - width: PixelAttribute<"35%">; - height: PixelAttribute<"auto">; - aspectRatio: number | null; -}; - -const ensurePixelString = (value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => { - if (!value || value === defaultValue) { - return defaultValue; - } - - if (typeof value === "number") { - return `${value}px` satisfies Pixel; - } - - return value; -}; - -type CustomImageBlockProps = CustomBaseImageNodeViewProps & { - imageFromFileSystem: string | undefined; - setFailedToLoadImage: (isError: boolean) => void; +type CustomImageBlockProps = CustomImageNodeViewProps & { editorContainer: HTMLDivElement | null; + imageFromFileSystem: string | undefined; setEditorContainer: (editorContainer: HTMLDivElement | null) => void; + setFailedToLoadImage: (isError: boolean) => void; src: string | undefined; }; export const CustomImageBlock: React.FC = (props) => { // props const { - node, - updateAttributes, - setFailedToLoadImage, - imageFromFileSystem, - selected, - getPos, editor, editorContainer, - src: resolvedImageSrc, + extension, + getPos, + imageFromFileSystem, + node, + selected, setEditorContainer, + setFailedToLoadImage, + src: resolvedImageSrc, + updateAttributes, } = props; const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs; // states - const [size, setSize] = useState({ + const [size, setSize] = useState({ width: ensurePixelString(nodeWidth, "35%") ?? "35%", height: ensurePixelString(nodeHeight, "auto") ?? "auto", aspectRatio: nodeAspectRatio || null, @@ -77,7 +51,7 @@ export const CustomImageBlock: React.FC = (props) => { const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false); const updateAttributesSafely = useCallback( - (attributes: Partial, errorMessage: string) => { + (attributes: Partial, errorMessage: string) => { try { updateAttributes(attributes); } catch (error) { @@ -114,7 +88,7 @@ export const CustomImageBlock: React.FC = (props) => { const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE); const initialHeight = initialWidth / aspectRatioCalculated; - const initialComputedSize = { + const initialComputedSize: TCustomImageSize = { width: `${Math.round(initialWidth)}px` satisfies Pixel, height: `${Math.round(initialHeight)}px` satisfies Pixel, aspectRatio: aspectRatioCalculated, @@ -139,7 +113,7 @@ export const CustomImageBlock: React.FC = (props) => { } } setInitialResizeComplete(true); - }, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]); + }, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]); // for real time resizing useLayoutEffect(() => { @@ -168,7 +142,7 @@ export const CustomImageBlock: React.FC = (props) => { const handleResizeEnd = useCallback(() => { setIsResizing(false); updateAttributesSafely(size, "Failed to update attributes at the end of resizing:"); - }, [size, updateAttributes]); + }, [size, updateAttributesSafely]); const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { e.preventDefault(); @@ -242,7 +216,7 @@ export const CustomImageBlock: React.FC = (props) => { onLoad={handleImageLoad} onError={async (e) => { // for old image extension this command doesn't exist or if the image failed to load for the first time - if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) { + if (!extension.options.restoreImage || hasTriedRestoringImageOnce) { setFailedToLoadImage(true); return; } @@ -253,7 +227,7 @@ export const CustomImageBlock: React.FC = (props) => { if (!imgNodeSrc) { throw new Error("No source image to restore from"); } - await editor?.commands.restoreImage?.(imgNodeSrc); + await extension.options.restoreImage?.(imgNodeSrc); if (!imageRef.current) { throw new Error("Image reference not found"); } @@ -289,10 +263,10 @@ export const CustomImageBlock: React.FC = (props) => { "absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity" } image={{ - src: resolvedImageSrc, - aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio, - height: size.height, width: size.width, + height: size.height, + aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio, + src: resolvedImageSrc, }} /> )} diff --git a/packages/editor/src/core/extensions/custom-image/components/index.ts b/packages/editor/src/core/extensions/custom-image/components/index.ts deleted file mode 100644 index 9d12c3ecf19..00000000000 --- a/packages/editor/src/core/extensions/custom-image/components/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./toolbar"; -export * from "./image-block"; -export * from "./image-node"; -export * from "./image-uploader"; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/node-view.tsx similarity index 69% rename from packages/editor/src/core/extensions/custom-image/components/image-node.tsx rename to packages/editor/src/core/extensions/custom-image/components/node-view.tsx index 8dfe6974b75..74ea2c38c54 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/node-view.tsx @@ -2,25 +2,26 @@ 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"; +// local imports +import type { CustomImageExtension, TCustomImageAttributes } from "../types"; +import { CustomImageBlock } from "./block"; +import { CustomImageUploader } from "./uploader"; -export type CustomBaseImageNodeViewProps = { +export type CustomImageNodeViewProps = Omit & { + extension: CustomImageExtension; getPos: () => number; editor: Editor; node: NodeViewProps["node"] & { - attrs: ImageAttributes; + attrs: TCustomImageAttributes; }; - updateAttributes: (attrs: Partial) => void; + updateAttributes: (attrs: Partial) => void; selected: boolean; }; -export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps; - -export const CustomImageNode = (props: CustomImageNodeProps) => { - const { getPos, editor, node, updateAttributes, selected } = props; +export const CustomImageNodeView: React.FC = (props) => { + const { editor, extension, node } = props; const { src: imgNodeSrc } = node.attrs; const [isUploaded, setIsUploaded] = useState(false); @@ -50,41 +51,37 @@ export const CustomImageNode = (props: CustomImageNodeProps) => { }, [resolvedSrc]); useEffect(() => { + if (!imgNodeSrc) { + setResolvedSrc(undefined); + return; + } + const getImageSource = async () => { - // @ts-expect-error function not expected here, but will still work and don't remove await - const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc); - setResolvedSrc(url as string); + const url = await extension.options.getImageSource?.(imgNodeSrc); + setResolvedSrc(url); }; getImageSource(); - }, [imgNodeSrc]); + }, [imgNodeSrc, extension.options]); return (
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? ( ) : ( )}
diff --git a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx index 61ae307bb66..43f178dc8f4 100644 --- a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx @@ -1,14 +1,14 @@ import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -// plane utils +// plane imports import { cn } from "@plane/utils"; type Props = { image: { - src: string; - height: string; width: string; + height: string; aspectRatio: number; + src: string; }; isOpen: boolean; toggleFullScreenMode: (val: boolean) => void; diff --git a/packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx b/packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx index 3179db0d4cd..f9cd28d48d0 100644 --- a/packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx @@ -1,16 +1,16 @@ import { useState } from "react"; -// plane utils +// plane imports import { cn } from "@plane/utils"; -// components +// local imports import { ImageFullScreenAction } from "./full-screen"; type Props = { containerClassName?: string; image: { - src: string; - height: string; width: string; + height: string; aspectRatio: number; + src: string; }; }; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/uploader.tsx similarity index 91% rename from packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx rename to packages/editor/src/core/extensions/custom-image/components/uploader.tsx index 17c9f817736..68626084ab8 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/uploader.tsx @@ -1,28 +1,30 @@ import { ImageIcon } from "lucide-react"; import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; -// plane utils +// plane imports import { cn } from "@plane/utils"; // constants import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; import { CORE_EXTENSIONS } from "@/constants/extension"; -// extensions -import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image"; // helpers import { EFileError } from "@/helpers/file"; import { getExtensionStorage } from "@/helpers/get-extension-storage"; // hooks import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload"; +// local imports +import { getImageComponentImageFileMap } from "../utils"; +import type { CustomImageNodeViewProps } from "./node-view"; -type CustomImageUploaderProps = CustomBaseImageNodeViewProps & { - maxFileSize: number; - loadImageFromFileSystem: (file: string) => void; +type CustomImageUploaderProps = CustomImageNodeViewProps & { failedToLoadImage: boolean; + loadImageFromFileSystem: (file: string) => void; + maxFileSize: number; setIsUploaded: (isUploaded: boolean) => void; }; export const CustomImageUploader = (props: CustomImageUploaderProps) => { const { editor, + extension, failedToLoadImage, getPos, loadImageFromFileSystem, @@ -71,12 +73,13 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { } } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [imageComponentImageFileMap, imageEntityId, updateAttributes, getPos] ); const uploadImageEditorCommand = useCallback( - async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file), - [editor, imageEntityId] + async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file), + [extension.options, imageEntityId] ); const handleProgressStatus = useCallback( @@ -93,7 +96,6 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { // hooks const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({ acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, - // @ts-expect-error - TODO: fix typings, and don't remove await from here for now editorCommand: uploadImageEditorCommand, handleProgressStatus, loadFileFromFileSystem: loadImageFromFileSystem, @@ -128,7 +130,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true }); } } - }, [meta, uploadFile, imageComponentImageFileMap]); + }, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]); const onFileChange = useCallback( async (e: ChangeEvent) => { @@ -163,7 +165,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => { } return "Add an image"; - }, [draggedInside, failedToLoadImage, isImageBeingUploaded]); + }, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]); return (
{ - [CORE_EXTENSIONS.CUSTOM_IMAGE]: { - insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; - uploadImage: (blockId: string, file: File) => () => Promise | undefined; - getImageSource?: (path: string) => () => Promise; - restoreImage: (src: string) => () => Promise; - }; - } -} - -export const getImageComponentImageFileMap = (editor: Editor) => - getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap; - -export interface CustomImageExtensionStorage { - fileMap: Map; - deletedImageSet: Map; - maxFileSize: number; -} - -export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; - -export const CustomImageExtension = (props: TFileHandler) => { - const { - getAssetSrc, - upload, - restore: restoreImageFn, - validation: { maxFileSize }, - } = props; - - return BaseImageExtension.extend, CustomImageExtensionStorage>({ - name: CORE_EXTENSIONS.CUSTOM_IMAGE, - 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, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "image-component", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return ["image-component", mergeAttributes(HTMLAttributes)]; - }, - - addKeyboardShortcuts() { - return { - ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), - ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), - }; - }, - - addStorage() { - return { - fileMap: new Map(), - deletedImageSet: new Map(), - maxFileSize, - // escape markdown for images - markdown: { - serialize() {}, - }, - }; - }, - - addCommands() { - return { - insertImageComponent: - (props) => - ({ commands }) => { - // Early return if there's an invalid file being dropped - if ( - props?.file && - !isFileValid({ - acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, - file: props.file, - maxFileSize, - onError: (_error, message) => alert(message), - }) - ) { - return false; - } - - // generate a unique id for the image to keep track of dropped - // files' file data - const fileId = uuidv4(); - - const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor); - - if (imageComponentImageFileMap) { - if (props?.event === "drop" && props.file) { - imageComponentImageFileMap.set(fileId, { - file: props.file, - event: props.event, - }); - } else if (props.event === "insert") { - imageComponentImageFileMap.set(fileId, { - event: props.event, - hasOpenedFileInputOnce: false, - }); - } - } - - 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: (blockId, file) => async () => { - const fileUrl = await upload(blockId, file); - return fileUrl; - }, - getImageSource: (path) => async () => await getAssetSrc(path), - restoreImage: (src) => async () => { - await restoreImageFn(src); - }, - }; - }, - - addNodeView() { - return ReactNodeViewRenderer(CustomImageNode); - }, - }); -}; diff --git a/packages/editor/src/core/extensions/custom-image/extension-config.ts b/packages/editor/src/core/extensions/custom-image/extension-config.ts new file mode 100644 index 00000000000..56714f53393 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/extension-config.ts @@ -0,0 +1,47 @@ +import { mergeAttributes } from "@tiptap/core"; +import { Image as BaseImageExtension } from "@tiptap/extension-image"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// local imports +import { type CustomImageExtension, ECustomImageAttributeNames, type InsertImageComponentProps } from "./types"; +import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils"; + +declare module "@tiptap/core" { + interface Commands { + [CORE_EXTENSIONS.CUSTOM_IMAGE]: { + insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; + }; + } +} + +export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtension.extend({ + name: CORE_EXTENSIONS.CUSTOM_IMAGE, + group: "block", + atom: true, + + addAttributes() { + const attributes = { + ...this.parent?.(), + ...Object.values(ECustomImageAttributeNames).reduce((acc, value) => { + acc[value] = { + default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value], + }; + return acc; + }, {}), + }; + + return attributes; + }, + + parseHTML() { + return [ + { + tag: "image-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["image-component", mergeAttributes(HTMLAttributes)]; + }, +}); diff --git a/packages/editor/src/core/extensions/custom-image/extension.ts b/packages/editor/src/core/extensions/custom-image/extension.ts new file mode 100644 index 00000000000..ec795da842b --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/extension.ts @@ -0,0 +1,121 @@ +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { v4 as uuidv4 } from "uuid"; +// constants +import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config"; +// helpers +import { isFileValid } from "@/helpers/file"; +import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; +// types +import type { TFileHandler, TReadOnlyFileHandler } from "@/types"; +// local imports +import { CustomImageNodeView } from "./components/node-view"; +import { CustomImageExtensionConfig } from "./extension-config"; +import { getImageComponentImageFileMap } from "./utils"; + +type Props = { + fileHandler: TFileHandler | TReadOnlyFileHandler; + isEditable: boolean; +}; + +export const CustomImageExtension = (props: Props) => { + const { fileHandler, isEditable } = props; + // derived values + const { getAssetSrc, restore: restoreImageFn } = fileHandler; + + return CustomImageExtensionConfig.extend({ + selectable: isEditable, + draggable: isEditable, + + addOptions() { + const upload = "upload" in fileHandler ? fileHandler.upload : undefined; + + return { + ...this.parent?.(), + getImageSource: getAssetSrc, + restoreImage: restoreImageFn, + uploadImage: upload, + }; + }, + + addStorage() { + const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0; + + return { + fileMap: new Map(), + deletedImageSet: new Map(), + maxFileSize, + // escape markdown for images + markdown: { + serialize() {}, + }, + }; + }, + + addCommands() { + return { + insertImageComponent: + (props) => + ({ commands }) => { + // Early return if there's an invalid file being dropped + if ( + props?.file && + !isFileValid({ + acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES, + file: props.file, + maxFileSize: this.storage.maxFileSize, + onError: (_error, message) => alert(message), + }) + ) { + return false; + } + + // generate a unique id for the image to keep track of dropped + // files' file data + const fileId = uuidv4(); + + const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor); + + if (imageComponentImageFileMap) { + if (props?.event === "drop" && props.file) { + imageComponentImageFileMap.set(fileId, { + file: props.file, + event: props.event, + }); + } else if (props.event === "insert") { + imageComponentImageFileMap.set(fileId, { + event: props.event, + hasOpenedFileInputOnce: false, + }); + } + } + + 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, + }); + }, + }; + }, + + addKeyboardShortcuts() { + return { + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNodeView); + }, + }); +}; diff --git a/packages/editor/src/core/extensions/custom-image/index.ts b/packages/editor/src/core/extensions/custom-image/index.ts deleted file mode 100644 index de2bb38789d..00000000000 --- a/packages/editor/src/core/extensions/custom-image/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 4a85ffd94cb..00000000000 --- a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { mergeAttributes } from "@tiptap/core"; -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 -import { TReadOnlyFileHandler } from "@/types"; - -export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { - const { getAssetSrc, restore: restoreImageFn } = props; - - return BaseImageExtension.extend, CustomImageExtensionStorage>({ - name: CORE_EXTENSIONS.CUSTOM_IMAGE, - selectable: false, - group: "block", - atom: true, - draggable: false, - - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - src: { - default: null, - }, - height: { - default: "auto", - }, - ["id"]: { - default: null, - }, - aspectRatio: { - default: null, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "image-component", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return ["image-component", mergeAttributes(HTMLAttributes)]; - }, - - addStorage() { - return { - fileMap: new Map(), - deletedImageSet: new Map(), - maxFileSize: 0, - // escape markdown for images - markdown: { - serialize() {}, - }, - }; - }, - - addCommands() { - return { - getImageSource: (path: string) => async () => await getAssetSrc(path), - restoreImage: (src) => async () => { - await restoreImageFn(src); - }, - }; - }, - - addNodeView() { - return ReactNodeViewRenderer(CustomImageNode); - }, - }); -}; diff --git a/packages/editor/src/core/extensions/custom-image/types.ts b/packages/editor/src/core/extensions/custom-image/types.ts new file mode 100644 index 00000000000..675d8a22155 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/types.ts @@ -0,0 +1,51 @@ +import type { Node } from "@tiptap/core"; +// types +import type { TFileHandler } from "@/types"; + +export enum ECustomImageAttributeNames { + ID = "id", + WIDTH = "width", + HEIGHT = "height", + ASPECT_RATIO = "aspectRatio", + SOURCE = "src", +} + +export type Pixel = `${number}px`; + +export type PixelAttribute = Pixel | TDefault; + +export type TCustomImageSize = { + width: PixelAttribute<"35%">; + height: PixelAttribute<"auto">; + aspectRatio: number | null; +}; + +export type TCustomImageAttributes = { + [ECustomImageAttributeNames.ID]: string | null; + [ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null; + [ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null; + [ECustomImageAttributeNames.ASPECT_RATIO]: number | null; + [ECustomImageAttributeNames.SOURCE]: string | null; +}; + +export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; + +export type InsertImageComponentProps = { + file?: File; + pos?: number; + event: "insert" | "drop"; +}; + +export type CustomImageExtensionOptions = { + getImageSource: TFileHandler["getAssetSrc"]; + restoreImage: TFileHandler["restore"]; + uploadImage?: TFileHandler["upload"]; +}; + +export type CustomImageExtensionStorage = { + fileMap: Map; + deletedImageSet: Map; + maxFileSize: number; +}; + +export type CustomImageExtension = Node; diff --git a/packages/editor/src/core/extensions/custom-image/utils.ts b/packages/editor/src/core/extensions/custom-image/utils.ts new file mode 100644 index 00000000000..0711e094f1b --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/utils.ts @@ -0,0 +1,33 @@ +import type { Editor } from "@tiptap/core"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; +// helpers +import { getExtensionStorage } from "@/helpers/get-extension-storage"; +// local imports +import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from "./types"; + +export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = { + [ECustomImageAttributeNames.SOURCE]: null, + [ECustomImageAttributeNames.ID]: null, + [ECustomImageAttributeNames.WIDTH]: "35%", + [ECustomImageAttributeNames.HEIGHT]: "auto", + [ECustomImageAttributeNames.ASPECT_RATIO]: null, +}; + +export const getImageComponentImageFileMap = (editor: Editor) => + getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap; + +export const ensurePixelString = ( + value: Pixel | TDefault | number | undefined | null, + defaultValue?: TDefault +) => { + if (!value || value === defaultValue) { + return defaultValue; + } + + if (typeof value === "number") { + return `${value}px` satisfies Pixel; + } + + return value; +}; diff --git a/packages/editor/src/core/extensions/extensions.ts b/packages/editor/src/core/extensions/extensions.ts index 0807fa62d43..cc888200589 100644 --- a/packages/editor/src/core/extensions/extensions.ts +++ b/packages/editor/src/core/extensions/extensions.ts @@ -16,7 +16,6 @@ import { CustomCodeInlineExtension, CustomColorExtension, CustomHorizontalRule, - CustomImageExtension, CustomKeymap, CustomLinkExtension, CustomMentionExtension, @@ -38,6 +37,8 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage"; import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions"; // types import type { IEditorProps } from "@/types"; +// local imports +import { CustomImageExtension } from "./custom-image/extension"; type TArguments = Pick< IEditorProps, @@ -191,12 +192,13 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { if (!disabledExtensions.includes("image")) { extensions.push( - ImageExtension(fileHandler).configure({ - HTMLAttributes: { - class: "rounded-md", - }, + ImageExtension({ + fileHandler, }), - CustomImageExtension(fileHandler) + CustomImageExtension({ + fileHandler, + isEditable: editable, + }) ); } diff --git a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx b/packages/editor/src/core/extensions/image/extension-config.tsx similarity index 50% rename from packages/editor/src/core/extensions/image/image-extension-without-props.tsx rename to packages/editor/src/core/extensions/image/extension-config.tsx index ba064bef485..6dbad2d24d6 100644 --- a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx +++ b/packages/editor/src/core/extensions/image/extension-config.tsx @@ -1,6 +1,12 @@ import { Image as BaseImageExtension } from "@tiptap/extension-image"; +// local imports +import { CustomImageExtensionOptions } from "../custom-image/types"; +import { ImageExtensionStorage } from "./extension"; -export const ImageExtensionWithoutProps = BaseImageExtension.extend({ +export const ImageExtensionConfig = BaseImageExtension.extend< + Pick, + ImageExtensionStorage +>({ addAttributes() { return { ...this.parent?.(), diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index 12844149cf8..80cf7c182b6 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -1,23 +1,33 @@ -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"; // types -import { TFileHandler } from "@/types"; +import type { TFileHandler, TReadOnlyFileHandler } from "@/types"; +// local imports +import { CustomImageNodeView } from "../custom-image/components/node-view"; +import { ImageExtensionConfig } from "./extension-config"; export type ImageExtensionStorage = { deletedImageSet: Map; }; -export const ImageExtension = (fileHandler: TFileHandler) => { - const { - getAssetSrc, - validation: { maxFileSize }, - } = fileHandler; +type Props = { + fileHandler: TFileHandler | TReadOnlyFileHandler; +}; + +export const ImageExtension = (props: Props) => { + const { fileHandler } = props; + // derived values + const { getAssetSrc } = fileHandler; + + return ImageExtensionConfig.extend({ + addOptions() { + return { + ...this.parent?.(), + getImageSource: getAssetSrc, + }; + }, - return BaseImageExtension.extend({ addKeyboardShortcuts() { return { ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), @@ -27,36 +37,17 @@ export const ImageExtension = (fileHandler: TFileHandler) => { // storage to keep track of image states Map addStorage() { + const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0; + return { deletedImageSet: new Map(), maxFileSize, }; }, - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - height: { - default: null, - }, - aspectRatio: { - default: null, - }, - }; - }, - - addCommands() { - return { - getImageSource: (path: string) => async () => await getAssetSrc(path), - }; - }, - // render custom image node addNodeView() { - return ReactNodeViewRenderer(CustomImageNode); + return ReactNodeViewRenderer(CustomImageNodeView); }, }); }; 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 deleted file mode 100644 index bd2c3f16b5f..00000000000 --- a/packages/editor/src/core/extensions/image/image-component-without-props.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { mergeAttributes } from "@tiptap/core"; -import { Image as BaseImageExtension } from "@tiptap/extension-image"; -// local imports -import { ImageExtensionStorage } from "./extension"; - -export const CustomImageComponentWithoutProps = BaseImageExtension.extend< - Record, - 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, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "image-component", - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return ["image-component", mergeAttributes(HTMLAttributes)]; - }, - - addStorage() { - return { - fileMap: new Map(), - deletedImageSet: new Map(), - maxFileSize: 0, - }; - }, -}); diff --git a/packages/editor/src/core/extensions/image/index.ts b/packages/editor/src/core/extensions/image/index.ts index 9c7dc65d783..02b5a53d639 100644 --- a/packages/editor/src/core/extensions/image/index.ts +++ b/packages/editor/src/core/extensions/image/index.ts @@ -1,3 +1,2 @@ export * from "./extension"; -export * from "./image-extension-without-props"; -export * from "./read-only-image"; +export * from "./extension-config"; diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx deleted file mode 100644 index 271c39fd8d5..00000000000 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Image as BaseImageExtension } from "@tiptap/extension-image"; -import { ReactNodeViewRenderer } from "@tiptap/react"; -// extensions -import { CustomImageNode } from "@/extensions"; -// types -import { TReadOnlyFileHandler } from "@/types"; - -export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => { - const { getAssetSrc } = props; - - return BaseImageExtension.extend({ - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - height: { - default: null, - }, - aspectRatio: { - default: null, - }, - }; - }, - - addCommands() { - return { - getImageSource: (path: string) => async () => await getAssetSrc(path), - }; - }, - - addNodeView() { - return ReactNodeViewRenderer(CustomImageNode); - }, - }); -}; diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index 3c3232885fc..c3a8e5d5c7a 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -1,7 +1,6 @@ export * from "./callout"; 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.ts b/packages/editor/src/core/extensions/read-only-extensions.ts index 0f422c620ba..c99b02312c8 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.ts +++ b/packages/editor/src/core/extensions/read-only-extensions.ts @@ -12,7 +12,6 @@ import { CustomHorizontalRule, CustomLinkExtension, CustomTypographyExtension, - ReadOnlyImageExtension, CustomCodeBlockExtension, CustomCodeInlineExtension, TableHeader, @@ -20,11 +19,11 @@ import { TableRow, Table, CustomMentionExtension, - CustomReadOnlyImageExtension, CustomTextAlignExtension, CustomCalloutReadOnlyExtension, CustomColorExtension, UtilityExtension, + ImageExtension, } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; @@ -32,6 +31,8 @@ import { isValidHttpUrl } from "@/helpers/common"; import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions"; // types import type { IReadOnlyEditorProps } from "@/types"; +// local imports +import { CustomImageExtension } from "./custom-image/extension"; type Props = Pick; @@ -135,12 +136,13 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { if (!disabledExtensions.includes("image")) { extensions.push( - ReadOnlyImageExtension(fileHandler).configure({ - HTMLAttributes: { - class: "rounded-md", - }, + ImageExtension({ + fileHandler, }), - CustomReadOnlyImageExtension(fileHandler) + CustomImageExtension({ + fileHandler, + isEditable: false, + }) ); } diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index 5fa15cb08dd..415a42bb3b4 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -2,8 +2,8 @@ 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"; +import type { InsertImageComponentProps } from "@/extensions/custom-image/types"; // helpers import { findTableAncestor } from "@/helpers/common";