676bd524ee
示例来自微信:  before: - firefox:  - edge:  after: - firefox:   - edge:  - 使用 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>
451 lines
16 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|