收拢 UI Editor 节点几何 seam
新增唯一节点几何模块并统一页面坐标、节点上下文与 offset 反算 迁移状态层重挂载、子节点保位、Inspector 与预览 resize 适配器 补充纯几何回归测试、更新 UI Editor 技术方案
This commit is contained in:
@@ -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(
|
||||
|
||||
+22
-38
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
-128
@@ -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;
|
||||
}
|
||||
-46
@@ -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
-9
@@ -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 动作,不改变既有新增或删除后的选择策略。
|
||||
|
||||
Reference in New Issue
Block a user