feat(canvas): add persistent node favorites with toolbar star and glow

This commit is contained in:
2026-04-09 14:12:43 +02:00
parent e4d39a21fd
commit b08e448be0
18 changed files with 625 additions and 76 deletions

View File

@@ -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<string, unknown>),
}),
preserveNodeFavorite(
normalizeColorAdjustData({
...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA),
...(value as Record<string, unknown>),
}),
value,
) as ColorAdjustData,
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<ColorAdjustData>({
@@ -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",
});

View File

@@ -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<CropNo
const { queueNodeDataUpdate } = useCanvasSync();
const graph = useCanvasGraph();
const normalizeData = useCallback((value: unknown) => normalizeCropNodeData(value), []);
const normalizeData = useCallback(
(value: unknown) =>
preserveNodeFavorite(normalizeCropNodeData(value), value) as CropNodeData,
[],
);
const previewAreaRef = useRef<HTMLDivElement | null>(null);
const interactionRef = useRef<CropInteractionState | null>(null);
const { localData, updateLocalData } = useNodeLocalData<CropNodeData>({
@@ -199,7 +204,7 @@ export default function CropNode({ id, data, selected, width }: NodeProps<CropNo
onSave: (next) =>
queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: next,
data: preserveNodeFavorite(next, data),
}),
debugLabel: "crop",
});

View File

@@ -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<Curv
const [presetSelection, setPresetSelection] = useState("custom");
const normalizeData = useCallback(
(value: unknown) =>
normalizeCurvesData({
...cloneAdjustmentData(DEFAULT_CURVES_DATA),
...(value as Record<string, unknown>),
}),
preserveNodeFavorite(
normalizeCurvesData({
...cloneAdjustmentData(DEFAULT_CURVES_DATA),
...(value as Record<string, unknown>),
}),
value,
) as CurvesData,
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<CurvesData>({
@@ -67,7 +71,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
onSave: (next) =>
queueNodeDataUpdate({
nodeId: id as Id<"nodes">,
data: next,
data: preserveNodeFavorite(next, data),
}),
debugLabel: "curves",
});

View File

@@ -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<string, unknown>),
}),
preserveNodeFavorite(
normalizeDetailAdjustData({
...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA),
...(value as Record<string, unknown>),
}),
value,
) as DetailAdjustData,
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<DetailAdjustData>({
@@ -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",
});

View File

@@ -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(() => {

View File

@@ -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<string, unknown>),
}),
preserveNodeFavorite(
normalizeLightAdjustData({
...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA),
...(value as Record<string, unknown>),
}),
value,
) as LightAdjustData,
[],
);
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<LightAdjustData>({
@@ -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",
});

View File

@@ -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) {