@umodeapporg/ui 0.14.0 → 0.16.0
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/components/Form/ControlledImage/index.d.ts +3 -0
- package/dist/components/Form/ControlledImage/index.js +2 -1
- package/dist/components/Form/ControlledImage/index.js.map +1 -1
- package/dist/components/UI/DataTable/DataTable.js +15 -4
- package/dist/components/UI/DataTable/DataTable.js.map +1 -1
- package/dist/components/UI/DataTable/DataTableBody.d.ts +5 -1
- package/dist/components/UI/DataTable/DataTableBody.js +15 -2
- package/dist/components/UI/DataTable/DataTableBody.js.map +1 -1
- package/dist/components/UI/DataTable/DataTableBodyCell.js +11 -0
- package/dist/components/UI/DataTable/DataTableBodyCell.js.map +1 -1
- package/dist/components/UI/DataTable/cells/DataTableEditableCell.js +7 -10
- package/dist/components/UI/DataTable/cells/DataTableEditableCell.js.map +1 -1
- package/dist/components/UI/DataTable/grid/GridProvider.d.ts +3 -0
- package/dist/components/UI/DataTable/grid/GridProvider.js +25 -3
- package/dist/components/UI/DataTable/grid/GridProvider.js.map +1 -1
- package/dist/components/UI/DataTable/grid/gridStore.d.ts +10 -2
- package/dist/components/UI/DataTable/grid/gridStore.js +30 -3
- package/dist/components/UI/DataTable/grid/gridStore.js.map +1 -1
- package/dist/components/UI/DataTable/grid/gridTypes.d.ts +17 -5
- package/dist/components/UI/DataTable/grid/gridTypes.js.map +1 -1
- package/dist/components/UI/DataTable/grid/useGridKeyboard.d.ts +4 -2
- package/dist/components/UI/DataTable/grid/useGridKeyboard.js +25 -11
- package/dist/components/UI/DataTable/grid/useGridKeyboard.js.map +1 -1
- package/dist/components/UI/ImageAiAdjust/ImageAiAdjustDialog.d.ts +6 -0
- package/dist/components/UI/ImageAiAdjust/ImageAiAdjustDialog.js +164 -74
- package/dist/components/UI/ImageAiAdjust/ImageAiAdjustDialog.js.map +1 -1
- package/dist/components/UI/ImageAiAdjust/ImageAiDiscardReasonDialog.d.ts +8 -0
- package/dist/components/UI/ImageAiAdjust/ImageAiDiscardReasonDialog.js +67 -0
- package/dist/components/UI/ImageAiAdjust/ImageAiDiscardReasonDialog.js.map +1 -0
- package/dist/components/UI/ImageAiAdjust/ImageAiVerificationNotice.d.ts +8 -0
- package/dist/components/UI/ImageAiAdjust/ImageAiVerificationNotice.js +39 -0
- package/dist/components/UI/ImageAiAdjust/ImageAiVerificationNotice.js.map +1 -0
- package/dist/components/UI/ImageAiAdjust/imageAiReview.d.ts +22 -0
- package/dist/components/UI/ImageAiAdjust/imageAiReview.js +37 -0
- package/dist/components/UI/ImageAiAdjust/imageAiReview.js.map +1 -0
- package/dist/components/UI/ImageAiAdjust/imageAiVerification.d.ts +19 -0
- package/dist/components/UI/ImageAiAdjust/imageAiVerification.js +25 -0
- package/dist/components/UI/ImageAiAdjust/imageAiVerification.js.map +1 -0
- package/dist/components/UI/ImageAiAdjust/useImageAiAdjust.d.ts +25 -2
- package/dist/components/UI/ImageAiAdjust/useImageAiAdjust.js +44 -19
- package/dist/components/UI/ImageAiAdjust/useImageAiAdjust.js.map +1 -1
- package/dist/components/UI/ImageMaskPainter/index.d.ts +19 -0
- package/dist/components/UI/ImageMaskPainter/index.js +267 -0
- package/dist/components/UI/ImageMaskPainter/index.js.map +1 -0
- package/dist/components/UI/ImageMaskPainter/maskStrokes.d.ts +19 -0
- package/dist/components/UI/ImageMaskPainter/maskStrokes.js +71 -0
- package/dist/components/UI/ImageMaskPainter/maskStrokes.js.map +1 -0
- package/dist/helpers/imageFile.d.ts +16 -0
- package/dist/helpers/imageFile.js +59 -10
- package/dist/helpers/imageFile.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +12 -10
- package/dist/index.js.map +1 -1
- package/dist/locales/en/ui.json.d.ts +36 -1
- package/dist/locales/en/ui.json.js +7 -1
- package/dist/locales/en/ui.json.js.map +1 -1
- package/dist/locales/index.d.ts +70 -0
- package/dist/locales/pt/ui.json.d.ts +36 -1
- package/dist/locales/pt/ui.json.js +7 -1
- package/dist/locales/pt/ui.json.js.map +1 -1
- package/package.json +5 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState, useEffect, useCallback } from "react";
|
|
3
|
+
import classNames from "classnames";
|
|
4
|
+
import { useTranslation } from "react-i18next";
|
|
5
|
+
import ControlledButton from "../../Form/ControlledButton.js";
|
|
6
|
+
import ControlledSlider from "../ControlledSlider.js";
|
|
7
|
+
import { MaterialSymbol } from "../MaterialSymbol/index.js";
|
|
8
|
+
import { readThemeToken } from "../../../helpers/themeTokens.js";
|
|
9
|
+
import { uiLogger } from "../../../helpers/uiLogger.js";
|
|
10
|
+
import { useResolvedTheme } from "../../../hooks/useResolvedTheme.js";
|
|
11
|
+
import { redrawStrokes, overlayHasPaint, exportMaskFile, drawStrokeTail } from "./maskStrokes.js";
|
|
12
|
+
const IMAGE_MASK_BRUSH_MIN = 8;
|
|
13
|
+
const IMAGE_MASK_BRUSH_MAX = 96;
|
|
14
|
+
const DEFAULT_BRUSH_SIZE = 32;
|
|
15
|
+
const ImageMaskPainter = ({
|
|
16
|
+
imageUrl,
|
|
17
|
+
onMaskChange,
|
|
18
|
+
disabled = false,
|
|
19
|
+
maskFileName = "mask.png",
|
|
20
|
+
imageClassName,
|
|
21
|
+
className
|
|
22
|
+
}) => {
|
|
23
|
+
const { t } = useTranslation("ui");
|
|
24
|
+
const resolvedTheme = useResolvedTheme();
|
|
25
|
+
const canvasRef = useRef(null);
|
|
26
|
+
const cursorRef = useRef(null);
|
|
27
|
+
const strokesRef = useRef([]);
|
|
28
|
+
const activeStrokeRef = useRef(null);
|
|
29
|
+
const onMaskChangeRef = useRef(onMaskChange);
|
|
30
|
+
const emitSequenceRef = useRef(0);
|
|
31
|
+
const [loadedUrl, setLoadedUrl] = useState(null);
|
|
32
|
+
const ready = loadedUrl === imageUrl;
|
|
33
|
+
const [mode, setMode] = useState("paint");
|
|
34
|
+
const [brushSize, setBrushSize] = useState(DEFAULT_BRUSH_SIZE);
|
|
35
|
+
const [strokeCount, setStrokeCount] = useState(0);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
onMaskChangeRef.current = onMaskChange;
|
|
38
|
+
}, [onMaskChange]);
|
|
39
|
+
const overlayColor = useCallback(() => readThemeToken("brand"), []);
|
|
40
|
+
const getContext = useCallback(() => canvasRef.current?.getContext("2d") ?? null, []);
|
|
41
|
+
const repaint = useCallback(() => {
|
|
42
|
+
const context = getContext();
|
|
43
|
+
if (context) redrawStrokes(context, strokesRef.current, overlayColor());
|
|
44
|
+
}, [getContext, overlayColor]);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (ready) repaint();
|
|
47
|
+
}, [ready, resolvedTheme, repaint]);
|
|
48
|
+
const emitMask = useCallback(async () => {
|
|
49
|
+
const sequence = ++emitSequenceRef.current;
|
|
50
|
+
const canvas = canvasRef.current;
|
|
51
|
+
try {
|
|
52
|
+
const mask = canvas && overlayHasPaint(canvas) ? await exportMaskFile(canvas, maskFileName) : null;
|
|
53
|
+
if (sequence === emitSequenceRef.current) onMaskChangeRef.current(mask);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
uiLogger.error("Failed to export the image mask", error);
|
|
56
|
+
if (sequence === emitSequenceRef.current) onMaskChangeRef.current(null);
|
|
57
|
+
}
|
|
58
|
+
}, [maskFileName]);
|
|
59
|
+
const handleImageLoad = (event) => {
|
|
60
|
+
const canvas = canvasRef.current;
|
|
61
|
+
const { naturalWidth, naturalHeight } = event.currentTarget;
|
|
62
|
+
if (!canvas || !naturalWidth || !naturalHeight) return;
|
|
63
|
+
canvas.width = naturalWidth;
|
|
64
|
+
canvas.height = naturalHeight;
|
|
65
|
+
strokesRef.current = [];
|
|
66
|
+
activeStrokeRef.current = null;
|
|
67
|
+
setStrokeCount(0);
|
|
68
|
+
setLoadedUrl(imageUrl);
|
|
69
|
+
};
|
|
70
|
+
const toNaturalPoint = (event) => {
|
|
71
|
+
const canvas = event.currentTarget;
|
|
72
|
+
const rect = canvas.getBoundingClientRect();
|
|
73
|
+
const scale = rect.width > 0 ? canvas.width / rect.width : 1;
|
|
74
|
+
return {
|
|
75
|
+
point: { x: (event.clientX - rect.left) * scale, y: (event.clientY - rect.top) * scale },
|
|
76
|
+
scale
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
const moveCursor = (event) => {
|
|
80
|
+
const cursor = cursorRef.current;
|
|
81
|
+
if (!cursor) return;
|
|
82
|
+
if (event.pointerType !== "mouse") {
|
|
83
|
+
cursor.style.opacity = "0";
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
87
|
+
cursor.style.opacity = "1";
|
|
88
|
+
cursor.style.transform = `translate(${event.clientX - rect.left - brushSize / 2}px, ${event.clientY - rect.top - brushSize / 2}px)`;
|
|
89
|
+
};
|
|
90
|
+
const hideCursor = () => {
|
|
91
|
+
if (cursorRef.current) cursorRef.current.style.opacity = "0";
|
|
92
|
+
};
|
|
93
|
+
const handlePointerDown = (event) => {
|
|
94
|
+
if (disabled || !ready || activeStrokeRef.current) return;
|
|
95
|
+
if (event.pointerType === "mouse" && event.button !== 0) return;
|
|
96
|
+
const context = getContext();
|
|
97
|
+
if (!context) return;
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
try {
|
|
100
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
const { point, scale } = toNaturalPoint(event);
|
|
104
|
+
const stroke = { mode, size: Math.max(1, brushSize * scale), points: [point] };
|
|
105
|
+
activeStrokeRef.current = { pointerId: event.pointerId, stroke };
|
|
106
|
+
drawStrokeTail(context, stroke, overlayColor());
|
|
107
|
+
};
|
|
108
|
+
const handlePointerMove = (event) => {
|
|
109
|
+
moveCursor(event);
|
|
110
|
+
const active = activeStrokeRef.current;
|
|
111
|
+
if (!active || active.pointerId !== event.pointerId) return;
|
|
112
|
+
const context = getContext();
|
|
113
|
+
if (!context) return;
|
|
114
|
+
active.stroke.points.push(toNaturalPoint(event).point);
|
|
115
|
+
drawStrokeTail(context, active.stroke, overlayColor());
|
|
116
|
+
};
|
|
117
|
+
const finishStroke = (event) => {
|
|
118
|
+
const active = activeStrokeRef.current;
|
|
119
|
+
if (!active || active.pointerId !== event.pointerId) return;
|
|
120
|
+
activeStrokeRef.current = null;
|
|
121
|
+
strokesRef.current = [...strokesRef.current, active.stroke];
|
|
122
|
+
setStrokeCount(strokesRef.current.length);
|
|
123
|
+
void emitMask();
|
|
124
|
+
};
|
|
125
|
+
const handleUndo = () => {
|
|
126
|
+
if (strokesRef.current.length === 0) return;
|
|
127
|
+
strokesRef.current = strokesRef.current.slice(0, -1);
|
|
128
|
+
setStrokeCount(strokesRef.current.length);
|
|
129
|
+
repaint();
|
|
130
|
+
void emitMask();
|
|
131
|
+
};
|
|
132
|
+
const handleClear = () => {
|
|
133
|
+
if (strokesRef.current.length === 0) return;
|
|
134
|
+
strokesRef.current = [];
|
|
135
|
+
setStrokeCount(0);
|
|
136
|
+
repaint();
|
|
137
|
+
void emitMask();
|
|
138
|
+
};
|
|
139
|
+
const controlsDisabled = disabled || !ready;
|
|
140
|
+
return /* @__PURE__ */ jsxs("div", { className: classNames("flex min-w-0 flex-col gap-y-2", className), children: [
|
|
141
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
142
|
+
/* @__PURE__ */ jsxs(
|
|
143
|
+
ControlledButton,
|
|
144
|
+
{
|
|
145
|
+
size: "sm",
|
|
146
|
+
variant: mode === "paint" ? "filled" : "outlined",
|
|
147
|
+
"aria-pressed": mode === "paint",
|
|
148
|
+
disabled: controlsDisabled,
|
|
149
|
+
onClick: () => setMode("paint"),
|
|
150
|
+
className: "flex items-center gap-x-1.5 normal-case",
|
|
151
|
+
children: [
|
|
152
|
+
/* @__PURE__ */ jsx(MaterialSymbol, { "aria-hidden": true, icon: "brush", size: 14 }),
|
|
153
|
+
t("imageMaskPainter.paint")
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
),
|
|
157
|
+
/* @__PURE__ */ jsxs(
|
|
158
|
+
ControlledButton,
|
|
159
|
+
{
|
|
160
|
+
size: "sm",
|
|
161
|
+
variant: mode === "erase" ? "filled" : "outlined",
|
|
162
|
+
"aria-pressed": mode === "erase",
|
|
163
|
+
disabled: controlsDisabled,
|
|
164
|
+
onClick: () => setMode("erase"),
|
|
165
|
+
className: "flex items-center gap-x-1.5 normal-case",
|
|
166
|
+
children: [
|
|
167
|
+
/* @__PURE__ */ jsx(MaterialSymbol, { "aria-hidden": true, icon: "ink_eraser", size: 14 }),
|
|
168
|
+
t("imageMaskPainter.erase")
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
),
|
|
172
|
+
/* @__PURE__ */ jsxs("label", { className: "flex min-w-32 flex-1 items-center gap-x-2 text-xs text-foreground-muted", children: [
|
|
173
|
+
/* @__PURE__ */ jsx("span", { className: "shrink-0", children: t("imageMaskPainter.brushSize") }),
|
|
174
|
+
/* @__PURE__ */ jsx(
|
|
175
|
+
ControlledSlider,
|
|
176
|
+
{
|
|
177
|
+
value: brushSize,
|
|
178
|
+
min: IMAGE_MASK_BRUSH_MIN,
|
|
179
|
+
max: IMAGE_MASK_BRUSH_MAX,
|
|
180
|
+
step: 2,
|
|
181
|
+
disabled: controlsDisabled,
|
|
182
|
+
label: t("imageMaskPainter.brushSize"),
|
|
183
|
+
onChange: setBrushSize
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
] }),
|
|
187
|
+
/* @__PURE__ */ jsxs(
|
|
188
|
+
ControlledButton,
|
|
189
|
+
{
|
|
190
|
+
size: "sm",
|
|
191
|
+
variant: "text",
|
|
192
|
+
tone: "neutral",
|
|
193
|
+
disabled: controlsDisabled || strokeCount === 0,
|
|
194
|
+
onClick: handleUndo,
|
|
195
|
+
className: "flex items-center gap-x-1.5 normal-case",
|
|
196
|
+
children: [
|
|
197
|
+
/* @__PURE__ */ jsx(MaterialSymbol, { "aria-hidden": true, icon: "undo", size: 14 }),
|
|
198
|
+
t("imageMaskPainter.undo")
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
),
|
|
202
|
+
/* @__PURE__ */ jsxs(
|
|
203
|
+
ControlledButton,
|
|
204
|
+
{
|
|
205
|
+
size: "sm",
|
|
206
|
+
variant: "text",
|
|
207
|
+
tone: "neutral",
|
|
208
|
+
disabled: controlsDisabled || strokeCount === 0,
|
|
209
|
+
onClick: handleClear,
|
|
210
|
+
className: "flex items-center gap-x-1.5 normal-case",
|
|
211
|
+
children: [
|
|
212
|
+
/* @__PURE__ */ jsx(MaterialSymbol, { "aria-hidden": true, icon: "layers_clear", size: 14 }),
|
|
213
|
+
t("imageMaskPainter.clear")
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
] }),
|
|
218
|
+
/* @__PURE__ */ jsx("div", { className: "flex items-center justify-center rounded-xl bg-ink/95 p-2", children: /* @__PURE__ */ jsxs("div", { className: "relative inline-flex max-w-full overflow-hidden", children: [
|
|
219
|
+
/* @__PURE__ */ jsx(
|
|
220
|
+
"img",
|
|
221
|
+
{
|
|
222
|
+
src: imageUrl,
|
|
223
|
+
alt: "",
|
|
224
|
+
draggable: false,
|
|
225
|
+
onLoad: handleImageLoad,
|
|
226
|
+
className: classNames(
|
|
227
|
+
"block select-none rounded-lg object-contain",
|
|
228
|
+
imageClassName ?? "max-h-[38vh] w-auto max-w-full"
|
|
229
|
+
)
|
|
230
|
+
},
|
|
231
|
+
imageUrl
|
|
232
|
+
),
|
|
233
|
+
/* @__PURE__ */ jsx(
|
|
234
|
+
"canvas",
|
|
235
|
+
{
|
|
236
|
+
ref: canvasRef,
|
|
237
|
+
role: "img",
|
|
238
|
+
"aria-label": t("imageMaskPainter.surfaceLabel"),
|
|
239
|
+
onPointerDown: handlePointerDown,
|
|
240
|
+
onPointerMove: handlePointerMove,
|
|
241
|
+
onPointerUp: finishStroke,
|
|
242
|
+
onPointerCancel: finishStroke,
|
|
243
|
+
onPointerLeave: hideCursor,
|
|
244
|
+
className: classNames(
|
|
245
|
+
"absolute inset-0 size-full touch-none rounded-lg opacity-50",
|
|
246
|
+
controlsDisabled ? "cursor-not-allowed" : "cursor-crosshair"
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
),
|
|
250
|
+
/* @__PURE__ */ jsx(
|
|
251
|
+
"span",
|
|
252
|
+
{
|
|
253
|
+
ref: cursorRef,
|
|
254
|
+
"aria-hidden": true,
|
|
255
|
+
style: { width: brushSize, height: brushSize },
|
|
256
|
+
className: "pointer-events-none absolute left-0 top-0 rounded-full border-2 border-brand opacity-0 shadow-sm"
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
] }) })
|
|
260
|
+
] });
|
|
261
|
+
};
|
|
262
|
+
export {
|
|
263
|
+
IMAGE_MASK_BRUSH_MAX,
|
|
264
|
+
IMAGE_MASK_BRUSH_MIN,
|
|
265
|
+
ImageMaskPainter as default
|
|
266
|
+
};
|
|
267
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../../../../src/components/UI/ImageMaskPainter/index.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport ControlledButton from '@/components/Form/ControlledButton';\nimport ControlledSlider from '@/components/UI/ControlledSlider';\nimport { MaterialSymbol } from '@/components/UI/MaterialSymbol';\nimport { readThemeToken } from '@/helpers/themeTokens';\nimport { uiLogger } from '@/helpers/uiLogger';\nimport { useResolvedTheme } from '@/hooks/useResolvedTheme';\n\nimport {\n drawStrokeTail,\n exportMaskFile,\n IMaskPoint,\n IMaskStroke,\n overlayHasPaint,\n redrawStrokes,\n TMaskBrushMode,\n} from './maskStrokes';\n\nexport type { TMaskBrushMode } from './maskStrokes';\n\n/** Brush diameter in on-screen pixels; converted to natural pixels per stroke. */\nexport const IMAGE_MASK_BRUSH_MIN = 8;\nexport const IMAGE_MASK_BRUSH_MAX = 96;\nconst DEFAULT_BRUSH_SIZE = 32;\n\nexport interface IImageMaskPainterProps {\n imageUrl: string;\n /**\n * Called after every stroke, undo and clear with a PNG at the image's natural resolution\n * (white = painted, black = untouched), or null when nothing is painted.\n */\n onMaskChange: (mask: File | null) => void;\n disabled?: boolean;\n maskFileName?: string;\n /** Sizes the image; defaults to fitting a dialog preview. */\n imageClassName?: string;\n className?: string;\n}\n\nconst ImageMaskPainter: React.FC<IImageMaskPainterProps> = ({\n imageUrl,\n onMaskChange,\n disabled = false,\n maskFileName = 'mask.png',\n imageClassName,\n className,\n}) => {\n const { t } = useTranslation('ui');\n const resolvedTheme = useResolvedTheme();\n\n const canvasRef = useRef<HTMLCanvasElement | null>(null);\n const cursorRef = useRef<HTMLSpanElement | null>(null);\n const strokesRef = useRef<Array<IMaskStroke>>([]);\n const activeStrokeRef = useRef<{ pointerId: number; stroke: IMaskStroke } | null>(null);\n const onMaskChangeRef = useRef(onMaskChange);\n const emitSequenceRef = useRef(0);\n\n const [loadedUrl, setLoadedUrl] = useState<string | null>(null);\n const ready = loadedUrl === imageUrl;\n const [mode, setMode] = useState<TMaskBrushMode>('paint');\n const [brushSize, setBrushSize] = useState(DEFAULT_BRUSH_SIZE);\n const [strokeCount, setStrokeCount] = useState(0);\n\n useEffect(() => {\n onMaskChangeRef.current = onMaskChange;\n }, [onMaskChange]);\n\n const overlayColor = useCallback(() => readThemeToken('brand'), []);\n\n const getContext = useCallback(() => canvasRef.current?.getContext('2d') ?? null, []);\n\n const repaint = useCallback(() => {\n const context = getContext();\n if (context) redrawStrokes(context, strokesRef.current, overlayColor());\n }, [getContext, overlayColor]);\n\n // The brand token differs per theme; strokes already on screen follow it.\n useEffect(() => {\n if (ready) repaint();\n }, [ready, resolvedTheme, repaint]);\n\n const emitMask = useCallback(async () => {\n const sequence = ++emitSequenceRef.current;\n const canvas = canvasRef.current;\n try {\n const mask = canvas && overlayHasPaint(canvas) ? await exportMaskFile(canvas, maskFileName) : null;\n if (sequence === emitSequenceRef.current) onMaskChangeRef.current(mask);\n } catch (error) {\n uiLogger.error('Failed to export the image mask', error);\n if (sequence === emitSequenceRef.current) onMaskChangeRef.current(null);\n }\n }, [maskFileName]);\n\n const handleImageLoad = (event: React.SyntheticEvent<HTMLImageElement>) => {\n const canvas = canvasRef.current;\n const { naturalWidth, naturalHeight } = event.currentTarget;\n if (!canvas || !naturalWidth || !naturalHeight) return;\n canvas.width = naturalWidth;\n canvas.height = naturalHeight;\n strokesRef.current = [];\n activeStrokeRef.current = null;\n setStrokeCount(0);\n setLoadedUrl(imageUrl);\n };\n\n const toNaturalPoint = (event: React.PointerEvent<HTMLCanvasElement>): { point: IMaskPoint; scale: number } => {\n const canvas = event.currentTarget;\n const rect = canvas.getBoundingClientRect();\n const scale = rect.width > 0 ? canvas.width / rect.width : 1;\n return {\n point: { x: (event.clientX - rect.left) * scale, y: (event.clientY - rect.top) * scale },\n scale,\n };\n };\n\n const moveCursor = (event: React.PointerEvent<HTMLCanvasElement>) => {\n const cursor = cursorRef.current;\n if (!cursor) return;\n if (event.pointerType !== 'mouse') {\n cursor.style.opacity = '0';\n return;\n }\n const rect = event.currentTarget.getBoundingClientRect();\n cursor.style.opacity = '1';\n cursor.style.transform = `translate(${event.clientX - rect.left - brushSize / 2}px, ${event.clientY - rect.top - brushSize / 2}px)`;\n };\n\n const hideCursor = () => {\n if (cursorRef.current) cursorRef.current.style.opacity = '0';\n };\n\n const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {\n if (disabled || !ready || activeStrokeRef.current) return;\n if (event.pointerType === 'mouse' && event.button !== 0) return;\n const context = getContext();\n if (!context) return;\n\n event.preventDefault();\n try {\n event.currentTarget.setPointerCapture(event.pointerId);\n } catch {\n // Synthetic events carry no active pointer; drawing still works without capture.\n }\n const { point, scale } = toNaturalPoint(event);\n const stroke: IMaskStroke = { mode, size: Math.max(1, brushSize * scale), points: [point] };\n activeStrokeRef.current = { pointerId: event.pointerId, stroke };\n drawStrokeTail(context, stroke, overlayColor());\n };\n\n const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {\n moveCursor(event);\n const active = activeStrokeRef.current;\n if (!active || active.pointerId !== event.pointerId) return;\n const context = getContext();\n if (!context) return;\n\n active.stroke.points.push(toNaturalPoint(event).point);\n drawStrokeTail(context, active.stroke, overlayColor());\n };\n\n const finishStroke = (event: React.PointerEvent<HTMLCanvasElement>) => {\n const active = activeStrokeRef.current;\n if (!active || active.pointerId !== event.pointerId) return;\n activeStrokeRef.current = null;\n strokesRef.current = [...strokesRef.current, active.stroke];\n setStrokeCount(strokesRef.current.length);\n void emitMask();\n };\n\n const handleUndo = () => {\n if (strokesRef.current.length === 0) return;\n strokesRef.current = strokesRef.current.slice(0, -1);\n setStrokeCount(strokesRef.current.length);\n repaint();\n void emitMask();\n };\n\n const handleClear = () => {\n if (strokesRef.current.length === 0) return;\n strokesRef.current = [];\n setStrokeCount(0);\n repaint();\n void emitMask();\n };\n\n const controlsDisabled = disabled || !ready;\n\n return (\n <div className={classNames('flex min-w-0 flex-col gap-y-2', className)}>\n <div className=\"flex flex-wrap items-center gap-2\">\n <ControlledButton\n size=\"sm\"\n variant={mode === 'paint' ? 'filled' : 'outlined'}\n aria-pressed={mode === 'paint'}\n disabled={controlsDisabled}\n onClick={() => setMode('paint')}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"brush\" size={14} />\n {t('imageMaskPainter.paint')}\n </ControlledButton>\n <ControlledButton\n size=\"sm\"\n variant={mode === 'erase' ? 'filled' : 'outlined'}\n aria-pressed={mode === 'erase'}\n disabled={controlsDisabled}\n onClick={() => setMode('erase')}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"ink_eraser\" size={14} />\n {t('imageMaskPainter.erase')}\n </ControlledButton>\n\n <label className=\"flex min-w-32 flex-1 items-center gap-x-2 text-xs text-foreground-muted\">\n <span className=\"shrink-0\">{t('imageMaskPainter.brushSize')}</span>\n <ControlledSlider\n value={brushSize}\n min={IMAGE_MASK_BRUSH_MIN}\n max={IMAGE_MASK_BRUSH_MAX}\n step={2}\n disabled={controlsDisabled}\n label={t('imageMaskPainter.brushSize')}\n onChange={setBrushSize}\n />\n </label>\n\n <ControlledButton\n size=\"sm\"\n variant=\"text\"\n tone=\"neutral\"\n disabled={controlsDisabled || strokeCount === 0}\n onClick={handleUndo}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"undo\" size={14} />\n {t('imageMaskPainter.undo')}\n </ControlledButton>\n <ControlledButton\n size=\"sm\"\n variant=\"text\"\n tone=\"neutral\"\n disabled={controlsDisabled || strokeCount === 0}\n onClick={handleClear}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"layers_clear\" size={14} />\n {t('imageMaskPainter.clear')}\n </ControlledButton>\n </div>\n\n <div className=\"flex items-center justify-center rounded-xl bg-ink/95 p-2\">\n <div className=\"relative inline-flex max-w-full overflow-hidden\">\n <img\n key={imageUrl}\n src={imageUrl}\n alt=\"\"\n draggable={false}\n onLoad={handleImageLoad}\n className={classNames(\n 'block select-none rounded-lg object-contain',\n imageClassName ?? 'max-h-[38vh] w-auto max-w-full',\n )}\n />\n <canvas\n ref={canvasRef}\n role=\"img\"\n aria-label={t('imageMaskPainter.surfaceLabel')}\n onPointerDown={handlePointerDown}\n onPointerMove={handlePointerMove}\n onPointerUp={finishStroke}\n onPointerCancel={finishStroke}\n onPointerLeave={hideCursor}\n className={classNames(\n 'absolute inset-0 size-full touch-none rounded-lg opacity-50',\n controlsDisabled ? 'cursor-not-allowed' : 'cursor-crosshair',\n )}\n />\n <span\n ref={cursorRef}\n aria-hidden\n style={{ width: brushSize, height: brushSize }}\n className=\"pointer-events-none absolute left-0 top-0 rounded-full border-2 border-brand opacity-0 shadow-sm\"\n />\n </div>\n </div>\n </div>\n );\n};\n\nexport default ImageMaskPainter;\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type TMaskBrushMode = 'paint' | 'erase';
|
|
2
|
+
export interface IMaskPoint {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
}
|
|
6
|
+
/** A stroke in the image's natural pixel space, so the mask never depends on the on-screen size. */
|
|
7
|
+
export interface IMaskStroke {
|
|
8
|
+
mode: TMaskBrushMode;
|
|
9
|
+
size: number;
|
|
10
|
+
points: Array<IMaskPoint>;
|
|
11
|
+
}
|
|
12
|
+
/** Draws the newest segment of a stroke that is still being painted. */
|
|
13
|
+
export declare const drawStrokeTail: (context: CanvasRenderingContext2D, stroke: IMaskStroke, color: string) => void;
|
|
14
|
+
/** Repaints the whole overlay; used after undo, clear and theme changes. */
|
|
15
|
+
export declare const redrawStrokes: (context: CanvasRenderingContext2D, strokes: ReadonlyArray<IMaskStroke>, color: string) => void;
|
|
16
|
+
/** Cheap check on a downscaled copy: thin strokes still leave a non-zero averaged alpha. */
|
|
17
|
+
export declare const overlayHasPaint: (overlay: HTMLCanvasElement) => boolean;
|
|
18
|
+
/** White where the overlay is painted, black elsewhere, at the overlay's (natural) resolution. */
|
|
19
|
+
export declare const exportMaskFile: (overlay: HTMLCanvasElement, fileName: string) => Promise<File | null>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const MASK_KEEP = "#000000";
|
|
2
|
+
const MASK_CHANGE = "#ffffff";
|
|
3
|
+
const COVERAGE_SAMPLE = 256;
|
|
4
|
+
const strokeSegment = (context, stroke, from, to) => {
|
|
5
|
+
context.globalCompositeOperation = stroke.mode === "erase" ? "destination-out" : "source-over";
|
|
6
|
+
context.lineWidth = stroke.size;
|
|
7
|
+
context.lineCap = "round";
|
|
8
|
+
context.lineJoin = "round";
|
|
9
|
+
context.beginPath();
|
|
10
|
+
context.moveTo(from.x, from.y);
|
|
11
|
+
context.lineTo(to.x + (from.x === to.x && from.y === to.y ? 0.01 : 0), to.y);
|
|
12
|
+
context.stroke();
|
|
13
|
+
};
|
|
14
|
+
const drawStrokeTail = (context, stroke, color) => {
|
|
15
|
+
const { points } = stroke;
|
|
16
|
+
if (points.length === 0) return;
|
|
17
|
+
context.save();
|
|
18
|
+
context.strokeStyle = color;
|
|
19
|
+
strokeSegment(context, stroke, points[Math.max(0, points.length - 2)], points[points.length - 1]);
|
|
20
|
+
context.restore();
|
|
21
|
+
};
|
|
22
|
+
const redrawStrokes = (context, strokes, color) => {
|
|
23
|
+
context.save();
|
|
24
|
+
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
|
|
25
|
+
context.strokeStyle = color;
|
|
26
|
+
strokes.forEach((stroke) => {
|
|
27
|
+
stroke.points.forEach((point, index) => {
|
|
28
|
+
strokeSegment(context, stroke, stroke.points[Math.max(0, index - 1)], point);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
context.restore();
|
|
32
|
+
};
|
|
33
|
+
const overlayHasPaint = (overlay) => {
|
|
34
|
+
if (!overlay.width || !overlay.height) return false;
|
|
35
|
+
const ratio = Math.min(1, COVERAGE_SAMPLE / Math.max(overlay.width, overlay.height));
|
|
36
|
+
const sample = document.createElement("canvas");
|
|
37
|
+
sample.width = Math.max(1, Math.round(overlay.width * ratio));
|
|
38
|
+
sample.height = Math.max(1, Math.round(overlay.height * ratio));
|
|
39
|
+
const context = sample.getContext("2d");
|
|
40
|
+
if (!context) return false;
|
|
41
|
+
context.drawImage(overlay, 0, 0, sample.width, sample.height);
|
|
42
|
+
const { data } = context.getImageData(0, 0, sample.width, sample.height);
|
|
43
|
+
for (let index = 3; index < data.length; index += 4) {
|
|
44
|
+
if (data[index] > 0) return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
};
|
|
48
|
+
const exportMaskFile = async (overlay, fileName) => {
|
|
49
|
+
const canvas = document.createElement("canvas");
|
|
50
|
+
canvas.width = overlay.width;
|
|
51
|
+
canvas.height = overlay.height;
|
|
52
|
+
const context = canvas.getContext("2d");
|
|
53
|
+
if (!context) return null;
|
|
54
|
+
context.fillStyle = MASK_CHANGE;
|
|
55
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
56
|
+
context.globalCompositeOperation = "destination-in";
|
|
57
|
+
context.drawImage(overlay, 0, 0);
|
|
58
|
+
context.globalCompositeOperation = "destination-over";
|
|
59
|
+
context.fillStyle = MASK_KEEP;
|
|
60
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
61
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
|
62
|
+
if (!blob) return null;
|
|
63
|
+
return new File([blob], fileName, { type: "image/png" });
|
|
64
|
+
};
|
|
65
|
+
export {
|
|
66
|
+
drawStrokeTail,
|
|
67
|
+
exportMaskFile,
|
|
68
|
+
overlayHasPaint,
|
|
69
|
+
redrawStrokes
|
|
70
|
+
};
|
|
71
|
+
//# sourceMappingURL=maskStrokes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"maskStrokes.js","sources":["../../../../src/components/UI/ImageMaskPainter/maskStrokes.ts"],"sourcesContent":["export type TMaskBrushMode = 'paint' | 'erase';\n\nexport interface IMaskPoint {\n x: number;\n y: number;\n}\n\n/** A stroke in the image's natural pixel space, so the mask never depends on the on-screen size. */\nexport interface IMaskStroke {\n mode: TMaskBrushMode;\n size: number;\n points: Array<IMaskPoint>;\n}\n\n/** The export side is data, not interface colour: the backend reads white as \"may change\". */\nconst MASK_KEEP = '#000000';\nconst MASK_CHANGE = '#ffffff';\nconst COVERAGE_SAMPLE = 256;\n\nconst strokeSegment = (context: CanvasRenderingContext2D, stroke: IMaskStroke, from: IMaskPoint, to: IMaskPoint) => {\n context.globalCompositeOperation = stroke.mode === 'erase' ? 'destination-out' : 'source-over';\n context.lineWidth = stroke.size;\n context.lineCap = 'round';\n context.lineJoin = 'round';\n context.beginPath();\n context.moveTo(from.x, from.y);\n // A zero-length line still paints a round dot with a round cap in every engine but Safari.\n context.lineTo(to.x + (from.x === to.x && from.y === to.y ? 0.01 : 0), to.y);\n context.stroke();\n};\n\n/** Draws the newest segment of a stroke that is still being painted. */\nexport const drawStrokeTail = (context: CanvasRenderingContext2D, stroke: IMaskStroke, color: string): void => {\n const { points } = stroke;\n if (points.length === 0) return;\n context.save();\n context.strokeStyle = color;\n strokeSegment(context, stroke, points[Math.max(0, points.length - 2)], points[points.length - 1]);\n context.restore();\n};\n\n/** Repaints the whole overlay; used after undo, clear and theme changes. */\nexport const redrawStrokes = (\n context: CanvasRenderingContext2D,\n strokes: ReadonlyArray<IMaskStroke>,\n color: string,\n): void => {\n context.save();\n context.clearRect(0, 0, context.canvas.width, context.canvas.height);\n context.strokeStyle = color;\n strokes.forEach((stroke) => {\n stroke.points.forEach((point, index) => {\n strokeSegment(context, stroke, stroke.points[Math.max(0, index - 1)], point);\n });\n });\n context.restore();\n};\n\n/** Cheap check on a downscaled copy: thin strokes still leave a non-zero averaged alpha. */\nexport const overlayHasPaint = (overlay: HTMLCanvasElement): boolean => {\n if (!overlay.width || !overlay.height) return false;\n const ratio = Math.min(1, COVERAGE_SAMPLE / Math.max(overlay.width, overlay.height));\n const sample = document.createElement('canvas');\n sample.width = Math.max(1, Math.round(overlay.width * ratio));\n sample.height = Math.max(1, Math.round(overlay.height * ratio));\n const context = sample.getContext('2d');\n if (!context) return false;\n context.drawImage(overlay, 0, 0, sample.width, sample.height);\n const { data } = context.getImageData(0, 0, sample.width, sample.height);\n for (let index = 3; index < data.length; index += 4) {\n if (data[index] > 0) return true;\n }\n return false;\n};\n\n/** White where the overlay is painted, black elsewhere, at the overlay's (natural) resolution. */\nexport const exportMaskFile = async (overlay: HTMLCanvasElement, fileName: string): Promise<File | null> => {\n const canvas = document.createElement('canvas');\n canvas.width = overlay.width;\n canvas.height = overlay.height;\n const context = canvas.getContext('2d');\n if (!context) return null;\n\n context.fillStyle = MASK_CHANGE;\n context.fillRect(0, 0, canvas.width, canvas.height);\n context.globalCompositeOperation = 'destination-in';\n context.drawImage(overlay, 0, 0);\n context.globalCompositeOperation = 'destination-over';\n context.fillStyle = MASK_KEEP;\n context.fillRect(0, 0, canvas.width, canvas.height);\n\n const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));\n if (!blob) return null;\n return new File([blob], fileName, { type: 'image/png' });\n};\n"],"names":[],"mappings":"AAeA,MAAM,YAAY;AAClB,MAAM,cAAc;AACpB,MAAM,kBAAkB;AAExB,MAAM,gBAAgB,CAAC,SAAmC,QAAqB,MAAkB,OAAmB;AAClH,UAAQ,2BAA2B,OAAO,SAAS,UAAU,oBAAoB;AACjF,UAAQ,YAAY,OAAO;AAC3B,UAAQ,UAAU;AAClB,UAAQ,WAAW;AACnB,UAAQ,UAAA;AACR,UAAQ,OAAO,KAAK,GAAG,KAAK,CAAC;AAE7B,UAAQ,OAAO,GAAG,KAAK,KAAK,MAAM,GAAG,KAAK,KAAK,MAAM,GAAG,IAAI,OAAO,IAAI,GAAG,CAAC;AAC3E,UAAQ,OAAA;AACV;AAGO,MAAM,iBAAiB,CAAC,SAAmC,QAAqB,UAAwB;AAC7G,QAAM,EAAE,WAAW;AACnB,MAAI,OAAO,WAAW,EAAG;AACzB,UAAQ,KAAA;AACR,UAAQ,cAAc;AACtB,gBAAc,SAAS,QAAQ,OAAO,KAAK,IAAI,GAAG,OAAO,SAAS,CAAC,CAAC,GAAG,OAAO,OAAO,SAAS,CAAC,CAAC;AAChG,UAAQ,QAAA;AACV;AAGO,MAAM,gBAAgB,CAC3B,SACA,SACA,UACS;AACT,UAAQ,KAAA;AACR,UAAQ,UAAU,GAAG,GAAG,QAAQ,OAAO,OAAO,QAAQ,OAAO,MAAM;AACnE,UAAQ,cAAc;AACtB,UAAQ,QAAQ,CAAC,WAAW;AAC1B,WAAO,OAAO,QAAQ,CAAC,OAAO,UAAU;AACtC,oBAAc,SAAS,QAAQ,OAAO,OAAO,KAAK,IAAI,GAAG,QAAQ,CAAC,CAAC,GAAG,KAAK;AAAA,IAC7E,CAAC;AAAA,EACH,CAAC;AACD,UAAQ,QAAA;AACV;AAGO,MAAM,kBAAkB,CAAC,YAAwC;AACtE,MAAI,CAAC,QAAQ,SAAS,CAAC,QAAQ,OAAQ,QAAO;AAC9C,QAAM,QAAQ,KAAK,IAAI,GAAG,kBAAkB,KAAK,IAAI,QAAQ,OAAO,QAAQ,MAAM,CAAC;AACnF,QAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,SAAO,QAAQ,KAAK,IAAI,GAAG,KAAK,MAAM,QAAQ,QAAQ,KAAK,CAAC;AAC5D,SAAO,SAAS,KAAK,IAAI,GAAG,KAAK,MAAM,QAAQ,SAAS,KAAK,CAAC;AAC9D,QAAM,UAAU,OAAO,WAAW,IAAI;AACtC,MAAI,CAAC,QAAS,QAAO;AACrB,UAAQ,UAAU,SAAS,GAAG,GAAG,OAAO,OAAO,OAAO,MAAM;AAC5D,QAAM,EAAE,KAAA,IAAS,QAAQ,aAAa,GAAG,GAAG,OAAO,OAAO,OAAO,MAAM;AACvE,WAAS,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS,GAAG;AACnD,QAAI,KAAK,KAAK,IAAI,EAAG,QAAO;AAAA,EAC9B;AACA,SAAO;AACT;AAGO,MAAM,iBAAiB,OAAO,SAA4B,aAA2C;AAC1G,QAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,SAAO,QAAQ,QAAQ;AACvB,SAAO,SAAS,QAAQ;AACxB,QAAM,UAAU,OAAO,WAAW,IAAI;AACtC,MAAI,CAAC,QAAS,QAAO;AAErB,UAAQ,YAAY;AACpB,UAAQ,SAAS,GAAG,GAAG,OAAO,OAAO,OAAO,MAAM;AAClD,UAAQ,2BAA2B;AACnC,UAAQ,UAAU,SAAS,GAAG,CAAC;AAC/B,UAAQ,2BAA2B;AACnC,UAAQ,YAAY;AACpB,UAAQ,SAAS,GAAG,GAAG,OAAO,OAAO,OAAO,MAAM;AAElD,QAAM,OAAO,MAAM,IAAI,QAAqB,CAAC,YAAY,OAAO,OAAO,SAAS,WAAW,CAAC;AAC5F,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,IAAI,KAAK,CAAC,IAAI,GAAG,UAAU,EAAE,MAAM,aAAa;AACzD;"}
|
|
@@ -1,8 +1,24 @@
|
|
|
1
|
+
/** Most AI upload endpoints cap files at 8 MB; this leaves room for multipart overhead. */
|
|
2
|
+
export declare const AI_UPLOAD_SAFE_BYTES: number;
|
|
3
|
+
export type TImageOutputType = 'image/jpeg' | 'image/png' | 'image/webp';
|
|
1
4
|
export interface IResizeImageOptions {
|
|
2
5
|
maxDimension?: number;
|
|
3
6
|
quality?: number;
|
|
7
|
+
/** Defaults to JPEG. PNG and WebP keep transparency. */
|
|
8
|
+
outputType?: TImageOutputType;
|
|
4
9
|
}
|
|
10
|
+
/** File extension (without dot) for an image MIME type, `jpg` when unknown. */
|
|
11
|
+
export declare const imageExtensionForType: (type: string) => string;
|
|
5
12
|
export declare const resizeImageFile: (file: File, options?: IResizeImageOptions) => Promise<File>;
|
|
13
|
+
export interface IFitImageToUploadBudgetOptions {
|
|
14
|
+
maxBytes?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Keeps the source image untouched whenever it already fits the upload budget, so an AI edit
|
|
18
|
+
* works on the original pixels. Only an oversized (or non PNG/JPEG/WebP) file is re-encoded,
|
|
19
|
+
* with high quality and the source format when possible, stepping down until it fits.
|
|
20
|
+
*/
|
|
21
|
+
export declare const fitImageToUploadBudget: (file: File, { maxBytes }?: IFitImageToUploadBudgetOptions) => Promise<File>;
|
|
6
22
|
export declare const fileFromUrl: (url: string, fileName: string, signal?: AbortSignal) => Promise<File>;
|
|
7
23
|
/**
|
|
8
24
|
* Mede a imagem sem baixá-la de novo quando o navegador já a tem em cache.
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
const MAX_DIMENSION = 1280;
|
|
2
2
|
const JPEG_QUALITY = 0.82;
|
|
3
3
|
const OUTPUT_TYPE = "image/jpeg";
|
|
4
|
+
const AI_UPLOAD_SAFE_BYTES = 7.5 * 1024 * 1024;
|
|
5
|
+
const AI_UPLOAD_MAX_DIMENSION = 4096;
|
|
6
|
+
const AI_UPLOAD_QUALITY = 0.95;
|
|
7
|
+
const AI_UPLOAD_FALLBACK_DIMENSION = 3072;
|
|
8
|
+
const AI_UPLOAD_FALLBACK_QUALITY = 0.9;
|
|
9
|
+
const AI_ACCEPTED_TYPES = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
10
|
+
const EXTENSION_BY_TYPE = {
|
|
11
|
+
"image/jpeg": "jpg",
|
|
12
|
+
"image/png": "png",
|
|
13
|
+
"image/webp": "webp"
|
|
14
|
+
};
|
|
4
15
|
const loadImage = (url) => new Promise((resolve, reject) => {
|
|
5
16
|
const image = new Image();
|
|
6
17
|
image.onload = () => resolve(image);
|
|
@@ -13,29 +24,64 @@ const scaledSize = (width, height, maxDimension) => {
|
|
|
13
24
|
const ratio = maxDimension / longest;
|
|
14
25
|
return { width: Math.round(width * ratio), height: Math.round(height * ratio) };
|
|
15
26
|
};
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
const baseName = (name) => name.replace(/\.[^.]+$/, "") || name || "image";
|
|
28
|
+
const imageExtensionForType = (type) => type in EXTENSION_BY_TYPE ? EXTENSION_BY_TYPE[type] : "jpg";
|
|
29
|
+
const withExtension = (name, type) => `${baseName(name)}.${EXTENSION_BY_TYPE[type]}`;
|
|
30
|
+
const encodeImageFile = async (file, options) => {
|
|
20
31
|
const objectUrl = URL.createObjectURL(file);
|
|
21
32
|
try {
|
|
22
33
|
const image = await loadImage(objectUrl);
|
|
23
|
-
const { width, height } = scaledSize(image.naturalWidth, image.naturalHeight, maxDimension);
|
|
34
|
+
const { width, height } = scaledSize(image.naturalWidth, image.naturalHeight, options.maxDimension);
|
|
24
35
|
const canvas = document.createElement("canvas");
|
|
25
36
|
canvas.width = width;
|
|
26
37
|
canvas.height = height;
|
|
27
38
|
const context = canvas.getContext("2d");
|
|
28
|
-
if (!context) return
|
|
39
|
+
if (!context) return null;
|
|
40
|
+
if (options.outputType === "image/jpeg") {
|
|
41
|
+
context.fillStyle = "#ffffff";
|
|
42
|
+
context.fillRect(0, 0, width, height);
|
|
43
|
+
}
|
|
29
44
|
context.drawImage(image, 0, 0, width, height);
|
|
30
|
-
const blob = await new Promise(
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
const blob = await new Promise(
|
|
46
|
+
(resolve) => canvas.toBlob(resolve, options.outputType, options.quality)
|
|
47
|
+
);
|
|
48
|
+
if (!blob) return null;
|
|
49
|
+
return new File([blob], withExtension(file.name, options.outputType), { type: options.outputType });
|
|
33
50
|
} catch {
|
|
34
|
-
return
|
|
51
|
+
return null;
|
|
35
52
|
} finally {
|
|
36
53
|
URL.revokeObjectURL(objectUrl);
|
|
37
54
|
}
|
|
38
55
|
};
|
|
56
|
+
const resizeImageFile = async (file, options = {}) => {
|
|
57
|
+
const encoded = await encodeImageFile(file, {
|
|
58
|
+
maxDimension: options.maxDimension ?? MAX_DIMENSION,
|
|
59
|
+
quality: options.quality ?? JPEG_QUALITY,
|
|
60
|
+
outputType: options.outputType ?? OUTPUT_TYPE
|
|
61
|
+
});
|
|
62
|
+
if (!encoded || encoded.size >= file.size) return file;
|
|
63
|
+
return encoded;
|
|
64
|
+
};
|
|
65
|
+
const fitImageToUploadBudget = async (file, { maxBytes = AI_UPLOAD_SAFE_BYTES } = {}) => {
|
|
66
|
+
const isAccepted = AI_ACCEPTED_TYPES.has(file.type);
|
|
67
|
+
if (isAccepted && file.size <= maxBytes) return file;
|
|
68
|
+
const keepsAlpha = file.type === "image/png" || file.type === "image/webp" || !isAccepted;
|
|
69
|
+
const sourceType = isAccepted ? file.type : "image/png";
|
|
70
|
+
const attempts = [
|
|
71
|
+
{ maxDimension: AI_UPLOAD_MAX_DIMENSION, quality: AI_UPLOAD_QUALITY, outputType: sourceType },
|
|
72
|
+
...keepsAlpha ? [{ maxDimension: AI_UPLOAD_MAX_DIMENSION, quality: AI_UPLOAD_QUALITY, outputType: "image/webp" }] : [],
|
|
73
|
+
{ maxDimension: AI_UPLOAD_MAX_DIMENSION, quality: AI_UPLOAD_QUALITY, outputType: "image/jpeg" },
|
|
74
|
+
{ maxDimension: AI_UPLOAD_FALLBACK_DIMENSION, quality: AI_UPLOAD_FALLBACK_QUALITY, outputType: "image/jpeg" }
|
|
75
|
+
];
|
|
76
|
+
let smallest = null;
|
|
77
|
+
for (const attempt of attempts) {
|
|
78
|
+
const encoded = await encodeImageFile(file, attempt);
|
|
79
|
+
if (!encoded) continue;
|
|
80
|
+
if (encoded.size <= maxBytes) return encoded;
|
|
81
|
+
if (!smallest || encoded.size < smallest.size) smallest = encoded;
|
|
82
|
+
}
|
|
83
|
+
return smallest && smallest.size < file.size ? smallest : file;
|
|
84
|
+
};
|
|
39
85
|
const fileFromUrl = async (url, fileName, signal) => {
|
|
40
86
|
const blob = await (await fetch(url, { cache: "reload", mode: "cors", signal })).blob();
|
|
41
87
|
const type = blob.type || "image/jpeg";
|
|
@@ -52,7 +98,10 @@ const measureImageDimensions = async (src) => {
|
|
|
52
98
|
}
|
|
53
99
|
};
|
|
54
100
|
export {
|
|
101
|
+
AI_UPLOAD_SAFE_BYTES,
|
|
55
102
|
fileFromUrl,
|
|
103
|
+
fitImageToUploadBudget,
|
|
104
|
+
imageExtensionForType,
|
|
56
105
|
measureImageDimensions,
|
|
57
106
|
resizeImageFile
|
|
58
107
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"imageFile.js","sources":["../../src/helpers/imageFile.ts"],"sourcesContent":["const MAX_DIMENSION = 1280;\nconst JPEG_QUALITY = 0.82;\nconst OUTPUT_TYPE = 'image/jpeg';\n\nexport interface IResizeImageOptions {\n maxDimension?: number;\n quality?: number;\n}\n\nconst loadImage = (url: string): Promise<HTMLImageElement> =>\n new Promise((resolve, reject) => {\n const image = new Image();\n image.onload = () => resolve(image);\n image.onerror = () => reject(new Error('Failed to load image'));\n image.src = url;\n });\n\nconst scaledSize = (width: number, height: number, maxDimension: number): { width: number; height: number } => {\n const longest = Math.max(width, height);\n if (longest <= maxDimension) return { width, height };\n const ratio = maxDimension / longest;\n return { width: Math.round(width * ratio), height: Math.round(height * ratio) };\n};\n\nconst
|
|
1
|
+
{"version":3,"file":"imageFile.js","sources":["../../src/helpers/imageFile.ts"],"sourcesContent":["const MAX_DIMENSION = 1280;\nconst JPEG_QUALITY = 0.82;\nconst OUTPUT_TYPE = 'image/jpeg';\n\n/** Most AI upload endpoints cap files at 8 MB; this leaves room for multipart overhead. */\nexport const AI_UPLOAD_SAFE_BYTES = 7.5 * 1024 * 1024;\nconst AI_UPLOAD_MAX_DIMENSION = 4096;\nconst AI_UPLOAD_QUALITY = 0.95;\nconst AI_UPLOAD_FALLBACK_DIMENSION = 3072;\nconst AI_UPLOAD_FALLBACK_QUALITY = 0.9;\n\nexport type TImageOutputType = 'image/jpeg' | 'image/png' | 'image/webp';\n\nconst AI_ACCEPTED_TYPES = new Set<string>(['image/jpeg', 'image/png', 'image/webp']);\n\nconst EXTENSION_BY_TYPE: Record<TImageOutputType, string> = {\n 'image/jpeg': 'jpg',\n 'image/png': 'png',\n 'image/webp': 'webp',\n};\n\nexport interface IResizeImageOptions {\n maxDimension?: number;\n quality?: number;\n /** Defaults to JPEG. PNG and WebP keep transparency. */\n outputType?: TImageOutputType;\n}\n\nconst loadImage = (url: string): Promise<HTMLImageElement> =>\n new Promise((resolve, reject) => {\n const image = new Image();\n image.onload = () => resolve(image);\n image.onerror = () => reject(new Error('Failed to load image'));\n image.src = url;\n });\n\nconst scaledSize = (width: number, height: number, maxDimension: number): { width: number; height: number } => {\n const longest = Math.max(width, height);\n if (longest <= maxDimension) return { width, height };\n const ratio = maxDimension / longest;\n return { width: Math.round(width * ratio), height: Math.round(height * ratio) };\n};\n\nconst baseName = (name: string): string => name.replace(/\\.[^.]+$/, '') || name || 'image';\n\n/** File extension (without dot) for an image MIME type, `jpg` when unknown. */\nexport const imageExtensionForType = (type: string): string =>\n type in EXTENSION_BY_TYPE ? EXTENSION_BY_TYPE[type as TImageOutputType] : 'jpg';\n\nconst withExtension = (name: string, type: TImageOutputType): string => `${baseName(name)}.${EXTENSION_BY_TYPE[type]}`;\n\nconst encodeImageFile = async (file: File, options: Required<IResizeImageOptions>): Promise<File | null> => {\n const objectUrl = URL.createObjectURL(file);\n try {\n const image = await loadImage(objectUrl);\n const { width, height } = scaledSize(image.naturalWidth, image.naturalHeight, options.maxDimension);\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n const context = canvas.getContext('2d');\n if (!context) return null;\n // JPEG has no alpha: without a fill, transparent pixels would turn black.\n if (options.outputType === 'image/jpeg') {\n context.fillStyle = '#ffffff';\n context.fillRect(0, 0, width, height);\n }\n context.drawImage(image, 0, 0, width, height);\n\n const blob = await new Promise<Blob | null>((resolve) =>\n canvas.toBlob(resolve, options.outputType, options.quality),\n );\n if (!blob) return null;\n\n return new File([blob], withExtension(file.name, options.outputType), { type: options.outputType });\n } catch {\n return null;\n } finally {\n URL.revokeObjectURL(objectUrl);\n }\n};\n\nexport const resizeImageFile = async (file: File, options: IResizeImageOptions = {}): Promise<File> => {\n const encoded = await encodeImageFile(file, {\n maxDimension: options.maxDimension ?? MAX_DIMENSION,\n quality: options.quality ?? JPEG_QUALITY,\n outputType: options.outputType ?? OUTPUT_TYPE,\n });\n if (!encoded || encoded.size >= file.size) return file;\n return encoded;\n};\n\nexport interface IFitImageToUploadBudgetOptions {\n maxBytes?: number;\n}\n\n/**\n * Keeps the source image untouched whenever it already fits the upload budget, so an AI edit\n * works on the original pixels. Only an oversized (or non PNG/JPEG/WebP) file is re-encoded,\n * with high quality and the source format when possible, stepping down until it fits.\n */\nexport const fitImageToUploadBudget = async (\n file: File,\n { maxBytes = AI_UPLOAD_SAFE_BYTES }: IFitImageToUploadBudgetOptions = {},\n): Promise<File> => {\n const isAccepted = AI_ACCEPTED_TYPES.has(file.type);\n if (isAccepted && file.size <= maxBytes) return file;\n\n const keepsAlpha = file.type === 'image/png' || file.type === 'image/webp' || !isAccepted;\n const sourceType: TImageOutputType = isAccepted ? (file.type as TImageOutputType) : 'image/png';\n const attempts: Array<Required<IResizeImageOptions>> = [\n { maxDimension: AI_UPLOAD_MAX_DIMENSION, quality: AI_UPLOAD_QUALITY, outputType: sourceType },\n ...(keepsAlpha\n ? [{ maxDimension: AI_UPLOAD_MAX_DIMENSION, quality: AI_UPLOAD_QUALITY, outputType: 'image/webp' as const }]\n : []),\n { maxDimension: AI_UPLOAD_MAX_DIMENSION, quality: AI_UPLOAD_QUALITY, outputType: 'image/jpeg' },\n { maxDimension: AI_UPLOAD_FALLBACK_DIMENSION, quality: AI_UPLOAD_FALLBACK_QUALITY, outputType: 'image/jpeg' },\n ];\n\n let smallest: File | null = null;\n for (const attempt of attempts) {\n const encoded = await encodeImageFile(file, attempt);\n if (!encoded) continue;\n if (encoded.size <= maxBytes) return encoded;\n if (!smallest || encoded.size < smallest.size) smallest = encoded;\n }\n\n return smallest && smallest.size < file.size ? smallest : file;\n};\n\n// `reload` em vez de `force-cache`: a mesma imagem costuma já ter sido baixada por um <img>\n// comum, que não manda `Origin` e por isso é guardada sem `Access-Control-Allow-Origin`.\n// Reaproveitar essa entrada é o que faz o CORS falhar \"hora sim, hora não\".\nexport const fileFromUrl = async (url: string, fileName: string, signal?: AbortSignal): Promise<File> => {\n const blob = await (await fetch(url, { cache: 'reload', mode: 'cors', signal })).blob();\n const type = blob.type || 'image/jpeg';\n return new File([blob], fileName, { type });\n};\n\n/**\n * Mede a imagem sem baixá-la de novo quando o navegador já a tem em cache.\n *\n * Devolve `null` em vez de lançar: medir é para AVISAR o usuário, então uma imagem que não carrega\n * aqui não pode derrubar a tela nem virar um alerta falso.\n */\nexport const measureImageDimensions = async (src: string): Promise<{ width: number; height: number } | null> => {\n if (!src) return null;\n try {\n const image = await loadImage(src);\n if (!image.naturalWidth || !image.naturalHeight) return null;\n return { width: image.naturalWidth, height: image.naturalHeight };\n } catch {\n return null;\n }\n};\n"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export { default as ControlledSlider } from './components/UI/ControlledSlider';
|
|
|
35
35
|
export { default as ControlledTooltip } from './components/UI/ControlledTooltip';
|
|
36
36
|
export { default as CustomBadge } from './components/UI/CustomBadge';
|
|
37
37
|
export { default as CustomCard } from './components/UI/CustomCard';
|
|
38
|
+
export type { IImageMaskPainterProps } from './components/UI/ImageMaskPainter/index';
|
|
39
|
+
export { default as ImageMaskPainter } from './components/UI/ImageMaskPainter/index';
|
|
38
40
|
export { default as InformationCard } from './components/UI/InformationCard';
|
|
39
41
|
export { default as LoadingSpinner } from './components/UI/LoadingSpinner';
|
|
40
42
|
export * from './components/UI/MaterialSymbol/index';
|
package/dist/index.js
CHANGED
|
@@ -35,12 +35,13 @@ import { default as default30 } from "./components/UI/ControlledSlider.js";
|
|
|
35
35
|
import { default as default31 } from "./components/UI/ControlledTooltip.js";
|
|
36
36
|
import { default as default32 } from "./components/UI/CustomBadge.js";
|
|
37
37
|
import { default as default33 } from "./components/UI/CustomCard.js";
|
|
38
|
-
import { default as default34 } from "./components/UI/
|
|
39
|
-
import { default as default35 } from "./components/UI/
|
|
38
|
+
import { default as default34 } from "./components/UI/ImageMaskPainter/index.js";
|
|
39
|
+
import { default as default35 } from "./components/UI/InformationCard.js";
|
|
40
|
+
import { default as default36 } from "./components/UI/LoadingSpinner.js";
|
|
40
41
|
import { MaterialSymbol, combineClasses } from "./components/UI/MaterialSymbol/index.js";
|
|
41
|
-
import { default as
|
|
42
|
-
import { default as
|
|
43
|
-
import { default as
|
|
42
|
+
import { default as default37 } from "./components/UI/NoDataFound/index.js";
|
|
43
|
+
import { default as default38 } from "./components/UI/SkeletonLoading.js";
|
|
44
|
+
import { default as default39 } from "./components/UI/UserMenu.js";
|
|
44
45
|
import { composeRefs } from "./helpers/composeRefs.js";
|
|
45
46
|
import { LAYERED_SURFACE_ATTRIBUTE, LAYERED_SURFACE_DATA_ATTRIBUTE, LAYERED_SURFACE_SELECTOR, isPressOnLayeredSurface } from "./helpers/layeredSurfaces.js";
|
|
46
47
|
import { readThemeToken, useThemeChartTokens } from "./helpers/themeTokens.js";
|
|
@@ -85,25 +86,26 @@ export {
|
|
|
85
86
|
default32 as CustomBadge,
|
|
86
87
|
default33 as CustomCard,
|
|
87
88
|
default14 as ErrorPage,
|
|
88
|
-
default34 as
|
|
89
|
+
default34 as ImageMaskPainter,
|
|
90
|
+
default35 as InformationCard,
|
|
89
91
|
LAYERED_SURFACE_ATTRIBUTE,
|
|
90
92
|
LAYERED_SURFACE_DATA_ATTRIBUTE,
|
|
91
93
|
LAYERED_SURFACE_SELECTOR,
|
|
92
94
|
default15 as LinkButton,
|
|
93
|
-
|
|
95
|
+
default36 as LoadingSpinner,
|
|
94
96
|
MOTION_OFFSET_PX,
|
|
95
97
|
MaterialSymbol,
|
|
96
|
-
|
|
98
|
+
default37 as NoDataFound,
|
|
97
99
|
default22 as OutlinedChip,
|
|
98
100
|
default16 as PageTitle,
|
|
99
101
|
PageTitleNavigationProvider,
|
|
100
102
|
default19 as ShellLanguageToggle,
|
|
101
103
|
default20 as ShellThemeToggle,
|
|
102
|
-
|
|
104
|
+
default38 as SkeletonLoading,
|
|
103
105
|
UiAccessProvider,
|
|
104
106
|
UiNavigationProvider,
|
|
105
107
|
default17 as UnifiedSearchBar,
|
|
106
|
-
|
|
108
|
+
default39 as UserMenu,
|
|
107
109
|
collapseVariants,
|
|
108
110
|
combineClasses,
|
|
109
111
|
composeRefs,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|