From bcd46b6aa923056d0caadcb3fed8a2565e27141f Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:58:11 +0530 Subject: [PATCH 1/4] fix: missing editor package (#5708) --- packages/editor/package.json | 5 +++-- yarn.lock | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/editor/package.json b/packages/editor/package.json index 3ca0aa3e70..5c1897f046 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -66,19 +66,20 @@ "tailwind-merge": "^1.14.0", "tippy.js": "^6.3.7", "tiptap-markdown": "^0.8.9", + "uuid": "^10.0.0", "y-indexeddb": "^9.0.12", "y-prosemirror": "^1.2.5", "y-protocols": "^1.0.6", "yjs": "^13.6.15" }, "devDependencies": { + "@plane/eslint-config": "*", + "@plane/typescript-config": "*", "@types/node": "18.15.3", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", - "@plane/eslint-config": "*", "postcss": "^8.4.38", "tailwind-config-custom": "*", - "@plane/typescript-config": "*", "tsup": "^7.2.0", "typescript": "5.3.3" }, diff --git a/yarn.lock b/yarn.lock index fa6a6f231c..d2493aca3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4038,7 +4038,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@^18.3.5": +"@types/react@*", "@types/react@^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react@^18.2.42", "@types/react@^18.2.48": version "18.2.48" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.48.tgz#11df5664642d0bd879c1f58bc1d37205b064e8f1" integrity sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w== @@ -4047,6 +4047,14 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^18.3.5": + version "18.3.9" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.9.tgz#2cdf5f425ec8a133d67e9e3673909738b783db20" + integrity sha512-+BpAVyTpJkNWWSSnaLBk6ePpHLOGJKnEQNbINNovPWzvEUyAe3e+/d494QdEh71RekM/qV7lw6jzf1HGrJyAtQ== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/reactcss@*": version "1.2.12" resolved "https://registry.yarnpkg.com/@types/reactcss/-/reactcss-1.2.12.tgz#57f6f046e7aafbe0288689bd96a2d5664378ca7b" From e9d5db0093beef0a74c2502e7a1b84b01bb2b5cc Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 30 Sep 2024 19:23:24 +0530 Subject: [PATCH 2/4] [WEB-2568] chore: minor improvements for issue activity component. (#5725) --- web/ce/components/issues/issue-details/index.ts | 1 + .../issues/issue-details/issue-type-activity.tsx | 8 ++++++++ .../issue-activity/activity/activity-list.tsx | 6 +++++- web/ee/components/issues/issue-details/index.ts | 1 + .../issues/issue-details/issue-type-activity.tsx | 1 + 5 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 web/ce/components/issues/issue-details/issue-type-activity.tsx create mode 100644 web/ee/components/issues/issue-details/issue-type-activity.tsx diff --git a/web/ce/components/issues/issue-details/index.ts b/web/ce/components/issues/issue-details/index.ts index 8a71f51f5d..3979faf948 100644 --- a/web/ce/components/issues/issue-details/index.ts +++ b/web/ce/components/issues/issue-details/index.ts @@ -1,3 +1,4 @@ export * from "./issue-identifier"; export * from "./issue-properties-activity"; export * from "./issue-type-switcher"; +export * from "./issue-type-activity"; diff --git a/web/ce/components/issues/issue-details/issue-type-activity.tsx b/web/ce/components/issues/issue-details/issue-type-activity.tsx new file mode 100644 index 0000000000..8def50f48c --- /dev/null +++ b/web/ce/components/issues/issue-details/issue-type-activity.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; + +export type TIssueTypeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueTypeActivity: FC = observer(() => <>); diff --git a/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx b/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx index f78980eef0..18cd3481fe 100644 --- a/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/activity/activity-list.tsx @@ -2,7 +2,9 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks import { useIssueDetail } from "@/hooks/store"; -// components +// plane web components +import { IssueTypeActivity } from "@/plane-web/components/issues/issue-details"; +// local components import { IssueDefaultActivity, IssueNameActivity, @@ -77,6 +79,8 @@ export const IssueActivityItem: FC = observer((props) => { return ; case "inbox": return ; + case "type": + return ; default: return <>; } diff --git a/web/ee/components/issues/issue-details/index.ts b/web/ee/components/issues/issue-details/index.ts index 8a71f51f5d..3979faf948 100644 --- a/web/ee/components/issues/issue-details/index.ts +++ b/web/ee/components/issues/issue-details/index.ts @@ -1,3 +1,4 @@ export * from "./issue-identifier"; export * from "./issue-properties-activity"; export * from "./issue-type-switcher"; +export * from "./issue-type-activity"; diff --git a/web/ee/components/issues/issue-details/issue-type-activity.tsx b/web/ee/components/issues/issue-details/issue-type-activity.tsx new file mode 100644 index 0000000000..1e2cb90c25 --- /dev/null +++ b/web/ee/components/issues/issue-details/issue-type-activity.tsx @@ -0,0 +1 @@ +export * from "ce/components/issues/issue-details"; From bfef0e89e0687ec39a35d110b3e46c1f9ce4259e Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 30 Sep 2024 19:43:14 +0530 Subject: [PATCH 3/4] [PE-46] fix: added aspect ratio to resizing (#5693) * fix: added aspect ratio to resizing * fix: image loading * fix: image uploading and adding only necessary keys to listen to * fix: image aspect ratio maintainance done * fix: loading of images with uploads * fix: custom image extension loading fixed * fix: refactored all the upload logic * fix: focus detection for editor fixed * fix: drop images and inserting images cleaned up * fix: cursor focus after image node insertion and multi drop/paste range error fix * fix: image types fixed * fix: remove old images' upload code and cleaning up the code * fix: imports * fix: this reference in the plugin * fix: added file validation * fix: added error handling while reading files * fix: prevent old data to be updated in updateAttributes * fix: props types for node and image block * fix: remove unnecessary dependency * fix: seperated display message logic from ui * chore: added comments to better explain the loading states * fix: added getPos to deps * fix: remove click event on failed to load state * fix: css for error and selected state --- .../components/editors/editor-container.tsx | 3 +- .../src/core/components/menus/menu-items.ts | 4 +- .../custom-image/components/image-block.tsx | 309 +++++++++++------- .../custom-image/components/image-node.tsx | 113 ++----- .../components/image-uploader.tsx | 157 ++++++--- .../components/toolbar/full-screen.tsx | 47 ++- .../custom-image/components/toolbar/root.tsx | 1 + .../extensions/custom-image/custom-image.ts | 43 ++- .../custom-image/read-only-custom-image.ts | 3 + packages/editor/src/core/extensions/drop.tsx | 61 +++- .../editor/src/core/extensions/extensions.tsx | 2 +- .../image/image-component-without-props.tsx | 10 +- .../image/image-extension-without-props.tsx | 7 - .../src/core/extensions/slash-commands.tsx | 5 +- .../src/core/helpers/editor-commands.ts | 46 ++- packages/editor/src/core/hooks/use-editor.ts | 5 +- .../editor/src/core/hooks/use-file-upload.ts | 76 +++-- .../plugins/image/image-upload-handler.ts | 137 -------- .../editor/src/core/plugins/image/index.ts | 2 - .../src/core/plugins/image/upload-image.ts | 87 ----- .../src/core/plugins/image/utils/index.ts | 1 - .../core/plugins/image/utils/placeholder.ts | 17 - packages/editor/src/styles/editor.css | 2 +- .../rich-text-editor/rich-text-editor.tsx | 2 +- .../components/inbox/content/issue-root.tsx | 2 +- .../issues/issue-detail/main-content.tsx | 2 +- .../issues/peek-overview/issue-detail.tsx | 2 +- 27 files changed, 555 insertions(+), 591 deletions(-) delete mode 100644 packages/editor/src/core/plugins/image/image-upload-handler.ts delete mode 100644 packages/editor/src/core/plugins/image/upload-image.ts delete mode 100644 packages/editor/src/core/plugins/image/utils/placeholder.ts diff --git a/packages/editor/src/core/components/editors/editor-container.tsx b/packages/editor/src/core/components/editors/editor-container.tsx index 2e6ad52503..e070d7e45e 100644 --- a/packages/editor/src/core/components/editors/editor-container.tsx +++ b/packages/editor/src/core/components/editors/editor-container.tsx @@ -18,7 +18,8 @@ interface EditorContainerProps { export const EditorContainer: FC = (props) => { const { children, displayConfig, editor, editorContainerClassName, id } = props; - const handleContainerClick = () => { + const handleContainerClick = (event: React.MouseEvent) => { + if (event.target !== event.currentTarget) return; if (!editor) return; if (!editor.isEditable) return; try { diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index 6f3ae179f9..cf10081f1e 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -23,6 +23,7 @@ import { } from "lucide-react"; // helpers import { + insertImage, insertTableCommand, setText, toggleBlockquote, @@ -193,8 +194,7 @@ export const ImageItem = (editor: Editor) => key: "image", name: "Image", isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"), - command: (savedSelection: Selection | null) => - editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }), + command: (savedSelection: Selection | null) => insertImage({ editor, event: "insert", pos: savedSelection?.from }), icon: ImageIcon, }) as const; 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 index 34e936c723..42b51de5fb 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -7,105 +7,169 @@ 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; +type Pixel = `${number}px`; - const [size, setSize] = useState({ - width: width?.toString() || "35%", - height: height?.toString() || "auto", +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 = CustomImageNodeViewProps & { + imageFromFileSystem: string; + setFailedToLoadImage: (isError: boolean) => void; + editorContainer: HTMLDivElement | null; + setEditorContainer: (editorContainer: HTMLDivElement | null) => void; +}; + +export const CustomImageBlock: React.FC = (props) => { + // props + const { + node, + updateAttributes, + setFailedToLoadImage, + imageFromFileSystem, + selected, + getPos, + editor, + editorContainer, + setEditorContainer, + } = props; + const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs; + // states + const [size, setSize] = useState({ + width: ensurePixelString(width, "35%"), + height: ensurePixelString(height, "auto"), + aspectRatio: aspectRatio || 1, }); - const [isLoading, setIsLoading] = useState(true); + const [isResizing, setIsResizing] = useState(false); const [initialResizeComplete, setInitialResizeComplete] = useState(false); - const isShimmerVisible = isLoading || !initialResizeComplete; - const [editorContainer, setEditorContainer] = useState(null); - + // refs const containerRef = useRef(null); const containerRect = useRef(null); const imageRef = useRef(null); - const isResizing = useRef(false); - const aspectRatioRef = useRef(null); - useLayoutEffect(() => { - if (imageRef.current) { - const img = imageRef.current; - img.onload = () => { - const closestEditorContainer = img.closest(".editor-container"); - if (!closestEditorContainer) { - console.error("Editor container not found"); - return; - } + const handleImageLoad = useCallback(() => { + const img = imageRef.current; + if (!img) return; + let closestEditorContainer: HTMLDivElement | null = null; - setEditorContainer(closestEditorContainer as HTMLElement); - - if (width === "35%") { - const editorWidth = closestEditorContainer.clientWidth; - const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE); - const aspectRatio = img.naturalWidth / img.naturalHeight; - const initialHeight = initialWidth / aspectRatio; - - const newSize = { - width: `${Math.round(initialWidth)}px`, - height: `${Math.round(initialHeight)}px`, - }; - - setSize(newSize); - updateAttributes(newSize); - } - setInitialResizeComplete(true); - setIsLoading(false); - }; - } - }, [width, height, updateAttributes]); - - useLayoutEffect(() => { - setSize({ - width: width?.toString(), - height: height?.toString(), - }); - }, [width, height]); - - const handleResizeStart = useCallback( - (e: React.MouseEvent | React.TouchEvent) => { - e.preventDefault(); - e.stopPropagation(); - isResizing.current = true; - if (containerRef.current && editorContainer) { - aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", "")); - containerRect.current = containerRef.current.getBoundingClientRect(); + if (editorContainer) { + closestEditorContainer = editorContainer; + } else { + closestEditorContainer = img.closest(".editor-container") as HTMLDivElement | null; + if (!closestEditorContainer) { + console.error("Editor container not found"); + return; } - }, - [size, editorContainer] - ); + } + if (!closestEditorContainer) { + console.error("Editor container not found"); + return; + } + + setEditorContainer(closestEditorContainer); + const aspectRatio = img.naturalWidth / img.naturalHeight; + + if (width === "35%") { + const editorWidth = closestEditorContainer.clientWidth; + const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE); + const initialHeight = initialWidth / aspectRatio; + + const initialComputedSize = { + width: `${Math.round(initialWidth)}px` satisfies Pixel, + height: `${Math.round(initialHeight)}px` satisfies Pixel, + aspectRatio: aspectRatio, + }; + + setSize(initialComputedSize); + updateAttributes(initialComputedSize); + } else { + // as the aspect ratio in not stored for old images, we need to update the attrs + setSize((prevSize) => { + const newSize = { ...prevSize, aspectRatio }; + updateAttributes(newSize); + return newSize; + }); + } + setInitialResizeComplete(true); + }, [width, updateAttributes, editorContainer]); + + // for real time resizing + useLayoutEffect(() => { + setSize((prevSize) => ({ + ...prevSize, + width: ensurePixelString(width), + height: ensurePixelString(height), + })); + }, [width, height]); const handleResize = useCallback( (e: MouseEvent | TouchEvent) => { - if (!isResizing.current || !containerRef.current || !containerRect.current) return; - - if (size) { - aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", "")); - } - - if (!aspectRatioRef.current) return; + if (!containerRef.current || !containerRect.current || !size.aspectRatio) 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 / aspectRatioRef.current; + const newHeight = newWidth / size.aspectRatio; - setSize({ width: `${newWidth}px`, height: `${newHeight}px` }); + setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` })); }, [size] ); const handleResizeEnd = useCallback(() => { - if (isResizing.current) { - isResizing.current = false; - updateAttributes(size); - } + setIsResizing(false); + updateAttributes(size); }, [size, updateAttributes]); - const handleMouseDown = useCallback( + const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + + if (containerRef.current) { + containerRect.current = containerRef.current.getBoundingClientRect(); + } + }, []); + + useEffect(() => { + if (isResizing) { + window.addEventListener("mousemove", handleResize); + window.addEventListener("mouseup", handleResizeEnd); + window.addEventListener("mouseleave", handleResizeEnd); + + return () => { + window.removeEventListener("mousemove", handleResize); + window.removeEventListener("mouseup", handleResizeEnd); + window.removeEventListener("mouseleave", handleResizeEnd); + }; + } + }, [isResizing, handleResize, handleResizeEnd]); + + const handleImageMouseDown = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); const pos = getPos(); @@ -115,65 +179,86 @@ export const CustomImageBlock: React.FC = (props) => { [editor, getPos] ); - useEffect(() => { - if (!editorContainer) return; - - const handleMouseMove = (e: MouseEvent) => handleResize(e); - const handleMouseUp = () => handleResizeEnd(); - const handleMouseLeave = () => handleResizeEnd(); - - editorContainer.addEventListener("mousemove", handleMouseMove); - editorContainer.addEventListener("mouseup", handleMouseUp); - editorContainer.addEventListener("mouseleave", handleMouseLeave); - - return () => { - editorContainer.removeEventListener("mousemove", handleMouseMove); - editorContainer.removeEventListener("mouseup", handleMouseUp); - editorContainer.removeEventListener("mouseleave", handleMouseLeave); - }; - }, [handleResize, handleResizeEnd, editorContainer]); + // show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or) + // if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete + const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete; + // show the image utils only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) + const showImageUtils = editor.isEditable && remoteImageSrc && initialResizeComplete; + // show the preview image from the file system if the remote image's src is not set + const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem; return (
- {isShimmerVisible && ( -
+ {showImageLoader && ( +
)} { + console.error("Error loading image", e); + setFailedToLoadImage(true); + }} width={size.width} - height={size.height} className={cn("image-component block rounded-md", { - hidden: isShimmerVisible, + // hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then + hidden: showImageLoader, "read-only-image": !editor.isEditable, + "blur-sm opacity-80 loading-image": !remoteImageSrc, })} style={{ width: size.width, - height: size.height, + aspectRatio: size.aspectRatio, }} /> - - {editor.isEditable && selected &&
} - {editor.isEditable && ( + {showImageUtils && ( + + )} + {selected && displayedImageSrc === remoteImageSrc && ( +
+ )} + {showImageUtils && ( <> -
+
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 index 94d58f712b..c37bcd29cd 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -1,23 +1,14 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import { 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"; +import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image"; export type CustomImageNodeViewProps = { getPos: () => number; editor: Editor; node: ProsemirrorNode & { - attrs: { - src: string; - width: number | string; - height: number | string; - }; + attrs: ImageAttributes; }; updateAttributes: (attrs: Record) => void; selected: boolean; @@ -26,94 +17,60 @@ export type CustomImageNodeViewProps = { 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 [isUploaded, setIsUploaded] = useState(false); + const [imageFromFileSystem, setImageFromFileSystem] = useState(undefined); + const [failedToLoadImage, setFailedToLoadImage] = useState(false); - 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] - ); + const [editorContainer, setEditorContainer] = useState(null); + const imageComponentRef = useRef(null); 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 }); - } + const closestEditorContainer = imageComponentRef.current?.closest(".editor-container"); + if (!closestEditorContainer) { + console.error("Editor container not found"); + return; } - }, [getUploadEntity, uploadFile]); + setEditorContainer(closestEditorContainer as HTMLDivElement); + }, []); + + // the image is already uploaded if the image-component node has src attribute + // and we need to remove the blob from our file system useEffect(() => { - if (node.attrs.src) { + const remoteImageSrc = node.attrs.src; + if (remoteImageSrc) { setIsUploaded(true); + setImageFromFileSystem(undefined); + } else { + setIsUploaded(false); } }, [node.attrs.src]); - const existingFile = React.useMemo(() => { - const entity = getUploadEntity(); - return entity && entity.event === "drop" ? entity.file : undefined; - }, [getUploadEntity]); - return ( -
- {isUploaded ? ( +
+ {(isUploaded || imageFromFileSystem) && !failedToLoadImage ? ( ) : ( )}
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 index d288630c63..89cf36ca52 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx @@ -1,36 +1,111 @@ -import { ChangeEvent, useCallback, useEffect, useRef } from "react"; +import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; +import { Node as ProsemirrorNode } from "@tiptap/pm/model"; 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"; +import { useUploader, 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; - } -}; +// extensions +import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image"; export const CustomImageUploader = (props: { - onUpload: (url: string) => void; + failedToLoadImage: boolean; editor: Editor; - fileInputRef: RefType; - existingFile?: File; selected: boolean; + loadImageFromFileSystem: (file: string) => void; + setIsUploaded: (isUploaded: boolean) => void; + node: ProsemirrorNode & { + attrs: ImageAttributes; + }; + updateAttributes: (attrs: Record) => void; + getPos: () => number; }) => { - const { selected, onUpload, editor, fileInputRef, existingFile } = props; - const { loading, uploadFile } = useUploader({ onUpload, editor }); - const { handleUploadClick, ref: internalRef } = useFileUpload(); + const { + selected, + failedToLoadImage, + editor, + loadImageFromFileSystem, + node, + setIsUploaded, + updateAttributes, + getPos, + } = props; + // ref + const fileInputRef = useRef(null); + + const hasTriggeredFilePickerRef = useRef(false); + const imageEntityId = node.attrs.id; + + const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]); + + const onUpload = useCallback( + (url: string) => { + if (url) { + setIsUploaded(true); + // Update the node view's src attribute post upload + updateAttributes({ src: url }); + imageComponentImageFileMap?.delete(imageEntityId); + + const pos = getPos(); + // get current node + const getCurrentSelection = editor.state.selection; + const currentNode = editor.state.doc.nodeAt(getCurrentSelection.from); + + // only if the cursor is at the current image component, manipulate + // the cursor position + if (currentNode && currentNode.type.name === "imageComponent" && currentNode.attrs.src === url) { + // control cursor position after upload + const nextNode = editor.state.doc.nodeAt(pos + 1); + + if (nextNode && nextNode.type.name === "paragraph") { + // If there is a paragraph node after the image component, move the focus to the next node + editor.commands.setTextSelection(pos + 1); + } else { + // create a new paragraph after the image component post upload + editor.commands.createParagraphNear(); + } + } + } + }, + [imageComponentImageFileMap, imageEntityId, updateAttributes, getPos] + ); + // hooks + const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem }); const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile }); - const localRef = useRef(null); + // the meta data of the image component + const meta = useMemo( + () => imageComponentImageFileMap?.get(imageEntityId), + [imageComponentImageFileMap, imageEntityId] + ); + + // if the image component is dropped, we check if it has an existing file + const existingFile = useMemo(() => (meta && meta.event === "drop" ? meta.file : undefined), [meta]); + + // after the image component is mounted we start the upload process based on + // it's uploaded + useEffect(() => { + if (meta) { + if (meta.event === "drop" && "file" in meta) { + uploadFile(meta.file); + } else if (meta.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) { + if (meta.hasOpenedFileInputOnce) return; + fileInputRef.current.click(); + hasTriggeredFilePickerRef.current = true; + imageComponentImageFileMap?.set(imageEntityId, { ...meta, hasOpenedFileInputOnce: true }); + } + } + }, [meta, uploadFile, imageComponentImageFileMap]); + + // check if the image is dropped and set the local image as the existing file + useEffect(() => { + if (existingFile) { + uploadFile(existingFile); + } + }, [existingFile, uploadFile]); const onFileChange = useCallback( (e: ChangeEvent) => { @@ -44,13 +119,22 @@ export const CustomImageUploader = (props: { [uploadFile] ); - useEffect(() => { - // no need to validate as the file is already validated before the drop onto - // the editor - if (existingFile) { - uploadFile(existingFile); + const getDisplayMessage = useCallback(() => { + const isUploading = isImageBeingUploaded || existingFile; + if (failedToLoadImage) { + return "Error loading image"; } - }, [existingFile, uploadFile]); + + if (isUploading) { + return "Uploading..."; + } + + if (draggedInside) { + return "Drop image here"; + } + + return "Add an image"; + }, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]); return (
{ + if (!failedToLoadImage) { + fileInputRef.current?.click(); + } + }} > -
- {loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"} -
+
{getDisplayMessage()}
{ - localRef.current = element; - assignRef(fileInputRef, element); - assignRef(internalRef as RefType, element); - }} + ref={fileInputRef} hidden type="file" accept=".jpg,.jpeg,.png,.webp" 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 93019c6068..38ea23c992 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,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react"; // helpers import { cn } from "@/helpers/common"; @@ -8,6 +8,7 @@ type Props = { src: string; height: string; width: string; + aspectRatio: number; }; isOpen: boolean; toggleFullScreenMode: (val: boolean) => void; @@ -17,13 +18,13 @@ const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2]; export const ImageFullScreenAction: React.FC = (props) => { const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props; - const { height, src, width } = image; + const { src, width, aspectRatio } = image; // states const [magnification, setMagnification] = useState(1); + // refs + const modalRef = useRef(null); // derived values const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]); - const heightInNumber = useMemo(() => Number(height?.replace("px", "")), [height]); - const aspectRatio = useMemo(() => widthInNumber / heightInNumber, [heightInNumber, widthInNumber]); // close handler const handleClose = useCallback(() => { toggleFullScreenMode(false); @@ -55,25 +56,35 @@ export const ImageFullScreenAction: React.FC = (props) => { // keydown handler const handleKeyDown = useCallback( (e: KeyboardEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.key === "Escape") handleClose(); - if (e.key === "+" || e.key === "=") handleIncreaseMagnification(); - if (e.key === "-") handleDecreaseMagnification(); + if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === "Escape") handleClose(); + if (e.key === "+" || e.key === "=") handleIncreaseMagnification(); + if (e.key === "-") handleDecreaseMagnification(); + } }, [handleClose, handleDecreaseMagnification, handleIncreaseMagnification] ); + // click outside handler + const handleClickOutside = useCallback( + (e: React.MouseEvent) => { + if (modalRef.current && e.target === modalRef.current) { + handleClose(); + } + }, + [handleClose] + ); // register keydown listener useEffect(() => { - document.addEventListener("keydown", handleKeyDown); + if (isFullScreenEnabled) { + document.addEventListener("keydown", handleKeyDown); - if (!isFullScreenEnabled) { - document.removeEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; } - - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; }, [handleKeyDown, isFullScreenEnabled]); return ( @@ -86,7 +97,7 @@ export const ImageFullScreenAction: React.FC = (props) => { } )} > -
+
-
+
diff --git a/web/ee/components/issues/issue-modal/provider.tsx b/web/ee/components/issues/issue-modal/provider.tsx index 125c89ee48..406e8c191f 100644 --- a/web/ee/components/issues/issue-modal/provider.tsx +++ b/web/ee/components/issues/issue-modal/provider.tsx @@ -15,7 +15,7 @@ import { useIssueDetail } from "@/hooks/store"; // plane web helpers import { getPropertiesDefaultValues } from "@/plane-web/helpers/issue-properties.helper"; // plane web hooks -import { useIssueTypes } from "@/plane-web/hooks/store"; +import { useIssuePropertiesActivity, useIssueTypes } from "@/plane-web/hooks/store"; // plane web services import { IssuePropertyValuesService } from "@/plane-web/services/issue-types"; // plane web types @@ -44,7 +44,7 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) => getProjectIssueTypes, getProjectDefaultIssueType, } = useIssueTypes(); - + const { fetchPropertyActivities } = useIssuePropertiesActivity(); // helpers const getIssueTypeIdOnProjectChange = (projectId: string) => { // get active issue types for the project @@ -135,7 +135,10 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) => await issuePropertyValuesService .create(workspaceSlug, projectId, issueId, filteredIssuePropertyValues) .then(() => { + // mutate issue property values mutate(`ISSUE_PROPERTY_VALUES_${workspaceSlug}_${projectId}_${issueId}_${isIssueTypeDisplayEnabled}`); + // fetch property activities + fetchPropertyActivities(workspaceSlug, projectId, issueId); // reset issue property values setIssuePropertyValues({ ...getPropertiesDefaultValues(issueType?.activeProperties ?? []),