Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user