@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.
@@ -1,249 +1,105 @@
1
1
  /**
2
- * ArchbaseImagePickerEditor - Wrapper para react-image-picker-editor
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
- * Funcionalidades:
6
- * - Suporta diferentes tipos de entrada: URL, data URI (base64), blob URL
7
- * - Debounce para evitar múltiplas chamadas do callback
8
- * - Mantém compatibilidade total com a API anterior
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 { useDebouncedCallback } from '@mantine/hooks';
12
- import { useArchbaseTranslation } from '@archbase/core';
13
- import React, { memo, useEffect, useMemo, useRef, useState, useCallback } from 'react';
14
- import { useArchbaseTheme } from '@archbase/core';
15
- import ReactImagePickerEditor, { ImagePickerConf } from 'react-image-picker-editor';
16
- import 'react-image-picker-editor/dist/index.css';
17
- import './image_editor_styles.scss';
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
- // Mapeamento de idiomas para o react-image-picker-editor
23
- const LANGUAGE_MAP: Record<string, string> = {
24
- 'pt-BR': 'pt',
25
- 'pt': 'pt',
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
- if (!str) return false;
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
- if (!str) return false;
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
- if (!str) return false;
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; // Imagens base64 são geralmente grandes
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
- // Verificar padrão de base64
70
- const base64Regex = /^[A-Za-z0-9+/]+=*$/;
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
- * Converte base64 puro para data URI se necessário
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
- // Tentar detectar o tipo da imagem pelo header base64
90
- // JPEG começa com /9j/
91
- // PNG começa com iVBOR
92
- // GIF começa com R0lGOD
93
- // WebP começa com UklGR
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
- * Converte a configuração do Archbase para o formato do react-image-picker-editor
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
- return match[1] === 'jpeg' ? 'jpeg' : match[1];
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
- * Redimensiona uma imagem se exceder os limites de tamanho
161
- * @param dataUri Data URI da imagem original
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
- * ArchbaseImagePickerEditor - Componente de seleção e edição de imagens
267
- *
268
- * Funcionalidades:
269
- * - Seleção de imagens (upload)
270
- * - Edição com crop, redimensionamento
271
- * - Compressão de qualidade (JPEG/WebP)
272
- * - Conversão de formatos (PNG, JPEG, WebP)
273
- * - Filtros (contraste, brilho, saturação, sépia, blur)
274
- * - Suporte a dark mode
275
- * - Suporte a URL externa, data URI e blob URL
276
- *
277
- * @example
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, i18n } = useArchbaseTranslation();
303
-
304
- // Normalizar a imagem de entrada para formato suportado
305
- const normalizedInitialSrc = useMemo(() => normalizeImageSrc(imageSrcProp), [imageSrcProp]);
306
-
307
- const [imageSrc, setImageSrc] = useState<string | null>(normalizedInitialSrc);
308
- const [loadImage, setLoadImage] = useState<boolean>(!!normalizedInitialSrc);
309
- const imageName = useRef('download');
310
-
311
- // Ref para rastrear a última imagem reportada (evitar callbacks duplicados)
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
- // Ref para a imagem original (antes de normalização) para comparação
315
- const originalImagePropRef = useRef<string | undefined>(imageSrcProp);
156
+ const mergedConfig = useMemo<ArchbaseImagePickerConf>(
157
+ () => ({ ...DEFAULT_CONFIG, ...config }),
158
+ [config],
159
+ );
316
160
 
317
- // Ref para onProcessingChange para evitar closure stale no debounce
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
- // Ref para saber se é a primeira renderização
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
- // Sempre atualizar quando a prop mudar (mesmo que seja a mesma do ref inicial)
380
- const normalizedSrc = normalizeImageSrc(imageSrcProp);
381
-
382
- // Marcar que recebemos a imagem inicial quando receber um valor não vazio
383
- if (normalizedSrc && !hasReceivedInitialImage.current) {
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
- // atualizar o estado se o valor normalizado for diferente do atual
388
- if (normalizedSrc !== imageSrc) {
389
- setImageSrc(normalizedSrc);
390
- setLoadImage(!!normalizedSrc);
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
- // Atualizar a referência
394
- originalImagePropRef.current = imageSrcProp;
251
+ const handleDrop = useCallback(
252
+ (files: File[]) => {
253
+ const file = files?.[0];
254
+ if (file) ingestFile(file);
255
+ },
256
+ [ingestFile],
257
+ );
395
258
 
396
- // Atualizar a referência sem disparar callback na sincronização inicial
397
- if (isFirstRender.current) {
398
- lastReportedImageRef.current = normalizedSrc;
399
- isFirstRender.current = false;
400
- }
401
- }, [imageSrcProp]);
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
- // Handler quando a imagem muda no editor
404
- const handleImageChanged = useCallback(async (newDataUri: string) => {
405
- const timeSinceMount = Date.now() - mountTimeRef.current;
269
+ const triggerReupload = useCallback(() => {
270
+ fileInputRef.current?.click();
271
+ }, []);
406
272
 
407
- // processa se for diferente do valor atual
408
- if (imageSrc === newDataUri) {
409
- return;
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
- // Ignorar valores vazios APENAS nos primeiros 1000ms após montar
413
- // Isso evita que o react-image-picker-editor zere a imagem antes de carregar
414
- // Mas permite que o usuário remova a imagem depois
415
- if (!newDataUri && timeSinceMount < 1000) {
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
- // Notificar que o processamento iniciou
420
- onProcessingChange?.(true);
421
-
422
- // Redimensionar se necessário
423
- let processedDataUri = newDataUri;
424
- if (newDataUri && (mergedConfig.maxWidth || mergedConfig.maxHeight || mergedConfig.maxSizeKb)) {
425
- processedDataUri = await resizeImageIfNeeded(
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
- const newValue = processedDataUri || undefined;
435
-
436
- setImageSrc(processedDataUri || null);
437
- setLoadImage(!!processedDataUri);
438
-
439
- // Usar callback com debounce
440
- debouncedOnChange(newValue);
441
- }, [imageSrc, debouncedOnChange, onProcessingChange, mergedConfig.maxWidth, mergedConfig.maxHeight, mergedConfig.maxSizeKb, mergedConfig.compressInitial]);
442
-
443
- // Calcular tamanho e formato da imagem (apenas para data URIs)
444
- const sizeImage = useMemo(() => calculateImageSize(imageSrc), [imageSrc]);
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
- // Verificar se deve mostrar informações de tamanho
448
- const showSizeInfo = loadImage && sizeImage !== null && mergedConfig.showImageSize && isDataUri(imageSrc);
449
-
450
- // Estilo do container
451
- const containerStyle = useMemo(() => ({
452
- width: typeof mergedConfig.width === 'number' ? `${mergedConfig.width}px` : mergedConfig.width,
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
- // Prevenir navegação para data URIs em links
469
- const anchor = target.closest('a');
470
- if (anchor) {
471
- const href = anchor.getAttribute('href');
472
- if (href && href.startsWith('data:')) {
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
- <div className="ArchbaseImagePickerEditor" style={containerStyle} onClick={handleContainerClick}>
489
- {/* Componente react-image-picker-editor */}
490
- <ReactImagePickerEditor
491
- config={pickerConfig}
492
- imageSrcProp={imageSrc || ''}
493
- imageChanged={handleImageChanged}
494
- color={color}
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
- {/* Informação de tamanho (quando habilitado, imagem carregada e é data URI) */}
498
- {showSizeInfo && (
499
- <div
500
- className="caption image-caption"
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
- color: sizeImage! > 120 ? '#f44336' : 'unset',
503
- fontWeight: sizeImage! > 120 ? '500' : 'unset',
504
- marginTop: '4px',
505
- textAlign: 'center',
403
+ position: 'relative',
404
+ width: widthStyle,
405
+ height: heightStyle,
406
+ borderRadius: borderRadiusStyle,
407
+ overflow: 'hidden',
408
+ backgroundColor: containerBg,
506
409
  }}
507
410
  >
508
- <Text size="sm">{`${t('archbase:size')}: ${sizeImage}Kb ${format}`}</Text>
509
- </div>
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
- </div>
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
  );