Enhance canvas components with improved error handling and aspect ratio normalization

- Added error name tracking in NodeErrorBoundary for better debugging.
- Introduced aspect ratio normalization in PromptNode to ensure valid values are used.
- Updated debounced state management in CanvasInner for improved performance.
- Enhanced SelectContent component to support optional portal rendering.
This commit is contained in:
Matthias
2026-04-02 08:26:06 +02:00
parent 2142249ed5
commit 624beac6dc
10 changed files with 552 additions and 100 deletions

View File

@@ -0,0 +1,235 @@
export type PipelineStep<TNodeType extends string = string, TData = unknown> = {
nodeId: string;
type: TNodeType;
params: TData;
};
type IdLike = string | number;
export type PipelineNodeLike<
TNodeType extends string = string,
TData = unknown,
TId extends IdLike = string,
> = {
id: TId;
type: TNodeType;
data?: TData;
};
export type PipelineEdgeLike<TId extends IdLike = string> = {
source: TId;
target: TId;
};
type UpstreamTraversalOptions<
TNode extends PipelineNodeLike,
TEdge extends PipelineEdgeLike,
> = {
nodeId: TNode["id"];
nodes: readonly TNode[];
edges: readonly TEdge[];
getNodeId?: (node: TNode) => TNode["id"];
getNodeType?: (node: TNode) => TNode["type"];
getNodeData?: (node: TNode) => TNode["data"];
getEdgeSource?: (edge: TEdge) => TNode["id"];
getEdgeTarget?: (edge: TEdge) => TNode["id"];
};
type UpstreamWalkResult<TNode extends PipelineNodeLike, TEdge extends PipelineEdgeLike> = {
path: TNode[];
selectedEdges: TEdge[];
};
function toComparableId(value: IdLike): string {
return String(value);
}
function selectIncomingEdge<TNode extends PipelineNodeLike, TEdge extends PipelineEdgeLike>(
incomingEdges: readonly TEdge[],
getEdgeSource: (edge: TEdge) => TNode["id"],
): TEdge | null {
if (incomingEdges.length === 0) {
return null;
}
const sortedIncoming = [...incomingEdges].sort((left, right) =>
toComparableId(getEdgeSource(left)).localeCompare(toComparableId(getEdgeSource(right))),
);
return sortedIncoming[0] ?? null;
}
function walkUpstream<TNode extends PipelineNodeLike, TEdge extends PipelineEdgeLike>(
options: UpstreamTraversalOptions<TNode, TEdge>,
): UpstreamWalkResult<TNode, TEdge> {
const getNodeId = options.getNodeId ?? ((node: TNode) => node.id);
const getEdgeSource = options.getEdgeSource ?? ((edge: TEdge) => edge.source as TNode["id"]);
const getEdgeTarget = options.getEdgeTarget ?? ((edge: TEdge) => edge.target as TNode["id"]);
const byId = new Map<string, TNode>();
for (const node of options.nodes) {
byId.set(toComparableId(getNodeId(node)), node);
}
const incomingByTarget = new Map<string, TEdge[]>();
for (const edge of options.edges) {
const key = toComparableId(getEdgeTarget(edge));
const existing = incomingByTarget.get(key);
if (existing) {
existing.push(edge);
} else {
incomingByTarget.set(key, [edge]);
}
}
const path: TNode[] = [];
const selectedEdges: TEdge[] = [];
const visiting = new Set<string>();
const visit = (currentId: TNode["id"]): void => {
const key = toComparableId(currentId);
if (visiting.has(key)) {
throw new Error(`Cycle detected in pipeline graph at node '${key}'.`);
}
visiting.add(key);
const incomingEdges = incomingByTarget.get(key) ?? [];
const incoming = selectIncomingEdge(incomingEdges, getEdgeSource);
if (incoming) {
selectedEdges.push(incoming);
visit(getEdgeSource(incoming));
}
visiting.delete(key);
const current = byId.get(key);
if (current) {
path.push(current);
}
};
visit(options.nodeId);
return {
path,
selectedEdges,
};
}
export function collectPipeline<
TNode extends PipelineNodeLike,
TEdge extends PipelineEdgeLike,
>(
options: UpstreamTraversalOptions<TNode, TEdge> & {
isPipelineNode: (node: TNode) => boolean;
},
): PipelineStep<TNode["type"], TNode["data"]>[] {
const getNodeId = options.getNodeId ?? ((node: TNode) => node.id);
const getNodeType = options.getNodeType ?? ((node: TNode) => node.type);
const getNodeData = options.getNodeData ?? ((node: TNode) => node.data);
const traversal = walkUpstream(options);
const steps: PipelineStep<TNode["type"], TNode["data"]>[] = [];
for (const node of traversal.path) {
if (!options.isPipelineNode(node)) {
continue;
}
steps.push({
nodeId: toComparableId(getNodeId(node)),
type: getNodeType(node),
params: getNodeData(node),
});
}
return steps;
}
export function getSourceImage<
TNode extends PipelineNodeLike,
TEdge extends PipelineEdgeLike,
TSourceImage,
>(
options: UpstreamTraversalOptions<TNode, TEdge> & {
isSourceNode: (node: TNode) => boolean;
getSourceImageFromNode: (node: TNode) => TSourceImage | null | undefined;
},
): TSourceImage | null {
const traversal = walkUpstream(options);
for (let index = traversal.path.length - 1; index >= 0; index -= 1) {
const node = traversal.path[index];
if (!options.isSourceNode(node)) {
continue;
}
const sourceImage = options.getSourceImageFromNode(node);
if (sourceImage != null) {
return sourceImage;
}
}
return null;
}
function stableStringify(value: unknown): string {
if (value === null || value === undefined) {
return "null";
}
const valueType = typeof value;
if (valueType === "number" || valueType === "boolean") {
return JSON.stringify(value);
}
if (valueType === "string") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
if (valueType === "object") {
const record = value as Record<string, unknown>;
const sortedEntries = Object.entries(record).sort(([a], [b]) =>
a.localeCompare(b),
);
const serialized = sortedEntries
.map(([key, nestedValue]) => `${JSON.stringify(key)}:${stableStringify(nestedValue)}`)
.join(",");
return `{${serialized}}`;
}
return JSON.stringify(String(value));
}
function fnv1aHash(input: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash +=
(hash << 1) +
(hash << 4) +
(hash << 7) +
(hash << 8) +
(hash << 24);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
export function hashPipeline(
sourceImage: unknown,
steps: readonly PipelineStep[],
): string {
return fnv1aHash(
stableStringify({
sourceImage,
steps,
}),
);
}