@archbase/components 4.0.1 → 4.0.3

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.
@@ -0,0 +1,395 @@
1
+ import {
2
+ ActionIcon,
3
+ ActionIconVariant,
4
+ MantineSize,
5
+ TagsInput,
6
+ Tooltip,
7
+ useMantineColorScheme,
8
+ useMantineTheme,
9
+ } from '@mantine/core';
10
+ import { useForceUpdate } from '@mantine/hooks';
11
+ import type { CSSProperties, FocusEventHandler, ReactNode } from 'react';
12
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
13
+ import type { ArchbaseDataSource, DataSourceEvent } from '@archbase/data';
14
+ import { DataSourceEventNames } from '@archbase/data';
15
+ import { useArchbaseDidMount, useArchbaseDidUpdate, useArchbaseWillUnmount } from '@archbase/core';
16
+ import type { ComboboxData, ComboboxLikeRenderOptionInput, ComboboxGenericItem } from '@mantine/core';
17
+ import type { InputClearButtonProps, ScrollAreaProps } from '@mantine/core';
18
+
19
+ export interface ArchbaseTagInputEditProps<T, ID> {
20
+ /** Fonte de dados onde será atribuido o valor do edit */
21
+ dataSource?: ArchbaseDataSource<T, ID>;
22
+ /** Campo onde deverá ser atribuido o valor do edit na fonte de dados */
23
+ dataField?: string;
24
+ /** Indicador se o edit está desabilitado */
25
+ disabled?: boolean;
26
+ /** Indicador se o edit é somente leitura. Obs: usado em conjunto com o status da fonte de dados */
27
+ readOnly?: boolean;
28
+ /** Indicador se o preenchimento do edit é obrigatório */
29
+ required?: boolean;
30
+ /** Valor controlado do componente */
31
+ value?: string[];
32
+ /** Valor padrão para componente não controlado */
33
+ defaultValue?: string[];
34
+ /** Estilo do edit */
35
+ style?: CSSProperties;
36
+ /** Tamanho do edit */
37
+ size?: MantineSize;
38
+ /** Largura do edit */
39
+ width?: string | number | undefined;
40
+ /** Icone à direita */
41
+ icon?: ReactNode;
42
+ /** Dica para botão localizar */
43
+ tooltipIconSearch?: string;
44
+ /** Evento ocorre quando clica no botão localizar */
45
+ onActionSearchExecute?: () => void;
46
+ /** Texto sugestão do edit */
47
+ placeholder?: string;
48
+ /** Título do edit */
49
+ label?: string;
50
+ /** Descrição do edit */
51
+ description?: string;
52
+ /** Último erro ocorrido no edit */
53
+ error?: string;
54
+ /** Evento quando o foco sai do edit */
55
+ onFocusExit?: FocusEventHandler<T> | undefined;
56
+ /** Evento quando o edit recebe o foco */
57
+ onFocusEnter?: FocusEventHandler<T> | undefined;
58
+ /** Evento quando o valor do edit é alterado */
59
+ onChangeValue?: (value: string[]) => void;
60
+ /** Chamado quando uma tag é removida */
61
+ onRemove?: (value: string) => void;
62
+ /** Chamado quando o botão limpar é clicado */
63
+ onClear?: () => void;
64
+ onKeyDown?: (event: any) => void;
65
+ onKeyUp?: (event: any) => void;
66
+ /** Referência para o componente interno */
67
+ innerRef?: React.RefObject<HTMLInputElement> | undefined;
68
+ variant?: ActionIconVariant;
69
+
70
+ // Props específicas do TagsInput baseadas na interface oficial
71
+ /** Dados exibidos no dropdown. Valores devem ser únicos */
72
+ data?: ComboboxData;
73
+ /** Valor de busca controlado */
74
+ searchValue?: string;
75
+ /** Valor de busca padrão */
76
+ defaultSearchValue?: string;
77
+ /** Chamado quando a busca muda */
78
+ onSearchChange?: (value: string) => void;
79
+ /** Número máximo de tags, `Infinity` por padrão */
80
+ maxTags?: number;
81
+ /** Determina se tags duplicadas são permitidas, `false` por padrão */
82
+ allowDuplicates?: boolean;
83
+ /** Chamado quando usuário tenta submeter uma tag duplicada */
84
+ onDuplicate?: (value: string) => void;
85
+ /** Caracteres que devem disparar a divisão de tags, `[',']` por padrão */
86
+ splitChars?: string[];
87
+ /** Determina se o botão limpar deve ser exibido quando o componente tem valor, `false` por padrão */
88
+ clearable?: boolean;
89
+ /** Props passadas para o botão limpar */
90
+ clearButtonProps?: InputClearButtonProps & React.ComponentPropsWithoutRef<'button'>;
91
+ /** Props passadas para o input oculto */
92
+ hiddenInputProps?: Omit<React.ComponentPropsWithoutRef<'input'>, 'value'>;
93
+ /** Divisor usado para separar valores no atributo `value` do input oculto, `','` por padrão */
94
+ hiddenInputValuesDivider?: string;
95
+ /** Função para renderizar o conteúdo da opção */
96
+ renderOption?: (input: ComboboxLikeRenderOptionInput<ComboboxGenericItem>) => React.ReactNode;
97
+ /** Props passadas para o componente `ScrollArea` subjacente no dropdown */
98
+ scrollAreaProps?: ScrollAreaProps;
99
+ /** Determina se o valor digitado pelo usuário mas não submetido deve ser aceito quando o input perde o foco, `true` por padrão */
100
+ acceptValueOnBlur?: boolean;
101
+ /** Limite de itens exibidos no dropdown */
102
+ limit?: number;
103
+ /** Converter para transformar o valor antes de salvar no dataSource */
104
+ outputConverter?: (tags: string[]) => any;
105
+ /** Converter para transformar o valor do dataSource antes de exibir */
106
+ inputConverter?: (value: any) => string[];
107
+ }
108
+
109
+ export function ArchbaseTagInputEdit<T, ID>({
110
+ dataSource,
111
+ dataField,
112
+ disabled = false,
113
+ readOnly = false,
114
+ style,
115
+ placeholder,
116
+ label,
117
+ description,
118
+ error,
119
+ required,
120
+ size,
121
+ width,
122
+ innerRef,
123
+ value,
124
+ defaultValue,
125
+ icon,
126
+ onKeyDown,
127
+ onKeyUp,
128
+ onActionSearchExecute,
129
+ tooltipIconSearch = 'Clique aqui para Localizar',
130
+ onFocusExit = () => {},
131
+ onFocusEnter = () => {},
132
+ onChangeValue = () => {},
133
+ onRemove,
134
+ onClear,
135
+ variant,
136
+
137
+ // Props específicas do TagsInput
138
+ data,
139
+ searchValue,
140
+ defaultSearchValue,
141
+ onSearchChange,
142
+ maxTags = Infinity,
143
+ allowDuplicates = false,
144
+ onDuplicate,
145
+ splitChars = [','],
146
+ clearable = false,
147
+ clearButtonProps,
148
+ hiddenInputProps,
149
+ hiddenInputValuesDivider = ',',
150
+ renderOption,
151
+ scrollAreaProps,
152
+ acceptValueOnBlur = true,
153
+ limit,
154
+ outputConverter,
155
+ inputConverter,
156
+ }: ArchbaseTagInputEditProps<T, ID>) {
157
+ const [currentValue, setCurrentValue] = useState<string[]>(value || defaultValue || []);
158
+ const innerComponentRef = useRef<any>(null);
159
+ const theme = useMantineTheme();
160
+ const { colorScheme } = useMantineColorScheme();
161
+ const [internalError, setInternalError] = useState<string | undefined>(error);
162
+ const forceUpdate = useForceUpdate();
163
+
164
+ useEffect(() => {
165
+ setInternalError(undefined);
166
+ }, [currentValue]);
167
+
168
+ useEffect(() => {
169
+ if (error !== internalError) {
170
+ setInternalError(error);
171
+ }
172
+ }, [error]);
173
+
174
+ useEffect(() => {
175
+ if (value !== undefined && JSON.stringify(value) !== JSON.stringify(currentValue)) {
176
+ setCurrentValue(value);
177
+ }
178
+ }, [value]);
179
+
180
+ const loadDataSourceFieldValue = () => {
181
+ let initialValue: string[] = currentValue;
182
+
183
+ if (dataSource && dataField) {
184
+ const fieldValue = dataSource.getFieldValue(dataField);
185
+
186
+ // Usa o inputConverter se fornecido
187
+ if (inputConverter) {
188
+ initialValue = inputConverter(fieldValue);
189
+ } else {
190
+ // Lógica padrão de conversão
191
+ if (Array.isArray(fieldValue)) {
192
+ initialValue = fieldValue;
193
+ } else if (typeof fieldValue === 'string' && fieldValue) {
194
+ // Se o valor no datasource for uma string, tenta fazer parse como JSON ou separar por vírgula
195
+ try {
196
+ const parsed = JSON.parse(fieldValue);
197
+ initialValue = Array.isArray(parsed) ? parsed : [fieldValue];
198
+ } catch {
199
+ initialValue = fieldValue.split(',').map(item => item.trim()).filter(item => item);
200
+ }
201
+ } else {
202
+ initialValue = [];
203
+ }
204
+ }
205
+ }
206
+
207
+ initialValue = initialValue.filter(value => value && value.trim() !== '');
208
+ setCurrentValue(initialValue);
209
+ };
210
+
211
+ const fieldChangedListener = useCallback(() => {
212
+ loadDataSourceFieldValue();
213
+ }, []);
214
+
215
+ const dataSourceEvent = useCallback((event: DataSourceEvent<T>) => {
216
+ if (dataSource && dataField) {
217
+ if (
218
+ event.type === DataSourceEventNames.dataChanged ||
219
+ event.type === DataSourceEventNames.recordChanged ||
220
+ event.type === DataSourceEventNames.afterScroll ||
221
+ event.type === DataSourceEventNames.afterCancel ||
222
+ event.type === DataSourceEventNames.afterEdit
223
+ ) {
224
+ loadDataSourceFieldValue();
225
+ forceUpdate();
226
+ }
227
+
228
+ if (event.type === DataSourceEventNames.onFieldError && event.fieldName === dataField) {
229
+ setInternalError(event.error);
230
+ }
231
+ }
232
+ }, []);
233
+
234
+ useArchbaseDidMount(() => {
235
+ loadDataSourceFieldValue();
236
+ if (dataSource && dataField) {
237
+ dataSource.addListener(dataSourceEvent);
238
+ dataSource.addFieldChangeListener(dataField, fieldChangedListener);
239
+ }
240
+ });
241
+
242
+ useArchbaseDidUpdate(() => {
243
+ if (!value) { // Só carrega do datasource se não é controlado
244
+ loadDataSourceFieldValue();
245
+ }
246
+ }, []);
247
+
248
+ const handleChange = (changedValue: string[]) => {
249
+ // Filtra valores vazios ou que contenham apenas espaços
250
+ const filteredValue = changedValue.filter(value => value && value.trim() !== '');
251
+
252
+ setCurrentValue(filteredValue);
253
+
254
+ if (dataSource && !dataSource.isBrowsing() && dataField) {
255
+ // Usa o outputConverter se fornecido, senão salva como array
256
+ const valueToSave = outputConverter ? outputConverter(filteredValue) : filteredValue;
257
+ dataSource.setFieldValue(dataField, valueToSave);
258
+ }
259
+
260
+ if (onChangeValue) {
261
+ onChangeValue(filteredValue);
262
+ }
263
+ };
264
+
265
+ const handleRemove = (removedValue: string) => {
266
+ const newValue = currentValue.filter(value => value !== removedValue && value && value.trim() !== '');
267
+ setCurrentValue(newValue);
268
+
269
+ if (dataSource && !dataSource.isBrowsing() && dataField) {
270
+ const valueToSave = outputConverter ? outputConverter(newValue) : newValue;
271
+ dataSource.setFieldValue(dataField, valueToSave);
272
+ }
273
+
274
+ if (onChangeValue) {
275
+ onChangeValue(newValue);
276
+ }
277
+
278
+ if (onRemove) {
279
+ onRemove(removedValue);
280
+ }
281
+ };
282
+
283
+ const handleClear = () => {
284
+ const emptyValue: string[] = [];
285
+ setCurrentValue(emptyValue);
286
+
287
+ if (dataSource && !dataSource.isBrowsing() && dataField) {
288
+ // Usa o outputConverter se fornecido, senão salva como array vazio
289
+ const valueToSave = outputConverter ? outputConverter(emptyValue) : emptyValue;
290
+ dataSource.setFieldValue(dataField, valueToSave);
291
+ }
292
+
293
+ if (onChangeValue) {
294
+ onChangeValue(emptyValue);
295
+ }
296
+
297
+ if (onClear) {
298
+ onClear();
299
+ }
300
+ };
301
+
302
+ useArchbaseWillUnmount(() => {
303
+ if (dataSource && dataField) {
304
+ dataSource.removeListener(dataSourceEvent);
305
+ dataSource.removeFieldChangeListener(dataField, fieldChangedListener);
306
+ }
307
+ });
308
+
309
+ const handleOnFocusExit = (event) => {
310
+ if (onFocusExit) {
311
+ onFocusExit(event);
312
+ }
313
+ };
314
+
315
+ const handleOnFocusEnter = (event) => {
316
+ if (onFocusEnter) {
317
+ onFocusEnter(event);
318
+ }
319
+ };
320
+
321
+ const isReadOnly = () => {
322
+ let tmpReadOnly = readOnly;
323
+ if (dataSource && !readOnly) {
324
+ tmpReadOnly = dataSource.isBrowsing();
325
+ }
326
+ return tmpReadOnly;
327
+ };
328
+
329
+ return (
330
+ <TagsInput
331
+ disabled={disabled}
332
+ readOnly={isReadOnly()}
333
+ size={size!}
334
+ style={{
335
+ width,
336
+ ...style,
337
+ }}
338
+ value={currentValue}
339
+ defaultValue={defaultValue}
340
+ ref={innerRef || innerComponentRef}
341
+ required={required}
342
+ onChange={handleChange}
343
+ onRemove={handleRemove}
344
+ onClear={handleClear}
345
+ onBlur={handleOnFocusExit}
346
+ onFocus={handleOnFocusEnter}
347
+ placeholder={placeholder}
348
+ description={description}
349
+ onKeyDown={onKeyDown}
350
+ onKeyUp={onKeyUp}
351
+ label={label}
352
+ error={internalError}
353
+
354
+ // Props específicas do TagsInput
355
+ data={data}
356
+ searchValue={searchValue}
357
+ defaultSearchValue={defaultSearchValue}
358
+ onSearchChange={onSearchChange}
359
+ maxTags={maxTags}
360
+ allowDuplicates={allowDuplicates}
361
+ onDuplicate={onDuplicate}
362
+ splitChars={splitChars}
363
+ clearable={clearable}
364
+ clearButtonProps={clearButtonProps}
365
+ hiddenInputProps={hiddenInputProps}
366
+ hiddenInputValuesDivider={hiddenInputValuesDivider}
367
+ renderOption={renderOption}
368
+ scrollAreaProps={scrollAreaProps}
369
+ acceptValueOnBlur={acceptValueOnBlur}
370
+ limit={limit}
371
+
372
+ rightSection={
373
+ onActionSearchExecute ? (
374
+ <Tooltip withinPortal withArrow label={tooltipIconSearch}>
375
+ <ActionIcon
376
+ style={{
377
+ backgroundColor:
378
+ variant === 'filled'
379
+ ? colorScheme === 'dark'
380
+ ? theme.colors[theme.primaryColor][5]
381
+ : theme.colors[theme.primaryColor][6]
382
+ : undefined,
383
+ }}
384
+ tabIndex={-1}
385
+ variant={variant}
386
+ onClick={onActionSearchExecute}
387
+ >
388
+ {icon}
389
+ </ActionIcon>
390
+ </Tooltip>
391
+ ) : null
392
+ }
393
+ />
394
+ );
395
+ }
@@ -138,6 +138,12 @@ export type { ArchbaseMentionInputProps, ArchbaseMentionConfig } from './Archbas
138
138
  export { ArchbaseSignaturePad } from './ArchbaseSignaturePad'
139
139
  export type { ArchbaseSignaturePadProps } from './ArchbaseSignaturePad'
140
140
 
141
+ export { ArchbaseMultiSelect, SelectItem, SelectedItem } from './ArchbaseMultiSelect'
142
+ export type { ArchbaseMultiSelectProps } from './ArchbaseMultiSelect'
143
+
144
+ export { ArchbaseTagInputEdit } from './ArchbaseTagInputEdit'
145
+ export type { ArchbaseTagInputEditProps } from './ArchbaseTagInputEdit'
146
+
141
147
  export { ArchbaseBarcodeScanner } from './ArchbaseBarcodeScanner'
142
148
  export type { ArchbaseBarcodeScannerProps } from './ArchbaseBarcodeScanner'
143
149
 
@@ -156,6 +156,90 @@ function extractFormat(dataUri: string | null | undefined): string {
156
156
  return 'png';
157
157
  }
158
158
 
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
+ }
214
+
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
+
159
243
  /**
160
244
  * Props do ArchbaseImagePickerEditor
161
245
  * Mantém compatibilidade total com a versão anterior
@@ -174,6 +258,8 @@ export interface ArchbaseImagePickerEditorProps {
174
258
  imageChanged?: (newDataUri: string | undefined) => void;
175
259
  /** Variante dos botões de ação */
176
260
  variant?: ActionIconVariant;
261
+ /** Callback quando o estado de processamento muda (útil para desabilitar botões enquanto processa) */
262
+ onProcessingChange?: (isProcessing: boolean) => void;
177
263
  }
178
264
 
179
265
  /**
@@ -209,6 +295,7 @@ export const ArchbaseImagePickerEditor = memo(
209
295
  color = '#1e88e5',
210
296
  imageChanged,
211
297
  variant = 'transparent',
298
+ onProcessingChange,
212
299
  }: ArchbaseImagePickerEditorProps) => {
213
300
  const theme = useArchbaseTheme();
214
301
  const { colorScheme } = useMantineColorScheme();
@@ -227,9 +314,19 @@ export const ArchbaseImagePickerEditor = memo(
227
314
  // Ref para a imagem original (antes de normalização) para comparação
228
315
  const originalImagePropRef = useRef<string | undefined>(imageSrcProp);
229
316
 
317
+ // Ref para onProcessingChange para evitar closure stale no debounce
318
+ const onProcessingChangeRef = useRef(onProcessingChange);
319
+ onProcessingChangeRef.current = onProcessingChange;
320
+
230
321
  // Ref para saber se é a primeira renderização
231
322
  const isFirstRender = useRef(true);
232
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
+
233
330
  // Configuração padrão
234
331
  const defaultConfig: ArchbaseImagePickerConf = {
235
332
  objectFit: 'cover',
@@ -256,6 +353,8 @@ export const ArchbaseImagePickerEditor = memo(
256
353
  const debouncedOnChange = useDebouncedCallback((newDataUri: string | undefined) => {
257
354
  // Verificar se a imagem realmente mudou
258
355
  if (lastReportedImageRef.current === newDataUri) {
356
+ // Notificar que o processamento terminou (usando ref para evitar closure stale)
357
+ onProcessingChangeRef.current?.(false);
259
358
  return;
260
359
  }
261
360
 
@@ -270,41 +369,76 @@ export const ArchbaseImagePickerEditor = memo(
270
369
  if (imageChanged) {
271
370
  imageChanged(newDataUri);
272
371
  }
372
+
373
+ // Notificar que o processamento terminou (usando ref para evitar closure stale)
374
+ onProcessingChangeRef.current?.(false);
273
375
  }, 300); // 300ms de debounce
274
376
 
275
377
  // Sincronizar com prop externa
276
378
  useEffect(() => {
277
- // atualizar se a prop original realmente mudou
278
- if (imageSrcProp !== originalImagePropRef.current) {
279
- originalImagePropRef.current = imageSrcProp;
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
+ }
280
386
 
281
- const normalizedSrc = normalizeImageSrc(imageSrcProp);
387
+ // atualizar o estado se o valor normalizado for diferente do atual
388
+ if (normalizedSrc !== imageSrc) {
282
389
  setImageSrc(normalizedSrc);
283
390
  setLoadImage(!!normalizedSrc);
391
+ }
284
392
 
285
- // Atualizar a referência sem disparar callback na sincronização inicial
286
- if (isFirstRender.current) {
287
- lastReportedImageRef.current = normalizedSrc;
288
- isFirstRender.current = false;
289
- }
393
+ // Atualizar a referência
394
+ originalImagePropRef.current = imageSrcProp;
395
+
396
+ // Atualizar a referência sem disparar callback na sincronização inicial
397
+ if (isFirstRender.current) {
398
+ lastReportedImageRef.current = normalizedSrc;
399
+ isFirstRender.current = false;
290
400
  }
291
401
  }, [imageSrcProp]);
292
402
 
293
403
  // Handler quando a imagem muda no editor
294
- const handleImageChanged = useCallback((newDataUri: string) => {
295
- const newValue = newDataUri || undefined;
404
+ const handleImageChanged = useCallback(async (newDataUri: string) => {
405
+ const timeSinceMount = Date.now() - mountTimeRef.current;
296
406
 
297
407
  // Só processa se for diferente do valor atual
298
408
  if (imageSrc === newDataUri) {
299
409
  return;
300
410
  }
301
411
 
302
- setImageSrc(newDataUri || null);
303
- setLoadImage(!!newDataUri);
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) {
416
+ return;
417
+ }
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
431
+ );
432
+ }
433
+
434
+ const newValue = processedDataUri || undefined;
435
+
436
+ setImageSrc(processedDataUri || null);
437
+ setLoadImage(!!processedDataUri);
304
438
 
305
439
  // Usar callback com debounce
306
440
  debouncedOnChange(newValue);
307
- }, [imageSrc, debouncedOnChange]);
441
+ }, [imageSrc, debouncedOnChange, onProcessingChange, mergedConfig.maxWidth, mergedConfig.maxHeight, mergedConfig.maxSizeKb, mergedConfig.compressInitial]);
308
442
 
309
443
  // Calcular tamanho e formato da imagem (apenas para data URIs)
310
444
  const sizeImage = useMemo(() => calculateImageSize(imageSrc), [imageSrc]);
@@ -319,8 +453,39 @@ export const ArchbaseImagePickerEditor = memo(
319
453
  backgroundColor: mergedConfig.imageBackgroundColor ?? (colorScheme === 'dark' ? theme.colors.dark[7] : theme.white),
320
454
  }), [mergedConfig.width, mergedConfig.imageBackgroundColor, colorScheme, theme]);
321
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
+ }
467
+
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
+ }, []);
486
+
322
487
  return (
323
- <div className="ArchbaseImagePickerEditor" style={containerStyle}>
488
+ <div className="ArchbaseImagePickerEditor" style={containerStyle} onClick={handleContainerClick}>
324
489
  {/* Componente react-image-picker-editor */}
325
490
  <ReactImagePickerEditor
326
491
  config={pickerConfig}
@@ -12,6 +12,12 @@ export interface ArchbaseImagePickerConf {
12
12
  showImageSize?: boolean;
13
13
  onChangeImage?: (image: string|undefined) => void;
14
14
  imageBackgroundColor?: string;
15
+ /** Largura máxima da imagem em pixels (redimensiona automaticamente se exceder) */
16
+ maxWidth?: number;
17
+ /** Altura máxima da imagem em pixels (redimensiona automaticamente se exceder) */
18
+ maxHeight?: number;
19
+ /** Tamanho máximo da imagem em KB (recomprime se exceder) */
20
+ maxSizeKb?: number;
15
21
  }
16
22
 
17
23
  export interface IState {