From 3e102ea55a34103cd95ba22d03319765666da10c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 9 Mar 2026 16:13:03 +0300 Subject: [PATCH] Event-driven requestRender and component sync via SceneGraph emitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move requestRender() and component instance sync from manual call sites to graph event subscriptions. Removes 79 manual requestRender() calls (94→22) and all 9 syncIfInsideComponent calls. Batching: component sync uses queueMicrotask — mutated node IDs accumulate during a synchronous block, then deduplicate to ancestor component IDs and call syncInstances once per component. requestRender() is already rAF-batched (just increments counters checked by animation frame loop), so multiple event-triggered calls in one sync block collapse to a single repaint. Event subscriptions: - node:updated → invalidate render cache, schedule component sync, requestRender - node:created/deleted/reparented/reordered → schedule component sync, requestRender - Re-subscribes when graph instance is replaced (file open/reload) Also fixes collab sync gap: subscribe to node:reordered in use-collab.ts (same-parent reorder was never synced to Yjs peers). Remaining requestRender() calls are for pure UI state changes (pen tool, viewport pan/zoom, text editing, selection) and methods that bypass events (detachInstance, bringToFront/sendToBack, restorePageFromSnapshot). --- components.d.ts | 2 + src/composables/use-collab.ts | 4 + src/stores/editor.ts | 144 +++++++++++----------------------- 3 files changed, 51 insertions(+), 99 deletions(-) diff --git a/components.d.ts b/components.d.ts index d2fabe711..359775345 100644 --- a/components.d.ts +++ b/components.d.ts @@ -57,6 +57,7 @@ declare module 'vue' { IconLucideCheck: typeof import('~icons/lucide/check')['default'] IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] + IconLucideClipboardCopy: typeof import('~icons/lucide/clipboard-copy')['default'] IconLucideCode: typeof import('~icons/lucide/code')['default'] IconLucideCopy: typeof import('~icons/lucide/copy')['default'] IconLucideEye: typeof import('~icons/lucide/eye')['default'] @@ -93,6 +94,7 @@ declare module 'vue' { IconLucideSparkles: typeof import('~icons/lucide/sparkles')['default'] IconLucideSquare: typeof import('~icons/lucide/square')['default'] IconLucideStrikethrough: typeof import('~icons/lucide/strikethrough')['default'] + IconLucideTrash2: typeof import('~icons/lucide/trash2')['default'] IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default'] IconLucideUnderline: typeof import('~icons/lucide/underline')['default'] IconLucideUndo2: typeof import('~icons/lucide/undo2')['default'] diff --git a/src/composables/use-collab.ts b/src/composables/use-collab.ts index 98e3f2ec3..a5339927e 100644 --- a/src/composables/use-collab.ts +++ b/src/composables/use-collab.ts @@ -203,6 +203,9 @@ export function useCollab(store: EditorStore) { const unbindReparented = store.graph.emitter.on('node:reparented', (nodeId) => onGraphMutation(nodeId) ) + const unbindReordered = store.graph.emitter.on('node:reordered', (nodeId) => + onGraphMutation(nodeId) + ) const unbindDeleted = store.graph.emitter.on('node:deleted', (id) => { if (!suppressGraphSync && ydoc && ynodes) { suppressYjsEvents = true @@ -217,6 +220,7 @@ export function useCollab(store: EditorStore) { unbindUpdated() unbindCreated() unbindReparented() + unbindReordered() unbindDeleted() } } diff --git a/src/stores/editor.ts b/src/stores/editor.ts index f86d27fed..224301ee5 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -329,12 +329,10 @@ export function createEditorStore() { const remaining = graph.getPages() switchPage(remaining[newIdx].id) } - requestRender() } function renamePage(pageId: string, name: string) { graph.updateNode(pageId, { name }) - requestRender() } function setTool(tool: Tool) { @@ -421,7 +419,6 @@ export function createEditorStore() { label: 'Reorder', forward: () => { doReorderChild(nodeId, parentId, insertIndex) - requestRender() }, inverse: () => { graph.reorderChild(nodeId, origParentId, origIndex >= 0 ? origIndex : 0) @@ -432,11 +429,9 @@ export function createEditorStore() { computeLayout(graph, parentId) runLayoutForNode(parentId) } - requestRender() } }) - requestRender() } function reorderChildWithUndo(nodeId: string, newParentId: string, insertIndex: number) { @@ -457,18 +452,15 @@ export function createEditorStore() { graph.reorderChild(nodeId, newParentId, insertIndex) runLayoutForNode(newParentId) if (origParentId !== newParentId) runLayoutForNode(origParentId) - requestRender() }, inverse: () => { graph.reorderChild(nodeId, origParentId, origIndex) graph.updateNode(nodeId, { x: origX, y: origY }) runLayoutForNode(origParentId) if (origParentId !== newParentId) runLayoutForNode(newParentId) - requestRender() } }) - requestRender() } function reparentNodes(nodeIds: string[], newParentId: string) { @@ -485,7 +477,6 @@ export function createEditorStore() { continue graph.reparentNode(id, newParentId) } - requestRender() } function penAddVertex(x: number, y: number) { @@ -651,15 +642,12 @@ export function createEditorStore() { label: 'Edit text', forward: () => { graph.updateNode(result.nodeId, { text: newText }) - requestRender() }, inverse: () => { graph.updateNode(result.nodeId, { text: prevText }) - requestRender() } }) } - requestRender() } async function openFigFile(file: File, handle?: FileSystemFileHandle, path?: string) { @@ -992,35 +980,67 @@ export function createEditorStore() { } } + // ─── Graph event subscriptions ──────────────────────────────── + // Microtask-batched component sync: collects mutated node IDs during a + // synchronous block, deduplicates to unique ancestor components, then + // calls syncInstances once per component in one microtask. + let pendingComponentSync: Set | null = null + + function flushComponentSync() { + const ids = pendingComponentSync! + pendingComponentSync = null + const componentIds = new Set() + for (const id of ids) { + let current = graph.getNode(id) + while (current) { + if (current.type === 'COMPONENT') { + componentIds.add(current.id) + break + } + current = current.parentId ? graph.getNode(current.parentId) : undefined + } + } + for (const compId of componentIds) { + graph.syncInstances(compId) + } + if (componentIds.size > 0) requestRender() + } + + function scheduleComponentSync(nodeId: string) { + if (!pendingComponentSync) { + pendingComponentSync = new Set() + queueMicrotask(flushComponentSync) + } + pendingComponentSync.add(nodeId) + } + function onNodeUpdated(id: string, changes: Partial) { if ('vectorNetwork' in changes) { _renderer?.invalidateVectorPath(id) } _renderer?.invalidateNodePicture(id) + scheduleComponentSync(id) + requestRender() + } + + function onNodeStructureChanged(nodeId: string) { + scheduleComponentSync(nodeId) + requestRender() } function subscribeToGraph() { graph.emitter.on('node:updated', onNodeUpdated) + graph.emitter.on('node:created', (node) => onNodeStructureChanged(node.id)) + graph.emitter.on('node:deleted', onNodeStructureChanged) + graph.emitter.on('node:reparented', onNodeStructureChanged) + graph.emitter.on('node:reordered', onNodeStructureChanged) } subscribeToGraph() - function syncIfInsideComponent(nodeId: string) { - let current = graph.getNode(nodeId) - while (current) { - if (current.type === 'COMPONENT') { - graph.syncInstances(current.id) - return - } - current = current.parentId ? graph.getNode(current.parentId) : undefined - } - } - function updateNode(id: string, changes: Partial) { graph.updateNode(id, changes) runLayoutForNode(id) - syncIfInsideComponent(id) - requestRender() } function updateNodeWithUndo(id: string, changes: Partial, label = 'Update') { @@ -1031,23 +1051,17 @@ export function createEditorStore() { ) as Partial graph.updateNode(id, changes) runLayoutForNode(id) - syncIfInsideComponent(id) undo.push({ label, forward: () => { graph.updateNode(id, changes) runLayoutForNode(id) - syncIfInsideComponent(id) - requestRender() }, inverse: () => { graph.updateNode(id, previous) runLayoutForNode(id) - syncIfInsideComponent(id) - requestRender() } }) - requestRender() } function setLayoutMode(id: string, mode: LayoutMode) { @@ -1128,15 +1142,12 @@ export function createEditorStore() { graph.updateNode(id, finalState) if (mode !== 'NONE') computeLayout(graph, id) runLayoutForNode(id) - requestRender() }, inverse: () => { graph.updateNode(id, previous) runLayoutForNode(id) - requestRender() } }) - requestRender() } function wrapInAutoLayout() { @@ -1204,7 +1215,6 @@ export function createEditorStore() { computeLayout(graph, f.id) runLayoutForNode(f.id) state.selectedIds = new Set([f.id]) - requestRender() }, inverse: () => { // Move children back to original parent and delete frame @@ -1214,10 +1224,8 @@ export function createEditorStore() { } graph.deleteNode(frameId) state.selectedIds = prevSelection - requestRender() } }) - requestRender() } function groupSelected() { @@ -1281,7 +1289,6 @@ export function createEditorStore() { parent.childIds.splice(firstIndex, 0, g.id) for (const n of origPositions) graph.reparentNode(n.id, g.id) state.selectedIds = new Set([g.id]) - requestRender() }, inverse: () => { for (const orig of origPositions) { @@ -1290,10 +1297,8 @@ export function createEditorStore() { } graph.deleteNode(groupId) state.selectedIds = prevSelection - requestRender() } }) - requestRender() } function createComponentFromSelection() { @@ -1316,15 +1321,12 @@ export function createEditorStore() { forward: () => { graph.updateNode(node.id, { type: 'COMPONENT' }) state.selectedIds = new Set([node.id]) - requestRender() }, inverse: () => { graph.updateNode(node.id, { type: prevType }) state.selectedIds = prevSelection - requestRender() } }) - requestRender() return } } @@ -1381,7 +1383,6 @@ export function createEditorStore() { parent.childIds.splice(firstIndex, 0, c.id) for (const n of origPositions) graph.reparentNode(n.id, c.id) state.selectedIds = new Set([c.id]) - requestRender() }, inverse: () => { for (const orig of origPositions) { @@ -1390,10 +1391,8 @@ export function createEditorStore() { } graph.deleteNode(componentId) state.selectedIds = prevSelection - requestRender() } }) - requestRender() } function createComponentSetFromComponents() { @@ -1457,7 +1456,6 @@ export function createEditorStore() { parent.childIds.splice(firstIndex, 0, cs.id) for (const n of origPositions) graph.reparentNode(n.id, cs.id) state.selectedIds = new Set([cs.id]) - requestRender() }, inverse: () => { for (const orig of origPositions) { @@ -1466,10 +1464,8 @@ export function createEditorStore() { } graph.deleteNode(setId) state.selectedIds = prevSelection - requestRender() } }) - requestRender() } function createInstanceFromComponent(componentId: string, x?: number, y?: number) { @@ -1491,15 +1487,12 @@ export function createEditorStore() { forward: () => { graph.createInstance(componentId, parentId, { ...instance }) state.selectedIds = new Set([instanceId]) - requestRender() }, inverse: () => { graph.deleteNode(instanceId) state.selectedIds = new Set([componentId]) - requestRender() } }) - requestRender() return instanceId } @@ -1520,10 +1513,8 @@ export function createEditorStore() { }, inverse: () => { graph.updateNode(node.id, { type: 'INSTANCE', componentId: prevComponentId, overrides: {} }) - requestRender() } }) - requestRender() } function goToMainComponent() { @@ -1590,7 +1581,6 @@ export function createEditorStore() { } graph.deleteNode(node.id) state.selectedIds = new Set(childIds) - requestRender() }, inverse: () => { const g = graph.createNode('GROUP', parentId, { ...groupSnapshot, childIds: [] }) @@ -1601,10 +1591,8 @@ export function createEditorStore() { graph.updateNode(orig.id, { x: orig.x, y: orig.y }) } state.selectedIds = prevSelection - requestRender() } }) - requestRender() } function bringToFront() { @@ -1646,7 +1634,6 @@ export function createEditorStore() { if (!node) continue graph.updateNode(id, { visible: !node.visible }) } - requestRender() } function toggleLock() { @@ -1655,7 +1642,6 @@ export function createEditorStore() { if (!node) continue graph.updateNode(id, { locked: !node.locked }) } - requestRender() } function moveToPage(pageId: string) { @@ -1666,12 +1652,10 @@ export function createEditorStore() { graph.reparentNode(id, pageId) } clearSelection() - requestRender() } function renameNode(id: string, name: string) { graph.updateNode(id, { name }) - requestRender() } function createShape( @@ -1709,17 +1693,14 @@ export function createEditorStore() { label: `Create ${type.toLowerCase()}`, forward: () => { graph.createNode(snapshot.type, pid, snapshot) - requestRender() }, inverse: () => { graph.deleteNode(id) const next = new Set(state.selectedIds) next.delete(id) state.selectedIds = next - requestRender() } }) - requestRender() return id } @@ -1774,17 +1755,14 @@ export function createEditorStore() { graph.reparentNode(op.id, sectionId) graph.updateNode(op.id, { x: op.newX, y: op.newY }) } - requestRender() }, inverse: () => { for (const op of undoOps) { graph.reparentNode(op.id, op.oldParent) graph.updateNode(op.id, { x: op.oldX, y: op.oldY }) } - requestRender() } }) - requestRender() } function selectAll() { @@ -1821,15 +1799,12 @@ export function createEditorStore() { graph.createNode(snapshot.type, parentId, snapshot) } state.selectedIds = new Set(newIds) - requestRender() }, inverse: () => { for (const { id } of snapshots) graph.deleteNode(id) state.selectedIds = prevSelection - requestRender() } }) - requestRender() } } @@ -1912,17 +1887,14 @@ export function createEditorStore() { } computeAllLayouts(graph, pageId) state.selectedIds = new Set(created) - requestRender() }, inverse: () => { for (const id of [...created].reverse()) graph.deleteNode(id) computeAllLayouts(graph, pageId) state.selectedIds = prevSelection - requestRender() } }) void loadFontsForNodes(created) - requestRender() } } }) @@ -1965,15 +1937,12 @@ export function createEditorStore() { graph.createNode(snapshot.type, pid, snapshot) } state.selectedIds = new Set(newIds) - requestRender() }, inverse: () => { for (const { id } of [...created].reverse()) graph.deleteNode(id) state.selectedIds = prevSelection - requestRender() } }) - requestRender() } } @@ -1997,7 +1966,6 @@ export function createEditorStore() { forward: () => { for (const { id } of entries) graph.deleteNode(id) clearSelection() - requestRender() }, inverse: () => { for (const { snapshot, parentId, index } of [...entries].reverse()) { @@ -2007,11 +1975,9 @@ export function createEditorStore() { } } state.selectedIds = prevSelection - requestRender() } }) clearSelection() - requestRender() } function mobileCopy() { @@ -2037,7 +2003,6 @@ export function createEditorStore() { const n = graph.getNode(id) if (n) finals.set(id, { x: n.x, y: n.y }) } - for (const [id] of finals) syncIfInsideComponent(id) undo.push({ label: 'Move', forward: () => { @@ -2045,16 +2010,12 @@ export function createEditorStore() { graph.updateNode(id, pos) runLayoutForNode(id) } - for (const [id] of finals) syncIfInsideComponent(id) - requestRender() }, inverse: () => { for (const [id, pos] of originals) { graph.updateNode(id, pos) runLayoutForNode(id) } - for (const [id] of originals) syncIfInsideComponent(id) - requestRender() } }) } @@ -2067,7 +2028,6 @@ export function createEditorStore() { const n = graph.getNode(id) if (n) finals.set(id, { x: n.x, y: n.y, parentId: n.parentId ?? state.currentPageId }) } - for (const [id] of finals) syncIfInsideComponent(id) undo.push({ label: 'Move', forward: () => { @@ -2076,8 +2036,6 @@ export function createEditorStore() { graph.updateNode(id, { x: pos.x, y: pos.y }) runLayoutForNode(id) } - for (const [id] of finals) syncIfInsideComponent(id) - requestRender() }, inverse: () => { for (const [id, pos] of originals) { @@ -2085,8 +2043,6 @@ export function createEditorStore() { graph.updateNode(id, { x: pos.x, y: pos.y }) runLayoutForNode(id) } - for (const [id] of originals) syncIfInsideComponent(id) - requestRender() } }) } @@ -2134,20 +2090,15 @@ export function createEditorStore() { const node = graph.getNode(nodeId) if (!node) return const finalRect = { x: node.x, y: node.y, width: node.width, height: node.height } - syncIfInsideComponent(nodeId) undo.push({ label: 'Resize', forward: () => { graph.updateNode(nodeId, finalRect) runLayoutForNode(nodeId) - syncIfInsideComponent(nodeId) - requestRender() }, inverse: () => { graph.updateNode(nodeId, origRect) runLayoutForNode(nodeId) - syncIfInsideComponent(nodeId) - requestRender() } }) } @@ -2160,11 +2111,9 @@ export function createEditorStore() { label: 'Rotate', forward: () => { graph.updateNode(nodeId, { rotation: finalRotation }) - requestRender() }, inverse: () => { graph.updateNode(nodeId, { rotation: origRotation }) - requestRender() } }) } @@ -2180,19 +2129,16 @@ export function createEditorStore() { forward: () => { graph.updateNode(nodeId, current) runLayoutForNode(nodeId) - requestRender() }, inverse: () => { graph.updateNode(nodeId, previous) runLayoutForNode(nodeId) - requestRender() } }) } function undoAction() { undo.undo() - requestRender() } function redoAction() {