Files
Genarrative/src/components/image-editor/ImageCanvasWorldView.tsx
T
kdletters 946308b75e 保存图片画布生成器快照
将生成器对话框作为画布布局项序列化和恢复

生成成功后保留生成器快照并锚定到成品图层

图片类生成结果同步写入账号素材库

补充生成器持久化测试和浏览器回归相关文档
2026-06-17 23:57:25 +08:00

305 lines
11 KiB
TypeScript

import { Braces, ImageIcon } from 'lucide-react';
import type {
CSSProperties,
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
} from 'react';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { CANVAS_WORLD_SIZE } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasMarqueeState,
CanvasViewport,
GenerateDialogState,
QuickEditPanelState,
SnapGuide,
} from './ImageCanvasEditorTypes';
import {
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
getLayerKindLabel,
} from './ImageCanvasGenerationModel';
export type ImageCanvasWorldViewProps = {
viewport: CanvasViewport;
snapGuide: SnapGuide | null;
layers: CanvasLayer[];
selectedLayerIds: string[];
hoveredLayerId: string | null;
canvasMarquee: CanvasMarqueeState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
generateDialog: GenerateDialogState | null;
quickEditPanel: QuickEditPanelState | null;
generationComposerStyle: CSSProperties | null;
onLayerPointerDown: (
event: ReactPointerEvent<HTMLButtonElement>,
layer: CanvasLayer,
) => void;
onLayerClick: (
event: ReactMouseEvent<HTMLButtonElement>,
layer: CanvasLayer,
) => void;
onLayerContextMenu: (
event: ReactMouseEvent<HTMLButtonElement>,
layer: CanvasLayer,
) => void;
onLayerMouseEnter: (layerId: string) => void;
onLayerMouseLeave: (layerId: string) => void;
onOpenLayerMetadata: (layer: CanvasLayer) => void;
onGenerationFramePointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
) => void;
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
};
export function ImageCanvasWorldView({
viewport,
snapGuide,
layers,
selectedLayerIds,
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
generateDialog,
quickEditPanel,
generationComposerStyle,
onLayerPointerDown,
onLayerClick,
onLayerContextMenu,
onLayerMouseEnter,
onLayerMouseLeave,
onOpenLayerMetadata,
onGenerationFramePointerDown,
onActivateGenerationDialog,
}: ImageCanvasWorldViewProps) {
return (
<div
className="image-canvas-editor__world"
style={{
width: CANVAS_WORLD_SIZE,
height: CANVAS_WORLD_SIZE,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
}}
>
{snapGuide?.vertical !== undefined ? (
<div
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--vertical"
data-testid="image-canvas-editor-snap-guide-vertical"
style={{ left: snapGuide.vertical }}
/>
) : null}
{snapGuide?.horizontal !== undefined ? (
<div
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--horizontal"
data-testid="image-canvas-editor-snap-guide-horizontal"
style={{ top: snapGuide.horizontal }}
/>
) : null}
{layers
.slice()
.filter((layer) => !layer.hidden)
.sort((left, right) => left.zIndex - right.zIndex)
.map((layer) => {
const isSelected = selectedLayerIds.includes(layer.id);
const isHovered = hoveredLayerId === layer.id;
const kindLabel = getLayerKindLabel(layer);
const layerGeneratingLabel =
generateDialog?.mode === 'edit' &&
generateDialog.status === 'generating' &&
generateDialog.sourceLayerId === layer.id
? '修改中'
: quickEditPanel?.status === 'generating' &&
quickEditPanel.sourceLayerId === layer.id
? '生成中'
: null;
return (
<button
key={layer.id}
type="button"
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''} ${layerGeneratingLabel ? 'image-canvas-editor__layer--generating' : ''} ${layer.locked ? 'image-canvas-editor__layer--locked' : ''}`}
style={{
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
zIndex: layer.zIndex,
display: layer.hidden ? 'none' : undefined,
}}
onPointerDown={(event) => onLayerPointerDown(event, layer)}
onClick={(event) => onLayerClick(event, layer)}
onContextMenu={(event) => onLayerContextMenu(event, layer)}
onMouseEnter={() => onLayerMouseEnter(layer.id)}
onMouseLeave={() => onLayerMouseLeave(layer.id)}
aria-label={`选择${layer.title}`}
>
<img
src={layer.src}
alt={`画布图片:${layer.title}`}
style={{
transform:
layer.flipX || layer.flipY
? `scale(${layer.flipX ? -1 : 1}, ${
layer.flipY ? -1 : 1
})`
: undefined,
}}
/>
{kindLabel ? (
<span
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${layer.assetKind}`}
>
{kindLabel}
</span>
) : null}
<PlatformIconButton
asChild="spanButton"
variant="darkMini"
className={`image-canvas-editor__metadata-corner ${
kindLabel
? 'image-canvas-editor__metadata-corner--beside-kind'
: ''
}`}
label={`查看${layer.title}图片信息`}
icon={<Braces className="h-3 w-3" />}
onClick={(event) => {
event.stopPropagation();
onOpenLayerMetadata(layer);
}}
onPointerDown={(event) => event.stopPropagation()}
/>
{isHovered ? (
<PlatformPillBadge
tone="lightOverlay"
size="xs"
className="image-canvas-editor__size-badge"
>
{Math.round(layer.originalWidth)} x{' '}
{Math.round(layer.originalHeight)} px
</PlatformPillBadge>
) : null}
{layerGeneratingLabel ? (
<span
className="image-canvas-editor__generation-frame-progress image-canvas-editor__layer-generating-progress"
role="status"
>
{layerGeneratingLabel}
</span>
) : null}
</button>
);
})}
{canvasMarquee ? (
<div
className="image-canvas-editor__canvas-marquee"
aria-hidden="true"
style={{
left:
(Math.min(canvasMarquee.startX, canvasMarquee.currentX) -
viewport.x) /
viewport.scale,
top:
(Math.min(canvasMarquee.startY, canvasMarquee.currentY) -
viewport.y) /
viewport.scale,
width:
Math.abs(canvasMarquee.currentX - canvasMarquee.startX) /
viewport.scale,
height:
Math.abs(canvasMarquee.currentY - canvasMarquee.startY) /
viewport.scale,
}}
/>
) : null}
{canvasGenerationDialogs.map((dialog) => {
const hasGeneratedLayer = Boolean(
dialog.generatedLayerId &&
layers.some((layer) => layer.id === dialog.generatedLayerId),
);
return dialog.placeholder && !hasGeneratedLayer ? (
<div
key={dialog.id}
className={`image-canvas-editor__generation-frame ${
dialog.mode === 'icon'
? 'image-canvas-editor__generation-frame--icon'
: ''
} ${
dialog.status === 'generating'
? 'image-canvas-editor__generation-frame--generating'
: ''
}`}
role="button"
tabIndex={0}
style={{
left: dialog.placeholder.x,
top: dialog.placeholder.y,
width: dialog.placeholder.width,
height: dialog.placeholder.height,
}}
aria-label={getGenerationFrameAriaLabel(dialog)}
onPointerDown={(event) => onGenerationFramePointerDown(event, dialog)}
onDoubleClick={() => onActivateGenerationDialog(dialog)}
>
<span className="image-canvas-editor__generation-frame-label">
<ImageIcon className="h-4 w-4" />
{getGenerationFrameLabel(dialog)}
</span>
{dialog.mode === 'character' ? (
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--character">
角色
</span>
) : null}
{dialog.mode === 'spec' ? (
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--spec">
规范
</span>
) : null}
{dialog.mode === 'icon' ? (
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--icon">
图标
</span>
) : null}
<span className="image-canvas-editor__generation-frame-size">
{dialog.placeholder.originalWidth} x{' '}
{dialog.placeholder.originalHeight}
</span>
<span className="image-canvas-editor__generation-frame-icon">
<ImageIcon className="h-8 w-8" />
</span>
{dialog.status === 'generating' ? (
<span
className="image-canvas-editor__generation-frame-progress"
role="status"
>
生成中
</span>
) : null}
</div>
) : null;
})}
{(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'spec' ||
generateDialog?.mode === 'character' ||
generateDialog?.mode === 'icon') &&
generateDialog.status === 'generating' &&
generationComposerStyle ? (
<PlatformStatusMessage
tone="info"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status image-canvas-editor__generate-status--floating"
role="status"
style={generationComposerStyle}
>
生成中
</PlatformStatusMessage>
) : null}
</div>
);
}