feat(canvas): allow mixer renders and improve edge insert visibility
This commit is contained in:
@@ -8,6 +8,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import DefaultEdge from "@/components/canvas/edges/default-edge";
|
import DefaultEdge from "@/components/canvas/edges/default-edge";
|
||||||
|
|
||||||
|
const mockViewport = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
zoom: 1,
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock("@xyflow/react", async () => {
|
vi.mock("@xyflow/react", async () => {
|
||||||
const actual = await vi.importActual<typeof import("@xyflow/react")>(
|
const actual = await vi.importActual<typeof import("@xyflow/react")>(
|
||||||
"@xyflow/react",
|
"@xyflow/react",
|
||||||
@@ -18,6 +24,7 @@ vi.mock("@xyflow/react", async () => {
|
|||||||
EdgeLabelRenderer: ({ children }: { children: ReactNode }) => (
|
EdgeLabelRenderer: ({ children }: { children: ReactNode }) => (
|
||||||
<foreignObject>{children}</foreignObject>
|
<foreignObject>{children}</foreignObject>
|
||||||
),
|
),
|
||||||
|
useViewport: () => mockViewport,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,6 +102,8 @@ describe("DefaultEdge", () => {
|
|||||||
let container: HTMLDivElement | null = null;
|
let container: HTMLDivElement | null = null;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
mockViewport.zoom = 1;
|
||||||
|
|
||||||
if (root) {
|
if (root) {
|
||||||
act(() => {
|
act(() => {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
@@ -185,4 +194,33 @@ describe("DefaultEdge", () => {
|
|||||||
expect(edgePath).not.toBeNull();
|
expect(edgePath).not.toBeNull();
|
||||||
expect(edgePath?.getAttribute("d")).toBeTruthy();
|
expect(edgePath?.getAttribute("d")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies zoom-aware scaling with clamps to keep the plus legible", () => {
|
||||||
|
const onInsertClick = vi.fn<(anchor: EdgeInsertAnchor) => void>();
|
||||||
|
|
||||||
|
mockViewport.zoom = 0.2;
|
||||||
|
({ container, root } = renderEdge({ onInsertClick, isMenuOpen: true }));
|
||||||
|
const insertButton = getInsertButton(container);
|
||||||
|
expect(insertButton.style.transform).toContain("scale(2.2)");
|
||||||
|
|
||||||
|
mockViewport.zoom = 4;
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<svg>
|
||||||
|
<DefaultEdgeComponent {...baseProps} onInsertClick={onInsertClick} isMenuOpen />
|
||||||
|
</svg>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(insertButton.style.transform).toContain("scale(0.95)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses stronger visual styling for distant zoom visibility", () => {
|
||||||
|
const onInsertClick = vi.fn<(anchor: EdgeInsertAnchor) => void>();
|
||||||
|
({ container, root } = renderEdge({ onInsertClick, isMenuOpen: true }));
|
||||||
|
|
||||||
|
const insertButton = getInsertButton(container);
|
||||||
|
expect(insertButton.className).toContain("border-2");
|
||||||
|
expect(insertButton.className).toContain("ring-1");
|
||||||
|
expect(insertButton.className).toContain("shadow");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,26 @@ import {
|
|||||||
BaseEdge,
|
BaseEdge,
|
||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
getBezierPath,
|
getBezierPath,
|
||||||
|
useViewport,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
|
const MIN_EDGE_INSERT_BUTTON_SCALE = 0.95;
|
||||||
|
const MAX_EDGE_INSERT_BUTTON_SCALE = 2.2;
|
||||||
|
|
||||||
|
function getEdgeInsertButtonScale(zoom: number): number {
|
||||||
|
if (!Number.isFinite(zoom) || zoom <= 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inverseZoom = 1 / zoom;
|
||||||
|
return Math.min(
|
||||||
|
MAX_EDGE_INSERT_BUTTON_SCALE,
|
||||||
|
Math.max(MIN_EDGE_INSERT_BUTTON_SCALE, inverseZoom),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type DefaultEdgeInsertAnchor = {
|
export type DefaultEdgeInsertAnchor = {
|
||||||
edgeId: string;
|
edgeId: string;
|
||||||
screenX: number;
|
screenX: number;
|
||||||
@@ -41,6 +57,7 @@ export default function DefaultEdge({
|
|||||||
}: DefaultEdgeProps) {
|
}: DefaultEdgeProps) {
|
||||||
const [isEdgeHovered, setIsEdgeHovered] = useState(false);
|
const [isEdgeHovered, setIsEdgeHovered] = useState(false);
|
||||||
const [isButtonHovered, setIsButtonHovered] = useState(false);
|
const [isButtonHovered, setIsButtonHovered] = useState(false);
|
||||||
|
const { zoom } = useViewport();
|
||||||
|
|
||||||
const [edgePath, labelX, labelY] = useMemo(
|
const [edgePath, labelX, labelY] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -58,6 +75,7 @@ export default function DefaultEdge({
|
|||||||
const resolvedEdgeId = edgeId ?? id;
|
const resolvedEdgeId = edgeId ?? id;
|
||||||
const canInsert = Boolean(onInsertClick) && !disabled;
|
const canInsert = Boolean(onInsertClick) && !disabled;
|
||||||
const isInsertVisible = canInsert && (isMenuOpen || isEdgeHovered || isButtonHovered);
|
const isInsertVisible = canInsert && (isMenuOpen || isEdgeHovered || isButtonHovered);
|
||||||
|
const buttonScale = getEdgeInsertButtonScale(zoom);
|
||||||
|
|
||||||
const handleInsertClick = (event: MouseEvent<HTMLButtonElement>) => {
|
const handleInsertClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||||
if (!onInsertClick || disabled) {
|
if (!onInsertClick || disabled) {
|
||||||
@@ -97,9 +115,10 @@ export default function DefaultEdge({
|
|||||||
aria-label="Insert node"
|
aria-label="Insert node"
|
||||||
aria-hidden={!isInsertVisible}
|
aria-hidden={!isInsertVisible}
|
||||||
disabled={!canInsert}
|
disabled={!canInsert}
|
||||||
className="nodrag nopan absolute h-7 w-7 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-opacity"
|
className="nodrag nopan absolute h-8 w-8 items-center justify-center rounded-full border-2 border-border/90 bg-background/95 text-foreground shadow-[0_2px_10px_rgba(0,0,0,0.28)] ring-1 ring-background/90 transition-opacity transition-shadow"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px) scale(${buttonScale})`,
|
||||||
|
transformOrigin: "center center",
|
||||||
opacity: isInsertVisible ? 1 : 0,
|
opacity: isInsertVisible ? 1 : 0,
|
||||||
pointerEvents: isInsertVisible ? "all" : "none",
|
pointerEvents: isInsertVisible ? "all" : "none",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -108,7 +127,7 @@ export default function DefaultEdge({
|
|||||||
onMouseLeave={() => setIsButtonHovered(false)}
|
onMouseLeave={() => setIsButtonHovered(false)}
|
||||||
onClick={handleInsertClick}
|
onClick={handleInsertClick}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
<Plus className="h-[18px] w-[18px] stroke-[2.5]" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</EdgeLabelRenderer>
|
</EdgeLabelRenderer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const RENDER_ALLOWED_SOURCE_TYPES = new Set<string>([
|
|||||||
"image",
|
"image",
|
||||||
"asset",
|
"asset",
|
||||||
"ai-image",
|
"ai-image",
|
||||||
|
"mixer",
|
||||||
"crop",
|
"crop",
|
||||||
"curves",
|
"curves",
|
||||||
"color-adjust",
|
"color-adjust",
|
||||||
@@ -209,7 +210,7 @@ export function getCanvasConnectionValidationMessage(
|
|||||||
case "adjustment-target-forbidden":
|
case "adjustment-target-forbidden":
|
||||||
return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden.";
|
return "Adjustment-Ausgaben koennen nicht an Prompt- oder KI-Bild-Nodes angeschlossen werden.";
|
||||||
case "render-source-invalid":
|
case "render-source-invalid":
|
||||||
return "Render akzeptiert nur Bild-, Asset-, KI-Bild-, Crop- oder Adjustment-Input.";
|
return "Render akzeptiert nur Bild-, Asset-, KI-Bild-, Mixer-, Crop- oder Adjustment-Input.";
|
||||||
case "agent-source-invalid":
|
case "agent-source-invalid":
|
||||||
return "Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.";
|
return "Agent-Nodes akzeptieren nur Content- und Kontext-Inputs, keine Generierungs-Steuerknoten wie Prompt.";
|
||||||
case "agent-output-source-invalid":
|
case "agent-output-source-invalid":
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ describe("canvas connection policy", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows mixer as render source", () => {
|
||||||
|
expect(
|
||||||
|
validateCanvasConnectionPolicy({
|
||||||
|
sourceType: "mixer",
|
||||||
|
targetType: "render",
|
||||||
|
targetIncomingCount: 0,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("describes unsupported crop source message", () => {
|
it("describes unsupported crop source message", () => {
|
||||||
expect(getCanvasConnectionValidationMessage("crop-source-invalid")).toBe(
|
expect(getCanvasConnectionValidationMessage("crop-source-invalid")).toBe(
|
||||||
"Crop akzeptiert nur Bild-, Asset-, KI-Bild-, Video-, KI-Video-, Crop- oder Adjustment-Input.",
|
"Crop akzeptiert nur Bild-, Asset-, KI-Bild-, Video-, KI-Video-, Crop- oder Adjustment-Input.",
|
||||||
|
|||||||
Reference in New Issue
Block a user