@umodeapporg/ui 0.15.0 → 0.16.1

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.
Files changed (43) hide show
  1. package/dist/components/Form/ControlledImage/index.d.ts +3 -0
  2. package/dist/components/Form/ControlledImage/index.js +2 -1
  3. package/dist/components/Form/ControlledImage/index.js.map +1 -1
  4. package/dist/components/Form/ControlledSelect.js +11 -4
  5. package/dist/components/Form/ControlledSelect.js.map +1 -1
  6. package/dist/components/UI/ImageAiAdjust/ImageAiAdjustDialog.d.ts +6 -0
  7. package/dist/components/UI/ImageAiAdjust/ImageAiAdjustDialog.js +164 -74
  8. package/dist/components/UI/ImageAiAdjust/ImageAiAdjustDialog.js.map +1 -1
  9. package/dist/components/UI/ImageAiAdjust/ImageAiDiscardReasonDialog.d.ts +8 -0
  10. package/dist/components/UI/ImageAiAdjust/ImageAiDiscardReasonDialog.js +67 -0
  11. package/dist/components/UI/ImageAiAdjust/ImageAiDiscardReasonDialog.js.map +1 -0
  12. package/dist/components/UI/ImageAiAdjust/ImageAiVerificationNotice.d.ts +8 -0
  13. package/dist/components/UI/ImageAiAdjust/ImageAiVerificationNotice.js +39 -0
  14. package/dist/components/UI/ImageAiAdjust/ImageAiVerificationNotice.js.map +1 -0
  15. package/dist/components/UI/ImageAiAdjust/imageAiReview.d.ts +22 -0
  16. package/dist/components/UI/ImageAiAdjust/imageAiReview.js +37 -0
  17. package/dist/components/UI/ImageAiAdjust/imageAiReview.js.map +1 -0
  18. package/dist/components/UI/ImageAiAdjust/imageAiVerification.d.ts +19 -0
  19. package/dist/components/UI/ImageAiAdjust/imageAiVerification.js +25 -0
  20. package/dist/components/UI/ImageAiAdjust/imageAiVerification.js.map +1 -0
  21. package/dist/components/UI/ImageAiAdjust/useImageAiAdjust.d.ts +25 -2
  22. package/dist/components/UI/ImageAiAdjust/useImageAiAdjust.js +44 -19
  23. package/dist/components/UI/ImageAiAdjust/useImageAiAdjust.js.map +1 -1
  24. package/dist/components/UI/ImageMaskPainter/index.d.ts +19 -0
  25. package/dist/components/UI/ImageMaskPainter/index.js +267 -0
  26. package/dist/components/UI/ImageMaskPainter/index.js.map +1 -0
  27. package/dist/components/UI/ImageMaskPainter/maskStrokes.d.ts +19 -0
  28. package/dist/components/UI/ImageMaskPainter/maskStrokes.js +71 -0
  29. package/dist/components/UI/ImageMaskPainter/maskStrokes.js.map +1 -0
  30. package/dist/helpers/imageFile.d.ts +16 -0
  31. package/dist/helpers/imageFile.js +59 -10
  32. package/dist/helpers/imageFile.js.map +1 -1
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.js +12 -10
  35. package/dist/index.js.map +1 -1
  36. package/dist/locales/en/ui.json.d.ts +36 -1
  37. package/dist/locales/en/ui.json.js +7 -1
  38. package/dist/locales/en/ui.json.js.map +1 -1
  39. package/dist/locales/index.d.ts +70 -0
  40. package/dist/locales/pt/ui.json.d.ts +36 -1
  41. package/dist/locales/pt/ui.json.js +7 -1
  42. package/dist/locales/pt/ui.json.js.map +1 -1
  43. package/package.json +5 -1
@@ -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, already resized for transport. */
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, resizeImageFile } from "../../../helpers/imageFile.js";
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
- const ADJUSTED_IMAGE_MAX_DIMENSION = 1600;
7
- const ADJUSTED_IMAGE_QUALITY = 0.9;
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 adjustedFileName = (imageName) => {
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.jpg`;
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(() => setResultUrl(null), []);
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 baseFile = await fileFromUrl(imageUrl, "base-image.jpg", controller.signal);
56
+ const fetched = await fileFromUrl(imageUrl, "base-image", controller.signal);
45
57
  if (controller.signal.aborted) return;
46
- const baseImage = await resizeImageFile(baseFile);
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 response = await adjustImage({ baseImage, instructions: prompt, productId }, controller.signal);
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
- notify({ message: t("imageAiAdjust.errorGenerate"), type: "error" });
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
- return resizeImageFile(await fileFromUrl(resultUrl, adjustedFileName(imageName)), {
65
- maxDimension: ADJUSTED_IMAGE_MAX_DIMENSION,
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, resizeImageFile } from '@/helpers/imageFile';\nimport { uiLogger } from '@/helpers/uiLogger';\nimport { useNotification } from '@/hooks/useNotification';\n\nconst ADJUSTED_IMAGE_MAX_DIMENSION = 1600;\nconst ADJUSTED_IMAGE_QUALITY = 0.9;\n\nconst isAbortError = (error: unknown): boolean => error instanceof DOMException && error.name === 'AbortError';\n\nexport interface IImageAiAdjustRequest {\n /** The current image, already resized for transport. */\n baseImage: File;\n instructions: string;\n productId?: string;\n}\n\nexport interface IImageAiAdjustResult {\n imageUrl: string;\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}\n\nexport interface IUseImageAiAdjust {\n instructions: string;\n setInstructions: (value: string) => void;\n resultUrl: string | 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): string => {\n const base = (imageName ?? 'image').replace(/\\.[^.]+$/, '') || 'image';\n return `${base}-ia.jpg`;\n};\n\nexport const useImageAiAdjust = ({\n imageUrl,\n imageName,\n productId,\n adjustImage,\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 [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(() => setResultUrl(null), []);\n\n const generate = useCallback(async () => {\n const prompt = instructions.trim();\n if (!prompt || isGenerating) return;\n\n abortRef.current?.abort();\n const controller = new AbortController();\n abortRef.current = controller;\n setIsGenerating(true);\n\n try {\n const baseFile = await fileFromUrl(imageUrl, 'base-image.jpg', controller.signal);\n if (controller.signal.aborted) return;\n\n const baseImage = await resizeImageFile(baseFile);\n if (controller.signal.aborted) return;\n\n const response = await adjustImage({ baseImage, instructions: prompt, productId }, controller.signal);\n if (controller.signal.aborted) return;\n\n setResultUrl(response.imageUrl);\n } catch (error) {\n if (isAbortError(error)) return;\n uiLogger.error('Failed to adjust the image with AI', error);\n notify({ message: t('imageAiAdjust.errorGenerate'), type: 'error' });\n } finally {\n if (abortRef.current === controller) {\n abortRef.current = null;\n setIsGenerating(false);\n }\n }\n }, [instructions, isGenerating, imageUrl, productId, adjustImage, notify, t]);\n\n const buildResultFile = useCallback(async () => {\n if (!resultUrl) throw new Error('No adjusted image to save');\n\n return resizeImageFile(await fileFromUrl(resultUrl, adjustedFileName(imageName)), {\n maxDimension: ADJUSTED_IMAGE_MAX_DIMENSION,\n quality: ADJUSTED_IMAGE_QUALITY,\n });\n }, [resultUrl, imageName]);\n\n return {\n instructions,\n setInstructions,\n resultUrl,\n isGenerating,\n canGenerate: Boolean(instructions.trim()) && !isGenerating,\n generate,\n cancel,\n discardResult,\n buildResultFile,\n };\n};\n"],"names":[],"mappings":";;;;;AAOA,MAAM,+BAA+B;AACrC,MAAM,yBAAyB;AAE/B,MAAM,eAAe,CAAC,UAA4B,iBAAiB,gBAAgB,MAAM,SAAS;AAmClG,MAAM,mBAAmB,CAAC,cAA0C;AAClE,QAAM,QAAQ,aAAa,SAAS,QAAQ,YAAY,EAAE,KAAK;AAC/D,SAAO,GAAG,IAAI;AAChB;AAEO,MAAM,mBAAmB,CAAC;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAkD;AAChD,QAAM,EAAE,EAAA,IAAM,eAAe,IAAI;AACjC,QAAM,EAAE,OAAA,IAAW,gBAAA;AAEnB,QAAM,WAAW,OAA+B,IAAI;AACpD,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,EAAE;AACnD,QAAM,CAAC,WAAW,YAAY,IAAI,SAAwB,IAAI;AAC9D,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,KAAK;AAGtD;AAAA,IACE,MAAM,MAAM;AACV,eAAS,SAAS,MAAA;AAClB,eAAS,UAAU;AAAA,IACrB;AAAA,IACA,CAAA;AAAA,EAAC;AAGH,QAAM,SAAS,YAAY,MAAM;AAC/B,aAAS,SAAS,MAAA;AAAA,EACpB,GAAG,CAAA,CAAE;AAEL,QAAM,gBAAgB,YAAY,MAAM,aAAa,IAAI,GAAG,CAAA,CAAE;AAE9D,QAAM,WAAW,YAAY,YAAY;AACvC,UAAM,SAAS,aAAa,KAAA;AAC5B,QAAI,CAAC,UAAU,aAAc;AAE7B,aAAS,SAAS,MAAA;AAClB,UAAM,aAAa,IAAI,gBAAA;AACvB,aAAS,UAAU;AACnB,oBAAgB,IAAI;AAEpB,QAAI;AACF,YAAM,WAAW,MAAM,YAAY,UAAU,kBAAkB,WAAW,MAAM;AAChF,UAAI,WAAW,OAAO,QAAS;AAE/B,YAAM,YAAY,MAAM,gBAAgB,QAAQ;AAChD,UAAI,WAAW,OAAO,QAAS;AAE/B,YAAM,WAAW,MAAM,YAAY,EAAE,WAAW,cAAc,QAAQ,UAAA,GAAa,WAAW,MAAM;AACpG,UAAI,WAAW,OAAO,QAAS;AAE/B,mBAAa,SAAS,QAAQ;AAAA,IAChC,SAAS,OAAO;AACd,UAAI,aAAa,KAAK,EAAG;AACzB,eAAS,MAAM,sCAAsC,KAAK;AAC1D,aAAO,EAAE,SAAS,EAAE,6BAA6B,GAAG,MAAM,SAAS;AAAA,IACrE,UAAA;AACE,UAAI,SAAS,YAAY,YAAY;AACnC,iBAAS,UAAU;AACnB,wBAAgB,KAAK;AAAA,MACvB;AAAA,IACF;AAAA,EACF,GAAG,CAAC,cAAc,cAAc,UAAU,WAAW,aAAa,QAAQ,CAAC,CAAC;AAE5E,QAAM,kBAAkB,YAAY,YAAY;AAC9C,QAAI,CAAC,UAAW,OAAM,IAAI,MAAM,2BAA2B;AAE3D,WAAO,gBAAgB,MAAM,YAAY,WAAW,iBAAiB,SAAS,CAAC,GAAG;AAAA,MAChF,cAAc;AAAA,MACd,SAAS;AAAA,IAAA,CACV;AAAA,EACH,GAAG,CAAC,WAAW,SAAS,CAAC;AAEzB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,aAAa,QAAQ,aAAa,KAAA,CAAM,KAAK,CAAC;AAAA,IAC9C;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EAAA;AAEJ;"}
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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"}
@@ -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