全新popiart网站 react项目重构
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

619 lines
26 KiB

import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next";
import { Message, Modal } from "@arco-design/web-react";
import { createGenTask, getAiModelList } from "@/api/api";
import { MY_CREATIONS_REFRESH_QUERY, ROUTES } from "@/constants";
import { ensureSufficientPoints } from "@/layouts/globalPromptPanel/composerCommon";
import type { AiModelItem } from "@/layouts/globalPromptPanel/types";
import { buildGenTaskModelPayload, normalizeAiModelItem } from "@/layouts/globalPromptPanel/utils";
import { useGenerateCost } from "@/layouts/globalPromptPanel/useGenerateCost";
import { useUserStore } from "@/store/useUserStore";
import "./index.scss";
const EXPAND_IMAGE_SCALE_OPTIONS = ["1.5x", "2x", "3x"] as const;
type ExpandImageScale = (typeof EXPAND_IMAGE_SCALE_OPTIONS)[number];
const EXPAND_IMAGE_RATIO_OPTIONS = [
{ value: "original", iconClassName: "assetDetailsExpandImageModal__ratioIcon_original" },
{ value: "1:1", iconClassName: "assetDetailsExpandImageModal__ratioIcon_1x1" },
{ value: "3:4", iconClassName: "assetDetailsExpandImageModal__ratioIcon_3x4" },
{ value: "4:3", iconClassName: "assetDetailsExpandImageModal__ratioIcon_4x3" },
{ value: "16:9", iconClassName: "assetDetailsExpandImageModal__ratioIcon_16x9" },
] as const;
type ExpandImageRatio = (typeof EXPAND_IMAGE_RATIO_OPTIONS)[number]["value"];
const EXPAND_IMAGE_TASK_TYPE = 1;
const EXPAND_IMAGE_SUB_TYPE = 112;
const EXPAND_IMAGE_RESOLUTION = "1K";
const EXPAND_IMAGE_BATCH_SIZE = 1;
const EXPAND_IMAGE_COST_PARAM_VALUES: Record<string, string | number> = { batchSize: EXPAND_IMAGE_BATCH_SIZE };
const EXPAND_IMAGE_PREVIEW_HEIGHT = 369;
const EXPAND_IMAGE_ORIGINAL_IMAGE_MAX_SIZE = { width: 140, height: 122 };
const EXPAND_IMAGE_ORIGINAL_FRAME_TOP = 66;
const EXPAND_IMAGE_FIXED_RATIO_MAX_SIZE = { width: 560, height: 315 };
const EXPAND_IMAGE_MAX_DIRECTION_RATIO = 0.4;
const EXPAND_IMAGE_MAX_AXIS_RATIO = EXPAND_IMAGE_MAX_DIRECTION_RATIO * 2;
const EXPAND_IMAGE_RATIO_EPSILON = 0.0001;
const EXPAND_IMAGE_SCALE_MULTIPLIERS: Record<ExpandImageScale, number> = {
"1.5x": 1.5,
"2x": 2,
"3x": 3,
};
const EXPAND_IMAGE_RATIO_ASPECT_MAP: Record<Exclude<ExpandImageRatio, "original">, number> = {
"1:1": 1,
"3:4": 3 / 4,
"4:3": 4 / 3,
"16:9": 16 / 9,
};
interface ExpandImageSize {
width: number;
height: number;
}
interface ExpandImagePosition {
x: number;
y: number;
}
interface ExpandImageInsets {
top: number;
left: number;
right: number;
bottom: number;
}
interface ExpandImagePreviewLayout {
frameStyle: CSSProperties;
imageStyle: CSSProperties;
imageSize: ExpandImageSize;
maxOffsetX: number;
maxOffsetY: number;
widthMultiplier: number;
heightMultiplier: number;
}
interface ExpandImageDragState {
pointerId: number;
startClientX: number;
startClientY: number;
startPosition: ExpandImagePosition;
maxOffsetX: number;
maxOffsetY: number;
}
export interface AssetExpandImageModalProps {
visible: boolean;
imageUrl: string;
originalRatio?: string;
onClose: () => void;
}
function formatExpandImageMultiplier(value: number) {
const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}
function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function clampExpandImageInsetRatio(value: number) {
if (!Number.isFinite(value)) return 0;
return clampNumber(Math.round(value * 10000) / 10000, 0, EXPAND_IMAGE_MAX_DIRECTION_RATIO);
}
function fitExpandImageSize(aspectRatio: number, maxSize: ExpandImageSize): ExpandImageSize {
if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) {
return maxSize;
}
const maxAspectRatio = maxSize.width / maxSize.height;
if (aspectRatio >= maxAspectRatio) {
return {
width: maxSize.width,
height: Math.round(maxSize.width / aspectRatio),
};
}
return {
width: Math.round(maxSize.height * aspectRatio),
height: maxSize.height,
};
}
function getExpandImageAxisExpandRatio(maxOffset: number, imageSide: number) {
if (maxOffset <= 0 || imageSide <= 0) {
return 0;
}
return maxOffset / imageSide;
}
function isExpandImageAxisWithinLimit(maxOffset: number, imageSide: number) {
return getExpandImageAxisExpandRatio(maxOffset, imageSide) <= EXPAND_IMAGE_MAX_AXIS_RATIO + EXPAND_IMAGE_RATIO_EPSILON;
}
function isExpandImageLayoutWithinLimit(layout: ExpandImagePreviewLayout) {
return isExpandImageAxisWithinLimit(layout.maxOffsetX, layout.imageSize.width)
&& isExpandImageAxisWithinLimit(layout.maxOffsetY, layout.imageSize.height);
}
function getExpandImageAxisPositionBounds(maxOffset: number, imageSide: number) {
const totalExpandRatio = getExpandImageAxisExpandRatio(maxOffset, imageSide);
if (totalExpandRatio <= 0) {
return { min: 0.5, max: 0.5 };
}
if (totalExpandRatio > EXPAND_IMAGE_MAX_AXIS_RATIO + EXPAND_IMAGE_RATIO_EPSILON) {
return { min: 0.5, max: 0.5 };
}
const maxPosition = Math.min(1, EXPAND_IMAGE_MAX_DIRECTION_RATIO / totalExpandRatio);
const minPosition = Math.max(0, 1 - maxPosition);
return { min: minPosition, max: maxPosition };
}
function clampExpandImagePositionForLayout(position: ExpandImagePosition, layout: ExpandImagePreviewLayout): ExpandImagePosition {
const xBounds = getExpandImageAxisPositionBounds(layout.maxOffsetX, layout.imageSize.width);
const yBounds = getExpandImageAxisPositionBounds(layout.maxOffsetY, layout.imageSize.height);
return {
x: clampNumber(position.x, xBounds.min, xBounds.max),
y: clampNumber(position.y, yBounds.min, yBounds.max),
};
}
/**
* 后端 extraTaskParams 要的是原图四边外扩比例。
* 这里用“图片在目标框内的位置”反推 top/left/right/bottom,拖到边缘时对应边就是 0。
*/
function getExpandImageExtraTaskParams(layout: ExpandImagePreviewLayout, position: ExpandImagePosition): ExpandImageInsets {
const clampedPosition = clampExpandImagePositionForLayout(position, layout);
const left = layout.maxOffsetX * clampedPosition.x;
const top = layout.maxOffsetY * clampedPosition.y;
return {
top: clampExpandImageInsetRatio(top / layout.imageSize.height),
left: clampExpandImageInsetRatio(left / layout.imageSize.width),
right: clampExpandImageInsetRatio((layout.maxOffsetX - left) / layout.imageSize.width),
bottom: clampExpandImageInsetRatio((layout.maxOffsetY - top) / layout.imageSize.height),
};
}
function getGreatestCommonDivisor(a: number, b: number): number {
let nextA = Math.abs(Math.round(a));
let nextB = Math.abs(Math.round(b));
while (nextB > 0) {
const remainder = nextA % nextB;
nextA = nextB;
nextB = remainder;
}
return nextA || 1;
}
function getExpandImageNaturalRatioLabel(imageNaturalSize: ExpandImageSize | null) {
if (imageNaturalSize == null) return "1:1";
const divisor = getGreatestCommonDivisor(imageNaturalSize.width, imageNaturalSize.height);
return `${String(Math.round(imageNaturalSize.width / divisor))}:${String(Math.round(imageNaturalSize.height / divisor))}`;
}
function getExpandImageTaskRatio(ratio: ExpandImageRatio, originalRatio: string, imageNaturalSize: ExpandImageSize | null) {
if (ratio !== "original") return ratio;
const trimmedOriginalRatio = originalRatio.trim();
return trimmedOriginalRatio || getExpandImageNaturalRatioLabel(imageNaturalSize);
}
function buildExpandImageModelPayload(model: AiModelItem | null): Record<string, unknown> {
const payload = buildGenTaskModelPayload(model);
const platform = (model?.platform ?? "GATEWAY").trim();
if (platform) {
payload.aiPlatform = platform;
}
return payload;
}
function buildExpandImageTaskPayload(params: {
model: AiModelItem | null;
image: string;
ratio: string;
chatPrompt: string;
extraTaskParams?: ExpandImageInsets;
}): Record<string, unknown> | null {
const image = params.image.trim();
const code = (params.model?.code ?? "").trim();
const modelId = params.model?.id;
if (!image || !code || modelId == null || String(modelId).trim() === "") return null;
const modelPayload = buildExpandImageModelPayload(params.model);
if (Object.keys(modelPayload).length === 0) return null;
return {
type: EXPAND_IMAGE_TASK_TYPE,
subType: EXPAND_IMAGE_SUB_TYPE,
...modelPayload,
ratio: params.ratio,
resolution: EXPAND_IMAGE_RESOLUTION,
batchSize: EXPAND_IMAGE_BATCH_SIZE,
chatPrompt: params.chatPrompt,
images: [image],
...(params.extraTaskParams ? { extraTaskParams: params.extraTaskParams } : {}),
};
}
/**
* 预览框只负责展示“最终画布”和“原图可拖动位置”。
* 原比例走倍率放大;固定比例走目标比例框,按短边不动来推导需要扩展的长边。
*/
function getExpandImagePreviewLayout(ratio: ExpandImageRatio, scale: ExpandImageScale, imageNaturalSize: ExpandImageSize | null): ExpandImagePreviewLayout {
const naturalWidth = Math.max(1, imageNaturalSize?.width ?? 82);
const naturalHeight = Math.max(1, imageNaturalSize?.height ?? 122);
const naturalAspectRatio = naturalWidth / naturalHeight;
if (ratio === "original") {
const multiplier = EXPAND_IMAGE_SCALE_MULTIPLIERS[scale];
const imageSize = fitExpandImageSize(naturalAspectRatio, EXPAND_IMAGE_ORIGINAL_IMAGE_MAX_SIZE);
const width = Math.round(imageSize.width * multiplier);
const height = Math.round(imageSize.height * multiplier);
const top = Math.max(2, Math.round(EXPAND_IMAGE_ORIGINAL_FRAME_TOP - (height - EXPAND_IMAGE_ORIGINAL_IMAGE_MAX_SIZE.height * 1.5) / 2));
return {
frameStyle: { width, height, top },
imageStyle: imageSize,
imageSize,
maxOffsetX: Math.max(0, width - imageSize.width),
maxOffsetY: Math.max(0, height - imageSize.height),
widthMultiplier: multiplier,
heightMultiplier: multiplier,
};
}
const targetAspectRatio = EXPAND_IMAGE_RATIO_ASPECT_MAP[ratio];
let widthMultiplier = 1;
let heightMultiplier = 1;
if (targetAspectRatio > naturalAspectRatio) {
widthMultiplier = targetAspectRatio / naturalAspectRatio;
} else {
heightMultiplier = naturalAspectRatio / targetAspectRatio;
}
const frameSize = fitExpandImageSize(targetAspectRatio, EXPAND_IMAGE_FIXED_RATIO_MAX_SIZE);
const displayImageWidth = frameSize.width / widthMultiplier;
const displayImageHeight = frameSize.height / heightMultiplier;
return {
frameStyle: {
width: Math.round(frameSize.width),
height: Math.round(frameSize.height),
top: Math.round((EXPAND_IMAGE_PREVIEW_HEIGHT - frameSize.height) / 2),
},
imageStyle: {
width: Math.round(displayImageWidth),
height: Math.round(displayImageHeight),
},
imageSize: {
width: Math.round(displayImageWidth),
height: Math.round(displayImageHeight),
},
maxOffsetX: Math.max(0, Math.round(frameSize.width - displayImageWidth)),
maxOffsetY: Math.max(0, Math.round(frameSize.height - displayImageHeight)),
widthMultiplier,
heightMultiplier,
};
}
function isExpandImageScaleAvailable(scale: ExpandImageScale, imageNaturalSize: ExpandImageSize | null) {
return isExpandImageLayoutWithinLimit(getExpandImagePreviewLayout("original", scale, imageNaturalSize));
}
function isExpandImageRatioAvailable(ratio: ExpandImageRatio, imageNaturalSize: ExpandImageSize | null) {
if (ratio === "original") return true;
if (imageNaturalSize == null) return false;
return isExpandImageLayoutWithinLimit(getExpandImagePreviewLayout(ratio, "1.5x", imageNaturalSize));
}
export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", onClose }: AssetExpandImageModalProps) {
const { t } = useTranslation();
const setUser = useUserStore((s) => s.setUser);
const createTaskInFlightRef = useRef(false);
const dragStateRef = useRef<ExpandImageDragState | null>(null);
const [scale, setScale] = useState<ExpandImageScale>("1.5x");
const [ratio, setRatio] = useState<ExpandImageRatio>("original");
const [prompt, setPrompt] = useState("");
const [imageNaturalSize, setImageNaturalSize] = useState<ExpandImageSize | null>(null);
const [imagePosition, setImagePosition] = useState<ExpandImagePosition>({ x: 0.5, y: 0.5 });
const [isDraggingImage, setIsDraggingImage] = useState(false);
const [creating, setCreating] = useState(false);
const [activeModel, setActiveModel] = useState<AiModelItem | null>(null);
const [modelsLoading, setModelsLoading] = useState(false);
const isOriginalRatio = ratio === "original";
const previewLayout = useMemo(() => getExpandImagePreviewLayout(ratio, scale, imageNaturalSize), [imageNaturalSize, ratio, scale]);
const selectedLayoutAvailable = isExpandImageLayoutWithinLimit(previewLayout);
const boundedImagePosition = useMemo(() => clampExpandImagePositionForLayout(imagePosition, previewLayout), [imagePosition, previewLayout]);
const positionedImageStyle = useMemo<CSSProperties>(() => ({
...previewLayout.imageStyle,
left: Math.round(previewLayout.maxOffsetX * boundedImagePosition.x),
top: Math.round(previewLayout.maxOffsetY * boundedImagePosition.y),
}), [boundedImagePosition, previewLayout]);
const canDragImage = previewLayout.maxOffsetX > 0 || previewLayout.maxOffsetY > 0;
const taskRatio = useMemo(() => getExpandImageTaskRatio(ratio, originalRatio, imageNaturalSize), [imageNaturalSize, originalRatio, ratio]);
const resolvedPrompt = useMemo(() => prompt.trim() || t("pages.assetDetails.expandImage.defaultPrompt"), [prompt, t]);
const costPayload = useMemo(
() => visible ? buildExpandImageTaskPayload({
model: activeModel,
image: imageUrl,
ratio: taskRatio,
chatPrompt: resolvedPrompt,
}) ?? undefined : undefined,
[activeModel, imageUrl, resolvedPrompt, taskRatio, visible],
);
const costResult = useGenerateCost({
activeModelItem: visible ? activeModel : null,
selectedParamValues: EXPAND_IMAGE_COST_PARAM_VALUES,
taskPayload: costPayload,
activeTaskSubType: EXPAND_IMAGE_SUB_TYPE,
chatPrompt: resolvedPrompt,
});
const createCost = costResult.cost;
const modelReady = !modelsLoading && activeModel != null && costPayload != null;
const canCreateExpandImage = modelReady && imageNaturalSize != null && selectedLayoutAvailable;
const scaleHintText = isOriginalRatio
? scale
: t("pages.assetDetails.expandImage.sizeHint", {
width: formatExpandImageMultiplier(previewLayout.widthMultiplier),
height: formatExpandImageMultiplier(previewLayout.heightMultiplier),
});
const resetImageDragState = () => {
setImagePosition({ x: 0.5, y: 0.5 });
dragStateRef.current = null;
setIsDraggingImage(false);
};
const handleScaleChange = (nextScale: ExpandImageScale) => {
if (nextScale === scale) return;
if (!isExpandImageScaleAvailable(nextScale, imageNaturalSize)) return;
resetImageDragState();
setScale(nextScale);
};
const handleRatioChange = (nextRatio: ExpandImageRatio) => {
if (nextRatio === ratio) return;
if (!isExpandImageRatioAvailable(nextRatio, imageNaturalSize)) return;
resetImageDragState();
setRatio(nextRatio);
};
useEffect(() => {
setScale("1.5x");
setRatio("original");
setPrompt("");
setImageNaturalSize(null);
resetImageDragState();
}, [imageUrl]);
useEffect(() => {
setImagePosition({ x: 0.5, y: 0.5 });
dragStateRef.current = null;
setIsDraggingImage(false);
}, [imageUrl, ratio, scale]);
useEffect(() => {
if (!visible) return;
let cancelled = false;
setActiveModel(null);
setModelsLoading(true);
// 模型列表属于扩图弹窗内部字段,父级不需要知道具体模型结构。
void getAiModelList({ subType: EXPAND_IMAGE_SUB_TYPE })
.then((res) => {
if (cancelled) return;
const list = ((res as { data?: unknown[] }).data ?? []).map((item) => normalizeAiModelItem(item as AiModelItem));
setActiveModel(list[0] ?? null);
})
.catch(() => {
if (!cancelled) {
setActiveModel(null);
}
})
.finally(() => {
if (!cancelled) setModelsLoading(false);
});
return () => {
cancelled = true;
};
}, [visible]);
const handleImagePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!canDragImage || creating || !selectedLayoutAvailable) return;
event.preventDefault();
const clampedPosition = clampExpandImagePositionForLayout(imagePosition, previewLayout);
dragStateRef.current = {
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startPosition: clampedPosition,
maxOffsetX: previewLayout.maxOffsetX,
maxOffsetY: previewLayout.maxOffsetY,
};
setImagePosition(clampedPosition);
setIsDraggingImage(true);
event.currentTarget.setPointerCapture(event.pointerId);
};
const handleImagePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (dragState == null || dragState.pointerId !== event.pointerId) return;
event.preventDefault();
const nextPosition = {
x: dragState.maxOffsetX > 0
? (dragState.startPosition.x * dragState.maxOffsetX + event.clientX - dragState.startClientX) / dragState.maxOffsetX
: 0.5,
y: dragState.maxOffsetY > 0
? (dragState.startPosition.y * dragState.maxOffsetY + event.clientY - dragState.startClientY) / dragState.maxOffsetY
: 0.5,
};
setImagePosition(clampExpandImagePositionForLayout(nextPosition, previewLayout));
};
const finishImageDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (dragState == null || dragState.pointerId !== event.pointerId) return;
dragStateRef.current = null;
setIsDraggingImage(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const handleCreate = async () => {
if (createTaskInFlightRef.current || creating || !canCreateExpandImage) return;
const payload = buildExpandImageTaskPayload({
model: activeModel,
image: imageUrl,
ratio: taskRatio,
chatPrompt: resolvedPrompt,
extraTaskParams: getExpandImageExtraTaskParams(previewLayout, boundedImagePosition),
});
if (payload == null) {
Message.error(t("pages.assetDetails.expandImage.modelRequired"));
return;
}
createTaskInFlightRef.current = true;
setCreating(true);
try {
if (!(await ensureSufficientPoints(createCost))) return;
Message.success(t("composer.taskCreating"));
await createGenTask(payload);
onClose();
void setUser();
const href = `${ROUTES.CREATE_MY_CREATIONS}?type=1&${MY_CREATIONS_REFRESH_QUERY}=${String(Date.now())}`;
globalThis.location.assign(href);
return;
} catch {
/* request interceptor shows the error. */
} finally {
createTaskInFlightRef.current = false;
setCreating(false);
}
};
if (!visible) return null;
return (
<Modal visible={visible} title={null} footer={null} closable={false} maskClosable escToExit onCancel={onClose} alignCenter unmountOnExit className="assetDetailsExpandImageModal" style={{ width: 838, padding: 0 }}>
<div className="assetDetailsExpandImageModal__content">
<button type="button" className="assetDetailsExpandImageModal__close" onClick={onClose} aria-label={t("button.cancel")}>
<img src={new URL("/src/assets/images/ic_close.png", import.meta.url).href} alt="" />
</button>
<h3 className="assetDetailsExpandImageModal__title">{t("pages.assetDetails.expandImage.title")}</h3>
<div className="assetDetailsExpandImageModal__preview">
<span className="assetDetailsExpandImageModal__scaleHint">{scaleHintText}</span>
<div className="assetDetailsExpandImageModal__selection" style={previewLayout.frameStyle} aria-hidden="true">
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_topLeft" />
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_topCenter" />
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_topRight" />
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_rightCenter" />
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_bottomRight" />
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_bottomCenter" />
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_bottomLeft" />
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_leftCenter" />
<div
className={[
"assetDetailsExpandImageModal__selectionImageWrap",
canDragImage ? "assetDetailsExpandImageModal__selectionImageWrap_draggable" : "",
isDraggingImage ? "assetDetailsExpandImageModal__selectionImageWrap_dragging" : "",
].filter(Boolean).join(" ")}
style={positionedImageStyle}
onPointerDown={handleImagePointerDown}
onPointerMove={handleImagePointerMove}
onPointerUp={finishImageDrag}
onPointerCancel={finishImageDrag}
>
<img
className="assetDetailsExpandImageModal__selectionImage"
src={imageUrl}
alt=""
onLoad={(event) => {
const image = event.currentTarget;
setImageNaturalSize({
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
});
}}
/>
</div>
</div>
{isOriginalRatio ? (
<div className="assetDetailsExpandImageModal__scaleControl" role="radiogroup" aria-label={t("pages.assetDetails.expandImage.scaleAria")}>
{EXPAND_IMAGE_SCALE_OPTIONS.map((scaleOption) => (
<button
key={scaleOption}
type="button"
className={[
"assetDetailsExpandImageModal__scaleButton",
scaleOption === scale ? "assetDetailsExpandImageModal__scaleButton_active" : "",
].filter(Boolean).join(" ")}
role="radio"
aria-checked={scaleOption === scale}
aria-label={t("pages.assetDetails.expandImage.scaleOptionAria", { scale: scaleOption })}
disabled={!isExpandImageScaleAvailable(scaleOption, imageNaturalSize)}
onClick={() => {
handleScaleChange(scaleOption);
}}
>
{scaleOption}
</button>
))}
</div>
) : null}
</div>
<div className="assetDetailsExpandImageModal__ratioBar" role="radiogroup" aria-label={t("pages.assetDetails.expandImage.ratioAria")}>
{EXPAND_IMAGE_RATIO_OPTIONS.map((ratioOption) => {
const ratioLabel = ratioOption.value === "original" ? t("pages.assetDetails.expandImage.ratioOriginal") : ratioOption.value;
const ratioDisabled = !isExpandImageRatioAvailable(ratioOption.value, imageNaturalSize);
return (
<button
key={ratioOption.value}
type="button"
className={[
"assetDetailsExpandImageModal__ratioButton",
ratioOption.value === ratio ? "assetDetailsExpandImageModal__ratioButton_active" : "",
].filter(Boolean).join(" ")}
role="radio"
aria-checked={ratioOption.value === ratio}
aria-label={t("pages.assetDetails.expandImage.ratioOptionAria", { ratio: ratioLabel })}
disabled={ratioDisabled}
onClick={() => {
handleRatioChange(ratioOption.value);
}}
>
<span className={["assetDetailsExpandImageModal__ratioIcon", ratioOption.iconClassName].join(" ")} aria-hidden="true" />
<span className="assetDetailsExpandImageModal__ratioLabel">{ratioLabel}</span>
</button>
);
})}
</div>
<div className="assetDetailsExpandImageModal__promptBlock">
<input
className="assetDetailsExpandImageModal__promptInput"
value={prompt}
placeholder={t("pages.assetDetails.expandImage.promptPlaceholder")}
onChange={(event) => {
setPrompt(event.target.value);
}}
aria-label={t("pages.assetDetails.expandImage.promptAria")}
/>
<button
type="button"
className="assetDetailsExpandImageModal__createButton"
disabled={creating || !canCreateExpandImage}
aria-busy={creating}
aria-label={t("pages.assetDetails.expandImage.createAria", { credits: createCost })}
onClick={() => { void handleCreate(); }}
>
<img className="assetDetailsExpandImageModal__createButtonIcon" src={new URL("/src/assets/images/ic_stars_white.png", import.meta.url).href} alt="" />
<span className="assetDetailsExpandImageModal__createButtonText">{createCost}</span>
</button>
</div>
</div>
</Modal>
);
}