{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "zoom", "title": "Image Zoom", "description": "An accessible controlled or uncontrolled image zoom with native dialog behavior and animated viewport fitting", "dependencies": ["lucide-react"], "files": [ { "path": "registry/default/ui/zoom.tsx", "content": "'use client';\n\nimport { ZoomIn, ZoomOut } from 'lucide-react';\nimport {\n\ttype ComponentPropsWithRef,\n\ttype CSSProperties,\n\ttype ElementType,\n\ttype ImgHTMLAttributes,\n\ttype ReactElement,\n\ttype SyntheticEvent,\n\tuseCallback,\n\tuseEffect,\n\tuseRef,\n\tuseState,\n} from 'react';\nimport { createPortal } from 'react-dom';\nimport { cn } from '@/lib/utils';\n\nexport type ZoomRect = {\n\ttop: number;\n\tleft: number;\n\twidth: number;\n\theight: number;\n};\n\nexport type CalculateZoomTargetRectInput = {\n\tnaturalWidth: number;\n\tnaturalHeight: number;\n\tsourceWidth: number;\n\tsourceHeight: number;\n\tviewportWidth: number;\n\tviewportHeight: number;\n\tmargin?: number;\n};\n\nexport function calculateZoomTargetRect({\n\tnaturalWidth,\n\tnaturalHeight,\n\tsourceWidth,\n\tsourceHeight,\n\tviewportWidth,\n\tviewportHeight,\n\tmargin = 24,\n}: CalculateZoomTargetRectInput): ZoomRect {\n\tconst safeViewportWidth = positiveOr(viewportWidth, 1);\n\tconst safeViewportHeight = positiveOr(viewportHeight, 1);\n\tconst requestedMargin = nonNegativeOr(margin, 24);\n\tconst safeMargin = Math.min(requestedMargin, (safeViewportWidth - 1) / 2, (safeViewportHeight - 1) / 2);\n\tconst availableWidth = Math.max(1, safeViewportWidth - safeMargin * 2);\n\tconst availableHeight = Math.max(1, safeViewportHeight - safeMargin * 2);\n\tconst width = positiveOr(naturalWidth, positiveOr(sourceWidth, 1));\n\tconst height = positiveOr(naturalHeight, positiveOr(sourceHeight, 1));\n\tconst aspectRatio = width / height;\n\n\tlet targetWidth = availableWidth;\n\tlet targetHeight = targetWidth / aspectRatio;\n\tif (targetHeight > availableHeight) {\n\t\ttargetHeight = availableHeight;\n\t\ttargetWidth = targetHeight * aspectRatio;\n\t}\n\n\treturn {\n\t\ttop: (safeViewportHeight - targetHeight) / 2,\n\t\tleft: (safeViewportWidth - targetWidth) / 2,\n\t\twidth: targetWidth,\n\t\theight: targetHeight,\n\t};\n}\n\nexport type CalculateZoomObjectFitRectInput = {\n\tcontainerWidth: number;\n\tcontainerHeight: number;\n\tnaturalWidth: number;\n\tnaturalHeight: number;\n\tobjectFit?: CSSProperties['objectFit'];\n\tobjectPosition?: string;\n};\n\nexport function calculateZoomObjectFitRect({\n\tcontainerWidth,\n\tcontainerHeight,\n\tnaturalWidth,\n\tnaturalHeight,\n\tobjectFit = 'fill',\n\tobjectPosition = '50% 50%',\n}: CalculateZoomObjectFitRectInput): ZoomRect {\n\tconst safeContainerWidth = positiveOr(containerWidth, 1);\n\tconst safeContainerHeight = positiveOr(containerHeight, 1);\n\tconst safeNaturalWidth = positiveOr(naturalWidth, safeContainerWidth);\n\tconst safeNaturalHeight = positiveOr(naturalHeight, safeContainerHeight);\n\tconst containScale = Math.min(safeContainerWidth / safeNaturalWidth, safeContainerHeight / safeNaturalHeight);\n\tconst coverScale = Math.max(safeContainerWidth / safeNaturalWidth, safeContainerHeight / safeNaturalHeight);\n\tlet width = safeContainerWidth;\n\tlet height = safeContainerHeight;\n\n\tif (objectFit === 'contain' || objectFit === 'cover' || objectFit === 'scale-down') {\n\t\tconst scale =\n\t\t\tobjectFit === 'cover' ? coverScale : objectFit === 'scale-down' ? Math.min(1, containScale) : containScale;\n\t\twidth = safeNaturalWidth * scale;\n\t\theight = safeNaturalHeight * scale;\n\t} else if (objectFit === 'none') {\n\t\twidth = safeNaturalWidth;\n\t\theight = safeNaturalHeight;\n\t}\n\n\tconst [positionX, positionY] = parseObjectPosition(objectPosition);\n\treturn {\n\t\ttop: resolveObjectPosition(positionY, safeContainerHeight - height),\n\t\tleft: resolveObjectPosition(positionX, safeContainerWidth - width),\n\t\twidth,\n\t\theight,\n\t};\n}\n\ntype ZoomNaturalSize = {\n\tkey: string;\n\twidth: number;\n\theight: number;\n};\n\ntype ZoomImageRequest = {\n\tsrc?: string;\n\tsizes?: string;\n\tsrcSet?: string;\n};\n\ntype ZoomLayout = {\n\tsource: ZoomRect;\n\ttarget: ZoomRect;\n\trequestKey: string;\n\tsrc: string;\n\tsizes?: string;\n\tsrcSet?: string;\n\talt: string;\n\tborderRadius: string;\n\tobjectFit: CSSProperties['objectFit'];\n\tobjectPosition: string;\n\tsourceNaturalWidth: number;\n\tsourceNaturalHeight: number;\n\tzoomNaturalWidth: number;\n\tzoomNaturalHeight: number;\n};\n\ntype ZoomIcon = ElementType<{ className?: string }>;\n\nexport type ZoomProps = Omit, 'children'> & {\n\tchildren: ReactElement;\n\tactive?: boolean;\n\tdefaultActive?: boolean;\n\tonActiveChange?: (active: boolean) => void;\n\tdisabled?: boolean;\n\tisDisabled?: boolean;\n\tzoomMargin?: number;\n\tduration?: number;\n\tzoomImg?: ImgHTMLAttributes;\n\ta11yNameButtonZoom?: string;\n\ta11yNameButtonUnzoom?: string;\n\tdialogLabel?: string;\n\tclassDialog?: string;\n\toverlayClassName?: string;\n\tzoomImageClassName?: string;\n\tIconZoom?: ZoomIcon;\n\tIconUnzoom?: ZoomIcon;\n};\n\nexport function Zoom({\n\tchildren,\n\tactive,\n\tdefaultActive = false,\n\tonActiveChange,\n\tdisabled,\n\tisDisabled,\n\tzoomMargin = 24,\n\tduration = 220,\n\tzoomImg,\n\ta11yNameButtonZoom = '放大图片',\n\ta11yNameButtonUnzoom = '缩小图片',\n\tdialogLabel = '图片预览',\n\tclassDialog,\n\toverlayClassName,\n\tzoomImageClassName,\n\tIconZoom = ZoomIn,\n\tIconUnzoom = ZoomOut,\n\tclassName,\n\tref,\n\t...props\n}: ZoomProps) {\n\tconst rootRef = useRef(null);\n\tconst sourceButtonRef = useRef(null);\n\tconst closeButtonRef = useRef(null);\n\tconst dialogRef = useRef(null);\n\tconst returnFocusRef = useRef(null);\n\tconst zoomNaturalSizeRef = useRef(undefined);\n\tconst [uncontrolledActive, setUncontrolledActive] = useState(defaultActive);\n\tconst [present, setPresent] = useState(false);\n\tconst [expanded, setExpanded] = useState(false);\n\tconst [layout, setLayout] = useState();\n\tconst isControlled = active !== undefined;\n\tconst isActive = isControlled ? active : uncontrolledActive;\n\tconst unavailable = disabled || isDisabled;\n\tconst {\n\t\talt: zoomAlt,\n\t\tclassName: zoomImgClassName,\n\t\tonLoad: onZoomImageLoad,\n\t\tsizes: zoomSizes,\n\t\tsrc: zoomSrc,\n\t\tsrcSet: zoomSrcSet,\n\t\tstyle: zoomImgStyle,\n\t\t...zoomImgProps\n\t} = zoomImg ?? {};\n\n\tconst setRootRef = useCallback(\n\t\t(node: HTMLSpanElement | null) => {\n\t\t\trootRef.current = node;\n\t\t\tif (typeof ref === 'function') ref(node);\n\t\t\telse if (ref) ref.current = node;\n\t\t},\n\t\t[ref],\n\t);\n\n\tconst requestActiveChange = useCallback(\n\t\t(next: boolean) => {\n\t\t\tif (!isControlled) setUncontrolledActive(next);\n\t\t\tonActiveChange?.(next);\n\t\t},\n\t\t[isControlled, onActiveChange],\n\t);\n\n\tconst measure = useCallback(() => {\n\t\treturn measureZoomLayout(\n\t\t\trootRef.current,\n\t\t\tzoomMargin,\n\t\t\t{\n\t\t\t\tsrc: typeof zoomSrc === 'string' ? zoomSrc : undefined,\n\t\t\t\tsizes: zoomSizes,\n\t\t\t\tsrcSet: zoomSrcSet,\n\t\t\t},\n\t\t\tzoomNaturalSizeRef.current,\n\t\t);\n\t}, [zoomMargin, zoomSizes, zoomSrc, zoomSrcSet]);\n\n\tconst open = useCallback(() => {\n\t\tif (unavailable) return;\n\t\tconst next = measure();\n\t\tif (!next) return;\n\t\tsetLayout(next);\n\t\trequestActiveChange(true);\n\t}, [measure, requestActiveChange, unavailable]);\n\n\tconst close = useCallback(() => requestActiveChange(false), [requestActiveChange]);\n\n\tuseEffect(() => {\n\t\tif (unavailable && isActive) requestActiveChange(false);\n\t}, [isActive, requestActiveChange, unavailable]);\n\n\tuseEffect(() => {\n\t\tlet closeTimer: ReturnType | undefined;\n\t\tif (isActive && !unavailable) {\n\t\t\tconst next = measure();\n\t\t\tif (!next) {\n\t\t\t\trequestActiveChange(false);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetLayout(next);\n\t\t\tsetPresent(true);\n\t\t\treturn;\n\t\t}\n\t\tif (!present) return;\n\n\t\tsetExpanded(false);\n\t\tconst delay = prefersReducedMotion() ? 0 : nonNegativeOr(duration, 220);\n\t\tcloseTimer = setTimeout(() => {\n\t\t\tif (dialogRef.current?.open) dialogRef.current.close();\n\t\t\tsetPresent(false);\n\t\t\tsetLayout(undefined);\n\t\t\t(returnFocusRef.current ?? sourceButtonRef.current)?.focus({ preventScroll: true });\n\t\t\treturnFocusRef.current = null;\n\t\t}, delay);\n\t\treturn () => clearTimeout(closeTimer);\n\t}, [duration, isActive, measure, present, requestActiveChange, unavailable]);\n\n\tuseEffect(() => {\n\t\tif (!present || !isActive) return;\n\t\tconst dialog = dialogRef.current;\n\t\tif (dialog && !dialog.open) {\n\t\t\treturnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n\t\t\tdialog.showModal();\n\t\t}\n\t\tcloseButtonRef.current?.focus({ preventScroll: true });\n\t\tsetExpanded(false);\n\t\tlet secondFrame = 0;\n\t\tconst firstFrame = requestAnimationFrame(() => {\n\t\t\tsecondFrame = requestAnimationFrame(() => setExpanded(true));\n\t\t});\n\t\treturn () => {\n\t\t\tcancelAnimationFrame(firstFrame);\n\t\t\tcancelAnimationFrame(secondFrame);\n\t\t};\n\t}, [isActive, present]);\n\n\tuseEffect(() => {\n\t\tif (!present) return;\n\t\treturn acquireDocumentScrollLock();\n\t}, [present]);\n\n\tuseEffect(() => {\n\t\tif (!isActive) return;\n\t\tconst update = () => {\n\t\t\tconst next = measure();\n\t\t\tif (next) setLayout(next);\n\t\t\telse requestActiveChange(false);\n\t\t};\n\t\tupdate();\n\t\twindow.addEventListener('resize', update);\n\t\tconst image = rootRef.current?.querySelector('img');\n\t\tconst observer = image && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : undefined;\n\t\tif (image) observer?.observe(image);\n\t\treturn () => {\n\t\t\twindow.removeEventListener('resize', update);\n\t\t\tobserver?.disconnect();\n\t\t};\n\t}, [children, isActive, measure, requestActiveChange]);\n\n\tconst handleZoomImageLoad = (event: SyntheticEvent) => {\n\t\tconst image = event.currentTarget;\n\t\tsetLayout((current) => {\n\t\t\tif (!current) return current;\n\t\t\tconst size: ZoomNaturalSize = {\n\t\t\t\tkey: current.requestKey,\n\t\t\t\twidth: image.naturalWidth,\n\t\t\t\theight: image.naturalHeight,\n\t\t\t};\n\t\t\tzoomNaturalSizeRef.current = size;\n\t\t\treturn {\n\t\t\t\t...current,\n\t\t\t\tzoomNaturalWidth: size.width,\n\t\t\t\tzoomNaturalHeight: size.height,\n\t\t\t\ttarget: calculateZoomTargetRect({\n\t\t\t\t\tnaturalWidth: size.width,\n\t\t\t\t\tnaturalHeight: size.height,\n\t\t\t\t\tsourceWidth: current.source.width,\n\t\t\t\t\tsourceHeight: current.source.height,\n\t\t\t\t\tviewportWidth: window.innerWidth,\n\t\t\t\t\tviewportHeight: window.innerHeight,\n\t\t\t\t\tmargin: zoomMargin,\n\t\t\t\t}),\n\t\t\t};\n\t\t});\n\t\tonZoomImageLoad?.(event);\n\t};\n\n\tconst visibleRect = expanded ? layout?.target : layout?.source;\n\tconst visibleImageRect = layout\n\t\t? expanded\n\t\t\t? { top: 0, left: 0, width: layout.target.width, height: layout.target.height }\n\t\t\t: calculateZoomObjectFitRect({\n\t\t\t\t\tcontainerWidth: layout.source.width,\n\t\t\t\t\tcontainerHeight: layout.source.height,\n\t\t\t\t\tnaturalWidth: layout.sourceNaturalWidth,\n\t\t\t\t\tnaturalHeight: layout.sourceNaturalHeight,\n\t\t\t\t\tobjectFit: layout.objectFit,\n\t\t\t\t\tobjectPosition: layout.objectPosition,\n\t\t\t\t})\n\t\t: undefined;\n\tconst transitionDuration = prefersReducedMotion() ? 0 : nonNegativeOr(duration, 220);\n\tconst modal =\n\t\tpresent && layout && visibleRect && visibleImageRect && typeof document !== 'undefined'\n\t\t\t? createPortal(\n\t\t\t\t\t {\n\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\tclose();\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tonClose={() => {\n\t\t\t\t\t\t\tif (isActive) close();\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t,\n\t\t\t\t\tdocument.body,\n\t\t\t\t)\n\t\t\t: null;\n\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\t{children}\n\t\t\t\t\n\t\t\t\t\t{unavailable ? null : (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t\n\t\t\t{modal}\n\t\t\n\t);\n}\n\nfunction measureZoomLayout(\n\troot: HTMLElement | null,\n\tmargin: number,\n\tzoomRequest: ZoomImageRequest = {},\n\tcachedZoomSize?: ZoomNaturalSize,\n): ZoomLayout | undefined {\n\tconst image = root?.querySelector('img');\n\tif (!image) return undefined;\n\tconst rect = image.getBoundingClientRect();\n\tconst hasZoomRequest = Boolean(zoomRequest.src || zoomRequest.srcSet || zoomRequest.sizes);\n\tconst sourceSrc = image.getAttribute('src') || image.currentSrc || image.src;\n\tconst src = zoomRequest.src || sourceSrc;\n\tconst srcSet = hasZoomRequest ? zoomRequest.srcSet : image.getAttribute('srcset') || undefined;\n\tconst sizes = hasZoomRequest ? zoomRequest.sizes : image.getAttribute('sizes') || undefined;\n\tif (!src || rect.width <= 0 || rect.height <= 0) return undefined;\n\tconst sourceNaturalWidth = positiveOr(image.naturalWidth, rect.width);\n\tconst sourceNaturalHeight = positiveOr(image.naturalHeight, rect.height);\n\tconst requestKey = createZoomRequestKey(src, srcSet, sizes);\n\tconst zoomNaturalWidth =\n\t\tcachedZoomSize?.key === requestKey ? positiveOr(cachedZoomSize.width, sourceNaturalWidth) : sourceNaturalWidth;\n\tconst zoomNaturalHeight =\n\t\tcachedZoomSize?.key === requestKey ? positiveOr(cachedZoomSize.height, sourceNaturalHeight) : sourceNaturalHeight;\n\tconst imageStyle = getComputedStyle(image);\n\treturn {\n\t\tsource: {\n\t\t\ttop: rect.top,\n\t\t\tleft: rect.left,\n\t\t\twidth: rect.width,\n\t\t\theight: rect.height,\n\t\t},\n\t\ttarget: calculateZoomTargetRect({\n\t\t\tnaturalWidth: zoomNaturalWidth,\n\t\t\tnaturalHeight: zoomNaturalHeight,\n\t\t\tsourceWidth: rect.width,\n\t\t\tsourceHeight: rect.height,\n\t\t\tviewportWidth: window.innerWidth,\n\t\t\tviewportHeight: window.innerHeight,\n\t\t\tmargin,\n\t\t}),\n\t\trequestKey,\n\t\tsrc,\n\t\tsizes,\n\t\tsrcSet,\n\t\talt: image.alt,\n\t\tborderRadius: imageStyle.borderRadius,\n\t\tobjectFit: (imageStyle.objectFit || 'fill') as CSSProperties['objectFit'],\n\t\tobjectPosition: imageStyle.objectPosition || '50% 50%',\n\t\tsourceNaturalWidth,\n\t\tsourceNaturalHeight,\n\t\tzoomNaturalWidth,\n\t\tzoomNaturalHeight,\n\t};\n}\n\nfunction getZoomContainerStyle({\n\trect,\n\texpanded,\n\tborderRadius,\n\tduration,\n}: {\n\trect: ZoomRect;\n\texpanded: boolean;\n\tborderRadius: string;\n\tduration: number;\n}): CSSProperties {\n\treturn {\n\t\ttop: rect.top,\n\t\tleft: rect.left,\n\t\twidth: rect.width,\n\t\theight: rect.height,\n\t\tborderRadius: expanded ? '0.25rem' : borderRadius,\n\t\ttransitionProperty: 'top, left, width, height, border-radius',\n\t\ttransitionDuration: `${duration}ms`,\n\t\ttransitionTimingFunction: 'cubic-bezier(0.2, 0.8, 0.2, 1)',\n\t\twillChange: 'top, left, width, height',\n\t};\n}\n\nfunction getZoomImageStyle({\n\trect,\n\tduration,\n\tstyle,\n}: {\n\trect: ZoomRect;\n\tduration: number;\n\tstyle?: CSSProperties;\n}): CSSProperties {\n\treturn {\n\t\t...style,\n\t\tposition: 'absolute',\n\t\ttop: rect.top,\n\t\tleft: rect.left,\n\t\twidth: rect.width,\n\t\theight: rect.height,\n\t\tmaxWidth: 'none',\n\t\tmaxHeight: 'none',\n\t\tobjectFit: 'fill',\n\t\ttransitionProperty: 'top, left, width, height',\n\t\ttransitionDuration: `${duration}ms`,\n\t\ttransitionTimingFunction: 'cubic-bezier(0.2, 0.8, 0.2, 1)',\n\t\twillChange: 'top, left, width, height',\n\t};\n}\n\ntype ParsedObjectPosition = { relative: number; absolute: number };\n\nfunction parseObjectPosition(value: string): [ParsedObjectPosition, ParsedObjectPosition] {\n\tconst tokens = splitCssPositionComponents(value);\n\tif (tokens.length === 4) {\n\t\tif (isHorizontalPosition(tokens[0]) && isVerticalPosition(tokens[2])) {\n\t\t\treturn [parseEdgePosition(tokens[0], tokens[1]), parseEdgePosition(tokens[2], tokens[3])];\n\t\t}\n\t\tif (isVerticalPosition(tokens[0]) && isHorizontalPosition(tokens[2])) {\n\t\t\treturn [parseEdgePosition(tokens[2], tokens[3]), parseEdgePosition(tokens[0], tokens[1])];\n\t\t}\n\t}\n\tif (tokens.length === 3) {\n\t\tif (isHorizontalPosition(tokens[0]) && isVerticalPosition(tokens[2])) {\n\t\t\treturn [parseEdgePosition(tokens[0], tokens[1]), parseObjectPositionToken(tokens[2])];\n\t\t}\n\t\tif (isVerticalPosition(tokens[0]) && isHorizontalPosition(tokens[2])) {\n\t\t\treturn [parseObjectPositionToken(tokens[2]), parseEdgePosition(tokens[0], tokens[1])];\n\t\t}\n\t\tif (isHorizontalPosition(tokens[0]) && isVerticalPosition(tokens[1])) {\n\t\t\treturn [parseObjectPositionToken(tokens[0]), parseEdgePosition(tokens[1], tokens[2])];\n\t\t}\n\t\tif (isVerticalPosition(tokens[0]) && isHorizontalPosition(tokens[1])) {\n\t\t\treturn [parseEdgePosition(tokens[1], tokens[2]), parseObjectPositionToken(tokens[0])];\n\t\t}\n\t}\n\n\tlet [x = '50%', y = '50%'] = tokens;\n\tif (isVerticalPosition(x) || (x === 'center' && isHorizontalPosition(y))) [x, y] = [y, x];\n\treturn [parseObjectPositionToken(x), parseObjectPositionToken(y)];\n}\n\nfunction splitCssPositionComponents(value: string) {\n\tconst tokens: string[] = [];\n\tlet current = '';\n\tlet depth = 0;\n\tfor (const character of value.trim()) {\n\t\tif (character === '(') depth += 1;\n\t\tif (character === ')') depth = Math.max(0, depth - 1);\n\t\tif (/\\s/.test(character) && depth === 0) {\n\t\t\tif (current) tokens.push(current);\n\t\t\tcurrent = '';\n\t\t} else {\n\t\t\tcurrent += character;\n\t\t}\n\t}\n\tif (current) tokens.push(current);\n\treturn tokens;\n}\n\nfunction parseObjectPositionToken(token: string): ParsedObjectPosition {\n\tif (token === 'center') return { relative: 0.5, absolute: 0 };\n\tif (token === 'left' || token === 'top') return { relative: 0, absolute: 0 };\n\tif (token === 'right' || token === 'bottom') return { relative: 1, absolute: 0 };\n\tif (token.startsWith('calc(') && token.endsWith(')')) {\n\t\tconst terms = token\n\t\t\t.slice(5, -1)\n\t\t\t.replace(/\\s+/g, '')\n\t\t\t.match(/[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:%|px)/g);\n\t\tif (terms?.length) {\n\t\t\treturn terms.reduce(\n\t\t\t\t(result, term) => {\n\t\t\t\t\tconst value = Number.parseFloat(term);\n\t\t\t\t\tif (term.endsWith('%')) result.relative += value / 100;\n\t\t\t\t\telse result.absolute += value;\n\t\t\t\t\treturn result;\n\t\t\t\t},\n\t\t\t\t{ relative: 0, absolute: 0 },\n\t\t\t);\n\t\t}\n\t}\n\tif (token.endsWith('%')) {\n\t\tconst value = Number.parseFloat(token);\n\t\tif (Number.isFinite(value)) return { relative: value / 100, absolute: 0 };\n\t}\n\tif (token.endsWith('px') || token === '0') {\n\t\tconst value = Number.parseFloat(token);\n\t\tif (Number.isFinite(value)) return { relative: 0, absolute: value };\n\t}\n\treturn { relative: 0.5, absolute: 0 };\n}\n\nfunction parseEdgePosition(edge: string, offsetToken: string): ParsedObjectPosition {\n\tconst offset = parseObjectPositionToken(offsetToken);\n\treturn edge === 'right' || edge === 'bottom' ? { relative: 1 - offset.relative, absolute: -offset.absolute } : offset;\n}\n\nfunction isHorizontalPosition(value: string) {\n\treturn value === 'left' || value === 'right';\n}\n\nfunction isVerticalPosition(value: string) {\n\treturn value === 'top' || value === 'bottom';\n}\n\nfunction resolveObjectPosition(position: ParsedObjectPosition, remainingSpace: number) {\n\treturn remainingSpace * position.relative + position.absolute;\n}\n\nfunction createZoomRequestKey(src: string, srcSet?: string, sizes?: string) {\n\treturn JSON.stringify([normalizeZoomSourceKey(src), srcSet || '', sizes || '']);\n}\n\nfunction normalizeZoomSourceKey(src: string) {\n\ttry {\n\t\treturn new URL(src, typeof document === 'undefined' ? 'http://localhost/' : document.baseURI).href;\n\t} catch {\n\t\treturn src;\n\t}\n}\n\nlet documentScrollLockCount = 0;\nlet documentScrollLockPreviousOverflow = '';\n\nfunction acquireDocumentScrollLock() {\n\tconst root = document.documentElement;\n\tif (documentScrollLockCount === 0) {\n\t\tdocumentScrollLockPreviousOverflow = root.style.overflow;\n\t\troot.style.overflow = 'hidden';\n\t}\n\tdocumentScrollLockCount += 1;\n\tlet released = false;\n\treturn () => {\n\t\tif (released) return;\n\t\treleased = true;\n\t\tdocumentScrollLockCount = Math.max(0, documentScrollLockCount - 1);\n\t\tif (documentScrollLockCount === 0) {\n\t\t\troot.style.overflow = documentScrollLockPreviousOverflow;\n\t\t\tdocumentScrollLockPreviousOverflow = '';\n\t\t}\n\t};\n}\n\nfunction positiveOr(value: number, fallback: number) {\n\treturn Number.isFinite(value) && value > 0 ? value : fallback;\n}\n\nfunction nonNegativeOr(value: number, fallback: number) {\n\treturn Number.isFinite(value) && value >= 0 ? value : fallback;\n}\n\nfunction prefersReducedMotion() {\n\treturn (\n\t\ttypeof window !== 'undefined' &&\n\t\ttypeof window.matchMedia === 'function' &&\n\t\twindow.matchMedia('(prefers-reduced-motion: reduce)').matches\n\t);\n}\n", "type": "registry:component" } ], "type": "registry:component" }