From 4ab90709b7f3425207c77b33ada52002d1b16e8a Mon Sep 17 00:00:00 2001 From: myjeong19 Date: Wed, 13 Aug 2025 08:18:30 +0900 Subject: [PATCH 01/17] Fix. Images under toggle displayed even when closed --- .../image/hooks/image-viewer/index.ts | 7 +- .../hooks/image-viewer/use-active-index.ts | 20 ++ .../image-viewer/use-image-navigation.tsx | 38 --- .../image/hooks/image-viewer/use-images.ts | 17 ++ .../image/hooks/image-viewer/use-modal.ts | 14 + .../hooks/image-viewer/use-navigation.ts | 20 ++ .../components/image/image-viewer-tools.tsx | 69 ++--- .../src/lib/components/image/image-viewer.tsx | 79 +++--- .../core/src/lib/components/image/image.tsx | 40 +-- .../components/image/lib/extract-image-url.ts | 16 ++ .../image/lib/get-image-url-or-null.ts | 13 - .../image/lib/get-visible-images.ts | 9 + .../src/lib/components/image/lib/index.ts | 2 +- packages/core/src/lib/index.css | 16 +- .../story/src/stories/image/image.stories.tsx | 12 +- .../story/src/stories/image/toggle-image.json | 239 ++++++++++++++++++ 16 files changed, 445 insertions(+), 166 deletions(-) create mode 100644 packages/core/src/lib/components/image/hooks/image-viewer/use-active-index.ts delete mode 100644 packages/core/src/lib/components/image/hooks/image-viewer/use-image-navigation.tsx create mode 100644 packages/core/src/lib/components/image/hooks/image-viewer/use-images.ts create mode 100644 packages/core/src/lib/components/image/hooks/image-viewer/use-modal.ts create mode 100644 packages/core/src/lib/components/image/hooks/image-viewer/use-navigation.ts create mode 100644 packages/core/src/lib/components/image/lib/extract-image-url.ts delete mode 100644 packages/core/src/lib/components/image/lib/get-image-url-or-null.ts create mode 100644 packages/core/src/lib/components/image/lib/get-visible-images.ts create mode 100644 packages/story/src/stories/image/toggle-image.json diff --git a/packages/core/src/lib/components/image/hooks/image-viewer/index.ts b/packages/core/src/lib/components/image/hooks/image-viewer/index.ts index d9a495b..570ce9d 100644 --- a/packages/core/src/lib/components/image/hooks/image-viewer/index.ts +++ b/packages/core/src/lib/components/image/hooks/image-viewer/index.ts @@ -1,4 +1,9 @@ export * from "./use-cursor-visibility"; -export * from "./use-image-navigation"; +export * from "./use-navigation"; export * from "./use-image-scale"; export * from "./use-prevent-scroll"; + +export * from "./use-modal"; +export * from "./use-images"; +export * from "./use-active-index"; +export * from "./use-navigation"; diff --git a/packages/core/src/lib/components/image/hooks/image-viewer/use-active-index.ts b/packages/core/src/lib/components/image/hooks/image-viewer/use-active-index.ts new file mode 100644 index 0000000..0d1d2d2 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/image-viewer/use-active-index.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +export const useActiveIndex = ( + currentImageUrl: string, + imageUrls: string[], +) => { + const [activeImageIndex, setActiveImageIndex] = useState(() => { + const index = imageUrls.findIndex((imgUrl) => imgUrl === currentImageUrl); + return Math.max(0, index); + }); + + useEffect(() => { + if (imageUrls.length > 0) { + const index = imageUrls.findIndex((url) => url === currentImageUrl); + setActiveImageIndex(Math.max(0, index)); + } + }, [imageUrls, currentImageUrl]); + + return { activeImageIndex, setActiveImageIndex }; +}; diff --git a/packages/core/src/lib/components/image/hooks/image-viewer/use-image-navigation.tsx b/packages/core/src/lib/components/image/hooks/image-viewer/use-image-navigation.tsx deleted file mode 100644 index 2159c12..0000000 --- a/packages/core/src/lib/components/image/hooks/image-viewer/use-image-navigation.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useCallback, useMemo } from "react"; - -export const useImageNavigation = ( - currentImageIndex: number, - setCurrentImageIndex: React.Dispatch>, - urlsLength: number, -) => { - const toNextImage = useCallback(() => { - setCurrentImageIndex((previousIndex) => { - if (previousIndex < urlsLength - 1) { - return previousIndex + 1; - } - return previousIndex; - }); - }, [urlsLength, setCurrentImageIndex]); - - const toPreviousImage = useCallback(() => { - setCurrentImageIndex((previousIndex) => { - if (previousIndex > 0) { - return previousIndex - 1; - } - return previousIndex; - }); - }, [setCurrentImageIndex]); - - const hasNext = useMemo( - () => currentImageIndex < urlsLength - 1, - [currentImageIndex, urlsLength], - ); - const hasPrevious = useMemo(() => currentImageIndex > 0, [currentImageIndex]); - - return { - toNextImage, - toPreviousImage, - hasNext, - hasPrevious, - }; -}; diff --git a/packages/core/src/lib/components/image/hooks/image-viewer/use-images.ts b/packages/core/src/lib/components/image/hooks/image-viewer/use-images.ts new file mode 100644 index 0000000..085d29b --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/image-viewer/use-images.ts @@ -0,0 +1,17 @@ +import { useState, useCallback } from "react"; + +import { getVisibleImages } from "../../lib/get-visible-images"; + +export const useImages = () => { + const [imageUrls, setImageUrls] = useState([]); + + const collectImages = useCallback(() => { + const visibleImages = getVisibleImages(); + setImageUrls(visibleImages); + }, []); + + return { + imageUrls, + collectImages, + }; +}; diff --git a/packages/core/src/lib/components/image/hooks/image-viewer/use-modal.ts b/packages/core/src/lib/components/image/hooks/image-viewer/use-modal.ts new file mode 100644 index 0000000..2200072 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/image-viewer/use-modal.ts @@ -0,0 +1,14 @@ +import { useState } from "react"; + +export const useModal = () => { + const [isOpen, setIsOpen] = useState(false); + + const open = () => setIsOpen(true); + const close = () => setIsOpen(false); + + return { + isOpen, + open, + close, + }; +}; diff --git a/packages/core/src/lib/components/image/hooks/image-viewer/use-navigation.ts b/packages/core/src/lib/components/image/hooks/image-viewer/use-navigation.ts new file mode 100644 index 0000000..10841f7 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/image-viewer/use-navigation.ts @@ -0,0 +1,20 @@ +export const useNavigation = ( + activeImageIndex: number, + totalImages: number, + setActiveImageIndex: React.Dispatch>, +) => { + const toNextImage = () => + setActiveImageIndex((prev) => Math.min(prev + 1, totalImages - 1)); + const toPreviousImage = () => + setActiveImageIndex((prev) => Math.max(prev - 1, 0)); + + const hasNext = activeImageIndex < totalImages - 1; + const hasPrevious = activeImageIndex > 0; + + return { + toNextImage, + toPreviousImage, + hasNext, + hasPrevious, + }; +}; diff --git a/packages/core/src/lib/components/image/image-viewer-tools.tsx b/packages/core/src/lib/components/image/image-viewer-tools.tsx index 1a688f7..36d8e03 100644 --- a/packages/core/src/lib/components/image/image-viewer-tools.tsx +++ b/packages/core/src/lib/components/image/image-viewer-tools.tsx @@ -11,7 +11,7 @@ type ImageViewerToolsProps = { currentImageIndex: number; imageLength: number; scaleInputRef: React.MutableRefObject; - setIsOpened: React.Dispatch>; + close: () => void; hasPrevious: boolean; hasNext: boolean; toPreviousImage: () => void; @@ -35,14 +35,13 @@ const ImageViewerTools: React.FC = ({ scaleInputRef, hasPrevious, hasNext, - setIsOpened, + close, toPreviousImage, toNextImage, displayScale, onScaleUp, onScaleDown, isScaleFocus, - setIsScaleFocus, onScaleFocus, onScaleBlur, onScaleChange, @@ -55,41 +54,47 @@ const ImageViewerTools: React.FC = ({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} > -
- - - + + - - - -
+ + + + )}
- @@ -115,7 +120,7 @@ const ImageViewerTools: React.FC = ({ ) : ( @@ -139,7 +144,7 @@ const ImageViewerTools: React.FC = ({ diff --git a/packages/core/src/lib/components/image/image-viewer.tsx b/packages/core/src/lib/components/image/image-viewer.tsx index 18edcf3..6d0e076 100644 --- a/packages/core/src/lib/components/image/image-viewer.tsx +++ b/packages/core/src/lib/components/image/image-viewer.tsx @@ -1,11 +1,14 @@ -import { useState, useEffect, useCallback } from "react"; +import { useEffect } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { useCursorVisibility, - useImageNavigation, + useNavigation, useImageScale, usePreventScroll, + useModal, + useImages, + useActiveIndex, } from "./hooks/image-viewer"; import { getCursorStyle } from "./lib"; @@ -13,24 +16,23 @@ import { getCursorStyle } from "./lib"; import ImageViewerTools from "./image-viewer-tools"; type ImageViewerProps = { - children: React.ReactNode; - urls: string[]; url: string; - currentImageIndex: number; - setCurrentImageIndex: React.Dispatch>; + children: React.ReactNode; }; -const ImageViewer: React.FC = ({ - url, - urls, - children, - currentImageIndex, - setCurrentImageIndex, -}) => { - const [isOpened, setIsOpened] = useState(false); +const ImageViewer: React.FC = ({ url, children }) => { + const { isOpen, open, close } = useModal(); + const { imageUrls, collectImages } = useImages(); + const { activeImageIndex, setActiveImageIndex } = useActiveIndex( + url, + imageUrls, + ); - const { toNextImage, toPreviousImage, hasNext, hasPrevious } = - useImageNavigation(currentImageIndex, setCurrentImageIndex, urls.length); + const { toNextImage, toPreviousImage, hasNext, hasPrevious } = useNavigation( + activeImageIndex, + imageUrls.length, + setActiveImageIndex, + ); const { imageRef, @@ -55,14 +57,14 @@ const ImageViewer: React.FC = ({ const { isCursorVisible, handleMoveMouse } = useCursorVisibility(); useEffect(() => { - if (currentImageIndex || isOpened) { + if (activeImageIndex || isOpen) { setScale(1); setDisplayScale(100); } - }, [isOpened, currentImageIndex, setScale, setDisplayScale]); + }, [isOpen, activeImageIndex, setScale, setDisplayScale]); useEffect(() => { - if (!isOpened) { + if (!isOpen) { return; } @@ -70,7 +72,7 @@ const ImageViewer: React.FC = ({ const handleKeyDown = (e: KeyboardEvent) => { const keyDownEvents: { [key: string]: () => void } = { - Escape: () => setIsOpened(false), + Escape: close, "+": handleScaleUp, "=": handleScaleUp, "-": handleScaleDown, @@ -88,38 +90,33 @@ const ImageViewer: React.FC = ({ return () => window.removeEventListener("keydown", handleKeyDown); }, [ imageRef, - isOpened, + isOpen, handleScaleUp, handleScaleDown, toNextImage, toPreviousImage, + close, ]); - const handleImageClick = useCallback( - (clickedUrl: string) => { - const index = urls.findIndex((imgUrl) => imgUrl === clickedUrl); - if (index !== -1) { - setCurrentImageIndex(index); - setIsOpened(true); - } - }, - [urls, setCurrentImageIndex, setIsOpened], - ); + usePreventScroll(isOpen); - usePreventScroll(isOpened); + const handleViewerOpen = () => { + collectImages(); + open(); + }; return ( <> - {isOpened && ( + {isOpen && ( = ({ > + {!aria.disabled && ( + + {isVisible && ( + +

{content}

+ {hint &&

{hint}

} +
+ )} +
+ )} +
+ ); +}; diff --git a/packages/core/src/lib/components/image/viewer-tools.tsx b/packages/core/src/lib/components/image/viewer-tools.tsx new file mode 100644 index 0000000..a1a7270 --- /dev/null +++ b/packages/core/src/lib/components/image/viewer-tools.tsx @@ -0,0 +1,169 @@ +import React from "react"; + +import { motion } from "framer-motion"; +import { Icons } from "./icons"; +import { Tooltip } from "./tooltip"; +import { motionAnimate } from "./constants"; +import { handleDownload } from "./lib"; + +const DISABLED_IMAGE_LENGTH = 1; +const NEXT_IMAGE_INDEX = 2; + +const TOOL_ACTIONS = { + BACK: "Back", + NEXT: "Next", + ZOOM_OUT: "Zoom out", + ZOOM_IN: "Zoom in", + DOWNLOAD: "Download", + CLOSE: "Close", +} as const; + +const TOOL_ACTION_ARIA_LABELS = { + BACK: "image tools back button", + NEXT: "image tools next button", + ZOOM_OUT: "image tools zoom out button", + ZOOM_IN: "image tools zoom in button", + DOWNLOAD: "image download button", + CLOSE: "image viewer close button", + SCALER_INPUT: "scaler input", +} as const; + +interface ViewerToolsProps { + url: string; + currentImageIndex: number; + imageLength: number; + scaleInputRef: React.MutableRefObject; + close: () => void; + hasPrevious: boolean; + hasNext: boolean; + toPreviousImage: () => void; + toNextImage: () => void; + scale: number; + displayScale: number; + onScaleUp: () => void; + onScaleDown: () => void; + isScaleFocus: boolean; + setIsScaleFocus: (focused: boolean) => void; + onScaleFocus: () => void; + onScaleBlur: () => void; + onScaleChange: (e: React.ChangeEvent) => void; + onScaleEnter: (e: React.KeyboardEvent) => void; +} + +const ViewerTools: React.FC = ({ + url, + currentImageIndex, + imageLength, + scaleInputRef, + hasPrevious, + hasNext, + close, + toPreviousImage, + toNextImage, + displayScale, + onScaleUp, + onScaleDown, + isScaleFocus, + onScaleFocus, + onScaleBlur, + onScaleChange, + onScaleEnter, +}) => { + return ( + + {imageLength > DISABLED_IMAGE_LENGTH && ( +
+ } + /> + + } + /> +
+ )} + +
+ } + /> + +
+ {isScaleFocus ? ( + <> + + % + + ) : ( + + )} +
+ + } + /> +
+ + handleDownload(url)} + icon={} + /> + + } + /> +
+ ); +}; + +export default ViewerTools; From 5834abbe6b9c17958695244744c0fde727f12ec2 Mon Sep 17 00:00:00 2001 From: myjeong19 Date: Wed, 20 Aug 2025 05:54:58 +0900 Subject: [PATCH 04/17] fix: resolve image viewer responsive layout and navigation positioning bugs --- .../src/lib/components/image/image-viewer.tsx | 86 ++--- packages/core/src/lib/index.css | 335 +++++++++--------- 2 files changed, 216 insertions(+), 205 deletions(-) diff --git a/packages/core/src/lib/components/image/image-viewer.tsx b/packages/core/src/lib/components/image/image-viewer.tsx index 6d0e076..19abf62 100644 --- a/packages/core/src/lib/components/image/image-viewer.tsx +++ b/packages/core/src/lib/components/image/image-viewer.tsx @@ -1,6 +1,8 @@ import { useEffect } from "react"; import { AnimatePresence, motion } from "framer-motion"; +import { motionAnimate } from "./constants"; + import { useCursorVisibility, useNavigation, @@ -9,11 +11,11 @@ import { useModal, useImages, useActiveIndex, -} from "./hooks/image-viewer"; +} from "./hooks"; import { getCursorStyle } from "./lib"; -import ImageViewerTools from "./image-viewer-tools"; +import ViewerTools from "./viewer-tools"; type ImageViewerProps = { url: string; @@ -119,58 +121,58 @@ const ImageViewer: React.FC = ({ url, children }) => { {isOpen && ( - - )} - - - } - /> - - - handleDownload(url)} - icon={} - /> - - } - /> + + {children} ); }; +ViewerTools.Scaler = ToolsScaler; +ViewerTools.Navigation = ToolsNavigation; +ViewerTools.Download = ToolsDownload; +ViewerTools.Close = ToolsClose; + export default ViewerTools; From 17cba462b16bf5a013c76dad40f51458190468ab Mon Sep 17 00:00:00 2001 From: myjeong19 Date: Sun, 24 Aug 2025 03:36:22 +0900 Subject: [PATCH 06/17] feat: enhance image caption search logic and improve component architecture --- .../src/lib/components/image/image-viewer.tsx | 198 +++--------------- .../core/src/lib/components/image/image.tsx | 28 ++- .../image/lib/find-image-caption.ts | 27 +++ .../src/lib/components/image/lib/index.ts | 3 +- 4 files changed, 82 insertions(+), 174 deletions(-) create mode 100644 packages/core/src/lib/components/image/lib/find-image-caption.ts diff --git a/packages/core/src/lib/components/image/image-viewer.tsx b/packages/core/src/lib/components/image/image-viewer.tsx index 19abf62..9f23394 100644 --- a/packages/core/src/lib/components/image/image-viewer.tsx +++ b/packages/core/src/lib/components/image/image-viewer.tsx @@ -1,182 +1,44 @@ -import { useEffect } from "react"; -import { AnimatePresence, motion } from "framer-motion"; +"use client"; +import React from "react"; -import { motionAnimate } from "./constants"; +import { AnimatePresence } from "framer-motion"; -import { - useCursorVisibility, - useNavigation, - useImageScale, - usePreventScroll, - useModal, - useImages, - useActiveIndex, -} from "./hooks"; +import ViewerOverlay from "./viewer-overlay"; +import ViewerImage from "./viewer-image"; -import { getCursorStyle } from "./lib"; - -import ViewerTools from "./viewer-tools"; +import { useCursorVisibility, usePreventScroll } from "./hooks"; type ImageViewerProps = { url: string; - children: React.ReactNode; + caption: string; + close: () => void; }; -const ImageViewer: React.FC = ({ url, children }) => { - const { isOpen, open, close } = useModal(); - const { imageUrls, collectImages } = useImages(); - const { activeImageIndex, setActiveImageIndex } = useActiveIndex( - url, - imageUrls, - ); - - const { toNextImage, toPreviousImage, hasNext, hasPrevious } = useNavigation( - activeImageIndex, - imageUrls.length, - setActiveImageIndex, - ); - - const { - imageRef, - scaleInputRef, - isScaleFocus, - setIsScaleFocus, - handleScaleBlur, - handleScaleFocus, - handleScaleEnter, - handleScaleChange, - scale, - displayScale, - setScale, - setDisplayScale, - scaleOriginX, - scaleOriginY, - handleZoomInOut, - handleScaleUp, - handleScaleDown, - } = useImageScale(); - - const { isCursorVisible, handleMoveMouse } = useCursorVisibility(); - - useEffect(() => { - if (activeImageIndex || isOpen) { - setScale(1); - setDisplayScale(100); - } - }, [isOpen, activeImageIndex, setScale, setDisplayScale]); - - useEffect(() => { - if (!isOpen) { - return; - } +const ImageViewer: React.FC = ({ url, caption, close }) => { + const { isCursor, handleMouseLeave, handleMouseEnter } = + useCursorVisibility(); - imageRef.current?.focus(); - - const handleKeyDown = (e: KeyboardEvent) => { - const keyDownEvents: { [key: string]: () => void } = { - Escape: close, - "+": handleScaleUp, - "=": handleScaleUp, - "-": handleScaleDown, - ArrowLeft: toPreviousImage, - ArrowRight: toNextImage, - }; - const action = keyDownEvents[e.key]; - - if (action) { - action(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [ - imageRef, - isOpen, - handleScaleUp, - handleScaleDown, - toNextImage, - toPreviousImage, - close, - ]); - - usePreventScroll(isOpen); - - const handleViewerOpen = () => { - collectImages(); - open(); - }; + usePreventScroll(); return ( - <> - - - - {isOpen && ( - - - -
- - - {(isCursorVisible || isScaleFocus) && ( - - )} -
-
- )} -
- + +
+ + +
+
); }; diff --git a/packages/core/src/lib/components/image/image.tsx b/packages/core/src/lib/components/image/image.tsx index ec70844..0635d35 100644 --- a/packages/core/src/lib/components/image/image.tsx +++ b/packages/core/src/lib/components/image/image.tsx @@ -1,10 +1,11 @@ "use client"; - import React from "react"; import type { ImageArgs } from "../../types"; import RichText from "../internal/rich-text"; import ImageViewer from "./image-viewer"; import { extractImageUrl } from "./lib"; +import { useModal } from "./hooks"; +import { findImageCaption } from "./lib/find-image-caption"; type ImageProps = { children: React.ReactNode; @@ -15,16 +16,33 @@ const Image: React.FC = ({ children, ...props }) => { image: { caption, type }, } = props; - const url = extractImageUrl(props); + const url = extractImageUrl(props) || ""; + const { isOpen, open, close } = useModal(); + + const foundCaption = + props.blocks && props.blocks.length > 0 + ? findImageCaption(props.blocks, url) + : null; return ( <> + {isOpen && url && ( + + )} +
{url ? ( - - posting image - + {caption[0]?.text?.content ) : (

unsupported type: {type}

)} diff --git a/packages/core/src/lib/components/image/lib/find-image-caption.ts b/packages/core/src/lib/components/image/lib/find-image-caption.ts new file mode 100644 index 0000000..be861f8 --- /dev/null +++ b/packages/core/src/lib/components/image/lib/find-image-caption.ts @@ -0,0 +1,27 @@ +import { type ImageArgs, type Block } from "../../../types"; + +export const findImageCaption = ( + blocks: Block[], + imageUrl: string, +): string | null => { + for (const block of blocks) { + if (block.type === "image") { + const imageBlock = block as ImageArgs; + const url = + imageBlock.image.type === "file" + ? imageBlock.image.file.url + : imageBlock.image.external?.url; + + if (url === imageUrl) { + return imageBlock.image.caption?.[0]?.text?.content || null; + } + } + + if (block.blocks) { + const caption = findImageCaption(block.blocks, imageUrl); + if (caption) return caption; + } + } + + return null; +}; diff --git a/packages/core/src/lib/components/image/lib/index.ts b/packages/core/src/lib/components/image/lib/index.ts index b09b758..04a355b 100644 --- a/packages/core/src/lib/components/image/lib/index.ts +++ b/packages/core/src/lib/components/image/lib/index.ts @@ -2,4 +2,5 @@ export * from "./download-image-file"; export * from "./get-gap"; export * from "./extract-image-url"; export * from "./get-cursor-style"; -export * from "./scale-round"; +export * from "./normalize-display-scale"; +export * from "./find-image-caption"; From ff16bba5bddbf97914067a885ac269371601349a Mon Sep 17 00:00:00 2001 From: myjeong19 Date: Sun, 24 Aug 2025 03:36:33 +0900 Subject: [PATCH 07/17] refactor: clean up unused components and standardize CSS naming --- .../components/image/assets/arrow_back.svg | 13 -- .../components/image/assets/arrow_forward.svg | 13 -- .../src/lib/components/image/assets/close.svg | 17 -- .../lib/components/image/assets/download.svg | 13 -- .../src/lib/components/image/assets/index.ts | 15 -- .../src/lib/components/image/assets/minus.svg | 3 - .../src/lib/components/image/assets/plus.svg | 13 -- .../lib/components/image/constants/motion.ts | 2 +- .../image/constants/viewer-tools.ts | 18 ++ .../src/lib/components/image/hooks/index.ts | 3 - .../image/hooks/use-active-index.ts | 20 -- .../image/hooks/use-cursor-visibility.ts | 53 +++++ .../image/hooks/use-image-scale.tsx | 151 -------------- .../lib/components/image/hooks/use-images.ts | 13 +- .../lib/components/image/hooks/use-keydown.ts | 51 +++++ .../lib/components/image/hooks/use-modal.ts | 1 + .../components/image/hooks/use-navigation.ts | 56 +++-- ...event-scroll.tsx => use-prevent-scroll.ts} | 17 +- .../src/lib/components/image/icons/close.tsx | 2 +- .../src/lib/components/image/icons/plus.tsx | 2 +- .../image/image-viewer-tools-tooltip.tsx | 52 ----- .../components/image/image-viewer-tools.tsx | 156 -------------- ...le-round.ts => normalize-display-scale.ts} | 2 +- .../src/lib/components/image/reducer/index.ts | 2 + .../image/reducer/origin-reducer.ts | 38 ++++ .../components/image/reducer/scale-reducer.ts | 123 +++++++++++ .../src/lib/components/image/tools-close.tsx | 25 +++ .../lib/components/image/tools-download.tsx | 25 +++ .../lib/components/image/tools-navigation.tsx | 54 +++++ .../src/lib/components/image/tools-scaler.tsx | 105 ++++++++++ .../image/{tooltip.tsx => tools-tooltip.tsx} | 13 +- .../lib/components/image/viewer-overlay.tsx | 24 +++ packages/core/src/lib/index.css | 197 +++++++++--------- 33 files changed, 682 insertions(+), 610 deletions(-) delete mode 100644 packages/core/src/lib/components/image/assets/arrow_back.svg delete mode 100644 packages/core/src/lib/components/image/assets/arrow_forward.svg delete mode 100644 packages/core/src/lib/components/image/assets/close.svg delete mode 100644 packages/core/src/lib/components/image/assets/download.svg delete mode 100644 packages/core/src/lib/components/image/assets/index.ts delete mode 100644 packages/core/src/lib/components/image/assets/minus.svg delete mode 100644 packages/core/src/lib/components/image/assets/plus.svg create mode 100644 packages/core/src/lib/components/image/constants/viewer-tools.ts delete mode 100644 packages/core/src/lib/components/image/hooks/use-active-index.ts create mode 100644 packages/core/src/lib/components/image/hooks/use-cursor-visibility.ts delete mode 100644 packages/core/src/lib/components/image/hooks/use-image-scale.tsx create mode 100644 packages/core/src/lib/components/image/hooks/use-keydown.ts rename packages/core/src/lib/components/image/hooks/{use-prevent-scroll.tsx => use-prevent-scroll.ts} (50%) delete mode 100644 packages/core/src/lib/components/image/image-viewer-tools-tooltip.tsx delete mode 100644 packages/core/src/lib/components/image/image-viewer-tools.tsx rename packages/core/src/lib/components/image/lib/{scale-round.ts => normalize-display-scale.ts} (64%) create mode 100644 packages/core/src/lib/components/image/reducer/index.ts create mode 100644 packages/core/src/lib/components/image/reducer/origin-reducer.ts create mode 100644 packages/core/src/lib/components/image/reducer/scale-reducer.ts create mode 100644 packages/core/src/lib/components/image/tools-close.tsx create mode 100644 packages/core/src/lib/components/image/tools-download.tsx create mode 100644 packages/core/src/lib/components/image/tools-navigation.tsx create mode 100644 packages/core/src/lib/components/image/tools-scaler.tsx rename packages/core/src/lib/components/image/{tooltip.tsx => tools-tooltip.tsx} (84%) create mode 100644 packages/core/src/lib/components/image/viewer-overlay.tsx diff --git a/packages/core/src/lib/components/image/assets/arrow_back.svg b/packages/core/src/lib/components/image/assets/arrow_back.svg deleted file mode 100644 index 34d6e5b..0000000 --- a/packages/core/src/lib/components/image/assets/arrow_back.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/packages/core/src/lib/components/image/assets/arrow_forward.svg b/packages/core/src/lib/components/image/assets/arrow_forward.svg deleted file mode 100644 index f4bc832..0000000 --- a/packages/core/src/lib/components/image/assets/arrow_forward.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/packages/core/src/lib/components/image/assets/close.svg b/packages/core/src/lib/components/image/assets/close.svg deleted file mode 100644 index 1c1d5b5..0000000 --- a/packages/core/src/lib/components/image/assets/close.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - diff --git a/packages/core/src/lib/components/image/assets/download.svg b/packages/core/src/lib/components/image/assets/download.svg deleted file mode 100644 index 02be125..0000000 --- a/packages/core/src/lib/components/image/assets/download.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/packages/core/src/lib/components/image/assets/index.ts b/packages/core/src/lib/components/image/assets/index.ts deleted file mode 100644 index e5f89d7..0000000 --- a/packages/core/src/lib/components/image/assets/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import ArrowBack from "./arrow_back.svg"; -import ArrowForward from "./arrow_forward.svg"; -import Close from "./close.svg"; -import Download from "./download.svg"; -import Minus from "./minus.svg"; -import Plus from "./plus.svg"; - -export default { - ArrowBack, - ArrowForward, - Close, - Download, - Minus, - Plus, -}; diff --git a/packages/core/src/lib/components/image/assets/minus.svg b/packages/core/src/lib/components/image/assets/minus.svg deleted file mode 100644 index 7aa9db5..0000000 --- a/packages/core/src/lib/components/image/assets/minus.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/packages/core/src/lib/components/image/assets/plus.svg b/packages/core/src/lib/components/image/assets/plus.svg deleted file mode 100644 index 0836ec3..0000000 --- a/packages/core/src/lib/components/image/assets/plus.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/packages/core/src/lib/components/image/constants/motion.ts b/packages/core/src/lib/components/image/constants/motion.ts index b63e5fd..e29fed4 100644 --- a/packages/core/src/lib/components/image/constants/motion.ts +++ b/packages/core/src/lib/components/image/constants/motion.ts @@ -1,4 +1,4 @@ -export const motionAnimate = { +export const MOTION_STYLES = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, diff --git a/packages/core/src/lib/components/image/constants/viewer-tools.ts b/packages/core/src/lib/components/image/constants/viewer-tools.ts new file mode 100644 index 0000000..79586c4 --- /dev/null +++ b/packages/core/src/lib/components/image/constants/viewer-tools.ts @@ -0,0 +1,18 @@ +export const TOOLS_ACTIONS = { + BACK: "Back", + NEXT: "Next", + ZOOM_OUT: "Zoom out", + ZOOM_IN: "Zoom in", + DOWNLOAD: "Download", + CLOSE: "Close", +} as const; + +export const TOOLS_ARIA_LABELS = { + BACK: "image tools back button", + NEXT: "image tools next button", + ZOOM_OUT: "image tools zoom out button", + ZOOM_IN: "image tools zoom in button", + DOWNLOAD: "image download button", + CLOSE: "image viewer close button", + SCALER_INPUT: "scaler input", +} as const; diff --git a/packages/core/src/lib/components/image/hooks/index.ts b/packages/core/src/lib/components/image/hooks/index.ts index 570ce9d..b387a8c 100644 --- a/packages/core/src/lib/components/image/hooks/index.ts +++ b/packages/core/src/lib/components/image/hooks/index.ts @@ -1,9 +1,6 @@ export * from "./use-cursor-visibility"; export * from "./use-navigation"; -export * from "./use-image-scale"; export * from "./use-prevent-scroll"; - export * from "./use-modal"; export * from "./use-images"; -export * from "./use-active-index"; export * from "./use-navigation"; diff --git a/packages/core/src/lib/components/image/hooks/use-active-index.ts b/packages/core/src/lib/components/image/hooks/use-active-index.ts deleted file mode 100644 index 0d1d2d2..0000000 --- a/packages/core/src/lib/components/image/hooks/use-active-index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useEffect, useState } from "react"; - -export const useActiveIndex = ( - currentImageUrl: string, - imageUrls: string[], -) => { - const [activeImageIndex, setActiveImageIndex] = useState(() => { - const index = imageUrls.findIndex((imgUrl) => imgUrl === currentImageUrl); - return Math.max(0, index); - }); - - useEffect(() => { - if (imageUrls.length > 0) { - const index = imageUrls.findIndex((url) => url === currentImageUrl); - setActiveImageIndex(Math.max(0, index)); - } - }, [imageUrls, currentImageUrl]); - - return { activeImageIndex, setActiveImageIndex }; -}; diff --git a/packages/core/src/lib/components/image/hooks/use-cursor-visibility.ts b/packages/core/src/lib/components/image/hooks/use-cursor-visibility.ts new file mode 100644 index 0000000..b248220 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/use-cursor-visibility.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; + +export const useCursorVisibility = () => { + const [isCursor, setIsCursor] = useState(true); + const [isOverTools, setIsOverTools] = useState(false); + const cursorTimeOutRef = useRef(); + + const handleMoveMouse = useCallback(() => { + setIsCursor(true); + + clearTimeout(cursorTimeOutRef.current); + + if (cursorTimeOutRef.current) { + clearTimeout(cursorTimeOutRef.current); + } + + if (!isOverTools) { + cursorTimeOutRef.current = setTimeout(() => { + setIsCursor(false); + }, 2000); + } + }, [isOverTools]); + + useEffect(() => { + const handleMouseMove = () => { + handleMoveMouse(); + }; + + document.addEventListener("mousemove", handleMouseMove); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + if (cursorTimeOutRef.current) { + clearTimeout(cursorTimeOutRef.current); + } + }; + }, [handleMoveMouse]); + + const handleMouseLeave = () => { + setIsOverTools(false); + setIsCursor(false); + }; + + const handleMouseEnter = () => { + setIsOverTools(true); + setIsCursor(true); + clearTimeout(cursorTimeOutRef.current); + }; + + return { isCursor, handleMouseLeave, handleMouseEnter }; +}; diff --git a/packages/core/src/lib/components/image/hooks/use-image-scale.tsx b/packages/core/src/lib/components/image/hooks/use-image-scale.tsx deleted file mode 100644 index f5b2a50..0000000 --- a/packages/core/src/lib/components/image/hooks/use-image-scale.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useState, useRef, useCallback, useEffect } from "react"; - -import { scaleRound } from "../lib"; - -export const useImageScale = () => { - const [scale, setScale] = useState(1); - const [displayScale, setDisplayScale] = useState(100); - - const [scaleOriginX, setScaleOriginX] = useState(0.5); - const [scaleOriginY, setScaleOriginY] = useState(0.5); - - const [isScaleFocus, setIsScaleFocus] = useState(false); - - const imageRef = useRef(null); - const scaleInputRef = useRef(null); - - useEffect(() => { - if (isScaleFocus) { - imageRef.current?.focus(); - scaleInputRef.current?.select(); - } - }, [isScaleFocus]); - - const handleScaleFocus = useCallback(() => { - setIsScaleFocus(true); - }, []); - - const handleScaleBlur = useCallback(() => { - const displayScaleValue = scaleRound(displayScale); - setIsScaleFocus(false); - setScale(displayScaleValue / 100); - setDisplayScale(displayScaleValue); - }, [displayScale]); - - const handleScaleEnter = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - setScaleOriginX(0.5); - setScaleOriginY(0.5); - - let displayScaleValue = displayScale; - - if (displayScale <= 50 || displayScale >= 200) { - displayScaleValue = scaleRound(displayScale); - } - - const newScale = displayScaleValue / 100; - setScale(newScale); - setDisplayScale(displayScaleValue); - setIsScaleFocus(false); - } - }, - [displayScale], - ); - - const handleScaleChange = useCallback( - (event: React.ChangeEvent) => { - const { value } = event.target; - - setDisplayScale(+value); - - if (!isScaleFocus) { - const newScale = Math.max(50, Math.min(200, +displayScale)) / 100; - setScale(newScale); - } - }, - [displayScale, isScaleFocus], - ); - - const handleZoomInOut = useCallback( - (event: React.MouseEvent) => { - if (!imageRef.current) { - return; - } - - if (scale === 0.5) { - setScale(1); - setDisplayScale(100); - setScaleOriginX(0.5); - setScaleOriginY(0.5); - return; - } - - if (scale === 1.5) { - setScale(1); - setDisplayScale(100); - return; - } - - const { width, height, top, left } = - imageRef.current.getBoundingClientRect(); - - const currentMouseX = (event.clientX - left) / width; - const currentMouseY = (event.clientY - top) / height; - - const newScale = scale === 1 ? 1.5 : 1; - setScale(newScale); - setDisplayScale(newScale * 100); - - setScaleOriginX(currentMouseX); - setScaleOriginY(currentMouseY); - - imageRef.current.style.transition = "transform 0.3s ease"; - }, - [scale], - ); - - const handleScaleUp = useCallback(() => { - if (scale === 1) { - setScaleOriginX(0.5); - setScaleOriginY(0.5); - } - - setScale((previousScale) => Math.min(previousScale + 0.5, 2)); - setDisplayScale((previousDisplayScale) => { - return Math.min(+previousDisplayScale + 50, 200); - }); - }, [scale]); - - const handleScaleDown = useCallback(() => { - if (scale === 1) { - setScaleOriginX(0.5); - setScaleOriginY(0.5); - } - - setScale((previousScale) => Math.max(previousScale - 0.5, 0.5)); - setDisplayScale((previousDisplayScale) => { - return Math.max(+previousDisplayScale - 50, 50); - }); - }, [scale]); - - return { - imageRef, - scaleInputRef, - isScaleFocus, - setIsScaleFocus, - handleScaleBlur, - handleScaleFocus, - handleScaleChange, - handleScaleEnter, - scale, - displayScale, - setScale, - setDisplayScale, - scaleOriginX, - scaleOriginY, - handleZoomInOut, - handleScaleUp, - handleScaleDown, - }; -}; diff --git a/packages/core/src/lib/components/image/hooks/use-images.ts b/packages/core/src/lib/components/image/hooks/use-images.ts index 244b2ba..9403331 100644 --- a/packages/core/src/lib/components/image/hooks/use-images.ts +++ b/packages/core/src/lib/components/image/hooks/use-images.ts @@ -1,17 +1,14 @@ -import { useState, useCallback } from "react"; +"use client"; +import { useState, useEffect } from "react"; import { getVisibleImages } from "../lib/get-visible-images"; export const useImages = () => { const [imageUrls, setImageUrls] = useState([]); - const collectImages = useCallback(() => { - const visibleImages = getVisibleImages(); - setImageUrls(visibleImages); + useEffect(() => { + setImageUrls(getVisibleImages()); }, []); - return { - imageUrls, - collectImages, - }; + return imageUrls; }; diff --git a/packages/core/src/lib/components/image/hooks/use-keydown.ts b/packages/core/src/lib/components/image/hooks/use-keydown.ts new file mode 100644 index 0000000..b38c7b5 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/use-keydown.ts @@ -0,0 +1,51 @@ +"use client"; +import { useCallback, useEffect } from "react"; + +import type { OriginAction, ScaleAction } from "../reducer"; + +interface UseKeydownProps { + close: () => void; + scaleDispatch: React.Dispatch; + originDispatch: React.Dispatch; + toPreviousImage: () => void; + toNextImage: () => void; +} + +export const useKeydown = ({ + close, + scaleDispatch, + originDispatch, + toPreviousImage, + toNextImage, +}: UseKeydownProps) => { + const handleZoomIn = useCallback(() => { + originDispatch({ type: "reset" }); + scaleDispatch({ type: "zoomIn" }); + }, [originDispatch, scaleDispatch]); + + const handleZoomOut = useCallback(() => { + originDispatch({ type: "reset" }); + scaleDispatch({ type: "zoomOut" }); + }, [originDispatch, scaleDispatch]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const keyDownEvents: { [key: string]: () => void } = { + Escape: close, + "+": handleZoomIn, + "=": handleZoomIn, + "-": handleZoomOut, + ArrowLeft: toPreviousImage, + ArrowRight: toNextImage, + }; + const action = keyDownEvents[e.key]; + + if (action) { + action(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [close, handleZoomIn, handleZoomOut, toNextImage, toPreviousImage]); +}; diff --git a/packages/core/src/lib/components/image/hooks/use-modal.ts b/packages/core/src/lib/components/image/hooks/use-modal.ts index 2200072..ea70ecd 100644 --- a/packages/core/src/lib/components/image/hooks/use-modal.ts +++ b/packages/core/src/lib/components/image/hooks/use-modal.ts @@ -1,3 +1,4 @@ +"use client"; import { useState } from "react"; export const useModal = () => { diff --git a/packages/core/src/lib/components/image/hooks/use-navigation.ts b/packages/core/src/lib/components/image/hooks/use-navigation.ts index 10841f7..0af9e3f 100644 --- a/packages/core/src/lib/components/image/hooks/use-navigation.ts +++ b/packages/core/src/lib/components/image/hooks/use-navigation.ts @@ -1,20 +1,48 @@ -export const useNavigation = ( - activeImageIndex: number, - totalImages: number, - setActiveImageIndex: React.Dispatch>, -) => { - const toNextImage = () => - setActiveImageIndex((prev) => Math.min(prev + 1, totalImages - 1)); - const toPreviousImage = () => - setActiveImageIndex((prev) => Math.max(prev - 1, 0)); - - const hasNext = activeImageIndex < totalImages - 1; - const hasPrevious = activeImageIndex > 0; +"use client"; +import { useCallback, useEffect, useState } from "react"; + +const NAVIGATION = { + START_INDEX: 0, + MIN_INDEX: 0, + + NEXT_STEP: 1, + PREV_STEP: -1, + + FIRST_INDEX: 0, + LAST_INDEX_OFFSET: 1, +} as const; + +export const useNavigation = (url: string, imageUrls: string[]) => { + const [activeIndex, setActiveIndex] = useState(() => { + const index = imageUrls.findIndex((imgUrl) => imgUrl === url); + return Math.max(NAVIGATION.MIN_INDEX, index); + }); + + const toNextImage = useCallback(() => { + setActiveIndex((prev) => + Math.min( + prev + NAVIGATION.NEXT_STEP, + imageUrls.length - NAVIGATION.LAST_INDEX_OFFSET, + ), + ); + }, [imageUrls.length]); + + const toPreviousImage = useCallback(() => { + setActiveIndex((prev) => + Math.max(prev + NAVIGATION.PREV_STEP, NAVIGATION.MIN_INDEX), + ); + }, []); + + useEffect(() => { + if (imageUrls.length > NAVIGATION.MIN_INDEX) { + const index = imageUrls.findIndex((findUrl) => findUrl === url); + setActiveIndex(Math.max(NAVIGATION.MIN_INDEX, index)); + } + }, [imageUrls, url]); return { + activeIndex, toNextImage, toPreviousImage, - hasNext, - hasPrevious, }; }; diff --git a/packages/core/src/lib/components/image/hooks/use-prevent-scroll.tsx b/packages/core/src/lib/components/image/hooks/use-prevent-scroll.ts similarity index 50% rename from packages/core/src/lib/components/image/hooks/use-prevent-scroll.tsx rename to packages/core/src/lib/components/image/hooks/use-prevent-scroll.ts index ab98ea9..d6418e0 100644 --- a/packages/core/src/lib/components/image/hooks/use-prevent-scroll.tsx +++ b/packages/core/src/lib/components/image/hooks/use-prevent-scroll.ts @@ -1,19 +1,18 @@ +"use client"; import { useEffect } from "react"; import { getGapStyles, getGapWidth } from "../lib"; -export const usePreventScroll = (isOpened: boolean) => { +export const usePreventScroll = () => { useEffect(() => { const styleElement = document.createElement("style"); - if (isOpened) { - document.body.setAttribute("data-scroll-locked", "true"); - const gap = getGapWidth(); + document.body.setAttribute("data-scroll-locked", "true"); + const gap = getGapWidth(); - const scrollLockedStyles = getGapStyles(gap); - styleElement.textContent = scrollLockedStyles; - document.head.appendChild(styleElement); - } + const scrollLockedStyles = getGapStyles(gap); + styleElement.textContent = scrollLockedStyles; + document.head.appendChild(styleElement); return () => { document.body.removeAttribute("data-scroll-locked"); @@ -21,5 +20,5 @@ export const usePreventScroll = (isOpened: boolean) => { styleElement.parentNode.removeChild(styleElement); } }; - }, [isOpened]); + }, []); }; diff --git a/packages/core/src/lib/components/image/icons/close.tsx b/packages/core/src/lib/components/image/icons/close.tsx index d5f0f22..390719d 100644 --- a/packages/core/src/lib/components/image/icons/close.tsx +++ b/packages/core/src/lib/components/image/icons/close.tsx @@ -7,7 +7,7 @@ export const Close = () => { fill="none" xmlns="http://www.w3.org/2000/svg" > - + { fill="none" xmlns="http://www.w3.org/2000/svg" > - + = ({ - children, - className, - content, - hint, - disabled = false, -}) => { - const [isVisible, setIsVisible] = useState(false); - - return ( -
setIsVisible(true)} - onMouseLeave={() => setIsVisible(false)} - className="notion-image-viewer-tooltip-container" - > - {children} - {!disabled && ( - - {isVisible && ( - -

{content}

- -

{hint}

-
- )} -
- )} -
- ); -}; - -export default ImageViewerToolsToolTip; diff --git a/packages/core/src/lib/components/image/image-viewer-tools.tsx b/packages/core/src/lib/components/image/image-viewer-tools.tsx deleted file mode 100644 index 36d8e03..0000000 --- a/packages/core/src/lib/components/image/image-viewer-tools.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React from "react"; - -import { motion } from "framer-motion"; -import { handleDownload } from "./lib"; - -import Icon from "./assets"; -import Tooltip from "./image-viewer-tools-tooltip"; - -type ImageViewerToolsProps = { - url: string; - currentImageIndex: number; - imageLength: number; - scaleInputRef: React.MutableRefObject; - close: () => void; - hasPrevious: boolean; - hasNext: boolean; - toPreviousImage: () => void; - toNextImage: () => void; - scale: number; - displayScale: number; - onScaleUp: () => void; - onScaleDown: () => void; - isScaleFocus: boolean; - setIsScaleFocus: (focused: boolean) => void; - onScaleFocus: () => void; - onScaleBlur: () => void; - onScaleChange: (e: React.ChangeEvent) => void; - onScaleEnter: (e: React.KeyboardEvent) => void; -}; - -const ImageViewerTools: React.FC = ({ - url, - currentImageIndex, - imageLength, - scaleInputRef, - hasPrevious, - hasNext, - close, - toPreviousImage, - toNextImage, - displayScale, - onScaleUp, - onScaleDown, - isScaleFocus, - onScaleFocus, - onScaleBlur, - onScaleChange, - onScaleEnter, -}) => { - return ( - - {imageLength > 1 && ( -
- - - - - - - -
- )} - -
- - - - - {isScaleFocus ? ( -
- - % -
- ) : ( - - )} - - - -
- - - - - - -
- ); -}; - -export default ImageViewerTools; diff --git a/packages/core/src/lib/components/image/lib/scale-round.ts b/packages/core/src/lib/components/image/lib/normalize-display-scale.ts similarity index 64% rename from packages/core/src/lib/components/image/lib/scale-round.ts rename to packages/core/src/lib/components/image/lib/normalize-display-scale.ts index edfac1b..71d1658 100644 --- a/packages/core/src/lib/components/image/lib/scale-round.ts +++ b/packages/core/src/lib/components/image/lib/normalize-display-scale.ts @@ -1,4 +1,4 @@ -export const scaleRound = (scale: number) => { +export const normalizeDisplayScale = (scale: number) => { const roundedScale = Math.round(scale / 50) * 50; return Math.min(Math.max(roundedScale, 50), 200); }; diff --git a/packages/core/src/lib/components/image/reducer/index.ts b/packages/core/src/lib/components/image/reducer/index.ts new file mode 100644 index 0000000..85b0db2 --- /dev/null +++ b/packages/core/src/lib/components/image/reducer/index.ts @@ -0,0 +1,2 @@ +export * from "./origin-reducer"; +export * from "./scale-reducer"; diff --git a/packages/core/src/lib/components/image/reducer/origin-reducer.ts b/packages/core/src/lib/components/image/reducer/origin-reducer.ts new file mode 100644 index 0000000..4ab1929 --- /dev/null +++ b/packages/core/src/lib/components/image/reducer/origin-reducer.ts @@ -0,0 +1,38 @@ +"use client"; + +const ORIGIN = { + INITIAL_X: 0.5, + INITIAL_Y: 0.5, +}; + +export const initialOrigin = { + originX: ORIGIN.INITIAL_X, + originY: ORIGIN.INITIAL_Y, +} as const; + +type OriginActionType = "zoomInOut" | "reset"; + +export type OriginAction = { + type: OriginActionType; + payload?: typeof initialOrigin; +}; + +export const originReducer = ( + state: typeof initialOrigin, + action: OriginAction, +) => { + switch (action.type) { + case "reset": + return { + originX: ORIGIN.INITIAL_X, + originY: ORIGIN.INITIAL_Y, + }; + case "zoomInOut": + return { + originX: action.payload?.originX || ORIGIN.INITIAL_X, + originY: action.payload?.originY || ORIGIN.INITIAL_Y, + }; + default: + return state; + } +}; diff --git a/packages/core/src/lib/components/image/reducer/scale-reducer.ts b/packages/core/src/lib/components/image/reducer/scale-reducer.ts new file mode 100644 index 0000000..1e591f2 --- /dev/null +++ b/packages/core/src/lib/components/image/reducer/scale-reducer.ts @@ -0,0 +1,123 @@ +"use client"; + +import { normalizeDisplayScale } from "../lib"; + +type ScaleActionType = + | "enter" + | "blur" + | "zoomInOut" + | "zoomIn" + | "zoomOut" + | "reset" + | "changeStyleOnly" + | "changeDisplayOnly"; + +export type ScaleAction = { + type: ScaleActionType; + payload?: number; +}; + +const DISPLAY = { + INITIAL: 100, + STEP: 50, + MIN: 50, + MAX: 200, +}; + +const STYLE = { + INITIAL: 1, + STEP: 0.5, + ZOOM_IN_STEP: 1.5, + MIN_STEP: 0.5, + MAX_STEP: 2, +}; + +const CONVERSION = { + PERCENT_FACTOR: 100, +}; + +export const initialScale = { + displayScale: DISPLAY.INITIAL, + styleScale: STYLE.INITIAL, +} as const; + +export const scaleReducer = ( + state: typeof initialScale, + action: ScaleAction, +) => { + switch (action.type) { + case "changeStyleOnly": { + const invalidStyleScale = action.payload ?? state.displayScale; + const newStyleScale = + Math.max(DISPLAY.MIN, Math.min(DISPLAY.MAX, invalidStyleScale)) / + CONVERSION.PERCENT_FACTOR; + return { + ...state, + styleScale: newStyleScale, + }; + } + + case "changeDisplayOnly": { + const invalidDisplayScale = action.payload ?? state.displayScale; + + return { + ...state, + displayScale: invalidDisplayScale, + }; + } + + case "enter": { + if ( + state.displayScale <= DISPLAY.MIN || + state.displayScale >= DISPLAY.MAX + ) { + return { + ...state, + displayScale: normalizeDisplayScale(state.displayScale), + }; + } + + return { + ...state, + styleScale: state.displayScale / CONVERSION.PERCENT_FACTOR, + }; + } + + case "zoomInOut": { + const newStyleScale = + state.styleScale === STYLE.INITIAL ? STYLE.ZOOM_IN_STEP : STYLE.INITIAL; + return { + displayScale: newStyleScale * CONVERSION.PERCENT_FACTOR, + styleScale: newStyleScale, + }; + } + + case "reset": + return { + displayScale: DISPLAY.INITIAL, + styleScale: STYLE.INITIAL, + }; + + case "blur": { + const newScale = normalizeDisplayScale(state.displayScale); + return { + displayScale: newScale, + styleScale: newScale / CONVERSION.PERCENT_FACTOR, + }; + } + + case "zoomIn": + return { + styleScale: Math.min(state.styleScale + STYLE.STEP, STYLE.MAX_STEP), + displayScale: Math.min(state.displayScale + DISPLAY.STEP, DISPLAY.MAX), + }; + + case "zoomOut": + return { + styleScale: Math.max(state.styleScale - STYLE.STEP, STYLE.MIN_STEP), + displayScale: Math.max(state.displayScale - DISPLAY.STEP, DISPLAY.MIN), + }; + default: + return state; + } +}; diff --git a/packages/core/src/lib/components/image/tools-close.tsx b/packages/core/src/lib/components/image/tools-close.tsx new file mode 100644 index 0000000..b97594a --- /dev/null +++ b/packages/core/src/lib/components/image/tools-close.tsx @@ -0,0 +1,25 @@ +"use client"; +import React from "react"; + +import { TOOLS_ACTIONS, TOOLS_ARIA_LABELS } from "./constants/viewer-tools"; +import { Icons } from "./icons"; +import ToolsTooltip from "./tools-tooltip"; + +export interface ToolsCloseProps { + close: () => void; +} + +const ToolsClose: React.FC = ({ close }) => { + return ( + } + /> + ); +}; + +export default ToolsClose; diff --git a/packages/core/src/lib/components/image/tools-download.tsx b/packages/core/src/lib/components/image/tools-download.tsx new file mode 100644 index 0000000..de826ae --- /dev/null +++ b/packages/core/src/lib/components/image/tools-download.tsx @@ -0,0 +1,25 @@ +"use client"; +import React from "react"; +import ToolsTooltip from "./tools-tooltip"; + +import { Icons } from "./icons"; +import { TOOLS_ACTIONS, TOOLS_ARIA_LABELS } from "./constants/viewer-tools"; +import { handleDownload } from "./lib"; + +export interface ToolsDownloadProps { + url: string; +} + +const ToolsDownload: React.FC = ({ url }) => { + return ( + handleDownload(url)} + icon={} + /> + ); +}; + +export default ToolsDownload; diff --git a/packages/core/src/lib/components/image/tools-navigation.tsx b/packages/core/src/lib/components/image/tools-navigation.tsx new file mode 100644 index 0000000..c6d0527 --- /dev/null +++ b/packages/core/src/lib/components/image/tools-navigation.tsx @@ -0,0 +1,54 @@ +"use client"; +import React from "react"; +import ToolsTooltip from "./tools-tooltip"; + +import { TOOLS_ACTIONS, TOOLS_ARIA_LABELS } from "./constants/viewer-tools"; +import { Icons } from "./icons"; + +const NEXT_IMAGE_INDEX = 2; + +export interface ToolsNavigationProps { + activeIndex: number; + totalImages: number; + toPreviousImage: () => void; + toNextImage: () => void; +} +const ToolsNavigation: React.FC = ({ + activeIndex, + totalImages, + toPreviousImage, + toNextImage, +}) => { + const hasNext = activeIndex < totalImages - 1; + const hasPrevious = activeIndex > 0; + + return ( +
+ } + /> + + } + /> +
+ ); +}; + +export default ToolsNavigation; diff --git a/packages/core/src/lib/components/image/tools-scaler.tsx b/packages/core/src/lib/components/image/tools-scaler.tsx new file mode 100644 index 0000000..4352c2d --- /dev/null +++ b/packages/core/src/lib/components/image/tools-scaler.tsx @@ -0,0 +1,105 @@ +"use client"; +import React, { useEffect, useRef } from "react"; +import { TOOLS_ACTIONS, TOOLS_ARIA_LABELS } from "./constants/viewer-tools"; +import { Icons } from "./icons"; +import { initialScale, type ScaleAction } from "./reducer"; +import ToolsTooltip from "./tools-tooltip"; + +export interface ToolsScalerProps { + scaleState: typeof initialScale; + scaleDispatch: React.Dispatch; + isFocus: boolean; + setIsFocus: React.Dispatch>; +} + +const ToolsScaler: React.FC = ({ + scaleState, + scaleDispatch, + isFocus, + setIsFocus, +}) => { + const scaleInputRef = useRef(null); + + const handleInputBlur = () => { + scaleDispatch({ type: "blur" }); + setIsFocus(false); + }; + + const handleInputFocus = () => { + scaleInputRef.current?.focus(); + setIsFocus(true); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + scaleDispatch({ + type: "changeDisplayOnly", + payload: Number(e.target.value), + }); + }; + + const handleInputEnter = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + scaleDispatch({ type: "enter" }); + scaleInputRef.current?.blur(); + setIsFocus(false); + } + }; + + useEffect(() => { + if (isFocus && scaleInputRef.current) { + scaleInputRef.current.focus(); + scaleInputRef.current.select(); + } + }, [isFocus]); + + return ( +
+ scaleDispatch({ type: "zoomOut" })} + icon={} + /> + +
+ {isFocus ? ( + <> + + % + + ) : ( + + )} +
+ + scaleDispatch({ type: "zoomIn" })} + icon={} + /> +
+ ); +}; + +export default ToolsScaler; diff --git a/packages/core/src/lib/components/image/tooltip.tsx b/packages/core/src/lib/components/image/tools-tooltip.tsx similarity index 84% rename from packages/core/src/lib/components/image/tooltip.tsx rename to packages/core/src/lib/components/image/tools-tooltip.tsx index 967e4bd..e78e195 100644 --- a/packages/core/src/lib/components/image/tooltip.tsx +++ b/packages/core/src/lib/components/image/tools-tooltip.tsx @@ -1,3 +1,4 @@ +"use client"; import React, { useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; @@ -11,16 +12,16 @@ interface AriaProps { disabled?: boolean; } -interface TooltipProps { +interface ToolsTooltipProps { + className?: string; content: string; icon: React.ReactNode; - className?: string; hint?: string; onClick: () => void; aria: AriaProps; } -export const Tooltip: React.FC = ({ +const ToolsTooltip: React.FC = ({ className, content, hint, @@ -37,7 +38,7 @@ export const Tooltip: React.FC = ({
); }; + +export default ToolsTooltip; diff --git a/packages/core/src/lib/components/image/viewer-overlay.tsx b/packages/core/src/lib/components/image/viewer-overlay.tsx new file mode 100644 index 0000000..8561e2b --- /dev/null +++ b/packages/core/src/lib/components/image/viewer-overlay.tsx @@ -0,0 +1,24 @@ +"use client"; +import React from "react"; +import { motion } from "framer-motion"; +import { MOTION_STYLES } from "./constants"; + +interface ViewerOverlayProps { + close: () => void; + isCursor: boolean; +} + +const ViewerOverlay: React.FC = ({ close, isCursor }) => { + return ( + + ); +}; + +export default ViewerOverlay; diff --git a/packages/core/src/lib/index.css b/packages/core/src/lib/index.css index 06c1430..81e23ec 100644 --- a/packages/core/src/lib/index.css +++ b/packages/core/src/lib/index.css @@ -1074,11 +1074,7 @@ -moz-osx-font-smoothing: inherit; } -.notion-image-viewer-opener { - cursor: pointer; -} - -.notion-image-viewer-container { +.notion-viewer-container { display: flex; position: fixed; inset: 0; @@ -1086,97 +1082,57 @@ z-index: 999; justify-content: center; align-items: center; + flex-direction: column; padding-top: 32px; padding-bottom: 32px; - flex-direction: column; - height: 100vh; - width: 100vw; } @media screen and (min-width: 768px) { - .notion-image-viewer-container { + .notion-viewer-container { padding-left: 32px; padding-right: 32px; } } -.notion-image-viewer-overlay { +.notion-viewer-overlay { position: fixed; + overflow: hidden; inset: 0; background-color: rgba(15, 15, 15, 0.6); - cursor: default; - overscroll-behavior: contain; - overflow: hidden; + width: 100vw; height: 100vh; + z-index: 10; + display: flex; + justify-content: center; + align-items: center; + overscroll-behavior: contain; } -.notion-image-viewer-content { +.notion-viewer-content { position: relative; - max-width: fit-content; + width: auto; + height: auto; + display: flex; + align-items: center; + justify-content: center; + z-index: 15; + max-width: 100%; max-height: 100%; } -.notion-image-viewer-content > img { - max-width: fit-content; - max-height: 100%; +.notion-viewer-content > img { + width: 100%; + height: 100%; object-fit: contain; + object-position: center; z-index: 0; - transform-origin: 0.4s ease-in-out; + transition: transform 0.4s ease-in-out; } -.notion-image-viewer-tooltip-container { - position: relative; - display: inline-block; -} - -.notion-image-viewer-tooltip { +.notion-viewer-tools { display: flex; - align-items: center; - position: absolute; - top: -35px; - left: -35%; - transform: translateX(-50%); - background-color: rgb(38, 38, 38); - color: white; - padding: 8px; - border-radius: 4px; - font-size: 12px; - white-space: nowrap; - pointer-events: none; - z-index: 10; -} - -.notion-image-viewer-tooltip p:last-child { - margin-left: 5px; - color: #d3d3d3; - font-size: 13px; -} - -.notion-block-fallback { - padding: 10px; - margin: 4px 0; - border-radius: 4px; - background-color: var(--notion-gray_background); -} - -.notion-block-fallback-content { - font-size: 14px; - color: var(--fg-color-3); -} - -.notion-block-fallback-type { - color: var(--notion-gray); - background-color: var(--notion-gray_background_co); - padding: 2px 6px; - border-radius: 3px; - margin-right: 6px; - font-family: monaco, "Andale Mono", "Ubuntu Mono", monospace; -} - -.notion-image-viewer-tools { - display: flex; - position: absolute; - bottom: -3px; + position: fixed; + bottom: 30px; left: 50%; transform: translateX(-50%); align-items: center; @@ -1188,7 +1144,7 @@ height: 28px; } -.notion-image-viewer-tools button { +.notion-viewer-tools button { background: #000000a6; user-select: none; cursor: default; @@ -1207,33 +1163,33 @@ cursor: pointer; } -.notion-image-viewer-tools button:disabled { +.notion-viewer-tools button:disabled { pointer-events: none; cursor: default; fill: #bbbbbbdd; } -.notion-image-viewer-tools .notion-tools-navigation { +.notion-viewer-tools .notion-tools-navigation { display: flex; margin-right: 10px; } -.notion-image-viewer-tools .notion-tools-navigation-back { +.notion-viewer-tools .notion-tools-navigation-back { border-top-left-radius: 4px; border-bottom-left-radius: 4px; } -.notion-image-viewer-tools .notion-tools-navigation-next { +.notion-viewer-tools .notion-tools-navigation-next { border-top-right-radius: 4px; border-bottom-right-radius: 4px; } -.notion-image-viewer-tools .notion-tools-scaler { +.notion-viewer-tools .notion-tools-scaler { display: flex; font-size: 14px; } -.notion-image-viewer-tools .notion-tools-scaler-container { +.notion-viewer-tools .notion-tools-scaler-container { background: #000000a6; display: flex; align-items: center; @@ -1243,7 +1199,7 @@ height: 28px; } -.notion-image-viewer-tools .notion-tools-scaler-container input { +.notion-viewer-tools .notion-tools-scaler-container input { background: #000000a6; border: 0; padding: 0px; @@ -1255,11 +1211,11 @@ font-size: 15px; } -.notion-image-viewer-tools .notion-tools-scaler-container input + span { +.notion-viewer-tools .notion-tools-scaler-container input + span { width: 5px; } -.notion-image-viewer-tools .notion-tools-scaler-container button { +.notion-viewer-tools .notion-tools-scaler-container button { background: none; display: flex; justify-content: center; @@ -1268,70 +1224,109 @@ color: #bbbbbbdd; } -.notion-image-viewer-tools .notion-tools-scaler-container input:focus { +.notion-viewer-tools .notion-tools-scaler-container input:focus { color: white; outline: 0.8px solid #2d75a0; } -.notion-image-viewer-tools +.notion-viewer-tools .notion-tools-scaler-container input::-webkit-outer-spin-button, -.notion-image-viewer-tools +.notion-viewer-tools .notion-tools-scaler-container input::-webkit-inner-spin-button { -webkit-appearance: none; appearance: none; margin: 0; } -.notion-image-viewer-tools .notion-tools-scaler-container input[type="number"] { +.notion-viewer-tools .notion-tools-scaler-container input[type="number"] { -moz-appearance: textfield; appearance: textfield; } -.notion-image-viewer-tools button > svg { +.notion-viewer-tools button > svg { width: 14px; height: 100%; display: block; flex-shrink: 0; } -.notion-image-viewer-tools - .notion-tools-scaler-container - button:disabled - > svg { +.notion-viewer-tools .notion-tools-scaler-container button:disabled > svg { fill: #bbbbbbdd; } -.notion-image-viewer-tools .notion-tools-download { +.notion-viewer-tools .notion-tools-download { border-left: solid 0.05px #727272ae; border-right: solid 0.05px #727272ae; } -.notion-image-viewer-tools .notion-tools-scaler-zoom-out { +.notion-viewer-tools .notion-tools-scaler-zoom-out { border-top-left-radius: 4px; border-bottom-left-radius: 4px; } -.notion-image-viewer-tools .notion-tools-close { +.notion-viewer-tools .notion-tools-close { border-top-right-radius: 4px; border-bottom-right-radius: 4px; } @media screen and (max-width: 768px) { - .notion-image-viewer-content { + .notion-viewer-content { max-width: 100%; max-height: 100%; } - .notion-image-viewer-content > img { + .notion-viewer-content > img { max-width: 100%; max-height: 100%; object-fit: contain; } +} - .notion-image-viewer-tools { - position: fixed; - bottom: 30px; - left: 50%; - transform: translateX(-50%); - } +.notion-viewer-tooltip-container { + position: relative; + display: inline-block; +} + +.notion-viewer-tooltip { + display: flex; + align-items: center; + position: absolute; + top: -35px; + left: -35%; + transform: translateX(-50%); + background-color: rgb(38, 38, 38); + color: white; + padding: 8px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; + pointer-events: none; + z-index: 10; +} + +.notion-viewer-tooltip p:last-child { + margin-left: 5px; + color: #d3d3d3; + font-size: 13px; +} + +.notion-block-fallback { + padding: 10px; + margin: 4px 0; + border-radius: 4px; + background-color: var(--notion-gray_background); +} + +.notion-block-fallback-content { + font-size: 14px; + color: var(--fg-color-3); +} + +.notion-block-fallback-type { + color: var(--notion-gray); + background-color: var(--notion-gray_background_co); + padding: 2px 6px; + border-radius: 3px; + margin-right: 6px; + font-family: monaco, "Andale Mono", "Ubuntu Mono", monospace; } From 1afa68c5a8cd485de2df8f2d5822a18a65c841f0 Mon Sep 17 00:00:00 2001 From: myjeong19 Date: Sun, 24 Aug 2025 03:45:38 +0900 Subject: [PATCH 08/17] feat: add conditional navigation display for multiple images --- .../src/lib/components/image/viewer-image.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/core/src/lib/components/image/viewer-image.tsx b/packages/core/src/lib/components/image/viewer-image.tsx index 8014c8f..a03eaf7 100644 --- a/packages/core/src/lib/components/image/viewer-image.tsx +++ b/packages/core/src/lib/components/image/viewer-image.tsx @@ -113,13 +113,15 @@ const ViewerImage: React.FC = ({ handleMouseLeave={handleMouseLeave} handleMouseEnter={handleMouseEnter} > - + {imageUrls.length > 1 && ( + + )} Date: Sun, 24 Aug 2025 06:48:55 +0900 Subject: [PATCH 09/17] fix: improve origin transition across different scale scenarios --- frontend_code_review.rules.md | 573 ++++++++++++++++++ package.json | 2 +- packages/core/package.json | 2 +- .../lib/components/image/hooks/use-keydown.ts | 29 +- .../components/image/hooks/use-navigation.ts | 2 +- .../image/hooks/use-zoom-controls.ts | 61 ++ .../components/image/reducer/scale-reducer.ts | 6 +- .../src/lib/components/image/tools-scaler.tsx | 32 +- .../src/lib/components/image/viewer-image.tsx | 54 +- 9 files changed, 720 insertions(+), 41 deletions(-) create mode 100644 frontend_code_review.rules.md create mode 100644 packages/core/src/lib/components/image/hooks/use-zoom-controls.ts diff --git a/frontend_code_review.rules.md b/frontend_code_review.rules.md new file mode 100644 index 0000000..c033c12 --- /dev/null +++ b/frontend_code_review.rules.md @@ -0,0 +1,573 @@ +# React 코드리뷰 가이드 + +> **변경하기 쉬운 코드**를 만들기 위한 실용적 가이드 +> TypeScript, React, Next.js, TanStack Query, Zustand 환경 기준 + +## 🎯 사용법 + +🎨 UX/성능 (2분) → 🏗️ 아키텍처 (5분) → 📦 컴포넌트 (10분) → 📝 코드 (전체) + +각 섹션은 **🚨 Critical → ⚡ High → 💡 Medium** 순으로 우선순위가 있습니다. + +--- + +## 📝 1. 코드 레벨 리뷰 + +### 🚨 Critical - 즉시 수정 필요 + +#### **타입 안정성** + +```typescript +// ❌ Bad: any 타입 남용 +const handleSubmit = (data: any) => { + console.log(data.user.name); // 런타임 에러 위험 +}; + +// ✅ Good: 구체적 타입 정의 +interface FormData { + user: { name: string; email: string }; +} +const handleSubmit = (data: FormData) => { + console.log(data.user.name); // 타입 안전 +}; +``` + +#### **에러 처리** + +```typescript +// ❌ Bad: 에러 처리 없음 +const fetchUser = async (id: string) => { + const response = await fetch(`/api/users/${id}`); + return response.json(); // 에러 상황 무시 +}; + +// ✅ Good: 적절한 에러 처리 +const fetchUser = async (id: string) => { + try { + const response = await fetch(`/api/users/${id}`); + if (!response.ok) throw new Error("User not found"); + return await response.json(); + } catch (error) { + console.error("fetchUser error:", error); + throw error; + } +}; +``` + +### ⚡ High - 권장 수정 + +#### **명명 규칙** + +```typescript +// ❌ Bad: 모호한 이름 +const data = useQuery('users'); +const handleClick = () => {...}; +const temp = users.filter(u => u.active); + +// ✅ Good: 의도가 명확한 이름 +const { data: userList } = useQuery('users'); +const handleDeleteUser = () => {...}; +const activeUsers = users.filter(user => user.isActive); +``` + +#### **하드코딩 제거** + +```typescript +// ❌ Bad: 매직 넘버와 하드코딩 +setTimeout(() => refetch(), 3000); +if (user.status === 'premium') {...} + +// ✅ Good: 상수화 +const CONSTANTS = { + DEBOUNCE_DELAY: 3000, + USER_STATUS: { + PREMIUM: 'premium', + BASIC: 'basic' + } +} as const; + +setTimeout(() => refetch(), CONSTANTS.DEBOUNCE_DELAY); +if (user.status === CONSTANTS.USER_STATUS.PREMIUM) {...} +``` + +### 💡 Medium - 개선 권장 + +#### **ES6+ 문법 활용** + +```typescript +// ❌ Bad: 구식 문법 +var isLoggedIn = user && user.isActive ? true : false; +const userName = user ? user.name : "Anonymous"; + +// ✅ Good: 최신 문법 +const isLoggedIn = user?.isActive ?? false; +const userName = user?.name ?? "Anonymous"; +``` + +--- + +## 📦 2. 컴포넌트 구조 리뷰 + +### 🚨 Critical - 즉시 수정 필요 + +#### **단일 책임 원칙** + +```typescript +// ❌ Bad: 한 컴포넌트에서 모든 것 처리 +function UserDashboard() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + + // 데이터 페칭, 필터링, UI 렌더링 모두 여기에... + useEffect(() => { + fetchUsers().then(setUsers); + }, []); + + const filteredUsers = users.filter(user => user.name.includes(searchTerm)); + + return ( +
+ setSearchTerm(e.target.value)} /> + {loading &&
Loading...
} + {filteredUsers.map(user => ( +
{/* 복잡한 사용자 카드 UI... */}
+ ))} +
+ ); +} + +// ✅ Good: 책임 분리 +function useUsers() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetchUsers() + .then(setUsers) + .finally(() => setLoading(false)); + }, []); + + return { users, loading }; +} + +function UserDashboard() { + const { users, loading } = useUsers(); + + return ( +
+ + +
+ ); +} +``` + +#### **Props 타입 정의** + +```typescript +// ❌ Bad: Props 타입 없음 +function UserCard({ user, onEdit, onDelete }) { + return
{user.name}
; +} + +// ✅ Good: 명확한 Props 타입 +interface UserCardProps { + user: { + id: string; + name: string; + email: string; + }; + onEdit: (userId: string) => void; + onDelete: (userId: string) => void; +} + +function UserCard({ user, onEdit, onDelete }: UserCardProps) { + return ( +
+

{user.name}

+

{user.email}

+ + +
+ ); +} +``` + +### ⚡ High - 권장 수정 + +#### **상태 관리 분리** + +```typescript +// ❌ Bad: 서버 데이터를 useState로 관리 +function UserProfile() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetchUser().then((data) => { + setUser(data); + setLoading(false); + }); + }, []); +} + +// ✅ Good: 적절한 도구로 각각 관리 +function UserProfile() { + // 서버 상태는 TanStack Query + const { data: user, isLoading } = useQuery({ + queryKey: ["user"], + queryFn: fetchUser, + }); + + // UI 상태는 useState + const [isModalOpen, setIsModalOpen] = useState(false); + + // 전역 상태는 Zustand + const theme = useThemeStore((state) => state.theme); +} +``` + +#### **불필요한 리렌더링 방지** + +```typescript +// ❌ Bad: 매번 새로운 객체/함수 생성 +function TodoList({ todos }) { + return ( +
+ {todos.map(todo => ( + updateTodo(id)} // 매번 새 함수! + style={{ padding: '8px' }} // 매번 새 객체! + /> + ))} +
+ ); +} + +// ✅ Good: 적절한 메모이제이션 +function TodoList({ todos }) { + const handleUpdate = useCallback((id: string) => { + updateTodo(id); + }, []); + + const itemStyle = useMemo(() => ({ padding: '8px' }), []); + + return ( +
+ {todos.map(todo => ( + + ))} +
+ ); +} +``` + +### 💡 Medium - 개선 권장 + +#### **컴포넌트 크기 최적화** + +```typescript +// ❌ Bad: 거대한 컴포넌트 (100줄+) +function ProductPage() { + // 50줄의 상태와 로직... + // 100줄의 JSX... +} + +// ✅ Good: 작고 명확한 컴포넌트 +function ProductPage() { + return ( +
+ + + + +
+ ); +} +``` + +--- + +## 🏗️ 3. 아키텍처 리뷰 + +### 🚨 Critical - 즉시 수정 필요 + +#### **순환 참조 방지** + +```typescript +// ❌ Bad: 순환 참조 +// components/UserCard.tsx +import { formatUserData } from "../utils/userUtils"; + +// utils/userUtils.tsx +import { UserCard } from "../components/UserCard"; // 순환 참조! + +// ✅ Good: 단방향 의존성 +// components/UserCard.tsx +import { formatUserData } from "../utils/userUtils"; + +// utils/userUtils.tsx +// UserCard 관련 코드는 별도 분리 또는 다른 접근법 사용 +``` + +#### **적절한 폴더 구조** + +``` +// ❌ Bad: 평면적 구조 +src/ +├── components/ +│ ├── Header.tsx +│ ├── Footer.tsx +│ ├── UserCard.tsx +│ ├── ProductCard.tsx +│ └── ... (50개 컴포넌트) + +// ✅ Good: 기능별 그룹화 +src/ +├── components/ +│ ├── common/ +│ │ ├── Header.tsx +│ │ └── Footer.tsx +│ ├── user/ +│ │ ├── UserCard.tsx +│ │ └── UserList.tsx +│ └── product/ +│ ├── ProductCard.tsx +│ └── ProductList.tsx +├── hooks/ +├── utils/ +└── types/ +``` + +### ⚡ High - 권장 수정 + +#### **의존성 주입** + +```typescript +// ❌ Bad: 하드코딩된 의존성 +function UserService() { + const apiClient = new ApiClient("https://api.example.com"); // 하드코딩 + + return { + getUser: (id: string) => apiClient.get(`/users/${id}`), + }; +} + +// ✅ Good: 의존성 주입 +interface ApiClient { + get(url: string): Promise; +} + +function createUserService(apiClient: ApiClient) { + return { + getUser: (id: string) => apiClient.get(`/users/${id}`), + }; +} +``` + +--- + +## 🎨 4. UX/성능 리뷰 + +### 🚨 Critical - 즉시 수정 필요 + +#### **로딩/에러 상태** + +```typescript +// ❌ Bad: 상태 처리 없음 +function UserList() { + const { data } = useQuery({ + queryKey: ['users'], + queryFn: fetchUsers, + }); + + return ( +
+ {data?.map(user => ( + + ))} +
+ ); +} + +// ✅ Good: 모든 상태 처리 +function UserList() { + const { data, isLoading, error } = useQuery({ + queryKey: ['users'], + queryFn: fetchUsers, + }); + + if (isLoading) return ; + if (error) return ; + if (!data?.length) return ; + + return ( +
+ {data.map(user => ( + + ))} +
+ ); +} +``` + +### ⚡ High - 권장 수정 + +#### **이미지 최적화** + +```typescript +// ❌ Bad: 일반 img 태그 +function Avatar({ src, alt }: { src: string; alt: string }) { + return {alt}; +} + +// ✅ Good: Next.js Image 최적화 +import Image from 'next/image'; + +function Avatar({ src, alt }: { src: string; alt: string }) { + return ( + {alt} + ); +} +``` + +#### **접근성 고려** + +```typescript +// ❌ Bad: 접근성 무시 +function Modal({ isOpen, onClose, children }) { + if (!isOpen) return null; + + return ( +
+
+ + {children} +
+
+ ); +} + +// ✅ Good: 접근성 적용 +function Modal({ isOpen, onClose, children, title }: ModalProps) { + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + if (!isOpen) return null; + + return ( +
+
+
+ + +
+ {children} +
+
+ ); +} +``` + +--- + +## 🛠️ 자동화 가능한 항목들 + +다음 항목들은 도구로 자동 체크 가능하므로 ESLint/Prettier 설정 권장: + +### **ESLint 추천 룰** + +```json +{ + "extends": [ + "@typescript-eslint/recommended", + "plugin:react-hooks/recommended" + ], + "rules": { + "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/no-explicit-any": "warn", + "react-hooks/exhaustive-deps": "warn", + "prefer-const": "error" + } +} +``` + +### **자동화 도구 설정** + +```json +// package.json +{ + "lint-staged": { + "*.{ts,tsx}": ["eslint --fix", "prettier --write"] + } +} +``` + +--- + +## 📝 개선 가이드 템플릿 + +각 문제점에 대해 다음 형식으로 제시: + +```` +🔍 **문제**: [구체적인 문제점] +💡 **해결**: [개선 방향] +⚠️ **이유**: [왜 바꿔야 하는지] + +📖 **Before**: +```typescript +// 문제가 있는 코드 +```` + +📖 **After**: + +```typescript +// 개선된 코드 +``` + +🎯 **학습 포인트**: [핵심 개념이나 패턴] + +``` + +--- + +## 🆕 추가 제안 영역 + +### 최신 React 패턴 +- React 18+ 기능 (Suspense, Transitions) +- 최신 Hook 활용 +- Server Components 고려 + +### 성능 최적화 +- 번들 크기 줄이기 +- 이미지 최적화 +- 캐싱 전략 + +### 개발 경험 개선 +- 유용한 라이브러리 추천 +- 개발 도구 설정 +- 디버깅 팁 + + +``` diff --git a/package.json b/package.json index a4a70d0..75a6da2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@notionpresso/react", - "version": "0.0.1", + "version": "0.0.7", "description": "this is a react library for notionpresso", "main": "index.js", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 107b275..f478a56 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@notionpresso/react", - "version": "0.0.6", + "version": "0.0.7", "private": false, "homepage": "https://notionpresso.com", "description": "This is wellmade React components for Notion Opensource", diff --git a/packages/core/src/lib/components/image/hooks/use-keydown.ts b/packages/core/src/lib/components/image/hooks/use-keydown.ts index b38c7b5..4548ea6 100644 --- a/packages/core/src/lib/components/image/hooks/use-keydown.ts +++ b/packages/core/src/lib/components/image/hooks/use-keydown.ts @@ -1,40 +1,27 @@ "use client"; -import { useCallback, useEffect } from "react"; - -import type { OriginAction, ScaleAction } from "../reducer"; +import { useEffect } from "react"; +import type { UseZoomControls } from "./use-zoom-controls"; interface UseKeydownProps { close: () => void; - scaleDispatch: React.Dispatch; - originDispatch: React.Dispatch; + zoomControls: UseZoomControls; toPreviousImage: () => void; toNextImage: () => void; } export const useKeydown = ({ close, - scaleDispatch, - originDispatch, + zoomControls, toPreviousImage, toNextImage, }: UseKeydownProps) => { - const handleZoomIn = useCallback(() => { - originDispatch({ type: "reset" }); - scaleDispatch({ type: "zoomIn" }); - }, [originDispatch, scaleDispatch]); - - const handleZoomOut = useCallback(() => { - originDispatch({ type: "reset" }); - scaleDispatch({ type: "zoomOut" }); - }, [originDispatch, scaleDispatch]); - useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const keyDownEvents: { [key: string]: () => void } = { Escape: close, - "+": handleZoomIn, - "=": handleZoomIn, - "-": handleZoomOut, + "+": zoomControls.handleZoomIn, + "=": zoomControls.handleZoomIn, + "-": zoomControls.handleZoomOut, ArrowLeft: toPreviousImage, ArrowRight: toNextImage, }; @@ -47,5 +34,5 @@ export const useKeydown = ({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [close, handleZoomIn, handleZoomOut, toNextImage, toPreviousImage]); + }, [close, zoomControls, toNextImage, toPreviousImage]); }; diff --git a/packages/core/src/lib/components/image/hooks/use-navigation.ts b/packages/core/src/lib/components/image/hooks/use-navigation.ts index 0af9e3f..b6845e1 100644 --- a/packages/core/src/lib/components/image/hooks/use-navigation.ts +++ b/packages/core/src/lib/components/image/hooks/use-navigation.ts @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -const NAVIGATION = { +export const NAVIGATION = { START_INDEX: 0, MIN_INDEX: 0, diff --git a/packages/core/src/lib/components/image/hooks/use-zoom-controls.ts b/packages/core/src/lib/components/image/hooks/use-zoom-controls.ts new file mode 100644 index 0000000..34f3854 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/use-zoom-controls.ts @@ -0,0 +1,61 @@ +"use client"; +import { useCallback } from "react"; + +import { + initialOrigin, + initialScale, + type OriginAction, + type ScaleAction, +} from "../reducer"; + +const MAX_ZOOM_IN_THRESHOLD = 150; +const ZOOM_OUT_THRESHOLD = 100; + +interface UseZoomControlsProps { + scaleState: typeof initialScale; + lastMousePosition: typeof initialOrigin; + originDispatch: React.Dispatch; + scaleDispatch: React.Dispatch; +} + +export interface UseZoomControls { + handleZoomIn: () => void; + handleZoomOut: () => void; +} + +export const useZoomControls = ({ + scaleState, + originDispatch, + lastMousePosition, + scaleDispatch, +}: UseZoomControlsProps): UseZoomControls => { + const handleZoomIn = useCallback(() => { + if (scaleState.displayScale > MAX_ZOOM_IN_THRESHOLD) { + originDispatch({ type: "reset" }); + } + + scaleDispatch({ type: "zoomIn" }); + }, [scaleState.displayScale, originDispatch, scaleDispatch]); + + const handleZoomOut = useCallback(() => { + if (scaleState.displayScale > ZOOM_OUT_THRESHOLD) { + originDispatch({ + type: "zoomInOut", + payload: lastMousePosition, + }); + } else { + originDispatch({ type: "reset" }); + } + scaleDispatch({ type: "zoomOut" }); + }, [ + scaleState.displayScale, + originDispatch, + lastMousePosition, + scaleDispatch, + ]); + + return { + handleZoomIn, + handleZoomOut, + }; +}; diff --git a/packages/core/src/lib/components/image/reducer/scale-reducer.ts b/packages/core/src/lib/components/image/reducer/scale-reducer.ts index 1e591f2..f3db7b8 100644 --- a/packages/core/src/lib/components/image/reducer/scale-reducer.ts +++ b/packages/core/src/lib/components/image/reducer/scale-reducer.ts @@ -17,14 +17,14 @@ export type ScaleAction = { payload?: number; }; -const DISPLAY = { +export const DISPLAY = { INITIAL: 100, STEP: 50, MIN: 50, MAX: 200, }; -const STYLE = { +export const STYLE = { INITIAL: 1, STEP: 0.5, ZOOM_IN_STEP: 1.5, @@ -32,7 +32,7 @@ const STYLE = { MAX_STEP: 2, }; -const CONVERSION = { +export const CONVERSION = { PERCENT_FACTOR: 100, }; diff --git a/packages/core/src/lib/components/image/tools-scaler.tsx b/packages/core/src/lib/components/image/tools-scaler.tsx index 4352c2d..9f68472 100644 --- a/packages/core/src/lib/components/image/tools-scaler.tsx +++ b/packages/core/src/lib/components/image/tools-scaler.tsx @@ -2,21 +2,34 @@ import React, { useEffect, useRef } from "react"; import { TOOLS_ACTIONS, TOOLS_ARIA_LABELS } from "./constants/viewer-tools"; import { Icons } from "./icons"; -import { initialScale, type ScaleAction } from "./reducer"; +import { + initialScale, + OriginAction, + type ScaleAction, + initialOrigin, + DISPLAY as DISPLAY_STYLE, +} from "./reducer"; +import type { UseZoomControls } from "./hooks/use-zoom-controls"; import ToolsTooltip from "./tools-tooltip"; export interface ToolsScalerProps { scaleState: typeof initialScale; scaleDispatch: React.Dispatch; + originDispatch: React.Dispatch; isFocus: boolean; setIsFocus: React.Dispatch>; + lastMousePosition: typeof initialOrigin; + zoomControls: UseZoomControls; } const ToolsScaler: React.FC = ({ scaleState, scaleDispatch, + originDispatch, isFocus, setIsFocus, + lastMousePosition, + zoomControls, }) => { const scaleInputRef = useRef(null); @@ -27,6 +40,13 @@ const ToolsScaler: React.FC = ({ const handleInputFocus = () => { scaleInputRef.current?.focus(); + + scaleDispatch({ type: "reset" }); + originDispatch({ + type: "zoomInOut", + payload: lastMousePosition, + }); + setIsFocus(true); }; @@ -39,9 +59,13 @@ const ToolsScaler: React.FC = ({ const handleInputEnter = (e: React.KeyboardEvent) => { if (e.key === "Enter") { + if (DISPLAY_STYLE.INITIAL > scaleState.displayScale) { + originDispatch({ type: "reset" }); + } + scaleDispatch({ type: "enter" }); - scaleInputRef.current?.blur(); setIsFocus(false); + scaleInputRef.current?.blur(); } }; @@ -59,7 +83,7 @@ const ToolsScaler: React.FC = ({ content={TOOLS_ACTIONS.ZOOM_OUT} hint="-" aria={{ label: TOOLS_ARIA_LABELS.ZOOM_OUT }} - onClick={() => scaleDispatch({ type: "zoomOut" })} + onClick={zoomControls.handleZoomOut} icon={} /> @@ -95,7 +119,7 @@ const ToolsScaler: React.FC = ({ content={TOOLS_ACTIONS.ZOOM_IN} hint="+" aria={{ label: TOOLS_ARIA_LABELS.ZOOM_IN }} - onClick={() => scaleDispatch({ type: "zoomIn" })} + onClick={zoomControls.handleZoomIn} icon={} />
diff --git a/packages/core/src/lib/components/image/viewer-image.tsx b/packages/core/src/lib/components/image/viewer-image.tsx index a03eaf7..39b9cc9 100644 --- a/packages/core/src/lib/components/image/viewer-image.tsx +++ b/packages/core/src/lib/components/image/viewer-image.tsx @@ -9,7 +9,7 @@ import React, { import { motion } from "framer-motion"; -import { useNavigation, useImages } from "./hooks"; +import { useNavigation, useImages, NAVIGATION } from "./hooks"; import { getCursorStyle } from "./lib"; import { MOTION_STYLES } from "./constants"; @@ -20,8 +20,12 @@ import { initialScale, originReducer, scaleReducer, + STYLE as SCALE_STYLE, + DISPLAY as DISPLAY_STYLE, + CONVERSION, } from "./reducer"; import { useKeydown } from "./hooks/use-keydown"; +import { useZoomControls } from "./hooks/use-zoom-controls"; interface ViewerImageProps { url: string; @@ -42,6 +46,7 @@ const ViewerImage: React.FC = ({ }) => { const imageRef = useRef(null); const [isFocus, setIsFocus] = useState(false); + const [lastMousePosition, setLastMousePosition] = useState(initialOrigin); const imageUrls = useImages(); @@ -56,6 +61,13 @@ const ViewerImage: React.FC = ({ initialOrigin, ); + const zoomControls = useZoomControls({ + scaleState, + originDispatch, + lastMousePosition, + scaleDispatch, + }); + useEffect(() => { if (activeIndex) { scaleDispatch({ type: "reset" }); @@ -64,8 +76,7 @@ const ViewerImage: React.FC = ({ useKeydown({ close, - scaleDispatch, - originDispatch, + zoomControls, toPreviousImage, toNextImage, }); @@ -81,13 +92,33 @@ const ViewerImage: React.FC = ({ const currentMouseX = (event.clientX - left) / width; const currentMouseY = (event.clientY - top) / height; - originDispatch({ - type: "zoomInOut", - payload: { originX: currentMouseX, originY: currentMouseY }, + setLastMousePosition({ + originX: currentMouseX, + originY: currentMouseY, }); - scaleDispatch({ type: "zoomInOut" }); + + if (scaleState.displayScale === DISPLAY_STYLE.MIN) { + return scaleDispatch({ type: "zoomIn" }); + } + + if (scaleState.styleScale > SCALE_STYLE.INITIAL) { + scaleDispatch({ type: "zoomInOut" }); + } else { + originDispatch({ + type: "zoomInOut", + payload: { originX: currentMouseX, originY: currentMouseY }, + }); + scaleDispatch({ type: "zoomInOut" }); + } }, - [imageRef, originDispatch, scaleDispatch], + [ + imageRef, + originDispatch, + scaleDispatch, + scaleState.styleScale, + scaleState.displayScale, + setLastMousePosition, + ], ); return ( @@ -101,7 +132,7 @@ const ViewerImage: React.FC = ({ src={imageUrls[activeIndex]} style={{ transform: `scale(${scaleState.styleScale})`, - transformOrigin: `${originState.originX * 100}% ${originState.originY * 100}%`, + transformOrigin: `${originState.originX * CONVERSION.PERCENT_FACTOR}% ${originState.originY * CONVERSION.PERCENT_FACTOR}%`, cursor: isCursor ? getCursorStyle(scaleState.styleScale) : "none", }} onClick={handleZoomInOut} @@ -113,7 +144,7 @@ const ViewerImage: React.FC = ({ handleMouseLeave={handleMouseLeave} handleMouseEnter={handleMouseEnter} > - {imageUrls.length > 1 && ( + {imageUrls.length > NAVIGATION.MIN_INDEX && ( = ({ From 196e6fc67e1ff090166393c02011e9f457a622cc Mon Sep 17 00:00:00 2001 From: myjeong19 Date: Sun, 24 Aug 2025 07:09:09 +0900 Subject: [PATCH 10/17] fix: remove condition in scale reset useEffect --- packages/core/src/lib/components/image/viewer-image.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/lib/components/image/viewer-image.tsx b/packages/core/src/lib/components/image/viewer-image.tsx index 39b9cc9..7b827fb 100644 --- a/packages/core/src/lib/components/image/viewer-image.tsx +++ b/packages/core/src/lib/components/image/viewer-image.tsx @@ -69,9 +69,7 @@ const ViewerImage: React.FC = ({ }); useEffect(() => { - if (activeIndex) { - scaleDispatch({ type: "reset" }); - } + scaleDispatch({ type: "reset" }); }, [activeIndex]); useKeydown({ From e1ac03174dc74b0d3c6b13ca49b5d5641439f9d8 Mon Sep 17 00:00:00 2001 From: myjeong19 Date: Sun, 24 Aug 2025 15:54:12 +0900 Subject: [PATCH 11/17] chore: delete personal file --- frontend_code_review.rules.md | 573 ---------------------------------- 1 file changed, 573 deletions(-) delete mode 100644 frontend_code_review.rules.md diff --git a/frontend_code_review.rules.md b/frontend_code_review.rules.md deleted file mode 100644 index c033c12..0000000 --- a/frontend_code_review.rules.md +++ /dev/null @@ -1,573 +0,0 @@ -# React 코드리뷰 가이드 - -> **변경하기 쉬운 코드**를 만들기 위한 실용적 가이드 -> TypeScript, React, Next.js, TanStack Query, Zustand 환경 기준 - -## 🎯 사용법 - -🎨 UX/성능 (2분) → 🏗️ 아키텍처 (5분) → 📦 컴포넌트 (10분) → 📝 코드 (전체) - -각 섹션은 **🚨 Critical → ⚡ High → 💡 Medium** 순으로 우선순위가 있습니다. - ---- - -## 📝 1. 코드 레벨 리뷰 - -### 🚨 Critical - 즉시 수정 필요 - -#### **타입 안정성** - -```typescript -// ❌ Bad: any 타입 남용 -const handleSubmit = (data: any) => { - console.log(data.user.name); // 런타임 에러 위험 -}; - -// ✅ Good: 구체적 타입 정의 -interface FormData { - user: { name: string; email: string }; -} -const handleSubmit = (data: FormData) => { - console.log(data.user.name); // 타입 안전 -}; -``` - -#### **에러 처리** - -```typescript -// ❌ Bad: 에러 처리 없음 -const fetchUser = async (id: string) => { - const response = await fetch(`/api/users/${id}`); - return response.json(); // 에러 상황 무시 -}; - -// ✅ Good: 적절한 에러 처리 -const fetchUser = async (id: string) => { - try { - const response = await fetch(`/api/users/${id}`); - if (!response.ok) throw new Error("User not found"); - return await response.json(); - } catch (error) { - console.error("fetchUser error:", error); - throw error; - } -}; -``` - -### ⚡ High - 권장 수정 - -#### **명명 규칙** - -```typescript -// ❌ Bad: 모호한 이름 -const data = useQuery('users'); -const handleClick = () => {...}; -const temp = users.filter(u => u.active); - -// ✅ Good: 의도가 명확한 이름 -const { data: userList } = useQuery('users'); -const handleDeleteUser = () => {...}; -const activeUsers = users.filter(user => user.isActive); -``` - -#### **하드코딩 제거** - -```typescript -// ❌ Bad: 매직 넘버와 하드코딩 -setTimeout(() => refetch(), 3000); -if (user.status === 'premium') {...} - -// ✅ Good: 상수화 -const CONSTANTS = { - DEBOUNCE_DELAY: 3000, - USER_STATUS: { - PREMIUM: 'premium', - BASIC: 'basic' - } -} as const; - -setTimeout(() => refetch(), CONSTANTS.DEBOUNCE_DELAY); -if (user.status === CONSTANTS.USER_STATUS.PREMIUM) {...} -``` - -### 💡 Medium - 개선 권장 - -#### **ES6+ 문법 활용** - -```typescript -// ❌ Bad: 구식 문법 -var isLoggedIn = user && user.isActive ? true : false; -const userName = user ? user.name : "Anonymous"; - -// ✅ Good: 최신 문법 -const isLoggedIn = user?.isActive ?? false; -const userName = user?.name ?? "Anonymous"; -``` - ---- - -## 📦 2. 컴포넌트 구조 리뷰 - -### 🚨 Critical - 즉시 수정 필요 - -#### **단일 책임 원칙** - -```typescript -// ❌ Bad: 한 컴포넌트에서 모든 것 처리 -function UserDashboard() { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - - // 데이터 페칭, 필터링, UI 렌더링 모두 여기에... - useEffect(() => { - fetchUsers().then(setUsers); - }, []); - - const filteredUsers = users.filter(user => user.name.includes(searchTerm)); - - return ( -
- setSearchTerm(e.target.value)} /> - {loading &&
Loading...
} - {filteredUsers.map(user => ( -
{/* 복잡한 사용자 카드 UI... */}
- ))} -
- ); -} - -// ✅ Good: 책임 분리 -function useUsers() { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(false); - - useEffect(() => { - setLoading(true); - fetchUsers() - .then(setUsers) - .finally(() => setLoading(false)); - }, []); - - return { users, loading }; -} - -function UserDashboard() { - const { users, loading } = useUsers(); - - return ( -
- - -
- ); -} -``` - -#### **Props 타입 정의** - -```typescript -// ❌ Bad: Props 타입 없음 -function UserCard({ user, onEdit, onDelete }) { - return
{user.name}
; -} - -// ✅ Good: 명확한 Props 타입 -interface UserCardProps { - user: { - id: string; - name: string; - email: string; - }; - onEdit: (userId: string) => void; - onDelete: (userId: string) => void; -} - -function UserCard({ user, onEdit, onDelete }: UserCardProps) { - return ( -
-

{user.name}

-

{user.email}

- - -
- ); -} -``` - -### ⚡ High - 권장 수정 - -#### **상태 관리 분리** - -```typescript -// ❌ Bad: 서버 데이터를 useState로 관리 -function UserProfile() { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(false); - - useEffect(() => { - setLoading(true); - fetchUser().then((data) => { - setUser(data); - setLoading(false); - }); - }, []); -} - -// ✅ Good: 적절한 도구로 각각 관리 -function UserProfile() { - // 서버 상태는 TanStack Query - const { data: user, isLoading } = useQuery({ - queryKey: ["user"], - queryFn: fetchUser, - }); - - // UI 상태는 useState - const [isModalOpen, setIsModalOpen] = useState(false); - - // 전역 상태는 Zustand - const theme = useThemeStore((state) => state.theme); -} -``` - -#### **불필요한 리렌더링 방지** - -```typescript -// ❌ Bad: 매번 새로운 객체/함수 생성 -function TodoList({ todos }) { - return ( -
- {todos.map(todo => ( - updateTodo(id)} // 매번 새 함수! - style={{ padding: '8px' }} // 매번 새 객체! - /> - ))} -
- ); -} - -// ✅ Good: 적절한 메모이제이션 -function TodoList({ todos }) { - const handleUpdate = useCallback((id: string) => { - updateTodo(id); - }, []); - - const itemStyle = useMemo(() => ({ padding: '8px' }), []); - - return ( -
- {todos.map(todo => ( - - ))} -
- ); -} -``` - -### 💡 Medium - 개선 권장 - -#### **컴포넌트 크기 최적화** - -```typescript -// ❌ Bad: 거대한 컴포넌트 (100줄+) -function ProductPage() { - // 50줄의 상태와 로직... - // 100줄의 JSX... -} - -// ✅ Good: 작고 명확한 컴포넌트 -function ProductPage() { - return ( -
- - - - -
- ); -} -``` - ---- - -## 🏗️ 3. 아키텍처 리뷰 - -### 🚨 Critical - 즉시 수정 필요 - -#### **순환 참조 방지** - -```typescript -// ❌ Bad: 순환 참조 -// components/UserCard.tsx -import { formatUserData } from "../utils/userUtils"; - -// utils/userUtils.tsx -import { UserCard } from "../components/UserCard"; // 순환 참조! - -// ✅ Good: 단방향 의존성 -// components/UserCard.tsx -import { formatUserData } from "../utils/userUtils"; - -// utils/userUtils.tsx -// UserCard 관련 코드는 별도 분리 또는 다른 접근법 사용 -``` - -#### **적절한 폴더 구조** - -``` -// ❌ Bad: 평면적 구조 -src/ -├── components/ -│ ├── Header.tsx -│ ├── Footer.tsx -│ ├── UserCard.tsx -│ ├── ProductCard.tsx -│ └── ... (50개 컴포넌트) - -// ✅ Good: 기능별 그룹화 -src/ -├── components/ -│ ├── common/ -│ │ ├── Header.tsx -│ │ └── Footer.tsx -│ ├── user/ -│ │ ├── UserCard.tsx -│ │ └── UserList.tsx -│ └── product/ -│ ├── ProductCard.tsx -│ └── ProductList.tsx -├── hooks/ -├── utils/ -└── types/ -``` - -### ⚡ High - 권장 수정 - -#### **의존성 주입** - -```typescript -// ❌ Bad: 하드코딩된 의존성 -function UserService() { - const apiClient = new ApiClient("https://api.example.com"); // 하드코딩 - - return { - getUser: (id: string) => apiClient.get(`/users/${id}`), - }; -} - -// ✅ Good: 의존성 주입 -interface ApiClient { - get(url: string): Promise; -} - -function createUserService(apiClient: ApiClient) { - return { - getUser: (id: string) => apiClient.get(`/users/${id}`), - }; -} -``` - ---- - -## 🎨 4. UX/성능 리뷰 - -### 🚨 Critical - 즉시 수정 필요 - -#### **로딩/에러 상태** - -```typescript -// ❌ Bad: 상태 처리 없음 -function UserList() { - const { data } = useQuery({ - queryKey: ['users'], - queryFn: fetchUsers, - }); - - return ( -
- {data?.map(user => ( - - ))} -
- ); -} - -// ✅ Good: 모든 상태 처리 -function UserList() { - const { data, isLoading, error } = useQuery({ - queryKey: ['users'], - queryFn: fetchUsers, - }); - - if (isLoading) return ; - if (error) return ; - if (!data?.length) return ; - - return ( -
- {data.map(user => ( - - ))} -
- ); -} -``` - -### ⚡ High - 권장 수정 - -#### **이미지 최적화** - -```typescript -// ❌ Bad: 일반 img 태그 -function Avatar({ src, alt }: { src: string; alt: string }) { - return {alt}; -} - -// ✅ Good: Next.js Image 최적화 -import Image from 'next/image'; - -function Avatar({ src, alt }: { src: string; alt: string }) { - return ( - {alt} - ); -} -``` - -#### **접근성 고려** - -```typescript -// ❌ Bad: 접근성 무시 -function Modal({ isOpen, onClose, children }) { - if (!isOpen) return null; - - return ( -
-
- - {children} -
-
- ); -} - -// ✅ Good: 접근성 적용 -function Modal({ isOpen, onClose, children, title }: ModalProps) { - useEffect(() => { - if (isOpen) { - document.body.style.overflow = 'hidden'; - } - return () => { - document.body.style.overflow = 'unset'; - }; - }, [isOpen]); - - if (!isOpen) return null; - - return ( -
-
-
- - -
- {children} -
-
- ); -} -``` - ---- - -## 🛠️ 자동화 가능한 항목들 - -다음 항목들은 도구로 자동 체크 가능하므로 ESLint/Prettier 설정 권장: - -### **ESLint 추천 룰** - -```json -{ - "extends": [ - "@typescript-eslint/recommended", - "plugin:react-hooks/recommended" - ], - "rules": { - "@typescript-eslint/no-unused-vars": "error", - "@typescript-eslint/no-explicit-any": "warn", - "react-hooks/exhaustive-deps": "warn", - "prefer-const": "error" - } -} -``` - -### **자동화 도구 설정** - -```json -// package.json -{ - "lint-staged": { - "*.{ts,tsx}": ["eslint --fix", "prettier --write"] - } -} -``` - ---- - -## 📝 개선 가이드 템플릿 - -각 문제점에 대해 다음 형식으로 제시: - -```` -🔍 **문제**: [구체적인 문제점] -💡 **해결**: [개선 방향] -⚠️ **이유**: [왜 바꿔야 하는지] - -📖 **Before**: -```typescript -// 문제가 있는 코드 -```` - -📖 **After**: - -```typescript -// 개선된 코드 -``` - -🎯 **학습 포인트**: [핵심 개념이나 패턴] - -``` - ---- - -## 🆕 추가 제안 영역 - -### 최신 React 패턴 -- React 18+ 기능 (Suspense, Transitions) -- 최신 Hook 활용 -- Server Components 고려 - -### 성능 최적화 -- 번들 크기 줄이기 -- 이미지 최적화 -- 캐싱 전략 - -### 개발 경험 개선 -- 유용한 라이브러리 추천 -- 개발 도구 설정 -- 디버깅 팁 - - -``` From 44414a8f2eee93dc0bdadf92b8c84f1459093334 Mon Sep 17 00:00:00 2001 From: myjeong19 Date: Tue, 26 Aug 2025 09:29:23 +0900 Subject: [PATCH 12/17] refactor: image component utility functions --- .../components/image/constants/image-size.ts | 20 +++ .../lib/components/image/constants/index.ts | 3 + .../lib/components/image/constants/motion.ts | 1 - .../image/constants/viewer-tools.ts | 17 +++ .../components/image/hooks/use-image-size.ts | 137 ++++++++++++++++++ .../core/src/lib/components/image/image.tsx | 7 +- .../components/image/lib/browser-detection.ts | 4 + .../{get-cursor-style.ts => cursor-style.ts} | 0 ...wnload-image-file.ts => download-image.ts} | 0 .../components/image/lib/extract-image-url.ts | 16 -- .../image/lib/find-image-caption.ts | 27 ---- .../image/lib/{get-gap.ts => gap.ts} | 0 .../image/lib/get-visible-images.ts | 9 -- .../lib/components/image/lib/image-search.ts | 52 +++++++ .../lib/components/image/lib/image-size.ts | 74 ++++++++++ .../src/lib/components/image/lib/index.ts | 14 +- .../image/lib/normalize-display-scale.ts | 4 - .../lib/components/image/lib/normalizers.ts | 22 +++ .../components/image/lib/resize-listener.ts | 23 +++ .../lib/components/image/tools-tooltip.tsx | 4 +- .../src/lib/components/image/viewer-image.tsx | 8 +- .../src/lib/components/image/viewer-tools.tsx | 2 - 22 files changed, 372 insertions(+), 72 deletions(-) create mode 100644 packages/core/src/lib/components/image/constants/image-size.ts create mode 100644 packages/core/src/lib/components/image/hooks/use-image-size.ts create mode 100644 packages/core/src/lib/components/image/lib/browser-detection.ts rename packages/core/src/lib/components/image/lib/{get-cursor-style.ts => cursor-style.ts} (100%) rename packages/core/src/lib/components/image/lib/{download-image-file.ts => download-image.ts} (100%) delete mode 100644 packages/core/src/lib/components/image/lib/extract-image-url.ts delete mode 100644 packages/core/src/lib/components/image/lib/find-image-caption.ts rename packages/core/src/lib/components/image/lib/{get-gap.ts => gap.ts} (100%) delete mode 100644 packages/core/src/lib/components/image/lib/get-visible-images.ts create mode 100644 packages/core/src/lib/components/image/lib/image-search.ts create mode 100644 packages/core/src/lib/components/image/lib/image-size.ts delete mode 100644 packages/core/src/lib/components/image/lib/normalize-display-scale.ts create mode 100644 packages/core/src/lib/components/image/lib/normalizers.ts create mode 100644 packages/core/src/lib/components/image/lib/resize-listener.ts diff --git a/packages/core/src/lib/components/image/constants/image-size.ts b/packages/core/src/lib/components/image/constants/image-size.ts new file mode 100644 index 0000000..76f66f9 --- /dev/null +++ b/packages/core/src/lib/components/image/constants/image-size.ts @@ -0,0 +1,20 @@ +export const BREAKPOINTS = { + MOBILE: 768, + TABLET: 1024, + DESKTOP: 1440, +} as const; + +export const PORTRAIT_RATIOS = { + DESKTOP: 0.45, + TABLET: 0.598, + MOBILE: 0.86, +} as const; + +export const LANDSCAPE_RATIOS = { + DESKTOP: 0.9, + TABLET: 0.9375, + MOBILE: 1.0, +} as const; + +export const MAX_HEIGHT_RATIO = 0.9; +export const PRECISION = 1000; diff --git a/packages/core/src/lib/components/image/constants/index.ts b/packages/core/src/lib/components/image/constants/index.ts index 1bce857..6fa453b 100644 --- a/packages/core/src/lib/components/image/constants/index.ts +++ b/packages/core/src/lib/components/image/constants/index.ts @@ -1 +1,4 @@ export * from "./motion"; +export * from "./image-size"; +export * from "./viewer-tools"; +export * from "./motion"; diff --git a/packages/core/src/lib/components/image/constants/motion.ts b/packages/core/src/lib/components/image/constants/motion.ts index e29fed4..e1d2b16 100644 --- a/packages/core/src/lib/components/image/constants/motion.ts +++ b/packages/core/src/lib/components/image/constants/motion.ts @@ -2,5 +2,4 @@ export const MOTION_STYLES = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, - transition: { duration: 0.1, ease: "easeInOut" }, }; diff --git a/packages/core/src/lib/components/image/constants/viewer-tools.ts b/packages/core/src/lib/components/image/constants/viewer-tools.ts index 79586c4..0cc071c 100644 --- a/packages/core/src/lib/components/image/constants/viewer-tools.ts +++ b/packages/core/src/lib/components/image/constants/viewer-tools.ts @@ -15,4 +15,21 @@ export const TOOLS_ARIA_LABELS = { DOWNLOAD: "image download button", CLOSE: "image viewer close button", SCALER_INPUT: "scaler input", + IMAGE_VIEWER: "image viewer container", + TOOLTIP_DESCRIPTION: "tooltip description", +} as const; + +export const TOOLS_ARIA_CONTROLS = { + IMAGE_VIEWER: "image-viewer-container", + ZOOM_CONTROLS: "zoom-controls-panel", + NAVIGATION_PANEL: "navigation-panel", +} as const; + +export const TOOLS_ARIA_DESCRIBEDBY = { + ZOOM_OUT: "zoom-out-description", + ZOOM_IN: "zoom-in-description", + DOWNLOAD: "download-description", + CLOSE: "close-description", + BACK: "back-description", + NEXT: "next-description", } as const; diff --git a/packages/core/src/lib/components/image/hooks/use-image-size.ts b/packages/core/src/lib/components/image/hooks/use-image-size.ts new file mode 100644 index 0000000..bca8ed2 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/use-image-size.ts @@ -0,0 +1,137 @@ +"use client"; +import { useEffect, useState, RefObject, useCallback } from "react"; +import { + getMaxSize, + getViewport, + addResizeListener, + removeResizeListener, + supportsIntersectionObserver, + isSafari, +} from "../lib"; + +interface ImageMaxSize { + maxWidth: number; + maxHeight: number; +} + +export const useImageSize = ( + imageRef: RefObject, + activeIndex: number, +): ImageMaxSize => { + const [maxSize, setMaxSize] = useState({ + maxWidth: 0, + maxHeight: 0, + }); + + const calculateMaxSize = useCallback(() => { + const image = imageRef.current; + if (!image) return; + + const { naturalWidth, naturalHeight } = image; + if (naturalWidth === 0 || naturalHeight === 0) return; + + try { + const { width: viewportWidth, height: viewportHeight } = getViewport(); + const calculatedSize = getMaxSize( + naturalWidth, + naturalHeight, + viewportWidth, + viewportHeight, + ); + setMaxSize(calculatedSize); + } catch (error) { + console.error("❌ Error calculating max size:", error); + } + }, [imageRef]); + + const handleResize = useCallback(() => { + requestAnimationFrame(calculateMaxSize); + }, [calculateMaxSize]); + + const handleSafariIntersection = useCallback(() => { + const image = imageRef.current; + if (!image) return; + + if (isSafari() && supportsIntersectionObserver()) { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setTimeout(() => calculateMaxSize(), 100); + observer.disconnect(); + } + }); + }, + { threshold: 0.1 }, + ); + + observer.observe(image); + return observer; + } + + return null; + }, [imageRef, calculateMaxSize]); + + const handleImageLoad = useCallback(() => { + const image = imageRef.current; + if (!image) return; + + const handleLoad = () => calculateMaxSize(); + + if (image.complete && image.naturalWidth > 0) { + calculateMaxSize(); + } else { + image.addEventListener("load", handleLoad, { once: true }); + } + + return handleLoad; + }, [imageRef, calculateMaxSize]); + + const handleResizeEvents = useCallback(() => { + return addResizeListener(handleResize); + }, [handleResize]); + + const cleanup = useCallback( + ( + handleLoad: () => void, + visualViewport: VisualViewport | null, + observer: IntersectionObserver | null, + ) => { + const image = imageRef.current; + if (image) { + image.removeEventListener("load", handleLoad); + } + + removeResizeListener(handleResize, visualViewport); + + if (observer) { + observer.disconnect(); + } + }, + [imageRef, handleResize], + ); + + useEffect(() => { + const image = imageRef.current; + if (!image) return; + + const handleLoad = handleImageLoad(); + const visualViewport = handleResizeEvents(); + const observer = handleSafariIntersection(); + + return () => { + if (handleLoad) { + cleanup(handleLoad, visualViewport, observer ?? null); + } + }; + }, [ + imageRef, + activeIndex, + handleImageLoad, + handleResizeEvents, + handleSafariIntersection, + cleanup, + ]); + + return maxSize; +}; diff --git a/packages/core/src/lib/components/image/image.tsx b/packages/core/src/lib/components/image/image.tsx index 0635d35..8c1fdb3 100644 --- a/packages/core/src/lib/components/image/image.tsx +++ b/packages/core/src/lib/components/image/image.tsx @@ -24,9 +24,12 @@ const Image: React.FC = ({ children, ...props }) => { ? findImageCaption(props.blocks, url) : null; + const isViewer = url && isOpen; + const isCaption = caption.length !== 0; + return ( <> - {isOpen && url && ( + {isViewer && ( = ({ children, ...props }) => { )} - {caption.length !== 0 && ( + {isCaption && (
diff --git a/packages/core/src/lib/components/image/lib/browser-detection.ts b/packages/core/src/lib/components/image/lib/browser-detection.ts new file mode 100644 index 0000000..1d3b7e7 --- /dev/null +++ b/packages/core/src/lib/components/image/lib/browser-detection.ts @@ -0,0 +1,4 @@ +export const isSafari = () => + /^((?!chrome|android).)*safari/i.test(navigator.userAgent); +export const supportsIntersectionObserver = () => + "IntersectionObserver" in window; diff --git a/packages/core/src/lib/components/image/lib/get-cursor-style.ts b/packages/core/src/lib/components/image/lib/cursor-style.ts similarity index 100% rename from packages/core/src/lib/components/image/lib/get-cursor-style.ts rename to packages/core/src/lib/components/image/lib/cursor-style.ts diff --git a/packages/core/src/lib/components/image/lib/download-image-file.ts b/packages/core/src/lib/components/image/lib/download-image.ts similarity index 100% rename from packages/core/src/lib/components/image/lib/download-image-file.ts rename to packages/core/src/lib/components/image/lib/download-image.ts diff --git a/packages/core/src/lib/components/image/lib/extract-image-url.ts b/packages/core/src/lib/components/image/lib/extract-image-url.ts deleted file mode 100644 index 102ffd0..0000000 --- a/packages/core/src/lib/components/image/lib/extract-image-url.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ContextedBlock } from "../../../types"; - -export const extractImageUrl = (block: ContextedBlock): string | null => { - if (block.type !== "image") return null; - - const { image } = block; - - switch (image.type) { - case "file": - return image.file.url || null; - case "external": - return image.external.url || null; - default: - return null; - } -}; diff --git a/packages/core/src/lib/components/image/lib/find-image-caption.ts b/packages/core/src/lib/components/image/lib/find-image-caption.ts deleted file mode 100644 index be861f8..0000000 --- a/packages/core/src/lib/components/image/lib/find-image-caption.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type ImageArgs, type Block } from "../../../types"; - -export const findImageCaption = ( - blocks: Block[], - imageUrl: string, -): string | null => { - for (const block of blocks) { - if (block.type === "image") { - const imageBlock = block as ImageArgs; - const url = - imageBlock.image.type === "file" - ? imageBlock.image.file.url - : imageBlock.image.external?.url; - - if (url === imageUrl) { - return imageBlock.image.caption?.[0]?.text?.content || null; - } - } - - if (block.blocks) { - const caption = findImageCaption(block.blocks, imageUrl); - if (caption) return caption; - } - } - - return null; -}; diff --git a/packages/core/src/lib/components/image/lib/get-gap.ts b/packages/core/src/lib/components/image/lib/gap.ts similarity index 100% rename from packages/core/src/lib/components/image/lib/get-gap.ts rename to packages/core/src/lib/components/image/lib/gap.ts diff --git a/packages/core/src/lib/components/image/lib/get-visible-images.ts b/packages/core/src/lib/components/image/lib/get-visible-images.ts deleted file mode 100644 index b8f45f8..0000000 --- a/packages/core/src/lib/components/image/lib/get-visible-images.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const getVisibleImages = (): string[] => { - const visibleImages = document.querySelectorAll( - ".notion-image img:not(.notion-toggle:not(.notion-toggle-open) .notion-image img)", - ); - - return Array.from(visibleImages).map( - (image) => (image as HTMLImageElement).src, - ); -}; diff --git a/packages/core/src/lib/components/image/lib/image-search.ts b/packages/core/src/lib/components/image/lib/image-search.ts new file mode 100644 index 0000000..da11800 --- /dev/null +++ b/packages/core/src/lib/components/image/lib/image-search.ts @@ -0,0 +1,52 @@ +import { normalizeUrl } from "./normalizers"; +import type { Block, ImageArgs } from "../../../types"; + +export const getVisibleImages = (): string[] => { + const visibleImages = document.querySelectorAll( + ".notion-image img:not(.notion-toggle:not(.notion-toggle-open) .notion-image img)", + ); + + return Array.from(visibleImages).map((image) => + normalizeUrl((image as HTMLImageElement).src), + ); +}; + +export const getClickedImageIndex = (clickedUrl: string) => { + const visibleImages = getVisibleImages(); + const normalizedClickedUrl = normalizeUrl(clickedUrl); + + return visibleImages.findIndex((imgSrc) => { + const normalizedImgSrc = normalizeUrl(imgSrc); + return normalizedImgSrc === normalizedClickedUrl; + }); +}; + +export const findImageCaption = ( + blocks: Block[], + imageUrl: string, +): string | null => { + for (const block of blocks) { + if (block.type === "image") { + const imageUrlFromBlock = + block.image.type === "file" + ? block.image.file.url + : block.image.external?.url; + + if (imageUrlFromBlock === imageUrl) { + return block.image.caption?.[0]?.plain_text || null; + } + } + } + return null; +}; + +export const extractImageUrl = (props: ImageArgs): string | null => { + switch (props.image.type) { + case "file": + return props.image.file.url; + case "external": + return props.image.external.url; + default: + return null; + } +}; diff --git a/packages/core/src/lib/components/image/lib/image-size.ts b/packages/core/src/lib/components/image/lib/image-size.ts new file mode 100644 index 0000000..d08a2ea --- /dev/null +++ b/packages/core/src/lib/components/image/lib/image-size.ts @@ -0,0 +1,74 @@ +import { + BREAKPOINTS, + LANDSCAPE_RATIOS, + MAX_HEIGHT_RATIO, + PORTRAIT_RATIOS, +} from "../constants"; + +export const getViewport = () => { + if (window.visualViewport?.width) { + return { + width: Math.round(window.visualViewport.width), + height: Math.round(window.visualViewport.height), + }; + } + + return { + width: Math.round(document.documentElement.clientWidth ?? 0), + height: Math.round(document.documentElement.clientHeight ?? 0), + }; +}; +const getRatio = (aspectRatio: number, viewportWidth: number): number => { + const isPortrait = aspectRatio < 1.0; + const ratios = isPortrait ? PORTRAIT_RATIOS : LANDSCAPE_RATIOS; + + if (viewportWidth >= BREAKPOINTS.DESKTOP) { + return ratios.DESKTOP; + } + + if (viewportWidth >= BREAKPOINTS.TABLET) { + const t = + (viewportWidth - BREAKPOINTS.TABLET) / + (BREAKPOINTS.DESKTOP - BREAKPOINTS.TABLET); + return ratios.TABLET + t * (ratios.DESKTOP - ratios.TABLET); + } + + const t = + (viewportWidth - BREAKPOINTS.MOBILE) / + (BREAKPOINTS.TABLET - BREAKPOINTS.MOBILE); + return ratios.MOBILE + t * (ratios.TABLET - ratios.MOBILE); +}; + +export const getMaxSize = ( + naturalWidth: number, + naturalHeight: number, + viewportWidth: number, + viewportHeight: number, +) => { + const aspectRatio = naturalWidth / naturalHeight; + + if (viewportWidth <= BREAKPOINTS.MOBILE) { + let maxWidth = viewportWidth; + let maxHeight = maxWidth / aspectRatio; + + const maxAllowedHeight = viewportHeight * MAX_HEIGHT_RATIO; + if (maxHeight > maxAllowedHeight) { + maxHeight = maxAllowedHeight; + maxWidth = maxHeight * aspectRatio; + } + + return { maxWidth, maxHeight }; + } + + const viewportRatio = getRatio(aspectRatio, viewportWidth); + let maxWidth = viewportWidth * viewportRatio; + let maxHeight = maxWidth / aspectRatio; + + const maxAllowedHeight = viewportHeight * MAX_HEIGHT_RATIO; + if (maxHeight > maxAllowedHeight) { + maxHeight = maxAllowedHeight; + maxWidth = maxHeight * aspectRatio; + } + + return { maxWidth, maxHeight }; +}; diff --git a/packages/core/src/lib/components/image/lib/index.ts b/packages/core/src/lib/components/image/lib/index.ts index 04a355b..a88db2d 100644 --- a/packages/core/src/lib/components/image/lib/index.ts +++ b/packages/core/src/lib/components/image/lib/index.ts @@ -1,6 +1,8 @@ -export * from "./download-image-file"; -export * from "./get-gap"; -export * from "./extract-image-url"; -export * from "./get-cursor-style"; -export * from "./normalize-display-scale"; -export * from "./find-image-caption"; +export * from "./browser-detection"; +export * from "./cursor-style"; +export * from "./download-image"; +export * from "./gap"; +export * from "./image-search"; +export * from "./image-size"; +export * from "./normalizers"; +export * from "./resize-listener"; diff --git a/packages/core/src/lib/components/image/lib/normalize-display-scale.ts b/packages/core/src/lib/components/image/lib/normalize-display-scale.ts deleted file mode 100644 index 71d1658..0000000 --- a/packages/core/src/lib/components/image/lib/normalize-display-scale.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const normalizeDisplayScale = (scale: number) => { - const roundedScale = Math.round(scale / 50) * 50; - return Math.min(Math.max(roundedScale, 50), 200); -}; diff --git a/packages/core/src/lib/components/image/lib/normalizers.ts b/packages/core/src/lib/components/image/lib/normalizers.ts new file mode 100644 index 0000000..d411135 --- /dev/null +++ b/packages/core/src/lib/components/image/lib/normalizers.ts @@ -0,0 +1,22 @@ +export const normalizeDisplayScale = (scale: number) => { + const roundedScale = Math.round(scale / 50) * 50; + return Math.min(Math.max(roundedScale, 50), 200); +}; + +export const normalizeUrl = (url: string) => { + if (url.startsWith("http://") || url.startsWith("https://")) { + try { + const urlObj = new URL(url); + + if (urlObj.origin === window.location.origin) { + return urlObj.pathname; + } else { + return url; + } + } catch { + return url; + } + } + + return url; +}; diff --git a/packages/core/src/lib/components/image/lib/resize-listener.ts b/packages/core/src/lib/components/image/lib/resize-listener.ts new file mode 100644 index 0000000..208fd13 --- /dev/null +++ b/packages/core/src/lib/components/image/lib/resize-listener.ts @@ -0,0 +1,23 @@ +export const addResizeListener = ( + callback: () => void, +): VisualViewport | null => { + const visualViewport = window.visualViewport; + if (visualViewport) { + visualViewport.addEventListener("resize", callback); + return visualViewport; + } else { + window.addEventListener("resize", callback); + return null; + } +}; + +export const removeResizeListener = ( + callback: () => void, + visualViewport: VisualViewport | null, +): void => { + if (visualViewport) { + visualViewport.removeEventListener("resize", callback); + } else { + window.removeEventListener("resize", callback); + } +}; diff --git a/packages/core/src/lib/components/image/tools-tooltip.tsx b/packages/core/src/lib/components/image/tools-tooltip.tsx index e78e195..23a2d3d 100644 --- a/packages/core/src/lib/components/image/tools-tooltip.tsx +++ b/packages/core/src/lib/components/image/tools-tooltip.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; -const variants = { +const MOTION_VARIANTS = { hidden: { opacity: 0, y: -5 }, visible: { opacity: 1, y: 0 }, }; @@ -57,7 +57,7 @@ const ToolsTooltip: React.FC = ({ initial="hidden" animate="visible" exit="hidden" - variants={variants} + variants={MOTION_VARIANTS} >

{content}

{hint &&

{hint}

} diff --git a/packages/core/src/lib/components/image/viewer-image.tsx b/packages/core/src/lib/components/image/viewer-image.tsx index 7b827fb..1195205 100644 --- a/packages/core/src/lib/components/image/viewer-image.tsx +++ b/packages/core/src/lib/components/image/viewer-image.tsx @@ -9,7 +9,7 @@ import React, { import { motion } from "framer-motion"; -import { useNavigation, useImages, NAVIGATION } from "./hooks"; +import { useNavigation, useImages } from "./hooks"; import { getCursorStyle } from "./lib"; import { MOTION_STYLES } from "./constants"; @@ -118,6 +118,8 @@ const ViewerImage: React.FC = ({ setLastMousePosition, ], ); + const isViewerTools = isCursor || isFocus; + const isViewerNavigation = imageUrls.length > 1; return (
@@ -137,12 +139,12 @@ const ViewerImage: React.FC = ({ aria-label={`Image ${activeIndex + 1}/${imageUrls.length} at ${scaleState.displayScale}%`} {...MOTION_STYLES} /> - {(isCursor || isFocus) && ( + {isViewerTools && ( - {imageUrls.length > NAVIGATION.MIN_INDEX && ( + {isViewerNavigation && ( void; From 4346ff09463e6642af06206e41ca3fa927231fbf Mon Sep 17 00:00:00 2001 From: myjeong19 Date: Tue, 26 Aug 2025 09:29:43 +0900 Subject: [PATCH 13/17] fix: Safari image size calculation bug --- .../src/lib/components/image/hooks/index.ts | 4 +- .../lib/components/image/hooks/use-images.ts | 8 +- .../components/image/hooks/use-navigation.ts | 46 +++--- .../image/hooks/use-zoom-controls.ts | 59 +++++--- .../src/lib/components/image/icons/minus.tsx | 1 - .../src/lib/components/image/icons/plus.tsx | 1 - .../src/lib/components/image/image-viewer.tsx | 7 +- .../core/src/lib/components/image/image.tsx | 9 +- .../src/lib/components/image/tools-close.tsx | 11 +- .../lib/components/image/tools-download.tsx | 11 +- .../lib/components/image/tools-navigation.tsx | 8 +- .../src/lib/components/image/tools-scaler.tsx | 34 +++-- .../lib/components/image/tools-tooltip.tsx | 6 + .../src/lib/components/image/viewer-image.tsx | 143 +++++++----------- .../lib/components/image/viewer-overlay.tsx | 5 +- .../src/lib/components/image/viewer-tools.tsx | 1 + packages/core/src/lib/index.css | 35 +++-- 17 files changed, 207 insertions(+), 182 deletions(-) diff --git a/packages/core/src/lib/components/image/hooks/index.ts b/packages/core/src/lib/components/image/hooks/index.ts index b387a8c..becead3 100644 --- a/packages/core/src/lib/components/image/hooks/index.ts +++ b/packages/core/src/lib/components/image/hooks/index.ts @@ -3,4 +3,6 @@ export * from "./use-navigation"; export * from "./use-prevent-scroll"; export * from "./use-modal"; export * from "./use-images"; -export * from "./use-navigation"; +export * from "./use-image-size"; +export * from "./use-keydown"; +export * from "./use-zoom-controls"; diff --git a/packages/core/src/lib/components/image/hooks/use-images.ts b/packages/core/src/lib/components/image/hooks/use-images.ts index 9403331..0b037bf 100644 --- a/packages/core/src/lib/components/image/hooks/use-images.ts +++ b/packages/core/src/lib/components/image/hooks/use-images.ts @@ -1,14 +1,14 @@ "use client"; import { useState, useEffect } from "react"; -import { getVisibleImages } from "../lib/get-visible-images"; +import { getVisibleImages } from "../lib"; export const useImages = () => { - const [imageUrls, setImageUrls] = useState([]); + const [visibleImages, setVisibleImages] = useState([]); useEffect(() => { - setImageUrls(getVisibleImages()); + setVisibleImages(getVisibleImages()); }, []); - return imageUrls; + return visibleImages; }; diff --git a/packages/core/src/lib/components/image/hooks/use-navigation.ts b/packages/core/src/lib/components/image/hooks/use-navigation.ts index b6845e1..da00683 100644 --- a/packages/core/src/lib/components/image/hooks/use-navigation.ts +++ b/packages/core/src/lib/components/image/hooks/use-navigation.ts @@ -1,44 +1,44 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; +import { OriginAction, ScaleAction } from "../reducer"; +import { getClickedImageIndex } from "../lib"; export const NAVIGATION = { - START_INDEX: 0, MIN_INDEX: 0, - + MAX_INDEX_OFFSET: 1, NEXT_STEP: 1, PREV_STEP: -1, - - FIRST_INDEX: 0, - LAST_INDEX_OFFSET: 1, } as const; -export const useNavigation = (url: string, imageUrls: string[]) => { +export const useNavigation = ( + url: string, + visibleImages: string[], + scaleDispatch: React.Dispatch, + originDispatch: React.Dispatch, +) => { const [activeIndex, setActiveIndex] = useState(() => { - const index = imageUrls.findIndex((imgUrl) => imgUrl === url); - return Math.max(NAVIGATION.MIN_INDEX, index); + const clickedIndex = getClickedImageIndex(url); + return Math.max(NAVIGATION.MIN_INDEX, clickedIndex); }); const toNextImage = useCallback(() => { - setActiveIndex((prev) => + setActiveIndex((prevActiveIndex) => Math.min( - prev + NAVIGATION.NEXT_STEP, - imageUrls.length - NAVIGATION.LAST_INDEX_OFFSET, + prevActiveIndex + NAVIGATION.NEXT_STEP, + visibleImages.length - NAVIGATION.MAX_INDEX_OFFSET, ), ); - }, [imageUrls.length]); + scaleDispatch({ type: "reset" }); + originDispatch({ type: "reset" }); + }, [visibleImages.length, scaleDispatch, originDispatch]); const toPreviousImage = useCallback(() => { - setActiveIndex((prev) => - Math.max(prev + NAVIGATION.PREV_STEP, NAVIGATION.MIN_INDEX), + setActiveIndex((prevActiveIndex) => + Math.max(prevActiveIndex + NAVIGATION.PREV_STEP, NAVIGATION.MIN_INDEX), ); - }, []); - - useEffect(() => { - if (imageUrls.length > NAVIGATION.MIN_INDEX) { - const index = imageUrls.findIndex((findUrl) => findUrl === url); - setActiveIndex(Math.max(NAVIGATION.MIN_INDEX, index)); - } - }, [imageUrls, url]); + scaleDispatch({ type: "reset" }); + originDispatch({ type: "reset" }); + }, [scaleDispatch, originDispatch]); return { activeIndex, diff --git a/packages/core/src/lib/components/image/hooks/use-zoom-controls.ts b/packages/core/src/lib/components/image/hooks/use-zoom-controls.ts index 34f3854..7be718e 100644 --- a/packages/core/src/lib/components/image/hooks/use-zoom-controls.ts +++ b/packages/core/src/lib/components/image/hooks/use-zoom-controls.ts @@ -2,18 +2,14 @@ import { useCallback } from "react"; import { - initialOrigin, initialScale, type OriginAction, type ScaleAction, + DISPLAY as DISPLAY_STYLE, } from "../reducer"; -const MAX_ZOOM_IN_THRESHOLD = 150; -const ZOOM_OUT_THRESHOLD = 100; - interface UseZoomControlsProps { scaleState: typeof initialScale; - lastMousePosition: typeof initialOrigin; originDispatch: React.Dispatch; scaleDispatch: React.Dispatch; } @@ -21,41 +17,62 @@ interface UseZoomControlsProps { export interface UseZoomControls { handleZoomIn: () => void; handleZoomOut: () => void; + handleZoomInOut: (event: React.MouseEvent) => void; } export const useZoomControls = ({ scaleState, originDispatch, - lastMousePosition, scaleDispatch, -}: UseZoomControlsProps): UseZoomControls => { +}: UseZoomControlsProps) => { const handleZoomIn = useCallback(() => { - if (scaleState.displayScale > MAX_ZOOM_IN_THRESHOLD) { + if (scaleState.displayScale <= DISPLAY_STYLE.INITIAL) { originDispatch({ type: "reset" }); } - scaleDispatch({ type: "zoomIn" }); }, [scaleState.displayScale, originDispatch, scaleDispatch]); const handleZoomOut = useCallback(() => { - if (scaleState.displayScale > ZOOM_OUT_THRESHOLD) { - originDispatch({ - type: "zoomInOut", - payload: lastMousePosition, - }); - } else { + if (scaleState.displayScale <= DISPLAY_STYLE.INITIAL) { originDispatch({ type: "reset" }); } + scaleDispatch({ type: "zoomOut" }); - }, [ - scaleState.displayScale, - originDispatch, - lastMousePosition, - scaleDispatch, - ]); + }, [scaleState.displayScale, originDispatch, scaleDispatch]); + + const handleZoomInOut = useCallback( + (event: React.MouseEvent) => { + const { width, height, top, left } = + event.currentTarget.getBoundingClientRect(); + const currentMouseX = (event.clientX - left) / width; + const currentMouseY = (event.clientY - top) / height; + + const isZoomIn = scaleState.displayScale <= DISPLAY_STYLE.INITIAL; + const isZoomMin = scaleState.displayScale > DISPLAY_STYLE.MIN; + const isZoomMax = scaleState.displayScale >= DISPLAY_STYLE.MAX; + + if (isZoomIn) { + if (isZoomMin) { + originDispatch({ + type: "zoomInOut", + payload: { originX: currentMouseX, originY: currentMouseY }, + }); + } + scaleDispatch({ type: "zoomIn" }); + } else { + if (isZoomMax) { + scaleDispatch({ type: "reset" }); + } else { + scaleDispatch({ type: "zoomOut" }); + } + } + }, + [originDispatch, scaleDispatch, scaleState.displayScale], + ); return { handleZoomIn, handleZoomOut, + handleZoomInOut, }; }; diff --git a/packages/core/src/lib/components/image/icons/minus.tsx b/packages/core/src/lib/components/image/icons/minus.tsx index 8f9fa9f..efc32e6 100644 --- a/packages/core/src/lib/components/image/icons/minus.tsx +++ b/packages/core/src/lib/components/image/icons/minus.tsx @@ -4,7 +4,6 @@ export const Minus = () => { width="40" height="40" viewBox="0 0 40 40" - fill="none" xmlns="http://www.w3.org/2000/svg" > diff --git a/packages/core/src/lib/components/image/icons/plus.tsx b/packages/core/src/lib/components/image/icons/plus.tsx index 1e250b7..9cc420c 100644 --- a/packages/core/src/lib/components/image/icons/plus.tsx +++ b/packages/core/src/lib/components/image/icons/plus.tsx @@ -4,7 +4,6 @@ export const Plus = () => { width="40" height="40" viewBox="0 0 40 40" - fill="none" xmlns="http://www.w3.org/2000/svg" > diff --git a/packages/core/src/lib/components/image/image-viewer.tsx b/packages/core/src/lib/components/image/image-viewer.tsx index 9f23394..fb03ed3 100644 --- a/packages/core/src/lib/components/image/image-viewer.tsx +++ b/packages/core/src/lib/components/image/image-viewer.tsx @@ -1,12 +1,13 @@ "use client"; import React from "react"; -import { AnimatePresence } from "framer-motion"; +import { motion, AnimatePresence } from "framer-motion"; import ViewerOverlay from "./viewer-overlay"; import ViewerImage from "./viewer-image"; import { useCursorVisibility, usePreventScroll } from "./hooks"; +import { MOTION_STYLES } from "./constants"; type ImageViewerProps = { url: string; @@ -22,7 +23,7 @@ const ImageViewer: React.FC = ({ url, caption, close }) => { return ( -
+ = ({ url, caption, close }) => { caption={caption} close={close} /> -
+
); }; diff --git a/packages/core/src/lib/components/image/image.tsx b/packages/core/src/lib/components/image/image.tsx index 8c1fdb3..4858115 100644 --- a/packages/core/src/lib/components/image/image.tsx +++ b/packages/core/src/lib/components/image/image.tsx @@ -3,14 +3,15 @@ import React from "react"; import type { ImageArgs } from "../../types"; import RichText from "../internal/rich-text"; import ImageViewer from "./image-viewer"; -import { extractImageUrl } from "./lib"; import { useModal } from "./hooks"; -import { findImageCaption } from "./lib/find-image-caption"; +import { extractImageUrl, findImageCaption } from "./lib"; type ImageProps = { children: React.ReactNode; } & ImageArgs; +const DEFAULT_CAPTION = "posting image"; + const Image: React.FC = ({ children, ...props }) => { const { image: { caption, type }, @@ -33,7 +34,7 @@ const Image: React.FC = ({ children, ...props }) => { )} @@ -42,7 +43,7 @@ const Image: React.FC = ({ children, ...props }) => { {url ? ( {caption[0]?.text?.content diff --git a/packages/core/src/lib/components/image/tools-close.tsx b/packages/core/src/lib/components/image/tools-close.tsx index b97594a..a6ca7d6 100644 --- a/packages/core/src/lib/components/image/tools-close.tsx +++ b/packages/core/src/lib/components/image/tools-close.tsx @@ -1,9 +1,13 @@ "use client"; import React from "react"; -import { TOOLS_ACTIONS, TOOLS_ARIA_LABELS } from "./constants/viewer-tools"; import { Icons } from "./icons"; import ToolsTooltip from "./tools-tooltip"; +import { + TOOLS_ACTIONS, + TOOLS_ARIA_LABELS, + TOOLS_ARIA_CONTROLS, +} from "./constants"; export interface ToolsCloseProps { close: () => void; @@ -15,7 +19,10 @@ const ToolsClose: React.FC = ({ close }) => { className="notion-tools-close" content={TOOLS_ACTIONS.CLOSE} hint="esc" - aria={{ label: TOOLS_ARIA_LABELS.CLOSE }} + aria={{ + label: TOOLS_ARIA_LABELS.CLOSE, + controls: TOOLS_ARIA_CONTROLS.IMAGE_VIEWER, + }} onClick={close} icon={} /> diff --git a/packages/core/src/lib/components/image/tools-download.tsx b/packages/core/src/lib/components/image/tools-download.tsx index de826ae..1726c09 100644 --- a/packages/core/src/lib/components/image/tools-download.tsx +++ b/packages/core/src/lib/components/image/tools-download.tsx @@ -3,7 +3,11 @@ import React from "react"; import ToolsTooltip from "./tools-tooltip"; import { Icons } from "./icons"; -import { TOOLS_ACTIONS, TOOLS_ARIA_LABELS } from "./constants/viewer-tools"; +import { + TOOLS_ACTIONS, + TOOLS_ARIA_LABELS, + TOOLS_ARIA_DESCRIBEDBY, +} from "./constants"; import { handleDownload } from "./lib"; export interface ToolsDownloadProps { @@ -15,7 +19,10 @@ const ToolsDownload: React.FC = ({ url }) => { handleDownload(url)} icon={} /> diff --git a/packages/core/src/lib/components/image/tools-navigation.tsx b/packages/core/src/lib/components/image/tools-navigation.tsx index c6d0527..b9e6c7e 100644 --- a/packages/core/src/lib/components/image/tools-navigation.tsx +++ b/packages/core/src/lib/components/image/tools-navigation.tsx @@ -2,7 +2,11 @@ import React from "react"; import ToolsTooltip from "./tools-tooltip"; -import { TOOLS_ACTIONS, TOOLS_ARIA_LABELS } from "./constants/viewer-tools"; +import { + TOOLS_ACTIONS, + TOOLS_ARIA_LABELS, + TOOLS_ARIA_DESCRIBEDBY, +} from "./constants"; import { Icons } from "./icons"; const NEXT_IMAGE_INDEX = 2; @@ -31,6 +35,7 @@ const ToolsNavigation: React.FC = ({ aria={{ label: TOOLS_ARIA_LABELS.BACK, disabled: !hasPrevious, + describedby: TOOLS_ARIA_DESCRIBEDBY.BACK, }} onClick={toPreviousImage} icon={} @@ -43,6 +48,7 @@ const ToolsNavigation: React.FC = ({ aria={{ label: TOOLS_ARIA_LABELS.NEXT, disabled: !hasNext, + describedby: TOOLS_ARIA_DESCRIBEDBY.NEXT, }} onClick={toNextImage} icon={} diff --git a/packages/core/src/lib/components/image/tools-scaler.tsx b/packages/core/src/lib/components/image/tools-scaler.tsx index 9f68472..9448ad1 100644 --- a/packages/core/src/lib/components/image/tools-scaler.tsx +++ b/packages/core/src/lib/components/image/tools-scaler.tsx @@ -1,16 +1,19 @@ "use client"; import React, { useEffect, useRef } from "react"; -import { TOOLS_ACTIONS, TOOLS_ARIA_LABELS } from "./constants/viewer-tools"; -import { Icons } from "./icons"; import { initialScale, OriginAction, type ScaleAction, - initialOrigin, DISPLAY as DISPLAY_STYLE, } from "./reducer"; import type { UseZoomControls } from "./hooks/use-zoom-controls"; import ToolsTooltip from "./tools-tooltip"; +import { Icons } from "./icons"; +import { + TOOLS_ACTIONS, + TOOLS_ARIA_DESCRIBEDBY, + TOOLS_ARIA_LABELS, +} from "./constants"; export interface ToolsScalerProps { scaleState: typeof initialScale; @@ -18,7 +21,6 @@ export interface ToolsScalerProps { originDispatch: React.Dispatch; isFocus: boolean; setIsFocus: React.Dispatch>; - lastMousePosition: typeof initialOrigin; zoomControls: UseZoomControls; } @@ -28,7 +30,6 @@ const ToolsScaler: React.FC = ({ originDispatch, isFocus, setIsFocus, - lastMousePosition, zoomControls, }) => { const scaleInputRef = useRef(null); @@ -40,13 +41,7 @@ const ToolsScaler: React.FC = ({ const handleInputFocus = () => { scaleInputRef.current?.focus(); - scaleDispatch({ type: "reset" }); - originDispatch({ - type: "zoomInOut", - payload: lastMousePosition, - }); - setIsFocus(true); }; @@ -76,13 +71,20 @@ const ToolsScaler: React.FC = ({ } }, [isFocus]); + const isZoomIn = scaleState.displayScale === DISPLAY_STYLE.MAX; + const isZoomOut = scaleState.displayScale === DISPLAY_STYLE.MIN; + return (
} /> @@ -104,6 +106,8 @@ const ToolsScaler: React.FC = ({ onChange={handleInputChange} onKeyDown={handleInputEnter} autoFocus + aria-label={TOOLS_ARIA_LABELS.SCALER_INPUT} + aria-disabled={isFocus} /> % @@ -118,7 +122,11 @@ const ToolsScaler: React.FC = ({ className="notion-tools-scaler-zoom-in" content={TOOLS_ACTIONS.ZOOM_IN} hint="+" - aria={{ label: TOOLS_ARIA_LABELS.ZOOM_IN }} + aria={{ + label: TOOLS_ARIA_LABELS.ZOOM_IN, + disabled: isZoomIn, + describedby: TOOLS_ARIA_DESCRIBEDBY.ZOOM_IN, + }} onClick={zoomControls.handleZoomIn} icon={} /> diff --git a/packages/core/src/lib/components/image/tools-tooltip.tsx b/packages/core/src/lib/components/image/tools-tooltip.tsx index 23a2d3d..1a61b05 100644 --- a/packages/core/src/lib/components/image/tools-tooltip.tsx +++ b/packages/core/src/lib/components/image/tools-tooltip.tsx @@ -10,6 +10,8 @@ const MOTION_VARIANTS = { interface AriaProps { label: string; disabled?: boolean; + describedby?: string; + controls?: string; } interface ToolsTooltipProps { @@ -43,6 +45,10 @@ const ToolsTooltip: React.FC = ({ + + {TOOLS_ARIA_HINTS.SCALER_INPUT_BUTTON} + + + )} + + ); +}; + +export default ToolsScalerInput; diff --git a/packages/core/src/lib/components/image/tools-scaler.tsx b/packages/core/src/lib/components/image/tools-scaler.tsx index 17a0299..35eb652 100644 --- a/packages/core/src/lib/components/image/tools-scaler.tsx +++ b/packages/core/src/lib/components/image/tools-scaler.tsx @@ -1,5 +1,6 @@ "use client"; -import React, { useEffect, useRef } from "react"; +import React from "react"; + import { type ScaleState, type OriginAction, @@ -12,8 +13,10 @@ import { Icons } from "./icons"; import { TOOLS_ACTIONS, TOOLS_ARIA_DESCRIBEDBY, + TOOLS_ARIA_HINTS, TOOLS_ARIA_LABELS, } from "./constants"; +import ToolsScalerInput from "./tools-scaler-input"; export interface ToolsScalerProps { scaleState: ScaleState; @@ -32,45 +35,6 @@ const ToolsScaler: React.FC = ({ setIsFocus, zoomControls, }) => { - const scaleInputRef = useRef(null); - - const handleInputBlur = () => { - scaleDispatch({ type: "blur" }); - setIsFocus(false); - }; - - const handleInputFocus = () => { - scaleInputRef.current?.focus(); - scaleDispatch({ type: "reset" }); - setIsFocus(true); - }; - - const handleInputChange = (e: React.ChangeEvent) => { - scaleDispatch({ - type: "changeDisplayOnly", - payload: Number(e.target.value), - }); - }; - - const handleInputEnter = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - if (DISPLAY_STYLE.INITIAL > scaleState.displayScale) { - originDispatch({ type: "reset" }); - } - - scaleDispatch({ type: "enter" }); - setIsFocus(false); - scaleInputRef.current?.blur(); - } - }; - - useEffect(() => { - if (isFocus && scaleInputRef.current) { - scaleInputRef.current.focus(); - scaleInputRef.current.select(); - } - }, [isFocus]); - const isZoomIn = scaleState.displayScale === DISPLAY_STYLE.MAX; const isZoomOut = scaleState.displayScale === DISPLAY_STYLE.MIN; @@ -82,40 +46,22 @@ const ToolsScaler: React.FC = ({ hint="-" aria={{ label: TOOLS_ARIA_LABELS.ZOOM_OUT, - disabled: isZoomOut, describedby: TOOLS_ARIA_DESCRIBEDBY.ZOOM_OUT, + hint: TOOLS_ARIA_HINTS.ZOOM_OUT, }} + disabled={isZoomOut} onClick={zoomControls.handleZoomOut} icon={} /> -
- {isFocus ? ( - <> - - % - - ) : ( - - )} +
+
= ({ hint="+" aria={{ label: TOOLS_ARIA_LABELS.ZOOM_IN, - disabled: isZoomIn, describedby: TOOLS_ARIA_DESCRIBEDBY.ZOOM_IN, + hint: TOOLS_ARIA_HINTS.ZOOM_IN, }} + disabled={isZoomIn} onClick={zoomControls.handleZoomIn} icon={} /> diff --git a/packages/core/src/lib/components/image/tools-tooltip.tsx b/packages/core/src/lib/components/image/tools-tooltip.tsx index 1a61b05..1b81a96 100644 --- a/packages/core/src/lib/components/image/tools-tooltip.tsx +++ b/packages/core/src/lib/components/image/tools-tooltip.tsx @@ -7,11 +7,10 @@ const MOTION_VARIANTS = { visible: { opacity: 1, y: 0 }, }; -interface AriaProps { +interface Aria { label: string; - disabled?: boolean; - describedby?: string; - controls?: string; + describedby: string; + hint: string; } interface ToolsTooltipProps { @@ -20,16 +19,18 @@ interface ToolsTooltipProps { icon: React.ReactNode; hint?: string; onClick: () => void; - aria: AriaProps; + disabled?: boolean; + aria: Aria; } const ToolsTooltip: React.FC = ({ className, + onClick, content, hint, - aria, - onClick, icon, + disabled, + aria, }) => { const [isVisible, setIsVisible] = useState(false); @@ -43,19 +44,19 @@ const ToolsTooltip: React.FC = ({ className="notion-viewer-tooltip-container" > - {!aria.disabled && ( + + {aria?.hint} + + {!disabled && ( {isVisible && ( = ({ const imageRef = useRef(null); const [isFocus, setIsFocus] = useState(false); + const [announce, setAnnounce] = useState(""); const visibleImages = useImages(); @@ -53,12 +54,18 @@ const ViewerImage: React.FC = ({ initialOrigin, ); - const { activeIndex, toNextImage, toPreviousImage } = useNavigation( + const handleAnnounce = (message: string) => { + setAnnounce(message); + setTimeout(() => setAnnounce(""), 0); + }; + + const { activeIndex, toNextImage, toPreviousImage } = useNavigation({ url, visibleImages, scaleDispatch, originDispatch, - ); + onAnnounce: handleAnnounce, + }); const { maxWidth, maxHeight } = useImageSize(imageRef, activeIndex); @@ -66,6 +73,7 @@ const ViewerImage: React.FC = ({ scaleState, originDispatch, scaleDispatch, + onAnnounce: handleAnnounce, }); useKeydown({ @@ -92,7 +100,6 @@ const ViewerImage: React.FC = ({ > = ({ {...MOTION_STYLES} /> +
+ {announce} +
{isTools && ( = ({ + - )} diff --git a/packages/core/src/lib/index.css b/packages/core/src/lib/index.css index a2c7475..5875a1c 100644 --- a/packages/core/src/lib/index.css +++ b/packages/core/src/lib/index.css @@ -1337,3 +1337,15 @@ margin-right: 6px; font-family: monaco, "Andale Mono", "Ubuntu Mono", monospace; } + +.notion-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} From c80c5f0e6dc83f84ae946f00c727340a1975d40d Mon Sep 17 00:00:00 2001 From: myjeong19 Date: Fri, 29 Aug 2025 18:54:13 +0900 Subject: [PATCH 17/17] Feat. Add lazy loading for image performance --- packages/core/src/lib/components/image/image.tsx | 6 +++++- .../core/src/lib/components/image/lib/image-loading.ts | 8 ++++++++ packages/core/src/lib/components/image/lib/index.ts | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/lib/components/image/lib/image-loading.ts diff --git a/packages/core/src/lib/components/image/image.tsx b/packages/core/src/lib/components/image/image.tsx index 4858115..15b8405 100644 --- a/packages/core/src/lib/components/image/image.tsx +++ b/packages/core/src/lib/components/image/image.tsx @@ -4,7 +4,7 @@ import type { ImageArgs } from "../../types"; import RichText from "../internal/rich-text"; import ImageViewer from "./image-viewer"; import { useModal } from "./hooks"; -import { extractImageUrl, findImageCaption } from "./lib"; +import { extractImageUrl, findImageCaption, setImageLoading } from "./lib"; type ImageProps = { children: React.ReactNode; @@ -44,8 +44,12 @@ const Image: React.FC = ({ children, ...props }) => { {caption[0]?.text?.content ) : (

unsupported type: {type}

diff --git a/packages/core/src/lib/components/image/lib/image-loading.ts b/packages/core/src/lib/components/image/lib/image-loading.ts new file mode 100644 index 0000000..da4bfe3 --- /dev/null +++ b/packages/core/src/lib/components/image/lib/image-loading.ts @@ -0,0 +1,8 @@ +export const setImageLoading = (imageElement: HTMLImageElement | null) => { + if (!imageElement) return; + + const rect = imageElement.getBoundingClientRect(); + const isInViewport = rect.top < window.innerHeight && rect.bottom > 0; + + imageElement.loading = isInViewport ? "eager" : "lazy"; +}; diff --git a/packages/core/src/lib/components/image/lib/index.ts b/packages/core/src/lib/components/image/lib/index.ts index 2a93fd0..0409da1 100644 --- a/packages/core/src/lib/components/image/lib/index.ts +++ b/packages/core/src/lib/components/image/lib/index.ts @@ -2,6 +2,7 @@ export * from "./browser-detection"; export * from "./cursor-style"; export * from "./download-image"; export * from "./gap"; +export * from "./image-loading"; export * from "./image-search"; export * from "./image-size"; export * from "./normalizers";