From 24f42061c545a63eadd8acbe5b1e3fc64f7d07b6 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 25 Jun 2026 23:25:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=9D=A2=E6=9D=BF=E6=89=93=E5=BC=80=E5=90=8E=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 composer 可见安全区计算,打开后自动平移画布避开视口边界和工具栏。 统一在生成面板 surface 中按真实 DOM 尺寸触发避让,覆盖生成器、快速编辑、裁扩和角色动作面板。 补充 overlay 与 generation surface 回归测试。 同步 Lovart 面板方案和共享踩坑文档。 --- docs/project-memory/shared-memory/pitfalls.md | 8 ++ ...】生成类面板Lovart统一改造方案-2026-06-17.md | 1 + .../image-editor/ImageCanvasEditorView.tsx | 1 + .../ImageCanvasOverlayModel.test.ts | 61 ++++++++ .../image-editor/ImageCanvasOverlayModel.ts | 67 +++++++++ .../useImageCanvasGenerationSurface.test.tsx | 107 +++++++++++++- .../useImageCanvasGenerationSurface.tsx | 131 ++++++++++++++++++ 7 files changed, 374 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 26a9f5c48..bb95eae6d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -263,6 +263,14 @@ - 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`。 - 关联:`src/components/image-editor/ImageCanvasGenerationPlacementModel.ts`、`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 +## 图片画布生成类 composer 打开后必须自动进入可见安全区 + +- 现象:生成器、快速编辑、裁扩或角色动作面板打开后,面板可能在当前画布视口外,或被底部工具栏 / 左下 dock 盖住,用户只看到一部分甚至完全看不到输入框。 +- 原因:placement 只负责选择画布世界坐标里的占位落点,面板实际 DOM 宽高、`translateX(-50%)`、移动端 fixed 样式和工具栏覆盖区域没有反向修正 viewport。 +- 处理:所有画布内 composer / 面板渲染后统一走 `resolveViewportForOverlayVisibility(...)`,用真实 DOM 矩形和工具栏安全边界只平移 viewport;新增入口不要在各自按钮 handler 里写独立偏移。 +- 验证:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`。 +- 关联:`src/components/image-editor/ImageCanvasOverlayModel.ts`、`src/components/image-editor/useImageCanvasGenerationSurface.tsx`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 + ## 图片画布图片改造也必须创建独立生成器占位 - 现象:点击图片图层的“改造”后,输入框直接挂在原图上,提交时既不像其它生成入口一样有独立占位,也容易让用户误以为会覆盖源图。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index fe1e6f353..d89c6881c 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -99,6 +99,7 @@ - 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。 - 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。 - 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;该占位必须复用新建图片的 placement 避让逻辑,和已有素材 / 生成占位至少保留 32px 画布间距,不允许固定放到原图右侧后压住其它素材;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板。 +- 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。 ## 画布悬浮信息 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 430521b00..1d8660559 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -763,6 +763,7 @@ export function ImageCanvasEditorView() { canvasSize, viewport, setViewport, + canvasViewportRef, setLayers, layerCounterRef, specToolWrapRef, diff --git a/src/components/image-editor/ImageCanvasOverlayModel.test.ts b/src/components/image-editor/ImageCanvasOverlayModel.test.ts index 097dfbabb..ed0e2fd48 100644 --- a/src/components/image-editor/ImageCanvasOverlayModel.test.ts +++ b/src/components/image-editor/ImageCanvasOverlayModel.test.ts @@ -15,6 +15,7 @@ import { resolveQuickEditFocusViewport, resolveQuickEditPanelStyle, resolveSelectedToolbarStyle, + resolveViewportForOverlayVisibility, } from './ImageCanvasOverlayModel'; function createLayer(overrides: Partial = {}): CanvasLayer { @@ -182,6 +183,66 @@ describe('ImageCanvasOverlayModel', () => { ).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 }); + }); + it('recognizes canvas generation composer dialog modes', () => { expect(isCanvasGenerationComposerVisible(createDialog({ mode: 'generate' }))).toBe( true, diff --git a/src/components/image-editor/ImageCanvasOverlayModel.ts b/src/components/image-editor/ImageCanvasOverlayModel.ts index be6bb684d..2d6fa63fb 100644 --- a/src/components/image-editor/ImageCanvasOverlayModel.ts +++ b/src/components/image-editor/ImageCanvasOverlayModel.ts @@ -25,6 +25,7 @@ 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; @@ -35,6 +36,72 @@ 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, diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index be112d68c..f81996560 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { useRef, useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState, @@ -31,6 +31,52 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }; }); +let mockedComposerRect: DOMRect | null = null; +let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect; + +beforeEach(() => { + originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function getBoundingClientRect() { + if ( + mockedComposerRect && + this instanceof HTMLElement && + this.classList.contains('image-canvas-editor__generation-composer') + ) { + return mockedComposerRect; + } + return originalGetBoundingClientRect.call(this); + }; +}); + +afterEach(() => { + mockedComposerRect = null; + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; +}); + +function createDomRect({ + left, + top, + right, + bottom, +}: { + left: number; + top: number; + right: number; + bottom: number; +}) { + return { + x: left, + y: top, + left, + top, + right, + bottom, + width: right - left, + height: bottom - top, + toJSON: () => ({}), + } as DOMRect; +} + function createLayer(overrides: Partial = {}): CanvasLayer { const id = overrides.id ?? 'layer-a'; return { @@ -66,6 +112,7 @@ function GenerationSurfaceHarness() { const iconSpecButtonRef = useRef(null); const generationReferenceButtonRef = useRef(null); const publicationReferenceButtonRef = useRef(null); + const canvasViewportRef = useRef(null); const [viewport, setViewport] = useState({ x: 10, y: 20, scale: 2 }); const dialogs = useCanvasGenerationDialogs(); const activeDialog = dialogs.generateDialog; @@ -78,6 +125,7 @@ function GenerationSurfaceHarness() { canvasSize: { width: 900, height: 640 }, viewport, setViewport, + canvasViewportRef, setLayers, layerCounterRef, specToolWrapRef, @@ -136,6 +184,8 @@ function GenerationSurfaceHarness() { {surface.generationComposerStyle?.top ?? '-'} + {viewport.x} + {viewport.y} - {surface.generationComposerNode} +
+
底部工具栏
+ {surface.generationComposerNode} +
); } @@ -357,4 +410,54 @@ describe('useImageCanvasGenerationSurface', () => { screen.getByRole('dialog', { name: '生成游戏背景音乐' }), ).toBeTruthy(); }); + + it('pans the canvas when an opened composer would be covered by the bottom toolbar', async () => { + render(); + const viewportElement = screen.getByTestId('viewport'); + const toolbarElement = viewportElement.querySelector( + '.image-canvas-editor__bottom-toolbar', + ) as HTMLElement; + + Object.defineProperty(viewportElement, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 900, + bottom: 640, + width: 900, + height: 640, + toJSON: () => ({}), + }), + }); + Object.defineProperty(toolbarElement, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 250, + y: 570, + left: 250, + top: 570, + right: 650, + bottom: 620, + width: 400, + height: 50, + toJSON: () => ({}), + }), + }); + mockedComposerRect = createDomRect({ + left: 114, + top: 520, + right: 786, + bottom: 700, + }); + + fireEvent.click(screen.getByRole('button', { name: '切换生成' })); + await screen.findByRole('dialog', { name: '生成图片' }); + + await act(async () => {}); + + expect(Number(screen.getByTestId('viewport-y').textContent)).toBe(1086); + }); }); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index d55924e65..d8d578d0e 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -7,6 +7,7 @@ import { type SetStateAction, useCallback, useEffect, + useLayoutEffect, useRef, } from 'react'; import { createPortal } from 'react-dom'; @@ -40,6 +41,7 @@ import { resolveGenerationComposerStyle, resolveIconComposerStyle, resolveQuickEditPanelStyle, + resolveViewportForOverlayVisibility, } from './ImageCanvasOverlayModel'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; @@ -54,6 +56,7 @@ type ImageCanvasGenerationSurfaceOptions = { canvasSize: { width: number; height: number }; viewport: CanvasViewport; setViewport: Dispatch>; + canvasViewportRef: RefObject; setLayers: Dispatch>; layerCounterRef: MutableRefObject; specToolWrapRef: RefObject; @@ -133,12 +136,73 @@ 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, @@ -179,6 +243,7 @@ export function useImageCanvasGenerationSurface({ const toolbarOptionCloseTimerRef = useRef | null>(null); + const adjustedComposerVisibilityKeyRef = useRef(null); const generationWorkflow = useImageCanvasGenerationWorkflow({ layers, canvasSize, @@ -251,6 +316,72 @@ 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) {