autoSizedText.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import {useLayoutEffect, useRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. interface Props {
  5. children: React.ReactNode;
  6. }
  7. export function AutoSizedText({children}: Props) {
  8. const childRef = useRef<HTMLDivElement>(null);
  9. const fontSize = useRef<number>(0);
  10. const fontSizeLowerBound = useRef<number>(0);
  11. const fontSizeUpperBound = useRef<number>(0);
  12. useLayoutEffect(() => {
  13. const childElement = childRef.current; // This is `SizedChild`
  14. const parentElement = childRef.current?.parentElement; // This is the parent of `AutoSizedText`
  15. if (!childElement || !parentElement) {
  16. return undefined;
  17. }
  18. if (!window.ResizeObserver) {
  19. // `ResizeObserver` is missing in a test environment. In this case,
  20. // run one iteration of the resize behaviour so a test can at least
  21. // verify that the component doesn't crash.
  22. const childDimensions = getElementDimensions(childElement);
  23. const parentDimensions = getElementDimensions(parentElement);
  24. adjustFontSize(childDimensions, parentDimensions);
  25. return undefined;
  26. }
  27. // On component first mount, register a `ResizeObserver` on the containing element. The handler fires
  28. // on component mount, and every time the element changes size after that
  29. const observer = new ResizeObserver(entries => {
  30. // The entries list contains an array of every observed item. Here it is only one element
  31. const entry = entries[0];
  32. if (!entry) {
  33. return;
  34. }
  35. // The resize handler passes the parent's dimensions, so we don't have to get the bounding box
  36. const parentDimensions = entry.contentRect;
  37. // Reset the iteration parameters
  38. fontSizeLowerBound.current = 0;
  39. fontSizeUpperBound.current = parentDimensions.height;
  40. let iterationCount = 0;
  41. const span = Sentry.startInactiveSpan({
  42. op: 'function',
  43. name: 'AutoSizedText.iterate',
  44. onlyIfParent: true,
  45. });
  46. // Run the resize iteration in a loop. This blocks the main UI thread and prevents
  47. // visible layout jitter. If this was done through a `ResizeObserver` or React State
  48. // each step in the resize iteration would be visible to the user
  49. while (iterationCount <= ITERATION_LIMIT) {
  50. const childDimensions = getElementDimensions(childElement);
  51. const widthDifference = parentDimensions.width - childDimensions.width;
  52. const heightDifference = parentDimensions.height - childDimensions.height;
  53. const childFitsIntoParent = heightDifference >= 0 && widthDifference >= 0;
  54. const childIsWithinWidthTolerance =
  55. Math.abs(widthDifference) <= MAXIMUM_DIFFERENCE;
  56. const childIsWithinHeightTolerance =
  57. Math.abs(heightDifference) <= MAXIMUM_DIFFERENCE;
  58. if (
  59. childFitsIntoParent &&
  60. (childIsWithinWidthTolerance || childIsWithinHeightTolerance)
  61. ) {
  62. // Stop the iteration, we've found a fit!
  63. span.setAttribute('widthDifference', widthDifference);
  64. span.setAttribute('heightDifference', heightDifference);
  65. break;
  66. }
  67. adjustFontSize(childDimensions, parentDimensions);
  68. iterationCount += 1;
  69. }
  70. span.setAttribute('iterationCount', iterationCount);
  71. span.end();
  72. });
  73. observer.observe(parentElement);
  74. return () => {
  75. observer.disconnect();
  76. };
  77. }, []);
  78. const adjustFontSize = (childDimensions: Dimensions, parentDimensions: Dimensions) => {
  79. const childElement = childRef.current;
  80. if (!childElement) {
  81. return;
  82. }
  83. let newFontSize;
  84. if (
  85. childDimensions.width > parentDimensions.width ||
  86. childDimensions.height > parentDimensions.height
  87. ) {
  88. // The element is bigger than the parent, scale down
  89. newFontSize = (fontSizeLowerBound.current + fontSize.current) / 2;
  90. fontSizeUpperBound.current = fontSize.current;
  91. } else if (
  92. childDimensions.width < parentDimensions.width ||
  93. childDimensions.height < parentDimensions.height
  94. ) {
  95. // The element is smaller than the parent, scale up
  96. newFontSize = (fontSizeUpperBound.current + fontSize.current) / 2;
  97. fontSizeLowerBound.current = fontSize.current;
  98. }
  99. // Store font size in a ref so we don't have to measure styles to get it
  100. fontSize.current = newFontSize;
  101. childElement.style.fontSize = `${newFontSize}px`;
  102. };
  103. return <SizedChild ref={childRef}>{children}</SizedChild>;
  104. }
  105. const SizedChild = styled('div')`
  106. display: inline-block;
  107. `;
  108. const ITERATION_LIMIT = 20;
  109. // The maximum difference strongly affects the number of iterations required.
  110. // A value of 10 means that matches are often found in fewer than 5 iterations.
  111. // A value of 5 raises it to 6-7. A value of 1 brings it closer to 10. A value of
  112. // 0 never converges.
  113. // Note that on modern computers, even with 6x CPU throttling the iterations usually
  114. // finish in under 5ms.
  115. const MAXIMUM_DIFFERENCE = 1; // px
  116. type Dimensions = {
  117. height: number;
  118. width: number;
  119. };
  120. function getElementDimensions(element: HTMLElement): Dimensions {
  121. const bbox = element.getBoundingClientRect();
  122. return {
  123. width: bbox.width,
  124. height: bbox.height,
  125. };
  126. }