diff --git a/packages/core-v2/src/gesture/recognizer.ts b/packages/core-v2/src/gesture/recognizer.ts index 8f3cfa9..6489aa4 100644 --- a/packages/core-v2/src/gesture/recognizer.ts +++ b/packages/core-v2/src/gesture/recognizer.ts @@ -103,12 +103,16 @@ export const pointOf = ( const nonNegative = (value: number) => Number.isFinite(value) ? Math.max(0, value) : 0; +const PIXELS = /^\s*(-?(?:\d+\.?\d*|\.\d+))px\s*$/; +const PERCENT = /^\s*(-?(?:\d+\.?\d*|\.\d+))%\s*$/; + /** - * Resolve an arbitrary CSS length against a box with the gesture host's size. + * Resolve a CSS length against a box with the gesture host's size. * - * A short-lived off-screen box lets the browser handle `rem`, viewport units, - * percentages, `calc()`, and `clamp()` consistently. This runs only during - * pointer-down for edge-constrained definitions. + * Numbers, pixel lengths, and percentages are computed directly. Anything + * else (`rem`, viewport units, `calc()`, `clamp()`) is measured with a + * short-lived off-screen box so the browser resolves it exactly. This runs + * only during pointer-down for edge-constrained definitions. */ export const resolveCssDistance = ( distance: OriginGestureDistance, @@ -117,6 +121,13 @@ export const resolveCssDistance = ( bounds: OriginRect, ): number => { if (typeof distance === "number") return nonNegative(distance); + const pixels = PIXELS.exec(distance); + if (pixels) return nonNegative(Number(pixels[1])); + const percent = PERCENT.exec(distance); + if (percent) { + const size = axis === "horizontal" ? bounds.width : bounds.height; + return nonNegative((Number(percent[1]) / 100) * size); + } const document = host.ownerDocument; if (!document.body) return Math.max(0, Number.parseFloat(distance) || 0); diff --git a/packages/core-v2/src/scene/graph.ts b/packages/core-v2/src/scene/graph.ts index 6c37384..08b8236 100644 --- a/packages/core-v2/src/scene/graph.ts +++ b/packages/core-v2/src/scene/graph.ts @@ -76,6 +76,14 @@ export class SceneGraph { * that back operation's id. Cancelling the back operation restores the edge. */ private readonly suspendedEdges = new Map(); + /** + * Operations indexed by origin key, in insertion order. Every addition and + * removal goes through {@link attach} and {@link detach} to keep it in sync + * with `operations`. + */ + private readonly outgoing = shallowReactive( + new Map(), + ); private nodeSequence = 0; private operationSequence = 0; @@ -117,10 +125,9 @@ export class SceneGraph { /** The unfinished operation originated by a node, if any. */ liveOutgoingOf(nodeKey: string): MutableOriginOperation | undefined { - for (const operation of this.operations.values()) - if (operation.originKey === nodeKey && operation.phase !== "finished") - return operation; - return undefined; + return this.outgoing + .get(nodeKey) + ?.find((operation) => operation.phase !== "finished"); } /** @@ -128,9 +135,7 @@ export class SceneGraph { * {@link liveOutgoingOf}, this includes a finished, connected resting edge. */ presentedOutgoingOf(nodeKey: string): MutableOriginOperation | undefined { - for (const operation of this.operations.values()) - if (operation.originKey === nodeKey) return operation; - return undefined; + return this.outgoing.get(nodeKey)?.[0]; } /** Retained entries preceding `node`, oldest first. */ @@ -257,7 +262,7 @@ export class SceneGraph { this.transferPosition(origin, target); operation.phase = "finished"; - this.operations.delete(operation.id); + this.detach(operation); this.suspendedEdges.delete(operation.id); if (operation.history === "push") { @@ -286,7 +291,7 @@ export class SceneGraph { const target = this.nodes.get(operation.entryTargetKey); const suspendedEdge = this.suspendedEdges.get(id); operation.phase = "finished"; - this.operations.delete(id); + this.detach(operation); this.suspendedEdges.delete(id); if (operation.history === "back") { @@ -294,7 +299,7 @@ export class SceneGraph { // Put the retained page back into its former position, then reconnect // the resting page→drawer edge. Both nodes keep their VNodes. this.transferPosition(origin, target); - this.operations.set(suspendedEdge.id, suspendedEdge); + this.attach(suspendedEdge); origin.incomingOperationId = suspendedEdge.id; target.state = "exposed"; } else if (target) { @@ -362,7 +367,7 @@ export class SceneGraph { const resting = this.operations.get(incoming.id); if (resting?.phase === "finished" && resting.choreography.persistAtRest) { // Re-root A→page→drawer as A→drawer before adding drawer→page. - this.operations.delete(resting.id); + this.detach(resting); this.transferPosition(target, origin); target.incomingOperationId = undefined; suspendedEdge = resting; @@ -461,10 +466,25 @@ export class SceneGraph { intent: "undecided", originRect: init.originRect, }); - this.operations.set(operation.id, operation); + this.attach(operation); return operation; } + private attach(operation: MutableOriginOperation): void { + this.operations.set(operation.id, operation); + const siblings = this.outgoing.get(operation.originKey) ?? []; + this.outgoing.set(operation.originKey, [...siblings, operation]); + } + + private detach(operation: MutableOriginOperation): void { + this.operations.delete(operation.id); + const siblings = (this.outgoing.get(operation.originKey) ?? []).filter( + (sibling) => sibling.id !== operation.id, + ); + if (siblings.length) this.outgoing.set(operation.originKey, siblings); + else this.outgoing.delete(operation.originKey); + } + private removeNode(nodeKey: string): void { this.nodes.delete(nodeKey); this.roots.value = this.roots.value.filter((key) => key !== nodeKey); @@ -487,7 +507,7 @@ export class SceneGraph { operation.targetKey === nodeKey || operation.entryTargetKey === nodeKey ) - this.operations.delete(operation.id); + this.detach(operation); this.removeNode(nodeKey); } diff --git a/packages/core-v2/src/scene/scene.ts b/packages/core-v2/src/scene/scene.ts index c744b49..1ecbcec 100644 --- a/packages/core-v2/src/scene/scene.ts +++ b/packages/core-v2/src/scene/scene.ts @@ -3,6 +3,7 @@ import { inject, markRaw, nextTick, + shallowRef, type ComputedRef, type CSSProperties, type InjectionKey, @@ -66,24 +67,13 @@ const Z_INDEX_PER_LAYER = 10_000; * Ancestors' source effects are intentionally excluded: only the initiating * component receives the source side of an operation. * - * @param viewport - Measures the scene bounds supplied to choreographies. + * @param effectsOf - The current effect set of an operation's choreography. */ export const nodeEffectLayers = ( graph: SceneGraph, node: SceneNodeRecord, - viewport: () => OriginRect, + effectsOf: (operation: MutableOriginOperation) => OriginEffectSet, ): OriginEffect[] => { - const effectsOf = (operation: MutableOriginOperation): OriginEffectSet => - operation.choreography.effects({ - progress: operation.progress, - velocity: operation.velocity, - phase: operation.phase, - intent: operation.intent, - originRect: operation.originRect, - targetRect: operation.targetRect, - viewport: viewport(), - }); - const chain = graph.incomingChain(node); // Evaluate choreographies node-first, then apply them outermost-first. const effectSets = chain.map(effectsOf); @@ -111,16 +101,13 @@ export const nodeEffectLayers = ( export const composeNodeStyle = ( graph: SceneGraph, nodeKey: string, - viewport: () => OriginRect, + effectsOf: (operation: MutableOriginOperation) => OriginEffectSet, ): CSSProperties => { const node = graph.node(nodeKey); if (!node) return {}; - // Measure the scene at most once, and only when an edge needs it. - let measured: OriginRect | undefined; - const lazyViewport = () => (measured ??= viewport()); const { style, layer } = composeEffects( - nodeEffectLayers(graph, node, lazyViewport), + nodeEffectLayers(graph, node, effectsOf), NODE_BASE_STYLE, ); style.zIndex = Z_INDEX_BASE + layer * Z_INDEX_PER_LAYER + node.sequence; @@ -278,6 +265,22 @@ class Scene implements OriginScene { /** DOM registered by the renderer, used only for measurement. */ private readonly hosts = new Map(); private container: HTMLElement | null = null; + private resizeObserver: ResizeObserver | null = null; + /** + * Scene-container bounds, or the browser window before mounting. Cached and + * refreshed on resize and whenever an operation begins, so compositing + * never forces layout. + */ + private readonly viewport = shallowRef(windowRect()); + /** + * One memoized effect set per operation. It recomputes only when that + * operation's progress, phase, intent, rects, or the viewport change, so + * nodes sharing an edge do not evaluate its choreography again. + */ + private readonly effectSets = new WeakMap< + MutableOriginOperation, + ComputedRef + >(); constructor(options: CreateOriginSceneOptions) { // Never proxy the scene: its methods rely on raw internal state. @@ -307,6 +310,7 @@ class Scene implements OriginScene { originKey: string, action: OriginAction, ): Promise { + this.refreshViewport(); const operation = this.graph.begin( originKey, action, @@ -357,11 +361,20 @@ class Scene implements OriginScene { } registerContainer(element: HTMLElement | null): void { + if (element === this.container) return; + this.resizeObserver?.disconnect(); + this.resizeObserver = null; this.container = element; + if (!element) return; + this.refreshViewport(); + if (typeof ResizeObserver !== "undefined") { + this.resizeObserver = new ResizeObserver(() => this.refreshViewport()); + this.resizeObserver.observe(element); + } } styleForNode(nodeKey: string): CSSProperties { - return composeNodeStyle(this.graph, nodeKey, () => this.viewport()); + return composeNodeStyle(this.graph, nodeKey, this.effectsOf); } isNodeInteractive(nodeKey: string): boolean { @@ -392,10 +405,39 @@ class Scene implements OriginScene { return host ? measureRect(host) : undefined; } - /** Scene-container bounds, or the browser window before mounting. */ - private viewport(): OriginRect { - return this.container ? measureRect(this.container) : windowRect(); + /** Re-measure the container, replacing the cached rect only on change. */ + private refreshViewport(): void { + const next = this.container ? measureRect(this.container) : windowRect(); + const current = this.viewport.value; + if ( + next.top !== current.top || + next.left !== current.left || + next.width !== current.width || + next.height !== current.height + ) + this.viewport.value = next; } + + private readonly effectsOf = ( + operation: MutableOriginOperation, + ): OriginEffectSet => { + let effects = this.effectSets.get(operation); + if (!effects) { + effects = computed(() => + operation.choreography.effects({ + progress: operation.progress, + velocity: operation.velocity, + phase: operation.phase, + intent: operation.intent, + originRect: operation.originRect, + targetRect: operation.targetRect, + viewport: this.viewport.value, + }), + ); + this.effectSets.set(operation, effects); + } + return effects.value; + }; } /**