c39d63b65f
## 修改 - 按动画帧合并高频拖动更新,结束时持久化最终状态 - 对未移动图层子树做 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>
1483 lines
46 KiB
TypeScript
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>
|
|
);
|
|
}
|