@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.
- package/dist/archbase-components-4.0.3.tgz +0 -0
- package/dist/editors/ArchbaseImageEdit.d.ts +9 -1
- package/dist/editors/ArchbaseMultiSelect.d.ts +110 -0
- package/dist/editors/ArchbaseTagInputEdit.d.ts +91 -0
- package/dist/editors/index.d.ts +4 -0
- package/dist/image/editor/index.d.ts +3 -1
- package/dist/image/editor/models/index.models.d.ts +6 -0
- package/dist/index.js +9125 -8580
- package/package.json +6 -6
- package/src/datagrid/main/archbase-data-grid.tsx +40 -33
- package/src/editors/ArchbaseDatePickerEdit.tsx +19 -11
- package/src/editors/ArchbaseImageEdit.tsx +34 -5
- package/src/editors/ArchbaseMultiSelect.tsx +642 -0
- package/src/editors/ArchbaseTagInputEdit.tsx +395 -0
- package/src/editors/index.tsx +6 -0
- package/src/image/editor/index.tsx +180 -15
- package/src/image/editor/models/index.models.ts +6 -0
|
@@ -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
|
+
}
|
package/src/editors/index.tsx
CHANGED
|
@@ -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
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
387
|
+
// Só 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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
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
|
-
|
|
303
|
-
|
|
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 {
|