diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bcb521af..6c2da894d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Reduce large `.fig` page-switch work to the active page and coalesce Layers tree rebuilds. (#420) - Center text glyphs within explicit line-height leading in CanvasKit paragraph rendering. ### Added diff --git a/packages/fig/src/instance-overrides/constraints.ts b/packages/fig/src/instance-overrides/constraints.ts index 130eeafee..9fb48199e 100644 --- a/packages/fig/src/instance-overrides/constraints.ts +++ b/packages/fig/src/instance-overrides/constraints.ts @@ -3,6 +3,7 @@ import { copyGeometryPaths } from '@open-pencil/scene-graph/copy' import { buildClonesMap } from './sync' import type { OverrideContext } from './types' +import { overrideCandidates } from './utils' /** * Apply SCALE constraint resizing to children of instances whose size @@ -13,8 +14,7 @@ export function applyConstraintScaling(ctx: OverrideContext): void { const { graph } = ctx const scaled = new Set() - for (const node of graph.getAllNodes()) { - if (ctx.activeNodeIds && !ctx.activeNodeIds.has(node.id)) continue + for (const node of overrideCandidates(graph, ctx.activeNodeIds)) { if (node.type !== 'INSTANCE' || !node.componentId) continue const comp = graph.getNode(node.componentId) if (!comp || comp.width <= 0 || comp.height <= 0) continue @@ -197,8 +197,7 @@ function scaleChildren( function normalizeOutOfBoundsSingleChildren(ctx: OverrideContext): void { const { graph } = ctx - for (const parent of graph.getAllNodes()) { - if (ctx.activeNodeIds && !ctx.activeNodeIds.has(parent.id)) continue + for (const parent of overrideCandidates(graph, ctx.activeNodeIds)) { if (parent.childIds.length !== 1) continue const child = graph.getNode(parent.childIds[0]) if (!child?.visible || !child.componentId) continue diff --git a/packages/fig/src/instance-overrides/index.ts b/packages/fig/src/instance-overrides/index.ts index 8eb569a55..9126378f8 100644 --- a/packages/fig/src/instance-overrides/index.ts +++ b/packages/fig/src/instance-overrides/index.ts @@ -35,6 +35,7 @@ import { applySymbolOverrides } from './symbol/overrides' import { propagateNodePropsTransitively, propagateOverridesTransitively } from './sync' import { indexCloneNodes } from './sync/sources' import type { InstanceNodeChange, OverrideContext, ComponentPropValue } from './types' +import { overrideCandidates } from './utils' /** * Identify nodes whose kiwi NC has explicit property values that DIFFER @@ -84,15 +85,10 @@ function buildKiwiGeometryNodes( return result } -function propagateResolvedFills( - graph: SceneGraph, - protectedNodes: Set, - activeNodeIds?: Set -): void { +function propagateResolvedFills(graph: SceneGraph, protectedNodes: Set): void { for (let pass = 0; pass < 10; pass++) { let changed = false for (const node of graph.getAllNodes()) { - if (activeNodeIds && !activeNodeIds.has(node.id)) continue if (!node.componentId) continue const source = graph.getNode(node.componentId) if (!source || isEqual(source.fills, node.fills)) continue @@ -144,7 +140,7 @@ function sameDerivedGlyphSource( return hasSameCopySource(source, target) } -function propagateResolvedTextClones(graph: SceneGraph): void { +function propagateResolvedTextClones(graph: SceneGraph, activeNodeIds?: Set): void { const ordered: SceneNode[] = [] const visited = new Set() const visiting = new Set() @@ -157,7 +153,7 @@ function propagateResolvedTextClones(graph: SceneGraph): void { visited.add(node.id) if (node.type === 'TEXT' && node.componentId) ordered.push(node) } - for (const nodeId of graph.nodes.keys()) { + for (const nodeId of activeNodeIds ?? graph.nodes.keys()) { const node = graph.getNode(nodeId) if (node?.type === 'TEXT' && node.componentId) visit(node) } @@ -326,12 +322,8 @@ export function populateAndApplyOverrides( } applyDerivedSymbolData(ctx) - propagateResolvedFills( - graph, - new Set([...ctx.kiwiPropertyNodes, ...overriddenNodes]), - ctx.activeNodeIds - ) - propagateResolvedTextClones(graph) + propagateResolvedFills(graph, new Set([...ctx.kiwiPropertyNodes, ...overriddenNodes])) + propagateResolvedTextClones(graph, ctx.activeNodeIds) applyConstraintScaling(ctx) applyComponentProperties(ctx) diff --git a/packages/fig/src/instance-overrides/resolve.ts b/packages/fig/src/instance-overrides/resolve.ts index 0a788e108..e93c2f5a2 100644 --- a/packages/fig/src/instance-overrides/resolve.ts +++ b/packages/fig/src/instance-overrides/resolve.ts @@ -9,6 +9,7 @@ import { snapshotChildSources } from './sync/sources' import type { InstanceNodeChange, OverrideContext } from './types' +import { overrideCandidates } from './utils' const MAX_CHAIN_DEPTH = 20 const siblingIndexCache = new WeakMap>() @@ -41,7 +42,7 @@ export function preComputeRoots(ctx: OverrideContext): void { return nodeId } - for (const node of ctx.graph.getAllNodes()) { + for (const node of overrideCandidates(ctx.graph, ctx.activeNodeIds)) { if (!node.componentId) continue resolve(node.id) const clones = ctx.preComputedClones.get(node.componentId) @@ -484,7 +485,13 @@ export function repopulateInstance(ctx: OverrideContext, nodeId: string, compId: indexCloneSubtree(ctx.graph, nodeId, ctx.preComputedClones) applyStrokeDescendants(ctx, nodeId, previousStrokes) } - remapRepopulatedChildSources(ctx.graph, nodeId, previousSources, ctx.preComputedClones) + remapRepopulatedChildSources( + ctx.graph, + nodeId, + previousSources, + ctx.preComputedClones, + ctx.activeNodeIds + ) ctx.swappedInstances.add(nodeId) ctx.componentIdRoot.clear() candidateCache.delete(ctx) diff --git a/packages/fig/src/instance-overrides/sync/clones.ts b/packages/fig/src/instance-overrides/sync/clones.ts index c009d8051..57da545d5 100644 --- a/packages/fig/src/instance-overrides/sync/clones.ts +++ b/packages/fig/src/instance-overrides/sync/clones.ts @@ -1,6 +1,7 @@ import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' import type { ProtectionMap } from '../patches' +import { overrideCandidates } from '../utils' import { syncNodeProps } from './fields' import { indexCloneSubtree, remapRepopulatedChildSources, snapshotChildSources } from './sources' @@ -10,11 +11,12 @@ export function recloneChildren( tgtNode: SceneNode, swappedInstances: Set, protections?: ProtectionMap, - cloneSources?: Map + cloneSources?: Map, + activeNodeIds?: Set ): void { const srcChild = graph.getNode(srcChildId) if (!srcChild) return - const effectiveCloneSources = cloneSources ?? buildClonesMap(graph) + const effectiveCloneSources = cloneSources ?? buildClonesMap(graph, activeNodeIds) const previousSources = snapshotChildSources(graph, tgtNode.id) for (const childId of Array.from(tgtNode.childIds)) graph.deleteNode(childId) @@ -24,7 +26,13 @@ export function recloneChildren( graph.populateInstanceChildren(tgtNode.id, srcChildId, 'fig-import') indexCloneSubtree(graph, tgtNode.id, effectiveCloneSources) } - remapRepopulatedChildSources(graph, tgtNode.id, previousSources, effectiveCloneSources) + remapRepopulatedChildSources( + graph, + tgtNode.id, + previousSources, + effectiveCloneSources, + activeNodeIds + ) swappedInstances.add(tgtNode.id) } @@ -35,12 +43,13 @@ export function syncChildrenDeep( swappedInstances: Set, skip?: Set, protections?: ProtectionMap, - cloneSources?: Map + cloneSources?: Map, + activeNodeIds?: Set ): void { const src = graph.getNode(sourceId) const tgt = graph.getNode(targetId) if (!src || !tgt) return - const effectiveCloneSources = cloneSources ?? buildClonesMap(graph) + const effectiveCloneSources = cloneSources ?? buildClonesMap(graph, activeNodeIds) const len = Math.min(src.childIds.length, tgt.childIds.length) for (let i = 0; i < len; i++) { if (skip?.has(tgt.childIds[i])) continue @@ -55,7 +64,8 @@ export function syncChildrenDeep( tgtNode, swappedInstances, protections, - effectiveCloneSources + effectiveCloneSources, + activeNodeIds ) continue } @@ -68,7 +78,8 @@ export function syncChildrenDeep( swappedInstances, skip, protections, - effectiveCloneSources + effectiveCloneSources, + activeNodeIds ) } } @@ -78,8 +89,7 @@ export function buildClonesMap( activeNodeIds?: Set ): Map { const clonesOf = new Map() - for (const node of graph.getAllNodes()) { - if (activeNodeIds && !activeNodeIds.has(node.id)) continue + for (const node of overrideCandidates(graph, activeNodeIds)) { if (!node.componentId) continue let arr = clonesOf.get(node.componentId) if (!arr) { diff --git a/packages/fig/src/instance-overrides/sync/propagate.ts b/packages/fig/src/instance-overrides/sync/propagate.ts index 2d988e818..027c35972 100644 --- a/packages/fig/src/instance-overrides/sync/propagate.ts +++ b/packages/fig/src/instance-overrides/sync/propagate.ts @@ -141,9 +141,18 @@ export function propagateOverridesTransitively( graph.populateInstanceChildren(node.id, sourceId, 'fig-import') indexCloneSubtree(graph, node.id, clonesOf) } - remapRepopulatedChildSources(graph, node.id, previousSources, clonesOf) + remapRepopulatedChildSources(graph, node.id, previousSources, clonesOf, activeNodeIds) } else if (source.childIds.length > 0 && node.childIds.length > 0) { - syncChildrenDeep(graph, sourceId, node.id, swappedInstances, skip, protections, clonesOf) + syncChildrenDeep( + graph, + sourceId, + node.id, + swappedInstances, + skip, + protections, + clonesOf, + activeNodeIds + ) } syncQueue.push(cloneId) } diff --git a/packages/fig/src/instance-overrides/sync/sources.ts b/packages/fig/src/instance-overrides/sync/sources.ts index 945df2393..84ab89139 100644 --- a/packages/fig/src/instance-overrides/sync/sources.ts +++ b/packages/fig/src/instance-overrides/sync/sources.ts @@ -1,5 +1,7 @@ import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import { overrideCandidates } from '../utils' + interface ChildSourceSnapshot { id: string path: number[] @@ -7,15 +9,27 @@ interface ChildSourceSnapshot { } const refreshedCloneSourceMaps = new WeakSet>() +const cloneSourceIdCaches = new WeakMap, Map>>() + +function cloneSourceIds(cloneSources: Map): Map> { + let cached = cloneSourceIdCaches.get(cloneSources) + if (!cached) { + cached = new Map([...cloneSources].map(([sourceId, cloneIds]) => [sourceId, new Set(cloneIds)])) + cloneSourceIdCaches.set(cloneSources, cached) + } + return cached +} // SceneGraph.instanceIndex contains INSTANCE nodes only, while generated text, // frame, and vector descendants also use componentId as clone provenance. -function refreshCloneSources(graph: SceneGraph, cloneSources: Map): void { +function refreshCloneSources( + graph: SceneGraph, + cloneSources: Map, + activeNodeIds?: Set +): void { if (refreshedCloneSourceMaps.has(cloneSources)) return - const knownIds = new Map( - [...cloneSources].map(([sourceId, cloneIds]) => [sourceId, new Set(cloneIds)]) - ) - for (const node of graph.getAllNodes()) { + const knownIds = cloneSourceIds(cloneSources) + for (const node of overrideCandidates(graph, activeNodeIds)) { if (!node.componentId) continue let known = knownIds.get(node.componentId) if (!known) { @@ -35,13 +49,13 @@ export function indexCloneNodes( nodeIds: Iterable, cloneSources: Map ): void { - const knownIds = new Map>() + const knownIds = cloneSourceIds(cloneSources) for (const nodeId of nodeIds) { const node = graph.getNode(nodeId) if (!node?.componentId) continue let known = knownIds.get(node.componentId) if (!known) { - known = new Set(cloneSources.get(node.componentId)) + known = new Set() knownIds.set(node.componentId, known) } if (known.has(node.id)) continue @@ -107,9 +121,10 @@ export function remapRepopulatedChildSources( graph: SceneGraph, parentId: string, previousSources: ChildSourceSnapshot[], - cloneSources?: Map + cloneSources?: Map, + activeNodeIds?: Set ): void { - if (cloneSources) refreshCloneSources(graph, cloneSources) + if (cloneSources) refreshCloneSources(graph, cloneSources, activeNodeIds) for (const previous of previousSources) { const replacement = resolveChildPath(graph, parentId, previous.path) if (!replacement || replacement.type !== previous.type) continue @@ -122,10 +137,20 @@ export function remapRepopulatedChildSources( if (clone?.componentId !== previous.id) continue graph.updateNode(cloneId, { componentId: replacement.id }) if (cloneSources) { - const replacements = cloneSources.get(replacement.id) - if (replacements) { - if (!replacements.includes(cloneId)) replacements.push(cloneId) - } else cloneSources.set(replacement.id, [cloneId]) + let replacements = cloneSources.get(replacement.id) + if (!replacements) { + replacements = [] + cloneSources.set(replacement.id, replacements) + } + if (!replacements.includes(cloneId)) { + replacements.push(cloneId) + let known = cloneSourceIds(cloneSources).get(replacement.id) + if (!known) { + known = new Set() + cloneSourceIds(cloneSources).set(replacement.id, known) + } + known.add(cloneId) + } } } } diff --git a/packages/fig/src/instance-overrides/utils.ts b/packages/fig/src/instance-overrides/utils.ts new file mode 100644 index 000000000..d183c35dd --- /dev/null +++ b/packages/fig/src/instance-overrides/utils.ts @@ -0,0 +1,15 @@ +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' + +export function* overrideCandidates( + graph: SceneGraph, + activeNodeIds?: Set +): Iterable { + if (!activeNodeIds) { + yield* graph.getAllNodes() + return + } + for (const id of activeNodeIds) { + const node = graph.getNode(id) + if (node) yield node + } +} diff --git a/packages/fig/tests/instance-overrides.test.ts b/packages/fig/tests/instance-overrides.test.ts index 384b47cc1..cf542b302 100644 --- a/packages/fig/tests/instance-overrides.test.ts +++ b/packages/fig/tests/instance-overrides.test.ts @@ -27,6 +27,37 @@ describe('@open-pencil/fig instance interpretation', () => { expect(graph.getNode(populated?.childIds[0] ?? '')?.text).toBe('Label') }) + test('limits lazy population to required global propagation scans', () => { + const graph = new SceneGraph() + const activePage = graph.getPages()[0] + const unrelatedPage = graph.addPage('Unrelated') + const component = graph.createNode('COMPONENT', unrelatedPage.id, { + width: 100, + height: 40 + }) + graph.createNode('TEXT', component.id, { text: 'Label' }) + const instance = graph.createNode('INSTANCE', activePage.id, { + width: 100, + height: 40, + componentId: component.id + }) + for (let index = 0; index < 5_000; index++) { + graph.createNode('RECTANGLE', unrelatedPage.id) + } + + let globalScans = 0 + const getAllNodes = graph.getAllNodes.bind(graph) + graph.getAllNodes = () => { + globalScans++ + return getAllNodes() + } + + populateAndApplyOverrides(graph, new Map(), new Map(), [], [activePage.id]) + + expect(graph.getNode(instance.id)?.childIds).toHaveLength(1) + expect(globalScans).toBe(2) + }) + test('resolves text clone chains to their source values', () => { const graph = new SceneGraph() const pageId = graph.getPages()[0].id diff --git a/packages/vue/src/primitives/LayerTree/LayerTreeRoot.vue b/packages/vue/src/primitives/LayerTree/LayerTreeRoot.vue index 42bc596f8..e5207a0f0 100644 --- a/packages/vue/src/primitives/LayerTree/LayerTreeRoot.vue +++ b/packages/vue/src/primitives/LayerTree/LayerTreeRoot.vue @@ -8,7 +8,6 @@ import { useEditor } from '#vue/editor/context' import { provideLayerTree } from '#vue/primitives/LayerTree/context' import { buildLayerTreeModel, - indexLayerNodes, layerSelectionForTarget, patchLayerNode, visibleLayerRows @@ -55,14 +54,29 @@ const { draggingId, instruction, instructionTargetId, setupItem } = useLayerDrag expandNode ) +let rebuildPending = false +let rebuildToken = 0 + function rebuildTree() { + rebuildPending = false + rebuildToken++ const model = buildLayerTreeModel(editor.graph, editor.state.currentPageId) items.value = model.items - nodesById = indexLayerNodes(items.value) + nodesById = model.byId expanded.value = expanded.value.filter((id) => nodesById.has(id)) treeVersion.value++ } +function scheduleTreeRebuild() { + if (rebuildPending) return + rebuildPending = true + const token = ++rebuildToken + queueMicrotask(() => { + if (!rebuildPending || token !== rebuildToken) return + rebuildTree() + }) +} + rebuildTree() const PATCHABLE_NODE_KEYS = new Set([ @@ -127,10 +141,10 @@ function onSelectionChanged(ids: string[]) { const unsubscribe = [ editor.onEditorEvent('graph:replaced', rebuildTree), editor.onEditorEvent('page:changed', rebuildTree), - editor.onEditorEvent('node:created', rebuildTree), - editor.onEditorEvent('node:deleted', rebuildTree), - editor.onEditorEvent('node:reparented', rebuildTree), - editor.onEditorEvent('node:reordered', rebuildTree), + editor.onEditorEvent('node:created', scheduleTreeRebuild), + editor.onEditorEvent('node:deleted', scheduleTreeRebuild), + editor.onEditorEvent('node:reparented', scheduleTreeRebuild), + editor.onEditorEvent('node:reordered', scheduleTreeRebuild), editor.onEditorEvent('node:updated', patchTreeNode), editor.onEditorEvent('selection:changed', onSelectionChanged) ]