@jvs-milkdown/components 1.2.0 → 1.2.2

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 (139) hide show
  1. package/lib/code-block/index.js +37 -2
  2. package/lib/code-block/index.js.map +1 -1
  3. package/lib/image-block/index.js +722 -137
  4. package/lib/image-block/index.js.map +1 -1
  5. package/lib/table-block/index.js +67 -44
  6. package/lib/table-block/index.js.map +1 -1
  7. package/lib/tsconfig.tsbuildinfo +1 -1
  8. package/package.json +10 -10
  9. package/src/code-block/config.ts +4 -0
  10. package/src/code-block/view/components/code-block.tsx +24 -0
  11. package/src/code-block/view/node-view.ts +15 -0
  12. package/src/image-block/config.ts +10 -0
  13. package/src/image-block/schema.ts +16 -0
  14. package/src/image-block/view/components/__tests__/image-viewer.onImageLoadError.spec.tsx +14 -0
  15. package/src/image-block/view/components/image-block.tsx +40 -0
  16. package/src/image-block/view/components/image-viewer.tsx +667 -98
  17. package/src/image-block/view/index.ts +42 -0
  18. package/src/table-block/view/component.tsx +31 -14
  19. package/src/table-block/view/operation.ts +49 -30
  20. package/lib/__internal__/components/icon.d.ts +0 -24
  21. package/lib/__internal__/components/icon.d.ts.map +0 -1
  22. package/lib/__internal__/components/image-input.d.ts +0 -17
  23. package/lib/__internal__/components/image-input.d.ts.map +0 -1
  24. package/lib/__internal__/keep-alive.d.ts +0 -2
  25. package/lib/__internal__/keep-alive.d.ts.map +0 -1
  26. package/lib/__internal__/meta.d.ts +0 -3
  27. package/lib/__internal__/meta.d.ts.map +0 -1
  28. package/lib/__tests__/setup.d.ts +0 -2
  29. package/lib/__tests__/setup.d.ts.map +0 -1
  30. package/lib/code-block/config.d.ts +0 -23
  31. package/lib/code-block/config.d.ts.map +0 -1
  32. package/lib/code-block/index.d.ts +0 -5
  33. package/lib/code-block/index.d.ts.map +0 -1
  34. package/lib/code-block/view/components/code-block.d.ts +0 -16
  35. package/lib/code-block/view/components/code-block.d.ts.map +0 -1
  36. package/lib/code-block/view/components/copy-button.d.ts +0 -9
  37. package/lib/code-block/view/components/copy-button.d.ts.map +0 -1
  38. package/lib/code-block/view/components/language-picker.d.ts +0 -5
  39. package/lib/code-block/view/components/language-picker.d.ts.map +0 -1
  40. package/lib/code-block/view/components/preview-panel.d.ts +0 -9
  41. package/lib/code-block/view/components/preview-panel.d.ts.map +0 -1
  42. package/lib/code-block/view/index.d.ts +0 -3
  43. package/lib/code-block/view/index.d.ts.map +0 -1
  44. package/lib/code-block/view/loader.d.ts +0 -13
  45. package/lib/code-block/view/loader.d.ts.map +0 -1
  46. package/lib/code-block/view/node-view.d.ts +0 -40
  47. package/lib/code-block/view/node-view.d.ts.map +0 -1
  48. package/lib/image-block/config.d.ts +0 -16
  49. package/lib/image-block/config.d.ts.map +0 -1
  50. package/lib/image-block/convert-plugin.d.ts +0 -2
  51. package/lib/image-block/convert-plugin.d.ts.map +0 -1
  52. package/lib/image-block/index.d.ts +0 -9
  53. package/lib/image-block/index.d.ts.map +0 -1
  54. package/lib/image-block/paste-rule.d.ts +0 -2
  55. package/lib/image-block/paste-rule.d.ts.map +0 -1
  56. package/lib/image-block/remark-plugin.d.ts +0 -2
  57. package/lib/image-block/remark-plugin.d.ts.map +0 -1
  58. package/lib/image-block/schema.d.ts +0 -3
  59. package/lib/image-block/schema.d.ts.map +0 -1
  60. package/lib/image-block/view/components/__tests__/image-viewer.onImageLoadError.spec.d.ts +0 -2
  61. package/lib/image-block/view/components/__tests__/image-viewer.onImageLoadError.spec.d.ts.map +0 -1
  62. package/lib/image-block/view/components/image-block.d.ts +0 -19
  63. package/lib/image-block/view/components/image-block.d.ts.map +0 -1
  64. package/lib/image-block/view/components/image-viewer.d.ts +0 -3
  65. package/lib/image-block/view/components/image-viewer.d.ts.map +0 -1
  66. package/lib/image-block/view/index.d.ts +0 -3
  67. package/lib/image-block/view/index.d.ts.map +0 -1
  68. package/lib/image-inline/components/image-inline.d.ts +0 -18
  69. package/lib/image-inline/components/image-inline.d.ts.map +0 -1
  70. package/lib/image-inline/config.d.ts +0 -11
  71. package/lib/image-inline/config.d.ts.map +0 -1
  72. package/lib/image-inline/index.d.ts +0 -5
  73. package/lib/image-inline/index.d.ts.map +0 -1
  74. package/lib/image-inline/view.d.ts +0 -3
  75. package/lib/image-inline/view.d.ts.map +0 -1
  76. package/lib/index.d.ts +0 -2
  77. package/lib/index.d.ts.map +0 -1
  78. package/lib/link-tooltip/command.d.ts +0 -2
  79. package/lib/link-tooltip/command.d.ts.map +0 -1
  80. package/lib/link-tooltip/configure.d.ts +0 -3
  81. package/lib/link-tooltip/configure.d.ts.map +0 -1
  82. package/lib/link-tooltip/edit/component.d.ts +0 -11
  83. package/lib/link-tooltip/edit/component.d.ts.map +0 -1
  84. package/lib/link-tooltip/edit/edit-configure.d.ts +0 -3
  85. package/lib/link-tooltip/edit/edit-configure.d.ts.map +0 -1
  86. package/lib/link-tooltip/edit/edit-view.d.ts +0 -15
  87. package/lib/link-tooltip/edit/edit-view.d.ts.map +0 -1
  88. package/lib/link-tooltip/index.d.ts +0 -7
  89. package/lib/link-tooltip/index.d.ts.map +0 -1
  90. package/lib/link-tooltip/preview/component.d.ts +0 -11
  91. package/lib/link-tooltip/preview/component.d.ts.map +0 -1
  92. package/lib/link-tooltip/preview/preview-configure.d.ts +0 -3
  93. package/lib/link-tooltip/preview/preview-configure.d.ts.map +0 -1
  94. package/lib/link-tooltip/preview/preview-view.d.ts +0 -14
  95. package/lib/link-tooltip/preview/preview-view.d.ts.map +0 -1
  96. package/lib/link-tooltip/slices.d.ts +0 -34
  97. package/lib/link-tooltip/slices.d.ts.map +0 -1
  98. package/lib/link-tooltip/tooltips.d.ts +0 -3
  99. package/lib/link-tooltip/tooltips.d.ts.map +0 -1
  100. package/lib/link-tooltip/utils.d.ts +0 -14
  101. package/lib/link-tooltip/utils.d.ts.map +0 -1
  102. package/lib/list-item-block/component.d.ts +0 -19
  103. package/lib/list-item-block/component.d.ts.map +0 -1
  104. package/lib/list-item-block/config.d.ts +0 -13
  105. package/lib/list-item-block/config.d.ts.map +0 -1
  106. package/lib/list-item-block/index.d.ts +0 -6
  107. package/lib/list-item-block/index.d.ts.map +0 -1
  108. package/lib/list-item-block/view.d.ts +0 -3
  109. package/lib/list-item-block/view.d.ts.map +0 -1
  110. package/lib/table-block/config.d.ts +0 -8
  111. package/lib/table-block/config.d.ts.map +0 -1
  112. package/lib/table-block/dnd/calc-drag-over.d.ts +0 -3
  113. package/lib/table-block/dnd/calc-drag-over.d.ts.map +0 -1
  114. package/lib/table-block/dnd/create-drag-handler.d.ts +0 -5
  115. package/lib/table-block/dnd/create-drag-handler.d.ts.map +0 -1
  116. package/lib/table-block/dnd/drag-over-handler.d.ts +0 -3
  117. package/lib/table-block/dnd/drag-over-handler.d.ts.map +0 -1
  118. package/lib/table-block/dnd/prepare-dnd-context.d.ts +0 -3
  119. package/lib/table-block/dnd/prepare-dnd-context.d.ts.map +0 -1
  120. package/lib/table-block/dnd/preview.d.ts +0 -3
  121. package/lib/table-block/dnd/preview.d.ts.map +0 -1
  122. package/lib/table-block/index.d.ts +0 -5
  123. package/lib/table-block/index.d.ts.map +0 -1
  124. package/lib/table-block/view/component.d.ts +0 -16
  125. package/lib/table-block/view/component.d.ts.map +0 -1
  126. package/lib/table-block/view/drag.d.ts +0 -10
  127. package/lib/table-block/view/drag.d.ts.map +0 -1
  128. package/lib/table-block/view/index.d.ts +0 -2
  129. package/lib/table-block/view/index.d.ts.map +0 -1
  130. package/lib/table-block/view/operation.d.ts +0 -13
  131. package/lib/table-block/view/operation.d.ts.map +0 -1
  132. package/lib/table-block/view/pointer.d.ts +0 -7
  133. package/lib/table-block/view/pointer.d.ts.map +0 -1
  134. package/lib/table-block/view/types.d.ts +0 -28
  135. package/lib/table-block/view/types.d.ts.map +0 -1
  136. package/lib/table-block/view/utils.d.ts +0 -12
  137. package/lib/table-block/view/utils.d.ts.map +0 -1
  138. package/lib/table-block/view/view.d.ts +0 -22
  139. package/lib/table-block/view/view.d.ts.map +0 -1
@@ -8,45 +8,63 @@ import { IMAGE_DATA_TYPE } from '../../schema'
8
8
 
9
9
  keepAlive(h, Fragment)
10
10
 
11
+ const ratioMap: Record<string, number> = {
12
+ '1:1': 1,
13
+ '4:3': 4 / 3,
14
+ '3:2': 3 / 2,
15
+ '16:9': 16 / 9,
16
+ }
17
+
11
18
  export const ImageViewer = defineComponent<MilkdownImageBlockProps>({
12
19
  props: {
13
- src: {
14
- type: Object,
15
- required: true,
16
- },
17
- caption: {
18
- type: Object,
19
- required: true,
20
- },
21
- ratio: {
22
- type: Object,
23
- required: true,
24
- },
25
- align: {
26
- type: Object,
27
- required: true,
28
- },
29
- selected: {
30
- type: Object,
31
- required: true,
32
- },
33
- readonly: {
34
- type: Object,
35
- required: true,
36
- },
37
- setAttr: {
38
- type: Function,
39
- required: true,
40
- },
41
- config: {
42
- type: Object,
43
- required: true,
44
- },
20
+ src: { type: Object, required: true },
21
+ caption: { type: Object, required: true },
22
+ ratio: { type: Object, required: true },
23
+ align: { type: Object, required: true },
24
+ cropRatio: { type: Object, required: true },
25
+ cropTop: { type: Object, required: true },
26
+ cropLeft: { type: Object, required: true },
27
+ cropWidth: { type: Object, required: true },
28
+ cropHeight: { type: Object, required: true },
29
+ borderWidth: { type: Object, required: true },
30
+ borderColor: { type: Object, required: true },
31
+ borderStyle: { type: Object, required: true },
32
+ selected: { type: Object, required: true },
33
+ readonly: { type: Object, required: true },
34
+ setAttr: { type: Function, required: true },
35
+ config: { type: Object, required: true },
45
36
  },
46
- setup({ src, caption, ratio, readonly, setAttr, config }) {
37
+ setup({
38
+ src,
39
+ caption,
40
+ ratio,
41
+ readonly,
42
+ setAttr,
43
+ config,
44
+ cropRatio,
45
+ cropTop,
46
+ cropLeft,
47
+ cropWidth,
48
+ cropHeight,
49
+ borderWidth,
50
+ borderColor,
51
+ borderStyle,
52
+ }) {
47
53
  const imageRef = ref<HTMLImageElement>()
54
+ const wrapperRef = ref<HTMLDivElement>()
48
55
  const showCaption = ref(Boolean(caption.value?.length))
49
56
  const timer = ref(0)
57
+ const showBorderPanel = ref(false)
58
+ const isCropping = ref(false)
59
+
60
+ const baseDisplayW = ref(0)
61
+ const baseDisplayH = ref(0)
62
+ const liveRatio = ref<number | null>(null)
63
+ const localBorderWidth = ref<number | null>(null)
64
+ const localBorderColor = ref<string | null>(null)
65
+
66
+ // Local crop state for interactive editing
67
+ const localCrop = ref({ top: 0, left: 0, width: 1, height: 1 })
50
68
 
51
69
  let resizeDir = ''
52
70
  let startY = 0
@@ -54,6 +72,14 @@ export const ImageViewer = defineComponent<MilkdownImageBlockProps>({
54
72
  let startHeight = 0
55
73
  let startWidth = 0
56
74
 
75
+ const isCropped = () => {
76
+ const t = cropTop.value ?? 0
77
+ const l = cropLeft.value ?? 0
78
+ const w = cropWidth.value ?? 1
79
+ const h = cropHeight.value ?? 1
80
+ return t > 0.001 || l > 0.001 || w < 0.999 || h < 0.999
81
+ }
82
+
57
83
  const onImageLoad = () => {
58
84
  const image = imageRef.value
59
85
  if (!image) return
@@ -68,21 +94,23 @@ export const ImageViewer = defineComponent<MilkdownImageBlockProps>({
68
94
 
69
95
  const height = image.naturalHeight
70
96
  const width = image.naturalWidth
71
- let transformedHeight =
72
- width < maxWidth ? height : maxWidth * (height / width)
97
+ if (!height || !width) return
73
98
 
74
- if (config.maxHeight && transformedHeight > config.maxHeight)
75
- transformedHeight = config.maxHeight
99
+ const aspect = width / height
100
+
101
+ let transformedWidth = width < maxWidth ? width : maxWidth
102
+ let transformedHeight = transformedWidth / aspect
76
103
 
77
- const h = (transformedHeight * (ratio.value ?? 1)).toFixed(2)
78
- image.dataset.origin = transformedHeight.toFixed(2)
79
- image.dataset.height = h
80
- image.style.height = `${h}px`
104
+ if (config.maxHeight && transformedHeight > config.maxHeight) {
105
+ transformedHeight = config.maxHeight
106
+ transformedWidth = transformedHeight * aspect
107
+ }
81
108
 
82
- if (config.maxWidth) image.style.maxWidth = `${config.maxWidth}px`
109
+ baseDisplayH.value = transformedHeight
110
+ baseDisplayW.value = transformedWidth
83
111
  }
84
112
 
85
- const onToggleCaption = (e: PointerEvent) => {
113
+ const onToggleCaption = (e: MouseEvent) => {
86
114
  e.preventDefault()
87
115
  e.stopPropagation()
88
116
  if (readonly.value) return
@@ -112,8 +140,6 @@ export const ImageViewer = defineComponent<MilkdownImageBlockProps>({
112
140
 
113
141
  const onResizeHandlePointerMove = (e: PointerEvent) => {
114
142
  e.preventDefault()
115
- const image = imageRef.value
116
- if (!image) return
117
143
 
118
144
  const deltaY = e.clientY - startY
119
145
  const deltaX = e.clientX - startX
@@ -121,6 +147,7 @@ export const ImageViewer = defineComponent<MilkdownImageBlockProps>({
121
147
  const aspect = startHeight / (startWidth || 1)
122
148
  let dhY = resizeDir.includes('bottom') ? deltaY : -deltaY
123
149
  let dhX = 0
150
+
124
151
  if (resizeDir.includes('right')) {
125
152
  dhX = deltaX * aspect
126
153
  } else if (resizeDir.includes('left')) {
@@ -128,32 +155,53 @@ export const ImageViewer = defineComponent<MilkdownImageBlockProps>({
128
155
  }
129
156
 
130
157
  const dh = Math.abs(dhX) > Math.abs(dhY) ? dhX : dhY
131
- let height = startHeight + dh
158
+ let newWrapperHeight = startHeight + dh
159
+
160
+ let newWrapperWidth = newWrapperHeight * (startWidth / (startHeight || 1))
161
+
162
+ if (newWrapperHeight < 20) {
163
+ newWrapperHeight = 20
164
+ newWrapperWidth = newWrapperHeight * (startWidth / (startHeight || 1))
165
+ }
166
+
167
+ if (newWrapperWidth < 20) {
168
+ newWrapperWidth = 20
169
+ newWrapperHeight = newWrapperWidth / (startWidth / (startHeight || 1))
170
+ }
171
+
172
+ const image = imageRef.value
173
+ const host = image?.closest('.milkdown-image-block')
174
+ const hostWidth = host?.getBoundingClientRect().width
175
+ const maxW =
176
+ config.maxWidth && hostWidth
177
+ ? Math.min(config.maxWidth, hostWidth)
178
+ : hostWidth || Infinity
179
+
180
+ if (newWrapperWidth > maxW) {
181
+ newWrapperWidth = maxW
182
+ newWrapperHeight = newWrapperWidth / (startWidth / (startHeight || 1))
183
+ }
132
184
 
133
- if (height < 20) height = 20
134
- if (config.maxHeight && height > config.maxHeight)
135
- height = config.maxHeight
185
+ if (config.maxHeight && newWrapperHeight > config.maxHeight) {
186
+ newWrapperHeight = config.maxHeight
187
+ }
188
+
189
+ const cHeight =
190
+ isCropped() && !isCropping.value ? (cropHeight.value ?? 1) : 1
191
+ const newBaseHeight = newWrapperHeight / cHeight
136
192
 
137
- const h = Number(height).toFixed(2)
138
- image.dataset.height = h
139
- image.style.height = `${h}px`
193
+ const newRatio = newBaseHeight / (baseDisplayH.value || 1)
194
+ liveRatio.value = Number(newRatio.toFixed(2))
140
195
  }
141
196
 
142
197
  const onResizeHandlePointerUp = () => {
143
198
  window.removeEventListener('pointermove', onResizeHandlePointerMove)
144
199
  window.removeEventListener('pointerup', onResizeHandlePointerUp)
145
200
 
146
- const image = imageRef.value
147
- if (!image) return
148
-
149
- const originHeight = Number(image.dataset.origin)
150
- const currentHeight = Number(image.dataset.height)
151
- const ratio = Number.parseFloat(
152
- Number(currentHeight / originHeight).toFixed(2)
153
- )
154
- if (Number.isNaN(ratio)) return
155
-
156
- setAttr('ratio', ratio)
201
+ if (liveRatio.value !== null) {
202
+ setAttr('ratio', liveRatio.value)
203
+ liveRatio.value = null
204
+ }
157
205
  }
158
206
 
159
207
  const onResizeHandlePointerDown = (e: PointerEvent, dir: string) => {
@@ -163,52 +211,573 @@ export const ImageViewer = defineComponent<MilkdownImageBlockProps>({
163
211
  resizeDir = dir
164
212
  startY = e.clientY
165
213
  startX = e.clientX
166
- const image = imageRef.value
167
- if (image) {
168
- startHeight = image.getBoundingClientRect().height
169
- startWidth = image.getBoundingClientRect().width
214
+ const wrapper = wrapperRef.value
215
+ if (wrapper) {
216
+ startHeight = wrapper.getBoundingClientRect().height
217
+ startWidth = wrapper.getBoundingClientRect().width
170
218
  }
219
+ liveRatio.value = ratio.value ?? 1
171
220
  window.addEventListener('pointermove', onResizeHandlePointerMove)
172
221
  window.addEventListener('pointerup', onResizeHandlePointerUp)
173
222
  }
174
223
 
224
+ // --- Crop box interaction ---
225
+ const borderStyleOptions = [
226
+ { label: 'none', value: 'none' },
227
+ { label: 'solid', value: 'solid' },
228
+ { label: 'dashed', value: 'dashed' },
229
+ { label: 'dotted', value: 'dotted' },
230
+ ]
231
+
232
+ const onToggleCropPanel = (e: MouseEvent) => {
233
+ e.preventDefault()
234
+ e.stopPropagation()
235
+ if (readonly.value) return
236
+ showBorderPanel.value = false
237
+ if (isCropping.value) {
238
+ isCropping.value = false
239
+ } else {
240
+ startCropping()
241
+ }
242
+ }
243
+
244
+ const onToggleBorderPanel = (e: MouseEvent) => {
245
+ e.preventDefault()
246
+ e.stopPropagation()
247
+ if (readonly.value) return
248
+ showBorderPanel.value = !showBorderPanel.value
249
+ }
250
+
251
+ const closePanels = () => {
252
+ showBorderPanel.value = false
253
+ }
254
+
255
+ const startCropping = () => {
256
+ localCrop.value = {
257
+ top: cropTop.value ?? 0,
258
+ left: cropLeft.value ?? 0,
259
+ width: cropWidth.value ?? 1,
260
+ height: cropHeight.value ?? 1,
261
+ }
262
+ isCropping.value = true
263
+ }
264
+
265
+ const applyCrop = () => {
266
+ setAttr('cropTop', localCrop.value.top)
267
+ setAttr('cropLeft', localCrop.value.left)
268
+ setAttr('cropWidth', localCrop.value.width)
269
+ setAttr('cropHeight', localCrop.value.height)
270
+ isCropping.value = false
271
+ }
272
+
273
+ const cancelCrop = () => {
274
+ isCropping.value = false
275
+ }
276
+
277
+ const resetCrop = () => {
278
+ setAttr('cropTop', 0)
279
+ setAttr('cropLeft', 0)
280
+ setAttr('cropWidth', 1)
281
+ setAttr('cropHeight', 1)
282
+ }
283
+
284
+ // Crop box drag state
285
+ let cropDragType = '' // 'move' | 'nw' | 'ne' | 'sw' | 'se' | 'n' | 's' | 'e' | 'w'
286
+ let cropStartX = 0
287
+ let cropStartY = 0
288
+ let cropStartBox = { top: 0, left: 0, width: 1, height: 1 }
289
+
290
+ const onCropBoxPointerDown = (e: PointerEvent, type: string) => {
291
+ e.preventDefault()
292
+ e.stopPropagation()
293
+ cropDragType = type
294
+ cropStartX = e.clientX
295
+ cropStartY = e.clientY
296
+ cropStartBox = { ...localCrop.value }
297
+ window.addEventListener('pointermove', onCropBoxPointerMove)
298
+ window.addEventListener('pointerup', onCropBoxPointerUp)
299
+ }
300
+
301
+ const onCropBoxPointerMove = (e: PointerEvent) => {
302
+ const wrapper = wrapperRef.value
303
+ if (!wrapper) return
304
+ const rect = wrapper.getBoundingClientRect()
305
+ if (!rect.width || !rect.height) return
306
+
307
+ const dx = (e.clientX - cropStartX) / rect.width
308
+ const dy = (e.clientY - cropStartY) / rect.height
309
+
310
+ const ar = ratioMap[cropRatio.value ?? 'free']
311
+ let box = { ...cropStartBox }
312
+
313
+ if (cropDragType === 'move') {
314
+ box.left = Math.max(0, Math.min(1 - box.width, cropStartBox.left + dx))
315
+ box.top = Math.max(0, Math.min(1 - box.height, cropStartBox.top + dy))
316
+ } else {
317
+ const isLeft =
318
+ cropDragType === 'nw' || cropDragType === 'sw' || cropDragType === 'w'
319
+ const isTop =
320
+ cropDragType === 'nw' || cropDragType === 'ne' || cropDragType === 'n'
321
+ const isRight =
322
+ cropDragType === 'ne' || cropDragType === 'se' || cropDragType === 'e'
323
+ const isBottom =
324
+ cropDragType === 'sw' || cropDragType === 'se' || cropDragType === 's'
325
+ const isHorizontal = cropDragType === 'n' || cropDragType === 's'
326
+ const isVertical = cropDragType === 'e' || cropDragType === 'w'
327
+
328
+ let newLeft = cropStartBox.left
329
+ let newTop = cropStartBox.top
330
+ let newWidth = cropStartBox.width
331
+ let newHeight = cropStartBox.height
332
+
333
+ if (isLeft) {
334
+ newWidth = cropStartBox.width - dx
335
+ newLeft = cropStartBox.left + dx
336
+ } else if (isRight) {
337
+ newWidth = cropStartBox.width + dx
338
+ }
339
+
340
+ if (isTop) {
341
+ newHeight = cropStartBox.height - dy
342
+ newTop = cropStartBox.top + dy
343
+ } else if (isBottom) {
344
+ newHeight = cropStartBox.height + dy
345
+ }
346
+
347
+ // Enforce aspect ratio if set (only for corner handles)
348
+ if (ar && !isHorizontal && !isVertical) {
349
+ const cropPixelW = newWidth * rect.width
350
+ const desiredH = cropPixelW / ar / rect.height
351
+
352
+ if (isTop) {
353
+ newTop = newTop + newHeight - desiredH
354
+ }
355
+ newHeight = desiredH
356
+ }
357
+
358
+ // Clamp
359
+ if (newWidth < 0.05) newWidth = 0.05
360
+ if (newHeight < 0.05) newHeight = 0.05
361
+ if (newLeft < 0) {
362
+ newWidth += newLeft
363
+ newLeft = 0
364
+ }
365
+ if (newTop < 0) {
366
+ newHeight += newTop
367
+ newTop = 0
368
+ }
369
+ if (newLeft + newWidth > 1) newWidth = 1 - newLeft
370
+ if (newTop + newHeight > 1) newHeight = 1 - newTop
371
+
372
+ box = { left: newLeft, top: newTop, width: newWidth, height: newHeight }
373
+ }
374
+
375
+ localCrop.value = box
376
+ }
377
+
378
+ const onCropBoxPointerUp = () => {
379
+ window.removeEventListener('pointermove', onCropBoxPointerMove)
380
+ window.removeEventListener('pointerup', onCropBoxPointerUp)
381
+ cropDragType = ''
382
+ }
383
+
175
384
  return () => {
385
+ const currentBorderStyle = borderStyle.value ?? 'none'
386
+ const currentBorderWidth =
387
+ localBorderWidth.value !== null
388
+ ? localBorderWidth.value
389
+ : (borderWidth.value ?? 0)
390
+ const currentBorderColor =
391
+ localBorderColor.value !== null
392
+ ? localBorderColor.value
393
+ : (borderColor.value ?? '#000000')
394
+ const cTop = cropTop.value ?? 0
395
+ const cLeft = cropLeft.value ?? 0
396
+ const cWidth = cropWidth.value ?? 1
397
+ const cHeight = cropHeight.value ?? 1
398
+
399
+ const cropped = isCropped()
400
+
401
+ const borderStyleStr =
402
+ currentBorderStyle !== 'none' && currentBorderWidth > 0
403
+ ? `${currentBorderWidth}px ${currentBorderStyle} ${currentBorderColor}`
404
+ : undefined
405
+
406
+ const currentRatio =
407
+ liveRatio.value !== null ? liveRatio.value : (ratio.value ?? 1)
408
+
409
+ let baseW = baseDisplayW.value * currentRatio
410
+ let baseH = baseDisplayH.value * currentRatio
411
+
412
+ const image = imageRef.value
413
+ const host = image?.closest('.milkdown-image-block')
414
+ const hostWidth = host?.getBoundingClientRect().width
415
+ const maxW =
416
+ config.maxWidth && hostWidth
417
+ ? Math.min(config.maxWidth, hostWidth)
418
+ : hostWidth || Infinity
419
+
420
+ const visibleW = isCropping.value
421
+ ? baseW
422
+ : cropped
423
+ ? baseW * cWidth
424
+ : baseW
425
+
426
+ if (visibleW > maxW && maxW > 0) {
427
+ const scale = maxW / visibleW
428
+ baseW = baseW * scale
429
+ baseH = baseH * scale
430
+ }
431
+
432
+ const cropPxW = baseW * cWidth
433
+ const cropPxH = baseH * cHeight
434
+ const cropPxL = baseW * cLeft
435
+ const cropPxT = baseH * cTop
436
+
437
+ const wrapperStyle: Record<string, string> = {
438
+ minWidth: '20px',
439
+ minHeight: '20px',
440
+ border: !isCropping.value ? (borderStyleStr ?? '') : '',
441
+ }
442
+ if (cropped && !isCropping.value && cropPxW > 0 && cropPxH > 0) {
443
+ wrapperStyle.width = `${cropPxW}px`
444
+ wrapperStyle.height = `${cropPxH}px`
445
+ }
446
+ const cropClipStyle: Record<string, string> | null =
447
+ cropped && !isCropping.value && baseW > 0 && baseH > 0
448
+ ? {
449
+ width: `${cropPxW}px`,
450
+ height: `${cropPxH}px`,
451
+ overflow: 'hidden',
452
+ }
453
+ : null
454
+
455
+ const imgStyle: Record<string, string> = {
456
+ minWidth: '0px',
457
+ minHeight: '0px',
458
+ }
459
+ if (cropped && !isCropping.value && baseW > 0 && baseH > 0) {
460
+ imgStyle.width = `${baseW}px`
461
+ imgStyle.height = `${baseH}px`
462
+ imgStyle.maxWidth = 'none'
463
+ imgStyle.marginLeft = `${-cropPxL}px`
464
+ imgStyle.marginTop = `${-cropPxT}px`
465
+ } else {
466
+ if (baseH > 0 && baseW > 0) {
467
+ imgStyle.width = `${baseW}px`
468
+ imgStyle.height = `${baseH}px`
469
+ if (config.maxWidth) imgStyle.maxWidth = `${config.maxWidth}px`
470
+ }
471
+ }
472
+
176
473
  return (
177
474
  <>
178
- <div class="image-wrapper">
179
- <div class="operation">
180
- <div class="operation-item" onPointerdown={onToggleCaption}>
475
+ {!isCropping.value ? (
476
+ <div
477
+ class="image-toolbar"
478
+ onClick={(e) => e.stopPropagation()}
479
+ onMousedown={(e) => e.stopPropagation()}
480
+ onDragstart={(e) => {
481
+ e.preventDefault()
482
+ e.stopPropagation()
483
+ }}
484
+ >
485
+ <div
486
+ class="image-toolbar-item"
487
+ title="标题"
488
+ onClick={onToggleCaption}
489
+ >
181
490
  <Icon icon={config.captionIcon} />
182
491
  </div>
492
+ {config.cropIcon ? (
493
+ <div
494
+ class="image-toolbar-item"
495
+ title="裁剪"
496
+ onClick={onToggleCropPanel}
497
+ >
498
+ <Icon icon={config.cropIcon} />
499
+ </div>
500
+ ) : null}
501
+ {cropped ? (
502
+ <div
503
+ class="image-toolbar-item"
504
+ title="恢复裁剪"
505
+ onClick={(e: MouseEvent) => {
506
+ e.stopPropagation()
507
+ resetCrop()
508
+ }}
509
+ >
510
+ <Icon icon={config.resetCropIcon ?? '↩'} />
511
+ </div>
512
+ ) : null}
513
+ {config.borderIcon ? (
514
+ <div
515
+ class="image-toolbar-item"
516
+ title="边框"
517
+ onClick={onToggleBorderPanel}
518
+ >
519
+ <Icon icon={config.borderIcon} />
520
+ </div>
521
+ ) : null}
522
+
523
+ {showBorderPanel.value ? (
524
+ <div
525
+ class="setting-panel border-panel"
526
+ draggable="true"
527
+ onClick={(e) => e.stopPropagation()}
528
+ onMousedown={(e) => e.stopPropagation()}
529
+ onPointerdown={(e) => e.stopPropagation()}
530
+ onDragstart={(e) => {
531
+ e.preventDefault()
532
+ e.stopPropagation()
533
+ }}
534
+ >
535
+ <div class="setting-panel-title">{'边框设置'}</div>
536
+ <div class="setting-row">
537
+ <span class="setting-label">{'样式'}</span>
538
+ <div class="border-style-options">
539
+ {borderStyleOptions.map((opt) => (
540
+ <div
541
+ class={`border-style-option ${currentBorderStyle === opt.value ? 'active' : ''}`}
542
+ onClick={(e: MouseEvent) => {
543
+ e.stopPropagation()
544
+ setAttr('borderStyle', opt.value)
545
+ }}
546
+ >
547
+ {opt.label === 'none'
548
+ ? '无'
549
+ : opt.label === 'solid'
550
+ ? '实线'
551
+ : opt.label === 'dashed'
552
+ ? '虚线'
553
+ : '点线'}
554
+ </div>
555
+ ))}
556
+ </div>
557
+ </div>
558
+ {currentBorderStyle !== 'none' ? (
559
+ <div class="setting-row">
560
+ <span class="setting-label">{'宽度'}</span>
561
+ <input
562
+ draggable="true"
563
+ type="range"
564
+ min="1"
565
+ max="10"
566
+ value={currentBorderWidth}
567
+ onInput={(e: Event) => {
568
+ const target = e.target as HTMLInputElement
569
+ localBorderWidth.value = Number(target.value)
570
+ }}
571
+ onChange={(e: Event) => {
572
+ const target = e.target as HTMLInputElement
573
+ setAttr('borderWidth', Number(target.value))
574
+ localBorderWidth.value = null
575
+ }}
576
+ onClick={(e: MouseEvent) => e.stopPropagation()}
577
+ onMousedown={(e: MouseEvent) => e.stopPropagation()}
578
+ onPointerdown={(e: PointerEvent) => e.stopPropagation()}
579
+ onDragstart={(e: DragEvent) => {
580
+ e.preventDefault()
581
+ e.stopPropagation()
582
+ }}
583
+ />
584
+ <span class="setting-value">{currentBorderWidth}px</span>
585
+ </div>
586
+ ) : null}
587
+ {currentBorderStyle !== 'none' ? (
588
+ <div class="setting-row">
589
+ <span class="setting-label">{'颜色'}</span>
590
+ <input
591
+ draggable="true"
592
+ type="color"
593
+ value={currentBorderColor}
594
+ onInput={(e: Event) => {
595
+ const target = e.target as HTMLInputElement
596
+ localBorderColor.value = target.value
597
+ }}
598
+ onChange={(e: Event) => {
599
+ const target = e.target as HTMLInputElement
600
+ setAttr('borderColor', target.value)
601
+ localBorderColor.value = null
602
+ }}
603
+ onClick={(e: MouseEvent) => e.stopPropagation()}
604
+ onMousedown={(e: MouseEvent) => e.stopPropagation()}
605
+ onPointerdown={(e: PointerEvent) => e.stopPropagation()}
606
+ onDragstart={(e: DragEvent) => {
607
+ e.preventDefault()
608
+ e.stopPropagation()
609
+ }}
610
+ />
611
+ </div>
612
+ ) : null}
613
+ </div>
614
+ ) : null}
183
615
  </div>
184
- <img
185
- ref={imageRef}
186
- data-type={IMAGE_DATA_TYPE}
187
- onLoad={onImageLoad}
188
- src={src.value}
189
- alt={caption.value}
190
- onError={(e) =>
191
- Promise.resolve(config.onImageLoadError?.(e)).catch(() => {})
192
- }
193
- />
194
- <div
195
- class="image-resize-handle top-left"
196
- onPointerdown={(e) => onResizeHandlePointerDown(e, 'top-left')}
197
- />
198
- <div
199
- class="image-resize-handle top-right"
200
- onPointerdown={(e) => onResizeHandlePointerDown(e, 'top-right')}
201
- />
202
- <div
203
- class="image-resize-handle bottom-left"
204
- onPointerdown={(e) => onResizeHandlePointerDown(e, 'bottom-left')}
205
- />
206
- <div
207
- class="image-resize-handle bottom-right"
208
- onPointerdown={(e) =>
209
- onResizeHandlePointerDown(e, 'bottom-right')
210
- }
211
- />
616
+ ) : null}
617
+
618
+ <div
619
+ class={`image-wrapper${isCropping.value ? ' cropping' : ''}`}
620
+ ref={wrapperRef}
621
+ style={wrapperStyle}
622
+ onClick={closePanels}
623
+ >
624
+ {cropClipStyle ? (
625
+ <div class="crop-clip" style={cropClipStyle}>
626
+ <img
627
+ draggable="false"
628
+ ref={imageRef}
629
+ data-type={IMAGE_DATA_TYPE}
630
+ onLoad={onImageLoad}
631
+ src={src.value}
632
+ alt={caption.value}
633
+ style={imgStyle}
634
+ onError={(e) =>
635
+ Promise.resolve(config.onImageLoadError?.(e)).catch(
636
+ () => {}
637
+ )
638
+ }
639
+ />
640
+ </div>
641
+ ) : (
642
+ <img
643
+ draggable="false"
644
+ ref={imageRef}
645
+ data-type={IMAGE_DATA_TYPE}
646
+ onLoad={onImageLoad}
647
+ src={src.value}
648
+ alt={caption.value}
649
+ style={imgStyle}
650
+ onError={(e) =>
651
+ Promise.resolve(config.onImageLoadError?.(e)).catch(() => {})
652
+ }
653
+ />
654
+ )}
655
+
656
+ {/* Crop overlay */}
657
+ {isCropping.value ? (
658
+ <div class="crop-overlay" onClick={(e) => e.stopPropagation()}>
659
+ <div
660
+ class="crop-box"
661
+ style={{
662
+ top: `${(localCrop.value.top * 100).toFixed(2)}%`,
663
+ left: `${(localCrop.value.left * 100).toFixed(2)}%`,
664
+ width: `${(localCrop.value.width * 100).toFixed(2)}%`,
665
+ height: `${(localCrop.value.height * 100).toFixed(2)}%`,
666
+ }}
667
+ onPointerdown={(e: PointerEvent) =>
668
+ onCropBoxPointerDown(e, 'move')
669
+ }
670
+ >
671
+ {/* 3x3 grid lines */}
672
+ <div class="crop-grid">
673
+ <div class="crop-grid-line horizontal first" />
674
+ <div class="crop-grid-line horizontal second" />
675
+ <div class="crop-grid-line vertical first" />
676
+ <div class="crop-grid-line vertical second" />
677
+ </div>
678
+ {/* Corner handles */}
679
+ <div
680
+ class="crop-handle nw"
681
+ onPointerdown={(e: PointerEvent) =>
682
+ onCropBoxPointerDown(e, 'nw')
683
+ }
684
+ />
685
+ <div
686
+ class="crop-handle ne"
687
+ onPointerdown={(e: PointerEvent) =>
688
+ onCropBoxPointerDown(e, 'ne')
689
+ }
690
+ />
691
+ <div
692
+ class="crop-handle sw"
693
+ onPointerdown={(e: PointerEvent) =>
694
+ onCropBoxPointerDown(e, 'sw')
695
+ }
696
+ />
697
+ <div
698
+ class="crop-handle se"
699
+ onPointerdown={(e: PointerEvent) =>
700
+ onCropBoxPointerDown(e, 'se')
701
+ }
702
+ />
703
+ {/* Edge midpoint handles */}
704
+ <div
705
+ class="crop-handle n"
706
+ onPointerdown={(e: PointerEvent) =>
707
+ onCropBoxPointerDown(e, 'n')
708
+ }
709
+ />
710
+ <div
711
+ class="crop-handle s"
712
+ onPointerdown={(e: PointerEvent) =>
713
+ onCropBoxPointerDown(e, 's')
714
+ }
715
+ />
716
+ <div
717
+ class="crop-handle e"
718
+ onPointerdown={(e: PointerEvent) =>
719
+ onCropBoxPointerDown(e, 'e')
720
+ }
721
+ />
722
+ <div
723
+ class="crop-handle w"
724
+ onPointerdown={(e: PointerEvent) =>
725
+ onCropBoxPointerDown(e, 'w')
726
+ }
727
+ />
728
+ </div>
729
+ {/* Bottom toolbar */}
730
+ <div class="crop-toolbar">
731
+ <div
732
+ class="crop-toolbar-btn"
733
+ onClick={(e: MouseEvent) => {
734
+ e.stopPropagation()
735
+ applyCrop()
736
+ }}
737
+ >
738
+ <Icon icon={config.confirmIcon ?? '✓'} />
739
+ </div>
740
+ <div
741
+ class="crop-toolbar-btn"
742
+ onClick={(e: MouseEvent) => {
743
+ e.stopPropagation()
744
+ cancelCrop()
745
+ }}
746
+ >
747
+ <Icon icon={config.cancelIcon ?? '✕'} />
748
+ </div>
749
+ </div>
750
+ </div>
751
+ ) : null}
752
+
753
+ {!isCropping.value ? (
754
+ <>
755
+ <div
756
+ class="image-resize-handle top-left"
757
+ onPointerdown={(e) =>
758
+ onResizeHandlePointerDown(e, 'top-left')
759
+ }
760
+ />
761
+ <div
762
+ class="image-resize-handle top-right"
763
+ onPointerdown={(e) =>
764
+ onResizeHandlePointerDown(e, 'top-right')
765
+ }
766
+ />
767
+ <div
768
+ class="image-resize-handle bottom-left"
769
+ onPointerdown={(e) =>
770
+ onResizeHandlePointerDown(e, 'bottom-left')
771
+ }
772
+ />
773
+ <div
774
+ class="image-resize-handle bottom-right"
775
+ onPointerdown={(e) =>
776
+ onResizeHandlePointerDown(e, 'bottom-right')
777
+ }
778
+ />
779
+ </>
780
+ ) : null}
212
781
  </div>
213
782
  {showCaption.value && (
214
783
  <input