收拢 UI Editor 节点几何 seam

新增唯一节点几何模块并统一页面坐标、节点上下文与 offset 反算

迁移状态层重挂载、子节点保位、Inspector 与预览 resize 适配器

补充纯几何回归测试、更新 UI Editor 技术方案
This commit is contained in:
2026-08-19 14:54:24 +08:00
parent d96f081a44
commit 10317e16e7
11 changed files with 422 additions and 382 deletions
@@ -0,0 +1,203 @@
import type { Node } from './types/Node';
import type { NodeId } from './types/NodeId';
import type { Transform } from './types/Transform';
export type ResizeAxis = 'horizontal' | 'vertical';
export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
export type PageRect = {
min: [number, number];
max: [number, number];
};
export type NodePageContext = {
node: Node;
rect: PageRect;
parentRect: PageRect;
};
const MIN_NODE_SIZE = 1;
export function pageRectFromSize(size: [number, number]): PageRect {
return { min: [0, 0], max: [...size] };
}
export function pageRectSize(rect: PageRect): [number, number] {
return [rect.max[0] - rect.min[0], rect.max[1] - rect.min[1]];
}
export function isValidPageRect(rect: PageRect): boolean {
return [...rect.min, ...rect.max].every(Number.isFinite);
}
export function resolvePageRect(
transform: Transform,
parentRect: PageRect,
): PageRect {
const [parentWidth, parentHeight] = pageRectSize(parentRect);
return {
min: [
parentRect.min[0] +
parentWidth * transform.anchor_min[0] +
transform.offset_min[0],
parentRect.min[1] +
parentHeight * transform.anchor_min[1] +
transform.offset_min[1],
],
max: [
parentRect.min[0] +
parentWidth * transform.anchor_max[0] +
transform.offset_max[0],
parentRect.min[1] +
parentHeight * transform.anchor_max[1] +
transform.offset_max[1],
],
};
}
export function findNodePageContext(
node: Node,
nodeId: NodeId,
parentRect: PageRect,
): NodePageContext | null {
const rect = resolvePageRect(node.layout.transform, parentRect);
if (node.id === nodeId) return { node, rect, parentRect };
for (const child of node.children) {
const found = findNodePageContext(child, nodeId, rect);
if (found) return found;
}
return null;
}
export function setOffsetsForPageRect(
transform: Transform,
rect: PageRect,
parentRect: PageRect,
): Transform {
const [parentWidth, parentHeight] = pageRectSize(parentRect);
return {
...structuredClone(transform),
offset_min: [
rect.min[0] - parentRect.min[0] - parentWidth * transform.anchor_min[0],
rect.min[1] - parentRect.min[1] - parentHeight * transform.anchor_min[1],
],
offset_max: [
rect.max[0] - parentRect.min[0] - parentWidth * transform.anchor_max[0],
rect.max[1] - parentRect.min[1] - parentHeight * transform.anchor_max[1],
],
};
}
export function resolveAnchorPresetTransform(
transform: Transform,
anchorMin: [number, number],
anchorMax: [number, number],
parentSize?: { width: number; height: number },
): Transform {
const next: Transform = {
...structuredClone(transform),
anchor_min: [...anchorMin],
anchor_max: [...anchorMax],
};
if (!parentSize || parentSize.width <= 0 || parentSize.height <= 0) {
return next;
}
return setOffsetsForPageRect(
next,
resolvePageRect(
transform,
pageRectFromSize([parentSize.width, parentSize.height]),
),
pageRectFromSize([parentSize.width, parentSize.height]),
);
}
/** Converts a same-page reparent operation into the target parent's local transform. */
export function resolveReparentTransform(
transform: Transform,
nodeRect: PageRect,
targetParentRect: PageRect,
): Transform {
return setOffsetsForPageRect(transform, nodeRect, targetParentRect);
}
/** Keeps each direct child's page rectangle stable while its parent changes rectangle. */
export function resolveChildrenTransformsForParentRect(
children: readonly Node[],
oldParentRect: PageRect,
newParentRect: PageRect,
): Array<{ id: NodeId; transform: Transform }> {
return children.map((child) => ({
id: child.id,
transform: setOffsetsForPageRect(
child.layout.transform,
resolvePageRect(child.layout.transform, oldParentRect),
newParentRect,
),
}));
}
export function resolveProportionalResizeAxis(
startSize: [number, number],
delta: [number, number],
): ResizeAxis {
const startWidth = Math.max(MIN_NODE_SIZE, startSize[0]);
const startHeight = Math.max(MIN_NODE_SIZE, startSize[1]);
return Math.abs(delta[0]) / startWidth >= Math.abs(delta[1]) / startHeight
? 'horizontal'
: 'vertical';
}
export function resizePageRect(
startRect: PageRect,
handle: ResizeHandle,
delta: [number, number],
keepRatio: boolean,
ratioAxis?: ResizeAxis,
): PageRect {
const next: PageRect = { min: [...startRect.min], max: [...startRect.max] };
if (handle.includes('w')) {
next.min[0] = Math.min(
startRect.max[0] - MIN_NODE_SIZE,
startRect.min[0] + delta[0],
);
} else if (handle.includes('e')) {
next.max[0] = Math.max(
startRect.min[0] + MIN_NODE_SIZE,
startRect.max[0] + delta[0],
);
}
if (handle.includes('n')) {
next.min[1] = Math.min(
startRect.max[1] - MIN_NODE_SIZE,
startRect.min[1] + delta[1],
);
} else if (handle.includes('s')) {
next.max[1] = Math.max(
startRect.min[1] + MIN_NODE_SIZE,
startRect.max[1] + delta[1],
);
}
if (keepRatio && handle.length === 2) {
const [startWidth, startHeight] = pageRectSize(startRect);
const [nextWidth, nextHeight] = pageRectSize(next);
const axis =
ratioAxis ??
resolveProportionalResizeAxis([startWidth, startHeight], delta);
const ratio =
Math.max(MIN_NODE_SIZE, startWidth) /
Math.max(MIN_NODE_SIZE, startHeight);
const width =
axis === 'horizontal'
? Math.max(MIN_NODE_SIZE, nextWidth)
: Math.max(MIN_NODE_SIZE, nextHeight) * ratio;
const height = width / ratio;
if (handle.includes('w')) next.min[0] = startRect.max[0] - width;
else next.max[0] = startRect.min[0] + width;
if (handle.includes('n')) next.min[1] = startRect.max[1] - height;
else next.max[1] = startRect.min[1] + height;
}
return next;
}
@@ -1,5 +1,14 @@
import { useCallback, useRef, useState } from 'react';
import {
findNodePageContext,
isValidPageRect,
pageRectFromSize,
pageRectSize,
resolveChildrenTransformsForParentRect,
resolvePageRect,
resolveReparentTransform,
} from './nodeTransformGeometry';
import { validateSpriteBorder } from './spriteBorder';
import type { ChildrenDisplayMode } from './types/ChildrenDisplayMode';
import type { Component } from './types/Component';
@@ -58,11 +67,6 @@ export type NodeTransformOptions = {
export type NodeLayoutPatch = Partial<Node['layout']>;
type Rect = {
min: [number, number];
size: [number, number];
};
type NodeLocation = {
node: Node;
parent: Node | null;
@@ -183,33 +187,6 @@ function synchronizeDesignImageTrees(state: State): void {
}
}
function resolveNodeRect(
transform: Node['layout']['transform'],
parent: Rect,
): Rect {
const min: [number, number] = [
parent.min[0] +
parent.size[0] * transform.anchor_min[0] +
transform.offset_min[0],
parent.min[1] +
parent.size[1] * transform.anchor_min[1] +
transform.offset_min[1],
];
const max: [number, number] = [
parent.min[0] +
parent.size[0] * transform.anchor_max[0] +
transform.offset_max[0],
parent.min[1] +
parent.size[1] * transform.anchor_max[1] +
transform.offset_max[1],
];
return { min, size: [max[0] - min[0], max[1] - min[1]] };
}
function isValidRect(rect: Rect): boolean {
return rect.min.every(Number.isFinite) && rect.size.every(Number.isFinite);
}
function findNodeLocation(
node: Node,
id: NodeId,
@@ -229,54 +206,10 @@ function findNodeLocation(
return null;
}
function findNodePageRect(
node: Node,
id: NodeId,
parentRect: Rect,
): Rect | null {
const rect = resolveNodeRect(node.layout.transform, parentRect);
if (node.id === id) return rect;
for (const child of node.children) {
const found = findNodePageRect(child, id, rect);
if (found) return found;
}
return null;
}
function containsNode(root: Node, id: NodeId): boolean {
return findNodeLocation(root, id) !== null;
}
function setOffsetsForPageRect(
transform: Node['layout']['transform'],
pageRect: Rect,
parentPageRect: Rect,
): Node['layout']['transform'] | null {
if (!isValidRect(pageRect) || !isValidRect(parentPageRect)) return null;
const next = structuredClone(transform);
next.offset_min = [
pageRect.min[0] -
parentPageRect.min[0] -
parentPageRect.size[0] * next.anchor_min[0],
pageRect.min[1] -
parentPageRect.min[1] -
parentPageRect.size[1] * next.anchor_min[1],
];
const pageMax: [number, number] = [
pageRect.min[0] + pageRect.size[0],
pageRect.min[1] + pageRect.size[1],
];
next.offset_max = [
pageMax[0] -
parentPageRect.min[0] -
parentPageRect.size[0] * next.anchor_max[0],
pageMax[1] -
parentPageRect.min[1] -
parentPageRect.size[1] * next.anchor_max[1],
];
return next;
}
export type DesignImageInput = {
id: UIDesignImageId;
image: UIDesignImage;
@@ -977,53 +910,38 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
) {
return { ok: false, reason: 'invalid' };
}
const pageRect: Rect = {
min: [0, 0],
size: [
image.pixel_size[0] / image.pixels_per_unit,
image.pixel_size[1] / image.pixels_per_unit,
],
};
const oldParentRect = findNodePageRect(
const pageRect = pageRectFromSize([
image.pixel_size[0] / image.pixels_per_unit,
image.pixel_size[1] / image.pixels_per_unit,
]);
const oldParentContext = findNodePageContext(
tree.root,
location.parent.id,
pageRect,
);
const oldNodeRect = findNodePageRect(tree.root, nodeId, pageRect);
const oldNodeContext = findNodePageContext(tree.root, nodeId, pageRect);
if (
!oldParentRect ||
!isValidRect(oldParentRect) ||
!oldNodeRect ||
!isValidRect(oldNodeRect)
!oldParentContext ||
!isValidPageRect(oldParentContext.rect) ||
!oldNodeContext ||
!isValidPageRect(oldNodeContext.rect)
) {
return { ok: false, reason: 'invalid' };
}
const newNodeRect = resolveNodeRect(transform, oldParentRect);
if (!isValidRect(newNodeRect)) {
return { ok: false, reason: 'invalid' };
}
const childTransforms = location.node.children.map((child) => {
const childRect = resolveNodeRect(
child.layout.transform,
oldNodeRect,
);
return {
id: child.id,
transform: setOffsetsForPageRect(
child.layout.transform,
childRect,
newNodeRect,
),
};
});
if (childTransforms.some((child) => child.transform === null)) {
const newNodeRect = resolvePageRect(transform, oldParentContext.rect);
if (!isValidPageRect(newNodeRect)) {
return { ok: false, reason: 'invalid' };
}
const childTransforms = resolveChildrenTransformsForParentRect(
location.node.children,
oldNodeContext.rect,
newNodeRect,
);
for (const child of childTransforms) {
const nextChild = nextNode.children.find(
(candidate) => candidate.id === child.id,
);
if (!nextChild || !child.transform) {
if (!nextChild) {
return { ok: false, reason: 'invalid' };
}
nextChild.layout.transform = child.transform;
@@ -1332,39 +1250,39 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
if (sourceTreeId === targetTreeId) {
const image = current.ui_design_images[sourceTreeId];
if (!image) return { ok: false, reason: 'missing' };
const logicalSize: Rect = {
min: [0, 0],
size: [
image.pixel_size[0] / image.pixels_per_unit,
image.pixel_size[1] / image.pixels_per_unit,
],
};
const logicalSize = pageRectFromSize([
image.pixel_size[0] / image.pixels_per_unit,
image.pixel_size[1] / image.pixels_per_unit,
]);
if (
!isValidRect(logicalSize) ||
logicalSize.size.some((value) => value <= 0)
!isValidPageRect(logicalSize) ||
pageRectSize(logicalSize).some((value) => value <= 0)
) {
return { ok: false, reason: 'invalid' };
}
const pageRect = findNodePageRect(sourceTree.root, nodeId, logicalSize);
const targetPageRect = findNodePageRect(
const pageContext = findNodePageContext(
sourceTree.root,
nodeId,
logicalSize,
);
const targetContext = findNodePageContext(
targetTree.root,
targetParentId,
logicalSize,
);
if (
!pageRect ||
pageRect.size.some((value) => value <= 0) ||
!targetPageRect ||
targetPageRect.size.some((value) => value <= 0)
!pageContext ||
pageRectSize(pageContext.rect).some((value) => value <= 0) ||
!targetContext ||
pageRectSize(targetContext.rect).some((value) => value <= 0)
) {
return { ok: false, reason: 'invalid' };
}
nextTransform = setOffsetsForPageRect(
nextTransform = resolveReparentTransform(
source.node.layout.transform,
pageRect,
targetPageRect,
pageContext.rect,
targetContext.rect,
);
if (!nextTransform) return { ok: false, reason: 'invalid' };
}
const next = cloneState(current);
const nextSourceTree = next.ui_trees.find(
@@ -9,6 +9,12 @@ import {
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
pageRectFromSize,
pageRectSize,
resolveAnchorPresetTransform,
resolvePageRect,
} from '../../../../../features/ui-editor/nodeTransformGeometry';
import type { Transform } from '../../../../../features/ui-editor/types/Transform';
import { type AnchorMode, AnchorPresetIcon } from './AnchorPresetIcon';
@@ -123,33 +129,14 @@ function applyPreset(
preset: AnchorPreset,
parentSize?: TransformEditorParentSize,
): Transform {
const next = cloneTransform(transform);
const [nextAnchorMinX, nextAnchorMaxX] = modeAnchors(preset.x);
const [nextAnchorMinY, nextAnchorMaxY] = modeAnchors(preset.y);
if (hasUsableParentSize(parentSize)) {
const currentMinX =
parentSize.width * transform.anchor_min[0] + transform.offset_min[0];
const currentMinY =
parentSize.height * transform.anchor_min[1] + transform.offset_min[1];
const currentMaxX =
parentSize.width * transform.anchor_max[0] + transform.offset_max[0];
const currentMaxY =
parentSize.height * transform.anchor_max[1] + transform.offset_max[1];
next.offset_min = [
currentMinX - parentSize.width * nextAnchorMinX,
currentMinY - parentSize.height * nextAnchorMinY,
];
next.offset_max = [
currentMaxX - parentSize.width * nextAnchorMaxX,
currentMaxY - parentSize.height * nextAnchorMaxY,
];
}
next.anchor_min = [nextAnchorMinX, nextAnchorMinY];
next.anchor_max = [nextAnchorMaxX, nextAnchorMaxY];
return next;
return resolveAnchorPresetTransform(
transform,
[nextAnchorMinX, nextAnchorMinY],
[nextAnchorMaxX, nextAnchorMaxY],
parentSize,
);
}
function resolvedGeometry(
@@ -159,20 +146,17 @@ function resolvedGeometry(
if (!hasUsableParentSize(parentSize)) {
return null;
}
const left =
parentSize.width * transform.anchor_min[0] + transform.offset_min[0];
const top =
parentSize.height * transform.anchor_min[1] + transform.offset_min[1];
const right =
parentSize.width * transform.anchor_max[0] + transform.offset_max[0];
const bottom =
parentSize.height * transform.anchor_max[1] + transform.offset_max[1];
const rect = resolvePageRect(
transform,
pageRectFromSize([parentSize.width, parentSize.height]),
);
const [width, height] = pageRectSize(rect);
return {
left,
top,
width: right - left,
height: bottom - top,
invalid: right < left || bottom < top,
left: rect.min[0],
top: rect.min[1],
width,
height,
invalid: width < 0 || height < 0,
};
}
@@ -23,11 +23,11 @@ import {
useState,
} from 'react';
import { findNodePageContext } from '../../../../features/ui-editor/nodeTransformGeometry';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UiEditorPageController } from '../../useUiEditorPage';
import { UiNodeContextMenu } from '../UiNodeContextMenu';
import { ExclusiveChildrenTabs } from './ExclusiveChildrenTabs';
import { findNodePageContext } from './nodeTransformGeometry';
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
@@ -3,6 +3,7 @@ import type {
PointerEvent as ReactPointerEvent,
} from 'react';
import type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry';
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UITree } from '../../../../features/ui-editor/types/UITree';
@@ -14,9 +15,8 @@ import {
} from '../../../../features/ui-editor/utils/layout/controlLayoutToCss';
import { ComponentView } from './components/ComponentView';
import type { PreviewComponentResources } from './components/types';
import type { ResizeHandle } from './nodeTransformGeometry';
export type { ResizeHandle } from './nodeTransformGeometry';
export type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry';
export type UiEditorRenderMode = 'editor-overlay' | 'final-preview';
@@ -1,128 +0,0 @@
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
import type { ResizeAxis } from './proportionalResize';
import { resolveProportionalResize } from './proportionalResize';
export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
export type PageRect = {
min: [number, number];
max: [number, number];
};
export type NodePageContext = {
rect: PageRect;
parentRect: PageRect;
};
const MIN_NODE_SIZE = 1;
export function resolvePageRect(
transform: UiNode['layout']['transform'],
parentRect: PageRect,
): PageRect {
return {
min: [
parentRect.min[0] +
(parentRect.max[0] - parentRect.min[0]) * transform.anchor_min[0] +
transform.offset_min[0],
parentRect.min[1] +
(parentRect.max[1] - parentRect.min[1]) * transform.anchor_min[1] +
transform.offset_min[1],
],
max: [
parentRect.min[0] +
(parentRect.max[0] - parentRect.min[0]) * transform.anchor_max[0] +
transform.offset_max[0],
parentRect.min[1] +
(parentRect.max[1] - parentRect.min[1]) * transform.anchor_max[1] +
transform.offset_max[1],
],
};
}
export function findNodePageContext(
node: UiNode,
nodeId: string,
parentRect: PageRect,
): NodePageContext | null {
const rect = resolvePageRect(node.layout.transform, parentRect);
if (node.id === nodeId) return { rect, parentRect };
for (const child of node.children) {
const found = findNodePageContext(child, nodeId, rect);
if (found) return found;
}
return null;
}
export function setOffsetsForPageRect(
transform: UiNode['layout']['transform'],
rect: PageRect,
parentRect: PageRect,
): UiNode['layout']['transform'] {
const parentWidth = parentRect.max[0] - parentRect.min[0];
const parentHeight = parentRect.max[1] - parentRect.min[1];
const next = structuredClone(transform);
next.offset_min = [
rect.min[0] - parentRect.min[0] - parentWidth * next.anchor_min[0],
rect.min[1] - parentRect.min[1] - parentHeight * next.anchor_min[1],
];
next.offset_max = [
rect.max[0] - parentRect.min[0] - parentWidth * next.anchor_max[0],
rect.max[1] - parentRect.min[1] - parentHeight * next.anchor_max[1],
];
return next;
}
export function resizePageRect(
startRect: PageRect,
handle: ResizeHandle,
delta: [number, number],
keepRatio: boolean,
ratioAxis?: ResizeAxis,
): PageRect {
const next: PageRect = {
min: [startRect.min[0], startRect.min[1]],
max: [startRect.max[0], startRect.max[1]],
};
if (handle.includes('w')) {
next.min[0] = Math.min(
startRect.max[0] - MIN_NODE_SIZE,
startRect.min[0] + delta[0],
);
} else if (handle.includes('e')) {
next.max[0] = Math.max(
startRect.min[0] + MIN_NODE_SIZE,
startRect.max[0] + delta[0],
);
}
if (handle.includes('n')) {
next.min[1] = Math.min(
startRect.max[1] - MIN_NODE_SIZE,
startRect.min[1] + delta[1],
);
} else if (handle.includes('s')) {
next.max[1] = Math.max(
startRect.min[1] + MIN_NODE_SIZE,
startRect.max[1] + delta[1],
);
}
if (keepRatio && handle.length === 2) {
const {
size: [width, height],
} = resolveProportionalResize({
startSize: [
startRect.max[0] - startRect.min[0],
startRect.max[1] - startRect.min[1],
],
size: [next.max[0] - next.min[0], next.max[1] - next.min[1]],
delta,
axis: ratioAxis,
});
if (handle.includes('w')) next.min[0] = startRect.max[0] - width;
else next.max[0] = startRect.min[0] + width;
if (handle.includes('n')) next.min[1] = startRect.max[1] - height;
else next.max[1] = startRect.min[1] + height;
}
return next;
}
@@ -1,46 +0,0 @@
export type ResizeAxis = 'horizontal' | 'vertical';
const MIN_SIZE = 1;
export type ProportionalResizeInput = {
startSize: [number, number];
size: [number, number];
delta: [number, number];
axis?: ResizeAxis;
};
export type ProportionalResizeResult = {
axis: ResizeAxis;
size: [number, number];
};
export function resolveProportionalResizeAxis(
startSize: [number, number],
delta: [number, number],
): ResizeAxis {
const startWidth = Math.max(MIN_SIZE, startSize[0]);
const startHeight = Math.max(MIN_SIZE, startSize[1]);
return Math.abs(delta[0]) / startWidth >= Math.abs(delta[1]) / startHeight
? 'horizontal'
: 'vertical';
}
export function resolveProportionalResize({
startSize,
size,
delta,
axis,
}: ProportionalResizeInput): ProportionalResizeResult {
const startWidth = Math.max(MIN_SIZE, startSize[0]);
const startHeight = Math.max(MIN_SIZE, startSize[1]);
const resolvedAxis = axis ?? resolveProportionalResizeAxis(startSize, delta);
const ratio = startWidth / startHeight;
const width =
resolvedAxis === 'horizontal'
? Math.max(MIN_SIZE, size[0])
: Math.max(MIN_SIZE, size[1]) * ratio;
return {
axis: resolvedAxis,
size: [width, width / ratio],
};
}
@@ -6,21 +6,18 @@ import {
useRef,
} from 'react';
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
import type { UITree } from '../../../../features/ui-editor/types/UITree';
import type { UiEditorPageController } from '../../useUiEditorPage';
import {
findNodePageContext,
type PageRect,
type ResizeAxis,
type ResizeHandle,
resizePageRect,
setOffsetsForPageRect,
} from './nodeTransformGeometry';
import {
type ResizeAxis,
resolveProportionalResizeAxis,
} from './proportionalResize';
setOffsetsForPageRect,
} from '../../../../features/ui-editor/nodeTransformGeometry';
import type { Node as UiNode } from '../../../../features/ui-editor/types/Node';
import type { UITree } from '../../../../features/ui-editor/types/UITree';
import type { UiEditorPageController } from '../../useUiEditorPage';
type ViewportScale = { scale: number };
type GestureBase = {
@@ -9,6 +9,11 @@ import {
prepareSpriteAssetBatch,
} from '../../features/ui-editor/importAdapter';
import { applyMergeResult } from '../../features/ui-editor/merge';
import {
findNodePageContext,
pageRectFromSize,
pageRectSize,
} from '../../features/ui-editor/nodeTransformGeometry';
import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview';
import { collectUiNodeIds } from '../../features/ui-editor/treeUtils';
@@ -436,42 +441,24 @@ export function useUiEditorPage(
setNodePreviewVisible,
],
);
const findNodeContext = useCallback(function findNodeContext(
node: UiNode,
nodeId: NodeId,
parentSize: { width: number; height: number },
): { node: UiNode; parentSize: { width: number; height: number } } | null {
if (node.id === nodeId) return { node, parentSize };
const width =
parentSize.width *
(node.layout.transform.anchor_max[0] -
node.layout.transform.anchor_min[0]) +
node.layout.transform.offset_max[0] -
node.layout.transform.offset_min[0];
const height =
parentSize.height *
(node.layout.transform.anchor_max[1] -
node.layout.transform.anchor_min[1]) +
node.layout.transform.offset_max[1] -
node.layout.transform.offset_min[1];
for (const child of node.children) {
const found = findNodeContext(child, nodeId, { width, height });
if (found) return found;
}
return null;
}, []);
const selectedNodeContext = useMemo(() => {
if (!activeImage || !treeForActiveImage || !selectedNodeId) return null;
const ppu = activeImage.pixels_per_unit;
const width = activeImage.pixel_size[0] / ppu;
const height = activeImage.pixel_size[1] / ppu;
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
return findNodeContext(treeForActiveImage.root, selectedNodeId, {
width,
height,
});
}, [activeImage, findNodeContext, selectedNodeId, treeForActiveImage]);
const context = findNodePageContext(
treeForActiveImage.root,
selectedNodeId,
pageRectFromSize([width, height]),
);
if (!context) return null;
const [parentWidth, parentHeight] = pageRectSize(context.parentRect);
return {
node: context.node,
parentSize: { width: parentWidth, height: parentHeight },
};
}, [activeImage, selectedNodeId, treeForActiveImage]);
async function importAssets(imported: ImportedAsset[]) {
if (!importKind || !projectPath) return;
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import {
findNodePageContext,
pageRectFromSize,
resolveAnchorPresetTransform,
resolveChildrenTransformsForParentRect,
resolvePageRect,
resolveReparentTransform,
} from '../src/features/ui-editor/nodeTransformGeometry';
import type { Node } from '../src/features/ui-editor/types/Node';
import type { Transform } from '../src/features/ui-editor/types/Transform';
const ROOT_TRANSFORM: Transform = {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
};
function node(id: string, transform: Transform, children: Node[] = []): Node {
return {
id,
layout: { transform } as Node['layout'],
metadata: {} as Node['metadata'],
components: [],
children_display_mode: 'Stack',
children,
};
}
describe('nodeTransformGeometry', () => {
it('resolves nested context and inverts page offsets at the same seam', () => {
const childTransform: Transform = {
anchor_min: [0.5, 0.5],
anchor_max: [0.5, 0.5],
offset_min: [-10, -15],
offset_max: [30, 25],
};
const root = node('root', ROOT_TRANSFORM, [
node(
'parent',
{
anchor_min: [0, 0],
anchor_max: [0, 0],
offset_min: [20, 30],
offset_max: [220, 130],
},
[node('child', childTransform)],
),
]);
const context = findNodePageContext(
root,
'child',
pageRectFromSize([400, 300]),
);
expect(context).toMatchObject({
parentRect: { min: [20, 30], max: [220, 130] },
rect: { min: [110, 65], max: [150, 105] },
});
expect(
resolveReparentTransform(
childTransform,
context!.rect,
context!.parentRect,
),
).toEqual(childTransform);
});
it('preserves direct child page rectangles when a parent changes', () => {
const child = node('child', {
anchor_min: [0.25, 0],
anchor_max: [0.75, 1],
offset_min: [5, 10],
offset_max: [-10, -15],
});
const oldParentRect = {
min: [20, 30] as [number, number],
max: [220, 130] as [number, number],
};
const newParentRect = {
min: [40, 50] as [number, number],
max: [340, 250] as [number, number],
};
const oldChildRect = resolvePageRect(child.layout.transform, oldParentRect);
const [{ transform }] = resolveChildrenTransformsForParentRect(
[child],
oldParentRect,
newParentRect,
);
expect(resolvePageRect(transform, newParentRect)).toEqual(oldChildRect);
});
it('keeps the page rectangle stable when inspector anchors change', () => {
const transform: Transform = {
anchor_min: [0.5, 0.5],
anchor_max: [0.5, 0.5],
offset_min: [-60, -20],
offset_max: [40, 30],
};
const parentSize = { width: 300, height: 200 };
const pageRect = pageRectFromSize([parentSize.width, parentSize.height]);
const next = resolveAnchorPresetTransform(
transform,
[0, 0],
[1, 1],
parentSize,
);
expect(resolvePageRect(next, pageRect)).toEqual(
resolvePageRect(transform, pageRect),
);
});
});
@@ -2,6 +2,12 @@
## 2026-08-19 UI Editor 节点右键菜单
## 2026-08-19 UI Editor 节点几何唯一 seam
`features/ui-editor/nodeTransformGeometry.ts` 是 UI Editor 节点几何的唯一 seam:它统一解析页面 rect 和节点 context、按 anchor / offset 反算 transform、同树 reparent 意图、父节点变换时的直接子节点页面位置保持,以及预览 resize 的等比约束。页面 Inspector、预览 pointer interaction 与状态迁移分别作为该 Module 的 adapter;它们不得再递归计算节点尺寸、各自反算 offset 或复制子节点保持逻辑。跨界面移动仍只保留原局部 transform,不进入 page rect 换算。
此 seam 的纯测试覆盖嵌套 context、offset 反算、同树重挂载、子节点页面位置保持与 Inspector anchor preset 保位;几何修复应在此 Module 局部验证后由三个 adapter 复用。
左侧 `UI Tree` 与预览画布复用同一个节点右键菜单组件和条目模型,顺序固定为“新增子节点 → 新增同级节点 → 删除节点及子节点”。页面根仅显示“新增子节点”;虚拟超级根不提供菜单。预览仅在“编辑叠加”模式中响应已渲染节点的鼠标右键,右键会选中该节点并阻止嵌套节点事件冒泡;“最终预览”以及画布空白处保留原有行为,空白处不接管浏览器原生菜单。
菜单由页面级 portal 呈现,使用屏幕坐标且在视口边缘内收,避免受预览画布缩放和容器裁切影响。点击菜单外、按 Escape、窗口失焦、滚动或调整窗口大小均关闭菜单。全局 `controller.editor.isLocked` 时,节点仍可选中和查看菜单,但所有结构修改项禁用;状态层的 mutation 校验继续作为最终防线。点击可用项先关闭菜单再执行现有 controller 动作,不改变既有新增或删除后的选择策略。