perf(canvas): 画布媒体 - 优化媒体加载、播放器与节点交互 (#357)
Merge origin/main at fb089b2 and retain canvas media performance, player layout, and interaction updates.
Co-authored-by: ddCat <2695092045@qq.com>
This commit is contained in:
@@ -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, Settings2, Star, Trash2, Type, Video } from "lucide-react";
|
||||
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, RefreshCw, Settings2, Star, Trash2, Type, Video } from "lucide-react";
|
||||
|
||||
import { useCanvasNodeActions } from "./canvas-node-action-context";
|
||||
|
||||
@@ -23,6 +23,8 @@ type CanvasNodeProps = {
|
||||
dragOffset?: Position;
|
||||
scale: number;
|
||||
isSelected: boolean;
|
||||
mediaActive?: boolean;
|
||||
hydrateMediaPreview?: boolean;
|
||||
isRelated: boolean;
|
||||
isFocusRelated: boolean;
|
||||
isConnectionTarget: boolean;
|
||||
@@ -67,6 +69,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
dragOffset,
|
||||
scale,
|
||||
isSelected,
|
||||
mediaActive = false,
|
||||
hydrateMediaPreview = false,
|
||||
isRelated,
|
||||
isFocusRelated,
|
||||
isConnectionTarget,
|
||||
@@ -112,7 +116,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
const { download: downloadNode, duplicate: duplicateNode, deleteNode } = useCanvasNodeActions();
|
||||
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
|
||||
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
|
||||
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
|
||||
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content || data.metadata?.storageKey);
|
||||
const mediaDimensionLabel = formatMediaDimensionLabel(data, hasImageContent || hasVideoContent);
|
||||
const isComposerNode = data.type === CanvasNodeType.Config;
|
||||
const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent;
|
||||
@@ -261,7 +265,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
return (
|
||||
<div
|
||||
data-node-id={data.id}
|
||||
className={`node-element absolute flex select-none flex-col ${dragOffset ? "cursor-grabbing" : data.type === CanvasNodeType.Drawing ? "cursor-pointer" : "cursor-default"} ${isSelected ? "z-[var(--z-node-active)]" : "z-[var(--z-node)]"}`}
|
||||
className={`node-element absolute flex select-none flex-col ${dragOffset ? "cursor-grabbing" : data.type === CanvasNodeType.Drawing ? "cursor-pointer" : "cursor-default"} ${isSelected && data.type === CanvasNodeType.Video ? "z-[var(--z-node-toolbar)]" : isSelected || isFocusRelated || isConnectionTarget ? "z-[var(--z-node-active)]" : "z-[var(--z-node)]"}`}
|
||||
style={{
|
||||
transform: `translate(${data.position.x + (dragOffset?.x || 0)}px, ${data.position.y + (dragOffset?.y || 0)}px)`,
|
||||
width: data.width,
|
||||
@@ -282,7 +286,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
node={data}
|
||||
scale={scale}
|
||||
dimensionLabel={mediaDimensionLabel}
|
||||
active={hovered || isSelected || isFocusRelated}
|
||||
active={isSelected || isFocusRelated}
|
||||
editable={!readOnly && !data.metadata?.locked && Boolean(onTitleChange)}
|
||||
editing={isEditingTitle}
|
||||
draft={titleDraft}
|
||||
@@ -300,7 +304,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
// 固定占位但不绘制描边,避免聚焦切换时边框宽度变化造成白边跳动。
|
||||
border: isComposerNode ? "0" : "1px solid transparent",
|
||||
boxShadow: isComposerNode ? "none" : isSelected || hovered ? theme.node.hoverShadow : theme.node.shadow,
|
||||
boxShadow: isComposerNode ? "none" : isSelected || isFocusRelated ? theme.node.hoverShadow : theme.node.shadow,
|
||||
}}
|
||||
onMouseDown={(event) => onMouseDown(event, data.id)}
|
||||
onDoubleClick={(event) => {
|
||||
@@ -338,7 +342,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script ? "overflow-visible" : "overflow-hidden"}`}
|
||||
style={
|
||||
{
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
background: hasImageContent || hasVideoContent || hasAudioContent ? "transparent" : theme.node.fill,
|
||||
"--batch-from-x": `${batchMotion?.x || 0}px`,
|
||||
"--batch-from-y": `${batchMotion?.y || 0}px`,
|
||||
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
|
||||
@@ -371,6 +375,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onOpenTaskDetails={onOpenTaskDetails}
|
||||
onToggleBatch={() => onToggleBatch?.(data.id)}
|
||||
reduceMediaEffects={reduceMediaEffects}
|
||||
mediaActive={mediaActive}
|
||||
hydrateMediaPreview={hydrateMediaPreview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -460,8 +466,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
</> : null}
|
||||
</div>
|
||||
|
||||
{!readOnly && data.type !== CanvasNodeType.Script && (hovered || forceInputVisible) ? <ConnectionSideRail side="left" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
|
||||
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection && hovered ? <ConnectionSideRail side="right" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
|
||||
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} theme={theme} visible={hovered || forceInputVisible} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
|
||||
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection ? <ConnectionSideRail side="right" scale={scale} theme={theme} visible={hovered} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
@@ -474,6 +480,7 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
|
||||
previous.dragOffset?.y === next.dragOffset?.y &&
|
||||
previous.scale === next.scale &&
|
||||
previous.isSelected === next.isSelected &&
|
||||
previous.mediaActive === next.mediaActive &&
|
||||
previous.isRelated === next.isRelated &&
|
||||
previous.isFocusRelated === next.isFocusRelated &&
|
||||
previous.isConnectionTarget === next.isConnectionTarget &&
|
||||
@@ -629,21 +636,21 @@ function NodeExternalHeader({ node, scale, dimensionLabel, active, editable, edi
|
||||
const inverseScale = 1 / Math.max(scale, 0.05);
|
||||
const Icon = nodeTypeIcon(node.type);
|
||||
const maxHeaderWidth = Math.min(240, node.width * scale);
|
||||
const externalHeaderWidth = node.width * scale;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="canvas-node-external-header absolute bottom-full left-0 z-[var(--node-z-overlay)] flex h-6 items-center gap-1 overflow-hidden"
|
||||
style={{
|
||||
width: dimensionLabel ? externalHeaderWidth : undefined,
|
||||
width: dimensionLabel ? "calc(var(--canvas-node-width) * var(--canvas-live-scale, 1))" : undefined,
|
||||
maxWidth: dimensionLabel ? undefined : maxHeaderWidth,
|
||||
"--canvas-node-width": `${node.width}px`,
|
||||
borderRadius: "var(--r-sm)",
|
||||
background: "transparent",
|
||||
paddingInline: "var(--space-1-half)",
|
||||
color: active ? theme.node.text : theme.node.label,
|
||||
transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`,
|
||||
transformOrigin: "left bottom",
|
||||
}}
|
||||
} as React.CSSProperties}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -729,53 +736,93 @@ function NodeStatusBadge({ status }: { status: "loading" | "success" | "error" }
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionSideRail({ side, scale, theme, onPointerDown }: { side: "left" | "right"; scale: number; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
|
||||
function ConnectionSideRail({ side, scale, theme, visible = false, onPointerDown }: { side: "left" | "right"; scale: number; theme: CanvasTheme; visible?: boolean; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
|
||||
const handleRef = useRef<HTMLSpanElement>(null);
|
||||
const anchorRatioRef = useRef(0.5);
|
||||
const [railHovered, setRailHovered] = useState(false);
|
||||
const inverseScale = 1 / Math.max(scale, 0.05);
|
||||
const railSize = 80;
|
||||
// Keep the control responsive to zoom, but avoid sub-pixel circles and
|
||||
// strokes at far zoom levels where the plus sign appears visually off-center.
|
||||
const handleSize = Math.max(20, 8 * inverseScale);
|
||||
// LibTV centers the visual quick-add icon in an approximately 80px
|
||||
// circular hit zone, then offsets it toward the node edge. Keep that
|
||||
// visual layer separate from the real centered connection anchor.
|
||||
const sideOffset = side === "left" ? 25 : -25;
|
||||
|
||||
const resetAnchor = useCallback(() => {
|
||||
anchorRatioRef.current = 0.5;
|
||||
if (handleRef.current) handleRef.current.style.top = "50%";
|
||||
}, []);
|
||||
const resetHandle = useCallback(() => {
|
||||
if (!handleRef.current) return;
|
||||
handleRef.current.style.transform = `translate(${sideOffset}px, 0) scale(1)`;
|
||||
}, [sideOffset]);
|
||||
|
||||
const updateAnchor = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const railBounds = event.currentTarget.getBoundingClientRect();
|
||||
const nodeBounds = event.currentTarget.parentElement?.getBoundingClientRect() || railBounds;
|
||||
const screenPadding = Math.min(railBounds.height * 0.35, 12);
|
||||
const railRatio = Math.min(1 - screenPadding / Math.max(railBounds.height, 1), Math.max(screenPadding / Math.max(railBounds.height, 1), (event.clientY - railBounds.top) / Math.max(railBounds.height, 1)));
|
||||
const anchorY = railBounds.top + railBounds.height * railRatio;
|
||||
anchorRatioRef.current = Math.min(1, Math.max(0, (anchorY - nodeBounds.top) / Math.max(nodeBounds.height, 1)));
|
||||
if (handleRef.current) handleRef.current.style.top = `${railRatio * 100}%`;
|
||||
const updateHandle = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const width = Math.max(bounds.width, 1);
|
||||
const height = Math.max(bounds.height, 1);
|
||||
const followLimit = 30;
|
||||
const deltaX = event.clientX - (bounds.left + width / 2);
|
||||
const deltaY = event.clientY - (bounds.top + height / 2);
|
||||
const offsetScreenX = Math.max(-followLimit, Math.min(followLimit, deltaX));
|
||||
const offsetScreenY = Math.max(-followLimit, Math.min(followLimit, deltaY));
|
||||
const focus = 1 + Math.max(0, 1 - Math.hypot(offsetScreenX, offsetScreenY) / followLimit) * 0.1;
|
||||
// While the pointer is inside the rail LibTV uses the rail center as
|
||||
// the origin. The side-specific +/-25px offset is only the resting
|
||||
// position used after leaving the rail.
|
||||
const offsetX = offsetScreenX * inverseScale;
|
||||
const offsetY = offsetScreenY * inverseScale;
|
||||
if (handleRef.current) handleRef.current.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${focus})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group pointer-events-auto absolute top-1/2 z-[var(--node-z-overlay)] touch-none -translate-y-1/2 opacity-100 outline-none transition-opacity duration-150"
|
||||
style={{ width: 56 * inverseScale, height: `min(100%, ${72 * inverseScale}px)`, ...(side === "left" ? { right: "100%" } : { left: "100%" }) }}
|
||||
onPointerEnter={updateAnchor}
|
||||
onPointerMove={updateAnchor}
|
||||
onPointerLeave={resetAnchor}
|
||||
onPointerDown={(event) => onPointerDown(event, anchorRatioRef.current)}
|
||||
data-canvas-connection-rail={side}
|
||||
className={`group pointer-events-auto absolute top-1/2 z-[var(--node-z-overlay)] flex -translate-y-1/2 items-center justify-center touch-none cursor-crosshair rounded-full outline-none transition-opacity duration-150 ${visible || railHovered ? "opacity-100" : "opacity-0"}`}
|
||||
style={{ width: railSize, height: `min(100%, ${railSize}px)`, cursor: "crosshair", ...(side === "left" ? { right: "100%" } : { left: "100%" }) }}
|
||||
onPointerEnter={(event) => {
|
||||
setRailHovered(true);
|
||||
updateHandle(event);
|
||||
}}
|
||||
onPointerMove={updateHandle}
|
||||
onPointerLeave={() => {
|
||||
setRailHovered(false);
|
||||
resetHandle();
|
||||
}}
|
||||
onPointerDown={(event) => onPointerDown(event, 0.5)}
|
||||
aria-label={`${side === "left" ? "输入" : "输出"}连接点,单击创建节点或拖动连线`}
|
||||
>
|
||||
<span
|
||||
ref={handleRef}
|
||||
className="absolute grid -translate-y-1/2 place-items-center rounded-full border transition-[background-color,box-shadow] duration-150 group-hover:brightness-125 group-focus-visible:brightness-125"
|
||||
className="absolute left-1/2 top-1/2 block transition-transform duration-[80ms] ease-out group-hover:brightness-125 group-focus-visible:brightness-125"
|
||||
style={{
|
||||
top: "50%",
|
||||
width: 18 * inverseScale,
|
||||
height: 18 * inverseScale,
|
||||
...(side === "left" ? { right: 6 * inverseScale } : { left: 6 * inverseScale }),
|
||||
borderWidth: inverseScale,
|
||||
background: theme.spatial.elevated,
|
||||
borderColor: theme.node.activeStroke,
|
||||
color: theme.node.activeStroke,
|
||||
boxShadow: "none",
|
||||
width: handleSize,
|
||||
height: handleSize,
|
||||
marginLeft: -handleSize / 2,
|
||||
marginTop: -handleSize / 2,
|
||||
transform: `translate(${sideOffset}px, 0) scale(1)`,
|
||||
transition: "transform 80ms ease-out",
|
||||
transformOrigin: "center",
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
<Plus style={{ width: 10 * inverseScale, height: 10 * inverseScale }} strokeWidth={2} />
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="block"
|
||||
width={handleSize}
|
||||
height={handleSize}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
marginLeft: -handleSize / 2,
|
||||
marginTop: -handleSize / 2,
|
||||
}}
|
||||
>
|
||||
<circle cx="10" cy="10" r="9.35" fill={theme.spatial.elevated} />
|
||||
<circle cx="10" cy="10" r="9.35" stroke={theme.node.activeStroke} strokeWidth="1.2" />
|
||||
<path d="M10 6.5v7M6.5 10h7" stroke={theme.node.activeStroke} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user