mjpic 1.0.19 → 1.0.21

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.
@@ -15,6 +15,132 @@ 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
+
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
+
18
144
  const handleSaveClick = async () => {
19
145
  if (!stageRef.current) return;
20
146
 
@@ -88,47 +214,51 @@ export const Header = ({ stageRef }: HeaderProps) => {
88
214
  finalWidth = Math.round(finalWidth);
89
215
  finalHeight = Math.round(finalHeight);
90
216
 
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);
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;
229
+ const cachedCanvas = (imageNode as any)._cacheCanvas as HTMLCanvasElement | undefined;
230
+ const source = (canUseRawImage && rawImage)
231
+ ? rawImage
232
+ : ((cachedCanvas || imageNode.toCanvas({ pixelRatio })) as CanvasImageSource);
233
+ const exportedCanvas = sanitizeTransparentEdges(createCanvasFromSource(source, finalWidth, finalHeight));
234
+
235
+ const tempCanvas = document.createElement('canvas');
236
+ tempCanvas.width = finalWidth;
237
+ tempCanvas.height = finalHeight;
238
+ const ctx = tempCanvas.getContext('2d');
239
+
240
+ if (!ctx) {
241
+ throw new Error('Failed to create canvas context');
131
242
  }
243
+
244
+ ctx.imageSmoothingEnabled = true;
245
+ ctx.imageSmoothingQuality = 'high';
246
+
247
+ const isLossy = format === 'image/jpeg' || format === 'image/webp';
248
+ if (isLossy) {
249
+ const { border } = config;
250
+ const bg =
251
+ (border && border.size > 0 && border.color) ||
252
+ pickOpaqueEdgeColor(exportedCanvas) ||
253
+ '#ffffff';
254
+
255
+ ctx.fillStyle = bg;
256
+ ctx.fillRect(0, 0, finalWidth, finalHeight);
257
+ }
258
+
259
+ ctx.drawImage(exportedCanvas, 0, 0, finalWidth, finalHeight);
260
+
261
+ const dataUrl = tempCanvas.toDataURL(format, quality / 100);
132
262
 
133
263
  // Restore visibility
134
264
  if (transformer) transformer.visible(transformerVisible);
@@ -73,6 +73,82 @@ interface ImageState {
73
73
  reset: () => void;
74
74
  }
75
75
 
76
+ const sanitizeTransparentEdges = (canvas: HTMLCanvasElement) => {
77
+ const ctx = canvas.getContext('2d', { willReadFrequently: true } as any) as CanvasRenderingContext2D | null;
78
+ if (!ctx) return;
79
+
80
+ const { width, height } = canvas;
81
+ if (width <= 1 || height <= 1) return;
82
+
83
+ const imageData = ctx.getImageData(0, 0, width, height);
84
+ const { data } = imageData;
85
+
86
+ const isRowTransparent = (y: number) => {
87
+ for (let x = 0; x < width; x += 1) {
88
+ if (data[(y * width + x) * 4 + 3] !== 0) return false;
89
+ }
90
+ return true;
91
+ };
92
+
93
+ const isColumnTransparent = (x: number) => {
94
+ for (let y = 0; y < height; y += 1) {
95
+ if (data[(y * width + x) * 4 + 3] !== 0) return false;
96
+ }
97
+ return true;
98
+ };
99
+
100
+ const copyRow = (fromY: number, toY: number) => {
101
+ for (let x = 0; x < width; x += 1) {
102
+ const from = (fromY * width + x) * 4;
103
+ const to = (toY * width + x) * 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
+ const copyColumn = (fromX: number, toX: number) => {
112
+ for (let y = 0; y < height; y += 1) {
113
+ const from = (y * width + fromX) * 4;
114
+ const to = (y * width + toX) * 4;
115
+ data[to] = data[from];
116
+ data[to + 1] = data[from + 1];
117
+ data[to + 2] = data[from + 2];
118
+ data[to + 3] = data[from + 3];
119
+ }
120
+ };
121
+
122
+ let top = 0;
123
+ while (top < height && isRowTransparent(top)) top += 1;
124
+
125
+ let bottom = height - 1;
126
+ while (bottom >= 0 && isRowTransparent(bottom)) bottom -= 1;
127
+
128
+ let left = 0;
129
+ while (left < width && isColumnTransparent(left)) left += 1;
130
+
131
+ let right = width - 1;
132
+ while (right >= 0 && isColumnTransparent(right)) right -= 1;
133
+
134
+ if (top >= height || bottom < 0 || left >= width || right < 0) return;
135
+
136
+ for (let y = 0; y < top; y += 1) {
137
+ copyRow(top, y);
138
+ }
139
+ for (let y = bottom + 1; y < height; y += 1) {
140
+ copyRow(bottom, y);
141
+ }
142
+ for (let x = 0; x < left; x += 1) {
143
+ copyColumn(left, x);
144
+ }
145
+ for (let x = right + 1; x < width; x += 1) {
146
+ copyColumn(right, x);
147
+ }
148
+
149
+ ctx.putImageData(imageData, 0, 0);
150
+ };
151
+
76
152
  const defaultConfig: ImageConfig = {
77
153
  rotation: 0,
78
154
  scale: 1,
@@ -171,20 +247,25 @@ export const useImageStore = create<ImageState>((set, get) => ({
171
247
  const { previewImage, cropRect, config, originalWidth, originalHeight } = get();
172
248
  if (!previewImage || cropRect.width === 0 || cropRect.height === 0) return;
173
249
 
250
+ const roundedCrop = {
251
+ x: Math.round(cropRect.x),
252
+ y: Math.round(cropRect.y),
253
+ width: Math.round(cropRect.width),
254
+ height: Math.round(cropRect.height)
255
+ };
256
+
174
257
  const img = new Image();
175
258
  img.crossOrigin = 'anonymous';
176
259
  img.onload = () => {
177
260
  const canvas = document.createElement('canvas');
178
- canvas.width = cropRect.width;
179
- canvas.height = cropRect.height;
261
+ canvas.width = roundedCrop.width;
262
+ canvas.height = roundedCrop.height;
180
263
  const ctx = canvas.getContext('2d');
181
264
 
182
265
  if (ctx) {
183
- // High quality smoothing
184
266
  ctx.imageSmoothingEnabled = true;
185
267
  ctx.imageSmoothingQuality = 'high';
186
268
 
187
- // 1. Determine actual draw size (handle resize config)
188
269
  let drawWidth = originalWidth;
189
270
  let drawHeight = originalHeight;
190
271
  if (config.resize && config.resize.width > 0 && config.resize.height > 0) {
@@ -192,43 +273,35 @@ export const useImageStore = create<ImageState>((set, get) => ({
192
273
  drawHeight = config.resize.height;
193
274
  }
194
275
 
195
- // 2. Calculate border
196
276
  const { size = 0, applyHorizontal = true, applyVertical = false, color = '#ffffff' } = config.border || {};
197
277
  let borderW = 0;
198
278
  let borderH = 0;
199
279
  if (size > 0) {
200
- if (applyHorizontal) borderW = drawWidth * (size / 100);
201
- if (applyVertical) borderH = drawHeight * (size / 100);
280
+ if (applyHorizontal) borderW = Math.round(drawWidth * (size / 100));
281
+ if (applyVertical) borderH = Math.round(drawHeight * (size / 100));
202
282
  }
203
283
 
204
284
  const totalWidth = drawWidth + borderW * 2;
205
285
  const totalHeight = drawHeight + borderH * 2;
206
286
 
207
- // 3. Setup centers
208
287
  const contentCx = totalWidth / 2;
209
288
  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
289
+ const cropCx = roundedCrop.x + roundedCrop.width / 2;
290
+ const cropCy = roundedCrop.y + roundedCrop.height / 2;
216
291
  const dx = cropCx - contentCx;
217
292
  const dy = cropCy - contentCy;
218
-
219
- // 4. Draw
293
+
220
294
  ctx.translate(canvas.width / 2, canvas.height / 2);
221
295
  ctx.translate(-dx, -dy);
222
296
  ctx.rotate((config.rotation * Math.PI) / 180);
223
-
224
- // Draw Border Background
297
+
225
298
  if (borderW > 0 || borderH > 0) {
226
- ctx.fillStyle = color;
227
- ctx.fillRect(-totalWidth/2, -totalHeight/2, totalWidth, totalHeight);
299
+ ctx.fillStyle = color;
300
+ ctx.fillRect(-totalWidth / 2, -totalHeight / 2, totalWidth, totalHeight);
228
301
  }
229
-
230
- // Draw Image (Centered)
231
- ctx.drawImage(img, -drawWidth/2, -drawHeight/2, drawWidth, drawHeight);
302
+
303
+ ctx.drawImage(img, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
304
+ sanitizeTransparentEdges(canvas);
232
305
 
233
306
  const croppedImage = canvas.toDataURL('image/png');
234
307
 
@@ -246,8 +319,8 @@ export const useImageStore = create<ImageState>((set, get) => ({
246
319
  const newHistoryItem: HistoryItem = {
247
320
  config: newConfig,
248
321
  sourceImage: croppedImage,
249
- width: cropRect.width,
250
- height: cropRect.height
322
+ width: roundedCrop.width,
323
+ height: roundedCrop.height
251
324
  };
252
325
 
253
326
  const newHistory = history.slice(0, historyIndex + 1);
@@ -255,8 +328,8 @@ export const useImageStore = create<ImageState>((set, get) => ({
255
328
 
256
329
  set({
257
330
  previewImage: croppedImage,
258
- originalWidth: cropRect.width,
259
- originalHeight: cropRect.height,
331
+ originalWidth: roundedCrop.width,
332
+ originalHeight: roundedCrop.height,
260
333
  config: newConfig,
261
334
  cropRect: { x: 0, y: 0, width: 0, height: 0 },
262
335
  history: newHistory,