diff --git a/packages/editor/src/core/components/editors/editor-container.tsx b/packages/editor/src/core/components/editors/editor-container.tsx index 2e6ad525038..e070d7e45e5 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 6f3ae179f93..cf10081f1e5 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 34e936c723e..42b51de5fb7 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; - } - - 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); + const handleImageLoad = useCallback(() => { + const img = imageRef.current; + if (!img) return; + let closestEditorContainer: HTMLDivElement | null = null; + + if (editorContainer) { + closestEditorContainer = editorContainer; + } else { + closestEditorContainer = img.closest(".editor-container") as HTMLDivElement | null; + if (!closestEditorContainer) { + console.error("Editor container not found"); + return; + } + } + 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; + }); } - }, [width, height, updateAttributes]); + setInitialResizeComplete(true); + }, [width, updateAttributes, editorContainer]); + // for real time resizing useLayoutEffect(() => { - setSize({ - width: width?.toString(), - height: height?.toString(), - }); + setSize((prevSize) => ({ + ...prevSize, + width: ensurePixelString(width), + height: ensurePixelString(height), + })); }, [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(); - } - }, - [size, editorContainer] - ); - 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, - }} - /> - - {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 94d58f712b2..c37bcd29cde 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 d288630c637..89cf36ca52b 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 93019c60680..38ea23c9925 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) => { } )} > -
+
-
+