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

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}
</>
);
}