Files
Genarrative/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx
T
suzmii 35d3a92afd 快速编辑提示词支持 @ 引用素材,@ 面板补标签筛选
- 快速编辑面板提示词换成与聊天同一个 `ResourceReferenceInput`,`@` 候选、插入动作与引用模型全部复用聊天那一套
- 共享 composer / 快速编辑面板新增 `promptInputSlot` 注入位,宿主控件替换默认 textarea,不传时 DOM 与行为逐字不变
- `ResourceReferenceInput` 新增 `showPolishAction`:快速编辑的润色仍由 ResourcePromptPolishSlot 承担,面板里不再出现第二个润色入口
- `ResourceReferenceInput` 的多行输入区把 Enter 留给换行,只有单行输入区才用 Enter 提交
- `@` 面板的搜索与筛选改用共享 `PlatformResourceFilterBar`,搜索 + 功能分类 + 标签三者在面板内叠加生效
- 新增 `resourceReferenceMatchesTagSelection` / `resourceReferenceTagLibrary`,标签口径(多标签 AND、计数与排序)落在共享标签库,两个页签各持一份
- 宿主新增 `quickEditReferences` 与 `applyResourceQuickEditDraft`,派生请求的 prompt 就是输入区回填的那份文本(`@显示名`),不引入第二种引用格式
- `useImageCanvasFloatingOptionDismiss` 新增 `isInsideExtraOverlay`:宿主 portal 到 body 的 `@` 选择器与候选菜单不再被判成点外部而收起快速编辑面板
- 资源画布快速编辑提示词输入区补样式(铺满 composer 一整行、高度上限),`@` 面板去掉手写搜索行样式
- 用例:新增标签库口径、标签筛选(多标签叠加 + 关键字 + 功能分类)、两页签独立标签、聊天与快速编辑草稿一致性、快速编辑内 `@` 出选择器且派生 payload 断言;扩展 AGC 资源工作台 V3 端到端验收用例
2026-09-12 16:44:55 +08:00

466 lines
17 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;
/**
* 从项目素材库选择参考图。传入时参考图来源菜单多一项,
* 点击由宿主打开独立的参考图选择弹窗。
*/
onRequestProjectAssetPicker?: () => 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;
/**
* 提示词输入控件的主机注入位(AGC 用它在快速编辑提示词里跑 `@` 引用生成器)。
*
* 传了它就用宿主控件替换默认的 `AutoGrowTextArea`(宿主自己负责等价的可访问性:
* 同一个 `aria-label`、同一份提示词文本);不传时 DOM 与行为逐字不变。
* 场景错误提示的 `aria-describedby` 只挂在默认 textarea 上,需要它的调用方不要传本插槽。
*/
promptInputSlot?: ReactNode;
/**
* 提示词下方的主机注入位(AGC 用它放「AI 润色」)。
*
* 不传时整块不渲染,默认 DOM 与行为逐字不变;共享组件内不做任何 Tauri 调用,
* 注入节点的取数与副作用全部留在宿主。节点自己负责在 composer 网格里的落位
* (铺满一行用 `grid-column: 1 / -1`)。
*/
promptActionSlot?: 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,
onRequestProjectAssetPicker,
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,
promptInputSlot,
promptActionSlot,
}: 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}
{promptInputSlot ?? (
<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,
)
}
/>
)}
{promptActionSlot}
{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>
{onRequestProjectAssetPicker ? (
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
setIsPickingGenerationReferenceFromCanvas?.(false);
onRequestProjectAssetPicker();
}}
>
从项目素材中选择
</PlatformFloatingMenuItem>
) : null}
</PlatformFloatingMenu>,
)
: null}
</>
);
}