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] [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 2e6ad5250..e070d7e45 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 6f3ae179f..cf10081f1 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 34e936c72..42b51de5f 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 94d58f712..c37bcd29c 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 d288630c6..89cf36ca5 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 93019c606..38ea23c99 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) => { } )} > -
+
-
+