473eb18bc3
让生成参考图和角色动作参考图通过 objectKey 换签显示 让视频图层 poster 与框选预览复用签名读取 补充参考槽、框选预览和视频图层回归测试 沉淀框选预览换签缓存踩坑
490 lines
18 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|