@archbase/components 4.0.17 → 4.0.19
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/archbase-components-4.0.19.tgz +0 -0
- package/dist/editors/ArchbaseImageEdit.d.ts +21 -1
- package/dist/image/editor/functions/crop-image.d.ts +32 -0
- package/dist/image/editor/index.d.ts +0 -30
- package/dist/image/editor/models/index.models.d.ts +16 -0
- package/dist/index.css +1 -1
- package/dist/index.js +8139 -7944
- package/package.json +5 -6
- package/src/editors/ArchbaseImageEdit.tsx +57 -2
- package/src/image/editor/functions/crop-image.ts +172 -0
- package/src/image/editor/index.tsx +411 -413
- package/src/image/editor/models/index.models.ts +16 -0
- package/dist/archbase-components-4.0.17.tgz +0 -0
|
@@ -1,249 +1,105 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ArchbaseImagePickerEditor
|
|
3
|
-
* Componente para seleção, edição e compressão de imagens em png, jpeg e webp
|
|
2
|
+
* ArchbaseImagePickerEditor — picker + editor de imagens próprio do Archbase.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Implementação:
|
|
5
|
+
* - Upload via @mantine/dropzone (drag-and-drop + click). O conteúdo do
|
|
6
|
+
* arquivo é lido com FileReader.readAsDataURL(), o que preserva os bytes
|
|
7
|
+
* originais — sem nenhum canvas roundtrip. Logo, PNGs/WebPs com canal
|
|
8
|
+
* alfa chegam ao callback com a transparência intacta.
|
|
9
|
+
* - Preview com <img> nativa, sem any third-party.
|
|
10
|
+
* - Botões de adicionar (re-upload), editar (crop/rotate em modal),
|
|
11
|
+
* download e deletar — ActionIcons do Mantine.
|
|
12
|
+
* - Crop opcional via react-easy-crop, com mime de saída respeitando
|
|
13
|
+
* `preserveTransparency` (ver crop-image.ts).
|
|
14
|
+
*
|
|
15
|
+
* Mantém a API externa anterior (props `config`, `imageSrcProp`,
|
|
16
|
+
* `imageChanged`, `variant`, `onProcessingChange`) para compatibilidade
|
|
17
|
+
* com ArchbaseImageEdit e demais consumidores.
|
|
9
18
|
*/
|
|
10
|
-
import { ActionIconVariant, Text, useMantineColorScheme } from '@mantine/core';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
|
|
19
|
+
import { ActionIcon, ActionIconVariant, Box, Button, Group, Modal, Slider, Stack, Text, useMantineColorScheme } from '@mantine/core';
|
|
20
|
+
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone';
|
|
21
|
+
import { useDebouncedCallback, useDisclosure } from '@mantine/hooks';
|
|
22
|
+
import { useArchbaseTranslation, useArchbaseTheme } from '@archbase/core';
|
|
23
|
+
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
24
|
+
import Cropper, { Area } from 'react-easy-crop';
|
|
25
|
+
import {
|
|
26
|
+
IconCrop,
|
|
27
|
+
IconDownload,
|
|
28
|
+
IconPhoto,
|
|
29
|
+
IconPhotoPlus,
|
|
30
|
+
IconRotate,
|
|
31
|
+
IconTrash,
|
|
32
|
+
IconUpload,
|
|
33
|
+
IconX,
|
|
34
|
+
} from '@tabler/icons-react';
|
|
18
35
|
import { ArchbaseImagePickerConf } from './models/index.models';
|
|
36
|
+
import { getCroppedImage, resizeDataUri } from './functions/crop-image';
|
|
19
37
|
|
|
20
38
|
export * from './models/index.models';
|
|
21
39
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
'en': 'en',
|
|
27
|
-
'en-US': 'en',
|
|
28
|
-
'es': 'es',
|
|
29
|
-
'es-ES': 'es',
|
|
30
|
-
'fr': 'fr',
|
|
31
|
-
'fr-FR': 'fr',
|
|
32
|
-
'de': 'de',
|
|
33
|
-
'de-DE': 'de',
|
|
34
|
-
'fa': 'fa',
|
|
35
|
-
};
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Utilidades de detecção de formato — exportadas para uso em outros pontos do
|
|
42
|
+
// pacote (ArchbaseImageEdit, etc).
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
36
44
|
|
|
37
|
-
/**
|
|
38
|
-
* Verifica se uma string é um data URI válido (com prefixo data:image/...)
|
|
39
|
-
*/
|
|
40
45
|
function isDataUri(str: string | null | undefined): boolean {
|
|
41
|
-
|
|
42
|
-
return str.startsWith('data:');
|
|
46
|
+
return !!str && str.startsWith('data:');
|
|
43
47
|
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Verifica se uma string é um blob URL
|
|
47
|
-
*/
|
|
48
48
|
function isBlobUrl(str: string | null | undefined): boolean {
|
|
49
|
-
|
|
50
|
-
return str.startsWith('blob:');
|
|
49
|
+
return !!str && str.startsWith('blob:');
|
|
51
50
|
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Verifica se uma string é uma URL externa (http/https)
|
|
55
|
-
*/
|
|
56
51
|
function isExternalUrl(str: string | null | undefined): boolean {
|
|
57
|
-
|
|
58
|
-
return str.startsWith('http://') || str.startsWith('https://');
|
|
52
|
+
return !!str && (str.startsWith('http://') || str.startsWith('https://'));
|
|
59
53
|
}
|
|
60
54
|
|
|
61
55
|
/**
|
|
62
|
-
* Verifica se uma string parece ser base64 puro (sem prefixo data:)
|
|
63
|
-
* Base64 válido contém apenas A-Z, a-z, 0-9, +, /, = e tem comprimento múltiplo de 4
|
|
56
|
+
* Verifica se uma string parece ser base64 puro (sem prefixo data:).
|
|
64
57
|
*/
|
|
65
58
|
function isRawBase64(str: string | null | undefined): boolean {
|
|
66
|
-
if (!str || str.length < 100) return false;
|
|
67
|
-
// Verificar se não começa com prefixos conhecidos
|
|
59
|
+
if (!str || str.length < 100) return false;
|
|
68
60
|
if (isDataUri(str) || isBlobUrl(str) || isExternalUrl(str)) return false;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// Verificar apenas os primeiros 100 caracteres para performance
|
|
72
|
-
return base64Regex.test(str.substring(0, 100));
|
|
61
|
+
const head = str.substring(0, 100);
|
|
62
|
+
return /^[A-Za-z0-9+/]+=*$/.test(head);
|
|
73
63
|
}
|
|
74
64
|
|
|
75
65
|
/**
|
|
76
|
-
* Normaliza a imagem de entrada para um formato que o componente pode exibir
|
|
77
|
-
*
|
|
66
|
+
* Normaliza a imagem de entrada para um formato que o componente pode exibir:
|
|
67
|
+
* - data URIs, blob URLs e URLs externas são retornados como estão;
|
|
68
|
+
* - base64 puro é convertido em data URI, com mime inferido pelos primeiros
|
|
69
|
+
* bytes do conteúdo (JPEG/PNG/GIF/WebP).
|
|
78
70
|
*/
|
|
79
71
|
function normalizeImageSrc(src: string | null | undefined): string | null {
|
|
80
72
|
if (!src) return null;
|
|
81
|
-
|
|
82
|
-
// Se já é um formato válido, retornar como está
|
|
83
|
-
if (isDataUri(src) || isBlobUrl(src) || isExternalUrl(src)) {
|
|
84
|
-
return src;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Se parece ser base64 puro, converter para data URI
|
|
73
|
+
if (isDataUri(src) || isBlobUrl(src) || isExternalUrl(src)) return src;
|
|
88
74
|
if (isRawBase64(src)) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
let mimeType = 'image/jpeg'; // Padrão
|
|
95
|
-
if (src.startsWith('/9j/')) {
|
|
96
|
-
mimeType = 'image/jpeg';
|
|
97
|
-
} else if (src.startsWith('iVBOR')) {
|
|
98
|
-
mimeType = 'image/png';
|
|
99
|
-
} else if (src.startsWith('R0lGOD')) {
|
|
100
|
-
mimeType = 'image/gif';
|
|
101
|
-
} else if (src.startsWith('UklGR')) {
|
|
102
|
-
mimeType = 'image/webp';
|
|
103
|
-
}
|
|
75
|
+
let mimeType = 'image/jpeg';
|
|
76
|
+
if (src.startsWith('/9j/')) mimeType = 'image/jpeg';
|
|
77
|
+
else if (src.startsWith('iVBOR')) mimeType = 'image/png';
|
|
78
|
+
else if (src.startsWith('R0lGOD')) mimeType = 'image/gif';
|
|
79
|
+
else if (src.startsWith('UklGR')) mimeType = 'image/webp';
|
|
104
80
|
return `data:${mimeType};base64,${src}`;
|
|
105
81
|
}
|
|
106
|
-
|
|
107
|
-
// Se não reconhecemos o formato, retornar como está (pode ser uma URL relativa)
|
|
108
82
|
return src;
|
|
109
83
|
}
|
|
110
84
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
*/
|
|
114
|
-
function toImagePickerConf(
|
|
115
|
-
config: ArchbaseImagePickerConf,
|
|
116
|
-
colorScheme: 'light' | 'dark',
|
|
117
|
-
language: string
|
|
118
|
-
): ImagePickerConf {
|
|
119
|
-
return {
|
|
120
|
-
width: typeof config.width === 'number' ? `${config.width}px` : (config.width as string),
|
|
121
|
-
height: typeof config.height === 'number' ? `${config.height}px` : (config.height as string),
|
|
122
|
-
borderRadius: typeof config.borderRadius === 'number' ? `${config.borderRadius}px` : (config.borderRadius as string),
|
|
123
|
-
aspectRatio: config.aspectRatio ?? undefined,
|
|
124
|
-
objectFit: config.objectFit,
|
|
125
|
-
compressInitial: config.compressInitial ?? undefined,
|
|
126
|
-
hideDeleteBtn: config.hideDeleteBtn,
|
|
127
|
-
hideDownloadBtn: config.hideDownloadBtn,
|
|
128
|
-
hideEditBtn: config.hideEditBtn,
|
|
129
|
-
hideAddBtn: config.hideAddBtn,
|
|
130
|
-
darkMode: colorScheme === 'dark',
|
|
131
|
-
language: LANGUAGE_MAP[language] || 'en',
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Calcula o tamanho da imagem em KB a partir de uma string base64
|
|
137
|
-
*/
|
|
138
|
-
function calculateImageSize(dataUri: string | null | undefined): number | null {
|
|
139
|
-
if (!dataUri || !dataUri.length || !isDataUri(dataUri)) return null;
|
|
140
|
-
// Remove o header do data URI para calcular apenas o conteúdo base64
|
|
85
|
+
function calculateImageSizeKb(dataUri: string | null | undefined): number | null {
|
|
86
|
+
if (!dataUri || !isDataUri(dataUri)) return null;
|
|
141
87
|
const base64 = dataUri.split(',')[1];
|
|
142
88
|
if (!base64) return null;
|
|
143
|
-
// Fórmula: (3/4) * length / 1024 para obter KB aproximado
|
|
144
89
|
return Math.ceil(((3 / 4) * base64.length) / 1024);
|
|
145
90
|
}
|
|
146
91
|
|
|
147
|
-
/**
|
|
148
|
-
* Extrai o formato da imagem do data URI
|
|
149
|
-
*/
|
|
150
92
|
function extractFormat(dataUri: string | null | undefined): string {
|
|
151
93
|
if (!dataUri || !isDataUri(dataUri)) return '';
|
|
152
|
-
const match = dataUri.match(/data:image\/([a-zA-Z]+);base64,/);
|
|
153
|
-
if (match && match[1])
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
return 'png';
|
|
94
|
+
const match = dataUri.match(/data:image\/([a-zA-Z+\-.]+);base64,/);
|
|
95
|
+
if (match && match[1]) return match[1] === 'jpeg' ? 'jpeg' : match[1];
|
|
96
|
+
return '';
|
|
157
97
|
}
|
|
158
98
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
* @param maxWidth Largura máxima em pixels
|
|
163
|
-
* @param maxHeight Altura máxima em pixels
|
|
164
|
-
* @param maxSizeKb Tamanho máximo em KB
|
|
165
|
-
* @param quality Qualidade inicial da compressão (0-100)
|
|
166
|
-
* @returns Promise com o data URI redimensionado
|
|
167
|
-
*/
|
|
168
|
-
async function resizeImageIfNeeded(
|
|
169
|
-
dataUri: string,
|
|
170
|
-
maxWidth?: number,
|
|
171
|
-
maxHeight?: number,
|
|
172
|
-
maxSizeKb?: number,
|
|
173
|
-
quality: number = 80
|
|
174
|
-
): Promise<string> {
|
|
175
|
-
return new Promise((resolve) => {
|
|
176
|
-
// Se não há limites definidos, retorna como está
|
|
177
|
-
if (!maxWidth && !maxHeight && !maxSizeKb) {
|
|
178
|
-
resolve(dataUri);
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const img = new Image();
|
|
183
|
-
img.onload = () => {
|
|
184
|
-
let targetWidth = img.width;
|
|
185
|
-
let targetHeight = img.height;
|
|
186
|
-
|
|
187
|
-
// Calcular novo tamanho se exceder limites
|
|
188
|
-
if (maxWidth && img.width > maxWidth) {
|
|
189
|
-
const ratio = maxWidth / img.width;
|
|
190
|
-
targetWidth = maxWidth;
|
|
191
|
-
targetHeight = Math.round(img.height * ratio);
|
|
192
|
-
}
|
|
193
|
-
if (maxHeight && targetHeight > maxHeight) {
|
|
194
|
-
const ratio = maxHeight / targetHeight;
|
|
195
|
-
targetHeight = maxHeight;
|
|
196
|
-
targetWidth = Math.round(targetWidth * ratio);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Se não precisa redimensionar e não há limite de tamanho, retorna original
|
|
200
|
-
if (targetWidth === img.width && targetHeight === img.height && !maxSizeKb) {
|
|
201
|
-
resolve(dataUri);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Criar canvas para redimensionar
|
|
206
|
-
const canvas = document.createElement('canvas');
|
|
207
|
-
canvas.width = targetWidth;
|
|
208
|
-
canvas.height = targetHeight;
|
|
209
|
-
const ctx = canvas.getContext('2d');
|
|
210
|
-
if (!ctx) {
|
|
211
|
-
resolve(dataUri);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Componente
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
214
102
|
|
|
215
|
-
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
|
|
216
|
-
|
|
217
|
-
// Determinar formato de saída
|
|
218
|
-
const format = extractFormat(dataUri);
|
|
219
|
-
const mimeType = format === 'png' ? 'image/png' : 'image/jpeg';
|
|
220
|
-
|
|
221
|
-
// Função para comprimir até atingir o tamanho desejado
|
|
222
|
-
const compressToSize = (currentQuality: number): string => {
|
|
223
|
-
const result = canvas.toDataURL(mimeType, currentQuality / 100);
|
|
224
|
-
if (maxSizeKb) {
|
|
225
|
-
const sizeKb = Math.ceil(((3 / 4) * result.split(',')[1].length) / 1024);
|
|
226
|
-
// Se ainda está grande e qualidade pode ser reduzida
|
|
227
|
-
if (sizeKb > maxSizeKb && currentQuality > 20) {
|
|
228
|
-
return compressToSize(currentQuality - 10);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return result;
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
resolve(compressToSize(quality));
|
|
235
|
-
};
|
|
236
|
-
img.onerror = () => {
|
|
237
|
-
resolve(dataUri);
|
|
238
|
-
};
|
|
239
|
-
img.src = dataUri;
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Props do ArchbaseImagePickerEditor
|
|
245
|
-
* Mantém compatibilidade total com a versão anterior
|
|
246
|
-
*/
|
|
247
103
|
export interface ArchbaseImagePickerEditorProps {
|
|
248
104
|
/** Configuração do editor (interface ArchbaseImagePickerConf) */
|
|
249
105
|
config?: ArchbaseImagePickerConf;
|
|
@@ -262,32 +118,19 @@ export interface ArchbaseImagePickerEditorProps {
|
|
|
262
118
|
onProcessingChange?: (isProcessing: boolean) => void;
|
|
263
119
|
}
|
|
264
120
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
* ```tsx
|
|
279
|
-
* <ArchbaseImagePickerEditor
|
|
280
|
-
* imageSrcProp={imageValue}
|
|
281
|
-
* config={{
|
|
282
|
-
* width: 300,
|
|
283
|
-
* height: 200,
|
|
284
|
-
* compressInitial: 80,
|
|
285
|
-
* objectFit: 'contain',
|
|
286
|
-
* onChangeImage: (newImage) => setImageValue(newImage),
|
|
287
|
-
* }}
|
|
288
|
-
* />
|
|
289
|
-
* ```
|
|
290
|
-
*/
|
|
121
|
+
const DEFAULT_CONFIG: ArchbaseImagePickerConf = {
|
|
122
|
+
objectFit: 'cover',
|
|
123
|
+
hideDeleteBtn: false,
|
|
124
|
+
hideDownloadBtn: false,
|
|
125
|
+
hideEditBtn: false,
|
|
126
|
+
hideAddBtn: false,
|
|
127
|
+
compressInitial: null,
|
|
128
|
+
showImageSize: true,
|
|
129
|
+
width: 330,
|
|
130
|
+
height: 250,
|
|
131
|
+
borderRadius: '8px',
|
|
132
|
+
};
|
|
133
|
+
|
|
291
134
|
export const ArchbaseImagePickerEditor = memo(
|
|
292
135
|
({
|
|
293
136
|
config = {},
|
|
@@ -299,216 +142,371 @@ export const ArchbaseImagePickerEditor = memo(
|
|
|
299
142
|
}: ArchbaseImagePickerEditorProps) => {
|
|
300
143
|
const theme = useArchbaseTheme();
|
|
301
144
|
const { colorScheme } = useMantineColorScheme();
|
|
302
|
-
const { t
|
|
303
|
-
|
|
304
|
-
//
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const lastReportedImageRef = useRef<string | null | undefined>(undefined);
|
|
145
|
+
const { t } = useArchbaseTranslation();
|
|
146
|
+
// Wrapper para silenciar o tipo `string | object` retornado pelo i18next
|
|
147
|
+
// e prover fallback ao próprio default em pt-BR quando a chave não existir.
|
|
148
|
+
const tr = useCallback(
|
|
149
|
+
(key: string, fallback: string): string => {
|
|
150
|
+
const out = t(key, { defaultValue: fallback }) as unknown;
|
|
151
|
+
return typeof out === 'string' ? out : fallback;
|
|
152
|
+
},
|
|
153
|
+
[t],
|
|
154
|
+
);
|
|
313
155
|
|
|
314
|
-
|
|
315
|
-
|
|
156
|
+
const mergedConfig = useMemo<ArchbaseImagePickerConf>(
|
|
157
|
+
() => ({ ...DEFAULT_CONFIG, ...config }),
|
|
158
|
+
[config],
|
|
159
|
+
);
|
|
316
160
|
|
|
317
|
-
//
|
|
161
|
+
// ---- Estado --------------------------------------------------------
|
|
162
|
+
const initialSrc = useMemo(() => normalizeImageSrc(imageSrcProp), [imageSrcProp]);
|
|
163
|
+
const [imageSrc, setImageSrc] = useState<string | null>(initialSrc);
|
|
164
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
165
|
+
|
|
166
|
+
// Modal de crop
|
|
167
|
+
const [cropOpen, { open: openCrop, close: closeCrop }] = useDisclosure(false);
|
|
168
|
+
const [cropPosition, setCropPosition] = useState({ x: 0, y: 0 });
|
|
169
|
+
const [cropZoom, setCropZoom] = useState(1);
|
|
170
|
+
const [cropRotation, setCropRotation] = useState(0);
|
|
171
|
+
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
|
172
|
+
|
|
173
|
+
// Refs auxiliares
|
|
174
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
175
|
+
const lastReportedRef = useRef<string | null | undefined>(initialSrc);
|
|
318
176
|
const onProcessingChangeRef = useRef(onProcessingChange);
|
|
319
177
|
onProcessingChangeRef.current = onProcessingChange;
|
|
320
178
|
|
|
321
|
-
//
|
|
322
|
-
const isFirstRender = useRef(true);
|
|
323
|
-
|
|
324
|
-
// Ref para saber se já recebemos a imagem inicial (evita que o picker zere antes de carregar)
|
|
325
|
-
const hasReceivedInitialImage = useRef(false);
|
|
326
|
-
|
|
327
|
-
// Timestamp de quando o componente foi montado (para ignorar valores vazios só no início)
|
|
328
|
-
const mountTimeRef = useRef(Date.now());
|
|
329
|
-
|
|
330
|
-
// Configuração padrão
|
|
331
|
-
const defaultConfig: ArchbaseImagePickerConf = {
|
|
332
|
-
objectFit: 'cover',
|
|
333
|
-
hideDeleteBtn: false,
|
|
334
|
-
hideDownloadBtn: false,
|
|
335
|
-
hideEditBtn: false,
|
|
336
|
-
hideAddBtn: false,
|
|
337
|
-
compressInitial: null,
|
|
338
|
-
showImageSize: true,
|
|
339
|
-
width: 330,
|
|
340
|
-
height: 250,
|
|
341
|
-
borderRadius: '8px',
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
const mergedConfig = useMemo(() => ({ ...defaultConfig, ...config }), [config]);
|
|
345
|
-
|
|
346
|
-
// Converter para configuração do react-image-picker-editor
|
|
347
|
-
const pickerConfig = useMemo(
|
|
348
|
-
() => toImagePickerConf(mergedConfig, colorScheme as 'light' | 'dark', i18n.language),
|
|
349
|
-
[mergedConfig, colorScheme, i18n.language]
|
|
350
|
-
);
|
|
351
|
-
|
|
352
|
-
// Callback com debounce para evitar múltiplas chamadas rápidas
|
|
353
|
-
const debouncedOnChange = useDebouncedCallback((newDataUri: string | undefined) => {
|
|
354
|
-
// Verificar se a imagem realmente mudou
|
|
355
|
-
if (lastReportedImageRef.current === newDataUri) {
|
|
356
|
-
// Notificar que o processamento terminou (usando ref para evitar closure stale)
|
|
357
|
-
onProcessingChangeRef.current?.(false);
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
lastReportedImageRef.current = newDataUri;
|
|
362
|
-
|
|
363
|
-
// Chamar callback do config (API principal)
|
|
364
|
-
if (mergedConfig.onChangeImage) {
|
|
365
|
-
mergedConfig.onChangeImage(newDataUri);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Chamar callback legado (para compatibilidade)
|
|
369
|
-
if (imageChanged) {
|
|
370
|
-
imageChanged(newDataUri);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Notificar que o processamento terminou (usando ref para evitar closure stale)
|
|
374
|
-
onProcessingChangeRef.current?.(false);
|
|
375
|
-
}, 300); // 300ms de debounce
|
|
376
|
-
|
|
377
|
-
// Sincronizar com prop externa
|
|
179
|
+
// Sincronizar com prop externa, evitando reentrância do que nós mesmos emitimos.
|
|
378
180
|
useEffect(() => {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
hasReceivedInitialImage.current = true;
|
|
385
|
-
}
|
|
181
|
+
const normalized = normalizeImageSrc(imageSrcProp);
|
|
182
|
+
if (normalized === lastReportedRef.current) return;
|
|
183
|
+
lastReportedRef.current = normalized;
|
|
184
|
+
setImageSrc(normalized);
|
|
185
|
+
}, [imageSrcProp]);
|
|
386
186
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
187
|
+
// Manter ref de processing sincronizada e refletir externamente
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
onProcessingChangeRef.current?.(isProcessing);
|
|
190
|
+
}, [isProcessing]);
|
|
191
|
+
|
|
192
|
+
// Notificação debounceada — evita rajadas de callbacks ao redimensionar.
|
|
193
|
+
const debouncedNotify = useDebouncedCallback((dataUri: string | undefined) => {
|
|
194
|
+
if (lastReportedRef.current === dataUri) return;
|
|
195
|
+
lastReportedRef.current = dataUri ?? null;
|
|
196
|
+
mergedConfig.onChangeImage?.(dataUri);
|
|
197
|
+
imageChanged?.(dataUri);
|
|
198
|
+
if (mergedConfig.debug) {
|
|
199
|
+
const mime = dataUri?.match(/^data:(image\/[a-zA-Z+\-.]+);/)?.[1] ?? null;
|
|
200
|
+
// eslint-disable-next-line no-console
|
|
201
|
+
console.log('[ArchbaseImagePickerEditor] notify', {
|
|
202
|
+
mime,
|
|
203
|
+
sizeKb: dataUri ? calculateImageSizeKb(dataUri) : null,
|
|
204
|
+
});
|
|
391
205
|
}
|
|
206
|
+
}, 200);
|
|
207
|
+
|
|
208
|
+
// ---- Upload -------------------------------------------------------
|
|
209
|
+
const ingestFile = useCallback(
|
|
210
|
+
async (file: File) => {
|
|
211
|
+
setIsProcessing(true);
|
|
212
|
+
try {
|
|
213
|
+
// Ler arquivo como data URI puro — preserva bytes originais
|
|
214
|
+
// (sem canvas roundtrip), garantindo PNG/WebP com alpha intactos.
|
|
215
|
+
const dataUri = await new Promise<string>((resolve, reject) => {
|
|
216
|
+
const reader = new FileReader();
|
|
217
|
+
reader.onload = () => resolve(reader.result as string);
|
|
218
|
+
reader.onerror = () => reject(reader.error);
|
|
219
|
+
reader.readAsDataURL(file);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Aplicar limites de tamanho/peso (opcionais).
|
|
223
|
+
const processed = await resizeDataUri(
|
|
224
|
+
dataUri,
|
|
225
|
+
mergedConfig.maxWidth,
|
|
226
|
+
mergedConfig.maxHeight,
|
|
227
|
+
mergedConfig.maxSizeKb,
|
|
228
|
+
mergedConfig.compressInitial ?? 92,
|
|
229
|
+
mergedConfig.preserveTransparency ?? false,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
setImageSrc(processed);
|
|
233
|
+
debouncedNotify(processed);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
// eslint-disable-next-line no-console
|
|
236
|
+
console.error('[ArchbaseImagePickerEditor] failed to read file', e);
|
|
237
|
+
} finally {
|
|
238
|
+
setIsProcessing(false);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
[
|
|
242
|
+
debouncedNotify,
|
|
243
|
+
mergedConfig.maxWidth,
|
|
244
|
+
mergedConfig.maxHeight,
|
|
245
|
+
mergedConfig.maxSizeKb,
|
|
246
|
+
mergedConfig.compressInitial,
|
|
247
|
+
mergedConfig.preserveTransparency,
|
|
248
|
+
],
|
|
249
|
+
);
|
|
392
250
|
|
|
393
|
-
|
|
394
|
-
|
|
251
|
+
const handleDrop = useCallback(
|
|
252
|
+
(files: File[]) => {
|
|
253
|
+
const file = files?.[0];
|
|
254
|
+
if (file) ingestFile(file);
|
|
255
|
+
},
|
|
256
|
+
[ingestFile],
|
|
257
|
+
);
|
|
395
258
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
259
|
+
const handleHiddenInputChange = useCallback(
|
|
260
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
261
|
+
const file = e.target.files?.[0];
|
|
262
|
+
if (file) ingestFile(file);
|
|
263
|
+
// Permite re-selecionar o mesmo arquivo
|
|
264
|
+
e.target.value = '';
|
|
265
|
+
},
|
|
266
|
+
[ingestFile],
|
|
267
|
+
);
|
|
402
268
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
269
|
+
const triggerReupload = useCallback(() => {
|
|
270
|
+
fileInputRef.current?.click();
|
|
271
|
+
}, []);
|
|
406
272
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
273
|
+
// ---- Actions ------------------------------------------------------
|
|
274
|
+
const handleDelete = useCallback(() => {
|
|
275
|
+
setImageSrc(null);
|
|
276
|
+
debouncedNotify(undefined);
|
|
277
|
+
}, [debouncedNotify]);
|
|
278
|
+
|
|
279
|
+
const handleDownload = useCallback(() => {
|
|
280
|
+
if (!imageSrc) return;
|
|
281
|
+
// Para data URI, fazer download manual para evitar bloqueio em alguns browsers
|
|
282
|
+
const ext = extractFormat(imageSrc) || 'png';
|
|
283
|
+
const a = document.createElement('a');
|
|
284
|
+
a.href = imageSrc;
|
|
285
|
+
a.download = `image.${ext === 'jpeg' ? 'jpg' : ext}`;
|
|
286
|
+
document.body.appendChild(a);
|
|
287
|
+
a.click();
|
|
288
|
+
document.body.removeChild(a);
|
|
289
|
+
}, [imageSrc]);
|
|
290
|
+
|
|
291
|
+
// ---- Crop ---------------------------------------------------------
|
|
292
|
+
const onCropComplete = useCallback((_croppedArea: Area, croppedAreaPx: Area) => {
|
|
293
|
+
setCroppedAreaPixels(croppedAreaPx);
|
|
294
|
+
}, []);
|
|
411
295
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
296
|
+
const handleOpenCrop = useCallback(() => {
|
|
297
|
+
setCropPosition({ x: 0, y: 0 });
|
|
298
|
+
setCropZoom(1);
|
|
299
|
+
setCropRotation(0);
|
|
300
|
+
setCroppedAreaPixels(null);
|
|
301
|
+
openCrop();
|
|
302
|
+
}, [openCrop]);
|
|
303
|
+
|
|
304
|
+
const handleApplyCrop = useCallback(async () => {
|
|
305
|
+
if (!imageSrc || !croppedAreaPixels) {
|
|
306
|
+
closeCrop();
|
|
416
307
|
return;
|
|
417
308
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
newDataUri,
|
|
427
|
-
mergedConfig.maxWidth,
|
|
428
|
-
mergedConfig.maxHeight,
|
|
429
|
-
mergedConfig.maxSizeKb,
|
|
430
|
-
mergedConfig.compressInitial ?? 80
|
|
309
|
+
setIsProcessing(true);
|
|
310
|
+
try {
|
|
311
|
+
const cropped = await getCroppedImage(
|
|
312
|
+
imageSrc,
|
|
313
|
+
croppedAreaPixels,
|
|
314
|
+
cropRotation,
|
|
315
|
+
mergedConfig.preserveTransparency ?? false,
|
|
316
|
+
mergedConfig.compressInitial ?? 92,
|
|
431
317
|
);
|
|
318
|
+
setImageSrc(cropped);
|
|
319
|
+
debouncedNotify(cropped);
|
|
320
|
+
} catch (e) {
|
|
321
|
+
// eslint-disable-next-line no-console
|
|
322
|
+
console.error('[ArchbaseImagePickerEditor] crop failed', e);
|
|
323
|
+
} finally {
|
|
324
|
+
setIsProcessing(false);
|
|
325
|
+
closeCrop();
|
|
432
326
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
//
|
|
444
|
-
const
|
|
327
|
+
}, [
|
|
328
|
+
imageSrc,
|
|
329
|
+
croppedAreaPixels,
|
|
330
|
+
cropRotation,
|
|
331
|
+
mergedConfig.preserveTransparency,
|
|
332
|
+
mergedConfig.compressInitial,
|
|
333
|
+
closeCrop,
|
|
334
|
+
debouncedNotify,
|
|
335
|
+
]);
|
|
336
|
+
|
|
337
|
+
// ---- Render -------------------------------------------------------
|
|
338
|
+
const sizeKb = useMemo(() => calculateImageSizeKb(imageSrc), [imageSrc]);
|
|
445
339
|
const format = useMemo(() => extractFormat(imageSrc), [imageSrc]);
|
|
340
|
+
const showSizeInfo = !!(mergedConfig.showImageSize && sizeKb !== null);
|
|
446
341
|
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
backgroundColor: mergedConfig.imageBackgroundColor ?? (colorScheme === 'dark' ? theme.colors.dark[7] : theme.white),
|
|
454
|
-
}), [mergedConfig.width, mergedConfig.imageBackgroundColor, colorScheme, theme]);
|
|
455
|
-
|
|
456
|
-
// Handler para prevenir submit de formulário e navegação acidental
|
|
457
|
-
const handleContainerClick = useCallback((e: React.MouseEvent) => {
|
|
458
|
-
const target = e.target as HTMLElement;
|
|
459
|
-
|
|
460
|
-
// Prevenir submit de formulário quando botões são clicados
|
|
461
|
-
// Os botões do react-image-picker-editor não têm type="button",
|
|
462
|
-
// então agem como type="submit" quando dentro de um form
|
|
463
|
-
const button = target.closest('button');
|
|
464
|
-
if (button && !button.getAttribute('type')) {
|
|
465
|
-
e.preventDefault();
|
|
466
|
-
}
|
|
342
|
+
const widthStyle = typeof mergedConfig.width === 'number' ? `${mergedConfig.width}px` : mergedConfig.width;
|
|
343
|
+
const heightStyle = typeof mergedConfig.height === 'number' ? `${mergedConfig.height}px` : mergedConfig.height;
|
|
344
|
+
const borderRadiusStyle =
|
|
345
|
+
typeof mergedConfig.borderRadius === 'number'
|
|
346
|
+
? `${mergedConfig.borderRadius}px`
|
|
347
|
+
: mergedConfig.borderRadius;
|
|
467
348
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
e.preventDefault();
|
|
474
|
-
// Se for o botão de download, fazer download manualmente
|
|
475
|
-
if (anchor.hasAttribute('download')) {
|
|
476
|
-
const link = document.createElement('a');
|
|
477
|
-
link.href = href;
|
|
478
|
-
link.download = anchor.getAttribute('download') || 'image';
|
|
479
|
-
document.body.appendChild(link);
|
|
480
|
-
link.click();
|
|
481
|
-
document.body.removeChild(link);
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
}, []);
|
|
349
|
+
const containerBg =
|
|
350
|
+
mergedConfig.imageBackgroundColor ??
|
|
351
|
+
(colorScheme === 'dark' ? theme.colors.dark[7] : theme.white);
|
|
352
|
+
|
|
353
|
+
const showPlaceholder = !imageSrc;
|
|
486
354
|
|
|
487
355
|
return (
|
|
488
|
-
<
|
|
489
|
-
{/*
|
|
490
|
-
<
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
356
|
+
<Box style={{ width: widthStyle }}>
|
|
357
|
+
{/* Input file escondido — reaproveitado para botão "Adicionar" */}
|
|
358
|
+
<input
|
|
359
|
+
ref={fileInputRef}
|
|
360
|
+
type="file"
|
|
361
|
+
accept={IMAGE_MIME_TYPE.join(',')}
|
|
362
|
+
style={{ display: 'none' }}
|
|
363
|
+
onChange={handleHiddenInputChange}
|
|
495
364
|
/>
|
|
496
365
|
|
|
497
|
-
{
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
366
|
+
{showPlaceholder ? (
|
|
367
|
+
<Dropzone
|
|
368
|
+
onDrop={handleDrop}
|
|
369
|
+
accept={IMAGE_MIME_TYPE}
|
|
370
|
+
maxFiles={1}
|
|
371
|
+
multiple={false}
|
|
372
|
+
loading={isProcessing}
|
|
373
|
+
styles={{
|
|
374
|
+
root: {
|
|
375
|
+
width: widthStyle,
|
|
376
|
+
height: heightStyle,
|
|
377
|
+
borderRadius: borderRadiusStyle,
|
|
378
|
+
backgroundColor: containerBg,
|
|
379
|
+
display: 'flex',
|
|
380
|
+
alignItems: 'center',
|
|
381
|
+
justifyContent: 'center',
|
|
382
|
+
},
|
|
383
|
+
}}
|
|
384
|
+
>
|
|
385
|
+
<Stack align="center" gap="xs" justify="center" style={{ pointerEvents: 'none' }}>
|
|
386
|
+
<Dropzone.Accept>
|
|
387
|
+
<IconUpload size={32} color={color} />
|
|
388
|
+
</Dropzone.Accept>
|
|
389
|
+
<Dropzone.Reject>
|
|
390
|
+
<IconX size={32} color="var(--mantine-color-red-6)" />
|
|
391
|
+
</Dropzone.Reject>
|
|
392
|
+
<Dropzone.Idle>
|
|
393
|
+
<IconPhotoPlus size={32} color={color} />
|
|
394
|
+
</Dropzone.Idle>
|
|
395
|
+
<Text size="sm" c="dimmed" ta="center">
|
|
396
|
+
{tr('archbase:Arraste uma imagem ou clique para selecionar', 'Arraste uma imagem ou clique para selecionar')}
|
|
397
|
+
</Text>
|
|
398
|
+
</Stack>
|
|
399
|
+
</Dropzone>
|
|
400
|
+
) : (
|
|
401
|
+
<Box
|
|
501
402
|
style={{
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
403
|
+
position: 'relative',
|
|
404
|
+
width: widthStyle,
|
|
405
|
+
height: heightStyle,
|
|
406
|
+
borderRadius: borderRadiusStyle,
|
|
407
|
+
overflow: 'hidden',
|
|
408
|
+
backgroundColor: containerBg,
|
|
506
409
|
}}
|
|
507
410
|
>
|
|
508
|
-
<
|
|
509
|
-
|
|
411
|
+
<img
|
|
412
|
+
src={imageSrc}
|
|
413
|
+
alt=""
|
|
414
|
+
style={{
|
|
415
|
+
width: '100%',
|
|
416
|
+
height: '100%',
|
|
417
|
+
objectFit: mergedConfig.objectFit ?? 'contain',
|
|
418
|
+
display: 'block',
|
|
419
|
+
}}
|
|
420
|
+
/>
|
|
421
|
+
</Box>
|
|
510
422
|
)}
|
|
511
|
-
|
|
423
|
+
|
|
424
|
+
{/* Barra de ações — fora da imagem para não competir por área de clique
|
|
425
|
+
em previews pequenos (ex.: favicons 48px). */}
|
|
426
|
+
{imageSrc && (
|
|
427
|
+
<Group gap={4} mt={6} justify="flex-start" wrap="nowrap">
|
|
428
|
+
{!mergedConfig.hideAddBtn && (
|
|
429
|
+
<ActionIcon variant={variant} color={color} title={tr('archbase:Trocar imagem', 'Trocar imagem')} onClick={triggerReupload}>
|
|
430
|
+
<IconPhoto size={18} />
|
|
431
|
+
</ActionIcon>
|
|
432
|
+
)}
|
|
433
|
+
{!mergedConfig.hideEditBtn && (
|
|
434
|
+
<ActionIcon variant={variant} color={color} title={tr('archbase:Editar', 'Editar')} onClick={handleOpenCrop}>
|
|
435
|
+
<IconCrop size={18} />
|
|
436
|
+
</ActionIcon>
|
|
437
|
+
)}
|
|
438
|
+
{!mergedConfig.hideDownloadBtn && (
|
|
439
|
+
<ActionIcon variant={variant} color={color} title={tr('archbase:Baixar', 'Baixar')} onClick={handleDownload}>
|
|
440
|
+
<IconDownload size={18} />
|
|
441
|
+
</ActionIcon>
|
|
442
|
+
)}
|
|
443
|
+
{!mergedConfig.hideDeleteBtn && (
|
|
444
|
+
<ActionIcon variant={variant} color="red" title={tr('archbase:Remover', 'Remover')} onClick={handleDelete}>
|
|
445
|
+
<IconTrash size={18} />
|
|
446
|
+
</ActionIcon>
|
|
447
|
+
)}
|
|
448
|
+
</Group>
|
|
449
|
+
)}
|
|
450
|
+
|
|
451
|
+
{showSizeInfo && imageSrc && (
|
|
452
|
+
<Text size="xs" c="dimmed" mt={4} ta="center">
|
|
453
|
+
{`${tr('archbase:size', 'tamanho')}: ${sizeKb}Kb ${format}`}
|
|
454
|
+
</Text>
|
|
455
|
+
)}
|
|
456
|
+
|
|
457
|
+
{/* Modal de crop/rotate */}
|
|
458
|
+
<Modal
|
|
459
|
+
opened={cropOpen}
|
|
460
|
+
onClose={closeCrop}
|
|
461
|
+
title={tr('archbase:Editar imagem', 'Editar imagem')}
|
|
462
|
+
size="xl"
|
|
463
|
+
centered
|
|
464
|
+
>
|
|
465
|
+
{imageSrc && (
|
|
466
|
+
<Stack>
|
|
467
|
+
<Box style={{ position: 'relative', width: '100%', height: 400, background: '#333' }}>
|
|
468
|
+
<Cropper
|
|
469
|
+
image={imageSrc}
|
|
470
|
+
crop={cropPosition}
|
|
471
|
+
zoom={cropZoom}
|
|
472
|
+
rotation={cropRotation}
|
|
473
|
+
aspect={mergedConfig.aspectRatio ?? undefined}
|
|
474
|
+
onCropChange={setCropPosition}
|
|
475
|
+
onZoomChange={setCropZoom}
|
|
476
|
+
onRotationChange={setCropRotation}
|
|
477
|
+
onCropComplete={onCropComplete}
|
|
478
|
+
restrictPosition={false}
|
|
479
|
+
/>
|
|
480
|
+
</Box>
|
|
481
|
+
<Group grow align="flex-end">
|
|
482
|
+
<Stack gap={2}>
|
|
483
|
+
<Text size="xs">{tr('archbase:Zoom', 'Zoom')}</Text>
|
|
484
|
+
<Slider min={1} max={5} step={0.1} value={cropZoom} onChange={setCropZoom} />
|
|
485
|
+
</Stack>
|
|
486
|
+
<Stack gap={2}>
|
|
487
|
+
<Text size="xs">{tr('archbase:Rotação', 'Rotação')}</Text>
|
|
488
|
+
<Slider
|
|
489
|
+
min={0}
|
|
490
|
+
max={360}
|
|
491
|
+
step={1}
|
|
492
|
+
value={cropRotation}
|
|
493
|
+
onChange={setCropRotation}
|
|
494
|
+
thumbChildren={<IconRotate size={12} />}
|
|
495
|
+
/>
|
|
496
|
+
</Stack>
|
|
497
|
+
</Group>
|
|
498
|
+
<Group justify="flex-end">
|
|
499
|
+
<Button variant="default" onClick={closeCrop} disabled={isProcessing}>
|
|
500
|
+
{tr('archbase:Cancelar', 'Cancelar')}
|
|
501
|
+
</Button>
|
|
502
|
+
<Button onClick={handleApplyCrop} loading={isProcessing}>
|
|
503
|
+
{tr('archbase:Aplicar', 'Aplicar')}
|
|
504
|
+
</Button>
|
|
505
|
+
</Group>
|
|
506
|
+
</Stack>
|
|
507
|
+
)}
|
|
508
|
+
</Modal>
|
|
509
|
+
</Box>
|
|
512
510
|
);
|
|
513
511
|
},
|
|
514
512
|
);
|