fix(editor): 完善 fixed 与 absolute 定位切换逻辑

EditorNodeInfo 增加 path 复用节点路径,修复 right/bottom 锚定及冲突定位属性的计算问题。
This commit is contained in:
roymondchen 2026-06-11 20:48:47 +08:00
parent 9fe10e274c
commit 1298104732
6 changed files with 306 additions and 81 deletions

View File

@ -203,11 +203,11 @@ class Editor extends BaseService {
}
if (!root) {
return { node: null, parent: null, page: null };
return { node: null, parent: null, page: null, path: [] };
}
if (id === root.id) {
return { node: root, parent: null, page: null };
return { node: root, parent: null, page: null, path: [] };
}
// 大多数查找的目标都在当前页面内,优先在当前页面子树中查找以避免对整棵树做全量遍历。
@ -693,7 +693,7 @@ class Editor extends BaseService {
const node = toRaw(info.node);
let newConfig = await toggleFixedPosition(toRaw(config), node, root, this.getLayout);
let newConfig = await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);

View File

@ -335,6 +335,7 @@ export interface EditorNodeInfo {
node: MNode | null;
parent: MContainer | null;
page: MPage | MPageFragment | null;
path: MNode[];
}
// #endregion EditorNodeInfo

View File

@ -132,6 +132,34 @@ const getMiddleTop = (node: MNode, parentNode: MNode, stage: StageCore | null) =
return (Math.min(parentHeight, wrapperHeightDeal) - height) / 2;
};
// 同时存在一对相反的定位属性(如 left/right时只保留一个避免元素被拉伸
const removeConflictPosition = (style: Record<string, any>, primary: string, secondary: string) => {
// '' / 0 / undefined / null 视为无效值
const isInvalid = (value: any) => value === '' || value === 0 || value === undefined || value === null;
if (!(primary in style) || !(secondary in style)) {
return;
}
const primaryValue = style[primary];
const secondaryValue = style[secondary];
const primaryInvalid = isInvalid(primaryValue);
const secondaryInvalid = isInvalid(secondaryValue);
if (primaryInvalid && !secondaryInvalid) {
delete style[primary];
} else if (secondaryInvalid && !primaryInvalid) {
delete style[secondary];
} else if (primaryInvalid && secondaryInvalid) {
// 都是无效值时优先保留 0否则保留 primaryleft/top
if (secondaryValue === 0 && primaryValue !== 0) {
delete style[primary];
} else {
delete style[secondary];
}
}
};
export const getInitPositionStyle = (style: Record<string, any> = {}, layout: Layout) => {
if (layout === Layout.ABSOLUTE) {
const newStyle: Record<string, any> = {
@ -139,6 +167,9 @@ export const getInitPositionStyle = (style: Record<string, any> = {}, layout: La
position: 'absolute',
};
removeConflictPosition(newStyle, 'left', 'right');
removeConflictPosition(newStyle, 'top', 'bottom');
if (typeof newStyle.left === 'undefined' && typeof newStyle.right === 'undefined') {
newStyle.left = 0;
}
@ -178,43 +209,68 @@ export const setLayout = (node: MNode, layout: Layout) => {
return node;
};
export const change2Fixed = (node: MNode, root: MApp) => {
type PositionKey = 'left' | 'top' | 'right' | 'bottom';
// 判断 style 是否显式设置了某个定位属性,使用 typeof 判断以避免将 0如 right: 0误判为未设置
const hasPositionValue = (style: Record<string, any> | undefined, key: PositionKey): boolean =>
typeof style?.[key] !== 'undefined' && style?.[key] !== '';
/**
* 沿
* 使 left right
* 0
* @param path
* @param dir
* @param opposite
* @param sign change2Fixed 1Fixed2Other -1
* @param init
*/
const accumulatePositionOffset = (
path: MNode[],
dir: PositionKey,
opposite: PositionKey,
sign: 1 | -1,
init = 0,
): number => {
let offset = init;
for (const value of path) {
if (hasPositionValue(value.style, opposite) || !isNumber(value.style?.[dir] || 0)) {
return 0;
}
offset = offset + sign * Number(value.style?.[dir] || 0);
}
return offset;
};
export const change2Fixed = (node: MNode, path: MNode[]) => {
const style = {
...(node.style || {}),
};
const path = getNodePath(node.id, root.items);
const offset = {
left: 0,
top: 0,
};
if (!node.style?.right && isNumber(node.style?.left || 0)) {
for (const value of path) {
if (value.style?.right || !isNumber(value.style?.left || 0)) {
offset.left = 0;
break;
}
offset.left = offset.left + Number(value.style?.left || 0);
// 水平方向:以 left 锚定则累加祖先 left以 right 锚定则累加祖先 right
if (!hasPositionValue(node.style, 'right') && isNumber(node.style?.left || 0)) {
const left = accumulatePositionOffset(path, 'left', 'right', 1);
if (left) {
style.left = left;
}
} else if (hasPositionValue(node.style, 'right') && isNumber(node.style?.right || 0)) {
const right = accumulatePositionOffset(path, 'right', 'left', 1);
if (right) {
style.right = right;
}
}
if (!node.style?.bottom && isNumber(node.style?.top || 0)) {
for (const value of path) {
if (value.style?.bottom || !isNumber(value.style?.top || 0)) {
offset.top = 0;
break;
}
offset.top = offset.top + Number(value.style?.top || 0);
// 垂直方向:以 top 锚定则累加祖先 top以 bottom 锚定则累加祖先 bottom
if (!hasPositionValue(node.style, 'bottom') && isNumber(node.style?.top || 0)) {
const top = accumulatePositionOffset(path, 'top', 'bottom', 1);
if (top) {
style.top = top;
}
} else if (hasPositionValue(node.style, 'bottom') && isNumber(node.style?.bottom || 0)) {
const bottom = accumulatePositionOffset(path, 'bottom', 'top', 1);
if (bottom) {
style.bottom = bottom;
}
}
if (offset.left) {
style.left = offset.left;
}
if (offset.top) {
style.top = offset.top;
}
return style;
@ -222,34 +278,29 @@ export const change2Fixed = (node: MNode, root: MApp) => {
export const Fixed2Other = async (
node: MNode,
root: MApp,
path: MNode[],
getLayout: (parent: MNode, node?: MNode) => Promise<Layout>,
) => {
const path = getNodePath(node.id, root.items);
const cur = path.pop();
const offset = {
left: cur?.style?.left || 0,
top: cur?.style?.top || 0,
left: 0,
top: 0,
right: 0,
bottom: 0,
};
if (!node.style?.right && isNumber(node.style?.left || 0)) {
for (const value of path) {
if (value.style?.right || !isNumber(value.style?.left || 0)) {
offset.left = 0;
break;
}
offset.left = offset.left - Number(value.style?.left || 0);
}
// 水平方向:抵消祖先在对应锚定方向上的偏移量,初始值为节点自身的偏移
if (!hasPositionValue(node.style, 'right') && isNumber(node.style?.left || 0)) {
offset.left = accumulatePositionOffset(path, 'left', 'right', -1, Number(cur?.style?.left || 0));
} else if (hasPositionValue(node.style, 'right') && isNumber(node.style?.right || 0)) {
offset.right = accumulatePositionOffset(path, 'right', 'left', -1, Number(cur?.style?.right || 0));
}
if (!node.style?.bottom && isNumber(node.style?.top || 0)) {
for (const value of path) {
if (value.style?.bottom || !isNumber(value.style?.top || 0)) {
offset.top = 0;
break;
}
offset.top = offset.top - Number(value.style?.top || 0);
}
// 垂直方向:同上
if (!hasPositionValue(node.style, 'bottom') && isNumber(node.style?.top || 0)) {
offset.top = accumulatePositionOffset(path, 'top', 'bottom', -1, Number(cur?.style?.top || 0));
} else if (hasPositionValue(node.style, 'bottom') && isNumber(node.style?.bottom || 0)) {
offset.bottom = accumulatePositionOffset(path, 'bottom', 'top', -1, Number(cur?.style?.bottom || 0));
}
const style = node.style || {};
@ -269,6 +320,14 @@ export const Fixed2Other = async (
style.top = offset.top;
}
if (offset.right) {
style.right = offset.right;
}
if (offset.bottom) {
style.bottom = offset.bottom;
}
return {
...style,
position: 'absolute',
@ -460,11 +519,11 @@ export const resolveSelectedNode = (
throw new Error('没有ID无法选中');
}
const { node, parent, page } = getNodeInfoFn(id);
const { node, parent, page, path } = getNodeInfoFn(id);
if (!node) throw new Error('获取不到组件信息');
if (node.id === rootId) throw new Error('不能选根节点');
return { node, parent, page };
return { node, parent, page, path };
};
/**
@ -479,16 +538,16 @@ export const resolveSelectedNode = (
export const toggleFixedPosition = async (
dist: MNode,
src: MNode,
root: MApp,
path: MNode[],
getLayoutFn: (parent: MNode, node?: MNode | null) => Promise<Layout>,
): Promise<MNode> => {
const newConfig = cloneDeep(dist);
if (!isPop(src) && newConfig.style?.position) {
if (isFixed(newConfig.style) && !isFixed(src.style || {})) {
newConfig.style = change2Fixed(newConfig, root);
newConfig.style = change2Fixed(newConfig, path);
} else if (!isFixed(newConfig.style) && isFixed(src.style || {})) {
newConfig.style = await Fixed2Other(newConfig, root, getLayoutFn);
newConfig.style = await Fixed2Other(newConfig, path, getLayoutFn);
}
}

View File

@ -19,8 +19,9 @@
import { beforeAll, describe, expect, test } from 'vitest';
import { cloneDeep } from 'lodash-es';
import type { MApp } from '@tmagic/core';
import type { MApp, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import { getNodePath } from '@tmagic/utils';
import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
@ -553,7 +554,30 @@ describe('remove', () => {
});
describe('update', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
beforeAll(() => {
editorService.set('root', cloneDeep(root));
// dist 版 getNodeInfo 尚未返回 path测试中手动补齐以匹配 toggleFixedPosition 入参
const originalGetNodeInfo = editorService.getNodeInfo.bind(editorService);
editorService.getNodeInfo = (id, raw = true) => {
const info = originalGetNodeInfo(id, raw);
if (!Array.isArray(info.path) && info.node) {
const appRoot = editorService.get('root');
if (appRoot && `${id}` !== `${appRoot.id}`) {
const path = getNodePath(id, appRoot.items) as MNode[];
if (path.length) {
path.unshift(appRoot as unknown as MNode);
info.path = path;
} else {
info.path = [];
}
} else {
info.path = [];
}
}
return info;
};
});
test('正常', async () => {
await editorService.select(NodeId.PAGE_ID);

View File

@ -20,6 +20,7 @@ import { describe, expect, test, vi } from 'vitest';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import { getNodePath } from '@tmagic/utils';
import type { EditorNodeInfo } from '@editor/type';
import { LayerOffset, Layout } from '@editor/type';
@ -342,20 +343,31 @@ const mockRoot: MApp = {
],
};
/** 构建从 root 到目标节点的路径(含 root 与目标节点),与 getNodeInfo 返回的 path 一致 */
const buildNodePath = (root: MApp, nodeId: string | number): MNode[] => {
const path = getNodePath(nodeId, root.items) as MNode[];
if (path.length) {
path.unshift(root as unknown as MNode);
}
return path;
};
const mockNodePath = buildNodePath(mockRoot, 'node_1');
const mockGetNodeInfo = (id: string | number): EditorNodeInfo => {
const page = mockRoot.items[0];
if (`${id}` === `${mockRoot.id}`) {
return { node: mockRoot as unknown as MNode, parent: null, page: null };
return { node: mockRoot as unknown as MNode, parent: null, page: null, path: [] };
}
if (`${id}` === `${page.id}`) {
return { node: page, parent: mockRoot as unknown as MContainer, page: page as any };
return { node: page, parent: mockRoot as unknown as MContainer, page: page as any, path: [] };
}
const items = (page as MContainer).items || [];
const node = items.find((n: MNode) => `${n.id}` === `${id}`);
if (node) {
return { node, parent: page as MContainer, page: page as any };
return { node, parent: page as MContainer, page: page as any, path: buildNodePath(mockRoot, id) };
}
return { node: null, parent: null, page: null };
return { node: null, parent: null, page: null, path: [] };
};
describe('resolveSelectedNode', () => {
@ -402,7 +414,7 @@ describe('toggleFixedPosition', () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
const result = await editor.toggleFixedPosition(dist, src, mockNodePath, getLayoutFn);
expect(result.style?.position).toBe('fixed');
expect(result).not.toBe(dist);
});
@ -411,7 +423,7 @@ describe('toggleFixedPosition', () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
const result = await editor.toggleFixedPosition(dist, src, [...mockNodePath], getLayoutFn);
expect(result.style?.position).toBe('absolute');
});
@ -419,7 +431,7 @@ describe('toggleFixedPosition', () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 30, left: 40 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
const result = await editor.toggleFixedPosition(dist, src, mockNodePath, getLayoutFn);
expect(result.style?.top).toBe(30);
expect(result.style?.left).toBe(40);
});
@ -433,7 +445,7 @@ describe('toggleFixedPosition', () => {
};
const dist: MNode = { id: 'node_1', type: 'pop', style: { position: 'fixed', top: 10, left: 20 }, name: 'pop' };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
const result = await editor.toggleFixedPosition(dist, src, mockNodePath, getLayoutFn);
expect(result.style?.position).toBe('fixed');
});
@ -441,7 +453,7 @@ describe('toggleFixedPosition', () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute' } };
const dist: MNode = { id: 'node_1', type: 'text', style: { width: 100 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
const result = await editor.toggleFixedPosition(dist, src, mockNodePath, getLayoutFn);
expect(result.style?.position).toBeUndefined();
});
@ -449,7 +461,7 @@ describe('toggleFixedPosition', () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
const result = await editor.toggleFixedPosition(dist, src, mockNodePath, getLayoutFn);
expect(result).not.toBe(dist);
expect(dist.style?.top).toBe(20);
});
@ -680,18 +692,19 @@ describe('classifyDragSources', () => {
node: container1.items.find((n) => `${n.id}` === `${id}`) ?? null,
parent: container1,
page,
path: [],
};
}
if (`${id}` === 'c3') {
return { node: child3, parent: container2, page };
return { node: child3, parent: container2, page, path: [] };
}
if (`${id}` === 'cont1') {
return { node: container1, parent: page, page };
return { node: container1, parent: page, page, path: [] };
}
if (`${id}` === 'cont2') {
return { node: container2, parent: page, page };
return { node: container2, parent: page, page, path: [] };
}
return { node: null, parent: null, page: null };
return { node: null, parent: null, page: null, path: [] };
};
return { root, getNodeInfo };
@ -750,9 +763,10 @@ describe('classifyDragSources', () => {
node: { id: 'c1', type: 'text' },
parent: targetParent,
page: { id: 'page_1', type: NodeType.PAGE, items: [] } as any,
path: [],
};
}
return { node: null, parent: null, page: null };
return { node: null, parent: null, page: null, path: [] };
});
expect(result.sameParentIndices).toEqual([0]);
expect(result.crossParentConfigs).toHaveLength(0);
@ -860,20 +874,20 @@ describe('change2Fixed / Fixed2Other', () => {
test('change2Fixed 累加路径上的 left/top', () => {
const node = root.items[0]!.items![0] as MNode;
const style = editor.change2Fixed(node, root);
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
expect(style.left).toBeGreaterThan(0);
expect(style.top).toBeGreaterThan(0);
});
test('Fixed2Other 转回 absolute', async () => {
const node = root.items[0]!.items![0] as MNode;
const style = await editor.Fixed2Other(node, root, async () => Layout.ABSOLUTE);
const style = await editor.Fixed2Other(node, buildNodePath(root, node.id), async () => Layout.ABSOLUTE);
expect(style.position).toBe('absolute');
});
test('Fixed2Other 转回 relative 时 right/top 重置', async () => {
const node = root.items[0]!.items![0] as MNode;
const style = await editor.Fixed2Other(node, root, async () => Layout.RELATIVE);
const style = await editor.Fixed2Other(node, buildNodePath(root, node.id), async () => Layout.RELATIVE);
expect(style.position).toBe('relative');
});
});
@ -941,6 +955,54 @@ describe('补充getInitPositionStyle / setLayout / setChildrenLayout', () =>
expect(style.left).toBe(0);
});
test('Layout.ABSOLUTE - left/right 同时存在left 有效 right 无效时删掉 right', () => {
const style = editor.getInitPositionStyle({ left: 20, right: '' }, Layout.ABSOLUTE);
expect(style.left).toBe(20);
expect('right' in style).toBe(false);
});
test('Layout.ABSOLUTE - left/right 同时存在right 有效 left 无效时删掉 left', () => {
const style = editor.getInitPositionStyle({ left: 0, right: 30 }, Layout.ABSOLUTE);
expect(style.right).toBe(30);
expect('left' in style).toBe(false);
});
test('Layout.ABSOLUTE - left/right 都无效时优先保留值为 0 的right=0', () => {
const style = editor.getInitPositionStyle({ left: '', right: 0 }, Layout.ABSOLUTE);
expect(style.right).toBe(0);
expect('left' in style).toBe(false);
});
test('Layout.ABSOLUTE - left/right 都无效且都非 0 时默认保留 left', () => {
const style = editor.getInitPositionStyle({ left: undefined, right: null }, Layout.ABSOLUTE);
expect('left' in style).toBe(true);
expect('right' in style).toBe(false);
});
test('Layout.ABSOLUTE - left/right 都有效时都保留', () => {
const style = editor.getInitPositionStyle({ left: 10, right: 20 }, Layout.ABSOLUTE);
expect(style.left).toBe(10);
expect(style.right).toBe(20);
});
test('Layout.ABSOLUTE - top/bottom 同时存在bottom 有效 top 无效时删掉 top', () => {
const style = editor.getInitPositionStyle({ top: 0, bottom: 50 }, Layout.ABSOLUTE);
expect(style.bottom).toBe(50);
expect('top' in style).toBe(false);
});
test('Layout.ABSOLUTE - top/bottom 都无效时优先保留值为 0 的top=0', () => {
const style = editor.getInitPositionStyle({ top: 0, bottom: undefined }, Layout.ABSOLUTE);
expect(style.top).toBe(0);
expect('bottom' in style).toBe(false);
});
test('Layout.ABSOLUTE - 仅存在 left 时不处理冲突', () => {
const style = editor.getInitPositionStyle({ left: '' }, Layout.ABSOLUTE);
expect(style.left).toBe('');
expect('right' in style).toBe(false);
});
test('Layout.RELATIVE - 走 getRelativeStyle', () => {
const style = editor.getInitPositionStyle({ color: 'red' }, Layout.RELATIVE);
expect(style.position).toBe('relative');
@ -1210,7 +1272,7 @@ describe('补充change2Fixed 边界', () => {
],
};
const node = root.items[0]!.items![0] as MNode;
const style = editor.change2Fixed(node, root);
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
expect(style.left).toBe(5);
});
@ -1228,9 +1290,86 @@ describe('补充change2Fixed 边界', () => {
],
};
const node = root.items[0]!.items![0] as MNode;
const style = editor.change2Fixed(node, root);
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
expect(style.left).toBeUndefined();
});
test('节点以 right 锚定时累加路径上的 right', () => {
const root: MApp = {
id: 'app',
type: NodeType.ROOT,
items: [
{
id: 'p1',
type: NodeType.PAGE,
style: { right: 10 },
items: [{ id: 'b', type: 'text', style: { position: 'absolute', right: 5 } }],
} as any,
],
};
const node = root.items[0]!.items![0] as MNode;
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
expect(style.right).toBe(15);
expect(style.left).toBeUndefined();
});
test('节点以 bottom 锚定时累加路径上的 bottom', () => {
const root: MApp = {
id: 'app',
type: NodeType.ROOT,
items: [
{
id: 'p1',
type: NodeType.PAGE,
style: { bottom: 20 },
items: [{ id: 'b', type: 'text', style: { position: 'absolute', bottom: 5 } }],
} as any,
],
};
const node = root.items[0]!.items![0] as MNode;
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
expect(style.bottom).toBe(25);
expect(style.top).toBeUndefined();
});
test('right: 0 不被误判为未设置 right仍走 right 分支', () => {
const root: MApp = {
id: 'app',
type: NodeType.ROOT,
items: [
{
id: 'p1',
type: NodeType.PAGE,
style: { left: 10, top: 20 },
items: [{ id: 'b', type: 'text', style: { position: 'absolute', right: 0 } }],
} as any,
],
};
const node = root.items[0]!.items![0] as MNode;
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
// 父节点为 left 锚定,无法换算 right放弃补偿保持 right: 0 且不写入 left
expect(style.left).toBeUndefined();
expect(style.right).toBe(0);
});
test('Fixed2Other 以 right 锚定时抵消路径上的 right', async () => {
const root: MApp = {
id: 'app',
type: NodeType.ROOT,
items: [
{
id: 'p1',
type: NodeType.PAGE,
style: { right: 10 },
items: [{ id: 'b', type: 'text', style: { position: 'absolute', right: 15 } }],
} as any,
],
};
const node = root.items[0]!.items![0] as MNode;
const style = await editor.Fixed2Other(node, buildNodePath(root, node.id), async () => Layout.ABSOLUTE);
expect(style.position).toBe('absolute');
expect(style.right).toBe(5);
});
});
describe('补充classifyDragSources 同父容器索引异常', () => {
@ -1255,7 +1394,7 @@ describe('toggleFixedPosition', () => {
const result = await editor.toggleFixedPosition(
{ id: 'a', type: 'text', style: { position: 'absolute', top: 0, left: 0 } } as any,
{ id: 'a', type: 'text', style: { position: 'fixed' } } as any,
root,
[...buildNodePath(root, 'a')],
async () => Layout.ABSOLUTE,
);
expect(result.style?.position).toBeDefined();
@ -1277,7 +1416,7 @@ describe('toggleFixedPosition', () => {
const result = await editor.toggleFixedPosition(
{ id: 'a', type: 'text', style: { position: 'fixed', left: 0, top: 0 } } as any,
{ id: 'a', type: 'text', style: { position: 'absolute' } } as any,
root,
buildNodePath(root, 'a'),
async () => Layout.ABSOLUTE,
);
expect(result.style?.position).toBe('fixed');

View File

@ -121,6 +121,7 @@ export const getNodeInfo = (id: Id, root: { id: Id; items?: MNode[] } | null, sk
node: null,
parent: null,
page: null,
path: [],
};
if (!root) return info;
@ -131,6 +132,7 @@ export const getNodeInfo = (id: Id, root: { id: Id; items?: MNode[] } | null, sk
}
const path = getNodePath(id, root.items, skip);
info.path = path;
if (!path.length) return info;