create-projection-node.mjs 68 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592
  1. import { statsBuffer, getValueTransition, cancelFrame, time, frameData, frameSteps, microtask, frame, activeAnimations } from 'motion-dom';
  2. import { SubscriptionManager, noop } from 'motion-utils';
  3. import { animateSingleValue } from '../../animation/animate/single-value.mjs';
  4. import { getOptimisedAppearId } from '../../animation/optimized-appear/get-appear-id.mjs';
  5. import { isSVGElement } from '../../render/dom/utils/is-svg-element.mjs';
  6. import { FlatTree } from '../../render/utils/flat-tree.mjs';
  7. import { clamp } from '../../utils/clamp.mjs';
  8. import { delay } from '../../utils/delay.mjs';
  9. import { mixNumber } from '../../utils/mix/number.mjs';
  10. import { resolveMotionValue } from '../../value/utils/resolve-motion-value.mjs';
  11. import { mixValues } from '../animation/mix-values.mjs';
  12. import { copyBoxInto, copyAxisDeltaInto } from '../geometry/copy.mjs';
  13. import { translateAxis, transformBox, applyBoxDelta, applyTreeDeltas } from '../geometry/delta-apply.mjs';
  14. import { calcLength, calcRelativePosition, calcRelativeBox, calcBoxDelta, isNear } from '../geometry/delta-calc.mjs';
  15. import { removeBoxTransforms } from '../geometry/delta-remove.mjs';
  16. import { createBox, createDelta } from '../geometry/models.mjs';
  17. import { boxEqualsRounded, isDeltaZero, axisDeltaEquals, aspectRatio, boxEquals } from '../geometry/utils.mjs';
  18. import { NodeStack } from '../shared/stack.mjs';
  19. import { scaleCorrectors } from '../styles/scale-correction.mjs';
  20. import { buildProjectionTransform } from '../styles/transform.mjs';
  21. import { eachAxis } from '../utils/each-axis.mjs';
  22. import { hasTransform, hasScale, has2DTranslate } from '../utils/has-transform.mjs';
  23. import { globalProjectionState } from './state.mjs';
  24. const metrics = {
  25. nodes: 0,
  26. calculatedTargetDeltas: 0,
  27. calculatedProjections: 0,
  28. };
  29. const transformAxes = ["", "X", "Y", "Z"];
  30. const hiddenVisibility = { visibility: "hidden" };
  31. /**
  32. * We use 1000 as the animation target as 0-1000 maps better to pixels than 0-1
  33. * which has a noticeable difference in spring animations
  34. */
  35. const animationTarget = 1000;
  36. let id = 0;
  37. function resetDistortingTransform(key, visualElement, values, sharedAnimationValues) {
  38. const { latestValues } = visualElement;
  39. // Record the distorting transform and then temporarily set it to 0
  40. if (latestValues[key]) {
  41. values[key] = latestValues[key];
  42. visualElement.setStaticValue(key, 0);
  43. if (sharedAnimationValues) {
  44. sharedAnimationValues[key] = 0;
  45. }
  46. }
  47. }
  48. function cancelTreeOptimisedTransformAnimations(projectionNode) {
  49. projectionNode.hasCheckedOptimisedAppear = true;
  50. if (projectionNode.root === projectionNode)
  51. return;
  52. const { visualElement } = projectionNode.options;
  53. if (!visualElement)
  54. return;
  55. const appearId = getOptimisedAppearId(visualElement);
  56. if (window.MotionHasOptimisedAnimation(appearId, "transform")) {
  57. const { layout, layoutId } = projectionNode.options;
  58. window.MotionCancelOptimisedAnimation(appearId, "transform", frame, !(layout || layoutId));
  59. }
  60. const { parent } = projectionNode;
  61. if (parent && !parent.hasCheckedOptimisedAppear) {
  62. cancelTreeOptimisedTransformAnimations(parent);
  63. }
  64. }
  65. function createProjectionNode({ attachResizeListener, defaultParent, measureScroll, checkIsScrollRoot, resetTransform, }) {
  66. return class ProjectionNode {
  67. constructor(latestValues = {}, parent = defaultParent?.()) {
  68. /**
  69. * A unique ID generated for every projection node.
  70. */
  71. this.id = id++;
  72. /**
  73. * An id that represents a unique session instigated by startUpdate.
  74. */
  75. this.animationId = 0;
  76. /**
  77. * A Set containing all this component's children. This is used to iterate
  78. * through the children.
  79. *
  80. * TODO: This could be faster to iterate as a flat array stored on the root node.
  81. */
  82. this.children = new Set();
  83. /**
  84. * Options for the node. We use this to configure what kind of layout animations
  85. * we should perform (if any).
  86. */
  87. this.options = {};
  88. /**
  89. * We use this to detect when its safe to shut down part of a projection tree.
  90. * We have to keep projecting children for scale correction and relative projection
  91. * until all their parents stop performing layout animations.
  92. */
  93. this.isTreeAnimating = false;
  94. this.isAnimationBlocked = false;
  95. /**
  96. * Flag to true if we think this layout has been changed. We can't always know this,
  97. * currently we set it to true every time a component renders, or if it has a layoutDependency
  98. * if that has changed between renders. Additionally, components can be grouped by LayoutGroup
  99. * and if one node is dirtied, they all are.
  100. */
  101. this.isLayoutDirty = false;
  102. /**
  103. * Flag to true if we think the projection calculations for this node needs
  104. * recalculating as a result of an updated transform or layout animation.
  105. */
  106. this.isProjectionDirty = false;
  107. /**
  108. * Flag to true if the layout *or* transform has changed. This then gets propagated
  109. * throughout the projection tree, forcing any element below to recalculate on the next frame.
  110. */
  111. this.isSharedProjectionDirty = false;
  112. /**
  113. * Flag transform dirty. This gets propagated throughout the whole tree but is only
  114. * respected by shared nodes.
  115. */
  116. this.isTransformDirty = false;
  117. /**
  118. * Block layout updates for instant layout transitions throughout the tree.
  119. */
  120. this.updateManuallyBlocked = false;
  121. this.updateBlockedByResize = false;
  122. /**
  123. * Set to true between the start of the first `willUpdate` call and the end of the `didUpdate`
  124. * call.
  125. */
  126. this.isUpdating = false;
  127. /**
  128. * If this is an SVG element we currently disable projection transforms
  129. */
  130. this.isSVG = false;
  131. /**
  132. * Flag to true (during promotion) if a node doing an instant layout transition needs to reset
  133. * its projection styles.
  134. */
  135. this.needsReset = false;
  136. /**
  137. * Flags whether this node should have its transform reset prior to measuring.
  138. */
  139. this.shouldResetTransform = false;
  140. /**
  141. * Store whether this node has been checked for optimised appear animations. As
  142. * effects fire bottom-up, and we want to look up the tree for appear animations,
  143. * this makes sure we only check each path once, stopping at nodes that
  144. * have already been checked.
  145. */
  146. this.hasCheckedOptimisedAppear = false;
  147. /**
  148. * An object representing the calculated contextual/accumulated/tree scale.
  149. * This will be used to scale calculcated projection transforms, as these are
  150. * calculated in screen-space but need to be scaled for elements to layoutly
  151. * make it to their calculated destinations.
  152. *
  153. * TODO: Lazy-init
  154. */
  155. this.treeScale = { x: 1, y: 1 };
  156. /**
  157. *
  158. */
  159. this.eventHandlers = new Map();
  160. this.hasTreeAnimated = false;
  161. // Note: Currently only running on root node
  162. this.updateScheduled = false;
  163. this.scheduleUpdate = () => this.update();
  164. this.projectionUpdateScheduled = false;
  165. this.checkUpdateFailed = () => {
  166. if (this.isUpdating) {
  167. this.isUpdating = false;
  168. this.clearAllSnapshots();
  169. }
  170. };
  171. /**
  172. * This is a multi-step process as shared nodes might be of different depths. Nodes
  173. * are sorted by depth order, so we need to resolve the entire tree before moving to
  174. * the next step.
  175. */
  176. this.updateProjection = () => {
  177. this.projectionUpdateScheduled = false;
  178. /**
  179. * Reset debug counts. Manually resetting rather than creating a new
  180. * object each frame.
  181. */
  182. if (statsBuffer.value) {
  183. metrics.nodes =
  184. metrics.calculatedTargetDeltas =
  185. metrics.calculatedProjections =
  186. 0;
  187. }
  188. this.nodes.forEach(propagateDirtyNodes);
  189. this.nodes.forEach(resolveTargetDelta);
  190. this.nodes.forEach(calcProjection);
  191. this.nodes.forEach(cleanDirtyNodes);
  192. if (statsBuffer.addProjectionMetrics) {
  193. statsBuffer.addProjectionMetrics(metrics);
  194. }
  195. };
  196. /**
  197. * Frame calculations
  198. */
  199. this.resolvedRelativeTargetAt = 0.0;
  200. this.hasProjected = false;
  201. this.isVisible = true;
  202. this.animationProgress = 0;
  203. /**
  204. * Shared layout
  205. */
  206. // TODO Only running on root node
  207. this.sharedNodes = new Map();
  208. this.latestValues = latestValues;
  209. this.root = parent ? parent.root || parent : this;
  210. this.path = parent ? [...parent.path, parent] : [];
  211. this.parent = parent;
  212. this.depth = parent ? parent.depth + 1 : 0;
  213. for (let i = 0; i < this.path.length; i++) {
  214. this.path[i].shouldResetTransform = true;
  215. }
  216. if (this.root === this)
  217. this.nodes = new FlatTree();
  218. }
  219. addEventListener(name, handler) {
  220. if (!this.eventHandlers.has(name)) {
  221. this.eventHandlers.set(name, new SubscriptionManager());
  222. }
  223. return this.eventHandlers.get(name).add(handler);
  224. }
  225. notifyListeners(name, ...args) {
  226. const subscriptionManager = this.eventHandlers.get(name);
  227. subscriptionManager && subscriptionManager.notify(...args);
  228. }
  229. hasListeners(name) {
  230. return this.eventHandlers.has(name);
  231. }
  232. /**
  233. * Lifecycles
  234. */
  235. mount(instance, isLayoutDirty = this.root.hasTreeAnimated) {
  236. if (this.instance)
  237. return;
  238. this.isSVG = isSVGElement(instance);
  239. this.instance = instance;
  240. const { layoutId, layout, visualElement } = this.options;
  241. if (visualElement && !visualElement.current) {
  242. visualElement.mount(instance);
  243. }
  244. this.root.nodes.add(this);
  245. this.parent && this.parent.children.add(this);
  246. if (isLayoutDirty && (layout || layoutId)) {
  247. this.isLayoutDirty = true;
  248. }
  249. if (attachResizeListener) {
  250. let cancelDelay;
  251. const resizeUnblockUpdate = () => (this.root.updateBlockedByResize = false);
  252. attachResizeListener(instance, () => {
  253. this.root.updateBlockedByResize = true;
  254. cancelDelay && cancelDelay();
  255. cancelDelay = delay(resizeUnblockUpdate, 250);
  256. if (globalProjectionState.hasAnimatedSinceResize) {
  257. globalProjectionState.hasAnimatedSinceResize = false;
  258. this.nodes.forEach(finishAnimation);
  259. }
  260. });
  261. }
  262. if (layoutId) {
  263. this.root.registerSharedNode(layoutId, this);
  264. }
  265. // Only register the handler if it requires layout animation
  266. if (this.options.animate !== false &&
  267. visualElement &&
  268. (layoutId || layout)) {
  269. this.addEventListener("didUpdate", ({ delta, hasLayoutChanged, hasRelativeLayoutChanged, layout: newLayout, }) => {
  270. if (this.isTreeAnimationBlocked()) {
  271. this.target = undefined;
  272. this.relativeTarget = undefined;
  273. return;
  274. }
  275. // TODO: Check here if an animation exists
  276. const layoutTransition = this.options.transition ||
  277. visualElement.getDefaultTransition() ||
  278. defaultLayoutTransition;
  279. const { onLayoutAnimationStart, onLayoutAnimationComplete, } = visualElement.getProps();
  280. /**
  281. * The target layout of the element might stay the same,
  282. * but its position relative to its parent has changed.
  283. */
  284. const hasTargetChanged = !this.targetLayout ||
  285. !boxEqualsRounded(this.targetLayout, newLayout);
  286. /*
  287. * Note: Disabled to fix relative animations always triggering new
  288. * layout animations. If this causes further issues, we can try
  289. * a different approach to detecting relative target changes.
  290. */
  291. // || hasRelativeLayoutChanged
  292. /**
  293. * If the layout hasn't seemed to have changed, it might be that the
  294. * element is visually in the same place in the document but its position
  295. * relative to its parent has indeed changed. So here we check for that.
  296. */
  297. const hasOnlyRelativeTargetChanged = !hasLayoutChanged && hasRelativeLayoutChanged;
  298. if (this.options.layoutRoot ||
  299. this.resumeFrom ||
  300. hasOnlyRelativeTargetChanged ||
  301. (hasLayoutChanged &&
  302. (hasTargetChanged || !this.currentAnimation))) {
  303. if (this.resumeFrom) {
  304. this.resumingFrom = this.resumeFrom;
  305. this.resumingFrom.resumingFrom = undefined;
  306. }
  307. this.setAnimationOrigin(delta, hasOnlyRelativeTargetChanged);
  308. const animationOptions = {
  309. ...getValueTransition(layoutTransition, "layout"),
  310. onPlay: onLayoutAnimationStart,
  311. onComplete: onLayoutAnimationComplete,
  312. };
  313. if (visualElement.shouldReduceMotion ||
  314. this.options.layoutRoot) {
  315. animationOptions.delay = 0;
  316. animationOptions.type = false;
  317. }
  318. this.startAnimation(animationOptions);
  319. }
  320. else {
  321. /**
  322. * If the layout hasn't changed and we have an animation that hasn't started yet,
  323. * finish it immediately. Otherwise it will be animating from a location
  324. * that was probably never commited to screen and look like a jumpy box.
  325. */
  326. if (!hasLayoutChanged) {
  327. finishAnimation(this);
  328. }
  329. if (this.isLead() && this.options.onExitComplete) {
  330. this.options.onExitComplete();
  331. }
  332. }
  333. this.targetLayout = newLayout;
  334. });
  335. }
  336. }
  337. unmount() {
  338. this.options.layoutId && this.willUpdate();
  339. this.root.nodes.remove(this);
  340. const stack = this.getStack();
  341. stack && stack.remove(this);
  342. this.parent && this.parent.children.delete(this);
  343. this.instance = undefined;
  344. cancelFrame(this.updateProjection);
  345. }
  346. // only on the root
  347. blockUpdate() {
  348. this.updateManuallyBlocked = true;
  349. }
  350. unblockUpdate() {
  351. this.updateManuallyBlocked = false;
  352. }
  353. isUpdateBlocked() {
  354. return this.updateManuallyBlocked || this.updateBlockedByResize;
  355. }
  356. isTreeAnimationBlocked() {
  357. return (this.isAnimationBlocked ||
  358. (this.parent && this.parent.isTreeAnimationBlocked()) ||
  359. false);
  360. }
  361. // Note: currently only running on root node
  362. startUpdate() {
  363. if (this.isUpdateBlocked())
  364. return;
  365. this.isUpdating = true;
  366. this.nodes && this.nodes.forEach(resetSkewAndRotation);
  367. this.animationId++;
  368. }
  369. getTransformTemplate() {
  370. const { visualElement } = this.options;
  371. return visualElement && visualElement.getProps().transformTemplate;
  372. }
  373. willUpdate(shouldNotifyListeners = true) {
  374. this.root.hasTreeAnimated = true;
  375. if (this.root.isUpdateBlocked()) {
  376. this.options.onExitComplete && this.options.onExitComplete();
  377. return;
  378. }
  379. /**
  380. * If we're running optimised appear animations then these must be
  381. * cancelled before measuring the DOM. This is so we can measure
  382. * the true layout of the element rather than the WAAPI animation
  383. * which will be unaffected by the resetSkewAndRotate step.
  384. *
  385. * Note: This is a DOM write. Worst case scenario is this is sandwiched
  386. * between other snapshot reads which will cause unnecessary style recalculations.
  387. * This has to happen here though, as we don't yet know which nodes will need
  388. * snapshots in startUpdate(), but we only want to cancel optimised animations
  389. * if a layout animation measurement is actually going to be affected by them.
  390. */
  391. if (window.MotionCancelOptimisedAnimation &&
  392. !this.hasCheckedOptimisedAppear) {
  393. cancelTreeOptimisedTransformAnimations(this);
  394. }
  395. !this.root.isUpdating && this.root.startUpdate();
  396. if (this.isLayoutDirty)
  397. return;
  398. this.isLayoutDirty = true;
  399. for (let i = 0; i < this.path.length; i++) {
  400. const node = this.path[i];
  401. node.shouldResetTransform = true;
  402. node.updateScroll("snapshot");
  403. if (node.options.layoutRoot) {
  404. node.willUpdate(false);
  405. }
  406. }
  407. const { layoutId, layout } = this.options;
  408. if (layoutId === undefined && !layout)
  409. return;
  410. const transformTemplate = this.getTransformTemplate();
  411. this.prevTransformTemplateValue = transformTemplate
  412. ? transformTemplate(this.latestValues, "")
  413. : undefined;
  414. this.updateSnapshot();
  415. shouldNotifyListeners && this.notifyListeners("willUpdate");
  416. }
  417. update() {
  418. this.updateScheduled = false;
  419. const updateWasBlocked = this.isUpdateBlocked();
  420. // When doing an instant transition, we skip the layout update,
  421. // but should still clean up the measurements so that the next
  422. // snapshot could be taken correctly.
  423. if (updateWasBlocked) {
  424. this.unblockUpdate();
  425. this.clearAllSnapshots();
  426. this.nodes.forEach(clearMeasurements);
  427. return;
  428. }
  429. if (!this.isUpdating) {
  430. this.nodes.forEach(clearIsLayoutDirty);
  431. }
  432. this.isUpdating = false;
  433. /**
  434. * Write
  435. */
  436. this.nodes.forEach(resetTransformStyle);
  437. /**
  438. * Read ==================
  439. */
  440. // Update layout measurements of updated children
  441. this.nodes.forEach(updateLayout);
  442. /**
  443. * Write
  444. */
  445. // Notify listeners that the layout is updated
  446. this.nodes.forEach(notifyLayoutUpdate);
  447. this.clearAllSnapshots();
  448. /**
  449. * Manually flush any pending updates. Ideally
  450. * we could leave this to the following requestAnimationFrame but this seems
  451. * to leave a flash of incorrectly styled content.
  452. */
  453. const now = time.now();
  454. frameData.delta = clamp(0, 1000 / 60, now - frameData.timestamp);
  455. frameData.timestamp = now;
  456. frameData.isProcessing = true;
  457. frameSteps.update.process(frameData);
  458. frameSteps.preRender.process(frameData);
  459. frameSteps.render.process(frameData);
  460. frameData.isProcessing = false;
  461. }
  462. didUpdate() {
  463. if (!this.updateScheduled) {
  464. this.updateScheduled = true;
  465. microtask.read(this.scheduleUpdate);
  466. }
  467. }
  468. clearAllSnapshots() {
  469. this.nodes.forEach(clearSnapshot);
  470. this.sharedNodes.forEach(removeLeadSnapshots);
  471. }
  472. scheduleUpdateProjection() {
  473. if (!this.projectionUpdateScheduled) {
  474. this.projectionUpdateScheduled = true;
  475. frame.preRender(this.updateProjection, false, true);
  476. }
  477. }
  478. scheduleCheckAfterUnmount() {
  479. /**
  480. * If the unmounting node is in a layoutGroup and did trigger a willUpdate,
  481. * we manually call didUpdate to give a chance to the siblings to animate.
  482. * Otherwise, cleanup all snapshots to prevents future nodes from reusing them.
  483. */
  484. frame.postRender(() => {
  485. if (this.isLayoutDirty) {
  486. this.root.didUpdate();
  487. }
  488. else {
  489. this.root.checkUpdateFailed();
  490. }
  491. });
  492. }
  493. /**
  494. * Update measurements
  495. */
  496. updateSnapshot() {
  497. if (this.snapshot || !this.instance)
  498. return;
  499. this.snapshot = this.measure();
  500. if (this.snapshot &&
  501. !calcLength(this.snapshot.measuredBox.x) &&
  502. !calcLength(this.snapshot.measuredBox.y)) {
  503. this.snapshot = undefined;
  504. }
  505. }
  506. updateLayout() {
  507. if (!this.instance)
  508. return;
  509. // TODO: Incorporate into a forwarded scroll offset
  510. this.updateScroll();
  511. if (!(this.options.alwaysMeasureLayout && this.isLead()) &&
  512. !this.isLayoutDirty) {
  513. return;
  514. }
  515. /**
  516. * When a node is mounted, it simply resumes from the prevLead's
  517. * snapshot instead of taking a new one, but the ancestors scroll
  518. * might have updated while the prevLead is unmounted. We need to
  519. * update the scroll again to make sure the layout we measure is
  520. * up to date.
  521. */
  522. if (this.resumeFrom && !this.resumeFrom.instance) {
  523. for (let i = 0; i < this.path.length; i++) {
  524. const node = this.path[i];
  525. node.updateScroll();
  526. }
  527. }
  528. const prevLayout = this.layout;
  529. this.layout = this.measure(false);
  530. this.layoutCorrected = createBox();
  531. this.isLayoutDirty = false;
  532. this.projectionDelta = undefined;
  533. this.notifyListeners("measure", this.layout.layoutBox);
  534. const { visualElement } = this.options;
  535. visualElement &&
  536. visualElement.notify("LayoutMeasure", this.layout.layoutBox, prevLayout ? prevLayout.layoutBox : undefined);
  537. }
  538. updateScroll(phase = "measure") {
  539. let needsMeasurement = Boolean(this.options.layoutScroll && this.instance);
  540. if (this.scroll &&
  541. this.scroll.animationId === this.root.animationId &&
  542. this.scroll.phase === phase) {
  543. needsMeasurement = false;
  544. }
  545. if (needsMeasurement) {
  546. const isRoot = checkIsScrollRoot(this.instance);
  547. this.scroll = {
  548. animationId: this.root.animationId,
  549. phase,
  550. isRoot,
  551. offset: measureScroll(this.instance),
  552. wasRoot: this.scroll ? this.scroll.isRoot : isRoot,
  553. };
  554. }
  555. }
  556. resetTransform() {
  557. if (!resetTransform)
  558. return;
  559. const isResetRequested = this.isLayoutDirty ||
  560. this.shouldResetTransform ||
  561. this.options.alwaysMeasureLayout;
  562. const hasProjection = this.projectionDelta && !isDeltaZero(this.projectionDelta);
  563. const transformTemplate = this.getTransformTemplate();
  564. const transformTemplateValue = transformTemplate
  565. ? transformTemplate(this.latestValues, "")
  566. : undefined;
  567. const transformTemplateHasChanged = transformTemplateValue !== this.prevTransformTemplateValue;
  568. if (isResetRequested &&
  569. (hasProjection ||
  570. hasTransform(this.latestValues) ||
  571. transformTemplateHasChanged)) {
  572. resetTransform(this.instance, transformTemplateValue);
  573. this.shouldResetTransform = false;
  574. this.scheduleRender();
  575. }
  576. }
  577. measure(removeTransform = true) {
  578. const pageBox = this.measurePageBox();
  579. let layoutBox = this.removeElementScroll(pageBox);
  580. /**
  581. * Measurements taken during the pre-render stage
  582. * still have transforms applied so we remove them
  583. * via calculation.
  584. */
  585. if (removeTransform) {
  586. layoutBox = this.removeTransform(layoutBox);
  587. }
  588. roundBox(layoutBox);
  589. return {
  590. animationId: this.root.animationId,
  591. measuredBox: pageBox,
  592. layoutBox,
  593. latestValues: {},
  594. source: this.id,
  595. };
  596. }
  597. measurePageBox() {
  598. const { visualElement } = this.options;
  599. if (!visualElement)
  600. return createBox();
  601. const box = visualElement.measureViewportBox();
  602. const wasInScrollRoot = this.scroll?.wasRoot || this.path.some(checkNodeWasScrollRoot);
  603. if (!wasInScrollRoot) {
  604. // Remove viewport scroll to give page-relative coordinates
  605. const { scroll } = this.root;
  606. if (scroll) {
  607. translateAxis(box.x, scroll.offset.x);
  608. translateAxis(box.y, scroll.offset.y);
  609. }
  610. }
  611. return box;
  612. }
  613. removeElementScroll(box) {
  614. const boxWithoutScroll = createBox();
  615. copyBoxInto(boxWithoutScroll, box);
  616. if (this.scroll?.wasRoot) {
  617. return boxWithoutScroll;
  618. }
  619. /**
  620. * Performance TODO: Keep a cumulative scroll offset down the tree
  621. * rather than loop back up the path.
  622. */
  623. for (let i = 0; i < this.path.length; i++) {
  624. const node = this.path[i];
  625. const { scroll, options } = node;
  626. if (node !== this.root && scroll && options.layoutScroll) {
  627. /**
  628. * If this is a new scroll root, we want to remove all previous scrolls
  629. * from the viewport box.
  630. */
  631. if (scroll.wasRoot) {
  632. copyBoxInto(boxWithoutScroll, box);
  633. }
  634. translateAxis(boxWithoutScroll.x, scroll.offset.x);
  635. translateAxis(boxWithoutScroll.y, scroll.offset.y);
  636. }
  637. }
  638. return boxWithoutScroll;
  639. }
  640. applyTransform(box, transformOnly = false) {
  641. const withTransforms = createBox();
  642. copyBoxInto(withTransforms, box);
  643. for (let i = 0; i < this.path.length; i++) {
  644. const node = this.path[i];
  645. if (!transformOnly &&
  646. node.options.layoutScroll &&
  647. node.scroll &&
  648. node !== node.root) {
  649. transformBox(withTransforms, {
  650. x: -node.scroll.offset.x,
  651. y: -node.scroll.offset.y,
  652. });
  653. }
  654. if (!hasTransform(node.latestValues))
  655. continue;
  656. transformBox(withTransforms, node.latestValues);
  657. }
  658. if (hasTransform(this.latestValues)) {
  659. transformBox(withTransforms, this.latestValues);
  660. }
  661. return withTransforms;
  662. }
  663. removeTransform(box) {
  664. const boxWithoutTransform = createBox();
  665. copyBoxInto(boxWithoutTransform, box);
  666. for (let i = 0; i < this.path.length; i++) {
  667. const node = this.path[i];
  668. if (!node.instance)
  669. continue;
  670. if (!hasTransform(node.latestValues))
  671. continue;
  672. hasScale(node.latestValues) && node.updateSnapshot();
  673. const sourceBox = createBox();
  674. const nodeBox = node.measurePageBox();
  675. copyBoxInto(sourceBox, nodeBox);
  676. removeBoxTransforms(boxWithoutTransform, node.latestValues, node.snapshot ? node.snapshot.layoutBox : undefined, sourceBox);
  677. }
  678. if (hasTransform(this.latestValues)) {
  679. removeBoxTransforms(boxWithoutTransform, this.latestValues);
  680. }
  681. return boxWithoutTransform;
  682. }
  683. setTargetDelta(delta) {
  684. this.targetDelta = delta;
  685. this.root.scheduleUpdateProjection();
  686. this.isProjectionDirty = true;
  687. }
  688. setOptions(options) {
  689. this.options = {
  690. ...this.options,
  691. ...options,
  692. crossfade: options.crossfade !== undefined ? options.crossfade : true,
  693. };
  694. }
  695. clearMeasurements() {
  696. this.scroll = undefined;
  697. this.layout = undefined;
  698. this.snapshot = undefined;
  699. this.prevTransformTemplateValue = undefined;
  700. this.targetDelta = undefined;
  701. this.target = undefined;
  702. this.isLayoutDirty = false;
  703. }
  704. forceRelativeParentToResolveTarget() {
  705. if (!this.relativeParent)
  706. return;
  707. /**
  708. * If the parent target isn't up-to-date, force it to update.
  709. * This is an unfortunate de-optimisation as it means any updating relative
  710. * projection will cause all the relative parents to recalculate back
  711. * up the tree.
  712. */
  713. if (this.relativeParent.resolvedRelativeTargetAt !==
  714. frameData.timestamp) {
  715. this.relativeParent.resolveTargetDelta(true);
  716. }
  717. }
  718. resolveTargetDelta(forceRecalculation = false) {
  719. /**
  720. * Once the dirty status of nodes has been spread through the tree, we also
  721. * need to check if we have a shared node of a different depth that has itself
  722. * been dirtied.
  723. */
  724. const lead = this.getLead();
  725. this.isProjectionDirty || (this.isProjectionDirty = lead.isProjectionDirty);
  726. this.isTransformDirty || (this.isTransformDirty = lead.isTransformDirty);
  727. this.isSharedProjectionDirty || (this.isSharedProjectionDirty = lead.isSharedProjectionDirty);
  728. const isShared = Boolean(this.resumingFrom) || this !== lead;
  729. /**
  730. * We don't use transform for this step of processing so we don't
  731. * need to check whether any nodes have changed transform.
  732. */
  733. const canSkip = !(forceRecalculation ||
  734. (isShared && this.isSharedProjectionDirty) ||
  735. this.isProjectionDirty ||
  736. this.parent?.isProjectionDirty ||
  737. this.attemptToResolveRelativeTarget ||
  738. this.root.updateBlockedByResize);
  739. if (canSkip)
  740. return;
  741. const { layout, layoutId } = this.options;
  742. /**
  743. * If we have no layout, we can't perform projection, so early return
  744. */
  745. if (!this.layout || !(layout || layoutId))
  746. return;
  747. this.resolvedRelativeTargetAt = frameData.timestamp;
  748. /**
  749. * If we don't have a targetDelta but do have a layout, we can attempt to resolve
  750. * a relativeParent. This will allow a component to perform scale correction
  751. * even if no animation has started.
  752. */
  753. if (!this.targetDelta && !this.relativeTarget) {
  754. const relativeParent = this.getClosestProjectingParent();
  755. if (relativeParent &&
  756. relativeParent.layout &&
  757. this.animationProgress !== 1) {
  758. this.relativeParent = relativeParent;
  759. this.forceRelativeParentToResolveTarget();
  760. this.relativeTarget = createBox();
  761. this.relativeTargetOrigin = createBox();
  762. calcRelativePosition(this.relativeTargetOrigin, this.layout.layoutBox, relativeParent.layout.layoutBox);
  763. copyBoxInto(this.relativeTarget, this.relativeTargetOrigin);
  764. }
  765. else {
  766. this.relativeParent = this.relativeTarget = undefined;
  767. }
  768. }
  769. /**
  770. * If we have no relative target or no target delta our target isn't valid
  771. * for this frame.
  772. */
  773. if (!this.relativeTarget && !this.targetDelta)
  774. return;
  775. /**
  776. * Lazy-init target data structure
  777. */
  778. if (!this.target) {
  779. this.target = createBox();
  780. this.targetWithTransforms = createBox();
  781. }
  782. /**
  783. * If we've got a relative box for this component, resolve it into a target relative to the parent.
  784. */
  785. if (this.relativeTarget &&
  786. this.relativeTargetOrigin &&
  787. this.relativeParent &&
  788. this.relativeParent.target) {
  789. this.forceRelativeParentToResolveTarget();
  790. calcRelativeBox(this.target, this.relativeTarget, this.relativeParent.target);
  791. /**
  792. * If we've only got a targetDelta, resolve it into a target
  793. */
  794. }
  795. else if (this.targetDelta) {
  796. if (Boolean(this.resumingFrom)) {
  797. // TODO: This is creating a new object every frame
  798. this.target = this.applyTransform(this.layout.layoutBox);
  799. }
  800. else {
  801. copyBoxInto(this.target, this.layout.layoutBox);
  802. }
  803. applyBoxDelta(this.target, this.targetDelta);
  804. }
  805. else {
  806. /**
  807. * If no target, use own layout as target
  808. */
  809. copyBoxInto(this.target, this.layout.layoutBox);
  810. }
  811. /**
  812. * If we've been told to attempt to resolve a relative target, do so.
  813. */
  814. if (this.attemptToResolveRelativeTarget) {
  815. this.attemptToResolveRelativeTarget = false;
  816. const relativeParent = this.getClosestProjectingParent();
  817. if (relativeParent &&
  818. Boolean(relativeParent.resumingFrom) ===
  819. Boolean(this.resumingFrom) &&
  820. !relativeParent.options.layoutScroll &&
  821. relativeParent.target &&
  822. this.animationProgress !== 1) {
  823. this.relativeParent = relativeParent;
  824. this.forceRelativeParentToResolveTarget();
  825. this.relativeTarget = createBox();
  826. this.relativeTargetOrigin = createBox();
  827. calcRelativePosition(this.relativeTargetOrigin, this.target, relativeParent.target);
  828. copyBoxInto(this.relativeTarget, this.relativeTargetOrigin);
  829. }
  830. else {
  831. this.relativeParent = this.relativeTarget = undefined;
  832. }
  833. }
  834. /**
  835. * Increase debug counter for resolved target deltas
  836. */
  837. if (statsBuffer.value) {
  838. metrics.calculatedTargetDeltas++;
  839. }
  840. }
  841. getClosestProjectingParent() {
  842. if (!this.parent ||
  843. hasScale(this.parent.latestValues) ||
  844. has2DTranslate(this.parent.latestValues)) {
  845. return undefined;
  846. }
  847. if (this.parent.isProjecting()) {
  848. return this.parent;
  849. }
  850. else {
  851. return this.parent.getClosestProjectingParent();
  852. }
  853. }
  854. isProjecting() {
  855. return Boolean((this.relativeTarget ||
  856. this.targetDelta ||
  857. this.options.layoutRoot) &&
  858. this.layout);
  859. }
  860. calcProjection() {
  861. const lead = this.getLead();
  862. const isShared = Boolean(this.resumingFrom) || this !== lead;
  863. let canSkip = true;
  864. /**
  865. * If this is a normal layout animation and neither this node nor its nearest projecting
  866. * is dirty then we can't skip.
  867. */
  868. if (this.isProjectionDirty || this.parent?.isProjectionDirty) {
  869. canSkip = false;
  870. }
  871. /**
  872. * If this is a shared layout animation and this node's shared projection is dirty then
  873. * we can't skip.
  874. */
  875. if (isShared &&
  876. (this.isSharedProjectionDirty || this.isTransformDirty)) {
  877. canSkip = false;
  878. }
  879. /**
  880. * If we have resolved the target this frame we must recalculate the
  881. * projection to ensure it visually represents the internal calculations.
  882. */
  883. if (this.resolvedRelativeTargetAt === frameData.timestamp) {
  884. canSkip = false;
  885. }
  886. if (canSkip)
  887. return;
  888. const { layout, layoutId } = this.options;
  889. /**
  890. * If this section of the tree isn't animating we can
  891. * delete our target sources for the following frame.
  892. */
  893. this.isTreeAnimating = Boolean((this.parent && this.parent.isTreeAnimating) ||
  894. this.currentAnimation ||
  895. this.pendingAnimation);
  896. if (!this.isTreeAnimating) {
  897. this.targetDelta = this.relativeTarget = undefined;
  898. }
  899. if (!this.layout || !(layout || layoutId))
  900. return;
  901. /**
  902. * Reset the corrected box with the latest values from box, as we're then going
  903. * to perform mutative operations on it.
  904. */
  905. copyBoxInto(this.layoutCorrected, this.layout.layoutBox);
  906. /**
  907. * Record previous tree scales before updating.
  908. */
  909. const prevTreeScaleX = this.treeScale.x;
  910. const prevTreeScaleY = this.treeScale.y;
  911. /**
  912. * Apply all the parent deltas to this box to produce the corrected box. This
  913. * is the layout box, as it will appear on screen as a result of the transforms of its parents.
  914. */
  915. applyTreeDeltas(this.layoutCorrected, this.treeScale, this.path, isShared);
  916. /**
  917. * If this layer needs to perform scale correction but doesn't have a target,
  918. * use the layout as the target.
  919. */
  920. if (lead.layout &&
  921. !lead.target &&
  922. (this.treeScale.x !== 1 || this.treeScale.y !== 1)) {
  923. lead.target = lead.layout.layoutBox;
  924. lead.targetWithTransforms = createBox();
  925. }
  926. const { target } = lead;
  927. if (!target) {
  928. /**
  929. * If we don't have a target to project into, but we were previously
  930. * projecting, we want to remove the stored transform and schedule
  931. * a render to ensure the elements reflect the removed transform.
  932. */
  933. if (this.prevProjectionDelta) {
  934. this.createProjectionDeltas();
  935. this.scheduleRender();
  936. }
  937. return;
  938. }
  939. if (!this.projectionDelta || !this.prevProjectionDelta) {
  940. this.createProjectionDeltas();
  941. }
  942. else {
  943. copyAxisDeltaInto(this.prevProjectionDelta.x, this.projectionDelta.x);
  944. copyAxisDeltaInto(this.prevProjectionDelta.y, this.projectionDelta.y);
  945. }
  946. /**
  947. * Update the delta between the corrected box and the target box before user-set transforms were applied.
  948. * This will allow us to calculate the corrected borderRadius and boxShadow to compensate
  949. * for our layout reprojection, but still allow them to be scaled correctly by the user.
  950. * It might be that to simplify this we may want to accept that user-set scale is also corrected
  951. * and we wouldn't have to keep and calc both deltas, OR we could support a user setting
  952. * to allow people to choose whether these styles are corrected based on just the
  953. * layout reprojection or the final bounding box.
  954. */
  955. calcBoxDelta(this.projectionDelta, this.layoutCorrected, target, this.latestValues);
  956. if (this.treeScale.x !== prevTreeScaleX ||
  957. this.treeScale.y !== prevTreeScaleY ||
  958. !axisDeltaEquals(this.projectionDelta.x, this.prevProjectionDelta.x) ||
  959. !axisDeltaEquals(this.projectionDelta.y, this.prevProjectionDelta.y)) {
  960. this.hasProjected = true;
  961. this.scheduleRender();
  962. this.notifyListeners("projectionUpdate", target);
  963. }
  964. /**
  965. * Increase debug counter for recalculated projections
  966. */
  967. if (statsBuffer.value) {
  968. metrics.calculatedProjections++;
  969. }
  970. }
  971. hide() {
  972. this.isVisible = false;
  973. // TODO: Schedule render
  974. }
  975. show() {
  976. this.isVisible = true;
  977. // TODO: Schedule render
  978. }
  979. scheduleRender(notifyAll = true) {
  980. this.options.visualElement?.scheduleRender();
  981. if (notifyAll) {
  982. const stack = this.getStack();
  983. stack && stack.scheduleRender();
  984. }
  985. if (this.resumingFrom && !this.resumingFrom.instance) {
  986. this.resumingFrom = undefined;
  987. }
  988. }
  989. createProjectionDeltas() {
  990. this.prevProjectionDelta = createDelta();
  991. this.projectionDelta = createDelta();
  992. this.projectionDeltaWithTransform = createDelta();
  993. }
  994. setAnimationOrigin(delta, hasOnlyRelativeTargetChanged = false) {
  995. const snapshot = this.snapshot;
  996. const snapshotLatestValues = snapshot
  997. ? snapshot.latestValues
  998. : {};
  999. const mixedValues = { ...this.latestValues };
  1000. const targetDelta = createDelta();
  1001. if (!this.relativeParent ||
  1002. !this.relativeParent.options.layoutRoot) {
  1003. this.relativeTarget = this.relativeTargetOrigin = undefined;
  1004. }
  1005. this.attemptToResolveRelativeTarget = !hasOnlyRelativeTargetChanged;
  1006. const relativeLayout = createBox();
  1007. const snapshotSource = snapshot ? snapshot.source : undefined;
  1008. const layoutSource = this.layout ? this.layout.source : undefined;
  1009. const isSharedLayoutAnimation = snapshotSource !== layoutSource;
  1010. const stack = this.getStack();
  1011. const isOnlyMember = !stack || stack.members.length <= 1;
  1012. const shouldCrossfadeOpacity = Boolean(isSharedLayoutAnimation &&
  1013. !isOnlyMember &&
  1014. this.options.crossfade === true &&
  1015. !this.path.some(hasOpacityCrossfade));
  1016. this.animationProgress = 0;
  1017. let prevRelativeTarget;
  1018. this.mixTargetDelta = (latest) => {
  1019. const progress = latest / 1000;
  1020. mixAxisDelta(targetDelta.x, delta.x, progress);
  1021. mixAxisDelta(targetDelta.y, delta.y, progress);
  1022. this.setTargetDelta(targetDelta);
  1023. if (this.relativeTarget &&
  1024. this.relativeTargetOrigin &&
  1025. this.layout &&
  1026. this.relativeParent &&
  1027. this.relativeParent.layout) {
  1028. calcRelativePosition(relativeLayout, this.layout.layoutBox, this.relativeParent.layout.layoutBox);
  1029. mixBox(this.relativeTarget, this.relativeTargetOrigin, relativeLayout, progress);
  1030. /**
  1031. * If this is an unchanged relative target we can consider the
  1032. * projection not dirty.
  1033. */
  1034. if (prevRelativeTarget &&
  1035. boxEquals(this.relativeTarget, prevRelativeTarget)) {
  1036. this.isProjectionDirty = false;
  1037. }
  1038. if (!prevRelativeTarget)
  1039. prevRelativeTarget = createBox();
  1040. copyBoxInto(prevRelativeTarget, this.relativeTarget);
  1041. }
  1042. if (isSharedLayoutAnimation) {
  1043. this.animationValues = mixedValues;
  1044. mixValues(mixedValues, snapshotLatestValues, this.latestValues, progress, shouldCrossfadeOpacity, isOnlyMember);
  1045. }
  1046. this.root.scheduleUpdateProjection();
  1047. this.scheduleRender();
  1048. this.animationProgress = progress;
  1049. };
  1050. this.mixTargetDelta(this.options.layoutRoot ? 1000 : 0);
  1051. }
  1052. startAnimation(options) {
  1053. this.notifyListeners("animationStart");
  1054. this.currentAnimation && this.currentAnimation.stop();
  1055. if (this.resumingFrom && this.resumingFrom.currentAnimation) {
  1056. this.resumingFrom.currentAnimation.stop();
  1057. }
  1058. if (this.pendingAnimation) {
  1059. cancelFrame(this.pendingAnimation);
  1060. this.pendingAnimation = undefined;
  1061. }
  1062. /**
  1063. * Start the animation in the next frame to have a frame with progress 0,
  1064. * where the target is the same as when the animation started, so we can
  1065. * calculate the relative positions correctly for instant transitions.
  1066. */
  1067. this.pendingAnimation = frame.update(() => {
  1068. globalProjectionState.hasAnimatedSinceResize = true;
  1069. activeAnimations.layout++;
  1070. this.currentAnimation = animateSingleValue(0, animationTarget, {
  1071. ...options,
  1072. onUpdate: (latest) => {
  1073. this.mixTargetDelta(latest);
  1074. options.onUpdate && options.onUpdate(latest);
  1075. },
  1076. onStop: () => {
  1077. activeAnimations.layout--;
  1078. },
  1079. onComplete: () => {
  1080. activeAnimations.layout--;
  1081. options.onComplete && options.onComplete();
  1082. this.completeAnimation();
  1083. },
  1084. });
  1085. if (this.resumingFrom) {
  1086. this.resumingFrom.currentAnimation = this.currentAnimation;
  1087. }
  1088. this.pendingAnimation = undefined;
  1089. });
  1090. }
  1091. completeAnimation() {
  1092. if (this.resumingFrom) {
  1093. this.resumingFrom.currentAnimation = undefined;
  1094. this.resumingFrom.preserveOpacity = undefined;
  1095. }
  1096. const stack = this.getStack();
  1097. stack && stack.exitAnimationComplete();
  1098. this.resumingFrom =
  1099. this.currentAnimation =
  1100. this.animationValues =
  1101. undefined;
  1102. this.notifyListeners("animationComplete");
  1103. }
  1104. finishAnimation() {
  1105. if (this.currentAnimation) {
  1106. this.mixTargetDelta && this.mixTargetDelta(animationTarget);
  1107. this.currentAnimation.stop();
  1108. }
  1109. this.completeAnimation();
  1110. }
  1111. applyTransformsToTarget() {
  1112. const lead = this.getLead();
  1113. let { targetWithTransforms, target, layout, latestValues } = lead;
  1114. if (!targetWithTransforms || !target || !layout)
  1115. return;
  1116. /**
  1117. * If we're only animating position, and this element isn't the lead element,
  1118. * then instead of projecting into the lead box we instead want to calculate
  1119. * a new target that aligns the two boxes but maintains the layout shape.
  1120. */
  1121. if (this !== lead &&
  1122. this.layout &&
  1123. layout &&
  1124. shouldAnimatePositionOnly(this.options.animationType, this.layout.layoutBox, layout.layoutBox)) {
  1125. target = this.target || createBox();
  1126. const xLength = calcLength(this.layout.layoutBox.x);
  1127. target.x.min = lead.target.x.min;
  1128. target.x.max = target.x.min + xLength;
  1129. const yLength = calcLength(this.layout.layoutBox.y);
  1130. target.y.min = lead.target.y.min;
  1131. target.y.max = target.y.min + yLength;
  1132. }
  1133. copyBoxInto(targetWithTransforms, target);
  1134. /**
  1135. * Apply the latest user-set transforms to the targetBox to produce the targetBoxFinal.
  1136. * This is the final box that we will then project into by calculating a transform delta and
  1137. * applying it to the corrected box.
  1138. */
  1139. transformBox(targetWithTransforms, latestValues);
  1140. /**
  1141. * Update the delta between the corrected box and the final target box, after
  1142. * user-set transforms are applied to it. This will be used by the renderer to
  1143. * create a transform style that will reproject the element from its layout layout
  1144. * into the desired bounding box.
  1145. */
  1146. calcBoxDelta(this.projectionDeltaWithTransform, this.layoutCorrected, targetWithTransforms, latestValues);
  1147. }
  1148. registerSharedNode(layoutId, node) {
  1149. if (!this.sharedNodes.has(layoutId)) {
  1150. this.sharedNodes.set(layoutId, new NodeStack());
  1151. }
  1152. const stack = this.sharedNodes.get(layoutId);
  1153. stack.add(node);
  1154. const config = node.options.initialPromotionConfig;
  1155. node.promote({
  1156. transition: config ? config.transition : undefined,
  1157. preserveFollowOpacity: config && config.shouldPreserveFollowOpacity
  1158. ? config.shouldPreserveFollowOpacity(node)
  1159. : undefined,
  1160. });
  1161. }
  1162. isLead() {
  1163. const stack = this.getStack();
  1164. return stack ? stack.lead === this : true;
  1165. }
  1166. getLead() {
  1167. const { layoutId } = this.options;
  1168. return layoutId ? this.getStack()?.lead || this : this;
  1169. }
  1170. getPrevLead() {
  1171. const { layoutId } = this.options;
  1172. return layoutId ? this.getStack()?.prevLead : undefined;
  1173. }
  1174. getStack() {
  1175. const { layoutId } = this.options;
  1176. if (layoutId)
  1177. return this.root.sharedNodes.get(layoutId);
  1178. }
  1179. promote({ needsReset, transition, preserveFollowOpacity, } = {}) {
  1180. const stack = this.getStack();
  1181. if (stack)
  1182. stack.promote(this, preserveFollowOpacity);
  1183. if (needsReset) {
  1184. this.projectionDelta = undefined;
  1185. this.needsReset = true;
  1186. }
  1187. if (transition)
  1188. this.setOptions({ transition });
  1189. }
  1190. relegate() {
  1191. const stack = this.getStack();
  1192. if (stack) {
  1193. return stack.relegate(this);
  1194. }
  1195. else {
  1196. return false;
  1197. }
  1198. }
  1199. resetSkewAndRotation() {
  1200. const { visualElement } = this.options;
  1201. if (!visualElement)
  1202. return;
  1203. // If there's no detected skew or rotation values, we can early return without a forced render.
  1204. let hasDistortingTransform = false;
  1205. /**
  1206. * An unrolled check for rotation values. Most elements don't have any rotation and
  1207. * skipping the nested loop and new object creation is 50% faster.
  1208. */
  1209. const { latestValues } = visualElement;
  1210. if (latestValues.z ||
  1211. latestValues.rotate ||
  1212. latestValues.rotateX ||
  1213. latestValues.rotateY ||
  1214. latestValues.rotateZ ||
  1215. latestValues.skewX ||
  1216. latestValues.skewY) {
  1217. hasDistortingTransform = true;
  1218. }
  1219. // If there's no distorting values, we don't need to do any more.
  1220. if (!hasDistortingTransform)
  1221. return;
  1222. const resetValues = {};
  1223. if (latestValues.z) {
  1224. resetDistortingTransform("z", visualElement, resetValues, this.animationValues);
  1225. }
  1226. // Check the skew and rotate value of all axes and reset to 0
  1227. for (let i = 0; i < transformAxes.length; i++) {
  1228. resetDistortingTransform(`rotate${transformAxes[i]}`, visualElement, resetValues, this.animationValues);
  1229. resetDistortingTransform(`skew${transformAxes[i]}`, visualElement, resetValues, this.animationValues);
  1230. }
  1231. // Force a render of this element to apply the transform with all skews and rotations
  1232. // set to 0.
  1233. visualElement.render();
  1234. // Put back all the values we reset
  1235. for (const key in resetValues) {
  1236. visualElement.setStaticValue(key, resetValues[key]);
  1237. if (this.animationValues) {
  1238. this.animationValues[key] = resetValues[key];
  1239. }
  1240. }
  1241. // Schedule a render for the next frame. This ensures we won't visually
  1242. // see the element with the reset rotate value applied.
  1243. visualElement.scheduleRender();
  1244. }
  1245. getProjectionStyles(styleProp) {
  1246. if (!this.instance || this.isSVG)
  1247. return undefined;
  1248. if (!this.isVisible) {
  1249. return hiddenVisibility;
  1250. }
  1251. const styles = {
  1252. visibility: "",
  1253. };
  1254. const transformTemplate = this.getTransformTemplate();
  1255. if (this.needsReset) {
  1256. this.needsReset = false;
  1257. styles.opacity = "";
  1258. styles.pointerEvents =
  1259. resolveMotionValue(styleProp?.pointerEvents) || "";
  1260. styles.transform = transformTemplate
  1261. ? transformTemplate(this.latestValues, "")
  1262. : "none";
  1263. return styles;
  1264. }
  1265. const lead = this.getLead();
  1266. if (!this.projectionDelta || !this.layout || !lead.target) {
  1267. const emptyStyles = {};
  1268. if (this.options.layoutId) {
  1269. emptyStyles.opacity =
  1270. this.latestValues.opacity !== undefined
  1271. ? this.latestValues.opacity
  1272. : 1;
  1273. emptyStyles.pointerEvents =
  1274. resolveMotionValue(styleProp?.pointerEvents) || "";
  1275. }
  1276. if (this.hasProjected && !hasTransform(this.latestValues)) {
  1277. emptyStyles.transform = transformTemplate
  1278. ? transformTemplate({}, "")
  1279. : "none";
  1280. this.hasProjected = false;
  1281. }
  1282. return emptyStyles;
  1283. }
  1284. const valuesToRender = lead.animationValues || lead.latestValues;
  1285. this.applyTransformsToTarget();
  1286. styles.transform = buildProjectionTransform(this.projectionDeltaWithTransform, this.treeScale, valuesToRender);
  1287. if (transformTemplate) {
  1288. styles.transform = transformTemplate(valuesToRender, styles.transform);
  1289. }
  1290. const { x, y } = this.projectionDelta;
  1291. styles.transformOrigin = `${x.origin * 100}% ${y.origin * 100}% 0`;
  1292. if (lead.animationValues) {
  1293. /**
  1294. * If the lead component is animating, assign this either the entering/leaving
  1295. * opacity
  1296. */
  1297. styles.opacity =
  1298. lead === this
  1299. ? valuesToRender.opacity ??
  1300. this.latestValues.opacity ??
  1301. 1
  1302. : this.preserveOpacity
  1303. ? this.latestValues.opacity
  1304. : valuesToRender.opacityExit;
  1305. }
  1306. else {
  1307. /**
  1308. * Or we're not animating at all, set the lead component to its layout
  1309. * opacity and other components to hidden.
  1310. */
  1311. styles.opacity =
  1312. lead === this
  1313. ? valuesToRender.opacity !== undefined
  1314. ? valuesToRender.opacity
  1315. : ""
  1316. : valuesToRender.opacityExit !== undefined
  1317. ? valuesToRender.opacityExit
  1318. : 0;
  1319. }
  1320. /**
  1321. * Apply scale correction
  1322. */
  1323. for (const key in scaleCorrectors) {
  1324. if (valuesToRender[key] === undefined)
  1325. continue;
  1326. const { correct, applyTo, isCSSVariable } = scaleCorrectors[key];
  1327. /**
  1328. * Only apply scale correction to the value if we have an
  1329. * active projection transform. Otherwise these values become
  1330. * vulnerable to distortion if the element changes size without
  1331. * a corresponding layout animation.
  1332. */
  1333. const corrected = styles.transform === "none"
  1334. ? valuesToRender[key]
  1335. : correct(valuesToRender[key], lead);
  1336. if (applyTo) {
  1337. const num = applyTo.length;
  1338. for (let i = 0; i < num; i++) {
  1339. styles[applyTo[i]] = corrected;
  1340. }
  1341. }
  1342. else {
  1343. // If this is a CSS variable, set it directly on the instance.
  1344. // Replacing this function from creating styles to setting them
  1345. // would be a good place to remove per frame object creation
  1346. if (isCSSVariable) {
  1347. this.options.visualElement.renderState.vars[key] = corrected;
  1348. }
  1349. else {
  1350. styles[key] = corrected;
  1351. }
  1352. }
  1353. }
  1354. /**
  1355. * Disable pointer events on follow components. This is to ensure
  1356. * that if a follow component covers a lead component it doesn't block
  1357. * pointer events on the lead.
  1358. */
  1359. if (this.options.layoutId) {
  1360. styles.pointerEvents =
  1361. lead === this
  1362. ? resolveMotionValue(styleProp?.pointerEvents) || ""
  1363. : "none";
  1364. }
  1365. return styles;
  1366. }
  1367. clearSnapshot() {
  1368. this.resumeFrom = this.snapshot = undefined;
  1369. }
  1370. // Only run on root
  1371. resetTree() {
  1372. this.root.nodes.forEach((node) => node.currentAnimation?.stop());
  1373. this.root.nodes.forEach(clearMeasurements);
  1374. this.root.sharedNodes.clear();
  1375. }
  1376. };
  1377. }
  1378. function updateLayout(node) {
  1379. node.updateLayout();
  1380. }
  1381. function notifyLayoutUpdate(node) {
  1382. const snapshot = node.resumeFrom?.snapshot || node.snapshot;
  1383. if (node.isLead() &&
  1384. node.layout &&
  1385. snapshot &&
  1386. node.hasListeners("didUpdate")) {
  1387. const { layoutBox: layout, measuredBox: measuredLayout } = node.layout;
  1388. const { animationType } = node.options;
  1389. const isShared = snapshot.source !== node.layout.source;
  1390. // TODO Maybe we want to also resize the layout snapshot so we don't trigger
  1391. // animations for instance if layout="size" and an element has only changed position
  1392. if (animationType === "size") {
  1393. eachAxis((axis) => {
  1394. const axisSnapshot = isShared
  1395. ? snapshot.measuredBox[axis]
  1396. : snapshot.layoutBox[axis];
  1397. const length = calcLength(axisSnapshot);
  1398. axisSnapshot.min = layout[axis].min;
  1399. axisSnapshot.max = axisSnapshot.min + length;
  1400. });
  1401. }
  1402. else if (shouldAnimatePositionOnly(animationType, snapshot.layoutBox, layout)) {
  1403. eachAxis((axis) => {
  1404. const axisSnapshot = isShared
  1405. ? snapshot.measuredBox[axis]
  1406. : snapshot.layoutBox[axis];
  1407. const length = calcLength(layout[axis]);
  1408. axisSnapshot.max = axisSnapshot.min + length;
  1409. /**
  1410. * Ensure relative target gets resized and rerendererd
  1411. */
  1412. if (node.relativeTarget && !node.currentAnimation) {
  1413. node.isProjectionDirty = true;
  1414. node.relativeTarget[axis].max =
  1415. node.relativeTarget[axis].min + length;
  1416. }
  1417. });
  1418. }
  1419. const layoutDelta = createDelta();
  1420. calcBoxDelta(layoutDelta, layout, snapshot.layoutBox);
  1421. const visualDelta = createDelta();
  1422. if (isShared) {
  1423. calcBoxDelta(visualDelta, node.applyTransform(measuredLayout, true), snapshot.measuredBox);
  1424. }
  1425. else {
  1426. calcBoxDelta(visualDelta, layout, snapshot.layoutBox);
  1427. }
  1428. const hasLayoutChanged = !isDeltaZero(layoutDelta);
  1429. let hasRelativeLayoutChanged = false;
  1430. if (!node.resumeFrom) {
  1431. const relativeParent = node.getClosestProjectingParent();
  1432. /**
  1433. * If the relativeParent is itself resuming from a different element then
  1434. * the relative snapshot is not relavent
  1435. */
  1436. if (relativeParent && !relativeParent.resumeFrom) {
  1437. const { snapshot: parentSnapshot, layout: parentLayout } = relativeParent;
  1438. if (parentSnapshot && parentLayout) {
  1439. const relativeSnapshot = createBox();
  1440. calcRelativePosition(relativeSnapshot, snapshot.layoutBox, parentSnapshot.layoutBox);
  1441. const relativeLayout = createBox();
  1442. calcRelativePosition(relativeLayout, layout, parentLayout.layoutBox);
  1443. if (!boxEqualsRounded(relativeSnapshot, relativeLayout)) {
  1444. hasRelativeLayoutChanged = true;
  1445. }
  1446. if (relativeParent.options.layoutRoot) {
  1447. node.relativeTarget = relativeLayout;
  1448. node.relativeTargetOrigin = relativeSnapshot;
  1449. node.relativeParent = relativeParent;
  1450. }
  1451. }
  1452. }
  1453. }
  1454. node.notifyListeners("didUpdate", {
  1455. layout,
  1456. snapshot,
  1457. delta: visualDelta,
  1458. layoutDelta,
  1459. hasLayoutChanged,
  1460. hasRelativeLayoutChanged,
  1461. });
  1462. }
  1463. else if (node.isLead()) {
  1464. const { onExitComplete } = node.options;
  1465. onExitComplete && onExitComplete();
  1466. }
  1467. /**
  1468. * Clearing transition
  1469. * TODO: Investigate why this transition is being passed in as {type: false } from Framer
  1470. * and why we need it at all
  1471. */
  1472. node.options.transition = undefined;
  1473. }
  1474. function propagateDirtyNodes(node) {
  1475. /**
  1476. * Increase debug counter for nodes encountered this frame
  1477. */
  1478. if (statsBuffer.value) {
  1479. metrics.nodes++;
  1480. }
  1481. if (!node.parent)
  1482. return;
  1483. /**
  1484. * If this node isn't projecting, propagate isProjectionDirty. It will have
  1485. * no performance impact but it will allow the next child that *is* projecting
  1486. * but *isn't* dirty to just check its parent to see if *any* ancestor needs
  1487. * correcting.
  1488. */
  1489. if (!node.isProjecting()) {
  1490. node.isProjectionDirty = node.parent.isProjectionDirty;
  1491. }
  1492. /**
  1493. * Propagate isSharedProjectionDirty and isTransformDirty
  1494. * throughout the whole tree. A future revision can take another look at
  1495. * this but for safety we still recalcualte shared nodes.
  1496. */
  1497. node.isSharedProjectionDirty || (node.isSharedProjectionDirty = Boolean(node.isProjectionDirty ||
  1498. node.parent.isProjectionDirty ||
  1499. node.parent.isSharedProjectionDirty));
  1500. node.isTransformDirty || (node.isTransformDirty = node.parent.isTransformDirty);
  1501. }
  1502. function cleanDirtyNodes(node) {
  1503. node.isProjectionDirty =
  1504. node.isSharedProjectionDirty =
  1505. node.isTransformDirty =
  1506. false;
  1507. }
  1508. function clearSnapshot(node) {
  1509. node.clearSnapshot();
  1510. }
  1511. function clearMeasurements(node) {
  1512. node.clearMeasurements();
  1513. }
  1514. function clearIsLayoutDirty(node) {
  1515. node.isLayoutDirty = false;
  1516. }
  1517. function resetTransformStyle(node) {
  1518. const { visualElement } = node.options;
  1519. if (visualElement && visualElement.getProps().onBeforeLayoutMeasure) {
  1520. visualElement.notify("BeforeLayoutMeasure");
  1521. }
  1522. node.resetTransform();
  1523. }
  1524. function finishAnimation(node) {
  1525. node.finishAnimation();
  1526. node.targetDelta = node.relativeTarget = node.target = undefined;
  1527. node.isProjectionDirty = true;
  1528. }
  1529. function resolveTargetDelta(node) {
  1530. node.resolveTargetDelta();
  1531. }
  1532. function calcProjection(node) {
  1533. node.calcProjection();
  1534. }
  1535. function resetSkewAndRotation(node) {
  1536. node.resetSkewAndRotation();
  1537. }
  1538. function removeLeadSnapshots(stack) {
  1539. stack.removeLeadSnapshot();
  1540. }
  1541. function mixAxisDelta(output, delta, p) {
  1542. output.translate = mixNumber(delta.translate, 0, p);
  1543. output.scale = mixNumber(delta.scale, 1, p);
  1544. output.origin = delta.origin;
  1545. output.originPoint = delta.originPoint;
  1546. }
  1547. function mixAxis(output, from, to, p) {
  1548. output.min = mixNumber(from.min, to.min, p);
  1549. output.max = mixNumber(from.max, to.max, p);
  1550. }
  1551. function mixBox(output, from, to, p) {
  1552. mixAxis(output.x, from.x, to.x, p);
  1553. mixAxis(output.y, from.y, to.y, p);
  1554. }
  1555. function hasOpacityCrossfade(node) {
  1556. return (node.animationValues && node.animationValues.opacityExit !== undefined);
  1557. }
  1558. const defaultLayoutTransition = {
  1559. duration: 0.45,
  1560. ease: [0.4, 0, 0.1, 1],
  1561. };
  1562. const userAgentContains = (string) => typeof navigator !== "undefined" &&
  1563. navigator.userAgent &&
  1564. navigator.userAgent.toLowerCase().includes(string);
  1565. /**
  1566. * Measured bounding boxes must be rounded in Safari and
  1567. * left untouched in Chrome, otherwise non-integer layouts within scaled-up elements
  1568. * can appear to jump.
  1569. */
  1570. const roundPoint = userAgentContains("applewebkit/") && !userAgentContains("chrome/")
  1571. ? Math.round
  1572. : noop;
  1573. function roundAxis(axis) {
  1574. // Round to the nearest .5 pixels to support subpixel layouts
  1575. axis.min = roundPoint(axis.min);
  1576. axis.max = roundPoint(axis.max);
  1577. }
  1578. function roundBox(box) {
  1579. roundAxis(box.x);
  1580. roundAxis(box.y);
  1581. }
  1582. function shouldAnimatePositionOnly(animationType, snapshot, layout) {
  1583. return (animationType === "position" ||
  1584. (animationType === "preserve-aspect" &&
  1585. !isNear(aspectRatio(snapshot), aspectRatio(layout), 0.2)));
  1586. }
  1587. function checkNodeWasScrollRoot(node) {
  1588. return node !== node.root && node.scroll?.wasRoot;
  1589. }
  1590. export { cleanDirtyNodes, createProjectionNode, mixAxis, mixAxisDelta, mixBox, propagateDirtyNodes };