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.
- package/dist/client/assets/index-DORSlWYO.js +197 -0
- package/dist/client/index.html +1 -1
- package/package.json +2 -2
- package/src/components/layout/CanvasArea.tsx +363 -71
- package/src/components/layout/Header.tsx +112 -13
- package/src/store/useImageStore.ts +77 -0
- package/dist/client/assets/index-BJ_kQgaS.js +0 -197
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
4
|
+
"version": "1.0.22",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"mjpic": "
|
|
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
|
|
804
|
-
const scaleY = node.scaleY();
|
|
1120
|
+
const finalBox = pendingTransformBoxRef.current;
|
|
805
1121
|
|
|
806
1122
|
node.scaleX(1);
|
|
807
1123
|
node.scaleY(1);
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
830
|
-
const
|
|
831
|
-
const
|
|
832
|
-
const
|
|
833
|
-
const
|
|
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:
|
|
839
|
-
height:
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|