bedee3ba8d
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
313 lines
9.4 KiB
TypeScript
313 lines
9.4 KiB
TypeScript
import {
|
|
CopyOutlined,
|
|
DeleteOutlined,
|
|
DownloadOutlined,
|
|
DownOutlined,
|
|
ExpandOutlined,
|
|
MutedOutlined,
|
|
PauseCircleOutlined,
|
|
PictureOutlined,
|
|
PlayCircleOutlined,
|
|
ReloadOutlined,
|
|
SaveOutlined,
|
|
SoundOutlined,
|
|
ThunderboltOutlined,
|
|
} from "@ant-design/icons";
|
|
import { useEffect, useRef, useState, type ChangeEvent, type MouseEvent } from "react";
|
|
import type { CanvasOption } from "./canvasTypes";
|
|
import { getOptionLabel } from "./canvasUtils";
|
|
import { formatCanvasVideoTime } from "./canvasWorkflowDeserialize";
|
|
|
|
export function CanvasSelectChip({
|
|
value,
|
|
options,
|
|
open,
|
|
onToggle,
|
|
onChange,
|
|
ariaLabel,
|
|
className,
|
|
compact = false,
|
|
}: {
|
|
value: string;
|
|
options: CanvasOption[];
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
onChange: (value: string) => void;
|
|
ariaLabel: string;
|
|
className?: string;
|
|
compact?: boolean;
|
|
}) {
|
|
return (
|
|
<div className={`canvas-select-chip${open ? " is-open" : ""}${compact ? " canvas-select-chip--compact" : ""}${className ? ` ${className}` : ""}`}>
|
|
<button
|
|
type="button"
|
|
className="canvas-select-chip__trigger"
|
|
aria-label={ariaLabel}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onToggle();
|
|
}}
|
|
>
|
|
<span className="canvas-select-chip__value">{getOptionLabel(options, value)}</span>
|
|
<DownOutlined className="canvas-select-chip__arrow" />
|
|
</button>
|
|
{open ? (
|
|
<div
|
|
className="canvas-select-chip__dropdown"
|
|
role="listbox"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
{options.map((option, index) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
className={`canvas-select-chip__option${option.value === value ? " is-active" : ""}`}
|
|
role="option"
|
|
aria-selected={option.value === value}
|
|
style={{ animationDelay: `${index * 18}ms` }}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onChange(option.value);
|
|
}}
|
|
>
|
|
<span className="canvas-select-chip__option-dot" />
|
|
<span>{option.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
export function CanvasNodeVideoPlayer({ src, title, onVideoMeta }: { src: string; title: string; onVideoMeta?: (width: number, height: number) => void }) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
useEffect(() => {
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
}, [src]);
|
|
|
|
const stopPlayerEvent = (event: MouseEvent<HTMLElement>) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
|
|
const syncVideoState = () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
setCurrentTime(video.currentTime || 0);
|
|
setDuration(Number.isFinite(video.duration) ? video.duration : 0);
|
|
setIsMuted(video.muted);
|
|
setIsPlaying(!video.paused && !video.ended);
|
|
};
|
|
|
|
const togglePlayback = async (event: MouseEvent<HTMLButtonElement>) => {
|
|
stopPlayerEvent(event);
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
if (video.paused || video.ended) {
|
|
try {
|
|
await video.play();
|
|
} catch {
|
|
setIsPlaying(false);
|
|
}
|
|
} else {
|
|
video.pause();
|
|
}
|
|
syncVideoState();
|
|
};
|
|
|
|
const toggleMuted = (event: MouseEvent<HTMLButtonElement>) => {
|
|
stopPlayerEvent(event);
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
video.muted = !video.muted;
|
|
setIsMuted(video.muted);
|
|
};
|
|
|
|
const handleSeek = (event: ChangeEvent<HTMLInputElement>) => {
|
|
event.stopPropagation();
|
|
const video = videoRef.current;
|
|
const nextTime = Number(event.target.value);
|
|
if (!video || !Number.isFinite(nextTime)) return;
|
|
video.currentTime = nextTime;
|
|
setCurrentTime(nextTime);
|
|
};
|
|
|
|
return (
|
|
<div className={`studio-canvas-video-player${isPlaying ? " is-playing" : ""}`}>
|
|
<video
|
|
ref={videoRef}
|
|
src={src}
|
|
playsInline
|
|
preload="metadata"
|
|
onLoadedMetadata={() => {
|
|
syncVideoState();
|
|
const video = videoRef.current;
|
|
if (video && video.videoWidth > 0 && video.videoHeight > 0 && onVideoMeta) {
|
|
onVideoMeta(video.videoWidth, video.videoHeight);
|
|
}
|
|
}}
|
|
onTimeUpdate={syncVideoState}
|
|
onPlay={syncVideoState}
|
|
onPause={syncVideoState}
|
|
onEnded={syncVideoState}
|
|
aria-label={title}
|
|
/>
|
|
<span className="studio-canvas-video-player__shade" aria-hidden="true" />
|
|
<button
|
|
type="button"
|
|
className="studio-canvas-video-player__center"
|
|
aria-label={isPlaying ? "Pause video preview" : "Play video preview"}
|
|
title={isPlaying ? "Pause" : "Play"}
|
|
onMouseDown={stopPlayerEvent}
|
|
onClick={togglePlayback}
|
|
>
|
|
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
|
</button>
|
|
<div className="studio-canvas-video-player__controls" onMouseDown={stopPlayerEvent}>
|
|
<button
|
|
type="button"
|
|
className="studio-canvas-video-player__button"
|
|
aria-label={isPlaying ? "Pause video preview" : "Play video preview"}
|
|
onClick={togglePlayback}
|
|
>
|
|
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
|
</button>
|
|
<span className="studio-canvas-video-player__time">
|
|
{formatCanvasVideoTime(currentTime)} / {formatCanvasVideoTime(duration)}
|
|
</span>
|
|
<input
|
|
className="studio-canvas-video-player__seek"
|
|
type="range"
|
|
min="0"
|
|
max={Math.max(duration, 0)}
|
|
step="0.1"
|
|
value={Math.min(currentTime, duration || currentTime)}
|
|
aria-label="Video preview progress"
|
|
onMouseDown={stopPlayerEvent}
|
|
onChange={handleSeek}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="studio-canvas-video-player__button"
|
|
aria-label={isMuted ? "Unmute video preview" : "Mute video preview"}
|
|
onClick={toggleMuted}
|
|
>
|
|
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface CanvasNodeToolbarAction {
|
|
key: string;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
disabled?: boolean;
|
|
loading?: boolean;
|
|
}
|
|
|
|
export function CanvasNodeToolbar({
|
|
actions,
|
|
onAction,
|
|
moreActions,
|
|
onMoreAction,
|
|
}: {
|
|
actions: CanvasNodeToolbarAction[];
|
|
onAction: (key: string) => void;
|
|
moreActions?: CanvasNodeToolbarAction[];
|
|
onMoreAction?: (key: string) => void;
|
|
}) {
|
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
const moreRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!moreOpen) return;
|
|
const handler = (e: globalThis.MouseEvent) => {
|
|
if (moreRef.current && !moreRef.current.contains(e.target as Node)) {
|
|
setMoreOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handler);
|
|
return () => document.removeEventListener("mousedown", handler);
|
|
}, [moreOpen]);
|
|
|
|
return (
|
|
<div
|
|
className="studio-canvas-node-toolbar"
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
{actions.map((action) => (
|
|
<button
|
|
key={action.key}
|
|
type="button"
|
|
className={`studio-canvas-node-toolbar__btn${action.disabled ? " is-disabled" : ""}${action.loading ? " is-loading" : ""}`}
|
|
disabled={action.disabled || action.loading}
|
|
title={action.label}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onAction(action.key);
|
|
}}
|
|
>
|
|
{action.icon}
|
|
<span>{action.label}</span>
|
|
</button>
|
|
))}
|
|
{moreActions && moreActions.length > 0 && (
|
|
<div className="studio-canvas-node-toolbar__more" ref={moreRef}>
|
|
<button
|
|
type="button"
|
|
className={`studio-canvas-node-toolbar__btn studio-canvas-node-toolbar__more-trigger${moreOpen ? " is-open" : ""}`}
|
|
title="更多操作"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setMoreOpen((v) => !v);
|
|
}}
|
|
>
|
|
<ExpandOutlined />
|
|
<span>更多</span>
|
|
</button>
|
|
{moreOpen && (
|
|
<div className="studio-canvas-node-toolbar__dropdown">
|
|
{moreActions.map((action) => (
|
|
<button
|
|
key={action.key}
|
|
type="button"
|
|
className={`studio-canvas-node-toolbar__dropdown-item${action.disabled ? " is-disabled" : ""}`}
|
|
disabled={action.disabled}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setMoreOpen(false);
|
|
onMoreAction?.(action.key);
|
|
}}
|
|
>
|
|
{action.icon}
|
|
<span>{action.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|