fix(canvas): 画布交互 - 优化节点视觉、分镜列与滑动性能
This commit is contained in:
@@ -113,7 +113,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
} as CSSProperties;
|
||||
const composerSurfaceStyle = {
|
||||
...composerTokens,
|
||||
border: `1px solid ${theme.node.edge}`,
|
||||
border: `1px solid color-mix(in srgb, ${theme.node.edge} 42%, transparent)`,
|
||||
background: theme.node.panel,
|
||||
color: theme.node.text,
|
||||
boxShadow: theme.node.shadow,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, RefreshCw, Replace, Settings2, Star, Trash2, Type, Upload, Video } from "lucide-react";
|
||||
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, RefreshCw, Settings2, Star, Trash2, Type, Video } from "lucide-react";
|
||||
|
||||
import { useCanvasNodeActions } from "./canvas-node-action-context";
|
||||
|
||||
@@ -95,7 +95,6 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onOpenTaskDetails,
|
||||
onOpenVersions,
|
||||
onViewImage,
|
||||
onReplaceMedia,
|
||||
onOpenTextEditor,
|
||||
onOpenDirector,
|
||||
onOpenDrawing,
|
||||
@@ -112,7 +111,6 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
|
||||
const isComposerNode = data.type === CanvasNodeType.Config;
|
||||
const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent;
|
||||
const hasReplaceAction = !readOnly && (data.type === CanvasNodeType.Image || data.type === CanvasNodeType.Video);
|
||||
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
|
||||
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
|
||||
const showStatusTrack = Boolean(resourceLabel || data.metadata?.locked || isBatchRoot || (isBatchChild && !readOnly) || (hasMediaContent && !readOnly));
|
||||
@@ -367,25 +365,6 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasReplaceAction && (!hasMediaContent || isSelected || hovered) ? (
|
||||
<div
|
||||
className="absolute right-3 top-3 z-[var(--node-z-overlay)]"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-node-inline-action grid size-9 place-items-center p-0 backdrop-blur-xl transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
style={{ outlineColor: theme.accent.primary }}
|
||||
onClick={(event) => { event.stopPropagation(); onReplaceMedia?.(data); }}
|
||||
aria-label={hasMediaContent ? "替换媒体" : data.type === CanvasNodeType.Image ? "上传图片" : "上传视频"}
|
||||
title={hasMediaContent ? "替换媒体" : data.type === CanvasNodeType.Image ? "上传图片" : "上传视频"}
|
||||
>
|
||||
{hasMediaContent ? <Replace className="size-3.5" /> : <Upload className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.type === CanvasNodeType.Text && data.metadata?.workflowKind !== "character" && !readOnly ? (
|
||||
<div
|
||||
className={`absolute bottom-[10%] left-1/2 z-[var(--node-z-overlay)] -translate-x-1/2 motion-safe:transition motion-safe:duration-200 ${isSelected ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"}`}
|
||||
@@ -424,7 +403,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
</button>
|
||||
) : null}
|
||||
{showStatusTrack ? (
|
||||
<div className={`absolute top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${hasReplaceAction ? "right-14" : "right-3"} ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
|
||||
<div className={`absolute right-3 top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
|
||||
{resourceLabel && data.type !== CanvasNodeType.Image ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
|
||||
{hasMediaContent && !readOnly ? <ResourceStorageBadge storageKey={data.metadata?.storageKey} active={isActive} theme={theme} /> : null}
|
||||
{isBatchRoot ? <BatchToggleBadge count={batchCount} expanded={batchExpanded} theme={theme} onToggle={() => onToggleBatch?.(data.id)} /> : null}
|
||||
@@ -638,7 +617,7 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
|
||||
style={{
|
||||
maxWidth: maxHeaderWidth,
|
||||
borderRadius: "var(--r-sm)",
|
||||
background: theme.node.panel,
|
||||
background: "transparent",
|
||||
paddingInline: "var(--space-1-half)",
|
||||
color: active ? theme.node.text : theme.node.label,
|
||||
transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`,
|
||||
@@ -653,7 +632,7 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
|
||||
autoFocus
|
||||
value={draft}
|
||||
className="h-6 min-w-20 max-w-[190px] flex-1 truncate rounded bg-transparent px-1.5 text-xs font-medium outline-none"
|
||||
style={{ background: theme.spatial.elevated, color: theme.node.text }}
|
||||
style={{ background: "transparent", color: theme.node.text }}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onBlur={onCommit}
|
||||
|
||||
@@ -242,7 +242,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
</section>
|
||||
|
||||
<section className="shrink-0 border-t border-border/70 p-2">
|
||||
<button type="button" onClick={onOpenAssets} className="flex h-9 w-full items-center gap-2 rounded-md border border-border/80 px-2 text-xs text-foreground/65 hover:border-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)]">
|
||||
<button type="button" onClick={onOpenAssets} className="flex h-9 w-full items-center gap-2 rounded-md border border-border/45 px-2 text-xs text-foreground/65 hover:border-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)]">
|
||||
<Images className="size-3.5 text-foreground/42" />
|
||||
<span className="flex-1 text-left">引用项目资产</span>
|
||||
<span className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/45">{mediaAssetCount.toLocaleString("zh-CN")}</span>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, use
|
||||
import { Button, Checkbox, Dropdown, Input, InputNumber, Modal, Segmented, Select, Table, Tooltip } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, Minus, MoreHorizontal, Plus, RefreshCw, Send, Square, Trash2, Video } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, MoreHorizontal, Plus, RefreshCw, Send, Square, Trash2, Video } from "lucide-react";
|
||||
|
||||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
@@ -33,8 +33,17 @@ import type { TaskStatus } from "@/services/api/task-center";
|
||||
|
||||
const STORYBOARD_PROMPT_MIN_HEIGHT = 40;
|
||||
const STORYBOARD_PROMPT_MAX_HEIGHT = 116;
|
||||
const SCRIPT_GRID_TEMPLATE = "72px 150px minmax(280px, 1.4fr) minmax(220px, 1fr) 58px";
|
||||
const SCRIPT_GRID_TEMPLATE = "72px minmax(220px, 1fr) minmax(300px, 1.4fr) minmax(220px, 1fr) 58px";
|
||||
const EMPTY_STORYBOARD_ROWS: StoryboardRow[] = [];
|
||||
const DEFAULT_STORYBOARD_COLUMNS: StoryboardColumn[] = ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"];
|
||||
const LEGACY_STORYBOARD_COLUMNS: StoryboardColumn[] = ["shotNumber", "durationSeconds", "plotDescription", "dialogue"];
|
||||
|
||||
function resolveStoryboardVisibleColumns(columns?: StoryboardColumn[]) {
|
||||
if (!columns?.length || (columns.length === LEGACY_STORYBOARD_COLUMNS.length && LEGACY_STORYBOARD_COLUMNS.every((column) => columns.includes(column)))) {
|
||||
return DEFAULT_STORYBOARD_COLUMNS;
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
const columnOptions: Array<{ label: string; value: StoryboardColumn }> = [
|
||||
{ label: "序号", value: "shotNumber" },
|
||||
@@ -276,10 +285,8 @@ export function CanvasScriptNodeContent({
|
||||
<HeaderCell borderColor={theme.node.stroke} align="center">
|
||||
序号
|
||||
</HeaderCell>
|
||||
<HeaderCell borderColor={theme.node.stroke} align="center">
|
||||
时长
|
||||
</HeaderCell>
|
||||
<HeaderCell borderColor={theme.node.stroke}>画面描述</HeaderCell>
|
||||
<HeaderCell borderColor={theme.node.stroke}>画面</HeaderCell>
|
||||
<HeaderCell borderColor={theme.node.stroke}>视频提示词</HeaderCell>
|
||||
<HeaderCell borderColor={theme.node.stroke}>台词/旁白</HeaderCell>
|
||||
<span className="text-center">操作</span>
|
||||
</div>
|
||||
@@ -308,16 +315,8 @@ export function CanvasScriptNodeContent({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-[32px_1fr_32px] items-center border-r px-2" style={{ borderColor: theme.node.stroke }}>
|
||||
<SmallButton title="减少 1 秒" onClick={() => onUpdateRow(row.id, { durationSeconds: Math.max(1, row.durationSeconds - 1) })}>
|
||||
<Minus className="size-3" />
|
||||
</SmallButton>
|
||||
<span className="text-center text-sm font-medium tabular-nums">{row.durationSeconds}s</span>
|
||||
<SmallButton title="增加 1 秒" onClick={() => onUpdateRow(row.id, { durationSeconds: Math.min(60, row.durationSeconds + 1) })}>
|
||||
<Plus className="size-3" />
|
||||
</SmallButton>
|
||||
</div>
|
||||
<CompactInput value={row.plotDescription} placeholder="描述画面内容" onChange={(value) => onUpdateRow(row.id, { plotDescription: value })} borderColor={theme.node.stroke} />
|
||||
<CompactInput value={row.videoMotionPrompt} placeholder="描述视频运动、镜头和动作" onChange={(value) => onUpdateRow(row.id, { videoMotionPrompt: value })} borderColor={theme.node.stroke} />
|
||||
<CompactInput value={row.dialogue} placeholder="台词或旁白" onChange={(value) => onUpdateRow(row.id, { dialogue: value })} borderColor={theme.node.stroke} />
|
||||
<div className="grid h-full place-items-center">
|
||||
<button
|
||||
@@ -609,7 +608,7 @@ export function CanvasScriptEditor({
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const rows = node?.metadata?.storyboard?.rows || EMPTY_STORYBOARD_ROWS;
|
||||
const visibleColumns = node?.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"];
|
||||
const visibleColumns = resolveStoryboardVisibleColumns(node?.metadata?.storyboard?.visibleColumns);
|
||||
const videoInputMode = node?.metadata?.storyboardVideoInputMode || "direct";
|
||||
const filteredRows = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
|
||||
@@ -426,7 +426,7 @@ function CanvasGrid({ mode }: { mode: CanvasBackgroundMode }) {
|
||||
backgroundImage,
|
||||
backgroundSize: "var(--canvas-grid-size) var(--canvas-grid-size)",
|
||||
transform: "translate3d(var(--canvas-grid-x), var(--canvas-grid-y), 0)",
|
||||
opacity: 0.42,
|
||||
opacity: 0.46,
|
||||
willChange: "transform",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const NODE_SPECS = {
|
||||
workflowKind: "script",
|
||||
storyboard: {
|
||||
rows: [],
|
||||
visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
|
||||
visibleColumns: ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
|
||||
referenceNodeIds: [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -71,8 +71,8 @@ export const canvasThemes = {
|
||||
dark: {
|
||||
canvas: {
|
||||
background: "#0b0b0b",
|
||||
dot: "rgba(170,170,170,.22)",
|
||||
line: "rgba(170,170,170,.10)",
|
||||
dot: "rgba(178,178,178,.27)",
|
||||
line: "rgba(178,178,178,.14)",
|
||||
selectionFill: "rgba(255,255,255,.12)",
|
||||
},
|
||||
node: {
|
||||
|
||||
@@ -45,7 +45,7 @@ export function createCanvasNode(type: CanvasNodeType, position: Position, metad
|
||||
width: spec.width,
|
||||
height: spec.height,
|
||||
metadata: type === CanvasNodeType.Script
|
||||
? { ...spec.metadata, ...metadata, storyboard: metadata?.storyboard || { rows: [1, 2, 3].map((shotNumber) => createStoryboardRow(shotNumber)), visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"], referenceNodeIds: [] } }
|
||||
? { ...spec.metadata, ...metadata, storyboard: metadata?.storyboard || { rows: [1, 2, 3].map((shotNumber) => createStoryboardRow(shotNumber)), visibleColumns: ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"], referenceNodeIds: [] } }
|
||||
: { ...spec.metadata, ...metadata, ...(type === CanvasNodeType.Drawing ? { drawingId: metadata?.drawingId || `${id}-document` } : {}) },
|
||||
};
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export function storyboardPromptTemplateMetadata(row: StoryboardRow, kind: "imag
|
||||
|
||||
export function cinematicStoryboardColumns(columns?: StoryboardColumn[]): StoryboardColumn[] {
|
||||
return Array.from(new Set([
|
||||
...(columns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"]),
|
||||
...(columns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"]),
|
||||
"shotSize",
|
||||
"narrativeIntent",
|
||||
"viewerPOV",
|
||||
@@ -240,7 +240,7 @@ export function attachNodeToStoryboardRow(nodes: CanvasNodeData[], connection: P
|
||||
rows: (storyboard?.rows || []).map((item) => item.id !== rowId ? item : scriptNodeId === connection.fromNodeId
|
||||
? { ...item, imageNodeId: linkedNode.type === CanvasNodeType.Image ? linkedNode.id : item.imageNodeId, videoNodeId: linkedNode.type === CanvasNodeType.Video ? linkedNode.id : item.videoNodeId }
|
||||
: { ...item, referenceNodeIds: Array.from(new Set([...(item.referenceNodeIds || []), linkedNode.id])) }),
|
||||
visibleColumns: storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
|
||||
visibleColumns: storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
|
||||
referenceNodeIds: handleId === "storyboard:context" ? Array.from(new Set([...(storyboard?.referenceNodeIds || []), linkedNode.id])) : storyboard?.referenceNodeIds || [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -53,7 +53,7 @@ export function createShortDramaPipeline(center: Position) {
|
||||
workflowTitle: "分镜脚本",
|
||||
storyboard: {
|
||||
rows: [],
|
||||
visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
|
||||
visibleColumns: ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
|
||||
referenceNodeIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export function upsertProjectChapterStoryboard(
|
||||
|
||||
const storyboard: StoryboardData = {
|
||||
rows,
|
||||
visibleColumns: existing?.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
|
||||
visibleColumns: existing?.metadata?.storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
|
||||
referenceNodeIds: existing?.metadata?.storyboard?.referenceNodeIds || [],
|
||||
};
|
||||
const scriptNode: CanvasNodeData = existing
|
||||
@@ -75,7 +75,7 @@ function createChapterStoryboardNode(nodes: CanvasNodeData[], unit: Pick<Project
|
||||
chapterTitle: unit.title,
|
||||
storyboard: {
|
||||
rows,
|
||||
visibleColumns: ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
|
||||
visibleColumns: ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
|
||||
referenceNodeIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,22 +95,15 @@ export function useCanvasRenderModel({
|
||||
return hidden;
|
||||
}, [collapsedBatchChildIds, collapsingBatchIds, nodeById, nodes]);
|
||||
const connectionLayerBounds = useMemo(() => {
|
||||
if (!nodes.length) return { left: -1, top: -1, width: 2, height: 2 };
|
||||
const padding = 320;
|
||||
let left = Number.POSITIVE_INFINITY;
|
||||
let top = Number.POSITIVE_INFINITY;
|
||||
let right = Number.NEGATIVE_INFINITY;
|
||||
let bottom = Number.NEGATIVE_INFINITY;
|
||||
nodes.forEach((node) => {
|
||||
left = Math.min(left, node.position.x - padding);
|
||||
top = Math.min(top, node.position.y - padding);
|
||||
right = Math.max(right, node.position.x + node.width + padding);
|
||||
bottom = Math.max(bottom, node.position.y + node.height + padding);
|
||||
});
|
||||
return { left, top, width: Math.max(2, right - left), height: Math.max(2, bottom - top) };
|
||||
}, [nodes]);
|
||||
const padding = (reduceMediaEffects ? 96 : 144) / Math.max(viewport.k, 0.05);
|
||||
const left = -viewport.x / viewport.k - padding;
|
||||
const top = -viewport.y / viewport.k - padding;
|
||||
const width = viewportSize.width / viewport.k + padding * 2;
|
||||
const height = viewportSize.height / viewport.k + padding * 2;
|
||||
return { left, top, width: Math.max(2, width), height: Math.max(2, height) };
|
||||
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
|
||||
const renderBounds = useMemo(() => {
|
||||
const padding = (reduceMediaEffects ? 240 : 360) / viewport.k;
|
||||
const padding = (reduceMediaEffects ? 128 : 192) / viewport.k;
|
||||
const viewLeft = -viewport.x / viewport.k - padding;
|
||||
const viewTop = -viewport.y / viewport.k - padding;
|
||||
const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2;
|
||||
|
||||
@@ -80,7 +80,7 @@ export function useCanvasStoryboard({
|
||||
...node.metadata,
|
||||
storyboard: {
|
||||
rows: updater(node.metadata?.storyboard?.rows || []),
|
||||
visibleColumns: node.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
|
||||
visibleColumns: node.metadata?.storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
|
||||
referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [],
|
||||
},
|
||||
},
|
||||
@@ -225,7 +225,7 @@ export function useCanvasStoryboard({
|
||||
...scriptNode.metadata,
|
||||
storyboard: {
|
||||
rows: (scriptNode.metadata?.storyboard?.rows || []).map((row) => ({ ...row, imageNodeId: imageNodeByRowId.get(row.id) || row.imageNodeId })),
|
||||
visibleColumns: scriptNode.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
|
||||
visibleColumns: scriptNode.metadata?.storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
|
||||
referenceNodeIds: scriptNode.metadata?.storyboard?.referenceNodeIds || [],
|
||||
},
|
||||
},
|
||||
@@ -313,7 +313,7 @@ export function useCanvasStoryboard({
|
||||
...scriptNode.metadata,
|
||||
storyboard: {
|
||||
rows: allRows.map((row) => ({ ...row, videoNodeId: videoNodeByRowId.get(row.id) || row.videoNodeId })),
|
||||
visibleColumns: scriptNode.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"],
|
||||
visibleColumns: scriptNode.metadata?.storyboard?.visibleColumns || ["shotNumber", "plotDescription", "videoMotionPrompt", "dialogue"],
|
||||
referenceNodeIds: scriptNode.metadata?.storyboard?.referenceNodeIds || [],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user