From b08e448be091acab6518161da79d83c7d2855978 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Thu, 9 Apr 2026 14:12:43 +0200 Subject: [PATCH] feat(canvas): add persistent node favorites with toolbar star and glow --- app/globals.css | 23 +++ .../__tests__/base-node-wrapper.test.tsx | 134 +++++++++++++++ .../__tests__/use-canvas-sync-engine.test.ts | 63 +++++++ .../__tests__/use-node-local-data.test.tsx | 158 ++++++++++++++++++ components/canvas/asset-browser-panel.tsx | 37 ++-- components/canvas/nodes/color-adjust-node.tsx | 14 +- components/canvas/nodes/crop-node.tsx | 9 +- components/canvas/nodes/curves-node.tsx | 14 +- .../canvas/nodes/detail-adjust-node.tsx | 14 +- components/canvas/nodes/image-node.tsx | 44 +++-- components/canvas/nodes/light-adjust-node.tsx | 14 +- components/canvas/nodes/render-node.tsx | 16 +- components/canvas/video-browser-panel.tsx | 35 ++-- convex/nodes.ts | 14 +- lib/canvas-node-favorite.ts | 36 ++++ tests/canvas-node-favorite.test.ts | 54 ++++++ tests/crop-node-data-validation.test.ts | 21 +++ vitest.config.ts | 1 + 18 files changed, 625 insertions(+), 76 deletions(-) create mode 100644 components/canvas/__tests__/base-node-wrapper.test.tsx create mode 100644 lib/canvas-node-favorite.ts create mode 100644 tests/canvas-node-favorite.test.ts diff --git a/app/globals.css b/app/globals.css index 1d9d88b..f2735b5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -81,6 +81,9 @@ --sidebar-accent-foreground: oklch(0.25 0.012 60); --sidebar-border: oklch(0.91 0.01 75); --sidebar-ring: oklch(0.52 0.09 178); + --node-favorite-ring: oklch(0.72 0.16 88); + --node-favorite-glow: oklch(0.78 0.18 90 / 0.24); + --node-favorite-fill: oklch(0.93 0.08 92 / 0.45); } .dark { @@ -115,6 +118,9 @@ --sidebar-accent-foreground: oklch(0.93 0.008 80); --sidebar-border: oklch(1 0 0 / 8%); --sidebar-ring: oklch(0.62 0.1 178); + --node-favorite-ring: oklch(0.84 0.14 92); + --node-favorite-glow: oklch(0.82 0.16 92 / 0.36); + --node-favorite-fill: oklch(0.48 0.12 90 / 0.28); } @layer base { @@ -306,4 +312,21 @@ transition: none; } } + + .node-favorite-chrome { + position: relative; + isolation: isolate; + } + + .node-favorite-chrome::after { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + pointer-events: none; + box-shadow: + 0 0 0 1px var(--node-favorite-ring), + 0 0 0 3px var(--node-favorite-fill), + 0 0 22px -8px var(--node-favorite-glow); + } } diff --git a/components/canvas/__tests__/base-node-wrapper.test.tsx b/components/canvas/__tests__/base-node-wrapper.test.tsx new file mode 100644 index 0000000..a56d31e --- /dev/null +++ b/components/canvas/__tests__/base-node-wrapper.test.tsx @@ -0,0 +1,134 @@ +// @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(() => ({ + queueNodeDataUpdate: vi.fn(async () => undefined), + createNodeWithIntersection: vi.fn(async () => undefined), + getNode: vi.fn(), + getNodes: vi.fn(() => []), + getEdges: vi.fn(() => []), + setNodes: vi.fn(), + deleteElements: vi.fn(async () => undefined), +})); + +vi.mock("@xyflow/react", () => ({ + NodeToolbar: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + NodeResizeControl: () => null, + Position: { Top: "top" }, + useNodeId: () => "node-1", + useReactFlow: () => ({ + getNode: mocks.getNode, + getNodes: mocks.getNodes, + getEdges: mocks.getEdges, + setNodes: mocks.setNodes, + deleteElements: mocks.deleteElements, + }), + getConnectedEdges: () => [], +})); + +vi.mock("@/components/canvas/canvas-sync-context", () => ({ + useCanvasSync: () => ({ + queueNodeDataUpdate: mocks.queueNodeDataUpdate, + }), +})); + +vi.mock("@/components/canvas/canvas-placement-context", () => ({ + useCanvasPlacement: () => ({ + createNodeWithIntersection: mocks.createNodeWithIntersection, + }), +})); + +import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("BaseNodeWrapper", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + mocks.queueNodeDataUpdate.mockClear(); + mocks.createNodeWithIntersection.mockClear(); + mocks.getNode.mockReset(); + mocks.getNodes.mockClear(); + mocks.getEdges.mockClear(); + mocks.setNodes.mockClear(); + mocks.deleteElements.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; + }); + + async function renderWrapper(nodeData: Record, selected = true) { + mocks.getNode.mockReturnValue({ + id: "node-1", + type: "text", + data: nodeData, + position: { x: 0, y: 0 }, + style: {}, + }); + + await act(async () => { + root?.render( + +
Inner node content
+
, + ); + }); + } + + it("shows favorite toggle with duplicate and delete controls for selected nodes", async () => { + await renderWrapper({ label: "Frame" }, true); + + expect(container?.querySelector('button[title="Favorite"]')).toBeTruthy(); + expect(container?.querySelector('button[title="Duplicate"]')).toBeTruthy(); + expect(container?.querySelector('button[title="Delete"]')).toBeTruthy(); + }); + + it("toggles favorite and queues merged node data update", async () => { + await renderWrapper({ label: "Frame" }, true); + + const favoriteButton = container?.querySelector('button[title="Favorite"]'); + if (!(favoriteButton instanceof HTMLButtonElement)) { + throw new Error("Favorite button not found"); + } + + await act(async () => { + favoriteButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(mocks.queueNodeDataUpdate).toHaveBeenCalledWith({ + nodeId: "node-1", + data: { + label: "Frame", + isFavorite: true, + }, + }); + expect(container?.querySelector('button[title="Duplicate"]')).toBeTruthy(); + expect(container?.querySelector('button[title="Delete"]')).toBeTruthy(); + }); + + it("applies favorite chrome marker on favorite nodes", async () => { + await renderWrapper({ label: "Frame", isFavorite: true }, true); + + const rootElement = container?.firstElementChild; + expect(rootElement?.className).toContain("node-favorite-chrome"); + }); +}); diff --git a/components/canvas/__tests__/use-canvas-sync-engine.test.ts b/components/canvas/__tests__/use-canvas-sync-engine.test.ts index 016ac21..1abd593 100644 --- a/components/canvas/__tests__/use-canvas-sync-engine.test.ts +++ b/components/canvas/__tests__/use-canvas-sync-engine.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import type { Id } from "@/convex/_generated/dataModel"; import { createCanvasSyncEngineController } from "@/components/canvas/use-canvas-sync-engine"; @@ -75,6 +77,67 @@ describe("useCanvasSyncEngine", () => { expect(controller.pendingDataAfterCreateRef.current.has("req-2")).toBe(false); }); + it("keeps favorite fields in pinned and deferred optimistic data updates", async () => { + const enqueueSyncMutation = vi.fn(async () => undefined); + + const controller = createCanvasSyncEngineController({ + canvasId: asCanvasId("canvas-1"), + isSyncOnline: true, + getEnqueueSyncMutation: () => enqueueSyncMutation, + getRunBatchRemoveNodes: () => vi.fn(async () => undefined), + getRunSplitEdgeAtExistingNode: () => vi.fn(async () => undefined), + }); + + const favoritePayload = { + storageId: "storage-next", + filename: "hero.png", + isFavorite: true, + }; + + await controller.queueNodeDataUpdate({ + nodeId: asNodeId("optimistic_req-favorite"), + data: favoritePayload, + }); + + expect( + controller.pendingLocalNodeDataUntilConvexMatchesRef.current.get( + "optimistic_req-favorite", + ), + ).toEqual(favoritePayload); + + await controller.syncPendingMoveForClientRequest( + "req-favorite", + asNodeId("node-favorite"), + ); + + expect(enqueueSyncMutation).toHaveBeenCalledWith("updateData", { + nodeId: asNodeId("node-favorite"), + data: favoritePayload, + }); + expect( + controller.pendingLocalNodeDataUntilConvexMatchesRef.current.get("node-favorite"), + ).toEqual(favoritePayload); + }); + + it("uses favorite-preserving payloads in media replacement write paths", () => { + const imageNodeSource = readFileSync( + resolve(process.cwd(), "components/canvas/nodes/image-node.tsx"), + "utf8", + ); + const assetBrowserSource = readFileSync( + resolve(process.cwd(), "components/canvas/asset-browser-panel.tsx"), + "utf8", + ); + const videoBrowserSource = readFileSync( + resolve(process.cwd(), "components/canvas/video-browser-panel.tsx"), + "utf8", + ); + + expect(imageNodeSource).toContain("preserveNodeFavorite("); + expect(assetBrowserSource).toContain("preserveNodeFavorite("); + expect(videoBrowserSource).toContain("preserveNodeFavorite("); + }); + it("pins local node data immediately when queueing an update", async () => { const enqueueSyncMutation = vi.fn(async () => undefined); diff --git a/components/canvas/__tests__/use-node-local-data.test.tsx b/components/canvas/__tests__/use-node-local-data.test.tsx index 821c552..ed2c6cb 100644 --- a/components/canvas/__tests__/use-node-local-data.test.tsx +++ b/components/canvas/__tests__/use-node-local-data.test.tsx @@ -2,6 +2,7 @@ import React, { act, useEffect } from "react"; import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -10,6 +11,7 @@ import { useCanvasGraphPreviewOverrides, } from "@/components/canvas/canvas-graph-context"; import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data"; +import { readNodeFavorite } from "@/lib/canvas-node-favorite"; type AdjustmentData = { exposure: number; @@ -582,3 +584,159 @@ describe("useNodeLocalData preview overrides", () => { vi.useRealTimers(); }); }); + +describe("favorite retention in strict local node flows", () => { + type LocalDataConfig = { + normalize: (value: unknown) => unknown; + onSave: (value: unknown) => Promise | void; + data: unknown; + }; + + const createNodeProps = (data: Record) => + ({ + id: "node-1", + data, + selected: false, + width: 320, + height: 240, + dragging: false, + zIndex: 0, + isConnectable: true, + type: "curves", + xPos: 0, + yPos: 0, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }) as const; + + const setupNodeHarness = async (modulePath: string) => { + vi.resetModules(); + + let capturedConfig: LocalDataConfig | null = null; + const queueNodeDataUpdate = vi.fn(async () => undefined); + + vi.doMock("@/components/canvas/canvas-sync-context", () => ({ + useCanvasSync: () => ({ + queueNodeDataUpdate, + queueNodeResize: vi.fn(async () => undefined), + status: { isOffline: false }, + }), + })); + + vi.doMock("@/components/canvas/canvas-graph-context", () => ({ + useCanvasGraph: () => ({ nodes: [], edges: [], previewNodeDataOverrides: new Map() }), + })); + + vi.doMock("@/components/canvas/canvas-presets-context", () => ({ + useCanvasAdjustmentPresets: () => [], + useSaveCanvasAdjustmentPreset: () => vi.fn(async () => undefined), + })); + + vi.doMock("@/components/canvas/nodes/base-node-wrapper", () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, + })); + + vi.doMock("@/components/canvas/nodes/adjustment-preview", () => ({ + default: () => null, + })); + + vi.doMock("@/components/ui/select", () => ({ + Select: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectValue: () => null, + })); + + vi.doMock("@/src/components/tool-ui/parameter-slider", () => ({ + ParameterSlider: () => null, + })); + + vi.doMock("@/hooks/use-pipeline-preview", () => ({ + usePipelinePreview: () => ({ + canvasRef: { current: null }, + hasSource: false, + isRendering: false, + previewAspectRatio: 1, + histogram: null, + error: null, + }), + })); + + vi.doMock("@/lib/canvas-render-preview", () => ({ + collectPipelineFromGraph: () => [], + getSourceImageFromGraph: () => null, + shouldFastPathPreviewPipeline: () => false, + findSourceNodeFromGraph: () => null, + resolveRenderPreviewInputFromGraph: () => ({ sourceUrl: null, steps: [] }), + })); + + vi.doMock("@/components/ui/dialog", () => ({ + Dialog: ({ children }: { children: React.ReactNode }) => <>{children}, + DialogContent: ({ children }: { children: React.ReactNode }) => <>{children}, + DialogTitle: ({ children }: { children: React.ReactNode }) => <>{children}, + })); + + vi.doMock("@/components/canvas/nodes/use-node-local-data", () => ({ + useNodeLocalData: (config: LocalDataConfig) => { + capturedConfig = config; + return { + localData: config.normalize(config.data), + applyLocalData: vi.fn(), + updateLocalData: vi.fn(), + }; + }, + })); + + vi.doMock("next-intl", () => ({ + useTranslations: () => () => "", + })); + + vi.doMock("@/lib/toast", () => ({ + toast: { success: vi.fn() }, + })); + + vi.doMock("@xyflow/react", () => ({ + Handle: () => null, + Position: { Left: "left", Right: "right" }, + })); + + const importedModule = (await import(modulePath)) as { + default: React.ComponentType>; + }; + renderToStaticMarkup(React.createElement(importedModule.default, createNodeProps({ isFavorite: true }))); + + if (capturedConfig === null) { + throw new Error("useNodeLocalData config was not captured"); + } + + const resolvedConfig = capturedConfig as LocalDataConfig; + return { capturedConfig: resolvedConfig, queueNodeDataUpdate }; + }; + + it("preserves isFavorite in normalized local data and saved payloads", async () => { + const targets = [ + "@/components/canvas/nodes/crop-node", + "@/components/canvas/nodes/curves-node", + "@/components/canvas/nodes/color-adjust-node", + "@/components/canvas/nodes/light-adjust-node", + "@/components/canvas/nodes/detail-adjust-node", + ]; + + for (const modulePath of targets) { + const { capturedConfig, queueNodeDataUpdate } = await setupNodeHarness(modulePath); + + const normalizedWithFavorite = capturedConfig.normalize({ isFavorite: true }); + expect(readNodeFavorite(normalizedWithFavorite)).toBe(true); + + const strictNextData = capturedConfig.normalize({}); + expect(readNodeFavorite(strictNextData)).toBe(false); + + await capturedConfig.onSave(strictNextData); + const queueCalls = (queueNodeDataUpdate as unknown as { mock: { calls: Array> } }) + .mock.calls; + const queuedPayload = queueCalls[0]?.[0] as { data?: unknown } | undefined; + expect(readNodeFavorite(queuedPayload?.data)).toBe(true); + } + }); +}); diff --git a/components/canvas/asset-browser-panel.tsx b/components/canvas/asset-browser-panel.tsx index eb41f86..e2427cb 100644 --- a/components/canvas/asset-browser-panel.tsx +++ b/components/canvas/asset-browser-panel.tsx @@ -11,6 +11,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { useAction } from "convex/react"; +import { useReactFlow } from "@xyflow/react"; import { X, Search, Loader2, AlertCircle } from "lucide-react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; @@ -19,6 +20,7 @@ import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { computeMediaNodeSize } from "@/lib/canvas-utils"; +import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { toast } from "@/lib/toast"; @@ -90,6 +92,7 @@ export function AssetBrowserPanel({ const [selectingAssetKey, setSelectingAssetKey] = useState(null); const searchFreepik = useAction(api.freepik.search); + const { getNode } = useReactFlow(); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length)); const requestSequenceRef = useRef(0); @@ -198,22 +201,26 @@ export function AssetBrowserPanel({ const assetKey = `${asset.assetType}-${asset.id}`; setSelectingAssetKey(assetKey); try { + const currentNode = getNode(nodeId); await queueNodeDataUpdate({ nodeId: nodeId as Id<"nodes">, - data: { - assetId: asset.id, - assetType: asset.assetType, - title: asset.title, - previewUrl: asset.previewUrl, - intrinsicWidth: asset.intrinsicWidth, - intrinsicHeight: asset.intrinsicHeight, - url: asset.previewUrl, - sourceUrl: asset.sourceUrl, - license: asset.license, - authorName: asset.authorName, - orientation: asset.orientation, - canvasId, - }, + data: preserveNodeFavorite( + { + assetId: asset.id, + assetType: asset.assetType, + title: asset.title, + previewUrl: asset.previewUrl, + intrinsicWidth: asset.intrinsicWidth, + intrinsicHeight: asset.intrinsicHeight, + url: asset.previewUrl, + sourceUrl: asset.sourceUrl, + license: asset.license, + authorName: asset.authorName, + orientation: asset.orientation, + canvasId, + }, + currentNode?.data, + ), }); const targetSize = computeMediaNodeSize("asset", { @@ -234,7 +241,7 @@ export function AssetBrowserPanel({ setSelectingAssetKey(null); } }, - [canvasId, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline], + [canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline], ); const handlePreviousPage = useCallback(() => { diff --git a/components/canvas/nodes/color-adjust-node.tsx b/components/canvas/nodes/color-adjust-node.tsx index c0d8e27..1eb81dd 100644 --- a/components/canvas/nodes/color-adjust-node.tsx +++ b/components/canvas/nodes/color-adjust-node.tsx @@ -25,6 +25,7 @@ import { normalizeColorAdjustData, type ColorAdjustData, } from "@/lib/image-pipeline/adjustment-types"; +import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import { COLOR_PRESETS } from "@/lib/image-pipeline/presets"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "@/lib/toast"; @@ -53,10 +54,13 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps const [presetSelection, setPresetSelection] = useState("custom"); const normalizeData = useCallback( (value: unknown) => - normalizeColorAdjustData({ - ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), - ...(value as Record), - }), + preserveNodeFavorite( + normalizeColorAdjustData({ + ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), + ...(value as Record), + }), + value, + ) as ColorAdjustData, [], ); const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ @@ -67,7 +71,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps onSave: (next) => queueNodeDataUpdate({ nodeId: id as Id<"nodes">, - data: next, + data: preserveNodeFavorite(next, data), }), debugLabel: "color-adjust", }); diff --git a/components/canvas/nodes/crop-node.tsx b/components/canvas/nodes/crop-node.tsx index 47eafba..e3b3777 100644 --- a/components/canvas/nodes/crop-node.tsx +++ b/components/canvas/nodes/crop-node.tsx @@ -21,6 +21,7 @@ import { type CropNodeData, type CropResizeMode, } from "@/lib/image-pipeline/crop-node-data"; +import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import type { Id } from "@/convex/_generated/dataModel"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -188,7 +189,11 @@ export default function CropNode({ id, data, selected, width }: NodeProps normalizeCropNodeData(value), []); + const normalizeData = useCallback( + (value: unknown) => + preserveNodeFavorite(normalizeCropNodeData(value), value) as CropNodeData, + [], + ); const previewAreaRef = useRef(null); const interactionRef = useRef(null); const { localData, updateLocalData } = useNodeLocalData({ @@ -199,7 +204,7 @@ export default function CropNode({ id, data, selected, width }: NodeProps queueNodeDataUpdate({ nodeId: id as Id<"nodes">, - data: next, + data: preserveNodeFavorite(next, data), }), debugLabel: "crop", }); diff --git a/components/canvas/nodes/curves-node.tsx b/components/canvas/nodes/curves-node.tsx index d462958..c819fd8 100644 --- a/components/canvas/nodes/curves-node.tsx +++ b/components/canvas/nodes/curves-node.tsx @@ -25,6 +25,7 @@ import { normalizeCurvesData, type CurvesData, } from "@/lib/image-pipeline/adjustment-types"; +import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import { CURVE_PRESETS } from "@/lib/image-pipeline/presets"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "@/lib/toast"; @@ -53,10 +54,13 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps - normalizeCurvesData({ - ...cloneAdjustmentData(DEFAULT_CURVES_DATA), - ...(value as Record), - }), + preserveNodeFavorite( + normalizeCurvesData({ + ...cloneAdjustmentData(DEFAULT_CURVES_DATA), + ...(value as Record), + }), + value, + ) as CurvesData, [], ); const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ @@ -67,7 +71,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps queueNodeDataUpdate({ nodeId: id as Id<"nodes">, - data: next, + data: preserveNodeFavorite(next, data), }), debugLabel: "curves", }); diff --git a/components/canvas/nodes/detail-adjust-node.tsx b/components/canvas/nodes/detail-adjust-node.tsx index 3bfebb2..6f7760f 100644 --- a/components/canvas/nodes/detail-adjust-node.tsx +++ b/components/canvas/nodes/detail-adjust-node.tsx @@ -25,6 +25,7 @@ import { normalizeDetailAdjustData, type DetailAdjustData, } from "@/lib/image-pipeline/adjustment-types"; +import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import { DETAIL_PRESETS } from "@/lib/image-pipeline/presets"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "@/lib/toast"; @@ -53,10 +54,13 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp const [presetSelection, setPresetSelection] = useState("custom"); const normalizeData = useCallback( (value: unknown) => - normalizeDetailAdjustData({ - ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), - ...(value as Record), - }), + preserveNodeFavorite( + normalizeDetailAdjustData({ + ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), + ...(value as Record), + }), + value, + ) as DetailAdjustData, [], ); const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ @@ -67,7 +71,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp onSave: (next) => queueNodeDataUpdate({ nodeId: id as Id<"nodes">, - data: next, + data: preserveNodeFavorite(next, data), }), debugLabel: "detail-adjust", }); diff --git a/components/canvas/nodes/image-node.tsx b/components/canvas/nodes/image-node.tsx index d2d013c..72d20be 100644 --- a/components/canvas/nodes/image-node.tsx +++ b/components/canvas/nodes/image-node.tsx @@ -36,6 +36,7 @@ import { createCompressedImagePreview, getImageDimensions, } from "@/components/canvas/canvas-media-utils"; +import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; const ALLOWED_IMAGE_TYPES = new Set([ "image/png", @@ -302,13 +303,16 @@ export default function ImageNode({ await queueNodeDataUpdate({ nodeId: id as Id<"nodes">, - data: { - storageId, - ...(previewUpload ?? {}), - filename: file.name, - mimeType: file.type, - ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}), - }, + data: preserveNodeFavorite( + { + storageId, + ...(previewUpload ?? {}), + filename: file.name, + mimeType: file.type, + ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}), + }, + data, + ), }); if (dimensions) { @@ -354,6 +358,7 @@ export default function ImageNode({ } }, [ + data, generateUploadUrl, id, isUploading, @@ -377,16 +382,19 @@ export default function ImageNode({ try { await queueNodeDataUpdate({ nodeId: id as Id<"nodes">, - data: { - storageId: item.storageId, - previewStorageId: item.previewStorageId, - filename: item.filename, - mimeType: item.mimeType, - width: item.width, - height: item.height, - previewWidth: item.previewWidth, - previewHeight: item.previewHeight, - }, + data: preserveNodeFavorite( + { + storageId: item.storageId, + previewStorageId: item.previewStorageId, + filename: item.filename, + mimeType: item.mimeType, + width: item.width, + height: item.height, + previewWidth: item.previewWidth, + previewHeight: item.previewHeight, + }, + data, + ), }); setMediaLibraryPhase("syncing"); @@ -414,7 +422,7 @@ export default function ImageNode({ ); } }, - [id, isNodeLoading, queueNodeDataUpdate, queueNodeResize, t], + [data, id, isNodeLoading, queueNodeDataUpdate, queueNodeResize, t], ); const handleClick = useCallback(() => { diff --git a/components/canvas/nodes/light-adjust-node.tsx b/components/canvas/nodes/light-adjust-node.tsx index 42c1889..cfcffb2 100644 --- a/components/canvas/nodes/light-adjust-node.tsx +++ b/components/canvas/nodes/light-adjust-node.tsx @@ -25,6 +25,7 @@ import { normalizeLightAdjustData, type LightAdjustData, } from "@/lib/image-pipeline/adjustment-types"; +import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import { LIGHT_PRESETS } from "@/lib/image-pipeline/presets"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "@/lib/toast"; @@ -53,10 +54,13 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps const [presetSelection, setPresetSelection] = useState("custom"); const normalizeData = useCallback( (value: unknown) => - normalizeLightAdjustData({ - ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), - ...(value as Record), - }), + preserveNodeFavorite( + normalizeLightAdjustData({ + ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), + ...(value as Record), + }), + value, + ) as LightAdjustData, [], ); const { localData, applyLocalData, updateLocalData } = useNodeLocalData({ @@ -67,7 +71,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps onSave: (next) => queueNodeDataUpdate({ nodeId: id as Id<"nodes">, - data: next, + data: preserveNodeFavorite(next, data), }), debugLabel: "light-adjust", }); diff --git a/components/canvas/nodes/render-node.tsx b/components/canvas/nodes/render-node.tsx index 666c0bf..449e657 100644 --- a/components/canvas/nodes/render-node.tsx +++ b/components/canvas/nodes/render-node.tsx @@ -26,6 +26,7 @@ import { isPipelineAbortError, renderFullWithWorkerFallback, } from "@/lib/image-pipeline/worker-client"; +import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import type { Id } from "@/convex/_generated/dataModel"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; @@ -105,6 +106,7 @@ type PersistedRenderData = { lastUploadFilename?: string; lastUploadError?: string; lastUploadErrorHash?: string; + isFavorite?: true; }; const DEFAULT_OUTPUT_RESOLUTION: RenderResolutionOption = "original"; @@ -348,7 +350,7 @@ function sanitizeRenderData(data: RenderNodeData): PersistedRenderData { next.lastUploadErrorHash = data.lastUploadErrorHash; } - return next; + return preserveNodeFavorite(next, data) as PersistedRenderData; } function formatBytes(bytes: number | undefined): string { @@ -496,6 +498,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr ); const steps = renderPreviewInput.steps; + const hasCropStep = useMemo(() => steps.some((step) => step.type === "crop"), [steps]); const previewDebounceMs = shouldFastPathPreviewPipeline( steps, graph.previewNodeDataOverrides, @@ -592,6 +595,15 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr }); const targetAspectRatio = useMemo(() => { + if ( + hasCropStep && + typeof previewAspectRatio === "number" && + Number.isFinite(previewAspectRatio) && + previewAspectRatio > 0 + ) { + return previewAspectRatio; + } + const sourceAspectRatio = resolveSourceAspectRatio(sourceNode); if (sourceAspectRatio && Number.isFinite(sourceAspectRatio) && sourceAspectRatio > 0) { return sourceAspectRatio; @@ -606,7 +618,7 @@ export default function RenderNode({ id, data, selected, width, height }: NodePr } return null; - }, [previewAspectRatio, sourceNode]); + }, [hasCropStep, previewAspectRatio, sourceNode]); useEffect(() => { if (!hasSource || targetAspectRatio === null) { diff --git a/components/canvas/video-browser-panel.tsx b/components/canvas/video-browser-panel.tsx index 2dab2c3..5e1541d 100644 --- a/components/canvas/video-browser-panel.tsx +++ b/components/canvas/video-browser-panel.tsx @@ -11,6 +11,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { useAction } from "convex/react"; +import { useReactFlow } from "@xyflow/react"; import { X, Search, Loader2, AlertCircle, Play, Pause } from "lucide-react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; @@ -18,6 +19,7 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import type { PexelsVideo, PexelsVideoFile } from "@/lib/pexels-types"; import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types"; +import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; import { toast } from "@/lib/toast"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; @@ -83,6 +85,7 @@ export function VideoBrowserPanel({ const searchVideos = useAction(api.pexels.searchVideos); const popularVideos = useAction(api.pexels.popularVideos); + const { getNode } = useReactFlow(); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const shouldSkipInitialSearchRef = useRef( Boolean(initialState?.results?.length), @@ -216,22 +219,26 @@ export function VideoBrowserPanel({ return; } try { + const currentNode = getNode(nodeId); await queueNodeDataUpdate({ nodeId: nodeId as Id<"nodes">, - data: { - pexelsId: video.id, - mp4Url: file.link, - thumbnailUrl: video.image, - width: video.width, - height: video.height, - duration: video.duration, - attribution: { - userName: video.user.name, - userUrl: video.user.url, - videoUrl: video.url, + data: preserveNodeFavorite( + { + pexelsId: video.id, + mp4Url: file.link, + thumbnailUrl: video.image, + width: video.width, + height: video.height, + duration: video.duration, + attribution: { + userName: video.user.name, + userUrl: video.user.url, + videoUrl: video.url, + }, + canvasId, }, - canvasId, - }, + currentNode?.data, + ), }); // Auto-resize to match aspect ratio @@ -253,7 +260,7 @@ export function VideoBrowserPanel({ setSelectingVideoId(null); } }, - [canvasId, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline], + [canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline], ); const handlePreviousPage = useCallback(() => { diff --git a/convex/nodes.ts b/convex/nodes.ts index 70ac18f..11c5c18 100644 --- a/convex/nodes.ts +++ b/convex/nodes.ts @@ -10,6 +10,7 @@ import { } from "../lib/canvas-connection-policy"; import { nodeTypeValidator } from "./node_type_validator"; import { normalizeCropNodeData } from "../lib/image-pipeline/crop-node-data"; +import { preserveNodeFavorite } from "../lib/canvas-node-favorite"; // ============================================================================ // Interne Helpers @@ -393,9 +394,12 @@ function normalizeNodeDataForWrite( data: unknown, ): unknown { if (nodeType === "crop") { - return normalizeCropNodeData(data, { - rejectDisallowedPayloadFields: true, - }); + return preserveNodeFavorite( + normalizeCropNodeData(data, { + rejectDisallowedPayloadFields: true, + }), + data, + ); } if (!isAdjustmentNodeType(nodeType)) { @@ -407,11 +411,11 @@ function normalizeNodeDataForWrite( } if (nodeType === "render") { - return normalizeRenderData(data); + return preserveNodeFavorite(normalizeRenderData(data), data); } assertNoAdjustmentImagePayload(nodeType, data); - return data; + return preserveNodeFavorite(data, data); } async function countIncomingEdges( diff --git a/lib/canvas-node-favorite.ts b/lib/canvas-node-favorite.ts new file mode 100644 index 0000000..a21b2ae --- /dev/null +++ b/lib/canvas-node-favorite.ts @@ -0,0 +1,36 @@ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +export function readNodeFavorite(data: unknown): boolean { + const source = toRecord(data); + return source.isFavorite === true; +} + +export function setNodeFavorite( + nextValue: boolean, + currentData: unknown, +): Record { + const source = toRecord(currentData); + + if (nextValue) { + return { + ...source, + isFavorite: true, + }; + } + + const { isFavorite: _isFavorite, ...rest } = source; + return rest; +} + +export function preserveNodeFavorite( + nextData: unknown, + previousData: unknown, +): Record { + return setNodeFavorite(readNodeFavorite(previousData), nextData); +} diff --git a/tests/canvas-node-favorite.test.ts b/tests/canvas-node-favorite.test.ts new file mode 100644 index 0000000..ffb5b7a --- /dev/null +++ b/tests/canvas-node-favorite.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { + preserveNodeFavorite, + readNodeFavorite, + setNodeFavorite, +} from "@/lib/canvas-node-favorite"; + +describe("canvas node favorite helpers", () => { + it("reads favorite from object data", () => { + expect(readNodeFavorite({ isFavorite: true })).toBe(true); + }); + + it("returns false when favorite flag is missing", () => { + expect(readNodeFavorite({})).toBe(false); + }); + + it("persists favorite when enabled", () => { + expect(setNodeFavorite(true, { label: "Frame" })).toEqual({ + label: "Frame", + isFavorite: true, + }); + }); + + it("removes favorite key when disabled", () => { + expect(setNodeFavorite(false, { label: "Frame", isFavorite: true })).toEqual({ + label: "Frame", + }); + }); + + it("preserves favorite after strict normalization", () => { + expect( + preserveNodeFavorite( + { + crop: { + x: 0, + y: 0, + width: 1, + height: 1, + }, + }, + { isFavorite: true }, + ), + ).toEqual({ + crop: { + x: 0, + y: 0, + width: 1, + height: 1, + }, + isFavorite: true, + }); + }); +}); diff --git a/tests/crop-node-data-validation.test.ts b/tests/crop-node-data-validation.test.ts index 43d8411..d6c292e 100644 --- a/tests/crop-node-data-validation.test.ts +++ b/tests/crop-node-data-validation.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_CROP_NODE_DATA, normalizeCropNodeData, } from "@/lib/image-pipeline/crop-node-data"; +import { preserveNodeFavorite } from "@/lib/canvas-node-favorite"; describe("crop node data validation", () => { it("normalizes and clamps crop rectangle data", () => { @@ -81,4 +82,24 @@ describe("crop node data validation", () => { ), ).toThrow("Crop node accepts parameter data only. 'imageData' is not allowed in data."); }); + + it("preserves favorite after strict crop normalization", () => { + const normalized = normalizeCropNodeData( + { + ...DEFAULT_CROP_NODE_DATA, + isFavorite: true, + }, + { rejectDisallowedPayloadFields: true }, + ); + + expect( + preserveNodeFavorite(normalized, { + ...DEFAULT_CROP_NODE_DATA, + isFavorite: true, + }), + ).toEqual({ + ...DEFAULT_CROP_NODE_DATA, + isFavorite: true, + }); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 5ce58b5..d1d5301 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ "components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/canvas-delete-handlers.test.tsx", "components/canvas/__tests__/canvas-media-utils.test.ts", + "components/canvas/__tests__/base-node-wrapper.test.tsx", "components/canvas/__tests__/use-node-local-data.test.tsx", "components/canvas/__tests__/use-canvas-sync-engine.test.ts", "components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",