Files
Genarrative/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx
T
kdletters 473eb18bc3 修复画板私有素材预览
让生成参考图和角色动作参考图通过 objectKey 换签显示

让视频图层 poster 与框选预览复用签名读取

补充参考槽、框选预览和视频图层回归测试

沉淀框选预览换签缓存踩坑
2026-06-23 23:24:08 +08:00

490 lines
18 KiB
TypeScript

import { Check, ChevronDown, Cpu, ImageIcon, ImagePlus } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
useCallback,
useRef,
useState,
} from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import type {
CanvasLayer,
CharacterReferenceImage,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import {
calculateEditorImageModelPrice,
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
getEditorImageModelDisplayName,
IMAGE_MODEL_NANOBANANA2,
normalizeEditorImageModel,
QUICK_EDIT_REFERENCE_LIMIT,
resolveEditorImageSizeLabel,
} from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasQuickEditPanelViewProps = {
panel: QuickEditPanelState;
sourceLayer: CanvasLayer;
style: CSSProperties;
referenceButtonRef?: RefObject<HTMLButtonElement | null>;
isReferenceMenuOpen?: boolean;
setIsReferenceMenuOpen?: Dispatch<SetStateAction<boolean>>;
setIsPickingQuickEditReferenceFromCanvas?: Dispatch<SetStateAction<boolean>>;
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
renderEditorPortal?: (node: ReactNode) => ReactNode;
buildPortalMenuStyle?: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
onRequestUpload?: (target: 'quick-edit-reference') => void;
onRememberImageModel?: (model: string) => void;
onSubmit: () => void;
};
type OpenPanel = 'dimensions' | 'model' | null;
function resetFailedPanelStatus<T extends { status: string; errorMessage?: string }>(
panel: T,
) {
return {
...panel,
status: panel.status === 'failed' ? 'idle' : panel.status,
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
};
}
function QuickEditReferenceChip({
reference,
index,
onRemove,
}: {
reference: CharacterReferenceImage;
index: number;
onRemove: () => void;
}) {
const label = `图${index + 1}`;
return (
<ImageCanvasReferenceSlot
tone="quick-edit"
icon={<ImageIcon 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}`}
/>
);
}
function getImageDimensionOptions(model: string | null | undefined) {
return (
EDITOR_IMAGE_DIMENSION_OPTIONS[
(normalizeEditorImageModel(model) ??
IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2]
);
}
function getPanelAspectRatio(panel: QuickEditPanelState) {
const options = getImageDimensionOptions(panel.model);
const aspectRatios = options.aspectRatios as readonly string[];
return panel.aspectRatio && aspectRatios.includes(panel.aspectRatio)
? panel.aspectRatio
: (options.aspectRatios[0] ?? '1:1');
}
function getPanelImageSize(panel: QuickEditPanelState) {
const options = getImageDimensionOptions(panel.model);
const imageSizes = options.imageSizes as readonly string[];
return panel.imageSize && imageSizes.includes(panel.imageSize)
? panel.imageSize
: (options.imageSizes.find((size) => size === '1K') ??
options.imageSizes[0] ??
'1K');
}
function QuickEditOptionChoice({
children,
selected,
className,
ariaLabel,
onClick,
}: {
children: ReactNode;
selected: boolean;
className?: string;
ariaLabel?: string;
onClick: () => void;
}) {
return (
<button
type="button"
className={['image-canvas-editor__option-popover-choice', className]
.filter(Boolean)
.join(' ')}
aria-pressed={selected}
aria-label={ariaLabel}
onClick={onClick}
>
{children}
</button>
);
}
export function ImageCanvasQuickEditPanelView({
panel,
style,
referenceButtonRef,
isReferenceMenuOpen = false,
setIsReferenceMenuOpen,
setIsPickingQuickEditReferenceFromCanvas,
setQuickEditPanel,
renderEditorPortal = (node) => node,
buildPortalMenuStyle = () => ({}),
onRequestUpload = () => {},
onRememberImageModel = () => {},
onSubmit,
}: ImageCanvasQuickEditPanelViewProps) {
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
const isRedraw = panel.mode === 'redraw';
const isGenerating = panel.status === 'generating';
const panelLabel = isRedraw ? '重绘图片' : '快速编辑图片';
const promptLabel = isRedraw ? '重绘提示词' : '快速编辑提示词';
const references = panel.quickEditReferences ?? [];
const selectedModel = normalizeEditorImageModel(panel.model);
const selectedModelLabel = getEditorImageModelDisplayName(selectedModel);
const dimensionOptions = getImageDimensionOptions(selectedModel);
const selectedAspectRatio = getPanelAspectRatio(panel);
const selectedImageSize = getPanelImageSize(panel);
const dimensionLabel = resolveEditorImageSizeLabel({
aspectRatio: selectedAspectRatio,
imageSize: selectedImageSize,
});
const canAddReferences =
!isRedraw && !isGenerating && references.length < QUICK_EDIT_REFERENCE_LIMIT;
const dismissOpenPanel = useCallback(() => setOpenPanel(null), []);
useImageCanvasFloatingOptionDismiss({
isOpen: openPanel !== null,
boundaryRefs: [dimensionsButtonRef, modelButtonRef],
onDismiss: dismissOpenPanel,
});
const updatePanel = (patch: Partial<QuickEditPanelState>) => {
setQuickEditPanel((currentPanel) =>
currentPanel
? {
...resetFailedPanelStatus(currentPanel),
...patch,
}
: currentPanel,
);
};
const updateModel = (model: string) => {
const nextOptions = getImageDimensionOptions(model);
const nextAspectRatios = nextOptions.aspectRatios as readonly string[];
const nextImageSizes = nextOptions.imageSizes as readonly string[];
onRememberImageModel(model);
setQuickEditPanel((currentPanel) => {
if (!currentPanel) {
return currentPanel;
}
return {
...resetFailedPanelStatus(currentPanel),
model,
aspectRatio:
currentPanel.aspectRatio &&
nextAspectRatios.includes(currentPanel.aspectRatio)
? currentPanel.aspectRatio
: (nextOptions.aspectRatios[0] ?? '1:1'),
imageSize:
currentPanel.imageSize && nextImageSizes.includes(currentPanel.imageSize)
? currentPanel.imageSize
: (nextOptions.imageSizes.find((size) => size === '1K') ??
nextOptions.imageSizes[0]),
};
});
};
const togglePanel = (nextPanel: Exclude<OpenPanel, null>) => {
setOpenPanel((currentPanel) =>
currentPanel === nextPanel ? null : nextPanel,
);
};
return (
<>
<form
className="image-canvas-editor__quick-edit-panel image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--quick-edit"
style={style}
role="dialog"
aria-label={panelLabel}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<div className="image-canvas-editor__reference-strip">
{!isRedraw
? references.map((reference, index) => (
<QuickEditReferenceChip
key={reference.id}
reference={reference}
index={index}
onRemove={() =>
setQuickEditPanel((currentPanel) =>
currentPanel
? {
...resetFailedPanelStatus(currentPanel),
quickEditReferences: (
currentPanel.quickEditReferences ?? []
).filter((item) => item.id !== reference.id),
}
: currentPanel,
)
}
/>
))
: null}
{!isRedraw ? (
<ImageCanvasReferenceSlot
buttonRef={referenceButtonRef}
tone="quick-edit"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
label="参考图"
ariaLabel="添加快速编辑参考图"
disabled={!canAddReferences}
isAdd
onClick={() => setIsReferenceMenuOpen?.((open) => !open)}
/>
) : null}
</div>
<PlatformTextField
variant="textarea"
aria-label={promptLabel}
value={panel.prompt}
disabled={isGenerating}
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt image-canvas-editor__quick-edit-prompt"
onChange={(event) =>
updatePanel({
prompt: event.target.value,
})
}
/>
{panel.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{panel.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__generation-composer-footer image-canvas-editor__quick-edit-footer">
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={dimensionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`${isRedraw ? '重绘' : '快速编辑'}尺寸 ${dimensionLabel}`}
aria-expanded={openPanel === 'dimensions'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => togglePanel('dimensions')}
>
{dimensionLabel}
</PlatformInlineOptionButton>
{openPanel === 'dimensions'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
label={`${isRedraw ? '重绘' : '快速编辑'}尺寸选项`}
placement="top-start"
style={buildPortalMenuStyle(
dimensionsButtonRef.current,
'above',
)}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-sections">
<div className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
比例
</span>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
{dimensionOptions.aspectRatios.map((aspectRatio) => (
<QuickEditOptionChoice
key={aspectRatio}
selected={selectedAspectRatio === aspectRatio}
className="image-canvas-editor__option-popover-choice--ratio"
ariaLabel={`比例 ${aspectRatio}`}
onClick={() => updatePanel({ aspectRatio })}
>
<span
className="image-canvas-editor__ratio-wireframe"
data-ratio={aspectRatio}
aria-hidden="true"
/>
<span>{aspectRatio}</span>
</QuickEditOptionChoice>
))}
</div>
</div>
<div className="image-canvas-editor__option-popover-section">
<span className="image-canvas-editor__option-popover-title">
尺寸
</span>
<div className="image-canvas-editor__option-popover-items">
{dimensionOptions.imageSizes.map((imageSize) => (
<QuickEditOptionChoice
key={imageSize}
selected={selectedImageSize === imageSize}
ariaLabel={`尺寸 ${imageSize}`}
onClick={() => updatePanel({ imageSize })}
>
{imageSize}
</QuickEditOptionChoice>
))}
</div>
</div>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model">
<PlatformInlineOptionButton
ref={modelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`${isRedraw ? '重绘' : '快速编辑'}模型 ${selectedModelLabel}`}
aria-expanded={openPanel === 'model'}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => togglePanel('model')}
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{selectedModelLabel}</span>
</span>
</PlatformInlineOptionButton>
{openPanel === 'model'
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model image-canvas-editor__portal-menu"
label={`${isRedraw ? '重绘' : '快速编辑'}模型选项`}
placement="top-start"
style={buildPortalMenuStyle(modelButtonRef.current, 'above')}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
const selected = selectedModel === option.value;
return (
<QuickEditOptionChoice
key={option.value}
selected={selected}
className="image-canvas-editor__option-popover-choice--model"
onClick={() => updateModel(option.value)}
>
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Cpu />
</span>
<span>{option.label}</span>
<Check
className="image-canvas-editor__option-selected-check"
data-visible={selected}
aria-hidden="true"
/>
</QuickEditOptionChoice>
);
})}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit image-canvas-editor__quick-edit-submit"
disabled={isGenerating}
aria-label={isGenerating ? '生成中' : '生成'}
>
{isGenerating ? (
'生成中'
) : (
<>
<span>生成</span>
<span className="image-canvas-editor__mud-point-inline">
{calculateEditorImageModelPrice(panel.model, selectedImageSize)}泥点
</span>
</>
)}
</PlatformActionButton>
</div>
</form>
{!isRedraw && isReferenceMenuOpen && referenceButtonRef
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="快速编辑参考图来源"
placement="top-start"
style={buildPortalMenuStyle(referenceButtonRef.current, 'above')}
>
<PlatformFloatingMenuItem
onClick={() => {
setIsReferenceMenuOpen?.(false);
setIsPickingQuickEditReferenceFromCanvas?.(true);
}}
>
从画布中选择
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
onClick={() => {
setIsReferenceMenuOpen?.(false);
setIsPickingQuickEditReferenceFromCanvas?.(false);
onRequestUpload('quick-edit-reference');
}}
>
上传图片
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</>
);
}