fix(canvas): 画布节点 hover - 消除指针跟随位移导致的抖动(修复 #186) (#190)

* fix(ci): 后端测试 - 修复 responses 流式测试数据与 Grok 生图测试 SSRF 放行

* fix(canvas): 画布节点 hover - 消除指针跟随位移导致的抖动(修复 #186)

---------

Co-authored-by: zhaoyue <zhaoyue@rongshantech.com>
This commit is contained in:
爱笑的毛毛虫andzhaoyue authored and GitHub committed 2026-08-08 18:28:10 +08:00
1 parent 554eb49000
commit b26b641ec3
4 files changed
+32 -22

No files matched your search

+2 -1
View File
@@ -57,7 +57,7 @@ func TestWriteMediaPartSanitizesFilenameAndSetsMimeType(t *testing.T) {
func TestParseTextEventStreamSupportsResponsesAndChat(t *testing.T) { func TestParseTextEventStreamSupportsResponsesAndChat(t *testing.T) {
responses := []byte(`event: response.output_text.delta responses := []byte(`event: response.output_text.delta
data: {"delta":"{\"title\":\"分镜\"}"} data: {"delta":"{\"title\":\"分镜\""}
event: response.output_text.delta event: response.output_text.delta
data: {"delta":"}"} data: {"delta":"}"}
@@ -159,6 +159,7 @@ func TestVolcengineArkImageRejectsMaskBeforeRequest(t *testing.T) {
} }
func TestRunGrokImageTaskUsesJSONEditContract(t *testing.T) { func TestRunGrokImageTaskUsesJSONEditContract(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/images/edits" { if r.URL.Path != "/v1/images/edits" {
t.Errorf("path = %q, want /v1/images/edits", r.URL.Path) t.Errorf("path = %q, want /v1/images/edits", r.URL.Path)
@@ -156,19 +156,17 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
onMouseEnter={() => onHoverStart?.(data.id)} onMouseEnter={() => onHoverStart?.(data.id)}
onMouseLeave={() => onHoverEnd?.(data.id)} onMouseLeave={() => onHoverEnd?.(data.id)}
> >
{/* 帧节点同样禁用指针跟随 3D 位移,hover 使用 CSS 静态抬升 */}
<CometCard <CometCard
containerClassName="h-full w-full" containerClassName="h-full w-full"
className="canvas-frame-shell overflow-hidden rounded-[var(--dock-radius)] border" className="canvas-frame-shell overflow-hidden rounded-[var(--dock-radius)] border"
rotateDepth={2.4} disabled
translateDepth={2} data-canvas-frame-hover-locked={!collapsed || editing || Boolean(dragOffset) || scale < 0.32 ? "true" : "false"}
disabled={Boolean(dragOffset) || !collapsed || editing || scale < 0.32}
glare={collapsed}
style={{ style={{
background: active ? theme.frame.activeFill : theme.frame.fill, background: active ? theme.frame.activeFill : theme.frame.fill,
borderColor: active ? theme.frame.activeStroke : theme.frame.stroke, borderColor: active ? theme.frame.activeStroke : theme.frame.stroke,
borderWidth: 1 / Math.max(scale, 0.05), borderWidth: 1 / Math.max(scale, 0.05),
boxShadow: isSelected ? `0 0 0 ${1 / Math.max(scale, 0.05)}px ${theme.frame.activeStroke}33, 0 24px 72px ${theme.spatial.shadow}` : `0 18px 54px ${theme.spatial.shadow}`, boxShadow: isSelected ? `0 0 0 ${1 / Math.max(scale, 0.05)}px ${theme.frame.activeStroke}33, 0 24px 72px ${theme.spatial.shadow}` : `0 18px 54px ${theme.spatial.shadow}`,
transition: "background-color 120ms ease-out, border-color 120ms ease-out",
}} }}
> >
<div className="pointer-events-auto absolute inset-x-0 top-0 z-10 flex items-center gap-1.5 px-1.5" style={{ height: FRAME_HEADER_HEIGHT, color: theme.node.text }}> <div className="pointer-events-auto absolute inset-x-0 top-0 z-10 flex items-center gap-1.5 px-1.5" style={{ height: FRAME_HEADER_HEIGHT, color: theme.node.text }}>
+5 -11
View File
@@ -149,9 +149,7 @@ export const CanvasNode = React.memo(function CanvasNode({
const mediaBorderColor = isActive ? theme.accent.primary : isRelated && !isBatchChild ? theme.accent.primary : "transparent"; const mediaBorderColor = isActive ? theme.accent.primary : isRelated && !isBatchChild ? theme.accent.primary : "transparent";
const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || []; const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || [];
const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null; const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null;
const cometDepth = hasMediaContent ? 6.8 : data.type === CanvasNodeType.Script ? 2.8 : 4.6; const nodeHoverLocked = reduceMediaEffects || Boolean(dragOffset) || isEditingContent || isEditingTitle || isGeneratingNode || scale < 0.32 || batchClosing || batchOpening;
const cometTranslate = hasMediaContent ? 6 : data.type === CanvasNodeType.Script ? 2.5 : 4;
const cometDisabled = reduceMediaEffects || Boolean(dragOffset) || isEditingContent || isEditingTitle || isGeneratingNode || scale < 0.32 || batchClosing || batchOpening;
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const resizeRef = useRef({ const resizeRef = useRef({
isResizing: false, isResizing: false,
@@ -318,13 +316,12 @@ export const CanvasNode = React.memo(function CanvasNode({
onCommit={commitTitle} onCommit={commitTitle}
onCancel={() => { setTitleDraft(data.title); setIsEditingTitle(false); }} onCancel={() => { setTitleDraft(data.title); setIsEditingTitle(false); }}
/> />
{/* 画布节点不启用指针跟随 3D 位移,hover 反馈统一走 CSS 静态抬升,避免鼠标移动形成反馈震荡 */}
<CometCard <CometCard
containerClassName="overflow-visible" containerClassName="overflow-visible"
className={`canvas-node-shell relative h-full w-full overflow-visible rounded-[var(--node-radius)] ${flushMediaContent ? "border-0" : "border"} ${isGeneratingNode ? "canvas-node-shell-generating" : ""}`} className={`canvas-node-shell relative h-full w-full overflow-visible rounded-[var(--node-radius)] ${flushMediaContent ? "border-0" : "border"} ${isGeneratingNode ? "canvas-node-shell-generating" : ""}`}
rotateDepth={cometDepth} disabled
translateDepth={cometTranslate} data-canvas-node-hover-locked={nodeHoverLocked ? "true" : "false"}
disabled={cometDisabled}
glare={!isGeneratingNode}
data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")} data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")}
style={{ style={{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill, background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
@@ -335,10 +332,7 @@ export const CanvasNode = React.memo(function CanvasNode({
? `0 0 0 1px ${theme.accent.primary}66, 0 0 0 4px ${theme.accent.primary}1a, 0 24px 72px ${theme.spatial.shadow}` // selected-primary:4px软色环 + resize handle ? `0 0 0 1px ${theme.accent.primary}66, 0 0 0 4px ${theme.accent.primary}1a, 0 24px 72px ${theme.spatial.shadow}` // selected-primary:4px软色环 + resize handle
: isSelected || (isRelated && !isBatchChild) : isSelected || (isRelated && !isBatchChild)
? `0 0 0 2px ${theme.accent.primary}40, 0 22px 60px ${theme.spatial.shadow}` // selected:2px边框环 ? `0 0 0 2px ${theme.accent.primary}40, 0 22px 60px ${theme.spatial.shadow}` // selected:2px边框环
: hovered : undefined, // idle:无额外阴影
? `0 16px 48px ${theme.spatial.shadow}` // hover:轻微抬升阴影
: undefined, // idle:无额外阴影
transition: "border-color 120ms ease-out, box-shadow 150ms ease-out",
}} }}
onMouseDown={(event) => onMouseDown(event, data.id)} onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => { onDoubleClick={(event) => {
+22 -5
View File
@@ -647,6 +647,7 @@
--node-active-glow-strong: 0 0 0 6px var(--cn-active-stroke-soft); --node-active-glow-strong: 0 0 0 6px var(--cn-active-stroke-soft);
--node-shadow: var(--shadow-sm); --node-shadow: var(--shadow-sm);
--node-shadow-hover: var(--shadow-md); --node-shadow-hover: var(--shadow-md);
--node-hover-lift: var(--space-1); /* 4px 节点 hover 静态抬升 */
--node-shadow-selected: var(--shadow-lg); --node-shadow-selected: var(--shadow-lg);
--node-shadow-selected-primary: var(--shadow-xl); --node-shadow-selected-primary: var(--shadow-xl);
--node-shadow-active: var(--shadow-2xl); --node-shadow-active: var(--shadow-2xl);
@@ -5308,17 +5309,29 @@
transition: transform 160ms var(--motion-ease-out); transition: transform 160ms var(--motion-ease-out);
} }
.canvas-node-shell { .canvas-node-shell,
.canvas-frame-shell {
transition: transition:
border-color 160ms ease, border-color var(--motion-state) var(--ease-aceternity),
box-shadow 160ms ease, box-shadow var(--motion-state) var(--ease-aceternity),
background-color 160ms ease; background-color var(--motion-state) var(--ease-aceternity),
transform var(--motion-panel) var(--ease-aceternity);
} }
.canvas-node-shell.aceternity-comet-card { .canvas-node-shell.aceternity-comet-card {
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.1), 0 2px 6px rgba(15, 23, 42, 0.06); box-shadow: 0 18px 48px rgba(15, 23, 42, 0.1), 0 2px 6px rgba(15, 23, 42, 0.06);
} }
.canvas-node-shell.aceternity-comet-card:hover,
.canvas-frame-shell:hover {
transform: translateY(var(--node-hover-lift));
}
.canvas-node-shell[data-canvas-node-hover-locked="true"],
.canvas-frame-shell[data-canvas-frame-hover-locked="true"] {
transform: none !important;
}
.canvas-node-shell.aceternity-comet-card:hover { .canvas-node-shell.aceternity-comet-card:hover {
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.18), 0 6px 20px rgba(15, 23, 42, 0.1); box-shadow: 0 30px 80px rgba(15, 23, 42, 0.18), 0 6px 20px rgba(15, 23, 42, 0.1);
} }
@@ -5332,8 +5345,10 @@
} }
[data-canvas-viewport-interacting="true"] .canvas-node-shell, [data-canvas-viewport-interacting="true"] .canvas-node-shell,
[data-canvas-viewport-interacting="true"] .canvas-frame-shell,
[data-canvas-viewport-interacting="true"] .aceternity-comet-card { [data-canvas-viewport-interacting="true"] .aceternity-comet-card {
transition: none !important; transition: none !important;
transform: none !important;
} }
/* 静止期用最终倍率重新排版 DOM,避免 Windows Chromium 复用交互期的低分辨率合成纹理。 */ /* 静止期用最终倍率重新排版 DOM,避免 Windows Chromium 复用交互期的低分辨率合成纹理。 */
@@ -5487,7 +5502,9 @@
.aceternity-comet-card, .aceternity-comet-card,
.aceternity-dock-command, .aceternity-dock-command,
.aceternity-floating-panel, .aceternity-floating-panel,
.canvas-menu-item { .canvas-menu-item,
.canvas-node-shell,
.canvas-frame-shell {
transform: none !important; transform: none !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }