diff --git a/apps/docs/components/ui/action-media.tsx b/apps/docs/components/ui/action-media.tsx index 32ce557eae6..79d47f5477a 100644 --- a/apps/docs/components/ui/action-media.tsx +++ b/apps/docs/components/ui/action-media.tsx @@ -1,8 +1,8 @@ 'use client' import { useRef, useState } from 'react' +import { Lightbox } from '@sim/emcn' import { cn, getAssetUrl } from '@/lib/utils' -import { Lightbox } from './lightbox' interface ActionImageProps { src: string @@ -17,10 +17,6 @@ interface ActionVideoProps { } export function ActionImage({ src, alt, enableLightbox = true }: ActionImageProps) { - const [isLightboxOpen, setIsLightboxOpen] = useState(false) - - const openLightbox = () => setIsLightboxOpen(true) - const image = ( ) + if (!enableLightbox) return image + return ( - <> - {enableLightbox ? ( - - ) : ( - image - )} - {enableLightbox && ( - setIsLightboxOpen(false)} - src={src} - alt={alt} - type='image' - /> - )} - + + + ) } export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProps) { const videoRef = useRef(null) - const startTimeRef = useRef(0) - const [isLightboxOpen, setIsLightboxOpen] = useState(false) + const [startTime, setStartTime] = useState(0) const resolvedSrc = getAssetUrl(src) const openLightbox = () => { - startTimeRef.current = videoRef.current?.currentTime ?? 0 - setIsLightboxOpen(true) + setStartTime(videoRef.current?.currentTime ?? 0) } const video = ( @@ -85,30 +67,18 @@ export function ActionVideo({ src, alt, enableLightbox = true }: ActionVideoProp /> ) + if (!enableLightbox) return video + return ( - <> - {enableLightbox ? ( - - ) : ( - video - )} - {enableLightbox && ( - setIsLightboxOpen(false)} - src={src} - alt={alt} - type='video' - startTime={startTimeRef.current} - /> - )} - + + + ) } diff --git a/apps/docs/components/ui/image.tsx b/apps/docs/components/ui/image.tsx index ce966c50fef..93bd3a30ac1 100644 --- a/apps/docs/components/ui/image.tsx +++ b/apps/docs/components/ui/image.tsx @@ -1,8 +1,7 @@ 'use client' -import { useState } from 'react' +import { Lightbox } from '@sim/emcn' import NextImage, { type ImageProps as NextImageProps } from 'next/image' -import { Lightbox } from '@/components/ui/lightbox' import { cn } from '@/lib/utils' interface ImageProps extends Omit { @@ -17,9 +16,7 @@ export function Image({ src, ...props }: ImageProps) { - const [isLightboxOpen, setIsLightboxOpen] = useState(false) - - const openLightbox = () => setIsLightboxOpen(true) + const lightboxSrc = typeof src === 'string' ? src : 'default' in src ? src.default.src : src.src const image = ( ) - return ( - <> - {enableLightbox ? ( - - ) : ( - image - )} + if (!enableLightbox) return image - {enableLightbox && ( - setIsLightboxOpen(false)} - src={typeof src === 'string' ? src : String(src)} - alt={alt} - type='image' - /> - )} - + return ( + + + ) } diff --git a/apps/docs/components/ui/lightbox.tsx b/apps/docs/components/ui/lightbox.tsx deleted file mode 100644 index d227f389d5b..00000000000 --- a/apps/docs/components/ui/lightbox.tsx +++ /dev/null @@ -1,104 +0,0 @@ -'use client' - -import { useEffect, useEffectEvent, useLayoutEffect, useRef } from 'react' -import { getAssetUrl } from '@/lib/utils' - -interface LightboxProps { - isOpen: boolean - onClose: () => void - src: string - alt: string - type: 'image' | 'video' - startTime?: number -} - -export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: LightboxProps) { - const overlayRef = useRef(null) - const mediaButtonRef = useRef(null) - const videoRef = useRef(null) - const previouslyFocusedRef = useRef(null) - const closeLightbox = useEffectEvent(onClose) - - useEffect(() => { - if (!isOpen) return - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - closeLightbox() - return - } - if (event.key === 'Tab') { - event.preventDefault() - mediaButtonRef.current?.focus() - } - } - - const handleClickOutside = (event: MouseEvent) => { - if (overlayRef.current && event.target === overlayRef.current) { - closeLightbox() - } - } - - const previousOverflow = document.body.style.overflow - - previouslyFocusedRef.current = document.activeElement as HTMLElement | null - document.addEventListener('keydown', handleKeyDown) - document.addEventListener('click', handleClickOutside) - document.body.style.overflow = 'hidden' - mediaButtonRef.current?.focus() - - return () => { - document.removeEventListener('keydown', handleKeyDown) - document.removeEventListener('click', handleClickOutside) - document.body.style.overflow = previousOverflow - previouslyFocusedRef.current?.focus() - } - }, [isOpen]) - - useLayoutEffect(() => { - if (isOpen && type === 'video' && videoRef.current && startTime != null && startTime > 0) { - videoRef.current.currentTime = startTime - } - }, [isOpen, startTime, type]) - - if (!isOpen) return null - - return ( -
-
- -
-
- ) -} diff --git a/apps/docs/components/ui/video.tsx b/apps/docs/components/ui/video.tsx index f3b1f4f9e0b..10ba67ab168 100644 --- a/apps/docs/components/ui/video.tsx +++ b/apps/docs/components/ui/video.tsx @@ -1,8 +1,8 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import { Lightbox } from '@sim/emcn' import { cn, getAssetUrl } from '@/lib/utils' -import { Lightbox } from './lightbox' interface VideoProps { src: string @@ -28,8 +28,7 @@ export function Video({ height, }: VideoProps) { const videoRef = useRef(null) - const startTimeRef = useRef(0) - const [isLightboxOpen, setIsLightboxOpen] = useState(false) + const [startTime, setStartTime] = useState(0) const [isInView, setIsInView] = useState(false) useEffect(() => { @@ -55,8 +54,7 @@ export function Video({ }, []) const openLightbox = () => { - startTimeRef.current = videoRef.current?.currentTime ?? 0 - setIsLightboxOpen(true) + setStartTime(videoRef.current?.currentTime ?? 0) } const video = ( @@ -78,31 +76,18 @@ export function Video({ /> ) - return ( - <> - {enableLightbox ? ( - - ) : ( - video - )} + if (!enableLightbox) return video - {enableLightbox && ( - setIsLightboxOpen(false)} - src={src} - alt={`Video: ${src}`} - type='video' - startTime={startTimeRef.current} - /> - )} - + return ( + + + ) } diff --git a/apps/sim/app/(landing)/components/content-image/content-image.tsx b/apps/sim/app/(landing)/components/content-image/content-image.tsx index 001430c7a4a..c14d508f9c8 100644 --- a/apps/sim/app/(landing)/components/content-image/content-image.tsx +++ b/apps/sim/app/(landing)/components/content-image/content-image.tsx @@ -1,9 +1,7 @@ 'use client' -import { useState } from 'react' -import { cn } from '@sim/emcn' +import { cn, Lightbox } from '@sim/emcn' import NextImage from 'next/image' -import { Lightbox } from '@/app/(landing)/components/lightbox' interface ContentImageProps { src: string @@ -24,30 +22,27 @@ export function ContentImage({ height = 450, className, }: ContentImageProps) { - const [isLightboxOpen, setIsLightboxOpen] = useState(false) - return ( - <> - setIsLightboxOpen(true)} - /> - setIsLightboxOpen(false)} - src={src} - alt={alt} - /> - + + + ) } diff --git a/apps/sim/app/(landing)/components/index.ts b/apps/sim/app/(landing)/components/index.ts index 657a1db4c07..d0ee91b5b1d 100644 --- a/apps/sim/app/(landing)/components/index.ts +++ b/apps/sim/app/(landing)/components/index.ts @@ -15,7 +15,6 @@ export { HomeStructuredData } from './home-structured-data' export type { JsonLdData } from './json-ld' export { JsonLd } from './json-ld' export { LandingShell } from './landing-shell' -export { Lightbox } from './lightbox' export { LogoShell } from './logo-shell' export { Navbar } from './navbar' export { PlatformHeroVisual } from './platform-hero-visual' diff --git a/apps/sim/app/(landing)/components/lightbox/index.ts b/apps/sim/app/(landing)/components/lightbox/index.ts deleted file mode 100644 index 1055bfa3ff7..00000000000 --- a/apps/sim/app/(landing)/components/lightbox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Lightbox } from './lightbox' diff --git a/apps/sim/app/(landing)/components/lightbox/lightbox.tsx b/apps/sim/app/(landing)/components/lightbox/lightbox.tsx deleted file mode 100644 index 7afd596cd72..00000000000 --- a/apps/sim/app/(landing)/components/lightbox/lightbox.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client' - -import { useEffect, useEffectEvent, useRef } from 'react' - -interface LightboxProps { - isOpen: boolean - onClose: () => void - src: string - alt: string -} - -export function Lightbox({ isOpen, onClose, src, alt }: LightboxProps) { - const overlayRef = useRef(null) - - const onCloseEvent = useEffectEvent(onClose) - - useEffect(() => { - if (!isOpen) return - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onCloseEvent() - } - } - - const handleClickOutside = (event: MouseEvent) => { - if (overlayRef.current && event.target === overlayRef.current) { - onCloseEvent() - } - } - - document.addEventListener('keydown', handleKeyDown) - document.addEventListener('click', handleClickOutside) - document.body.style.overflow = 'hidden' - - return () => { - document.removeEventListener('keydown', handleKeyDown) - document.removeEventListener('click', handleClickOutside) - document.body.style.overflow = 'unset' - } - }, [isOpen]) - - if (!isOpen) return null - - return ( -
-
- -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx index 0e6e3602842..ddf44547039 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -1,7 +1,7 @@ 'use client' import { memo, useCallback, useEffect, useRef, useState } from 'react' -import { cn } from '@sim/emcn' +import { bindPreviewWheelZoom, cn } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sanitizeRenderedHyperlinks, stripEmbeddedFrames } from '@/lib/core/security/url-safety' @@ -9,7 +9,6 @@ import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared' import { PreviewToolbar } from './preview-toolbar' -import { bindPreviewWheelZoom } from './preview-wheel-zoom' import { useDocPreviewBinary } from './use-doc-preview-binary' const logger = createLogger('DocxPreview') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx index d548c8ac2f2..653d7b2bde2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx @@ -9,9 +9,9 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf' import 'react-pdf/dist/Page/TextLayer.css' +import { bindPreviewWheelZoom } from '@sim/emcn' import { PREVIEW_LOADING_OVERLAY } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared' import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar' -import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom' /** * The worker runs in its own context that browser-polyfills cannot reach, so diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx index cc769c274a2..e99013757cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx @@ -1,11 +1,11 @@ 'use client' import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { bindPreviewWheelZoom } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { openSimPptxViewer, type SimPptxViewerHandle } from '@/lib/pptx-renderer/sim-pptx-viewer' import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar' -import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom' const logger = createLogger('PptxSandboxHost') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts index d5d941d26c0..0cd9ff3e40a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts @@ -1,7 +1,7 @@ 'use client' import { useCallback, useRef } from 'react' -import { bindPreviewHorizontalWheel } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom' +import { bindPreviewHorizontalWheel } from '@sim/emcn' /** * Ref callback that gives a preview scroll container horizontal wheel scrolling. diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx index 327e2808393..afa9665bf77 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx @@ -2,9 +2,8 @@ import type { MouseEvent, ReactNode } from 'react' import { useCallback, useLayoutEffect, useRef, useState } from 'react' -import { cn } from '@sim/emcn' +import { bindPreviewWheelZoom, cn } from '@sim/emcn' import { PreviewToolbar } from './preview-toolbar' -import { bindPreviewWheelZoom } from './preview-wheel-zoom' const ZOOM_MIN = 0.25 const ZOOM_MAX = 4 diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx index ff13612456f..10d16ddd7f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx @@ -1,4 +1,4 @@ -import { cn } from '@sim/emcn' +import { cn, Lightbox } from '@sim/emcn' import { getDocumentIcon } from '@/components/icons/document-icons' import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types' @@ -57,9 +57,15 @@ export function ChatMessageAttachments(props: { ) } return ( -
- {att.filename} -
+ + + ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx index db3f8c36ecd..4f42d169c1c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx @@ -1,7 +1,7 @@ 'use client' import React, { useState } from 'react' -import { cn, Loader } from '@sim/emcn' +import { cn, Lightbox, Loader } from '@sim/emcn' import { X } from '@sim/emcn/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import { getFileExtension } from '@/lib/uploads/utils/file-utils' @@ -41,74 +41,86 @@ const AttachedFileChip = React.memo(function AttachedFileChip({ const isMedia = isVideo || file.type.startsWith('image/') const extension = getFileExtension(file.name) const [previewFailed, setPreviewFailed] = useState(false) + const lightboxSrc = file.type.startsWith('image/') && !previewFailed ? file.previewUrl : undefined - return ( - /* Owns the width cap: it anchors the remove badge, which a max-content button would strand. */ -
onFileClick(file)} + aria-label={lightboxSrc ? `Preview ${file.name}` : `Open ${file.name}`} > - + + )} + {file.uploading && ( + + + + )} + + ) + + return ( + /* Owns the width cap: it anchors the remove badge, which a max-content button would strand. */ +
+ {lightboxSrc ? ( + + {preview} + + ) : ( + preview + )} {!file.uploading && ( + + ) + ) + await click('Open image') + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + document.body.replaceChildren() + document.body.removeAttribute('style') + vi.restoreAllMocks() + }) + + it.each([{ ctrlKey: true }, { metaKey: true }])( + 'zooms the media in both directions and cancels page zoom for %j', + (modifier) => { + const zoomIn = wheel({ ...modifier, deltaY: -40 }) + expect(zoomIn.defaultPrevented).toBe(true) + expect(Number(media().style.zoom)).toBeCloseTo(Math.exp(0.2)) + + const zoomOut = wheel({ ...modifier, deltaY: 40 }) + expect(zoomOut.defaultPrevented).toBe(true) + expect(Number(media().style.zoom)).toBeCloseTo(1) + } + ) + + it('leaves ordinary vertical scrolling available without changing zoom', () => { + const event = wheel({ deltaY: 40 }) + expect(event.defaultPrevented).toBe(false) + expect(media().style.zoom).toBe('1') + }) + + it('keeps toolbar controls open, clamps zoom, and resets to fit', async () => { + await click('Zoom in') + expect(media().style.zoom).toBe('1.25') + await click('Zoom out') + expect(media().style.zoom).toBe('1') + + wheel({ ctrlKey: true, deltaY: -10000 }) + expect(media().style.zoom).toBe('4') + expect(button('Zoom in').disabled).toBe(true) + wheel({ ctrlKey: true, deltaY: 10000 }) + expect(media().style.zoom).toBe('0.25') + expect(button('Zoom out').disabled).toBe(true) + await click('Reset zoom (25%)') + expect(media().style.zoom).toBe('1') + }) + + it('preserves the point beneath the gesture when zoom changes', () => { + const frame = button('Close media viewer') + const viewport = frame.parentElement?.parentElement + if (!viewport) throw new Error('Missing viewport') + const getBounds = vi.spyOn(frame, 'getBoundingClientRect') + getBounds.mockReturnValueOnce(new DOMRect(100, 100, 400, 300)) + getBounds.mockReturnValueOnce(new DOMRect(100, 100, 800, 600)) + + wheel({ ctrlKey: true, deltaY: -Math.log(2) / 0.005, clientX: 200, clientY: 175 }) + + expect(viewport.scrollLeft).toBeCloseTo(100) + expect(viewport.scrollTop).toBeCloseTo(75) + }) + + it('closes on the image, restores focus, and binds gestures again after reopening', async () => { + const oldMedia = media() + wheel({ ctrlKey: true, deltaY: -40 }) + await click('Close media viewer') + expect(document.querySelector('[role="dialog"]')).toBeNull() + await vi.waitFor(() => expect(document.activeElement).toBe(button('Open image'))) + const detachedWheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + ctrlKey: true, + deltaY: -40, + }) + oldMedia.dispatchEvent(detachedWheel) + expect(detachedWheel.defaultPrevented).toBe(false) + + await click('Open image') + expect(media().style.zoom).toBe('1') + expect(wheel({ ctrlKey: true, deltaY: -40 }).defaultPrevented).toBe(true) + expect(Number(media().style.zoom)).toBeGreaterThan(1) + }) + + it('dismisses on the viewport background and Escape', async () => { + const dialog = document.querySelector('[role="dialog"]') + await act(async () => dialog?.click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + await click('Open image') + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) +}) diff --git a/packages/emcn/src/components/lightbox/lightbox.tsx b/packages/emcn/src/components/lightbox/lightbox.tsx new file mode 100644 index 00000000000..cc67372190d --- /dev/null +++ b/packages/emcn/src/components/lightbox/lightbox.tsx @@ -0,0 +1,201 @@ +'use client' + +import { type ReactElement, useCallback, useLayoutEffect, useRef, useState } from 'react' +import { + bindPreviewWheelZoom, + Chip, + chipFieldSurfaceClass, + cn, + Modal, + ModalClose, + ModalContent, + ModalTrigger, +} from '@sim/emcn' +import { Minus, Plus } from '@sim/emcn/icons' + +export interface LightboxProps { + /** A button that opens the viewer. */ + children: ReactElement + src: string + alt: string + type?: 'image' | 'video' + /** Playback position to resume when a video opens. */ + startTime?: number +} + +const ZOOM_MIN = 0.25 +const ZOOM_MAX = 4 +const ZOOM_STEP = 0.25 +const ZOOM_WHEEL_SENSITIVITY = 0.005 +const MEDIA_CLASS = 'block h-auto max-h-[calc(100dvh-6rem)] w-auto max-w-[92vw] object-contain' + +interface ZoomAnchor { + clientX: number + clientY: number + fractionX: number + fractionY: number +} + +function centerViewport(viewport: HTMLDivElement | null) { + if (!viewport) return + viewport.scrollLeft = (viewport.scrollWidth - viewport.clientWidth) / 2 + viewport.scrollTop = (viewport.scrollHeight - viewport.clientHeight) / 2 +} + +/** + * A click-to-close media viewer with bottom zoom controls, the platform's modal + * focus trap, Escape dismissal, and focus restoration to its trigger. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function Lightbox({ children, src, alt, type = 'image', startTime = 0 }: LightboxProps) { + const viewportRef = useRef(null) + const mediaFrameRef = useRef(null) + const controlsRef = useRef(null) + const zoomAnchorRef = useRef(null) + const [open, setOpen] = useState(false) + const [zoom, setZoom] = useState(1) + + function handleOpenChange(nextOpen: boolean) { + if (nextOpen) { + zoomAnchorRef.current = null + setZoom(1) + } + setOpen(nextOpen) + } + + function handleControlZoom(nextZoom: number) { + zoomAnchorRef.current = null + setZoom(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, nextZoom))) + } + + const attachViewport = useCallback((viewport: HTMLDivElement | null) => { + viewportRef.current = viewport + if (!viewport) return + + const unbind = bindPreviewWheelZoom(viewport, (event) => { + const frame = mediaFrameRef.current?.getBoundingClientRect() + if (frame && frame.width > 0 && frame.height > 0) { + zoomAnchorRef.current = { + clientX: event.clientX, + clientY: event.clientY, + fractionX: (event.clientX - frame.left) / frame.width, + fractionY: (event.clientY - frame.top) / frame.height, + } + } + setZoom((current) => + Math.min( + ZOOM_MAX, + Math.max(ZOOM_MIN, current * Math.exp(-event.deltaY * ZOOM_WHEEL_SENSITIVITY)) + ) + ) + }) + + return () => { + unbind() + viewportRef.current = null + } + }, []) + + useLayoutEffect(() => { + const viewport = viewportRef.current + const anchor = zoomAnchorRef.current + const frame = mediaFrameRef.current?.getBoundingClientRect() + if (viewport && anchor && frame) { + viewport.scrollLeft += frame.left + anchor.fractionX * frame.width - anchor.clientX + viewport.scrollTop += frame.top + anchor.fractionY * frame.height - anchor.clientY + } else { + centerViewport(viewport) + } + zoomAnchorRef.current = null + }, [open, zoom]) + + return ( + + {children} + { + if (!controlsRef.current?.contains(event.target as Node)) setOpen(false) + }} + > +
+
+ + + +
+
+
+ handleControlZoom(zoom - ZOOM_STEP)} + /> + handleControlZoom(1)} + className='min-w-16 text-center tabular-nums' + > + {Math.round(zoom * 100)}% + + = ZOOM_MAX} + onClick={() => handleControlZoom(zoom + ZOOM_STEP)} + /> +
+
+
+ ) +} diff --git a/packages/emcn/src/components/modal/modal.tsx b/packages/emcn/src/components/modal/modal.tsx index 6309ee44ae7..bbf47f5e36e 100644 --- a/packages/emcn/src/components/modal/modal.tsx +++ b/packages/emcn/src/components/modal/modal.tsx @@ -426,6 +426,8 @@ export type ModalSize = keyof typeof MODAL_SIZES export interface ModalContentProps extends React.ComponentPropsWithoutRef { + /** Backdrop styling for specialized surfaces such as media viewers. */ + overlayClassName?: string /** * Whether to show the close button * @default true @@ -488,6 +490,7 @@ const ModalContent = React.forwardRef< ( { className, + overlayClassName, children, showClose = true, size = 'md', @@ -562,6 +565,7 @@ const ModalContent = React.forwardRef< return (