From 7884a05124413443372826991302fca06607a2f4 Mon Sep 17 00:00:00 2001 From: Ni55aN Date: Wed, 8 Jul 2026 10:30:50 +0000 Subject: [PATCH] feat(order): add z-index node ordering extension Add zIndexNodesOrder as a click-safe alternative to simpleNodesOrder. Split order strategies into dedicated modules with unit tests. Co-authored-by: Cursor --- src/extensions/index.ts | 2 +- src/extensions/order/index.ts | 2 + src/extensions/{order.ts => order/simple.ts} | 5 +- src/extensions/order/z-index.ts | 59 +++++++ test/order.test.ts | 157 +++++++++++++++++++ 5 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 src/extensions/order/index.ts rename src/extensions/{order.ts => order/simple.ts} (85%) create mode 100644 src/extensions/order/z-index.ts create mode 100644 test/order.test.ts diff --git a/src/extensions/index.ts b/src/extensions/index.ts index a8ac172..e90cb5e 100644 --- a/src/extensions/index.ts +++ b/src/extensions/index.ts @@ -4,7 +4,7 @@ * @module Extensions */ export { getBoundingBox } from './bounding-box' -export { simpleNodesOrder } from './order' +export { simpleNodesOrder, zIndexNodesOrder } from './order' export type { Params as Restrictor } from './restrictor' export { restrictor } from './restrictor' export { accumulateOnCtrl, selectableNodes, Selector, selector } from './selectable' diff --git a/src/extensions/order/index.ts b/src/extensions/order/index.ts new file mode 100644 index 0000000..afb8d32 --- /dev/null +++ b/src/extensions/order/index.ts @@ -0,0 +1,2 @@ +export { simpleNodesOrder } from './simple' +export { zIndexNodesOrder } from './z-index' diff --git a/src/extensions/order.ts b/src/extensions/order/simple.ts similarity index 85% rename from src/extensions/order.ts rename to src/extensions/order/simple.ts index a2d0d14..7b68de9 100644 --- a/src/extensions/order.ts +++ b/src/extensions/order/simple.ts @@ -1,9 +1,10 @@ import { BaseSchemes } from 'rete' -import { BaseArea, BaseAreaPlugin } from '../base' +import { BaseArea, BaseAreaPlugin } from '../../base' /** - * Simple nodes order extension + * Simple nodes order extension. + * Moves picked nodes in DOM tree to render them above others. * @param base The base area plugin * @listens nodepicked * @listens connectioncreated diff --git a/src/extensions/order/z-index.ts b/src/extensions/order/z-index.ts new file mode 100644 index 0000000..47d6d60 --- /dev/null +++ b/src/extensions/order/z-index.ts @@ -0,0 +1,59 @@ +import { BaseSchemes } from 'rete' + +import { BaseArea, BaseAreaPlugin } from '../../base' + +const CONNECTION_Z_INDEX = 0 +const NODE_BASE_Z_INDEX = 1 + +/** + * Node ordering extension that relies only on z-index. + * Use this extension when click handlers inside nodes must stay stable. + * @param base The base area plugin + * @listens nodecreated + * @listens nodepicked + * @listens connectioncreated + */ +export function zIndexNodesOrder(base: BaseAreaPlugin) { + const area = base as BaseAreaPlugin> + let nodeZIndex = NODE_BASE_Z_INDEX + + const setNodeBaseZIndex = (id: string) => { + const view = area.nodeViews.get(id) + + if (view) { + view.element.style.zIndex = String(NODE_BASE_Z_INDEX) + } + } + + const bringNodeToFront = (id: string) => { + const view = area.nodeViews.get(id) + + if (view) { + nodeZIndex += 1 + view.element.style.zIndex = String(nodeZIndex) + } + } + + const setConnectionBaseZIndex = (id: string) => { + const view = area.connectionViews.get(id) + + if (view) { + view.element.style.zIndex = String(CONNECTION_Z_INDEX) + } + } + + area.addPipe(context => { + if (!context || typeof context !== 'object' || !('type' in context)) return context + + if (context.type === 'nodecreated') { + setNodeBaseZIndex(context.data.id) + } + if (context.type === 'nodepicked') { + bringNodeToFront(context.data.id) + } + if (context.type === 'connectioncreated') { + setConnectionBaseZIndex(context.data.id) + } + return context + }) +} diff --git a/test/order.test.ts b/test/order.test.ts new file mode 100644 index 0000000..73fb1ec --- /dev/null +++ b/test/order.test.ts @@ -0,0 +1,157 @@ +import { simpleNodesOrder, zIndexNodesOrder } from '../src/extensions/order' + +type Pipe = (context: unknown) => unknown + +function createMockBase() { + const pipes: Pipe[] = [] + const reorder = jest.fn() + const holder = { firstChild: null } as unknown as HTMLElement + const add = jest.fn() + const remove = jest.fn() + + const base = { + nodeViews: new Map(), + connectionViews: new Map(), + area: { + content: { + holder, + reorder, + add, + remove + } + }, + addPipe: (pipe: Pipe) => { + pipes.push(pipe) + } + } + + return { base, pipes, reorder, holder, add, remove } +} + +describe('zIndexNodesOrder', () => { + it('sets baseline and raises picked nodes by z-index', () => { + const { base, pipes, reorder } = createMockBase() + const firstNode = { style: { zIndex: '' } } as unknown as HTMLElement + const secondNode = { style: { zIndex: '' } } as unknown as HTMLElement + const connection = { style: { zIndex: '' } } as unknown as HTMLElement + + base.nodeViews.set('n1', { element: firstNode }) + base.nodeViews.set('n2', { element: secondNode }) + base.connectionViews.set('c1', { element: connection }) + + zIndexNodesOrder(base as never) + + const [pipe] = pipes + + pipe({ type: 'nodecreated', data: { id: 'n1' } }) + pipe({ type: 'nodecreated', data: { id: 'n2' } }) + pipe({ type: 'nodepicked', data: { id: 'n1' } }) + pipe({ type: 'nodepicked', data: { id: 'n2' } }) + + expect(firstNode.style.zIndex).toBe('2') + expect(secondNode.style.zIndex).toBe('3') + expect(reorder).not.toHaveBeenCalled() + }) + + it('places connections below nodes without DOM reordering', () => { + const { base, pipes, reorder } = createMockBase() + const connection = { style: { zIndex: '' } } as unknown as HTMLElement + + base.connectionViews.set('c1', { element: connection }) + + zIndexNodesOrder(base as never) + + const [pipe] = pipes + + pipe({ type: 'connectioncreated', data: { id: 'c1' } }) + + expect(connection.style.zIndex).toBe('0') + expect(reorder).not.toHaveBeenCalled() + }) + + it('raises the same node on repeated picks', () => { + const { base, pipes } = createMockBase() + const node = { style: { zIndex: '' } } as unknown as HTMLElement + + base.nodeViews.set('n1', { element: node }) + + zIndexNodesOrder(base as never) + + const [pipe] = pipes + + pipe({ type: 'nodecreated', data: { id: 'n1' } }) + pipe({ type: 'nodepicked', data: { id: 'n1' } }) + pipe({ type: 'nodepicked', data: { id: 'n1' } }) + + expect(node.style.zIndex).toBe('3') + }) + + it('keeps newly created nodes at baseline even after previous picks', () => { + const { base, pipes } = createMockBase() + const firstNode = { style: { zIndex: '' } } as unknown as HTMLElement + const secondNode = { style: { zIndex: '' } } as unknown as HTMLElement + + base.nodeViews.set('n1', { element: firstNode }) + + zIndexNodesOrder(base as never) + + const [pipe] = pipes + + pipe({ type: 'nodecreated', data: { id: 'n1' } }) + pipe({ type: 'nodepicked', data: { id: 'n1' } }) + + base.nodeViews.set('n2', { element: secondNode }) + pipe({ type: 'nodecreated', data: { id: 'n2' } }) + + expect(firstNode.style.zIndex).toBe('2') + expect(secondNode.style.zIndex).toBe('1') + }) + + it('ignores missing views without errors', () => { + const { base, pipes, reorder } = createMockBase() + + zIndexNodesOrder(base as never) + + const [pipe] = pipes + + expect(() => pipe({ type: 'nodecreated', data: { id: 'unknown-node' } })).not.toThrow() + expect(() => pipe({ type: 'nodepicked', data: { id: 'unknown-node' } })).not.toThrow() + expect(() => pipe({ type: 'connectioncreated', data: { id: 'unknown-connection' } })).not.toThrow() + expect(reorder).not.toHaveBeenCalled() + }) + + it('returns input context for unsupported payloads', () => { + const { base, pipes } = createMockBase() + + zIndexNodesOrder(base as never) + + const [pipe] = pipes + const invalidContext = null + const unknownContext = { type: 'other', data: { id: 'n1' } } + + expect(pipe(invalidContext)).toBe(invalidContext) + expect(pipe(unknownContext)).toBe(unknownContext) + }) +}) + +describe('simpleNodesOrder', () => { + it('keeps DOM reorder behavior for picked nodes and new connections', () => { + const { base, pipes, reorder, holder } = createMockBase() + const node = { style: { zIndex: '' } } as unknown as HTMLElement + const connection = { style: { zIndex: '' } } as unknown as HTMLElement + + base.nodeViews.set('n1', { element: node }) + base.connectionViews.set('c1', { element: connection }) + + simpleNodesOrder(base as never) + + const [pipe] = pipes + + pipe({ type: 'nodepicked', data: { id: 'n1' } }) + pipe({ type: 'connectioncreated', data: { id: 'c1' } }) + + expect(reorder).toHaveBeenCalledTimes(2) + expect(reorder).toHaveBeenNthCalledWith(1, node, null) + expect(reorder).toHaveBeenNthCalledWith(2, connection, holder.firstChild) + }) +})