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.
- package/dist/client/assets/index-DORSlWYO.js +197 -0
- package/dist/client/index.html +1 -1
- package/package.json +2 -3
- package/src/components/layout/CanvasArea.tsx +367 -75
- package/src/components/layout/Header.tsx +170 -40
- package/src/store/useImageStore.ts +100 -27
- package/test-/350/276/203/351/253/230/345/210/206/350/276/250/347/216/207/347/232/204/347/205/247/347/211/207/345/216/213/347/274/251/345/210/206/350/276/250/347/216/207/345/220/216/347/224/273/351/235/242/344/270/212/345/207/272/347/216/260/346/235/241/347/272/271/_DSC2177_/345/211/257/346/234/254_/345/211/257/346/234/254 (1).jpg +0 -0
- package/test-/350/276/203/351/253/230/345/210/206/350/276/250/347/216/207/347/232/204/347/205/247/347/211/207/345/216/213/347/274/251/345/210/206/350/276/250/347/216/207/345/220/216/347/224/273/351/235/242/344/270/212/345/207/272/347/216/260/346/235/241/347/272/271/_DSC2177_/345/211/257/346/234/254_/345/211/257/346/234/254 (2).jpg +0 -0
- package/dist/client/assets/index-Tbz-to9P.js +0 -197
|
@@ -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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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 =
|
|
179
|
-
canvas.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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
299
|
+
ctx.fillStyle = color;
|
|
300
|
+
ctx.fillRect(-totalWidth / 2, -totalHeight / 2, totalWidth, totalHeight);
|
|
228
301
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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:
|
|
250
|
-
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:
|
|
259
|
-
originalHeight:
|
|
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,
|