f9e1e5d2a7
close #266 当前实现: 按下就重新选择 实际上应该: 松开而且没有发生拖动才重新选择 before:  after:  Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/270 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
428 lines
12 KiB
TypeScript
428 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act, renderHook } from '@testing-library/react';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
|
|
import type { UITree } from '../src/features/ui-editor/types/UITree';
|
|
import { useNodeTransformInteraction } from '../src/view/ui-editor/components/preview/useNodeTransformInteraction';
|
|
import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage';
|
|
|
|
function node(id: string, children: UiNode[] = []): UiNode {
|
|
return {
|
|
id,
|
|
layout: {
|
|
transform: {
|
|
anchor_min: [0, 0],
|
|
anchor_max: [1, 1],
|
|
offset_min: [0, 0],
|
|
offset_max: [0, 0],
|
|
},
|
|
custom_minimum_size: [0, 0],
|
|
size_flags_horizontal: 1,
|
|
size_flags_vertical: 1,
|
|
size_flags_stretch_ratio: 1,
|
|
container: 'None',
|
|
},
|
|
metadata: {
|
|
name: id,
|
|
description: '',
|
|
layout_status: 'NoProblem',
|
|
components_status: 'NoProblem',
|
|
allow_llm_edit_layout: true,
|
|
allow_llm_edit_component: true,
|
|
source: 'System',
|
|
},
|
|
components: [],
|
|
children,
|
|
};
|
|
}
|
|
|
|
const child = node('child');
|
|
const pageTree: UITree = {
|
|
src_ui_design: 'page',
|
|
root: node('root', [child]),
|
|
};
|
|
|
|
function transformedNode(
|
|
id: string,
|
|
transform: UiNode['layout']['transform'],
|
|
children: UiNode[] = [],
|
|
) {
|
|
return {
|
|
...node(id, children),
|
|
layout: { ...node(id).layout, transform },
|
|
};
|
|
}
|
|
|
|
function gestureTarget() {
|
|
const target = document.createElement('div');
|
|
Object.assign(target, {
|
|
setPointerCapture: vi.fn(),
|
|
hasPointerCapture: vi.fn(() => true),
|
|
releasePointerCapture: vi.fn(),
|
|
});
|
|
return target;
|
|
}
|
|
|
|
function pointerEvent(
|
|
target: HTMLDivElement,
|
|
pointerId: number,
|
|
clientX: number,
|
|
clientY: number,
|
|
eventTarget: EventTarget = target,
|
|
) {
|
|
return {
|
|
button: 0,
|
|
clientX,
|
|
clientY,
|
|
currentTarget: target,
|
|
target: eventTarget,
|
|
pointerId,
|
|
preventDefault: vi.fn(),
|
|
shiftKey: false,
|
|
stopPropagation: vi.fn(),
|
|
} as never;
|
|
}
|
|
|
|
function canvas() {
|
|
return {
|
|
selectNode: vi.fn(),
|
|
updateNodeTransform: vi.fn(),
|
|
} as Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
|
|
}
|
|
|
|
function renderInteraction({
|
|
activeImageId = 'page',
|
|
scale = 1,
|
|
tree = pageTree,
|
|
keepChildrenUnchanged = false,
|
|
onPreviewTransform,
|
|
selectedNodeId = null,
|
|
}: {
|
|
activeImageId?: string | null;
|
|
scale?: number;
|
|
tree?: UITree | null;
|
|
keepChildrenUnchanged?: boolean;
|
|
onPreviewTransform?: (
|
|
nodeId: string,
|
|
transform: UiNode['layout']['transform'] | null,
|
|
) => void;
|
|
selectedNodeId?: string | null;
|
|
} = {}) {
|
|
const canvasProjection = canvas();
|
|
const viewportRef = { current: { scale } };
|
|
const hook = renderHook(
|
|
({ imageId, currentTree }) =>
|
|
useNodeTransformInteraction({
|
|
activeImageId: imageId,
|
|
canvas: canvasProjection,
|
|
logicalSize: { width: 320, height: 180 },
|
|
spaceHeld: false,
|
|
tree: currentTree,
|
|
viewportRef,
|
|
keepChildrenUnchanged,
|
|
onPreviewTransform,
|
|
selectedNodeId,
|
|
}),
|
|
{ initialProps: { imageId: activeImageId, currentTree: tree } },
|
|
);
|
|
return { ...hook, canvasProjection, viewportRef };
|
|
}
|
|
|
|
describe('useNodeTransformInteraction', () => {
|
|
it('moves the current selection when dragging another node without selecting the hit node', () => {
|
|
const selected = transformedNode('selected', {
|
|
anchor_min: [0, 0],
|
|
anchor_max: [0, 0],
|
|
offset_min: [20, 20],
|
|
offset_max: [80, 70],
|
|
});
|
|
const tree: UITree = {
|
|
src_ui_design: 'page',
|
|
root: node('root', [selected, child]),
|
|
};
|
|
const { result, canvasProjection } = renderInteraction({
|
|
tree,
|
|
selectedNodeId: 'selected',
|
|
});
|
|
const target = gestureTarget();
|
|
|
|
act(() => {
|
|
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child);
|
|
result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0));
|
|
result.current.onNodePointerUp(pointerEvent(target, 1, 4, 0));
|
|
});
|
|
|
|
expect(canvasProjection.selectNode).not.toHaveBeenCalled();
|
|
expect(canvasProjection.updateNodeTransform).toHaveBeenCalledWith(
|
|
'page',
|
|
'selected',
|
|
expect.objectContaining({
|
|
offset_min: [24, 20],
|
|
offset_max: [84, 70],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('prevents native text selection when starting a node drag', () => {
|
|
const { result } = renderInteraction();
|
|
const target = gestureTarget();
|
|
const event = pointerEvent(target, 1, 0, 0);
|
|
|
|
act(() => {
|
|
result.current.onNodePointerDown(event, child);
|
|
});
|
|
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('cleans preview transforms for the selected parent when dragging from a child', () => {
|
|
const nestedChild = transformedNode('child', {
|
|
anchor_min: [0, 0],
|
|
anchor_max: [0, 0],
|
|
offset_min: [10, 10],
|
|
offset_max: [60, 50],
|
|
});
|
|
const parent = transformedNode(
|
|
'parent',
|
|
{
|
|
anchor_min: [0, 0],
|
|
anchor_max: [0, 0],
|
|
offset_min: [20, 20],
|
|
offset_max: [120, 100],
|
|
},
|
|
[nestedChild],
|
|
);
|
|
const tree: UITree = {
|
|
src_ui_design: 'page',
|
|
root: node('root', [parent]),
|
|
};
|
|
const onPreviewTransform = vi.fn();
|
|
const { result } = renderInteraction({
|
|
tree,
|
|
keepChildrenUnchanged: true,
|
|
onPreviewTransform,
|
|
selectedNodeId: 'parent',
|
|
});
|
|
const target = gestureTarget();
|
|
|
|
act(() => {
|
|
result.current.onNodePointerDown(
|
|
pointerEvent(target, 1, 0, 0),
|
|
nestedChild,
|
|
);
|
|
result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5));
|
|
result.current.onNodePointerCancel(pointerEvent(target, 1, 10, 5));
|
|
});
|
|
|
|
expect(onPreviewTransform).toHaveBeenCalledWith('parent', null);
|
|
expect(onPreviewTransform).toHaveBeenCalledWith('child', null);
|
|
});
|
|
|
|
it('previews stable child page rectangles when the parent moves', () => {
|
|
const nestedChild = transformedNode('child', {
|
|
anchor_min: [0, 0],
|
|
anchor_max: [0, 0],
|
|
offset_min: [10, 10],
|
|
offset_max: [60, 50],
|
|
});
|
|
const parent = transformedNode(
|
|
'parent',
|
|
{
|
|
anchor_min: [0, 0],
|
|
anchor_max: [0, 0],
|
|
offset_min: [20, 20],
|
|
offset_max: [120, 100],
|
|
},
|
|
[nestedChild],
|
|
);
|
|
const tree: UITree = {
|
|
src_ui_design: 'page',
|
|
root: node('root', [parent]),
|
|
};
|
|
const onPreviewTransform = vi.fn();
|
|
const { result } = renderInteraction({
|
|
tree,
|
|
keepChildrenUnchanged: true,
|
|
onPreviewTransform,
|
|
});
|
|
const target = gestureTarget();
|
|
|
|
act(() => {
|
|
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), parent);
|
|
result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5));
|
|
});
|
|
|
|
expect(onPreviewTransform).toHaveBeenNthCalledWith(
|
|
1,
|
|
'parent',
|
|
expect.objectContaining({ offset_min: [30, 25], offset_max: [130, 105] }),
|
|
);
|
|
expect(onPreviewTransform).toHaveBeenNthCalledWith(
|
|
2,
|
|
'child',
|
|
expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }),
|
|
);
|
|
});
|
|
|
|
it('previews stable child page rectangles when the parent resizes', () => {
|
|
const nestedChild = transformedNode('child', {
|
|
anchor_min: [0, 0],
|
|
anchor_max: [0, 0],
|
|
offset_min: [10, 10],
|
|
offset_max: [60, 50],
|
|
});
|
|
const parent = transformedNode(
|
|
'parent',
|
|
{
|
|
anchor_min: [0, 0],
|
|
anchor_max: [0, 0],
|
|
offset_min: [20, 20],
|
|
offset_max: [120, 100],
|
|
},
|
|
[nestedChild],
|
|
);
|
|
const tree: UITree = {
|
|
src_ui_design: 'page',
|
|
root: node('root', [parent]),
|
|
};
|
|
const onPreviewTransform = vi.fn();
|
|
const { result } = renderInteraction({
|
|
tree,
|
|
keepChildrenUnchanged: true,
|
|
onPreviewTransform,
|
|
});
|
|
const target = gestureTarget();
|
|
|
|
act(() => {
|
|
result.current.onNodeResizePointerDown(
|
|
pointerEvent(target, 1, 0, 0),
|
|
parent,
|
|
'nw',
|
|
);
|
|
result.current.onNodeResizePointerMove(pointerEvent(target, 1, 10, 5));
|
|
});
|
|
|
|
expect(onPreviewTransform).toHaveBeenNthCalledWith(
|
|
2,
|
|
'child',
|
|
expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }),
|
|
);
|
|
});
|
|
|
|
it('makes drag and resize mutually exclusive, then permits the next gesture', () => {
|
|
const { result, canvasProjection } = renderInteraction();
|
|
const dragTarget = gestureTarget();
|
|
const resizeTarget = gestureTarget();
|
|
|
|
act(() => {
|
|
result.current.onNodePointerDown(
|
|
pointerEvent(dragTarget, 1, 0, 0),
|
|
child,
|
|
);
|
|
result.current.onNodeResizePointerDown(
|
|
pointerEvent(resizeTarget, 2, 0, 0),
|
|
child,
|
|
'se',
|
|
);
|
|
result.current.onNodePointerMove(pointerEvent(dragTarget, 1, 12, 8));
|
|
});
|
|
|
|
expect(resizeTarget.setPointerCapture).not.toHaveBeenCalled();
|
|
expect(canvasProjection.updateNodeTransform).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
result.current.onNodePointerUp(pointerEvent(dragTarget, 1, 12, 8));
|
|
result.current.onNodeResizePointerDown(
|
|
pointerEvent(resizeTarget, 2, 0, 0),
|
|
child,
|
|
'se',
|
|
);
|
|
});
|
|
expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1);
|
|
expect(resizeTarget.setPointerCapture).toHaveBeenCalledWith(2);
|
|
});
|
|
|
|
it('defers selection until a clean pointerup and suppresses follow-up click', () => {
|
|
const { result, canvasProjection } = renderInteraction();
|
|
const target = gestureTarget();
|
|
act(() => {
|
|
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child);
|
|
});
|
|
expect(canvasProjection.selectNode).not.toHaveBeenCalled();
|
|
act(() => {
|
|
result.current.onNodePointerUp(pointerEvent(target, 1, 1, 1));
|
|
});
|
|
expect(canvasProjection.selectNode).toHaveBeenCalledWith('child');
|
|
expect(result.current.consumeNodeClick()).toBe(true);
|
|
expect(result.current.consumeNodeClick()).toBe(false);
|
|
});
|
|
|
|
it('does not select after a moved gesture or pointer cancellation', () => {
|
|
const { result, canvasProjection } = renderInteraction();
|
|
const target = gestureTarget();
|
|
act(() => {
|
|
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child);
|
|
result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0));
|
|
result.current.onNodePointerUp(pointerEvent(target, 1, 4, 0));
|
|
});
|
|
expect(canvasProjection.selectNode).not.toHaveBeenCalled();
|
|
expect(result.current.consumeNodeClick()).toBe(true);
|
|
|
|
act(() => {
|
|
result.current.onNodePointerDown(pointerEvent(target, 2, 0, 0), child);
|
|
result.current.onNodePointerCancel(pointerEvent(target, 2, 0, 0));
|
|
});
|
|
expect(canvasProjection.selectNode).not.toHaveBeenCalled();
|
|
expect(result.current.consumeNodeClick()).toBe(false);
|
|
});
|
|
|
|
it('cancels a gesture when its tree changes and ignores its later events', () => {
|
|
const { result, rerender, canvasProjection } = renderInteraction();
|
|
const target = gestureTarget();
|
|
|
|
act(() => {
|
|
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child);
|
|
});
|
|
act(() => {
|
|
rerender({
|
|
imageId: 'other',
|
|
currentTree: { ...pageTree, src_ui_design: 'other' },
|
|
});
|
|
});
|
|
act(() => {
|
|
result.current.onNodePointerMove(pointerEvent(target, 1, 12, 8));
|
|
});
|
|
|
|
expect(target.releasePointerCapture).toHaveBeenCalledWith(1);
|
|
expect(canvasProjection.updateNodeTransform).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('cancels instead of persisting a non-finite transform', () => {
|
|
const { result, canvasProjection, viewportRef } = renderInteraction();
|
|
const target = gestureTarget();
|
|
|
|
act(() => {
|
|
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child);
|
|
viewportRef.current.scale = Number.NaN;
|
|
result.current.onNodePointerMove(pointerEvent(target, 1, 12, 8));
|
|
});
|
|
|
|
expect(target.releasePointerCapture).toHaveBeenCalledWith(1);
|
|
expect(canvasProjection.updateNodeTransform).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('releases a captured pointer when the preview unmounts', () => {
|
|
const { result, unmount } = renderInteraction();
|
|
const target = gestureTarget();
|
|
|
|
act(() => {
|
|
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child);
|
|
unmount();
|
|
});
|
|
|
|
expect(target.releasePointerCapture).toHaveBeenCalledWith(1);
|
|
});
|
|
});
|