387a1c26e3
清理已跟踪原始日志与个人本地配置 修复前后端有效门禁、测试和构建问题 补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束 收紧图片编辑器状态、附件与媒体引用测试 排除已下线旧创作入口测试并清理 warning
354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
import { ImageIcon } from 'lucide-react';
|
|
import {
|
|
type CSSProperties,
|
|
type Dispatch,
|
|
type ReactNode,
|
|
type RefObject,
|
|
type SetStateAction,
|
|
} from 'react';
|
|
|
|
import {
|
|
PlatformFloatingMenu,
|
|
PlatformFloatingMenuItem,
|
|
} from '../common/PlatformFloatingMenu';
|
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
|
import { PlatformTextField } from '../common/PlatformTextField';
|
|
import type {
|
|
CharacterReferenceImage,
|
|
GenerateDialogState,
|
|
UploadTarget,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
|
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
|
|
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
|
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
|
|
|
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;
|
|
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;
|
|
formClassName?: string;
|
|
footerClassName?: string;
|
|
promptClassName?: string;
|
|
referenceSlotClassName?: string;
|
|
submitButtonClassName?: string;
|
|
submitLabel?: string;
|
|
submitAriaLabel?: string;
|
|
submittingStatusLabel?: string;
|
|
};
|
|
|
|
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 = () => {},
|
|
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,
|
|
formClassName,
|
|
footerClassName,
|
|
promptClassName = 'image-canvas-editor__generation-prompt',
|
|
referenceSlotClassName,
|
|
submitButtonClassName = 'image-canvas-editor__generation-submit',
|
|
submitLabel = '生成',
|
|
submitAriaLabel = '生成',
|
|
submittingStatusLabel = '生成中',
|
|
}: 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 finalFormClassName =
|
|
formClassName ??
|
|
'image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image';
|
|
const finalFooterClassName =
|
|
footerClassName ?? 'image-canvas-editor__generation-composer-footer';
|
|
|
|
useImageCanvasFloatingOptionDismiss({
|
|
isOpen: isGenerationReferenceMenuOpen,
|
|
boundaryRefs: [generationReferenceButtonRef],
|
|
onDismiss: () => setIsGenerationReferenceMenuOpen?.(false),
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<form
|
|
className={finalFormClassName}
|
|
style={style}
|
|
role="dialog"
|
|
aria-label={resolvedDialogLabel}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (dialog.status !== 'generating') {
|
|
onSubmit(dialog);
|
|
}
|
|
}}
|
|
>
|
|
{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}
|
|
<PlatformTextField
|
|
variant="textarea"
|
|
aria-label={resolvedPromptLabel}
|
|
value={dialog.prompt}
|
|
disabled={dialog.status === 'generating'}
|
|
placeholder={resolvedPromptPlaceholder}
|
|
size="sm"
|
|
density="compact"
|
|
className={promptClassName}
|
|
onChange={(event) =>
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog
|
|
? {
|
|
...resetFailedDialogStatus(currentDialog),
|
|
prompt: event.target.value,
|
|
}
|
|
: currentDialog,
|
|
)
|
|
}
|
|
/>
|
|
<div className={finalFooterClassName}>
|
|
<ImageCanvasGenerationImageOptionsView
|
|
dialog={dialog}
|
|
setGenerateDialog={setGenerateDialog}
|
|
includeDimensions={shouldIncludeDimensions}
|
|
includeModel={includeModel}
|
|
onRememberImageModel={onRememberImageModel}
|
|
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
|
|
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
|
|
optionLabelPrefix={resolvedOptionLabelPrefix}
|
|
cost={calculateEditorImageGenerationPrice({
|
|
model: dialog.imageModel,
|
|
imageSize: dialog.imageSize,
|
|
})}
|
|
submitLabel={resolvedSubmitLabel}
|
|
submitAriaLabel={resolvedSubmitAriaLabel}
|
|
submitButtonClassName={submitButtonClassName}
|
|
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' ? (
|
|
<PlatformStatusMessage
|
|
tone="error"
|
|
surface="platform"
|
|
size="xs"
|
|
className="image-canvas-editor__generate-status"
|
|
role="alert"
|
|
>
|
|
{dialog.errorMessage}
|
|
</PlatformStatusMessage>
|
|
) : null}
|
|
</form>
|
|
{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}
|
|
</>
|
|
);
|
|
}
|