From ed2679268d13b8cd22260939d9355f7e928ae5c6 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 27 Feb 2026 22:19:02 +0300 Subject: [PATCH] fix: fix zIndex-ing block --- docs/rendering/rendering-mechanism.md | 37 +++++- e2e/page-objects/GraphBlockComponentObject.ts | 25 ++++ e2e/page-objects/GraphPageObject.ts | 69 +++------- e2e/tests/block/block-zindex.spec.ts | 125 ++++++++++++++++++ src/components/canvas/blocks/Block.ts | 4 + src/lib/Tree.ts | 30 ++++- src/services/HitTest.ts | 58 +++++--- src/store/block/BlocksList.ts | 4 + 8 files changed, 270 insertions(+), 82 deletions(-) create mode 100644 e2e/tests/block/block-zindex.spec.ts diff --git a/docs/rendering/rendering-mechanism.md b/docs/rendering/rendering-mechanism.md index b0b054b5..4724aa40 100644 --- a/docs/rendering/rendering-mechanism.md +++ b/docs/rendering/rendering-mechanism.md @@ -86,12 +86,39 @@ For more details on this process, see [Component Lifecycle](../system/component- ## Z-Index Management -Components support z-index ordering through the Tree class: +The z-index system uses a **two-tier stacking model** that separates component *type* priority from visual *stacking order* within the same type. -1. Each component has a z-index value (default: 1) -2. Parent components maintain z-index groups to sort children -3. Children are rendered in z-index order during traversal -4. Z-index changes trigger re-ordering in the parent +### Tier 1: zIndex — Component Type Priority + +`zIndex` encodes the architectural importance of a component class. Different component types are assigned different base values: +- Blocks have a higher `DEFAULT_Z_INDEX` than connections +- Connections render above group backgrounds +- When two components of different types overlap, the one with the higher `zIndex` always wins, regardless of render order + +This value is set per component class in `graphConfig.ts` and changed via `updateZIndex()` (e.g., when a block is selected or dragged, its zIndex is incremented to rise above its unselected peers). + +### Tier 2: renderOrder — Visual Stacking Within a Type + +`renderOrder` determines stacking *within the same zIndex tier*. It is assigned by the scheduler during `_walkDown` traversal: the later a component is visited in the tree, the higher its `renderOrder`. + +`Tree.updateChildZIndex()` is the key mechanism: every time a component's `zIndex` changes, the child is **re-inserted at the end** of the parent's `children` Set (`delete` + `add`). Because the `children` Set preserves insertion order, the most recently interacted component gets the highest `renderOrder` in its tier — it appears on top visually. + +This means drag-and-drop and selection automatically bring the interacted block to the front of its z-group without any extra bookkeeping. + +### Hit Testing + +`HitTest.testHitBox()` sorts candidates by both criteria (descending — highest = topmost): +1. First by `zIndex` (type priority) +2. Then by `renderOrder` (within-type visual stacking) + +The first element of the returned array is the component the user most likely intended to interact with. + +### Summary + +| Criterion | Controls | Set by | +|---|---|---| +| `zIndex` | Which component *type* wins | `updateZIndex()`, class constant | +| `renderOrder` | Which instance wins *within its type* | Scheduler traversal order + `updateChildZIndex()` | ## Integration with Animation diff --git a/e2e/page-objects/GraphBlockComponentObject.ts b/e2e/page-objects/GraphBlockComponentObject.ts index cdc76b88..c6d779b7 100644 --- a/e2e/page-objects/GraphBlockComponentObject.ts +++ b/e2e/page-objects/GraphBlockComponentObject.ts @@ -137,4 +137,29 @@ export class GraphBlockComponentObject { getId(): string { return this.blockId; } + + /** + * Get the block's viewState (zIndex and renderOrder) from the canvas component. + * zIndex: 1 = normal, 2 = elevated (selected or being dragged) + * order: position in the sorted render list + */ + async getViewState(): Promise<{ zIndex: number; order: number }> { + return await this.page.evaluate((id) => { + const blockState = window.graph.blocks.getBlockState(id); + if (!blockState) throw new Error(`Block ${id} not found`); + const viewComponent = blockState.$viewComponent.value; + if (!viewComponent) throw new Error(`Block ${id} has no canvas view component`); + return viewComponent.$viewState.value; + }, this.blockId); + } + + /** + * Get combined z-index value (zIndex + renderOrder). + * Higher value means the block renders on top of blocks with lower values. + * This mirrors the CSS z-index calculation used in the React/HTML layer. + */ + async getCombinedZIndex(): Promise { + const { zIndex, order } = await this.getViewState(); + return zIndex + order; + } } diff --git a/e2e/page-objects/GraphPageObject.ts b/e2e/page-objects/GraphPageObject.ts index d439ffc7..7565c95c 100644 --- a/e2e/page-objects/GraphPageObject.ts +++ b/e2e/page-objects/GraphPageObject.ts @@ -80,10 +80,7 @@ export class GraphPageObject { }, config); // Wait for graph to be ready - await this.page.waitForFunction( - () => window.graphInitialized === true, - { timeout: 5000 } - ); + await this.page.waitForFunction(() => window.graphInitialized === true, { timeout: 5000 }); // Wait for initial render frames await this.waitForFrames(3); @@ -97,14 +94,11 @@ export class GraphPageObject { await this.page.evaluate((frameCount) => { return new Promise((resolve) => { const { schedule, ESchedulerPriority } = window.GraphModule; - schedule( - () => resolve(), - { - priority: ESchedulerPriority.LOWEST, - frameInterval: frameCount, - once: true, - } - ); + schedule(() => resolve(), { + priority: ESchedulerPriority.LOWEST, + frameInterval: frameCount, + once: true, + }); }); }, count); } @@ -118,7 +112,7 @@ export class GraphPageObject { return new Promise((resolve, reject) => { const startTime = Date.now(); const { schedule, ESchedulerPriority } = window.GraphModule; - + const check = () => { if (Date.now() - startTime > timeoutMs) { reject(new Error(`Scheduler did not become idle within ${timeoutMs}ms`)); @@ -126,14 +120,11 @@ export class GraphPageObject { } // Use graph's scheduler to wait for a frame - schedule( - () => resolve(), - { - priority: ESchedulerPriority.LOWEST, - frameInterval: 2, - once: true, - } - ); + schedule(() => resolve(), { + priority: ESchedulerPriority.LOWEST, + frameInterval: 2, + once: true, + }); }; check(); @@ -149,11 +140,7 @@ export class GraphPageObject { ({ eventName, timeout }) => { return new Promise((resolve, reject) => { const timer = setTimeout(() => { - reject( - new Error( - `Event ${eventName} did not fire within ${timeout}ms` - ) - ); + reject(new Error(`Event ${eventName} did not fire within ${timeout}ms`)); }, timeout); const handler = (event: any) => { @@ -205,9 +192,7 @@ export class GraphPageObject { if (options?.shift) { modifierKey = "Shift"; } else if (options?.ctrl || options?.meta) { - const isMac = await this.page.evaluate(() => - navigator.platform.toLowerCase().includes("mac") - ); + const isMac = await this.page.evaluate(() => navigator.platform.toLowerCase().includes("mac")); modifierKey = isMac ? "Meta" : "Control"; } @@ -232,11 +217,7 @@ export class GraphPageObject { /** * Double click at world coordinates */ - async doubleClick( - worldX: number, - worldY: number, - options?: { waitFrames?: number } - ): Promise { + async doubleClick(worldX: number, worldY: number, options?: { waitFrames?: number }): Promise { const { screenX, screenY, canvasBounds } = await this.page.evaluate( ({ wx, wy }) => { const [sx, sy] = window.graph.cameraService.getAbsoluteXY(wx, wy); @@ -264,11 +245,7 @@ export class GraphPageObject { /** * Hover at world coordinates */ - async hover( - worldX: number, - worldY: number, - options?: { waitFrames?: number } - ): Promise { + async hover(worldX: number, worldY: number, options?: { waitFrames?: number }): Promise { const { screenX, screenY, canvasBounds } = await this.page.evaluate( ({ wx, wy }) => { const [sx, sy] = window.graph.cameraService.getAbsoluteXY(wx, wy); @@ -305,10 +282,7 @@ export class GraphPageObject { ): Promise { const { fromScreen, toScreen, canvasBounds } = await this.page.evaluate( ({ fx, fy, tx, ty }) => { - const [fromSX, fromSY] = window.graph.cameraService.getAbsoluteXY( - fx, - fy - ); + const [fromSX, fromSY] = window.graph.cameraService.getAbsoluteXY(fx, fy); const [toSX, toSY] = window.graph.cameraService.getAbsoluteXY(tx, ty); const canvas = window.graph.getGraphCanvas(); @@ -367,17 +341,12 @@ export class GraphPageObject { /** * Check if connection exists between two blocks */ - async hasConnectionBetween( - sourceBlockId: string, - targetBlockId: string - ): Promise { + async hasConnectionBetween(sourceBlockId: string, targetBlockId: string): Promise { return await this.page.evaluate( ({ sourceBlockId, targetBlockId }) => { const connections = window.graph.connections.toJSON(); return connections.some( - (conn: any) => - conn.sourceBlockId === sourceBlockId && - conn.targetBlockId === targetBlockId + (conn: any) => conn.sourceBlockId === sourceBlockId && conn.targetBlockId === targetBlockId ); }, { sourceBlockId, targetBlockId } diff --git a/e2e/tests/block/block-zindex.spec.ts b/e2e/tests/block/block-zindex.spec.ts new file mode 100644 index 00000000..cfe51e42 --- /dev/null +++ b/e2e/tests/block/block-zindex.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from "@playwright/test"; +import { GraphPageObject } from "../../page-objects/GraphPageObject"; + +/** + * Concrete overlap scenario for z-index and hit-test ordering. + * + * World layout: + * + * y=50 ┌──────B──────┐ + * y=100 ┌──A──┐ │ ┌──C──┐ + * │ │ (B∩A) │ │ │ + * y=150 │ ├───────┼──────────┤ │ ← A→C connection at y=150 + * │ │ │ │ │ + * y=200 └─────┘ └──────────┘─────┘ + * x=50 x=150 x=300 x=400 x=550 + * + * B overlaps: + * - A's right side at x=150..200 + * - Connection A→C from x=200..300 at y=150 + * + * Overlap test point: (250, 150) — inside B AND on the A→C connection line + * + * Tests validate: + * 1. Click A → A renders above B + * 2. Click B → B renders above A + * 3. Hover/click at (250,150): B occludes the connection — B is hit, not A→C + * 4. Drag B over C → B renders above C + */ + +const BLOCK_A = { + id: "block-a", + is: "Block" as const, + x: 50, + y: 100, + width: 150, + height: 100, + name: "A", + anchors: [], + selected: false, +}; +const BLOCK_B = { + id: "block-b", + is: "Block" as const, + x: 150, + y: 50, + width: 150, + height: 150, + name: "B", + anchors: [], + selected: false, +}; +const BLOCK_C = { + id: "block-c", + is: "Block" as const, + x: 400, + y: 100, + width: 150, + height: 100, + name: "C", + anchors: [], + selected: false, +}; + +const CONN_AC = { id: "conn-ac", sourceBlockId: "block-a", targetBlockId: "block-c" }; + +test.describe("Z-Index and hit-test: overlap scenario", () => { + let graphPO: GraphPageObject; + + test.beforeEach(async ({ page }) => { + graphPO = new GraphPageObject(page); + await graphPO.initialize({ + blocks: [BLOCK_A, BLOCK_B, BLOCK_C], + connections: [CONN_AC], + settings: { canDrag: "all", dragThreshold: 0 }, + }); + }); + + test("clicking A brings it above B", async () => { + const blockA = graphPO.getBlockCOM("block-a"); + const blockB = graphPO.getBlockCOM("block-b"); + + await blockA.click(); + await graphPO.waitForFrames(3); + + expect(await blockA.getCombinedZIndex()).toBeGreaterThan(await blockB.getCombinedZIndex()); + }); + + test("clicking B brings it above A", async () => { + const blockA = graphPO.getBlockCOM("block-a"); + const blockB = graphPO.getBlockCOM("block-b"); + + // First bring A on top + await blockA.click(); + await graphPO.waitForFrames(3); + expect(await blockA.getCombinedZIndex()).toBeGreaterThan(await blockB.getCombinedZIndex()); + + // Now click B — B should take the top spot + await blockB.click(); + await graphPO.waitForFrames(3); + + expect(await blockB.getCombinedZIndex()).toBeGreaterThan(await blockA.getCombinedZIndex()); + }); + + test("B occludes connection A→C: clicking at overlap point (250, 150) selects B, not the connection", async () => { + const blockB = graphPO.getBlockCOM("block-b"); + + // (250, 150) is inside B and lies on the A→C connection line. + // Since blocks have a higher zIndex than connections, B should win the hit test. + await graphPO.click(250, 150); + await graphPO.waitForFrames(3); + + expect(await blockB.isSelected()).toBe(true); + }); + + test("dragging B over C: B renders above C", async () => { + const blockB = graphPO.getBlockCOM("block-b"); + const blockC = graphPO.getBlockCOM("block-c"); + + // Drag B so it overlaps C; drag triggers updateChildZIndex which brings B to front + const cCenter = await blockC.getWorldCenter(); + await blockB.dragTo(cCenter); + + expect(await blockB.getCombinedZIndex()).toBeGreaterThan(await blockC.getCombinedZIndex()); + }); +}); diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index 9f5026ca..289b01eb 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -183,6 +183,9 @@ export class Block { protected zIndexGroups: Map> = new Map(); protected zIndexChildrenCache = cache(() => { + // Sort by zIndex group, then within each group maintain the original + // insertion order from the children Set (which is stable and never + // changes when a block temporarily moves to a higher zIndex group). + // This ensures a selected/dragged block returns to its natural position + // in the render stack after being deselected or released. + const childrenInOrder = this.getChildrenArray(); return Array.from(this.zIndexGroups.keys()) .sort((a, b) => a - b) - .map((index) => Array.from(this.zIndexGroups.get(index) || [])) - .flat(2) as Tree[]; + .flatMap((index) => { + const group = this.zIndexGroups.get(index)!; + return childrenInOrder.filter((node) => group.has(node)); + }) as Tree[]; }); public renderOrder = 0; @@ -80,15 +88,27 @@ export class Tree { if (this.zIndex === index) { return; } + const oldZIndex = this.zIndex; this.zIndex = index; - this.parent?.updateChildZIndex(this); + this.parent?.updateChildZIndex(this, oldZIndex); } - public updateChildZIndex(child: Tree) { + public updateChildZIndex(child: Tree, oldZIndex: number) { if (!this.children.has(child)) { return; } - this.removeZIndex(child); + const set = this.zIndexGroups.get(oldZIndex); + if (set) { + set.delete(child); + if (!set.size) { + this.zIndexGroups.delete(oldZIndex); + } + } + // Bring child to the end of the insertion-order Set so it gets the + // highest renderOrder within its new zIndex group after re-sorting. + this.children.delete(child); + this.children.add(child); + this.childrenDirty = true; this.addInZIndex(child); } diff --git a/src/services/HitTest.ts b/src/services/HitTest.ts index 7a9fc4ca..bc864e11 100644 --- a/src/services/HitTest.ts +++ b/src/services/HitTest.ts @@ -300,43 +300,57 @@ export class HitTest extends Emitter { } /** - * Test hit box intersection with interactive elements and sort by z-index - * @param item Hit box data to test - * @returns Array of hit components sorted by z-index - */ - /** - * Test hit box intersection with interactive elements and sort by z-index - * @param item Hit box data to test - * @returns Array of hit components sorted by z-index + * Test hit box intersection with interactive elements and sort by visual stacking order. + * + * Sorting uses two criteria (both descending — highest value = topmost component): + * + * 1. **zIndex** — component *type* priority. Different component classes are assigned + * different base zIndex values to express their relative importance in the UI: + * blocks render above connections, which render above group backgrounds, etc. + * When two components of different types overlap, the one with the higher zIndex + * wins regardless of render order. + * + * 2. **renderOrder** — visual stacking order *within the same zIndex tier*. + * Set by the scheduler during tree traversal (`_walkDown`): the later a component + * is traversed, the higher its renderOrder. Because the most recently interacted + * component is moved to the end of the children Set (see `Tree.updateChildZIndex`), + * it gets the highest renderOrder in its tier and therefore appears on top visually. + * Hit testing mirrors this: the component that renders last (on top) is returned first. + * + * The first element of the returned array is the component the user most likely + * intended to interact with. + * + * @param item Hit box data to test (must include x, y as canvas click coordinates) + * @returns Components sorted by visual stacking order (topmost first) */ public testHitBox(item: HitBoxData): Component[] { - // Use interactive elements tree for hit testing const hitBoxes = this.interactiveTree.search(item); - const result = []; - - for (let i = 0; i < hitBoxes.length; i++) { - if (hitBoxes[i].item.onHitBox(item)) { - result.push(hitBoxes[i].item); + const result = hitBoxes.reduce((acc, hitbox) => { + if (hitbox.item.onHitBox(item)) { + acc.push(hitbox.item); } - } + return acc; + }, []); - const res = result.sort((a, b) => { - const aZIndex = typeof a.zIndex === "number" ? a.zIndex : -1; - const bZIndex = typeof b.zIndex === "number" ? b.zIndex : -1; + return result.sort((a, b) => { + const aZIndex = getNumberOr(a.zIndex, -1); + const bZIndex = getNumberOr(b.zIndex, -1); if (aZIndex !== bZIndex) { return bZIndex - aZIndex; } - const aOrder = typeof a.renderOrder === "number" ? a.renderOrder : -1; - const bOrder = typeof b.renderOrder === "number" ? b.renderOrder : -1; + const aOrder = getNumberOr(a.renderOrder, -1); + const bOrder = getNumberOr(b.renderOrder, -1); return bOrder - aOrder; }); - - return res; } } +function getNumberOr(number: unknown, orNumber: number): number { + return typeof number === "number" ? number : orNumber; +} + export class HitBox implements IHitBox { public destroyed = false; diff --git a/src/store/block/BlocksList.ts b/src/store/block/BlocksList.ts index 829184b2..b1a3eac7 100644 --- a/src/store/block/BlocksList.ts +++ b/src/store/block/BlocksList.ts @@ -301,6 +301,10 @@ export class BlockListStore { public setBlocks(blocks: TBlock[]) { const blockStates = blocks.map((block) => this.getOrCraeateBlockState(block)); this.applyBlocksState(blockStates); + // Sync selection state from the raw block data so that blocks initialized + // with selected:true are properly reflected in the selection bucket. + const selectedIds = blocks.filter((block) => block.selected).map((block) => block.id); + this.blockSelectionBucket.select(selectedIds, ESelectionStrategy.REPLACE); } protected getOrCraeateBlockState(block: TBlock) {