统一节点变换实时预览
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m17s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Native shell tests (pull_request) Successful in 19m52s

预览拖动和缩放时同步解析保持子节点页面矩形的变换

将检查器的子节点保持选项传入预览交互并补充回归测试
This commit is contained in:
2026-09-03 13:38:19 +08:00
parent 79d3e5e6de
commit e4680d6f70
4 changed files with 240 additions and 6 deletions
@@ -107,6 +107,7 @@ export function PreviewWorkspace({
logicalSize,
spaceHeld,
tree,
keepChildrenUnchanged: canvas.keepChildrenUnchanged,
viewportRef,
onPreviewTransform: updatePreviewTransform,
});
@@ -9,9 +9,12 @@ import {
import {
findNodePageContext,
type PageRect,
pageRectFromSize,
type ResizeAxis,
type ResizeHandle,
resizePageRect,
resolveChildrenTransformsForParentRect,
resolvePageRect,
resolveProportionalResizeAxis,
setOffsetsForPageRect,
} from '../../../../features/ui-editor/nodeTransformGeometry';
@@ -30,6 +33,7 @@ type GestureBase = {
startTransform: UiNode['layout']['transform'];
hasMoved: boolean;
pendingTransform?: UiNode['layout']['transform'];
previewNodeIds: string[];
};
type ActiveGesture =
@@ -79,12 +83,69 @@ function releasePointer(target: HTMLDivElement, pointerId: number) {
}
}
function previewNodeIds(
tree: UITree | null,
nodeId: string,
logicalSize: { width: number; height: number } | null,
keepChildrenUnchanged: boolean,
) {
if (!keepChildrenUnchanged || !tree || !logicalSize) return [nodeId];
const context = findNodePageContext(
tree.root,
nodeId,
pageRectFromSize([logicalSize.width, logicalSize.height]),
);
return context
? [nodeId, ...context.node.children.map((child) => child.id)]
: [nodeId];
}
function emitPreviewTransforms(
gesture: ActiveGesture,
tree: UITree | null,
logicalSize: { width: number; height: number } | null,
keepChildrenUnchanged: boolean,
onPreviewTransform: (
nodeId: string,
transform: UiNode['layout']['transform'] | null,
) => void,
) {
onPreviewTransform(gesture.nodeId, gesture.pendingTransform ?? null);
if (
!gesture.pendingTransform ||
!keepChildrenUnchanged ||
!tree ||
!logicalSize
) {
return;
}
const context = findNodePageContext(
tree.root,
gesture.nodeId,
pageRectFromSize([logicalSize.width, logicalSize.height]),
);
if (!context) return;
const newNodeRect = resolvePageRect(
gesture.pendingTransform,
context.parentRect,
);
if (!isFiniteRect(newNodeRect)) return;
for (const child of resolveChildrenTransformsForParentRect(
context.node.children,
context.rect,
newNodeRect,
)) {
onPreviewTransform(child.id, child.transform);
}
}
export function useNodeTransformInteraction({
activeImageId,
canvas,
logicalSize,
spaceHeld,
tree,
keepChildrenUnchanged,
viewportRef,
onPreviewTransform,
}: {
@@ -93,6 +154,7 @@ export function useNodeTransformInteraction({
logicalSize: { width: number; height: number } | null;
spaceHeld: boolean;
tree: UITree | null;
keepChildrenUnchanged: boolean;
viewportRef: RefObject<ViewportScale>;
onPreviewTransform?: (
nodeId: string,
@@ -109,6 +171,11 @@ export function useNodeTransformInteraction({
if (gesture) {
releasePointer(gesture.target, gesture.pointerId);
onPreviewTransformRef.current?.(gesture.nodeId, null);
for (const nodeId of gesture.previewNodeIds) {
if (nodeId !== gesture.nodeId) {
onPreviewTransformRef.current?.(nodeId, null);
}
}
}
activeGestureRef.current = null;
}, []);
@@ -163,9 +230,22 @@ export function useNodeTransformInteraction({
startClientY: event.clientY,
startTransform: structuredClone(node.layout.transform),
hasMoved: false,
previewNodeIds: previewNodeIds(
tree,
node.id,
logicalSize,
keepChildrenUnchanged,
),
};
},
[activeImageId, canvas, spaceHeld, tree?.root.id],
[
activeImageId,
canvas,
keepChildrenUnchanged,
logicalSize,
spaceHeld,
tree,
],
);
const onNodePointerMove = useCallback(
@@ -198,9 +278,23 @@ export function useNodeTransformInteraction({
return;
}
gesture.pendingTransform = nextTransform;
onPreviewTransform?.(gesture.nodeId, nextTransform);
emitPreviewTransforms(
gesture,
tree,
logicalSize,
keepChildrenUnchanged,
(nodeId, transform) =>
onPreviewTransformRef.current?.(nodeId, transform),
);
},
[acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef],
[
acceptsGestureEvent,
cancelGesture,
keepChildrenUnchanged,
logicalSize,
tree,
viewportRef,
],
);
const onNodePointerUp = useCallback(
@@ -276,9 +370,22 @@ export function useNodeTransformInteraction({
parentRect: context.parentRect,
ratioAxis: null,
hasMoved: false,
previewNodeIds: previewNodeIds(
tree,
node.id,
logicalSize,
keepChildrenUnchanged,
),
};
},
[activeImageId, canvas, logicalSize, spaceHeld, tree],
[
activeImageId,
canvas,
keepChildrenUnchanged,
logicalSize,
spaceHeld,
tree,
],
);
const onNodeResizePointerMove = useCallback(
@@ -336,9 +443,23 @@ export function useNodeTransformInteraction({
}
gesture.hasMoved = true;
gesture.pendingTransform = nextTransform;
onPreviewTransform?.(gesture.nodeId, nextTransform);
emitPreviewTransforms(
gesture,
tree,
logicalSize,
keepChildrenUnchanged,
(nodeId, transform) =>
onPreviewTransformRef.current?.(nodeId, transform),
);
},
[acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef],
[
acceptsGestureEvent,
cancelGesture,
keepChildrenUnchanged,
logicalSize,
tree,
viewportRef,
],
);
return {
@@ -1214,6 +1214,7 @@ export function useUiEditorSession(
tree: treeForActiveImage ?? null,
selectedNode: selectedNodeContext?.node ?? null,
selectedNodeId,
keepChildrenUnchanged,
hiddenNodeIds,
focusRequest,
status,
@@ -44,6 +44,17 @@ const pageTree: UITree = {
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, {
@@ -83,10 +94,17 @@ function renderInteraction({
activeImageId = 'page',
scale = 1,
tree = pageTree,
keepChildrenUnchanged = false,
onPreviewTransform,
}: {
activeImageId?: string | null;
scale?: number;
tree?: UITree | null;
keepChildrenUnchanged?: boolean;
onPreviewTransform?: (
nodeId: string,
transform: UiNode['layout']['transform'] | null,
) => void;
} = {}) {
const canvasProjection = canvas();
const viewportRef = { current: { scale } };
@@ -99,6 +117,8 @@ function renderInteraction({
spaceHeld: false,
tree: currentTree,
viewportRef,
keepChildrenUnchanged,
onPreviewTransform,
}),
{ initialProps: { imageId: activeImageId, currentTree: tree } },
);
@@ -106,6 +126,97 @@ function renderInteraction({
}
describe('useNodeTransformInteraction', () => {
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();