feat(canvas): implement dropped connection resolution and enhance connection handling
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,10 +9,10 @@ import { Palette } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||
import {
|
||||
ParameterSlider,
|
||||
type SliderConfig,
|
||||
@@ -49,42 +49,30 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
||||
const savePreset = useMutation(api.presets.save);
|
||||
const userPresets = useCanvasAdjustmentPresets("color-adjust") as PresetDoc[];
|
||||
|
||||
const [localData, setLocalData] = useState<ColorAdjustData>(() =>
|
||||
normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }),
|
||||
);
|
||||
const [presetSelection, setPresetSelection] = useState("custom");
|
||||
const localDataRef = useRef(localData);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setLocalData(
|
||||
normalizeColorAdjustData({ ...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA), ...data }),
|
||||
);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: localDataRef.current,
|
||||
});
|
||||
}, 16);
|
||||
const normalizeData = useCallback(
|
||||
(value: unknown) =>
|
||||
normalizeColorAdjustData({
|
||||
...cloneAdjustmentData(DEFAULT_COLOR_ADJUST_DATA),
|
||||
...(value as Record<string, unknown>),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<ColorAdjustData>({
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
onSave: (next) =>
|
||||
queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: next,
|
||||
}),
|
||||
debugLabel: "color-adjust",
|
||||
});
|
||||
|
||||
const updateData = (updater: (draft: ColorAdjustData) => ColorAdjustData) => {
|
||||
setPresetSelection("custom");
|
||||
setLocalData((current) => {
|
||||
const next = updater(current);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
updateLocalData(updater);
|
||||
};
|
||||
|
||||
const builtinOptions = useMemo(() => Object.entries(COLOR_PRESETS), []);
|
||||
@@ -165,9 +153,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
||||
if (!preset) return;
|
||||
const next = cloneAdjustmentData(preset);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
@@ -176,9 +162,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
|
||||
if (!preset) return;
|
||||
const next = normalizeColorAdjustData(preset.params);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,10 +9,10 @@ import { TrendingUp } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||
import {
|
||||
ParameterSlider,
|
||||
type SliderConfig,
|
||||
@@ -49,42 +49,30 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
||||
const savePreset = useMutation(api.presets.save);
|
||||
const userPresets = useCanvasAdjustmentPresets("curves") as PresetDoc[];
|
||||
|
||||
const [localData, setLocalData] = useState<CurvesData>(() =>
|
||||
normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }),
|
||||
);
|
||||
const [presetSelection, setPresetSelection] = useState("custom");
|
||||
const localDataRef = useRef(localData);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setLocalData(
|
||||
normalizeCurvesData({ ...cloneAdjustmentData(DEFAULT_CURVES_DATA), ...data }),
|
||||
);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: localDataRef.current,
|
||||
});
|
||||
}, 16);
|
||||
const normalizeData = useCallback(
|
||||
(value: unknown) =>
|
||||
normalizeCurvesData({
|
||||
...cloneAdjustmentData(DEFAULT_CURVES_DATA),
|
||||
...(value as Record<string, unknown>),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<CurvesData>({
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
onSave: (next) =>
|
||||
queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: next,
|
||||
}),
|
||||
debugLabel: "curves",
|
||||
});
|
||||
|
||||
const updateData = (updater: (draft: CurvesData) => CurvesData) => {
|
||||
setPresetSelection("custom");
|
||||
setLocalData((current) => {
|
||||
const next = updater(current);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
updateLocalData(updater);
|
||||
};
|
||||
|
||||
const builtinOptions = useMemo(() => Object.entries(CURVE_PRESETS), []);
|
||||
@@ -136,9 +124,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
||||
const preset = CURVE_PRESETS[key];
|
||||
if (!preset) return;
|
||||
setPresetSelection(value);
|
||||
setLocalData(cloneAdjustmentData(preset));
|
||||
localDataRef.current = cloneAdjustmentData(preset);
|
||||
queueSave();
|
||||
applyLocalData(cloneAdjustmentData(preset));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -148,9 +134,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
|
||||
if (!preset) return;
|
||||
const next = normalizeCurvesData(preset.params);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,10 +9,10 @@ import { Focus } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||
import {
|
||||
ParameterSlider,
|
||||
type SliderConfig,
|
||||
@@ -49,42 +49,30 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
||||
const savePreset = useMutation(api.presets.save);
|
||||
const userPresets = useCanvasAdjustmentPresets("detail-adjust") as PresetDoc[];
|
||||
|
||||
const [localData, setLocalData] = useState<DetailAdjustData>(() =>
|
||||
normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }),
|
||||
);
|
||||
const [presetSelection, setPresetSelection] = useState("custom");
|
||||
const localDataRef = useRef(localData);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setLocalData(
|
||||
normalizeDetailAdjustData({ ...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA), ...data }),
|
||||
);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: localDataRef.current,
|
||||
});
|
||||
}, 16);
|
||||
const normalizeData = useCallback(
|
||||
(value: unknown) =>
|
||||
normalizeDetailAdjustData({
|
||||
...cloneAdjustmentData(DEFAULT_DETAIL_ADJUST_DATA),
|
||||
...(value as Record<string, unknown>),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<DetailAdjustData>({
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
onSave: (next) =>
|
||||
queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: next,
|
||||
}),
|
||||
debugLabel: "detail-adjust",
|
||||
});
|
||||
|
||||
const updateData = (updater: (draft: DetailAdjustData) => DetailAdjustData) => {
|
||||
setPresetSelection("custom");
|
||||
setLocalData((current) => {
|
||||
const next = updater(current);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
updateLocalData(updater);
|
||||
};
|
||||
|
||||
const builtinOptions = useMemo(() => Object.entries(DETAIL_PRESETS), []);
|
||||
@@ -176,9 +164,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
||||
if (!preset) return;
|
||||
const next = cloneAdjustmentData(preset);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
@@ -187,9 +173,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
|
||||
if (!preset) return;
|
||||
const next = normalizeDetailAdjustData(preset.params);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -9,10 +9,10 @@ import { Sun } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
||||
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
|
||||
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
|
||||
import { useNodeLocalData } from "@/components/canvas/nodes/use-node-local-data";
|
||||
import {
|
||||
ParameterSlider,
|
||||
type SliderConfig,
|
||||
@@ -49,42 +49,30 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
||||
const savePreset = useMutation(api.presets.save);
|
||||
const userPresets = useCanvasAdjustmentPresets("light-adjust") as PresetDoc[];
|
||||
|
||||
const [localData, setLocalData] = useState<LightAdjustData>(() =>
|
||||
normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }),
|
||||
);
|
||||
const [presetSelection, setPresetSelection] = useState("custom");
|
||||
const localDataRef = useRef(localData);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setLocalData(
|
||||
normalizeLightAdjustData({ ...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA), ...data }),
|
||||
);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: localDataRef.current,
|
||||
});
|
||||
}, 16);
|
||||
const normalizeData = useCallback(
|
||||
(value: unknown) =>
|
||||
normalizeLightAdjustData({
|
||||
...cloneAdjustmentData(DEFAULT_LIGHT_ADJUST_DATA),
|
||||
...(value as Record<string, unknown>),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { localData, applyLocalData, updateLocalData } = useNodeLocalData<LightAdjustData>({
|
||||
data,
|
||||
normalize: normalizeData,
|
||||
saveDelayMs: 16,
|
||||
onSave: (next) =>
|
||||
queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: next,
|
||||
}),
|
||||
debugLabel: "light-adjust",
|
||||
});
|
||||
|
||||
const updateData = (updater: (draft: LightAdjustData) => LightAdjustData) => {
|
||||
setPresetSelection("custom");
|
||||
setLocalData((current) => {
|
||||
const next = updater(current);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
updateLocalData(updater);
|
||||
};
|
||||
|
||||
const builtinOptions = useMemo(() => Object.entries(LIGHT_PRESETS), []);
|
||||
@@ -187,9 +175,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
||||
if (!preset) return;
|
||||
const next = cloneAdjustmentData(preset);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
@@ -198,9 +184,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
|
||||
if (!preset) return;
|
||||
const next = normalizeLightAdjustData(preset.params);
|
||||
setPresetSelection(value);
|
||||
setLocalData(next);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
applyLocalData(next);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
106
components/canvas/nodes/use-node-local-data.ts
Normal file
106
components/canvas/nodes/use-node-local-data.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
|
||||
function hashNodeData(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function logNodeDataDebug(event: string, payload: Record<string, unknown>): void {
|
||||
const nodeSyncDebugEnabled =
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
(globalThis as typeof globalThis & { __LEMONSPACE_DEBUG_NODE_SYNC__?: boolean })
|
||||
.__LEMONSPACE_DEBUG_NODE_SYNC__ === true;
|
||||
|
||||
if (!nodeSyncDebugEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info("[Canvas node debug]", event, payload);
|
||||
}
|
||||
|
||||
export function useNodeLocalData<T>({
|
||||
data,
|
||||
normalize,
|
||||
saveDelayMs,
|
||||
onSave,
|
||||
debugLabel,
|
||||
}: {
|
||||
data: unknown;
|
||||
normalize: (value: unknown) => T;
|
||||
saveDelayMs: number;
|
||||
onSave: (value: T) => Promise<void> | void;
|
||||
debugLabel: string;
|
||||
}) {
|
||||
const [localData, setLocalDataState] = useState<T>(() => normalize(data));
|
||||
const localDataRef = useRef(localData);
|
||||
const hasPendingLocalChangesRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
localDataRef.current = localData;
|
||||
}, [localData]);
|
||||
|
||||
const queueSave = useDebouncedCallback(() => {
|
||||
void onSave(localDataRef.current);
|
||||
}, saveDelayMs);
|
||||
|
||||
useEffect(() => {
|
||||
const incomingData = normalize(data);
|
||||
const incomingHash = hashNodeData(incomingData);
|
||||
const localHash = hashNodeData(localDataRef.current);
|
||||
|
||||
if (incomingHash === localHash) {
|
||||
hasPendingLocalChangesRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPendingLocalChangesRef.current) {
|
||||
logNodeDataDebug("skip-stale-external-data", {
|
||||
nodeType: debugLabel,
|
||||
incomingHash,
|
||||
localHash,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
localDataRef.current = incomingData;
|
||||
setLocalDataState(incomingData);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [data, debugLabel, normalize]);
|
||||
|
||||
const applyLocalData = useCallback(
|
||||
(next: T) => {
|
||||
hasPendingLocalChangesRef.current = true;
|
||||
localDataRef.current = next;
|
||||
setLocalDataState(next);
|
||||
queueSave();
|
||||
},
|
||||
[queueSave],
|
||||
);
|
||||
|
||||
const updateLocalData = useCallback(
|
||||
(updater: (current: T) => T) => {
|
||||
hasPendingLocalChangesRef.current = true;
|
||||
setLocalDataState((current) => {
|
||||
const next = updater(current);
|
||||
localDataRef.current = next;
|
||||
queueSave();
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[queueSave],
|
||||
);
|
||||
|
||||
return {
|
||||
localData,
|
||||
applyLocalData,
|
||||
updateLocalData,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user