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/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/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 new file mode 100644 index 0000000..6fa453b --- /dev/null +++ b/packages/core/src/lib/components/image/constants/index.ts @@ -0,0 +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 new file mode 100644 index 0000000..e1d2b16 --- /dev/null +++ b/packages/core/src/lib/components/image/constants/motion.ts @@ -0,0 +1,5 @@ +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..4d60ca9 --- /dev/null +++ b/packages/core/src/lib/components/image/constants/viewer-tools.ts @@ -0,0 +1,41 @@ +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: "Previous image", + NEXT: "Next image", + ZOOM_OUT: "Zoom out", + ZOOM_IN: "Zoom in", + DOWNLOAD: "Download image", + CLOSE: "Close viewer", + SCALER_INPUT: "Scale percentage", + SCALER_INPUT_BUTTON: "Edit scale", +} as const; + +export const TOOLS_ARIA_DESCRIBEDBY = { + BACK: "previous-image-desc", + NEXT: "next-image-desc", + ZOOM_OUT: "zoom-out-desc", + ZOOM_IN: "zoom-in-desc", + DOWNLOAD: "download-image-desc", + CLOSE: "close-viewer-desc", + SCALER_INPUT: "scale-percentage-desc", + SCALER_INPUT_BUTTON: "edit-scale-desc", +} as const; + +export const TOOLS_ARIA_HINTS = { + BACK: "Go to previous image (Left Arrow)", + NEXT: "Go to next image (Right Arrow)", + ZOOM_OUT: "Zoom out (Minus -)", + ZOOM_IN: "Zoom in (Plus +)", + DOWNLOAD: "Download the current image", + CLOSE: "Close the viewer (Esc)", + SCALER_INPUT_BUTTON: "Opens scale input. Click to edit", + SCALER_INPUT: "Enter a scale percentage and press Enter to apply", +} as const; 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 deleted file mode 100644 index d9a495b..0000000 --- a/packages/core/src/lib/components/image/hooks/image-viewer/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./use-cursor-visibility"; -export * from "./use-image-navigation"; -export * from "./use-image-scale"; -export * from "./use-prevent-scroll"; diff --git a/packages/core/src/lib/components/image/hooks/image-viewer/use-cursor-visibility.tsx b/packages/core/src/lib/components/image/hooks/image-viewer/use-cursor-visibility.tsx deleted file mode 100644 index 2f21a01..0000000 --- a/packages/core/src/lib/components/image/hooks/image-viewer/use-cursor-visibility.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useState, useRef, useCallback } from "react"; - -export const useCursorVisibility = () => { - const [isCursorVisible, setIsCursorVisible] = useState(true); - const cursorTimeOutRef = useRef(); - - const handleMoveMouse = useCallback(() => { - setIsCursorVisible(true); - - clearTimeout(cursorTimeOutRef.current); - - if (cursorTimeOutRef.current) { - clearTimeout(cursorTimeOutRef.current); - } - - cursorTimeOutRef.current = setTimeout(() => { - setIsCursorVisible(false); - }, 2000); - }, []); - - return { - isCursorVisible, - handleMoveMouse, - }; -}; 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-image-scale.tsx b/packages/core/src/lib/components/image/hooks/image-viewer/use-image-scale.tsx deleted file mode 100644 index 2113547..0000000 --- a/packages/core/src/lib/components/image/hooks/image-viewer/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/image-viewer/use-prevent-scroll.tsx b/packages/core/src/lib/components/image/hooks/image-viewer/use-prevent-scroll.tsx deleted file mode 100644 index 570e3f6..0000000 --- a/packages/core/src/lib/components/image/hooks/image-viewer/use-prevent-scroll.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect } from "react"; - -import { getGapStyles, getGapWidth } from "../../lib"; - -export const usePreventScroll = (isOpened: boolean) => { - useEffect(() => { - const styleElement = document.createElement("style"); - - if (isOpened) { - document.body.setAttribute("data-scroll-locked", "true"); - const gap = getGapWidth(); - - const scrollLockedStyles = getGapStyles(gap); - styleElement.textContent = scrollLockedStyles; - document.head.appendChild(styleElement); - } - - return () => { - document.body.removeAttribute("data-scroll-locked"); - if (styleElement.parentNode) { - styleElement.parentNode.removeChild(styleElement); - } - }; - }, [isOpened]); -}; diff --git a/packages/core/src/lib/components/image/hooks/index.ts b/packages/core/src/lib/components/image/hooks/index.ts new file mode 100644 index 0000000..becead3 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/index.ts @@ -0,0 +1,8 @@ +export * from "./use-cursor-visibility"; +export * from "./use-navigation"; +export * from "./use-prevent-scroll"; +export * from "./use-modal"; +export * from "./use-images"; +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-cursor-visibility.ts b/packages/core/src/lib/components/image/hooks/use-cursor-visibility.ts new file mode 100644 index 0000000..9c0d531 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/use-cursor-visibility.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; + +interface UseCursorVisibilityReturn { + isCursor: boolean; + handleMouseLeave: () => void; + handleMouseEnter: () => void; +} + +export const useCursorVisibility = (): UseCursorVisibilityReturn => { + 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-size.ts b/packages/core/src/lib/components/image/hooks/use-image-size.ts new file mode 100644 index 0000000..ce0a326 --- /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 ImageMaxSizeReturn { + maxWidth: number; + maxHeight: number; +} + +export const useImageSize = ( + imageRef: RefObject, + activeIndex: number, +): ImageMaxSizeReturn => { + 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/hooks/use-images.ts b/packages/core/src/lib/components/image/hooks/use-images.ts new file mode 100644 index 0000000..1cddc76 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/use-images.ts @@ -0,0 +1,14 @@ +"use client"; +import { useState, useEffect } from "react"; + +import { getVisibleImages } from "../lib"; + +export const useImages = (): string[] => { + const [visibleImages, setVisibleImages] = useState([]); + + useEffect(() => { + setVisibleImages(getVisibleImages()); + }, []); + + return visibleImages; +}; 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..e5e57af --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/use-keydown.ts @@ -0,0 +1,38 @@ +"use client"; +import { useEffect } from "react"; +import type { UseZoomControlsReturn } from "./use-zoom-controls"; + +interface UseKeydownProps { + close: () => void; + zoomControls: UseZoomControlsReturn; + toPreviousImage: () => void; + toNextImage: () => void; +} + +export const useKeydown = ({ + close, + zoomControls, + toPreviousImage, + toNextImage, +}: UseKeydownProps): void => { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const keyDownEvents: { [key: string]: () => void } = { + Escape: close, + "+": zoomControls.handleZoomIn, + "=": zoomControls.handleZoomIn, + "-": zoomControls.handleZoomOut, + ArrowLeft: toPreviousImage, + ArrowRight: toNextImage, + }; + const action = keyDownEvents[e.key]; + + if (action) { + action(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [close, zoomControls, 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 new file mode 100644 index 0000000..ff2269b --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/use-modal.ts @@ -0,0 +1,21 @@ +"use client"; +import { useState } from "react"; + +interface UseModalReturn { + isOpen: boolean; + open: () => void; + close: () => void; +} + +export const useModal = (): UseModalReturn => { + 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/use-navigation.ts b/packages/core/src/lib/components/image/hooks/use-navigation.ts new file mode 100644 index 0000000..9c372ab --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/use-navigation.ts @@ -0,0 +1,83 @@ +"use client"; +import { useCallback, useState } from "react"; +import { OriginAction, ScaleAction } from "../reducer"; +import { getClickedImageIndex } from "../lib"; + +export const NAVIGATION = { + MIN_INDEX: 0, + MAX_INDEX_OFFSET: 1, + NEXT_STEP: 1, + PREV_STEP: -1, +} as const; + +interface UseNavigationProps { + url: string; + visibleImages: string[]; + scaleDispatch: React.Dispatch; + originDispatch: React.Dispatch; + onAnnounce: (message: string) => void; +} + +interface UseNavigationReturn { + activeIndex: number; + toNextImage: () => void; + toPreviousImage: () => void; +} + +export const useNavigation = ({ + url, + visibleImages, + scaleDispatch, + originDispatch, + onAnnounce, +}: UseNavigationProps): UseNavigationReturn => { + const [activeIndex, setActiveIndex] = useState(() => { + const clickedIndex = getClickedImageIndex(url); + return Math.max(NAVIGATION.MIN_INDEX, clickedIndex); + }); + + const toNextImage = useCallback(() => { + onAnnounce( + `Image ${Math.min(activeIndex + 2, visibleImages.length)} of ${ + visibleImages.length + }`, + ); + setActiveIndex((prevActiveIndex) => + Math.min( + prevActiveIndex + NAVIGATION.NEXT_STEP, + visibleImages.length - NAVIGATION.MAX_INDEX_OFFSET, + ), + ); + scaleDispatch({ type: "reset" }); + originDispatch({ type: "reset" }); + }, [ + visibleImages.length, + activeIndex, + scaleDispatch, + originDispatch, + onAnnounce, + ]); + + const toPreviousImage = useCallback(() => { + onAnnounce( + `Image ${Math.max(activeIndex, 0) + 1} of ${visibleImages.length}`, + ); + setActiveIndex((prevActiveIndex) => + Math.max(prevActiveIndex + NAVIGATION.PREV_STEP, NAVIGATION.MIN_INDEX), + ); + scaleDispatch({ type: "reset" }); + originDispatch({ type: "reset" }); + }, [ + visibleImages.length, + activeIndex, + scaleDispatch, + originDispatch, + onAnnounce, + ]); + + return { + activeIndex, + toNextImage, + toPreviousImage, + }; +}; diff --git a/packages/core/src/lib/components/image/hooks/use-prevent-scroll.ts b/packages/core/src/lib/components/image/hooks/use-prevent-scroll.ts new file mode 100644 index 0000000..92b5663 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/use-prevent-scroll.ts @@ -0,0 +1,24 @@ +"use client"; +import { useEffect } from "react"; + +import { getGapStyles, getGapWidth } from "../lib"; + +export const usePreventScroll = (): void => { + useEffect(() => { + const styleElement = document.createElement("style"); + + document.body.setAttribute("data-scroll-locked", "true"); + const gap = getGapWidth(); + + const scrollLockedStyles = getGapStyles(gap); + styleElement.textContent = scrollLockedStyles; + document.head.appendChild(styleElement); + + return () => { + document.body.removeAttribute("data-scroll-locked"); + if (styleElement.parentNode) { + styleElement.parentNode.removeChild(styleElement); + } + }; + }, []); +}; 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..389fb68 --- /dev/null +++ b/packages/core/src/lib/components/image/hooks/use-zoom-controls.ts @@ -0,0 +1,105 @@ +"use client"; +import { useCallback } from "react"; + +import { + type ScaleState, + type OriginAction, + type ScaleAction, + DISPLAY as DISPLAY_STYLE, +} from "../reducer"; +import { getZoomAnnouncer } from "../lib"; + +interface UseZoomControlsProps { + scaleState: ScaleState; + originDispatch: React.Dispatch; + scaleDispatch: React.Dispatch; + onAnnounce: (message: string) => void; +} + +export interface UseZoomControlsReturn { + handleZoomIn: () => void; + handleZoomOut: () => void; + handleZoomInOut: (event: React.MouseEvent) => void; +} + +export const useZoomControls = ({ + scaleState, + originDispatch, + scaleDispatch, + onAnnounce, +}: UseZoomControlsProps): UseZoomControlsReturn => { + const handleZoomIn = useCallback(() => { + if (scaleState.displayScale <= DISPLAY_STYLE.INITIAL) { + originDispatch({ type: "reset" }); + } + onAnnounce( + getZoomAnnouncer({ action: "in", displayScale: scaleState.displayScale }), + ); + scaleDispatch({ type: "zoomIn" }); + }, [scaleState.displayScale, originDispatch, scaleDispatch, onAnnounce]); + + const handleZoomOut = useCallback(() => { + if (scaleState.displayScale <= DISPLAY_STYLE.INITIAL) { + originDispatch({ type: "reset" }); + } + + onAnnounce( + getZoomAnnouncer({ + action: "out", + displayScale: scaleState.displayScale, + }), + ); + scaleDispatch({ type: "zoomOut" }); + }, [scaleState.displayScale, originDispatch, scaleDispatch, onAnnounce]); + + 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 }, + }); + } + onAnnounce( + getZoomAnnouncer({ + action: "in", + displayScale: scaleState.displayScale, + }), + ); + scaleDispatch({ type: "zoomIn" }); + return; + } else { + if (isZoomMax) { + scaleDispatch({ type: "reset" }); + onAnnounce(`Zoom ${DISPLAY_STYLE.INITIAL}%`); + } else { + onAnnounce( + getZoomAnnouncer({ + action: "out", + displayScale: scaleState.displayScale, + }), + ); + scaleDispatch({ type: "zoomOut" }); + return; + } + } + }, + [originDispatch, scaleDispatch, scaleState.displayScale, onAnnounce], + ); + + return { + handleZoomIn, + handleZoomOut, + handleZoomInOut, + }; +}; diff --git a/packages/core/src/lib/components/image/icons/arrow_back.tsx b/packages/core/src/lib/components/image/icons/arrow_back.tsx new file mode 100644 index 0000000..f6164ad --- /dev/null +++ b/packages/core/src/lib/components/image/icons/arrow_back.tsx @@ -0,0 +1,12 @@ +export const ArrowBack = () => { + return ( + + ); +}; diff --git a/packages/core/src/lib/components/image/icons/arrow_forward.tsx b/packages/core/src/lib/components/image/icons/arrow_forward.tsx new file mode 100644 index 0000000..8305fe4 --- /dev/null +++ b/packages/core/src/lib/components/image/icons/arrow_forward.tsx @@ -0,0 +1,12 @@ +export const ArrowForward = () => { + return ( + + ); +}; diff --git a/packages/core/src/lib/components/image/icons/close.tsx b/packages/core/src/lib/components/image/icons/close.tsx new file mode 100644 index 0000000..2faf404 --- /dev/null +++ b/packages/core/src/lib/components/image/icons/close.tsx @@ -0,0 +1,29 @@ +export const Close = () => { + return ( + + ); +}; diff --git a/packages/core/src/lib/components/image/icons/download.tsx b/packages/core/src/lib/components/image/icons/download.tsx new file mode 100644 index 0000000..a6d5951 --- /dev/null +++ b/packages/core/src/lib/components/image/icons/download.tsx @@ -0,0 +1,13 @@ +export const Download = () => { + return ( + + ); +}; diff --git a/packages/core/src/lib/components/image/icons/index.ts b/packages/core/src/lib/components/image/icons/index.ts new file mode 100644 index 0000000..4e0ab31 --- /dev/null +++ b/packages/core/src/lib/components/image/icons/index.ts @@ -0,0 +1,15 @@ +import { ArrowBack } from "./arrow_back"; +import { ArrowForward } from "./arrow_forward"; +import { Close } from "./close"; +import { Download } from "./download"; +import { Minus } from "./minus"; +import { Plus } from "./plus"; + +export const Icons = { + ArrowBack, + ArrowForward, + Close, + Download, + Minus, + Plus, +}; diff --git a/packages/core/src/lib/components/image/icons/minus.tsx b/packages/core/src/lib/components/image/icons/minus.tsx new file mode 100644 index 0000000..1cacc11 --- /dev/null +++ b/packages/core/src/lib/components/image/icons/minus.tsx @@ -0,0 +1,14 @@ +export const Minus = () => { + return ( + + ); +}; diff --git a/packages/core/src/lib/components/image/icons/plus.tsx b/packages/core/src/lib/components/image/icons/plus.tsx new file mode 100644 index 0000000..0f81bfe --- /dev/null +++ b/packages/core/src/lib/components/image/icons/plus.tsx @@ -0,0 +1,24 @@ +export const Plus = () => { + return ( + + ); +}; diff --git a/packages/core/src/lib/components/image/image-viewer-tools-tooltip.tsx b/packages/core/src/lib/components/image/image-viewer-tools-tooltip.tsx deleted file mode 100644 index 36cc946..0000000 --- a/packages/core/src/lib/components/image/image-viewer-tools-tooltip.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; - -type ImageViewerToolsToolTipProps = { - content: string; - children: React.ReactNode; - className?: string; - hint: string; - disabled?: boolean; -}; - -const ImageViewerToolsToolTip: React.FC = ({ - 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 1a688f7..0000000 --- a/packages/core/src/lib/components/image/image-viewer-tools.tsx +++ /dev/null @@ -1,151 +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; - setIsOpened: React.Dispatch>; - 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, - setIsOpened, - toPreviousImage, - toNextImage, - displayScale, - onScaleUp, - onScaleDown, - isScaleFocus, - setIsScaleFocus, - onScaleFocus, - onScaleBlur, - onScaleChange, - onScaleEnter, -}) => { - return ( - -
- - - - - - - -
- -
- - - - - {isScaleFocus ? ( -
- - % -
- ) : ( - - )} - - - -
- - - - - - -
- ); -}; - -export default ImageViewerTools; diff --git a/packages/core/src/lib/components/image/image-viewer.tsx b/packages/core/src/lib/components/image/image-viewer.tsx index 18edcf3..6e8ed21 100644 --- a/packages/core/src/lib/components/image/image-viewer.tsx +++ b/packages/core/src/lib/components/image/image-viewer.tsx @@ -1,183 +1,51 @@ -import { useState, useEffect, useCallback } from "react"; -import { AnimatePresence, motion } from "framer-motion"; +"use client"; +import React from "react"; -import { - useCursorVisibility, - useImageNavigation, - useImageScale, - usePreventScroll, -} from "./hooks/image-viewer"; +import { motion, AnimatePresence } from "framer-motion"; -import { getCursorStyle } from "./lib"; +import ViewerOverlay from "./viewer-overlay"; +import ViewerImage from "./viewer-image"; -import ImageViewerTools from "./image-viewer-tools"; +import { useCursorVisibility, usePreventScroll } from "./hooks"; +import { MOTION_STYLES } from "./constants"; type ImageViewerProps = { - children: React.ReactNode; - urls: string[]; url: string; - currentImageIndex: number; - setCurrentImageIndex: React.Dispatch>; + caption: string; + close: () => void; }; -const ImageViewer: React.FC = ({ - url, - urls, - children, - currentImageIndex, - setCurrentImageIndex, -}) => { - const [isOpened, setIsOpened] = useState(false); +const ImageViewer: React.FC = ({ url, caption, close }) => { + const { isCursor, handleMouseLeave, handleMouseEnter } = + useCursorVisibility(); - const { toNextImage, toPreviousImage, hasNext, hasPrevious } = - useImageNavigation(currentImageIndex, setCurrentImageIndex, urls.length); - - 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 (currentImageIndex || isOpened) { - setScale(1); - setDisplayScale(100); - } - }, [isOpened, currentImageIndex, setScale, setDisplayScale]); - - useEffect(() => { - if (!isOpened) { - return; - } - - imageRef.current?.focus(); - - const handleKeyDown = (e: KeyboardEvent) => { - const keyDownEvents: { [key: string]: () => void } = { - Escape: () => setIsOpened(false), - "+": 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, - isOpened, - handleScaleUp, - handleScaleDown, - toNextImage, - toPreviousImage, - ]); - - const handleImageClick = useCallback( - (clickedUrl: string) => { - const index = urls.findIndex((imgUrl) => imgUrl === clickedUrl); - if (index !== -1) { - setCurrentImageIndex(index); - setIsOpened(true); - } - }, - [urls, setCurrentImageIndex, setIsOpened], - ); - - usePreventScroll(isOpened); + usePreventScroll(); return ( - <> - - - - {isOpened && ( - - + + {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 new file mode 100644 index 0000000..35eb652 --- /dev/null +++ b/packages/core/src/lib/components/image/tools-scaler.tsx @@ -0,0 +1,84 @@ +"use client"; +import React from "react"; + +import { + type ScaleState, + type OriginAction, + type ScaleAction, + DISPLAY as DISPLAY_STYLE, +} from "./reducer"; +import type { UseZoomControlsReturn } from "./hooks/use-zoom-controls"; +import ToolsTooltip from "./tools-tooltip"; +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; + scaleDispatch: React.Dispatch; + originDispatch: React.Dispatch; + isFocus: boolean; + setIsFocus: React.Dispatch>; + zoomControls: UseZoomControlsReturn; +} + +const ToolsScaler: React.FC = ({ + scaleState, + scaleDispatch, + originDispatch, + isFocus, + setIsFocus, + zoomControls, +}) => { + const isZoomIn = scaleState.displayScale === DISPLAY_STYLE.MAX; + const isZoomOut = scaleState.displayScale === DISPLAY_STYLE.MIN; + + return ( +
+ } + /> + +
+ +
+ + } + /> +
+ ); +}; + +export default ToolsScaler; diff --git a/packages/core/src/lib/components/image/tools-tooltip.tsx b/packages/core/src/lib/components/image/tools-tooltip.tsx new file mode 100644 index 0000000..1b81a96 --- /dev/null +++ b/packages/core/src/lib/components/image/tools-tooltip.tsx @@ -0,0 +1,79 @@ +"use client"; +import React, { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; + +const MOTION_VARIANTS = { + hidden: { opacity: 0, y: -5 }, + visible: { opacity: 1, y: 0 }, +}; + +interface Aria { + label: string; + describedby: string; + hint: string; +} + +interface ToolsTooltipProps { + className?: string; + content: string; + icon: React.ReactNode; + hint?: string; + onClick: () => void; + disabled?: boolean; + aria: Aria; +} + +const ToolsTooltip: React.FC = ({ + className, + onClick, + content, + hint, + icon, + disabled, + aria, +}) => { + const [isVisible, setIsVisible] = useState(false); + + const handleMouseEnter = () => setIsVisible(true); + const handleMouseLeave = () => setIsVisible(false); + + return ( +
+ + + {aria?.hint} + + {!disabled && ( + + {isVisible && ( + +

{content}

+ {hint &&

{hint}

} +
+ )} +
+ )} +
+ ); +}; + +export default ToolsTooltip; diff --git a/packages/core/src/lib/components/image/viewer-image.tsx b/packages/core/src/lib/components/image/viewer-image.tsx new file mode 100644 index 0000000..291ca71 --- /dev/null +++ b/packages/core/src/lib/components/image/viewer-image.tsx @@ -0,0 +1,156 @@ +"use client"; +import React, { useReducer, useRef, useState } from "react"; + +import { motion } from "framer-motion"; + +import { + initialOrigin, + initialScale, + originReducer, + scaleReducer, + CONVERSION, +} from "./reducer"; + +import { + useNavigation, + useImages, + useKeydown, + useZoomControls, + useImageSize, +} from "./hooks"; + +import { getCursorStyle } from "./lib"; +import { MOTION_STYLES } from "./constants"; + +import ViewerTools from "./viewer-tools"; + +interface ViewerImageProps { + url: string; + isCursor: boolean; + caption: string; + close: () => void; + handleMouseLeave: () => void; + handleMouseEnter: () => void; +} + +const ViewerImage: React.FC = ({ + url, + caption, + isCursor, + close, + handleMouseLeave, + handleMouseEnter, +}) => { + const imageRef = useRef(null); + + const [isFocus, setIsFocus] = useState(false); + const [announce, setAnnounce] = useState(""); + + const visibleImages = useImages(); + + const [scaleState, scaleDispatch] = useReducer(scaleReducer, initialScale); + const [originState, originDispatch] = useReducer( + originReducer, + initialOrigin, + ); + + 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); + + const zoomControls = useZoomControls({ + scaleState, + originDispatch, + scaleDispatch, + onAnnounce: handleAnnounce, + }); + + useKeydown({ + close, + zoomControls, + toPreviousImage, + toNextImage, + }); + + const isTools = isCursor || isFocus; + const isViewerNavigation = visibleImages.length > 1; + + return ( + <> + 0 ? `${maxWidth}px` : "100vw", + maxHeight: maxHeight > 0 ? `${maxHeight}px` : "90vh", + transform: `scale(${scaleState.styleScale})`, + transformOrigin: `${originState.originX * CONVERSION.PERCENT_FACTOR}% ${originState.originY * CONVERSION.PERCENT_FACTOR}%`, + }} + onClick={zoomControls.handleZoomInOut} + > + + +
+ {announce} +
+ {isTools && ( + + {isViewerNavigation && ( + + )} + + + + + )} + + ); +}; + +export default ViewerImage; 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..b2a361e --- /dev/null +++ b/packages/core/src/lib/components/image/viewer-overlay.tsx @@ -0,0 +1,21 @@ +"use client"; +import React from "react"; + +interface ViewerOverlayProps { + close: () => void; + isCursor: boolean; +} + +const ViewerOverlay: React.FC = ({ close, isCursor }) => { + return ( +
+ ); +}; + +export default ViewerOverlay; 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..68619b2 --- /dev/null +++ b/packages/core/src/lib/components/image/viewer-tools.tsx @@ -0,0 +1,47 @@ +"use client"; + +import React from "react"; +import { motion } from "framer-motion"; +import { MOTION_STYLES } from "./constants"; + +import ToolsScaler, { type ToolsScalerProps } from "./tools-scaler"; +import ToolsNavigation, { type ToolsNavigationProps } from "./tools-navigation"; +import ToolsDownload, { type ToolsDownloadProps } from "./tools-download"; +import ToolsClose, { type ToolsCloseProps } from "./tools-close"; + +interface ViewerToolsProps { + children: React.ReactNode; + handleMouseLeave: () => void; + handleMouseEnter: () => void; +} + +interface ViewerToolsComponent { + Scaler: React.FC; + Navigation: React.FC; + Download: React.FC; + Close: React.FC; +} + +const ViewerTools: React.FC & ViewerToolsComponent = ({ + children, + handleMouseLeave, + handleMouseEnter, +}) => { + return ( + + {children} + + ); +}; + +ViewerTools.Scaler = ToolsScaler; +ViewerTools.Navigation = ToolsNavigation; +ViewerTools.Download = ToolsDownload; +ViewerTools.Close = ToolsClose; + +export default ViewerTools; diff --git a/packages/core/src/lib/index.css b/packages/core/src/lib/index.css index ac016bd..5875a1c 100644 --- a/packages/core/src/lib/index.css +++ b/packages/core/src/lib/index.css @@ -1062,230 +1062,239 @@ flex-direction: column; } -.notion-viewer-opener, -.notion-image-viewer-overlay { +.notion-viewer-opener { background: none; border: none; padding: 0; margin: 0; + cursor: pointer; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: inherit; + -moz-osx-font-smoothing: inherit; +} + +.notion-viewer-container { + display: flex; + position: fixed; + inset: 0; + overflow: hidden; + z-index: 999; + justify-content: center; + align-items: center; + flex-direction: column; + transition: transform 0.08s ease-in-out; + height: 100vh; +} + +@media screen and (min-width: 768px) { + .notion-viewer-container { + padding-left: 32px; + padding-right: 32px; + } +} + +.notion-viewer-overlay { + position: fixed; + overflow: hidden; + inset: 0; + background-color: rgba(15, 15, 15, 0.6); + width: 100vw; + height: 100vh; + z-index: 10; + display: flex; + justify-content: center; + align-items: center; + overscroll-behavior: contain; + transition: transform 0.08s ease-in-out; +} + +.notion-viewer-content { + position: relative; + display: flex; + align-items: center; + justify-content: center; width: auto; height: auto; + z-index: 100; overflow: visible; - text-align: left; - color: inherit; - font: inherit; - line-height: normal; - -webkit-font-smoothing: inherit; - -moz-osx-font-smoothing: inherit; - -webkit-appearance: none; - appearance: none; + transition: transform 0.2s ease-in-out; + transform-origin: center; } -.notion-image-viewer-tools button { - background-color: #060606c4; - padding: 6px; - width: 32px; - height: 32px; - border: 0; - margin: 0; - user-select: none; - cursor: pointer; +.notion-viewer-content > img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + z-index: 0; + transform-origin: center; } -.notion-image-viewer-tools button > img { +.notion-viewer-tools { + display: flex; + position: fixed; + bottom: 5%; + left: 50%; + transform: translateX(-50%); + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1.2; + opacity: 1; + transition: 0.2s all ease-in-out; + height: 28px; + z-index: 105; +} + +.notion-viewer-tools button { + background: #000000a6; + user-select: none; + cursor: default; + display: inline-flex; + align-items: center; + width: 28px; + height: 28px; + white-space: nowrap; + font-size: 14px; + justify-content: center; + flex-shrink: 0; + line-height: 1.2; + min-width: 0px; + border: none; + fill: white; cursor: pointer; } -.notion-image-viewer-tools button:disabled { +.notion-viewer-tools button:disabled { pointer-events: none; cursor: default; - background-color: #060606a1; } -.notion-image-viewer-tools button:hover { - background-color: #060606a1; +.notion-viewer-tools button:disabled svg { + opacity: 0.4; } -.notion-image-viewer-tools > .notion-image-viewer-controls { +.notion-viewer-tools .notion-tools-navigation { display: flex; - margin-right: 25px; + margin-right: 10px; } -.notion-image-viewer-tools - > .notion-image-viewer-controls - > .notion-image-viewer-tooltip-container:first-child - button, -.notion-image-viewer-tools > .notion-image-viewer-scaler button:first-child { - width: 36px; - padding-left: 8px; +.notion-viewer-tools .notion-tools-navigation-back { border-top-left-radius: 4px; border-bottom-left-radius: 4px; } -.notion-image-viewer-tools - > .notion-image-viewer-controls - > .notion-image-viewer-tooltip-container:last-child - button, -.notion-image-viewer-tools > nav > button:last-child { - width: 36px; - padding-right: 8px; +.notion-viewer-tools .notion-tools-navigation-next { border-top-right-radius: 4px; border-bottom-right-radius: 4px; } -.notion-image-viewer-tools .notion-image-viewer-scaler { +.notion-viewer-tools .notion-tools-scaler { display: flex; font-size: 14px; } -.notion-image-viewer-scaler-input { - background-color: #060606c4; +.notion-viewer-tools .notion-tools-scaler-container { + background: #000000a6; display: flex; align-items: center; justify-content: center; color: #888888; - width: 60px; - height: 32px; -} - -.notion-image-viewer-scaler-input:hover { - background-color: #060606a1; - display: flex; - align-items: center; + width: 50px; + height: 28px; } -.notion-image-viewer-scaler button { - width: 60; - padding: 5; -} - -.notion-image-viewer-scaler input { - background-color: transparent; +.notion-viewer-tools .notion-tools-scaler-container input { + background: #000000a6; border: 0; - font-size: inherit; - color: inherit; padding: 0px; - padding-top: 1px; - overflow: hidden; - line-height: 1; + font-size: inherit; + color: #fff; text-align: center; + border-radius: 4px; + width: 28px; + font-size: 15px; } -.notion-image-viewer-scaler-input-button { +.notion-viewer-tools .notion-tools-scaler-container input + span { + width: 5px; +} + +.notion-viewer-tools .notion-tools-scaler-container button { + background: none; display: flex; justify-content: center; align-items: center; - width: 60px !important; - color: #888888; + width: 42px; + color: #bbbbbbdd; } -.notion-image-viewer-tools .notion-image-viewer-scaler input:focus { +.notion-viewer-tools .notion-tools-scaler-container input:focus { color: white; - outline: 1px solid #2d75a0; - width: 28px; + outline: 0.8px solid #2d75a0; } -/* Chrome, Safari, Edge, Opera */ -.notion-image-viewer-tools - .notion-image-viewer-scaler + +.notion-viewer-tools + .notion-tools-scaler-container input::-webkit-outer-spin-button, -.notion-image-viewer-tools - .notion-image-viewer-scaler +.notion-viewer-tools + .notion-tools-scaler-container input::-webkit-inner-spin-button { -webkit-appearance: none; appearance: none; margin: 0; } -/* Firefox */ -.notion-image-viewer-tools .notion-image-viewer-scaler input[type="number"] { +.notion-viewer-tools .notion-tools-scaler-container input[type="number"] { -moz-appearance: textfield; appearance: textfield; } -.notion-image-viewer-tools .notion-image-viewer-scaler > button:first-of-type { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} -.notion-image-viewer-tools .notion-image-viewer-scaler > button:last-of-type { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} -.notion-image-viewer-tools button > img { - width: 100%; +.notion-viewer-tools button > svg { + width: 14px; height: 100%; -} -.notion-image-viewer-tools button.notion-image-viewer-tools-download { - border-left: solid 1px white; - border-right: solid 1px white; - opacity: 0.9; -} -.notion-image-viewer-tools button.notion-image-viewer-tools-close { - width: 36px; - padding-right: 8px; - border-top-right-radius: 6px; - border-bottom-right-radius: 6px; + display: block; + flex-shrink: 0; } -.notion-image-viewer-opener { - cursor: pointer; +.notion-viewer-tools .notion-tools-download { + border-left: solid 0.05px #727272ae; + border-right: solid 0.05px #727272ae; } -.notion-image-viewer-container { - display: flex; - position: fixed; - inset: 0; - overflow: hidden; - z-index: 999; - justify-content: center; - align-items: center; - padding-top: 32px; - padding-bottom: 32px; - flex-direction: column; - height: 100vh; - width: 100vw; +.notion-viewer-tools .notion-tools-scaler-zoom-out { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; } -@media screen and (min-width: 768px) { - .notion-image-viewer-container { - padding-left: 32px; - padding-right: 32px; - } +.notion-viewer-tools .notion-tools-close { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; } -.notion-image-viewer-overlay { - position: fixed; - inset: 0; - background-color: black; - opacity: 0.9; - cursor: default; - overscroll-behavior: contain; - overflow: hidden; - height: 100vh; -} +@media screen and (max-width: 768px) { + .notion-viewer-content { + max-width: 100%; + max-height: 100%; + } -.notion-image-viewer-container-image { - max-width: fit-content; - max-height: 100%; - object-fit: contain; - position: relative; - z-index: 0; - transform-origin: 0.4s ease-in-out; -} + .notion-viewer-content > img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } -.notion-image-viewer-tools { - display: flex; - position: absolute; - bottom: 30px; - left: 50%; - transform: translateX(-50%); - transition: 2s all ease-in-out; + .notion-viewer-tools { + bottom: 30px; + } } -.notion-image-viewer-tooltip-container { +.notion-viewer-tooltip-container { position: relative; display: inline-block; } -.notion-image-viewer-tooltip { +.notion-viewer-tooltip { display: flex; align-items: center; position: absolute; @@ -1302,7 +1311,7 @@ z-index: 10; } -.notion-image-viewer-tooltip p:last-child { +.notion-viewer-tooltip p:last-child { margin-left: 5px; color: #d3d3d3; font-size: 13px; @@ -1328,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; +} diff --git a/packages/story/src/stories/image/image.stories.tsx b/packages/story/src/stories/image/image.stories.tsx index 3e8941b..d32b447 100644 --- a/packages/story/src/stories/image/image.stories.tsx +++ b/packages/story/src/stories/image/image.stories.tsx @@ -1,8 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { _Block } from "@notionpresso/react"; import Component from "../../lib/Notion"; import json from "./image.json"; +import toggleImageJson from "./toggle-image.json"; -const blocks = json.blocks as any; +const blocks = json.blocks as _Block[]; +const toggleImageBlocks = toggleImageJson.blocks as _Block[]; const meta: Meta = { title: "Blocks/Image", @@ -18,3 +21,10 @@ export const Image: Story = { blocks: blocks, }, }; + +export const ToggleImage: Story = { + args: { + title: "Image", + blocks: toggleImageBlocks, + }, +}; diff --git a/packages/story/src/stories/image/toggle-image.json b/packages/story/src/stories/image/toggle-image.json new file mode 100644 index 0000000..a3ff133 --- /dev/null +++ b/packages/story/src/stories/image/toggle-image.json @@ -0,0 +1,239 @@ +{ + "object": "page", + "id": "1d19c395-c2fb-80d0-82bd-ed675d3e7209", + "created_time": "2025-04-10T18:10:00.000Z", + "last_edited_time": "2025-08-12T15:11:00.000Z", + "created_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "last_edited_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "cover": null, + "icon": null, + "parent": { + "type": "workspace", + "workspace": true + }, + "archived": false, + "in_trash": false, + "properties": { + "title": { + "id": "title", + "type": "title", + "title": [ + { + "type": "text", + "text": { + "content": "TSET", + "link": null + }, + "annotations": { + "bold": false, + "italic": false, + "strikethrough": false, + "underline": false, + "code": false, + "color": "default" + }, + "plain_text": "TSET", + "href": null + } + ] + } + }, + "url": "https://www.notion.so/TSET-1d19c395c2fb80d082bded675d3e7209", + "public_url": null, + "request_id": "2c3c8439-b173-4911-bacd-f5c82d24b63e", + "blocks": [ + { + "object": "block", + "id": "24d9c395-c2fb-8050-ae3f-c83763a76abf", + "parent": { + "type": "page_id", + "page_id": "1d19c395-c2fb-80d0-82bd-ed675d3e7209" + }, + "created_time": "2025-08-12T14:59:00.000Z", + "last_edited_time": "2025-08-12T14:59:00.000Z", + "created_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "last_edited_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "has_children": false, + "archived": false, + "in_trash": false, + "type": "paragraph", + "paragraph": { + "rich_text": [], + "color": "default" + }, + "blocks": [] + }, + { + "object": "block", + "id": "24d9c395-c2fb-809d-b700-d173c3905bb5", + "parent": { + "type": "page_id", + "page_id": "1d19c395-c2fb-80d0-82bd-ed675d3e7209" + }, + "created_time": "2025-08-12T14:59:00.000Z", + "last_edited_time": "2025-08-12T14:59:00.000Z", + "created_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "last_edited_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "has_children": false, + "archived": false, + "in_trash": false, + "type": "image", + "image": { + "caption": [], + "type": "external", + "external": { + "url": "https://images.unsplash.com/photo-1727087312697-13997c39c49c?q=80&w=2069&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA==" + } + }, + "blocks": [] + }, + { + "object": "block", + "id": "24d9c395-c2fb-8033-9ee8-c598e9618dfa", + "parent": { + "type": "page_id", + "page_id": "1d19c395-c2fb-80d0-82bd-ed675d3e7209" + }, + "created_time": "2025-08-12T14:59:00.000Z", + "last_edited_time": "2025-08-12T14:59:00.000Z", + "created_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "last_edited_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "has_children": false, + "archived": false, + "in_trash": false, + "type": "paragraph", + "paragraph": { + "rich_text": [], + "color": "default" + }, + "blocks": [] + }, + { + "object": "block", + "id": "24d9c395-c2fb-800f-bf2b-efc8dcc4c641", + "parent": { + "type": "page_id", + "page_id": "1d19c395-c2fb-80d0-82bd-ed675d3e7209" + }, + "created_time": "2025-08-12T14:59:00.000Z", + "last_edited_time": "2025-08-12T14:59:00.000Z", + "created_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "last_edited_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "has_children": false, + "archived": false, + "in_trash": false, + "type": "paragraph", + "paragraph": { + "rich_text": [], + "color": "default" + }, + "blocks": [] + }, + { + "object": "block", + "id": "24d9c395-c2fb-8068-8d20-ff3aa81f80d9", + "parent": { + "type": "page_id", + "page_id": "1d19c395-c2fb-80d0-82bd-ed675d3e7209" + }, + "created_time": "2025-08-12T14:59:00.000Z", + "last_edited_time": "2025-08-12T14:59:00.000Z", + "created_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "last_edited_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "has_children": true, + "archived": false, + "in_trash": false, + "type": "toggle", + "toggle": { + "rich_text": [ + { + "type": "text", + "text": { + "content": "Image", + "link": null + }, + "annotations": { + "bold": false, + "italic": false, + "strikethrough": false, + "underline": false, + "code": false, + "color": "default" + }, + "plain_text": "Image", + "href": null + } + ], + "color": "default" + }, + "blocks": [ + { + "object": "block", + "id": "24d9c395-c2fb-80bf-aeed-e171d96b2856", + "parent": { + "type": "block_id", + "block_id": "24d9c395-c2fb-8068-8d20-ff3aa81f80d9" + }, + "created_time": "2025-08-12T14:59:00.000Z", + "last_edited_time": "2025-08-12T14:59:00.000Z", + "created_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "last_edited_by": { + "object": "user", + "id": "52c52d67-2221-4853-bae4-9687a30c395a" + }, + "has_children": false, + "archived": false, + "in_trash": false, + "type": "image", + "image": { + "caption": [], + "type": "external", + "external": { + "url": "https://images.unsplash.com/photo-1727200453012-e29dbaa13492?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA==" + } + }, + "blocks": [] + } + ] + } + ] +}