@lobehub/ui 5.34.2 → 5.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/es/ActionIconGroup/ActionIconGroup.mjs +1 -1
  2. package/es/ActionIconGroup/ActionIconGroup.mjs.map +1 -1
  3. package/es/ActionIconGroup/type.d.mts +2 -2
  4. package/es/Alert/Alert.mjs +1 -1
  5. package/es/Alert/Alert.mjs.map +1 -1
  6. package/es/Burger/Burger.mjs +1 -1
  7. package/es/Burger/Burger.mjs.map +1 -1
  8. package/es/Burger/type.d.mts +2 -2
  9. package/es/CodeDiff/DiffPanel.mjs +1 -1
  10. package/es/CodeDiff/DiffPanel.mjs.map +1 -1
  11. package/es/CopyButton/CopyButton.mjs +1 -1
  12. package/es/CopyButton/CopyButton.mjs.map +1 -1
  13. package/es/CopyButton/type.d.mts +2 -2
  14. package/es/DownloadButton/DownloadButton.mjs +1 -1
  15. package/es/DownloadButton/DownloadButton.mjs.map +1 -1
  16. package/es/DownloadButton/type.d.mts +2 -2
  17. package/es/DraggablePanel/components/DraggablePanelHeader.mjs +1 -1
  18. package/es/DraggablePanel/components/DraggablePanelHeader.mjs.map +1 -1
  19. package/es/Drawer/Drawer.mjs +1 -1
  20. package/es/Drawer/Drawer.mjs.map +1 -1
  21. package/es/Drawer/type.d.mts +2 -2
  22. package/es/EditableText/ControlInput.d.mts +2 -2
  23. package/es/EditableText/ControlInput.mjs +1 -1
  24. package/es/EditableText/ControlInput.mjs.map +1 -1
  25. package/es/EditableText/EditableText.mjs +1 -1
  26. package/es/EditableText/EditableText.mjs.map +1 -1
  27. package/es/EmojiPicker/EmojiPicker.mjs +1 -1
  28. package/es/EmojiPicker/EmojiPicker.mjs.map +1 -1
  29. package/es/GuideCard/GuideCard.mjs +1 -1
  30. package/es/GuideCard/GuideCard.mjs.map +1 -1
  31. package/es/GuideCard/type.d.mts +2 -2
  32. package/es/Highlighter/FullFeatured.mjs +1 -1
  33. package/es/Highlighter/FullFeatured.mjs.map +1 -1
  34. package/es/Highlighter/type.d.mts +2 -2
  35. package/es/HotkeyInput/HotkeyInput.mjs +1 -1
  36. package/es/HotkeyInput/HotkeyInput.mjs.map +1 -1
  37. package/es/HtmlPreview/HtmlPreview.mjs +1 -1
  38. package/es/HtmlPreview/HtmlPreview.mjs.map +1 -1
  39. package/es/HtmlPreview/type.d.mts +2 -2
  40. package/es/Image/type.d.mts +1 -0
  41. package/es/Image/viewer/GalleryNav.mjs +1 -1
  42. package/es/Image/viewer/GalleryNav.mjs.map +1 -1
  43. package/es/Image/viewer/ImageViewer.mjs +75 -70
  44. package/es/Image/viewer/ImageViewer.mjs.map +1 -1
  45. package/es/Image/viewer/Toolbar.mjs +19 -7
  46. package/es/Image/viewer/Toolbar.mjs.map +1 -1
  47. package/es/Image/viewer/ViewerChrome.mjs +1 -1
  48. package/es/Image/viewer/ViewerChrome.mjs.map +1 -1
  49. package/es/Markdown/plugins/rehypeKatex.mjs +73 -0
  50. package/es/Markdown/plugins/rehypeKatex.mjs.map +1 -0
  51. package/es/Markdown/plugins/remarkCustomFootnotes.mjs +1 -2
  52. package/es/Markdown/plugins/remarkCustomFootnotes.mjs.map +1 -1
  53. package/es/Mermaid/FullFeatured.mjs +1 -1
  54. package/es/Mermaid/FullFeatured.mjs.map +1 -1
  55. package/es/Mermaid/type.d.mts +2 -2
  56. package/es/Modal/Modal.mjs +1 -1
  57. package/es/Modal/Modal.mjs.map +1 -1
  58. package/es/SortableList/components/DragHandle.d.mts +2 -2
  59. package/es/SortableList/components/DragHandle.mjs +1 -1
  60. package/es/SortableList/components/DragHandle.mjs.map +1 -1
  61. package/es/Tabs/Tabs.mjs +1 -1
  62. package/es/Tabs/Tabs.mjs.map +1 -1
  63. package/es/ThemeSwitch/ThemeSwitch.mjs +1 -1
  64. package/es/ThemeSwitch/ThemeSwitch.mjs.map +1 -1
  65. package/es/ThemeSwitch/type.d.mts +2 -2
  66. package/es/Toc/TocMobile.mjs +1 -1
  67. package/es/Toc/TocMobile.mjs.map +1 -1
  68. package/es/Video/index.mjs +1 -1
  69. package/es/Video/index.mjs.map +1 -1
  70. package/es/base-ui/Button/SplitButton.mjs +1 -1
  71. package/es/base-ui/Form/components/FormSubmitFooter.mjs +1 -1
  72. package/es/base-ui/Input/style.d.mts +1 -1
  73. package/es/base-ui/Modal/imperative.mjs +1 -1
  74. package/es/base-ui/Tag/Tag.mjs +1 -1
  75. package/es/base-ui/Tag/Tag.mjs.map +1 -1
  76. package/es/base-ui/Tag/style.mjs +8 -3
  77. package/es/base-ui/Tag/style.mjs.map +1 -1
  78. package/es/base-ui/Text/style.d.mts +1 -1
  79. package/es/base-ui/index.d.mts +4 -4
  80. package/es/base-ui/index.mjs +6 -6
  81. package/es/chat/ChatHeader/ChatHeader.mjs +1 -1
  82. package/es/chat/ChatHeader/ChatHeader.mjs.map +1 -1
  83. package/es/chat/EditableMessageList/EditableMessageList.mjs +1 -1
  84. package/es/chat/EditableMessageList/EditableMessageList.mjs.map +1 -1
  85. package/es/chat/TokenTag/TokenTag.mjs +1 -1
  86. package/es/chat/TokenTag/TokenTag.mjs.map +1 -1
  87. package/es/chat/TokenTag/type.d.mts +2 -2
  88. package/es/hooks/useMarkdown/useMarkdownRehypePlugins.mjs +1 -1
  89. package/es/hooks/useMarkdown/useMarkdownRehypePlugins.mjs.map +1 -1
  90. package/es/mdx/Mdx/index.mjs +1 -1
  91. package/es/mdx/Mdx/index.mjs.map +1 -1
  92. package/es/mobile/ChatHeader/ChatHeader.mjs +1 -1
  93. package/es/mobile/ChatHeader/ChatHeader.mjs.map +1 -1
  94. package/es/mobile/ChatInputArea/ChatInputArea.mjs +1 -1
  95. package/es/mobile/ChatInputArea/ChatInputArea.mjs.map +1 -1
  96. package/es/node_modules/.pnpm/unist-util-visit@5.1.0/node_modules/unist-util-visit/lib/index.mjs +2 -2
  97. package/package.json +4 -2
  98. package/es/node_modules/.pnpm/unist-util-is@6.0.1/node_modules/unist-util-is/lib/index.mjs +0 -117
  99. package/es/node_modules/.pnpm/unist-util-is@6.0.1/node_modules/unist-util-is/lib/index.mjs.map +0 -1
  100. package/es/node_modules/.pnpm/unist-util-visit-parents@6.0.2/node_modules/unist-util-visit-parents/lib/color.node.mjs +0 -12
  101. package/es/node_modules/.pnpm/unist-util-visit-parents@6.0.2/node_modules/unist-util-visit-parents/lib/color.node.mjs.map +0 -1
  102. package/es/node_modules/.pnpm/unist-util-visit-parents@6.0.2/node_modules/unist-util-visit-parents/lib/index.mjs +0 -330
  103. package/es/node_modules/.pnpm/unist-util-visit-parents@6.0.2/node_modules/unist-util-visit-parents/lib/index.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"HotkeyInput.mjs","names":["hotkeyMessages","useControlledState","Flexbox"],"sources":["../../src/HotkeyInput/HotkeyInput.tsx"],"sourcesContent":["'use client';\n\nimport { type InputRef } from 'antd';\nimport { cx, useThemeMode } from 'antd-style';\nimport { isEqual } from 'es-toolkit/compat';\nimport { Undo2Icon, XIcon } from 'lucide-react';\nimport {\n type FocusEvent,\n memo,\n type MouseEvent,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport { useHotkeys, useRecordHotkeys } from 'react-hotkeys-hook';\nimport useControlledState from 'use-merge-value';\n\nimport ActionIcon from '@/ActionIcon';\nimport { Flexbox } from '@/Flex';\nimport Hotkey from '@/Hotkey';\nimport { checkIsAppleDevice, NORMATIVE_MODIFIER, splitKeysByPlus } from '@/Hotkey/utils';\nimport hotkeyMessages from '@/i18n/resources/en/hotkey';\nimport { useTranslation } from '@/i18n/useTranslation';\n\nimport { styles, variants } from './style';\nimport { type HotkeyInputProps } from './type';\n\nconst HotkeyInput = memo<HotkeyInputProps>(\n ({\n value = '',\n defaultValue = '',\n resetValue = '',\n onChange,\n onClear,\n onConflict,\n placeholder,\n disabled,\n shadow,\n allowClear,\n allowReset = true,\n style,\n className,\n hotkeyConflicts = [],\n variant,\n texts,\n isApple,\n onBlur,\n onReset,\n onFocus,\n }) => {\n const [isFocused, setIsFocused] = useState(false);\n const [hasConflict, setHasConflict] = useState(false);\n const [hasInvalidCombination, setHasInvalidCombination] = useState(false);\n const inputRef = useRef<InputRef>(null);\n const { isDarkMode } = useThemeMode();\n const { t } = useTranslation(hotkeyMessages);\n const isAppleDevice = useMemo(() => checkIsAppleDevice(isApple), [isApple]);\n const [hotkeyValue, setHotkeyValue] = useControlledState(defaultValue, {\n defaultValue,\n onChange,\n value,\n });\n\n // 使用 useRecordHotkeys 处理快捷键录入\n const [recordedKeys, { start, stop, isRecording, resetKeys }] = useRecordHotkeys();\n\n useHotkeys(\n '*',\n () => {\n inputRef.current?.blur();\n },\n {\n enableOnContentEditable: true,\n enableOnFormTags: true,\n enabled: isRecording && !disabled,\n keydown: false,\n keyup: true,\n preventDefault: true,\n },\n );\n\n // 处理按键,保证格式正确:修饰键在前,最多一个非修饰键在后\n const formatKeys = useCallback((keysSet: Set<string>) => {\n const modifiers: string[] = [];\n const normalKeys: string[] = [];\n\n for (const key of keysSet) {\n // 处理不同表示的修饰键\n const normalizedKey: any = key.toLowerCase();\n if (NORMATIVE_MODIFIER.includes(normalizedKey)) {\n // 统一修饰键表示\n if (\n (!isAppleDevice && normalizedKey === 'ctrl') ||\n (isAppleDevice && normalizedKey === 'meta')\n ) {\n if (!modifiers.includes('mod')) modifiers.push('mod');\n } else if (!modifiers.includes(normalizedKey)) {\n modifiers.push(normalizedKey);\n }\n } else {\n normalKeys.push(key);\n }\n }\n\n // 至少需要一个修饰键\n if (modifiers.length === 0 && normalKeys.length > 0) {\n return { isValid: false, keys: [] };\n }\n\n // 只允许一个非修饰键,如果有多个,只保留最后一个\n const finalKey = normalKeys.length > 0 ? [normalKeys.at(-1)] : [];\n const shortcuts = [modifiers, finalKey];\n\n return {\n // 组合必须包含至少一个按键\n isValid: shortcuts.every((k) => k.length > 0),\n keys: shortcuts.flat(),\n };\n }, []);\n\n // 获取格式化后的按键字符串\n const { isValid, keys } = formatKeys(recordedKeys);\n const keysString = keys.join('+');\n\n // 检查快捷键冲突\n const checkHotkeyConflict = useCallback(\n (newHotkey: string): boolean => {\n return hotkeyConflicts\n .filter((conflictKey) => conflictKey !== resetValue)\n .some((conflictKey) => {\n const newKeys = splitKeysByPlus(newHotkey);\n const conflictKeys = splitKeysByPlus(conflictKey);\n return isEqual(newKeys, conflictKeys);\n });\n },\n [hotkeyConflicts],\n );\n\n // 当按键组合完成时处理结果\n useEffect(() => {\n if (recordedKeys.size > 0 && !isRecording) {\n if (!isValid) {\n setHasInvalidCombination(true);\n setHasConflict(false);\n return;\n }\n\n setHasInvalidCombination(false);\n const newKeysString = keysString;\n\n // 检查冲突\n const conflict = checkHotkeyConflict(newKeysString);\n if (conflict) {\n setHasConflict(true);\n onConflict?.(newKeysString);\n } else {\n setHasConflict(false);\n setHotkeyValue?.(newKeysString);\n }\n }\n }, [\n recordedKeys,\n isRecording,\n isValid,\n keysString,\n checkHotkeyConflict,\n setHotkeyValue,\n onConflict,\n ]);\n\n // 处理输入框焦点\n const handleFocus = (e: FocusEvent<HTMLInputElement>) => {\n if (disabled) return;\n setIsFocused(true);\n setHasConflict(false);\n setHasInvalidCombination(false);\n start(); // 开始记录\n onFocus?.(e);\n };\n\n const handleBlur = (e: FocusEvent<HTMLInputElement>) => {\n setIsFocused(false);\n stop(); // 停止记录\n onBlur?.(e);\n };\n\n const handleClear = (e: MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n setHotkeyValue?.('');\n resetKeys();\n setHasConflict(false);\n setHasInvalidCombination(false);\n setIsFocused(false);\n stop();\n onClear?.(hotkeyValue);\n };\n\n // 重置功能\n const handleReset = (e: MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n setHotkeyValue?.(resetValue);\n resetKeys();\n setHasConflict(false);\n setHasInvalidCombination(false);\n setIsFocused(false);\n stop(); // 停止记录\n onReset?.(hotkeyValue, resetValue);\n };\n\n const handleClick = (e: MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n if (disabled || isFocused) return;\n inputRef.current?.focus();\n };\n\n const placeholderText = placeholder ?? t('hotkey.placeholder');\n const resetTitle = texts?.reset ?? t('hotkey.reset');\n const clearTitle = texts?.clear ?? t('hotkey.clear');\n const conflictText = texts?.conflicts ?? t('hotkey.conflict');\n const invalidText = texts?.invalidCombination ?? t('hotkey.invalidCombination');\n\n return (\n <Flexbox\n className={className}\n gap={8}\n style={{\n position: 'relative',\n ...style,\n }}\n >\n <Flexbox\n horizontal\n align={'center'}\n justify={'space-between'}\n className={cx(\n variants({\n disabled,\n error: hasConflict || hasInvalidCombination,\n focused: isFocused,\n shadow,\n variant: variant || (isDarkMode ? 'filled' : 'outlined'),\n }),\n )}\n onClick={handleClick}\n >\n <div style={{ pointerEvents: 'none' }}>\n {isRecording ? (\n <span className={styles.placeholder}>\n {keys.length > 0 ? <Hotkey keys={keysString} /> : placeholderText}\n </span>\n ) : hotkeyValue ? (\n <Hotkey keys={hotkeyValue} />\n ) : (\n <span className={styles.placeholder}>{placeholderText}</span>\n )}\n </div>\n\n {/* 隐藏的输入框,用于接收焦点 */}\n <input\n readOnly\n className={styles.hiddenInput}\n disabled={disabled}\n ref={inputRef as any}\n style={{ pointerEvents: 'none' }}\n onBlur={handleBlur}\n onFocus={handleFocus}\n />\n\n {!isFocused && hotkeyValue && !disabled && (allowReset || allowClear) && (\n <Flexbox horizontal gap={4}>\n {allowReset && hotkeyValue !== resetValue && (\n <ActionIcon\n icon={Undo2Icon}\n size={'small'}\n title={resetTitle}\n variant={'filled'}\n onClick={handleReset}\n />\n )}\n {allowClear && (\n <ActionIcon\n icon={XIcon}\n size={'small'}\n title={clearTitle}\n variant={'filled'}\n onClick={handleClear}\n />\n )}\n </Flexbox>\n )}\n </Flexbox>\n {hasConflict && <div className={styles.errorText}>{conflictText}</div>}\n {hasInvalidCombination && <div className={styles.errorText}>{invalidText}</div>}\n </Flexbox>\n );\n },\n);\n\nHotkeyInput.displayName = 'HotkeyInput';\n\nexport default HotkeyInput;\n"],"mappings":"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"}
1
+ {"version":3,"file":"HotkeyInput.mjs","names":["hotkeyMessages","useControlledState","Flexbox"],"sources":["../../src/HotkeyInput/HotkeyInput.tsx"],"sourcesContent":["'use client';\n\nimport { type InputRef } from 'antd';\nimport { cx, useThemeMode } from 'antd-style';\nimport { isEqual } from 'es-toolkit/compat';\nimport { Undo2Icon, XIcon } from 'lucide-react';\nimport {\n type FocusEvent,\n memo,\n type MouseEvent,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport { useHotkeys, useRecordHotkeys } from 'react-hotkeys-hook';\nimport useControlledState from 'use-merge-value';\n\nimport ActionIcon from '@/base-ui/ActionIcon';\nimport { Flexbox } from '@/Flex';\nimport Hotkey from '@/Hotkey';\nimport { checkIsAppleDevice, NORMATIVE_MODIFIER, splitKeysByPlus } from '@/Hotkey/utils';\nimport hotkeyMessages from '@/i18n/resources/en/hotkey';\nimport { useTranslation } from '@/i18n/useTranslation';\n\nimport { styles, variants } from './style';\nimport { type HotkeyInputProps } from './type';\n\nconst HotkeyInput = memo<HotkeyInputProps>(\n ({\n value = '',\n defaultValue = '',\n resetValue = '',\n onChange,\n onClear,\n onConflict,\n placeholder,\n disabled,\n shadow,\n allowClear,\n allowReset = true,\n style,\n className,\n hotkeyConflicts = [],\n variant,\n texts,\n isApple,\n onBlur,\n onReset,\n onFocus,\n }) => {\n const [isFocused, setIsFocused] = useState(false);\n const [hasConflict, setHasConflict] = useState(false);\n const [hasInvalidCombination, setHasInvalidCombination] = useState(false);\n const inputRef = useRef<InputRef>(null);\n const { isDarkMode } = useThemeMode();\n const { t } = useTranslation(hotkeyMessages);\n const isAppleDevice = useMemo(() => checkIsAppleDevice(isApple), [isApple]);\n const [hotkeyValue, setHotkeyValue] = useControlledState(defaultValue, {\n defaultValue,\n onChange,\n value,\n });\n\n // 使用 useRecordHotkeys 处理快捷键录入\n const [recordedKeys, { start, stop, isRecording, resetKeys }] = useRecordHotkeys();\n\n useHotkeys(\n '*',\n () => {\n inputRef.current?.blur();\n },\n {\n enableOnContentEditable: true,\n enableOnFormTags: true,\n enabled: isRecording && !disabled,\n keydown: false,\n keyup: true,\n preventDefault: true,\n },\n );\n\n // 处理按键,保证格式正确:修饰键在前,最多一个非修饰键在后\n const formatKeys = useCallback((keysSet: Set<string>) => {\n const modifiers: string[] = [];\n const normalKeys: string[] = [];\n\n for (const key of keysSet) {\n // 处理不同表示的修饰键\n const normalizedKey: any = key.toLowerCase();\n if (NORMATIVE_MODIFIER.includes(normalizedKey)) {\n // 统一修饰键表示\n if (\n (!isAppleDevice && normalizedKey === 'ctrl') ||\n (isAppleDevice && normalizedKey === 'meta')\n ) {\n if (!modifiers.includes('mod')) modifiers.push('mod');\n } else if (!modifiers.includes(normalizedKey)) {\n modifiers.push(normalizedKey);\n }\n } else {\n normalKeys.push(key);\n }\n }\n\n // 至少需要一个修饰键\n if (modifiers.length === 0 && normalKeys.length > 0) {\n return { isValid: false, keys: [] };\n }\n\n // 只允许一个非修饰键,如果有多个,只保留最后一个\n const finalKey = normalKeys.length > 0 ? [normalKeys.at(-1)] : [];\n const shortcuts = [modifiers, finalKey];\n\n return {\n // 组合必须包含至少一个按键\n isValid: shortcuts.every((k) => k.length > 0),\n keys: shortcuts.flat(),\n };\n }, []);\n\n // 获取格式化后的按键字符串\n const { isValid, keys } = formatKeys(recordedKeys);\n const keysString = keys.join('+');\n\n // 检查快捷键冲突\n const checkHotkeyConflict = useCallback(\n (newHotkey: string): boolean => {\n return hotkeyConflicts\n .filter((conflictKey) => conflictKey !== resetValue)\n .some((conflictKey) => {\n const newKeys = splitKeysByPlus(newHotkey);\n const conflictKeys = splitKeysByPlus(conflictKey);\n return isEqual(newKeys, conflictKeys);\n });\n },\n [hotkeyConflicts],\n );\n\n // 当按键组合完成时处理结果\n useEffect(() => {\n if (recordedKeys.size > 0 && !isRecording) {\n if (!isValid) {\n setHasInvalidCombination(true);\n setHasConflict(false);\n return;\n }\n\n setHasInvalidCombination(false);\n const newKeysString = keysString;\n\n // 检查冲突\n const conflict = checkHotkeyConflict(newKeysString);\n if (conflict) {\n setHasConflict(true);\n onConflict?.(newKeysString);\n } else {\n setHasConflict(false);\n setHotkeyValue?.(newKeysString);\n }\n }\n }, [\n recordedKeys,\n isRecording,\n isValid,\n keysString,\n checkHotkeyConflict,\n setHotkeyValue,\n onConflict,\n ]);\n\n // 处理输入框焦点\n const handleFocus = (e: FocusEvent<HTMLInputElement>) => {\n if (disabled) return;\n setIsFocused(true);\n setHasConflict(false);\n setHasInvalidCombination(false);\n start(); // 开始记录\n onFocus?.(e);\n };\n\n const handleBlur = (e: FocusEvent<HTMLInputElement>) => {\n setIsFocused(false);\n stop(); // 停止记录\n onBlur?.(e);\n };\n\n const handleClear = (e: MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n setHotkeyValue?.('');\n resetKeys();\n setHasConflict(false);\n setHasInvalidCombination(false);\n setIsFocused(false);\n stop();\n onClear?.(hotkeyValue);\n };\n\n // 重置功能\n const handleReset = (e: MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n setHotkeyValue?.(resetValue);\n resetKeys();\n setHasConflict(false);\n setHasInvalidCombination(false);\n setIsFocused(false);\n stop(); // 停止记录\n onReset?.(hotkeyValue, resetValue);\n };\n\n const handleClick = (e: MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n if (disabled || isFocused) return;\n inputRef.current?.focus();\n };\n\n const placeholderText = placeholder ?? t('hotkey.placeholder');\n const resetTitle = texts?.reset ?? t('hotkey.reset');\n const clearTitle = texts?.clear ?? t('hotkey.clear');\n const conflictText = texts?.conflicts ?? t('hotkey.conflict');\n const invalidText = texts?.invalidCombination ?? t('hotkey.invalidCombination');\n\n return (\n <Flexbox\n className={className}\n gap={8}\n style={{\n position: 'relative',\n ...style,\n }}\n >\n <Flexbox\n horizontal\n align={'center'}\n justify={'space-between'}\n className={cx(\n variants({\n disabled,\n error: hasConflict || hasInvalidCombination,\n focused: isFocused,\n shadow,\n variant: variant || (isDarkMode ? 'filled' : 'outlined'),\n }),\n )}\n onClick={handleClick}\n >\n <div style={{ pointerEvents: 'none' }}>\n {isRecording ? (\n <span className={styles.placeholder}>\n {keys.length > 0 ? <Hotkey keys={keysString} /> : placeholderText}\n </span>\n ) : hotkeyValue ? (\n <Hotkey keys={hotkeyValue} />\n ) : (\n <span className={styles.placeholder}>{placeholderText}</span>\n )}\n </div>\n\n {/* 隐藏的输入框,用于接收焦点 */}\n <input\n readOnly\n className={styles.hiddenInput}\n disabled={disabled}\n ref={inputRef as any}\n style={{ pointerEvents: 'none' }}\n onBlur={handleBlur}\n onFocus={handleFocus}\n />\n\n {!isFocused && hotkeyValue && !disabled && (allowReset || allowClear) && (\n <Flexbox horizontal gap={4}>\n {allowReset && hotkeyValue !== resetValue && (\n <ActionIcon\n icon={Undo2Icon}\n size={'small'}\n title={resetTitle}\n variant={'filled'}\n onClick={handleReset}\n />\n )}\n {allowClear && (\n <ActionIcon\n icon={XIcon}\n size={'small'}\n title={clearTitle}\n variant={'filled'}\n onClick={handleClear}\n />\n )}\n </Flexbox>\n )}\n </Flexbox>\n {hasConflict && <div className={styles.errorText}>{conflictText}</div>}\n {hasInvalidCombination && <div className={styles.errorText}>{invalidText}</div>}\n </Flexbox>\n );\n },\n);\n\nHotkeyInput.displayName = 'HotkeyInput';\n\nexport default HotkeyInput;\n"],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import FlexBasic_default from "../Flex/FlexBasic.mjs";
3
3
  import { stopPropagation } from "../utils/dom.mjs";
4
- import ActionIcon from "../ActionIcon/ActionIcon.mjs";
4
+ import ActionIcon from "../base-ui/ActionIcon/ActionIcon.mjs";
5
5
  import SyntaxHighlighter from "../Highlighter/SyntaxHighlighter/index.mjs";
6
6
  import CopyButton from "../CopyButton/CopyButton.mjs";
7
7
  import { downloadBlob } from "../utils/downloadBlob.mjs";
@@ -1 +1 @@
1
- {"version":3,"file":"HtmlPreview.mjs","names":["Flexbox"],"sources":["../../src/HtmlPreview/HtmlPreview.tsx"],"sourcesContent":["'use client';\n\nimport { createStyles, cx, keyframes } from 'antd-style';\nimport { Download, Expand } from 'lucide-react';\nimport { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport ActionIcon from '@/ActionIcon';\nimport CopyButton from '@/CopyButton';\nimport { Flexbox } from '@/Flex';\nimport { actionsHoverCls, variants } from '@/Highlighter/style';\nimport SyntaxHighlighter from '@/Highlighter/SyntaxHighlighter';\nimport NeuralNetworkLoading from '@/NeuralNetworkLoading';\nimport Segmented from '@/Segmented';\nimport { stopPropagation } from '@/utils/dom';\nimport { downloadBlob } from '@/utils/downloadBlob';\n\nimport { containsScript, DEFAULT_HEIGHT, isFullHtmlDocument, isHtmlContentClosed } from './const';\nimport HtmlPreviewIframe from './Iframe';\nimport type { HtmlPreviewMode, HtmlPreviewProps } from './type';\n\n// Sheen sweep direction: left → right.\n// `background-position` works inversely from \"where the image is drawn\":\n// at `200%` the over-sized gradient starts off to the left of the\n// container, at `-200%` it ends off to the right — so animating\n// 200% → -200% moves the visible bright spot from left to right.\nconst shimmer = keyframes`\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n`;\n\nconst useStyles = createStyles(({ css, cssVar, isDarkMode }) => ({\n loadingBackdrop: css`\n pointer-events: none;\n\n position: absolute;\n z-index: 1;\n inset: 0;\n\n /* Subtle moving sheen so it doesn't look frozen. */\n background: linear-gradient(\n 90deg,\n transparent 0%,\n ${isDarkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.04)'} 50%,\n transparent 100%\n );\n background-repeat: no-repeat;\n background-size: 200% 100%;\n\n animation: ${shimmer} 1.6s ${cssVar.motionEaseInOut} infinite;\n `,\n loadingBadge: css`\n position: absolute;\n z-index: 2;\n inset-block-start: 12px;\n inset-inline-start: 12px;\n\n display: inline-flex;\n gap: 8px;\n align-items: center;\n\n padding-block: 4px;\n padding-inline: 6px 10px;\n border-radius: 999px;\n\n font-size: 12px;\n color: ${cssVar.colorTextDescription};\n\n background: ${cssVar.colorBgContainer};\n backdrop-filter: blur(8px);\n box-shadow: 0 0 0 1px ${cssVar.colorBorderSecondary};\n `,\n // The streaming source visible during Phase 1 — heavily faded so it\n // reads as \"this is preview-pending content\" rather than the finished\n // article. Auto-follows the tail so the user can see new tokens land\n // even on slow models.\n loadingSource: css`\n pointer-events: none;\n overflow: hidden;\n height: 100%;\n\n /* Faded out so the iframe transition feels like content lighting up,\n not like one document jump-cutting to another. */\n opacity: 0.45;\n\n /* SyntaxHighlighter sets its own background; flatten so the shimmer\n overlay reads cleanly on top. */\n & [data-code-type='highlighter'] {\n background: transparent;\n box-shadow: none;\n }\n\n /* Tail-follow is layout-only — we anchor the scrollable element to\n its scrollHeight via the ref + effect; CSS just keeps the\n overflow hidden. */\n & pre,\n & code {\n background: transparent !important;\n }\n `,\n loadingRoot: css`\n position: relative;\n overflow: hidden;\n background: ${isDarkMode ? '#1f1f1f' : '#fafafa'};\n `,\n // Inline top-right toolbar. Tagged with `actionsHoverCls` so the Highlighter\n // container's `&:hover .${actionsHoverCls} { opacity: 1 }` rule flips it\n // in/out as the user moves over the preview — same UX as the regular code\n // block actions.\n toolbar: cx(\n actionsHoverCls,\n css`\n position: absolute;\n z-index: 2;\n inset-block-start: 8px;\n inset-inline-end: 8px;\n\n padding: 4px;\n border-radius: ${cssVar.borderRadiusLG};\n\n opacity: 0;\n background: ${cssVar.colorBgContainer};\n backdrop-filter: blur(8px);\n box-shadow: 0 0 0 1px ${cssVar.colorBorderSecondary};\n\n transition: opacity 0.2s ${cssVar.motionEaseOut};\n\n &:focus-within {\n opacity: 1;\n }\n `,\n ),\n}));\n\nconst themeBackground = (theme?: 'light' | 'dark') => {\n if (theme === 'dark') return '#1f1f1f';\n if (theme === 'light') return '#ffffff';\n return undefined;\n};\n\nconst downloadHtml = async (content: string, fileName: string) => {\n const blob = new Blob([content], { type: 'text/html;charset=utf-8' });\n const url = URL.createObjectURL(blob);\n try {\n await downloadBlob(url, fileName);\n } finally {\n URL.revokeObjectURL(url);\n }\n};\n\nconst HtmlPreview = memo<HtmlPreviewProps>(\n ({\n actionIconSize,\n actionsRender,\n animated,\n bodyRender,\n children,\n className,\n classNames,\n copyable = true,\n defaultHeight,\n defaultMode = 'preview',\n downloadable = true,\n fileName,\n language = 'html',\n onExpand,\n sandbox,\n shadow,\n streamingMode = 'auto',\n style,\n styles: customStyles,\n theme,\n variant = 'filled',\n // `fullFeatured` / `showLanguage` / `defaultExpand` are accepted for API\n // compatibility with the rest of the Pre family but no longer drive a\n // separate header — the inline toolbar is always rendered.\n fullFeatured: _fullFeatured,\n showLanguage: _showLanguage,\n defaultExpand: _defaultExpand,\n ...rest\n }) => {\n const trimmedChildren = useMemo(() => (children || '').trim(), [children]);\n const isFragment = useMemo(() => !isFullHtmlDocument(trimmedChildren), [trimmedChildren]);\n\n // Per-session tracking. Reset on `animated` edge false → true.\n const [scriptLocked, setScriptLocked] = useState(false);\n const [headClosed, setHeadClosed] = useState(false);\n const [liveCommitted, setLiveCommitted] = useState(false);\n const prevAnimatedRef = useRef(animated);\n const lastCommitRef = useRef(0);\n const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const latestContentRef = useRef(trimmedChildren);\n useEffect(() => {\n latestContentRef.current = trimmedChildren;\n }, [trimmedChildren]);\n useEffect(() => {\n if (animated && !prevAnimatedRef.current) {\n setScriptLocked(false);\n setHeadClosed(false);\n setLiveCommitted(false);\n lastCommitRef.current = 0;\n if (pendingTimerRef.current) {\n clearTimeout(pendingTimerRef.current);\n pendingTimerRef.current = null;\n }\n }\n prevAnimatedRef.current = animated;\n }, [animated]);\n\n // Sticky script detection: once a `<script>` appears in this session,\n // auto mode locks into defer.\n useEffect(() => {\n if (animated && !scriptLocked && containsScript(trimmedChildren)) {\n setScriptLocked(true);\n }\n }, [trimmedChildren, animated, scriptLocked]);\n\n // Track whether the head section has closed. Until it does, the iframe\n // would render either nothing or invalid HTML (style tag mid-stream).\n // After `</head>` (or `</style>` as a fallback for documents that skip\n // an explicit head close) we know the visual baseline is locked in.\n useEffect(() => {\n if (animated && !headClosed) {\n const lowered = trimmedChildren.toLowerCase();\n if (lowered.includes('</head>') || lowered.includes('</style>')) {\n setHeadClosed(true);\n }\n }\n }, [trimmedChildren, animated, headClosed]);\n\n // Two-phase streaming commit:\n // Phase 1 (head still streaming) — don't mount iframe at all. There's\n // nothing meaningful to render and styles aren't applied yet.\n // Phase 2 (head closed, body streaming) — commit at most once every\n // ~250ms. Important: this is a TRUE throttle, not a debounce. The\n // pending timer is held in a ref so it survives effect re-runs;\n // when it fires it commits the latest content (via another ref)\n // and clears itself, allowing a fresh schedule. If chunks come in\n // faster than the throttle window (which they do at ~50/sec) we\n // still get a commit every 250ms instead of waiting for streaming\n // to pause.\n // On streaming end → flush immediately, cancel any pending timer.\n const [throttledContent, setThrottledContent] = useState(trimmedChildren);\n useEffect(() => {\n if (!animated) {\n if (pendingTimerRef.current) {\n clearTimeout(pendingTimerRef.current);\n pendingTimerRef.current = null;\n }\n lastCommitRef.current = Date.now();\n setThrottledContent(trimmedChildren);\n return;\n }\n if (!headClosed) return;\n\n const throttleMs = 250;\n const now = Date.now();\n const elapsed = now - lastCommitRef.current;\n\n if (elapsed >= throttleMs) {\n if (pendingTimerRef.current) {\n clearTimeout(pendingTimerRef.current);\n pendingTimerRef.current = null;\n }\n lastCommitRef.current = now;\n setThrottledContent(trimmedChildren);\n return;\n }\n\n // Schedule a future commit, but only if one isn't already pending —\n // every chunk arrival re-runs this effect; we don't want to reset\n // the timer each time (that's debounce, and during continuous\n // streaming it would never fire).\n if (pendingTimerRef.current === null) {\n pendingTimerRef.current = setTimeout(() => {\n lastCommitRef.current = Date.now();\n pendingTimerRef.current = null;\n setThrottledContent(latestContentRef.current);\n }, throttleMs - elapsed);\n }\n }, [trimmedChildren, animated, headClosed]);\n useEffect(\n () => () => {\n if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);\n },\n [],\n );\n\n // Live-streaming commitment is sticky for the rest of the session. The\n // decision is made the moment the head seals:\n // • `live` → commit unconditionally\n // • `auto` → commit only if no `<script>` has appeared yet (script-\n // bearing docs go down the defer path to avoid running setup() on\n // partial source)\n // • `defer` → never commit; wait for `</html>`\n // Sticky-ness matters for `auto`: if a `<script>` arrives *after* the\n // head has already closed and we've started live-streaming, we keep\n // streaming rather than yanking the rendered content back into a\n // loading state mid-flight. The shell→static swap at end of streaming\n // re-runs the document cleanly anyway.\n useEffect(() => {\n if (!animated || liveCommitted || !headClosed) return;\n if (streamingMode === 'live' || (streamingMode === 'auto' && !scriptLocked)) {\n setLiveCommitted(true);\n }\n }, [animated, headClosed, liveCommitted, scriptLocked, streamingMode]);\n\n // Streaming gate. The iframe can mount in three situations:\n // 1. content is no longer animating\n // 2. `</html>` has arrived\n // 3. live streaming has been committed this session\n const isStable = !animated || isHtmlContentClosed(trimmedChildren) || liveCommitted;\n\n const [mode, setMode] = useState<HtmlPreviewMode>(defaultMode);\n\n // Fragments cannot meaningfully render in preview — force source view.\n // For streaming content that's not yet stable we keep the user's mode\n // choice and substitute a loading placeholder in the body instead, so\n // the toggle UI doesn't flip back and forth as content arrives.\n const effectiveMode: HtmlPreviewMode = isFragment ? 'source' : mode;\n\n const contentRef = useRef(trimmedChildren);\n useEffect(() => {\n contentRef.current = trimmedChildren;\n }, [trimmedChildren]);\n\n // Tail-follow the streaming source visible during Phase 1 — anchor\n // the scroll position to the latest tokens so a slow model's output\n // doesn't sit pinned to the document head while the user waits.\n const loadingSourceRef = useRef<HTMLDivElement | null>(null);\n useEffect(() => {\n if (isStable) return;\n const node = loadingSourceRef.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n }, [trimmedChildren, isStable]);\n\n const getCopyContent = useCallback(() => contentRef.current, []);\n\n const handleDownload = useCallback(() => {\n void downloadHtml(contentRef.current, fileName || 'preview.html');\n }, [fileName]);\n\n const handleExpand = useCallback(() => {\n onExpand?.(contentRef.current);\n }, [onExpand]);\n\n const background = themeBackground(theme);\n\n const sourceBody = useMemo(\n () => (\n <SyntaxHighlighter\n animated={animated}\n className={classNames?.content}\n language={'html'}\n style={{ height: '100%', ...customStyles?.content }}\n variant={variant}\n >\n {trimmedChildren}\n </SyntaxHighlighter>\n ),\n [animated, classNames?.content, customStyles?.content, trimmedChildren, variant],\n );\n\n const { styles } = useStyles();\n\n const iframeBody = useMemo(\n () => (\n <HtmlPreviewIframe\n animated={animated}\n background={background}\n className={classNames?.iframe}\n content={throttledContent}\n defaultHeight={defaultHeight}\n sandbox={sandbox}\n style={customStyles?.iframe}\n />\n ),\n [\n background,\n classNames?.iframe,\n customStyles?.iframe,\n defaultHeight,\n sandbox,\n throttledContent,\n ],\n );\n\n // Shown when the user is on preview mode but the iframe isn't ready\n // yet (Phase 1 of streaming: head still arriving). Holds the eventual\n // iframe height to avoid layout shift on mount.\n //\n // Stream the raw source through `SyntaxHighlighter` at low opacity so\n // the user sees real progress on slow models (a 30-tps DeepSeek\n // pumping a ~5 KB head can otherwise sit on a static spinner for\n // 20+ seconds). A small \"Preparing preview…\" badge keeps the loading\n // state unambiguous. Tail-follow keeps the visible region anchored\n // to the latest tokens — see the `useEffect` below.\n const loadingBody = useMemo(\n () => (\n <div className={styles.loadingRoot} style={{ height: defaultHeight ?? DEFAULT_HEIGHT }}>\n <div className={styles.loadingSource} ref={loadingSourceRef}>\n <SyntaxHighlighter animated={animated} language={'html'} variant={'borderless'}>\n {trimmedChildren}\n </SyntaxHighlighter>\n </div>\n <div className={styles.loadingBackdrop} />\n <div className={styles.loadingBadge}>\n <NeuralNetworkLoading size={16} />\n <span>Preparing preview…</span>\n </div>\n </div>\n ),\n [animated, defaultHeight, styles, trimmedChildren],\n );\n\n const previewBody = isStable ? iframeBody : loadingBody;\n\n const defaultBody = effectiveMode === 'preview' ? previewBody : sourceBody;\n\n const body = useMemo(() => {\n if (!bodyRender) return defaultBody;\n return bodyRender({\n content: trimmedChildren,\n mode: effectiveMode,\n originalNode: defaultBody,\n });\n }, [bodyRender, defaultBody, effectiveMode, trimmedChildren]);\n\n const segmentOptions = useMemo(\n () => [\n { label: 'Preview', value: 'preview' as const },\n { label: 'Source', value: 'source' as const },\n ],\n [],\n );\n\n const iconSize = actionIconSize || 'small';\n\n const originalActions: ReactNode = (\n <>\n {!isFragment && (\n <Segmented\n options={segmentOptions}\n size={'small'}\n value={effectiveMode}\n onChange={(v) => setMode(v as HtmlPreviewMode)}\n />\n )}\n {copyable && <CopyButton content={getCopyContent} size={iconSize} />}\n {downloadable && (\n <ActionIcon\n icon={Download}\n size={iconSize}\n title={'Download HTML'}\n onClick={handleDownload}\n />\n )}\n {onExpand && (\n <ActionIcon\n icon={Expand}\n size={iconSize}\n title={'Open full preview'}\n onClick={handleExpand}\n />\n )}\n </>\n );\n\n const actions = actionsRender\n ? actionsRender({\n actionIconSize: iconSize,\n content: trimmedChildren,\n getContent: getCopyContent,\n mode: effectiveMode,\n originalNode: originalActions,\n setMode,\n })\n : originalActions;\n\n return (\n <div\n className={cx(variants({ shadow, variant }), className)}\n data-code-type=\"html-preview\"\n data-html-preview-language={language}\n style={style}\n {...rest}\n >\n <Flexbox\n horizontal\n align={'center'}\n className={cx(styles.toolbar, classNames?.header)}\n flex={'none'}\n gap={4}\n style={customStyles?.header}\n onClick={stopPropagation}\n >\n {actions}\n </Flexbox>\n {body}\n </div>\n );\n },\n);\n\nHtmlPreview.displayName = 'HtmlPreview';\n\nexport default HtmlPreview;\n"],"mappings":"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"}
1
+ {"version":3,"file":"HtmlPreview.mjs","names":["Flexbox"],"sources":["../../src/HtmlPreview/HtmlPreview.tsx"],"sourcesContent":["'use client';\n\nimport { createStyles, cx, keyframes } from 'antd-style';\nimport { Download, Expand } from 'lucide-react';\nimport { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport ActionIcon from '@/base-ui/ActionIcon';\nimport CopyButton from '@/CopyButton';\nimport { Flexbox } from '@/Flex';\nimport { actionsHoverCls, variants } from '@/Highlighter/style';\nimport SyntaxHighlighter from '@/Highlighter/SyntaxHighlighter';\nimport NeuralNetworkLoading from '@/NeuralNetworkLoading';\nimport Segmented from '@/Segmented';\nimport { stopPropagation } from '@/utils/dom';\nimport { downloadBlob } from '@/utils/downloadBlob';\n\nimport { containsScript, DEFAULT_HEIGHT, isFullHtmlDocument, isHtmlContentClosed } from './const';\nimport HtmlPreviewIframe from './Iframe';\nimport type { HtmlPreviewMode, HtmlPreviewProps } from './type';\n\n// Sheen sweep direction: left → right.\n// `background-position` works inversely from \"where the image is drawn\":\n// at `200%` the over-sized gradient starts off to the left of the\n// container, at `-200%` it ends off to the right — so animating\n// 200% → -200% moves the visible bright spot from left to right.\nconst shimmer = keyframes`\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n`;\n\nconst useStyles = createStyles(({ css, cssVar, isDarkMode }) => ({\n loadingBackdrop: css`\n pointer-events: none;\n\n position: absolute;\n z-index: 1;\n inset: 0;\n\n /* Subtle moving sheen so it doesn't look frozen. */\n background: linear-gradient(\n 90deg,\n transparent 0%,\n ${isDarkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.04)'} 50%,\n transparent 100%\n );\n background-repeat: no-repeat;\n background-size: 200% 100%;\n\n animation: ${shimmer} 1.6s ${cssVar.motionEaseInOut} infinite;\n `,\n loadingBadge: css`\n position: absolute;\n z-index: 2;\n inset-block-start: 12px;\n inset-inline-start: 12px;\n\n display: inline-flex;\n gap: 8px;\n align-items: center;\n\n padding-block: 4px;\n padding-inline: 6px 10px;\n border-radius: 999px;\n\n font-size: 12px;\n color: ${cssVar.colorTextDescription};\n\n background: ${cssVar.colorBgContainer};\n backdrop-filter: blur(8px);\n box-shadow: 0 0 0 1px ${cssVar.colorBorderSecondary};\n `,\n // The streaming source visible during Phase 1 — heavily faded so it\n // reads as \"this is preview-pending content\" rather than the finished\n // article. Auto-follows the tail so the user can see new tokens land\n // even on slow models.\n loadingSource: css`\n pointer-events: none;\n overflow: hidden;\n height: 100%;\n\n /* Faded out so the iframe transition feels like content lighting up,\n not like one document jump-cutting to another. */\n opacity: 0.45;\n\n /* SyntaxHighlighter sets its own background; flatten so the shimmer\n overlay reads cleanly on top. */\n & [data-code-type='highlighter'] {\n background: transparent;\n box-shadow: none;\n }\n\n /* Tail-follow is layout-only — we anchor the scrollable element to\n its scrollHeight via the ref + effect; CSS just keeps the\n overflow hidden. */\n & pre,\n & code {\n background: transparent !important;\n }\n `,\n loadingRoot: css`\n position: relative;\n overflow: hidden;\n background: ${isDarkMode ? '#1f1f1f' : '#fafafa'};\n `,\n // Inline top-right toolbar. Tagged with `actionsHoverCls` so the Highlighter\n // container's `&:hover .${actionsHoverCls} { opacity: 1 }` rule flips it\n // in/out as the user moves over the preview — same UX as the regular code\n // block actions.\n toolbar: cx(\n actionsHoverCls,\n css`\n position: absolute;\n z-index: 2;\n inset-block-start: 8px;\n inset-inline-end: 8px;\n\n padding: 4px;\n border-radius: ${cssVar.borderRadiusLG};\n\n opacity: 0;\n background: ${cssVar.colorBgContainer};\n backdrop-filter: blur(8px);\n box-shadow: 0 0 0 1px ${cssVar.colorBorderSecondary};\n\n transition: opacity 0.2s ${cssVar.motionEaseOut};\n\n &:focus-within {\n opacity: 1;\n }\n `,\n ),\n}));\n\nconst themeBackground = (theme?: 'light' | 'dark') => {\n if (theme === 'dark') return '#1f1f1f';\n if (theme === 'light') return '#ffffff';\n return undefined;\n};\n\nconst downloadHtml = async (content: string, fileName: string) => {\n const blob = new Blob([content], { type: 'text/html;charset=utf-8' });\n const url = URL.createObjectURL(blob);\n try {\n await downloadBlob(url, fileName);\n } finally {\n URL.revokeObjectURL(url);\n }\n};\n\nconst HtmlPreview = memo<HtmlPreviewProps>(\n ({\n actionIconSize,\n actionsRender,\n animated,\n bodyRender,\n children,\n className,\n classNames,\n copyable = true,\n defaultHeight,\n defaultMode = 'preview',\n downloadable = true,\n fileName,\n language = 'html',\n onExpand,\n sandbox,\n shadow,\n streamingMode = 'auto',\n style,\n styles: customStyles,\n theme,\n variant = 'filled',\n // `fullFeatured` / `showLanguage` / `defaultExpand` are accepted for API\n // compatibility with the rest of the Pre family but no longer drive a\n // separate header — the inline toolbar is always rendered.\n fullFeatured: _fullFeatured,\n showLanguage: _showLanguage,\n defaultExpand: _defaultExpand,\n ...rest\n }) => {\n const trimmedChildren = useMemo(() => (children || '').trim(), [children]);\n const isFragment = useMemo(() => !isFullHtmlDocument(trimmedChildren), [trimmedChildren]);\n\n // Per-session tracking. Reset on `animated` edge false → true.\n const [scriptLocked, setScriptLocked] = useState(false);\n const [headClosed, setHeadClosed] = useState(false);\n const [liveCommitted, setLiveCommitted] = useState(false);\n const prevAnimatedRef = useRef(animated);\n const lastCommitRef = useRef(0);\n const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const latestContentRef = useRef(trimmedChildren);\n useEffect(() => {\n latestContentRef.current = trimmedChildren;\n }, [trimmedChildren]);\n useEffect(() => {\n if (animated && !prevAnimatedRef.current) {\n setScriptLocked(false);\n setHeadClosed(false);\n setLiveCommitted(false);\n lastCommitRef.current = 0;\n if (pendingTimerRef.current) {\n clearTimeout(pendingTimerRef.current);\n pendingTimerRef.current = null;\n }\n }\n prevAnimatedRef.current = animated;\n }, [animated]);\n\n // Sticky script detection: once a `<script>` appears in this session,\n // auto mode locks into defer.\n useEffect(() => {\n if (animated && !scriptLocked && containsScript(trimmedChildren)) {\n setScriptLocked(true);\n }\n }, [trimmedChildren, animated, scriptLocked]);\n\n // Track whether the head section has closed. Until it does, the iframe\n // would render either nothing or invalid HTML (style tag mid-stream).\n // After `</head>` (or `</style>` as a fallback for documents that skip\n // an explicit head close) we know the visual baseline is locked in.\n useEffect(() => {\n if (animated && !headClosed) {\n const lowered = trimmedChildren.toLowerCase();\n if (lowered.includes('</head>') || lowered.includes('</style>')) {\n setHeadClosed(true);\n }\n }\n }, [trimmedChildren, animated, headClosed]);\n\n // Two-phase streaming commit:\n // Phase 1 (head still streaming) — don't mount iframe at all. There's\n // nothing meaningful to render and styles aren't applied yet.\n // Phase 2 (head closed, body streaming) — commit at most once every\n // ~250ms. Important: this is a TRUE throttle, not a debounce. The\n // pending timer is held in a ref so it survives effect re-runs;\n // when it fires it commits the latest content (via another ref)\n // and clears itself, allowing a fresh schedule. If chunks come in\n // faster than the throttle window (which they do at ~50/sec) we\n // still get a commit every 250ms instead of waiting for streaming\n // to pause.\n // On streaming end → flush immediately, cancel any pending timer.\n const [throttledContent, setThrottledContent] = useState(trimmedChildren);\n useEffect(() => {\n if (!animated) {\n if (pendingTimerRef.current) {\n clearTimeout(pendingTimerRef.current);\n pendingTimerRef.current = null;\n }\n lastCommitRef.current = Date.now();\n setThrottledContent(trimmedChildren);\n return;\n }\n if (!headClosed) return;\n\n const throttleMs = 250;\n const now = Date.now();\n const elapsed = now - lastCommitRef.current;\n\n if (elapsed >= throttleMs) {\n if (pendingTimerRef.current) {\n clearTimeout(pendingTimerRef.current);\n pendingTimerRef.current = null;\n }\n lastCommitRef.current = now;\n setThrottledContent(trimmedChildren);\n return;\n }\n\n // Schedule a future commit, but only if one isn't already pending —\n // every chunk arrival re-runs this effect; we don't want to reset\n // the timer each time (that's debounce, and during continuous\n // streaming it would never fire).\n if (pendingTimerRef.current === null) {\n pendingTimerRef.current = setTimeout(() => {\n lastCommitRef.current = Date.now();\n pendingTimerRef.current = null;\n setThrottledContent(latestContentRef.current);\n }, throttleMs - elapsed);\n }\n }, [trimmedChildren, animated, headClosed]);\n useEffect(\n () => () => {\n if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);\n },\n [],\n );\n\n // Live-streaming commitment is sticky for the rest of the session. The\n // decision is made the moment the head seals:\n // • `live` → commit unconditionally\n // • `auto` → commit only if no `<script>` has appeared yet (script-\n // bearing docs go down the defer path to avoid running setup() on\n // partial source)\n // • `defer` → never commit; wait for `</html>`\n // Sticky-ness matters for `auto`: if a `<script>` arrives *after* the\n // head has already closed and we've started live-streaming, we keep\n // streaming rather than yanking the rendered content back into a\n // loading state mid-flight. The shell→static swap at end of streaming\n // re-runs the document cleanly anyway.\n useEffect(() => {\n if (!animated || liveCommitted || !headClosed) return;\n if (streamingMode === 'live' || (streamingMode === 'auto' && !scriptLocked)) {\n setLiveCommitted(true);\n }\n }, [animated, headClosed, liveCommitted, scriptLocked, streamingMode]);\n\n // Streaming gate. The iframe can mount in three situations:\n // 1. content is no longer animating\n // 2. `</html>` has arrived\n // 3. live streaming has been committed this session\n const isStable = !animated || isHtmlContentClosed(trimmedChildren) || liveCommitted;\n\n const [mode, setMode] = useState<HtmlPreviewMode>(defaultMode);\n\n // Fragments cannot meaningfully render in preview — force source view.\n // For streaming content that's not yet stable we keep the user's mode\n // choice and substitute a loading placeholder in the body instead, so\n // the toggle UI doesn't flip back and forth as content arrives.\n const effectiveMode: HtmlPreviewMode = isFragment ? 'source' : mode;\n\n const contentRef = useRef(trimmedChildren);\n useEffect(() => {\n contentRef.current = trimmedChildren;\n }, [trimmedChildren]);\n\n // Tail-follow the streaming source visible during Phase 1 — anchor\n // the scroll position to the latest tokens so a slow model's output\n // doesn't sit pinned to the document head while the user waits.\n const loadingSourceRef = useRef<HTMLDivElement | null>(null);\n useEffect(() => {\n if (isStable) return;\n const node = loadingSourceRef.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n }, [trimmedChildren, isStable]);\n\n const getCopyContent = useCallback(() => contentRef.current, []);\n\n const handleDownload = useCallback(() => {\n void downloadHtml(contentRef.current, fileName || 'preview.html');\n }, [fileName]);\n\n const handleExpand = useCallback(() => {\n onExpand?.(contentRef.current);\n }, [onExpand]);\n\n const background = themeBackground(theme);\n\n const sourceBody = useMemo(\n () => (\n <SyntaxHighlighter\n animated={animated}\n className={classNames?.content}\n language={'html'}\n style={{ height: '100%', ...customStyles?.content }}\n variant={variant}\n >\n {trimmedChildren}\n </SyntaxHighlighter>\n ),\n [animated, classNames?.content, customStyles?.content, trimmedChildren, variant],\n );\n\n const { styles } = useStyles();\n\n const iframeBody = useMemo(\n () => (\n <HtmlPreviewIframe\n animated={animated}\n background={background}\n className={classNames?.iframe}\n content={throttledContent}\n defaultHeight={defaultHeight}\n sandbox={sandbox}\n style={customStyles?.iframe}\n />\n ),\n [\n background,\n classNames?.iframe,\n customStyles?.iframe,\n defaultHeight,\n sandbox,\n throttledContent,\n ],\n );\n\n // Shown when the user is on preview mode but the iframe isn't ready\n // yet (Phase 1 of streaming: head still arriving). Holds the eventual\n // iframe height to avoid layout shift on mount.\n //\n // Stream the raw source through `SyntaxHighlighter` at low opacity so\n // the user sees real progress on slow models (a 30-tps DeepSeek\n // pumping a ~5 KB head can otherwise sit on a static spinner for\n // 20+ seconds). A small \"Preparing preview…\" badge keeps the loading\n // state unambiguous. Tail-follow keeps the visible region anchored\n // to the latest tokens — see the `useEffect` below.\n const loadingBody = useMemo(\n () => (\n <div className={styles.loadingRoot} style={{ height: defaultHeight ?? DEFAULT_HEIGHT }}>\n <div className={styles.loadingSource} ref={loadingSourceRef}>\n <SyntaxHighlighter animated={animated} language={'html'} variant={'borderless'}>\n {trimmedChildren}\n </SyntaxHighlighter>\n </div>\n <div className={styles.loadingBackdrop} />\n <div className={styles.loadingBadge}>\n <NeuralNetworkLoading size={16} />\n <span>Preparing preview…</span>\n </div>\n </div>\n ),\n [animated, defaultHeight, styles, trimmedChildren],\n );\n\n const previewBody = isStable ? iframeBody : loadingBody;\n\n const defaultBody = effectiveMode === 'preview' ? previewBody : sourceBody;\n\n const body = useMemo(() => {\n if (!bodyRender) return defaultBody;\n return bodyRender({\n content: trimmedChildren,\n mode: effectiveMode,\n originalNode: defaultBody,\n });\n }, [bodyRender, defaultBody, effectiveMode, trimmedChildren]);\n\n const segmentOptions = useMemo(\n () => [\n { label: 'Preview', value: 'preview' as const },\n { label: 'Source', value: 'source' as const },\n ],\n [],\n );\n\n const iconSize = actionIconSize || 'small';\n\n const originalActions: ReactNode = (\n <>\n {!isFragment && (\n <Segmented\n options={segmentOptions}\n size={'small'}\n value={effectiveMode}\n onChange={(v) => setMode(v as HtmlPreviewMode)}\n />\n )}\n {copyable && <CopyButton content={getCopyContent} size={iconSize} />}\n {downloadable && (\n <ActionIcon\n icon={Download}\n size={iconSize}\n title={'Download HTML'}\n onClick={handleDownload}\n />\n )}\n {onExpand && (\n <ActionIcon\n icon={Expand}\n size={iconSize}\n title={'Open full preview'}\n onClick={handleExpand}\n />\n )}\n </>\n );\n\n const actions = actionsRender\n ? actionsRender({\n actionIconSize: iconSize,\n content: trimmedChildren,\n getContent: getCopyContent,\n mode: effectiveMode,\n originalNode: originalActions,\n setMode,\n })\n : originalActions;\n\n return (\n <div\n className={cx(variants({ shadow, variant }), className)}\n data-code-type=\"html-preview\"\n data-html-preview-language={language}\n style={style}\n {...rest}\n >\n <Flexbox\n horizontal\n align={'center'}\n className={cx(styles.toolbar, classNames?.header)}\n flex={'none'}\n gap={4}\n style={customStyles?.header}\n onClick={stopPropagation}\n >\n {actions}\n </Flexbox>\n {body}\n </div>\n );\n },\n);\n\nHtmlPreview.displayName = 'HtmlPreview';\n\nexport default HtmlPreview;\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import { DivProps } from "../types/index.mjs";
2
- import { ActionIconProps } from "../ActionIcon/type.mjs";
3
- import "../ActionIcon/index.mjs";
2
+ import { ActionIconProps } from "../base-ui/ActionIcon/type.mjs";
3
+ import "../base-ui/ActionIcon/index.mjs";
4
4
  import { CSSProperties, ReactNode, Ref } from "react";
5
5
  //#region src/HtmlPreview/type.d.ts
6
6
  type HtmlPreviewMode = 'preview' | 'source';
@@ -5,6 +5,7 @@ interface ImagePreviewOptions {
5
5
  autoZoomThreshold?: number;
6
6
  defaultZoom?: ZoomPolicy;
7
7
  maxScale?: number;
8
+ onDownload?: (source: string) => void | Promise<void>;
8
9
  onOpenChange?: (open: boolean) => void;
9
10
  src?: string;
10
11
  toolbarAddon?: ReactNode;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import Center from "../../Flex/Center.mjs";
3
- import ActionIcon from "../../ActionIcon/ActionIcon.mjs";
3
+ import ActionIcon from "../../base-ui/ActionIcon/ActionIcon.mjs";
4
4
  import { useTranslation } from "../../i18n/useTranslation.mjs";
5
5
  import { styles } from "../style.mjs";
6
6
  import image_default from "../../i18n/resources/en/image.mjs";
@@ -1 +1 @@
1
- {"version":3,"file":"GalleryNav.mjs","names":["imageMessages"],"sources":["../../../src/Image/viewer/GalleryNav.tsx"],"sourcesContent":["'use client';\n\nimport { cx } from 'antd-style';\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\nimport { memo } from 'react';\n\nimport ActionIcon from '@/ActionIcon';\nimport { Center } from '@/Flex';\nimport imageMessages from '@/i18n/resources/en/image';\nimport { useTranslation } from '@/i18n/useTranslation';\n\nimport { styles } from '../style';\n\nexport interface GalleryNavProps {\n current: number;\n hasNext: boolean;\n hasPrev: boolean;\n next: () => void;\n prev: () => void;\n total: number;\n}\n\nconst GalleryNav = memo<GalleryNavProps>(({ current, hasNext, hasPrev, next, prev, total }) => {\n const { t } = useTranslation(imageMessages);\n\n return (\n <>\n {hasPrev && (\n <ActionIcon\n className={cx(styles.viewerNavButton, styles.viewerNavPrev)}\n icon={ChevronLeft}\n size={'large'}\n title={t('image.prev')}\n onClick={prev}\n />\n )}\n {hasNext && (\n <ActionIcon\n className={cx(styles.viewerNavButton, styles.viewerNavNext)}\n icon={ChevronRight}\n size={'large'}\n title={t('image.next')}\n onClick={next}\n />\n )}\n <Center horizontal className={styles.viewerCounter}>\n {current + 1} / {total}\n </Center>\n </>\n );\n});\n\nGalleryNav.displayName = 'GalleryNav';\n\nexport default GalleryNav;\n"],"mappings":";;;;;;;;;;;AAsBA,MAAM,aAAa,MAAuB,EAAE,SAAS,SAAS,SAAS,MAAM,MAAM,YAAY;CAC7F,MAAM,EAAE,MAAM,eAAeA,aAAa;CAE1C,OACE,qBAAA,YAAA,EAAA,UAAA;EACG,WACC,oBAAC,YAAD;GACE,WAAW,GAAG,OAAO,iBAAiB,OAAO,aAAa;GAC1D,MAAM;GACN,MAAM;GACN,OAAO,EAAE,YAAY;GACrB,SAAS;EACV,CAAA;EAEF,WACC,oBAAC,YAAD;GACE,WAAW,GAAG,OAAO,iBAAiB,OAAO,aAAa;GAC1D,MAAM;GACN,MAAM;GACN,OAAO,EAAE,YAAY;GACrB,SAAS;EACV,CAAA;EAEH,qBAAC,QAAD;GAAQ,YAAA;GAAW,WAAW,OAAO;GAArC,UAAA;IACG,UAAU;IAAE;IAAI;GACX;;CACR,EAAA,CAAA;AAEN,CAAC;AAED,WAAW,cAAc"}
1
+ {"version":3,"file":"GalleryNav.mjs","names":["imageMessages"],"sources":["../../../src/Image/viewer/GalleryNav.tsx"],"sourcesContent":["'use client';\n\nimport { cx } from 'antd-style';\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\nimport { memo } from 'react';\n\nimport ActionIcon from '@/base-ui/ActionIcon';\nimport { Center } from '@/Flex';\nimport imageMessages from '@/i18n/resources/en/image';\nimport { useTranslation } from '@/i18n/useTranslation';\n\nimport { styles } from '../style';\n\nexport interface GalleryNavProps {\n current: number;\n hasNext: boolean;\n hasPrev: boolean;\n next: () => void;\n prev: () => void;\n total: number;\n}\n\nconst GalleryNav = memo<GalleryNavProps>(({ current, hasNext, hasPrev, next, prev, total }) => {\n const { t } = useTranslation(imageMessages);\n\n return (\n <>\n {hasPrev && (\n <ActionIcon\n className={cx(styles.viewerNavButton, styles.viewerNavPrev)}\n icon={ChevronLeft}\n size={'large'}\n title={t('image.prev')}\n onClick={prev}\n />\n )}\n {hasNext && (\n <ActionIcon\n className={cx(styles.viewerNavButton, styles.viewerNavNext)}\n icon={ChevronRight}\n size={'large'}\n title={t('image.next')}\n onClick={next}\n />\n )}\n <Center horizontal className={styles.viewerCounter}>\n {current + 1} / {total}\n </Center>\n </>\n );\n});\n\nGalleryNav.displayName = 'GalleryNav';\n\nexport default GalleryNav;\n"],"mappings":";;;;;;;;;;;AAsBA,MAAM,aAAa,MAAuB,EAAE,SAAS,SAAS,SAAS,MAAM,MAAM,YAAY;CAC7F,MAAM,EAAE,MAAM,eAAeA,aAAa;CAE1C,OACE,qBAAA,YAAA,EAAA,UAAA;EACG,WACC,oBAAC,YAAD;GACE,WAAW,GAAG,OAAO,iBAAiB,OAAO,aAAa;GAC1D,MAAM;GACN,MAAM;GACN,OAAO,EAAE,YAAY;GACrB,SAAS;EACV,CAAA;EAEF,WACC,oBAAC,YAAD;GACE,WAAW,GAAG,OAAO,iBAAiB,OAAO,aAAa;GAC1D,MAAM;GACN,MAAM;GACN,OAAO,EAAE,YAAY;GACrB,SAAS;EACV,CAAA;EAEH,qBAAC,QAAD;GAAQ,YAAA;GAAW,WAAW,OAAO;GAArC,UAAA;IACG,UAAU;IAAE;IAAI;GACX;;CACR,EAAA,CAAA;AAEN,CAAC;AAED,WAAW,cAAc"}
@@ -15,6 +15,7 @@ import { useZoomPan } from "./useZoomPan.mjs";
15
15
  import ViewerChrome from "./ViewerChrome.mjs";
16
16
  import { memo, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
17
17
  import { jsx, jsxs } from "react/jsx-runtime";
18
+ import { motion } from "motion/react";
18
19
  import { useMergeRefs } from "react-merge-refs";
19
20
  import { Dialog } from "@base-ui/react/dialog";
20
21
  //#region src/Image/viewer/ImageViewer.tsx
@@ -204,76 +205,80 @@ const ImageViewer = memo(({ entries, index, openerFocusElement, token }) => {
204
205
  useEffect(() => () => {
205
206
  if (!openerEntry.element.isConnected) endClosePreview(token);
206
207
  }, [openerEntry, token]);
207
- return /* @__PURE__ */ jsx(Dialog.Root, {
208
- modal: true,
209
- open: true,
210
- onOpenChange: handleDialogOpenChange,
211
- children: /* @__PURE__ */ jsxs(Dialog.Portal, {
212
- container: appElement ?? void 0,
213
- children: [
214
- /* @__PURE__ */ jsx(Dialog.Backdrop, {
215
- forceRender: true,
216
- className: styles.viewerBackdrop,
217
- ref: backdropRef,
218
- style: zIndex === void 0 ? void 0 : { zIndex },
219
- onClick: gestures.onSurfaceClick
220
- }),
221
- /* @__PURE__ */ jsxs(Dialog.Popup, {
222
- "aria-label": currentEntry.element.alt || void 0,
223
- className: styles.viewerPopup,
224
- finalFocus,
225
- initialFocus: popupNodeRef,
226
- ref: popupRef,
227
- style: zIndex === void 0 ? void 0 : { zIndex: zIndex + 1 },
228
- tabIndex: -1,
229
- onClick: gestures.onSurfaceClick,
230
- onPointerCancel: gestures.onPointerFinish,
231
- onPointerDown: gestures.onPointerDown,
232
- onPointerMove: gestures.onPointerMove,
233
- onPointerUp: gestures.onPointerFinish,
234
- children: [/* @__PURE__ */ jsx("img", {
235
- alt: currentEntry.element.alt,
236
- className: styles.viewerImage,
237
- ref: imageRef,
238
- src: source,
239
- style: {
240
- cursor: gestures.cursor,
241
- height: imageRect.height,
242
- left: imageRect.x,
243
- top: imageRect.y,
244
- width: imageRect.width
245
- },
246
- onClick: gestures.onImageClick,
247
- onDoubleClick: gestures.onImageDoubleClick,
248
- onLoad: handleLoad
249
- }), /* @__PURE__ */ jsx(ViewerChrome, {
250
- canZoomIn,
251
- canZoomOut,
252
- chromeRef,
253
- current: currentIndex,
254
- fitRect,
255
- flipHorizontal,
256
- flipVertical,
257
- hasNext,
258
- hasPrev,
259
- natural,
260
- next,
261
- prev,
262
- rotateLeft,
263
- rotateRight,
264
- rotation,
265
- scale,
266
- source,
267
- toggleActualSize,
268
- toolbarAddon: currentEntry.options.toolbarAddon,
269
- total: entries.length,
270
- zoomIn,
271
- zoomOut,
272
- onClose: handleClose
273
- })]
274
- }),
275
- /* @__PURE__ */ jsx(ToastHost, { root: appElement ?? void 0 })
276
- ]
208
+ return /* @__PURE__ */ jsx(MotionComponent, {
209
+ value: motionComponent ?? motion,
210
+ children: /* @__PURE__ */ jsx(Dialog.Root, {
211
+ modal: true,
212
+ open: true,
213
+ onOpenChange: handleDialogOpenChange,
214
+ children: /* @__PURE__ */ jsxs(Dialog.Portal, {
215
+ container: appElement ?? void 0,
216
+ children: [
217
+ /* @__PURE__ */ jsx(Dialog.Backdrop, {
218
+ forceRender: true,
219
+ className: styles.viewerBackdrop,
220
+ ref: backdropRef,
221
+ style: zIndex === void 0 ? void 0 : { zIndex },
222
+ onClick: gestures.onSurfaceClick
223
+ }),
224
+ /* @__PURE__ */ jsxs(Dialog.Popup, {
225
+ "aria-label": currentEntry.element.alt || void 0,
226
+ className: styles.viewerPopup,
227
+ finalFocus,
228
+ initialFocus: popupNodeRef,
229
+ ref: popupRef,
230
+ style: zIndex === void 0 ? void 0 : { zIndex: zIndex + 1 },
231
+ tabIndex: -1,
232
+ onClick: gestures.onSurfaceClick,
233
+ onPointerCancel: gestures.onPointerFinish,
234
+ onPointerDown: gestures.onPointerDown,
235
+ onPointerMove: gestures.onPointerMove,
236
+ onPointerUp: gestures.onPointerFinish,
237
+ children: [/* @__PURE__ */ jsx("img", {
238
+ alt: currentEntry.element.alt,
239
+ className: styles.viewerImage,
240
+ ref: imageRef,
241
+ src: source,
242
+ style: {
243
+ cursor: gestures.cursor,
244
+ height: imageRect.height,
245
+ left: imageRect.x,
246
+ top: imageRect.y,
247
+ width: imageRect.width
248
+ },
249
+ onClick: gestures.onImageClick,
250
+ onDoubleClick: gestures.onImageDoubleClick,
251
+ onLoad: handleLoad
252
+ }), /* @__PURE__ */ jsx(ViewerChrome, {
253
+ canZoomIn,
254
+ canZoomOut,
255
+ chromeRef,
256
+ current: currentIndex,
257
+ fitRect,
258
+ flipHorizontal,
259
+ flipVertical,
260
+ hasNext,
261
+ hasPrev,
262
+ natural,
263
+ next,
264
+ prev,
265
+ rotateLeft,
266
+ rotateRight,
267
+ rotation,
268
+ scale,
269
+ source,
270
+ toggleActualSize,
271
+ toolbarAddon: currentEntry.options.toolbarAddon,
272
+ total: entries.length,
273
+ zoomIn,
274
+ zoomOut,
275
+ onClose: handleClose,
276
+ onDownload: currentEntry.options.onDownload
277
+ })]
278
+ }),
279
+ /* @__PURE__ */ jsx(ToastHost, { root: appElement ?? void 0 })
280
+ ]
281
+ })
277
282
  })
278
283
  });
279
284
  });
@@ -1 +1 @@
1
- {"version":3,"file":"ImageViewer.mjs","names":[],"sources":["../../../src/Image/viewer/ImageViewer.tsx"],"sourcesContent":["'use client';\n\nimport { Dialog, type DialogRootProps } from '@base-ui/react/dialog';\nimport {\n memo,\n type SyntheticEvent,\n use,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport { useMergeRefs } from 'react-merge-refs';\n\nimport { ToastHost } from '@/base-ui/Toast';\nimport { useLayerZIndex } from '@/base-ui/zIndex';\nimport { MotionComponent } from '@/MotionProvider';\nimport { useAppElement } from '@/ThemeProvider';\n\nimport { styles } from '../style';\nimport { computeFit, type Size, unrotatedRect } from './geometry';\nimport { beginClosePreview, endClosePreview, type PreviewEntry } from './registry';\nimport { useFinalFocus } from './useFinalFocus';\nimport { useFlipTransition } from './useFlipTransition';\nimport { readNatural, useGalleryNav } from './useGalleryNav';\nimport { useRefitTransition } from './useRefitTransition';\nimport { useViewerGestures } from './useViewerGestures';\nimport { useZoomPan } from './useZoomPan';\nimport ViewerChrome from './ViewerChrome';\n\nexport interface ImageViewerProps {\n entries: PreviewEntry[];\n index: number;\n openerFocusElement: HTMLElement | null;\n token: number;\n}\n\nconst readViewport = (): Size => ({ height: window.innerHeight, width: window.innerWidth });\n\nconst prefersReducedMotion = () =>\n window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n\nconst ImageViewer = memo<ImageViewerProps>(({ entries, index, openerFocusElement, token }) => {\n const openerEntry = entries[index];\n const appElement = useAppElement();\n const motionComponent = use(MotionComponent);\n const [animated] = useState(() => !!motionComponent && !prefersReducedMotion());\n\n const { ref: layerRef, zIndex } = useLayerZIndex<HTMLDivElement>('modal');\n\n const [currentIndex, setCurrentIndex] = useState(index);\n const currentEntry = entries[currentIndex];\n const currentEntryRef = useRef(currentEntry);\n currentEntryRef.current = currentEntry;\n\n const [source, setSource] = useState(openerEntry.src);\n const [natural, setNatural] = useState<Size>(() => readNatural(openerEntry.element));\n const [viewport, setViewport] = useState<Size>(readViewport);\n\n const closeRef = useRef<(() => void) | null>(null);\n const requestClose = useCallback(() => closeRef.current?.(), []);\n\n // useZoomPan needs to gate its wheel/toolbar entry points on close-in-flight,\n // but that state only exists once useFlipTransition (below) has run — and it\n // needs useZoomPan's own transform values first. Break the cycle with a\n // stable indirection: the ref is repointed at the real isClosing once it's\n // available, written directly during render like the other *Ref fields here.\n const isClosingIndirectRef = useRef<() => boolean>(() => false);\n const isClosingIndirect = useCallback(() => isClosingIndirectRef.current(), []);\n\n const isTransitioningIndirectRef = useRef<() => boolean>(() => true);\n const isTransitioningIndirect = useCallback(() => isTransitioningIndirectRef.current(), []);\n\n const fillViewport = Boolean(\n currentEntry.previewSrc && currentEntry.previewSrc !== currentEntry.src,\n );\n\n const {\n canZoomIn,\n canZoomOut,\n dragBy,\n dragEnd,\n escIntent,\n flipHorizontal,\n flipVertical,\n flipX,\n flipY,\n getInitialScale,\n handleDoubleClick,\n handleWheel,\n isClean,\n isZoomed,\n reset,\n resetCloseTracking,\n rotate,\n rotateLeft,\n rotateRight,\n rotation,\n scale,\n setNatural: syncZoomNatural,\n setViewport: syncZoomViewport,\n toggleActualSize,\n x,\n y,\n zoomIn,\n zoomOut,\n } = useZoomPan({\n autoZoomThreshold: currentEntry.options.autoZoomThreshold,\n defaultZoom: currentEntry.options.defaultZoom,\n fillViewport,\n isClosing: isClosingIndirect,\n isTransitioning: isTransitioningIndirect,\n maxScale: currentEntry.options.maxScale,\n natural,\n onCloseRequest: requestClose,\n viewport,\n });\n\n const fitRect = useMemo(\n () => computeFit(natural, viewport, rotation, fillViewport),\n [natural, viewport, rotation, fillViewport],\n );\n const fitRectRef = useRef(fitRect);\n fitRectRef.current = fitRect;\n const getFitRect = useCallback(() => fitRectRef.current, []);\n\n const imageRect = useMemo(() => unrotatedRect(fitRect, rotation), [fitRect, rotation]);\n\n const viewportRef = useRef(viewport);\n viewportRef.current = viewport;\n const getViewportWidth = useCallback(() => viewportRef.current.width, []);\n\n const getCloseSource = useCallback(() => currentEntryRef.current.element, []);\n\n const transform = useMemo(\n () => ({ flipX, flipY, rotate, scale, x, y }),\n [flipX, flipY, rotate, scale, x, y],\n );\n const handleClosed = useCallback(() => endClosePreview(token), [token]);\n\n const {\n backdropRef,\n chromeRef,\n close,\n imageRef: flipImageRef,\n isClosing,\n isTransitioning,\n switchTo,\n } = useFlipTransition({\n animated,\n getCloseSource,\n getFitRect,\n getInitialScale,\n getViewportWidth,\n onClosed: handleClosed,\n source: openerEntry.element,\n transform,\n });\n isClosingIndirectRef.current = isClosing;\n isTransitioningIndirectRef.current = isTransitioning;\n\n useRefitTransition({\n animated,\n fillViewport,\n isTransitioning,\n natural,\n rotation,\n scale,\n viewport,\n x,\n y,\n });\n const finalFocus = useFinalFocus(openerFocusElement);\n\n const isCleanRef = useRef(isClean);\n isCleanRef.current = isClean;\n\n const handleClose = useCallback(() => {\n beginClosePreview(token);\n close({ fade: !isCleanRef.current });\n }, [close, token]);\n closeRef.current = handleClose;\n\n const handleDialogOpenChange = useCallback<NonNullable<DialogRootProps['onOpenChange']>>(\n (open, eventDetails) => {\n if (open) return;\n // Base UI fires this on every Esc regardless of whether we're already\n // mid-close (our Dialog.Root's open prop never actually flips to\n // false), so without this guard a second Esc during the close window\n // reads escIntent() off the close spring's mid-flight scale, decides\n // 'reset', and cancels the close animation out from under itself.\n if (isClosing()) return;\n if (eventDetails.reason === 'escape-key' && escIntent() === 'reset') {\n eventDetails.cancel();\n reset();\n return;\n }\n handleClose();\n },\n [escIntent, handleClose, isClosing, reset],\n );\n\n const gestures = useViewerGestures({\n dragBy,\n dragEnd,\n handleDoubleClick,\n handleWheel,\n isClean,\n isZoomed,\n onClose: handleClose,\n reset,\n zoomIn,\n zoomOut,\n });\n\n const { hasNext, hasPrev, next, prev } = useGalleryNav({\n cancelPendingClose: gestures.cancelPendingClose,\n currentIndex,\n entries,\n flipX,\n flipY,\n getInitialScale,\n resetCloseTracking,\n rotate,\n scale,\n setCurrentIndex,\n setNatural,\n setSource,\n switchTo,\n syncZoomNatural,\n x,\n y,\n });\n\n const popupNodeRef = useRef<HTMLDivElement | null>(null);\n const popupRef = useMergeRefs<HTMLDivElement>([layerRef, gestures.popupRef, popupNodeRef]);\n const imageRef = useMergeRefs<HTMLImageElement>([flipImageRef, gestures.imageRef]);\n\n const handleLoad = useCallback(\n (event: SyntheticEvent<HTMLImageElement>) => {\n const loaded = event.currentTarget;\n if (!loaded.naturalWidth || !loaded.naturalHeight) return;\n const next = { height: loaded.naturalHeight, width: loaded.naturalWidth };\n setNatural((prev) =>\n prev.width === next.width && prev.height === next.height ? prev : next,\n );\n syncZoomNatural(next);\n },\n [syncZoomNatural],\n );\n\n useEffect(() => {\n const handleResize = () => {\n const next = readViewport();\n setViewport(next);\n syncZoomViewport(next);\n };\n window.addEventListener('resize', handleResize);\n return () => window.removeEventListener('resize', handleResize);\n }, [syncZoomViewport]);\n\n useEffect(() => {\n const { previewSrc, src } = currentEntry;\n if (!previewSrc || previewSrc === src) return;\n let cancelled = false;\n const loader = new window.Image();\n const handleLoaded = () => {\n if (!cancelled) setSource(previewSrc);\n };\n loader.addEventListener('load', handleLoaded);\n loader.src = previewSrc;\n return () => {\n cancelled = true;\n loader.removeEventListener('load', handleLoaded);\n };\n }, [currentEntry]);\n\n useEffect(\n () => () => {\n const thumbnailWasRemoved = !openerEntry.element.isConnected;\n if (thumbnailWasRemoved) endClosePreview(token);\n },\n [openerEntry, token],\n );\n\n return (\n <Dialog.Root modal open onOpenChange={handleDialogOpenChange}>\n <Dialog.Portal container={appElement ?? undefined}>\n {/* Base UI drops a nested dialog's backdrop so overlays don't stack. The viewer is\n not an overlay on the surface it opened from — it replaces the screen — so it\n keeps its own scrim, or a viewer opened from a maskless drawer would show the\n page straight through the letterbox around the image. */}\n <Dialog.Backdrop\n forceRender\n className={styles.viewerBackdrop}\n ref={backdropRef}\n style={zIndex === undefined ? undefined : { zIndex }}\n onClick={gestures.onSurfaceClick}\n />\n <Dialog.Popup\n aria-label={currentEntry.element.alt || undefined}\n className={styles.viewerPopup}\n finalFocus={finalFocus}\n // Focus the popup itself instead of Base UI's default (the first\n // tabbable — the close button): a focus-visible close button pins\n // the idle auto-hide open forever and pops its tooltip on open.\n initialFocus={popupNodeRef}\n ref={popupRef}\n style={zIndex === undefined ? undefined : { zIndex: zIndex + 1 }}\n tabIndex={-1}\n onClick={gestures.onSurfaceClick}\n onPointerCancel={gestures.onPointerFinish}\n onPointerDown={gestures.onPointerDown}\n onPointerMove={gestures.onPointerMove}\n onPointerUp={gestures.onPointerFinish}\n >\n <img\n alt={currentEntry.element.alt}\n className={styles.viewerImage}\n ref={imageRef}\n src={source}\n style={{\n cursor: gestures.cursor,\n height: imageRect.height,\n left: imageRect.x,\n top: imageRect.y,\n width: imageRect.width,\n }}\n onClick={gestures.onImageClick}\n onDoubleClick={gestures.onImageDoubleClick}\n onLoad={handleLoad}\n />\n <ViewerChrome\n canZoomIn={canZoomIn}\n canZoomOut={canZoomOut}\n chromeRef={chromeRef}\n current={currentIndex}\n fitRect={fitRect}\n flipHorizontal={flipHorizontal}\n flipVertical={flipVertical}\n hasNext={hasNext}\n hasPrev={hasPrev}\n natural={natural}\n next={next}\n prev={prev}\n rotateLeft={rotateLeft}\n rotateRight={rotateRight}\n rotation={rotation}\n scale={scale}\n source={source}\n toggleActualSize={toggleActualSize}\n toolbarAddon={currentEntry.options.toolbarAddon}\n total={entries.length}\n zoomIn={zoomIn}\n zoomOut={zoomOut}\n onClose={handleClose}\n />\n </Dialog.Popup>\n <ToastHost root={appElement ?? undefined} />\n </Dialog.Portal>\n </Dialog.Root>\n );\n});\n\nImageViewer.displayName = 'ImageViewer';\n\nexport default ImageViewer;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAsCA,MAAM,sBAA4B;CAAE,QAAQ,OAAO;CAAa,OAAO,OAAO;AAAW;AAEzF,MAAM,6BACJ,OAAO,aAAa,kCAAkC,CAAC,CAAC,WAAW;AAErE,MAAM,cAAc,MAAwB,EAAE,SAAS,OAAO,oBAAoB,YAAY;CAC5F,MAAM,cAAc,QAAQ;CAC5B,MAAM,aAAa,cAAc;CACjC,MAAM,kBAAkB,IAAI,eAAe;CAC3C,MAAM,CAAC,YAAY,eAAe,CAAC,CAAC,mBAAmB,CAAC,qBAAqB,CAAC;CAE9E,MAAM,EAAE,KAAK,UAAU,WAAW,eAA+B,OAAO;CAExE,MAAM,CAAC,cAAc,mBAAmB,SAAS,KAAK;CACtD,MAAM,eAAe,QAAQ;CAC7B,MAAM,kBAAkB,OAAO,YAAY;CAC3C,gBAAgB,UAAU;CAE1B,MAAM,CAAC,QAAQ,aAAa,SAAS,YAAY,GAAG;CACpD,MAAM,CAAC,SAAS,cAAc,eAAqB,YAAY,YAAY,OAAO,CAAC;CACnF,MAAM,CAAC,UAAU,eAAe,SAAe,YAAY;CAE3D,MAAM,WAAW,OAA4B,IAAI;CACjD,MAAM,eAAe,kBAAkB,SAAS,UAAU,GAAG,CAAC,CAAC;CAO/D,MAAM,uBAAuB,aAA4B,KAAK;CAC9D,MAAM,oBAAoB,kBAAkB,qBAAqB,QAAQ,GAAG,CAAC,CAAC;CAE9E,MAAM,6BAA6B,aAA4B,IAAI;CACnE,MAAM,0BAA0B,kBAAkB,2BAA2B,QAAQ,GAAG,CAAC,CAAC;CAE1F,MAAM,eAAe,QACnB,aAAa,cAAc,aAAa,eAAe,aAAa,GACtE;CAEA,MAAM,EACJ,WACA,YACA,QACA,SACA,WACA,gBACA,cACA,OACA,OACA,iBACA,mBACA,aACA,SACA,UACA,OACA,oBACA,QACA,YACA,aACA,UACA,OACA,YAAY,iBACZ,aAAa,kBACb,kBACA,GACA,GACA,QACA,YACE,WAAW;EACb,mBAAmB,aAAa,QAAQ;EACxC,aAAa,aAAa,QAAQ;EAClC;EACA,WAAW;EACX,iBAAiB;EACjB,UAAU,aAAa,QAAQ;EAC/B;EACA,gBAAgB;EAChB;CACF,CAAC;CAED,MAAM,UAAU,cACR,WAAW,SAAS,UAAU,UAAU,YAAY,GAC1D;EAAC;EAAS;EAAU;EAAU;CAAY,CAC5C;CACA,MAAM,aAAa,OAAO,OAAO;CACjC,WAAW,UAAU;CACrB,MAAM,aAAa,kBAAkB,WAAW,SAAS,CAAC,CAAC;CAE3D,MAAM,YAAY,cAAc,cAAc,SAAS,QAAQ,GAAG,CAAC,SAAS,QAAQ,CAAC;CAErF,MAAM,cAAc,OAAO,QAAQ;CACnC,YAAY,UAAU;CACtB,MAAM,mBAAmB,kBAAkB,YAAY,QAAQ,OAAO,CAAC,CAAC;CAExE,MAAM,iBAAiB,kBAAkB,gBAAgB,QAAQ,SAAS,CAAC,CAAC;CAE5E,MAAM,YAAY,eACT;EAAE;EAAO;EAAO;EAAQ;EAAO;EAAG;CAAE,IAC3C;EAAC;EAAO;EAAO;EAAQ;EAAO;EAAG;CAAC,CACpC;CACA,MAAM,eAAe,kBAAkB,gBAAgB,KAAK,GAAG,CAAC,KAAK,CAAC;CAEtE,MAAM,EACJ,aACA,WACA,OACA,UAAU,cACV,WACA,iBACA,aACE,kBAAkB;EACpB;EACA;EACA;EACA;EACA;EACA,UAAU;EACV,QAAQ,YAAY;EACpB;CACF,CAAC;CACD,qBAAqB,UAAU;CAC/B,2BAA2B,UAAU;CAErC,mBAAmB;EACjB;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF,CAAC;CACD,MAAM,aAAa,cAAc,kBAAkB;CAEnD,MAAM,aAAa,OAAO,OAAO;CACjC,WAAW,UAAU;CAErB,MAAM,cAAc,kBAAkB;EACpC,kBAAkB,KAAK;EACvB,MAAM,EAAE,MAAM,CAAC,WAAW,QAAQ,CAAC;CACrC,GAAG,CAAC,OAAO,KAAK,CAAC;CACjB,SAAS,UAAU;CAEnB,MAAM,yBAAyB,aAC5B,MAAM,iBAAiB;EACtB,IAAI,MAAM;EAMV,IAAI,UAAU,GAAG;EACjB,IAAI,aAAa,WAAW,gBAAgB,UAAU,MAAM,SAAS;GACnE,aAAa,OAAO;GACpB,MAAM;GACN;EACF;EACA,YAAY;CACd,GACA;EAAC;EAAW;EAAa;EAAW;CAAK,CAC3C;CAEA,MAAM,WAAW,kBAAkB;EACjC;EACA;EACA;EACA;EACA;EACA;EACA,SAAS;EACT;EACA;EACA;CACF,CAAC;CAED,MAAM,EAAE,SAAS,SAAS,MAAM,SAAS,cAAc;EACrD,oBAAoB,SAAS;EAC7B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF,CAAC;CAED,MAAM,eAAe,OAA8B,IAAI;CACvD,MAAM,WAAW,aAA6B;EAAC;EAAU,SAAS;EAAU;CAAY,CAAC;CACzF,MAAM,WAAW,aAA+B,CAAC,cAAc,SAAS,QAAQ,CAAC;CAEjF,MAAM,aAAa,aAChB,UAA4C;EAC3C,MAAM,SAAS,MAAM;EACrB,IAAI,CAAC,OAAO,gBAAgB,CAAC,OAAO,eAAe;EACnD,MAAM,OAAO;GAAE,QAAQ,OAAO;GAAe,OAAO,OAAO;EAAa;EACxE,YAAY,SACV,KAAK,UAAU,KAAK,SAAS,KAAK,WAAW,KAAK,SAAS,OAAO,IACpE;EACA,gBAAgB,IAAI;CACtB,GACA,CAAC,eAAe,CAClB;CAEA,gBAAgB;EACd,MAAM,qBAAqB;GACzB,MAAM,OAAO,aAAa;GAC1B,YAAY,IAAI;GAChB,iBAAiB,IAAI;EACvB;EACA,OAAO,iBAAiB,UAAU,YAAY;EAC9C,aAAa,OAAO,oBAAoB,UAAU,YAAY;CAChE,GAAG,CAAC,gBAAgB,CAAC;CAErB,gBAAgB;EACd,MAAM,EAAE,YAAY,QAAQ;EAC5B,IAAI,CAAC,cAAc,eAAe,KAAK;EACvC,IAAI,YAAY;EAChB,MAAM,SAAS,IAAI,OAAO,MAAM;EAChC,MAAM,qBAAqB;GACzB,IAAI,CAAC,WAAW,UAAU,UAAU;EACtC;EACA,OAAO,iBAAiB,QAAQ,YAAY;EAC5C,OAAO,MAAM;EACb,aAAa;GACX,YAAY;GACZ,OAAO,oBAAoB,QAAQ,YAAY;EACjD;CACF,GAAG,CAAC,YAAY,CAAC;CAEjB,sBACc;EAEV,IAAI,CADyB,YAAY,QAAQ,aACxB,gBAAgB,KAAK;CAChD,GACA,CAAC,aAAa,KAAK,CACrB;CAEA,OACE,oBAAC,OAAO,MAAR;EAAa,OAAA;EAAM,MAAA;EAAK,cAAc;EACpC,UAAA,qBAAC,OAAO,QAAR;GAAe,WAAW,cAAc,KAAA;GAAxC,UAAA;IAKE,oBAAC,OAAO,UAAR;KACE,aAAA;KACA,WAAW,OAAO;KAClB,KAAK;KACL,OAAO,WAAW,KAAA,IAAY,KAAA,IAAY,EAAE,OAAO;KACnD,SAAS,SAAS;IACnB,CAAA;IACD,qBAAC,OAAO,OAAR;KACE,cAAY,aAAa,QAAQ,OAAO,KAAA;KACxC,WAAW,OAAO;KACN;KAIZ,cAAc;KACd,KAAK;KACL,OAAO,WAAW,KAAA,IAAY,KAAA,IAAY,EAAE,QAAQ,SAAS,EAAE;KAC/D,UAAU;KACV,SAAS,SAAS;KAClB,iBAAiB,SAAS;KAC1B,eAAe,SAAS;KACxB,eAAe,SAAS;KACxB,aAAa,SAAS;KAfxB,UAAA,CAiBE,oBAAC,OAAD;MACE,KAAK,aAAa,QAAQ;MAC1B,WAAW,OAAO;MAClB,KAAK;MACL,KAAK;MACL,OAAO;OACL,QAAQ,SAAS;OACjB,QAAQ,UAAU;OAClB,MAAM,UAAU;OAChB,KAAK,UAAU;OACf,OAAO,UAAU;MACnB;MACA,SAAS,SAAS;MAClB,eAAe,SAAS;MACxB,QAAQ;KACT,CAAA,GACD,oBAAC,cAAD;MACa;MACC;MACD;MACX,SAAS;MACA;MACO;MACF;MACL;MACA;MACA;MACH;MACA;MACM;MACC;MACH;MACH;MACC;MACU;MAClB,cAAc,aAAa,QAAQ;MACnC,OAAO,QAAQ;MACP;MACC;MACT,SAAS;KACV,CAAA,CACW;;IACd,oBAAC,WAAD,EAAW,MAAM,cAAc,KAAA,EAAY,CAAA;GAC9B;;CACJ,CAAA;AAEjB,CAAC;AAED,YAAY,cAAc"}
1
+ {"version":3,"file":"ImageViewer.mjs","names":[],"sources":["../../../src/Image/viewer/ImageViewer.tsx"],"sourcesContent":["'use client';\n\nimport { Dialog, type DialogRootProps } from '@base-ui/react/dialog';\nimport { motion } from 'motion/react';\nimport {\n memo,\n type SyntheticEvent,\n use,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport { useMergeRefs } from 'react-merge-refs';\n\nimport { ToastHost } from '@/base-ui/Toast';\nimport { useLayerZIndex } from '@/base-ui/zIndex';\nimport { MotionComponent } from '@/MotionProvider';\nimport { useAppElement } from '@/ThemeProvider';\n\nimport { styles } from '../style';\nimport { computeFit, type Size, unrotatedRect } from './geometry';\nimport { beginClosePreview, endClosePreview, type PreviewEntry } from './registry';\nimport { useFinalFocus } from './useFinalFocus';\nimport { useFlipTransition } from './useFlipTransition';\nimport { readNatural, useGalleryNav } from './useGalleryNav';\nimport { useRefitTransition } from './useRefitTransition';\nimport { useViewerGestures } from './useViewerGestures';\nimport { useZoomPan } from './useZoomPan';\nimport ViewerChrome from './ViewerChrome';\n\nexport interface ImageViewerProps {\n entries: PreviewEntry[];\n index: number;\n openerFocusElement: HTMLElement | null;\n token: number;\n}\n\nconst readViewport = (): Size => ({ height: window.innerHeight, width: window.innerWidth });\n\nconst prefersReducedMotion = () =>\n window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n\nconst ImageViewer = memo<ImageViewerProps>(({ entries, index, openerFocusElement, token }) => {\n const openerEntry = entries[index];\n const appElement = useAppElement();\n const motionComponent = use(MotionComponent);\n const [animated] = useState(() => !!motionComponent && !prefersReducedMotion());\n\n const { ref: layerRef, zIndex } = useLayerZIndex<HTMLDivElement>('modal');\n\n const [currentIndex, setCurrentIndex] = useState(index);\n const currentEntry = entries[currentIndex];\n const currentEntryRef = useRef(currentEntry);\n currentEntryRef.current = currentEntry;\n\n const [source, setSource] = useState(openerEntry.src);\n const [natural, setNatural] = useState<Size>(() => readNatural(openerEntry.element));\n const [viewport, setViewport] = useState<Size>(readViewport);\n\n const closeRef = useRef<(() => void) | null>(null);\n const requestClose = useCallback(() => closeRef.current?.(), []);\n\n // useZoomPan needs to gate its wheel/toolbar entry points on close-in-flight,\n // but that state only exists once useFlipTransition (below) has run — and it\n // needs useZoomPan's own transform values first. Break the cycle with a\n // stable indirection: the ref is repointed at the real isClosing once it's\n // available, written directly during render like the other *Ref fields here.\n const isClosingIndirectRef = useRef<() => boolean>(() => false);\n const isClosingIndirect = useCallback(() => isClosingIndirectRef.current(), []);\n\n const isTransitioningIndirectRef = useRef<() => boolean>(() => true);\n const isTransitioningIndirect = useCallback(() => isTransitioningIndirectRef.current(), []);\n\n const fillViewport = Boolean(\n currentEntry.previewSrc && currentEntry.previewSrc !== currentEntry.src,\n );\n\n const {\n canZoomIn,\n canZoomOut,\n dragBy,\n dragEnd,\n escIntent,\n flipHorizontal,\n flipVertical,\n flipX,\n flipY,\n getInitialScale,\n handleDoubleClick,\n handleWheel,\n isClean,\n isZoomed,\n reset,\n resetCloseTracking,\n rotate,\n rotateLeft,\n rotateRight,\n rotation,\n scale,\n setNatural: syncZoomNatural,\n setViewport: syncZoomViewport,\n toggleActualSize,\n x,\n y,\n zoomIn,\n zoomOut,\n } = useZoomPan({\n autoZoomThreshold: currentEntry.options.autoZoomThreshold,\n defaultZoom: currentEntry.options.defaultZoom,\n fillViewport,\n isClosing: isClosingIndirect,\n isTransitioning: isTransitioningIndirect,\n maxScale: currentEntry.options.maxScale,\n natural,\n onCloseRequest: requestClose,\n viewport,\n });\n\n const fitRect = useMemo(\n () => computeFit(natural, viewport, rotation, fillViewport),\n [natural, viewport, rotation, fillViewport],\n );\n const fitRectRef = useRef(fitRect);\n fitRectRef.current = fitRect;\n const getFitRect = useCallback(() => fitRectRef.current, []);\n\n const imageRect = useMemo(() => unrotatedRect(fitRect, rotation), [fitRect, rotation]);\n\n const viewportRef = useRef(viewport);\n viewportRef.current = viewport;\n const getViewportWidth = useCallback(() => viewportRef.current.width, []);\n\n const getCloseSource = useCallback(() => currentEntryRef.current.element, []);\n\n const transform = useMemo(\n () => ({ flipX, flipY, rotate, scale, x, y }),\n [flipX, flipY, rotate, scale, x, y],\n );\n const handleClosed = useCallback(() => endClosePreview(token), [token]);\n\n const {\n backdropRef,\n chromeRef,\n close,\n imageRef: flipImageRef,\n isClosing,\n isTransitioning,\n switchTo,\n } = useFlipTransition({\n animated,\n getCloseSource,\n getFitRect,\n getInitialScale,\n getViewportWidth,\n onClosed: handleClosed,\n source: openerEntry.element,\n transform,\n });\n isClosingIndirectRef.current = isClosing;\n isTransitioningIndirectRef.current = isTransitioning;\n\n useRefitTransition({\n animated,\n fillViewport,\n isTransitioning,\n natural,\n rotation,\n scale,\n viewport,\n x,\n y,\n });\n const finalFocus = useFinalFocus(openerFocusElement);\n\n const isCleanRef = useRef(isClean);\n isCleanRef.current = isClean;\n\n const handleClose = useCallback(() => {\n beginClosePreview(token);\n close({ fade: !isCleanRef.current });\n }, [close, token]);\n closeRef.current = handleClose;\n\n const handleDialogOpenChange = useCallback<NonNullable<DialogRootProps['onOpenChange']>>(\n (open, eventDetails) => {\n if (open) return;\n // Base UI fires this on every Esc regardless of whether we're already\n // mid-close (our Dialog.Root's open prop never actually flips to\n // false), so without this guard a second Esc during the close window\n // reads escIntent() off the close spring's mid-flight scale, decides\n // 'reset', and cancels the close animation out from under itself.\n if (isClosing()) return;\n if (eventDetails.reason === 'escape-key' && escIntent() === 'reset') {\n eventDetails.cancel();\n reset();\n return;\n }\n handleClose();\n },\n [escIntent, handleClose, isClosing, reset],\n );\n\n const gestures = useViewerGestures({\n dragBy,\n dragEnd,\n handleDoubleClick,\n handleWheel,\n isClean,\n isZoomed,\n onClose: handleClose,\n reset,\n zoomIn,\n zoomOut,\n });\n\n const { hasNext, hasPrev, next, prev } = useGalleryNav({\n cancelPendingClose: gestures.cancelPendingClose,\n currentIndex,\n entries,\n flipX,\n flipY,\n getInitialScale,\n resetCloseTracking,\n rotate,\n scale,\n setCurrentIndex,\n setNatural,\n setSource,\n switchTo,\n syncZoomNatural,\n x,\n y,\n });\n\n const popupNodeRef = useRef<HTMLDivElement | null>(null);\n const popupRef = useMergeRefs<HTMLDivElement>([layerRef, gestures.popupRef, popupNodeRef]);\n const imageRef = useMergeRefs<HTMLImageElement>([flipImageRef, gestures.imageRef]);\n\n const handleLoad = useCallback(\n (event: SyntheticEvent<HTMLImageElement>) => {\n const loaded = event.currentTarget;\n if (!loaded.naturalWidth || !loaded.naturalHeight) return;\n const next = { height: loaded.naturalHeight, width: loaded.naturalWidth };\n setNatural((prev) =>\n prev.width === next.width && prev.height === next.height ? prev : next,\n );\n syncZoomNatural(next);\n },\n [syncZoomNatural],\n );\n\n useEffect(() => {\n const handleResize = () => {\n const next = readViewport();\n setViewport(next);\n syncZoomViewport(next);\n };\n window.addEventListener('resize', handleResize);\n return () => window.removeEventListener('resize', handleResize);\n }, [syncZoomViewport]);\n\n useEffect(() => {\n const { previewSrc, src } = currentEntry;\n if (!previewSrc || previewSrc === src) return;\n let cancelled = false;\n const loader = new window.Image();\n const handleLoaded = () => {\n if (!cancelled) setSource(previewSrc);\n };\n loader.addEventListener('load', handleLoaded);\n loader.src = previewSrc;\n return () => {\n cancelled = true;\n loader.removeEventListener('load', handleLoaded);\n };\n }, [currentEntry]);\n\n useEffect(\n () => () => {\n const thumbnailWasRemoved = !openerEntry.element.isConnected;\n if (thumbnailWasRemoved) endClosePreview(token);\n },\n [openerEntry, token],\n );\n\n return (\n // The chrome is built from Base UI controls, which need a motion component to\n // render at all. The viewer itself still degrades to a plain fade without one —\n // `animated` stays false — but the toolbar has no such fallback, so an app that\n // skipped ConfigProvider gets motion here rather than a crash.\n <MotionComponent value={motionComponent ?? motion}>\n <Dialog.Root modal open onOpenChange={handleDialogOpenChange}>\n <Dialog.Portal container={appElement ?? undefined}>\n {/* Base UI drops a nested dialog's backdrop so overlays don't stack. The viewer is\n not an overlay on the surface it opened from — it replaces the screen — so it\n keeps its own scrim, or a viewer opened from a maskless drawer would show the\n page straight through the letterbox around the image. */}\n <Dialog.Backdrop\n forceRender\n className={styles.viewerBackdrop}\n ref={backdropRef}\n style={zIndex === undefined ? undefined : { zIndex }}\n onClick={gestures.onSurfaceClick}\n />\n <Dialog.Popup\n aria-label={currentEntry.element.alt || undefined}\n className={styles.viewerPopup}\n finalFocus={finalFocus}\n // Focus the popup itself instead of Base UI's default (the first\n // tabbable — the close button): a focus-visible close button pins\n // the idle auto-hide open forever and pops its tooltip on open.\n initialFocus={popupNodeRef}\n ref={popupRef}\n style={zIndex === undefined ? undefined : { zIndex: zIndex + 1 }}\n tabIndex={-1}\n onClick={gestures.onSurfaceClick}\n onPointerCancel={gestures.onPointerFinish}\n onPointerDown={gestures.onPointerDown}\n onPointerMove={gestures.onPointerMove}\n onPointerUp={gestures.onPointerFinish}\n >\n <img\n alt={currentEntry.element.alt}\n className={styles.viewerImage}\n ref={imageRef}\n src={source}\n style={{\n cursor: gestures.cursor,\n height: imageRect.height,\n left: imageRect.x,\n top: imageRect.y,\n width: imageRect.width,\n }}\n onClick={gestures.onImageClick}\n onDoubleClick={gestures.onImageDoubleClick}\n onLoad={handleLoad}\n />\n <ViewerChrome\n canZoomIn={canZoomIn}\n canZoomOut={canZoomOut}\n chromeRef={chromeRef}\n current={currentIndex}\n fitRect={fitRect}\n flipHorizontal={flipHorizontal}\n flipVertical={flipVertical}\n hasNext={hasNext}\n hasPrev={hasPrev}\n natural={natural}\n next={next}\n prev={prev}\n rotateLeft={rotateLeft}\n rotateRight={rotateRight}\n rotation={rotation}\n scale={scale}\n source={source}\n toggleActualSize={toggleActualSize}\n toolbarAddon={currentEntry.options.toolbarAddon}\n total={entries.length}\n zoomIn={zoomIn}\n zoomOut={zoomOut}\n onClose={handleClose}\n onDownload={currentEntry.options.onDownload}\n />\n </Dialog.Popup>\n <ToastHost root={appElement ?? undefined} />\n </Dialog.Portal>\n </Dialog.Root>\n </MotionComponent>\n );\n});\n\nImageViewer.displayName = 'ImageViewer';\n\nexport default ImageViewer;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAuCA,MAAM,sBAA4B;CAAE,QAAQ,OAAO;CAAa,OAAO,OAAO;AAAW;AAEzF,MAAM,6BACJ,OAAO,aAAa,kCAAkC,CAAC,CAAC,WAAW;AAErE,MAAM,cAAc,MAAwB,EAAE,SAAS,OAAO,oBAAoB,YAAY;CAC5F,MAAM,cAAc,QAAQ;CAC5B,MAAM,aAAa,cAAc;CACjC,MAAM,kBAAkB,IAAI,eAAe;CAC3C,MAAM,CAAC,YAAY,eAAe,CAAC,CAAC,mBAAmB,CAAC,qBAAqB,CAAC;CAE9E,MAAM,EAAE,KAAK,UAAU,WAAW,eAA+B,OAAO;CAExE,MAAM,CAAC,cAAc,mBAAmB,SAAS,KAAK;CACtD,MAAM,eAAe,QAAQ;CAC7B,MAAM,kBAAkB,OAAO,YAAY;CAC3C,gBAAgB,UAAU;CAE1B,MAAM,CAAC,QAAQ,aAAa,SAAS,YAAY,GAAG;CACpD,MAAM,CAAC,SAAS,cAAc,eAAqB,YAAY,YAAY,OAAO,CAAC;CACnF,MAAM,CAAC,UAAU,eAAe,SAAe,YAAY;CAE3D,MAAM,WAAW,OAA4B,IAAI;CACjD,MAAM,eAAe,kBAAkB,SAAS,UAAU,GAAG,CAAC,CAAC;CAO/D,MAAM,uBAAuB,aAA4B,KAAK;CAC9D,MAAM,oBAAoB,kBAAkB,qBAAqB,QAAQ,GAAG,CAAC,CAAC;CAE9E,MAAM,6BAA6B,aAA4B,IAAI;CACnE,MAAM,0BAA0B,kBAAkB,2BAA2B,QAAQ,GAAG,CAAC,CAAC;CAE1F,MAAM,eAAe,QACnB,aAAa,cAAc,aAAa,eAAe,aAAa,GACtE;CAEA,MAAM,EACJ,WACA,YACA,QACA,SACA,WACA,gBACA,cACA,OACA,OACA,iBACA,mBACA,aACA,SACA,UACA,OACA,oBACA,QACA,YACA,aACA,UACA,OACA,YAAY,iBACZ,aAAa,kBACb,kBACA,GACA,GACA,QACA,YACE,WAAW;EACb,mBAAmB,aAAa,QAAQ;EACxC,aAAa,aAAa,QAAQ;EAClC;EACA,WAAW;EACX,iBAAiB;EACjB,UAAU,aAAa,QAAQ;EAC/B;EACA,gBAAgB;EAChB;CACF,CAAC;CAED,MAAM,UAAU,cACR,WAAW,SAAS,UAAU,UAAU,YAAY,GAC1D;EAAC;EAAS;EAAU;EAAU;CAAY,CAC5C;CACA,MAAM,aAAa,OAAO,OAAO;CACjC,WAAW,UAAU;CACrB,MAAM,aAAa,kBAAkB,WAAW,SAAS,CAAC,CAAC;CAE3D,MAAM,YAAY,cAAc,cAAc,SAAS,QAAQ,GAAG,CAAC,SAAS,QAAQ,CAAC;CAErF,MAAM,cAAc,OAAO,QAAQ;CACnC,YAAY,UAAU;CACtB,MAAM,mBAAmB,kBAAkB,YAAY,QAAQ,OAAO,CAAC,CAAC;CAExE,MAAM,iBAAiB,kBAAkB,gBAAgB,QAAQ,SAAS,CAAC,CAAC;CAE5E,MAAM,YAAY,eACT;EAAE;EAAO;EAAO;EAAQ;EAAO;EAAG;CAAE,IAC3C;EAAC;EAAO;EAAO;EAAQ;EAAO;EAAG;CAAC,CACpC;CACA,MAAM,eAAe,kBAAkB,gBAAgB,KAAK,GAAG,CAAC,KAAK,CAAC;CAEtE,MAAM,EACJ,aACA,WACA,OACA,UAAU,cACV,WACA,iBACA,aACE,kBAAkB;EACpB;EACA;EACA;EACA;EACA;EACA,UAAU;EACV,QAAQ,YAAY;EACpB;CACF,CAAC;CACD,qBAAqB,UAAU;CAC/B,2BAA2B,UAAU;CAErC,mBAAmB;EACjB;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF,CAAC;CACD,MAAM,aAAa,cAAc,kBAAkB;CAEnD,MAAM,aAAa,OAAO,OAAO;CACjC,WAAW,UAAU;CAErB,MAAM,cAAc,kBAAkB;EACpC,kBAAkB,KAAK;EACvB,MAAM,EAAE,MAAM,CAAC,WAAW,QAAQ,CAAC;CACrC,GAAG,CAAC,OAAO,KAAK,CAAC;CACjB,SAAS,UAAU;CAEnB,MAAM,yBAAyB,aAC5B,MAAM,iBAAiB;EACtB,IAAI,MAAM;EAMV,IAAI,UAAU,GAAG;EACjB,IAAI,aAAa,WAAW,gBAAgB,UAAU,MAAM,SAAS;GACnE,aAAa,OAAO;GACpB,MAAM;GACN;EACF;EACA,YAAY;CACd,GACA;EAAC;EAAW;EAAa;EAAW;CAAK,CAC3C;CAEA,MAAM,WAAW,kBAAkB;EACjC;EACA;EACA;EACA;EACA;EACA;EACA,SAAS;EACT;EACA;EACA;CACF,CAAC;CAED,MAAM,EAAE,SAAS,SAAS,MAAM,SAAS,cAAc;EACrD,oBAAoB,SAAS;EAC7B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF,CAAC;CAED,MAAM,eAAe,OAA8B,IAAI;CACvD,MAAM,WAAW,aAA6B;EAAC;EAAU,SAAS;EAAU;CAAY,CAAC;CACzF,MAAM,WAAW,aAA+B,CAAC,cAAc,SAAS,QAAQ,CAAC;CAEjF,MAAM,aAAa,aAChB,UAA4C;EAC3C,MAAM,SAAS,MAAM;EACrB,IAAI,CAAC,OAAO,gBAAgB,CAAC,OAAO,eAAe;EACnD,MAAM,OAAO;GAAE,QAAQ,OAAO;GAAe,OAAO,OAAO;EAAa;EACxE,YAAY,SACV,KAAK,UAAU,KAAK,SAAS,KAAK,WAAW,KAAK,SAAS,OAAO,IACpE;EACA,gBAAgB,IAAI;CACtB,GACA,CAAC,eAAe,CAClB;CAEA,gBAAgB;EACd,MAAM,qBAAqB;GACzB,MAAM,OAAO,aAAa;GAC1B,YAAY,IAAI;GAChB,iBAAiB,IAAI;EACvB;EACA,OAAO,iBAAiB,UAAU,YAAY;EAC9C,aAAa,OAAO,oBAAoB,UAAU,YAAY;CAChE,GAAG,CAAC,gBAAgB,CAAC;CAErB,gBAAgB;EACd,MAAM,EAAE,YAAY,QAAQ;EAC5B,IAAI,CAAC,cAAc,eAAe,KAAK;EACvC,IAAI,YAAY;EAChB,MAAM,SAAS,IAAI,OAAO,MAAM;EAChC,MAAM,qBAAqB;GACzB,IAAI,CAAC,WAAW,UAAU,UAAU;EACtC;EACA,OAAO,iBAAiB,QAAQ,YAAY;EAC5C,OAAO,MAAM;EACb,aAAa;GACX,YAAY;GACZ,OAAO,oBAAoB,QAAQ,YAAY;EACjD;CACF,GAAG,CAAC,YAAY,CAAC;CAEjB,sBACc;EAEV,IAAI,CADyB,YAAY,QAAQ,aACxB,gBAAgB,KAAK;CAChD,GACA,CAAC,aAAa,KAAK,CACrB;CAEA,OAKE,oBAAC,iBAAD;EAAiB,OAAO,mBAAmB;EACzC,UAAA,oBAAC,OAAO,MAAR;GAAa,OAAA;GAAM,MAAA;GAAK,cAAc;GACpC,UAAA,qBAAC,OAAO,QAAR;IAAe,WAAW,cAAc,KAAA;IAAxC,UAAA;KAKE,oBAAC,OAAO,UAAR;MACE,aAAA;MACA,WAAW,OAAO;MAClB,KAAK;MACL,OAAO,WAAW,KAAA,IAAY,KAAA,IAAY,EAAE,OAAO;MACnD,SAAS,SAAS;KACnB,CAAA;KACD,qBAAC,OAAO,OAAR;MACE,cAAY,aAAa,QAAQ,OAAO,KAAA;MACxC,WAAW,OAAO;MACN;MAIZ,cAAc;MACd,KAAK;MACL,OAAO,WAAW,KAAA,IAAY,KAAA,IAAY,EAAE,QAAQ,SAAS,EAAE;MAC/D,UAAU;MACV,SAAS,SAAS;MAClB,iBAAiB,SAAS;MAC1B,eAAe,SAAS;MACxB,eAAe,SAAS;MACxB,aAAa,SAAS;MAfxB,UAAA,CAiBE,oBAAC,OAAD;OACE,KAAK,aAAa,QAAQ;OAC1B,WAAW,OAAO;OAClB,KAAK;OACL,KAAK;OACL,OAAO;QACL,QAAQ,SAAS;QACjB,QAAQ,UAAU;QAClB,MAAM,UAAU;QAChB,KAAK,UAAU;QACf,OAAO,UAAU;OACnB;OACA,SAAS,SAAS;OAClB,eAAe,SAAS;OACxB,QAAQ;MACT,CAAA,GACD,oBAAC,cAAD;OACa;OACC;OACD;OACX,SAAS;OACA;OACO;OACF;OACL;OACA;OACA;OACH;OACA;OACM;OACC;OACH;OACH;OACC;OACU;OAClB,cAAc,aAAa,QAAQ;OACnC,OAAO,QAAQ;OACP;OACC;OACT,SAAS;OACT,YAAY,aAAa,QAAQ;MAClC,CAAA,CACW;;KACd,oBAAC,WAAD,EAAW,MAAM,cAAc,KAAA,EAAY,CAAA;IAC9B;;EACJ,CAAA;CACE,CAAA;AAErB,CAAC;AAED,YAAY,cAAc"}
@@ -2,8 +2,8 @@
2
2
  import FlexBasic_default from "../../Flex/FlexBasic.mjs";
3
3
  import Center from "../../Flex/Center.mjs";
4
4
  import TooltipGroup from "../../base-ui/Tooltip/TooltipGroup.mjs";
5
- import ActionIcon from "../../ActionIcon/ActionIcon.mjs";
6
5
  import DropdownMenu from "../../base-ui/DropdownMenu/DropdownMenu.mjs";
6
+ import ActionIcon from "../../base-ui/ActionIcon/ActionIcon.mjs";
7
7
  import { useTranslation } from "../../i18n/useTranslation.mjs";
8
8
  import { downloadBlob } from "../../utils/downloadBlob.mjs";
9
9
  import { styles } from "../style.mjs";
@@ -39,7 +39,7 @@ const usePercentage = (scale, matchScale) => {
39
39
  const getSnapshot = useCallback(() => Math.round(scale.get() / matchScale * 100), [matchScale, scale]);
40
40
  return useSyncExternalStore(subscribe, getSnapshot);
41
41
  };
42
- const Toolbar = memo(({ canZoomIn, canZoomOut, fitRect, flipHorizontal, flipVertical, natural, onMoreOpenChange, rotateLeft, rotateRight, rotation, scale, source, toggleActualSize, toolbarAddon, zoomIn, zoomOut }) => {
42
+ const Toolbar = memo(({ canZoomIn, canZoomOut, fitRect, flipHorizontal, flipVertical, natural, onDownload, onMoreOpenChange, rotateLeft, rotateRight, rotation, scale, source, toggleActualSize, toolbarAddon, zoomIn, zoomOut }) => {
43
43
  const { t } = useTranslation(image_default);
44
44
  const [containerEl, setContainerEl] = useState(null);
45
45
  const [copyLoading, setCopyLoading] = useState(false);
@@ -48,24 +48,36 @@ const Toolbar = memo(({ canZoomIn, canZoomOut, fitRect, flipHorizontal, flipVert
48
48
  const percentage = usePercentage(scale, matchScale);
49
49
  const canToggleActualSize = Math.abs(matchScale - 1) > .01;
50
50
  const showFitAffordance = canToggleActualSize && percentage === 100;
51
- const handleDownload = useCallback(async () => {
52
- setDownloadLoading(true);
51
+ const defaultDownload = useCallback(async (src) => {
53
52
  try {
54
- const blob = await (await fetch(source, { mode: "cors" })).blob();
53
+ const blob = await (await fetch(src, { mode: "cors" })).blob();
55
54
  const blobUrl = URL.createObjectURL(blob);
56
- let fileName = getFileNameFromUrl(source);
55
+ let fileName = getFileNameFromUrl(src);
57
56
  const ext = getExtensionFromMimeType(blob.type);
58
57
  if (!fileName.includes(".")) fileName = `${fileName}.${ext}`;
59
58
  else if (fileName.endsWith(".svg+xml")) fileName = fileName.replace(/\.svg\+xml$/i, ".svg");
60
59
  await downloadBlob(blobUrl, fileName);
61
60
  URL.revokeObjectURL(blobUrl);
62
61
  toast.success(t("image.downloadSuccess"));
62
+ } catch {
63
+ window.open(src, "_blank", "noopener,noreferrer");
64
+ }
65
+ }, [t]);
66
+ const handleDownload = useCallback(async () => {
67
+ setDownloadLoading(true);
68
+ try {
69
+ await (onDownload ?? defaultDownload)(source);
63
70
  } catch {
64
71
  toast.error(t("image.downloadFailed"));
65
72
  } finally {
66
73
  setDownloadLoading(false);
67
74
  }
68
- }, [source, t]);
75
+ }, [
76
+ defaultDownload,
77
+ onDownload,
78
+ source,
79
+ t
80
+ ]);
69
81
  const handleCopy = useCallback(async () => {
70
82
  setCopyLoading(true);
71
83
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"Toolbar.mjs","names":["imageMessages","Flexbox"],"sources":["../../../src/Image/viewer/Toolbar.tsx"],"sourcesContent":["'use client';\n\nimport {\n Copy,\n Download,\n Ellipsis,\n FlipHorizontal,\n FlipVertical,\n RotateCcw,\n RotateCw,\n ZoomIn,\n ZoomOut,\n} from 'lucide-react';\nimport type { MotionValue } from 'motion/react';\nimport { memo, type ReactNode, useCallback, useMemo, useState, useSyncExternalStore } from 'react';\n\nimport ActionIcon from '@/ActionIcon';\nimport DropdownMenu, { type DropdownItem } from '@/base-ui/DropdownMenu';\nimport { toast } from '@/base-ui/Toast';\nimport { Center, Flexbox } from '@/Flex';\nimport imageMessages from '@/i18n/resources/en/image';\nimport { useTranslation } from '@/i18n/useTranslation';\nimport { TooltipGroup } from '@/Tooltip';\nimport { getClipboardBlob } from '@/utils/blobToPng';\nimport { downloadBlob } from '@/utils/downloadBlob';\n\nimport { styles } from '../style';\nimport ActualSizeIcon from './ActualSizeIcon';\nimport { naturalScale, type Rect, type Rotation, type Size } from './geometry';\n\nconst getFileNameFromUrl = (url: string): string => {\n try {\n const pathname = new URL(url).pathname;\n const match = pathname.match(/\\/([^/]+)$/);\n return match ? decodeURIComponent(match[1]) : 'image';\n } catch {\n return 'image';\n }\n};\n\nconst getExtensionFromMimeType = (mimeType: string): string => {\n const map: Record<string, string> = {\n 'image/svg+xml': 'svg',\n 'image/png': 'png',\n 'image/jpeg': 'jpg',\n 'image/jpg': 'jpg',\n 'image/webp': 'webp',\n 'image/gif': 'gif',\n };\n return map[mimeType?.toLowerCase()] || mimeType?.split('/')[1]?.split('+')[0] || 'png';\n};\n\nconst usePercentage = (scale: MotionValue<number>, matchScale: number): number => {\n const subscribe = useCallback((onChange: () => void) => scale.on('change', onChange), [scale]);\n const getSnapshot = useCallback(\n () => Math.round((scale.get() / matchScale) * 100),\n [matchScale, scale],\n );\n\n return useSyncExternalStore(subscribe, getSnapshot);\n};\n\nexport interface ToolbarProps {\n canZoomIn: boolean;\n canZoomOut: boolean;\n fitRect: Rect;\n flipHorizontal: () => void;\n flipVertical: () => void;\n natural: Size;\n onMoreOpenChange?: (open: boolean) => void;\n rotateLeft: () => void;\n rotateRight: () => void;\n rotation: Rotation;\n scale: MotionValue<number>;\n source: string;\n toggleActualSize: () => void;\n toolbarAddon?: ReactNode;\n zoomIn: () => void;\n zoomOut: () => void;\n}\n\nconst Toolbar = memo<ToolbarProps>(\n ({\n canZoomIn,\n canZoomOut,\n fitRect,\n flipHorizontal,\n flipVertical,\n natural,\n onMoreOpenChange,\n rotateLeft,\n rotateRight,\n rotation,\n scale,\n source,\n toggleActualSize,\n toolbarAddon,\n zoomIn,\n zoomOut,\n }) => {\n const { t } = useTranslation(imageMessages);\n const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);\n const [copyLoading, setCopyLoading] = useState(false);\n const [downloadLoading, setDownloadLoading] = useState(false);\n\n const matchScale = naturalScale(natural, fitRect, rotation);\n const percentage = usePercentage(scale, matchScale);\n // An image small enough that computeFit never scaled it down is already at\n // 100% when fitted, so the two states the toggle switches between are the\n // same state and the control has nothing to do.\n const canToggleActualSize = Math.abs(matchScale - 1) > 0.01;\n // The control reads as \"go to actual size\" by default, and only flips to\n // the fit affordance when it can actually take you back — an image that\n // fits at 100% is at both states at once and would otherwise offer to\n // \"fit to screen\" while already fitted.\n const showFitAffordance = canToggleActualSize && percentage === 100;\n\n const handleDownload = useCallback(async () => {\n setDownloadLoading(true);\n try {\n const response = await fetch(source, { mode: 'cors' });\n const blob = await response.blob();\n const blobUrl = URL.createObjectURL(blob);\n let fileName = getFileNameFromUrl(source);\n const ext = getExtensionFromMimeType(blob.type);\n if (!fileName.includes('.')) {\n fileName = `${fileName}.${ext}`;\n } else if (fileName.endsWith('.svg+xml')) {\n fileName = fileName.replace(/\\.svg\\+xml$/i, '.svg');\n }\n await downloadBlob(blobUrl, fileName);\n URL.revokeObjectURL(blobUrl);\n toast.success(t('image.downloadSuccess'));\n } catch {\n toast.error(t('image.downloadFailed'));\n } finally {\n setDownloadLoading(false);\n }\n }, [source, t]);\n\n const handleCopy = useCallback(async () => {\n setCopyLoading(true);\n try {\n const response = await fetch(source, { mode: 'cors' });\n const blob = await response.blob();\n const clipboardBlob = await getClipboardBlob(blob);\n await navigator.clipboard.write([new ClipboardItem(clipboardBlob)]);\n toast.success(t('image.copySuccess'));\n } catch {\n toast.error(t('image.copyFailed'));\n } finally {\n setCopyLoading(false);\n }\n }, [source, t]);\n\n const moreItems = useMemo<DropdownItem[]>(\n () => [\n {\n icon: FlipHorizontal,\n key: 'flip-horizontal',\n label: t('image.flipHorizontal'),\n onClick: flipHorizontal,\n },\n {\n icon: FlipVertical,\n key: 'flip-vertical',\n label: t('image.flipVertical'),\n onClick: flipVertical,\n },\n { icon: RotateCcw, key: 'rotate-left', label: t('image.rotateLeft'), onClick: rotateLeft },\n {\n icon: RotateCw,\n key: 'rotate-right',\n label: t('image.rotateRight'),\n onClick: rotateRight,\n },\n { icon: Copy, key: 'copy', label: t('image.copy'), onClick: handleCopy },\n ],\n [flipHorizontal, flipVertical, handleCopy, rotateLeft, rotateRight, t],\n );\n\n return (\n <TooltipGroup popupContainer={containerEl ?? undefined}>\n <div className={styles.toolbar} ref={setContainerEl}>\n <Flexbox horizontal align=\"center\" className={styles.toolbarRow} gap={8}>\n <ActionIcon\n disabled={!canZoomOut}\n icon={ZoomOut}\n style={{ borderRadius: 999 }}\n title={t('image.zoomOut')}\n onClick={zoomOut}\n />\n <Center horizontal className={styles.toolbarPercentage}>\n {percentage}%\n </Center>\n <ActionIcon\n disabled={!canZoomIn}\n icon={ZoomIn}\n style={{ borderRadius: 999 }}\n title={t('image.zoomIn')}\n onClick={zoomIn}\n />\n <ActionIcon\n data-actual-size={showFitAffordance ? 'fit' : 'actual'}\n disabled={!canToggleActualSize}\n icon={ActualSizeIcon}\n style={{ borderRadius: 999 }}\n title={showFitAffordance ? t('image.fitToScreen') : t('image.actualSize')}\n onClick={toggleActualSize}\n />\n <ActionIcon\n icon={Download}\n loading={downloadLoading}\n style={{ borderRadius: 999 }}\n title={t('image.download')}\n onClick={handleDownload}\n />\n <DropdownMenu\n items={moreItems}\n placement=\"top\"\n portalProps={{ container: containerEl ?? undefined }}\n onOpenChange={(open) => onMoreOpenChange?.(open)}\n >\n <ActionIcon\n icon={Ellipsis}\n loading={copyLoading}\n style={{ borderRadius: 999 }}\n title={t('image.more')}\n />\n </DropdownMenu>\n {toolbarAddon}\n </Flexbox>\n </div>\n </TooltipGroup>\n );\n },\n);\n\nToolbar.displayName = 'Toolbar';\n\nexport default Toolbar;\n"],"mappings":";;;;;;;;;;;;;;;;;;AA8BA,MAAM,sBAAsB,QAAwB;CAClD,IAAI;EAEF,MAAM,QADW,IAAI,IAAI,GAAG,CAAC,CAAC,SACP,MAAM,YAAY;EACzC,OAAO,QAAQ,mBAAmB,MAAM,EAAE,IAAI;CAChD,QAAQ;EACN,OAAO;CACT;AACF;AAEA,MAAM,4BAA4B,aAA6B;CAS7D,OAAO;EAPL,iBAAiB;EACjB,aAAa;EACb,cAAc;EACd,aAAa;EACb,cAAc;EACd,aAAa;CAEN,EAAE,UAAU,YAAY,MAAM,UAAU,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC,MAAM;AACnF;AAEA,MAAM,iBAAiB,OAA4B,eAA+B;CAChF,MAAM,YAAY,aAAa,aAAyB,MAAM,GAAG,UAAU,QAAQ,GAAG,CAAC,KAAK,CAAC;CAC7F,MAAM,cAAc,kBACZ,KAAK,MAAO,MAAM,IAAI,IAAI,aAAc,GAAG,GACjD,CAAC,YAAY,KAAK,CACpB;CAEA,OAAO,qBAAqB,WAAW,WAAW;AACpD;AAqBA,MAAM,UAAU,MACb,EACC,WACA,YACA,SACA,gBACA,cACA,SACA,kBACA,YACA,aACA,UACA,OACA,QACA,kBACA,cACA,QACA,cACI;CACJ,MAAM,EAAE,MAAM,eAAeA,aAAa;CAC1C,MAAM,CAAC,aAAa,kBAAkB,SAA6B,IAAI;CACvE,MAAM,CAAC,aAAa,kBAAkB,SAAS,KAAK;CACpD,MAAM,CAAC,iBAAiB,sBAAsB,SAAS,KAAK;CAE5D,MAAM,aAAa,aAAa,SAAS,SAAS,QAAQ;CAC1D,MAAM,aAAa,cAAc,OAAO,UAAU;CAIlD,MAAM,sBAAsB,KAAK,IAAI,aAAa,CAAC,IAAI;CAKvD,MAAM,oBAAoB,uBAAuB,eAAe;CAEhE,MAAM,iBAAiB,YAAY,YAAY;EAC7C,mBAAmB,IAAI;EACvB,IAAI;GAEF,MAAM,OAAO,OAAM,MADI,MAAM,QAAQ,EAAE,MAAM,OAAO,CAAC,EAAA,CACzB,KAAK;GACjC,MAAM,UAAU,IAAI,gBAAgB,IAAI;GACxC,IAAI,WAAW,mBAAmB,MAAM;GACxC,MAAM,MAAM,yBAAyB,KAAK,IAAI;GAC9C,IAAI,CAAC,SAAS,SAAS,GAAG,GACxB,WAAW,GAAG,SAAS,GAAG;QACrB,IAAI,SAAS,SAAS,UAAU,GACrC,WAAW,SAAS,QAAQ,gBAAgB,MAAM;GAEpD,MAAM,aAAa,SAAS,QAAQ;GACpC,IAAI,gBAAgB,OAAO;GAC3B,MAAM,QAAQ,EAAE,uBAAuB,CAAC;EAC1C,QAAQ;GACN,MAAM,MAAM,EAAE,sBAAsB,CAAC;EACvC,UAAU;GACR,mBAAmB,KAAK;EAC1B;CACF,GAAG,CAAC,QAAQ,CAAC,CAAC;CAEd,MAAM,aAAa,YAAY,YAAY;EACzC,eAAe,IAAI;EACnB,IAAI;GAEF,MAAM,OAAO,OAAM,MADI,MAAM,QAAQ,EAAE,MAAM,OAAO,CAAC,EAAA,CACzB,KAAK;GACjC,MAAM,gBAAgB,MAAM,iBAAiB,IAAI;GACjD,MAAM,UAAU,UAAU,MAAM,CAAC,IAAI,cAAc,aAAa,CAAC,CAAC;GAClE,MAAM,QAAQ,EAAE,mBAAmB,CAAC;EACtC,QAAQ;GACN,MAAM,MAAM,EAAE,kBAAkB,CAAC;EACnC,UAAU;GACR,eAAe,KAAK;EACtB;CACF,GAAG,CAAC,QAAQ,CAAC,CAAC;CAEd,MAAM,YAAY,cACV;EACJ;GACE,MAAM;GACN,KAAK;GACL,OAAO,EAAE,sBAAsB;GAC/B,SAAS;EACX;EACA;GACE,MAAM;GACN,KAAK;GACL,OAAO,EAAE,oBAAoB;GAC7B,SAAS;EACX;EACA;GAAE,MAAM;GAAW,KAAK;GAAe,OAAO,EAAE,kBAAkB;GAAG,SAAS;EAAW;EACzF;GACE,MAAM;GACN,KAAK;GACL,OAAO,EAAE,mBAAmB;GAC5B,SAAS;EACX;EACA;GAAE,MAAM;GAAM,KAAK;GAAQ,OAAO,EAAE,YAAY;GAAG,SAAS;EAAW;CACzE,GACA;EAAC;EAAgB;EAAc;EAAY;EAAY;EAAa;CAAC,CACvE;CAEA,OACE,oBAAC,cAAD;EAAc,gBAAgB,eAAe,KAAA;EAC3C,UAAA,oBAAC,OAAD;GAAK,WAAW,OAAO;GAAS,KAAK;GACnC,UAAA,qBAACC,mBAAD;IAAS,YAAA;IAAW,OAAM;IAAS,WAAW,OAAO;IAAY,KAAK;IAAtE,UAAA;KACE,oBAAC,YAAD;MACE,UAAU,CAAC;MACX,MAAM;MACN,OAAO,EAAE,cAAc,IAAI;MAC3B,OAAO,EAAE,eAAe;MACxB,SAAS;KACV,CAAA;KACD,qBAAC,QAAD;MAAQ,YAAA;MAAW,WAAW,OAAO;MAArC,UAAA,CACG,YAAW,GACN;;KACR,oBAAC,YAAD;MACE,UAAU,CAAC;MACX,MAAM;MACN,OAAO,EAAE,cAAc,IAAI;MAC3B,OAAO,EAAE,cAAc;MACvB,SAAS;KACV,CAAA;KACD,oBAAC,YAAD;MACE,oBAAkB,oBAAoB,QAAQ;MAC9C,UAAU,CAAC;MACX,MAAM;MACN,OAAO,EAAE,cAAc,IAAI;MAC3B,OAAO,oBAAoB,EAAE,mBAAmB,IAAI,EAAE,kBAAkB;MACxE,SAAS;KACV,CAAA;KACD,oBAAC,YAAD;MACE,MAAM;MACN,SAAS;MACT,OAAO,EAAE,cAAc,IAAI;MAC3B,OAAO,EAAE,gBAAgB;MACzB,SAAS;KACV,CAAA;KACD,oBAAC,cAAD;MACE,OAAO;MACP,WAAU;MACV,aAAa,EAAE,WAAW,eAAe,KAAA,EAAU;MACnD,eAAe,SAAS,mBAAmB,IAAI;MAE/C,UAAA,oBAAC,YAAD;OACE,MAAM;OACN,SAAS;OACT,OAAO,EAAE,cAAc,IAAI;OAC3B,OAAO,EAAE,YAAY;MACtB,CAAA;KACW,CAAA;KACb;IACM;;EACN,CAAA;CACO,CAAA;AAElB,CACF;AAEA,QAAQ,cAAc"}
1
+ {"version":3,"file":"Toolbar.mjs","names":["imageMessages","Flexbox"],"sources":["../../../src/Image/viewer/Toolbar.tsx"],"sourcesContent":["'use client';\n\nimport {\n Copy,\n Download,\n Ellipsis,\n FlipHorizontal,\n FlipVertical,\n RotateCcw,\n RotateCw,\n ZoomIn,\n ZoomOut,\n} from 'lucide-react';\nimport type { MotionValue } from 'motion/react';\nimport { memo, type ReactNode, useCallback, useMemo, useState, useSyncExternalStore } from 'react';\n\nimport ActionIcon from '@/base-ui/ActionIcon';\nimport DropdownMenu, { type DropdownItem } from '@/base-ui/DropdownMenu';\nimport { toast } from '@/base-ui/Toast';\nimport { Center, Flexbox } from '@/Flex';\nimport imageMessages from '@/i18n/resources/en/image';\nimport { useTranslation } from '@/i18n/useTranslation';\nimport { TooltipGroup } from '@/Tooltip';\nimport { getClipboardBlob } from '@/utils/blobToPng';\nimport { downloadBlob } from '@/utils/downloadBlob';\n\nimport { styles } from '../style';\nimport ActualSizeIcon from './ActualSizeIcon';\nimport { naturalScale, type Rect, type Rotation, type Size } from './geometry';\n\nconst getFileNameFromUrl = (url: string): string => {\n try {\n const pathname = new URL(url).pathname;\n const match = pathname.match(/\\/([^/]+)$/);\n return match ? decodeURIComponent(match[1]) : 'image';\n } catch {\n return 'image';\n }\n};\n\nconst getExtensionFromMimeType = (mimeType: string): string => {\n const map: Record<string, string> = {\n 'image/svg+xml': 'svg',\n 'image/png': 'png',\n 'image/jpeg': 'jpg',\n 'image/jpg': 'jpg',\n 'image/webp': 'webp',\n 'image/gif': 'gif',\n };\n return map[mimeType?.toLowerCase()] || mimeType?.split('/')[1]?.split('+')[0] || 'png';\n};\n\nconst usePercentage = (scale: MotionValue<number>, matchScale: number): number => {\n const subscribe = useCallback((onChange: () => void) => scale.on('change', onChange), [scale]);\n const getSnapshot = useCallback(\n () => Math.round((scale.get() / matchScale) * 100),\n [matchScale, scale],\n );\n\n return useSyncExternalStore(subscribe, getSnapshot);\n};\n\nexport interface ToolbarProps {\n canZoomIn: boolean;\n canZoomOut: boolean;\n fitRect: Rect;\n flipHorizontal: () => void;\n flipVertical: () => void;\n natural: Size;\n onDownload?: (source: string) => void | Promise<void>;\n onMoreOpenChange?: (open: boolean) => void;\n rotateLeft: () => void;\n rotateRight: () => void;\n rotation: Rotation;\n scale: MotionValue<number>;\n source: string;\n toggleActualSize: () => void;\n toolbarAddon?: ReactNode;\n zoomIn: () => void;\n zoomOut: () => void;\n}\n\nconst Toolbar = memo<ToolbarProps>(\n ({\n canZoomIn,\n canZoomOut,\n fitRect,\n flipHorizontal,\n flipVertical,\n natural,\n onDownload,\n onMoreOpenChange,\n rotateLeft,\n rotateRight,\n rotation,\n scale,\n source,\n toggleActualSize,\n toolbarAddon,\n zoomIn,\n zoomOut,\n }) => {\n const { t } = useTranslation(imageMessages);\n const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);\n const [copyLoading, setCopyLoading] = useState(false);\n const [downloadLoading, setDownloadLoading] = useState(false);\n\n const matchScale = naturalScale(natural, fitRect, rotation);\n const percentage = usePercentage(scale, matchScale);\n // An image small enough that computeFit never scaled it down is already at\n // 100% when fitted, so the two states the toggle switches between are the\n // same state and the control has nothing to do.\n const canToggleActualSize = Math.abs(matchScale - 1) > 0.01;\n // The control reads as \"go to actual size\" by default, and only flips to\n // the fit affordance when it can actually take you back — an image that\n // fits at 100% is at both states at once and would otherwise offer to\n // \"fit to screen\" while already fitted.\n const showFitAffordance = canToggleActualSize && percentage === 100;\n\n const defaultDownload = useCallback(\n async (src: string) => {\n try {\n const response = await fetch(src, { mode: 'cors' });\n const blob = await response.blob();\n const blobUrl = URL.createObjectURL(blob);\n let fileName = getFileNameFromUrl(src);\n const ext = getExtensionFromMimeType(blob.type);\n if (!fileName.includes('.')) {\n fileName = `${fileName}.${ext}`;\n } else if (fileName.endsWith('.svg+xml')) {\n fileName = fileName.replace(/\\.svg\\+xml$/i, '.svg');\n }\n await downloadBlob(blobUrl, fileName);\n URL.revokeObjectURL(blobUrl);\n toast.success(t('image.downloadSuccess'));\n } catch {\n // A cross-origin source without CORS headers (e.g. a presigned URL reached\n // through a redirect, which sends `Origin: null`) can never be read as a\n // blob; opening it lets the browser handle the save instead of failing.\n window.open(src, '_blank', 'noopener,noreferrer');\n }\n },\n [t],\n );\n\n const handleDownload = useCallback(async () => {\n setDownloadLoading(true);\n try {\n await (onDownload ?? defaultDownload)(source);\n } catch {\n toast.error(t('image.downloadFailed'));\n } finally {\n setDownloadLoading(false);\n }\n }, [defaultDownload, onDownload, source, t]);\n\n const handleCopy = useCallback(async () => {\n setCopyLoading(true);\n try {\n const response = await fetch(source, { mode: 'cors' });\n const blob = await response.blob();\n const clipboardBlob = await getClipboardBlob(blob);\n await navigator.clipboard.write([new ClipboardItem(clipboardBlob)]);\n toast.success(t('image.copySuccess'));\n } catch {\n toast.error(t('image.copyFailed'));\n } finally {\n setCopyLoading(false);\n }\n }, [source, t]);\n\n const moreItems = useMemo<DropdownItem[]>(\n () => [\n {\n icon: FlipHorizontal,\n key: 'flip-horizontal',\n label: t('image.flipHorizontal'),\n onClick: flipHorizontal,\n },\n {\n icon: FlipVertical,\n key: 'flip-vertical',\n label: t('image.flipVertical'),\n onClick: flipVertical,\n },\n { icon: RotateCcw, key: 'rotate-left', label: t('image.rotateLeft'), onClick: rotateLeft },\n {\n icon: RotateCw,\n key: 'rotate-right',\n label: t('image.rotateRight'),\n onClick: rotateRight,\n },\n { icon: Copy, key: 'copy', label: t('image.copy'), onClick: handleCopy },\n ],\n [flipHorizontal, flipVertical, handleCopy, rotateLeft, rotateRight, t],\n );\n\n return (\n <TooltipGroup popupContainer={containerEl ?? undefined}>\n <div className={styles.toolbar} ref={setContainerEl}>\n <Flexbox horizontal align=\"center\" className={styles.toolbarRow} gap={8}>\n <ActionIcon\n disabled={!canZoomOut}\n icon={ZoomOut}\n style={{ borderRadius: 999 }}\n title={t('image.zoomOut')}\n onClick={zoomOut}\n />\n <Center horizontal className={styles.toolbarPercentage}>\n {percentage}%\n </Center>\n <ActionIcon\n disabled={!canZoomIn}\n icon={ZoomIn}\n style={{ borderRadius: 999 }}\n title={t('image.zoomIn')}\n onClick={zoomIn}\n />\n <ActionIcon\n data-actual-size={showFitAffordance ? 'fit' : 'actual'}\n disabled={!canToggleActualSize}\n icon={ActualSizeIcon}\n style={{ borderRadius: 999 }}\n title={showFitAffordance ? t('image.fitToScreen') : t('image.actualSize')}\n onClick={toggleActualSize}\n />\n <ActionIcon\n icon={Download}\n loading={downloadLoading}\n style={{ borderRadius: 999 }}\n title={t('image.download')}\n onClick={handleDownload}\n />\n <DropdownMenu\n items={moreItems}\n placement=\"top\"\n portalProps={{ container: containerEl ?? undefined }}\n onOpenChange={(open) => onMoreOpenChange?.(open)}\n >\n <ActionIcon\n icon={Ellipsis}\n loading={copyLoading}\n style={{ borderRadius: 999 }}\n title={t('image.more')}\n />\n </DropdownMenu>\n {toolbarAddon}\n </Flexbox>\n </div>\n </TooltipGroup>\n );\n },\n);\n\nToolbar.displayName = 'Toolbar';\n\nexport default Toolbar;\n"],"mappings":"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"}