Просмотр исходного кода

feat: add Cropper componnent (#2642)

* feat: add Cropper componnent

* docs: add Cropper & DragMove icon & overview

---------

Co-authored-by: 代强 <[email protected]>
YyumeiZhang 10 месяцев назад
Родитель
Сommit
7c205edb90
58 измененных файлов с 2321 добавлено и 44 удалено
  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. 1 1
      content/plus/dragMove/index-en-US.md
  21. 1 1
      content/plus/dragMove/index.md
  22. 1 1
      content/show/chart/index-en-US.md
  23. 1 1
      content/show/chart/index.md
  24. 281 0
      content/show/cropper/index-en-US.md
  25. 286 0
      content/show/cropper/index.md
  26. 1 1
      content/show/list/index-en-US.md
  27. 1 1
      content/show/list/index.md
  28. 1 1
      content/show/modal/index-en-US.md
  29. 1 1
      content/show/modal/index.md
  30. 1 1
      content/show/overflowlist/index-en-US.md
  31. 1 1
      content/show/overflowlist/index.md
  32. 1 1
      content/show/popover/index-en-US.md
  33. 1 1
      content/show/popover/index.md
  34. 1 1
      content/show/scrolllist/index-en-US.md
  35. 1 1
      content/show/scrolllist/index.md
  36. 1 1
      content/show/sidesheet/index-en-US.md
  37. 1 1
      content/show/sidesheet/index.md
  38. 1 1
      content/show/table/index-en-US.md
  39. 1 1
      content/show/table/index.md
  40. 1 1
      content/show/tag/index-en-US.md
  41. 1 1
      content/show/tag/index.md
  42. 1 1
      content/show/timeline/index-en-US.md
  43. 1 1
      content/show/timeline/index.md
  44. 1 1
      content/show/tooltip/index-en-US.md
  45. 1 1
      content/show/tooltip/index.md
  46. 3 1
      content/start/overview/index-en-US.md
  47. 3 1
      content/start/overview/index.md
  48. 26 0
      packages/semi-foundation/cropper/constants.ts
  49. 116 0
      packages/semi-foundation/cropper/cropper.scss
  50. 821 0
      packages/semi-foundation/cropper/foundation.ts
  51. 12 0
      packages/semi-foundation/cropper/utils.ts
  52. 6 0
      packages/semi-foundation/cropper/variables.scss
  53. 320 0
      packages/semi-ui/cropper/_story/cropper.stories.jsx
  54. 90 0
      packages/semi-ui/cropper/_story/cropper.stories.tsx
  55. 298 0
      packages/semi-ui/cropper/index.tsx
  56. 1 0
      packages/semi-ui/index.ts
  57. 7 0
      src/images/docIcons/doc-cropper.svg
  58. 8 0
      src/images/docIcons/doc-dragmove.svg

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 1 - 0
content/order.js

@@ -67,6 +67,7 @@ const order = [
     'empty',
     'highlight',
     'image',
+    'cropper',
     'list',
     'modal',
     'overflowlist',

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

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

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

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

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

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

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

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

+ 1 - 1
content/plus/dragMove/index-en-US.md

@@ -3,7 +3,7 @@ localeCode: en-US
 order: 26
 category: Plus
 title:  DragMove
-icon: doc-configprovider
+icon: doc-dragmove
 dir: column
 brief: Set elements to change their position by dragging
 showNew: true

+ 1 - 1
content/plus/dragMove/index.md

@@ -3,7 +3,7 @@ localeCode: zh-CN
 order: 26
 category: Plus
 title:  DragMove 拖拽移动
-icon: doc-configprovider
+icon: doc-dragmove
 dir: column
 brief: 可通过拖拽改变位置
 showNew: true

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

@@ -1,6 +1,6 @@
 ---
 localeCode: en-US
-order: 79
+order: 80
 category: Show
 title: Data Visualization
 icon: doc-vchart

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

@@ -1,6 +1,6 @@
 ---
 localeCode: zh-CN
-order: 79
+order: 80
 category: 展示类
 title:  Data Visualization 数据可视化
 icon: doc-vchart

+ 281 - 0
content/show/cropper/index-en-US.md

@@ -0,0 +1,281 @@
+---
+localeCode: en-US
+order: 69
+category: Plus
+title:  Cropper
+icon: doc-cropper
+dir: column
+brief: Freely crop pictures
+showNew: true
+---
+
+## When to use
+
+Cropper is used to crop pictures. It supports custom cropping box styles. The positions of the cropping box, cropped image can be adjusted by dragging. It can zoom and rotate the cropped pictures.
+
+## Demos
+
+Cropper is supported starting from version v2.73.0.
+
+```jsx
+import { Cropper } from '@douyinfe/semi-ui';
+```
+
+### Basic usage
+
+Use `sr` to set the cropped image; use `shape` to set the shape of the cropping box, which defaults to square.
+
+```jsx live=true dir=column noInline=true
+import { Cropper, Button, RadioGroup, Radio } from '@douyinfe/semi-ui';
+
+const containerStyle = {
+  width: 550,
+  height: 300,
+  margin: 20,
+}
+
+function Demo() {
+    const ref = useRef(null);
+  const [shape, setShape] = useState('rect');
+
+    const onButtonClick = useCallback(() => {
+        const value = ref.current.getCropperCanvas();
+        const previewContainer = document.getElementById('previewContainer');
+        previewContainer.innerHTML = '';
+        previewContainer.appendChild(value);
+    }, []);
+
+    const onShapeChange = useCallback((e) => {
+        setShape(e.target.value);
+    }, []);
+
+    return <>
+        <RadioGroup onChange={onShapeChange} value={shape}>
+            <Radio value={'rect'}>rect</Radio>
+            <Radio value={'round'}>round</Radio>
+            <Radio value={'roundRect'}>roundRect</Radio>
+        </RadioGroup>
+        <Cropper
+            ref={ref} 
+            src={'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/image.png'}
+            style={containerStyle}
+            shape={shape}
+        />
+        <Button onClick={onButtonClick}>Get Cropped Image</Button>
+        <div id='previewContainer'/>
+    </>;
+}
+
+render(<Demo />)
+```
+
+### Customize crop box ratio
+
+The initial crop box ratio can be passed through `defaultAspectRatio` (default is 1). A fixed crop box ratio can be set via `aspectRatio`.
+
+Setting `defaultAspectRatio` only takes effect on the initial crop box ratio. When dragging, the crop box ratio will change with dragging.
+
+When setting `aspectRatio`, the crop box ratio is fixed, and the crop box will change according to this ratio when dragging.
+
+```jsx live=true dir=column noInline=true
+import { Cropper, Button, RadioGroup, Radio } from '@douyinfe/semi-ui';
+
+const containerStyle = {
+  width: 550,
+  height: 300,
+  margin: 20,
+}
+
+function Demo() {
+    const ref = useRef(null);
+    const shape = useState('rect');
+
+    const onButtonClick = useCallback(() => {
+        const value = ref.current.getCropperCanvas();
+        const previewContainer = document.getElementById('previewContainer-aspect');
+        previewContainer.innerHTML = '';
+        previewContainer.appendChild(value);
+    }, []);
+
+    return <>
+        <Cropper
+            aspectRatio={3/4}
+            ref={ref} 
+            src={'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/image.png'}
+            style={containerStyle}
+        />
+        <Button onClick={onButtonClick}>Get Cropped Image</Button>
+        <div id='previewContainer-aspect' />
+    </>;
+}
+
+render(<Demo />)
+```
+
+### Controlled rotation/zooming of images
+
+Control image rotation and zoom through `rotate` and `zoom`, and get the latest `zoom` value through `onZoomChange`
+
+```jsx live=true dir=column noInline=true
+import { Cropper, Button, Slider } from '@douyinfe/semi-ui';
+
+const containerStyle = {
+  width: 550,
+  height: 300,
+  margin: 20,
+}
+
+const actionStyle = {
+  marginTop: 20,
+  display: 'flex',
+  alignItems: 'center',
+  justifyContent: 'center',
+  width: 'fit-content'
+}
+
+function Demo() {
+  const [rotate, setRotate] = useState(0);
+  const [zoom, setZoom] = useState(1);
+  const ref = useRef();
+
+  const onZoomChange = useCallback((value) => {
+    setZoom(value);
+  })
+
+  const onSliderChange = useCallback((value) => {
+    setRotate(value);
+  }, []);
+
+  const onButtonClick = useCallback(() => {
+    const value = ref.current.getCropperCanvas();
+    const previewContainer = document.getElementById('previewContainer-control');
+    previewContainer.innerHTML = '';
+    previewContainer.appendChild(value);
+  }, []);
+
+  return (
+      <div id='cropper-container'>
+           <Cropper 
+              ref={ref} 
+              src={'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/image.png'}
+              style={containerStyle}
+              rotate={rotate}
+              zoom={zoom}
+              onZoomChange={onZoomChange}
+           />
+           <div style={actionStyle} >
+            <span>Rotate</span>
+            <Slider
+              style={{ width: 500}}
+              value={rotate}
+              step={1}
+              min={-360}
+              max={360}
+              onChange={onSliderChange}
+            />
+           </div>
+           <div style={actionStyle} >
+            <span>Zoom</span>
+            <Slider
+              style={{ width: 500}}
+              value={zoom}
+              step={0.1}
+              min={0.1}
+              max={3}
+              onChange={onZoomChange}
+            />
+           </div>
+           <br />
+           <Button onClick={onButtonClick}>Get Cropped Image</Button>
+           <br />
+           <div >
+            <div id='previewContainer-control'
+            />
+          </div>
+      </div>
+  );
+};
+
+render(<Demo />)
+```
+
+### Crop box settings
+
+The crop box style can be customized through `cropperBoxStyle`, `cropperBoxClassName`. You can use `showResizeBox` to set whether to display the adjustment blocks at the corners of the crop box.
+
+```jsx live=true dir=column noInline=true
+import { Cropper, Button, Switch } from '@douyinfe/semi-ui';
+
+const containerStyle = {
+  width: 550,
+  height: 300,
+  margin: 20,
+}
+
+const centerStyle = {
+    display: 'flex', 
+    alignItems: 'center', 
+    justifyContent: 'center',
+    width: 'fit-content'
+}
+
+function Demo() {
+    const ref = useRef(null);
+
+    const onButtonClick = useCallback(() => {
+        const value = ref.current.getCropperCanvas();
+        const previewContainer = document.getElementById('previewContainer-cropperBox');
+        previewContainer.innerHTML = '';
+        previewContainer.appendChild(value);
+    }, []);
+
+    return <>
+        <strong>showResizeBox = false,and change the outline color of cropper box</strong>
+        <Cropper
+            ref={ref} 
+            src={'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/image.png'}
+            style={containerStyle}
+            cropperBoxStyle={{ outlineColor: 'var(--semi-color-bg-0)'}}
+            showResizeBox={false}
+        />
+        <Button onClick={onButtonClick}>Get Cropped Image</Button>
+        <div id='previewContainer-cropperBox'/>
+    </>;
+}
+
+render(<Demo />)
+```
+
+### API
+
+| PROPERTIES | INSTRUCTIONS | TYPE | DEFAULT |
+|-----|------|-----|------|
+| aspectRatio | Crop box width to height ratio | number | - |
+| className | className | string | - |
+| cropperBoxClassName | The class name passed to the crop box | string | - |
+| cropperBoxStyle | The style passed to the crop box | CSSProperties | - |
+| defaultAspectRatio | Initial crop box ratio | number | 1 |
+| imgProps | Attributes passed through to the img tag | object | - |
+| fill | The fill color of the non-picture parts in the cropped result | string | 'rgba(0, 0, 0, 0)'  |
+| maxZoom | Maximum zoom factor | number | 3 |
+| minZoom | Minimum zoom factor | number | 0.1 |
+| onZoomChange | Callback during zoom transformation | (zoom: number) => void | - |
+| rotate | rotation angle | number | - |
+| shape | Crop box shape | 'rect' \| 'round' \| 'roundRect' | 'rect' |
+| src | The address of the cropped image | string | - |
+| showResizeBox | Whether to display the adjustment block of the cropping box | boolean | true |
+| style | Style  | CSSProperties | - |
+| zoom | Zoom value | number | - |
+| zoomStep | Zoom step size | number | 0.1 |
+
+### Methods
+
+Methods bound to component instances can be called through ref to achieve certain special interactions
+
+| Name    | Description  |
+|---------|--------------|
+| getCropperCanvas  | Get the canvas of the cropped image |
+
+## Design Token
+
+<DesignToken/>

+ 286 - 0
content/show/cropper/index.md

@@ -0,0 +1,286 @@
+---
+localeCode: zh-CN
+order: 69
+category: Plus
+title:  Cropper 图片裁切
+icon: doc-cropper
+dir: column
+brief: 通过设定裁切框的宽高比例,自由裁切图片
+showNew: true
+---
+
+## 使用场景
+
+Cropper 用于裁切图片,支持自定义裁切框样式,可通过拖动调整裁切框位置,被裁切图片位置;可缩放,旋转被裁切图片。
+
+
+## 代码演示
+
+### 如何引入
+
+Cropper 从 v2.73.0 开始支持
+
+```jsx
+import { Cropper } from '@douyinfe/semi-ui';
+```
+
+### 基本用法
+
+通过 `sr` 设置被裁切的图片; 可通过 `shape` 设置裁切框形状,默认为方形。
+
+```jsx live=true dir=column noInline=true
+import { Cropper, Button, RadioGroup, Radio } from '@douyinfe/semi-ui';
+
+const containerStyle = {
+  width: 550,
+  height: 300,
+  margin: 20,
+}
+
+function Demo() {
+    const ref = useRef(null);
+  const [shape, setShape] = useState('rect');
+
+    const onButtonClick = useCallback(() => {
+        const value = ref.current.getCropperCanvas();
+        const previewContainer = document.getElementById('previewContainer');
+        previewContainer.innerHTML = '';
+        previewContainer.appendChild(value);
+    }, []);
+
+    const onShapeChange = useCallback((e) => {
+        setShape(e.target.value);
+    }, []);
+
+    return <>
+        <RadioGroup onChange={onShapeChange} value={shape}>
+            <Radio value={'rect'}>rect</Radio>
+            <Radio value={'round'}>round</Radio>
+            <Radio value={'roundRect'}>roundRect</Radio>
+        </RadioGroup>
+        <Cropper
+            ref={ref} 
+            src={'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/image.png'}
+            style={containerStyle}
+            shape={shape}
+        />
+        <Button onClick={onButtonClick}>裁切</Button>
+        <div id='previewContainer'/>
+    </>;
+}
+
+render(<Demo />)
+```
+
+### 自定义裁切框比例
+
+可通过 `defaultAspectRatio` 初始的裁切框比例(默认为 1)。可通过 `aspectRatio` 设置固定的裁切框比例。
+
+设置 `defaultAspectRatio`仅对初始的裁切框比例生效, 拖动时,裁切框比例会随着拖动而变化。
+
+设置 `aspectRatio` 时,裁切框比例固定,拖动时将裁切框将以此比例变化。
+
+```jsx live=true dir=column noInline=true
+import { Cropper, Button, RadioGroup, Radio } from '@douyinfe/semi-ui';
+
+const containerStyle = {
+  width: 550,
+  height: 300,
+  margin: 20,
+}
+
+function Demo() {
+    const ref = useRef(null);
+    const shape = useState('rect');
+
+    const onButtonClick = useCallback(() => {
+        const value = ref.current.getCropperCanvas();
+        const previewContainer = document.getElementById('previewContainer-aspect');
+        previewContainer.innerHTML = '';
+        previewContainer.appendChild(value);
+    }, []);
+
+    return <>
+        <Cropper
+            aspectRatio={3/4}
+            ref={ref} 
+            src={'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/image.png'}
+            style={containerStyle}
+        />
+        <Button onClick={onButtonClick}>裁切</Button>
+        <div id='previewContainer-aspect' />
+    </>;
+}
+
+render(<Demo />)
+```
+
+### 受控旋转/缩放图片
+
+通过 `rotate` 和 `zoom` 控制图片旋转和缩放, 可通过 `onZoomChange` 拿到最新的 `zoom` 值。
+
+```jsx live=true dir=column noInline=true
+import { Cropper, Button, Slider } from '@douyinfe/semi-ui';
+
+const containerStyle = {
+  width: 550,
+  height: 300,
+  margin: 20,
+}
+
+const actionStyle = {
+  marginTop: 20,
+  display: 'flex',
+  alignItems: 'center',
+  justifyContent: 'center',
+  width: 'fit-content'
+}
+
+function Demo() {
+  const [rotate, setRotate] = useState(0);
+  const [zoom, setZoom] = useState(1);
+  const ref = useRef();
+
+  const onZoomChange = useCallback((value) => {
+    setZoom(value);
+  })
+
+  const onSliderChange = useCallback((value) => {
+    setRotate(value);
+  }, []);
+
+  const onButtonClick = useCallback(() => {
+    const value = ref.current.getCropperCanvas();
+    const previewContainer = document.getElementById('previewContainer-control');
+    previewContainer.innerHTML = '';
+    previewContainer.appendChild(value);
+  }, []);
+
+  return (
+      <div id='cropper-container'>
+           <Cropper 
+              ref={ref} 
+              src={'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/image.png'}
+              style={containerStyle}
+              rotate={rotate}
+              zoom={zoom}
+              onZoomChange={onZoomChange}
+           />
+           <div style={actionStyle} >
+            <span>Rotate</span>
+            <Slider
+              style={{ width: 500}}
+              value={rotate}
+              step={1}
+              min={-360}
+              max={360}
+              onChange={onSliderChange}
+            />
+           </div>
+           <div style={actionStyle} >
+            <span>Zoom</span>
+            <Slider
+              style={{ width: 500}}
+              value={zoom}
+              step={0.1}
+              min={0.1}
+              max={3}
+              onChange={onZoomChange}
+            />
+           </div>
+           <br />
+           <Button onClick={onButtonClick}>裁切</Button>
+           <br />
+           <div 
+            // style={{ background: 'pink' }} 
+           >
+            <div id='previewContainer-control'
+            />
+          </div>
+      </div>
+  );
+};
+
+render(<Demo />)
+```
+
+### 裁切框设置
+
+可通过 `cropperBoxStyle`, `cropperBoxClassName` 自定义裁切框样式。可通过 `showResizeBox` 设置是否展示裁切框边角的调整块。
+
+```jsx live=true dir=column noInline=true
+import { Cropper, Button, Switch } from '@douyinfe/semi-ui';
+
+const containerStyle = {
+  width: 550,
+  height: 300,
+  margin: 20,
+}
+
+const centerStyle = {
+    display: 'flex', 
+    alignItems: 'center', 
+    justifyContent: 'center',
+    width: 'fit-content'
+}
+
+function Demo() {
+    const ref = useRef(null);
+
+    const onButtonClick = useCallback(() => {
+        const value = ref.current.getCropperCanvas();
+        const previewContainer = document.getElementById('previewContainer-cropperBox');
+        previewContainer.innerHTML = '';
+        previewContainer.appendChild(value);
+    }, []);
+
+    return <>
+        <strong>showResizeBox = false,并修改边框颜色</strong>
+        <Cropper
+            ref={ref} 
+            src={'https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/other/image.png'}
+            style={containerStyle}
+            cropperBoxStyle={{ outlineColor: 'var(--semi-color-bg-0)'}}
+            showResizeBox={false}
+        />
+        <Button onClick={onButtonClick}>裁切</Button>
+        <div id='previewContainer-cropperBox'/>
+    </>;
+}
+
+render(<Demo />)
+```
+
+### API
+
+| 属性 | 说明 | 类型 | 默认值 |
+|-----|------|-----|------|
+| aspectRatio | 裁切框比例 | number | - |
+| className | 类名 | string | - |
+| cropperBoxClassName | 裁切框类名 | string | - |
+| cropperBoxStyle | 裁切框样式 | CSSProperties | - |
+| defaultAspectRatio | 初始裁切框比例 | number | 1 |
+| imgProps | 透传给 img 标签的属性 | object | - |
+| fill | 裁切结果中非图片部分的填充色 | string | 'rgba(0, 0, 0, 0)'  |
+| maxZoom | 最大缩放倍数 | number | 3 |
+| minZoom | 最小缩放倍数 | number | 0.1 |
+| onZoomChange | 缩放回调 | (zoom: number) => void | - |
+| rotate | 旋转角度 | number | - |
+| shape | 裁切框形状 | 'rect' \| 'round' \| 'roundRect' | 'rect' |
+| src | 图片地址 | string | - |
+| showResizeBox | 是否展示调整块 | boolean | true |
+| style | 样式  | CSSProperties | - |
+| zoom | 缩放比例 | number | - |
+| zoomStep | 缩放步长 | number | 0.1 |
+
+### Methods
+
+绑定在组件实例上的方法,可以通过 ref 调用实现某些特殊交互
+
+| Name    | Description  |
+|---------|--------------|
+| getCropperCanvas  | 获取裁剪图片的 canvas |
+
+## 设计变量
+
+<DesignToken/>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -24,7 +24,8 @@ CodeHighlight,
 Markdown,
 Lottie,
 Chat,
-HotKeys
+HotKeys,
+DragMove
 ```
 
 
@@ -79,6 +80,7 @@ Descriptions,
 Dropdown,
 Empty,
 Image,
+Cropper,
 List,
 Modal,
 OverflowList,

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

@@ -25,7 +25,8 @@ CodeHighlight 代码高亮,
 Markdown 渲染器,
 Lottie 动画,
 Chat 聊天,
-HotKeys 快捷键
+HotKeys 快捷键,
+DragMove 拖拽移动
 ```
 
 ## 输入类
@@ -80,6 +81,7 @@ Dropdown 下拉框,
 Empty 空状态,
 Highlight 高亮文本,
 Image 图片,
+Cropper 图片裁切,
 List 列表,
 Modal 模态对话框,
 OverflowList 折叠列表,

+ 26 - 0
packages/semi-foundation/cropper/constants.ts

@@ -0,0 +1,26 @@
+import { BASE_CLASS_PREFIX } from '../base/constants';
+
+const moduleName = `${BASE_CLASS_PREFIX}-cropper`;
+
+const cssClasses = {
+    PREFIX: `${moduleName}`,
+    IMG: `${moduleName}-img`,
+    IMG_WRAPPER: `${moduleName}-img-wrapper`,
+    CROPPER_BOX: `${moduleName}-box`,
+    CROPPER_VIEW_BOX: `${moduleName}-view-box`,
+    CROPPER_VIEW_BOX_ROUND: `${moduleName}-view-box-round`,
+    CROPPER_IMG: `${moduleName}-view-img`,
+    MASK: `${moduleName}-mask`,
+    CORNER: `${moduleName}-box-corner`,
+};
+
+const strings = {
+    shape: ['rect', 'round', 'roundRect'],
+    corner: ['tl', 'tm', 'tr',
+        'ml', 'mr',
+        'bl', 'bm', 'br'],
+    roundCorner: ['tm', 'ml', 'mr', 'bm'],
+
+};
+
+export { cssClasses, strings };

+ 116 - 0
packages/semi-foundation/cropper/cropper.scss

@@ -0,0 +1,116 @@
+@import "./variables.scss";
+$module: #{$prefix}-cropper;
+
+$half_corner_width: calc($width-cropper_box_corner / 2);
+
+.#{$module} {
+    position: relative;
+
+    &-img {
+      position: absolute;
+      user-select: none;
+    }
+
+    &-img-wrapper {
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      overflow: hidden;
+    }
+
+    &-mask {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      background: $color-cropper_mask-bg;
+      cursor: move;;
+    }
+
+    &-box {
+      position: absolute;
+      outline: $width-cropper_box-outline solid $color-cropper_box-outline;
+
+      &-corner {
+        position: absolute;
+        background: $color-cropper_box_corner-bg;
+        width: $width-cropper_box_corner;
+        height: $width-cropper_box_corner;
+        z-index: 1;
+
+        &-tl {
+          top: -$half_corner_width;
+          left: -$half_corner_width;
+          cursor: nwse-resize;
+        }
+
+        &-tr {
+          top: -$half_corner_width;
+          right: -$half_corner_width;
+          cursor: nesw-resize;
+        }
+
+        &-tm {
+          top: -$half_corner_width;
+          left: 50%;
+          margin-left: -$half_corner_width;
+          cursor: ns-resize;
+        }
+
+        &-ml {
+          top: 50%;
+          left: -$half_corner_width;
+          margin-top: -$half_corner_width;
+          cursor: ew-resize;
+        }
+
+        &-mr {
+          top: 50%;
+          right: -$half_corner_width;
+          margin-top: -$half_corner_width;
+          cursor: ew-resize;
+        }
+
+        &-bl {
+          bottom: -$half_corner_width;
+          left: -$half_corner_width;
+          cursor: nesw-resize;
+        }
+
+        &-bm {
+          bottom: -$half_corner_width;
+          left: 50%;
+          margin-left: -$half_corner_width; 
+          cursor: ns-resize;
+        }
+
+        &-br {
+          bottom: -$half_corner_width;
+          right: -$half_corner_width;
+          cursor: nwse-resize
+        }
+      }
+    }
+
+    &-view-box {
+      position: absolute;
+      overflow: hidden;
+      cursor: move;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+
+      &-round {
+        border-radius: 50%;
+      }
+    }
+
+    &-view-img {
+      // position: absolute;
+      user-select: none;
+    }
+}

+ 821 - 0
packages/semi-foundation/cropper/foundation.ts

@@ -0,0 +1,821 @@
+import BaseFoundation, { DefaultAdapter } from "../base/foundation";
+import { getMiddle, getAspectHW } from "./utils";
+
+export interface CropperAdapter<P = Record<string, any>, S = Record<string, any>> extends DefaultAdapter<P, S> {
+    getContainer: () => HTMLElement;
+    notifyZoomChange: (zoom: number) => void;
+    getImg: () => HTMLImageElement
+}
+
+interface Point {
+    x: number;
+    y: number
+}
+
+export interface ImageData {
+    originalWidth: number;
+    originalHeight: number;
+    scale: number
+}
+
+export interface ImageDataState {
+    width: number;
+    height: number;
+    centerPoint: Point
+}
+
+export interface CropperBox {
+    width: number;
+    height: number;
+    centerPoint: Point
+}
+
+export interface ContainerData {
+    width: number;
+    height: number
+}
+
+export interface CropperBoxBorder {
+    borderTop: number;
+    borderLeft: number
+}
+
+export default class CropperFoundation <P = Record<string, any>, S = Record<string, any>> extends BaseFoundation<CropperAdapter<P, S>, P, S> {
+    imgData: ImageData;
+    containerData: ContainerData;
+    boxMoveDir: string;
+    cropperBoxMoveStart: Point;
+    imgMoveStart: Point;
+    moveRange: {
+        xMax: number;
+        xMin: number;
+        yMax: number;
+        yMin: number
+    };
+    boxMoveParam: {
+        paramX: number;
+        paramY: number
+    }
+    cropperBox: CropperBoxBorder;
+    rangeX: [number, number];
+    rangeY: [number, number];
+    initial: boolean;
+    
+    constructor(adapter: CropperAdapter<P, S>) {
+        super({ ...adapter });
+
+        this.containerData = {} as ContainerData;
+        this.imgData = {} as ImageData;
+        this.boxMoveDir = '';
+        this.boxMoveParam = {
+            paramX: 0,
+            paramY: 0,
+        };
+        this.rangeX = null;
+        this.rangeY = null;
+        this.initial = false;
+    }
+
+    init() {
+        // 获取容器的宽高
+        // get cropping Container 's width & height
+        const container = this._adapter.getContainer();
+        this.containerData.width = container.clientWidth;
+        this.containerData.height = container.clientHeight;
+        this.cropperBoxMoveStart = null;
+    }
+
+    destroy() {
+        this.unBindMoveEvent();
+        this.unBindResizeEvent();
+    }
+
+    getImgDataWhenResize = (ratio: number) => {
+        const { imgData } = this.getStates();
+        const newImgData = {
+            width: imgData.width * ratio,
+            height: imgData.height * ratio,
+            centerPoint: {
+                x: imgData.centerPoint.x * ratio,
+                y: imgData.centerPoint.y * ratio,
+            }
+        };
+        this.imgData.scale *= ratio;
+        return newImgData;
+    }
+
+    getCropperBoxWhenResize = (ratio: number, newContainerData: ContainerData) => {
+        const { cropperBox } = this.getStates();
+        const { aspectRatio } = this.getProps();
+        const tempCropperBox = {
+            width: cropperBox.width * ratio,
+            height: cropperBox.height * ratio,
+            centerPoint: {
+                x: cropperBox.centerPoint.x * ratio,
+                y: cropperBox.centerPoint.y * ratio,
+            }
+        };
+        let xMin = tempCropperBox.centerPoint.x - tempCropperBox.width / 2;
+        let xMax = tempCropperBox.centerPoint.x + tempCropperBox.width / 2;
+        let yMin = tempCropperBox.centerPoint.y - tempCropperBox.height / 2;
+        let yMax = tempCropperBox.centerPoint.y + tempCropperBox.height / 2;
+        if (aspectRatio) {
+            if (xMax > newContainerData.width) {
+                xMax = newContainerData.width;
+                xMin = tempCropperBox.width > newContainerData.width ? 
+                    0 : newContainerData.width - tempCropperBox.width;
+                tempCropperBox.width = xMax - xMin;
+                tempCropperBox.height = tempCropperBox.width / aspectRatio;
+                yMax = yMin + tempCropperBox.height;
+            }
+            if (yMax > newContainerData.height) {
+                yMax = newContainerData.height;
+                yMin = tempCropperBox.height > newContainerData.height ? 
+                    0 : newContainerData.height - tempCropperBox.height;
+                tempCropperBox.height = yMax - yMin;
+                tempCropperBox.width = tempCropperBox.height * aspectRatio;
+                xMax = xMin + tempCropperBox.width;
+            }
+        } else {
+            if (xMax > newContainerData.width) {
+                xMax = newContainerData.width;
+                xMin = tempCropperBox.width > newContainerData.width ? 
+                    0 : newContainerData.width - tempCropperBox.width;
+            }
+            if (yMax > newContainerData.height) {
+                yMax = newContainerData.height;
+                yMin = tempCropperBox.height > newContainerData.height ? 
+                    0 : newContainerData.height - tempCropperBox.height;
+            }
+        }
+        return {
+            width: xMax - xMin,
+            height: yMax - yMin,
+            centerPoint: {
+                x: (xMax + xMin) / 2,
+                y: (yMax + yMin) / 2,
+            }
+        };
+    }
+
+
+    handleResize = () => {
+        const { loaded } = this.getStates();
+        if (!this.initial) {
+            this.initial = true;
+            return;
+        }
+        if (!loaded) {
+            return;
+        }
+        const container = this._adapter.getContainer();
+        const newContainerData = {
+            width: container.clientWidth,
+            height: container.clientHeight,
+        };
+        const ratio = newContainerData.width / this.containerData.width;
+        const newImgData = this.getImgDataWhenResize(ratio);
+        const newCropperBox = this.getCropperBoxWhenResize(ratio, newContainerData);
+       
+        this.containerData = newContainerData;
+        this.setState({
+            imgData: newImgData,
+            cropperBox: newCropperBox,
+        } as any);
+    }
+
+    handleImageLoad = (e: any) => {
+        /**
+         * 1. 图片加载完成后,获得图片的原始大小
+         * 2. 计算图片的缩放比例,中心点位置
+         */
+        const { naturalWidth, naturalHeight } = e.target;
+        const { width: containerWidth, height: containerHeight } = this.containerData;
+        this.imgData.originalWidth = naturalWidth;
+        this.imgData.originalHeight = naturalHeight;
+        let scale = 1;
+        const newImgDataState = {} as ImageDataState;
+        /* 计算图片加载后的初始显示尺寸 */
+        if (naturalWidth / containerWidth > naturalHeight / containerHeight) {
+            scale = containerWidth / naturalWidth;
+            newImgDataState.width = containerWidth;
+            newImgDataState.height = naturalHeight * scale; 
+        } else {
+            scale = containerHeight / naturalHeight;
+            newImgDataState.width = naturalWidth * scale;
+            newImgDataState.height = containerHeight;
+        }
+        this.imgData.scale = scale;
+        newImgDataState.centerPoint = {} as Point;
+        newImgDataState.centerPoint.x = containerWidth / 2;
+        newImgDataState.centerPoint.y = containerHeight / 2;
+        /* 计算裁切框大小 */
+        const newCropperBoxState = {} as CropperBox;
+        const { defaultAspectRatio, aspectRatio } = this.getProps();
+        const calcAspect = aspectRatio || defaultAspectRatio;
+        if (containerWidth / containerHeight > calcAspect) {
+            newCropperBoxState.width = containerHeight * calcAspect;
+            newCropperBoxState.height = containerHeight;
+        } else {
+            newCropperBoxState.width = containerWidth;
+            newCropperBoxState.height = containerWidth / calcAspect;
+        }
+        newCropperBoxState.centerPoint = {} as Point;
+        newCropperBoxState.centerPoint.x = containerWidth / 2;
+        newCropperBoxState.centerPoint.y = containerHeight / 2;
+        this.setState({
+            imgData: newImgDataState,
+            cropperBox: newCropperBoxState,
+            loaded: true,
+        } as any);
+    }
+
+    handleWheel = (e: any) => {
+        // 防止双手缩放导致页面被放大
+        e.preventDefault();
+        const { imgData, zoom: currZoom } = this.getStates();
+        const { maxZoom, minZoom, zoomStep } = this.getProps();
+
+        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 (_zoom === undefined) {
+            return;
+        }
+        const boundingRect = e.currentTarget.getBoundingClientRect();
+        const offsetX = e.clientX - boundingRect.left;
+        const offsetY = e.clientY - boundingRect.top;
+        const scaleCenter = {
+            x: offsetX,
+            y: - offsetY,
+        };
+        
+        // 计算新的中心点位置
+        const currentPoint = { ...imgData.centerPoint } as Point;
+        currentPoint.y = - currentPoint.y;
+
+        const newCenterPoint = {
+            x: (currentPoint.x - scaleCenter.x) / currZoom * _zoom + scaleCenter.x,
+            y: - [(currentPoint.y - scaleCenter.y) / currZoom * _zoom + scaleCenter.y],
+        };
+
+        const newWidth = imgData.width / currZoom * _zoom;
+        const newHeight = imgData.height / currZoom * _zoom;
+       
+        const newImgDataState = {
+            width: newWidth,
+            height: newHeight,
+            centerPoint: newCenterPoint
+        };
+        this.setState({
+            imgData: newImgDataState,
+            zoom: _zoom
+        } as any);
+
+        this._adapter.notifyZoomChange(_zoom);
+    }
+
+    getMoveParamByDir(dir: string) {
+        let paramX = 0, paramY = 0;
+        switch (dir) {
+            case 'tl':
+                paramX = -1; paramY = -1; break;
+            case 'tm':
+                paramY = -1; break;
+            case 'tr':
+                paramX = 1; paramY = -1; break;
+            case 'ml':
+                paramX = -1; break;
+            case 'mr':
+                paramX = 1; break;
+            case 'bl':
+                paramX = -1; paramY = 1; break;
+            case 'bm':
+                paramY = 1; break;
+            case 'br':
+                paramX = 1; paramY = 1; break;
+            default:
+                break; 
+        }
+        return {
+            paramX,
+            paramY
+        };
+    }
+
+    getRangeForAspectChange = () => {
+        const { cropperBox } = this.getStates();
+        const { aspectRatio } = this.getProps();
+        const { width: containerWidth, height: containerHeight } = this.containerData;
+        // 可能的最大宽高
+        let height: number, width: number;
+        // 裁剪框当前的位置
+        const xMin = cropperBox.centerPoint.x - cropperBox.width / 2;
+        const xMax = cropperBox.centerPoint.x + cropperBox.width / 2;
+        const yMin = cropperBox.centerPoint.y - cropperBox.height / 2;
+        const yMax = cropperBox.centerPoint.y + cropperBox.height / 2;
+        switch (this.boxMoveDir) {
+            case 'tl':
+                height = yMax;
+                width = xMax;
+                [width, height] = getAspectHW(width, height, aspectRatio);
+                this.rangeX = [xMax - width, xMax];
+                this.rangeY = [yMax - height, yMax];
+                break;
+            case 'tm':
+                height = yMax;
+                const leftHalfWidth = cropperBox.centerPoint.x;
+                const rightHalfWidth = containerWidth - cropperBox.centerPoint.x;
+                width = 2 * (leftHalfWidth < rightHalfWidth ? leftHalfWidth : rightHalfWidth);
+                [width, height] = getAspectHW(width, height, aspectRatio);
+                this.rangeX = [
+                    cropperBox.centerPoint.x - width / 2, 
+                    cropperBox.centerPoint.x + width / 2
+                ];
+                this.rangeY = [yMax - height, yMax];
+                break;
+            case 'tr':
+                height = yMax;
+                width = containerWidth - xMin;
+                [width, height] = getAspectHW(width, height, aspectRatio);
+                this.rangeX = [xMin, xMin + width];
+                this.rangeY = [yMax - height, yMax];
+                break;
+            case 'ml':
+                width = xMax;
+                const topHalfHeight = cropperBox.centerPoint.y;
+                const bottomHalfHeight = containerHeight - cropperBox.centerPoint.y;
+                height = 2 * (topHalfHeight < bottomHalfHeight ? topHalfHeight : bottomHalfHeight);
+                [width, height] = getAspectHW(width, height, aspectRatio);
+                this.rangeX = [xMax - width, xMax];
+                this.rangeY = [
+                    cropperBox.centerPoint.y - height / 2, 
+                    cropperBox.centerPoint.y + height / 2
+                ];
+                break;
+            case 'mr':
+                width = containerWidth - xMin;
+                const topHalfHeight2 = cropperBox.centerPoint.y;
+                const bottomHalfHeight2 = containerHeight - cropperBox.centerPoint.y;
+                height = 2 * (topHalfHeight2 < bottomHalfHeight2 ? topHalfHeight2 : bottomHalfHeight2);
+                [width, height] = getAspectHW(width, height, aspectRatio);
+                this.rangeX = [xMin, xMin + width];
+                this.rangeY = [
+                    cropperBox.centerPoint.y - height / 2,
+                    cropperBox.centerPoint.y + height / 2
+                ];
+                break;
+            case 'bl':
+                height = containerHeight - yMin;
+                width = xMax;
+                [width, height] = getAspectHW(width, height, aspectRatio);
+                this.rangeX = [xMax - width, xMax];
+                this.rangeY = [yMin, yMin + height];
+                break;
+            case 'bm':
+                height = containerHeight - yMin;
+                const leftHalfWidth2 = cropperBox.centerPoint.x;
+                const rightHalfWidth2 = containerWidth - cropperBox.centerPoint.x;
+                width = 2 * (leftHalfWidth2 < rightHalfWidth2 ? leftHalfWidth2 : rightHalfWidth2);
+                [width, height] = getAspectHW(width, height, aspectRatio); 
+                this.rangeX = [
+                    cropperBox.centerPoint.x - width / 2,
+                    cropperBox.centerPoint.x + width / 2,
+                ];
+                this.rangeY = [yMin, yMin + height]; 
+                break;
+            case 'br':
+                height = containerHeight - yMin;
+                width = containerWidth - xMin;
+                [width, height] = getAspectHW(width, height, aspectRatio);
+                this.rangeX = [xMin, xMin + width];
+                this.rangeY = [yMin, yMin + height];
+                break;
+            default:
+                break;
+        }
+    }
+
+    handleCornerMouseDown = (e: any) => {
+        const currentTarget = e.currentTarget;
+        if (!currentTarget) {
+            return;
+        }
+        e.preventDefault();
+        const dir = currentTarget.dataset.dir;
+        this.boxMoveDir = dir;
+        this.boxMoveParam = this.getMoveParamByDir(dir);
+        this.bindResizeEvent();
+        const { aspectRatio } = this.getProps();
+        if (aspectRatio) {
+            this.getRangeForAspectChange();
+        } else {
+            this.rangeX = [0, this.containerData.width];
+            this.rangeY = [0, this.containerData.height];
+        }
+        
+    }
+
+    bindResizeEvent = () => {
+        const { aspectRatio } = this.getProps();
+        document.addEventListener('mousemove', aspectRatio ? this.handleCornerAspectMouseMove : this.handleCornerMouseMove);
+        document.addEventListener('mouseup', this.handleCornerMouseUp);
+    }
+
+    unBindResizeEvent = () => {
+        const { aspectRatio } = this.getProps();
+        document.removeEventListener('mousemove', aspectRatio ? this.handleCornerAspectMouseMove : this.handleCornerMouseMove);
+        document.removeEventListener('mouseup', this.handleCornerMouseUp);
+    }
+
+    viewIMGDragStart = (e: any) => {
+        e.preventDefault();
+    }
+
+    handleCornerAspectMouseMove = (e: any) => {
+        e.preventDefault();
+        const { clientX, clientY } = e;
+        const { cropperBox } = this.getStates();
+        const { aspectRatio } = this.getProps();
+        const boundingRect = this._adapter.getContainer().getBoundingClientRect();
+        const newCropperBoxPos = {
+            width: cropperBox.width,
+            height: cropperBox.height,
+            centerPoint: { ...cropperBox.centerPoint }
+        };
+        let offsetX: number, offsetY: number;
+        if (['ml', 'mr'].includes(this.boxMoveDir)) {
+            offsetX = getMiddle(clientX - boundingRect.left, this.rangeX);
+        } else {
+            offsetY = getMiddle(clientY - boundingRect.top, this.rangeY);
+        }
+        switch (this.boxMoveDir) {
+            case 'tl':
+                newCropperBoxPos.height = this.rangeY[1] - offsetY;
+                newCropperBoxPos.width = newCropperBoxPos.height * aspectRatio;
+                newCropperBoxPos.centerPoint = {
+                    x: this.rangeX[1] - newCropperBoxPos.width / 2,
+                    y: this.rangeY[1] - newCropperBoxPos.height / 2,
+                };
+                break;
+            case 'tm':
+                newCropperBoxPos.height = this.rangeY[1] - offsetY;
+                newCropperBoxPos.width = newCropperBoxPos.height * aspectRatio;
+                newCropperBoxPos.centerPoint = {
+                    x: cropperBox.centerPoint.x,
+                    y: this.rangeY[1] - newCropperBoxPos.height / 2,
+                };
+                break;
+            case 'tr':
+                newCropperBoxPos.height = this.rangeY[1] - offsetY;
+                newCropperBoxPos.width = newCropperBoxPos.height * aspectRatio;
+                newCropperBoxPos.centerPoint = {
+                    x: this.rangeX[0] + newCropperBoxPos.width / 2,
+                    y: this.rangeY[1] - newCropperBoxPos.height / 2,
+                };
+                break;
+            case 'ml':
+                newCropperBoxPos.width = this.rangeX[1] - offsetX;
+                newCropperBoxPos.height = newCropperBoxPos.width / aspectRatio;
+                newCropperBoxPos.centerPoint = {
+                    x: this.rangeX[1] - newCropperBoxPos.width / 2,
+                    y: cropperBox.centerPoint.y,
+                };
+                break;
+            case 'mr':
+                newCropperBoxPos.width = offsetX - this.rangeX[0];
+                newCropperBoxPos.height = newCropperBoxPos.width / aspectRatio;
+                newCropperBoxPos.centerPoint = {
+                    x: this.rangeX[0] + newCropperBoxPos.width / 2,
+                    y: cropperBox.centerPoint.y,
+                };
+                break;
+            case 'bl':
+                newCropperBoxPos.height = offsetY - this.rangeY[0];
+                newCropperBoxPos.width = newCropperBoxPos.height * aspectRatio;
+                newCropperBoxPos.centerPoint = {
+                    x: this.rangeX[1] - newCropperBoxPos.width / 2,
+                    y: this.rangeY[0] + newCropperBoxPos.height / 2,
+                };
+                break;
+            case 'bm':
+                newCropperBoxPos.height = offsetY - this.rangeY[0];
+                newCropperBoxPos.width = newCropperBoxPos.height * aspectRatio;
+                newCropperBoxPos.centerPoint = {
+                    x: cropperBox.centerPoint.x,
+                    y: this.rangeY[0] + newCropperBoxPos.height / 2,
+                };
+                break;
+            case 'br':
+                newCropperBoxPos.height = offsetY - this.rangeY[0];
+                newCropperBoxPos.width = newCropperBoxPos.height * aspectRatio;
+                newCropperBoxPos.centerPoint = {
+                    x: this.rangeX[0] + newCropperBoxPos.width / 2,
+                    y: this.rangeY[0] + newCropperBoxPos.height / 2,
+                };
+                break;
+            default:
+                break;
+        }
+        if (newCropperBoxPos.height === 0 && newCropperBoxPos.width === 0) {
+            this.changeDir();
+            this.getRangeForAspectChange();
+        } 
+        this.setState({
+            cropperBox: newCropperBoxPos
+        } as any);
+    }
+
+    changeDir = () => {
+        if (this.boxMoveDir.includes('t')) {
+            this.boxMoveDir = this.boxMoveDir.replace('t', 'b');
+        } else if (this.boxMoveDir.includes('b')) {
+            this.boxMoveDir = this.boxMoveDir.replace('b', 't');
+        }
+        if (this.boxMoveDir.includes('l')) {
+            this.boxMoveDir = this.boxMoveDir.replace('l', 'r');
+        } else if (this.boxMoveDir.includes('r')) {
+            this.boxMoveDir = this.boxMoveDir.replace('r', 'l');
+        }
+    }
+
+    handleCornerMouseMove = (e: any) => {
+        e.preventDefault();
+        const { clientX, clientY } = e;
+        const { cropperBox } = this.getStates();
+        const boundingRect = this._adapter.getContainer().getBoundingClientRect();
+        let offsetX = getMiddle(clientX - boundingRect.left, this.rangeX);
+        let offsetY = getMiddle(clientY - boundingRect.top, this.rangeY);
+        const newCropperBoxPos = {
+            width: cropperBox.width,
+            height: cropperBox.height,
+            centerPoint: {
+                x: cropperBox.centerPoint.x,
+                y: cropperBox.centerPoint.y
+            }
+        };
+        const { paramX, paramY } = this.boxMoveParam;
+        let x: number, y: number;
+        if (paramX) {
+            x = cropperBox.centerPoint.x + paramX * cropperBox.width / 2;
+            newCropperBoxPos.width = cropperBox.width + paramX * (offsetX - x);
+            if (newCropperBoxPos.width < 0) {
+                newCropperBoxPos.width = - newCropperBoxPos.width;
+                this.boxMoveParam.paramX = -paramX;
+            }
+            newCropperBoxPos.centerPoint.x = offsetX - paramX * newCropperBoxPos.width / 2;
+        }
+        if (paramY) {
+            y = cropperBox.centerPoint.y + paramY * cropperBox.height / 2;
+            newCropperBoxPos.height = cropperBox.height + paramY * (offsetY - y);
+            if (newCropperBoxPos.height < 0) {
+                newCropperBoxPos.height = -newCropperBoxPos.height;
+                this.boxMoveParam.paramY = -paramY;
+            }
+            newCropperBoxPos.centerPoint.y = offsetY - paramY * newCropperBoxPos.height / 2;
+        }
+        
+        this.setState({
+            cropperBox: newCropperBoxPos
+        } as any);
+    }
+
+    handleCornerMouseUp = (e: any) => {
+        this.boxMoveParam = { paramX: 0, paramY: 0 };
+        this.unBindResizeEvent();
+    }
+
+    handleCropperBoxMouseDown = (e: any) => {
+        const target = e.target;
+        const { cropperBox } = this.getStates();
+        const container = this._adapter.getContainer();
+        const boundingRect = container.getBoundingClientRect();
+        if (target.dataset.dir) {
+            // 如果鼠标是落在了corner上,那么不做任何操作
+            return;
+        }
+        // 移动裁切框
+        this.cropperBoxMoveStart = {
+            x: e.clientX,
+            y: e.clientY
+        };
+        this.bindMoveEvent();
+        // 计算 cropperBox 中心点移动范围
+        this.moveRange = {
+            xMin: cropperBox.width / 2,
+            xMax: boundingRect.width - cropperBox.width / 2,
+            yMin: cropperBox.height / 2,
+            yMax: boundingRect.height - cropperBox.height / 2,
+        };
+    }
+
+    bindMoveEvent = () => {
+        document.addEventListener('mousemove', this.handleCropperBoxMouseMove);
+        document.addEventListener('mouseup', this.handleCropperBoxMouseUp);
+    }
+
+    unBindMoveEvent = () => {
+        document.removeEventListener('mousemove', this.handleCropperBoxMouseMove);
+        document.removeEventListener('mouseup', this.handleCropperBoxMouseUp);
+    }
+
+    handleCropperBoxMouseMove = (e: any) => {
+        if (!this.cropperBoxMoveStart) {
+            return;
+        }
+        const { clientX, clientY } = e;
+        const { cropperBox } = this.getStates();
+        const offsetX = clientX - this.cropperBoxMoveStart.x;
+        const offsetY = clientY - this.cropperBoxMoveStart.y;
+        const newCenterPointX = getMiddle(cropperBox.centerPoint.x + offsetX, [this.moveRange.xMin, this.moveRange.xMax]);
+        const newCenterPointY = getMiddle(cropperBox.centerPoint.y + offsetY, [this.moveRange.yMin, this.moveRange.yMax]);
+        const newCropperBoxPos = {
+            width: cropperBox.width,
+            height: cropperBox.height,
+            centerPoint: {
+                x: newCenterPointX,
+                y: newCenterPointY
+            }
+        };
+        this.cropperBoxMoveStart = {
+            x: clientX,
+            y: clientY
+        };
+        this.setState({
+            cropperBox: newCropperBoxPos
+        } as any);
+    }
+
+    handleCropperBoxMouseUp = (e: any) => {
+        if (!this.cropperBoxMoveStart) {
+            return;
+        }
+        this.cropperBoxMoveStart = null;
+        this.unBindMoveEvent();
+    }
+
+    handleMaskMouseDown = (e: any) => {
+        if (e.currentTarget !== e.target) {
+            return;
+        }
+        this.bindImgMoveEvent();
+        // 记录开始移动的位置
+        this.imgMoveStart = {
+            x: e.clientX,
+            y: e.clientY
+        };
+    }
+
+    bindImgMoveEvent = () => {
+        document.addEventListener('mousemove', this.handleImgMove);
+        document.addEventListener('mouseup', this.handleImgMoveUp);
+    }
+
+    unBindImgMoveEvent = () => {
+        document.removeEventListener('mousemove', this.handleImgMove);
+        document.removeEventListener('mouseup', this.handleImgMoveUp);
+    }
+
+    handleImgMove = (e: any) => {
+        if (!this.imgMoveStart) {
+            return;
+        }
+        const { clientX, clientY } = e;
+        const { imgData } = this.getStates();
+        const offsetX = clientX - this.imgMoveStart.x;
+        const offsetY = clientY - this.imgMoveStart.y;
+        const newCenterPointX = imgData.centerPoint.x + offsetX;
+        const newCenterPointY = imgData.centerPoint.y + offsetY;
+        const newImgData = {
+            width: imgData.width,
+            height: imgData.height,
+            centerPoint: {
+                x: newCenterPointX,
+                y: newCenterPointY
+            }
+        };
+        this.imgMoveStart = {
+            x: clientX,
+            y: clientY
+        };
+        this.setState({
+            imgData: newImgData
+        } as any);
+    }
+
+    handleImgMoveUp = (e: any) => {
+        if (!this.imgMoveStart) {
+            return;
+        }
+        this.imgMoveStart = null;
+        this.unBindImgMoveEvent();
+    }
+
+    getCropperCanvas = () => {
+        const { cropperBox, imgData, rotate, zoom } = this.getStates();
+        const { fill } = this.getProps();
+        const canvas = document.createElement('canvas');
+        const ctx = canvas.getContext('2d');
+        const img = this._adapter.getImg();
+
+        // 计算包含旋转后的图片的矩形容器的宽高
+        const angle = rotate * Math.PI / 180;
+        const sine = Math.abs(Math.sin(angle));
+        const cosine = Math.abs(Math.cos(angle));
+        const imgWidth = this.imgData.originalWidth;
+        const imgHeight = this.imgData.originalHeight;
+        const containerWidth = imgWidth * cosine + imgHeight * sine;
+        const containerHeight = imgHeight * cosine + imgWidth * sine;
+
+        // 判断裁切区域和外接矩形是否存在交集,如果不存在,则直接返回空白图片
+        // 计算需要裁剪的区域实际大小和位置
+        const cropperContainerWidth = containerWidth * zoom * this.imgData.scale;
+        const cropperContainerHeight = containerHeight * zoom * this.imgData.scale;
+        const cropperContainerTop = imgData.centerPoint.y - cropperContainerHeight / 2;
+        const cropperContainerLeft = imgData.centerPoint.x - cropperContainerWidth / 2;
+        const cropperBoxLeft = cropperBox.centerPoint.x - cropperBox.width / 2;
+        const cropperBoxTop = cropperBox.centerPoint.y - cropperBox.height / 2;
+        const realZoom = zoom * this.imgData.scale;
+        
+        const relativeCropLeft = (cropperBoxLeft - cropperContainerLeft) / realZoom;
+        const relativeCropTop = (cropperBoxTop - cropperContainerTop) / realZoom;
+        const relativeWidth = cropperBox.width / realZoom;
+        const relativeHeight = cropperBox.height / realZoom;
+        const relativeCropRight = relativeCropLeft + relativeWidth;
+        const relativeCropBottom = relativeCropTop + relativeHeight;
+
+        if (relativeCropRight < 0 || relativeCropBottom < 0 || relativeCropLeft > containerWidth || relativeCropTop > containerHeight) {
+            // 没有交集,直接返回空白图片
+            const emptyCanvas = document.createElement('canvas');
+            const ctx = emptyCanvas.getContext('2d');
+            emptyCanvas.width = relativeWidth;
+            emptyCanvas.height = relativeHeight;
+            ctx.fillStyle = fill;
+            ctx.fillRect(0, 0, relativeWidth, relativeHeight);
+            return emptyCanvas;
+        }
+
+        canvas.width = containerWidth;
+        canvas.height = containerHeight;
+        ctx.fillStyle = fill;
+        ctx.fillRect(0, 0, containerWidth, containerHeight);
+
+        const halfWidth = containerWidth / 2;
+        const halfHeight = containerHeight / 2;
+        ctx.translate(halfWidth, halfHeight);
+        ctx.rotate(rotate * Math.PI / 180);
+        ctx.translate(-halfWidth, -halfHeight);
+
+        const imgX = (containerWidth - imgWidth) / 2;
+        const imgY = (containerHeight - imgHeight) / 2;
+        ctx.drawImage(img, 0, 0, imgWidth, imgHeight, imgX, imgY, imgWidth, imgHeight);
+
+        const canvas2 = document.createElement('canvas');
+        const ctx2 = canvas2.getContext('2d');
+        // 为了避免裁剪时候,超出被裁切的画布的部分颜色不正常,需要将裁切区域限制在画布范围内。
+        // 相对位置会在后续进行修正
+        let realLeft = relativeCropLeft;
+        let realTop = relativeCropTop;
+        let realWidth = relativeWidth;
+        let realHeight = relativeHeight;
+       
+        if (relativeCropLeft < 0) {
+            realLeft = 0;
+        }
+        if (relativeCropTop < 0) {
+            realTop = 0;
+        }
+        if (relativeCropRight > containerWidth) {
+            realWidth = containerWidth - realLeft;
+        } else if (relativeCropLeft < 0) {
+            realWidth = relativeCropRight;
+        }
+
+        if (relativeCropBottom > containerHeight) {
+            realHeight = containerHeight - realTop;
+        } else if (relativeCropTop < 0) {
+            realHeight = relativeCropBottom;
+        }
+
+        const imgDataResult = ctx.getImageData(realLeft, realTop, realWidth, realHeight);
+        canvas2.width = relativeWidth;
+        canvas2.height = relativeHeight;
+        ctx2.fillStyle = fill;
+        ctx2.fillRect(0, 0, relativeWidth, relativeHeight);
+        ctx2.putImageData(
+            imgDataResult, 
+            relativeCropLeft < 0 ? - relativeCropLeft : 0,  
+            relativeCropTop < 0 ? - relativeCropTop : 0,
+        );
+        return canvas2;
+    }
+}

+ 12 - 0
packages/semi-foundation/cropper/utils.ts

@@ -0,0 +1,12 @@
+export function getMiddle(value: number, [min, max]) {
+    return Math.min(Math.max(value, min), max);
+}
+
+export function getAspectHW(width: number, height: number, aspect: number) {
+    if (width / height > aspect) {
+        width = height * aspect;
+    } else {
+        height = width / aspect;
+    }
+    return [width, height];
+}

+ 6 - 0
packages/semi-foundation/cropper/variables.scss

@@ -0,0 +1,6 @@
+$color-cropper_mask-bg: var(--semi-color-overlay-bg); // 裁切框遮罩背景颜色
+$color-cropper_box-outline: var(--semi-color-primary); // 裁切框边框颜色
+$color-cropper_box_corner-bg: var(--semi-color-primary); // 裁切框调整块背景色
+
+$width-cropper_box-outline: 1px; // 裁切框边框宽度
+$width-cropper_box_corner: 10px; // 裁切框调整块宽高

+ 320 - 0
packages/semi-ui/cropper/_story/cropper.stories.jsx

@@ -0,0 +1,320 @@
+import React, { useCallback, useState, useRef, useMemo } from 'react';
+import Cropper from '../index';
+import { Slider, Button, RadioGroup, Radio } from '@douyinfe/semi-ui';
+
+export default {
+  title: 'Cropper',
+}
+
+const containerStyle = {
+  width: 600,
+  height: 500,
+  margin: '50px 0 0 50px',
+}
+
+const containerStyleX = {
+  width: 550,
+  height: 300,
+  margin: '50px 0 0 50px',
+}
+
+const actionStyle = {
+  marginTop: 20,
+  display: 'flex',
+  alignItems: 'center',
+  justifyContent: 'center',
+  width: 'fit-content'
+}
+
+export const Basic = () => {
+  const [rotate, setRotate] = useState(0);
+  const [zoom, setZoom] = useState(1);
+  const ref = useRef();
+
+  const onZoomChange = useCallback((value) => {
+    setZoom(value);
+  })
+
+  const onSliderChange = useCallback((value) => {
+    setRotate(value);
+  }, []);
+
+  const onButtonClick = useCallback(() => {
+    const value = ref.current.getCropperCanvas();
+    const previewContainer = document.getElementById('previewContainer');
+    previewContainer.innerHTML = '';
+    previewContainer.appendChild(value);
+  }, []);
+
+  return (
+      <div id='cropper-container'>
+           <Cropper 
+              ref={ref} 
+              src={"https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/abstract.jpg"}
+              // src={'https://lf3-static.bytednsdoc.com/obj/eden-cn/9130eh7pltbfnuhog/16.jpeg'}
+              style={containerStyle}
+              rotate={rotate}
+              zoom={zoom}
+              onZoomChange={onZoomChange}
+           />
+           <div style={actionStyle} >
+            <span>Rotate</span>
+            <Slider
+              style={{ width: 720}}
+              value={rotate}
+              step={1}
+              min={-360}
+              max={360}
+              onChange={onSliderChange}
+            />
+           </div>
+           <div style={actionStyle} >
+            <span>Zoom</span>
+            <Slider
+              style={{ width: 720}}
+              value={zoom}
+              step={0.1}
+              min={0.1}
+              max={3}
+              onChange={onZoomChange}
+            />
+           </div>
+           <br />
+           <Button onClick={onButtonClick}>裁切</Button>
+           <br />
+           <div 
+            // style={{ background: 'pink' }} 
+           >
+            <div id='previewContainer'
+              style={{
+                transformOrigin: 'top left',
+                transform: 'scale(0.5)',
+              }}
+            />
+          </div>
+      </div>
+  );
+};
+
+export const Round = () => {
+  const [rotate, setRotate] = useState(0);
+  const [zoom, setZoom] = useState(1);
+  const [shape, setShape] = useState('round');
+  const ref = useRef();
+
+  const onZoomChange = useCallback((value) => {
+    setZoom(value);
+  })
+
+  const onSliderChange = useCallback((value) => {
+    setRotate(value);
+  }, []);
+
+  const onButtonClick = useCallback(() => {
+    const value = ref.current.getCropperCanvas();
+    const previewContainer = document.getElementById('previewContainer');
+    previewContainer.innerHTML = '';
+    previewContainer.appendChild(value);
+  }, []);
+
+  const onShapeChange = useCallback((e) => {
+    setShape(e.target.value);
+  }, []);
+
+  return (
+      <div id='cropper-container'>
+          <RadioGroup onChange={onShapeChange} value={shape}>
+            <Radio value={'round'}>round</Radio>
+            <Radio value={'rect'}>rect</Radio>
+            <Radio value={'roundRect'}>roundRect</Radio>
+          </RadioGroup>
+           <Cropper 
+              ref={ref} 
+              src={"https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/abstract.jpg"}
+              style={containerStyleX}
+              rotate={rotate}
+              zoom={zoom}
+              shape={shape}
+              onZoomChange={onZoomChange}
+           />
+           <div style={actionStyle} >
+            <span>Rotate</span>
+            <Slider
+              style={{ width: 720}}
+              value={rotate}
+              step={1}
+              min={-360}
+              max={360}
+              onChange={onSliderChange}
+            />
+           </div>
+           <div style={actionStyle} >
+            <span>Zoom</span>
+            <Slider
+              style={{ width: 720}}
+              value={zoom}
+              step={0.1}
+              min={0.1}
+              max={3}
+              onChange={onZoomChange}
+            />
+           </div>
+           <br />
+           <Button onClick={onButtonClick}>裁切</Button>
+           <br />
+           <div 
+            // style={{ background: 'pink' }} 
+           >
+            <div id='previewContainer'
+              style={{
+                transformOrigin: 'top left',
+                transform: 'scale(0.5)',
+              }}
+            />
+          </div>
+      </div>
+  );
+};
+
+export const Aspect = () => {
+  const [rotate, setRotate] = useState(0);
+  const [zoom, setZoom] = useState(1);
+  const ref = useRef();
+
+  const onZoomChange = useCallback((value) => {
+    setZoom(value);
+  })
+
+  const onSliderChange = useCallback((value) => {
+    setRotate(value);
+  }, []);
+
+  const onButtonClick = useCallback(() => {
+    const value = ref.current.getCropperCanvas();
+    const previewContainer = document.getElementById('previewContainer');
+    previewContainer.innerHTML = '';
+    previewContainer.appendChild(value);
+  }, []);
+
+  return (
+      <div id='cropper-container'>
+           <Cropper 
+              ref={ref} 
+              src={"https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/abstract.jpg"}
+              style={containerStyle}
+              rotate={rotate}
+              zoom={zoom}
+              onZoomChange={onZoomChange}
+              aspectRatio={3/4}
+           />
+           <div style={actionStyle} >
+            <span>Rotate</span>
+            <Slider
+              style={{ width: 720}}
+              value={rotate}
+              step={1}
+              min={-360}
+              max={360}
+              onChange={onSliderChange}
+            />
+           </div>
+           <div style={actionStyle} >
+            <span>Zoom</span>
+            <Slider
+              style={{ width: 720}}
+              value={zoom}
+              step={0.1}
+              min={0.1}
+              max={3}
+              onChange={onZoomChange}
+            />
+           </div>
+           <br />
+           <Button onClick={onButtonClick}>裁切</Button>
+           <br />
+           <div 
+            // style={{ background: 'pink' }} 
+           >
+            <div id='previewContainer'
+              style={{
+                transformOrigin: 'top left',
+                transform: 'scale(0.5)',
+              }}
+            />
+          </div>
+      </div>
+  );
+}
+
+export const NoResizeBox = () => {
+  const [rotate, setRotate] = useState(0);
+  const [zoom, setZoom] = useState(1);
+  const ref = useRef();
+
+  const onZoomChange = useCallback((value) => {
+    setZoom(value);
+  })
+
+  const onSliderChange = useCallback((value) => {
+    setRotate(value);
+  }, []);
+
+  const onButtonClick = useCallback(() => {
+    const value = ref.current.getCropperCanvas();
+    const previewContainer = document.getElementById('previewContainer');
+    previewContainer.innerHTML = '';
+    previewContainer.appendChild(value);
+  }, []);
+
+  return (
+      <div id='cropper-container'>
+           <Cropper 
+              ref={ref} 
+              showResizeBox={false}
+              src={"https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/abstract.jpg"}
+              style={containerStyleX}
+              rotate={rotate}
+              cropperBoxStyle={{ outlineColor: 'var(--semi-color-bg-0)'}}
+              zoom={zoom}
+              onZoomChange={onZoomChange}
+           />
+           <div style={actionStyle} >
+            <span>Rotate</span>
+            <Slider
+              style={{ width: 720}}
+              value={rotate}
+              step={1}
+              min={-360}
+              max={360}
+              onChange={onSliderChange}
+            />
+           </div>
+           <div style={actionStyle} >
+            <span>Zoom</span>
+            <Slider
+              style={{ width: 720}}
+              value={zoom}
+              step={0.1}
+              min={0.1}
+              max={3}
+              onChange={onZoomChange}
+            />
+           </div>
+           <br />
+           <Button onClick={onButtonClick}>裁切</Button>
+           <br />
+           <div 
+            // style={{ background: 'pink' }} 
+           >
+            <div id='previewContainer'
+              style={{
+                transformOrigin: 'top left',
+                transform: 'scale(0.5)',
+              }}
+            />
+          </div>
+      </div>
+  );
+}
+
+

+ 90 - 0
packages/semi-ui/cropper/_story/cropper.stories.tsx

@@ -0,0 +1,90 @@
+import React, { useCallback, useState, useRef } from 'react';
+import Cropper from '../index';
+import { Slider, Button } from '@douyinfe/semi-ui';
+
+export default {
+  title: 'Cropper',
+}
+
+const containerStyle = {
+  width: 600,
+  height: 500,
+  margin: '50px 0 0 50px',
+}
+
+const actionStyle = {
+  marginTop: 20,
+  display: 'flex',
+  alignItems: 'center',
+  justifyContent: 'center',
+  width: 'fit-content'
+}
+
+export const Basic = () => {
+  const [rotate, setRotate] = useState(0);
+  const [zoom, setZoom] = useState(1);
+  const ref = useRef();
+
+  const onZoomChange = useCallback((value) => {
+    setZoom(value);
+  }, [])
+
+  const onSliderChange = useCallback((value) => {
+    setRotate(value);
+  }, []);
+
+  const onButtonClick = useCallback(() => {
+    const value = (ref.current as any)?.getCropperCanvas?.();
+    const previewContainer = document.getElementById('previewContainer');
+    previewContainer.innerHTML = '';
+    previewContainer.appendChild(value);
+  }, []);
+
+  return (
+      <div id='cropper-container'>
+           <Cropper 
+              ref={ref} 
+              src={"https://lf3-static.bytednsdoc.com/obj/eden-cn/ptlz_zlp/ljhwZthlaukjlkulzlp/root-web-sites/abstract.jpg"}
+              style={containerStyle}
+              rotate={rotate}
+              zoom={zoom}
+              onZoomChange={onZoomChange}
+           />
+           <div style={actionStyle} >
+            <span>Rotate</span>
+            <Slider
+              style={{ width: 720}}
+              value={rotate}
+              step={1}
+              min={-360}
+              max={360}
+              onChange={onSliderChange}
+            />
+           </div>
+           <div style={actionStyle} >
+            <span>Zoom</span>
+            <Slider
+              style={{ width: 720}}
+              value={zoom}
+              step={0.1}
+              min={0.1}
+              max={3}
+              onChange={onZoomChange}
+            />
+           </div>
+           <br />
+           <Button onClick={onButtonClick}>裁切</Button>
+           <br />
+           <div 
+            // style={{ background: 'pink' }} 
+           >
+            <div id='previewContainer'
+              style={{
+                transformOrigin: 'top left',
+                transform: 'scale(0.5)',
+              }}
+            />
+          </div>
+      </div>
+  );
+};

+ 298 - 0
packages/semi-ui/cropper/index.tsx

@@ -0,0 +1,298 @@
+import React from 'react';
+import BaseComponent from '../_base/baseComponent';
+import cls from "classnames";
+import PropTypes from 'prop-types';
+import "@douyinfe/semi-foundation/cropper/cropper.scss";
+import CropperFoundation, { CropperAdapter, ImageDataState, CropperBox } from '@douyinfe/semi-foundation/cropper/foundation';
+import { cssClasses, strings } from '@douyinfe/semi-foundation/cropper/constants';
+import ResizeObserver, { ObserverProperty } from '../resizeObserver';
+import { isUndefined } from 'lodash';
+
+interface CropperProps {
+    className?: string;
+    style?: React.CSSProperties;
+    /* The address of the image that needs to be cropped */
+    src?: string;
+    /* Parameters that need to be transparently transmitted to the img node */
+    imgProps?: React.ImgHTMLAttributes<HTMLImageElement>;
+    /* The shape to crop, defaults to rectangle */
+    shape?: 'rect' | 'round' | 'roundRect';
+    /* Controlled crop ratio */
+    aspectRatio?: number;
+    /* The initial width-to-height ratio of the cropping box, default is 1 */
+    defaultAspectRatio?: number;
+    /* controlled scaling */
+    /* when img loaded,After the image is loaded, an initial layer of scaling 
+        will be performed on the image to fit the zoom area.
+        The zoom parameter is to zoom based on the initial zoom.
+    */
+    zoom?: number;
+    onZoomChange?: (zoom: number) => void;
+    /* Image rotation angle */
+    rotate?: number;
+    /* Show crop box resizing box ?*/
+    showResizeBox?: boolean;
+    cropperBoxStyle?: React.CSSProperties;
+    cropperBoxCls?: string;
+    /* The fill color of the non-picture parts in the cut result */
+    fill?: string;
+    maxZoom: number;
+    minZoom: number;
+    zoomStep: number
+}
+
+interface CropperState {
+    imgData: ImageDataState;
+    cropperBox: CropperBox;
+    zoom: number;
+    rotate: number;
+    loaded: boolean
+}
+
+const prefixCls = cssClasses.PREFIX;
+
+class Cropper extends BaseComponent<CropperProps, CropperState> {
+
+    static __SemiComponentName__ = "Cropper";
+
+    static propTypes = {
+        className: PropTypes.string,
+        style: PropTypes.object,
+        
+    };
+
+    static defaultProps = {
+        shape: 'rect',
+        defaultAspectRatio: 1,
+        showResizeBox: true,
+        fill: 'rgba(0, 0, 0, 0)',
+        maxZoom: 3,
+        minZoom: 0.1,
+        zoomStep: 0.1,
+    }
+
+    containerRef: HTMLDivElement;
+    imgRef: React.RefObject<HTMLImageElement>;
+    foundation: CropperFoundation;
+
+    constructor(props: CropperProps) {
+        super(props);
+    
+        this.state = {
+            imgData: {
+                width: 0,
+                height: 0,
+                centerPoint: {
+                    x: 0,
+                    y: 0
+                }
+            },
+            cropperBox: {
+                width: 0,
+                height: 0,
+                centerPoint: {
+                    x: 0,
+                    y: 0,
+                }
+            },
+            zoom: 1,
+            rotate: 0,
+            loaded: false,
+        };
+        this.foundation = new CropperFoundation(this.adapter);
+        this.imgRef = React.createRef();
+    }
+
+    get adapter(): CropperAdapter<CropperProps, CropperState> {
+        return {
+            ...super.adapter,
+            getContainer: () => this.containerRef as unknown as HTMLElement,
+            notifyZoomChange: (zoom: number) => {
+                const { onZoomChange } = this.props;
+                onZoomChange?.(zoom);
+            },
+            getImg: () => this.imgRef.current,
+        };
+    }
+
+    static getDerivedStateFromProps(nextProps: CropperProps, prevState: CropperState) {  
+        const { rotate: newRotate, zoom: newZoom } = nextProps;
+        const { rotate, zoom, imgData, cropperBox, loaded } = prevState;
+        let nextWidth = imgData.width, nextHeight = imgData.height;
+        let nextImgCenter = { ...imgData.centerPoint };
+        const nextState = {} as any;
+        if (!loaded) {
+            return null;
+        }
+        if (!isUndefined(newRotate) && newRotate !== rotate) {
+            nextState.rotate = newRotate;
+            if (loaded) {
+                // 因为以裁切框的左上方顶点作为原点,所以centerPoint 的 y 坐标与实际的坐标系方向相反,
+                // 因此 y 方向需要先做变换,再使用旋转变换公式计算中心点坐标
+                const rotateCenter = {
+                    x: cropperBox.centerPoint.x,
+                    y: - cropperBox.centerPoint.y
+                };
+                const imgCenter = {
+                    x: imgData.centerPoint.x,
+                    y: - imgData.centerPoint.y
+                };
+                const angle = (newRotate - rotate) * Math.PI / 180;
+                nextImgCenter = {
+                    x: (imgCenter.x - rotateCenter.x) * Math.cos(angle) + (imgCenter.y - rotateCenter.y) * Math.sin(angle) + rotateCenter.x,
+                    y: - (-(imgCenter.x - rotateCenter.x) * Math.sin(angle) + (imgCenter.y - rotateCenter.y) * Math.cos(angle) + rotateCenter.y),
+                };
+            }
+        }
+        if (!isUndefined(newRotate) && newZoom !== zoom) {
+            nextState.zoom = newZoom;
+            if (loaded) {
+                // 同上
+                const scaleCenter = {
+                    x: cropperBox.centerPoint.x,
+                    y: - cropperBox.centerPoint.y
+                };
+                const currentImgCenter = {
+                    x: nextImgCenter.x,
+                    y: - nextImgCenter.y
+                };
+                nextWidth = imgData.width / zoom * newZoom;
+                nextHeight = imgData.height / zoom * newZoom;
+                nextImgCenter = {
+                    x: (currentImgCenter.x - scaleCenter.x) / zoom * newZoom + scaleCenter.x,
+                    y: - [(currentImgCenter.y - scaleCenter.y) / zoom * newZoom + scaleCenter.y],
+                };
+            } 
+        }
+
+        if ((newRotate !== rotate || newZoom !== zoom)) {
+            nextState.imgData = {
+                width: nextWidth,
+                height: nextHeight,
+                centerPoint: nextImgCenter,
+            };
+        }
+
+        if (Object.keys(nextState).length) {
+            return nextState;
+        }
+        return null;
+    }
+
+    componentDidMount(): void {
+        this.foundation.init();
+    }
+
+    componentWillUnmount(): void {
+        this.foundation.destroy();
+        this.unRegisterImageWrapRef();
+    }
+
+    unRegisterImageWrapRef = (): void => {
+        if (this.containerRef) {
+            (this.containerRef as any).removeEventListener("wheel", this.foundation.handleWheel);
+        }
+        this.containerRef = null;
+    };
+
+    registryImageWrapRef = (ref: any): void => {
+        this.unRegisterImageWrapRef();
+        if (ref) {
+            // We need to use preventDefault to prevent the page from being enlarged when zooming in with two fingers.
+            ref.addEventListener("wheel", this.foundation.handleWheel, { passive: false });
+        }
+        this.containerRef = ref;
+    };
+
+    // ref method: Get the cropped canvas
+    getCropperCanvas = () => {
+        return this.foundation.getCropperCanvas();
+    }
+
+    render() {
+        const { className, style, src, shape, showResizeBox, cropperBoxStyle, cropperBoxCls } = this.props;
+        const { imgData, cropperBox, rotate, loaded } = this.state;
+        const imgX = imgData.centerPoint.x - imgData.width / 2;
+        const imgY = imgData.centerPoint.y - imgData.height / 2;
+        const cropperBoxX = cropperBox.centerPoint.x - cropperBox.width / 2;
+        const cropperBoxY = cropperBox.centerPoint.y - cropperBox.height / 2;
+        const cropperImgX = imgX - cropperBoxX;
+        const cropperImgY = imgY - cropperBoxY;
+
+        return (<ResizeObserver 
+            onResize={this.foundation.handleResize} 
+            observerProperty={ObserverProperty.Width}
+        >
+            <div
+                className={cls(prefixCls, className)}
+                style={style}
+                ref={this.registryImageWrapRef}
+            >
+                {/* Img layer */}
+                <div className={cssClasses.IMG_WRAPPER}>
+                    <img
+                        ref={this.imgRef}
+                        src={src}
+                        onLoad={this.foundation.handleImageLoad}
+                        className={cssClasses.IMG}
+                        crossOrigin='anonymous'
+                        style={{
+                            width: imgData.width,
+                            height: imgData.height,
+                            transformOrigin: 'center',
+                            transform: `translate(${imgX}px, ${imgY}px) rotate(${rotate}deg)`,
+                        }}
+                    />
+                </div>
+                {/* Mask layer */}
+                <div 
+                    className={cssClasses.MASK} 
+                    onMouseDown={this.foundation.handleMaskMouseDown}
+                />
+                {/* Cropper box */}
+                <div
+                    className={cls(cssClasses.CROPPER_BOX, { 
+                        [cropperBoxCls]: cropperBoxCls,
+                        [cssClasses.CROPPER_VIEW_BOX_ROUND]: shape === 'round',
+                    })}
+                    style={{
+                        ...cropperBoxStyle,
+                        width: cropperBox.width,
+                        height: cropperBox.height,
+                        transform: `translate(${cropperBoxX}px, ${cropperBoxY}px)`,
+                    }}
+                    onMouseDown={this.foundation.handleCropperBoxMouseDown}
+                >
+                    <div 
+                        className={cls(cssClasses.CROPPER_VIEW_BOX, {
+                            [cssClasses.CROPPER_VIEW_BOX_ROUND]: shape.includes('round'),
+                        })} 
+                    >
+                        <img 
+                            onDragStart={this.foundation.viewIMGDragStart}
+                            className={cssClasses.CROPPER_IMG}
+                            src={src}
+                            style={{
+                                width: imgData.width,
+                                height: imgData.height,
+                                transformOrigin: 'center',
+                                transform: `translate(${cropperImgX}px, ${cropperImgY}px) rotate(${rotate}deg)`,
+                            }}
+                        />
+                    </div>
+                    {/* 裁剪框的拖拽操作按钮 */}  
+                    {loaded && showResizeBox && (shape === 'round' ? strings.roundCorner : strings.corner).map(corner => (
+                        <div 
+                            className={cls(cssClasses.CORNER, `${cssClasses.CORNER}-${corner}`)}
+                            data-dir={corner}
+                            key={corner}
+                            onMouseDown={this.foundation.handleCornerMouseDown}
+                        />
+                    ))}
+                </div>
+            </div>
+        </ResizeObserver>);
+    }
+}
+
+export default Cropper;

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

@@ -125,3 +125,4 @@ export {
 
 export { default as JsonViewer } from './jsonViewer';
 export { default as DragMove } from './dragMove';
+export { default as Cropper } from './cropper';

+ 7 - 0
src/images/docIcons/doc-cropper.svg

@@ -0,0 +1,7 @@
+<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 2H5ZM11 8.5C11 9.88071 9.88071 11 8.5 11C7.11929 11 6 9.88071 6 8.5C6 7.11929 7.11929 6 8.5 6C9.88071 6 11 7.11929 11 8.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"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M2 5C2 3.34315 3.34315 2 5 2H19C20.6569 2 22 3.34315 22 5V19C22 20.6569 20.6569 22 19 22H5C3.34315 22 2 20.6569 2 19V5ZM6 5C5.44772 5 5 5.44772 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V6C19 5.44772 18.5523 5 18 5H6Z" fill="#AAB2BF"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9 4.25C9 3.83579 8.66421 3.5 8.25 3.5H6C4.61929 3.5 3.5 4.61929 3.5 6V8.25C3.5 8.66421 3.83579 9 4.25 9V9C4.66421 9 5 8.66421 5 8.25V6C5 5.44772 5.44772 5 6 5H8.25C8.66421 5 9 4.66421 9 4.25V4.25ZM15.75 5C15.3358 5 15 4.66421 15 4.25V4.25C15 3.83579 15.3358 3.5 15.75 3.5H18C19.3807 3.5 20.5 4.61929 20.5 6V8.25C20.5 8.66421 20.1642 9 19.75 9V9C19.3358 9 19 8.66421 19 8.25V6C19 5.44772 18.5523 5 18 5H15.75ZM15 19.75C15 19.3358 15.3358 19 15.75 19H18C18.5523 19 19 18.5523 19 18V15.75C19 15.3358 19.3358 15 19.75 15V15C20.1642 15 20.5 15.3358 20.5 15.75V18C20.5 19.3807 19.3807 20.5 18 20.5H15.75C15.3358 20.5 15 20.1642 15 19.75V19.75ZM4.25 15C4.66421 15 5 15.3358 5 15.75V18C5 18.5523 5.44772 19 6 19H8.25C8.66421 19 9 19.3358 9 19.75V19.75C9 20.1642 8.66421 20.5 8.25 20.5H6C4.61929 20.5 3.5 19.3807 3.5 18V15.75C3.5 15.3358 3.83579 15 4.25 15V15Z" fill="white"/>
+</svg>

Разница между файлами не показана из-за своего большого размера
+ 8 - 0
src/images/docIcons/doc-dragmove.svg


Некоторые файлы не были показаны из-за большого количества измененных файлов