@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.
- 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/Form/ControlledSelect.js +11 -4
- package/dist/components/Form/ControlledSelect.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 @@
|
|
|
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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"}
|
|
@@ -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
|