Files
Genarrative/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx
T
k88936 676bd524ee
Project CI / Repository checks (push) Successful in 1m23s
Project CI / Frontend tests (push) Successful in 3m1s
Project CI / Backend tests (push) Successful in 3m51s
Project CI / Native shell tests (push) Successful in 13m32s
滑动条样式调整 (#149)
示例来自微信:
![shotmd-1786091231.jpg](/attachments/b59bae77-00ca-4669-8a54-a9786ae53c93)

before:
- firefox:
![shotmd-1786091298.jpg](/attachments/80bbd46e-551d-4b44-abe4-d3d0ecaa34dd)

- edge:
![shotmd-1786091062.jpg](/attachments/7918c523-1b5b-4439-926d-591e0029c5b9)

after:
- firefox:
![shotmd-1786110119.jpg](/attachments/31b22537-236b-4782-bc47-28ac5ecb0f9f)
![shotmd-1786110060.jpg](/attachments/b48d5343-fd07-451d-943a-55000600e248)

- edge:

![shotmd-1786110050.jpg](/attachments/cfd0d8e4-7d52-45f2-bab8-1b573b3a43b0)

- 使用 Lexical + OverlayScrollbars 实现统一可控的滑动条样式, 取代原来的textarea

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/149
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-08 15:10:16 +08:00

451 lines
16 KiB
TypeScript

import { Check, ChevronDown, Cpu } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type SetStateAction,
useCallback,
useRef,
useState,
} from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import {
countDialogExtraImageReferences,
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
getEditorImageModelDisplayName,
IMAGE_MODEL_NANOBANANA2,
normalizeEditorImageModel,
QUICK_EDIT_MODEL_OPTIONS,
resizeGenerationPlaceholderToImageSelection,
resolveDialogExtraImageReferenceLimit,
resolveEditorImageSizeLabel,
} from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasGenerationImageOptionsViewProps = {
dialog: GenerateDialogState;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
includeDimensions: boolean;
includeModel?: boolean;
hasPendingImageReferenceUploads?: boolean;
onRememberImageModel: (model: string) => void;
optionLabelPrefix?: string;
dimensionRatioAriaLabelPrefix?: string;
dimensionSizeAriaLabelPrefix?: string;
cost?: number;
submitLabel?: string;
submitAriaLabel?: string;
submitButtonClassName?: string;
onSubmitRequest: () => void;
lockedModel?: string;
styleControl?: ReactNode;
renderEditorPortal?: (node: ReactNode) => ReactNode;
buildPortalMenuStyle?: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
};
type OpenPanel = 'dimensions' | 'model' | null;
function resetFailedDialogStatus(dialog: GenerateDialogState) {
return {
...dialog,
status: dialog.status === 'failed' ? 'idle' : dialog.status,
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
};
}
function getImageDimensionOptions(model: string | null | undefined) {
return (
EDITOR_IMAGE_DIMENSION_OPTIONS[
(model ??
IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2]
);
}
function normalizeImageDialogSelection(dialog: GenerateDialogState) {
const model = normalizeEditorImageModel(
dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2,
);
const options = getImageDimensionOptions(model);
const aspectRatios = options.aspectRatios as readonly string[];
const imageSizes = options.imageSizes as readonly string[];
return {
model,
aspectRatio:
dialog.aspectRatio && aspectRatios.includes(dialog.aspectRatio)
? dialog.aspectRatio
: (options.aspectRatios[0] ?? '1:1'),
imageSize:
dialog.imageSize && imageSizes.includes(dialog.imageSize)
? dialog.imageSize
: (options.imageSizes.find((size) => size === '1K') ??
options.imageSizes[0] ??
'1K'),
options,
};
}
function OptionChoice({
children,
selected,
className,
ariaLabel,
onClick,
}: {
children: ReactNode;
selected: boolean;
className?: string;
ariaLabel?: string;
onClick: () => void;
}) {
return (
<button
type="button"
className={['image-canvas-editor__option-popover-choice', className]
.filter(Boolean)
.join(' ')}
aria-label={ariaLabel}
aria-pressed={selected}
onClick={onClick}
>
{children}
</button>
);
}
export function ImageCanvasGenerationImageOptionsView({
dialog,
setGenerateDialog,
includeDimensions,
includeModel = true,
hasPendingImageReferenceUploads = false,
onRememberImageModel,
optionLabelPrefix,
dimensionRatioAriaLabelPrefix,
dimensionSizeAriaLabelPrefix,
cost,
submitLabel = '生成',
submitAriaLabel = '生成',
submitButtonClassName = 'image-canvas-editor__generation-submit',
onSubmitRequest,
lockedModel,
styleControl,
renderEditorPortal = (node) => node,
buildPortalMenuStyle = () => ({}),
}: ImageCanvasGenerationImageOptionsViewProps) {
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
const isQuickEdit = dialog.mode === 'quick-edit';
const supportsImageStyle =
dialog.mode === 'generate' ||
dialog.mode === 'character' ||
dialog.mode === 'icon';
const normalizedLockedModel = lockedModel
? normalizeEditorImageModel(lockedModel)
: null;
const selection = normalizeImageDialogSelection(
normalizedLockedModel
? { ...dialog, imageModel: normalizedLockedModel }
: dialog,
);
const modelOptions = isQuickEdit
? [...QUICK_EDIT_MODEL_OPTIONS]
: [...EDITOR_IMAGE_MODEL_OPTIONS];
const selectedModel = modelOptions.some(
(option) => option.value === selection.model,
)
? selection.model
: (modelOptions[0]?.value ?? selection.model);
const selectedModelLabel =
modelOptions.find((option) => option.value === selectedModel)?.label ??
getEditorImageModelDisplayName(selectedModel);
const selectedSizeLabel = resolveEditorImageSizeLabel({
aspectRatio: selection.aspectRatio,
imageSize: selection.imageSize,
});
const isGenerating = dialog.status === 'generating';
const isModelLocked = Boolean(normalizedLockedModel) && includeModel;
const labelPrefix = optionLabelPrefix ?? '生成图片';
const ratioAriaLabelPrefix = dimensionRatioAriaLabelPrefix?.trim() ?? '';
const sizeAriaLabelPrefix = dimensionSizeAriaLabelPrefix?.trim() ?? '';
const dismissOpenPanel = useCallback(() => setOpenPanel(null), []);
const formatDimensionOptionLabel = (prefix: string, value: string) =>
prefix ? `${prefix} ${value}` : value;
useImageCanvasFloatingOptionDismiss({
isOpen: openPanel !== null,
boundaryRefs: [dimensionsButtonRef, modelButtonRef],
onDismiss: dismissOpenPanel,
});
const updateDialog = (patch: Partial<GenerateDialogState>) => {
setGenerateDialog((currentDialog) =>
currentDialog && currentDialog.mode === dialog.mode
? resizeGenerationPlaceholderToImageSelection({
...resetFailedDialogStatus(currentDialog),
...patch,
})
: currentDialog,
);
};
const updateImageModel = (model: string) => {
if (hasPendingImageReferenceUploads) {
window.alert('参考图正在上传,请等待上传完成后再切换模型');
return;
}
const referenceLimit = resolveDialogExtraImageReferenceLimit(dialog, model);
const referenceCount = countDialogExtraImageReferences(dialog);
if (referenceCount > referenceLimit) {
window.alert(
`当前已有 ${referenceCount} 张参考图,${getEditorImageModelDisplayName(model)} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`,
);
return;
}
setGenerateDialog((currentDialog) => {
if (!currentDialog || currentDialog.mode !== dialog.mode) {
return currentDialog;
}
if (!includeDimensions) {
return {
...resetFailedDialogStatus(currentDialog),
imageModel: model,
};
}
const nextOptions = getImageDimensionOptions(model);
const nextAspectRatios = nextOptions.aspectRatios as readonly string[];
const nextImageSizes = nextOptions.imageSizes as readonly string[];
const nextDialog = {
...resetFailedDialogStatus(currentDialog),
imageModel: model,
aspectRatio:
currentDialog.aspectRatio &&
nextAspectRatios.includes(currentDialog.aspectRatio)
? currentDialog.aspectRatio
: (nextOptions.aspectRatios[0] ?? '1:1'),
imageSize:
currentDialog.imageSize &&
nextImageSizes.includes(currentDialog.imageSize)
? currentDialog.imageSize
: (nextOptions.imageSizes.find((size) => size === '1K') ??
nextOptions.imageSizes[0] ??
'1K'),
};
return resizeGenerationPlaceholderToImageSelection(nextDialog);
});
onRememberImageModel(model);
};
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
setOpenPanel((currentPanel) => (currentPanel === panel ? null : panel));
};
return (
<>
{includeDimensions ? (
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={dimensionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`${labelPrefix}尺寸 ${selectedSizeLabel}`}
aria-expanded={openPanel === 'dimensions'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => togglePanel('dimensions')}
>
{selectedSizeLabel}
</PlatformInlineOptionButton>
{openPanel === 'dimensions'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
label={`${labelPrefix}尺寸选项`}
placement="top-start"
style={buildPortalMenuStyle(
dimensionsButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-sections">
<div className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
比例
</span>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
{selection.options.aspectRatios.map((aspectRatio) => (
<OptionChoice
key={aspectRatio}
selected={selection.aspectRatio === aspectRatio}
className="image-canvas-editor__option-popover-choice--ratio"
ariaLabel={formatDimensionOptionLabel(
ratioAriaLabelPrefix,
aspectRatio,
)}
onClick={() => updateDialog({ aspectRatio })}
>
<span
className="image-canvas-editor__ratio-wireframe"
data-ratio={aspectRatio}
aria-hidden="true"
/>
<span>{aspectRatio}</span>
</OptionChoice>
))}
</div>
</div>
<div className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
尺寸
</span>
<div className="image-canvas-editor__option-popover-items">
{selection.options.imageSizes.map((imageSize) => (
<OptionChoice
key={imageSize}
selected={selection.imageSize === imageSize}
ariaLabel={formatDimensionOptionLabel(
sizeAriaLabelPrefix,
imageSize,
)}
onClick={() => updateDialog({ imageSize })}
>
{imageSize}
</OptionChoice>
))}
</div>
</div>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : null}
{styleControl ??
(supportsImageStyle ? (
<label className="image-canvas-editor__image-style-toggle">
<input
type="checkbox"
checked={dialog.style === 'pixelArt'}
disabled={isGenerating}
onChange={(event) =>
updateDialog({
style: event.target.checked ? 'pixelArt' : 'none',
})
}
/>
<span>像素艺术</span>
</label>
) : null)}
{includeModel ? (
<div
className={[
'image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model',
isModelLocked
? 'image-canvas-editor__readonly-generation-option'
: '',
]
.filter(Boolean)
.join(' ')}
>
<PlatformInlineOptionButton
ref={modelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`${labelPrefix}模型 ${selectedModelLabel}`}
aria-expanded={isModelLocked ? undefined : openPanel === 'model'}
disabled={isGenerating || isModelLocked}
trailingIcon={
isModelLocked ? undefined : <ChevronDown className="h-3 w-3" />
}
onClick={() => {
if (!isModelLocked) {
togglePanel('model');
}
}}
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{selectedModelLabel}</span>
</span>
</PlatformInlineOptionButton>
{!isModelLocked && openPanel === 'model'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model image-canvas-editor__portal-menu"
label={`${labelPrefix}模型选项`}
placement="top-start"
style={buildPortalMenuStyle(modelButtonRef.current, 'above')}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
{modelOptions.map((option) => {
const selected = selectedModel === option.value;
return (
<OptionChoice
key={option.value}
selected={selected}
className="image-canvas-editor__option-popover-choice--model"
onClick={() => updateImageModel(option.value)}
>
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{option.label}</span>
<Check
className="image-canvas-editor__option-selected-check"
data-visible={selected}
aria-hidden="true"
/>
</OptionChoice>
);
})}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : null}
{typeof cost === 'number' ? (
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className={submitButtonClassName}
disabled={isGenerating || hasPendingImageReferenceUploads}
aria-label={submitAriaLabel}
onClick={onSubmitRequest}
>
{isGenerating ? (
'生成中'
) : (
<>
<span>{submitLabel}</span>
<span className="image-canvas-editor__mud-point-inline">
{cost}泥点
</span>
</>
)}
</PlatformActionButton>
) : null}
</>
);
}