mjpic 1.0.20 → 1.0.22

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.
@@ -6,7 +6,7 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <meta name="description" content="敏捷图片(mjpic)是一个轻量级网页版图片处理工具,设计灵感来源于光影魔术手。" />
8
8
  <title>敏捷图片 (mjpic) - 轻量级网页版图片处理工具</title>
9
- <script type="module" crossorigin src="/assets/index-BJ_kQgaS.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-DORSlWYO.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-C6nMBMvY.css">
11
11
  </head>
12
12
  <body>
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "mjpic",
3
3
  "description": "敏捷图片(mjpic)是一个轻量级网页版图片处理工具,设计灵感来源于光影魔术手。",
4
- "version": "1.0.20",
4
+ "version": "1.0.22",
5
5
  "type": "module",
6
6
  "bin": {
7
- "mjpic": "./dist/cli/cli.js"
7
+ "mjpic": "dist/cli/cli.js"
8
8
  },
9
9
  "scripts": {
10
10
  "client:dev": "vite",
@@ -10,6 +10,19 @@ interface CanvasAreaProps {
10
10
  stageRef: React.MutableRefObject<Konva.Stage | null>;
11
11
  }
12
12
 
13
+ type TransformBox = {
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ rotation: number;
19
+ };
20
+
21
+ type Point = {
22
+ x: number;
23
+ y: number;
24
+ };
25
+
13
26
  export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
14
27
  const { previewImage, config, loadImage, setOriginalSize, originalWidth, originalHeight, cropRect, setCropRect, updateConfig } = useImageStore();
15
28
  const { isStraightenToolActive, setStraightenToolActive, activeTool } = useUIStore();
@@ -29,6 +42,8 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
29
42
  const imgDimensionsRef = useRef({ width: 0, height: 0 });
30
43
  const cropDragRafRef = useRef<number | null>(null);
31
44
  const pendingCropRectRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null);
45
+ const pendingTransformBoxRef = useRef<TransformBox | null>(null);
46
+ const transformStartBoxRef = useRef<TransformBox | null>(null);
32
47
 
33
48
  // Straighten Tool State
34
49
  const [straightenLine, setStraightenLine] = useState<{ start: {x: number, y: number}, end: {x: number, y: number} } | null>(null);
@@ -108,6 +123,300 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
108
123
  contentX = cx - displayTotalWidth / 2;
109
124
  contentY = cy - displayTotalHeight / 2;
110
125
  }
126
+
127
+ const clampValue = (value: number, min: number, max: number) => {
128
+ if (min > max) return min;
129
+ return Math.min(Math.max(value, min), max);
130
+ };
131
+
132
+ const resolveCropTransformBox = (oldBox: TransformBox, proposedBox: TransformBox, activeAnchor?: string | null): TransformBox => {
133
+ const boundsLeft = contentX;
134
+ const boundsTop = contentY;
135
+ const boundsRight = contentX + displayTotalWidth;
136
+ const boundsBottom = contentY + displayTotalHeight;
137
+
138
+ const oldLeft = oldBox.x;
139
+ const oldTop = oldBox.y;
140
+ const oldRight = oldBox.x + oldBox.width;
141
+ const oldBottom = oldBox.y + oldBox.height;
142
+ const oldCenterX = oldBox.x + oldBox.width / 2;
143
+ const oldCenterY = oldBox.y + oldBox.height / 2;
144
+ const makeBox = (x: number, y: number, width: number, height: number): TransformBox => ({
145
+ x,
146
+ y,
147
+ width,
148
+ height,
149
+ rotation: oldBox.rotation
150
+ });
151
+
152
+ const minFreeWidth = 20;
153
+ const minFreeHeight = 20;
154
+
155
+ const aspectRatio = !isFreeCrop && config.crop?.aspectRatio
156
+ ? (() => {
157
+ const [w, h] = config.crop.aspectRatio.split(':').map((value) => parseInt(value, 10));
158
+ return w > 0 && h > 0 ? w / h : null;
159
+ })()
160
+ : null;
161
+
162
+ if (!activeAnchor) {
163
+ const width = clampValue(proposedBox.width, minFreeWidth, boundsRight - boundsLeft);
164
+ const height = clampValue(proposedBox.height, minFreeHeight, boundsBottom - boundsTop);
165
+ return {
166
+ ...makeBox(
167
+ clampValue(proposedBox.x, boundsLeft, boundsRight - width),
168
+ clampValue(proposedBox.y, boundsTop, boundsBottom - height),
169
+ width,
170
+ height
171
+ )
172
+ };
173
+ }
174
+
175
+ if (!aspectRatio) {
176
+ if (activeAnchor === 'top-left') {
177
+ const left = clampValue(proposedBox.x, boundsLeft, oldRight - minFreeWidth);
178
+ const top = clampValue(proposedBox.y, boundsTop, oldBottom - minFreeHeight);
179
+ return makeBox(left, top, oldRight - left, oldBottom - top);
180
+ }
181
+ if (activeAnchor === 'top-right') {
182
+ const right = clampValue(proposedBox.x + proposedBox.width, oldLeft + minFreeWidth, boundsRight);
183
+ const top = clampValue(proposedBox.y, boundsTop, oldBottom - minFreeHeight);
184
+ return makeBox(oldLeft, top, right - oldLeft, oldBottom - top);
185
+ }
186
+ if (activeAnchor === 'bottom-left') {
187
+ const left = clampValue(proposedBox.x, boundsLeft, oldRight - minFreeWidth);
188
+ const bottom = clampValue(proposedBox.y + proposedBox.height, oldTop + minFreeHeight, boundsBottom);
189
+ return makeBox(left, oldTop, oldRight - left, bottom - oldTop);
190
+ }
191
+ if (activeAnchor === 'bottom-right') {
192
+ const right = clampValue(proposedBox.x + proposedBox.width, oldLeft + minFreeWidth, boundsRight);
193
+ const bottom = clampValue(proposedBox.y + proposedBox.height, oldTop + minFreeHeight, boundsBottom);
194
+ return makeBox(oldLeft, oldTop, right - oldLeft, bottom - oldTop);
195
+ }
196
+ if (activeAnchor === 'top-center') {
197
+ const top = clampValue(proposedBox.y, boundsTop, oldBottom - minFreeHeight);
198
+ return makeBox(oldLeft, top, oldBox.width, oldBottom - top);
199
+ }
200
+ if (activeAnchor === 'bottom-center') {
201
+ const bottom = clampValue(proposedBox.y + proposedBox.height, oldTop + minFreeHeight, boundsBottom);
202
+ return makeBox(oldLeft, oldTop, oldBox.width, bottom - oldTop);
203
+ }
204
+ if (activeAnchor === 'middle-left') {
205
+ const left = clampValue(proposedBox.x, boundsLeft, oldRight - minFreeWidth);
206
+ return makeBox(left, oldTop, oldRight - left, oldBox.height);
207
+ }
208
+ if (activeAnchor === 'middle-right') {
209
+ const right = clampValue(proposedBox.x + proposedBox.width, oldLeft + minFreeWidth, boundsRight);
210
+ return makeBox(oldLeft, oldTop, right - oldLeft, oldBox.height);
211
+ }
212
+ }
213
+
214
+ const ratio = aspectRatio || 1;
215
+ const minRatioWidth = Math.max(minFreeWidth, minFreeHeight * ratio);
216
+
217
+ if (activeAnchor === 'top-left') {
218
+ const widthFromX = clampValue(oldRight - proposedBox.x, minRatioWidth, oldRight - boundsLeft);
219
+ const widthFromY = clampValue((oldBottom - proposedBox.y) * ratio, minRatioWidth, (oldBottom - boundsTop) * ratio);
220
+ const width = Math.min(Math.max(widthFromX, widthFromY), oldRight - boundsLeft, (oldBottom - boundsTop) * ratio);
221
+ const height = width / ratio;
222
+ return makeBox(oldRight - width, oldBottom - height, width, height);
223
+ }
224
+ if (activeAnchor === 'top-right') {
225
+ const widthFromX = clampValue(proposedBox.x + proposedBox.width - oldLeft, minRatioWidth, boundsRight - oldLeft);
226
+ const widthFromY = clampValue((oldBottom - proposedBox.y) * ratio, minRatioWidth, (oldBottom - boundsTop) * ratio);
227
+ const width = Math.min(Math.max(widthFromX, widthFromY), boundsRight - oldLeft, (oldBottom - boundsTop) * ratio);
228
+ const height = width / ratio;
229
+ return makeBox(oldLeft, oldBottom - height, width, height);
230
+ }
231
+ if (activeAnchor === 'bottom-left') {
232
+ const widthFromX = clampValue(oldRight - proposedBox.x, minRatioWidth, oldRight - boundsLeft);
233
+ const widthFromY = clampValue((proposedBox.y + proposedBox.height - oldTop) * ratio, minRatioWidth, (boundsBottom - oldTop) * ratio);
234
+ const width = Math.min(Math.max(widthFromX, widthFromY), oldRight - boundsLeft, (boundsBottom - oldTop) * ratio);
235
+ const height = width / ratio;
236
+ return makeBox(oldRight - width, oldTop, width, height);
237
+ }
238
+ if (activeAnchor === 'bottom-right') {
239
+ const widthFromX = clampValue(proposedBox.x + proposedBox.width - oldLeft, minRatioWidth, boundsRight - oldLeft);
240
+ const widthFromY = clampValue((proposedBox.y + proposedBox.height - oldTop) * ratio, minRatioWidth, (boundsBottom - oldTop) * ratio);
241
+ const width = Math.min(Math.max(widthFromX, widthFromY), boundsRight - oldLeft, (boundsBottom - oldTop) * ratio);
242
+ const height = width / ratio;
243
+ return makeBox(oldLeft, oldTop, width, height);
244
+ }
245
+ if (activeAnchor === 'top-center') {
246
+ const height = clampValue(oldBottom - proposedBox.y, minFreeHeight, oldBottom - boundsTop);
247
+ const width = Math.min(Math.max(height * ratio, minRatioWidth), displayTotalWidth);
248
+ const x = clampValue(oldCenterX - width / 2, boundsLeft, boundsRight - width);
249
+ return makeBox(x, oldBottom - height, width, height);
250
+ }
251
+ if (activeAnchor === 'bottom-center') {
252
+ const height = clampValue(proposedBox.y + proposedBox.height - oldTop, minFreeHeight, boundsBottom - oldTop);
253
+ const width = Math.min(Math.max(height * ratio, minRatioWidth), displayTotalWidth);
254
+ const x = clampValue(oldCenterX - width / 2, boundsLeft, boundsRight - width);
255
+ return makeBox(x, oldTop, width, height);
256
+ }
257
+ if (activeAnchor === 'middle-left') {
258
+ const width = clampValue(oldRight - proposedBox.x, minRatioWidth, oldRight - boundsLeft);
259
+ const height = width / ratio;
260
+ const y = clampValue(oldCenterY - height / 2, boundsTop, boundsBottom - height);
261
+ return makeBox(oldRight - width, y, width, height);
262
+ }
263
+ if (activeAnchor === 'middle-right') {
264
+ const width = clampValue(proposedBox.x + proposedBox.width - oldLeft, minRatioWidth, boundsRight - oldLeft);
265
+ const height = width / ratio;
266
+ const y = clampValue(oldCenterY - height / 2, boundsTop, boundsBottom - height);
267
+ return makeBox(oldLeft, y, width, height);
268
+ }
269
+
270
+ return oldBox;
271
+ };
272
+
273
+ const resolveAnchorDragPosition = (baseBox: TransformBox, proposedPos: Point, activeAnchor?: string | null): Point => {
274
+ const boundsLeft = contentX;
275
+ const boundsTop = contentY;
276
+ const boundsRight = contentX + displayTotalWidth;
277
+ const boundsBottom = contentY + displayTotalHeight;
278
+
279
+ const left = baseBox.x;
280
+ const top = baseBox.y;
281
+ const right = baseBox.x + baseBox.width;
282
+ const bottom = baseBox.y + baseBox.height;
283
+ const centerX = baseBox.x + baseBox.width / 2;
284
+ const centerY = baseBox.y + baseBox.height / 2;
285
+
286
+ const minFreeWidth = 20;
287
+ const minFreeHeight = 20;
288
+ const aspectRatio = !isFreeCrop && config.crop?.aspectRatio
289
+ ? (() => {
290
+ const [w, h] = config.crop.aspectRatio.split(':').map((value) => parseInt(value, 10));
291
+ return w > 0 && h > 0 ? w / h : null;
292
+ })()
293
+ : null;
294
+
295
+ if (!activeAnchor) {
296
+ return proposedPos;
297
+ }
298
+
299
+ if (!aspectRatio) {
300
+ if (activeAnchor === 'top-left') {
301
+ return {
302
+ x: clampValue(proposedPos.x, boundsLeft, right - minFreeWidth),
303
+ y: clampValue(proposedPos.y, boundsTop, bottom - minFreeHeight)
304
+ };
305
+ }
306
+ if (activeAnchor === 'top-right') {
307
+ return {
308
+ x: clampValue(proposedPos.x, left + minFreeWidth, boundsRight),
309
+ y: clampValue(proposedPos.y, boundsTop, bottom - minFreeHeight)
310
+ };
311
+ }
312
+ if (activeAnchor === 'bottom-left') {
313
+ return {
314
+ x: clampValue(proposedPos.x, boundsLeft, right - minFreeWidth),
315
+ y: clampValue(proposedPos.y, top + minFreeHeight, boundsBottom)
316
+ };
317
+ }
318
+ if (activeAnchor === 'bottom-right') {
319
+ return {
320
+ x: clampValue(proposedPos.x, left + minFreeWidth, boundsRight),
321
+ y: clampValue(proposedPos.y, top + minFreeHeight, boundsBottom)
322
+ };
323
+ }
324
+ if (activeAnchor === 'top-center') {
325
+ return {
326
+ x: centerX,
327
+ y: clampValue(proposedPos.y, boundsTop, bottom - minFreeHeight)
328
+ };
329
+ }
330
+ if (activeAnchor === 'bottom-center') {
331
+ return {
332
+ x: centerX,
333
+ y: clampValue(proposedPos.y, top + minFreeHeight, boundsBottom)
334
+ };
335
+ }
336
+ if (activeAnchor === 'middle-left') {
337
+ return {
338
+ x: clampValue(proposedPos.x, boundsLeft, right - minFreeWidth),
339
+ y: centerY
340
+ };
341
+ }
342
+ if (activeAnchor === 'middle-right') {
343
+ return {
344
+ x: clampValue(proposedPos.x, left + minFreeWidth, boundsRight),
345
+ y: centerY
346
+ };
347
+ }
348
+ }
349
+
350
+ const ratio = aspectRatio || 1;
351
+ const minRatioWidth = Math.max(minFreeWidth, minFreeHeight * ratio);
352
+
353
+ if (activeAnchor === 'top-left') {
354
+ const width = Math.min(
355
+ Math.max(right - proposedPos.x, (bottom - proposedPos.y) * ratio, minRatioWidth),
356
+ right - boundsLeft,
357
+ (bottom - boundsTop) * ratio
358
+ );
359
+ return { x: right - width, y: bottom - width / ratio };
360
+ }
361
+ if (activeAnchor === 'top-right') {
362
+ const width = Math.min(
363
+ Math.max(proposedPos.x - left, (bottom - proposedPos.y) * ratio, minRatioWidth),
364
+ boundsRight - left,
365
+ (bottom - boundsTop) * ratio
366
+ );
367
+ return { x: left + width, y: bottom - width / ratio };
368
+ }
369
+ if (activeAnchor === 'bottom-left') {
370
+ const width = Math.min(
371
+ Math.max(right - proposedPos.x, (proposedPos.y - top) * ratio, minRatioWidth),
372
+ right - boundsLeft,
373
+ (boundsBottom - top) * ratio
374
+ );
375
+ return { x: right - width, y: top + width / ratio };
376
+ }
377
+ if (activeAnchor === 'bottom-right') {
378
+ const width = Math.min(
379
+ Math.max(proposedPos.x - left, (proposedPos.y - top) * ratio, minRatioWidth),
380
+ boundsRight - left,
381
+ (boundsBottom - top) * ratio
382
+ );
383
+ return { x: left + width, y: top + width / ratio };
384
+ }
385
+ if (activeAnchor === 'top-center') {
386
+ const height = Math.min(
387
+ Math.max(bottom - proposedPos.y, minFreeHeight),
388
+ bottom - boundsTop,
389
+ displayTotalWidth / ratio
390
+ );
391
+ return { x: centerX, y: bottom - height };
392
+ }
393
+ if (activeAnchor === 'bottom-center') {
394
+ const height = Math.min(
395
+ Math.max(proposedPos.y - top, minFreeHeight),
396
+ boundsBottom - top,
397
+ displayTotalWidth / ratio
398
+ );
399
+ return { x: centerX, y: top + height };
400
+ }
401
+ if (activeAnchor === 'middle-left') {
402
+ const width = Math.min(
403
+ Math.max(right - proposedPos.x, minRatioWidth),
404
+ right - boundsLeft,
405
+ displayTotalHeight * ratio
406
+ );
407
+ return { x: right - width, y: centerY };
408
+ }
409
+ if (activeAnchor === 'middle-right') {
410
+ const width = Math.min(
411
+ Math.max(proposedPos.x - left, minRatioWidth),
412
+ boundsRight - left,
413
+ displayTotalHeight * ratio
414
+ );
415
+ return { x: left + width, y: centerY };
416
+ }
417
+
418
+ return proposedPos;
419
+ };
111
420
 
112
421
  useEffect(() => {
113
422
  if (image && image.width && image.height) {
@@ -797,48 +1106,51 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
797
1106
  }}
798
1107
  onTransformStart={() => {
799
1108
  isTransformingRef.current = true;
1109
+ pendingTransformBoxRef.current = null;
1110
+ transformStartBoxRef.current = {
1111
+ x: contentX + cropRect.x * scale,
1112
+ y: contentY + cropRect.y * scale,
1113
+ width: cropRect.width * scale,
1114
+ height: cropRect.height * scale,
1115
+ rotation: 0
1116
+ };
800
1117
  }}
801
1118
  onTransformEnd={(e) => {
802
1119
  const node = e.target;
803
- const scaleX = node.scaleX();
804
- const scaleY = node.scaleY();
1120
+ const finalBox = pendingTransformBoxRef.current;
805
1121
 
806
1122
  node.scaleX(1);
807
1123
  node.scaleY(1);
808
-
809
- // Base size comes from current cropRect state * display scale
810
- const baseWidth = cropRect.width * scale;
811
- const baseHeight = cropRect.height * scale;
812
-
813
- let newWidth = Math.max(20, baseWidth * scaleX);
814
- let newHeight = Math.max(20, baseHeight * scaleY);
815
-
816
- if (!isFreeCrop && config.crop?.aspectRatio) {
817
- const aspectRatioParts = config.crop.aspectRatio.split(':');
818
- const ratioW = parseInt(aspectRatioParts[0]);
819
- const ratioH = parseInt(aspectRatioParts[1]);
820
- const targetRatio = ratioW / ratioH;
821
-
822
- if (scaleX > scaleY) {
823
- newHeight = newWidth / targetRatio;
824
- } else {
825
- newWidth = newHeight * targetRatio;
826
- }
1124
+
1125
+ if (!finalBox) {
1126
+ requestAnimationFrame(() => {
1127
+ transformerRef.current?.forceUpdate();
1128
+ transformerRef.current?.getLayer()?.batchDraw();
1129
+ });
1130
+
1131
+ setTimeout(() => {
1132
+ isTransformingRef.current = false;
1133
+ }, 50);
1134
+ return;
827
1135
  }
828
-
829
- // Constrain to TOTAL width/height
830
- const maxX = totalWidth - newWidth / scale;
831
- const maxY = totalHeight - newHeight / scale;
832
- const newX = Math.round(Math.min(Math.max(0, (node.x() - contentX) / scale), maxX));
833
- const newY = Math.round(Math.min(Math.max(0, (node.y() - contentY) / scale), maxY));
1136
+
1137
+ const constrainedX = Math.min(Math.max(contentX, finalBox.x), contentX + displayTotalWidth - finalBox.width);
1138
+ const constrainedY = Math.min(Math.max(contentY, finalBox.y), contentY + displayTotalHeight - finalBox.height);
1139
+ const newX = Math.round((constrainedX - contentX) / scale);
1140
+ const newY = Math.round((constrainedY - contentY) / scale);
1141
+ const newWidth = Math.round(finalBox.width / scale);
1142
+ const newHeight = Math.round(finalBox.height / scale);
834
1143
 
835
1144
  setCropRect({
836
1145
  x: newX,
837
1146
  y: newY,
838
- width: Math.round(newWidth / scale),
839
- height: Math.round(newHeight / scale)
1147
+ width: newWidth,
1148
+ height: newHeight
840
1149
  });
841
1150
 
1151
+ pendingTransformBoxRef.current = null;
1152
+ transformStartBoxRef.current = null;
1153
+
842
1154
  requestAnimationFrame(() => {
843
1155
  transformerRef.current?.forceUpdate();
844
1156
  transformerRef.current?.getLayer()?.batchDraw();
@@ -894,56 +1206,36 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
894
1206
  anchorStroke="#3b82f6"
895
1207
  anchorFill="#3b82f6"
896
1208
  anchorSize={8}
1209
+ anchorDragBoundFunc={(oldAbsPos, newAbsPos) => {
1210
+ const activeAnchor = transformerRef.current?.getActiveAnchor();
1211
+ const fallbackBaseBox: TransformBox = {
1212
+ x: contentX + cropRect.x * scale,
1213
+ y: contentY + cropRect.y * scale,
1214
+ width: cropRect.width * scale,
1215
+ height: cropRect.height * scale,
1216
+ rotation: 0
1217
+ };
1218
+ const baseBox = transformStartBoxRef.current || fallbackBaseBox;
1219
+ return resolveAnchorDragPosition(baseBox, newAbsPos as Point, activeAnchor);
1220
+ }}
897
1221
  // 所有裁剪模式都支持 8 个方向的手柄,方便对称裁剪
898
1222
  enabledAnchors={[
899
1223
  'top-left', 'top-right', 'bottom-left', 'bottom-right',
900
1224
  'top-center', 'bottom-center', 'middle-left', 'middle-right'
901
1225
  ]}
902
1226
  boundBoxFunc={(oldBox, newBox) => {
903
- if (newBox.width < 20 || newBox.height < 20) {
904
- return oldBox;
905
- }
906
-
907
- if (!isFreeCrop && config.crop?.aspectRatio) {
908
- const aspectRatioParts = config.crop.aspectRatio.split(':');
909
- const ratioW = parseInt(aspectRatioParts[0]);
910
- const ratioH = parseInt(aspectRatioParts[1]);
911
- const targetRatio = ratioW / ratioH;
912
-
913
- if (Math.abs(newBox.width - oldBox.width) > Math.abs(newBox.height - oldBox.height)) {
914
- newBox.height = newBox.width / targetRatio;
915
- } else {
916
- newBox.width = newBox.height * targetRatio;
917
- }
918
- }
919
-
920
- // 检测正在使用的手柄,保持中心位置不变
921
1227
  const transformerInstance = transformerRef.current;
922
- if (transformerInstance) {
923
- const activeAnchor = transformerInstance.getActiveAnchor();
924
-
925
- if (activeAnchor === 'top-center' || activeAnchor === 'bottom-center') {
926
- // 使用上/下手柄时,保持水平中心位置不变
927
- const oldCenterX = oldBox.x + oldBox.width / 2;
928
- const newCenterX = newBox.x + newBox.width / 2;
929
- newBox.x += oldCenterX - newCenterX;
930
- } else if (activeAnchor === 'middle-left' || activeAnchor === 'middle-right') {
931
- // 使用左/右手柄时,保持垂直中心位置不变
932
- const oldCenterY = oldBox.y + oldBox.height / 2;
933
- const newCenterY = newBox.y + newBox.height / 2;
934
- newBox.y += oldCenterY - newCenterY;
935
- }
936
- }
937
-
938
- const relX = newBox.x - contentX;
939
- const relY = newBox.y - contentY;
940
- if (relX < -5 || relY < -5 ||
941
- relX + newBox.width > displayTotalWidth + 5 ||
942
- relY + newBox.height > displayTotalHeight + 5) {
943
- return oldBox;
944
- }
1228
+ const activeAnchor = transformerInstance?.getActiveAnchor();
1229
+ const baseBox = transformStartBoxRef.current || (oldBox as TransformBox);
1230
+ const resolvedBox = resolveCropTransformBox(
1231
+ baseBox,
1232
+ newBox as TransformBox,
1233
+ activeAnchor
1234
+ );
1235
+
1236
+ pendingTransformBoxRef.current = { ...resolvedBox };
945
1237
 
946
- return newBox;
1238
+ return resolvedBox;
947
1239
  }}
948
1240
  />
949
1241
  </Group>
@@ -47,6 +47,100 @@ export const Header = ({ stageRef }: HeaderProps) => {
47
47
  );
48
48
  };
49
49
 
50
+ const createCanvasFromSource = (source: CanvasImageSource, width: number, height: number) => {
51
+ const canvas = document.createElement('canvas');
52
+ canvas.width = Math.max(1, Math.round(width));
53
+ canvas.height = Math.max(1, Math.round(height));
54
+ const ctx = canvas.getContext('2d');
55
+
56
+ if (ctx) {
57
+ ctx.imageSmoothingEnabled = true;
58
+ ctx.imageSmoothingQuality = 'high';
59
+ ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
60
+ }
61
+
62
+ return canvas;
63
+ };
64
+
65
+ const sanitizeTransparentEdges = (canvas: HTMLCanvasElement) => {
66
+ const ctx = canvas.getContext('2d', { willReadFrequently: true } as any) as CanvasRenderingContext2D | null;
67
+ if (!ctx) return canvas;
68
+
69
+ const { width, height } = canvas;
70
+ if (width <= 1 || height <= 1) return canvas;
71
+
72
+ const imageData = ctx.getImageData(0, 0, width, height);
73
+ const { data } = imageData;
74
+
75
+ const isRowTransparent = (y: number) => {
76
+ for (let x = 0; x < width; x += 1) {
77
+ if (data[(y * width + x) * 4 + 3] !== 0) return false;
78
+ }
79
+ return true;
80
+ };
81
+
82
+ const isColumnTransparent = (x: number) => {
83
+ for (let y = 0; y < height; y += 1) {
84
+ if (data[(y * width + x) * 4 + 3] !== 0) return false;
85
+ }
86
+ return true;
87
+ };
88
+
89
+ const copyRow = (fromY: number, toY: number) => {
90
+ for (let x = 0; x < width; x += 1) {
91
+ const from = (fromY * width + x) * 4;
92
+ const to = (toY * width + x) * 4;
93
+ data[to] = data[from];
94
+ data[to + 1] = data[from + 1];
95
+ data[to + 2] = data[from + 2];
96
+ data[to + 3] = data[from + 3];
97
+ }
98
+ };
99
+
100
+ const copyColumn = (fromX: number, toX: number) => {
101
+ for (let y = 0; y < height; y += 1) {
102
+ const from = (y * width + fromX) * 4;
103
+ const to = (y * width + toX) * 4;
104
+ data[to] = data[from];
105
+ data[to + 1] = data[from + 1];
106
+ data[to + 2] = data[from + 2];
107
+ data[to + 3] = data[from + 3];
108
+ }
109
+ };
110
+
111
+ let top = 0;
112
+ while (top < height && isRowTransparent(top)) top += 1;
113
+
114
+ let bottom = height - 1;
115
+ while (bottom >= 0 && isRowTransparent(bottom)) bottom -= 1;
116
+
117
+ let left = 0;
118
+ while (left < width && isColumnTransparent(left)) left += 1;
119
+
120
+ let right = width - 1;
121
+ while (right >= 0 && isColumnTransparent(right)) right -= 1;
122
+
123
+ if (top >= height || bottom < 0 || left >= width || right < 0) {
124
+ return canvas;
125
+ }
126
+
127
+ for (let y = 0; y < top; y += 1) {
128
+ copyRow(top, y);
129
+ }
130
+ for (let y = bottom + 1; y < height; y += 1) {
131
+ copyRow(bottom, y);
132
+ }
133
+ for (let x = 0; x < left; x += 1) {
134
+ copyColumn(left, x);
135
+ }
136
+ for (let x = right + 1; x < width; x += 1) {
137
+ copyColumn(right, x);
138
+ }
139
+
140
+ ctx.putImageData(imageData, 0, 0);
141
+ return canvas;
142
+ };
143
+
50
144
  const handleSaveClick = async () => {
51
145
  if (!stageRef.current) return;
52
146
 
@@ -120,8 +214,23 @@ export const Header = ({ stageRef }: HeaderProps) => {
120
214
  finalWidth = Math.round(finalWidth);
121
215
  finalHeight = Math.round(finalHeight);
122
216
 
217
+ const hasActiveFilters =
218
+ config.brightness !== 0 ||
219
+ config.contrast !== 0 ||
220
+ config.sharpness > 0 ||
221
+ Boolean(config.enhancements?.autoEnhance) ||
222
+ Boolean(config.enhancements?.fillLight) ||
223
+ Boolean(config.enhancements?.autoWhiteBalance);
224
+ const hasVisibleBorder = Boolean(config.border && config.border.size > 0);
225
+ const hasRotation = config.rotation !== 0;
226
+ const canUseRawImage = !hasActiveFilters && !hasVisibleBorder && !hasRotation;
227
+
228
+ const rawImage = imageNode.image() as CanvasImageSource | undefined;
123
229
  const cachedCanvas = (imageNode as any)._cacheCanvas as HTMLCanvasElement | undefined;
124
- const srcCanvas = (cachedCanvas || imageNode.toCanvas({ pixelRatio })) as HTMLCanvasElement;
230
+ const source = (canUseRawImage && rawImage)
231
+ ? rawImage
232
+ : ((cachedCanvas || imageNode.toCanvas({ pixelRatio })) as CanvasImageSource);
233
+ const exportedCanvas = sanitizeTransparentEdges(createCanvasFromSource(source, finalWidth, finalHeight));
125
234
 
126
235
  const tempCanvas = document.createElement('canvas');
127
236
  tempCanvas.width = finalWidth;
@@ -140,24 +249,14 @@ export const Header = ({ stageRef }: HeaderProps) => {
140
249
  const { border } = config;
141
250
  const bg =
142
251
  (border && border.size > 0 && border.color) ||
143
- (cachedCanvas ? pickOpaqueEdgeColor(cachedCanvas) : null) ||
252
+ pickOpaqueEdgeColor(exportedCanvas) ||
144
253
  '#ffffff';
145
254
 
146
255
  ctx.fillStyle = bg;
147
256
  ctx.fillRect(0, 0, finalWidth, finalHeight);
148
257
  }
149
258
 
150
- ctx.drawImage(
151
- srcCanvas,
152
- 0,
153
- 0,
154
- Math.round(srcCanvas.width || finalWidth),
155
- Math.round(srcCanvas.height || finalHeight),
156
- 0,
157
- 0,
158
- finalWidth,
159
- finalHeight
160
- );
259
+ ctx.drawImage(exportedCanvas, 0, 0, finalWidth, finalHeight);
161
260
 
162
261
  const dataUrl = tempCanvas.toDataURL(format, quality / 100);
163
262