AGC 官方 LLM Router 账号链路与流式联网输出 #242
@@ -110,6 +110,8 @@ export function PreviewWorkspace({
|
||||
keepChildrenUnchanged: canvas.keepChildrenUnchanged,
|
||||
viewportRef,
|
||||
onPreviewTransform: updatePreviewTransform,
|
||||
previewRef: viewportElementRef,
|
||||
selectedNodeId: canvas.selectedNodeId,
|
||||
});
|
||||
const {
|
||||
onNodePointerDown,
|
||||
@@ -118,6 +120,9 @@ export function PreviewWorkspace({
|
||||
onNodeResizePointerDown,
|
||||
onNodeResizePointerMove,
|
||||
onNodeResizePointerUp,
|
||||
onNodePointerCancel,
|
||||
onNodeResizePointerCancel,
|
||||
consumeNodeClick,
|
||||
} = nodeInteractions;
|
||||
|
||||
const setViewport = useCallback((next: CanvasViewport) => {
|
||||
@@ -412,13 +417,16 @@ export function PreviewWorkspace({
|
||||
fontFaces: canvas.fontFaces,
|
||||
}}
|
||||
onSelectNode={canvas.selectNode}
|
||||
consumeNodeClick={consumeNodeClick}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onNodePointerDown={onNodePointerDown}
|
||||
onNodePointerMove={onNodePointerMove}
|
||||
onNodePointerUp={onNodePointerUp}
|
||||
onNodePointerCancel={onNodePointerCancel}
|
||||
onNodeResizePointerDown={onNodeResizePointerDown}
|
||||
onNodeResizePointerMove={onNodeResizePointerMove}
|
||||
onNodeResizePointerUp={onNodeResizePointerUp}
|
||||
onNodeResizePointerCancel={onNodeResizePointerCancel}
|
||||
onSelectExclusiveChild={canvas.selectExclusiveChild}
|
||||
viewportScale={viewport.scale}
|
||||
/>
|
||||
|
||||
+31
-10
@@ -36,6 +36,7 @@ type UiTreeRendererProps = {
|
||||
selectedNodeId: NodeId | null;
|
||||
resources: PreviewComponentResources;
|
||||
onSelectNode: (id: NodeId) => void;
|
||||
consumeNodeClick?: () => boolean;
|
||||
onNodeContextMenu: (
|
||||
event: ReactMouseEvent<HTMLDivElement>,
|
||||
node: UiNode,
|
||||
@@ -44,6 +45,7 @@ type UiTreeRendererProps = {
|
||||
onNodePointerDown: NodePointerDown;
|
||||
onNodePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onNodePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onNodePointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onNodeResizePointerDown: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
node: UiNode,
|
||||
@@ -51,6 +53,7 @@ type UiTreeRendererProps = {
|
||||
) => void;
|
||||
onNodeResizePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onNodeResizePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onNodeResizePointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onSelectExclusiveChild: (nodeId: NodeId) => void;
|
||||
viewportScale: number;
|
||||
};
|
||||
@@ -87,13 +90,16 @@ function RenderNode({
|
||||
selectedNodeId,
|
||||
resources,
|
||||
onSelectNode,
|
||||
consumeNodeClick = () => false,
|
||||
onNodeContextMenu,
|
||||
onNodePointerDown,
|
||||
onNodePointerMove,
|
||||
onNodePointerUp,
|
||||
onNodePointerCancel,
|
||||
onNodeResizePointerDown,
|
||||
onNodeResizePointerMove,
|
||||
onNodeResizePointerUp,
|
||||
onNodeResizePointerCancel,
|
||||
onSelectExclusiveChild,
|
||||
viewportScale,
|
||||
}: Omit<UiTreeRendererProps, 'tree'> & {
|
||||
@@ -118,6 +124,8 @@ function RenderNode({
|
||||
|
||||
const isEditorOverlay = renderMode === 'editor-overlay';
|
||||
const isFrameVisible = isEditorOverlay || showFrame;
|
||||
const receivesPointerGesture = parentContainer === undefined;
|
||||
const hasDirectPointerGesture = receivesPointerGesture && !isRoot;
|
||||
const exclusiveVisibleChildId =
|
||||
node.children_display_mode === 'Exclusive'
|
||||
? resolveExclusiveVisibleChildId(node.children, hiddenNodeIds)
|
||||
@@ -125,7 +133,7 @@ function RenderNode({
|
||||
return (
|
||||
<div
|
||||
data-node-id={node.id}
|
||||
className={`absolute min-h-0 min-w-0 ${isRoot ? 'cursor-default' : 'cursor-move'}`}
|
||||
className={`absolute min-h-0 min-w-0 select-none ${isRoot ? 'cursor-default' : 'cursor-move'}`}
|
||||
style={{
|
||||
...geometry,
|
||||
...(parentContainer
|
||||
@@ -146,10 +154,18 @@ function RenderNode({
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectNode(node.id);
|
||||
}}
|
||||
onClick={
|
||||
hasDirectPointerGesture
|
||||
? (event) => {
|
||||
event.stopPropagation();
|
||||
consumeNodeClick();
|
||||
}
|
||||
: (event) => {
|
||||
event.stopPropagation();
|
||||
if (consumeNodeClick()) return;
|
||||
onSelectNode(node.id);
|
||||
}
|
||||
}
|
||||
onContextMenu={(event) => {
|
||||
if (!isEditorOverlay) return;
|
||||
event.preventDefault();
|
||||
@@ -158,11 +174,13 @@ function RenderNode({
|
||||
onNodeContextMenu(event, node, Boolean(isRoot));
|
||||
}}
|
||||
onPointerDown={
|
||||
parentContainer ? undefined : (event) => onNodePointerDown(event, node)
|
||||
receivesPointerGesture
|
||||
? (event) => onNodePointerDown(event, node)
|
||||
: undefined
|
||||
}
|
||||
onPointerMove={parentContainer ? undefined : onNodePointerMove}
|
||||
onPointerUp={parentContainer ? undefined : onNodePointerUp}
|
||||
onPointerCancel={parentContainer ? undefined : onNodePointerUp}
|
||||
onPointerMove={receivesPointerGesture ? onNodePointerMove : undefined}
|
||||
onPointerUp={receivesPointerGesture ? onNodePointerUp : undefined}
|
||||
onPointerCancel={receivesPointerGesture ? onNodePointerCancel : undefined}
|
||||
title={isFrameVisible ? node.metadata.name || undefined : undefined}
|
||||
>
|
||||
{isFrameVisible && node.metadata.name ? (
|
||||
@@ -202,13 +220,16 @@ function RenderNode({
|
||||
selectedNodeId={selectedNodeId}
|
||||
resources={resources}
|
||||
onSelectNode={onSelectNode}
|
||||
consumeNodeClick={consumeNodeClick}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
onNodePointerDown={onNodePointerDown}
|
||||
onNodePointerMove={onNodePointerMove}
|
||||
onNodePointerUp={onNodePointerUp}
|
||||
onNodePointerCancel={onNodePointerCancel}
|
||||
onNodeResizePointerDown={onNodeResizePointerDown}
|
||||
onNodeResizePointerMove={onNodeResizePointerMove}
|
||||
onNodeResizePointerUp={onNodeResizePointerUp}
|
||||
onNodeResizePointerCancel={onNodeResizePointerCancel}
|
||||
onSelectExclusiveChild={onSelectExclusiveChild}
|
||||
viewportScale={viewportScale}
|
||||
/>
|
||||
@@ -238,7 +259,7 @@ function RenderNode({
|
||||
}
|
||||
onPointerMove={onNodeResizePointerMove}
|
||||
onPointerUp={onNodeResizePointerUp}
|
||||
onPointerCancel={onNodeResizePointerUp}
|
||||
onPointerCancel={onNodeResizePointerCancel}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
|
||||
+88
-20
@@ -31,6 +31,7 @@ type GestureBase = {
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startTransform: UiNode['layout']['transform'];
|
||||
hitNodeId: string;
|
||||
hasMoved: boolean;
|
||||
pendingTransform?: UiNode['layout']['transform'];
|
||||
previewNodeIds: string[];
|
||||
@@ -83,6 +84,41 @@ function releasePointer(target: HTMLDivElement, pointerId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function findNodeById(node: UiNode, nodeId: string): UiNode | null {
|
||||
if (node.id === nodeId) return node;
|
||||
for (const child of node.children) {
|
||||
const match = findNodeById(child, nodeId);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveHitNodeId(
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
fallback: string,
|
||||
) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return fallback;
|
||||
return (
|
||||
target.closest<HTMLElement>('[data-node-id]')?.dataset.nodeId ?? fallback
|
||||
);
|
||||
}
|
||||
|
||||
function isInsidePreview(
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
previewRef: RefObject<HTMLElement | null> | undefined,
|
||||
) {
|
||||
const preview = previewRef?.current;
|
||||
if (!preview) return true;
|
||||
const rect = preview.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
function previewNodeIds(
|
||||
tree: UITree | null,
|
||||
nodeId: string,
|
||||
@@ -148,6 +184,8 @@ export function useNodeTransformInteraction({
|
||||
keepChildrenUnchanged,
|
||||
viewportRef,
|
||||
onPreviewTransform,
|
||||
previewRef,
|
||||
selectedNodeId,
|
||||
}: {
|
||||
activeImageId: UiEditorCanvasProjection['activeImageId'];
|
||||
canvas: Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
|
||||
@@ -160,11 +198,14 @@ export function useNodeTransformInteraction({
|
||||
nodeId: string,
|
||||
transform: UiNode['layout']['transform'] | null,
|
||||
) => void;
|
||||
previewRef?: RefObject<HTMLElement | null>;
|
||||
selectedNodeId?: string | null;
|
||||
}) {
|
||||
const activeGestureRef = useRef<ActiveGesture | null>(null);
|
||||
// Keep cleanup stable while still invoking the latest preview callback.
|
||||
const onPreviewTransformRef = useRef(onPreviewTransform);
|
||||
onPreviewTransformRef.current = onPreviewTransform;
|
||||
const suppressNextNodeClickRef = useRef(false);
|
||||
|
||||
const cancelGesture = useCallback(() => {
|
||||
const gesture = activeGestureRef.current;
|
||||
@@ -178,10 +219,16 @@ export function useNodeTransformInteraction({
|
||||
}
|
||||
}
|
||||
activeGestureRef.current = null;
|
||||
suppressNextNodeClickRef.current = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => cancelGesture, [cancelGesture]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('blur', cancelGesture);
|
||||
return () => window.removeEventListener('blur', cancelGesture);
|
||||
}, [cancelGesture]);
|
||||
|
||||
useEffect(() => {
|
||||
const gesture = activeGestureRef.current;
|
||||
if (
|
||||
@@ -217,22 +264,29 @@ export function useNodeTransformInteraction({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selectedNode =
|
||||
selectedNodeId && tree ? findNodeById(tree.root, selectedNodeId) : null;
|
||||
const dragNode =
|
||||
selectedNode && selectedNode.id !== tree?.root.id ? selectedNode : node;
|
||||
if (!isFiniteTransform(dragNode.layout.transform)) return;
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
canvas.selectNode(node.id);
|
||||
suppressNextNodeClickRef.current = false;
|
||||
event.preventDefault();
|
||||
activeGestureRef.current = {
|
||||
kind: 'drag',
|
||||
treeId: activeImageId,
|
||||
nodeId: node.id,
|
||||
nodeId: dragNode.id,
|
||||
pointerId: event.pointerId,
|
||||
target: event.currentTarget,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startTransform: structuredClone(node.layout.transform),
|
||||
startTransform: structuredClone(dragNode.layout.transform),
|
||||
hitNodeId: resolveHitNodeId(event, node.id),
|
||||
hasMoved: false,
|
||||
previewNodeIds: previewNodeIds(
|
||||
tree,
|
||||
node.id,
|
||||
dragNode.id,
|
||||
logicalSize,
|
||||
keepChildrenUnchanged,
|
||||
),
|
||||
@@ -240,10 +294,10 @@ export function useNodeTransformInteraction({
|
||||
},
|
||||
[
|
||||
activeImageId,
|
||||
canvas,
|
||||
keepChildrenUnchanged,
|
||||
logicalSize,
|
||||
spaceHeld,
|
||||
selectedNodeId,
|
||||
tree,
|
||||
],
|
||||
);
|
||||
@@ -268,6 +322,7 @@ export function useNodeTransformInteraction({
|
||||
return;
|
||||
}
|
||||
gesture.hasMoved = true;
|
||||
suppressNextNodeClickRef.current = true;
|
||||
const nextTransform = structuredClone(gesture.startTransform);
|
||||
nextTransform.offset_min[0] += logicalDeltaX;
|
||||
nextTransform.offset_min[1] += logicalDeltaY;
|
||||
@@ -302,11 +357,11 @@ export function useNodeTransformInteraction({
|
||||
if (!acceptsGestureEvent(event)) return;
|
||||
event.stopPropagation();
|
||||
const gesture = activeGestureRef.current;
|
||||
if (
|
||||
event.type !== 'pointercancel' &&
|
||||
gesture?.hasMoved &&
|
||||
gesture.pendingTransform
|
||||
) {
|
||||
if (!gesture) return;
|
||||
const cleanPointerUpInside =
|
||||
!gesture?.hasMoved && isInsidePreview(event, previewRef);
|
||||
if (cleanPointerUpInside) canvas.selectNode(gesture.hitNodeId);
|
||||
if (gesture.hasMoved && gesture.pendingTransform) {
|
||||
canvas.updateNodeTransform(
|
||||
gesture.treeId,
|
||||
gesture.nodeId,
|
||||
@@ -314,8 +369,19 @@ export function useNodeTransformInteraction({
|
||||
);
|
||||
}
|
||||
cancelGesture();
|
||||
if (gesture.hasMoved || cleanPointerUpInside)
|
||||
suppressNextNodeClickRef.current = true;
|
||||
},
|
||||
[acceptsGestureEvent, cancelGesture, canvas],
|
||||
[acceptsGestureEvent, cancelGesture, canvas, previewRef],
|
||||
);
|
||||
|
||||
const onNodePointerCancel = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!acceptsGestureEvent(event)) return;
|
||||
event.stopPropagation();
|
||||
cancelGesture();
|
||||
},
|
||||
[acceptsGestureEvent, cancelGesture],
|
||||
);
|
||||
|
||||
const onNodeResizePointerDown = useCallback(
|
||||
@@ -355,7 +421,7 @@ export function useNodeTransformInteraction({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
canvas.selectNode(node.id);
|
||||
suppressNextNodeClickRef.current = false;
|
||||
activeGestureRef.current = {
|
||||
kind: 'resize',
|
||||
treeId: activeImageId,
|
||||
@@ -366,6 +432,7 @@ export function useNodeTransformInteraction({
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startTransform: structuredClone(node.layout.transform),
|
||||
hitNodeId: node.id,
|
||||
startRect: context.rect,
|
||||
parentRect: context.parentRect,
|
||||
ratioAxis: null,
|
||||
@@ -378,14 +445,7 @@ export function useNodeTransformInteraction({
|
||||
),
|
||||
};
|
||||
},
|
||||
[
|
||||
activeImageId,
|
||||
canvas,
|
||||
keepChildrenUnchanged,
|
||||
logicalSize,
|
||||
spaceHeld,
|
||||
tree,
|
||||
],
|
||||
[activeImageId, keepChildrenUnchanged, logicalSize, spaceHeld, tree],
|
||||
);
|
||||
|
||||
const onNodeResizePointerMove = useCallback(
|
||||
@@ -442,6 +502,7 @@ export function useNodeTransformInteraction({
|
||||
return;
|
||||
}
|
||||
gesture.hasMoved = true;
|
||||
suppressNextNodeClickRef.current = true;
|
||||
gesture.pendingTransform = nextTransform;
|
||||
emitPreviewTransforms(
|
||||
gesture,
|
||||
@@ -463,11 +524,18 @@ export function useNodeTransformInteraction({
|
||||
);
|
||||
|
||||
return {
|
||||
consumeNodeClick: useCallback(() => {
|
||||
if (!suppressNextNodeClickRef.current) return false;
|
||||
suppressNextNodeClickRef.current = false;
|
||||
return true;
|
||||
}, []),
|
||||
onNodePointerDown,
|
||||
onNodePointerMove,
|
||||
onNodePointerUp,
|
||||
onNodePointerCancel,
|
||||
onNodeResizePointerDown,
|
||||
onNodeResizePointerMove,
|
||||
onNodeResizePointerUp: onNodePointerUp,
|
||||
onNodeResizePointerCancel: onNodePointerCancel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, 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';
|
||||
@@ -9,6 +9,10 @@ import { ExclusiveChildrenTabs } from '../src/view/ui-editor/components/preview/
|
||||
import { UiTreeRenderer } from '../src/view/ui-editor/components/preview/UiTreeRenderer';
|
||||
import { UiNodeContextMenu } from '../src/view/ui-editor/components/UiNodeContextMenu';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function node(id: string, children: UiNode[] = []): UiNode {
|
||||
return {
|
||||
id,
|
||||
@@ -62,6 +66,7 @@ function renderTree(
|
||||
options: {
|
||||
showFrame?: boolean;
|
||||
selectedNodeId?: string | null;
|
||||
onSelectNode?: ReturnType<typeof vi.fn>;
|
||||
onNodeContextMenu?: ReturnType<typeof vi.fn>;
|
||||
} = {},
|
||||
) {
|
||||
@@ -73,20 +78,41 @@ function renderTree(
|
||||
hiddenNodeIds={hiddenNodeIds}
|
||||
selectedNodeId={options.selectedNodeId ?? null}
|
||||
resources={resources}
|
||||
onSelectNode={vi.fn()}
|
||||
onSelectNode={options.onSelectNode ?? vi.fn()}
|
||||
onNodeContextMenu={options.onNodeContextMenu ?? vi.fn()}
|
||||
onNodePointerDown={vi.fn()}
|
||||
onNodePointerMove={vi.fn()}
|
||||
onNodePointerUp={vi.fn()}
|
||||
onNodePointerCancel={vi.fn()}
|
||||
onNodeResizePointerDown={vi.fn()}
|
||||
onNodeResizePointerMove={vi.fn()}
|
||||
onNodeResizePointerUp={vi.fn()}
|
||||
onNodeResizePointerCancel={vi.fn()}
|
||||
viewportScale={1}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('UI tree preview visibility', () => {
|
||||
it('disables native text selection on preview node layers', () => {
|
||||
const rendered = renderTree('editor-overlay', new Set());
|
||||
|
||||
expect(
|
||||
rendered.container
|
||||
.querySelector('[data-node-id="child"]')
|
||||
?.classList.contains('select-none'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not use click alone to select directly draggable nodes', () => {
|
||||
const onSelectNode = vi.fn();
|
||||
const rendered = renderTree('editor-overlay', new Set(), { onSelectNode });
|
||||
fireEvent.click(
|
||||
rendered.container.querySelector('[data-node-id="child"]')!,
|
||||
);
|
||||
expect(onSelectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shares node menu restrictions, disabled actions, and dismissal behavior', () => {
|
||||
const onClose = vi.fn();
|
||||
const onInsertChild = vi.fn();
|
||||
@@ -150,9 +176,11 @@ describe('UI tree preview visibility', () => {
|
||||
onNodePointerDown={vi.fn()}
|
||||
onNodePointerMove={vi.fn()}
|
||||
onNodePointerUp={vi.fn()}
|
||||
onNodePointerCancel={vi.fn()}
|
||||
onNodeResizePointerDown={vi.fn()}
|
||||
onNodeResizePointerMove={vi.fn()}
|
||||
onNodeResizePointerUp={vi.fn()}
|
||||
onNodeResizePointerCancel={vi.fn()}
|
||||
viewportScale={1}
|
||||
/>,
|
||||
);
|
||||
@@ -227,9 +255,11 @@ describe('UI tree preview visibility', () => {
|
||||
onNodePointerDown={vi.fn()}
|
||||
onNodePointerMove={vi.fn()}
|
||||
onNodePointerUp={vi.fn()}
|
||||
onNodePointerCancel={vi.fn()}
|
||||
onNodeResizePointerDown={vi.fn()}
|
||||
onNodeResizePointerMove={vi.fn()}
|
||||
onNodeResizePointerUp={vi.fn()}
|
||||
onNodeResizePointerCancel={vi.fn()}
|
||||
viewportScale={1}
|
||||
/>,
|
||||
);
|
||||
@@ -299,9 +329,11 @@ describe('UI tree preview visibility', () => {
|
||||
onNodePointerDown={onNodePointerDown}
|
||||
onNodePointerMove={vi.fn()}
|
||||
onNodePointerUp={vi.fn()}
|
||||
onNodePointerCancel={vi.fn()}
|
||||
onNodeResizePointerDown={vi.fn()}
|
||||
onNodeResizePointerMove={vi.fn()}
|
||||
onNodeResizePointerUp={vi.fn()}
|
||||
onNodeResizePointerCancel={vi.fn()}
|
||||
onSelectExclusiveChild={onSelectChild}
|
||||
viewportScale={1}
|
||||
/>,
|
||||
@@ -331,9 +363,11 @@ describe('UI tree preview visibility', () => {
|
||||
onNodePointerDown={vi.fn()}
|
||||
onNodePointerMove={vi.fn()}
|
||||
onNodePointerUp={vi.fn()}
|
||||
onNodePointerCancel={vi.fn()}
|
||||
onNodeResizePointerDown={vi.fn()}
|
||||
onNodeResizePointerMove={vi.fn()}
|
||||
onNodeResizePointerUp={vi.fn()}
|
||||
onNodeResizePointerCancel={vi.fn()}
|
||||
viewportScale={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -70,12 +70,14 @@ function pointerEvent(
|
||||
pointerId: number,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
eventTarget: EventTarget = target,
|
||||
) {
|
||||
return {
|
||||
button: 0,
|
||||
clientX,
|
||||
clientY,
|
||||
currentTarget: target,
|
||||
target: eventTarget,
|
||||
pointerId,
|
||||
preventDefault: vi.fn(),
|
||||
shiftKey: false,
|
||||
@@ -96,6 +98,7 @@ function renderInteraction({
|
||||
tree = pageTree,
|
||||
keepChildrenUnchanged = false,
|
||||
onPreviewTransform,
|
||||
selectedNodeId = null,
|
||||
}: {
|
||||
activeImageId?: string | null;
|
||||
scale?: number;
|
||||
@@ -105,6 +108,7 @@ function renderInteraction({
|
||||
nodeId: string,
|
||||
transform: UiNode['layout']['transform'] | null,
|
||||
) => void;
|
||||
selectedNodeId?: string | null;
|
||||
} = {}) {
|
||||
const canvasProjection = canvas();
|
||||
const viewportRef = { current: { scale } };
|
||||
@@ -119,6 +123,7 @@ function renderInteraction({
|
||||
viewportRef,
|
||||
keepChildrenUnchanged,
|
||||
onPreviewTransform,
|
||||
selectedNodeId,
|
||||
}),
|
||||
{ initialProps: { imageId: activeImageId, currentTree: tree } },
|
||||
);
|
||||
@@ -126,6 +131,95 @@ function renderInteraction({
|
||||
}
|
||||
|
||||
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],
|
||||
@@ -250,6 +344,40 @@ describe('useNodeTransformInteraction', () => {
|
||||
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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# UI 编辑器 Godot 容器布局模型
|
||||
|
||||
更新时间:`2026-08-18`
|
||||
更新时间:`2026-09-03`
|
||||
|
||||
## 范围
|
||||
|
||||
@@ -40,3 +40,9 @@ Container 专属数据是互斥 tagged union:HBox/VBox 存 `alignment + separa
|
||||
## 编辑器交互
|
||||
|
||||
布局只在 Inspector 编辑。Preview 不为 Container 管理的 child 提供拖拽或缩放手柄;Inspector 会提示其 Transform 被父 Container 忽略。普通 Control 仍保留原 Transform 编辑和自由预览操作。
|
||||
|
||||
Preview 的节点选择采用“松开确认”语义:节点或调整手柄按下时只建立指针手势,不立即改变选择;沿用现有 `2px` 屏幕位移阈值(达到阈值即视为拖动),未达到阈值且在预览区域内松开时才选择按下命中的节点,发生拖动、取消、失焦或在区域外松开均不选择。发生拖动时,Transform 更新作用于按下前已选中的节点;没有当前选择时才回退到按下节点。拖动外层节点时,嵌套 child 产生的后续 click 也不得重新选择;未发生拖动时,嵌套 child 仍按实际命中节点选择。
|
||||
|
||||
取消、失焦、树切换或无效几何导致的手势终止会同时清理下一次 click 抑制状态;只有正常 pointerup 才会根据拖动结果抑制后续 click。
|
||||
|
||||
预览节点层禁止浏览器原生文字选择;拖动手势开始时阻止默认行为,避免 pointer drag 被浏览器解释为文本选取。
|
||||
|
||||
Reference in New Issue
Block a user