feat(canvas): highlight favorites without hiding nodes
This commit is contained in:
184
components/canvas/__tests__/canvas-favorites-visibility.test.ts
Normal file
184
components/canvas/__tests__/canvas-favorites-visibility.test.ts
Normal file
@@ -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<string, unknown>,
|
||||||
|
options?: {
|
||||||
|
style?: RFNode<Record<string, unknown>>["style"];
|
||||||
|
className?: string;
|
||||||
|
},
|
||||||
|
): RFNode<Record<string, unknown>> {
|
||||||
|
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<Record<string, unknown>>["style"];
|
||||||
|
className?: string;
|
||||||
|
},
|
||||||
|
): RFEdge<Record<string, unknown>> {
|
||||||
|
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<Record<string, unknown>>[] = [];
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
173
components/canvas/__tests__/canvas-toolbar.test.tsx
Normal file
173
components/canvas/__tests__/canvas-toolbar.test.tsx
Normal file
@@ -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 }) => <div>{children}</div>,
|
||||||
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
|
||||||
|
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuSeparator: () => <hr />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/canvas/credit-display", () => ({
|
||||||
|
CreditDisplay: () => <div data-testid="credit-display" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/canvas/export-button", () => ({
|
||||||
|
ExportButton: ({ canvasName }: { canvasName: string }) => (
|
||||||
|
<button type="button">Export {canvasName}</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CanvasToolbar
|
||||||
|
activeTool="select"
|
||||||
|
onToolChange={vi.fn()}
|
||||||
|
onFavoriteFilterChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CanvasToolbar
|
||||||
|
activeTool="select"
|
||||||
|
onToolChange={vi.fn()}
|
||||||
|
favoriteFilterActive={false}
|
||||||
|
onFavoriteFilterChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let favoriteButton = container?.querySelector('button[title="Favoriten hervorheben"]');
|
||||||
|
expect(favoriteButton?.getAttribute("aria-pressed")).toBe("false");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<CanvasToolbar
|
||||||
|
activeTool="select"
|
||||||
|
onToolChange={vi.fn()}
|
||||||
|
favoriteFilterActive
|
||||||
|
onFavoriteFilterChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CanvasToolbar
|
||||||
|
activeTool="select"
|
||||||
|
onToolChange={vi.fn()}
|
||||||
|
favoriteFilterActive={false}
|
||||||
|
onFavoriteFilterChange={onFavoriteFilterChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CanvasToolbar
|
||||||
|
activeTool="select"
|
||||||
|
onToolChange={vi.fn()}
|
||||||
|
favoriteFilterActive
|
||||||
|
onFavoriteFilterChange={onFavoriteFilterChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
140
components/canvas/canvas-favorites-visibility.ts
Normal file
140
components/canvas/canvas-favorites-visibility.ts
Normal file
@@ -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<Record<string, unknown>>;
|
||||||
|
type CanvasEdge = RFEdge<Record<string, unknown>>;
|
||||||
|
|
||||||
|
type ProjectCanvasFavoritesVisibilityArgs = {
|
||||||
|
nodes: readonly CanvasNode[];
|
||||||
|
edges: readonly CanvasEdge[];
|
||||||
|
favoritesOnly: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectCanvasFavoritesVisibilityResult = {
|
||||||
|
nodes: CanvasNode[];
|
||||||
|
edges: CanvasEdge[];
|
||||||
|
favoriteNodeIds: ReadonlySet<string>;
|
||||||
|
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<string, unknown> | undefined,
|
||||||
|
right: Record<string, unknown> | 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<string, unknown> | undefined {
|
||||||
|
return style ? (style as Record<string, unknown>) : 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<string>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Redo2,
|
Redo2,
|
||||||
Scissors,
|
Scissors,
|
||||||
|
Star,
|
||||||
Undo2,
|
Undo2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -40,12 +41,18 @@ interface CanvasToolbarProps {
|
|||||||
canvasName?: string;
|
canvasName?: string;
|
||||||
activeTool: CanvasNavTool;
|
activeTool: CanvasNavTool;
|
||||||
onToolChange: (tool: CanvasNavTool) => void;
|
onToolChange: (tool: CanvasNavTool) => void;
|
||||||
|
favoriteFilterActive?: boolean;
|
||||||
|
onFavoriteFilterChange?: (active: boolean) => void;
|
||||||
|
favoriteCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CanvasToolbar({
|
export default function CanvasToolbar({
|
||||||
canvasName,
|
canvasName,
|
||||||
activeTool,
|
activeTool,
|
||||||
onToolChange,
|
onToolChange,
|
||||||
|
favoriteFilterActive = false,
|
||||||
|
onFavoriteFilterChange,
|
||||||
|
favoriteCount,
|
||||||
}: CanvasToolbarProps) {
|
}: CanvasToolbarProps) {
|
||||||
const { createNodeWithIntersection } = useCanvasPlacement();
|
const { createNodeWithIntersection } = useCanvasPlacement();
|
||||||
const getCenteredPosition = useCenteredFlowNodePosition();
|
const getCenteredPosition = useCenteredFlowNodePosition();
|
||||||
@@ -66,6 +73,10 @@ export default function CanvasToolbar({
|
|||||||
|
|
||||||
const byCategory = catalogEntriesByCategory();
|
const byCategory = catalogEntriesByCategory();
|
||||||
const resolvedCanvasName = canvasName?.trim() || "Unbenannter Canvas";
|
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) => (
|
const toolBtn = (tool: CanvasNavTool, icon: React.ReactNode, label: string) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -144,6 +155,21 @@ export default function CanvasToolbar({
|
|||||||
)}
|
)}
|
||||||
{toolBtn("scissor", <Scissors className="size-4" />, "Schere (K) — Verbindungen kappen")}
|
{toolBtn("scissor", <Scissors className="size-4" />, "Schere (K) — Verbindungen kappen")}
|
||||||
|
|
||||||
|
{onFavoriteFilterChange ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={favoriteFilterActive ? "secondary" : "ghost"}
|
||||||
|
className="size-9 shrink-0"
|
||||||
|
aria-label={favoritesLabel}
|
||||||
|
title={favoritesLabel}
|
||||||
|
aria-pressed={favoriteFilterActive}
|
||||||
|
onClick={() => onFavoriteFilterChange(!favoriteFilterActive)}
|
||||||
|
>
|
||||||
|
<Star className={favoriteFilterActive ? "size-4 fill-current" : "size-4"} />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-p
|
|||||||
import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
|
import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
|
||||||
import { HANDLE_GLOW_RADIUS_PX } from "./canvas-connection-magnetism";
|
import { HANDLE_GLOW_RADIUS_PX } from "./canvas-connection-magnetism";
|
||||||
import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context";
|
import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context";
|
||||||
|
import { projectCanvasFavoritesVisibility } from "./canvas-favorites-visibility";
|
||||||
|
|
||||||
interface CanvasInnerProps {
|
interface CanvasInnerProps {
|
||||||
canvasId: Id<"canvases">;
|
canvasId: Id<"canvases">;
|
||||||
@@ -168,6 +169,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
{ x: number; y: number }[] | null
|
{ x: number; y: number }[] | null
|
||||||
>(null);
|
>(null);
|
||||||
const [navTool, setNavTool] = useState<CanvasNavTool>("select");
|
const [navTool, setNavTool] = useState<CanvasNavTool>("select");
|
||||||
|
const [focusFavorites, setFocusFavorites] = useState(false);
|
||||||
|
|
||||||
useCanvasLocalSnapshotPersistence<RFNode, RFEdge>({
|
useCanvasLocalSnapshotPersistence<RFNode, RFEdge>({
|
||||||
canvasId: canvasId as string,
|
canvasId: canvasId as string,
|
||||||
@@ -208,6 +210,16 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
[edges],
|
[edges],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const favoriteProjection = useMemo(
|
||||||
|
() =>
|
||||||
|
projectCanvasFavoritesVisibility({
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
favoritesOnly: focusFavorites,
|
||||||
|
}),
|
||||||
|
[edges, nodes, focusFavorites],
|
||||||
|
);
|
||||||
|
|
||||||
const pendingRemovedEdgeIds = useMemo(
|
const pendingRemovedEdgeIds = useMemo(
|
||||||
() => {
|
() => {
|
||||||
void convexEdges;
|
void convexEdges;
|
||||||
@@ -582,6 +594,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
canvasName={canvas?.name}
|
canvasName={canvas?.name}
|
||||||
activeTool={navTool}
|
activeTool={navTool}
|
||||||
onToolChange={handleNavToolChange}
|
onToolChange={handleNavToolChange}
|
||||||
|
favoriteFilterActive={focusFavorites}
|
||||||
|
onFavoriteFilterChange={setFocusFavorites}
|
||||||
|
favoriteCount={favoriteProjection.favoriteCount}
|
||||||
/>
|
/>
|
||||||
<CanvasAppMenu canvasId={canvasId} />
|
<CanvasAppMenu canvasId={canvasId} />
|
||||||
<CanvasCommandPalette />
|
<CanvasCommandPalette />
|
||||||
@@ -643,8 +658,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
|||||||
<CanvasGraphProvider nodes={canvasGraphNodes} edges={canvasGraphEdges}>
|
<CanvasGraphProvider nodes={canvasGraphNodes} edges={canvasGraphEdges}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
style={edgeInsertReflowStyle}
|
style={edgeInsertReflowStyle}
|
||||||
nodes={nodes}
|
nodes={favoriteProjection.nodes}
|
||||||
edges={edges}
|
edges={favoriteProjection.edges}
|
||||||
onlyRenderVisibleElements
|
onlyRenderVisibleElements
|
||||||
defaultEdgeOptions={defaultEdgeOptions}
|
defaultEdgeOptions={defaultEdgeOptions}
|
||||||
connectionLineComponent={CustomConnectionLine}
|
connectionLineComponent={CustomConnectionLine}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export default defineConfig({
|
|||||||
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
||||||
"components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",
|
"components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",
|
||||||
"components/canvas/__tests__/canvas-sidebar.test.tsx",
|
"components/canvas/__tests__/canvas-sidebar.test.tsx",
|
||||||
|
"components/canvas/__tests__/canvas-toolbar.test.tsx",
|
||||||
|
"components/canvas/__tests__/canvas-favorites-visibility.test.ts",
|
||||||
"components/canvas/__tests__/asset-browser-panel.test.tsx",
|
"components/canvas/__tests__/asset-browser-panel.test.tsx",
|
||||||
"components/canvas/__tests__/video-browser-panel.test.tsx",
|
"components/canvas/__tests__/video-browser-panel.test.tsx",
|
||||||
"components/media/__tests__/media-preview-utils.test.ts",
|
"components/media/__tests__/media-preview-utils.test.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user