统一生成器对话框范围行为

移除生成器对话框打开后的安全范围自动拉动视口。

取消快速编辑、裁扩和角色动画面板的画布范围夹取。

保留创建生成器时聚焦新占位的行为并更新回归测试。
This commit is contained in:
2026-07-07 19:36:02 +08:00
parent 862653c987
commit 00db7d39ea
6 changed files with 53 additions and 312 deletions
@@ -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,
@@ -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> = {}): 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', () => {
@@ -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,
};
}
@@ -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(<GenerationSurfaceHarness />);
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,
);
});
});
@@ -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<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,
@@ -246,7 +183,6 @@ export function useImageCanvasGenerationSurface({
const toolbarOptionCloseTimerRef = useRef<ReturnType<
typeof setTimeout
> | null>(null);
const adjustedComposerVisibilityKeyRef = useRef<string | null>(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;
@@ -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(
<GenerationWorkflowHarness
initialLayers={[
@@ -945,7 +945,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
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(
<GenerationWorkflowHarness
initialLayers={[