Files
Genarrative/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.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

309 lines
11 KiB
TypeScript

import { ClipboardList, ImagePlus } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
} from 'react';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
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;
hasPendingImageReferenceUploads?: boolean;
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,
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasCharacterGenerationComposerViewProps) {
useImageCanvasFloatingOptionDismiss({
isOpen: isCharacterSpecMenuOpen || isCharacterReferenceMenuOpen,
boundaryRefs: [characterSpecButtonRef, characterReferenceButtonRef],
onDismiss: () => {
setIsCharacterSpecMenuOpen(false);
setIsCharacterReferenceMenuOpen(false);
},
});
return (
<div
className="image-canvas-editor__character-composer"
style={style}
role="dialog"
aria-label="生成角色形象"
onPointerDown={(event) => event.stopPropagation()}
>
<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>
<div className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
<AutoGrowTextArea
aria-label="角色设定"
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder="你希望角色如何设计?"
className="image-canvas-editor__generation-prompt"
onValueChange={(value) =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'character'
? {
...resetFailedDialogStatus(currentDialog),
prompt: value,
}
: currentDialog,
)
}
/>
</div>
{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}
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
optionLabelPrefix="生成图片"
cost={calculateEditorImageGenerationPrice({
kind: 'character',
model: dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel="生成"
submitAriaLabel="生成"
onSubmitRequest={() => {
if (dialog.status !== 'generating') {
onSubmit(dialog);
}
}}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
</div>
</div>
);
}