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.
@@ -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-Tbz-to9P.js"></script>
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.19",
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
- // Get the cached canvas from the image node
92
- // This gives us the filtered image without group transformations
93
- const cachedCanvas = (imageNode as any)._cacheCanvas;
94
-
95
- let dataUrl: string;
96
-
97
- if (cachedCanvas) {
98
- // Create a temporary canvas with the correct output size
99
- const tempCanvas = document.createElement('canvas');
100
- tempCanvas.width = finalWidth;
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 = cropRect.width;
179
- canvas.height = cropRect.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
- // Crop center (relative to Total Content top-left)
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
- ctx.fillStyle = color;
227
- ctx.fillRect(-totalWidth/2, -totalHeight/2, totalWidth, totalHeight);
223
+ ctx.fillStyle = color;
224
+ ctx.fillRect(-totalWidth / 2, -totalHeight / 2, totalWidth, totalHeight);
228
225
  }
229
-
230
- // Draw Image (Centered)
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: cropRect.width,
250
- height: cropRect.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: cropRect.width,
259
- originalHeight: cropRect.height,
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,