From 8cf51ffefd2ccbba80b0fa8bb9ead67119b99754 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 13 May 2026 05:38:14 +0300 Subject: [PATCH] perf(editor): use repaint previews while resizing --- packages/core/src/scene-graph/index.ts | 24 +++++- packages/vue/src/canvas/useCanvasInput.ts | 4 +- packages/vue/src/shared/input/resize.ts | 37 +++++++-- tests/e2e/editor/resize-performance.spec.ts | 92 +++++++++++++++++++++ 4 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/editor/resize-performance.spec.ts diff --git a/packages/core/src/scene-graph/index.ts b/packages/core/src/scene-graph/index.ts index 316f656d5..116df2f86 100644 --- a/packages/core/src/scene-graph/index.ts +++ b/packages/core/src/scene-graph/index.ts @@ -359,13 +359,29 @@ export class SceneGraph { ]) updateNodePositionPreview(id: string, x: number, y: number): void { + this.updateNodePreview(id, { x, y }) + } + + updateNodePreview(id: string, changes: Partial): void { const node = this.nodes.get(id) if (!node) return - if (node.x === x && node.y === y) return - this.absPosCache.clear() + if ((Object.keys(changes) as (keyof SceneNode)[]).every((key) => node[key] === changes[key])) { + return + } + const affectsLayout = Object.keys(changes).some((k) => SceneGraph.LAYOUT_AFFECTING_KEYS.has(k)) + if (affectsLayout) this.absPosCache.clear() + if ( + node.type === 'TEXT' && + node.textPicture && + Object.keys(changes).some((k) => SceneGraph.TEXT_PICTURE_KEYS.has(k)) + ) { + node.textPicture = null + } + if (changes.vectorNetwork) { + changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) } + } this.positionPreviewVersion++ - node.x = x - node.y = y + Object.assign(node, changes) } updateNode(id: string, changes: Partial): void { diff --git a/packages/vue/src/canvas/useCanvasInput.ts b/packages/vue/src/canvas/useCanvasInput.ts index 4f38774fc..f53193fbc 100644 --- a/packages/vue/src/canvas/useCanvasInput.ts +++ b/packages/vue/src/canvas/useCanvasInput.ts @@ -19,7 +19,7 @@ import { handleDrawMove, handleDrawUp } from '#vue/shared/input/draw' import { handleMoveMove, handleMoveUp } from '#vue/shared/input/move' import { handleNodeEditMove } from '#vue/shared/input/node-edit' import { setupPanZoom } from '#vue/shared/input/pan-zoom' -import { applyResize } from '#vue/shared/input/resize' +import { applyResize, commitResizePreview } from '#vue/shared/input/resize' import { updateHoverCursor } from '#vue/shared/input/select' import { useSpaceHeld } from '#vue/shared/input/space-key' import type { DragState } from '#vue/shared/input/types' @@ -178,7 +178,7 @@ export function useCanvasInput( else if (d.type === 'text-select') { drag.value = null return - } else if (d.type === 'resize') editor.commitResize(d.nodeId, d.origRect) + } else if (d.type === 'resize') commitResizePreview(d, editor) else if (d.type === 'pen-drag') { const penState = editor.state.penState as | (typeof editor.state.penState & { diff --git a/packages/vue/src/shared/input/resize.ts b/packages/vue/src/shared/input/resize.ts index ed8bf957a..88bf0f328 100644 --- a/packages/vue/src/shared/input/resize.ts +++ b/packages/vue/src/shared/input/resize.ts @@ -7,13 +7,7 @@ import { calculateResizeRect } from '#vue/shared/input/resize/rect' import { scaleVectorNetworkForResize } from '#vue/shared/input/resize/vector' import type { DragResize } from '#vue/shared/input/types' -export function applyResize( - d: DragResize, - cx: number, - cy: number, - constrain: boolean, - editor: Editor -) { +function resizeChanges(d: DragResize, cx: number, cy: number, constrain: boolean) { const { origRect } = d const newRect = calculateResizeRect(d.handle, origRect, cx - d.startX, cy - d.startY, constrain) @@ -27,6 +21,31 @@ export function applyResize( newRect.height ) if (resizedVectorNetwork) changes.vectorNetwork = resizedVectorNetwork - - editor.updateNode(d.nodeId, changes) + return changes +} + +export function applyResize( + d: DragResize, + cx: number, + cy: number, + constrain: boolean, + editor: Editor +) { + editor.graph.updateNodePreview(d.nodeId, resizeChanges(d, cx, cy, constrain)) + editor.requestRepaint() +} + +export function commitResizePreview(d: DragResize, editor: Editor) { + const node = editor.graph.getNode(d.nodeId) + if (!node) return + const finalChanges: Partial = { + x: node.x, + y: node.y, + width: node.width, + height: node.height + } + if (node.vectorNetwork) finalChanges.vectorNetwork = node.vectorNetwork + editor.graph.updateNodePreview(d.nodeId, d.origRect) + editor.updateNode(d.nodeId, finalChanges) + editor.commitResize(d.nodeId, d.origRect) } diff --git a/tests/e2e/editor/resize-performance.spec.ts b/tests/e2e/editor/resize-performance.spec.ts new file mode 100644 index 000000000..88f3487d4 --- /dev/null +++ b/tests/e2e/editor/resize-performance.spec.ts @@ -0,0 +1,92 @@ +import { expect, test } from '@playwright/test' + +import { CanvasHelper } from '#tests/helpers/canvas' + +test('resizing uses repaint-only previews until mouseup', async ({ page }) => { + await page.goto('/') + const canvas = new CanvasHelper(page) + await canvas.waitForInit() + canvas.errors.length = 0 + await canvas.clearCanvas() + + const id = await page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + store.state.zoom = 1 + store.state.panX = 0 + store.state.panY = 0 + const node = store.graph.createNode('FRAME', store.state.currentPageId, { + name: 'Resizable Card', + x: 100, + y: 100, + width: 240, + height: 140, + fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }] + }) + for (let i = 0; i < 12; i++) { + store.graph.createNode('TEXT', node.id, { + name: `Label ${i}`, + text: `Label ${i}`, + x: 16, + y: 16 + i * 18, + width: 160, + height: 16 + }) + } + store.select([node.id]) + store.requestRender() + return node.id + }) + await canvas.waitForRender() + + await page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const originalStoreUpdate = store.updateNode.bind(store) + const originalGraphUpdate = store.graph.updateNode.bind(store.graph) + let storeUpdateCount = 0 + let graphUpdateCount = 0 + let repaintCount = 0 + store.updateNode = ((nodeId, changes) => { + storeUpdateCount++ + return originalStoreUpdate(nodeId, changes) + }) as typeof store.updateNode + store.graph.updateNode = ((nodeId, changes) => { + graphUpdateCount++ + return originalGraphUpdate(nodeId, changes) + }) as typeof store.graph.updateNode + store.onEditorEvent('repaint:requested', () => { + repaintCount++ + }) + Object.assign(window, { + __openPencilResizeCounters: () => ({ storeUpdateCount, graphUpdateCount, repaintCount }) + }) + }) + + await canvas.drag(340, 240, 420, 290, 12) + + const counters = await page.evaluate(() => { + const getCounters = ( + window as typeof window & { + __openPencilResizeCounters?: () => { + storeUpdateCount: number + graphUpdateCount: number + repaintCount: number + } + } + ).__openPencilResizeCounters + return getCounters?.() ?? null + }) + const node = await page.evaluate((nodeId) => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + return store.graph.getNode(nodeId) + }, id) + + expect(counters?.storeUpdateCount).toBeLessThanOrEqual(1) + expect(counters?.graphUpdateCount).toBeLessThanOrEqual(1) + expect(counters?.repaintCount).toBeGreaterThan(0) + expect(node?.width).toBeGreaterThan(240) + expect(node?.height).toBeGreaterThan(140) + canvas.assertNoErrors() +})