修复画布生成面板打开后不可见

新增 composer 可见安全区计算,打开后自动平移画布避开视口边界和工具栏。

统一在生成面板 surface 中按真实 DOM 尺寸触发避让,覆盖生成器、快速编辑、裁扩和角色动作面板。

补充 overlay 与 generation surface 回归测试。

同步 Lovart 面板方案和共享踩坑文档。
This commit is contained in:
2026-06-25 23:25:52 +08:00
parent c5295a619c
commit 24f42061c5
7 changed files with 374 additions and 2 deletions
@@ -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`
## 图片画布图片改造也必须创建独立生成器占位
- 现象:点击图片图层的“改造”后,输入框直接挂在原图上,提交时既不像其它生成入口一样有独立占位,也容易让用户误以为会覆盖源图。
@@ -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 里手写单独的避让偏移。
## 画布悬浮信息
@@ -763,6 +763,7 @@ export function ImageCanvasEditorView() {
canvasSize,
viewport,
setViewport,
canvasViewportRef,
setLayers,
layerCounterRef,
specToolWrapRef,
@@ -15,6 +15,7 @@ import {
resolveQuickEditFocusViewport,
resolveQuickEditPanelStyle,
resolveSelectedToolbarStyle,
resolveViewportForOverlayVisibility,
} from './ImageCanvasOverlayModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): 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,
@@ -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,
@@ -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> = {}): CanvasLayer {
const id = overrides.id ?? 'layer-a';
return {
@@ -66,6 +112,7 @@ function GenerationSurfaceHarness() {
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(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() {
<span data-testid="composer-top">
{surface.generationComposerStyle?.top ?? '-'}
</span>
<span data-testid="viewport-x">{viewport.x}</span>
<span data-testid="viewport-y">{viewport.y}</span>
<button
type="button"
onClick={() => surface.switchGenerationTool('generate')}
@@ -204,7 +254,10 @@ function GenerationSurfaceHarness() {
>
</button>
{surface.generationComposerNode}
<div ref={canvasViewportRef} data-testid="viewport">
<div className="image-canvas-editor__bottom-toolbar"></div>
{surface.generationComposerNode}
</div>
</div>
);
}
@@ -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(<GenerationSurfaceHarness />);
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);
});
});
@@ -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<SetStateAction<CanvasViewport>>;
canvasViewportRef: RefObject<HTMLDivElement | null>;
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
layerCounterRef: MutableRefObject<number>;
specToolWrapRef: RefObject<HTMLSpanElement | null>;
@@ -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<HTMLElement>(
[
'.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<ReturnType<
typeof setTimeout
> | null>(null);
const adjustedComposerVisibilityKeyRef = useRef<string | null>(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) {