Files
Genarrative/src/components/image-editor/ImageCanvasWorldView.tsx
T
kdletters c39d63b65f
Project CI / Frontend tests (push) Failing after 45s
Project CI / Native shell tests (push) Failing after 46s
Project CI / Repository checks (push) Failing after 47s
Project CI / Backend tests (push) Successful in 3m25s
优化图片画布拖动性能 (#173)
## 修改

- 按动画帧合并高频拖动更新,结束时持久化最终状态
- 对未移动图层子树做 memo,保留并发资源与业务字段更新
- 补充拖动合帧、最终 flush、并发状态和图层渲染回归
- 同步真实位图性能验收与排障文档

## 验证

- 8 个相关测试文件 202/202 通过
- TypeScript typecheck、编码检查、Prettier、git diff --check 通过
- 12000×12000 画布、360 张独立 512×512 PNG:有效位置更新 23.44/s → 58.18/s,gap p95 119.8ms → 32.9ms,>50ms 停顿 32 → 0,持久化快照 240 → 1
- 可信 CDP 输入最终运动观测约 46.9 FPS,不以空转 rAF 代替拖动帧率

Reviewed-on: #173
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-08-21 15:33:02 +08:00

1483 lines
46 KiB
TypeScript

import {
CanvasWorld as SharedCanvasWorld,
LayerRenderer,
SelectionOverlay,
} from '@genarrative/image-canvas-react';
import {
AppWindow,
Clapperboard,
ClipboardList,
Grid2X2,
ImageIcon,
Info,
Megaphone,
Mountain,
Music,
Pause,
PersonStanding,
Play,
Sparkles,
UserRound,
Volume2,
VolumeX,
} from 'lucide-react';
import type {
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
RefObject,
SetStateAction,
} from 'react';
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
CANVAS_WORLD_SIZE,
isCanvasAssetKindOverrideCompatible,
resolveLayerResourceAssetKind,
} from './ImageCanvasEditorModel';
import type {
CanvasAssetKind,
CanvasGenerationDialogState,
CanvasLayer,
CanvasMarqueeState,
CanvasViewport,
CropExpandPanelState,
CropExpandResizeHandle,
GenerateDialogState,
SnapGuide,
} from './ImageCanvasEditorTypes';
import {
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
} from './ImageCanvasGenerationModel';
import {
getCanvasMediaPreviewClassName,
getCanvasMediaPreviewMarker,
} from './ImageCanvasMediaModel';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
function getGenerationPlaceholderMeta(dialog: CanvasGenerationDialogState) {
switch (dialog.mode) {
case 'character':
return {
frameClassName: 'image-canvas-editor__generation-frame--character',
badgeClassName: 'character',
badgeLabel: '角色',
icon: <UserRound className="h-8 w-8" />,
labelIcon: <UserRound className="h-4 w-4" />,
};
case 'character-animation':
return {
frameClassName:
'image-canvas-editor__generation-frame--character-animation',
badgeClassName: 'character-animation',
badgeLabel: '动作',
icon: <PersonStanding className="h-8 w-8" />,
labelIcon: <PersonStanding className="h-4 w-4" />,
};
case 'spec':
return {
frameClassName: 'image-canvas-editor__generation-frame--spec',
badgeClassName: 'spec',
badgeLabel: '规范',
icon: <ClipboardList className="h-8 w-8" />,
labelIcon: <ClipboardList className="h-4 w-4" />,
};
case 'icon':
return {
frameClassName: 'image-canvas-editor__generation-frame--icon',
badgeClassName: 'icon',
badgeLabel: '图标',
icon: <Grid2X2 className="h-8 w-8" />,
labelIcon: <Grid2X2 className="h-4 w-4" />,
};
case 'ui-design':
return {
frameClassName: 'image-canvas-editor__generation-frame--ui-design',
badgeClassName: 'ui-design',
badgeLabel: 'UI设计',
icon: <AppWindow className="h-8 w-8" />,
labelIcon: <AppWindow className="h-4 w-4" />,
};
case 'scene':
return {
frameClassName: 'image-canvas-editor__generation-frame--scene',
badgeClassName: 'scene',
badgeLabel: '场景',
icon: <Mountain className="h-8 w-8" />,
labelIcon: <Mountain className="h-4 w-4" />,
};
case 'quick-edit':
return {
frameClassName: 'image-canvas-editor__generation-frame--quick-edit',
badgeClassName: 'quick-edit',
badgeLabel: '快编',
icon: <Sparkles className="h-8 w-8" />,
labelIcon: <Sparkles className="h-4 w-4" />,
};
case 'publication':
return {
frameClassName: 'image-canvas-editor__generation-frame--publication',
badgeClassName: 'publication',
badgeLabel: '宣发',
icon: <Megaphone className="h-8 w-8" />,
labelIcon: <Megaphone className="h-4 w-4" />,
};
case 'video':
return {
frameClassName: 'image-canvas-editor__generation-frame--video',
badgeClassName: 'video',
badgeLabel: '视频',
icon: <Clapperboard className="h-8 w-8" />,
labelIcon: <Clapperboard className="h-4 w-4" />,
};
case 'audio-sound-effect':
return {
frameClassName:
'image-canvas-editor__generation-frame--audio-sound-effect',
badgeClassName: 'sound-effect',
badgeLabel: '音效',
icon: <Volume2 className="h-8 w-8" />,
labelIcon: <Volume2 className="h-4 w-4" />,
};
case 'audio-background-music':
return {
frameClassName:
'image-canvas-editor__generation-frame--audio-background-music',
badgeClassName: 'background-music',
badgeLabel: '背景音乐',
icon: <Music className="h-8 w-8" />,
labelIcon: <Music className="h-4 w-4" />,
};
default:
return {
frameClassName: '',
badgeClassName: null,
badgeLabel: null,
icon: <ImageIcon className="h-8 w-8" />,
labelIcon: <ImageIcon className="h-4 w-4" />,
};
}
}
function getInverseViewportScale(viewport: CanvasViewport) {
if (!Number.isFinite(viewport.scale) || viewport.scale <= 0) {
return 1;
}
return 1 / viewport.scale;
}
function buildInverseScaleStyle(
inverseScale: number,
): CSSProperties & Record<string, string> {
return {
'--image-canvas-editor-inverse-scale': String(inverseScale),
'--image-canvas-editor-corner-offset': `calc(0.35rem * ${inverseScale})`,
'--image-canvas-editor-kind-offset': `calc(0.38rem * ${inverseScale})`,
'--image-canvas-editor-beside-kind-offset': `calc(1.95rem * ${inverseScale})`,
'--image-canvas-editor-frame-label-offset': `calc(-1.35rem * ${inverseScale})`,
};
}
function isCanvasMediaContainerLayer(layer: CanvasLayer) {
return (
layer.mediaType === 'audio' ||
layer.mediaType === 'video' ||
layer.mediaType === 'image-sequence'
);
}
function getLayerMetadataMediaLabel(layer: CanvasLayer) {
if (layer.mediaType === 'audio') {
return '音频';
}
if (layer.mediaType === 'video') {
return '视频';
}
if (layer.mediaType === 'image-sequence') {
return '序列帧';
}
return '图片';
}
const CANVAS_ASSET_KIND_TAG_OPTIONS: Array<{
value: CanvasAssetKind | null;
label: string;
className: string;
}> = [
{ value: null, label: '未知', className: 'unknown' },
{ value: 'spec', label: '规范', className: 'spec' },
{ value: 'character', label: '角色', className: 'character' },
{
value: 'character-animation',
label: '动作',
className: 'character-animation',
},
{ value: 'icon', label: '图标', className: 'icon' },
{ value: 'icon-spritesheet', label: '图集', className: 'icon-spritesheet' },
{ value: 'icon-spec', label: '图标规范', className: 'icon-spec' },
{
value: 'publication-material',
label: '宣发素材',
className: 'publication-material',
},
{ value: 'ui-design', label: 'UI设计', className: 'ui-design' },
{ value: 'scene', label: '场景', className: 'scene' },
{ value: 'video', label: '视频', className: 'video' },
{ value: 'sound-effect', label: '音效', className: 'sound-effect' },
{
value: 'background-music',
label: '背景音乐',
className: 'background-music',
},
];
const UNKNOWN_ASSET_KIND_TAG_OPTION = CANVAS_ASSET_KIND_TAG_OPTIONS[0] as {
value: null;
label: string;
className: string;
};
function getLayerKindTagMeta(layer: CanvasLayer) {
const knownKind = layer.assetKind
? CANVAS_ASSET_KIND_TAG_OPTIONS.find(
(option) => option.value === layer.assetKind,
)
: null;
if (knownKind) {
return knownKind;
}
return UNKNOWN_ASSET_KIND_TAG_OPTION;
}
export type ImageCanvasWorldViewProps = {
viewport: CanvasViewport;
snapGuide: SnapGuide | null;
layers: CanvasLayer[];
selectedLayerIds: string[];
hoveredLayerId: string | null;
canvasMarquee: CanvasMarqueeState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
generateDialog: GenerateDialogState | null;
cropExpandPanel: CropExpandPanelState | null;
cropExpandSourceLayer: CanvasLayer | null;
generationComposerStyle: CSSProperties | null;
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
onLayerPointerDown: (
event: ReactPointerEvent<HTMLElement>,
layer: CanvasLayer,
) => void;
onLayerClick: (
event: ReactMouseEvent<HTMLElement>,
layer: CanvasLayer,
) => void;
onSelectLayer: (layerId: string | null) => void;
onLayerContextMenu: (
event: ReactMouseEvent<HTMLElement>,
layer: CanvasLayer,
) => void;
onLayerMouseEnter: (layerId: string) => void;
onLayerMouseLeave: (layerId: string) => void;
onOpenLayerMetadata: (layer: CanvasLayer) => void;
onUpdateLayerAssetKind: (
layerId: string,
assetKind: CanvasAssetKind | null,
) => void;
onGenerationFramePointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
) => void;
onGenerationFrameContextMenu: (
event: ReactMouseEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
) => void;
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onCropExpandHandlePointerDown: (
event: ReactPointerEvent<HTMLButtonElement>,
handle: CropExpandResizeHandle,
) => void;
};
type ImageCanvasLayerHandlers = Pick<
ImageCanvasWorldViewProps,
| 'onLayerPointerDown'
| 'onLayerClick'
| 'onSelectLayer'
| 'onLayerContextMenu'
| 'onLayerMouseEnter'
| 'onLayerMouseLeave'
| 'onOpenLayerMetadata'
| 'onUpdateLayerAssetKind'
> & {
setOpenLayerKindMenuId: (value: SetStateAction<string | null>) => void;
};
const EMPTY_LAYER_ID_SET: ReadonlySet<string> = new Set();
const CROP_EXPAND_HANDLES: Array<{
value: CropExpandResizeHandle;
label: string;
}> = [
{ value: 'top-left', label: '左上角' },
{ value: 'top', label: '上边' },
{ value: 'top-right', label: '右上角' },
{ value: 'right', label: '右边' },
{ value: 'bottom-right', label: '右下角' },
{ value: 'bottom', label: '下边' },
{ value: 'bottom-left', label: '左下角' },
{ value: 'left', label: '左边' },
];
function stopMediaEventPropagation(
event: ReactMouseEvent<HTMLElement> | ReactPointerEvent<HTMLElement>,
) {
event.stopPropagation();
}
function selectMediaLayerWithoutDrag(
event: ReactPointerEvent<HTMLElement>,
onSelectLayer: () => void,
) {
event.stopPropagation();
onSelectLayer();
}
function handleLayerKeyboardActivation(event: ReactKeyboardEvent<HTMLElement>) {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
event.currentTarget.click();
}
function handleLayerTagKeyboardActivation(
event: ReactKeyboardEvent<HTMLElement>,
) {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.click();
}
function stopMediaControlKeyPropagation(
event: ReactKeyboardEvent<HTMLElement>,
) {
if (event.key === 'Backspace' || event.key === 'Delete') {
return;
}
event.stopPropagation();
}
function isImageAlreadyDecoded(image: HTMLImageElement | null) {
return Boolean(image?.complete && image.naturalWidth > 0);
}
function getLayerMetricLabel(layer: CanvasLayer) {
return `${Math.round(layer.originalWidth)} x ${Math.round(
layer.originalHeight,
)} px`;
}
function getAudioLayerIcon(layer: CanvasLayer) {
return layer.assetKind === 'background-music' ? (
<Music className="h-8 w-8" aria-hidden="true" />
) : (
<Volume2 className="h-8 w-8" aria-hidden="true" />
);
}
function formatAudioControlTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds <= 0) {
return '0:00';
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60)
.toString()
.padStart(2, '0');
return `${minutes}:${remainingSeconds}`;
}
function ImageCanvasAudioLayerCard({
layer,
isHovered,
onSelectLayer,
}: {
layer: CanvasLayer;
isHovered: boolean;
onSelectLayer: () => void;
}) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const previewClassName = getCanvasMediaPreviewClassName(
getCanvasMediaPreviewMarker(layer),
);
const [isPlaying, setIsPlaying] = useState(false);
const { resolvedUrl } = useResolvedAssetReadUrl(layer.src, {
objectKey: layer.objectKey,
refreshKey: layer.taskId ?? layer.resourceId,
});
const prompt = layer.prompt?.trim();
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const showPlaybackButton = isPlaying || isHovered;
const controlDuration = duration > 0 ? duration : 0;
const safeCurrentTime =
controlDuration > 0 ? Math.min(currentTime, controlDuration) : 0;
useEffect(() => {
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
}, [layer.src]);
const togglePlayback = async () => {
const audio = audioRef.current;
if (!audio) {
return;
}
if (isPlaying) {
audio.pause();
return;
}
try {
await audio.play();
} catch {
setIsPlaying(false);
}
};
const handleSeekChange = (nextValue: string) => {
const nextTime = Number(nextValue);
if (!Number.isFinite(nextTime)) {
return;
}
const clampedTime =
controlDuration > 0
? Math.min(Math.max(nextTime, 0), controlDuration)
: Math.max(nextTime, 0);
if (audioRef.current) {
audioRef.current.currentTime = clampedTime;
}
setCurrentTime(clampedTime);
};
const handleVolumeChange = (nextValue: string) => {
const nextVolume = Number(nextValue);
if (!Number.isFinite(nextVolume)) {
return;
}
const clampedVolume = Math.min(Math.max(nextVolume, 0), 1);
if (audioRef.current) {
audioRef.current.volume = clampedVolume;
audioRef.current.muted = clampedVolume === 0;
}
setVolume(clampedVolume);
setIsMuted(clampedVolume === 0);
};
const toggleMute = () => {
const nextMuted = !isMuted;
if (audioRef.current) {
audioRef.current.muted = nextMuted;
}
setIsMuted(nextMuted);
};
return (
<div
className={`image-canvas-editor__audio-card ${previewClassName} ${
isHovered ? 'image-canvas-editor__audio-card--controls-visible' : ''
}`}
>
<button
type="button"
className="image-canvas-editor__audio-card-playback"
aria-label={isPlaying ? `暂停${layer.title}` : `播放${layer.title}`}
onClick={(event) => {
event.stopPropagation();
void togglePlayback();
}}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
>
{showPlaybackButton ? (
isPlaying ? (
<Pause className="h-8 w-8" aria-hidden="true" />
) : (
<Play className="h-8 w-8" aria-hidden="true" />
)
) : (
getAudioLayerIcon(layer)
)}
</button>
{prompt ? (
<span className="image-canvas-editor__audio-card-title">{prompt}</span>
) : null}
<div
className="image-canvas-editor__audio-card-controls image-canvas-editor__audio-card-controls--integrated"
onClick={stopMediaEventPropagation}
onKeyDown={stopMediaControlKeyPropagation}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
>
<audio
ref={audioRef}
className="image-canvas-editor__audio-card-native-control"
src={resolvedUrl || undefined}
preload="metadata"
aria-label={`画布音频:${layer.title}`}
onClick={stopMediaEventPropagation}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
onLoadedMetadata={(event) => {
const nextDuration = event.currentTarget.duration;
setDuration(
Number.isFinite(nextDuration) && nextDuration > 0
? nextDuration
: 0,
);
}}
onTimeUpdate={(event) =>
setCurrentTime(event.currentTarget.currentTime)
}
onVolumeChange={(event) => {
setVolume(event.currentTarget.volume);
setIsMuted(event.currentTarget.muted);
}}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
/>
<span className="image-canvas-editor__audio-card-time">
{formatAudioControlTime(safeCurrentTime)}
</span>
<input
type="range"
className="image-canvas-editor__audio-card-progress"
min="0"
max={controlDuration || 0}
step="0.1"
value={safeCurrentTime}
disabled={!controlDuration}
aria-label={`调整${layer.title}播放进度`}
onChange={(event) => handleSeekChange(event.currentTarget.value)}
/>
<span className="image-canvas-editor__audio-card-time">
{formatAudioControlTime(controlDuration)}
</span>
<button
type="button"
className="image-canvas-editor__audio-card-volume-toggle"
aria-label={isMuted ? `恢复${layer.title}音量` : `静音${layer.title}`}
onClick={(event) => {
event.stopPropagation();
toggleMute();
}}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
>
{isMuted ? (
<VolumeX className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<Volume2 className="h-3.5 w-3.5" aria-hidden="true" />
)}
</button>
<input
type="range"
className="image-canvas-editor__audio-card-volume"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
aria-label={`调整${layer.title}音量`}
onChange={(event) => handleVolumeChange(event.currentTarget.value)}
/>
</div>
</div>
);
}
function ImageCanvasVideoLayer({
layer,
mediaTransform,
}: {
layer: CanvasLayer;
mediaTransform?: string;
}) {
const { resolvedUrl } = useResolvedAssetReadUrl(layer.src, {
objectKey: layer.objectKey,
refreshKey: layer.taskId ?? layer.resourceId,
});
const { resolvedUrl: posterUrl } = useResolvedAssetReadUrl(
layer.thumbnailSrc,
{
refreshKey: `${layer.taskId ?? layer.resourceId}:poster`,
},
);
const [showGeneratedPreview, setShowGeneratedPreview] = useState(
!layer.thumbnailSrc,
);
const previewClassName = getCanvasMediaPreviewClassName(
getCanvasMediaPreviewMarker(layer),
);
useEffect(() => {
setShowGeneratedPreview(!layer.thumbnailSrc);
}, [layer.src, layer.thumbnailSrc]);
return (
<>
<video
className="image-canvas-editor__layer-video"
src={resolvedUrl || undefined}
poster={posterUrl || undefined}
controls
playsInline
preload="metadata"
aria-label={`画布视频:${layer.title}`}
style={{ transform: mediaTransform }}
onLoadedData={() => setShowGeneratedPreview(false)}
onPlay={() => setShowGeneratedPreview(false)}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => {
if (event.target !== event.currentTarget) {
event.stopPropagation();
}
}}
/>
<span
className={`image-canvas-editor__layer-video-preview ${previewClassName} ${
showGeneratedPreview
? 'image-canvas-editor__layer-video-preview--visible'
: ''
}`}
aria-hidden="true"
/>
</>
);
}
function ImageCanvasImageLayer({
layer,
mediaTransform,
}: {
layer: CanvasLayer;
mediaTransform?: string;
}) {
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(
layer.src,
{
objectKey: layer.objectKey,
refreshKey: layer.taskId ?? layer.resourceId,
},
);
const imageRef = useRef<HTMLImageElement | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setIsLoaded(isImageAlreadyDecoded(imageRef.current));
}, [resolvedUrl]);
const isLoading =
isResolving ||
(shouldResolve && !resolvedUrl) ||
Boolean(resolvedUrl && !isLoaded);
return (
<>
{isLoading ? (
<span
className="image-canvas-editor__layer-image-loading"
aria-hidden="true"
/>
) : null}
{resolvedUrl ? (
<img
ref={imageRef}
src={resolvedUrl}
alt={`画布图片:${layer.title}`}
decoding="async"
className={
isLoaded
? 'image-canvas-editor__layer-image--loaded'
: 'image-canvas-editor__layer-image--loading'
}
style={{ transform: mediaTransform }}
onLoad={() => setIsLoaded(true)}
onError={() => setIsLoaded(true)}
/>
) : null}
</>
);
}
function getLayerImageSequenceFrames(layer: CanvasLayer) {
return layer.imageSequenceFrames ?? [];
}
function getImageSequenceFrameKey(
layer: CanvasLayer,
frame: ReturnType<typeof getLayerImageSequenceFrames>[number],
index: number,
) {
return [
layer.id,
layer.taskId ?? layer.resourceId ?? '',
frame.objectKey ?? '',
frame.imageSrc,
index,
].join(':');
}
function ImageCanvasImageSequenceFrame({
layer,
frame,
frameKey,
isVisible,
isLoaded,
mediaTransform,
onFrameReady,
}: {
layer: CanvasLayer;
frame: ReturnType<typeof getLayerImageSequenceFrames>[number];
frameKey: string;
isVisible: boolean;
isLoaded: boolean;
mediaTransform?: string;
onFrameReady: (frameKey: string) => void;
}) {
const { resolvedUrl } = useResolvedAssetReadUrl(frame.imageSrc, {
objectKey: frame.objectKey,
refreshKey: `${layer.taskId ?? layer.resourceId}:${frameKey}`,
});
const frameImageRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
if (resolvedUrl && isImageAlreadyDecoded(frameImageRef.current)) {
onFrameReady(frameKey);
}
}, [frameKey, onFrameReady, resolvedUrl]);
if (!resolvedUrl) {
return null;
}
return (
<img
ref={frameImageRef}
src={resolvedUrl}
alt={isVisible ? `画布序列帧:${layer.title}` : ''}
aria-hidden={isVisible ? undefined : true}
decoding="async"
className="image-canvas-editor__layer-image-sequence-frame"
style={{
inset: 0,
opacity: isVisible && isLoaded ? 1 : 0,
position: 'absolute',
transition: 'none',
transform: mediaTransform,
zIndex: 1,
}}
onLoad={() => onFrameReady(frameKey)}
onError={() => onFrameReady(frameKey)}
/>
);
}
function ImageCanvasImageSequenceLayer({
layer,
mediaTransform,
}: {
layer: CanvasLayer;
mediaTransform?: string;
}) {
const frames = getLayerImageSequenceFrames(layer);
const frameItems = frames.map((frame, index) => ({
frame,
index,
key: getImageSequenceFrameKey(layer, frame, index),
}));
const frameSequenceKey = frameItems.map((item) => item.key).join('|');
const firstFrameKey = frameItems[0]?.key ?? '';
const [frameIndex, setFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(frames.length > 1);
const currentFrameItem =
frameItems[Math.min(frameIndex, frameItems.length - 1)];
const currentFrameKey = currentFrameItem?.key ?? '';
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey);
const visibleFrameLoaded = Boolean(
visibleFrameKey && loadedFrameKeys.has(visibleFrameKey),
);
const hasFrameSource = frameItems.some((item) => item.frame.imageSrc.trim());
const handleFrameReady = useCallback((readyFrameKey: string) => {
setLoadedFrameKeys((currentKeys) => {
if (currentKeys.has(readyFrameKey)) {
return currentKeys;
}
const nextKeys = new Set(currentKeys);
nextKeys.add(readyFrameKey);
return nextKeys;
});
}, []);
useEffect(() => {
setFrameIndex(0);
setIsPlaying(frames.length > 1);
setLoadedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
}, [firstFrameKey, frameSequenceKey, frames.length]);
useEffect(() => {
if (!currentFrameKey) {
setVisibleFrameKey('');
return;
}
if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) {
return;
}
setVisibleFrameKey((currentKey) =>
currentKey === currentFrameKey ? currentKey : currentFrameKey,
);
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
useEffect(() => {
if (!isPlaying || frames.length <= 1) {
return undefined;
}
const durationMs =
typeof layer.imageSequenceDurationMs === 'number' &&
layer.imageSequenceDurationMs > 0
? layer.imageSequenceDurationMs
: (frames.length / 8) * 1_000;
const intervalMs = durationMs / frames.length;
const timer = window.setInterval(() => {
setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length);
}, intervalMs);
return () => window.clearInterval(timer);
}, [frames.length, isPlaying, layer.imageSequenceDurationMs]);
return (
<>
{hasFrameSource && !visibleFrameLoaded ? (
<span
className="image-canvas-editor__layer-image-loading"
aria-hidden="true"
/>
) : null}
{frameItems.map((item) => (
<ImageCanvasImageSequenceFrame
key={item.key}
layer={layer}
frame={item.frame}
frameKey={item.key}
isVisible={item.key === visibleFrameKey}
isLoaded={loadedFrameKeys.has(item.key)}
mediaTransform={mediaTransform}
onFrameReady={handleFrameReady}
/>
))}
<div
className="image-canvas-editor__image-sequence-controls"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="image-canvas-editor__image-sequence-toggle"
aria-label={
isPlaying
? `暂停${layer.title}序列帧播放`
: `播放${layer.title}序列帧`
}
disabled={frames.length <= 1}
onClick={() => setIsPlaying((currentValue) => !currentValue)}
>
{isPlaying ? (
<Pause className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<Play className="h-3.5 w-3.5" aria-hidden="true" />
)}
</button>
<span className="image-canvas-editor__image-sequence-count">
{`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${
frames.length || 0
}`}
</span>
</div>
</>
);
}
const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({
layer,
isSelected,
isHovered,
layerGeneratingLabel,
isKindMenuOpen,
inverseScaleStyle,
handlers,
openLayerKindTriggerRef,
openLayerKindMenuRef,
}: {
layer: CanvasLayer;
isSelected: boolean;
isHovered: boolean;
layerGeneratingLabel: string | null;
isKindMenuOpen: boolean;
inverseScaleStyle: CSSProperties & Record<string, string>;
handlers: ImageCanvasLayerHandlers;
openLayerKindTriggerRef: RefObject<HTMLSpanElement | null>;
openLayerKindMenuRef: RefObject<HTMLDivElement | null>;
}) {
const kindTagMeta = getLayerKindTagMeta(layer);
const kindLabel = kindTagMeta.label;
const mediaTransform =
layer.flipX || layer.flipY
? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})`
: undefined;
const isMediaContainer = isCanvasMediaContainerLayer(layer);
const metadataMediaLabel = getLayerMetadataMediaLabel(layer);
return (
<div>
<LayerRenderer
as={isMediaContainer ? 'div' : 'button'}
tabIndex={isMediaContainer ? 0 : undefined}
layer={layer}
selected={isSelected}
hovered={isHovered}
generating={Boolean(layerGeneratingLabel)}
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' : ''}`}
onPointerDown={(event) => handlers.onLayerPointerDown(event, layer)}
onClick={(event) => {
handlers.setOpenLayerKindMenuId(null);
handlers.onLayerClick(event, layer);
}}
onContextMenu={(event) => {
handlers.setOpenLayerKindMenuId(null);
handlers.onLayerContextMenu(event, layer);
}}
onKeyDown={isMediaContainer ? handleLayerKeyboardActivation : undefined}
onMouseEnter={() => handlers.onLayerMouseEnter(layer.id)}
onMouseLeave={() => handlers.onLayerMouseLeave(layer.id)}
aria-label={`选择${layer.title}`}
>
{layer.mediaType === 'audio' ? (
<ImageCanvasAudioLayerCard
layer={layer}
isHovered={isHovered}
onSelectLayer={() => handlers.onSelectLayer(layer.id)}
/>
) : layer.mediaType === 'video' ? (
<ImageCanvasVideoLayer
layer={layer}
mediaTransform={mediaTransform}
/>
) : layer.mediaType === 'image-sequence' ? (
<ImageCanvasImageSequenceLayer
layer={layer}
mediaTransform={mediaTransform}
/>
) : (
<ImageCanvasImageLayer
layer={layer}
mediaTransform={mediaTransform}
/>
)}
{kindLabel ? (
<span
ref={isKindMenuOpen ? openLayerKindTriggerRef : undefined}
role="button"
tabIndex={0}
aria-label={`素材标签:${kindLabel}`}
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${kindTagMeta.className} image-canvas-editor__kind-badge--beside-info image-canvas-editor__kind-badge--editable`}
style={inverseScaleStyle}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
handlers.setOpenLayerKindMenuId((currentId) =>
currentId === layer.id ? null : layer.id,
);
}}
onKeyDown={handleLayerTagKeyboardActivation}
>
{kindLabel}
</span>
) : null}
<PlatformIconButton
asChild="spanButton"
variant="darkMini"
className="image-canvas-editor__metadata-corner"
label={`查看${layer.title}${metadataMediaLabel}信息`}
icon={<Info className="h-3 w-3" />}
style={inverseScaleStyle}
onClick={(event) => {
event.stopPropagation();
handlers.onOpenLayerMetadata(layer);
}}
onPointerDown={(event) => event.stopPropagation()}
/>
{isHovered && layer.mediaType !== 'audio' ? (
<PlatformPillBadge
tone="lightOverlay"
size="xs"
className="image-canvas-editor__size-badge"
style={inverseScaleStyle}
>
{getLayerMetricLabel(layer)}
</PlatformPillBadge>
) : null}
{layerGeneratingLabel ? (
<span
className="image-canvas-editor__generation-frame-progress image-canvas-editor__layer-generating-progress"
role="status"
>
{layerGeneratingLabel}
</span>
) : null}
</LayerRenderer>
{isKindMenuOpen ? (
<div
ref={openLayerKindMenuRef}
className="image-canvas-editor__kind-menu-anchor"
style={{
left: layer.x + layer.width,
top: layer.y,
zIndex: layer.zIndex + 30,
...inverseScaleStyle,
}}
>
<PlatformFloatingMenu
label="选择素材标签"
placement="bottom-end"
className="image-canvas-editor__kind-menu"
onWheelCapture={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
{CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => {
const compatible = isCanvasAssetKindOverrideCompatible(
resolveLayerResourceAssetKind(layer),
option.value,
);
return (
<PlatformFloatingMenuItem
key={option.value ?? 'unknown'}
className={
option.value === (layer.assetKind ?? null)
? 'image-canvas-editor__kind-menu-item--active'
: undefined
}
disabled={!compatible}
title={
compatible ? undefined : '该标签与素材的媒体类型不兼容'
}
onClick={(event) => {
event.stopPropagation();
handlers.onUpdateLayerAssetKind(layer.id, option.value);
handlers.setOpenLayerKindMenuId(null);
}}
>
{option.label}
</PlatformFloatingMenuItem>
);
})}
</PlatformFloatingMenu>
</div>
) : null}
</div>
);
});
export function ImageCanvasWorldView({
viewport,
snapGuide,
layers,
selectedLayerIds,
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
generateDialog,
cropExpandPanel,
cropExpandSourceLayer,
generationComposerStyle,
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
onLayerPointerDown,
onLayerClick,
onSelectLayer,
onLayerContextMenu,
onLayerMouseEnter,
onLayerMouseLeave,
onOpenLayerMetadata,
onUpdateLayerAssetKind,
onGenerationFramePointerDown,
onGenerationFrameContextMenu,
onActivateGenerationDialog,
onCropExpandHandlePointerDown,
}: ImageCanvasWorldViewProps) {
const inverseScale = getInverseViewportScale(viewport);
const inverseScaleStyle = useMemo(
() => buildInverseScaleStyle(inverseScale),
[inverseScale],
);
const selectedLayerIdSet = useMemo(
() => new Set(selectedLayerIds),
[selectedLayerIds],
);
const [openLayerKindMenuId, setOpenLayerKindMenuId] = useState<string | null>(
null,
);
const openLayerKindTriggerRef = useRef<HTMLSpanElement | null>(null);
const openLayerKindMenuRef = useRef<HTMLDivElement | null>(null);
const latestLayerHandlersRef = useRef<ImageCanvasLayerHandlers>({
onLayerPointerDown,
onLayerClick,
onSelectLayer,
onLayerContextMenu,
onLayerMouseEnter,
onLayerMouseLeave,
onOpenLayerMetadata,
onUpdateLayerAssetKind,
setOpenLayerKindMenuId,
});
useLayoutEffect(() => {
latestLayerHandlersRef.current = {
onLayerPointerDown,
onLayerClick,
onSelectLayer,
onLayerContextMenu,
onLayerMouseEnter,
onLayerMouseLeave,
onOpenLayerMetadata,
onUpdateLayerAssetKind,
setOpenLayerKindMenuId,
};
}, [
onLayerPointerDown,
onLayerClick,
onSelectLayer,
onLayerContextMenu,
onLayerMouseEnter,
onLayerMouseLeave,
onOpenLayerMetadata,
onUpdateLayerAssetKind,
]);
const layerHandlers = useMemo<ImageCanvasLayerHandlers>(
() => ({
onLayerPointerDown: (event, layer) =>
latestLayerHandlersRef.current.onLayerPointerDown(event, layer),
onLayerClick: (event, layer) =>
latestLayerHandlersRef.current.onLayerClick(event, layer),
onSelectLayer: (layerId) =>
latestLayerHandlersRef.current.onSelectLayer(layerId),
onLayerContextMenu: (event, layer) =>
latestLayerHandlersRef.current.onLayerContextMenu(event, layer),
onLayerMouseEnter: (layerId) =>
latestLayerHandlersRef.current.onLayerMouseEnter(layerId),
onLayerMouseLeave: (layerId) =>
latestLayerHandlersRef.current.onLayerMouseLeave(layerId),
onOpenLayerMetadata: (layer) =>
latestLayerHandlersRef.current.onOpenLayerMetadata(layer),
onUpdateLayerAssetKind: (layerId, assetKind) =>
latestLayerHandlersRef.current.onUpdateLayerAssetKind(
layerId,
assetKind,
),
setOpenLayerKindMenuId: (value) =>
latestLayerHandlersRef.current.setOpenLayerKindMenuId(value),
}),
[],
);
const visibleLayers = useMemo(
() =>
layers
.filter((layer) => !layer.hidden)
.slice()
.sort((left, right) => left.zIndex - right.zIndex),
[layers],
);
useEffect(() => {
if (!openLayerKindMenuId) {
return undefined;
}
const closeKindMenuWhenClickingOutside = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (
openLayerKindMenuRef.current?.contains(target) ||
openLayerKindTriggerRef.current?.contains(target)
) {
return;
}
setOpenLayerKindMenuId(null);
};
document.addEventListener('pointerdown', closeKindMenuWhenClickingOutside);
return () => {
document.removeEventListener(
'pointerdown',
closeKindMenuWhenClickingOutside,
);
};
}, [openLayerKindMenuId]);
return (
<SharedCanvasWorld
className="image-canvas-editor__world"
data-testid="image-canvas-world"
viewport={viewport}
worldSize={CANVAS_WORLD_SIZE}
>
{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}
{visibleLayers.map((layer) => {
const isSelected = selectedLayerIdSet.has(layer.id);
const isHovered = hoveredLayerId === layer.id;
const layerGeneratingLabel = splittingIconSpritesheetLayerIds.has(
layer.id,
)
? '拆图中'
: generateDialog?.mode === 'edit' &&
generateDialog.status === 'generating' &&
generateDialog.sourceLayerId === layer.id
? '修改中'
: null;
return (
<MemoizedCanvasLayerNode
key={layer.id}
layer={layer}
isSelected={isSelected}
isHovered={isHovered}
layerGeneratingLabel={layerGeneratingLabel}
isKindMenuOpen={openLayerKindMenuId === layer.id}
inverseScaleStyle={inverseScaleStyle}
handlers={layerHandlers}
openLayerKindTriggerRef={openLayerKindTriggerRef}
openLayerKindMenuRef={openLayerKindMenuRef}
/>
);
})}
<SelectionOverlay
className="image-canvas-editor__canvas-marquee"
marquee={canvasMarquee}
viewport={viewport}
/>
{cropExpandPanel && cropExpandSourceLayer ? (
<div
className="image-canvas-editor__crop-expand-frame"
style={{
left: cropExpandPanel.frame.x,
top: cropExpandPanel.frame.y,
width: cropExpandPanel.frame.width,
height: cropExpandPanel.frame.height,
}}
aria-label="裁扩边界"
role="group"
>
<div
className="image-canvas-editor__crop-expand-source-mask"
aria-hidden="true"
style={{
left: cropExpandSourceLayer.x - cropExpandPanel.frame.x,
top: cropExpandSourceLayer.y - cropExpandPanel.frame.y,
width: cropExpandSourceLayer.width,
height: cropExpandSourceLayer.height,
}}
/>
{CROP_EXPAND_HANDLES.map((handle) => (
<button
key={handle.value}
type="button"
className={`image-canvas-editor__crop-expand-handle image-canvas-editor__crop-expand-handle--${handle.value}`}
aria-label={`拖拽裁扩${handle.label}`}
onPointerDown={(event) =>
onCropExpandHandlePointerDown(event, handle.value)
}
/>
))}
</div>
) : null}
{canvasGenerationDialogs.map((dialog) => {
const hasGeneratedLayer = Boolean(
dialog.generatedLayerId &&
layers.some((layer) => layer.id === dialog.generatedLayerId),
);
return dialog.placeholder && !hasGeneratedLayer
? (() => {
const isFocused = generateDialog?.id === dialog.id;
const isSelected = selectedLayerIdSet.has(
getCanvasGenerationSelectionId(dialog.id),
);
const showFocusedChrome =
isFocused ||
isSelected ||
dialog.status === 'generating' ||
dialog.status === 'pending-confirmation';
const placeholderMeta = getGenerationPlaceholderMeta(dialog);
return (
<div
key={dialog.id}
className={`image-canvas-editor__generation-frame ${
placeholderMeta.frameClassName
} ${
dialog.status === 'generating'
? 'image-canvas-editor__generation-frame--generating'
: ''
} ${
dialog.status === 'pending-confirmation'
? 'image-canvas-editor__generation-frame--pending-confirmation'
: ''
} ${
isFocused || isSelected
? 'image-canvas-editor__generation-frame--focused'
: ''
}`}
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)
}
onContextMenu={(event) =>
onGenerationFrameContextMenu(event, dialog)
}
onDoubleClick={() => onActivateGenerationDialog(dialog)}
>
{showFocusedChrome ? (
<span
className="image-canvas-editor__generation-frame-label"
style={inverseScaleStyle}
>
{placeholderMeta.labelIcon}
{getGenerationFrameLabel(dialog)}
</span>
) : null}
{placeholderMeta.badgeLabel ? (
<span
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${placeholderMeta.badgeClassName}`}
style={inverseScaleStyle}
>
{placeholderMeta.badgeLabel}
</span>
) : null}
{showFocusedChrome &&
dialog.mode !== 'audio-sound-effect' &&
dialog.mode !== 'audio-background-music' ? (
<span
className="image-canvas-editor__generation-frame-size"
style={inverseScaleStyle}
>
{`${dialog.placeholder.originalWidth} x ${dialog.placeholder.originalHeight}`}
</span>
) : null}
<span
className="image-canvas-editor__generation-frame-icon"
style={
dialog.mode === 'icon' && dialog.status !== 'generating'
? undefined
: inverseScaleStyle
}
>
{placeholderMeta.icon}
</span>
{dialog.status === 'generating' ? (
<span
className="image-canvas-editor__generation-frame-progress"
role="status"
>
生成中
</span>
) : null}
{dialog.status === 'pending-confirmation' ? (
<span
className="image-canvas-editor__generation-frame-progress image-canvas-editor__generation-frame-progress--pending-confirmation"
role="status"
>
结果待确认
</span>
) : null}
</div>
);
})()
: null;
})}
{(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'scene' ||
generateDialog?.mode === 'spec' ||
generateDialog?.mode === 'character' ||
generateDialog?.mode === 'icon' ||
generateDialog?.mode === 'ui-design' ||
generateDialog?.mode === 'quick-edit' ||
generateDialog?.mode === 'publication' ||
generateDialog?.mode === 'character-animation' ||
generateDialog?.mode === 'video' ||
generateDialog?.mode === 'audio-sound-effect' ||
generateDialog?.mode === 'audio-background-music') &&
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}
</SharedCanvasWorld>
);
}