فهرست منبع

feat: [Image] add Image (#1091)

* feat: [Image] add Image

* fix: optimize code & add preload module in preview

* fix: Prompt for the internationalized preview footer operation area

* fix: [Image] add site doc & rtl & optimize code

* fix: [Image] optimize doc & code

* fix: fix doc error & open lazyLoad by default

* fix: [Image] optimize code & fix esc bug

* fix: [Image] Modify loading success/failure style & optimize doc

* docs: [Image] optimize doc

Co-authored-by: pointhalo <[email protected]>
YyumeiZhang 3 سال پیش
والد
کامیت
860cf96ada
88فایلهای تغییر یافته به همراه3420 افزوده شده و 40 حذف شده
  1. 1 1
      content/feedback/banner/index-en-US.md
  2. 1 1
      content/feedback/banner/index.md
  3. 1 1
      content/feedback/notification/index-en-US.md
  4. 1 1
      content/feedback/notification/index.md
  5. 1 1
      content/feedback/popconfirm/index-en-US.md
  6. 1 1
      content/feedback/popconfirm/index.md
  7. 1 1
      content/feedback/progress/index-en-US.md
  8. 1 1
      content/feedback/progress/index.md
  9. 1 1
      content/feedback/skeleton/index-en-US.md
  10. 1 1
      content/feedback/skeleton/index.md
  11. 1 1
      content/feedback/spin/index-en-US.md
  12. 1 1
      content/feedback/spin/index.md
  13. 1 1
      content/feedback/toast/index-en-US.md
  14. 1 1
      content/feedback/toast/index.md
  15. 1 0
      content/order.js
  16. 1 1
      content/other/configprovider/index-en-US.md
  17. 1 1
      content/other/configprovider/index.md
  18. 1 1
      content/other/locale/index-en-US.md
  19. 1 1
      content/other/locale/index.md
  20. 56 0
      content/show/image/index-en-US.md
  21. 56 0
      content/show/image/index.md
  22. 2 2
      content/show/list/index-en-US.md
  23. 1 1
      content/show/list/index.md
  24. 1 1
      content/show/modal/index-en-US.md
  25. 1 1
      content/show/modal/index.md
  26. 1 1
      content/show/overflowlist/index-en-US.md
  27. 1 1
      content/show/overflowlist/index.md
  28. 1 1
      content/show/popover/index-en-US.md
  29. 1 1
      content/show/popover/index.md
  30. 1 1
      content/show/scrolllist/index-en-US.md
  31. 1 1
      content/show/scrolllist/index.md
  32. 1 1
      content/show/sidesheet/index-en-US.md
  33. 1 1
      content/show/sidesheet/index.md
  34. 1 1
      content/show/table/index-en-US.md
  35. 1 1
      content/show/table/index.md
  36. 1 1
      content/show/tag/index-en-US.md
  37. 1 1
      content/show/tag/index.md
  38. 1 1
      content/show/timeline/index-en-US.md
  39. 1 1
      content/show/timeline/index.md
  40. 1 1
      content/show/tooltip/index-en-US.md
  41. 1 1
      content/show/tooltip/index.md
  42. 1 0
      content/start/overview/index-en-US.md
  43. 1 0
      content/start/overview/index.md
  44. 11 0
      packages/semi-foundation/image/animation.scss
  45. 7 0
      packages/semi-foundation/image/constants.ts
  46. 221 0
      packages/semi-foundation/image/image.scss
  47. 64 0
      packages/semi-foundation/image/imageFoundation.tsx
  48. 41 0
      packages/semi-foundation/image/previewFooterFoundation.tsx
  49. 25 0
      packages/semi-foundation/image/previewFoundation.tsx
  50. 260 0
      packages/semi-foundation/image/previewImageFoundation.tsx
  51. 260 0
      packages/semi-foundation/image/previewInnerFoundation.tsx
  52. 51 0
      packages/semi-foundation/image/rtl.scss
  53. 86 0
      packages/semi-foundation/image/utils.ts
  54. 47 0
      packages/semi-foundation/image/variables.scss
  55. 3 0
      packages/semi-theme-default/scss/variables.scss
  56. 374 0
      packages/semi-ui/image/_story/image.stories.js
  57. 210 0
      packages/semi-ui/image/image.tsx
  58. 1 0
      packages/semi-ui/image/index-en-US.md
  59. 1 0
      packages/semi-ui/image/index.md
  60. 15 0
      packages/semi-ui/image/index.tsx
  61. 194 0
      packages/semi-ui/image/interface.tsx
  62. 194 0
      packages/semi-ui/image/preview.tsx
  63. 18 0
      packages/semi-ui/image/previewContext.tsx
  64. 277 0
      packages/semi-ui/image/previewFooter.tsx
  65. 30 0
      packages/semi-ui/image/previewHeader.tsx
  66. 218 0
      packages/semi-ui/image/previewImage.tsx
  67. 402 0
      packages/semi-ui/image/previewInner.tsx
  68. 3 0
      packages/semi-ui/index.ts
  69. 14 1
      packages/semi-ui/locale/interface.ts
  70. 13 0
      packages/semi-ui/locale/source/ar.ts
  71. 13 0
      packages/semi-ui/locale/source/de.ts
  72. 13 0
      packages/semi-ui/locale/source/en_GB.ts
  73. 13 0
      packages/semi-ui/locale/source/en_US.ts
  74. 13 0
      packages/semi-ui/locale/source/es.ts
  75. 13 0
      packages/semi-ui/locale/source/fr.ts
  76. 13 0
      packages/semi-ui/locale/source/id_ID.ts
  77. 13 0
      packages/semi-ui/locale/source/it.ts
  78. 13 0
      packages/semi-ui/locale/source/ja_JP.ts
  79. 13 0
      packages/semi-ui/locale/source/ko_KR.ts
  80. 13 0
      packages/semi-ui/locale/source/ms_MY.ts
  81. 13 0
      packages/semi-ui/locale/source/pt_BR.ts
  82. 13 0
      packages/semi-ui/locale/source/ru_RU.ts
  83. 13 0
      packages/semi-ui/locale/source/th_TH.ts
  84. 13 0
      packages/semi-ui/locale/source/tr_TR.ts
  85. 13 0
      packages/semi-ui/locale/source/vi_VN.ts
  86. 13 0
      packages/semi-ui/locale/source/zh_CN.ts
  87. 13 0
      packages/semi-ui/locale/source/zh_TW.ts
  88. 5 0
      src/images/docIcons/doc-image.svg

+ 1 - 1
content/feedback/banner/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 64
+order: 65
 category: Feedback
 title:  Banner
 subTitle: Banner

+ 1 - 1
content/feedback/banner/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 64
+order: 65
 category: 反馈类
 title:  Banner 通知横幅
 icon: doc-banner

+ 1 - 1
content/feedback/notification/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 65
+order: 66
 category: Feedback
 title:  Notification
 subTitle: Notification

+ 1 - 1
content/feedback/notification/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 65
+order: 66
 category: 反馈类
 title: Notification 通知
 icon: doc-notification

+ 1 - 1
content/feedback/popconfirm/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 66
+order: 67
 category: Feedback
 title:  Popconfirm
 subTitle: Popconfirm

+ 1 - 1
content/feedback/popconfirm/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 66
+order: 67
 category: 反馈类
 title:  Popconfirm 气泡确认框
 icon: doc-popconfirm

+ 1 - 1
content/feedback/progress/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 67
+order: 68
 category: Feedback
 title: Progress
 subTitle: Progress

+ 1 - 1
content/feedback/progress/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 67
+order: 68
 category: 反馈类
 title: Progress 进度条
 icon: doc-progress

+ 1 - 1
content/feedback/skeleton/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 68
+order: 69
 category: Feedback
 title: Skeleton
 subTitle: Skeleton

+ 1 - 1
content/feedback/skeleton/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 68
+order: 69
 category: 反馈类
 title: Skeleton 骨架屏
 icon: doc-skeleton

+ 1 - 1
content/feedback/spin/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 69
+order: 70
 category: Feedback
 title: Spin
 subTitle: Spin

+ 1 - 1
content/feedback/spin/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 69
+order: 70
 category: 反馈类
 title: Spin 加载器
 icon: doc-spin

+ 1 - 1
content/feedback/toast/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 70
+order: 71
 category: Feedback
 title: Toast
 subTitle: Toast

+ 1 - 1
content/feedback/toast/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 70
+order: 71
 category: 反馈类
 title: Toast 提示
 icon: doc-toast

+ 1 - 0
content/order.js

@@ -52,6 +52,7 @@ const order = [
     'descriptions',
     'dropdown',
     'empty',
+    'image',
     'list',
     'modal',
     'overflowlist',

+ 1 - 1
content/other/configprovider/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 71
+order: 72
 category: Other
 title: ConfigProvider
 icon: doc-configprovider

+ 1 - 1
content/other/configprovider/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 71
+order: 72
 category: 其他
 title:  ConfigProvider 全局配置
 icon: doc-configprovider

+ 1 - 1
content/other/locale/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 72
+order: 73
 category: Other
 title: LocaleProvider
 subTitle: LocaleProvider

+ 1 - 1
content/other/locale/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 72
+order: 73
 category: 其他
 title:  LocaleProvider 多语言
 icon: doc-i18n

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 56 - 0
content/show/image/index-en-US.md


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 56 - 0
content/show/image/index.md


+ 2 - 2
content/show/list/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 54
+order: 55
 category: Show
 title: List
 subTitle: List
@@ -13,7 +13,7 @@ brief: Lists display a set of related contents
 
 ### How to import
 
-```jsx import 
+```jsx import
 import { List } from '@douyinfe/semi-ui';
 ```
 

+ 1 - 1
content/show/list/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 54
+order: 55
 category: 展示类
 title: List 列表
 icon: doc-list

+ 1 - 1
content/show/modal/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 55
+order: 56
 category: Show
 title:  Modal
 subTitle: Modal

+ 1 - 1
content/show/modal/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 55
+order: 56
 category: 展示类
 title:  Modal 模态对话框
 icon: doc-modal

+ 1 - 1
content/show/overflowlist/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 56
+order: 57
 category: Show
 title: OverflowList
 subTitle: OverflowList

+ 1 - 1
content/show/overflowlist/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 56
+order: 57
 category: 展示类
 title: OverflowList 折叠列表
 icon: doc-overflowList

+ 1 - 1
content/show/popover/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 57
+order: 58
 category: Show
 title: Popover
 subTitle: Popover

+ 1 - 1
content/show/popover/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 57
+order: 58
 category: 展示类
 title: Popover 气泡卡片
 icon: doc-popover

+ 1 - 1
content/show/scrolllist/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 58
+order: 59
 category: Show
 title:  ScrollList
 subTitle: ScrollList

+ 1 - 1
content/show/scrolllist/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 58
+order: 59
 category: 展示类
 title: ScrollList 滚动列表
 icon: doc-scrolllist

+ 1 - 1
content/show/sidesheet/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 59
+order: 60
 category: Show
 title: SideSheet
 subTitle: SideSheet

+ 1 - 1
content/show/sidesheet/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 59
+order: 60
 category: 展示类
 title: SideSheet 滑动侧边栏
 icon: doc-sidesheet

+ 1 - 1
content/show/table/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 60
+order: 61
 category: Show
 title: Table
 subTitle: Table

+ 1 - 1
content/show/table/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 60
+order: 61
 category: 展示类
 title:  Table 表格
 icon: doc-table

+ 1 - 1
content/show/tag/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 61
+order: 62
 category: Show
 title: Tag
 subTitle: Tag

+ 1 - 1
content/show/tag/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 61
+order: 62
 category: 展示类
 title: Tag 标签
 icon: doc-tag

+ 1 - 1
content/show/timeline/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 62
+order: 63
 category: Show
 title:  Timeline
 subTitle: Timeline

+ 1 - 1
content/show/timeline/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 62
+order: 63
 category: 展示类
 title: Timeline 时间轴
 icon: doc-timeline

+ 1 - 1
content/show/tooltip/index-en-US.md

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 63
+order: 64
 category: Show
 title: Tooltip
 subTitle: Tooltip

+ 1 - 1
content/show/tooltip/index.md

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 63
+order: 64
 category: 展示类
 title: Tooltip 工具提示
 icon: doc-tooltip

+ 1 - 0
content/start/overview/index-en-US.md

@@ -66,6 +66,7 @@ Collapsible,
 Descriptions,
 Dropdown,
 Empty,
+Image,
 List,
 Modal,
 OverflowList,

+ 1 - 0
content/start/overview/index.md

@@ -67,6 +67,7 @@ Collapsible 折叠,
 Descriptions 描述列表,
 Dropdown 下拉框,
 Empty 空状态,
+Image 图片,
 List 列表,
 Modal 模态对话框,
 OverflowList 折叠列表,

+ 11 - 0
packages/semi-foundation/image/animation.scss

@@ -0,0 +1,11 @@
+$transform_rotate-image_preview_spinner: var(--semi-transform_rotate-clockwise360deg); // 预览图像加载spin旋转角度
+$transform_scale3d-image_preview_image_img: 1, 1, 1; // 预览图片放大
+$transform_rotate-image_preview_image_img: var(--semi-transform-rotate-none); // 预览图片旋转角度
+
+$transition_duration-image_preview_image_img: 0.3s; // 预览图像动画持续时间
+$transition_function-image_preview_image_img: cubic-bezier(0.215, 0.61, 0.355, 1); // 预览图片动画函数
+$transition_delay-image_preview_image_img: 0s; // 预览图片延迟时间
+
+$transition_duration-image_preview: 500ms; // 预览图片透明度变化动画时间
+
+$transform_rotate-image_preview_icon_rtl: var(--semi-transform_rotate-clockwise180deg); // rtl模式下向前/向后切换图片按钮旋转角度

+ 7 - 0
packages/semi-foundation/image/constants.ts

@@ -0,0 +1,7 @@
+import { BASE_CLASS_PREFIX } from "../base/constants";
+
+const cssClasses = {
+    PREFIX: `${BASE_CLASS_PREFIX}-image`,
+} as const;
+
+export { cssClasses };

+ 221 - 0
packages/semi-foundation/image/image.scss

@@ -0,0 +1,221 @@
+@import "./animation.scss";
+@import "./variables.scss";
+
+$module: #{$prefix}-image;
+
+.#{$module} {
+
+    border-radius: $radius-image;
+    position: relative;
+    display: inline-block;
+    overflow: hidden;
+
+    &-img {
+        vertical-align: middle;
+        border-radius: inherit;
+
+        &-preview {
+            cursor: zoom-in;
+        }
+
+        &-error {
+            opacity: 0;
+        }
+    }
+
+    &-overlay {
+        position: absolute;
+        top: 0;
+        left: 0;
+        width: 100%;
+        height: 100%;
+    }
+}
+
+.#{$module}-status {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    border-radius: $radius-image;
+    background-color: $color-image_status-bg;
+    color: $color-image_status;
+}
+
+.#{$module}-preview {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    z-index: $z-image_preview;
+    background-color: var(--semi-color-overlay-bg);
+    transition: opacity $transition_duration-image_preview;
+    overflow: hidden;
+
+    &-popup {
+        position: absolute;
+    }
+
+    .#{$module}-preview-hide {
+        opacity: 0;
+    }
+
+    &-icon {
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        width: $width-image_preview_icon;
+        height: $height-image_preview_icon;
+        border-radius: 50%;
+        position: absolute;
+        top: 50%;
+        transform: translateY(-50%);
+        background: $color-image_preview_icon-bg;
+        cursor: pointer;
+        color: $color-image_preview_icon;
+    }
+
+    &-prev {
+        left: $spacing-image_preview_icon-x;
+    }
+
+    &-next {
+        right: $spacing-image_preview_icon-x;
+    }
+
+    &-header {
+        position: absolute;
+        top: 0;
+        left: 0;
+        right: 0;
+        font-weight: normal;
+        @include font-size-regular;
+        color: $color-image_preview_header;
+        height: $height-image_preview_header;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        padding: $spacing-image_preview_header-paddingY $spacing-image_preview_header-paddingX;
+        z-index: $z-image_preview_header;
+
+        &-title {
+            flex: 1;
+        }
+
+        &-close {
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            cursor: pointer;
+            width: $width-image_preview_header_close;
+            height: $height-image_preview_header_close;
+            border-radius: 50%;
+
+            &:hover {
+                background-color: $color-image_header_close-bg;
+            }
+        }
+    }
+
+    &-footer {
+        display: flex;
+        align-items: center;
+        padding: $spacing-image_preview_footer-paddingY $spacing-image_preview_footer-paddingX;
+        background: $color-image_preview_footer-bg;
+        border-radius: $radius-image_preview_footer;
+        height: $height-image_preview_footer;
+
+        &-wrapper {
+            position: absolute;
+            left: 50%;
+            bottom: 16px;
+            transform: translateX(-50%);
+        }
+
+        &-page {
+            color: $color-image_preview_footer_icon;
+            @include font-size-header-6;
+            margin: $spacing-image_preview_footer_page-marginY $spacing-image_preview_footer_page-marginX;
+        }
+
+        .#{$prefix}-icon {
+            color: $color-image_preview_footer_icon;
+            cursor: pointer;
+        }
+
+        &-gap {
+            margin-left: $spacing-image_preview_footer_gap-marginLeft;
+        }
+    
+        .#{$prefix}-slider {
+            width: $width-image_preview_footer_slider;
+            padding: $spacing-image_preview_footer_slider-paddingY $spacing-image_preview_footer_slider-paddingX;
+    
+            .#{$prefix}-slider-rail {
+                color: $color-image_preview_footer_slider_rail;
+                height: $height-image_preview_footer_slider;
+            }
+    
+            .#{$prefix}-slider-track {
+                height: $height-image_preview_footer_slider;
+            }
+    
+            .#{$prefix}-slider-handle {
+                width: $width-image_preview_footer_slider_handle;
+                height: $height-image_preview_footer_slider_handle;
+                margin-top: $spacing-image_preview_footer_slider_handle-marginTop;
+                box-sizing: border-box;
+            }
+        }
+
+        .#{$prefix}-divider {
+            background: $color-image-preview_divider-bg;
+            margin: $spacing-image_preview_footer_divider-marginY $spacing-image_preview_footer_divider-marginX;
+        }
+        
+        .#{$module}-preview-footer-disabled {
+            color: $color-image_preview_disabled;
+            cursor: default;
+            pointer-events: none;
+        }
+
+    }
+
+    &-image {
+        position: relative;
+        height: 100%;
+
+        &-img {
+            position: absolute;
+            transform: scale3d($transform_scale3d-image_preview_image_img) $transform_rotate-image_preview_image_img;
+            transition: transform $transition_duration-image_preview_image_img  $transition_delay-image_preview_image_img;
+            z-index: 0;
+        }
+
+        &-spin {
+            position: absolute;
+            top: 50%;
+            left: 50%;
+            transform: translate(-50%, -50%);
+
+            .#{$prefix}-spin-wrapper {
+                color: $color-image_preview_image_spin;
+            }
+        }
+    }
+    
+    @keyframes spinner {
+        to {
+            transform: $transform_rotate-image_preview_spinner;
+        }
+    }
+}
+
+// Remove the default border of img when src is empty or src is invalid
+img[src=""], img:not([src]) {
+    opacity: 0;
+}
+
+@import "./rtl.scss";

+ 64 - 0
packages/semi-foundation/image/imageFoundation.tsx

@@ -0,0 +1,64 @@
+import BaseFoundation, { DefaultAdapter } from "../base/foundation";
+import { isObject } from "lodash";
+
+
+export interface ImageAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
+    getIsInGroup: () => boolean;
+}
+
+
+export default class ImageFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<ImageAdapter<P, S>, P, S> {
+    constructor(adapter: ImageAdapter<P, S>) {
+        super({ ...adapter });
+    }
+
+    handleClick = (e: any) => {
+        const { imageID, preview } = this.getProps();
+        // if preview = false, then it cannot preview
+        if (!preview) {
+            return;
+        }
+        // if image in group, then use group's Preview components
+        if (this._adapter.getIsInGroup()) {
+            const { setCurrentIndex, handleVisibleChange } = this._adapter.getContexts();
+            setCurrentIndex(imageID);
+            handleVisibleChange(true);
+        } else {
+            // image isn't in group, then use it's own Preview components
+            this.handlePreviewVisibleChange(true);
+        }
+    }
+
+    handleLoaded = (e: any) => {
+        const { onLoad } = this.getProps();
+        onLoad && onLoad(e);
+        this.setState ({
+            loadStatus: "success",
+        } as any);
+    }
+
+    handleError = (e: any) => {
+        const { onError } = this.getProps();
+        onError && onError(e);
+        this.setState ({
+            loadStatus: "error",
+        } as any);
+    }
+
+    handlePreviewVisibleChange = (newVisible: boolean) => {
+        const { preview } = this.getProps();
+        if (isObject(preview)) {
+            const { onVisibleChange } = preview as any;
+            onVisibleChange && onVisibleChange(newVisible);
+            if (!("visible" in this.getProps())) {
+                this.setState({
+                    previewVisible: newVisible,
+                } as any);
+            }
+        } else {
+            this.setState({
+                previewVisible: newVisible,
+            } as any);
+        }
+    }
+}

+ 41 - 0
packages/semi-foundation/image/previewFooterFoundation.tsx

@@ -0,0 +1,41 @@
+import BaseFoundation, { DefaultAdapter } from "../base/foundation";
+
+export interface PreviewFooterAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
+    setStartMouseOffset: (time: number) => void;
+}
+
+export default class PreviewFooterFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<PreviewFooterAdapter<P, S>, P, S> {
+    
+    changeSliderValue = (type: string): void => {
+        const { zoom, step, min, max } = this.getProps();
+        let newValue = type === "plus" ? zoom + step : zoom - step;
+        if (newValue > max) {
+            newValue = max;
+        } else if (newValue < min) {
+            newValue = min;
+        }
+        this.handleValueChange(newValue);
+    };
+
+    handleValueChange = (value: number): void => {
+        const { onZoomIn, onZoomOut, zoom } = this.getProps();
+        if (value > zoom) {
+            onZoomIn(value / 100);
+        } else {
+            onZoomOut(value / 100);
+        }
+        this._adapter.setStartMouseOffset(value);
+    };
+
+    handleRatioClick = (): void => {
+        const { ratio, onAdjustRatio } = this.getProps();
+        const type = ratio === "adaptation" ? "realSize" : "adaptation";
+        onAdjustRatio(type);
+    }
+
+    handleRotate = (direction: string): void => {
+        const { onRotate } = this.getProps();
+        onRotate && onRotate(direction);
+    }
+    
+}

+ 25 - 0
packages/semi-foundation/image/previewFoundation.tsx

@@ -0,0 +1,25 @@
+import BaseFoundation, { DefaultAdapter } from "../base/foundation";
+
+export default class PreviewFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<Partial<DefaultAdapter>> {
+    
+    handleVisibleChange = (newVisible : boolean) => {
+        const { visible, onVisibleChange } = this.getProps();
+        if (!(visible in this.getProps())) {
+            this.setState({
+                visible: newVisible,
+            });
+        }
+        onVisibleChange && onVisibleChange(newVisible);
+    };
+
+    handleCurrentIndexChange = (index: number) => {
+        const { currentIndex, onChange } = this.getProps();
+        if (!(currentIndex in this.getProps())) {
+            this.setState({
+                currentIndex: index,
+            } as any);
+        }
+        onChange && onChange(index);
+    };
+    
+}

+ 260 - 0
packages/semi-foundation/image/previewImageFoundation.tsx

@@ -0,0 +1,260 @@
+import BaseFoundation, { DefaultAdapter } from "../base/foundation";
+import { handlePrevent } from "../utils/a11y";
+import { throttle, isUndefined } from "lodash";
+
+export interface PreviewImageAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
+    getOriginImageSize: () => { originImageWidth: number; originImageHeight: number; }; 
+    setOriginImageSize: (size: { originImageWidth: number; originImageHeight: number; }) => void;
+    getContainerRef: () => any;
+    getImageRef: () => any;
+    getMouseMove: () => boolean;
+    setStartMouseMove: (move: boolean) => void;
+    getMouseOffset: () => { x: number; y: number };
+    setStartMouseOffset: (offset: { x: number; y: number }) => void;
+    setLoading: (loading: boolean) => void;
+}
+
+export interface DragDirection {
+    canDragVertical: boolean;
+    canDragHorizontal: boolean;
+}
+
+export interface ExtremeBounds {
+    left: number;
+    top: number;
+}
+
+export interface ImageOffset {
+    x: number;
+    y: number;
+}
+
+export default class PreviewImageFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<PreviewImageAdapter<P, S>, P, S> {
+    constructor(adapter: PreviewImageAdapter<P, S>) {
+        super({ ...adapter });
+    }
+
+    _isImageVertical = (): boolean => this.getProp("rotation") % 180 !== 0;
+
+    _getImageBounds = (): DOMRect => {
+        const imageRef = this._adapter.getImageRef();
+        return imageRef?.getBoundingClientRect();
+    };
+
+    _getContainerBounds = (): DOMRect => {
+        const containerRef = this._adapter.getContainerRef();
+        return containerRef?.current?.getBoundingClientRect();
+    }
+
+    _getOffset = (e: any): ImageOffset => {
+        const { left, top } = this._getImageBounds();
+        return {
+            x: e.clientX - left,
+            y: e.clientY - top,
+        };
+    }
+
+    setLoading = (loading: boolean) => {
+        this._adapter.setLoading(loading);
+    }
+
+    handleWindowResize = (): void => {
+        const { setRatio } = this.getProps();
+        const { ratio } = this.getProps();
+        const { originImageWidth, originImageHeight } = this._adapter.getOriginImageSize();
+        if (originImageWidth && originImageHeight) {
+            if (ratio !== "adaptation") {
+                setRatio("adaptation");
+            } else {
+                this.handleResizeImage();
+            } 
+        }
+    };
+
+    handleLoad = (e: any): void => {
+        if (e.target) {
+            const { width: w, height: h } = e.target as any;
+            this._adapter.setOriginImageSize({ originImageWidth: w, originImageHeight: h });
+            this.setState({
+                loading: false,
+            } as any);
+            this.handleResizeImage();
+        }
+        const { src, onLoad } = this.getProps();
+        onLoad && onLoad(src);
+    }
+
+    handleError = (e: any): void => {
+        const { onError, src } = this.getProps();
+        this.setState({
+            loading: false,
+        } as any);
+        onError && onError(src);
+    }
+
+    handleResizeImage = () => {
+        const horizontal = !this._isImageVertical();
+        const { originImageWidth, originImageHeight } = this._adapter.getOriginImageSize();
+        const imgWidth = horizontal ? originImageWidth : originImageHeight;
+        const imgHeight = horizontal ? originImageHeight : originImageWidth;
+        const { onZoom } = this.getProps();
+        const containerRef = this._adapter.getContainerRef();
+        if (containerRef && containerRef.current) {
+            const { width: containerWidth, height: containerHeight } = this._getContainerBounds();
+            const reservedWidth = containerWidth - 80;
+            const reservedHeight = containerHeight - 80;
+            const _zoom = Number(
+                Math.min(reservedWidth / imgWidth, reservedHeight / imgHeight).toFixed(2)
+            );
+            onZoom(_zoom);
+        }
+    }
+
+    handleRightClickImage = (e: any) => {
+        const { disableDownload } = this.getProps();
+        if (disableDownload) {
+            e.preventDefault();
+            e.stopPropagation();
+            return false;
+        } else {
+            return true;
+        }
+    };
+
+    handleWheel = (e: React.WheelEvent<HTMLImageElement>) => {
+        this.onWheel(e);
+        handlePrevent(e);
+    }
+
+    onWheel = throttle((e: React.WheelEvent<HTMLImageElement>): void => {
+        const { onZoom, zoomStep, maxZoom, minZoom } = this.getProps();
+        const { currZoom } = this.getStates();
+        let _zoom:number;
+        if (e.deltaY < 0) {
+            /* zoom in */
+            if (currZoom + zoomStep <= maxZoom) {
+                _zoom = Number((currZoom + zoomStep).toFixed(2));
+            }
+        } else if (e.deltaY > 0) {
+            /* zoom out */
+            if (currZoom - zoomStep >= minZoom) {
+                _zoom = Number((currZoom - zoomStep).toFixed(2));
+            }
+        }
+        if (!isUndefined(_zoom)) {
+            onZoom(_zoom);
+        }
+    }, 50);
+
+    calcCanDragDirection = (): DragDirection => {
+        const { width, height } = this.getStates();
+        const { rotation } = this.getProps();
+        const { width: containerWidth, height: containerHeight } =this._getContainerBounds();
+        let canDragHorizontal = width > containerWidth;
+        let canDragVertical = height > containerHeight;
+        if (this._isImageVertical()) {
+            canDragHorizontal = height > containerWidth;
+            canDragVertical = width > containerHeight;
+        }
+        return {
+            canDragVertical,
+            canDragHorizontal,
+        };
+    };
+
+    handleZoomChange = (newZoom: number, e: any): void => {
+        const imageRef = this._adapter.getImageRef();
+        const { originImageWidth, originImageHeight } = this._adapter.getOriginImageSize();
+        const { canDragVertical, canDragHorizontal } = this.calcCanDragDirection();
+        const canDrag = canDragVertical || canDragHorizontal;
+        const { width: containerWidth, height: containerHeight } = this._getContainerBounds();
+        const newWidth = Math.floor(originImageWidth * newZoom);
+        const newHeight = Math.floor(originImageHeight * newZoom);
+
+        // debugger;
+        let _offset;
+        const horizontal = !this._isImageVertical();
+        let newTop = 0;
+        let newLeft = 0;
+        if (horizontal) {
+            _offset = {
+                x: 0.5 * (containerWidth - newWidth),
+                y: 0.5 * (containerHeight - newHeight),
+            };
+           
+            newLeft = _offset.x;
+            newTop= _offset.y;
+        } else {
+            _offset = {
+                x: 0.5 * (containerWidth - newHeight),
+                y: 0.5 * (containerHeight - newWidth),
+            };
+            newLeft = _offset.x - (newWidth - newHeight) / 2;
+            newTop = _offset.y + (newWidth - newHeight) / 2;
+        }
+        
+        this.setState({
+            width: newWidth,
+            height: newHeight,
+            offset: _offset,
+            left: newLeft,
+            top: newTop,
+            currZoom: newZoom,
+        } as any);
+        imageRef && (imageRef.style.cursor = canDrag ? "grab" : "default");
+    };
+
+    calcExtremeBounds = (): ExtremeBounds => {
+        const { width, height } = this.getStates(); 
+        const { width: containerWidth, height: containerHeight } = this._getContainerBounds();
+        let extremeLeft = containerWidth - width;
+        let extremeTop = containerHeight - height;
+        if (this._isImageVertical()) {
+            extremeLeft = containerWidth - height;
+            extremeTop = containerHeight - width;
+        }
+        return {
+            left: extremeLeft,
+            top: extremeTop,
+        };
+    };
+
+    handleMoveImage = (e: any): void => {
+        const { offset, width, height, left, top } = this.getStates();
+        const { rotation } = this.getProps();
+        const startMouseMove = this._adapter.getMouseMove();
+        const startMouseOffset = this._adapter.getMouseOffset();
+        const { canDragVertical, canDragHorizontal } = this.calcCanDragDirection();
+        if (startMouseMove && (canDragVertical || canDragHorizontal)) {
+            const { pageX, pageY } = e;
+            const { left: extremeLeft, top: extremeTop } = this.calcExtremeBounds();
+            let newX = canDragHorizontal ? pageX - startMouseOffset.x : offset.x;
+            let newY = canDragVertical ? pageY - startMouseOffset.y : offset.y;
+            if (canDragHorizontal) {
+                newX = newX > 0 ? 0 : newX < extremeLeft ? extremeLeft : newX;
+            }
+            if (canDragVertical) {
+                newY = newY > 0 ? 0 : newY < extremeTop ? extremeTop : newY;
+
+            }
+            const _offset = {
+                x: newX,
+                y: newY,
+            };
+            this.setState({
+                offset: _offset,
+                left: this._isImageVertical() ? _offset.x - (width - height) / 2 : _offset.x,
+                top: this._isImageVertical() ? _offset.y + (width - height) / 2 : _offset.y,
+            } as any);
+        }
+    };
+
+    handleImageMouseDown = (e: any): void => {
+        this._adapter.setStartMouseOffset(this._getOffset(e));
+        this._adapter.setStartMouseMove(true);
+    };
+
+    handleImageMouseUp = (): void => {
+        this._adapter.setStartMouseMove(false);
+    };
+}

+ 260 - 0
packages/semi-foundation/image/previewInnerFoundation.tsx

@@ -0,0 +1,260 @@
+import BaseFoundation, { DefaultAdapter } from "../base/foundation";
+import KeyCode from "../utils/keyCode";
+import { getPreloadImagArr, downloadImage, isTargetEmit } from "./utils";
+
+export interface PreviewInnerAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
+    getIsInGroup: () => boolean;
+    notifyChange: (index: number) => void;
+    notifyZoom: (zoom: number, increase: boolean) => void;
+    notifyClose: () => void;
+    notifyVisibleChange: (visible: boolean) => void;
+    notifyRatioChange: (type: string) => void;
+    notifyRotateChange: (angle: number) => void;
+    notifyDownload: (src: string, index: number) => void;
+    registerKeyDownListener: () => void;
+    unregisterKeyDownListener: () => void;
+    getMouseActiveTime: () => number;
+    getStopTiming: () => boolean;
+    setStopTiming: (value: boolean) => void;
+    getStartMouseDown: () => {x: number, y: number};
+    setStartMouseDown: (x: number, y: number) => void;
+    setMouseActiveTime: (time: number) => void;
+}
+
+const NOT_CLOSE_TARGETS = ["icon", "footer"];
+const STOP_CLOSE_TARGET = ["icon", "footer", "header"];
+
+export default class PreviewInnerFoundation<P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<PreviewInnerAdapter<P, S>, P, S> {
+    constructor(adapter: PreviewInnerAdapter<P, S>) {
+        super({ ...adapter });
+    }
+
+    beforeShow() {
+        this._adapter.registerKeyDownListener();
+    }
+
+    afterHide() {
+        this._adapter.unregisterKeyDownListener();
+    }
+
+    handleRatio(type: string) {
+        this.setState({
+            ratio: type,
+        } as any);
+    }
+
+    handleViewVisibleChange = () => {
+        const nowTime = new Date().getTime();
+        const mouseActiveTime = this._adapter.getMouseActiveTime();
+        const stopTiming = this._adapter.getStopTiming();
+        const { viewerVisibleDelay } = this.getProps();
+        const { viewerVisible } = this.getStates();
+        if (nowTime - mouseActiveTime > viewerVisibleDelay && !stopTiming) {
+            viewerVisible && this.setState({
+                viewerVisible: false,
+            } as any);
+        }
+    }
+
+    handleMouseMoveEvent = (e: any, event: string) => {
+        const isTarget = isTargetEmit(e.nativeEvent, STOP_CLOSE_TARGET);
+        if (isTarget && event === "over") {
+            this._adapter.setStopTiming(true);
+        } else if (isTarget && event === "out") {
+            this._adapter.setStopTiming(false);
+        }
+    }
+
+    handleMouseMove = (e: any) => {
+        this._adapter.setMouseActiveTime(new Date().getTime());
+        this.setState({
+            viewerVisible: true,
+        } as any);
+    }
+
+    handleMouseUp = (e: any) => {
+        const { maskClosable } = this.getProps();
+        let couldClose = !isTargetEmit(e.nativeEvent, NOT_CLOSE_TARGETS);
+        const { clientX, clientY } = e;
+        const { x, y } = this._adapter.getStartMouseDown();
+        if (clientX !== x || y !== clientY) {
+            couldClose = false;
+        }
+        if (couldClose && maskClosable) {
+            this.handlePreviewClose();
+        }
+    }
+
+    handleMouseDown = (e: any) => {
+        const { clientX, clientY } = e;
+        this._adapter.setStartMouseDown(clientX, clientY);
+    }
+
+    handleKeyDown = (e: any) => {
+        const { closeOnEsc } = this.getProps();
+        if (closeOnEsc && e.keyCode === KeyCode.ESC) {
+            e.stopPropagation();
+            this._adapter.notifyVisibleChange(false);
+            this._adapter.notifyClose();
+            return;
+        }
+    }
+
+    handleSwitchImage = (direction: string) => {
+        const step = direction === "prev" ? -1 : 1;
+        const { imgSrc, currentIndex: currentIndexInState } = this.getStates();
+        const srcLength = imgSrc.length;
+        const newIndex = (currentIndexInState + step + srcLength) % srcLength;
+        if ("currentIndex" in this.getProps()) {
+            if (this._adapter.getIsInGroup()) {
+                const setCurrentIndex = this._adapter.getContext("setCurrentIndex");
+                setCurrentIndex(newIndex);
+            }
+        } else {
+            this.setState({
+                currentIndex: newIndex,
+            } as any);
+        }
+        this._adapter.notifyChange(newIndex);
+        this.setState({
+            direction,
+            rotation: 0,
+        } as any);
+        this._adapter.notifyRotateChange(0);
+    }  
+
+    handleDownload = () => {
+        const { currentIndex, imgSrc } = this.getStates();
+        const downloadSrc = imgSrc[currentIndex];
+        const downloadName = downloadSrc.slice(downloadSrc.lastIndexOf("/") + 1);
+        downloadImage(downloadSrc, downloadName);
+        this._adapter.notifyDownload(downloadSrc, currentIndex);
+    }
+
+    handlePreviewClose = () => {
+        this._adapter.notifyVisibleChange(false);
+        this._adapter.notifyClose();
+    }
+
+    handleAdjustRatio = (type: string) => {
+        this.setState({
+            ratio: type,
+        } as any);
+        this._adapter.notifyRatioChange(type);
+    }
+
+    handleRotateImage = (direction: string) => {
+        const { rotation } = this.getStates();
+        const newRotation = rotation + (direction === "left" ? 90 : (-90));
+        this.setState({
+            rotation: newRotation,
+        } as any);
+        this._adapter.notifyRotateChange(newRotation);
+    }
+
+    handleZoomImage = (newZoom: number) => {
+        const { zoom } = this.getStates();
+        this._adapter.notifyZoom(newZoom, newZoom > zoom);
+        this.setState({
+            zoom: newZoom,
+        } as any);
+    }
+
+    // 当 visible 改变之后,预览组件完成首张图片加载后,启动预加载
+    // 如: 1,2,3,4,5,6,7,8张图片, 点击第 4 张图片,preLoadGap 为 2
+    // 当 visible 从 false 变为 true ,首先加载第 4 张图片,当第 4 张图片加载完成后,
+    // 再按照 5,3,6,2的顺序预先加载这几张图片
+    // When visible changes, the preview component finishes loading the first image and starts preloading
+    // Such as: 1, 2, 3, 4, 5, 6, 7, 8 pictures, click the 4th picture, preLoadGap is 2
+    // When visible changes from false to true , load the 4th image first, when the 4th image is loaded,
+    // Preload these pictures in the order of 5, 3, 6, 2
+    preloadGapImage = () => {
+        const { preLoad, preLoadGap, infinite, currentIndex } = this.getProps();
+
+        const { imgSrc }= this.getStates();
+        if (!preLoad || typeof preLoadGap !== "number" || preLoadGap < 1){
+            return;
+        }
+
+        const preloadImages = getPreloadImagArr(imgSrc, currentIndex, preLoadGap, infinite);
+        const Img = new Image();
+        let index = 0;
+        function callback(e: any){
+            index++;
+            if (index < preloadImages.length) {
+                Img.src = preloadImages[index];
+            }
+        }
+        Img.onload = (e) => {
+            this.setLoadSuccessStatus(Img.src);
+            callback(e);
+        };
+        Img.onerror = callback;
+        Img.src = preloadImages[0];  
+    }
+
+    // 在切换左右图片时,当被切换图片完成加载后,根据方向决定下一个预加载的图片
+    // 如: 1,2,3,4,5,6,7,8张图片
+    // 当 preLoadGap 为 2, 从第 5 张图片进行切换
+    // - 如果向 右 切换到第 6 张,则第 6 张图片加载动作结束后(无论加载成功 or 失败),会预先加载第 8 张;
+    // - 如果向 左 切换到第 4 张,则第 4 张图片加载动作结束后(无论加载成功 or 失败),会预先加载第 2 张;
+    // When switching the left and right pictures, when the switched picture is loaded, the next preloaded picture is determined according to the direction
+    // Such as: 1, 2, 3, 4, 5, 6, 7, 8 pictures
+    // When preLoadGap is 2, switch from the 5th picture
+    // - If you switch to the 6th image(direction is next), the 8th image will be preloaded after the 6th image is loaded (whether it succeeds or fails to load);
+    // - If you switch to the 4th image(direction is prev), the second image will be preloaded after the 4th image is loaded (whether it succeeds or fails to load);
+    preloadSingleImage = () => {
+        const { preLoad, preLoadGap, infinite } = this.getProps();
+        const { imgSrc, currentIndex, direction, imgLoadStatus } = this.getStates();
+        if (!preLoad || typeof preLoadGap !== "number" || preLoadGap < 1){
+            return;
+        }
+        // 根据方向决定preload那个index
+        // Determine the index of preload according to the direction
+        let preloadIndex = currentIndex + (direction === "prev" ? -1 : 1) * preLoadGap;
+        if (preloadIndex < 0 || preloadIndex >= imgSrc.length) {
+            if (infinite) {
+                preloadIndex = (preloadIndex + imgSrc.length) % imgSrc.length;
+            } else {
+                return;
+            }
+        }
+        // 如果图片没有加载成功过,则进行预加载
+        // If the image has not been loaded successfully, preload it
+        if (!imgLoadStatus[preloadIndex]) {
+            const Img = new Image();
+            Img.onload = (e) => {
+                this.setLoadSuccessStatus(imgSrc[preloadIndex]);
+            };
+            Img.src = imgSrc[preloadIndex];
+        }
+    }
+
+    setLoadSuccessStatus = (src: string) => {
+        const { imgLoadStatus } = this.getStates();
+        const status = { ...imgLoadStatus };
+        status[src] = true;
+        this.setState({
+            imgLoadStatus: status,
+        } as any);
+    }
+
+    onImageLoad = (src: string) => {
+        const { preloadAfterVisibleChange } = this.getStates();
+        this.setLoadSuccessStatus(src);
+        // 当 preview 中当前加载的图片加载完成后,
+        // 如果是在visible change之后的第一次加载,则启动加载该currentIndex左右preloadGap范围的图片
+        // 如果是非第一次加载,是在左右切换图片,则根据方向预先加载单张图片
+        // When the currently loaded image in Preview is loaded,
+        // - It is the first load after visible change, start loading the images in the preloadGap range around the currentIndex
+        // - It is not the first load, the image is switched left and right, and a single image is preloaded according to the direction
+        if (preloadAfterVisibleChange) {
+            this.preloadGapImage();
+            this.setState({
+                preloadAfterVisibleChange: false,
+            } as any);
+        } else {
+            this.preloadSingleImage();
+        }
+    }   
+}

+ 51 - 0
packages/semi-foundation/image/rtl.scss

@@ -0,0 +1,51 @@
+@import "./variables.scss";
+@import "./animation.scss";
+
+$module: #{$prefix}-image;
+
+.#{$prefix}-rtl,
+.#{$prefix}-portal-rtl {
+
+    .#{$module}-preview {
+
+        direction: rtl;
+
+        &-group {
+            direction: rtl;
+        }
+        
+        &-prev {
+            right: $spacing-image_preview_icon-x;
+            left: auto;
+            transform: $transform_rotate-image_preview_icon-rtl;
+        }
+    
+        &-next {
+            left: $spacing-image_preview_icon-x;
+            right: auto;
+            transform: $transform_rotate-image_preview_icon-rtl;
+        }
+
+        &-footer {
+
+            &-page {
+                display: flex;
+                direction: rtl;
+            }
+
+            &-gap {
+                margin-right: $spacing-image_preview_footer_gap-marginLeft;
+                margin-left: $spacing-image_preview_footer_gap_rtl-marginLeft;
+            }
+
+            .#{$prefix}-icon-chevron_left {
+                transform: $transform_rotate-image_preview_icon_rtl;
+            }
+
+            .#{$prefix}-icon-chevron_right {
+                transform: $transform_rotate-image_preview_icon_rtl;
+            }
+        }
+
+    }
+}

+ 86 - 0
packages/semi-foundation/image/utils.ts

@@ -0,0 +1,86 @@
+export const isTargetEmit = (event, targetClasses): boolean => {
+    // e.path is the event-triggered bubbling path, which stores each node through which bubbling passes.
+    // e.path.length-4 is to remove elements above the root node, such as body, html, document, window
+    const isTarget = event?.path?.slice(0, event.path.length - 4).some((node): boolean => {
+        if (node.className && typeof node.className === "string") {
+            return targetClasses.some(c => node.className.includes(c));
+        }
+        return false;
+    });
+    return isTarget;
+};
+
+export const downloadImage = (src: string, filename: string): void => {
+    const image = new Image();
+    image.src = src;
+    image.crossOrigin = "anonymous";
+    image.onload = (e): void => {
+        const eleLink = document.createElement("a");
+        eleLink.download = filename;
+        eleLink.style.display = "none";
+        eleLink.download = filename;
+        eleLink.href = src;
+        const canvas = document.createElement("canvas");
+        canvas.width = image.width;
+        canvas.height = image.height;
+        const context = canvas.getContext("2d");
+        context.drawImage(image, 0, 0, image.width, image.height);
+        eleLink.href = canvas.toDataURL("image/jpeg");
+        document.body.appendChild(eleLink);
+        eleLink.click();
+        document.body.removeChild(eleLink);
+    };
+};
+
+export const crossMerge = (leftArr = [], rightArr = []) => {
+    let newArr = [];
+    const leftLen = leftArr.length;
+    const rightLen = rightArr.length;
+    const crossLength = leftLen <= rightLen ? leftLen : rightLen;
+    (new Array(crossLength).fill(0)).forEach((item, index) => {
+        newArr.push(rightArr[index]);
+        newArr.push(leftArr[index]);
+    });
+    if (leftLen > rightLen) {
+        newArr = newArr.concat(leftArr.slice(rightLen, leftLen));
+    } else if (leftLen < rightLen) {
+        newArr = newArr.concat(rightArr.slice(leftLen, rightLen));
+    }
+    return newArr;
+};
+
+export const getPreloadImagArr = (imgSrc: string[], currentIndex: number, preLoadGap: number, infinite: boolean) => {
+    const beginIndex = currentIndex - preLoadGap;
+    const endIndex = currentIndex + preLoadGap;
+    const srcLength = imgSrc.length;
+    let leftArr = [];
+    let rightArr = [];
+    if ( preLoadGap >= Math.floor(srcLength / 2)) {
+        if (infinite) {
+            leftArr = imgSrc.concat(imgSrc).slice(beginIndex + srcLength < 0 ? 0 : beginIndex + srcLength, currentIndex + srcLength);
+            rightArr = imgSrc.concat(imgSrc).slice(currentIndex + 1, endIndex + 1 < 2 * srcLength ? endIndex + 1 : 2 * srcLength);
+        } else {
+            leftArr = imgSrc.slice(0, currentIndex);
+            rightArr = imgSrc.slice(currentIndex + 1, srcLength);
+        }
+    } else {
+        if (infinite) {
+            leftArr = imgSrc.concat(imgSrc).slice(beginIndex + srcLength, currentIndex + srcLength);
+            rightArr = imgSrc.concat(imgSrc).slice(currentIndex + 1, endIndex + 1);
+        } else {
+            if (beginIndex >= 0 && endIndex < srcLength) {
+                leftArr = imgSrc.slice(beginIndex, currentIndex);
+                rightArr = imgSrc.slice(currentIndex + 1, endIndex + 1);
+            } else if (beginIndex < 0) {
+                leftArr = imgSrc.slice(0, currentIndex);
+                rightArr = imgSrc.slice(currentIndex + 1, 2 * preLoadGap + 1);
+            } else {
+                rightArr = imgSrc.slice(currentIndex + 1, srcLength);
+                leftArr = imgSrc.slice(srcLength - 2 * preLoadGap - 1, currentIndex);
+            }
+        }
+    }
+    const result = crossMerge(leftArr.reverse(), rightArr);
+    const duplicateResult = Array.from(new Set(result));
+    return duplicateResult;
+};

+ 47 - 0
packages/semi-foundation/image/variables.scss

@@ -0,0 +1,47 @@
+$spacing-image_mask_info_text-marginTop: 8px; // 图像预览遮罩文字上外边距
+$spacing-image_preview_icon-x: 24px; // 图像预览中部左右调节icon与页面距离
+$spacing-image_preview_header-paddingY: 0; // 图像预览header部分上下内边距
+$spacing-image_preview_header-paddingX: 24px; // 图像预览header部分左右内边距
+$spacing-image_preview_footer-paddingY: 0; // 图像预览footer操作区部分上下内边距
+$spacing-image_preview_footer-paddingX: 16px; // 图像预览footer操作区部分左右内边距
+$spacing-image_preview_footer_page-marginY: 0; // 图像预览footer操作区图像页数据上下内边距
+$spacing-image_preview_footer_page-marginX: 12px; // 图像预览footer操作区图像页数据左右内边距
+$spacing-image_preview_footer_slider-paddingY: 0; // 图像预览footer操作区slider上下内边距
+$spacing-image_preview_footer_slider-paddingX: 16px; // 图像预览footer操作区slider左右内边距
+$spacing-image_preview_footer_slider_handle-marginTop: 8px; // 图像预览footer操作区slider的滑块上外边距
+$spacing-image_preview_footer_divider-marginY: 0;  // 图像预览footer操作区slider的分割线上下外边距
+$spacing-image_preview_footer_divider-marginX: 16px; // 图像预览footer操作区slider的分割线左右外边距
+$spacing-image_preview_footer_gap-marginLeft: 16px; // 图像预览footer操作区icon的左外边距
+$spacing-image_preview_footer_gap_rtl-marginLeft: 0; // 图像预览footer操作区在rtl模式下icon的左外边距
+
+$width-image_preview_footer_slider: 132px; // 图像预览footer操作区slider宽度
+$width-image_preview_footer_slider_handle: 16px; // 图像预览footer操作区滑块宽度
+$width-image_preview_icon: 40px; // 图像预览footer操作区icon宽度
+$width-image_preview_header_close: 30px; // 图像预览header部分的关闭热区宽度
+
+$height-image_preview_header: 60px; // 图像预览header部分高度
+$height-image_preview_footer: 48px; // 图像预览footer部分高度
+$height-image_preview_footer_slider: 2px; // 图像预览footer中slider高度
+$height-image_preview_footer_slider_handle: 16px; // 图像预览footer中slider的滑块高度
+$height-image_preview_icon: 40px; // 图像预览footer操作区icon高度
+$height-image_preview_header_close: 30px; // 图像预览header部分的关闭热区高度
+
+$radius-image: var(--semi-border-radius-small); // 图像圆角
+$radius-image_preview_footer: 6px; // 图像预览footer操作区圆角
+
+$color-image_mask-bg: var(--semi-color-overlay-bg); // 图像蒙层背景色
+$color-image_mask_info_text: var(--semi-color-white); // 图像蒙层文字颜色
+$color-image_status-bg: var(--semi-color-fill-0); // 图像加载失败背景颜色
+$color-image_status: var(--semi-color-disabled-text); // 图像状态加载失败 icon 颜色
+$color-image_preview-bg: var(--semi-color-overlay-bg); // 图像预览背景色
+$color-image_preview_icon: var(--semi-color-white); // 图像预览中部左右icon背景色
+$color-image_preview_header: var(--semi-color-white); // 图像预览header文字颜色
+$color-image_preview_footer_icon: var(--semi-color-white); // 图像预览footer中icon颜色
+$color-image_preview_footer_slider_rail: var(--semi-color-white); // 图像预览footer中slider滑轨颜色 
+// 以下几个颜色在明暗主题下一致,所以没有采用变量写法
+$color-image_preview_disabled: rgba(249, 249, 249, 0.35); // 图像预览禁用颜色
+$color-image_preview_icon-bg: rgba(0, 0, 0, 0.75); //图像预览中部icon背景色
+$color-image_header_close-bg: rgba(0, 0, 0, 0.75); //图像预览header的关闭icon的hover背景色
+$color-image_preview_footer-bg: rgba(0, 0, 0, 0.75); // 图像预览footer部分背景色
+$color-image-preview_divider-bg: rgba(255, 255, 255, .5); // 图像预览footer中的分割线背景色
+$color-image_preview_image_spin: #ccc; // 图像预览的加载状态颜色

+ 3 - 0
packages/semi-theme-default/scss/variables.scss

@@ -42,6 +42,9 @@ $z-popover: 1030; // popover 组件 z-index
 $z-dropdown: 1050; // dropdown 组件 z-index
 $z-tooltip: 1060; // tooltip 组件 z-index
 // $z-avatar-default: 100;
+$z-image_preview: 1070; // Image 组件预览层z-index
+$z-image_preview_header: 1; // Image 组件预览层中 header 部分 z-index
+
 
 // font
 $font-family-regular: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',

+ 374 - 0
packages/semi-ui/image/_story/image.stories.js

@@ -0,0 +1,374 @@
+import React, { useState, useCallback, useMemo } from "react";
+import {
+    Image,
+    Button,
+    ImagePreview,
+    Row,
+    Col,
+    Icon,
+} from "../../index";
+import { 
+    IconChevronLeft, 
+    IconChevronRight, 
+    IconMinus,
+    IconPlus,
+    IconRotate,
+    IconDownload,
+    IconWindowAdaptionStroked,
+    IconRealSizeStroked,
+} from "@douyinfe/semi-icons";
+
+export default {
+    title: "Image",
+    parameters: {
+      chromatic: { disableSnapshot: true },
+    }
+}
+
+const srcList1 = [
+    "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/lion.jpeg",
+    "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/seaside.jpeg",
+    "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/beach.jpeg",
+];
+
+const srcList2 = [
+    "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/imag1.png",
+    "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/imag2.png",
+    "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/imag3.png",
+    "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/imag4.png",
+    "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/imag5.png",
+    "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/imag6.png",
+    "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/imag7.png",
+    "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/imag8.png",
+];
+
+export const basicImage = () => (
+    <Image 
+        width={360}
+        height={200}
+        src="https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/lion.jpeg"
+    />
+)
+
+export const ShowOperationTooltip = () => (
+    <Image 
+        width={360}
+        height={200}
+        src="https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/lion.jpeg"
+        preview={{
+            showTooltip: true,
+        }}
+    />
+)
+
+export const ControlledPreviewSingle = () => {
+    const [visible, setVisible] = useState(false);
+
+    const handlePreviewVisibleChange = useCallback((v) => {
+        setVisible(v);
+    }, []);
+
+    const handleClick = useCallback(() => {
+        setVisible(!visible);
+    }, [visible])
+
+    return (
+        <>
+            <Button onClick={handleClick}>{visible ? "hide" : "show single"}</Button>
+            <ImagePreview 
+                src={"https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/lion.jpeg"}
+                visible={visible}
+                onVisibleChange={handlePreviewVisibleChange}
+            />
+        </>
+    )
+}
+
+export const ControlledPreviewMultiple = () => {
+    const [visible, setVisible] = useState(false);
+
+    const handlePreviewVisibleChange = useCallback((v) => {
+        setVisible(v);
+    }, []);
+
+    const handleClick = useCallback(() => {
+        setVisible(!visible);
+    }, [visible])
+
+    return (
+        <>
+            <Button onClick={handleClick}>{visible ? "hide" : "show multiple"}</Button>
+            <ImagePreview 
+                src={srcList1}
+                visible={visible}
+                onVisibleChange={handlePreviewVisibleChange}
+            />
+        </>
+    )
+}
+
+export const BasicPreview = () => (
+    <ImagePreview>
+        {srcList1.map((src, index) => {
+            return (
+                <Image 
+                    key={index} 
+                    src={src} 
+                    width={200} 
+                    alt={`lamp${index + 1}`}
+                />
+        )})}
+    </ImagePreview>
+);
+
+// test all call back function
+export const TestCallBackFunc = () => {
+  
+    const visibleChange =  useCallback((v) => {
+        console.log("visible change", v);
+    }, []);
+
+    const change = useCallback((index) => {
+        console.log("change", index);
+    } , []);
+
+    const zoomIn = useCallback((zoom) => {
+        console.log("zoom in", zoom);
+    }, []);
+
+    const zoomOut = useCallback((zoom) => {
+        console.log("zoom out", zoom);
+    }, []);
+
+    const prev = useCallback((index) => {
+        console.log("prev", index);
+    }, []);
+
+    const next = useCallback((index) => {
+        console.log("next", index);
+    }, []);
+
+    const ratioChange = useCallback((type) => {
+        console.log("ratio change", type);
+    }, []);
+
+    const rotateChange = useCallback((angle) => {
+        console.log("rotate change", angle);
+    }, []);
+
+    const download = useCallback((src, index) =>{
+        console.log("download", src, index);
+    }, []);
+
+    return (
+        <>  
+            <ImagePreview
+                onVisibleChange={visibleChange}
+                onChange={change}
+                onClose={close}
+                onZoomIn={zoomIn}
+                onZoomOut={zoomOut}
+                onPrev={prev}
+                onNext={next}
+                onRatioChange={ratioChange}
+                onRotateChange={rotateChange}
+                onDownload={download}
+            >
+                <div >
+                    {srcList1.map((src, index) => {
+                        return (
+                            <Image key={index} src={src} width={200} alt={`lamp${index + 1}`} />
+                    )})}
+                </div>
+            </ImagePreview>
+        </>
+    )
+};
+
+export const GridImage= () => (
+    <>  
+        <ImagePreview
+            preview={{
+                preLoad: true,
+                preLoadGap: 3,
+                infinite: true,
+            }}
+        >
+            <Row style={{ width: 800 }}>
+                {srcList2.map((src, index) => {
+                    return (
+                        <Col span={6} style={{ height: 200 }} key={`col${index}`}>
+                            <Image key={index} src={src} style={{ width: 200, height: 200 }} width={200} alt={`lamp${index + 1}`} />
+                        </Col>
+                )})}
+            </Row>
+        </ImagePreview>
+    </>
+);
+
+export const CustomContainer = () => {
+    const srcList = useMemo(() => ([
+        "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/flower.jpeg",
+        "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/duck.jpeg",
+        "https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/swan.jpeg",
+    ]), []);
+
+    return ( 
+        <>
+            <div 
+                id="container" 
+                style={{ 
+                    height: 400, 
+                    position: "relative",
+                }} 
+            >
+                <ImagePreview
+                    getPopupContainer={() => {
+                        const node = document.getElementById("container");
+                        return node;
+                    }}
+                    style={{
+                        height: "100%",
+                        display: "flex",
+                        alignItems: "center",
+                        justifyContent: "center",
+                        flexWrap: "wrap",
+        
+                    }}
+                >
+                    {srcList.map((src, index) => {
+                        return (
+                            <Image 
+                                key={index} 
+                                src={src} 
+                                width={200} 
+                                alt={`lamp${index + 1}`} 
+                            />
+                        );
+                    })}
+                </ImagePreview>
+            </div>
+        </>
+    );
+}
+
+export const customRenderFooterMenu = () => {
+    const renderPreviewMenu = useCallback((props) => {
+        const {
+            ratio,
+            disabledPrev,
+            disabledNext,
+            disableZoomIn,
+            disableZoomOut,
+            disableDownload,
+            onDownload,
+            onNext,
+            onPrev,
+            onRotateLeft,
+            onRatioClick,
+            onZoomIn,
+            onZoomOut,
+        } = props;
+        return (
+            <div 
+            style={{ 
+                background: "grey", 
+                height: 40, 
+                width: 280, 
+                display: "flex",
+                alignItems: "center",
+                justifyContent: "space-around",
+                borderRadius: 3,
+            }}
+        >
+            <Button
+                icon={<IconChevronLeft size="large" />}
+                type="tertiary"
+                onClick={!disabledPrev ? onPrev : undefined}
+                disabled={disabledPrev}
+            />
+            <Button
+                icon={<IconChevronRight size="large" />}
+                type="tertiary"                     
+                onClick={!disabledNext ? onNext : undefined}
+                disabled={disabledNext}
+            />
+            <Button
+                icon={<IconMinus  size="large" />}
+                type="tertiary"
+                onClick={disableZoomOut ? onZoomOut : undefined}
+                disabled={disableZoomOut} 
+            />
+            <Button
+                icon={<IconPlus size="large" />}
+                type="tertiary"
+                onClick={!disableZoomIn ? onZoomIn : undefined} 
+                disabled={disableZoomIn}
+            />
+             <Button
+                icon={ratio === "adaptation" ? <IconRealSizeStroked size="large" /> : <IconWindowAdaptionStroked  size="large" />}
+                type="tertiary"
+                onClick={onRatioClick} 
+            />
+            <Button
+                icon={<IconRotate size="large" />}
+                type="tertiary"
+                onClick={onRotateLeft}
+            />
+            <Button
+                icon={<IconDownload size="large" />}
+                type="tertiary"
+                onClick={!disableDownload ? onDownload : undefined}
+                disabled={disableDownload}
+            />
+    </div>);
+    }, []);
+
+    return (
+        <>  
+            <ImagePreview
+                renderPreviewMenu={renderPreviewMenu}
+            >
+                {srcList1.map((src, index) => {
+                    return <Image key={index} src={src} width={200} alt={`lamp${index + 1}`} />
+                })}
+            </ImagePreview>
+        </>
+    );
+}
+
+export const CustomRenderTitle = () => (
+    <>  
+        <ImagePreview
+            renderHeader={(title) => (
+                <div
+                    style={{ 
+                        background: "green", 
+                        width: "100%", 
+                        height: "100%", 
+                        display: "flex", 
+                        alignItems: "center", 
+                        justifyContent: "center" 
+                    }}
+                >
+                    {title}
+                </div>
+            )}
+        >
+            <div >
+                {srcList1.map((src, index) => {
+                    return (
+                        <Image 
+                            key={index} 
+                            src={src} 
+                            width={200} 
+                            alt={`lamp${index + 1}`} 
+                            preview={{
+                                previewTitle: `lamp${index + 1}`,
+                            }} 
+                        />
+                )})}
+            </div>
+        </ImagePreview>
+    </>
+);

+ 210 - 0
packages/semi-ui/image/image.tsx

@@ -0,0 +1,210 @@
+/* eslint-disable jsx-a11y/click-events-have-key-events */
+/* eslint-disable jsx-a11y/no-static-element-interactions */
+import React from "react";
+import BaseComponent from "../_base/baseComponent";
+import { ImageProps, ImageStates } from "./interface";
+import PropTypes from "prop-types";
+import { cssClasses } from "@douyinfe/semi-foundation/image/constants";
+import cls from "classnames";
+import { IconUploadError, IconEyeOpened } from "@douyinfe/semi-icons";
+import PreviewInner from "./previewInner";
+import "@douyinfe/semi-foundation/image/image.scss";
+import { PreviewContext, PreviewContextProps } from "./previewContext";
+import ImageFoundation, { ImageAdapter } from "@douyinfe/semi-foundation/image/imageFoundation";
+import LocaleConsumer from "../locale/localeConsumer";
+import { Locale } from "../locale/interface";
+import { isObject } from "lodash";
+import Skeleton from "../skeleton";
+
+const prefixCls = cssClasses.PREFIX;
+
+export default class Image extends BaseComponent<ImageProps, ImageStates> {
+    static isSemiImage = true;
+    static contextType = PreviewContext;
+    static propTypes = {
+        style: PropTypes.object,
+        className: PropTypes.string,
+        src: PropTypes.string,
+        width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+        height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+        alt: PropTypes.string,
+        placeholder: PropTypes.node,
+        fallback: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+        preview: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]),
+        onLoad: PropTypes.func,
+        onError: PropTypes.func,
+        crossOrigin: PropTypes.string,
+        imageID: PropTypes.number,
+    }
+
+    static defaultProps = {
+        preview: true,
+    };
+
+    get adapter(): ImageAdapter<ImageProps, ImageStates> {
+        return {
+            ...super.adapter,
+            getIsInGroup: () => this.isInGroup(),
+        };
+    }
+
+    context: PreviewContextProps;
+    foundation: ImageFoundation;
+
+    constructor(props: ImageProps) {
+        super(props);
+        this.state = {
+            src: "",
+            loadStatus: "loading",
+            previewVisible: false,
+        };
+
+        this.foundation = new ImageFoundation(this.adapter);
+    }
+
+    static getDerivedStateFromProps(props: ImageProps, state: ImageStates) {
+        const willUpdateStates: Partial<ImageStates> = {};
+
+        if (props.src !== state.src) {
+            willUpdateStates.src = props.src;
+            willUpdateStates.loadStatus = "loading";
+        }
+
+        return willUpdateStates;
+    }
+
+    isInGroup() {
+        return Boolean(this.context && this.context.isGroup);
+    }
+
+    isLazyLoad() {
+        if (this.context) {
+            return this.context.lazyLoad;
+        }
+        return false;
+    }
+
+    handleClick = (e) => {
+        this.foundation.handleClick(e);
+    };
+
+    handleLoaded = (e) => {
+        this.foundation.handleLoaded(e);
+    }
+
+    handleError = (e) => {
+        this.foundation.handleError(e);
+    }
+
+    handlePreviewVisibleChange = (visible: boolean) => {
+        this.foundation.handlePreviewVisibleChange(visible);
+    }
+
+    renderDefaultLoading = () => {
+        const { width, height } = this.props;
+        return (
+            <Skeleton.Image style={{ width, height }} />
+        );
+    };
+
+    renderDefaultError = () => {
+        const prefixClsName = `${prefixCls}-status`;
+        return (
+            <div className={prefixClsName}>
+                <IconUploadError size={"extra-large"} />
+            </div>
+        );
+    };
+
+    renderLoad = () => {
+        const prefixClsName = `${prefixCls}-status`;
+        const { placeholder } = this.props;
+        return (
+            placeholder ? (
+                <div className={prefixClsName}> 
+                    {placeholder}
+                </div>
+            ) : this.renderDefaultLoading()
+        );
+    }
+
+    renderError = () => {
+        const { fallback } = this.props;
+        const prefixClsName = `${prefixCls}-status`;
+        const fallbackNode = typeof fallback === "string" ? (<img style={{ width: "100%", height: "100%" }}src={fallback} alt="fallback"/>) : fallback;
+        return (
+            fallback ? (
+                <div className={prefixClsName}>
+                    {fallbackNode}
+                </div>
+            ) :this.renderDefaultError()
+        );
+    }
+
+    renderExtra = () => {
+        const { loadStatus } = this.state;
+        return (
+            <div className={`${prefixCls}-overlay`}>
+                {loadStatus === "error" && this.renderError()}
+                {loadStatus === "loading" && this.renderLoad()}
+            </div>
+        );
+    }
+
+    getLocalTextByKey = (key: string) => (
+        <LocaleConsumer<Locale["Image"]> componentName="Image" >
+            {(locale: Locale["Image"]) => locale[key]}
+        </LocaleConsumer>
+    );
+
+    renderMask = () => (<div className={`${prefixCls}-mask`}>
+        <div className={`${prefixCls}-mask-info`}>
+            <IconEyeOpened size="extra-large"/>
+            <span className={`${prefixCls}-mask-info-text`}>{this.getLocalTextByKey("preview")}</span>
+        </div>
+    </div>)
+
+    render() {
+        const { src, loadStatus, previewVisible } = this.state;
+        const { width, height, alt, style, className, crossOrigin, preview } = this.props;
+        const outerStyle = Object.assign({ width, height }, style);
+        const outerCls = cls(prefixCls, className);
+        const canPreview = loadStatus === "success" && preview && !this.isInGroup();
+        const showPreviewCursor = preview && loadStatus === "success";
+        const previewSrc = isObject(preview) ? ((preview as any).src ?? src) : src;
+        const previewProps = isObject(preview) ? preview : {};
+        return ( 
+            // eslint-disable jsx-a11y/no-static-element-interactions
+            // eslint-disable jsx-a11y/click-events-have-key-events
+            <div
+                style={outerStyle}
+                className={outerCls}
+                onClick={this.handleClick}
+            >
+                <img
+                    src={this.isInGroup() && this.isLazyLoad() ? undefined : src}
+                    data-src={src}
+                    alt={alt}
+                    className={cls(`${prefixCls}-img`, {
+                        [`${prefixCls}-img-preview`]: showPreviewCursor,
+                        [`${prefixCls}-img-error`]: loadStatus === "error",
+                    })}
+                    width={width}
+                    height={height}
+                    crossOrigin={crossOrigin}
+                    onError={this.handleError}
+                    onLoad={this.handleLoaded}
+                />
+                {loadStatus !== "success" && this.renderExtra()}
+                {canPreview && 
+                    <PreviewInner
+                        {...previewProps}
+                        src={previewSrc}
+                        visible={previewVisible}
+                        onVisibleChange={this.handlePreviewVisibleChange}
+                    />
+                }
+            </div>
+        );
+    } 
+}

+ 1 - 0
packages/semi-ui/image/index-en-US.md

@@ -0,0 +1 @@
+../../../content/show/image/index-en-US.md

+ 1 - 0
packages/semi-ui/image/index.md

@@ -0,0 +1 @@
+../../../content/show/image/index.md

+ 15 - 0
packages/semi-ui/image/index.tsx

@@ -0,0 +1,15 @@
+import Image from "./image";
+import PreviewInner from "./previewInner";
+import Preview from "./preview";
+
+export default Image;
+export {
+    PreviewInner,
+    Preview,
+}; 
+
+export {
+    ImageProps,
+    PreviewImageProps,
+    PreviewProps,
+} from "./interface";

+ 194 - 0
packages/semi-ui/image/interface.tsx

@@ -0,0 +1,194 @@
+import { ReactNode } from "react";
+import { BaseProps } from "_base/baseComponent";
+import React from "react";
+
+export interface ImageStates {
+    src: string;
+    loadStatus: "loading" | "success" | "error";
+    previewVisible: boolean;
+}
+
+export interface ImageProps extends BaseProps{
+    src?: string;
+    width?: string | number;
+    height?: string | number;
+    alt?: string;
+    placeholder?: ReactNode;
+    fallback?: string | ReactNode;
+    preview?: boolean | PreviewProps;
+    onError?: (event: Event) => void;
+    onLoad?: (event: Event) => void;
+    crossOrigin?: "anonymous"| "use-credentials";
+    children?: ReactNode,
+    imageID?: number;
+}
+
+export interface PreviewProps extends BaseProps {
+    visible?: boolean;
+    src?: string | string[];
+    previewTitle?: ReactNode;
+    currentIndex?: number;
+    defaultIndex?: number;
+    defaultVisible?: boolean;
+    maskClosable?: boolean;
+    closable?: boolean;
+    zoomStep?: number;
+    infinite?: boolean;
+    showTooltip?: boolean;
+    closeOnEsc?: boolean;
+    prevTip?: string;
+    nextTip?: string;
+    zoomInTip?: string;
+    zoomOutTip?: string;
+    rotateTip?: string;
+    downloadTip?: string;
+    adaptiveTip?: string;
+    originTip?: string;
+    lazyLoad?: boolean;
+    lazyLoadMargin?: string;
+    preLoad?: boolean;
+    preLoadGap?: number;
+    viewerVisibleDelay?: number;
+    disableDownload?: boolean;
+    zIndex?: number;
+    children?: ReactNode,
+    renderHeader?: (info: any) => ReactNode;
+    renderPreviewMenu?: (props: MenuProps) => ReactNode;
+    getPopupContainer?: () => HTMLElement;
+    onVisibleChange?: (visible: boolean) => void;
+    onChange?: (index: number) => void
+    onClose?: () => void;
+    onZoomIn?: (zoom: number) => void;
+    onZoomOut?: (zoom: number) => void;
+    onPrev?: (index: number) => void;
+    onNext?: (index: number) => void;
+    onRatioChange?: (type: RatioType) => void;
+    onRotateChange?: (angle: number) => void;
+    onDownload?: (src: string, index: number) => void;
+}
+
+export interface MenuProps {
+    min?: number;
+    max?: number;
+    step?: number;
+    curPage?: number;
+    totalNum?: number; 
+    zoom?: number;
+    ratio?: RatioType,
+    disabledPrev?: boolean,
+    disabledNext?: boolean,
+    disableDownload?: boolean,
+    onDownload?: () => void,
+    onNext?: () => void,
+    onPrev?: () => void,
+    onZoomIn?: () => void,
+    onZoomOut?: () => void,
+    onRatioClick?: () => void,
+    onRotateLeft?: () => void,
+    onRotateRight?: () => void,
+}
+
+export type RatioType = "adaptation" | "realSize";
+
+export interface PreviewInnerStates {
+    imgSrc?: string[];
+    imgLoadStatus?: Map<string, boolean>;
+    zoom?: number;
+    rotation?: number;
+    ratio?: RatioType;
+    currentIndex?: number;
+    viewerVisible?: boolean;
+    visible?: boolean;
+    preloadAfterVisibleChange?: boolean;
+    direction?: string;
+}
+
+export interface SliderProps {
+    max?: number;
+    min?: number;
+    step?: number;
+}
+
+export interface HeaderProps {
+    renderHeader?: (info: any) => ReactNode,
+    title?: string;
+    titleStyle?: React.CSSProperties;
+    className?: string;
+    onClose?: () => void;
+}
+
+export interface FooterProps extends SliderProps {
+    curPage?: number;
+    totalNum?: number;
+    disabledPrev?: boolean;
+    disabledNext?: boolean;
+    disableDownload?: boolean;
+    className?: string;
+    zoom?: number;
+    ratio?: RatioType;
+    prevTip?: string;
+    nextTip?: string;
+    zoomInTip?: string;
+    zoomOutTip?: string;
+    rotateTip?: string;
+    downloadTip?: string;
+    adaptiveTip?: string;
+    originTip?: string;
+    showTooltip?: boolean;
+    onZoomIn?: (zoom: number) => void;
+    onZoomOut?: (zoom: number) => void;
+    onPrev?: () => void;
+    onNext?: () => void;
+    onAdjustRatio?: (type: RatioType) => void;
+    onRotate?: (direction: string) => void;
+    onDownload?: () => void;
+    renderPreviewMenu?: (props: MenuProps) => ReactNode;
+}
+
+export interface PreviewImageProps {
+    src?: string;
+    rotation?: number;
+    style?: React.CSSProperties;
+    maxZoom?: number;
+    minZoom?: number;
+    zoomStep?: number;
+    zoom?: number;
+    ratio?: RatioType;
+    disableDownload?: boolean;
+    clickZoom?: number;
+    setRatio?: (type: RatioType) => void;
+    onZoom?: (zoom: number) => void;
+    onLoad?: (src: string) => void;
+    onError?: (src: string) => void;
+}
+
+export interface ImageOffset {
+    x: number;
+    y: number;
+}
+
+export interface PreviewImageStates {
+    loading: boolean;
+    width: number;
+    height: number;
+    offset: ImageOffset;
+    currZoom: number;
+    top: number;
+    left: number;
+}
+
+export interface DragDirection {
+    canDragVertical: boolean;
+    canDragHorizontal: boolean;
+}
+
+export interface ExtremeBounds {
+    left: number;
+    top: number;
+}
+
+export interface PreviewState {
+    currentIndex: number;
+    visible: boolean;
+}
+

+ 194 - 0
packages/semi-ui/image/preview.tsx

@@ -0,0 +1,194 @@
+import React, { ReactNode } from "react";
+import { PreviewContext } from "./previewContext";
+import BaseComponent from "../_base/baseComponent";
+import PropTypes, { array } from "prop-types";
+import { PreviewProps, PreviewState } from "./interface";
+import PreviewInner from "./previewInner";
+import PreviewFoundation from "@douyinfe/semi-foundation/image/previewFoundation";
+import { getUuidShort } from "@douyinfe/semi-foundation/utils/uuid";
+import { cssClasses } from "@douyinfe/semi-foundation/image/constants";
+import { isObject } from "lodash";
+
+const prefixCls = cssClasses.PREFIX;
+
+export default class Preview extends BaseComponent<PreviewProps, PreviewState> {
+    static propTypes = {
+        style: PropTypes.object,
+        className: PropTypes.string,
+        visible: PropTypes.bool,
+        src: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
+        currentIndex: PropTypes.number,
+        defaultIndex: PropTypes.number,
+        defaultVisible: PropTypes.bool,
+        maskClosable: PropTypes.bool,
+        closable: PropTypes.bool,
+        zoomStep: PropTypes.number,
+        infinite: PropTypes.bool,
+        showTooltip: PropTypes.bool,
+        closeOnEsc: PropTypes.bool,
+        prevTip: PropTypes.string,
+        nextTip: PropTypes.string,
+        zoomInTip:PropTypes.string,
+        zoomOutTip: PropTypes.string,
+        downloadTip: PropTypes.string,
+        adaptiveTip:PropTypes.string,
+        originTip: PropTypes.string,
+        lazyLoad: PropTypes.bool,
+        lazyLoadMargin: PropTypes.string,
+        preLoad: PropTypes.bool,
+        preLoadGap: PropTypes.number,
+        disableDownload: PropTypes.bool,
+        zIndex: PropTypes.number,
+        renderHeader: PropTypes.func,
+        renderPreviewMenu: PropTypes.func,
+        getPopupContainer: PropTypes.func,
+        onVisibleChange: PropTypes.func,
+        onChange: PropTypes.func,
+        onClose: PropTypes.func,
+        onZoomIn: PropTypes.func,
+        onZoomOut: PropTypes.func,
+        onPrev: PropTypes.func,
+        onNext: PropTypes.func,
+        onDownload: PropTypes.func,
+        onRatioChange: PropTypes.func,
+        onRotateChange: PropTypes.func,
+    }
+
+    static defaultProps = {
+        src: [],
+        lazyLoad: true,
+        lazyLoadMargin: "0px 100px 100px 0px",
+    };
+
+    get adapter() {
+        return {
+            ...super.adapter,
+        };
+    }
+
+    foundation: PreviewFoundation;
+    previewGroupId: string;
+    previewRef: React.RefObject<PreviewInner>;
+
+    constructor(props) {
+        super(props);
+        this.state = {
+            currentIndex: props.currentIndex || props.defaultCurrentIndex || 0,
+            visible: props.visible || props.currentDefaultVisible || false,
+        };
+        this.foundation = new PreviewFoundation(this.adapter);
+        this.previewGroupId = getUuidShort({ prefix: "semi-image-preview-group", length: 4 });
+        this.previewRef = React.createRef<PreviewInner>();
+    }
+
+    componentDidMount() {
+        const { lazyLoadMargin } = this.props;
+        const allElement = document.querySelectorAll(`.${prefixCls}-img`);
+        // use IntersectionObserver to lazy load image
+        const observer = new IntersectionObserver(entries => {
+            entries.forEach(item => {
+                const src = (item.target as any).dataset?.src;
+                if (item.isIntersecting && src) {
+                    (item.target as any).src = src;
+                    observer.unobserve(item.target);
+                }
+            });
+        },
+        {
+            root: document.querySelector(`#${this.previewGroupId}`),
+            rootMargin: lazyLoadMargin, 
+        }
+        );
+        allElement.forEach(item => observer.observe(item));
+    }
+
+    static getDerivedStateFromProps(props: PreviewProps, state: PreviewState) {
+        const willUpdateStates: Partial<PreviewState> = {};
+        if (("currentIndex" in props) && (props.currentIndex !== state.currentIndex)) {
+            willUpdateStates.currentIndex = props.currentIndex;
+        }
+        if (("visible" in props) && (props.visible !== state.visible)) {
+            willUpdateStates.visible = props.visible;
+        }
+        return willUpdateStates;
+    }
+
+    handleVisibleChange = (newVisible : boolean) => {
+        this.foundation.handleVisibleChange(newVisible);
+    };
+
+    handleCurrentIndexChange = (index: number) => {
+        this.foundation.handleCurrentIndexChange(index);
+    };
+    
+    loopImageIndex = () => {
+        const { children } = this.props;
+        let index = 0;
+        const srcListInChildren = [];
+        const titles: ReactNode [] = [];
+        const loop = (children) => {
+            return React.Children.map(children, (child) => {
+                if (child && child.props && child.type) {
+                    if (child.type.isSemiImage) {
+                        const { src, preview, alt } = child.props;
+                        if (preview) {
+                            const previewSrc = isObject(preview) ? ((preview as any).src ?? src) : src;
+                            srcListInChildren.push(previewSrc);
+                            titles.push(preview?.previewTitle);
+                            return React.cloneElement(child, { imageID: index++ });
+                        }
+                        return child;
+                    }
+                }
+        
+                if (child && child.props && child.props.children) {
+                    return React.cloneElement(child, {
+                        children: loop(child.props.children),
+                    });
+                }
+        
+                return child;
+            });
+        };
+        
+        return {
+            srcListInChildren,
+            newChildren: loop(children),
+            titles,
+        };
+    };
+
+    render() {
+        const { src, style, lazyLoad, ...restProps } = this.props;
+        const { currentIndex, visible } = this.state;
+        const { srcListInChildren, newChildren, titles } = this.loopImageIndex();
+        const srcArr = Array.isArray(src) ? src : (typeof src === "string" ? [src] : []);
+        const finalSrcList = [...srcArr, ...srcListInChildren];
+        return (
+            <PreviewContext.Provider
+                value={{
+                    isGroup: finalSrcList.length > 1,
+                    previewSrc: finalSrcList,
+                    titles: titles,
+                    currentIndex,
+                    visible,
+                    lazyLoad,
+                    setCurrentIndex: this.handleCurrentIndexChange,
+                    handleVisibleChange: this.handleVisibleChange,
+                }}
+            >
+                <div id={this.previewGroupId} style={style} className={`${prefixCls}-preview-group`}>
+                    {newChildren}
+                </div>
+                <PreviewInner
+                    {...restProps}
+                    ref={this.previewRef}
+                    src={finalSrcList}
+                    currentIndex={currentIndex}
+                    visible={visible}
+                    onVisibleChange={this.handleVisibleChange}
+                />
+            </PreviewContext.Provider>
+        );
+    }
+}

+ 18 - 0
packages/semi-ui/image/previewContext.tsx

@@ -0,0 +1,18 @@
+import { createContext, ReactNode } from "react";
+import { PreviewImageProps, PreviewProps } from "./interface";
+export interface PreviewContextProps {
+    isGroup: boolean,
+    lazyLoad: boolean,
+    previewSrc: string[],
+    titles: ReactNode[],
+    currentIndex: number;
+    visible: boolean;
+    setCurrentIndex: (current: number) => void;
+    handleVisibleChange: (visible: boolean, preVisible?: boolean) => void;
+}
+
+export const PreviewContext = createContext<PreviewContextProps>({} as any);
+
+
+
+

+ 277 - 0
packages/semi-ui/image/previewFooter.tsx

@@ -0,0 +1,277 @@
+import React, { ReactNode } from "react";
+import BaseComponent from "../_base/baseComponent";
+import { IconChevronLeft, IconChevronRight, IconMinus, IconPlus, IconRotate, IconDownload, IconWindowAdaptionStroked, IconRealSizeStroked, IconSize } from "@douyinfe/semi-icons";
+import { FooterProps } from "./interface";
+import PropTypes from "prop-types";
+import Tooltip from "../tooltip";
+import Divider from "../divider";
+import Slider from "../slider";
+import Icon from "../icons";
+import { cssClasses } from "@douyinfe/semi-foundation/image/constants";
+import cls from "classnames";
+import PreviewFooterFoundation, { PreviewFooterAdapter } from "@douyinfe/semi-foundation/image/previewFooterFoundation";
+import LocaleConsumer from "../locale/localeConsumer";
+import { Locale } from "../locale/interface";
+import { throttle } from "lodash";
+
+const prefixCls = cssClasses.PREFIX;
+const footerPrefixCls = `${cssClasses.PREFIX}-preview-footer`;
+
+let mouseActiveTime: number = 0;
+
+export default class Footer extends BaseComponent<FooterProps> {
+    static propTypes = {
+        curPage: PropTypes.number,
+        totalNum: PropTypes.number,
+        disabledPrev: PropTypes.bool,
+        disabledNext: PropTypes.bool,
+        disableDownload: PropTypes.bool,
+        className: PropTypes.string,
+        zoom: PropTypes.number,
+        ratio: PropTypes.string,
+        prevTip: PropTypes.string,
+        nextTip: PropTypes.string,
+        zoomInTip: PropTypes.string,
+        zoomOutTip: PropTypes.string,
+        rotateTip: PropTypes.string,
+        downloadTip: PropTypes.string,
+        adaptiveTip: PropTypes.string,
+        originTip: PropTypes.string,
+        showTooltip: PropTypes.bool,
+        onZoomIn: PropTypes.func,
+        onZoomOut: PropTypes.func,
+        onPrev: PropTypes.func,
+        onNext: PropTypes.func,
+        onAdjustRatio: PropTypes.func,
+        onRotateLeft: PropTypes.func,
+        onDownload: PropTypes.func,
+    }
+
+    static defaultProps = {
+        min: 10,
+        max: 500,
+        step: 10,
+        showTooltip: false,
+        disableDownload: false,
+    }
+
+    get adapter(): PreviewFooterAdapter<FooterProps> {
+        return {
+            ...super.adapter,
+            setStartMouseOffset: (time: number) => {
+                mouseActiveTime = time;
+            }
+        };
+    }
+
+    foundation: PreviewFooterFoundation;
+
+    constructor(props: FooterProps) {
+        super(props);
+        this.foundation = new PreviewFooterFoundation(this.adapter);
+    }
+
+    changeSliderValue = (type: string): void => {
+        this.foundation.changeSliderValue(type);
+    };
+
+    handleMinusClick = () => {
+        this.changeSliderValue("minus");
+    }
+
+    handlePlusClick = () => {
+        this.changeSliderValue("plus");
+    }
+
+    handleRotateLeft = () => {
+        this.foundation.handleRotate("left");
+    }
+
+    handleRotateRight = () => {
+        this.foundation.handleRotate("right");
+    }
+
+    handleSlideChange = throttle((value): void => {
+        this.foundation.handleValueChange(value);
+    }, 50);
+
+    handleRatioClick = (): void => {
+        this.foundation.handleRatioClick();
+    }
+
+    customRenderViewMenu = (): ReactNode => {
+        const { min, max, step, curPage, totalNum, ratio, zoom, disabledPrev, disabledNext, 
+            disableDownload, onNext, onPrev, onDownload, renderPreviewMenu } 
+        = this.props;
+
+        const props = { min, max, step, curPage, totalNum, ratio, zoom,
+            disabledPrev, disabledNext, disableDownload, onNext, onPrev, onDownload,
+            onRotateLeft: this.handleRotateLeft,
+            onRotateRight: this.handleRotateRight,
+            disabledZoomIn: zoom === max,
+            disabledZoomOut: zoom === min,
+            onRatioClick: this.handleRatioClick,
+            onZoomIn: this.handlePlusClick,
+            onZoomOut: this.handleMinusClick,
+        };
+        return renderPreviewMenu(props);
+    }
+
+    // According to showTooltip in props, decide whether to use Tooltip to pack a layer
+    // 根据 props 中的 showTooltip 决定是否使用 Tooltip 包一层
+    getFinalIconElement = (element: ReactNode, content: ReactNode) => {
+        const { showTooltip } = this.props;
+        return showTooltip ? (
+            <Tooltip content={content}>
+                {element}
+            </Tooltip>
+        ): element;
+    }
+
+    getLocalTextByKey = (key: string) => (
+        <LocaleConsumer<Locale["Image"]> componentName="Image" >
+            {(locale: Locale["Image"]) => locale[key]}
+        </LocaleConsumer>
+    );
+
+    getIconChevronLeft = () => {
+        const { disabledPrev, onPrev, prevTip } = this.props;
+        const icon = <IconChevronLeft
+            size="large"
+            className={disabledPrev ? `${footerPrefixCls}-disabled` : ""}
+            onClick={!disabledPrev ? onPrev : undefined}
+        />;
+        const content = prevTip ?? this.getLocalTextByKey("prevTip");
+        return this.getFinalIconElement(icon, content);
+    }
+
+    getIconChevronRight = () => {
+        const { disabledNext, onNext, nextTip } = this.props;
+        const icon = <IconChevronRight
+            size="large"
+            className={disabledNext ? `${footerPrefixCls}-disabled` : ""}
+            onClick={!disabledNext ? onNext : undefined}
+        />;
+        const content = nextTip ?? this.getLocalTextByKey("nextTip");
+        return this.getFinalIconElement(icon, content);
+    }
+
+    getIconMinus = () => {
+        const { zoomOutTip, zoom, min } = this.props;
+        const disabledZoomOut = zoom === min;
+        const icon = <IconMinus 
+            size="large" 
+            onClick={!disabledZoomOut ? this.handleMinusClick : undefined} 
+            className={disabledZoomOut ? `${footerPrefixCls}-disabled` : ""}
+        />;
+        const content = zoomOutTip ?? this.getLocalTextByKey("zoomOutTip");
+        return this.getFinalIconElement(icon, content);
+    }
+
+    getIconPlus = () => {
+        const { zoomInTip, zoom, max } = this.props;
+        const disabledZoomIn = zoom === max;
+        const icon = <IconPlus 
+            size="large" 
+            onClick={!disabledZoomIn ? this.handlePlusClick : undefined}  
+            className={disabledZoomIn ? `${footerPrefixCls}-disabled` : ""}
+        />;
+        const content = zoomInTip ?? this.getLocalTextByKey("zoomInTip");
+        return this.getFinalIconElement(icon, content);
+    }
+
+    getIconRatio = () => {
+        const { ratio, originTip, adaptiveTip } = this.props;
+        const props = {
+            size: "large" as IconSize,
+            className: cls(`${footerPrefixCls}-gap`),
+            onClick: this.handleRatioClick,
+        };
+        const icon = ratio === "adaptation" ? <IconRealSizeStroked {...props} /> : <IconWindowAdaptionStroked {...props} />;
+        let content: any;
+        if (ratio === "adaptation") {
+            content = originTip ?? this.getLocalTextByKey("originTip");
+        } else {
+            content = adaptiveTip ?? this.getLocalTextByKey("adaptiveTip");
+        }
+        return this.getFinalIconElement(icon, content);
+    }
+
+    getIconRotate = () => {
+        const { rotateTip } = this.props;
+        const icon = <IconRotate
+            size="large"
+            onClick={this.handleRotateLeft}
+        />;
+        const content = rotateTip ?? this.getLocalTextByKey("rotateTip");
+        return this.getFinalIconElement(icon, content);
+    }
+
+    getIconDownload = () => {
+        const { downloadTip, onDownload, disableDownload } = this.props;
+        const icon = <IconDownload
+            size="large"
+            onClick={!disableDownload ? onDownload : undefined}
+            className={cls(`${footerPrefixCls}-gap`,
+                {
+                    [`${footerPrefixCls}-disabled`] : disableDownload,
+                },
+            )}
+        />;
+        const content = downloadTip ?? this.getLocalTextByKey("downloadTip");
+        return this.getFinalIconElement(icon, content);
+    }
+
+
+    render() {
+        const { 
+            min, 
+            max,
+            step,
+            curPage,
+            totalNum,
+            zoom,
+            showTooltip,
+            className,
+            renderPreviewMenu,
+        } = this.props;
+
+        if (renderPreviewMenu) {
+            return (
+                <div className={`${footerPrefixCls}-wrapper`}>
+                    {this.customRenderViewMenu()}
+                </div>
+            ); 
+        }
+
+        return (
+            <section className={cls(footerPrefixCls, `${footerPrefixCls}-wrapper`, className)}>
+                {this.getIconChevronLeft()}
+                <div className={`${footerPrefixCls}-page`}>
+                    <span>{curPage}</span><span>/</span><span>{totalNum}</span>
+                </div>
+                {this.getIconChevronRight()}
+                <Divider layout="vertical" />
+                {this.getIconMinus()}
+                <Slider
+                    value={zoom}
+                    min={min}
+                    max={max}
+                    step={step}
+                    tipFormatter={(v): string => `${v}%`}
+                    tooltipVisible={showTooltip ? undefined : false }
+                    onChange={this.handleSlideChange}
+                />
+                {this.getIconPlus()}
+                {this.getIconRatio()}
+                <Divider layout="vertical" />
+                {this.getIconRotate()}
+                {this.getIconDownload()}
+            </section>
+        );
+    }
+}
+
+
+
+

+ 30 - 0
packages/semi-ui/image/previewHeader.tsx

@@ -0,0 +1,30 @@
+import * as React from "react";
+import { IconClose } from "@douyinfe/semi-icons";
+import { cssClasses } from "@douyinfe/semi-foundation/image/constants";
+import cls from "classnames";
+import { HeaderProps } from "./interface";
+import { PreviewContext } from "./previewContext";
+
+const prefixCls = `${cssClasses.PREFIX}-preview-header`;
+
+const Header: React.FC<HeaderProps> = ({ onClose, titleStyle, className, renderHeader }) => (
+    <PreviewContext.Consumer>
+        {({ currentIndex, titles }) => {
+            let title;
+            if (titles && typeof currentIndex === "number") {
+                title = titles[currentIndex];
+            }
+            return (
+                <section className={cls(prefixCls, className)}>
+                    <section className={`${prefixCls}-title`} style={titleStyle}>{renderHeader ? renderHeader(title) : title}</section>
+                    {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
+                    <section className={`${prefixCls}-close`} onMouseUp={onClose}>
+                        <IconClose />
+                    </section>
+                </section>
+            );
+        }}
+    </PreviewContext.Consumer>
+);
+
+export default Header;

+ 218 - 0
packages/semi-ui/image/previewImage.tsx

@@ -0,0 +1,218 @@
+import React from "react";
+import BaseComponent from "../_base/baseComponent";
+import { cssClasses } from "@douyinfe/semi-foundation/image/constants";
+import { PreviewImageProps, PreviewImageStates } from "./interface";
+import PropTypes from "prop-types";
+import Spin from "../spin";
+import PreviewImageFoundation, { PreviewImageAdapter } from "@douyinfe/semi-foundation/image/previewImageFoundation";
+
+const prefixCls = cssClasses.PREFIX;
+const preViewImgPrefixCls = `${prefixCls}-preview-image`;
+let originImageWidth = null;
+let originImageHeight = null;
+let startMouseMove = false;
+// startMouseOffset:The offset of the mouse relative to the left and top of the picture
+let startMouseOffset = { x: 0, y: 0 };
+
+export default class PreviewImage extends BaseComponent<PreviewImageProps, PreviewImageStates> {
+    static propTypes = {
+        src: PropTypes.string,
+        rotation: PropTypes.number,
+        style: PropTypes.object,
+        maxZoom: PropTypes.number,
+        minZoom: PropTypes.number,
+        zoomStep: PropTypes.number,
+        zoom: PropTypes.number,
+        ratio: PropTypes.string,
+        disableDownload: PropTypes.number,
+        clickZoom: PropTypes.number,
+        setRatio: PropTypes.func,
+        onZoom: PropTypes.func,
+        onLoad: PropTypes.func,
+        onError: PropTypes.func,
+    }
+
+    static defaultProps = {
+        maxZoom: 5,
+        minZoom: 0.1,
+        zoomStep: 0.1,
+        zoom: undefined,
+    };
+
+    get adapter(): PreviewImageAdapter<PreviewImageProps, PreviewImageStates> {
+        return {
+            ...super.adapter,
+            getOriginImageSize: () => ({ originImageWidth, originImageHeight }),
+            setOriginImageSize: (size: { originImageWidth: number; originImageHeight: number; }) => {
+                originImageWidth = size.originImageWidth;
+                originImageHeight = size.originImageHeight;
+            },
+            getContainerRef: () => {
+                return this.containerRef;
+            },
+            getImageRef: () => {
+                return this.imageRef;
+            },
+            getMouseMove: () => startMouseMove,
+            setStartMouseMove: (move: boolean) => { startMouseMove = move; },
+            getMouseOffset: () => startMouseOffset,
+            setStartMouseOffset: (offset: { x: number; y: number }) => { startMouseOffset = offset; },
+            setLoading: (loading: boolean) => { 
+                this.setState({
+                    loading,
+                });
+            },
+        };
+    }
+
+    containerRef: React.RefObject<HTMLDivElement>;
+    imageRef: React.RefObject<HTMLImageElement>;
+    foundation: PreviewImageFoundation;
+
+    constructor(props) {
+        super(props);
+        this.state = {
+            width: 0,
+            height: 0,
+            loading: true,
+            offset: { x: 0, y: 0 },
+            currZoom: 0,
+            top: 0,
+            left: 0,
+        };
+        this.containerRef = React.createRef<HTMLDivElement>();
+        this.imageRef = React.createRef<HTMLImageElement>();
+        this.foundation = new PreviewImageFoundation(this.adapter);
+    }
+
+    componentDidMount() {
+        window.addEventListener("resize", this.onWindowResize);
+    }
+
+    componentWillUnmount() {
+        window.removeEventListener("resize", this.onWindowResize);
+    }
+
+    componentDidUpdate(prevProps: PreviewImageProps, prevStates: PreviewImageStates) {
+        // If src changes, start a new loading
+        if (this.props.src && this.props.src !== prevProps.src) {
+            this.foundation.setLoading(true);
+        } 
+        // If the incoming zoom changes, other content changes are determined based on the new zoom value
+        if ("zoom" in this.props && this.props.zoom !== prevStates.currZoom) {
+            this.handleZoomChange(this.props.zoom, null);
+        }
+        // When the incoming ratio is changed, if it"s adaptation, then resizeImage is triggered to make the image adapt to the page
+        // else if it"s adaptation is realSize, then onZoom(1) is called to make the image size the original size;
+        if ("ratio" in this.props && this.props.ratio !== prevProps.ratio) {
+            if (originImageWidth && originImageHeight) {
+                if (this.props.ratio === "adaptation") {
+                    this.resizeImage();
+                } else {
+                    this.props.onZoom(1);
+                }
+            }
+        }
+        // When the incoming rotation angle of the image changes, it needs to be resized to make the image fit on the page
+        if ("rotation" in this.props && this.props.rotation !== prevProps.rotation) {
+            this.onWindowResize();
+        }
+    }
+
+    onWindowResize = (): void => {
+        this.foundation.handleWindowResize();
+    };
+
+    handleZoomChange = (newZoom, e): void => {
+        this.foundation.handleZoomChange(newZoom, e);
+    };
+
+    // Determine the response method of right click according to the disableDownload parameter in props
+    handleRightClickImage = (e) => {
+        this.foundation.handleRightClickImage(e);
+    };
+
+    handleWheel = (e) => {
+        this.foundation.handleWheel(e);
+    }
+
+    handleLoad = (e): void => {
+        this.foundation.handleLoad(e);
+    }
+
+    handleError = (e): void => {
+        this.foundation.handleError(e);
+    }
+
+    resizeImage = () => {
+        this.foundation.handleResizeImage();
+    }
+
+    handleMoveImage = (e): void => {
+        this.foundation.handleMoveImage(e);
+    };
+  
+    // 为什么通过ref注册wheel而不是使用onWheel事件?
+    // 因为对于wheel事件,浏览器将 addEventListener 的 passive 默认值更改为 true。如此,事件监听器便不能取消事件,也不会在用户滚动页面时阻止页面呈现。
+    // 这里我们需要保持页面不动,仅放大图片,因此此处需要将 passive 更改设置为 false。
+    // Why register wheel via ref instead of using onWheel event?
+    // Because for wheel events, the browser changes the passive default of addEventListener to true. This way, the event listener cannot cancel the event, nor prevent the page from rendering when the user scrolls.
+    // Here we need to keep the page still and only zoom in on the image, so here we need to set the passive change to false.
+    // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners。
+    
+    registryImageRef = (ref): void => {
+        if (this.imageRef && this.imageRef.current) {
+            (this.imageRef as any).removeEventListener("wheel", this.handleWheel);
+        }
+        if (ref) {
+            ref.addEventListener("wheel", this.handleWheel, { passive: false });
+        }
+        this.imageRef = ref;
+    };
+
+    onImageMouseDown = (e: React.MouseEvent<HTMLImageElement>): void => {
+        this.foundation.handleImageMouseDown(e);
+    };
+
+    onImageMouseUp = (): void => {
+        this.foundation.handleImageMouseUp();
+    };
+
+    render() {
+        const { src, rotation } = this.props;
+        const { loading, width, height, top, left } = this.state;
+        const imgStyle = {
+            position: "absolute",
+            visibility: loading ? "hidden" : "visible",
+            transform: `rotate(${-rotation}deg)`,
+            top,
+            left,
+            width: loading ? "auto" : `${width}px`,
+            height: loading ? "auto" : `${height}px`,
+        };
+        return (
+            <div 
+                className={`${preViewImgPrefixCls}`}
+                ref={this.containerRef}
+            >
+                {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
+                <img
+                    ref={this.registryImageRef}
+                    src={src}
+                    alt="previewImag"
+                    className={`${preViewImgPrefixCls}-img`}
+                    key={src}
+                    onMouseMove={this.handleMoveImage}
+                    onMouseDown={this.onImageMouseDown}
+                    onMouseUp={this.onImageMouseUp}
+                    onContextMenu={this.handleRightClickImage}
+                    onDragStart={(e): void => e.preventDefault()}
+                    onLoad={this.handleLoad}
+                    onError={this.handleError}
+                    style={imgStyle as React.CSSProperties}
+                />
+                {loading && <Spin size={"large"} wrapperClassName={`${preViewImgPrefixCls}-spin`}/>}
+            </div>
+        );
+    }
+}

+ 402 - 0
packages/semi-ui/image/previewInner.tsx

@@ -0,0 +1,402 @@
+/* eslint-disable jsx-a11y/no-static-element-interactions */
+import React, { CSSProperties } from "react";
+import BaseComponent from "../_base/baseComponent";
+import { PreviewProps as PreviewInnerProps, PreviewInnerStates, RatioType } from "./interface";
+import PropTypes from "prop-types";
+import { cssClasses } from "@douyinfe/semi-foundation/image/constants";
+import cls from "classnames";
+import { isEqual, isFunction } from "lodash";
+import Portal from "../_portal";
+import { IconArrowLeft, IconArrowRight } from "@douyinfe/semi-icons";
+import Header from "./previewHeader";
+import Footer from "./previewFooter";
+import PreviewImage from "./previewImage";
+import PreviewInnerFoundation, { PreviewInnerAdapter } from "@douyinfe/semi-foundation/image/previewInnerFoundation";
+import { PreviewContext, PreviewContextProps } from "./previewContext";
+
+const prefixCls = cssClasses.PREFIX;
+
+let startMouseDown = { x: 0, y: 0 };
+
+let mouseActiveTime: number = null;
+let stopTiming = false;
+let timer = null;
+// let bodyOverflowValue = document.body.style.overflow;
+
+export default class PreviewInner extends BaseComponent<PreviewInnerProps, PreviewInnerStates> {
+    static contextType = PreviewContext;
+    
+    static propTypes = {
+        style: PropTypes.object,
+        className: PropTypes.string,
+        visible: PropTypes.bool,
+        src: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
+        currentIndex: PropTypes.number,
+        defaultIndex: PropTypes.number,
+        defaultVisible: PropTypes.bool,
+        maskClosable: PropTypes.bool,
+        closable: PropTypes.bool,
+        zoomStep: PropTypes.number,
+        infinite: PropTypes.bool,
+        showTooltip: PropTypes.bool,
+        closeOnEsc: PropTypes.bool,
+        prevTip: PropTypes.string,
+        nextTip: PropTypes.string,
+        zoomInTip:PropTypes.string,
+        zoomOutTip: PropTypes.string,
+        downloadTip: PropTypes.string,
+        adaptiveTip:PropTypes.string,
+        originTip: PropTypes.string,
+        lazyLoad: PropTypes.bool,
+        preLoad: PropTypes.bool,
+        preLoadGap: PropTypes.number,
+        disableDownload: PropTypes.bool,
+        viewerVisibleDelay: PropTypes.number,
+        zIndex: PropTypes.number,
+        renderHeader: PropTypes.func,
+        renderPreviewMenu: PropTypes.func,
+        getPopupContainer: PropTypes.func,
+        onVisibleChange: PropTypes.func,
+        onChange: PropTypes.func,
+        onClose: PropTypes.func,
+        onZoomIn: PropTypes.func,
+        onZoomOut: PropTypes.func,
+        onPrev: PropTypes.func,
+        onNext: PropTypes.func,
+        onDownload: PropTypes.func,
+        onRatioChange: PropTypes.func,
+        onRotateChange: PropTypes.func,
+    }
+
+    static defaultProps = {
+        showTooltip: false,
+        zoomStep: 0.1,
+        infinite: false,
+        closeOnEsc: true,
+        lazyLoad: false,
+        preLoad: true, 
+        preLoadGap: 2,
+        zIndex: 1000,
+        maskClosable: true,
+        viewerVisibleDelay: 10000,
+    };
+
+    get adapter(): PreviewInnerAdapter<PreviewInnerProps, PreviewInnerStates> {
+        return {
+            ...super.adapter,
+            getIsInGroup: () => this.isInGroup(),
+            notifyChange: (index: number) => {
+                const { onChange } = this.props;
+                isFunction(onChange) && onChange(index);
+            },
+            notifyZoom: (zoom: number, increase: boolean) => {
+                const { onZoomIn, onZoomOut } = this.props;
+                if (increase) {
+                    isFunction(onZoomIn) && onZoomIn(zoom);
+                } else {
+                    isFunction(onZoomOut) && onZoomOut(zoom);
+                }
+            },
+            notifyClose: () => {
+                const { onClose } = this.props;
+                isFunction(onClose) && onClose();
+            },
+            notifyVisibleChange: (visible: boolean) => {
+                const { onVisibleChange } = this.props;
+                isFunction(onVisibleChange) && onVisibleChange(visible);
+            },
+            notifyRatioChange: (type: string) => {
+                const { onRatioChange } = this.props;
+                isFunction(onRatioChange) && onRatioChange(type);
+            },
+            notifyRotateChange: (angle: number) => {
+                const { onRotateChange } = this.props;
+                isFunction(onRotateChange) && onRotateChange(angle);   
+            },
+            notifyDownload: (src: string, index: number) => {
+                const { onDownload } = this.props;
+                isFunction(onDownload) && onDownload(src, index);  
+            },
+            registerKeyDownListener: () => {
+                window && window.addEventListener("keydown", this.handleKeyDown);
+            },
+            unregisterKeyDownListener: () => {
+                window && window.removeEventListener("keydown", this.handleKeyDown);
+            },
+            getMouseActiveTime: () => {
+                return mouseActiveTime;
+            },
+            getStopTiming: () => {
+                return stopTiming;
+            },
+            setStopTiming: (value) => {
+                stopTiming = value;
+            },
+            getStartMouseDown: () => {
+                return startMouseDown;
+            },
+            setStartMouseDown: (x: number, y: number) => {
+                startMouseDown = { x, y };
+            },
+            setMouseActiveTime: (time: number) => {
+                mouseActiveTime = time;
+            },
+        };
+        
+    }
+
+    timer;
+    context: PreviewContextProps;
+    foundation: PreviewInnerFoundation;
+
+    constructor(props: PreviewInnerProps) {
+        super(props);
+        this.state = {
+            imgSrc: [],
+            imgLoadStatus: new Map(),
+            zoom: 0.1,
+            currentIndex: 0,
+            ratio: "adaptation",
+            rotation: 0,
+            viewerVisible: true,
+            visible: false,
+            preloadAfterVisibleChange: true,
+            direction: "",
+        }; 
+        this.foundation = new PreviewInnerFoundation(this.adapter);
+    }
+
+    static getDerivedStateFromProps(props: PreviewInnerProps, state: PreviewInnerStates) {
+        const willUpdateStates: Partial<PreviewInnerStates> = {};
+        let src = [];
+        if (props.visible) {
+            // if src in props
+            src = Array.isArray(props.src) ? props.src : [props.src];
+        } 
+        if (!isEqual(src, state.imgSrc)) {
+            willUpdateStates.imgSrc = src;
+        }
+        if (props.visible !== state.visible) {
+            willUpdateStates.visible = props.visible;
+            if (props.visible) {
+                willUpdateStates.preloadAfterVisibleChange = true;
+            }
+        }
+        if ("currentIndex" in props && props.currentIndex !== state.currentIndex) {
+            willUpdateStates.currentIndex = props.currentIndex;
+        }
+        return willUpdateStates;
+    }
+
+    componentDidUpdate(prevProps: PreviewInnerProps, prevState: PreviewInnerStates) {
+        if (prevState.visible !== this.props.visible && this.props.visible) {
+            mouseActiveTime = new Date().getTime();
+            timer && clearInterval(timer);
+            timer = setInterval(this.viewVisibleChange, 1000);
+        }
+        // hide => show
+        if (!prevProps.visible && this.props.visible) {
+            this.foundation.beforeShow();
+        }
+        // show => hide
+        if (prevProps.visible && !this.props.visible) {
+            this.foundation.afterHide();
+        }
+    }
+
+    componentWillUnmount() {
+        timer && clearInterval(timer);
+    }
+
+    isInGroup() {
+        return Boolean(this.context && this.context.isGroup);
+    }
+
+    viewVisibleChange = () => {
+        this.foundation.handleViewVisibleChange();
+    }
+
+    handleSwitchImage = (direction: string) => {
+        this.foundation.handleSwitchImage(direction);
+    }
+
+    handleDownload = () => {
+        this.foundation.handleDownload();
+    }
+
+    handlePreviewClose = () => {
+        this.foundation.handlePreviewClose();
+    }
+
+    handleAdjustRatio = (type: string) => {
+        this.foundation.handleAdjustRatio(type);
+    }
+
+    handleRotateImage = (direction) => {
+        this.foundation.handleRotateImage(direction);
+    }
+
+    handleZoomImage = (newZoom: number) => {
+        this.foundation.handleZoomImage(newZoom);
+    }
+
+    handleMouseUp = (e): void => {
+        this.foundation.handleMouseUp(e);
+    }
+
+    handleMouseMove = (e): void => {
+        this.foundation.handleMouseMove(e);
+    }
+
+    handleMouseEvent = (e, event: string) => {
+        this.foundation.handleMouseMoveEvent(e, event);
+    }
+
+    handleKeyDown = (e: KeyboardEvent) => {
+        this.foundation.handleKeyDown(e);
+    };
+
+    onImageError = () => {
+        this.foundation.preloadSingleImage();
+    }
+
+    onImageLoad = (src) => {
+        this.foundation.onImageLoad(src);
+    }   
+    
+    handleMouseDown = (e): void => {
+        this.foundation.handleMouseDown(e);
+    }
+
+    handleRatio = (type: RatioType): void => {
+        this.foundation.handleRatio(type);
+    }
+
+    render() {
+        const { 
+            getPopupContainer, 
+            zIndex, 
+            visible, 
+            className, 
+            style, 
+            infinite, 
+            zoomStep,
+            prevTip,
+            nextTip,
+            zoomInTip,
+            zoomOutTip,
+            rotateTip,
+            downloadTip,
+            adaptiveTip,
+            originTip,
+            showTooltip,
+            disableDownload,
+            renderPreviewMenu,
+            renderHeader,
+        } = this.props;
+        const { currentIndex, imgSrc, zoom, ratio, rotation, viewerVisible } = this.state;
+        let wrapperStyle: {
+            zIndex?: CSSProperties["zIndex"];
+            position?: CSSProperties["position"];
+        } = {
+            zIndex,
+        };
+
+        if (getPopupContainer) {
+            wrapperStyle = {
+                zIndex,
+                position: "static",
+            };
+        }
+        const previewPrefixCls = `${prefixCls}-preview`;
+        const previewWrapperCls = cls(previewPrefixCls, 
+            {
+                [`${prefixCls}-hide`]: !visible,
+                [`${previewPrefixCls}-popup`]: getPopupContainer,
+            },
+            className,
+        );
+        const hideViewerCls = !viewerVisible ? `${previewPrefixCls}-hide` : "";
+        const total = imgSrc.length;
+        const showPrev = total !== 1 && (infinite || currentIndex !== 0); 
+        const showNext = total !== 1 && (infinite || currentIndex !== total - 1);
+        return (
+            <Portal 
+                getPopupContainer={getPopupContainer}
+                style={wrapperStyle}
+            >
+                {visible && 
+                // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events,jsx-a11y/no-static-element-interactions
+                <div 
+                    className={previewWrapperCls}
+                    style={style}
+                    onMouseDown={this.handleMouseDown}
+                    onMouseUp={this.handleMouseUp}
+                    onMouseMove={this.handleMouseMove}
+                    onMouseOver={(e): void => this.handleMouseEvent(e, "over")}
+                    onMouseOut={(e): void => this.handleMouseEvent(e, "out")}
+                >
+                    <Header className={cls(hideViewerCls)} onClose={this.handlePreviewClose} renderHeader={renderHeader}/>
+                    <PreviewImage 
+                        src={imgSrc[currentIndex]}
+                        onZoom={this.handleZoomImage}
+                        disableDownload={disableDownload}
+                        setRatio={this.handleRatio}
+                        zoom={zoom}
+                        ratio={ratio}
+                        zoomStep={zoomStep}
+                        rotation={rotation}
+                        onError={this.onImageError}
+                        onLoad={this.onImageLoad}
+                    />
+                    {showPrev && (
+                        // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
+                        <div
+                            className={cls(`${previewPrefixCls}-icon`, `${previewPrefixCls}-prev`, hideViewerCls)}
+                            onClick={(): void => this.handleSwitchImage("prev")}
+                        >
+                            <IconArrowLeft size="large" />
+                        </div>
+                    )}
+                    {showNext && (
+                        // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
+                        <div
+                            className={cls(`${previewPrefixCls}-icon`, `${previewPrefixCls}-next`, hideViewerCls)}
+                            onClick={(): void => this.handleSwitchImage("next")}
+                        >
+                            <IconArrowRight size="large" />
+                        </div>
+                    )}
+                    <Footer
+                        className={hideViewerCls}
+                        totalNum={total}
+                        curPage={currentIndex + 1}
+                        disabledPrev={!showPrev}
+                        disabledNext={!showNext}
+                        zoom={zoom * 100}
+                        step={zoomStep * 100}
+                        showTooltip={showTooltip}
+                        ratio={ratio}
+                        prevTip={prevTip}
+                        nextTip={nextTip}
+                        zoomInTip={zoomInTip}
+                        zoomOutTip={zoomOutTip}
+                        rotateTip={rotateTip}
+                        downloadTip={downloadTip}
+                        disableDownload={disableDownload}
+                        adaptiveTip={adaptiveTip}
+                        originTip={originTip}
+                        onPrev={(): void => this.handleSwitchImage("prev")}
+                        onNext={(): void => this.handleSwitchImage("next")}
+                        onZoomIn={this.handleZoomImage}
+                        onZoomOut={this.handleZoomImage}
+                        onDownload={this.handleDownload}
+                        onRotate={this.handleRotateImage}
+                        onAdjustRatio={this.handleAdjustRatio}
+                        renderPreviewMenu={renderPreviewMenu}
+                    />
+                </div>}
+            </Portal>
+        );
+    }
+}

+ 3 - 0
packages/semi-ui/index.ts

@@ -95,3 +95,6 @@ export {
     withField,
     ArrayField,
 } from './form';
+
+export { default as Image } from './image'; 
+export { Preview as ImagePreview } from './image';

+ 14 - 1
packages/semi-ui/locale/interface.ts

@@ -151,5 +151,18 @@ export interface Locale {
     };
     Form: {
         optional: string;
-    }
+    };
+    Image: {
+        preview: string;
+        loading: string;
+        loadError: string;
+        prevTip: string;
+        nextTip: string;
+        zoomInTip: string;
+        zoomOutTip: string;
+        rotateTip: string;
+        downloadTip: string;
+        adaptiveTip: string;
+        originTip: string;
+    };
 }

+ 13 - 0
packages/semi-ui/locale/source/ar.ts

@@ -153,6 +153,19 @@ const local: Locale = {
     Form: {
         optional: '(اختياري)',
     },
+    Image: {
+        preview: 'معاينة',
+        loading: 'جار التحميل',
+        loadError: 'فشل في التحميل',
+        prevTip: "السابق",
+        nextTip: "التالي",
+        zoomInTip: "تكبير",
+        zoomOutTip: "تصغير",
+        rotateTip: "استدارة",
+        downloadTip: "تنزيل",
+        adaptiveTip: "التكيف مع الصفحة",
+        originTip: "الحجم الأصلي",
+    },
 };
 
 // [i18n-Arabic]

+ 13 - 0
packages/semi-ui/locale/source/de.ts

@@ -153,6 +153,19 @@ const local: Locale = {
     Form: {
         optional: '(Optional)',
     },
+    Image: {
+        preview: 'Vorschau',
+        loading: 'Wird geladen',
+        loadError: 'Laden fehlgeschlagen',
+        prevTip: 'Zurück',
+        nextTip: 'Weiter',
+        zoomInTip: 'Vergrößern',
+        zoomOutTip: 'herauszoomen',
+        rotateTip: 'Drehen',
+        downloadTip: 'herunterladen',
+        adaptiveTip: 'An die Seite anpassen',
+        originTip: 'Originalgröße',
+    },
 };
 
 // [i18n-German]

+ 13 - 0
packages/semi-ui/locale/source/en_GB.ts

@@ -153,6 +153,19 @@ const local: Locale = {
     Form: {
         optional: '(optional)',
     },
+    Image: {
+        preview: 'Preview',
+        loading: 'Loading',
+        loadError: 'Failed to load',
+        prevTip: 'Previous',
+        nextTip: 'Next',
+        zoomInTip: 'Zoom in',
+        zoomOutTip: 'Zoom out',
+        rotateTip: 'Rotate',
+        downloadTip: 'Download',
+        adaptiveTip: 'Adapt to the page',
+        originTip: 'Original size',
+    },
 };
 
 // [i18n-English(GB)]

+ 13 - 0
packages/semi-ui/locale/source/en_US.ts

@@ -153,6 +153,19 @@ const local: Locale = {
     Form: {
         optional: '(optional)',
     },
+    Image: {
+        preview: 'Preview',
+        loading: 'Loading',
+        loadError: 'Failed to load',
+        prevTip: 'Previous',
+        nextTip: 'Next',
+        zoomInTip: 'Zoom in',
+        zoomOutTip: 'Zoom out',
+        rotateTip: 'Rotate',
+        downloadTip: 'Download',
+        adaptiveTip: 'Adapt to the page',
+        originTip: 'Original size',
+    },
 };
 
 // [i18n-English(US)]

+ 13 - 0
packages/semi-ui/locale/source/es.ts

@@ -158,6 +158,19 @@ const locale: Locale = {
     Form: {
         optional: '(opcional)',
     },
+    Image: {
+        preview: 'Avance',
+        loading: 'Cargando',
+        loadError: 'Falló al cargar',
+        prevTip: 'Anterior',
+        nextTip: 'Siguiente',
+        zoomInTip: 'Acercar',
+        zoomOutTip: 'alejar',
+        rotateTip: 'Rotar',
+        downloadTip: 'descargar',
+        adaptiveTip: 'Adaptarse a la página',
+        originTip: 'Tamaño original',
+    },
 };
 
 export default locale;

+ 13 - 0
packages/semi-ui/locale/source/fr.ts

@@ -153,6 +153,19 @@ const local: Locale = {
     Form: {
         optional: '(optionnel)',
     },
+    Image: {
+        preview: 'Aperçu',
+        loading: 'Chargement',
+        loadError: 'Échec du chargement',
+        prevTip : 'Précédent',
+        nextTip : 'Suivant',
+        zoomInTip : 'Zoom avant',
+        zoomOutTip : 'Zoom arrière',
+        rotateTip : 'Rotation',
+        downloadTip : 'Télécharger',
+        adaptiveTip : 'Adapter à la page',
+        originTip : 'Taille d\'origine',
+    },
 };
 
 // [i18n-French]

+ 13 - 0
packages/semi-ui/locale/source/id_ID.ts

@@ -153,6 +153,19 @@ const local: Locale = {
     Form: {
         optional: '(opsional)',
     },
+    Image: {
+        preview: 'Pratinjau',
+        loading: 'Memuat',
+        loadError: 'Gagal untuk memuat',
+        prevTip: 'Sebelumnya',
+        nextTip: 'Selanjutnya',
+        zoomInTip: 'Memperbesar',
+        zoomOutTip: 'memperkecil',
+        rotateTip: 'Putar',
+        downloadTip: 'unduh',
+        adaptiveTip: 'Beradaptasi dengan halaman',
+        originTip: 'Ukuran asli',
+    },
 };
 
 // [i18n-Indonesia(ID)]

+ 13 - 0
packages/semi-ui/locale/source/it.ts

@@ -153,6 +153,19 @@ const local: Locale = {
     Form: {
         optional: '(opzionale)',
     },
+    Image: {
+        preview: 'Anteprima',
+        loading: 'Caricamento in corso',
+        loadError: 'Caricamento fallito',
+        prevTip: 'Precedente',
+        nextTip: 'Avanti',
+        zoomInTip: 'Ingrandisci',
+        zoomOutTip: 'rimpicciolisci',
+        rotateTip: 'Ruota',
+        downloadTip: 'scarica',
+        adaptiveTip: 'Adatta alla pagina',
+        originTip: 'Formato originale',
+    },
 };
 
 // [i18n-Italian]

+ 13 - 0
packages/semi-ui/locale/source/ja_JP.ts

@@ -154,6 +154,19 @@ const local: Locale = {
     Form: {
         optional: '(オプション)',
     },
+    Image: {
+        preview: 'プレビュー',
+        loading: '読み込み中',
+        loadError: '読み込みに失敗しました',
+        prevTip: '前へ',
+        nextTip: '次へ',
+        zoomInTip: 'ズームイン',
+        zoomOutTip: 'ズームアウト',
+        rotateTip: '回転',
+        downloadTip: 'ダウンロード',
+        adaptiveTip: 'ページに適応',
+        originTip: '元のサイズ',
+    },
 };
 
 // [i18n-Japan]

+ 13 - 0
packages/semi-ui/locale/source/ko_KR.ts

@@ -154,6 +154,19 @@ const local: Locale = {
     Form: {
         optional: '(선택 과목)',
     },
+    Image: {
+        preview: '시사',
+        loading: '로딩 중',
+        loadError: '불러 오지 못했습니다',
+        prevTip: '이전',
+        nextTip: '다음',
+        zoomInTip: '확대',
+        zoomOutTip: '축소',
+        rotateTip: '회전',
+        downloadTip: '다운로드',
+        adaptiveTip: '페이지에 맞게 조정',
+        originTip: '원래 크기',
+    },
 };
 
 // [i18n-Korea]

+ 13 - 0
packages/semi-ui/locale/source/ms_MY.ts

@@ -153,6 +153,19 @@ const local: Locale = {
     Form: {
         optional: '(pilihan)',
     },
+    Image: {
+        preview: 'Pratonton',
+        loading: 'Memuatkan',
+        loadError: 'Gagal memuatkan',
+        prevTip: 'Sebelumnya',
+        nextTip: 'Seterusnya',
+        zoomInTip: 'Zum masuk',
+        zoomOutTip: 'zum keluar',
+        rotateTip: 'Putar',
+        downloadTip: 'muat turun',
+        adaptiveTip: 'Menyesuaikan diri dengan halaman',
+        originTip: 'Saiz asal',
+    },
 };
 
 // [i18n-Malaysia(MY)]

+ 13 - 0
packages/semi-ui/locale/source/pt_BR.ts

@@ -161,6 +161,19 @@ const local: Locale = {
     Form: {
         optional: '(opcional)',
     },
+    Image: {
+        preview: 'Visualizar',
+        loading: 'Carregando',
+        loadError: 'Falha ao carregar',
+        prevTip: 'Anterior',
+        nextTip: 'Próximo',
+        zoomInTip: 'Ampliar',
+        zoomOutTip: 'reduzir',
+        rotateTip: 'Girar',
+        downloadTip: 'baixar',
+        adaptiveTip: 'Adaptar à página',
+        originTip: 'Tamanho original',
+    },
 };
 
 // 葡萄牙语

+ 13 - 0
packages/semi-ui/locale/source/ru_RU.ts

@@ -156,6 +156,19 @@ const local: Locale = {
     Form: {
         optional: '(по желанию)',
     },
+    Image: {
+        preview: 'предварительный просмотр',
+        loading: 'Загрузка',
+        loadError: 'Ошибка загрузки',
+        prevTip: 'Предыдущий',
+        nextTip: 'Далее',
+        zoomInTip: 'Увеличить',
+        zoomOutTip: 'уменьшить масштаб',
+        rotateTip: 'Повернуть',
+        downloadTip: 'скачать',
+        adaptiveTip: 'Адаптировать к странице',
+        originTip: 'Исходный размер',
+    },
 };
 
 // [i18n-Russia] 俄罗斯语

+ 13 - 0
packages/semi-ui/locale/source/th_TH.ts

@@ -157,6 +157,19 @@ const local: Locale = {
     Form: {
         optional: '(ไม่จำเป็น)',
     },
+    Image: {
+        preview: 'ดูตัวอย่าง',
+        loading: 'กำลังโหลด',
+        loadError: 'โหลดไม่สำเร็จ',
+        prevTip: 'ก่อนหน้า',
+        nextTip: 'ถัดไป',
+        zoomInTip: 'ซูมเข้า',
+        zoomOutTip: 'ซูมออก',
+        rotateTip: 'หมุน',
+        downloadTip: 'ดาวน์โหลด',
+        adaptiveTip: 'ปรับให้เข้ากับหน้า',
+        originTip: 'ขนาดเดิม',
+    },
 };
 
 // [i18n-Thai]

+ 13 - 0
packages/semi-ui/locale/source/tr_TR.ts

@@ -153,6 +153,19 @@ const local: Locale = {
     Form: {
         optional: '(isteğe bağlı)',
     },
+    Image: {
+        preview: 'Ön izleme',
+        loading: 'Yükleniyor',
+        loadError: 'Yükleme başarısız',
+        prevTip: 'Önceki',
+        nextTip: 'Sonraki',
+        zoomInTip: 'Yakınlaştır',
+        zoomOutTip: 'uzaklaştır',
+        rotateTip: 'Döndür',
+        downloadTip: 'indir',
+        adaptiveTip: 'Sayfaya uyarla',
+        originTip: 'Orijinal boyut',
+    },
 };
 
 // [i18n-Turkish] 

+ 13 - 0
packages/semi-ui/locale/source/vi_VN.ts

@@ -156,6 +156,19 @@ const local: Locale = {
     Form: {
         optional: '(không bắt buộc)',
     },
+    Image: {
+        preview: 'xem trước',
+        loading: 'Đang tải',
+        loadError: 'Không tải được',
+        prevTip: 'Trước đó',
+        nextTip: 'Next',
+        zoomInTip: 'Phóng to',
+        zoomOutTip: 'thu nhỏ',
+        rotateTip: 'Xoay',
+        downloadTip: 'download',
+        adaptiveTip: 'Thích ứng với trang',
+        originTip: 'Kích thước ban đầu',
+    },
 };
 
 // [i18n-Vietnam] 越南语

+ 13 - 0
packages/semi-ui/locale/source/zh_CN.ts

@@ -154,6 +154,19 @@ const local: Locale = {
     Form: {
         optional: '(可选)',
     },
+    Image: {
+        preview: '预览',
+        loading: '加载中',
+        loadError: '加载失败',
+        prevTip: '上一张',
+        nextTip: '下一张',
+        zoomInTip: '放大',
+        zoomOutTip: '缩小',
+        rotateTip: '旋转',
+        downloadTip: '下载',
+        adaptiveTip: '适应页面',
+        originTip: '原始尺寸',
+    },
 };
 
 // 中文

+ 13 - 0
packages/semi-ui/locale/source/zh_TW.ts

@@ -154,6 +154,19 @@ const local: Locale = {
     Form: {
         optional: '(可選)',
     },
+    Image: {
+        preview: '預覽',
+        loading: '加載中',
+        loadError: '加載失敗',
+        prevTip: '上一張',
+        nextTip: '下一張',
+        zoomInTip: '放大',
+        zoomOutTip: '縮小',
+        rotateTip: '旋轉',
+        downloadTip: '下載',
+        adaptiveTip: '適應頁面',
+        originTip: '原始尺寸',
+    },
 };
 
 // 中文

+ 5 - 0
src/images/docIcons/doc-image.svg

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4 4H11V11H4V4Z" fill="#FBCD2C"/>
+<path d="M3 11H20V20H3V11Z" fill="#3BCE4A"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M5 2C3.34315 2 2 3.34315 2 5V19C2 20.6569 3.34315 22 5 22H19C20.6569 22 22 20.6569 22 19V5C22 3.34315 20.6569 2 19 2H5ZM10 7.5C10 8.88071 8.88071 10 7.5 10C6.11929 10 5 8.88071 5 7.5C5 6.11929 6.11929 5 7.5 5C8.88071 5 10 6.11929 10 7.5ZM16.7071 11.7071C16.3166 11.3166 15.6834 11.3166 15.2929 11.7071L11 16L9.70711 14.7071C9.31658 14.3166 8.68342 14.3166 8.29289 14.7071L5 18V19H19V14L16.7071 11.7071Z" fill="#324350"/>
+</svg>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است