From 00db7d39eaab9f487973c96f4ce91f6891378139 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 7 Jul 2026 19:36:02 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=94=9F=E6=88=90=E5=99=A8?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E8=8C=83=E5=9B=B4=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除生成器对话框打开后的安全范围自动拉动视口。 取消快速编辑、裁扩和角色动画面板的画布范围夹取。 保留创建生成器时聚焦新占位的行为并更新回归测试。 --- ...mageCanvasGenerationPlacementModel.test.ts | 2 +- .../ImageCanvasOverlayModel.test.ts | 88 ++++-------- .../image-editor/ImageCanvasOverlayModel.ts | 123 ++-------------- .../useImageCanvasGenerationSurface.test.tsx | 17 ++- .../useImageCanvasGenerationSurface.tsx | 131 ------------------ .../useImageCanvasGenerationWorkflow.test.tsx | 4 +- 6 files changed, 53 insertions(+), 312 deletions(-) diff --git a/src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts b/src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts index eacdc479e..ea26a79ab 100644 --- a/src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts @@ -193,7 +193,7 @@ describe('ImageCanvasGenerationPlacementModel', () => { expect(placement.y).toBe(-22); }); - it('centers the viewport on the chosen placement without changing zoom', () => { + it('focuses the viewport on the chosen placement without changing zoom', () => { const zoomedViewport = { ...viewport, scale: 1.75 }; const nextViewport = centerViewportOnPlacement({ canvasSize, diff --git a/src/components/image-editor/ImageCanvasOverlayModel.test.ts b/src/components/image-editor/ImageCanvasOverlayModel.test.ts index 9c875b93d..100e4c620 100644 --- a/src/components/image-editor/ImageCanvasOverlayModel.test.ts +++ b/src/components/image-editor/ImageCanvasOverlayModel.test.ts @@ -4,18 +4,19 @@ import type { CanvasGenerationDialogState, CanvasLayer, CharacterAnimationPanelState, + CropExpandPanelState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; import { isCanvasGenerationComposerVisible, resolveCharacterAnimationPanelStyle, + resolveCropExpandPanelStyle, resolveGenerationAnchor, resolveGenerationComposerStyle, resolveIconComposerStyle, resolveQuickEditFocusViewport, resolveQuickEditPanelStyle, resolveSelectedToolbarStyle, - resolveViewportForOverlayVisibility, } from './ImageCanvasOverlayModel'; function createLayer(overrides: Partial = {}): CanvasLayer { @@ -146,7 +147,7 @@ describe('ImageCanvasOverlayModel', () => { expect(sourceBottom + 12 + 260).toBeLessThanOrEqual(640); }); - it('positions quick edit and character animation panels with viewport bounds', () => { + it('positions generation panels from their anchors without clamping to viewport bounds', () => { const sourceLayer = createLayer({ x: 740, y: 460, width: 240, height: 180 }); const quickEditPanel: QuickEditPanelState = { sourceLayerId: sourceLayer.id, @@ -164,6 +165,17 @@ describe('ImageCanvasOverlayModel', () => { durationSeconds: 4, status: 'idle', }; + const cropExpandPanel: CropExpandPanelState = { + sourceLayerId: sourceLayer.id, + frame: { + x: sourceLayer.x, + y: sourceLayer.y, + width: sourceLayer.width, + height: sourceLayer.height, + }, + ratio: 'free', + status: 'idle', + }; expect( resolveQuickEditPanelStyle({ @@ -172,7 +184,15 @@ describe('ImageCanvasOverlayModel', () => { viewport: { x: 20, y: 10, scale: 1 }, canvasSize: { width: 900, height: 640 }, }), - ).toEqual({ left: 880, top: 376 }); + ).toEqual({ left: 880, top: 662 }); + expect( + resolveCropExpandPanelStyle({ + panel: cropExpandPanel, + sourceLayer, + viewport: { x: 20, y: 10, scale: 1 }, + canvasSize: { width: 900, height: 640 }, + }), + ).toEqual({ left: 1012, top: 470 }); expect( resolveCharacterAnimationPanelStyle({ panel: characterPanel, @@ -180,67 +200,7 @@ describe('ImageCanvasOverlayModel', () => { viewport: { x: 20, y: 10, scale: 1 }, canvasSize: { width: 900, height: 640 }, }), - ).toEqual({ left: 536, top: 120 }); - }); - - it('pans the viewport until an opened composer is inside the visible safe area', () => { - expect( - resolveViewportForOverlayVisibility({ - viewport: { x: 10, y: 20, scale: 1 }, - overlayBounds: { - left: 210, - top: 430, - right: 690, - bottom: 650, - }, - safeBounds: { - left: 12, - top: 12, - right: 888, - bottom: 560, - }, - }), - ).toEqual({ x: 10, y: -70, scale: 1 }); - }); - - it('pans sideways when a centered composer would leave the viewport', () => { - expect( - resolveViewportForOverlayVisibility({ - viewport: { x: 0, y: 0, scale: 1 }, - overlayBounds: { - left: -80, - top: 120, - right: 420, - bottom: 320, - }, - safeBounds: { - left: 12, - top: 12, - right: 888, - bottom: 560, - }, - }), - ).toEqual({ x: 92, y: 0, scale: 1 }); - }); - - it('centers an oversized composer within the available safe area', () => { - expect( - resolveViewportForOverlayVisibility({ - viewport: { x: 0, y: 0, scale: 1 }, - overlayBounds: { - left: 20, - top: 20, - right: 700, - bottom: 620, - }, - safeBounds: { - left: 12, - top: 12, - right: 612, - bottom: 512, - }, - }), - ).toEqual({ x: -48, y: -58, scale: 1 }); + ).toEqual({ left: 1012, top: 470 }); }); it('recognizes canvas generation composer dialog modes', () => { diff --git a/src/components/image-editor/ImageCanvasOverlayModel.ts b/src/components/image-editor/ImageCanvasOverlayModel.ts index 18dfbe524..8f0916dd4 100644 --- a/src/components/image-editor/ImageCanvasOverlayModel.ts +++ b/src/components/image-editor/ImageCanvasOverlayModel.ts @@ -1,4 +1,3 @@ -import { clamp } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogMode, CanvasGenerationDialogState, @@ -22,7 +21,6 @@ const QUICK_EDIT_FOCUS_SOURCE_TOP = 56; const QUICK_EDIT_PANEL_GAP = 12; const QUICK_EDIT_PANEL_ESTIMATED_HEIGHT = 260; const QUICK_EDIT_PANEL_BOTTOM_MARGIN = 4; -const OVERLAY_VISIBILITY_EPSILON = 0.5; export type CanvasOverlayStyle = { left: number; @@ -33,72 +31,6 @@ export type CanvasComposerOverlayStyle = CanvasOverlayStyle & { width?: string; }; -export type CanvasOverlayBounds = { - left: number; - top: number; - right: number; - bottom: number; -}; - -function resolveOverlayAxisShift({ - start, - end, - safeStart, - safeEnd, -}: { - start: number; - end: number; - safeStart: number; - safeEnd: number; -}) { - const size = end - start; - const safeSize = safeEnd - safeStart; - if (size > safeSize) { - return safeStart + (safeSize - size) / 2 - start; - } - if (start < safeStart) { - return safeStart - start; - } - if (end > safeEnd) { - return safeEnd - end; - } - return 0; -} - -export function resolveViewportForOverlayVisibility({ - viewport, - overlayBounds, - safeBounds, -}: { - viewport: CanvasViewport; - overlayBounds: CanvasOverlayBounds; - safeBounds: CanvasOverlayBounds; -}): CanvasViewport { - const shiftX = resolveOverlayAxisShift({ - start: overlayBounds.left, - end: overlayBounds.right, - safeStart: safeBounds.left, - safeEnd: safeBounds.right, - }); - const shiftY = resolveOverlayAxisShift({ - start: overlayBounds.top, - end: overlayBounds.bottom, - safeStart: safeBounds.top, - safeEnd: safeBounds.bottom, - }); - if ( - Math.abs(shiftX) <= OVERLAY_VISIBILITY_EPSILON && - Math.abs(shiftY) <= OVERLAY_VISIBILITY_EPSILON - ) { - return viewport; - } - return { - ...viewport, - x: viewport.x + shiftX, - y: viewport.y + shiftY, - }; -} - export function resolveGenerationAnchor({ dialog, generatedLayer, @@ -208,7 +140,6 @@ export function resolveQuickEditPanelStyle({ panel, sourceLayer, viewport, - canvasSize, }: { panel: QuickEditPanelState | null; sourceLayer: CanvasLayer | null; @@ -219,24 +150,12 @@ export function resolveQuickEditPanelStyle({ return null; } return { - left: clamp( - viewport.x + - (sourceLayer.x + sourceLayer.width / 2) * viewport.scale, - 12, - Math.max(12, canvasSize.width - 12), - ), - top: clamp( + left: + viewport.x + (sourceLayer.x + sourceLayer.width / 2) * viewport.scale, + top: viewport.y + - (sourceLayer.y + sourceLayer.height) * viewport.scale + - QUICK_EDIT_PANEL_GAP, - QUICK_EDIT_PANEL_BOTTOM_MARGIN, - Math.max( - QUICK_EDIT_PANEL_BOTTOM_MARGIN, - canvasSize.height - - QUICK_EDIT_PANEL_ESTIMATED_HEIGHT - - QUICK_EDIT_PANEL_BOTTOM_MARGIN, - ), - ), + (sourceLayer.y + sourceLayer.height) * viewport.scale + + QUICK_EDIT_PANEL_GAP, }; } @@ -244,7 +163,6 @@ export function resolveCropExpandPanelStyle({ panel, sourceLayer, viewport, - canvasSize, }: { panel: CropExpandPanelState | null; sourceLayer: CanvasLayer | null; @@ -255,18 +173,9 @@ export function resolveCropExpandPanelStyle({ return null; } return { - left: clamp( - viewport.x + - (sourceLayer.x + sourceLayer.width) * viewport.scale + - 12, - 12, - Math.max(12, canvasSize.width - 312), - ), - top: clamp( - viewport.y + sourceLayer.y * viewport.scale, - 12, - Math.max(12, canvasSize.height - 320), - ), + left: + viewport.x + (sourceLayer.x + sourceLayer.width) * viewport.scale + 12, + top: viewport.y + sourceLayer.y * viewport.scale, }; } @@ -274,7 +183,6 @@ export function resolveCharacterAnimationPanelStyle({ panel, sourceLayer, viewport, - canvasSize, }: { panel: CharacterAnimationPanelState | null; sourceLayer: CanvasLayer | null; @@ -285,18 +193,9 @@ export function resolveCharacterAnimationPanelStyle({ return null; } return { - left: clamp( - viewport.x + - (sourceLayer.x + sourceLayer.width) * viewport.scale + - 12, - 12, - Math.max(12, canvasSize.width - 364), - ), - top: clamp( - viewport.y + sourceLayer.y * viewport.scale, - 12, - Math.max(12, canvasSize.height - 520), - ), + left: + viewport.x + (sourceLayer.x + sourceLayer.width) * viewport.scale + 12, + top: viewport.y + sourceLayer.y * viewport.scale, }; } diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index 37776cff1..7996aeff9 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -413,7 +413,7 @@ describe('useImageCanvasGenerationSurface', () => { ).toBeTruthy(); }); - it('pans the canvas when an opened composer would be covered by the bottom toolbar', async () => { + it('does not pan the canvas again when a focused composer leaves the visible viewport', async () => { render(); const viewportElement = screen.getByTestId('viewport'); const toolbarElement = viewportElement.querySelector( @@ -457,9 +457,22 @@ describe('useImageCanvasGenerationSurface', () => { fireEvent.click(screen.getByRole('button', { name: '切换生成' })); await screen.findByRole('dialog', { name: '生成图片' }); + const focusedViewportX = Number( + screen.getByTestId('viewport-x').textContent, + ); + const focusedViewportY = Number( + screen.getByTestId('viewport-y').textContent, + ); await act(async () => {}); - expect(Number(screen.getByTestId('viewport-y').textContent)).toBe(1086); + expect(focusedViewportX).toBe(10); + expect(focusedViewportY).toBe(1228); + expect(Number(screen.getByTestId('viewport-x').textContent)).toBe( + focusedViewportX, + ); + expect(Number(screen.getByTestId('viewport-y').textContent)).toBe( + focusedViewportY, + ); }); }); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index 8b2bafd85..4ad836552 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -7,7 +7,6 @@ import { type SetStateAction, useCallback, useEffect, - useLayoutEffect, useRef, } from 'react'; import { createPortal } from 'react-dom'; @@ -42,7 +41,6 @@ import { resolveGenerationComposerStyle, resolveIconComposerStyle, resolveQuickEditPanelStyle, - resolveViewportForOverlayVisibility, } from './ImageCanvasOverlayModel'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; @@ -138,73 +136,12 @@ function buildToolbarPortalMenuStyle( } const TOOLBAR_OPTION_CLOSE_DELAY_MS = 160; -const COMPOSER_SAFE_MARGIN = 12; -const COMPOSER_MIN_SAFE_HEIGHT = 120; - -function getViewportLocalBounds( - element: Element | null, - viewportRect: DOMRect, -) { - const rect = element?.getBoundingClientRect(); - if (!rect) { - return null; - } - return { - left: rect.left - viewportRect.left, - top: rect.top - viewportRect.top, - right: rect.right - viewportRect.left, - bottom: rect.bottom - viewportRect.top, - }; -} - -function getComposerSafeBounds(viewportElement: HTMLElement) { - const viewportRect = viewportElement.getBoundingClientRect(); - const bottomToolbarBounds = getViewportLocalBounds( - viewportElement.querySelector('.image-canvas-editor__bottom-toolbar'), - viewportRect, - ); - const panelDockBounds = getViewportLocalBounds( - viewportElement.querySelector('.image-canvas-editor__panel-dock'), - viewportRect, - ); - const coveredBottom = Math.min( - viewportRect.height, - ...[bottomToolbarBounds, panelDockBounds] - .map((bounds) => bounds?.top) - .filter((top): top is number => typeof top === 'number'), - ); - const safeBottom = Math.max( - COMPOSER_SAFE_MARGIN + COMPOSER_MIN_SAFE_HEIGHT, - coveredBottom - COMPOSER_SAFE_MARGIN, - ); - return { - left: COMPOSER_SAFE_MARGIN, - top: COMPOSER_SAFE_MARGIN, - right: Math.max( - COMPOSER_SAFE_MARGIN, - viewportRect.width - COMPOSER_SAFE_MARGIN, - ), - bottom: safeBottom, - }; -} - -function getActiveComposerElement(viewportElement: HTMLElement) { - return viewportElement.querySelector( - [ - '.image-canvas-editor__generation-composer[role="dialog"]', - '.image-canvas-editor__quick-edit-panel[role="dialog"]', - '.image-canvas-editor__crop-expand-panel[role="dialog"]', - '.image-canvas-editor__character-animation-panel[role="dialog"]', - ].join(', '), - ); -} export function useImageCanvasGenerationSurface({ layers, canvasSize, viewport, setViewport, - canvasViewportRef, setLayers, layerCounterRef, specToolWrapRef, @@ -246,7 +183,6 @@ export function useImageCanvasGenerationSurface({ const toolbarOptionCloseTimerRef = useRef | null>(null); - const adjustedComposerVisibilityKeyRef = useRef(null); const generationWorkflow = useImageCanvasGenerationWorkflow({ layers, canvasSize, @@ -320,73 +256,6 @@ export function useImageCanvasGenerationSurface({ viewport, canvasSize, }); - const activeComposerVisibilityKey = - activeCanvasGenerationDialog && - activeCanvasGenerationDialog.status !== 'generating' && - activeCanvasGenerationDialog.composerOpen !== false && - generationComposerStyle - ? `${activeCanvasGenerationDialog.id}:${activeCanvasGenerationDialog.mode}:${activeCanvasGenerationDialog.placeholder?.x ?? '-'}:${activeCanvasGenerationDialog.placeholder?.y ?? '-'}:${activeCanvasGenerationDialog.placeholder?.width ?? '-'}:${activeCanvasGenerationDialog.placeholder?.height ?? '-'}` - : generationWorkflow.quickEditPanel && - generationWorkflow.quickEditSourceLayer && - quickEditPanelStyle - ? `quick-edit-panel:${generationWorkflow.quickEditPanel.sourceLayerId}:${generationWorkflow.quickEditPanel.mode ?? 'quick-edit'}` - : generationWorkflow.cropExpandPanel && - generationWorkflow.cropExpandSourceLayer && - cropExpandPanelStyle - ? `crop-expand-panel:${generationWorkflow.cropExpandPanel.sourceLayerId}` - : generationWorkflow.characterAnimationPanel && - generationWorkflow.characterAnimationSourceLayer && - characterAnimationPanelStyle - ? `character-animation-panel:${generationWorkflow.characterAnimationPanel.sourceLayerId}` - : null; - - useLayoutEffect(() => { - if (!activeComposerVisibilityKey) { - adjustedComposerVisibilityKeyRef.current = null; - return; - } - if ( - adjustedComposerVisibilityKeyRef.current === activeComposerVisibilityKey - ) { - return; - } - const viewportElement = canvasViewportRef.current; - if (!viewportElement) { - return; - } - const composerElement = getActiveComposerElement(viewportElement); - if (!composerElement) { - return; - } - const viewportRect = viewportElement.getBoundingClientRect(); - if (viewportRect.width <= 0 || viewportRect.height <= 0) { - return; - } - const composerRect = composerElement.getBoundingClientRect(); - if (composerRect.width <= 0 || composerRect.height <= 0) { - return; - } - const nextViewport = resolveViewportForOverlayVisibility({ - viewport, - overlayBounds: { - left: composerRect.left - viewportRect.left, - top: composerRect.top - viewportRect.top, - right: composerRect.right - viewportRect.left, - bottom: composerRect.bottom - viewportRect.top, - }, - safeBounds: getComposerSafeBounds(viewportElement), - }); - if (nextViewport !== viewport) { - adjustedComposerVisibilityKeyRef.current = activeComposerVisibilityKey; - setViewport(nextViewport); - } - }, [ - activeComposerVisibilityKey, - canvasViewportRef, - setViewport, - viewport, - ]); - const clearToolbarOptionCloseTimer = useCallback(() => { if (!toolbarOptionCloseTimerRef.current) { return; diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index c1bc0b962..992f48830 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -922,7 +922,7 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); - it('places a new generation placeholder away from existing canvas images and centers the viewport on it', () => { + it('places a new generation placeholder away from existing canvas images and focuses the viewport on it', () => { render( { expect(screen.getByTestId('viewport').textContent).toBe('1538:20:2'); }); - it('places a new video generation placeholder away from existing canvas images and centers the viewport on it', () => { + it('places a new video generation placeholder away from existing canvas images and focuses the viewport on it', () => { render(