Initial commit: OmniAI Web Frontend

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:38:01 +08:00
commit bedee3ba8d
183 changed files with 94805 additions and 0 deletions
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],
};
}
+312
View File
@@ -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>
);
}
+203
View File
@@ -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 }>;
+338
View File
@@ -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;
}
+498
View File
@@ -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,
},
};
}
+202
View File
@@ -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,
};
}),
};
}
+189
View File
@@ -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,
};
}
+65
View File
@@ -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 };
}
+85
View File
@@ -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);
};
}, []);
}
+326
View File
@@ -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,
};
}