拆分图片画布浮层定位模型
新增 ImageCanvasOverlayModel 承载生成输入框、图片工具栏、快速编辑和角色动画面板定位规则 新增浮层定位模型单测覆盖锚定、边界限制和生成面板可见模式 更新图片画布前端拆分计划和 TRACKING 记录本阶段验证 精简 ImageCanvasEditorView 中的浮层坐标计算
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -22,22 +21,25 @@ import {
|
||||
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
||||
import { ImageCanvasStageView } from './ImageCanvasStageView';
|
||||
import {
|
||||
TOOLBAR_HALF_WIDTH,
|
||||
clamp,
|
||||
createLayerFromAsset,
|
||||
isGeneratedLayer,
|
||||
isLayerLinkedToAsset,
|
||||
resolveContextMenuPosition,
|
||||
serializeLayer,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import {
|
||||
ICON_COMPOSER_HORIZONTAL_CHROME_REM,
|
||||
ICON_COMPOSER_MIN_WIDTH_REM,
|
||||
ICON_DESCRIPTION_CARD_WIDTH_REM,
|
||||
getGenerationFrameAriaLabel,
|
||||
getGenerationFrameLabel,
|
||||
getLayerKindLabel,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
isCanvasGenerationComposerVisible,
|
||||
resolveCharacterAnimationPanelStyle,
|
||||
resolveGenerationAnchor,
|
||||
resolveGenerationComposerStyle,
|
||||
resolveIconComposerStyle,
|
||||
resolveQuickEditPanelStyle,
|
||||
resolveSelectedToolbarStyle,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
import type {
|
||||
AssetPointerDragState,
|
||||
CanvasContextMenuState,
|
||||
@@ -302,36 +304,21 @@ export function ImageCanvasEditorView() {
|
||||
[activeCanvasGenerationDialog, layers],
|
||||
);
|
||||
const generationAnchor = activeCanvasGenerationDialog
|
||||
? (activeGenerationLayer ??
|
||||
activeCanvasGenerationDialog.placeholder ??
|
||||
null)
|
||||
: null;
|
||||
const generationComposerStyle =
|
||||
activeCanvasGenerationDialog?.status !== 'generating' &&
|
||||
activeCanvasGenerationDialog?.composerOpen !== false &&
|
||||
generationAnchor
|
||||
? {
|
||||
left:
|
||||
viewport.x +
|
||||
(generationAnchor.x + generationAnchor.width / 2) * viewport.scale,
|
||||
top:
|
||||
viewport.y +
|
||||
(generationAnchor.y + generationAnchor.height) * viewport.scale +
|
||||
10,
|
||||
}
|
||||
: null;
|
||||
const selectedToolbarStyle = selectedLayer
|
||||
? {
|
||||
left: clamp(
|
||||
viewport.x +
|
||||
selectedLayer.x * viewport.scale +
|
||||
(selectedLayer.width * viewport.scale) / 2,
|
||||
TOOLBAR_HALF_WIDTH,
|
||||
Math.max(TOOLBAR_HALF_WIDTH, canvasSize.width - TOOLBAR_HALF_WIDTH),
|
||||
),
|
||||
top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12),
|
||||
}
|
||||
? resolveGenerationAnchor({
|
||||
dialog: activeCanvasGenerationDialog,
|
||||
generatedLayer: activeGenerationLayer,
|
||||
})
|
||||
: null;
|
||||
const generationComposerStyle = resolveGenerationComposerStyle({
|
||||
dialog: activeCanvasGenerationDialog,
|
||||
anchor: generationAnchor,
|
||||
viewport,
|
||||
});
|
||||
const selectedToolbarStyle = resolveSelectedToolbarStyle({
|
||||
selectedLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
});
|
||||
const imageContextMenuLayer = imageContextMenu
|
||||
? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null)
|
||||
: null;
|
||||
@@ -396,10 +383,7 @@ export function ImageCanvasEditorView() {
|
||||
setSelectedLayerIds(layerId ? [layerId] : []);
|
||||
if (layerId) {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'generate' ||
|
||||
currentDialog?.mode === 'spec' ||
|
||||
currentDialog?.mode === 'character' ||
|
||||
currentDialog?.mode === 'icon'
|
||||
isCanvasGenerationComposerVisible(currentDialog)
|
||||
? {
|
||||
...currentDialog,
|
||||
composerOpen: false,
|
||||
@@ -509,56 +493,23 @@ export function ImageCanvasEditorView() {
|
||||
closeGenerateComposer,
|
||||
clearDeletedLayerGenerationState,
|
||||
} = generationWorkflow;
|
||||
const iconComposerStyle: CSSProperties | null =
|
||||
activeCanvasGenerationDialog?.mode === 'icon' && generationComposerStyle
|
||||
? {
|
||||
...generationComposerStyle,
|
||||
width: `${Math.max(
|
||||
ICON_COMPOSER_MIN_WIDTH_REM,
|
||||
ICON_COMPOSER_HORIZONTAL_CHROME_REM +
|
||||
iconDescriptionValues.length * ICON_DESCRIPTION_CARD_WIDTH_REM,
|
||||
).toFixed(1)}rem`,
|
||||
}
|
||||
: null;
|
||||
const quickEditPanelStyle =
|
||||
quickEditPanel && quickEditSourceLayer
|
||||
? {
|
||||
left: clamp(
|
||||
viewport.x +
|
||||
(quickEditSourceLayer.x + quickEditSourceLayer.width / 2) *
|
||||
viewport.scale,
|
||||
12,
|
||||
Math.max(12, canvasSize.width - 12),
|
||||
),
|
||||
top: clamp(
|
||||
viewport.y +
|
||||
(quickEditSourceLayer.y + quickEditSourceLayer.height) *
|
||||
viewport.scale +
|
||||
12,
|
||||
12,
|
||||
Math.max(12, canvasSize.height - 360),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const characterAnimationPanelStyle =
|
||||
characterAnimationPanel && characterAnimationSourceLayer
|
||||
? {
|
||||
left: clamp(
|
||||
viewport.x +
|
||||
(characterAnimationSourceLayer.x +
|
||||
characterAnimationSourceLayer.width) *
|
||||
viewport.scale +
|
||||
12,
|
||||
12,
|
||||
Math.max(12, canvasSize.width - 364),
|
||||
),
|
||||
top: clamp(
|
||||
viewport.y + characterAnimationSourceLayer.y * viewport.scale,
|
||||
12,
|
||||
Math.max(12, canvasSize.height - 520),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const iconComposerStyle = resolveIconComposerStyle({
|
||||
dialog: activeCanvasGenerationDialog,
|
||||
composerStyle: generationComposerStyle,
|
||||
iconDescriptionCount: iconDescriptionValues.length,
|
||||
});
|
||||
const quickEditPanelStyle = resolveQuickEditPanelStyle({
|
||||
panel: quickEditPanel,
|
||||
sourceLayer: quickEditSourceLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
});
|
||||
const characterAnimationPanelStyle = resolveCharacterAnimationPanelStyle({
|
||||
panel: characterAnimationPanel,
|
||||
sourceLayer: characterAnimationSourceLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
});
|
||||
const {
|
||||
canvasClipboard,
|
||||
pasteCanvasClipboard,
|
||||
|
||||
188
src/components/image-editor/ImageCanvasOverlayModel.test.ts
Normal file
188
src/components/image-editor/ImageCanvasOverlayModel.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CharacterAnimationPanelState,
|
||||
QuickEditPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
isCanvasGenerationComposerVisible,
|
||||
resolveCharacterAnimationPanelStyle,
|
||||
resolveGenerationAnchor,
|
||||
resolveGenerationComposerStyle,
|
||||
resolveIconComposerStyle,
|
||||
resolveQuickEditPanelStyle,
|
||||
resolveSelectedToolbarStyle,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
id: 'layer-a',
|
||||
resourceId: 'resource-a',
|
||||
title: '图层A',
|
||||
src: 'data:image/png;base64,layer',
|
||||
x: 100,
|
||||
y: 80,
|
||||
width: 240,
|
||||
height: 120,
|
||||
originalWidth: 240,
|
||||
originalHeight: 120,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createDialog(
|
||||
overrides: Partial<CanvasGenerationDialogState> = {},
|
||||
): CanvasGenerationDialogState {
|
||||
return {
|
||||
id: 'dialog-a',
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 300,
|
||||
y: 200,
|
||||
width: 360,
|
||||
height: 260,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImageCanvasOverlayModel', () => {
|
||||
it('anchors generation composer to generated layers before placeholders', () => {
|
||||
const generatedLayer = createLayer({ x: 40, y: 60, width: 320, height: 180 });
|
||||
const dialog = createDialog({ generatedLayerId: generatedLayer.id });
|
||||
const anchor = resolveGenerationAnchor({ dialog, generatedLayer });
|
||||
|
||||
expect(anchor).toBe(generatedLayer);
|
||||
expect(
|
||||
resolveGenerationComposerStyle({
|
||||
dialog,
|
||||
anchor,
|
||||
viewport: { x: 10, y: 20, scale: 2 },
|
||||
}),
|
||||
).toEqual({
|
||||
left: 410,
|
||||
top: 510,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides generation composer while generating or explicitly closed', () => {
|
||||
const placeholder = createDialog().placeholder ?? null;
|
||||
|
||||
expect(
|
||||
resolveGenerationComposerStyle({
|
||||
dialog: createDialog({ status: 'generating' }),
|
||||
anchor: placeholder,
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveGenerationComposerStyle({
|
||||
dialog: createDialog({ composerOpen: false }),
|
||||
anchor: placeholder,
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('expands icon composer width by description count', () => {
|
||||
expect(
|
||||
resolveIconComposerStyle({
|
||||
dialog: createDialog({ mode: 'icon' }),
|
||||
composerStyle: { left: 100, top: 200 },
|
||||
iconDescriptionCount: 6,
|
||||
}),
|
||||
).toEqual({
|
||||
left: 100,
|
||||
top: 200,
|
||||
width: '52.8rem',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveIconComposerStyle({
|
||||
dialog: createDialog({ mode: 'generate' }),
|
||||
composerStyle: { left: 100, top: 200 },
|
||||
iconDescriptionCount: 6,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps selected layer toolbar inside the canvas width', () => {
|
||||
expect(
|
||||
resolveSelectedToolbarStyle({
|
||||
selectedLayer: createLayer({ x: -500, y: -100 }),
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
}),
|
||||
).toEqual({ left: 132, top: 10 });
|
||||
|
||||
expect(
|
||||
resolveSelectedToolbarStyle({
|
||||
selectedLayer: createLayer({ x: 1200, y: 100 }),
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
}),
|
||||
).toEqual({ left: 768, top: 88 });
|
||||
});
|
||||
|
||||
it('positions quick edit and character animation panels with viewport bounds', () => {
|
||||
const sourceLayer = createLayer({ x: 740, y: 460, width: 240, height: 180 });
|
||||
const quickEditPanel: QuickEditPanelState = {
|
||||
sourceLayerId: sourceLayer.id,
|
||||
prompt: '',
|
||||
size: '1024x1024',
|
||||
model: 'gpt-image-2',
|
||||
status: 'idle',
|
||||
};
|
||||
const characterPanel: CharacterAnimationPanelState = {
|
||||
sourceLayerId: sourceLayer.id,
|
||||
promptText: '',
|
||||
resolution: '480p',
|
||||
ratio: 'same',
|
||||
frameCount: 32,
|
||||
durationSeconds: 4,
|
||||
status: 'idle',
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveQuickEditPanelStyle({
|
||||
panel: quickEditPanel,
|
||||
sourceLayer,
|
||||
viewport: { x: 20, y: 10, scale: 1 },
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
}),
|
||||
).toEqual({ left: 880, top: 280 });
|
||||
expect(
|
||||
resolveCharacterAnimationPanelStyle({
|
||||
panel: characterPanel,
|
||||
sourceLayer,
|
||||
viewport: { x: 20, y: 10, scale: 1 },
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
}),
|
||||
).toEqual({ left: 536, top: 120 });
|
||||
});
|
||||
|
||||
it('recognizes canvas generation composer dialog modes', () => {
|
||||
expect(isCanvasGenerationComposerVisible(createDialog({ mode: 'generate' }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isCanvasGenerationComposerVisible(createDialog({ mode: 'spec' }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isCanvasGenerationComposerVisible({
|
||||
mode: 'edit',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isCanvasGenerationComposerVisible(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
188
src/components/image-editor/ImageCanvasOverlayModel.ts
Normal file
188
src/components/image-editor/ImageCanvasOverlayModel.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
TOOLBAR_HALF_WIDTH,
|
||||
clamp,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import {
|
||||
ICON_COMPOSER_HORIZONTAL_CHROME_REM,
|
||||
ICON_COMPOSER_MIN_WIDTH_REM,
|
||||
ICON_DESCRIPTION_CARD_WIDTH_REM,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
CanvasGenerationDialogMode,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
CharacterAnimationPanelState,
|
||||
GenerateDialogState,
|
||||
QuickEditPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type CanvasSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type OverlayAnchor = Pick<CanvasLayer, 'x' | 'y' | 'width' | 'height'>;
|
||||
|
||||
export type CanvasOverlayStyle = {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
export type CanvasComposerOverlayStyle = CanvasOverlayStyle & {
|
||||
width?: string;
|
||||
};
|
||||
|
||||
export function resolveGenerationAnchor({
|
||||
dialog,
|
||||
generatedLayer,
|
||||
}: {
|
||||
dialog: CanvasGenerationDialogState | null;
|
||||
generatedLayer: CanvasLayer | null;
|
||||
}) {
|
||||
if (!dialog) {
|
||||
return null;
|
||||
}
|
||||
return generatedLayer ?? dialog.placeholder ?? null;
|
||||
}
|
||||
|
||||
export function resolveGenerationComposerStyle({
|
||||
dialog,
|
||||
anchor,
|
||||
viewport,
|
||||
}: {
|
||||
dialog: CanvasGenerationDialogState | null;
|
||||
anchor: OverlayAnchor | null;
|
||||
viewport: CanvasViewport;
|
||||
}): CanvasOverlayStyle | null {
|
||||
if (
|
||||
!dialog ||
|
||||
dialog.status === 'generating' ||
|
||||
dialog.composerOpen === false ||
|
||||
!anchor
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left: viewport.x + (anchor.x + anchor.width / 2) * viewport.scale,
|
||||
top: viewport.y + (anchor.y + anchor.height) * viewport.scale + 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveIconComposerStyle({
|
||||
dialog,
|
||||
composerStyle,
|
||||
iconDescriptionCount,
|
||||
}: {
|
||||
dialog: CanvasGenerationDialogState | null;
|
||||
composerStyle: CanvasOverlayStyle | null;
|
||||
iconDescriptionCount: number;
|
||||
}): CanvasComposerOverlayStyle | null {
|
||||
if (dialog?.mode !== 'icon' || !composerStyle) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...composerStyle,
|
||||
width: `${Math.max(
|
||||
ICON_COMPOSER_MIN_WIDTH_REM,
|
||||
ICON_COMPOSER_HORIZONTAL_CHROME_REM +
|
||||
iconDescriptionCount * ICON_DESCRIPTION_CARD_WIDTH_REM,
|
||||
).toFixed(1)}rem`,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSelectedToolbarStyle({
|
||||
selectedLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
}: {
|
||||
selectedLayer: CanvasLayer | null;
|
||||
viewport: CanvasViewport;
|
||||
canvasSize: CanvasSize;
|
||||
}): CanvasOverlayStyle | null {
|
||||
if (!selectedLayer) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left: clamp(
|
||||
viewport.x +
|
||||
selectedLayer.x * viewport.scale +
|
||||
(selectedLayer.width * viewport.scale) / 2,
|
||||
TOOLBAR_HALF_WIDTH,
|
||||
Math.max(TOOLBAR_HALF_WIDTH, canvasSize.width - TOOLBAR_HALF_WIDTH),
|
||||
),
|
||||
top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveQuickEditPanelStyle({
|
||||
panel,
|
||||
sourceLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
}: {
|
||||
panel: QuickEditPanelState | null;
|
||||
sourceLayer: CanvasLayer | null;
|
||||
viewport: CanvasViewport;
|
||||
canvasSize: CanvasSize;
|
||||
}): CanvasOverlayStyle | null {
|
||||
if (!panel || !sourceLayer) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left: clamp(
|
||||
viewport.x +
|
||||
(sourceLayer.x + sourceLayer.width / 2) * viewport.scale,
|
||||
12,
|
||||
Math.max(12, canvasSize.width - 12),
|
||||
),
|
||||
top: clamp(
|
||||
viewport.y +
|
||||
(sourceLayer.y + sourceLayer.height) * viewport.scale +
|
||||
12,
|
||||
12,
|
||||
Math.max(12, canvasSize.height - 360),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCharacterAnimationPanelStyle({
|
||||
panel,
|
||||
sourceLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
}: {
|
||||
panel: CharacterAnimationPanelState | null;
|
||||
sourceLayer: CanvasLayer | null;
|
||||
viewport: CanvasViewport;
|
||||
canvasSize: CanvasSize;
|
||||
}): CanvasOverlayStyle | null {
|
||||
if (!panel || !sourceLayer) {
|
||||
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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function isCanvasGenerationComposerVisible(
|
||||
dialog: GenerateDialogState | null,
|
||||
): dialog is GenerateDialogState & { mode: CanvasGenerationDialogMode } {
|
||||
return (
|
||||
dialog?.mode === 'generate' ||
|
||||
dialog?.mode === 'spec' ||
|
||||
dialog?.mode === 'character' ||
|
||||
dialog?.mode === 'icon'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user