mjpic 1.0.19 → 1.0.20
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/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-BJ_kQgaS.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-C6nMBMvY.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mjpic",
|
|
3
3
|
"description": "敏捷图片(mjpic)是一个轻量级网页版图片处理工具,设计灵感来源于光影魔术手。",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.20",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mjpic": "./dist/cli/cli.js"
|
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
"i18next-browser-languagedetector": "^8.2.1",
|
|
31
31
|
"konva": "^9.3.16",
|
|
32
32
|
"lucide-react": "^0.511.0",
|
|
33
|
-
"mjpic": "^1.0.7",
|
|
34
33
|
"open": "^11.0.0",
|
|
35
34
|
"react": "^18.3.1",
|
|
36
35
|
"react-dom": "^18.3.1",
|
|
@@ -760,8 +760,8 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
|
|
|
760
760
|
}}
|
|
761
761
|
onDragMove={(e) => {
|
|
762
762
|
const node = e.target;
|
|
763
|
-
const newX = (node.x() - contentX) / scale;
|
|
764
|
-
const newY = (node.y() - contentY) / scale;
|
|
763
|
+
const newX = Math.round((node.x() - contentX) / scale);
|
|
764
|
+
const newY = Math.round((node.y() - contentY) / scale);
|
|
765
765
|
|
|
766
766
|
pendingCropRectRef.current = { ...cropRect, x: newX, y: newY };
|
|
767
767
|
if (cropDragRafRef.current !== null) return;
|
|
@@ -782,8 +782,8 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
|
|
|
782
782
|
pendingCropRectRef.current = null;
|
|
783
783
|
|
|
784
784
|
const node = e.target;
|
|
785
|
-
const newX = (node.x() - contentX) / scale;
|
|
786
|
-
const newY = (node.y() - contentY) / scale;
|
|
785
|
+
const newX = Math.round((node.x() - contentX) / scale);
|
|
786
|
+
const newY = Math.round((node.y() - contentY) / scale);
|
|
787
787
|
setCropRect({ ...cropRect, x: newX, y: newY });
|
|
788
788
|
|
|
789
789
|
requestAnimationFrame(() => {
|
|
@@ -829,14 +829,14 @@ export const CanvasArea = ({ stageRef }: CanvasAreaProps) => {
|
|
|
829
829
|
// Constrain to TOTAL width/height
|
|
830
830
|
const maxX = totalWidth - newWidth / scale;
|
|
831
831
|
const maxY = totalHeight - newHeight / scale;
|
|
832
|
-
const newX = Math.min(Math.max(0, (node.x() - contentX) / scale), maxX);
|
|
833
|
-
const newY = Math.min(Math.max(0, (node.y() - contentY) / scale), maxY);
|
|
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));
|
|
834
834
|
|
|
835
835
|
setCropRect({
|
|
836
836
|
x: newX,
|
|
837
837
|
y: newY,
|
|
838
|
-
width: newWidth / scale,
|
|
839
|
-
height: newHeight / scale
|
|
838
|
+
width: Math.round(newWidth / scale),
|
|
839
|
+
height: Math.round(newHeight / scale)
|
|
840
840
|
});
|
|
841
841
|
|
|
842
842
|
requestAnimationFrame(() => {
|
|
@@ -15,6 +15,38 @@ export const Header = ({ stageRef }: HeaderProps) => {
|
|
|
15
15
|
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
|
16
16
|
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
|
|
17
17
|
|
|
18
|
+
const pickOpaqueEdgeColor = (canvas: HTMLCanvasElement): string | null => {
|
|
19
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true } as any) as CanvasRenderingContext2D | null;
|
|
20
|
+
if (!ctx) return null;
|
|
21
|
+
const w = canvas.width;
|
|
22
|
+
const h = canvas.height;
|
|
23
|
+
if (w <= 0 || h <= 0) return null;
|
|
24
|
+
|
|
25
|
+
const scan = (sx: number, sy: number, sw: number, sh: number) => {
|
|
26
|
+
try {
|
|
27
|
+
const data = ctx.getImageData(sx, sy, sw, sh).data;
|
|
28
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
29
|
+
const a = data[i + 3];
|
|
30
|
+
if (a !== 0) {
|
|
31
|
+
return `rgb(${data[i]}, ${data[i + 1]}, ${data[i + 2]})`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
scan(0, 0, w, 1) ||
|
|
42
|
+
scan(0, h - 1, w, 1) ||
|
|
43
|
+
scan(0, 0, 1, h) ||
|
|
44
|
+
scan(w - 1, 0, 1, h) ||
|
|
45
|
+
scan(Math.max(0, Math.floor(w / 2) - 8), 0, Math.min(16, w), Math.min(16, h)) ||
|
|
46
|
+
null
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
18
50
|
const handleSaveClick = async () => {
|
|
19
51
|
if (!stageRef.current) return;
|
|
20
52
|
|
|
@@ -88,47 +120,46 @@ export const Header = ({ stageRef }: HeaderProps) => {
|
|
|
88
120
|
finalWidth = Math.round(finalWidth);
|
|
89
121
|
finalHeight = Math.round(finalHeight);
|
|
90
122
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
tempCanvas.height = finalHeight;
|
|
102
|
-
const ctx = tempCanvas.getContext('2d');
|
|
103
|
-
|
|
104
|
-
if (ctx) {
|
|
105
|
-
// Set high quality rendering
|
|
106
|
-
ctx.imageSmoothingEnabled = true;
|
|
107
|
-
ctx.imageSmoothingQuality = 'high';
|
|
108
|
-
|
|
109
|
-
// Ensure dimensions are integers when drawing to avoid sub-pixel interpolation artifacts
|
|
110
|
-
ctx.drawImage(
|
|
111
|
-
cachedCanvas,
|
|
112
|
-
0, 0, Math.round(cachedCanvas.width), Math.round(cachedCanvas.height),
|
|
113
|
-
0, 0, finalWidth, finalHeight
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
// Get the data URL from the temporary canvas
|
|
117
|
-
dataUrl = tempCanvas.toDataURL(format, quality / 100);
|
|
118
|
-
} else {
|
|
119
|
-
// Fallback: use toCanvas if context creation fails
|
|
120
|
-
const fallbackCanvas = imageNode.toCanvas({
|
|
121
|
-
pixelRatio: pixelRatio
|
|
122
|
-
});
|
|
123
|
-
dataUrl = fallbackCanvas.toDataURL(format, quality / 100);
|
|
124
|
-
}
|
|
125
|
-
} else {
|
|
126
|
-
// Fallback: use toCanvas if no cache
|
|
127
|
-
const fallbackCanvas = imageNode.toCanvas({
|
|
128
|
-
pixelRatio: pixelRatio
|
|
129
|
-
});
|
|
130
|
-
dataUrl = fallbackCanvas.toDataURL(format, quality / 100);
|
|
123
|
+
const cachedCanvas = (imageNode as any)._cacheCanvas as HTMLCanvasElement | undefined;
|
|
124
|
+
const srcCanvas = (cachedCanvas || imageNode.toCanvas({ pixelRatio })) as HTMLCanvasElement;
|
|
125
|
+
|
|
126
|
+
const tempCanvas = document.createElement('canvas');
|
|
127
|
+
tempCanvas.width = finalWidth;
|
|
128
|
+
tempCanvas.height = finalHeight;
|
|
129
|
+
const ctx = tempCanvas.getContext('2d');
|
|
130
|
+
|
|
131
|
+
if (!ctx) {
|
|
132
|
+
throw new Error('Failed to create canvas context');
|
|
131
133
|
}
|
|
134
|
+
|
|
135
|
+
ctx.imageSmoothingEnabled = true;
|
|
136
|
+
ctx.imageSmoothingQuality = 'high';
|
|
137
|
+
|
|
138
|
+
const isLossy = format === 'image/jpeg' || format === 'image/webp';
|
|
139
|
+
if (isLossy) {
|
|
140
|
+
const { border } = config;
|
|
141
|
+
const bg =
|
|
142
|
+
(border && border.size > 0 && border.color) ||
|
|
143
|
+
(cachedCanvas ? pickOpaqueEdgeColor(cachedCanvas) : null) ||
|
|
144
|
+
'#ffffff';
|
|
145
|
+
|
|
146
|
+
ctx.fillStyle = bg;
|
|
147
|
+
ctx.fillRect(0, 0, finalWidth, finalHeight);
|
|
148
|
+
}
|
|
149
|
+
|
|
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
|
+
);
|
|
161
|
+
|
|
162
|
+
const dataUrl = tempCanvas.toDataURL(format, quality / 100);
|
|
132
163
|
|
|
133
164
|
// Restore visibility
|
|
134
165
|
if (transformer) transformer.visible(transformerVisible);
|
|
@@ -171,20 +171,25 @@ export const useImageStore = create<ImageState>((set, get) => ({
|
|
|
171
171
|
const { previewImage, cropRect, config, originalWidth, originalHeight } = get();
|
|
172
172
|
if (!previewImage || cropRect.width === 0 || cropRect.height === 0) return;
|
|
173
173
|
|
|
174
|
+
const roundedCrop = {
|
|
175
|
+
x: Math.round(cropRect.x),
|
|
176
|
+
y: Math.round(cropRect.y),
|
|
177
|
+
width: Math.round(cropRect.width),
|
|
178
|
+
height: Math.round(cropRect.height)
|
|
179
|
+
};
|
|
180
|
+
|
|
174
181
|
const img = new Image();
|
|
175
182
|
img.crossOrigin = 'anonymous';
|
|
176
183
|
img.onload = () => {
|
|
177
184
|
const canvas = document.createElement('canvas');
|
|
178
|
-
canvas.width =
|
|
179
|
-
canvas.height =
|
|
185
|
+
canvas.width = roundedCrop.width;
|
|
186
|
+
canvas.height = roundedCrop.height;
|
|
180
187
|
const ctx = canvas.getContext('2d');
|
|
181
188
|
|
|
182
189
|
if (ctx) {
|
|
183
|
-
// High quality smoothing
|
|
184
190
|
ctx.imageSmoothingEnabled = true;
|
|
185
191
|
ctx.imageSmoothingQuality = 'high';
|
|
186
192
|
|
|
187
|
-
// 1. Determine actual draw size (handle resize config)
|
|
188
193
|
let drawWidth = originalWidth;
|
|
189
194
|
let drawHeight = originalHeight;
|
|
190
195
|
if (config.resize && config.resize.width > 0 && config.resize.height > 0) {
|
|
@@ -192,43 +197,34 @@ export const useImageStore = create<ImageState>((set, get) => ({
|
|
|
192
197
|
drawHeight = config.resize.height;
|
|
193
198
|
}
|
|
194
199
|
|
|
195
|
-
// 2. Calculate border
|
|
196
200
|
const { size = 0, applyHorizontal = true, applyVertical = false, color = '#ffffff' } = config.border || {};
|
|
197
201
|
let borderW = 0;
|
|
198
202
|
let borderH = 0;
|
|
199
203
|
if (size > 0) {
|
|
200
|
-
if (applyHorizontal) borderW = drawWidth * (size / 100);
|
|
201
|
-
if (applyVertical) borderH = drawHeight * (size / 100);
|
|
204
|
+
if (applyHorizontal) borderW = Math.round(drawWidth * (size / 100));
|
|
205
|
+
if (applyVertical) borderH = Math.round(drawHeight * (size / 100));
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
const totalWidth = drawWidth + borderW * 2;
|
|
205
209
|
const totalHeight = drawHeight + borderH * 2;
|
|
206
210
|
|
|
207
|
-
// 3. Setup centers
|
|
208
211
|
const contentCx = totalWidth / 2;
|
|
209
212
|
const contentCy = totalHeight / 2;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const cropCx = cropRect.x + cropRect.width / 2;
|
|
213
|
-
const cropCy = cropRect.y + cropRect.height / 2;
|
|
214
|
-
|
|
215
|
-
// Offset
|
|
213
|
+
const cropCx = roundedCrop.x + roundedCrop.width / 2;
|
|
214
|
+
const cropCy = roundedCrop.y + roundedCrop.height / 2;
|
|
216
215
|
const dx = cropCx - contentCx;
|
|
217
216
|
const dy = cropCy - contentCy;
|
|
218
|
-
|
|
219
|
-
// 4. Draw
|
|
217
|
+
|
|
220
218
|
ctx.translate(canvas.width / 2, canvas.height / 2);
|
|
221
219
|
ctx.translate(-dx, -dy);
|
|
222
220
|
ctx.rotate((config.rotation * Math.PI) / 180);
|
|
223
|
-
|
|
224
|
-
// Draw Border Background
|
|
221
|
+
|
|
225
222
|
if (borderW > 0 || borderH > 0) {
|
|
226
|
-
|
|
227
|
-
|
|
223
|
+
ctx.fillStyle = color;
|
|
224
|
+
ctx.fillRect(-totalWidth / 2, -totalHeight / 2, totalWidth, totalHeight);
|
|
228
225
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
ctx.drawImage(img, -drawWidth/2, -drawHeight/2, drawWidth, drawHeight);
|
|
226
|
+
|
|
227
|
+
ctx.drawImage(img, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
|
|
232
228
|
|
|
233
229
|
const croppedImage = canvas.toDataURL('image/png');
|
|
234
230
|
|
|
@@ -246,8 +242,8 @@ export const useImageStore = create<ImageState>((set, get) => ({
|
|
|
246
242
|
const newHistoryItem: HistoryItem = {
|
|
247
243
|
config: newConfig,
|
|
248
244
|
sourceImage: croppedImage,
|
|
249
|
-
width:
|
|
250
|
-
height:
|
|
245
|
+
width: roundedCrop.width,
|
|
246
|
+
height: roundedCrop.height
|
|
251
247
|
};
|
|
252
248
|
|
|
253
249
|
const newHistory = history.slice(0, historyIndex + 1);
|
|
@@ -255,8 +251,8 @@ export const useImageStore = create<ImageState>((set, get) => ({
|
|
|
255
251
|
|
|
256
252
|
set({
|
|
257
253
|
previewImage: croppedImage,
|
|
258
|
-
originalWidth:
|
|
259
|
-
originalHeight:
|
|
254
|
+
originalWidth: roundedCrop.width,
|
|
255
|
+
originalHeight: roundedCrop.height,
|
|
260
256
|
config: newConfig,
|
|
261
257
|
cropRect: { x: 0, y: 0, width: 0, height: 0 },
|
|
262
258
|
history: newHistory,
|