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>
428 lines
15 KiB
TypeScript
428 lines
15 KiB
TypeScript
import { ImageIcon } from 'lucide-react';
|
|
import {
|
|
type CSSProperties,
|
|
type Dispatch,
|
|
type ReactNode,
|
|
type RefObject,
|
|
type SetStateAction,
|
|
} from 'react';
|
|
|
|
import { CUSTOM_EDITOR_SCENE_STYLE_PRESET } from '../../../packages/shared/src/contracts/editorScene';
|
|
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
|
import {
|
|
PlatformFloatingMenu,
|
|
PlatformFloatingMenuItem,
|
|
} from '../common/PlatformFloatingMenu';
|
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
|
import type {
|
|
CharacterReferenceImage,
|
|
GenerateDialogState,
|
|
UploadTarget,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
|
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
|
|
import {
|
|
EDITOR_SCENE_CONTENT_REQUIRED_ERROR,
|
|
EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR,
|
|
} from './ImageCanvasGenerationSubmissionModel';
|
|
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
|
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
|
|
|
const EDITOR_SCENE_CONTENT_ERROR_ID = 'editor-scene-content-error';
|
|
const EDITOR_SCENE_CUSTOM_STYLE_ERROR_ID = 'editor-scene-custom-style-error';
|
|
|
|
type ReferenceLabelFormatter = (
|
|
reference: CharacterReferenceImage,
|
|
index: number,
|
|
) => string;
|
|
|
|
type ReferenceTone =
|
|
| 'default'
|
|
| 'spec'
|
|
| 'icon'
|
|
| 'ui'
|
|
| 'quick-edit'
|
|
| 'video'
|
|
| 'audio';
|
|
|
|
type ImageCanvasBasicGenerationComposerViewProps = {
|
|
dialog: GenerateDialogState;
|
|
style: CSSProperties;
|
|
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
|
generationReferenceButtonRef?: RefObject<HTMLButtonElement | null>;
|
|
isGenerationReferenceMenuOpen?: boolean;
|
|
setIsGenerationReferenceMenuOpen?: Dispatch<SetStateAction<boolean>>;
|
|
setIsPickingGenerationReferenceFromCanvas?: Dispatch<SetStateAction<boolean>>;
|
|
renderEditorPortal?: (node: ReactNode) => ReactNode;
|
|
buildPortalMenuStyle?: (
|
|
anchor: HTMLElement | null,
|
|
placement: 'above' | 'below',
|
|
) => CSSProperties;
|
|
onRequestUpload: (target: UploadTarget) => void;
|
|
onPickReferenceFromCanvas?: () => void;
|
|
onToggleReferenceMenu?: () => void;
|
|
onRememberImageModel?: (model: string) => void;
|
|
hasPendingImageReferenceUploads?: boolean;
|
|
onSubmit: (dialog: GenerateDialogState) => void;
|
|
dialogLabel?: string;
|
|
includeDimensions?: boolean;
|
|
includeModel?: boolean;
|
|
includeReferences?: boolean;
|
|
promptLabel?: string;
|
|
promptPlaceholder?: string;
|
|
optionLabelPrefix?: string;
|
|
dimensionRatioAriaLabelPrefix?: string;
|
|
dimensionSizeAriaLabelPrefix?: string;
|
|
referenceUploadTarget?: UploadTarget;
|
|
referenceButtonIcon?: ReactNode;
|
|
referenceButtonLabel?: string;
|
|
referenceButtonAriaLabel?: string;
|
|
referenceMenuLabel?: string;
|
|
referenceSlotTone?: ReferenceTone;
|
|
referenceSlotDisabled?: boolean;
|
|
referenceLabelFormatter?: ReferenceLabelFormatter;
|
|
referenceAriaLabelFormatter?: ReferenceLabelFormatter;
|
|
referenceRemoveLabelFormatter?: ReferenceLabelFormatter;
|
|
showReferenceAddButton?: boolean;
|
|
panelClassName?: string;
|
|
footerClassName?: string;
|
|
promptClassName?: string;
|
|
referenceSlotClassName?: string;
|
|
submitButtonClassName?: string;
|
|
submitLabel?: string;
|
|
submitAriaLabel?: string;
|
|
submittingStatusLabel?: string;
|
|
styleControl?: ReactNode;
|
|
};
|
|
|
|
function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
|
return {
|
|
...dialog,
|
|
status: dialog.status === 'failed' ? 'idle' : dialog.status,
|
|
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
|
|
};
|
|
}
|
|
|
|
export function ImageCanvasBasicGenerationComposerView({
|
|
dialog,
|
|
style,
|
|
setGenerateDialog,
|
|
generationReferenceButtonRef,
|
|
isGenerationReferenceMenuOpen = false,
|
|
setIsGenerationReferenceMenuOpen,
|
|
setIsPickingGenerationReferenceFromCanvas,
|
|
renderEditorPortal = (node) => node,
|
|
buildPortalMenuStyle = () => ({}),
|
|
onRequestUpload,
|
|
onPickReferenceFromCanvas,
|
|
onToggleReferenceMenu,
|
|
onRememberImageModel = () => {},
|
|
hasPendingImageReferenceUploads = false,
|
|
onSubmit,
|
|
dialogLabel,
|
|
includeDimensions = true,
|
|
includeModel = true,
|
|
includeReferences = true,
|
|
promptLabel,
|
|
promptPlaceholder,
|
|
optionLabelPrefix,
|
|
dimensionRatioAriaLabelPrefix,
|
|
dimensionSizeAriaLabelPrefix,
|
|
referenceUploadTarget = 'generation-reference',
|
|
referenceButtonIcon = <ImageIcon className="h-4 w-4" aria-hidden="true" />,
|
|
referenceButtonLabel = '参考图',
|
|
referenceButtonAriaLabel = '添加参考图',
|
|
referenceMenuLabel = '参考图来源',
|
|
referenceSlotTone = 'default',
|
|
referenceSlotDisabled = false,
|
|
referenceLabelFormatter,
|
|
referenceAriaLabelFormatter,
|
|
referenceRemoveLabelFormatter,
|
|
showReferenceAddButton = true,
|
|
panelClassName,
|
|
footerClassName,
|
|
promptClassName = 'image-canvas-editor__generation-prompt',
|
|
referenceSlotClassName,
|
|
submitButtonClassName = 'image-canvas-editor__generation-submit',
|
|
submitLabel = '生成',
|
|
submitAriaLabel = '生成',
|
|
submittingStatusLabel = '生成中',
|
|
styleControl,
|
|
}: ImageCanvasBasicGenerationComposerViewProps) {
|
|
const references = dialog.generationReferences ?? [];
|
|
const isQuickEdit = dialog.mode === 'quick-edit';
|
|
const resolvedDialogLabel =
|
|
dialogLabel ??
|
|
optionLabelPrefix ??
|
|
(isQuickEdit ? '快速编辑图片' : '生成图片');
|
|
const resolvedPromptLabel =
|
|
promptLabel ?? (isQuickEdit ? '快速编辑提示词' : '生成提示词');
|
|
const resolvedPromptPlaceholder =
|
|
promptPlaceholder ??
|
|
(isQuickEdit ? '你希望素材如何修改?' : '今天想生成什么画面?');
|
|
const resolvedOptionLabelPrefix =
|
|
optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
|
|
const resolvedReferenceButtonLabel = referenceButtonLabel;
|
|
const resolvedReferenceButtonAriaLabel =
|
|
referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`;
|
|
const shouldIncludeReferences = isQuickEdit ? false : includeReferences;
|
|
const shouldIncludeDimensions = includeDimensions;
|
|
const resolvedSubmitLabel =
|
|
isQuickEdit && submitLabel === '生成' ? '修改' : submitLabel;
|
|
const resolvedSubmitAriaLabel =
|
|
isQuickEdit && submitAriaLabel === '生成' ? '修改' : submitAriaLabel;
|
|
const resolvedSubmittingStatusLabel =
|
|
isQuickEdit && submittingStatusLabel === '生成中'
|
|
? '修改中'
|
|
: submittingStatusLabel;
|
|
const hasReferenceMenu = shouldIncludeReferences && onRequestUpload;
|
|
const finalPanelClassName =
|
|
panelClassName ??
|
|
'image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image';
|
|
const finalFooterClassName =
|
|
footerClassName ?? 'image-canvas-editor__generation-composer-footer';
|
|
|
|
const sceneContentError =
|
|
dialog.mode === 'scene' &&
|
|
dialog.status === 'failed' &&
|
|
dialog.errorMessage === EDITOR_SCENE_CONTENT_REQUIRED_ERROR
|
|
? dialog.errorMessage
|
|
: null;
|
|
const sceneCustomStyleError =
|
|
dialog.mode === 'scene' &&
|
|
dialog.status === 'failed' &&
|
|
dialog.errorMessage === EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR
|
|
? dialog.errorMessage
|
|
: null;
|
|
const hasSceneFieldError = Boolean(
|
|
sceneContentError || sceneCustomStyleError,
|
|
);
|
|
|
|
useImageCanvasFloatingOptionDismiss({
|
|
isOpen: isGenerationReferenceMenuOpen,
|
|
boundaryRefs: [generationReferenceButtonRef],
|
|
onDismiss: () => setIsGenerationReferenceMenuOpen?.(false),
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={finalPanelClassName}
|
|
style={style}
|
|
role="dialog"
|
|
aria-label={resolvedDialogLabel}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
>
|
|
{shouldIncludeReferences ? (
|
|
<div className="image-canvas-editor__reference-strip">
|
|
{references.map((reference, index) => {
|
|
const label =
|
|
(referenceLabelFormatter?.(reference, index) ??
|
|
reference.label) ||
|
|
`参考图${index + 1}`;
|
|
const ariaLabel =
|
|
referenceAriaLabelFormatter?.(reference, index) ?? label;
|
|
const removeLabel =
|
|
referenceRemoveLabelFormatter?.(reference, index) ??
|
|
`删除${ariaLabel}`;
|
|
|
|
return (
|
|
<ImageCanvasReferenceSlot
|
|
key={reference.id}
|
|
tone={referenceSlotTone}
|
|
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
|
|
imageSrc={reference.src}
|
|
objectKey={reference.objectKey}
|
|
label={label}
|
|
ariaLabel={ariaLabel}
|
|
title={reference.label}
|
|
onRemove={() =>
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog
|
|
? {
|
|
...resetFailedDialogStatus(currentDialog),
|
|
generationReferences: (
|
|
currentDialog.generationReferences ?? []
|
|
).filter((item) => item.id !== reference.id),
|
|
}
|
|
: currentDialog,
|
|
)
|
|
}
|
|
removeLabel={removeLabel}
|
|
className={referenceSlotClassName}
|
|
/>
|
|
);
|
|
})}
|
|
{showReferenceAddButton ? (
|
|
<ImageCanvasReferenceSlot
|
|
buttonRef={generationReferenceButtonRef}
|
|
tone={referenceSlotTone}
|
|
icon={referenceButtonIcon}
|
|
label={resolvedReferenceButtonLabel}
|
|
ariaLabel={resolvedReferenceButtonAriaLabel}
|
|
disabled={
|
|
dialog.status === 'generating' || referenceSlotDisabled
|
|
}
|
|
isAdd
|
|
onClick={() => onToggleReferenceMenu?.()}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<AutoGrowTextArea
|
|
aria-label={resolvedPromptLabel}
|
|
aria-invalid={sceneContentError ? true : undefined}
|
|
aria-describedby={
|
|
sceneContentError ? EDITOR_SCENE_CONTENT_ERROR_ID : undefined
|
|
}
|
|
value={dialog.prompt}
|
|
disabled={dialog.status === 'generating'}
|
|
placeholder={resolvedPromptPlaceholder}
|
|
className={promptClassName}
|
|
onValueChange={(value) =>
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog
|
|
? {
|
|
...resetFailedDialogStatus(currentDialog),
|
|
prompt: value,
|
|
}
|
|
: currentDialog,
|
|
)
|
|
}
|
|
/>
|
|
{sceneContentError ? (
|
|
<p
|
|
id={EDITOR_SCENE_CONTENT_ERROR_ID}
|
|
className="image-canvas-editor__scene-field-error"
|
|
role="alert"
|
|
>
|
|
{sceneContentError}
|
|
</p>
|
|
) : null}
|
|
{dialog.mode === 'scene' &&
|
|
dialog.sceneStylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET ? (
|
|
<AutoGrowTextArea
|
|
aria-label="自定义画风"
|
|
aria-invalid={sceneCustomStyleError ? true : undefined}
|
|
aria-describedby={
|
|
sceneCustomStyleError
|
|
? EDITOR_SCENE_CUSTOM_STYLE_ERROR_ID
|
|
: undefined
|
|
}
|
|
value={dialog.sceneCustomStyle ?? ''}
|
|
disabled={dialog.status === 'generating'}
|
|
placeholder="填写自定义画风,例如:90 年代复古像素风"
|
|
className={`${promptClassName} image-canvas-editor__scene-custom-style`}
|
|
onValueChange={(value) =>
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog
|
|
? {
|
|
...resetFailedDialogStatus(currentDialog),
|
|
sceneCustomStyle: value,
|
|
}
|
|
: currentDialog,
|
|
)
|
|
}
|
|
/>
|
|
) : null}
|
|
{sceneCustomStyleError ? (
|
|
<p
|
|
id={EDITOR_SCENE_CUSTOM_STYLE_ERROR_ID}
|
|
className="image-canvas-editor__scene-field-error"
|
|
role="alert"
|
|
>
|
|
{sceneCustomStyleError}
|
|
</p>
|
|
) : null}
|
|
<div className={finalFooterClassName}>
|
|
<ImageCanvasGenerationImageOptionsView
|
|
dialog={dialog}
|
|
setGenerateDialog={setGenerateDialog}
|
|
includeDimensions={shouldIncludeDimensions}
|
|
includeModel={includeModel}
|
|
onRememberImageModel={onRememberImageModel}
|
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
|
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
|
|
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
|
|
optionLabelPrefix={resolvedOptionLabelPrefix}
|
|
cost={calculateEditorImageGenerationPrice({
|
|
model: dialog.imageModel,
|
|
imageSize: dialog.imageSize,
|
|
})}
|
|
submitLabel={resolvedSubmitLabel}
|
|
submitAriaLabel={resolvedSubmitAriaLabel}
|
|
submitButtonClassName={submitButtonClassName}
|
|
onSubmitRequest={() => {
|
|
if (dialog.status !== 'generating') {
|
|
onSubmit(dialog);
|
|
}
|
|
}}
|
|
styleControl={styleControl}
|
|
renderEditorPortal={renderEditorPortal}
|
|
buildPortalMenuStyle={buildPortalMenuStyle}
|
|
/>
|
|
</div>
|
|
{dialog.status === 'generating' ? (
|
|
<PlatformStatusMessage
|
|
tone="info"
|
|
surface="platform"
|
|
size="xs"
|
|
className="image-canvas-editor__generate-status"
|
|
role="status"
|
|
>
|
|
{resolvedSubmittingStatusLabel}
|
|
</PlatformStatusMessage>
|
|
) : null}
|
|
{dialog.status === 'failed' && !hasSceneFieldError ? (
|
|
<PlatformStatusMessage
|
|
tone="error"
|
|
surface="platform"
|
|
size="xs"
|
|
className="image-canvas-editor__generate-status"
|
|
role="alert"
|
|
>
|
|
{dialog.errorMessage}
|
|
</PlatformStatusMessage>
|
|
) : null}
|
|
</div>
|
|
{hasReferenceMenu &&
|
|
isGenerationReferenceMenuOpen &&
|
|
generationReferenceButtonRef
|
|
? renderEditorPortal(
|
|
<PlatformFloatingMenu
|
|
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
|
|
label={referenceMenuLabel}
|
|
placement="top-start"
|
|
style={buildPortalMenuStyle(
|
|
generationReferenceButtonRef.current,
|
|
'above',
|
|
)}
|
|
>
|
|
<PlatformFloatingMenuItem
|
|
onClick={() => {
|
|
setIsGenerationReferenceMenuOpen?.(false);
|
|
if (onPickReferenceFromCanvas) {
|
|
onPickReferenceFromCanvas();
|
|
} else {
|
|
setIsPickingGenerationReferenceFromCanvas?.(true);
|
|
}
|
|
}}
|
|
>
|
|
从画布中选择
|
|
</PlatformFloatingMenuItem>
|
|
<PlatformFloatingMenuItem
|
|
onClick={() => {
|
|
setIsGenerationReferenceMenuOpen?.(false);
|
|
setIsPickingGenerationReferenceFromCanvas?.(false);
|
|
onRequestUpload(referenceUploadTarget);
|
|
}}
|
|
>
|
|
上传图片
|
|
</PlatformFloatingMenuItem>
|
|
</PlatformFloatingMenu>,
|
|
)
|
|
: null}
|
|
</>
|
|
);
|
|
}
|