Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
||||
import { canvasGenerationProgressStyle } from "./canvasUtils";
|
||||
|
||||
type NodeGenStatus = "submitting" | "running" | "success" | "error";
|
||||
|
||||
interface CanvasSmoothedProgressRingProps {
|
||||
progress: number;
|
||||
status: NodeGenStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function CanvasSmoothedProgressRing({ progress, status, message }: CanvasSmoothedProgressRingProps) {
|
||||
const smoothed = useSmoothedProgress(progress, status);
|
||||
return (
|
||||
<div
|
||||
className="studio-canvas-node-generation-progress"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={canvasGenerationProgressStyle(smoothed)}
|
||||
>
|
||||
<span className="studio-canvas-node-generation-progress__ring" aria-hidden="true" />
|
||||
<strong>{message}</strong>
|
||||
<em>{smoothed}%</em>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import type { WebCanvasWorkflowAssetRef } from "../../types";
|
||||
|
||||
interface CanvasGeneratedResultInput {
|
||||
url: string;
|
||||
taskId: string;
|
||||
mediaType: string;
|
||||
resultType?: "image" | "video";
|
||||
ossKey?: string | null;
|
||||
originalUrl?: string | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
interface PersistCanvasGeneratedResultInput extends CanvasGeneratedResultInput {
|
||||
title: string;
|
||||
client?: CanvasAssetPersistenceClient;
|
||||
}
|
||||
|
||||
interface CanvasAssetPersistenceClient {
|
||||
downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }>;
|
||||
uploadAsset(input: {
|
||||
dataUrl: string;
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
scope?: string;
|
||||
}): Promise<{ url: string; signedUrl?: string; ossKey?: string }>;
|
||||
uploadAssetByUrl(input: {
|
||||
sourceUrl: string;
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
scope?: string;
|
||||
}): Promise<{ url: string; signedUrl?: string; ossKey?: string }>;
|
||||
fetchAsset?: (url: string) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
headers: { get(name: string): string | null };
|
||||
blob(): Promise<Blob>;
|
||||
}>;
|
||||
}
|
||||
|
||||
function getDefaultClient(): CanvasAssetPersistenceClient {
|
||||
return {
|
||||
downloadTaskResult: (taskId) => aiGenerationClient.downloadTaskResult(taskId),
|
||||
uploadAsset: (input) => aiGenerationClient.uploadAsset(input),
|
||||
uploadAssetByUrl: (input) => aiGenerationClient.uploadAssetByUrl(input),
|
||||
fetchAsset: (url) => fetch(url, { credentials: "omit" }),
|
||||
};
|
||||
}
|
||||
|
||||
function getGeneratedResultOssKey(url: string): string | null {
|
||||
try {
|
||||
const key = decodeURIComponent(new URL(url, globalThis.location?.href || "http://localhost").pathname.replace(/^\/+/, ""));
|
||||
return /^users\/[^/]+\/generation-results\/.+/i.test(key) ? key : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCanvasAssetRefFromGeneratedResult(
|
||||
input: CanvasGeneratedResultInput,
|
||||
): WebCanvasWorkflowAssetRef {
|
||||
const inferredOssKey = input.ossKey || getGeneratedResultOssKey(input.url);
|
||||
const mediaType = input.mediaType || (input.resultType === "video" ? "video/mp4" : "image/png");
|
||||
return {
|
||||
url: input.url,
|
||||
mediaType,
|
||||
sourceTaskId: input.taskId,
|
||||
ossKey: inferredOssKey || null,
|
||||
originalUrl: input.originalUrl || null,
|
||||
expiresAt: input.expiresAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
function getCanvasResultExtension(mediaType: string, fallbackUrl: string) {
|
||||
const mime = mediaType.split(";")[0]?.trim().toLowerCase();
|
||||
if (mime === "image/jpeg") return "jpg";
|
||||
if (mime === "image/png") return "png";
|
||||
if (mime === "image/webp") return "webp";
|
||||
if (mime === "image/gif") return "gif";
|
||||
if (mime === "video/mp4") return "mp4";
|
||||
if (mime === "video/webm") return "webm";
|
||||
|
||||
try {
|
||||
const matched = new URL(fallbackUrl, globalThis.location?.href || "http://localhost")
|
||||
.pathname
|
||||
.match(/\.([a-z0-9]{2,5})$/i);
|
||||
if (matched?.[1]) return matched[1].toLowerCase();
|
||||
} catch {
|
||||
// Keep media fallback below.
|
||||
}
|
||||
|
||||
return mediaType.startsWith("video/") ? "mp4" : "png";
|
||||
}
|
||||
|
||||
function buildCanvasResultFileName(title: string, mediaType: string, fallbackUrl: string) {
|
||||
const normalized = title
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 80)
|
||||
.trim();
|
||||
return `${normalized || "canvas-result"}.${getCanvasResultExtension(mediaType, fallbackUrl)}`;
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read canvas result"));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error || new Error("Unable to read canvas result"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadCanvasResultBlob(
|
||||
input: PersistCanvasGeneratedResultInput,
|
||||
client: CanvasAssetPersistenceClient,
|
||||
) {
|
||||
try {
|
||||
return await client.downloadTaskResult(input.taskId);
|
||||
} catch {
|
||||
const fetchAsset = client.fetchAsset || ((url: string) => fetch(url, { credentials: "omit" }));
|
||||
const response = await fetchAsset(input.url);
|
||||
if (!response.ok) throw new Error(`Canvas result request failed: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
return {
|
||||
blob,
|
||||
contentType: blob.type || response.headers.get("content-type") || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistCanvasGeneratedResultAsset(
|
||||
input: PersistCanvasGeneratedResultInput,
|
||||
): Promise<WebCanvasWorkflowAssetRef> {
|
||||
const fallbackRef = createCanvasAssetRefFromGeneratedResult(input);
|
||||
if (fallbackRef.ossKey) return fallbackRef;
|
||||
|
||||
const client = input.client || getDefaultClient();
|
||||
const fallbackMediaType = input.mediaType || (input.resultType === "video" ? "video/mp4" : "image/png");
|
||||
const name = buildCanvasResultFileName(input.title, fallbackMediaType, input.url);
|
||||
|
||||
// Try server-side URL-to-OSS first
|
||||
try {
|
||||
const uploaded = await client.uploadAssetByUrl({
|
||||
sourceUrl: input.url,
|
||||
name,
|
||||
mimeType: fallbackMediaType,
|
||||
scope: "canvas-node-result",
|
||||
});
|
||||
return createCanvasAssetRefFromGeneratedResult({
|
||||
...input,
|
||||
url: uploaded.url,
|
||||
ossKey: uploaded.ossKey || null,
|
||||
mediaType: fallbackMediaType,
|
||||
originalUrl: input.originalUrl || input.url,
|
||||
});
|
||||
} catch (urlError) {
|
||||
console.warn("[canvas] URL upload failed, falling back to download+base64:", urlError instanceof Error ? urlError.message : urlError);
|
||||
}
|
||||
|
||||
// Fallback: download → base64 → upload
|
||||
try {
|
||||
const downloaded = await downloadCanvasResultBlob(input, client);
|
||||
const mimeType = downloaded.contentType || downloaded.blob.type || fallbackMediaType;
|
||||
const dataUrl = await blobToDataUrl(downloaded.blob);
|
||||
const uploaded = await client.uploadAsset({
|
||||
dataUrl,
|
||||
name: downloaded.filename || name,
|
||||
mimeType,
|
||||
scope: "canvas-node-result",
|
||||
});
|
||||
|
||||
return createCanvasAssetRefFromGeneratedResult({
|
||||
...input,
|
||||
url: uploaded.url,
|
||||
ossKey: uploaded.ossKey || null,
|
||||
mediaType: mimeType,
|
||||
originalUrl: input.originalUrl || input.url,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error || "");
|
||||
if (/413|too large/i.test(msg)) {
|
||||
console.warn("[canvas] OSS upload rejected (file too large), using temporary URL:", msg);
|
||||
} else {
|
||||
console.warn("[canvas] result persistence fallback:", error);
|
||||
}
|
||||
return fallbackRef;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { PublishCommunityCaseInput } from "../../api/communityClient";
|
||||
import type { WebCanvasWorkflow } from "../../types";
|
||||
|
||||
interface UploadedWorkflowAsset {
|
||||
url: string;
|
||||
ossKey?: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
interface CanvasCommunityCaseInput {
|
||||
workflow: WebCanvasWorkflow;
|
||||
projectId?: string | null;
|
||||
uploadedWorkflow: UploadedWorkflowAsset;
|
||||
}
|
||||
|
||||
function getCanvasWorkflowCoverUrl(workflow: WebCanvasWorkflow): string {
|
||||
return workflow.nodes.find((node) => node.previewUrl)?.previewUrl || "";
|
||||
}
|
||||
|
||||
function shortWorkflowDescription(workflow: WebCanvasWorkflow): string {
|
||||
const promptNode = workflow.nodes.find((node) => node.detail.trim());
|
||||
return promptNode?.detail.trim().slice(0, 120) || `包含 ${workflow.nodes.length} 个节点的画布工作流`;
|
||||
}
|
||||
|
||||
export function textToDataUrl(text: string, mimeType: string): string {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return `data:${mimeType};base64,${btoa(binary)}`;
|
||||
}
|
||||
|
||||
export function buildCanvasWorkflowJson(workflow: WebCanvasWorkflow): string {
|
||||
return JSON.stringify(workflow, null, 2);
|
||||
}
|
||||
|
||||
export function buildWorkflowFileName(title: string): string {
|
||||
const normalized = title.trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, "-");
|
||||
return `${(normalized || "canvas-workflow").slice(0, 72)}.json`;
|
||||
}
|
||||
|
||||
export function buildCanvasCommunityCaseInput({
|
||||
workflow,
|
||||
projectId,
|
||||
uploadedWorkflow,
|
||||
}: CanvasCommunityCaseInput): PublishCommunityCaseInput {
|
||||
const title = workflow.title.trim() || "画布工作流";
|
||||
const coverUrl = getCanvasWorkflowCoverUrl(workflow);
|
||||
const workflowAsset = {
|
||||
assetType: "workflow" as const,
|
||||
title,
|
||||
url: uploadedWorkflow.url,
|
||||
ossKey: uploadedWorkflow.ossKey,
|
||||
metadata: {
|
||||
workflow,
|
||||
fileName: uploadedWorkflow.fileName,
|
||||
contentType: "application/json",
|
||||
},
|
||||
};
|
||||
const coverAsset = coverUrl
|
||||
? [
|
||||
{
|
||||
assetType: "cover" as const,
|
||||
title: `${title} 封面`,
|
||||
url: coverUrl,
|
||||
metadata: { source: "workflow-preview" },
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
projectId: projectId || workflow.id,
|
||||
title,
|
||||
description: workflow.description.trim() || shortWorkflowDescription(workflow),
|
||||
coverUrl: coverUrl || null,
|
||||
tags: ["工作流案例", "画布页面社区"],
|
||||
metadata: {
|
||||
source: "canvas-workflow-export",
|
||||
communitySurface: "canvas",
|
||||
category: "工作流案例",
|
||||
workflow,
|
||||
workflowSnapshot: workflow,
|
||||
workflowData: workflow,
|
||||
workflowOssKey: uploadedWorkflow.ossKey || null,
|
||||
workflowUrl: uploadedWorkflow.url || null,
|
||||
nodeCount: workflow.nodes.length,
|
||||
edgeCount: workflow.edges.length,
|
||||
settings: workflow.settings,
|
||||
},
|
||||
assets: [workflowAsset, ...coverAsset],
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
ENTERPRISE_DEFAULT_VIDEO_MODEL,
|
||||
ENTERPRISE_DEFAULT_VIDEO_RESOLUTION,
|
||||
ENTERPRISE_VIDEO_MODEL_OPTIONS,
|
||||
ENTERPRISE_VIDEO_RESOLUTION_OPTIONS,
|
||||
} from "../../utils/enterpriseVideoPolicy";
|
||||
import type {
|
||||
CanvasNodeKind,
|
||||
CanvasNodeSize,
|
||||
CanvasOption,
|
||||
CanvasStylePickerTab,
|
||||
CanvasVideoMode,
|
||||
} from "./canvasTypes";
|
||||
|
||||
// --- Connector follow behavior ---
|
||||
export const connectorFollowRadius = 96;
|
||||
export const connectorFollowStrength = 1;
|
||||
export const connectorMaxFollowOffset = 62;
|
||||
export const connectorAnchorOutset = 9;
|
||||
|
||||
// --- Interaction thresholds ---
|
||||
export const canvasNodeClickMoveThreshold = 5;
|
||||
|
||||
// --- Auto-save timing ---
|
||||
export const canvasAutoSaveDebounceMs = 2_000;
|
||||
export const canvasAutoSaveIdleTimeoutMs = 800;
|
||||
|
||||
// --- Viewport zoom limits ---
|
||||
export const canvasViewportMinZoom = 0.25;
|
||||
export const canvasViewportMaxZoom = 2.6;
|
||||
|
||||
// --- Node sizes ---
|
||||
export const canvasNodeDefaultSizes: Record<CanvasNodeKind, CanvasNodeSize> = {
|
||||
text: { width: 380, height: 260 },
|
||||
image: { width: 460, height: 290 },
|
||||
video: { width: 560, height: 315 },
|
||||
};
|
||||
|
||||
export const canvasNodeMinSizes: Record<CanvasNodeKind, CanvasNodeSize> = {
|
||||
text: { width: 300, height: 220 },
|
||||
image: { width: 340, height: 220 },
|
||||
video: { width: 420, height: 236 },
|
||||
};
|
||||
|
||||
export const canvasNodeMaxSizes: Record<CanvasNodeKind, CanvasNodeSize> = {
|
||||
text: { width: 720, height: 520 },
|
||||
image: { width: 860, height: 620 },
|
||||
video: { width: 960, height: 540 },
|
||||
};
|
||||
|
||||
// --- Text model options ---
|
||||
export const textModelOptions = [
|
||||
{ id: "gemini-3.1-pro", label: "Gemini 3.1 Pro", description: "默认文本生成模型", speed: "20s" },
|
||||
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro", description: "通用文本生成模型", speed: "20s" },
|
||||
{ id: "gpt-4o", label: "GPT-4o", description: "通用文本生成模型", speed: "20s" },
|
||||
];
|
||||
export const defaultTextModelId = textModelOptions[0].id;
|
||||
|
||||
// --- Image model options ---
|
||||
export const imageModelOptions: CanvasOption[] = [
|
||||
{ value: "wan2.7-image", label: "wan 2.7 · 0.20 积分" },
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro · 0.20 积分" },
|
||||
{ value: "gpt-image-2", label: "GPT-Image-2 · 0.20 积分" },
|
||||
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP · 0.20 积分" },
|
||||
{ value: "nano-banana-pro", label: "Nano Banana Pro · 0.20 积分" },
|
||||
{ value: "nano-banana-2", label: "Nano Banana 2 · 0.20 积分" },
|
||||
{ value: "nano-banana-fast", label: "Nano Banana · 0.20 积分" },
|
||||
];
|
||||
|
||||
export const imageRatioOptions: CanvasOption[] = [
|
||||
{ value: "16:9", label: "16:9" },
|
||||
{ value: "9:16", label: "9:16" },
|
||||
{ value: "1:1", label: "1:1" },
|
||||
{ value: "4:3", label: "4:3" },
|
||||
{ value: "3:4", label: "3:4" },
|
||||
];
|
||||
|
||||
export const imageFocusRatioOptions: CanvasOption[] = [
|
||||
{ value: "16:9", label: "16:9" },
|
||||
{ value: "3:2", label: "3:2" },
|
||||
{ value: "4:3", label: "4:3" },
|
||||
{ value: "1:1", label: "1:1" },
|
||||
{ value: "3:4", label: "3:4" },
|
||||
{ value: "2:3", label: "2:3" },
|
||||
{ value: "9:16", label: "9:16" },
|
||||
];
|
||||
|
||||
export const imageQualityOptions: CanvasOption[] = [
|
||||
{ value: "1K", label: "1K" },
|
||||
{ value: "2K", label: "2K" },
|
||||
{ value: "4K", label: "4K" },
|
||||
];
|
||||
|
||||
// --- Style picker ---
|
||||
export const canvasStylePickerTabs: Array<{ key: CanvasStylePickerTab; label: string }> = [
|
||||
{ key: "square", label: "广场" },
|
||||
{ key: "favorites", label: "我的收藏" },
|
||||
{ key: "recent", label: "最近使用" },
|
||||
];
|
||||
|
||||
export const canvasStylePickerCategories = [
|
||||
"推荐",
|
||||
"摄影写真",
|
||||
"电商营销",
|
||||
"动漫游戏",
|
||||
"风格插画",
|
||||
"平面设计",
|
||||
"建筑及室内设计",
|
||||
"创意玩法",
|
||||
"文创周边",
|
||||
"小说推文",
|
||||
];
|
||||
|
||||
// --- 4K capable image models ---
|
||||
export const image4kCapableModels = new Set([
|
||||
"wan2.7-image-pro",
|
||||
"nano-banana-2",
|
||||
"nano-banana-pro",
|
||||
"nano-banana-pro-4k",
|
||||
"nano-banana-pro-4k-vip",
|
||||
"gpt-image-2-vip",
|
||||
]);
|
||||
|
||||
// --- Video model options ---
|
||||
export const videoModelOptions: CanvasOption[] = [
|
||||
...ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
];
|
||||
|
||||
// --- Video ratio & duration options ---
|
||||
export const videoRatioOptions: CanvasOption[] = [
|
||||
{ value: "21:9", label: "21:9" },
|
||||
{ value: "16:9", label: "16:9" },
|
||||
{ value: "4:3", label: "4:3" },
|
||||
{ value: "1:1", label: "1:1" },
|
||||
{ value: "3:4", label: "3:4" },
|
||||
{ value: "9:16", label: "9:16" },
|
||||
];
|
||||
|
||||
export const videoDurationOptions: CanvasOption[] = [
|
||||
{ value: "4", label: "4s" },
|
||||
{ value: "5", label: "5s" },
|
||||
{ value: "6", label: "6s" },
|
||||
{ value: "7", label: "7s" },
|
||||
{ value: "8", label: "8s" },
|
||||
{ value: "9", label: "9s" },
|
||||
{ value: "10", label: "10s" },
|
||||
{ value: "11", label: "11s" },
|
||||
{ value: "12", label: "12s" },
|
||||
{ value: "13", label: "13s" },
|
||||
{ value: "14", label: "14s" },
|
||||
{ value: "15", label: "15s" },
|
||||
];
|
||||
|
||||
// --- Video quality & resolution by model ---
|
||||
export const fallbackVideoQualityOptions: CanvasOption[] = [
|
||||
...ENTERPRISE_VIDEO_RESOLUTION_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
];
|
||||
|
||||
export const videoResolutionByModel: Record<string, CanvasOption[]> = {
|
||||
...Object.fromEntries(ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => [option.value, fallbackVideoQualityOptions])),
|
||||
};
|
||||
|
||||
export const videoDefaultQualityByModel: Record<string, string> = {
|
||||
...Object.fromEntries(ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => [option.value, ENTERPRISE_DEFAULT_VIDEO_RESOLUTION])),
|
||||
};
|
||||
|
||||
// --- Defaults ---
|
||||
export const defaultImageModel = "nano-banana-pro";
|
||||
export const defaultVideoModel = ENTERPRISE_DEFAULT_VIDEO_MODEL;
|
||||
export const canvasVideoModes: CanvasVideoMode[] = ["text2video", "img2video", "firstlast"];
|
||||
|
||||
// --- Poll constants ---
|
||||
export const imageNodeTaskPollIntervalMs = 3000;
|
||||
export const imageNodeTaskPollAttempts = 180;
|
||||
export const videoNodeTaskPollIntervalMs = 5000;
|
||||
export const videoNodeTaskPollAttempts = 360;
|
||||
|
||||
// --- Asset library ---
|
||||
export const assetTypePromptLabel: Record<import("../assets/localAssetStore").AssetLibraryCategory, string> = {
|
||||
character: "角色主体",
|
||||
scene: "环境场景",
|
||||
prop: "关键道具",
|
||||
video: "动态镜头",
|
||||
image: "图片素材",
|
||||
asset: "通用素材",
|
||||
other: "其他素材",
|
||||
};
|
||||
|
||||
export const assetLibraryCategories = [
|
||||
{ key: "character", label: "人物" },
|
||||
{ key: "scene", label: "场景" },
|
||||
{ key: "prop", label: "物品" },
|
||||
{ key: "video", label: "视频" },
|
||||
{ key: "image", label: "图片" },
|
||||
{ key: "asset", label: "素材" },
|
||||
{ key: "other", label: "其他" },
|
||||
] satisfies Array<{ key: import("../assets/localAssetStore").AssetLibraryCategory; label: string }>;
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||
import type {
|
||||
WebCanvasWorkflow,
|
||||
WebCanvasWorkflowAssetRef,
|
||||
WebCanvasWorkflowTaskRef,
|
||||
WebGenerationPreviewTask,
|
||||
WebProjectSummary,
|
||||
WebUserSession,
|
||||
} from "../../types";
|
||||
|
||||
export type { CreatePreviewTaskInput, WebCanvasWorkflow, WebCanvasWorkflowAssetRef, WebCanvasWorkflowTaskRef, WebGenerationPreviewTask, WebProjectSummary, WebUserSession };
|
||||
|
||||
export interface CanvasSaveWorkflowOptions {
|
||||
silent?: boolean;
|
||||
reason?: "manual" | "autosave" | "publish";
|
||||
}
|
||||
|
||||
export interface CanvasPageProps {
|
||||
workflow: WebCanvasWorkflow;
|
||||
projectId?: string | null;
|
||||
projects?: WebProjectSummary[];
|
||||
projectsLoaded?: boolean;
|
||||
onOpenCommunity: () => void;
|
||||
onOpenProject?: (project: WebProjectSummary) => void;
|
||||
onStartCreate?: () => void;
|
||||
isAuthenticated: boolean;
|
||||
session: WebUserSession | null;
|
||||
onOpenLogin: () => void;
|
||||
onSaveWorkflow?: (workflow: WebCanvasWorkflow, options?: CanvasSaveWorkflowOptions) => Promise<WebProjectSummary | void>;
|
||||
onCreateTask?: (input: CreatePreviewTaskInput) => Promise<WebGenerationPreviewTask>;
|
||||
}
|
||||
|
||||
export interface CanvasTextNode {
|
||||
id: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
content: string;
|
||||
isEditingContent: boolean;
|
||||
isComposerOpen: boolean;
|
||||
selectedModelId: string;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
size: CanvasNodeSize;
|
||||
}
|
||||
|
||||
export interface CanvasTextNodeDrag {
|
||||
nodeId: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export interface CanvasTextGenerationState {
|
||||
status: "running" | "success" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CanvasImageNode {
|
||||
id: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
imageUrl: string;
|
||||
model: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
fileName?: string;
|
||||
assetRef?: WebCanvasWorkflowAssetRef | null;
|
||||
taskRef?: WebCanvasWorkflowTaskRef | null;
|
||||
styleReference?: CanvasStyleReference;
|
||||
focusSelection?: CanvasImageFocusSelection;
|
||||
marking?: string;
|
||||
sourceTextNodeId?: string;
|
||||
sourceImageNodeId?: string;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
size: CanvasNodeSize;
|
||||
}
|
||||
|
||||
export interface CanvasImageReferenceItem {
|
||||
nodeId: string;
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export interface CanvasImageNodeDrag {
|
||||
nodeId: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export interface CanvasImageGenerationState {
|
||||
status: "submitting" | "running" | "success" | "error";
|
||||
message: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export interface CanvasImageFocusSelection {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: string;
|
||||
}
|
||||
|
||||
export interface CanvasImageFocusDrag {
|
||||
nodeId: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
rect: {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CanvasStyleReference {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
category: string;
|
||||
imageUrl: string;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export interface CanvasStyleCase extends CanvasStyleReference {
|
||||
summary: string;
|
||||
favoriteCount: number;
|
||||
likeCount: number;
|
||||
isFavorited: boolean;
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
export type CanvasStylePickerTab = "square" | "favorites" | "recent";
|
||||
|
||||
export interface CanvasVideoNode {
|
||||
id: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
videoUrl?: string;
|
||||
model: string;
|
||||
aspectRatio: string;
|
||||
resolution: string;
|
||||
duration: string;
|
||||
videoMode: "text2video" | "img2video" | "firstlast";
|
||||
assetRef?: WebCanvasWorkflowAssetRef | null;
|
||||
taskRef?: WebCanvasWorkflowTaskRef | null;
|
||||
marking?: string;
|
||||
cameraMotion?: string;
|
||||
sourceTextNodeId: string;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
size: CanvasNodeSize;
|
||||
}
|
||||
|
||||
export type CanvasVideoMode = CanvasVideoNode["videoMode"];
|
||||
|
||||
export interface CanvasVideoNodeDrag {
|
||||
nodeId: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export interface CanvasVideoGenerationState {
|
||||
status: "submitting" | "running" | "success" | "error";
|
||||
message: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export type CanvasNodeKind = "text" | "image" | "video";
|
||||
export type CanvasNodeSide = "left" | "right";
|
||||
export type CanvasNodePortSlot = "center";
|
||||
|
||||
export type CanvasCopiedNode =
|
||||
| { kind: "text"; node: CanvasTextNode }
|
||||
| { kind: "image"; node: CanvasImageNode }
|
||||
| { kind: "video"; node: CanvasVideoNode };
|
||||
|
||||
export interface CanvasNodeSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface CanvasSelectedNode {
|
||||
kind: CanvasNodeKind;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface CanvasNodePackage {
|
||||
id: string;
|
||||
title: string;
|
||||
nodeIds: CanvasSelectedNode[];
|
||||
collapsed?: boolean;
|
||||
collapsedBounds?: CanvasNodeBounds;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CanvasNodeBounds {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface CanvasNodePackageDrag {
|
||||
packageId: string;
|
||||
collapsed: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
hasMoved: boolean;
|
||||
textOrigins: Record<string, CanvasPoint>;
|
||||
imageOrigins: Record<string, CanvasPoint>;
|
||||
videoOrigins: Record<string, CanvasPoint>;
|
||||
collapsedBounds?: CanvasNodeBounds;
|
||||
}
|
||||
|
||||
export interface CanvasNodeResizeDrag {
|
||||
kind: CanvasNodeKind;
|
||||
nodeId: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originWidth: number;
|
||||
originHeight: number;
|
||||
}
|
||||
|
||||
export interface CanvasSelectionDrag {
|
||||
start: CanvasPoint;
|
||||
current: CanvasPoint;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export interface CanvasViewport {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface CanvasPanDrag {
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
}
|
||||
|
||||
export interface CanvasAlignGuide {
|
||||
axis: "x" | "y";
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface CanvasNodePort {
|
||||
nodeId: string;
|
||||
kind: CanvasNodeKind;
|
||||
side: CanvasNodeSide;
|
||||
slot: CanvasNodePortSlot;
|
||||
}
|
||||
|
||||
export interface CanvasManualLink {
|
||||
id: string;
|
||||
from: CanvasNodePort;
|
||||
to: CanvasNodePort;
|
||||
}
|
||||
|
||||
// --- Canvas @-mention types ---
|
||||
|
||||
export interface CanvasPromptMentionOption {
|
||||
token: string; // e.g. "@图片1"
|
||||
label: string; // e.g. "图片1"
|
||||
kind: "image" | "video" | "text";
|
||||
nodeId: string;
|
||||
nodeTitle: string;
|
||||
previewUrl?: string;
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
export interface CanvasPromptMentionState {
|
||||
open: boolean;
|
||||
query: string;
|
||||
start: number;
|
||||
caret: number;
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
export interface CanvasAssetSaveSource {
|
||||
kind: "text" | "image" | "video";
|
||||
name: string;
|
||||
description: string;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface CanvasProjectSaveState {
|
||||
status: "idle" | "saving" | "success" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CanvasFloatingMenuPosition {
|
||||
left: number;
|
||||
top: number;
|
||||
originLeft: number;
|
||||
originTop: number;
|
||||
}
|
||||
|
||||
export interface CanvasConnectorFollowOffset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface CanvasConnectorDrag {
|
||||
port: CanvasNodePort;
|
||||
startX: number;
|
||||
startY: number;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export interface CanvasPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface CanvasOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { aiGenerationClient, type AiTaskStatus } from "../../api/aiGenerationClient";
|
||||
import type { ServerCommunityCase } from "../../api/communityClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import type { WebCanvasWorkflow } from "../../types";
|
||||
import type { AssetLibraryCategory } from "../assets/localAssetStore";
|
||||
import { communityCaseToPromptCase, getCommunityCaseCover } from "../community/communityCaseUtils";
|
||||
import { toHappyHorseDisplayModel } from "../../utils/happyHorseRouting";
|
||||
import { toViduDisplayModel } from "../../utils/viduRouting";
|
||||
import { toPixverseDisplayModel } from "../../utils/pixverseRouting";
|
||||
import type {
|
||||
CanvasImageFocusSelection,
|
||||
CanvasNodeKind,
|
||||
CanvasNodePort,
|
||||
CanvasNodeSide,
|
||||
CanvasNodeSize,
|
||||
CanvasOption,
|
||||
CanvasPoint,
|
||||
CanvasSelectedNode,
|
||||
CanvasStyleCase,
|
||||
CanvasStyleReference,
|
||||
CanvasVideoMode,
|
||||
} from "./canvasTypes";
|
||||
import {
|
||||
assetLibraryCategories,
|
||||
assetTypePromptLabel,
|
||||
canvasNodeDefaultSizes,
|
||||
canvasNodeMaxSizes,
|
||||
canvasNodeMinSizes,
|
||||
canvasVideoModes,
|
||||
canvasViewportMaxZoom,
|
||||
canvasViewportMinZoom,
|
||||
defaultImageModel,
|
||||
defaultVideoModel,
|
||||
imageModelOptions,
|
||||
videoModelOptions,
|
||||
} from "./canvasConstants";
|
||||
|
||||
// ─── Utility Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getCanvasNodeSideDirection(side: CanvasNodeSide) {
|
||||
return side === "right" ? 1 : -1;
|
||||
}
|
||||
|
||||
export function normalizeCanvasLinkPorts(first: CanvasNodePort, second: CanvasNodePort) {
|
||||
if (first.side === second.side) return null;
|
||||
if (first.kind === second.kind && first.nodeId === second.nodeId) return null;
|
||||
|
||||
return first.side === "right"
|
||||
? { from: first, to: second }
|
||||
: { from: second, to: first };
|
||||
}
|
||||
|
||||
export function getCanvasPortIdentity(port: CanvasNodePort) {
|
||||
return `${port.kind}:${port.nodeId}:${port.side}:${port.slot}`;
|
||||
}
|
||||
|
||||
export function getCanvasLinkIdentity(from: CanvasNodePort, to: CanvasNodePort) {
|
||||
return `${getCanvasPortIdentity(from)}->${getCanvasPortIdentity(to)}`;
|
||||
}
|
||||
|
||||
export function moveCanvasNodesForPackageDrag<T extends { id: string; position: CanvasPoint }>(
|
||||
currentNodes: T[],
|
||||
originPositions: Record<string, CanvasPoint>,
|
||||
delta: CanvasPoint
|
||||
): T[] {
|
||||
return currentNodes.map((node) => {
|
||||
const origin = originPositions[node.id];
|
||||
if (!origin) return node;
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: origin.x + delta.x,
|
||||
y: origin.y + delta.y,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createCanvasNodeSize(kind: CanvasNodeKind) {
|
||||
return { ...canvasNodeDefaultSizes[kind] };
|
||||
}
|
||||
|
||||
export function clampCanvasNodeSize(kind: CanvasNodeKind, width: number, height: number): CanvasNodeSize {
|
||||
const minSize = canvasNodeMinSizes[kind];
|
||||
const maxSize = canvasNodeMaxSizes[kind];
|
||||
return {
|
||||
width: Math.min(maxSize.width, Math.max(minSize.width, Math.round(width))),
|
||||
height: Math.min(maxSize.height, Math.max(minSize.height, Math.round(height))),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCanvasSelectionKey(node: CanvasSelectedNode) {
|
||||
return `${node.kind}:${node.id}`;
|
||||
}
|
||||
|
||||
export function normalizeCanvasSelectionRect(start: CanvasPoint, current: CanvasPoint) {
|
||||
const left = Math.min(start.x, current.x);
|
||||
const top = Math.min(start.y, current.y);
|
||||
const width = Math.abs(current.x - start.x);
|
||||
const height = Math.abs(current.y - start.y);
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
export function doCanvasRectsIntersect(
|
||||
first: { left: number; top: number; width: number; height: number },
|
||||
second: { left: number; top: number; width: number; height: number }
|
||||
) {
|
||||
return (
|
||||
first.left <= second.left + second.width &&
|
||||
first.left + first.width >= second.left &&
|
||||
first.top <= second.top + second.height &&
|
||||
first.top + first.height >= second.top
|
||||
);
|
||||
}
|
||||
|
||||
export function clampCanvasViewportZoom(zoom: number) {
|
||||
return Math.min(canvasViewportMaxZoom, Math.max(canvasViewportMinZoom, zoom));
|
||||
}
|
||||
|
||||
export function getOptionLabel(options: CanvasOption[], value: string) {
|
||||
return options.find((option) => option.value === value)?.label || value;
|
||||
}
|
||||
|
||||
import {
|
||||
getImageQualityOptions,
|
||||
getDefaultImageQuality,
|
||||
getVideoQualityOptions,
|
||||
getDefaultVideoQuality,
|
||||
getVideoQualityLabel,
|
||||
} from "../../utils/modelOptions";
|
||||
|
||||
export {
|
||||
getImageQualityOptions,
|
||||
getDefaultImageQuality,
|
||||
getVideoQualityOptions,
|
||||
getDefaultVideoQuality,
|
||||
getVideoQualityLabel,
|
||||
};
|
||||
|
||||
export function hasCanvasOptionValue(options: CanvasOption[], value: string | undefined) {
|
||||
return Boolean(value && options.some((option) => option.value === value));
|
||||
}
|
||||
|
||||
export function getWorkflowNodeMetadataString(
|
||||
node: WebCanvasWorkflow["nodes"][number],
|
||||
key: string,
|
||||
fallback = "",
|
||||
) {
|
||||
const params = (node as unknown as { params?: Record<string, unknown> }).params;
|
||||
const paramValue = params?.[key];
|
||||
if (typeof paramValue === "string" && paramValue.trim()) return paramValue.trim();
|
||||
const metadata = (node as unknown as { metadata?: Record<string, unknown> }).metadata;
|
||||
const value = metadata?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
export function getWorkflowNodeStringField(
|
||||
node: WebCanvasWorkflow["nodes"][number],
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const params = (node as unknown as { params?: Record<string, unknown> }).params;
|
||||
const paramValue = params?.[key];
|
||||
if (typeof paramValue === "string") return paramValue;
|
||||
const metadata = (node as unknown as { metadata?: Record<string, unknown> }).metadata;
|
||||
const value = metadata?.[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function isLikelyImageFileName(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
return /\.(png|jpe?g|webp)(\?.*)?$/i.test(trimmed);
|
||||
}
|
||||
|
||||
export function getWorkflowImageNodePrompt(node: WebCanvasWorkflow["nodes"][number]) {
|
||||
const storedPrompt = getWorkflowNodeStringField(node, "prompt");
|
||||
if (storedPrompt !== undefined) return storedPrompt;
|
||||
const detail = node.detail || "";
|
||||
return isLikelyImageFileName(detail) ? "" : detail;
|
||||
}
|
||||
|
||||
export function getWorkflowImageNodeFileName(node: WebCanvasWorkflow["nodes"][number], index: number) {
|
||||
const storedFileName = getWorkflowNodeStringField(node, "fileName");
|
||||
if (storedFileName?.trim()) return storedFileName.trim();
|
||||
if (isLikelyImageFileName(node.detail || "")) return node.detail.trim();
|
||||
return node.label || `图片节点 ${index + 1}`;
|
||||
}
|
||||
|
||||
export function resolveWorkflowImageModel(node: WebCanvasWorkflow["nodes"][number], workflowModel: string) {
|
||||
const storedModel = getWorkflowNodeMetadataString(node, "model");
|
||||
if (hasCanvasOptionValue(imageModelOptions, storedModel)) return storedModel;
|
||||
if (hasCanvasOptionValue(imageModelOptions, workflowModel)) return workflowModel;
|
||||
return defaultImageModel;
|
||||
}
|
||||
|
||||
export function resolveWorkflowVideoModel(node: WebCanvasWorkflow["nodes"][number], workflowModel: string) {
|
||||
const raw = getWorkflowNodeMetadataString(node, "model");
|
||||
const storedModel = toPixverseDisplayModel(toViduDisplayModel(toHappyHorseDisplayModel(raw)));
|
||||
if (hasCanvasOptionValue(videoModelOptions, storedModel)) return storedModel;
|
||||
return defaultVideoModel;
|
||||
}
|
||||
|
||||
export function resolveWorkflowRatio(
|
||||
node: WebCanvasWorkflow["nodes"][number],
|
||||
workflowRatio: string,
|
||||
options: CanvasOption[],
|
||||
fallback: string,
|
||||
) {
|
||||
const storedRatio = getWorkflowNodeMetadataString(node, "aspectRatio");
|
||||
if (hasCanvasOptionValue(options, storedRatio)) return storedRatio;
|
||||
if (hasCanvasOptionValue(options, workflowRatio)) return workflowRatio;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function resolveWorkflowVideoMode(node: WebCanvasWorkflow["nodes"][number]) {
|
||||
const storedMode = getWorkflowNodeMetadataString(node, "videoMode");
|
||||
return canvasVideoModes.includes(storedMode as CanvasVideoMode) ? (storedMode as CanvasVideoMode) : "text2video";
|
||||
}
|
||||
|
||||
export function resolveImageQuality(model: string, imageSize: string) {
|
||||
const options = getImageQualityOptions(model || defaultImageModel);
|
||||
return options.some((option) => option.value === imageSize)
|
||||
? imageSize
|
||||
: getDefaultImageQuality(model || defaultImageModel);
|
||||
}
|
||||
|
||||
export function resolveVideoQuality(model: string, resolution: string) {
|
||||
const options = getVideoQualityOptions(model || defaultVideoModel);
|
||||
return options.some((option) => option.value === resolution)
|
||||
? resolution
|
||||
: getDefaultVideoQuality(model || defaultVideoModel);
|
||||
}
|
||||
|
||||
export function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read canvas image"));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error || new Error("Unable to read canvas image"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
onProgress: (e) => {
|
||||
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
},
|
||||
});
|
||||
if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
||||
return resultUrl;
|
||||
}
|
||||
|
||||
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
onProgress: (e) => {
|
||||
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
},
|
||||
});
|
||||
if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
||||
return resultUrl;
|
||||
}
|
||||
|
||||
export function normalizeCanvasGenerationProgress(state?: { status: "submitting" | "running" | "success" | "error"; progress?: number }) {
|
||||
if (!state) return 0;
|
||||
if (state.status === "success") return 100;
|
||||
if (state.status === "error") return 0;
|
||||
const serverProgress = Number(state.progress || 0);
|
||||
if (state.status === "submitting") return Math.max(6, Math.min(18, serverProgress || 8));
|
||||
return Math.max(18, Math.min(96, serverProgress || 36));
|
||||
}
|
||||
|
||||
export function canvasGenerationProgressStyle(progress: number) {
|
||||
return {
|
||||
"--canvas-generation-progress": `${Math.max(0, Math.min(100, progress)) * 3.6}deg`,
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
export function buildCopyTitle(title: string) {
|
||||
return title.includes("副本") ? title : `${title} 副本`;
|
||||
}
|
||||
|
||||
export function buildReversePromptFromAsset(asset: { type: AssetLibraryCategory; name: string; description: string }) {
|
||||
return `AI 反推提示词:${assetTypePromptLabel[asset.type]}「${asset.name}」,${asset.description},保留主体轮廓、材质细节、色彩氛围和镜头语言,电影感构图,高质量细节。`;
|
||||
}
|
||||
|
||||
export function positionFloatingMenu(clientX: number, clientY: number, width: number, height: number, offset = 0) {
|
||||
const edgeGap = 8;
|
||||
const canOpenRight = clientX + offset + width + edgeGap <= window.innerWidth;
|
||||
const canOpenBelow = clientY + offset + height + edgeGap <= window.innerHeight;
|
||||
|
||||
return {
|
||||
left: Math.max(edgeGap, canOpenRight ? clientX + offset : clientX - width - offset),
|
||||
top: Math.max(edgeGap, canOpenBelow ? clientY + offset : clientY - height - offset),
|
||||
};
|
||||
}
|
||||
|
||||
export function toCanvasStyleString(value: unknown, fallback = ""): string {
|
||||
if (typeof value === "string") return value.trim() || fallback;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function buildCanvasStyleKeywords(item: Omit<CanvasStyleCase, "keywords">, tags: string[] = []) {
|
||||
return [
|
||||
item.title,
|
||||
item.author,
|
||||
item.category,
|
||||
item.summary,
|
||||
item.prompt || "",
|
||||
...tags,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function communityCaseToCanvasStyleCase(item: ServerCommunityCase): CanvasStyleCase | null {
|
||||
const promptCase = communityCaseToPromptCase(item);
|
||||
const imageUrl = promptCase?.imageUrl || getCommunityCaseCover(item);
|
||||
if (!imageUrl) return null;
|
||||
|
||||
const metadata = item.metadata || {};
|
||||
const title = promptCase?.title || item.title;
|
||||
const author = promptCase?.author || item.username || "OmniAI";
|
||||
const category =
|
||||
promptCase?.category ||
|
||||
toCanvasStyleString(metadata.category ?? metadata.styleCategory) ||
|
||||
item.tags.find(Boolean) ||
|
||||
"社区风格";
|
||||
const prompt =
|
||||
promptCase?.prompt ||
|
||||
toCanvasStyleString(metadata.prompt ?? metadata.inputPrompt ?? metadata.descriptionPrompt, item.description || "");
|
||||
const summary = promptCase?.summary || item.description || prompt || "来自社区广场的风格参考";
|
||||
const baseCase: Omit<CanvasStyleCase, "keywords"> = {
|
||||
id: promptCase?.id || `server-style-case-${item.id}`,
|
||||
title,
|
||||
author,
|
||||
category,
|
||||
imageUrl,
|
||||
prompt,
|
||||
summary,
|
||||
favoriteCount: item.favoriteCount,
|
||||
likeCount: item.likeCount,
|
||||
isFavorited: item.isFavorited,
|
||||
};
|
||||
|
||||
return {
|
||||
...baseCase,
|
||||
keywords: buildCanvasStyleKeywords(baseCase, item.tags),
|
||||
};
|
||||
}
|
||||
|
||||
export function createStyleReferenceFromCase(item: CanvasStyleCase): CanvasStyleReference {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
author: item.author,
|
||||
category: item.category,
|
||||
imageUrl: item.imageUrl,
|
||||
prompt: item.prompt,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampCanvasPercent(value: number) {
|
||||
return Math.min(100, Math.max(0, value));
|
||||
}
|
||||
|
||||
export function parseCanvasRatio(value: string): number | null {
|
||||
const [width, height] = value.split(":").map(Number);
|
||||
return width > 0 && height > 0 ? width / height : null;
|
||||
}
|
||||
|
||||
export function normalizeImageFocusSelectionFromAnchor(
|
||||
start: { x: number; y: number },
|
||||
end: { x: number; y: number },
|
||||
ratio: string,
|
||||
containerRatio: number,
|
||||
): CanvasImageFocusSelection {
|
||||
const targetRatio = parseCanvasRatio(ratio);
|
||||
const directionX = end.x >= start.x ? 1 : -1;
|
||||
const directionY = end.y >= start.y ? 1 : -1;
|
||||
let width = Math.abs(end.x - start.x);
|
||||
let height = Math.abs(end.y - start.y);
|
||||
|
||||
if (targetRatio && Number.isFinite(containerRatio) && containerRatio > 0 && width > 0 && height > 0) {
|
||||
const percentRatio = targetRatio / containerRatio;
|
||||
if (width / height > percentRatio) {
|
||||
width = height * percentRatio;
|
||||
} else {
|
||||
height = width / percentRatio;
|
||||
}
|
||||
}
|
||||
|
||||
const maxWidth = directionX > 0 ? 100 - start.x : start.x;
|
||||
const maxHeight = directionY > 0 ? 100 - start.y : start.y;
|
||||
width = Math.min(width, maxWidth);
|
||||
height = Math.min(height, maxHeight);
|
||||
|
||||
if (targetRatio && Number.isFinite(containerRatio) && containerRatio > 0 && width > 0 && height > 0) {
|
||||
const percentRatio = targetRatio / containerRatio;
|
||||
if (width / height > percentRatio) {
|
||||
width = height * percentRatio;
|
||||
} else {
|
||||
height = width / percentRatio;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x: clampCanvasPercent(directionX > 0 ? start.x : start.x - width),
|
||||
y: clampCanvasPercent(directionY > 0 ? start.y : start.y - height),
|
||||
width: clampCanvasPercent(width),
|
||||
height: clampCanvasPercent(height),
|
||||
ratio,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyImageFocusRatioFromTopLeft(
|
||||
selection: CanvasImageFocusSelection,
|
||||
ratio: string,
|
||||
containerRatio: number,
|
||||
): CanvasImageFocusSelection {
|
||||
const targetRatio = parseCanvasRatio(ratio);
|
||||
if (!targetRatio || !Number.isFinite(containerRatio) || containerRatio <= 0) {
|
||||
return { ...selection, ratio };
|
||||
}
|
||||
|
||||
const percentRatio = targetRatio / containerRatio;
|
||||
let width = Math.max(4, selection.width);
|
||||
let height = Math.max(4, selection.height);
|
||||
if (width / height > percentRatio) {
|
||||
width = height * percentRatio;
|
||||
} else {
|
||||
height = width / percentRatio;
|
||||
}
|
||||
|
||||
const maxWidth = Math.max(4, 100 - selection.x);
|
||||
const maxHeight = Math.max(4, 100 - selection.y);
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth;
|
||||
height = width / percentRatio;
|
||||
}
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = height * percentRatio;
|
||||
}
|
||||
|
||||
return {
|
||||
...selection,
|
||||
width: clampCanvasPercent(width),
|
||||
height: clampCanvasPercent(height),
|
||||
ratio,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkflowNodeStyleReference(node: WebCanvasWorkflow["nodes"][number]): CanvasStyleReference | undefined {
|
||||
const metadata = (node as unknown as { metadata?: Record<string, unknown> }).metadata;
|
||||
const rawStyle = metadata?.styleReference;
|
||||
if (!rawStyle || typeof rawStyle !== "object" || Array.isArray(rawStyle)) return undefined;
|
||||
const style = rawStyle as Record<string, unknown>;
|
||||
const imageUrl = toCanvasStyleString(style.imageUrl);
|
||||
const title = toCanvasStyleString(style.title);
|
||||
if (!imageUrl || !title) return undefined;
|
||||
return {
|
||||
id: toCanvasStyleString(style.id, `workflow-style-${node.id}`),
|
||||
title,
|
||||
author: toCanvasStyleString(style.author, "OmniAI"),
|
||||
category: toCanvasStyleString(style.category, "社区风格"),
|
||||
imageUrl,
|
||||
prompt: toCanvasStyleString(style.prompt),
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkflowNodeFocusSelection(node: WebCanvasWorkflow["nodes"][number]): CanvasImageFocusSelection | undefined {
|
||||
const metadata = (node as unknown as { metadata?: Record<string, unknown> }).metadata;
|
||||
const rawSelection = metadata?.focusSelection;
|
||||
if (!rawSelection || typeof rawSelection !== "object" || Array.isArray(rawSelection)) return undefined;
|
||||
const selection = rawSelection as Record<string, unknown>;
|
||||
const width = Number(selection.width);
|
||||
const height = Number(selection.height);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return undefined;
|
||||
return {
|
||||
x: clampCanvasPercent(Number(selection.x) || 0),
|
||||
y: clampCanvasPercent(Number(selection.y) || 0),
|
||||
width: clampCanvasPercent(width),
|
||||
height: clampCanvasPercent(height),
|
||||
ratio: toCanvasStyleString(selection.ratio, "16:9"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { WebCanvasWorkflow, WebCanvasWorkflowNodePackage } from "../../types";
|
||||
import type { AssetLibraryCategory } from "../assets/localAssetStore";
|
||||
import type {
|
||||
CanvasImageNode,
|
||||
CanvasManualLink,
|
||||
CanvasNodeKind,
|
||||
CanvasNodePackage,
|
||||
CanvasNodeSize,
|
||||
CanvasSelectedNode,
|
||||
CanvasTextNode,
|
||||
CanvasVideoNode,
|
||||
} from "./canvasTypes";
|
||||
import {
|
||||
assetLibraryCategories,
|
||||
canvasNodeDefaultSizes,
|
||||
defaultTextModelId,
|
||||
imageRatioOptions,
|
||||
videoRatioOptions,
|
||||
} from "./canvasConstants";
|
||||
import {
|
||||
clampCanvasNodeSize,
|
||||
getCanvasSelectionKey,
|
||||
getWorkflowImageNodeFileName,
|
||||
getWorkflowImageNodePrompt,
|
||||
getWorkflowNodeFocusSelection,
|
||||
getWorkflowNodeMetadataString,
|
||||
getWorkflowNodeStyleReference,
|
||||
resolveImageQuality,
|
||||
resolveVideoQuality,
|
||||
resolveWorkflowImageModel,
|
||||
resolveWorkflowRatio,
|
||||
resolveWorkflowVideoMode,
|
||||
resolveWorkflowVideoModel,
|
||||
} from "./canvasUtils";
|
||||
|
||||
export function resolveAssetCategory(value: string): AssetLibraryCategory {
|
||||
return assetLibraryCategories.find((category) => category.key === value || category.label === value)?.key || "prop";
|
||||
}
|
||||
|
||||
export function getWorkflowNodeCanvasKind(node: WebCanvasWorkflow["nodes"][number]): CanvasNodeKind {
|
||||
if (node.kind === "image") return "image";
|
||||
if (node.kind === "video") return "video";
|
||||
return "text";
|
||||
}
|
||||
|
||||
export function getWorkflowNodeSize(node: WebCanvasWorkflow["nodes"][number], kind: CanvasNodeKind): CanvasNodeSize {
|
||||
return clampCanvasNodeSize(
|
||||
kind,
|
||||
Number(node.size?.width) || canvasNodeDefaultSizes[kind].width,
|
||||
Number(node.size?.height) || canvasNodeDefaultSizes[kind].height,
|
||||
);
|
||||
}
|
||||
|
||||
export function createTextNodesFromWorkflow(workflow: WebCanvasWorkflow): CanvasTextNode[] {
|
||||
return workflow.nodes
|
||||
.filter((node) => getWorkflowNodeCanvasKind(node) === "text")
|
||||
.map((node, index) => ({
|
||||
id: node.id || `workflow-text-${index + 1}`,
|
||||
title: node.label || `文本节点 ${index + 1}`,
|
||||
prompt: node.kind === "prompt" ? node.detail : "",
|
||||
content: node.kind === "prompt" ? "" : node.detail,
|
||||
isEditingContent: false,
|
||||
isComposerOpen: node.kind === "prompt" && !node.detail,
|
||||
selectedModelId: defaultTextModelId,
|
||||
position: { ...node.position },
|
||||
size: getWorkflowNodeSize(node, "text"),
|
||||
}));
|
||||
}
|
||||
|
||||
export function createImageNodesFromWorkflow(workflow: WebCanvasWorkflow): CanvasImageNode[] {
|
||||
return workflow.nodes
|
||||
.filter((node) => getWorkflowNodeCanvasKind(node) === "image")
|
||||
.map((node, index) => {
|
||||
const model = resolveWorkflowImageModel(node, workflow.settings.model);
|
||||
const aspectRatio = resolveWorkflowRatio(node, workflow.settings.ratio, imageRatioOptions, "16:9");
|
||||
const imageSize = resolveImageQuality(
|
||||
model,
|
||||
getWorkflowNodeMetadataString(node, "imageSize", workflow.settings.resolution || "2K"),
|
||||
);
|
||||
return {
|
||||
id: node.id || `workflow-image-${index + 1}`,
|
||||
title: node.label || `图片节点 ${index + 1}`,
|
||||
prompt: getWorkflowImageNodePrompt(node),
|
||||
imageUrl: node.assetRef?.url || node.previewUrl || "",
|
||||
model,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
assetRef: node.assetRef || null,
|
||||
taskRef: node.taskRef || null,
|
||||
styleReference: getWorkflowNodeStyleReference(node),
|
||||
focusSelection: getWorkflowNodeFocusSelection(node),
|
||||
marking: getWorkflowNodeMetadataString(node, "marking", ""),
|
||||
fileName: getWorkflowImageNodeFileName(node, index),
|
||||
position: { ...node.position },
|
||||
size: getWorkflowNodeSize(node, "image"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createVideoNodesFromWorkflow(workflow: WebCanvasWorkflow): CanvasVideoNode[] {
|
||||
return workflow.nodes
|
||||
.filter((node) => getWorkflowNodeCanvasKind(node) === "video")
|
||||
.map((node, index) => {
|
||||
const model = resolveWorkflowVideoModel(node, workflow.settings.model);
|
||||
const aspectRatio = resolveWorkflowRatio(node, workflow.settings.ratio, videoRatioOptions, "16:9");
|
||||
const resolution = resolveVideoQuality(
|
||||
model,
|
||||
getWorkflowNodeMetadataString(node, "resolution", workflow.settings.resolution || "720p"),
|
||||
);
|
||||
return {
|
||||
id: node.id || `workflow-video-${index + 1}`,
|
||||
title: node.label || `视频节点 ${index + 1}`,
|
||||
prompt: node.detail || "",
|
||||
videoUrl: node.assetRef?.url || node.previewUrl || "",
|
||||
model,
|
||||
aspectRatio,
|
||||
resolution,
|
||||
duration: getWorkflowNodeMetadataString(node, "duration", String(workflow.settings.duration || "4")).replace(/s$/i, ""),
|
||||
videoMode: resolveWorkflowVideoMode(node),
|
||||
marking: getWorkflowNodeMetadataString(node, "marking", ""),
|
||||
cameraMotion: getWorkflowNodeMetadataString(node, "cameraMotion", ""),
|
||||
assetRef: node.assetRef || null,
|
||||
taskRef: node.taskRef || null,
|
||||
sourceTextNodeId: "",
|
||||
position: { ...node.position },
|
||||
size: getWorkflowNodeSize(node, "video"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createManualLinksFromWorkflow(workflow: WebCanvasWorkflow): CanvasManualLink[] {
|
||||
const kindById = new Map<string, CanvasNodeKind>(
|
||||
workflow.nodes.map((node) => [node.id, getWorkflowNodeCanvasKind(node)]),
|
||||
);
|
||||
|
||||
return workflow.edges.flatMap((edge, index) => {
|
||||
const sourceKind = kindById.get(edge.source);
|
||||
const targetKind = kindById.get(edge.target);
|
||||
if (!sourceKind || !targetKind || edge.source === edge.target) return [];
|
||||
return [
|
||||
{
|
||||
id: edge.id || `workflow-link-${index + 1}`,
|
||||
from: { kind: sourceKind, nodeId: edge.source, side: "right", slot: "center" },
|
||||
to: { kind: targetKind, nodeId: edge.target, side: "left", slot: "center" },
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function createNodePackagesFromWorkflow(workflow: WebCanvasWorkflow): CanvasNodePackage[] {
|
||||
const nodeKindById = new Map<string, CanvasNodeKind>(
|
||||
workflow.nodes.map((node) => [node.id, getWorkflowNodeCanvasKind(node)]),
|
||||
);
|
||||
|
||||
return (workflow.packages || []).flatMap((nodePackage, index) => {
|
||||
const uniqueNodeKeys = new Set<string>();
|
||||
const packageNodeIds = Array.isArray(nodePackage.nodeIds) ? nodePackage.nodeIds : [];
|
||||
const nodeIds = packageNodeIds.reduce<CanvasSelectedNode[]>((items, node) => {
|
||||
const kind = node.kind || nodeKindById.get(node.id);
|
||||
if ((kind !== "text" && kind !== "image" && kind !== "video") || !nodeKindById.has(node.id)) return items;
|
||||
const key = getCanvasSelectionKey({ kind, id: node.id });
|
||||
if (uniqueNodeKeys.has(key)) return items;
|
||||
uniqueNodeKeys.add(key);
|
||||
items.push({ kind, id: node.id });
|
||||
return items;
|
||||
}, []);
|
||||
if (nodeIds.length < 2) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: nodePackage.id || `canvas-node-package-${index + 1}`,
|
||||
title: nodePackage.title || `打包节点 ${index + 1}`,
|
||||
nodeIds,
|
||||
collapsed: nodePackage.collapsed === true,
|
||||
collapsedBounds: nodePackage.collapsedBounds || undefined,
|
||||
updatedAt: nodePackage.updatedAt,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkflowPackagesFromCanvasPackages(
|
||||
nodePackages: CanvasNodePackage[],
|
||||
): WebCanvasWorkflowNodePackage[] {
|
||||
return nodePackages.map((nodePackage) => ({
|
||||
id: nodePackage.id,
|
||||
title: nodePackage.title,
|
||||
nodeIds: nodePackage.nodeIds.map((node) => ({ kind: node.kind, id: node.id })),
|
||||
collapsed: nodePackage.collapsed === true,
|
||||
collapsedBounds: nodePackage.collapsedBounds || null,
|
||||
updatedAt: nodePackage.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function formatCanvasProjectUpdatedAt(updatedAt: string) {
|
||||
const date = new Date(updatedAt);
|
||||
if (Number.isNaN(date.getTime())) return "最近更新";
|
||||
|
||||
const diffMinutes = Math.max(0, Math.floor((Date.now() - date.getTime()) / 60000));
|
||||
if (diffMinutes < 1) return "刚刚更新";
|
||||
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours} 小时前`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) return `${diffDays} 天前`;
|
||||
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCanvasVideoTime(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0:00";
|
||||
const totalSeconds = Math.floor(value);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||
import type { WebCanvasWorkflow, WebCanvasWorkflowNode, WebCanvasWorkflowTaskRef } from "../../types";
|
||||
import { toHappyHorseDisplayModel } from "../../utils/happyHorseRouting";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import { normalizeCanvasWorkflowSchema } from "./canvasWorkflowSchema";
|
||||
|
||||
export interface CanvasExecutionImageReference {
|
||||
nodeId: string;
|
||||
title: string;
|
||||
url: string;
|
||||
ossKey?: string | null;
|
||||
}
|
||||
|
||||
export interface CanvasNodeExecutionContext {
|
||||
node: WebCanvasWorkflowNode;
|
||||
prompt: string;
|
||||
imageReferences: CanvasExecutionImageReference[];
|
||||
upstreamTaskRefs: WebCanvasWorkflowTaskRef[];
|
||||
}
|
||||
|
||||
function getNodeText(node: WebCanvasWorkflowNode | undefined): string {
|
||||
if (!node) return "";
|
||||
return String(node.detail || node.params?.prompt || "").trim();
|
||||
}
|
||||
|
||||
function getNodeAssetUrl(node: WebCanvasWorkflowNode | undefined): string {
|
||||
return String(node?.assetRef?.url || node?.previewUrl || "").trim();
|
||||
}
|
||||
|
||||
function incomingEdges(workflow: WebCanvasWorkflow, nodeId: string) {
|
||||
return workflow.edges.filter((edge) => edge.target === nodeId);
|
||||
}
|
||||
|
||||
export function resolveCanvasNodeExecutionContext(
|
||||
workflowInput: WebCanvasWorkflow,
|
||||
nodeId: string,
|
||||
): CanvasNodeExecutionContext {
|
||||
const workflow = normalizeCanvasWorkflowSchema(workflowInput);
|
||||
const nodeById = new Map(workflow.nodes.map((node) => [node.id, node]));
|
||||
const node = nodeById.get(nodeId);
|
||||
if (!node) throw new Error(`Canvas node not found: ${nodeId}`);
|
||||
|
||||
const imageReferences: CanvasExecutionImageReference[] = [];
|
||||
const upstreamPrompts: string[] = [];
|
||||
const upstreamTaskRefs: WebCanvasWorkflowTaskRef[] = [];
|
||||
const seenImages = new Set<string>();
|
||||
|
||||
incomingEdges(workflow, nodeId).forEach((edge) => {
|
||||
const sourceNode = nodeById.get(edge.source);
|
||||
if (!sourceNode) return;
|
||||
if (sourceNode.taskRef) upstreamTaskRefs.push(sourceNode.taskRef);
|
||||
const sourceKind = sourceNode.kind;
|
||||
if (sourceKind === "image") {
|
||||
const url = getNodeAssetUrl(sourceNode);
|
||||
if (url && !seenImages.has(`${sourceNode.id}:${url}`)) {
|
||||
seenImages.add(`${sourceNode.id}:${url}`);
|
||||
imageReferences.push({
|
||||
nodeId: sourceNode.id,
|
||||
title: sourceNode.label,
|
||||
url,
|
||||
ossKey: sourceNode.assetRef?.ossKey || null,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const text = getNodeText(sourceNode);
|
||||
if (text) upstreamPrompts.push(text);
|
||||
});
|
||||
|
||||
return {
|
||||
node,
|
||||
prompt: getNodeText(node) || upstreamPrompts.join("\n"),
|
||||
imageReferences,
|
||||
upstreamTaskRefs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDuration(value: unknown, fallback = 5): number {
|
||||
const parsed = Number(String(value || "").replace(/s$/i, ""));
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function hasRunningCanvasTaskRef(node: WebCanvasWorkflowNode): boolean {
|
||||
const status = node.taskRef?.status;
|
||||
return status === "queued" || status === "pending" || status === "running";
|
||||
}
|
||||
|
||||
export function buildCanvasVideoTaskInput(workflow: WebCanvasWorkflow, nodeId: string): CreatePreviewTaskInput {
|
||||
const context = resolveCanvasNodeExecutionContext(workflow, nodeId);
|
||||
const params = context.node.params || {};
|
||||
const referenceUrls = context.imageReferences.map((item) => item.url);
|
||||
const displayModel = toHappyHorseDisplayModel(String(params.model || workflow.settings.model || "happyhorse-1.0"));
|
||||
let model = resolveVideoRequestModel({ model: displayModel, referenceUrls });
|
||||
return {
|
||||
title: context.node.label || "视频节点生成",
|
||||
type: "video",
|
||||
prompt: context.prompt || (referenceUrls.length ? "根据参考图片生成视频" : ""),
|
||||
params: {
|
||||
model,
|
||||
ratio: String(params.aspectRatio || params.ratio || workflow.settings.ratio || "16:9"),
|
||||
quality: String(params.resolution || workflow.settings.resolution || "720P"),
|
||||
resolution: String(params.resolution || workflow.settings.resolution || "720P"),
|
||||
duration: normalizeDuration(params.duration || workflow.settings.duration),
|
||||
frameMode: params.videoMode === "firstlast" ? "start-end" : "omni",
|
||||
referenceUrls: referenceUrls.length ? referenceUrls : undefined,
|
||||
muted: false,
|
||||
hasReferenceVideo: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCanvasImageTaskInput(workflow: WebCanvasWorkflow, nodeId: string): CreatePreviewTaskInput {
|
||||
const context = resolveCanvasNodeExecutionContext(workflow, nodeId);
|
||||
const params = context.node.params || {};
|
||||
const referenceUrls = context.imageReferences.map((item) => item.url);
|
||||
return {
|
||||
title: context.node.label || "图片节点生成",
|
||||
type: "image",
|
||||
prompt: context.prompt || (referenceUrls.length ? "根据参考图片生成图片" : ""),
|
||||
params: {
|
||||
model: String(params.model || workflow.settings.model || "nano-banana-pro"),
|
||||
ratio: String(params.aspectRatio || params.ratio || workflow.settings.ratio || "16:9"),
|
||||
quality: String(params.imageSize || params.resolution || workflow.settings.resolution || "1K"),
|
||||
gridMode: "single",
|
||||
referenceUrls: referenceUrls.length ? referenceUrls : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
WebCanvasWorkflow,
|
||||
WebCanvasWorkflowAssetRef,
|
||||
WebCanvasWorkflowEdge,
|
||||
WebCanvasWorkflowNode,
|
||||
WebCanvasWorkflowPort,
|
||||
WebCanvasWorkflowTaskRef,
|
||||
} from "../../types";
|
||||
|
||||
export const CANVAS_WORKFLOW_SCHEMA_VERSION = 2;
|
||||
|
||||
type LegacyNodeKind = WebCanvasWorkflowNode["kind"];
|
||||
|
||||
export type CanvasWorkflowNodeKind = "text" | "image" | "video";
|
||||
|
||||
export interface CanvasTaskResultInput {
|
||||
taskId: string;
|
||||
status: WebCanvasWorkflowTaskRef["status"];
|
||||
resultUrl?: string | null;
|
||||
ossKey?: string | null;
|
||||
mediaType?: string | null;
|
||||
originalUrl?: string | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function toStringValue(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeNodeKind(kind: LegacyNodeKind): CanvasWorkflowNodeKind {
|
||||
if (kind === "image") return "image";
|
||||
if (kind === "video") return "video";
|
||||
return "text";
|
||||
}
|
||||
|
||||
export function getCanvasWorkflowNodeOutputType(kind: CanvasWorkflowNodeKind): WebCanvasWorkflowPort["type"] {
|
||||
if (kind === "image") return "image";
|
||||
if (kind === "video") return "video";
|
||||
return "text";
|
||||
}
|
||||
|
||||
export function getCanvasWorkflowNodeInputType(kind: CanvasWorkflowNodeKind): WebCanvasWorkflowPort["type"] {
|
||||
if (kind === "image" || kind === "video") return "image";
|
||||
return "text";
|
||||
}
|
||||
|
||||
export function createCanvasWorkflowPort(
|
||||
direction: "input" | "output",
|
||||
kind: CanvasWorkflowNodeKind,
|
||||
): WebCanvasWorkflowPort {
|
||||
return {
|
||||
id: direction === "output" ? "out" : "in",
|
||||
type: direction === "output" ? getCanvasWorkflowNodeOutputType(kind) : getCanvasWorkflowNodeInputType(kind),
|
||||
};
|
||||
}
|
||||
|
||||
function inferMediaTypeFromNode(kind: CanvasWorkflowNodeKind): string {
|
||||
if (kind === "image") return "image";
|
||||
if (kind === "video") return "video";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function normalizeAssetRef(node: WebCanvasWorkflowNode, normalizedKind: CanvasWorkflowNodeKind): WebCanvasWorkflowAssetRef | null {
|
||||
const metadata = isRecord(node.metadata) ? node.metadata : {};
|
||||
const candidate = isRecord(node.assetRef)
|
||||
? node.assetRef
|
||||
: isRecord(metadata.assetRef)
|
||||
? metadata.assetRef
|
||||
: null;
|
||||
const url = toStringValue(candidate?.url, node.previewUrl || "");
|
||||
if (!url) return null;
|
||||
|
||||
return {
|
||||
url,
|
||||
ossKey: toStringValue(candidate?.ossKey) || null,
|
||||
mediaType: toStringValue(candidate?.mediaType, inferMediaTypeFromNode(normalizedKind)),
|
||||
sourceTaskId: toStringValue(candidate?.sourceTaskId, toStringValue(metadata.sourceTaskId)) || null,
|
||||
originalUrl: toStringValue(candidate?.originalUrl) || null,
|
||||
expiresAt: toStringValue(candidate?.expiresAt) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskRef(node: WebCanvasWorkflowNode, assetRef: WebCanvasWorkflowAssetRef | null): WebCanvasWorkflowTaskRef | null {
|
||||
const metadata = isRecord(node.metadata) ? node.metadata : {};
|
||||
const candidate = isRecord(node.taskRef)
|
||||
? node.taskRef
|
||||
: isRecord(metadata.taskRef)
|
||||
? metadata.taskRef
|
||||
: null;
|
||||
const taskId = toStringValue(candidate?.taskId, assetRef?.sourceTaskId || toStringValue(metadata.sourceTaskId));
|
||||
if (!taskId) return null;
|
||||
const status = toStringValue(candidate?.status, "completed") as WebCanvasWorkflowTaskRef["status"];
|
||||
return {
|
||||
taskId,
|
||||
status,
|
||||
resultUrl: toStringValue(candidate?.resultUrl, assetRef?.url || node.previewUrl || "") || null,
|
||||
updatedAt: toStringValue(candidate?.updatedAt) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNodeParams(node: WebCanvasWorkflowNode): Record<string, unknown> {
|
||||
const metadata = isRecord(node.metadata) ? node.metadata : {};
|
||||
const params = isRecord(node.params) ? node.params : {};
|
||||
const reserved = new Set(["assetRef", "taskRef", "sourceTaskId"]);
|
||||
const metadataParams = Object.fromEntries(Object.entries(metadata).filter(([key]) => !reserved.has(key)));
|
||||
return {
|
||||
...metadataParams,
|
||||
...params,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCanvasWorkflowNode(node: WebCanvasWorkflowNode): WebCanvasWorkflowNode {
|
||||
const kind = normalizeNodeKind(node.kind);
|
||||
const assetRef = normalizeAssetRef(node, kind);
|
||||
const taskRef = normalizeTaskRef(node, assetRef);
|
||||
const inputs = Array.isArray(node.inputs) && node.inputs.length ? node.inputs : [createCanvasWorkflowPort("input", kind)];
|
||||
const outputs = Array.isArray(node.outputs) && node.outputs.length ? node.outputs : [createCanvasWorkflowPort("output", kind)];
|
||||
|
||||
return {
|
||||
...node,
|
||||
kind,
|
||||
inputs,
|
||||
outputs,
|
||||
assetRef,
|
||||
taskRef,
|
||||
params: normalizeNodeParams(node),
|
||||
previewUrl: assetRef?.url || node.previewUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCanvasWorkflowEdge(
|
||||
edge: WebCanvasWorkflowEdge,
|
||||
nodeById: Map<string, WebCanvasWorkflowNode>,
|
||||
): WebCanvasWorkflowEdge {
|
||||
const sourceNode = nodeById.get(edge.source);
|
||||
const targetNode = nodeById.get(edge.target);
|
||||
const sourcePort = edge.sourcePort || sourceNode?.outputs?.[0] || createCanvasWorkflowPort("output", "text");
|
||||
const targetPort = edge.targetPort || targetNode?.inputs?.[0] || createCanvasWorkflowPort("input", "text");
|
||||
return {
|
||||
...edge,
|
||||
sourcePort,
|
||||
targetPort,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCanvasWorkflowSchema(workflow: WebCanvasWorkflow): WebCanvasWorkflow {
|
||||
const nodes = workflow.nodes.map(normalizeCanvasWorkflowNode);
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
return {
|
||||
...workflow,
|
||||
schemaVersion: CANVAS_WORKFLOW_SCHEMA_VERSION,
|
||||
nodes,
|
||||
edges: workflow.edges.map((edge) => normalizeCanvasWorkflowEdge(edge, nodeById)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCanvasAssetRefFromTaskResult(
|
||||
kind: CanvasWorkflowNodeKind,
|
||||
result: CanvasTaskResultInput,
|
||||
): WebCanvasWorkflowAssetRef | null {
|
||||
const url = String(result.resultUrl || "").trim();
|
||||
if (!url) return null;
|
||||
return {
|
||||
url,
|
||||
ossKey: result.ossKey || null,
|
||||
mediaType: result.mediaType || inferMediaTypeFromNode(kind),
|
||||
sourceTaskId: result.taskId,
|
||||
originalUrl: result.originalUrl || null,
|
||||
expiresAt: result.expiresAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyCanvasNodeTaskResult(
|
||||
workflow: WebCanvasWorkflow,
|
||||
nodeId: string,
|
||||
result: CanvasTaskResultInput,
|
||||
): WebCanvasWorkflow {
|
||||
const normalized = normalizeCanvasWorkflowSchema(workflow);
|
||||
return {
|
||||
...normalized,
|
||||
nodes: normalized.nodes.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
const kind = normalizeNodeKind(node.kind);
|
||||
const assetRef = createCanvasAssetRefFromTaskResult(kind, result);
|
||||
return {
|
||||
...node,
|
||||
assetRef: assetRef || node.assetRef || null,
|
||||
taskRef: {
|
||||
taskId: result.taskId,
|
||||
status: result.status,
|
||||
resultUrl: result.resultUrl || assetRef?.url || node.taskRef?.resultUrl || null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
previewUrl: assetRef?.url || result.resultUrl || node.previewUrl,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { type Dispatch, type MutableRefObject, type SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
CanvasImageGenerationState,
|
||||
CanvasImageNode,
|
||||
CanvasTextGenerationState,
|
||||
CanvasVideoGenerationState,
|
||||
CanvasVideoNode,
|
||||
} from "./canvasTypes";
|
||||
import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence";
|
||||
import { waitForImageTaskResult, waitForVideoTaskResult } from "./canvasUtils";
|
||||
|
||||
const CANVAS_GEN_KEEPALIVE_KEY = "omniai:canvas-gen-tasks";
|
||||
|
||||
interface CanvasGenKeepaliveEntry {
|
||||
taskId: string;
|
||||
nodeId: string;
|
||||
nodeKind: "image" | "video";
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function loadCanvasGenKeepalive(): CanvasGenKeepaliveEntry[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CANVAS_GEN_KEEPALIVE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveCanvasGenKeepalive(entries: CanvasGenKeepaliveEntry[]): void {
|
||||
window.localStorage.setItem(CANVAS_GEN_KEEPALIVE_KEY, JSON.stringify(entries));
|
||||
}
|
||||
|
||||
export function addCanvasGenKeepalive(taskId: string, nodeId: string, nodeKind: "image" | "video", projectId: string): void {
|
||||
const entries = loadCanvasGenKeepalive().filter((e) => e.taskId !== taskId);
|
||||
entries.push({ taskId, nodeId, nodeKind, projectId });
|
||||
saveCanvasGenKeepalive(entries);
|
||||
}
|
||||
|
||||
export function removeCanvasGenKeepalive(taskId: string): void {
|
||||
saveCanvasGenKeepalive(loadCanvasGenKeepalive().filter((e) => e.taskId !== taskId));
|
||||
}
|
||||
|
||||
export interface UseCanvasGenerationParams {
|
||||
setImageNodes: Dispatch<SetStateAction<CanvasImageNode[]>>;
|
||||
setVideoNodes: Dispatch<SetStateAction<CanvasVideoNode[]>>;
|
||||
}
|
||||
|
||||
export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
const { setImageNodes, setVideoNodes } = params;
|
||||
|
||||
const [textGenerationState, setTextGenerationState] = useState<Record<string, CanvasTextGenerationState>>({});
|
||||
const [imageGenerationState, setImageGenerationState] = useState<Record<string, CanvasImageGenerationState>>({});
|
||||
const [videoGenerationState, setVideoGenerationState] = useState<Record<string, CanvasVideoGenerationState>>({});
|
||||
const [generationToast, setGenerationToast] = useState<string | null>(null);
|
||||
|
||||
const imageGenerationInFlightRef = useRef(new Set<string>());
|
||||
const textGenerationInFlightRef = useRef(new Set<string>());
|
||||
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
|
||||
const canvasGenKeepaliveRestoredRef = useRef(false);
|
||||
|
||||
const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => {
|
||||
setTextGenerationState((current) => ({ ...current, [nodeId]: state }));
|
||||
};
|
||||
|
||||
const setImageGenerationStatus = (nodeId: string, state: CanvasImageGenerationState) => {
|
||||
setImageGenerationState((current) => ({ ...current, [nodeId]: state }));
|
||||
};
|
||||
|
||||
const setVideoGenerationStatus = (nodeId: string, state: CanvasVideoGenerationState) => {
|
||||
setVideoGenerationState((current) => ({ ...current, [nodeId]: state }));
|
||||
};
|
||||
|
||||
// Toast auto-dismiss
|
||||
useEffect(() => {
|
||||
if (!generationToast) return undefined;
|
||||
const timer = window.setTimeout(() => setGenerationToast(null), 1800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [generationToast]);
|
||||
|
||||
const restoreKeepaliveTasks = (
|
||||
projectId: string,
|
||||
imageNodesList: CanvasImageNode[],
|
||||
videoNodesList: CanvasVideoNode[],
|
||||
) => {
|
||||
if (canvasGenKeepaliveRestoredRef.current) return;
|
||||
canvasGenKeepaliveRestoredRef.current = true;
|
||||
const storedTasks = loadCanvasGenKeepalive().filter((e) => e.projectId === projectId);
|
||||
for (const entry of storedTasks) {
|
||||
const nodeExists = [...imageNodesList, ...videoNodesList].some((n) => n.id === entry.nodeId);
|
||||
if (!nodeExists) {
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
continue;
|
||||
}
|
||||
if (entry.nodeKind === "image") {
|
||||
imageGenerationInFlightRef.current.add(entry.nodeId);
|
||||
setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 });
|
||||
void waitForImageTaskResult(entry.taskId, (status) => {
|
||||
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
||||
setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress });
|
||||
}).then(async (outputUrl) => {
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
|
||||
const ref = createCanvasAssetRefFromGeneratedResult({
|
||||
url: outputUrl, mediaType: "image/png", resultType: "image", taskId: entry.taskId, originalUrl: outputUrl,
|
||||
});
|
||||
setImageNodes((current) => current.map((n) =>
|
||||
n.id === entry.nodeId ? { ...n, imageUrl: outputUrl, assetRef: ref, taskRef: { taskId: entry.taskId, status: "completed", resultUrl: outputUrl, updatedAt: new Date().toISOString() } } : n
|
||||
));
|
||||
let durableRef = ref;
|
||||
try {
|
||||
durableRef = await persistCanvasGeneratedResultAsset({
|
||||
title: "image-node", url: outputUrl, mediaType: "image/png", resultType: "image", taskId: entry.taskId, originalUrl: outputUrl,
|
||||
});
|
||||
} catch { /* keep temp URL on failure */ }
|
||||
if (durableRef.url !== outputUrl || durableRef.ossKey) {
|
||||
setImageNodes((current) => current.map((n) =>
|
||||
n.id === entry.nodeId ? { ...n, imageUrl: durableRef.url, assetRef: durableRef, taskRef: { taskId: entry.taskId, status: "completed", resultUrl: durableRef.url, updatedAt: new Date().toISOString() } } : n
|
||||
));
|
||||
}
|
||||
}).catch(() => {
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" });
|
||||
}).finally(() => {
|
||||
imageGenerationInFlightRef.current.delete(entry.nodeId);
|
||||
});
|
||||
} else if (entry.nodeKind === "video") {
|
||||
imageGenerationInFlightRef.current.add(entry.nodeId);
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
|
||||
void waitForVideoTaskResult(entry.taskId, (status) => {
|
||||
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress });
|
||||
}).then(async (outputUrl) => {
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
|
||||
const ref = createCanvasAssetRefFromGeneratedResult({
|
||||
url: outputUrl, mediaType: "video/mp4", resultType: "video", taskId: entry.taskId, originalUrl: outputUrl,
|
||||
});
|
||||
setVideoNodes((current) => current.map((n) =>
|
||||
n.id === entry.nodeId ? { ...n, videoUrl: outputUrl, assetRef: ref, taskRef: { taskId: entry.taskId, status: "completed", resultUrl: outputUrl, updatedAt: new Date().toISOString() } } : n
|
||||
));
|
||||
let durableRef = ref;
|
||||
try {
|
||||
durableRef = await persistCanvasGeneratedResultAsset({
|
||||
title: "video-node", url: outputUrl, mediaType: "video/mp4", resultType: "video", taskId: entry.taskId, originalUrl: outputUrl,
|
||||
});
|
||||
} catch { /* keep temp URL on failure */ }
|
||||
if (durableRef.url !== outputUrl || durableRef.ossKey) {
|
||||
setVideoNodes((current) => current.map((n) =>
|
||||
n.id === entry.nodeId ? { ...n, videoUrl: durableRef.url, assetRef: durableRef, taskRef: { taskId: entry.taskId, status: "completed", resultUrl: durableRef.url, updatedAt: new Date().toISOString() } } : n
|
||||
));
|
||||
}
|
||||
}).catch(() => {
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
|
||||
}).finally(() => {
|
||||
imageGenerationInFlightRef.current.delete(entry.nodeId);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetGenerationState = () => {
|
||||
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
|
||||
textGenerationAbortControllersRef.current.clear();
|
||||
textGenerationInFlightRef.current.clear();
|
||||
imageGenerationInFlightRef.current.clear();
|
||||
setTextGenerationState({});
|
||||
setImageGenerationState({});
|
||||
setVideoGenerationState({});
|
||||
};
|
||||
|
||||
return {
|
||||
textGenerationState,
|
||||
imageGenerationState,
|
||||
videoGenerationState,
|
||||
generationToast,
|
||||
setGenerationToast,
|
||||
imageGenerationInFlightRef,
|
||||
textGenerationInFlightRef,
|
||||
textGenerationAbortControllersRef,
|
||||
canvasGenKeepaliveRestoredRef,
|
||||
setTextGenerationStatus,
|
||||
setImageGenerationStatus,
|
||||
setVideoGenerationStatus,
|
||||
restoreKeepaliveTasks,
|
||||
resetGenerationState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
export interface CanvasHistorySnapshot {
|
||||
textNodes: unknown[];
|
||||
imageNodes: unknown[];
|
||||
videoNodes: unknown[];
|
||||
manualLinks: unknown[];
|
||||
nodePackages: unknown[];
|
||||
}
|
||||
|
||||
interface CanvasHistoryState {
|
||||
past: CanvasHistorySnapshot[];
|
||||
future: CanvasHistorySnapshot[];
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
export function useCanvasHistory() {
|
||||
const historyRef = useRef<CanvasHistoryState>({ past: [], future: [] });
|
||||
const lastPushRef = useRef<number>(0);
|
||||
|
||||
const pushSnapshot = useCallback((snapshot: CanvasHistorySnapshot) => {
|
||||
const now = Date.now();
|
||||
// Debounce: skip if pushed within 300ms (e.g. rapid drag moves)
|
||||
if (now - lastPushRef.current < 300) return;
|
||||
lastPushRef.current = now;
|
||||
|
||||
const history = historyRef.current;
|
||||
history.past = [...history.past.slice(-(MAX_HISTORY - 1)), snapshot];
|
||||
history.future = [];
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(
|
||||
(currentSnapshot: CanvasHistorySnapshot): CanvasHistorySnapshot | null => {
|
||||
const history = historyRef.current;
|
||||
if (!history.past.length) return null;
|
||||
const previous = history.past[history.past.length - 1];
|
||||
history.past = history.past.slice(0, -1);
|
||||
history.future = [...history.future, currentSnapshot];
|
||||
return previous;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const redo = useCallback(
|
||||
(currentSnapshot: CanvasHistorySnapshot): CanvasHistorySnapshot | null => {
|
||||
const history = historyRef.current;
|
||||
if (!history.future.length) return null;
|
||||
const next = history.future[history.future.length - 1];
|
||||
history.future = history.future.slice(0, -1);
|
||||
history.past = [...history.past, currentSnapshot];
|
||||
return next;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const canUndo = useCallback(() => historyRef.current.past.length > 0, []);
|
||||
const canRedo = useCallback(() => historyRef.current.future.length > 0, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
historyRef.current = { past: [], future: [] };
|
||||
}, []);
|
||||
|
||||
return { pushSnapshot, undo, redo, canUndo, canRedo, clear };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export interface CanvasKeyboardActions {
|
||||
onDelete: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onSelectAll: () => void;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onDuplicate: () => void;
|
||||
onEscape: () => void;
|
||||
setSpacePanning: (active: boolean) => void;
|
||||
isInputFocused: () => boolean;
|
||||
}
|
||||
|
||||
export function useCanvasKeyboard(actions: CanvasKeyboardActions) {
|
||||
const actionsRef = useRef(actions);
|
||||
actionsRef.current = actions;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const a = actionsRef.current;
|
||||
if (a.isInputFocused()) return;
|
||||
|
||||
const ctrl = event.ctrlKey || event.metaKey;
|
||||
|
||||
if (event.key === "Delete" || event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
a.onDelete();
|
||||
return;
|
||||
}
|
||||
if (ctrl && event.key === "z" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
a.onUndo();
|
||||
return;
|
||||
}
|
||||
if (ctrl && (event.key === "y" || (event.key === "z" && event.shiftKey))) {
|
||||
event.preventDefault();
|
||||
a.onRedo();
|
||||
return;
|
||||
}
|
||||
if (ctrl && event.key === "a") {
|
||||
event.preventDefault();
|
||||
a.onSelectAll();
|
||||
return;
|
||||
}
|
||||
if (ctrl && event.key === "c") {
|
||||
event.preventDefault();
|
||||
a.onCopy();
|
||||
return;
|
||||
}
|
||||
if (ctrl && event.key === "v") {
|
||||
event.preventDefault();
|
||||
a.onPaste();
|
||||
return;
|
||||
}
|
||||
if (ctrl && event.key === "d") {
|
||||
event.preventDefault();
|
||||
a.onDuplicate();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
a.onEscape();
|
||||
return;
|
||||
}
|
||||
if (event.key === " " && !ctrl) {
|
||||
event.preventDefault();
|
||||
a.setSpacePanning(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === " ") {
|
||||
actionsRef.current.setSpacePanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { type Dispatch, type MouseEvent, type MutableRefObject, type SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import { canvasNodeClickMoveThreshold } from "./canvasConstants";
|
||||
import type {
|
||||
CanvasAlignGuide,
|
||||
CanvasImageNode,
|
||||
CanvasImageNodeDrag,
|
||||
CanvasNodeKind,
|
||||
CanvasNodePackage,
|
||||
CanvasNodePackageDrag,
|
||||
CanvasNodeResizeDrag,
|
||||
CanvasNodeSize,
|
||||
CanvasPanDrag,
|
||||
CanvasPoint,
|
||||
CanvasSelectedNode,
|
||||
CanvasSelectionDrag,
|
||||
CanvasTextNode,
|
||||
CanvasTextNodeDrag,
|
||||
CanvasVideoNode,
|
||||
CanvasVideoNodeDrag,
|
||||
CanvasViewport,
|
||||
} from "./canvasTypes";
|
||||
import { clampCanvasNodeSize, moveCanvasNodesForPackageDrag, normalizeCanvasSelectionRect } from "./canvasUtils";
|
||||
|
||||
export interface CanvasNodeDragCallbacks {
|
||||
pushHistorySnapshot: () => void;
|
||||
clearCanvasSelection: () => void;
|
||||
selectCanvasNode: (kind: CanvasNodeKind, id: string, addToSelection?: boolean) => void;
|
||||
applyCanvasSelection: (nodes: CanvasSelectedNode[]) => void;
|
||||
getCanvasPointFromClient: (clientX: number, clientY: number) => CanvasPoint;
|
||||
getNodesInSelectionRect: (rect: { left: number; top: number; width: number; height: number }) => CanvasSelectedNode[];
|
||||
expandCanvasNodePackage: (pkg: CanvasNodePackage) => void;
|
||||
onBeforeResize?: () => void;
|
||||
}
|
||||
|
||||
export interface UseCanvasNodeDragParams {
|
||||
zoomRef: MutableRefObject<number>;
|
||||
textNodesRef: MutableRefObject<CanvasTextNode[]>;
|
||||
imageNodesRef: MutableRefObject<CanvasImageNode[]>;
|
||||
videoNodesRef: MutableRefObject<CanvasVideoNode[]>;
|
||||
nodePackagesRef: MutableRefObject<CanvasNodePackage[]>;
|
||||
setTextNodes: Dispatch<SetStateAction<CanvasTextNode[]>>;
|
||||
setImageNodes: Dispatch<SetStateAction<CanvasImageNode[]>>;
|
||||
setVideoNodes: Dispatch<SetStateAction<CanvasVideoNode[]>>;
|
||||
setNodePackages: Dispatch<SetStateAction<CanvasNodePackage[]>>;
|
||||
setCanvasViewport: Dispatch<SetStateAction<CanvasViewport>>;
|
||||
callbacksRef: MutableRefObject<CanvasNodeDragCallbacks>;
|
||||
suppressNextPaneClickRef: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
export function useCanvasNodeDrag(params: UseCanvasNodeDragParams) {
|
||||
const {
|
||||
zoomRef,
|
||||
textNodesRef,
|
||||
imageNodesRef,
|
||||
videoNodesRef,
|
||||
nodePackagesRef,
|
||||
setTextNodes,
|
||||
setImageNodes,
|
||||
setVideoNodes,
|
||||
setNodePackages,
|
||||
setCanvasViewport,
|
||||
callbacksRef,
|
||||
suppressNextPaneClickRef,
|
||||
} = params;
|
||||
|
||||
const [textNodeDrag, setTextNodeDrag] = useState<CanvasTextNodeDrag | null>(null);
|
||||
const [imageNodeDrag, setImageNodeDrag] = useState<CanvasImageNodeDrag | null>(null);
|
||||
const [videoNodeDrag, setVideoNodeDrag] = useState<CanvasVideoNodeDrag | null>(null);
|
||||
const [packageDrag, setPackageDrag] = useState<CanvasNodePackageDrag | null>(null);
|
||||
const [selectionDrag, setSelectionDrag] = useState<CanvasSelectionDrag | null>(null);
|
||||
const [nodeResizeDrag, setNodeResizeDrag] = useState<CanvasNodeResizeDrag | null>(null);
|
||||
const [canvasPanDrag, setCanvasPanDrag] = useState<CanvasPanDrag | null>(null);
|
||||
const [alignGuides, setAlignGuides] = useState<CanvasAlignGuide[]>([]);
|
||||
|
||||
const didNodeDragStayClick = (event: globalThis.MouseEvent, drag: { startX: number; startY: number }) =>
|
||||
Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) <= canvasNodeClickMoveThreshold;
|
||||
|
||||
const computeAlignGuides = (draggedId: string, pos: { x: number; y: number }, size: { width: number; height: number }): CanvasAlignGuide[] => {
|
||||
const threshold = 6;
|
||||
const guides: CanvasAlignGuide[] = [];
|
||||
const cx = pos.x + size.width / 2;
|
||||
const cy = pos.y + size.height / 2;
|
||||
const right = pos.x + size.width;
|
||||
const bottom = pos.y + size.height;
|
||||
const others = [
|
||||
...textNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
|
||||
...imageNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
|
||||
...videoNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
|
||||
];
|
||||
for (const other of others) {
|
||||
const ocx = other.pos.x + other.size.width / 2;
|
||||
const ocy = other.pos.y + other.size.height / 2;
|
||||
const oRight = other.pos.x + other.size.width;
|
||||
const oBottom = other.pos.y + other.size.height;
|
||||
if (Math.abs(cx - ocx) < threshold) guides.push({ axis: "x", position: ocx });
|
||||
if (Math.abs(pos.x - other.pos.x) < threshold) guides.push({ axis: "x", position: other.pos.x });
|
||||
if (Math.abs(right - oRight) < threshold) guides.push({ axis: "x", position: oRight });
|
||||
if (Math.abs(cy - ocy) < threshold) guides.push({ axis: "y", position: ocy });
|
||||
if (Math.abs(pos.y - other.pos.y) < threshold) guides.push({ axis: "y", position: other.pos.y });
|
||||
if (Math.abs(bottom - oBottom) < threshold) guides.push({ axis: "y", position: oBottom });
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return guides.filter((g) => {
|
||||
const key = `${g.axis}:${Math.round(g.position)}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!textNodeDrag) return;
|
||||
const draggedNode = textNodesRef.current.find((n) => n.id === textNodeDrag.nodeId);
|
||||
const draggedSize = draggedNode?.size || { width: 320, height: 200 };
|
||||
const handleMove = (event: globalThis.MouseEvent) => {
|
||||
const hasMoved = !didNodeDragStayClick(event, textNodeDrag);
|
||||
if (hasMoved && !textNodeDrag.hasMoved) {
|
||||
callbacksRef.current.pushHistorySnapshot();
|
||||
callbacksRef.current.clearCanvasSelection();
|
||||
setTextNodeDrag((d) => d?.nodeId === textNodeDrag.nodeId ? { ...d, hasMoved: true } : d);
|
||||
}
|
||||
if (!hasMoved && !textNodeDrag.hasMoved) return;
|
||||
const newPos = {
|
||||
x: textNodeDrag.originX + (event.clientX - textNodeDrag.startX) / zoomRef.current,
|
||||
y: textNodeDrag.originY + (event.clientY - textNodeDrag.startY) / zoomRef.current,
|
||||
};
|
||||
setTextNodes((nodes) => nodes.map((n) => n.id === textNodeDrag.nodeId ? { ...n, position: newPos } : n));
|
||||
setAlignGuides(computeAlignGuides(textNodeDrag.nodeId, newPos, draggedSize));
|
||||
};
|
||||
const handleUp = (event: globalThis.MouseEvent) => {
|
||||
if (didNodeDragStayClick(event, textNodeDrag)) callbacksRef.current.selectCanvasNode("text", textNodeDrag.nodeId, event.shiftKey);
|
||||
setTextNodeDrag(null);
|
||||
setAlignGuides([]);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); };
|
||||
}, [textNodeDrag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageNodeDrag) return;
|
||||
const draggedNode = imageNodesRef.current.find((n) => n.id === imageNodeDrag.nodeId);
|
||||
const draggedSize = draggedNode?.size || { width: 320, height: 320 };
|
||||
const handleMove = (event: globalThis.MouseEvent) => {
|
||||
const hasMoved = !didNodeDragStayClick(event, imageNodeDrag);
|
||||
if (hasMoved && !imageNodeDrag.hasMoved) {
|
||||
callbacksRef.current.pushHistorySnapshot();
|
||||
callbacksRef.current.clearCanvasSelection();
|
||||
setImageNodeDrag((d) => d?.nodeId === imageNodeDrag.nodeId ? { ...d, hasMoved: true } : d);
|
||||
}
|
||||
if (!hasMoved && !imageNodeDrag.hasMoved) return;
|
||||
const newPos = {
|
||||
x: imageNodeDrag.originX + (event.clientX - imageNodeDrag.startX) / zoomRef.current,
|
||||
y: imageNodeDrag.originY + (event.clientY - imageNodeDrag.startY) / zoomRef.current,
|
||||
};
|
||||
setImageNodes((nodes) => nodes.map((n) => n.id === imageNodeDrag.nodeId ? { ...n, position: newPos } : n));
|
||||
setAlignGuides(computeAlignGuides(imageNodeDrag.nodeId, newPos, draggedSize));
|
||||
};
|
||||
const handleUp = (event: globalThis.MouseEvent) => {
|
||||
if (didNodeDragStayClick(event, imageNodeDrag)) callbacksRef.current.selectCanvasNode("image", imageNodeDrag.nodeId, event.shiftKey);
|
||||
setImageNodeDrag(null);
|
||||
setAlignGuides([]);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); };
|
||||
}, [imageNodeDrag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoNodeDrag) return;
|
||||
const draggedNode = videoNodesRef.current.find((n) => n.id === videoNodeDrag.nodeId);
|
||||
const draggedSize = draggedNode?.size || { width: 320, height: 240 };
|
||||
const handleMove = (event: globalThis.MouseEvent) => {
|
||||
const hasMoved = !didNodeDragStayClick(event, videoNodeDrag);
|
||||
if (hasMoved && !videoNodeDrag.hasMoved) {
|
||||
callbacksRef.current.pushHistorySnapshot();
|
||||
callbacksRef.current.clearCanvasSelection();
|
||||
setVideoNodeDrag((d) => d?.nodeId === videoNodeDrag.nodeId ? { ...d, hasMoved: true } : d);
|
||||
}
|
||||
if (!hasMoved && !videoNodeDrag.hasMoved) return;
|
||||
const newPos = {
|
||||
x: videoNodeDrag.originX + (event.clientX - videoNodeDrag.startX) / zoomRef.current,
|
||||
y: videoNodeDrag.originY + (event.clientY - videoNodeDrag.startY) / zoomRef.current,
|
||||
};
|
||||
setVideoNodes((nodes) => nodes.map((n) => n.id === videoNodeDrag.nodeId ? { ...n, position: newPos } : n));
|
||||
setAlignGuides(computeAlignGuides(videoNodeDrag.nodeId, newPos, draggedSize));
|
||||
};
|
||||
const handleUp = (event: globalThis.MouseEvent) => {
|
||||
if (didNodeDragStayClick(event, videoNodeDrag)) callbacksRef.current.selectCanvasNode("video", videoNodeDrag.nodeId, event.shiftKey);
|
||||
setVideoNodeDrag(null);
|
||||
setAlignGuides([]);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); };
|
||||
}, [videoNodeDrag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!packageDrag) return;
|
||||
const handleMove = (event: globalThis.MouseEvent) => {
|
||||
const deltaX = (event.clientX - packageDrag.startX) / zoomRef.current;
|
||||
const deltaY = (event.clientY - packageDrag.startY) / zoomRef.current;
|
||||
const hasMoved = Math.abs(event.clientX - packageDrag.startX) > canvasNodeClickMoveThreshold ||
|
||||
Math.abs(event.clientY - packageDrag.startY) > canvasNodeClickMoveThreshold;
|
||||
if (hasMoved && !packageDrag.hasMoved) {
|
||||
callbacksRef.current.pushHistorySnapshot();
|
||||
setPackageDrag((c) => c?.packageId === packageDrag.packageId ? { ...c, hasMoved: true } : c);
|
||||
}
|
||||
if (!hasMoved && !packageDrag.hasMoved) return;
|
||||
const delta = { x: deltaX, y: deltaY };
|
||||
if (packageDrag.collapsed && packageDrag.collapsedBounds) {
|
||||
setNodePackages((current) => current.map((pkg) =>
|
||||
pkg.id === packageDrag.packageId ? { ...pkg, collapsedBounds: { ...packageDrag.collapsedBounds!, left: packageDrag.collapsedBounds!.left + deltaX, top: packageDrag.collapsedBounds!.top + deltaY } } : pkg
|
||||
));
|
||||
} else {
|
||||
setTextNodes((nodes) => moveCanvasNodesForPackageDrag(nodes, packageDrag.textOrigins, delta));
|
||||
setImageNodes((nodes) => moveCanvasNodesForPackageDrag(nodes, packageDrag.imageOrigins, delta));
|
||||
setVideoNodes((nodes) => moveCanvasNodesForPackageDrag(nodes, packageDrag.videoOrigins, delta));
|
||||
}
|
||||
};
|
||||
const handleUp = () => {
|
||||
if (!packageDrag.hasMoved && packageDrag.collapsed) {
|
||||
const pkg = nodePackagesRef.current.find((p) => p.id === packageDrag.packageId);
|
||||
if (pkg) callbacksRef.current.expandCanvasNodePackage(pkg);
|
||||
}
|
||||
setPackageDrag(null);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); };
|
||||
}, [packageDrag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodeResizeDrag) return;
|
||||
callbacksRef.current.pushHistorySnapshot();
|
||||
const handleMove = (event: globalThis.MouseEvent) => {
|
||||
const nextSize = clampCanvasNodeSize(
|
||||
nodeResizeDrag.kind,
|
||||
nodeResizeDrag.originWidth + (event.clientX - nodeResizeDrag.startX) / zoomRef.current,
|
||||
nodeResizeDrag.originHeight + (event.clientY - nodeResizeDrag.startY) / zoomRef.current
|
||||
);
|
||||
if (nodeResizeDrag.kind === "text") {
|
||||
setTextNodes((nodes) => nodes.map((n) => n.id === nodeResizeDrag.nodeId ? { ...n, size: nextSize } : n));
|
||||
} else if (nodeResizeDrag.kind === "image") {
|
||||
setImageNodes((nodes) => nodes.map((n) => n.id === nodeResizeDrag.nodeId ? { ...n, size: nextSize } : n));
|
||||
} else {
|
||||
setVideoNodes((nodes) => nodes.map((n) => n.id === nodeResizeDrag.nodeId ? { ...n, size: nextSize } : n));
|
||||
}
|
||||
};
|
||||
const handleUp = () => setNodeResizeDrag(null);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); };
|
||||
}, [nodeResizeDrag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionDrag) return;
|
||||
const handleMove = (event: globalThis.MouseEvent) => {
|
||||
const current = callbacksRef.current.getCanvasPointFromClient(event.clientX, event.clientY);
|
||||
setSelectionDrag((drag) => drag ? { ...drag, current, hasMoved: drag.hasMoved || Math.hypot(current.x - drag.start.x, current.y - drag.start.y) > canvasNodeClickMoveThreshold } : drag);
|
||||
};
|
||||
const handleUp = (event: globalThis.MouseEvent) => {
|
||||
const current = callbacksRef.current.getCanvasPointFromClient(event.clientX, event.clientY);
|
||||
const hasMoved = selectionDrag.hasMoved || Math.hypot(current.x - selectionDrag.start.x, current.y - selectionDrag.start.y) > canvasNodeClickMoveThreshold;
|
||||
if (hasMoved) {
|
||||
const selectionRect = normalizeCanvasSelectionRect(selectionDrag.start, current);
|
||||
callbacksRef.current.applyCanvasSelection(callbacksRef.current.getNodesInSelectionRect(selectionRect));
|
||||
suppressNextPaneClickRef.current = true;
|
||||
} else {
|
||||
callbacksRef.current.clearCanvasSelection();
|
||||
}
|
||||
setSelectionDrag(null);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); };
|
||||
}, [selectionDrag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasPanDrag) return;
|
||||
const handleMove = (event: globalThis.MouseEvent) => {
|
||||
setCanvasViewport((vp) => ({ ...vp, x: canvasPanDrag.originX + event.clientX - canvasPanDrag.startX, y: canvasPanDrag.originY + event.clientY - canvasPanDrag.startY }));
|
||||
};
|
||||
const handleUp = () => setCanvasPanDrag(null);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("mouseup", handleUp); };
|
||||
}, [canvasPanDrag]);
|
||||
|
||||
const handleNodeResizeStart = (
|
||||
event: MouseEvent<HTMLButtonElement>,
|
||||
kind: CanvasNodeKind,
|
||||
nodeId: string,
|
||||
size: CanvasNodeSize
|
||||
) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
callbacksRef.current.selectCanvasNode(kind, nodeId);
|
||||
setSelectionDrag(null);
|
||||
setTextNodeDrag(null);
|
||||
setImageNodeDrag(null);
|
||||
setVideoNodeDrag(null);
|
||||
callbacksRef.current.onBeforeResize?.();
|
||||
setNodeResizeDrag({
|
||||
kind,
|
||||
nodeId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originWidth: size.width,
|
||||
originHeight: size.height,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
textNodeDrag, setTextNodeDrag,
|
||||
imageNodeDrag, setImageNodeDrag,
|
||||
videoNodeDrag, setVideoNodeDrag,
|
||||
packageDrag, setPackageDrag,
|
||||
selectionDrag, setSelectionDrag,
|
||||
nodeResizeDrag, setNodeResizeDrag,
|
||||
canvasPanDrag, setCanvasPanDrag,
|
||||
alignGuides, setAlignGuides,
|
||||
handleNodeResizeStart,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user