387a1c26e3
清理已跟踪原始日志与个人本地配置 修复前后端有效门禁、测试和构建问题 补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束 收紧图片编辑器状态、附件与媒体引用测试 排除已下线旧创作入口测试并清理 warning
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import { ClipboardList, ImagePlus } 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,
|
|
SpecGenerationType,
|
|
UploadTarget,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
|
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
|
|
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
|
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
|
|
|
type ImageCanvasCharacterGenerationComposerViewProps = {
|
|
dialog: GenerateDialogState;
|
|
style: CSSProperties;
|
|
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
|
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
|
isCharacterSpecMenuOpen: boolean;
|
|
isCharacterReferenceMenuOpen: boolean;
|
|
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
|
setIsCharacterSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
|
|
setIsCharacterReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
|
|
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
|
|
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
|
|
renderEditorPortal: (node: ReactNode) => ReactNode;
|
|
buildPortalMenuStyle: (
|
|
anchor: HTMLElement | null,
|
|
placement: 'above' | 'below',
|
|
) => CSSProperties;
|
|
onOpenSpecDialog: (specType: SpecGenerationType) => void;
|
|
onRequestUpload: (target: UploadTarget) => void;
|
|
onRememberImageModel: (model: string) => void;
|
|
onSubmit: (dialog: GenerateDialogState) => void;
|
|
};
|
|
|
|
function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
|
return {
|
|
...dialog,
|
|
status: dialog.status === 'failed' ? 'idle' : dialog.status,
|
|
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
|
|
};
|
|
}
|
|
|
|
function ReferenceChip({
|
|
reference,
|
|
index,
|
|
onRemove,
|
|
}: {
|
|
reference: CharacterReferenceImage;
|
|
index: number;
|
|
onRemove: () => void;
|
|
}) {
|
|
const label = reference.label || `参考图${index + 1}`;
|
|
return (
|
|
<ImageCanvasReferenceSlot
|
|
tone="character"
|
|
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
|
|
imageSrc={reference.src}
|
|
objectKey={reference.objectKey}
|
|
label={label}
|
|
ariaLabel={label}
|
|
title={reference.label}
|
|
onRemove={onRemove}
|
|
removeLabel={`删除${label}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function ImageCanvasCharacterGenerationComposerView({
|
|
dialog,
|
|
style,
|
|
characterSpecButtonRef,
|
|
characterReferenceButtonRef,
|
|
isCharacterSpecMenuOpen,
|
|
isCharacterReferenceMenuOpen,
|
|
setGenerateDialog,
|
|
setIsCharacterSpecMenuOpen,
|
|
setIsCharacterReferenceMenuOpen,
|
|
setIsPickingCharacterSpecFromCanvas,
|
|
setIsPickingCharacterReferenceFromCanvas,
|
|
renderEditorPortal,
|
|
buildPortalMenuStyle,
|
|
onOpenSpecDialog,
|
|
onRequestUpload,
|
|
onRememberImageModel,
|
|
onSubmit,
|
|
}: ImageCanvasCharacterGenerationComposerViewProps) {
|
|
useImageCanvasFloatingOptionDismiss({
|
|
isOpen: isCharacterSpecMenuOpen || isCharacterReferenceMenuOpen,
|
|
boundaryRefs: [characterSpecButtonRef, characterReferenceButtonRef],
|
|
onDismiss: () => {
|
|
setIsCharacterSpecMenuOpen(false);
|
|
setIsCharacterReferenceMenuOpen(false);
|
|
},
|
|
});
|
|
|
|
return (
|
|
<form
|
|
className="image-canvas-editor__character-composer"
|
|
style={style}
|
|
role="dialog"
|
|
aria-label="生成角色形象"
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (dialog.status !== 'generating') {
|
|
onSubmit(dialog);
|
|
}
|
|
}}
|
|
>
|
|
<div className="image-canvas-editor__reference-strip">
|
|
<ImageCanvasReferenceSlot
|
|
buttonRef={characterSpecButtonRef}
|
|
tone="spec"
|
|
icon={<ClipboardList className="h-4 w-4" aria-hidden="true" />}
|
|
imageSrc={dialog.characterSpecReference?.src}
|
|
objectKey={dialog.characterSpecReference?.objectKey}
|
|
label="角色规范"
|
|
ariaLabel={dialog.characterSpecReference?.label ?? '角色规范'}
|
|
title={dialog.characterSpecReference?.label ?? '角色规范'}
|
|
disabled={dialog.status === 'generating'}
|
|
isAdd={!dialog.characterSpecReference}
|
|
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
|
|
onRemove={
|
|
dialog.characterSpecReference
|
|
? () =>
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'character'
|
|
? {
|
|
...resetFailedDialogStatus(currentDialog),
|
|
characterSpecReference: null,
|
|
}
|
|
: currentDialog,
|
|
)
|
|
: undefined
|
|
}
|
|
removeLabel="删除角色规范"
|
|
/>
|
|
{isCharacterSpecMenuOpen
|
|
? renderEditorPortal(
|
|
<PlatformFloatingMenu
|
|
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
|
label="角色规范来源"
|
|
placement="top-start"
|
|
style={buildPortalMenuStyle(
|
|
characterSpecButtonRef.current,
|
|
'above',
|
|
)}
|
|
>
|
|
<PlatformFloatingMenuItem
|
|
className="image-canvas-editor__context-menu-item"
|
|
onClick={() => {
|
|
setIsPickingCharacterSpecFromCanvas(true);
|
|
setIsCharacterSpecMenuOpen(false);
|
|
}}
|
|
>
|
|
从画布中选择
|
|
</PlatformFloatingMenuItem>
|
|
<PlatformFloatingMenuItem
|
|
className="image-canvas-editor__context-menu-item"
|
|
onClick={() => {
|
|
setIsCharacterSpecMenuOpen(false);
|
|
onOpenSpecDialog('character');
|
|
}}
|
|
>
|
|
新建角色规范
|
|
</PlatformFloatingMenuItem>
|
|
<PlatformFloatingMenuItem
|
|
className="image-canvas-editor__context-menu-item"
|
|
onClick={() => {
|
|
setIsCharacterSpecMenuOpen(false);
|
|
onRequestUpload('character-spec');
|
|
}}
|
|
>
|
|
上传图片
|
|
</PlatformFloatingMenuItem>
|
|
</PlatformFloatingMenu>,
|
|
)
|
|
: null}
|
|
{(dialog.characterReferences ?? []).map((reference, index) => (
|
|
<ReferenceChip
|
|
key={reference.id}
|
|
reference={reference}
|
|
index={index}
|
|
onRemove={() =>
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'character'
|
|
? {
|
|
...resetFailedDialogStatus(currentDialog),
|
|
characterReferences: (
|
|
currentDialog.characterReferences ?? []
|
|
).filter((item) => item.id !== reference.id),
|
|
}
|
|
: currentDialog,
|
|
)
|
|
}
|
|
/>
|
|
))}
|
|
<ImageCanvasReferenceSlot
|
|
buttonRef={characterReferenceButtonRef}
|
|
tone="default"
|
|
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
|
|
label="参考图"
|
|
ariaLabel="上传常规参考图"
|
|
disabled={dialog.status === 'generating'}
|
|
isAdd
|
|
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
|
|
/>
|
|
{isCharacterReferenceMenuOpen
|
|
? renderEditorPortal(
|
|
<PlatformFloatingMenu
|
|
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
|
label="常规参考图来源"
|
|
placement="top-start"
|
|
style={buildPortalMenuStyle(
|
|
characterReferenceButtonRef.current,
|
|
'above',
|
|
)}
|
|
>
|
|
<PlatformFloatingMenuItem
|
|
className="image-canvas-editor__context-menu-item"
|
|
onClick={() => {
|
|
setIsPickingCharacterReferenceFromCanvas(true);
|
|
setIsCharacterReferenceMenuOpen(false);
|
|
}}
|
|
>
|
|
从画布中选择
|
|
</PlatformFloatingMenuItem>
|
|
<PlatformFloatingMenuItem
|
|
className="image-canvas-editor__context-menu-item"
|
|
onClick={() => {
|
|
setIsCharacterReferenceMenuOpen(false);
|
|
onRequestUpload('character-reference');
|
|
}}
|
|
>
|
|
上传图片
|
|
</PlatformFloatingMenuItem>
|
|
</PlatformFloatingMenu>,
|
|
)
|
|
: null}
|
|
</div>
|
|
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
|
|
<PlatformTextField
|
|
variant="textarea"
|
|
aria-label="角色设定"
|
|
value={dialog.prompt}
|
|
disabled={dialog.status === 'generating'}
|
|
placeholder="你希望角色如何设计?"
|
|
size="sm"
|
|
density="compact"
|
|
className="image-canvas-editor__generation-prompt"
|
|
onChange={(event) =>
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'character'
|
|
? {
|
|
...resetFailedDialogStatus(currentDialog),
|
|
prompt: event.target.value,
|
|
}
|
|
: currentDialog,
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
{dialog.status === 'failed' ? (
|
|
<PlatformStatusMessage
|
|
tone="error"
|
|
surface="platform"
|
|
size="xs"
|
|
className="image-canvas-editor__generate-status"
|
|
role="alert"
|
|
>
|
|
{dialog.errorMessage}
|
|
</PlatformStatusMessage>
|
|
) : null}
|
|
<div className="image-canvas-editor__generation-composer-footer">
|
|
<ImageCanvasGenerationImageOptionsView
|
|
dialog={dialog}
|
|
setGenerateDialog={setGenerateDialog}
|
|
includeDimensions
|
|
onRememberImageModel={onRememberImageModel}
|
|
optionLabelPrefix="生成图片"
|
|
cost={calculateEditorImageGenerationPrice({
|
|
kind: 'character',
|
|
model: dialog.imageModel,
|
|
imageSize: dialog.imageSize,
|
|
})}
|
|
submitLabel="生成"
|
|
submitAriaLabel="生成"
|
|
renderEditorPortal={renderEditorPortal}
|
|
buildPortalMenuStyle={buildPortalMenuStyle}
|
|
/>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|