@veltra/desktop 1.7.8 → 1.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/components/batch-edit/batch-edit-form.js +7 -3
  2. package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
  3. package/dist/components/batch-edit/batch-edit.js +1 -0
  4. package/dist/components/batch-edit/batch-edit.js.map +1 -1
  5. package/dist/components/batch-edit/use-edit-state.js.map +1 -1
  6. package/dist/components/form/form.js.map +1 -1
  7. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js +31 -6
  8. package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js.map +1 -1
  9. package/dist/components/form/is-field-modified.js +12 -0
  10. package/dist/components/form/is-field-modified.js.map +1 -0
  11. package/dist/components/form/style2.css +37 -1
  12. package/dist/components/form/use-form-fields.js +11 -6
  13. package/dist/components/form/use-form-fields.js.map +1 -1
  14. package/dist/components/form-item/form-item.js.map +1 -1
  15. package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js +18 -5
  16. package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js.map +1 -1
  17. package/dist/components/image-cropper/draw-crop.js +63 -0
  18. package/dist/components/image-cropper/draw-crop.js.map +1 -0
  19. package/dist/components/image-cropper/image-cropper.js +344 -0
  20. package/dist/components/image-cropper/image-cropper.js.map +1 -0
  21. package/dist/components/image-cropper/style.d.ts +1 -0
  22. package/dist/components/image-cropper/style.js +1 -0
  23. package/dist/components/image-cropper/style2.css +205 -0
  24. package/dist/components/image-cropper/use-image-loader.js +59 -0
  25. package/dist/components/image-cropper/use-image-loader.js.map +1 -0
  26. package/dist/components/image-cropper/use-preview.js +44 -0
  27. package/dist/components/image-cropper/use-preview.js.map +1 -0
  28. package/dist/components/image-cropper/use-selection.js +183 -0
  29. package/dist/components/image-cropper/use-selection.js.map +1 -0
  30. package/dist/components/image-cropper/use-transform.js +182 -0
  31. package/dist/components/image-cropper/use-transform.js.map +1 -0
  32. package/dist/components/index.d.ts +2 -1
  33. package/dist/components/index.js +3 -1
  34. package/dist/components/index.js.map +1 -1
  35. package/dist/index.d.ts +47 -45
  36. package/dist/index.js +46 -45
  37. package/dist/types/batch-edit.d.ts +2 -0
  38. package/dist/types/form-item.d.ts +4 -1
  39. package/dist/types/form.d.ts +10 -1
  40. package/dist/types/image-cropper.d.ts +68 -0
  41. package/dist/types/index.d.ts +2 -1
  42. package/package.json +6 -6
  43. package/src/components/auto-complete/style.scss +3 -15
  44. package/src/components/batch-edit/__test__/batch-edit.test.ts +138 -0
  45. package/src/components/batch-edit/batch-edit-form.vue +18 -4
  46. package/src/components/batch-edit/use-edit-state.ts +1 -1
  47. package/src/components/contextmenu/style.scss +4 -20
  48. package/src/components/form/__test__/form.test.ts +80 -0
  49. package/src/components/form/__test__/is-field-modified.test.ts +37 -0
  50. package/src/components/form/form.vue +28 -4
  51. package/src/components/form/is-field-modified.ts +16 -0
  52. package/src/components/form/style.scss +53 -2
  53. package/src/components/form/use-form-fields.ts +20 -6
  54. package/src/components/form-item/__test__/form-item.test.ts +90 -0
  55. package/src/components/form-item/form-item.vue +42 -7
  56. package/src/components/image-cropper/__test__/draw-crop.test.ts +131 -0
  57. package/src/components/image-cropper/__test__/image-cropper.test.ts +744 -0
  58. package/src/components/image-cropper/__test__/use-transform.test.ts +229 -0
  59. package/src/components/image-cropper/draw-crop.ts +112 -0
  60. package/src/components/image-cropper/image-cropper.vue +302 -0
  61. package/src/components/image-cropper/index.ts +1 -0
  62. package/src/components/image-cropper/style.scss +232 -0
  63. package/src/components/image-cropper/style.ts +1 -0
  64. package/src/components/image-cropper/use-image-loader.ts +79 -0
  65. package/src/components/image-cropper/use-preview.ts +58 -0
  66. package/src/components/image-cropper/use-selection.ts +255 -0
  67. package/src/components/image-cropper/use-transform.ts +256 -0
  68. package/src/components/index.ts +2 -0
  69. package/src/components/select/style.scss +3 -15
  70. package/src/types/batch-edit.ts +2 -0
  71. package/src/types/form-item.ts +4 -1
  72. package/src/types/form.ts +10 -2
  73. package/src/types/image-cropper.ts +73 -0
  74. package/src/types/index.ts +1 -0
@@ -0,0 +1,232 @@
1
+ @use 'pkg:@veltra/styles/mixins' as m;
2
+ @use 'pkg:@veltra/styles/functions' as fn;
3
+
4
+ $root-name: image-cropper;
5
+ @include m.b($root-name) {
6
+ position: relative;
7
+ display: flex;
8
+ flex-direction: column;
9
+ width: 100%;
10
+ height: 100%;
11
+ overflow: hidden;
12
+
13
+ @include m.e(toolbar) {
14
+ display: flex;
15
+ flex-shrink: 0;
16
+ flex-wrap: wrap;
17
+ align-items: center;
18
+ gap: 8px;
19
+ padding: 8px 12px;
20
+ border-bottom: 1px solid fn.use-var(border, color);
21
+ background-color: fn.use-var(bg-color, top);
22
+ }
23
+
24
+ @include m.e(tool-group) {
25
+ display: inline-flex;
26
+ align-items: center;
27
+ gap: 2px;
28
+ padding: 2px;
29
+ border: 1px solid fn.use-var(border, color);
30
+ border-radius: fn.use-var(radius, small);
31
+ background-color: fn.use-var(bg-color, middle);
32
+ }
33
+
34
+ @include m.e(tool) {
35
+ display: inline-flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ height: 28px;
39
+ padding: 0 10px;
40
+ border: 1px solid transparent;
41
+ border-radius: calc(#{fn.use-var(radius, small)} - 2px);
42
+ background: transparent;
43
+ color: fn.use-var(text-color, second);
44
+ font-size: 12px;
45
+ line-height: 1;
46
+ cursor: pointer;
47
+ transition:
48
+ background fn.use-var(transition, fast) fn.use-var(transition, ease),
49
+ color fn.use-var(transition, fast) fn.use-var(transition, ease);
50
+
51
+ &:hover {
52
+ color: fn.use-var(color, primary);
53
+ background: fn.color-a(color, 10, primary);
54
+ }
55
+
56
+ &.is-active {
57
+ color: fn.use-var(color, primary);
58
+ background: fn.color-a(color, 16, primary);
59
+ }
60
+
61
+ &:focus-visible {
62
+ box-shadow: fn.use-var(focus-ring);
63
+ }
64
+ }
65
+
66
+ @include m.em(tool, icon) {
67
+ width: 28px;
68
+ padding: 0;
69
+ }
70
+
71
+ // 水平翻转图标:上下箭头旋转为左右指向
72
+ @include m.e(flip-icon) {
73
+ transform: rotate(90deg);
74
+ }
75
+
76
+ @include m.e(canvas) {
77
+ position: relative;
78
+ flex: 1;
79
+ min-height: 0;
80
+ overflow: hidden;
81
+ background-color: fn.use-var(bg-color, bottom);
82
+ }
83
+
84
+ // 舞台:承载图片与选区层,尺寸为图片原始像素,随 transform 变换
85
+ @include m.e(stage) {
86
+ position: absolute;
87
+ left: 0;
88
+ top: 0;
89
+ // 变换原点为中心:旋转 / 翻转绕图片中心进行,不产生位置补偿
90
+ transform-origin: 50% 50%;
91
+ }
92
+
93
+ @include m.e(image) {
94
+ display: block;
95
+ width: 100%;
96
+ height: 100%;
97
+ user-select: none;
98
+ }
99
+
100
+ @include m.e(mask) {
101
+ position: absolute;
102
+ background-color: fn.color-a(bg-color, 70, black);
103
+ }
104
+
105
+ @include m.e(selection) {
106
+ position: absolute;
107
+ box-sizing: border-box;
108
+ border-style: solid;
109
+ border-color: fn.use-var(text-color, white);
110
+ // 选区在舞台内随图片缩放,边框用逆缩放保持屏幕恒定宽度
111
+ border-width: calc(1px * var(--u-image-cropper-inv-scale, 1));
112
+ cursor: move;
113
+ }
114
+
115
+ @include m.e(grid-line) {
116
+ position: absolute;
117
+ pointer-events: none;
118
+ background-color: fn.color-a(text-color, 50, white);
119
+
120
+ // 前两条为竖线,后两条为横线,与模板中的渲染顺序对应
121
+ &:nth-child(-n + 2) {
122
+ top: 0;
123
+ bottom: 0;
124
+ width: calc(1px * var(--u-image-cropper-inv-scale, 1));
125
+ }
126
+
127
+ &:nth-child(n + 3) {
128
+ left: 0;
129
+ right: 0;
130
+ height: calc(1px * var(--u-image-cropper-inv-scale, 1));
131
+ }
132
+
133
+ &:nth-child(1) {
134
+ left: 33.3333%;
135
+ }
136
+
137
+ &:nth-child(2) {
138
+ left: 66.6667%;
139
+ }
140
+
141
+ &:nth-child(3) {
142
+ top: 33.3333%;
143
+ }
144
+
145
+ &:nth-child(4) {
146
+ top: 66.6667%;
147
+ }
148
+ }
149
+
150
+ @include m.e(handle) {
151
+ position: absolute;
152
+ width: 10px;
153
+ height: 10px;
154
+ background-color: fn.use-var(text-color, white);
155
+ box-shadow: fn.use-var(shadow, sm);
156
+ // 手柄在舞台内随图片缩放,用逆缩放保持屏幕恒定尺寸
157
+ transform: translate(-50%, -50%) scale(var(--u-image-cropper-inv-scale, 1));
158
+
159
+ &[data-handle='nw'] {
160
+ left: 0;
161
+ top: 0;
162
+ cursor: nwse-resize;
163
+ }
164
+
165
+ &[data-handle='n'] {
166
+ left: 50%;
167
+ top: 0;
168
+ cursor: ns-resize;
169
+ }
170
+
171
+ &[data-handle='ne'] {
172
+ left: 100%;
173
+ top: 0;
174
+ cursor: nesw-resize;
175
+ }
176
+
177
+ &[data-handle='e'] {
178
+ left: 100%;
179
+ top: 50%;
180
+ cursor: ew-resize;
181
+ }
182
+
183
+ &[data-handle='se'] {
184
+ left: 100%;
185
+ top: 100%;
186
+ cursor: nwse-resize;
187
+ }
188
+
189
+ &[data-handle='s'] {
190
+ left: 50%;
191
+ top: 100%;
192
+ cursor: ns-resize;
193
+ }
194
+
195
+ &[data-handle='sw'] {
196
+ left: 0;
197
+ top: 100%;
198
+ cursor: nesw-resize;
199
+ }
200
+
201
+ &[data-handle='w'] {
202
+ left: 0;
203
+ top: 50%;
204
+ cursor: ew-resize;
205
+ }
206
+ }
207
+
208
+ // 实时预览区:悬浮在画布右下角,不拦截画布交互
209
+ @include m.e(preview) {
210
+ position: absolute;
211
+ right: 12px;
212
+ bottom: 12px;
213
+ z-index: 1;
214
+ display: flex;
215
+ align-items: center;
216
+ justify-content: center;
217
+ max-width: 30%;
218
+ max-height: 30%;
219
+ border: 1px solid fn.use-var(border, color);
220
+ border-radius: fn.use-var(radius, small);
221
+ background-color: fn.use-var(bg-color, top);
222
+ box-shadow: fn.use-var(shadow);
223
+ overflow: hidden;
224
+ pointer-events: none;
225
+ }
226
+
227
+ @include m.e(preview-canvas) {
228
+ display: block;
229
+ max-width: 100%;
230
+ max-height: 100%;
231
+ }
232
+ }
@@ -0,0 +1 @@
1
+ import './style.scss'
@@ -0,0 +1,79 @@
1
+ import { onBeforeUnmount, shallowRef, watch, type ShallowRef } from 'vue'
2
+
3
+ interface UseImageLoaderOptions {
4
+ /** 图片源(File / Blob / URL) */
5
+ src: () => File | Blob | string | undefined
6
+ }
7
+
8
+ interface UseImageLoaderReturn {
9
+ /** 用于渲染的图片地址 */
10
+ imageUrl: ShallowRef<string>
11
+ /** 是否加载完成 */
12
+ loaded: ShallowRef<boolean>
13
+ /** 是否加载失败 */
14
+ error: ShallowRef<boolean>
15
+ /** 图片原始宽度 */
16
+ naturalWidth: ShallowRef<number>
17
+ /** 图片原始高度 */
18
+ naturalHeight: ShallowRef<number>
19
+ /** 已加载完成的图片元素,供画布绘制(预览 / getResult 输出) */
20
+ imageEl: ShallowRef<HTMLImageElement | undefined>
21
+ }
22
+
23
+ /** 加载 File / Blob / URL 图片源,File / Blob 走 objectURL 并在替换与卸载时释放 */
24
+ export function useImageLoader(options: UseImageLoaderOptions): UseImageLoaderReturn {
25
+ const { src } = options
26
+
27
+ const imageUrl = shallowRef('')
28
+ const loaded = shallowRef(false)
29
+ const error = shallowRef(false)
30
+ const naturalWidth = shallowRef(0)
31
+ const naturalHeight = shallowRef(0)
32
+ const imageEl = shallowRef<HTMLImageElement>()
33
+
34
+ let objectUrl: string | undefined
35
+
36
+ function revokeObjectUrl() {
37
+ if (!objectUrl) return
38
+ URL.revokeObjectURL(objectUrl)
39
+ objectUrl = undefined
40
+ }
41
+
42
+ function load(source: File | Blob | string | undefined) {
43
+ revokeObjectUrl()
44
+ imageUrl.value = ''
45
+ loaded.value = false
46
+ error.value = false
47
+ naturalWidth.value = 0
48
+ naturalHeight.value = 0
49
+ imageEl.value = undefined
50
+
51
+ if (!source) return
52
+
53
+ const url = typeof source === 'string' ? source : (objectUrl = URL.createObjectURL(source))
54
+ const el = new Image()
55
+ // URL 源尽量走跨域匿名加载,保证后续画布导出不被污染
56
+ if (typeof source === 'string') el.crossOrigin = 'anonymous'
57
+
58
+ el.onload = () => {
59
+ naturalWidth.value = el.naturalWidth
60
+ naturalHeight.value = el.naturalHeight
61
+ imageEl.value = el
62
+ loaded.value = true
63
+ }
64
+ el.onerror = () => {
65
+ error.value = true
66
+ imageUrl.value = ''
67
+ }
68
+ el.src = url
69
+ imageUrl.value = url
70
+ }
71
+
72
+ watch(src, load, { immediate: true })
73
+
74
+ onBeforeUnmount(() => {
75
+ revokeObjectUrl()
76
+ })
77
+
78
+ return { imageUrl, loaded, error, naturalWidth, naturalHeight, imageEl }
79
+ }
@@ -0,0 +1,58 @@
1
+ import { watch, type ShallowRef } from 'vue'
2
+
3
+ import { drawCropToCanvas, type CropTransform } from './draw-crop'
4
+ import type { SelectionRect } from './use-selection'
5
+
6
+ interface UsePreviewOptions {
7
+ /** 预览画布元素 */
8
+ canvas: ShallowRef<HTMLCanvasElement | undefined>
9
+ /** 舞台内的图片元素(已加载完成) */
10
+ image: ShallowRef<HTMLImageElement | undefined>
11
+ /** 当前选区(图片像素坐标) */
12
+ selection: ShallowRef<SelectionRect | null>
13
+ /** 影响裁剪输出的变换(缩放 / 平移不影响输出,仅旋转 / 翻转触发重绘) */
14
+ transform: CropTransform
15
+ /** 裁剪画布区尺寸,预览显示尺寸以它的 30% 为上限 */
16
+ canvasSize: () => { width: number; height: number }
17
+ }
18
+
19
+ /**
20
+ * 实时预览:选区或旋转 / 翻转变换变化时把当前裁剪结果重绘到预览画布。
21
+ * 画布按显示尺寸(等比缩放到画布区 30% 以内)绘制,不能靠 CSS max-width/max-height 约束
22
+ * ——父级为 shrink-to-fit 宽度时百分比 max 约束失效,画布会按原尺寸溢出被裁剪
23
+ */
24
+ export function usePreview(options: UsePreviewOptions): void {
25
+ const { canvas, image, selection, transform, canvasSize } = options
26
+
27
+ watch(
28
+ [
29
+ canvas,
30
+ image,
31
+ selection,
32
+ () => transform.rotation,
33
+ () => transform.flipX,
34
+ () => transform.flipY,
35
+ () => {
36
+ const size = canvasSize()
37
+ return [size.width, size.height]
38
+ }
39
+ ],
40
+ () => {
41
+ const el = canvas.value
42
+ const img = image.value
43
+ const sel = selection.value
44
+ if (!el || !img || !sel) return
45
+
46
+ // 与 drawCropToCanvas 相同的宽高交换规则,先求旋转后的基准尺寸
47
+ const swapped = transform.rotation % 180 !== 0
48
+ const baseW = swapped ? sel.height : sel.width
49
+ const baseH = swapped ? sel.width : sel.height
50
+
51
+ const size = canvasSize()
52
+ const scale = Math.min((size.width * 0.3) / baseW, (size.height * 0.3) / baseH, 1)
53
+ drawCropToCanvas(img, sel, transform, el, { width: baseW * scale, height: baseH * scale })
54
+ },
55
+ // post:等待 v-if 切换后画布元素完成挂载
56
+ { flush: 'post', immediate: true }
57
+ )
58
+ }
@@ -0,0 +1,255 @@
1
+ import { onBeforeUnmount, shallowRef, watch, type Ref, type ShallowRef } from 'vue'
2
+
3
+ /** 裁剪选区(图片像素坐标) */
4
+ export interface SelectionRect {
5
+ x: number
6
+ y: number
7
+ width: number
8
+ height: number
9
+ }
10
+
11
+ /** 手柄方位:四角 + 四边 */
12
+ export type SelectionHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
13
+
14
+ /** 全部手柄方位 */
15
+ export const SELECTION_HANDLES: SelectionHandle[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
16
+
17
+ /** 选区最小边长(图片像素) */
18
+ const MIN_SIZE = 20
19
+
20
+ function clamp(value: number, min: number, max: number) {
21
+ return Math.min(Math.max(value, min), Math.max(min, max))
22
+ }
23
+
24
+ interface UseSelectionOptions {
25
+ /** 选区元素(mousedown 交互目标,手柄通过 data-handle 标识) */
26
+ target: ShallowRef<HTMLElement | undefined | null> | Ref<HTMLElement | undefined | null>
27
+ /** 图片原始宽(图片像素) */
28
+ imageWidth: () => number
29
+ /** 图片原始高(图片像素) */
30
+ imageHeight: () => number
31
+ /** 屏幕位移换算为图片像素位移(由 use-transform 提供,考虑缩放 / 旋转 / 翻转) */
32
+ toImageDelta: (dx: number, dy: number) => { x: number; y: number }
33
+ /** 宽高比约束(宽 / 高),不传或 <= 0 为自由比例 */
34
+ aspectRatio: () => number | undefined
35
+ }
36
+
37
+ interface UseSelectionReturn {
38
+ /** 当前选区(图片像素坐标) */
39
+ selection: ShallowRef<SelectionRect | null>
40
+ /** 以图片居中约 80% 区域初始化选区(受宽高比约束) */
41
+ initSelection: () => void
42
+ /** 清空选区 */
43
+ clearSelection: () => void
44
+ }
45
+
46
+ /** 裁剪选区交互:整体拖动 + 8 个手柄调整大小,支持宽高比约束与图片边界钳制 */
47
+ export function useSelection(options: UseSelectionOptions): UseSelectionReturn {
48
+ const { target, imageWidth, imageHeight, toImageDelta, aspectRatio } = options
49
+
50
+ const selection = shallowRef<SelectionRect | null>(null)
51
+
52
+ /** 有效宽高比,非法值视为自由比例 */
53
+ function validRatio() {
54
+ const ratio = aspectRatio()
55
+ return ratio && ratio > 0 ? ratio : undefined
56
+ }
57
+
58
+ /** 尺寸与位置钳制在图片像素范围内 */
59
+ function clampRect(rect: SelectionRect): SelectionRect {
60
+ const W = imageWidth()
61
+ const H = imageHeight()
62
+ const width = clamp(rect.width, Math.min(MIN_SIZE, W), W)
63
+ const height = clamp(rect.height, Math.min(MIN_SIZE, H), H)
64
+ return { width, height, x: clamp(rect.x, 0, W - width), y: clamp(rect.y, 0, H - height) }
65
+ }
66
+
67
+ /** 以选区中心为锚按比例重算选区 */
68
+ function applyRatio(sel: SelectionRect, ratio: number): SelectionRect {
69
+ const W = imageWidth()
70
+ const H = imageHeight()
71
+ const cx = sel.x + sel.width / 2
72
+ const cy = sel.y + sel.height / 2
73
+
74
+ let width = sel.width
75
+ let height = width / ratio
76
+ if (height > H) {
77
+ height = H
78
+ width = height * ratio
79
+ }
80
+ if (width > W) {
81
+ width = W
82
+ height = width / ratio
83
+ }
84
+
85
+ return clampRect({ x: cx - width / 2, y: cy - height / 2, width, height })
86
+ }
87
+
88
+ function initSelection() {
89
+ const W = imageWidth()
90
+ const H = imageHeight()
91
+ if (!W || !H) {
92
+ selection.value = null
93
+ return
94
+ }
95
+
96
+ let width = W * 0.8
97
+ let height = H * 0.8
98
+ const ratio = validRatio()
99
+ // 在居中 80% 框内内接指定比例
100
+ if (ratio) {
101
+ if (width / height > ratio) width = height * ratio
102
+ else height = width / ratio
103
+ }
104
+
105
+ selection.value = { x: (W - width) / 2, y: (H - height) / 2, width, height }
106
+ }
107
+
108
+ function clearSelection() {
109
+ selection.value = null
110
+ }
111
+
112
+ // 宽高比变化时以选区中心为锚按比例重算
113
+ watch(
114
+ () => aspectRatio(),
115
+ () => {
116
+ const ratio = validRatio()
117
+ if (!ratio || !selection.value) return
118
+ selection.value = applyRatio(selection.value, ratio)
119
+ }
120
+ )
121
+
122
+ // ---------- 拖动交互(参照 useDrag 模式:mousedown 起,document 级 move/up 收尾) ----------
123
+
124
+ interface DragSession {
125
+ /** 'move' 为整体拖动,其余为手柄方位 */
126
+ mode: 'move' | SelectionHandle
127
+ startX: number
128
+ startY: number
129
+ origin: SelectionRect
130
+ }
131
+
132
+ let session: DragSession | null = null
133
+
134
+ function handleMousedown(e: MouseEvent) {
135
+ const sel = selection.value
136
+ if (e.button !== 0 || !sel) return
137
+ // 阻止冒泡与默认行为:避免触发画布层交互(P3 平移)与原生选中
138
+ e.stopPropagation()
139
+ e.preventDefault()
140
+
141
+ const handle = (e.target as HTMLElement | null)
142
+ ?.closest('[data-handle]')
143
+ ?.getAttribute('data-handle')
144
+
145
+ session = {
146
+ mode: (handle as SelectionHandle | null) ?? 'move',
147
+ startX: e.clientX,
148
+ startY: e.clientY,
149
+ origin: { ...sel }
150
+ }
151
+
152
+ document.addEventListener('mousemove', handleMousemove, { passive: true })
153
+ document.addEventListener('mouseup', handleMouseup)
154
+ }
155
+
156
+ function handleMousemove(e: MouseEvent) {
157
+ if (!session) return
158
+
159
+ // 屏幕位移换算为图片像素位移(含缩放 / 旋转 / 翻转)
160
+ const { x: dx, y: dy } = toImageDelta(e.clientX - session.startX, e.clientY - session.startY)
161
+ selection.value =
162
+ session.mode === 'move'
163
+ ? moveSelection(session.origin, dx, dy)
164
+ : resizeSelection(session.origin, session.mode, dx, dy)
165
+ }
166
+
167
+ function handleMouseup() {
168
+ session = null
169
+ cleanup()
170
+ }
171
+
172
+ function cleanup() {
173
+ document.removeEventListener('mousemove', handleMousemove)
174
+ document.removeEventListener('mouseup', handleMouseup)
175
+ }
176
+
177
+ function moveSelection(origin: SelectionRect, dx: number, dy: number): SelectionRect {
178
+ return clampRect({ ...origin, x: origin.x + dx, y: origin.y + dy })
179
+ }
180
+
181
+ function resizeSelection(
182
+ origin: SelectionRect,
183
+ handle: SelectionHandle,
184
+ dx: number,
185
+ dy: number
186
+ ): SelectionRect {
187
+ const W = imageWidth()
188
+ const H = imageHeight()
189
+ const right = origin.x + origin.width
190
+ const bottom = origin.y + origin.height
191
+ const cx = origin.x + origin.width / 2
192
+ const cy = origin.y + origin.height / 2
193
+ const ratio = validRatio()
194
+
195
+ const west = handle.includes('w')
196
+ const east = handle.includes('e')
197
+ const north = handle.includes('n')
198
+ const south = handle.includes('s')
199
+
200
+ // 候选尺寸:被拖动的边随指针位移,对侧保持不动
201
+ let width = origin.width + (east ? dx : west ? -dx : 0)
202
+ let height = origin.height + (south ? dy : north ? -dy : 0)
203
+
204
+ // 各轴最大尺寸:拖动边受图片边界限制,未拖动的轴绕中心扩展
205
+ const maxWidth = east ? W - origin.x : west ? right : 2 * Math.min(cx, W - cx)
206
+ const maxHeight = south ? H - origin.y : north ? bottom : 2 * Math.min(cy, H - cy)
207
+
208
+ if (ratio) {
209
+ if ((east || west) && !north && !south) {
210
+ // 横向边中手柄:宽度驱动,高度按比例绕中心联动
211
+ height = width / ratio
212
+ } else if ((north || south) && !east && !west) {
213
+ // 纵向边中手柄:高度驱动,宽度按比例绕中心联动
214
+ width = height * ratio
215
+ } else {
216
+ // 角手柄:取位移更大的一侧驱动,单方向拖动也能生效
217
+ const widthFromHeight = height * ratio
218
+ width =
219
+ Math.abs(width - origin.width) >= Math.abs(widthFromHeight - origin.width)
220
+ ? width
221
+ : widthFromHeight
222
+ height = width / ratio
223
+ }
224
+ const maxCoupledWidth = Math.min(maxWidth, maxHeight * ratio)
225
+ width = clamp(width, Math.min(MIN_SIZE, maxCoupledWidth), maxCoupledWidth)
226
+ height = width / ratio
227
+ } else {
228
+ width = clamp(width, Math.min(MIN_SIZE, maxWidth), maxWidth)
229
+ height = clamp(height, Math.min(MIN_SIZE, maxHeight), maxHeight)
230
+ }
231
+
232
+ return clampRect({
233
+ x: west ? right - width : east ? origin.x : cx - width / 2,
234
+ y: north ? bottom - height : south ? origin.y : cy - height / 2,
235
+ width,
236
+ height
237
+ })
238
+ }
239
+
240
+ watch(
241
+ target,
242
+ (el, oldEl) => {
243
+ oldEl?.removeEventListener('mousedown', handleMousedown)
244
+ el?.addEventListener('mousedown', handleMousedown)
245
+ },
246
+ { immediate: true }
247
+ )
248
+
249
+ onBeforeUnmount(() => {
250
+ target.value?.removeEventListener('mousedown', handleMousedown)
251
+ cleanup()
252
+ })
253
+
254
+ return { selection, initSelection, clearSelection }
255
+ }