From 1f8e19598c557ef31426fe6b0adfa67c8ca88fce Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 8 May 2026 13:34:53 +0800 Subject: [PATCH] fix: refine canvas angle preview panel --- .../[id]/CanvasEditorLegacyPageClient.tsx | 1 + components/canvas/RealtimeCanvasDialog.tsx | 3 +- components/canvas/nodes/ImageNode.tsx | 439 ++++++++++++++++-- components/home/floating-sidebar-shell.tsx | 4 +- components/home/lovart-home.tsx | 8 +- 5 files changed, 411 insertions(+), 44 deletions(-) diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 836d114e5..8bb74b94a 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -665,6 +665,7 @@ function resolveCanvasToolbarItemWithFallback( ? item.label : fallback?.label || item.label, iconUrl: item.iconUrl || fallback?.iconUrl || "", + enabled: item.enabled, }; } diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 03838c8a5..808216b48 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -946,13 +946,14 @@ export function RealtimeCanvasDialog({ const resolveRealtimeModeItem = useCallback( ( key: "realtimeModeRealtime" | "realtimeModeEnhance", - fallback: { label: string; iconUrl: string } + fallback: { label: string; iconUrl: string; enabled?: boolean } ) => { const item = getCanvasToolbarItem(canvasToolbarConfig, key); const defaultItem = getDefaultCanvasToolbarItem(key); return { label: item.label === defaultItem.label ? fallback.label : item.label, iconUrl: item.iconUrl || fallback.iconUrl, + enabled: item.enabled ?? fallback.enabled ?? true, }; }, [canvasToolbarConfig] diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 59463fff6..419e62f0e 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -218,6 +218,14 @@ type CropInteractionState = { startRect: NormalizedCropRect; }; +type AnglePreviewDragState = { + pointerId: number; + startX: number; + startY: number; + startHorizontalAngle: number; + startVerticalAngle: number; +}; + interface BoxCutoutPoint { x: number; y: number; @@ -227,6 +235,8 @@ const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08; const BOX_CUTOUT_MIN_EDGE_PX = 24; const FLOATING_TOOLBAR_SCALE = 0.8; const IMAGE_EDIT_PANEL_SCALE = 0.5; +const ANGLE_PREVIEW_HORIZONTAL_DRAG_MULTIPLIER = 0.72; +const ANGLE_PREVIEW_VERTICAL_DRAG_MULTIPLIER = 0.46; const DEFAULT_CROP_PRESET_ID = "1:1"; const IMAGE_ADJUST_NOISE_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent( "" @@ -485,6 +495,51 @@ function AngleEditPanelSlider({ const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); +const fitSizeWithinBox = ({ + maxHeight, + maxWidth, + minHeight = 72, + minWidth = 72, + sourceHeight, + sourceWidth, +}: { + maxHeight: number; + maxWidth: number; + minHeight?: number; + minWidth?: number; + sourceHeight: number; + sourceWidth: number; +}) => { + const safeSourceWidth = sourceWidth > 0 ? sourceWidth : maxWidth; + const safeSourceHeight = sourceHeight > 0 ? sourceHeight : maxHeight; + let scale = Math.min(maxWidth / safeSourceWidth, maxHeight / safeSourceHeight); + let fittedWidth = safeSourceWidth * scale; + let fittedHeight = safeSourceHeight * scale; + + if (fittedWidth < minWidth) { + scale = minWidth / safeSourceWidth; + fittedWidth = safeSourceWidth * scale; + fittedHeight = safeSourceHeight * scale; + } + + if (fittedHeight < minHeight) { + scale = minHeight / safeSourceHeight; + fittedWidth = safeSourceWidth * scale; + fittedHeight = safeSourceHeight * scale; + } + + if (fittedWidth > maxWidth || fittedHeight > maxHeight) { + scale = Math.min(maxWidth / fittedWidth, maxHeight / fittedHeight); + fittedWidth *= scale; + fittedHeight *= scale; + } + + return { + width: Math.round(fittedWidth), + height: Math.round(fittedHeight), + }; +}; + const createNormalizedRect = ( start: BoxCutoutPoint, end: BoxCutoutPoint @@ -1498,6 +1553,7 @@ export function ImageNode({ const [angleVerticalAngle, setAngleVerticalAngle] = useState(-30); const [angleZoom, setAngleZoom] = useState(50); const [isApplyingAngleEdit, setIsApplyingAngleEdit] = useState(false); + const [isAnglePreviewDragging, setIsAnglePreviewDragging] = useState(false); const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); const [resizeAnnotatorOpen, setResizeAnnotatorOpen] = useState(false); const [promptDialogOpen, setPromptDialogOpen] = useState(false); @@ -1546,6 +1602,7 @@ export function ImageNode({ useState(null); const [boxCutoutDragEnd, setBoxCutoutDragEnd] = useState(null); + const anglePreviewDragRef = useRef(null); const activeImageAdjustments = imageAdjustPanelOpen ? imageAdjustmentsDraft : storedImageAdjustments; @@ -1569,8 +1626,8 @@ export function ImageNode({ useEffect(() => { if (!cropPanelOpen) return; if (cropRect) return; - const centerX = cropRect ? cropRect.x + cropRect.width / 2 : 0.5; - const centerY = cropRect ? cropRect.y + cropRect.height / 2 : 0.5; + const centerX = 0.5; + const centerY = 0.5; const preset = getCropPresetById(cropPresetId); if (preset) { setCropRect(createCenteredCropRectForAspect(preset.ratio, centerX, centerY)); @@ -1920,6 +1977,64 @@ export function ImageNode({ if (!source) return ""; return `${source.width} x ${source.height}`; }, [loadedResolution, metadataResolution]); + const angleSourceResolution = useMemo(() => { + const source = metadataResolution || loadedResolution; + if (source?.width && source?.height) { + return source; + } + return { + width: Math.max(1, Math.round(node.size.width || 1)), + height: Math.max(1, Math.round(node.size.height || 1)), + }; + }, [loadedResolution, metadataResolution, node.size.height, node.size.width]); + const anglePreviewFrameSize = useMemo( + () => + fitSizeWithinBox({ + sourceWidth: angleSourceResolution.width, + sourceHeight: angleSourceResolution.height, + maxWidth: anglePanelMode === "subject" ? 178 : 136, + maxHeight: anglePanelMode === "subject" ? 150 : 112, + minWidth: 74, + minHeight: 62, + }), + [anglePanelMode, angleSourceResolution.height, angleSourceResolution.width] + ); + const anglePreviewDepth = useMemo( + () => + Math.round( + clampNumber( + anglePanelMode === "subject" + ? anglePreviewFrameSize.width * 0.18 + : anglePreviewFrameSize.width * 0.22, + 16, + 36 + ) + ), + [anglePanelMode, anglePreviewFrameSize.width] + ); + const anglePreviewScale = useMemo( + () => + Number( + ( + (anglePanelMode === "subject" ? 0.9 : 0.92) + + (angleZoom / 100) * (anglePanelMode === "subject" ? 0.46 : 0.34) + ).toFixed(3) + ), + [anglePanelMode, angleZoom] + ); + const anglePreviewImageSrc = displayImageSrc || data.src; + const angleCameraOrbitPosition = useMemo(() => { + const horizontalRadians = (angleHorizontalAngle * Math.PI) / 180; + const normalizedVertical = (angleVerticalAngle + 30) / 90; + const x = Math.sin(horizontalRadians) * 92; + const y = -normalizedVertical * 60 + 24; + const z = Math.cos(horizontalRadians) * 28; + return { + x: Number(x.toFixed(2)), + y: Number(y.toFixed(2)), + z: Number(z.toFixed(2)), + }; + }, [angleHorizontalAngle, angleVerticalAngle]); const cropImageMetrics = useMemo(() => { if (boxCutoutMetrics) { return { @@ -2165,6 +2280,12 @@ export function ImageNode({ } }, [isSelected, resetBoxCutoutState]); + useEffect(() => { + if (anglePanelOpen) return; + anglePreviewDragRef.current = null; + setIsAnglePreviewDragging(false); + }, [anglePanelOpen]); + useEffect(() => { if (!hasCompareSource || isThisNodePicking) { setIsCompareMode(false); @@ -3390,6 +3511,64 @@ export function ImageNode({ setAngleZoom(50); }, []); + const handleAnglePreviewPointerDown = useCallback( + (event: React.PointerEvent) => { + if (isApplyingAngleEdit) return; + anglePreviewDragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startHorizontalAngle: angleHorizontalAngle, + startVerticalAngle: angleVerticalAngle, + }; + setIsAnglePreviewDragging(true); + event.currentTarget.setPointerCapture?.(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + }, + [angleHorizontalAngle, angleVerticalAngle, isApplyingAngleEdit] + ); + + const handleAnglePreviewPointerMove = useCallback( + (event: React.PointerEvent) => { + const dragState = anglePreviewDragRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + setAngleHorizontalAngle( + clampNumber( + dragState.startHorizontalAngle + + deltaX * ANGLE_PREVIEW_HORIZONTAL_DRAG_MULTIPLIER, + -180, + 180 + ) + ); + setAngleVerticalAngle( + clampNumber( + dragState.startVerticalAngle - + deltaY * ANGLE_PREVIEW_VERTICAL_DRAG_MULTIPLIER, + -30, + 60 + ) + ); + event.preventDefault(); + event.stopPropagation(); + }, + [] + ); + + const handleAnglePreviewPointerEnd = useCallback( + (event: React.PointerEvent) => { + if (anglePreviewDragRef.current?.pointerId !== event.pointerId) return; + anglePreviewDragRef.current = null; + setIsAnglePreviewDragging(false); + event.currentTarget.releasePointerCapture?.(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + }, + [] + ); + const handleApplyFlipRotate = useCallback( async (action: FlipRotateEditAction) => { if (!data.src) { @@ -7917,86 +8096,272 @@ export function ImageNode({
-
+
-
-
-
-
-
-
-
+ + + + + + +
-
- {displayTitle} -
+ {anglePanelMode === "subject" ? ( +
+
+
+
+ {displayTitle} +
+
+
+
+
+
+
+
+ ) : ( + <> +
+
+ {displayTitle} +
+
-
+
+
+
+ + +
+
+
+
+
+ + )} + +
{anglePanelMode === "subject" ? ( ) : ( diff --git a/components/home/floating-sidebar-shell.tsx b/components/home/floating-sidebar-shell.tsx index 0c5283697..e2992fe21 100644 --- a/components/home/floating-sidebar-shell.tsx +++ b/components/home/floating-sidebar-shell.tsx @@ -122,7 +122,7 @@ export function FloatingSidebarRail({ : "border border-white/8 bg-[#17181d]/94 text-white shadow-[0_22px_60px_rgba(6,8,14,0.34),inset_0_1px_0_rgba(255,255,255,0.05)]", isHorizontal ? "h-[56px] w-auto flex-row overflow-hidden rounded-[22px] px-3 py-2" - : "w-[60px] flex-col overflow-y-auto rounded-[28px] px-2 py-2.5", + : "w-[56px] flex-col overflow-y-auto rounded-[24px] px-2 py-2", className )} > @@ -153,7 +153,7 @@ export function FloatingSidebarAction({