From 9a346554ec25fbcf060d9065eb27fc502ab67d54 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 22:07:09 +0200 Subject: [PATCH] feat(canvas): highlight favorites without hiding nodes --- .../canvas-favorites-visibility.test.ts | 184 ++++++++++++++++++ .../canvas/__tests__/canvas-toolbar.test.tsx | 173 ++++++++++++++++ .../canvas/canvas-favorites-visibility.ts | 140 +++++++++++++ components/canvas/canvas-toolbar.tsx | 26 +++ components/canvas/canvas.tsx | 19 +- vitest.config.ts | 2 + 6 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 components/canvas/__tests__/canvas-favorites-visibility.test.ts create mode 100644 components/canvas/__tests__/canvas-toolbar.test.tsx create mode 100644 components/canvas/canvas-favorites-visibility.ts diff --git a/components/canvas/__tests__/canvas-favorites-visibility.test.ts b/components/canvas/__tests__/canvas-favorites-visibility.test.ts new file mode 100644 index 0000000..f4b117a --- /dev/null +++ b/components/canvas/__tests__/canvas-favorites-visibility.test.ts @@ -0,0 +1,184 @@ +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; +import { describe, expect, it } from "vitest"; + +import { projectCanvasFavoritesVisibility } from "../canvas-favorites-visibility"; + +function createNode( + id: string, + data: Record, + options?: { + style?: RFNode>["style"]; + className?: string; + }, +): RFNode> { + return { + id, + position: { x: 0, y: 0 }, + data, + style: options?.style, + className: options?.className, + type: "note", + }; +} + +function createEdge( + id: string, + source: string, + target: string, + options?: { + style?: RFEdge>["style"]; + className?: string; + }, +): RFEdge> { + return { + id, + source, + target, + style: options?.style, + className: options?.className, + type: "default", + }; +} + +describe("projectCanvasFavoritesVisibility", () => { + it("keeps nodes and edges unchanged when favorites focus mode is inactive", () => { + const nodes = [ + createNode("node-a", { isFavorite: true }), + createNode("node-b", { label: "normal" }, { style: { width: 280, height: 200 } }), + ]; + const edges = [ + createEdge("edge-a", "node-a", "node-b", { + style: { stroke: "rgb(0, 0, 0)", strokeWidth: 2 }, + }), + ]; + + const result = projectCanvasFavoritesVisibility({ + nodes, + edges, + favoritesOnly: false, + }); + + expect(result.nodes[0]).toBe(nodes[0]); + expect(result.nodes[1]).toBe(nodes[1]); + expect(result.edges[0]).toBe(edges[0]); + expect(result.favoriteCount).toBe(1); + expect(Array.from(result.favoriteNodeIds)).toEqual(["node-a"]); + expect(result.nodes[1]?.style).toEqual({ width: 280, height: 200 }); + expect(result.edges[0]?.style).toEqual({ stroke: "rgb(0, 0, 0)", strokeWidth: 2 }); + }); + + it("dims non-favorite nodes when favorites focus mode is active", () => { + const nodes = [ + createNode("node-a", { isFavorite: true }), + createNode("node-b", { label: "normal" }, { className: "custom-node" }), + createNode("node-c", { isFavorite: true }), + ]; + const edges: RFEdge>[] = []; + + const result = projectCanvasFavoritesVisibility({ + nodes, + edges, + favoritesOnly: true, + }); + + expect(result.nodes[0]).toBe(nodes[0]); + expect(result.nodes[2]).toBe(nodes[2]); + expect(result.nodes[1]).not.toBe(nodes[1]); + expect(result.nodes[1]?.style).toMatchObject({ + opacity: 0.28, + filter: "saturate(0.55)", + }); + expect(result.nodes[1]?.className).toContain("custom-node"); + expect(result.favoriteCount).toBe(2); + expect(Array.from(result.favoriteNodeIds)).toEqual(["node-a", "node-c"]); + }); + + it("dims edges when source and target are not both favorite", () => { + const nodes = [ + createNode("node-a", { isFavorite: true }), + createNode("node-b", { label: "normal" }), + createNode("node-c", { isFavorite: true }), + ]; + const edges = [ + createEdge("edge-aa", "node-a", "node-c", { + style: { stroke: "rgb(10, 10, 10)", strokeWidth: 2 }, + }), + createEdge("edge-ab", "node-a", "node-b", { + style: { stroke: "rgb(20, 20, 20)", strokeWidth: 2 }, + }), + createEdge("edge-bc", "node-b", "node-c", { + style: { stroke: "rgb(30, 30, 30)", strokeWidth: 2 }, + }), + ]; + + const result = projectCanvasFavoritesVisibility({ + nodes, + edges, + favoritesOnly: true, + }); + + expect(result.edges[0]).toBe(edges[0]); + expect(result.edges[1]).not.toBe(edges[1]); + expect(result.edges[2]).not.toBe(edges[2]); + expect(result.edges[0]?.style).toEqual({ stroke: "rgb(10, 10, 10)", strokeWidth: 2 }); + expect(result.edges[1]?.style).toMatchObject({ + stroke: "rgb(20, 20, 20)", + strokeWidth: 2, + opacity: 0.18, + }); + expect(result.edges[2]?.style).toMatchObject({ + stroke: "rgb(30, 30, 30)", + strokeWidth: 2, + opacity: 0.18, + }); + expect(result.edges[0]).toBe(edges[0]); + }); + + it("does not mutate input nodes or edges and only changes affected items", () => { + const nodes = [ + createNode("node-a", { isFavorite: true }), + createNode("node-b", { label: "normal" }, { style: { width: 240 } }), + createNode("node-c", { isFavorite: true }, { style: { width: 180 } }), + ]; + const edges = [ + createEdge("edge-ab", "node-a", "node-b", { style: { stroke: "red" } }), + createEdge("edge-ac", "node-a", "node-c", { style: { stroke: "green" } }), + createEdge("edge-bc", "node-b", "node-c", { style: { stroke: "blue" } }), + ]; + + const nodesBefore = structuredClone(nodes); + const edgesBefore = structuredClone(edges); + + const result = projectCanvasFavoritesVisibility({ + nodes, + edges, + favoritesOnly: true, + }); + + expect(nodes).toEqual(nodesBefore); + expect(edges).toEqual(edgesBefore); + expect(result.nodes[0]).toBe(nodes[0]); + expect(result.nodes[1]).not.toBe(nodes[1]); + expect(result.nodes[2]).toBe(nodes[2]); + expect(result.edges[0]).not.toBe(edges[0]); + expect(result.edges[1]).toBe(edges[1]); + expect(result.edges[2]).not.toBe(edges[2]); + }); + + it("dims all nodes and edges when focus mode is active with zero favorites", () => { + const nodes = [createNode("node-a", { label: "first" }), createNode("node-b", { label: "second" })]; + const edges = [createEdge("edge-ab", "node-a", "node-b")]; + + const result = projectCanvasFavoritesVisibility({ + nodes, + edges, + favoritesOnly: true, + }); + + expect(result.favoriteCount).toBe(0); + expect(result.favoriteNodeIds.size).toBe(0); + expect(result.nodes[0]?.style).toMatchObject({ opacity: 0.28, filter: "saturate(0.55)" }); + expect(result.nodes[1]?.style).toMatchObject({ opacity: 0.28, filter: "saturate(0.55)" }); + expect(result.edges[0]?.style).toMatchObject({ opacity: 0.18 }); + }); +}); diff --git a/components/canvas/__tests__/canvas-toolbar.test.tsx b/components/canvas/__tests__/canvas-toolbar.test.tsx new file mode 100644 index 0000000..199cbc0 --- /dev/null +++ b/components/canvas/__tests__/canvas-toolbar.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createNodeWithIntersection: vi.fn(async () => undefined), + getCenteredPosition: vi.fn(() => ({ x: 0, y: 0 })), +})); + +vi.mock("@/components/canvas/canvas-placement-context", () => ({ + useCanvasPlacement: () => ({ + createNodeWithIntersection: mocks.createNodeWithIntersection, + }), +})); + +vi.mock("@/hooks/use-centered-flow-node-position", () => ({ + useCenteredFlowNodePosition: () => mocks.getCenteredPosition, +})); + +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children }: { children: React.ReactNode }) => , + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, +})); + +vi.mock("@/components/canvas/credit-display", () => ({ + CreditDisplay: () =>
, +})); + +vi.mock("@/components/canvas/export-button", () => ({ + ExportButton: ({ canvasName }: { canvasName: string }) => ( + + ), +})); + +vi.mock("@/lib/canvas-node-catalog", () => ({ + NODE_CATEGORIES_ORDERED: [], + NODE_CATEGORY_META: {}, + catalogEntriesByCategory: () => new Map(), + getTemplateForCatalogType: () => null, + isNodePaletteEnabled: () => false, +})); + +import CanvasToolbar from "@/components/canvas/canvas-toolbar"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("CanvasToolbar", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + mocks.createNodeWithIntersection.mockClear(); + mocks.getCenteredPosition.mockClear(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + container = null; + root = null; + }); + + it("renders the favorites filter button", async () => { + await act(async () => { + root?.render( + , + ); + }); + + const favoriteButton = container?.querySelector('button[title="Favoriten hervorheben"]'); + expect(favoriteButton).not.toBeNull(); + }); + + it("reflects active state via aria-pressed", async () => { + await act(async () => { + root?.render( + , + ); + }); + + let favoriteButton = container?.querySelector('button[title="Favoriten hervorheben"]'); + expect(favoriteButton?.getAttribute("aria-pressed")).toBe("false"); + + await act(async () => { + root?.render( + , + ); + }); + + favoriteButton = container?.querySelector('button[title="Favoriten hervorheben"]'); + expect(favoriteButton?.getAttribute("aria-pressed")).toBe("true"); + }); + + it("toggles and calls onFavoriteFilterChange", async () => { + const onFavoriteFilterChange = vi.fn(); + + await act(async () => { + root?.render( + , + ); + }); + + const favoriteButton = container?.querySelector('button[title="Favoriten hervorheben"]'); + if (!(favoriteButton instanceof HTMLButtonElement)) { + throw new Error("Favorite filter button not found"); + } + + await act(async () => { + favoriteButton.click(); + }); + + expect(onFavoriteFilterChange).toHaveBeenCalledTimes(1); + expect(onFavoriteFilterChange).toHaveBeenCalledWith(true); + + onFavoriteFilterChange.mockClear(); + + await act(async () => { + root?.render( + , + ); + }); + + const activeFavoriteButton = container?.querySelector('button[title="Favoriten hervorheben"]'); + if (!(activeFavoriteButton instanceof HTMLButtonElement)) { + throw new Error("Active favorite filter button not found"); + } + + await act(async () => { + activeFavoriteButton.click(); + }); + + expect(onFavoriteFilterChange).toHaveBeenCalledTimes(1); + expect(onFavoriteFilterChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/components/canvas/canvas-favorites-visibility.ts b/components/canvas/canvas-favorites-visibility.ts new file mode 100644 index 0000000..06d8ddd --- /dev/null +++ b/components/canvas/canvas-favorites-visibility.ts @@ -0,0 +1,140 @@ +import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; + +import { readNodeFavorite } from "@/lib/canvas-node-favorite"; + +type CanvasNode = RFNode>; +type CanvasEdge = RFEdge>; + +type ProjectCanvasFavoritesVisibilityArgs = { + nodes: readonly CanvasNode[]; + edges: readonly CanvasEdge[]; + favoritesOnly: boolean; +}; + +type ProjectCanvasFavoritesVisibilityResult = { + nodes: CanvasNode[]; + edges: CanvasEdge[]; + favoriteNodeIds: ReadonlySet; + favoriteCount: number; +}; + +const DIMMED_NODE_OPACITY = 0.28; +const DIMMED_NODE_FILTER = "saturate(0.55)"; +const DIMMED_EDGE_OPACITY = 0.18; +const DIMMED_NODE_TRANSITION = "opacity 160ms ease, filter 160ms ease"; +const DIMMED_EDGE_TRANSITION = "opacity 160ms ease"; + +function mergeTransition(current: unknown, required: string): string { + if (typeof current !== "string" || current.trim().length === 0) { + return required; + } + if (current.includes(required)) { + return current; + } + return `${current}, ${required}`; +} + +function shallowEqualRecord( + left: Record | undefined, + right: Record | undefined, +): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + return leftKeys.every((key) => Object.is(left[key], right[key])); +} + +function styleToRecord( + style: CanvasNode["style"] | CanvasEdge["style"], +): Record | undefined { + return style ? (style as Record) : undefined; +} + +function buildDimmedNodeStyle( + style: CanvasNode["style"], +): CanvasNode["style"] { + const next = { + ...(style ?? {}), + opacity: DIMMED_NODE_OPACITY, + filter: DIMMED_NODE_FILTER, + transition: mergeTransition(style?.transition, DIMMED_NODE_TRANSITION), + }; + return next; +} + +function buildDimmedEdgeStyle( + style: CanvasEdge["style"], +): CanvasEdge["style"] { + const next = { + ...(style ?? {}), + opacity: DIMMED_EDGE_OPACITY, + transition: mergeTransition(style?.transition, DIMMED_EDGE_TRANSITION), + }; + return next; +} + +export function projectCanvasFavoritesVisibility({ + nodes, + edges, + favoritesOnly, +}: ProjectCanvasFavoritesVisibilityArgs): ProjectCanvasFavoritesVisibilityResult { + const favoriteNodeIds = new Set(); + + for (const node of nodes) { + if (readNodeFavorite(node.data)) { + favoriteNodeIds.add(node.id); + } + } + + const favoriteCount = favoriteNodeIds.size; + const hasFavorites = favoriteCount > 0; + + const projectedNodes = nodes.map((node) => { + const shouldDim = favoritesOnly && !favoriteNodeIds.has(node.id); + if (!shouldDim) { + return node; + } + + const nextStyle = buildDimmedNodeStyle(node.style); + return shallowEqualRecord(styleToRecord(node.style), styleToRecord(nextStyle)) + ? node + : { + ...node, + style: nextStyle, + }; + }); + + const projectedEdges = edges.map((edge) => { + const isFavoriteEdge = favoriteNodeIds.has(edge.source) && favoriteNodeIds.has(edge.target); + const shouldDim = favoritesOnly && (!hasFavorites || !isFavoriteEdge); + + if (!shouldDim) { + return edge; + } + + const nextStyle = buildDimmedEdgeStyle(edge.style); + return shallowEqualRecord(styleToRecord(edge.style), styleToRecord(nextStyle)) + ? edge + : { + ...edge, + style: nextStyle, + }; + }); + + return { + nodes: projectedNodes, + edges: projectedEdges, + favoriteNodeIds, + favoriteCount, + }; +} diff --git a/components/canvas/canvas-toolbar.tsx b/components/canvas/canvas-toolbar.tsx index 343c8d6..7ffe0e6 100644 --- a/components/canvas/canvas-toolbar.tsx +++ b/components/canvas/canvas-toolbar.tsx @@ -8,6 +8,7 @@ import { Plus, Redo2, Scissors, + Star, Undo2, } from "lucide-react"; @@ -40,12 +41,18 @@ interface CanvasToolbarProps { canvasName?: string; activeTool: CanvasNavTool; onToolChange: (tool: CanvasNavTool) => void; + favoriteFilterActive?: boolean; + onFavoriteFilterChange?: (active: boolean) => void; + favoriteCount?: number; } export default function CanvasToolbar({ canvasName, activeTool, onToolChange, + favoriteFilterActive = false, + onFavoriteFilterChange, + favoriteCount, }: CanvasToolbarProps) { const { createNodeWithIntersection } = useCanvasPlacement(); const getCenteredPosition = useCenteredFlowNodePosition(); @@ -66,6 +73,10 @@ export default function CanvasToolbar({ const byCategory = catalogEntriesByCategory(); const resolvedCanvasName = canvasName?.trim() || "Unbenannter Canvas"; + const favoritesLabel = + typeof favoriteCount === "number" + ? `Favoriten hervorheben (${favoriteCount})` + : "Favoriten hervorheben"; const toolBtn = (tool: CanvasNavTool, icon: React.ReactNode, label: string) => ( + ) : null} +