统一生成器对话框范围行为
移除生成器对话框打开后的安全范围自动拉动视口。 取消快速编辑、裁扩和角色动画面板的画布范围夹取。 保留创建生成器时聚焦新占位的行为并更新回归测试。
This commit is contained in:
@@ -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={[
|
||||
|
||||
Reference in New Issue
Block a user