@umodeapporg/ui 0.15.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/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,37 @@
|
|
|
1
|
+
import { uiLogger } from "../../../helpers/uiLogger.js";
|
|
2
|
+
const IMAGE_AI_REVIEW_REASON_CODES = [
|
|
3
|
+
"changed_the_product",
|
|
4
|
+
"wrong_color",
|
|
5
|
+
"bad_quality",
|
|
6
|
+
"person_changed",
|
|
7
|
+
"text_or_logo",
|
|
8
|
+
"not_what_i_asked",
|
|
9
|
+
"other"
|
|
10
|
+
];
|
|
11
|
+
const IMAGE_AI_REVIEW_REASON_MAX = 500;
|
|
12
|
+
const IMAGE_AI_REVIEW_REASON_LABEL_KEYS = {
|
|
13
|
+
changed_the_product: "imageAiReview.reasons.changed_the_product",
|
|
14
|
+
wrong_color: "imageAiReview.reasons.wrong_color",
|
|
15
|
+
bad_quality: "imageAiReview.reasons.bad_quality",
|
|
16
|
+
person_changed: "imageAiReview.reasons.person_changed",
|
|
17
|
+
text_or_logo: "imageAiReview.reasons.text_or_logo",
|
|
18
|
+
not_what_i_asked: "imageAiReview.reasons.not_what_i_asked",
|
|
19
|
+
other: "imageAiReview.reasons.other"
|
|
20
|
+
};
|
|
21
|
+
const isImageAiReviewReasonCode = (value) => typeof value === "string" && IMAGE_AI_REVIEW_REASON_CODES.includes(value);
|
|
22
|
+
const submitImageAiReview = async (handler, review) => {
|
|
23
|
+
if (!handler || !review.generationId) return;
|
|
24
|
+
try {
|
|
25
|
+
await handler(review);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
uiLogger.warn("Failed to submit the AI image review", error);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
export {
|
|
31
|
+
IMAGE_AI_REVIEW_REASON_CODES,
|
|
32
|
+
IMAGE_AI_REVIEW_REASON_LABEL_KEYS,
|
|
33
|
+
IMAGE_AI_REVIEW_REASON_MAX,
|
|
34
|
+
isImageAiReviewReasonCode,
|
|
35
|
+
submitImageAiReview
|
|
36
|
+
};
|
|
37
|
+
//# sourceMappingURL=imageAiReview.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"imageAiReview.js","sources":["../../../../src/components/UI/ImageAiAdjust/imageAiReview.ts"],"sourcesContent":["import { uiLogger } from '@/helpers/uiLogger';\n\n/** Why a user discarded an AI result; mirrors the backend ReviewReasonCode. */\nexport const IMAGE_AI_REVIEW_REASON_CODES = [\n 'changed_the_product',\n 'wrong_color',\n 'bad_quality',\n 'person_changed',\n 'text_or_logo',\n 'not_what_i_asked',\n 'other',\n] as const;\n\nexport type TImageAiReviewReasonCode = (typeof IMAGE_AI_REVIEW_REASON_CODES)[number];\n\nexport type TImageAiReviewDecision = 'approved' | 'discarded';\n\n/** Matches the backend limit for the free-text review reason. */\nexport const IMAGE_AI_REVIEW_REASON_MAX = 500;\n\n/** Keys in the `ui` namespace, so hosts can render the same labels. */\nexport const IMAGE_AI_REVIEW_REASON_LABEL_KEYS: Record<TImageAiReviewReasonCode, string> = {\n changed_the_product: 'imageAiReview.reasons.changed_the_product',\n wrong_color: 'imageAiReview.reasons.wrong_color',\n bad_quality: 'imageAiReview.reasons.bad_quality',\n person_changed: 'imageAiReview.reasons.person_changed',\n text_or_logo: 'imageAiReview.reasons.text_or_logo',\n not_what_i_asked: 'imageAiReview.reasons.not_what_i_asked',\n other: 'imageAiReview.reasons.other',\n};\n\nexport const isImageAiReviewReasonCode = (value: unknown): value is TImageAiReviewReasonCode =>\n typeof value === 'string' && (IMAGE_AI_REVIEW_REASON_CODES as ReadonlyArray<string>).includes(value);\n\nexport interface IImageAiDiscardReason {\n reasonCode: TImageAiReviewReasonCode;\n reason?: string;\n}\n\nexport interface IImageAiReview {\n generationId: string;\n decision: TImageAiReviewDecision;\n reasonCode?: TImageAiReviewReasonCode;\n reason?: string;\n}\n\nexport type TImageAiReviewHandler = (review: IImageAiReview) => Promise<void> | void;\n\n/** A review is feedback, never a gate: failures are logged and swallowed. */\nexport const submitImageAiReview = async (\n handler: TImageAiReviewHandler | undefined,\n review: IImageAiReview,\n): Promise<void> => {\n if (!handler || !review.generationId) return;\n\n try {\n await handler(review);\n } catch (error) {\n uiLogger.warn('Failed to submit the AI image review', error);\n }\n};\n"],"names":[],"mappings":";AAGO,MAAM,+BAA+B;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAOO,MAAM,6BAA6B;AAGnC,MAAM,oCAA8E;AAAA,EACzF,qBAAqB;AAAA,EACrB,aAAa;AAAA,EACb,aAAa;AAAA,EACb,gBAAgB;AAAA,EAChB,cAAc;AAAA,EACd,kBAAkB;AAAA,EAClB,OAAO;AACT;AAEO,MAAM,4BAA4B,CAAC,UACxC,OAAO,UAAU,YAAa,6BAAuD,SAAS,KAAK;AAiB9F,MAAM,sBAAsB,OACjC,SACA,WACkB;AAClB,MAAI,CAAC,WAAW,CAAC,OAAO,aAAc;AAEtC,MAAI;AACF,UAAM,QAAQ,MAAM;AAAA,EACtB,SAAS,OAAO;AACd,aAAS,KAAK,wCAAwC,KAAK;AAAA,EAC7D;AACF;"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Backend check of the dominant garment colour against the requested one (CIEDE2000). */
|
|
2
|
+
export interface IImageAiColorCheck {
|
|
3
|
+
targetHex: string;
|
|
4
|
+
dominantHex: string;
|
|
5
|
+
deltaE: number;
|
|
6
|
+
passed: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** What the backend measured on an AI result; every field is advisory, never blocking. */
|
|
9
|
+
export interface IImageAiVerification {
|
|
10
|
+
/** Mean normalized difference outside the painted area (whole image without a mask), 0..1. */
|
|
11
|
+
changeScore: number | null;
|
|
12
|
+
threshold: number;
|
|
13
|
+
changedMoreThanRequested: boolean;
|
|
14
|
+
/** True when pixels outside the mask were restored from the source. */
|
|
15
|
+
composited: boolean;
|
|
16
|
+
colorCheck: IImageAiColorCheck | null;
|
|
17
|
+
}
|
|
18
|
+
/** Coerces whatever the host received into the verification shape, or null when absent/garbled. */
|
|
19
|
+
export declare const normalizeImageAiVerification: (value: unknown) => IImageAiVerification | null;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
2
|
+
const finiteOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
3
|
+
const normalizeColorCheck = (value) => {
|
|
4
|
+
if (!isRecord(value) || typeof value.passed !== "boolean") return null;
|
|
5
|
+
return {
|
|
6
|
+
targetHex: typeof value.targetHex === "string" ? value.targetHex : "",
|
|
7
|
+
dominantHex: typeof value.dominantHex === "string" ? value.dominantHex : "",
|
|
8
|
+
deltaE: finiteOr(value.deltaE, 0),
|
|
9
|
+
passed: value.passed
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
const normalizeImageAiVerification = (value) => {
|
|
13
|
+
if (!isRecord(value)) return null;
|
|
14
|
+
return {
|
|
15
|
+
changeScore: finiteOr(value.changeScore, null),
|
|
16
|
+
threshold: finiteOr(value.threshold, 0),
|
|
17
|
+
changedMoreThanRequested: value.changedMoreThanRequested === true,
|
|
18
|
+
composited: value.composited === true,
|
|
19
|
+
colorCheck: normalizeColorCheck(value.colorCheck)
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export {
|
|
23
|
+
normalizeImageAiVerification
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=imageAiVerification.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"imageAiVerification.js","sources":["../../../../src/components/UI/ImageAiAdjust/imageAiVerification.ts"],"sourcesContent":["/** Backend check of the dominant garment colour against the requested one (CIEDE2000). */\nexport interface IImageAiColorCheck {\n targetHex: string;\n dominantHex: string;\n deltaE: number;\n passed: boolean;\n}\n\n/** What the backend measured on an AI result; every field is advisory, never blocking. */\nexport interface IImageAiVerification {\n /** Mean normalized difference outside the painted area (whole image without a mask), 0..1. */\n changeScore: number | null;\n threshold: number;\n changedMoreThanRequested: boolean;\n /** True when pixels outside the mask were restored from the source. */\n composited: boolean;\n colorCheck: IImageAiColorCheck | null;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;\n\nconst finiteOr = <T>(value: unknown, fallback: T): number | T =>\n typeof value === 'number' && Number.isFinite(value) ? value : fallback;\n\nconst normalizeColorCheck = (value: unknown): IImageAiColorCheck | null => {\n if (!isRecord(value) || typeof value.passed !== 'boolean') return null;\n return {\n targetHex: typeof value.targetHex === 'string' ? value.targetHex : '',\n dominantHex: typeof value.dominantHex === 'string' ? value.dominantHex : '',\n deltaE: finiteOr(value.deltaE, 0),\n passed: value.passed,\n };\n};\n\n/** Coerces whatever the host received into the verification shape, or null when absent/garbled. */\nexport const normalizeImageAiVerification = (value: unknown): IImageAiVerification | null => {\n if (!isRecord(value)) return null;\n return {\n changeScore: finiteOr(value.changeScore, null),\n threshold: finiteOr(value.threshold, 0),\n changedMoreThanRequested: value.changedMoreThanRequested === true,\n composited: value.composited === true,\n colorCheck: normalizeColorCheck(value.colorCheck),\n };\n};\n"],"names":[],"mappings":"AAmBA,MAAM,WAAW,CAAC,UAAqD,OAAO,UAAU,YAAY,UAAU;AAE9G,MAAM,WAAW,CAAI,OAAgB,aACnC,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,IAAI,QAAQ;AAEhE,MAAM,sBAAsB,CAAC,UAA8C;AACzE,MAAI,CAAC,SAAS,KAAK,KAAK,OAAO,MAAM,WAAW,UAAW,QAAO;AAClE,SAAO;AAAA,IACL,WAAW,OAAO,MAAM,cAAc,WAAW,MAAM,YAAY;AAAA,IACnE,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc;AAAA,IACzE,QAAQ,SAAS,MAAM,QAAQ,CAAC;AAAA,IAChC,QAAQ,MAAM;AAAA,EAAA;AAElB;AAGO,MAAM,+BAA+B,CAAC,UAAgD;AAC3F,MAAI,CAAC,SAAS,KAAK,EAAG,QAAO;AAC7B,SAAO;AAAA,IACL,aAAa,SAAS,MAAM,aAAa,IAAI;AAAA,IAC7C,WAAW,SAAS,MAAM,WAAW,CAAC;AAAA,IACtC,0BAA0B,MAAM,6BAA6B;AAAA,IAC7D,YAAY,MAAM,eAAe;AAAA,IACjC,YAAY,oBAAoB,MAAM,UAAU;AAAA,EAAA;AAEpD;"}
|
|
@@ -1,11 +1,30 @@
|
|
|
1
|
+
import { IImageAiVerification } from './imageAiVerification';
|
|
2
|
+
export type { IImageAiColorCheck, IImageAiVerification } from './imageAiVerification';
|
|
3
|
+
/** Matches the backend limit for the adjustment instructions. */
|
|
4
|
+
export declare const IMAGE_AI_ADJUST_MAX_INSTRUCTIONS = 2000;
|
|
5
|
+
/**
|
|
6
|
+
* An adjuster may reject with an error carrying `displayMessage`: a short reason the host has
|
|
7
|
+
* already vetted as safe to show (for example why the model refused). Anything else stays generic.
|
|
8
|
+
*/
|
|
9
|
+
export interface IImageAiAdjustDisplayableError {
|
|
10
|
+
displayMessage: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const getImageAiAdjustDisplayMessage: (error: unknown) => string | null;
|
|
1
13
|
export interface IImageAiAdjustRequest {
|
|
2
|
-
/** The current image,
|
|
14
|
+
/** The current image at full resolution, only re-encoded when it exceeds the upload budget. */
|
|
3
15
|
baseImage: File;
|
|
4
16
|
instructions: string;
|
|
5
17
|
productId?: string;
|
|
18
|
+
/** PNG at the source's natural size: white = the only area that may change, black = keep. */
|
|
19
|
+
mask?: File;
|
|
6
20
|
}
|
|
7
21
|
export interface IImageAiAdjustResult {
|
|
8
22
|
imageUrl: string;
|
|
23
|
+
/** Present when the host logs generations; enables the approve/discard review. */
|
|
24
|
+
generationId?: string;
|
|
25
|
+
costUsd?: number;
|
|
26
|
+
/** Backend checks on the result; a warning is shown when it changed more than asked. */
|
|
27
|
+
verification?: IImageAiVerification | null;
|
|
9
28
|
}
|
|
10
29
|
/** The host decides which AI backend serves the adjustment; the UI only knows this contract. */
|
|
11
30
|
export type TImageAiAdjuster = (request: IImageAiAdjustRequest, signal: AbortSignal) => Promise<IImageAiAdjustResult>;
|
|
@@ -14,11 +33,15 @@ export interface IUseImageAiAdjustParams {
|
|
|
14
33
|
imageName?: string;
|
|
15
34
|
productId?: string;
|
|
16
35
|
adjustImage: TImageAiAdjuster;
|
|
36
|
+
/** Optional painted region; sent with the request only when present. */
|
|
37
|
+
mask?: File | null;
|
|
17
38
|
}
|
|
18
39
|
export interface IUseImageAiAdjust {
|
|
19
40
|
instructions: string;
|
|
20
41
|
setInstructions: (value: string) => void;
|
|
21
42
|
resultUrl: string | null;
|
|
43
|
+
resultGenerationId: string | null;
|
|
44
|
+
resultVerification: IImageAiVerification | null;
|
|
22
45
|
isGenerating: boolean;
|
|
23
46
|
canGenerate: boolean;
|
|
24
47
|
generate: () => Promise<void>;
|
|
@@ -26,4 +49,4 @@ export interface IUseImageAiAdjust {
|
|
|
26
49
|
discardResult: () => void;
|
|
27
50
|
buildResultFile: () => Promise<File>;
|
|
28
51
|
}
|
|
29
|
-
export declare const useImageAiAdjust: ({ imageUrl, imageName, productId, adjustImage, }: IUseImageAiAdjustParams) => IUseImageAiAdjust;
|
|
52
|
+
export declare const useImageAiAdjust: ({ imageUrl, imageName, productId, adjustImage, mask, }: IUseImageAiAdjustParams) => IUseImageAiAdjust;
|
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import { useRef, useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { useTranslation } from "react-i18next";
|
|
3
|
-
import { fileFromUrl,
|
|
3
|
+
import { fileFromUrl, imageExtensionForType, fitImageToUploadBudget } from "../../../helpers/imageFile.js";
|
|
4
4
|
import { uiLogger } from "../../../helpers/uiLogger.js";
|
|
5
5
|
import { useNotification } from "../../../hooks/useNotification.js";
|
|
6
|
-
|
|
7
|
-
const
|
|
6
|
+
import { normalizeImageAiVerification } from "./imageAiVerification.js";
|
|
7
|
+
const IMAGE_AI_ADJUST_MAX_INSTRUCTIONS = 2e3;
|
|
8
8
|
const isAbortError = (error) => error instanceof DOMException && error.name === "AbortError";
|
|
9
|
-
const
|
|
9
|
+
const getImageAiAdjustDisplayMessage = (error) => {
|
|
10
|
+
if (typeof error !== "object" || error === null || !("displayMessage" in error)) return null;
|
|
11
|
+
const { displayMessage } = error;
|
|
12
|
+
return typeof displayMessage === "string" && displayMessage.trim() ? displayMessage.trim() : null;
|
|
13
|
+
};
|
|
14
|
+
const adjustedFileName = (imageName, type) => {
|
|
10
15
|
const base = (imageName ?? "image").replace(/\.[^.]+$/, "") || "image";
|
|
11
|
-
return `${base}-ia
|
|
16
|
+
return `${base}-ia.${imageExtensionForType(type)}`;
|
|
12
17
|
};
|
|
13
18
|
const useImageAiAdjust = ({
|
|
14
19
|
imageUrl,
|
|
15
20
|
imageName,
|
|
16
21
|
productId,
|
|
17
|
-
adjustImage
|
|
22
|
+
adjustImage,
|
|
23
|
+
mask
|
|
18
24
|
}) => {
|
|
19
25
|
const { t } = useTranslation("ui");
|
|
20
26
|
const { notify } = useNotification();
|
|
21
27
|
const abortRef = useRef(null);
|
|
22
28
|
const [instructions, setInstructions] = useState("");
|
|
23
29
|
const [resultUrl, setResultUrl] = useState(null);
|
|
30
|
+
const [resultGenerationId, setResultGenerationId] = useState(null);
|
|
31
|
+
const [resultVerification, setResultVerification] = useState(null);
|
|
24
32
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
25
33
|
useEffect(
|
|
26
34
|
() => () => {
|
|
@@ -32,46 +40,61 @@ const useImageAiAdjust = ({
|
|
|
32
40
|
const cancel = useCallback(() => {
|
|
33
41
|
abortRef.current?.abort();
|
|
34
42
|
}, []);
|
|
35
|
-
const discardResult = useCallback(() =>
|
|
43
|
+
const discardResult = useCallback(() => {
|
|
44
|
+
setResultUrl(null);
|
|
45
|
+
setResultGenerationId(null);
|
|
46
|
+
setResultVerification(null);
|
|
47
|
+
}, []);
|
|
36
48
|
const generate = useCallback(async () => {
|
|
37
49
|
const prompt = instructions.trim();
|
|
38
|
-
if (!prompt || isGenerating) return;
|
|
50
|
+
if (!prompt || prompt.length > IMAGE_AI_ADJUST_MAX_INSTRUCTIONS || isGenerating) return;
|
|
39
51
|
abortRef.current?.abort();
|
|
40
52
|
const controller = new AbortController();
|
|
41
53
|
abortRef.current = controller;
|
|
42
54
|
setIsGenerating(true);
|
|
43
55
|
try {
|
|
44
|
-
const
|
|
56
|
+
const fetched = await fileFromUrl(imageUrl, "base-image", controller.signal);
|
|
45
57
|
if (controller.signal.aborted) return;
|
|
46
|
-
const
|
|
58
|
+
const baseFile = new File([fetched], `base-image.${imageExtensionForType(fetched.type)}`, { type: fetched.type });
|
|
59
|
+
const baseImage = await fitImageToUploadBudget(baseFile);
|
|
47
60
|
if (controller.signal.aborted) return;
|
|
48
|
-
const
|
|
61
|
+
const request = { baseImage, instructions: prompt, productId };
|
|
62
|
+
if (mask) request.mask = mask;
|
|
63
|
+
const response = await adjustImage(request, controller.signal);
|
|
49
64
|
if (controller.signal.aborted) return;
|
|
50
65
|
setResultUrl(response.imageUrl);
|
|
66
|
+
setResultGenerationId(
|
|
67
|
+
typeof response.generationId === "string" && response.generationId ? response.generationId : null
|
|
68
|
+
);
|
|
69
|
+
setResultVerification(normalizeImageAiVerification(response.verification));
|
|
51
70
|
} catch (error) {
|
|
52
|
-
if (isAbortError(error)) return;
|
|
71
|
+
if (isAbortError(error) || controller.signal.aborted) return;
|
|
53
72
|
uiLogger.error("Failed to adjust the image with AI", error);
|
|
54
|
-
|
|
73
|
+
const reason = getImageAiAdjustDisplayMessage(error);
|
|
74
|
+
notify({
|
|
75
|
+
message: reason ? t("imageAiAdjust.errorRefused", { reason }) : t("imageAiAdjust.errorGenerate"),
|
|
76
|
+
type: "error"
|
|
77
|
+
});
|
|
55
78
|
} finally {
|
|
56
79
|
if (abortRef.current === controller) {
|
|
57
80
|
abortRef.current = null;
|
|
58
81
|
setIsGenerating(false);
|
|
59
82
|
}
|
|
60
83
|
}
|
|
61
|
-
}, [instructions, isGenerating, imageUrl, productId, adjustImage, notify, t]);
|
|
84
|
+
}, [instructions, isGenerating, imageUrl, productId, adjustImage, mask, notify, t]);
|
|
62
85
|
const buildResultFile = useCallback(async () => {
|
|
63
86
|
if (!resultUrl) throw new Error("No adjusted image to save");
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
quality: ADJUSTED_IMAGE_QUALITY
|
|
67
|
-
});
|
|
87
|
+
const fetched = await fileFromUrl(resultUrl, "adjusted-image");
|
|
88
|
+
return new File([fetched], adjustedFileName(imageName, fetched.type), { type: fetched.type });
|
|
68
89
|
}, [resultUrl, imageName]);
|
|
69
90
|
return {
|
|
70
91
|
instructions,
|
|
71
92
|
setInstructions,
|
|
72
93
|
resultUrl,
|
|
94
|
+
resultGenerationId,
|
|
95
|
+
resultVerification,
|
|
73
96
|
isGenerating,
|
|
74
|
-
canGenerate: Boolean(instructions.trim()) && !isGenerating,
|
|
97
|
+
canGenerate: Boolean(instructions.trim()) && instructions.trim().length <= IMAGE_AI_ADJUST_MAX_INSTRUCTIONS && !isGenerating,
|
|
75
98
|
generate,
|
|
76
99
|
cancel,
|
|
77
100
|
discardResult,
|
|
@@ -79,6 +102,8 @@ const useImageAiAdjust = ({
|
|
|
79
102
|
};
|
|
80
103
|
};
|
|
81
104
|
export {
|
|
105
|
+
IMAGE_AI_ADJUST_MAX_INSTRUCTIONS,
|
|
106
|
+
getImageAiAdjustDisplayMessage,
|
|
82
107
|
useImageAiAdjust
|
|
83
108
|
};
|
|
84
109
|
//# sourceMappingURL=useImageAiAdjust.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useImageAiAdjust.js","sources":["../../../../src/components/UI/ImageAiAdjust/useImageAiAdjust.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { fileFromUrl,
|
|
1
|
+
{"version":3,"file":"useImageAiAdjust.js","sources":["../../../../src/components/UI/ImageAiAdjust/useImageAiAdjust.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { fileFromUrl, fitImageToUploadBudget, imageExtensionForType } from '@/helpers/imageFile';\nimport { uiLogger } from '@/helpers/uiLogger';\nimport { useNotification } from '@/hooks/useNotification';\n\nimport { IImageAiVerification, normalizeImageAiVerification } from './imageAiVerification';\n\nexport type { IImageAiColorCheck, IImageAiVerification } from './imageAiVerification';\n\n/** Matches the backend limit for the adjustment instructions. */\nexport const IMAGE_AI_ADJUST_MAX_INSTRUCTIONS = 2000;\n\nconst isAbortError = (error: unknown): boolean => error instanceof DOMException && error.name === 'AbortError';\n\n/**\n * An adjuster may reject with an error carrying `displayMessage`: a short reason the host has\n * already vetted as safe to show (for example why the model refused). Anything else stays generic.\n */\nexport interface IImageAiAdjustDisplayableError {\n displayMessage: string;\n}\n\nexport const getImageAiAdjustDisplayMessage = (error: unknown): string | null => {\n if (typeof error !== 'object' || error === null || !('displayMessage' in error)) return null;\n const { displayMessage } = error as Partial<IImageAiAdjustDisplayableError>;\n return typeof displayMessage === 'string' && displayMessage.trim() ? displayMessage.trim() : null;\n};\n\nexport interface IImageAiAdjustRequest {\n /** The current image at full resolution, only re-encoded when it exceeds the upload budget. */\n baseImage: File;\n instructions: string;\n productId?: string;\n /** PNG at the source's natural size: white = the only area that may change, black = keep. */\n mask?: File;\n}\n\nexport interface IImageAiAdjustResult {\n imageUrl: string;\n /** Present when the host logs generations; enables the approve/discard review. */\n generationId?: string;\n costUsd?: number;\n /** Backend checks on the result; a warning is shown when it changed more than asked. */\n verification?: IImageAiVerification | null;\n}\n\n/** The host decides which AI backend serves the adjustment; the UI only knows this contract. */\nexport type TImageAiAdjuster = (request: IImageAiAdjustRequest, signal: AbortSignal) => Promise<IImageAiAdjustResult>;\n\nexport interface IUseImageAiAdjustParams {\n imageUrl: string;\n imageName?: string;\n productId?: string;\n adjustImage: TImageAiAdjuster;\n /** Optional painted region; sent with the request only when present. */\n mask?: File | null;\n}\n\nexport interface IUseImageAiAdjust {\n instructions: string;\n setInstructions: (value: string) => void;\n resultUrl: string | null;\n resultGenerationId: string | null;\n resultVerification: IImageAiVerification | null;\n isGenerating: boolean;\n canGenerate: boolean;\n generate: () => Promise<void>;\n cancel: () => void;\n discardResult: () => void;\n buildResultFile: () => Promise<File>;\n}\n\nconst adjustedFileName = (imageName: string | undefined, type: string): string => {\n const base = (imageName ?? 'image').replace(/\\.[^.]+$/, '') || 'image';\n return `${base}-ia.${imageExtensionForType(type)}`;\n};\n\nexport const useImageAiAdjust = ({\n imageUrl,\n imageName,\n productId,\n adjustImage,\n mask,\n}: IUseImageAiAdjustParams): IUseImageAiAdjust => {\n const { t } = useTranslation('ui');\n const { notify } = useNotification();\n\n const abortRef = useRef<AbortController | null>(null);\n const [instructions, setInstructions] = useState('');\n const [resultUrl, setResultUrl] = useState<string | null>(null);\n const [resultGenerationId, setResultGenerationId] = useState<string | null>(null);\n const [resultVerification, setResultVerification] = useState<IImageAiVerification | null>(null);\n const [isGenerating, setIsGenerating] = useState(false);\n\n // Closing the dialog mid-generation would leave an orphan request holding the connection.\n useEffect(\n () => () => {\n abortRef.current?.abort();\n abortRef.current = null;\n },\n [],\n );\n\n const cancel = useCallback(() => {\n abortRef.current?.abort();\n }, []);\n\n const discardResult = useCallback(() => {\n setResultUrl(null);\n setResultGenerationId(null);\n setResultVerification(null);\n }, []);\n\n const generate = useCallback(async () => {\n const prompt = instructions.trim();\n if (!prompt || prompt.length > IMAGE_AI_ADJUST_MAX_INSTRUCTIONS || isGenerating) return;\n\n abortRef.current?.abort();\n const controller = new AbortController();\n abortRef.current = controller;\n setIsGenerating(true);\n\n try {\n const fetched = await fileFromUrl(imageUrl, 'base-image', controller.signal);\n if (controller.signal.aborted) return;\n\n const baseFile = new File([fetched], `base-image.${imageExtensionForType(fetched.type)}`, { type: fetched.type });\n const baseImage = await fitImageToUploadBudget(baseFile);\n if (controller.signal.aborted) return;\n\n const request: IImageAiAdjustRequest = { baseImage, instructions: prompt, productId };\n if (mask) request.mask = mask;\n const response = await adjustImage(request, controller.signal);\n if (controller.signal.aborted) return;\n\n setResultUrl(response.imageUrl);\n setResultGenerationId(\n typeof response.generationId === 'string' && response.generationId ? response.generationId : null,\n );\n setResultVerification(normalizeImageAiVerification(response.verification));\n } catch (error) {\n // Hosts may reject a cancelled request with their own error type (axios CanceledError).\n if (isAbortError(error) || controller.signal.aborted) return;\n uiLogger.error('Failed to adjust the image with AI', error);\n const reason = getImageAiAdjustDisplayMessage(error);\n notify({\n message: reason ? t('imageAiAdjust.errorRefused', { reason }) : t('imageAiAdjust.errorGenerate'),\n type: 'error',\n });\n } finally {\n if (abortRef.current === controller) {\n abortRef.current = null;\n setIsGenerating(false);\n }\n }\n }, [instructions, isGenerating, imageUrl, productId, adjustImage, mask, notify, t]);\n\n const buildResultFile = useCallback(async () => {\n if (!resultUrl) throw new Error('No adjusted image to save');\n\n // Kept byte for byte: re-encoding would throw away the quality the adjustment preserved.\n const fetched = await fileFromUrl(resultUrl, 'adjusted-image');\n return new File([fetched], adjustedFileName(imageName, fetched.type), { type: fetched.type });\n }, [resultUrl, imageName]);\n\n return {\n instructions,\n setInstructions,\n resultUrl,\n resultGenerationId,\n resultVerification,\n isGenerating,\n canGenerate:\n Boolean(instructions.trim()) && instructions.trim().length <= IMAGE_AI_ADJUST_MAX_INSTRUCTIONS && !isGenerating,\n generate,\n cancel,\n discardResult,\n buildResultFile,\n };\n};\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type { TMaskBrushMode } from './maskStrokes';
|
|
2
|
+
/** Brush diameter in on-screen pixels; converted to natural pixels per stroke. */
|
|
3
|
+
export declare const IMAGE_MASK_BRUSH_MIN = 8;
|
|
4
|
+
export declare const IMAGE_MASK_BRUSH_MAX = 96;
|
|
5
|
+
export interface IImageMaskPainterProps {
|
|
6
|
+
imageUrl: string;
|
|
7
|
+
/**
|
|
8
|
+
* Called after every stroke, undo and clear with a PNG at the image's natural resolution
|
|
9
|
+
* (white = painted, black = untouched), or null when nothing is painted.
|
|
10
|
+
*/
|
|
11
|
+
onMaskChange: (mask: File | null) => void;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
maskFileName?: string;
|
|
14
|
+
/** Sizes the image; defaults to fitting a dialog preview. */
|
|
15
|
+
imageClassName?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
declare const ImageMaskPainter: React.FC<IImageMaskPainterProps>;
|
|
19
|
+
export default ImageMaskPainter;
|
|
@@ -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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"}
|