Files
lemonspace_app/components/canvas/canvas-node-change-helpers.ts
Matthias Meister b428f5f4df refactor: modularize canvas component by extracting low-level logic into dedicated helper modules
- Removed unused imports and functions from canvas.tsx to streamline the codebase.
- Introduced several helper modules for improved organization and maintainability, including canvas-helpers, canvas-node-change-helpers, and canvas-media-utils.
- Updated documentation in CLAUDE.md to reflect changes in the canvas architecture and the purpose of new internal modules.
2026-03-31 21:39:15 +02:00

224 lines
6.6 KiB
TypeScript

import type { Node as RFNode, NodeChange } from "@xyflow/react";
import {
AI_IMAGE_NODE_FOOTER_PX,
AI_IMAGE_NODE_HEADER_PX,
DEFAULT_ASPECT_RATIO,
parseAspectRatioString,
} from "@/lib/image-formats";
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
function isActiveResizeChange(change: NodeChange): boolean {
return change.type === "dimensions" &&
Boolean(change.dimensions) &&
(change.resizing === true || change.resizing === false);
}
function adjustAssetNodeDimensionsChange(
change: NodeChange,
node: RFNode,
allChanges: NodeChange[],
): NodeChange | null {
if (change.type !== "dimensions" || !change.dimensions) return change;
const isActiveResize = isActiveResizeChange(change);
const nodeResizing = Boolean((node as { resizing?: boolean }).resizing);
const hasResizingTrueInBatch = allChanges.some(
(candidate) =>
candidate.type === "dimensions" &&
"id" in candidate &&
candidate.id === change.id &&
candidate.resizing === true,
);
if (!isActiveResize && (nodeResizing || hasResizingTrueInBatch)) {
return null;
}
if (!isActiveResize) {
return change;
}
const nodeData = node.data as {
intrinsicWidth?: number;
intrinsicHeight?: number;
orientation?: string;
};
const hasIntrinsicRatioInput =
typeof nodeData.intrinsicWidth === "number" &&
nodeData.intrinsicWidth > 0 &&
typeof nodeData.intrinsicHeight === "number" &&
nodeData.intrinsicHeight > 0;
if (!hasIntrinsicRatioInput) {
return change;
}
const targetRatio = resolveMediaAspectRatio(
nodeData.intrinsicWidth,
nodeData.intrinsicHeight,
nodeData.orientation,
);
if (!Number.isFinite(targetRatio) || targetRatio <= 0) {
return change;
}
const previousWidth =
typeof node.style?.width === "number" ? node.style.width : change.dimensions.width;
const previousHeight =
typeof node.style?.height === "number" ? node.style.height : change.dimensions.height;
const widthDelta = Math.abs(change.dimensions.width - previousWidth);
const heightDelta = Math.abs(change.dimensions.height - previousHeight);
let constrainedWidth = change.dimensions.width;
let constrainedHeight = change.dimensions.height;
const assetChromeHeight = 88;
const assetMinPreviewHeight = 150;
const assetMinNodeHeight = assetChromeHeight + assetMinPreviewHeight;
const assetMinNodeWidth = 200;
if (heightDelta > widthDelta) {
const previewHeight = Math.max(1, constrainedHeight - assetChromeHeight);
constrainedWidth = previewHeight * targetRatio;
constrainedHeight = assetChromeHeight + previewHeight;
} else {
const previewHeight = constrainedWidth / targetRatio;
constrainedHeight = assetChromeHeight + previewHeight;
}
const minWidthFromPreview = assetMinPreviewHeight * targetRatio;
const minimumAllowedWidth = Math.max(assetMinNodeWidth, minWidthFromPreview);
const minPreviewFromWidth = minimumAllowedWidth / targetRatio;
const minimumAllowedHeight = Math.max(
assetMinNodeHeight,
assetChromeHeight + minPreviewFromWidth,
);
let enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth);
let enforcedHeight = assetChromeHeight + enforcedWidth / targetRatio;
if (enforcedHeight < minimumAllowedHeight) {
enforcedHeight = minimumAllowedHeight;
enforcedWidth = (enforcedHeight - assetChromeHeight) * targetRatio;
}
enforcedWidth = Math.max(enforcedWidth, minimumAllowedWidth);
enforcedHeight = assetChromeHeight + enforcedWidth / targetRatio;
return {
...change,
dimensions: {
...change.dimensions,
width: enforcedWidth,
height: enforcedHeight,
},
};
}
function adjustAiImageNodeDimensionsChange(
change: NodeChange,
node: RFNode,
): NodeChange {
if (change.type !== "dimensions" || !change.dimensions) return change;
const isActiveResize = isActiveResizeChange(change);
if (!isActiveResize) {
return change;
}
const nodeData = node.data as { aspectRatio?: string };
const arLabel =
typeof nodeData.aspectRatio === "string" && nodeData.aspectRatio.trim()
? nodeData.aspectRatio.trim()
: DEFAULT_ASPECT_RATIO;
let arW: number;
let arH: number;
try {
const parsed = parseAspectRatioString(arLabel);
arW = parsed.w;
arH = parsed.h;
} catch {
return change;
}
const chrome = AI_IMAGE_NODE_HEADER_PX + AI_IMAGE_NODE_FOOTER_PX;
const hPerW = arH / arW;
const previousWidth =
typeof node.style?.width === "number" ? node.style.width : change.dimensions.width;
const previousHeight =
typeof node.style?.height === "number" ? node.style.height : change.dimensions.height;
const widthDelta = Math.abs(change.dimensions.width - previousWidth);
const heightDelta = Math.abs(change.dimensions.height - previousHeight);
let constrainedWidth = change.dimensions.width;
let constrainedHeight = change.dimensions.height;
if (heightDelta > widthDelta) {
const viewportHeight = Math.max(1, constrainedHeight - chrome);
constrainedWidth = viewportHeight * (arW / arH);
constrainedHeight = chrome + viewportHeight;
} else {
constrainedHeight = chrome + constrainedWidth * hPerW;
}
const aiMinViewport = 120;
const aiMinOuterHeight = chrome + aiMinViewport;
const aiMinOuterWidthBase = 200;
const minimumAllowedWidth = Math.max(
aiMinOuterWidthBase,
aiMinViewport * (arW / arH),
);
const minimumAllowedHeight = Math.max(
aiMinOuterHeight,
chrome + minimumAllowedWidth * hPerW,
);
let enforcedWidth = Math.max(constrainedWidth, minimumAllowedWidth);
let enforcedHeight = chrome + enforcedWidth * hPerW;
if (enforcedHeight < minimumAllowedHeight) {
enforcedHeight = minimumAllowedHeight;
enforcedWidth = (enforcedHeight - chrome) * (arW / arH);
}
enforcedWidth = Math.max(enforcedWidth, minimumAllowedWidth);
enforcedHeight = chrome + enforcedWidth * hPerW;
return {
...change,
dimensions: {
...change.dimensions,
width: enforcedWidth,
height: enforcedHeight,
},
};
}
export function adjustNodeDimensionChanges(
changes: NodeChange[],
nodes: RFNode[],
): NodeChange[] {
return changes
.map((change) => {
if (change.type !== "dimensions" || !change.dimensions) {
return change;
}
const node = nodes.find((candidate) => candidate.id === change.id);
if (!node) {
return change;
}
if (node.type === "asset") {
return adjustAssetNodeDimensionsChange(change, node, changes);
}
if (node.type === "ai-image") {
return adjustAiImageNodeDimensionsChange(change, node);
}
return change;
})
.filter((change): change is NodeChange => change !== null);
}