ScrollSyncHelper.js 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /**
  2. * This class is copied from Microsoft/vscode repository
  3. * @see https://github.com/Microsoft/vscode/blob/0532a3429a18688a0c086a4212e7e5b4888b2a48/extensions/markdown/media/main.js
  4. */
  5. class ScrollSyncHelper {
  6. /**
  7. * @typedef {{ element: Element, line: number }} CodeLineElement
  8. */
  9. getCodeLineElements(parentElement) {
  10. /** @type {CodeLineElement[]} */
  11. let elements;
  12. if (!elements) {
  13. elements = Array.prototype.map.call(
  14. parentElement.getElementsByClassName('has-data-line'),
  15. (element) => {
  16. const line = +element.getAttribute('data-line');
  17. return { element, line };
  18. },
  19. )
  20. .filter((x) => { return !Number.isNaN(x.line) });
  21. }
  22. return elements;
  23. }
  24. /**
  25. * Find the html elements that map to a specific target line in the editor.
  26. *
  27. * If an exact match, returns a single element. If the line is between elements,
  28. * returns the element prior to and the element after the given line.
  29. *
  30. * @param {Element} element
  31. * @param {number} targetLine
  32. *
  33. * @returns {{ previous: CodeLineElement, next?: CodeLineElement }}
  34. */
  35. getElementsForSourceLine(element, targetLine) {
  36. const lines = this.getCodeLineElements(element);
  37. let previous = lines[0] || null;
  38. for (const entry of lines) {
  39. if (entry.line === targetLine) {
  40. return { previous: entry, next: null };
  41. }
  42. if (entry.line > targetLine) {
  43. return { previous, next: entry };
  44. }
  45. previous = entry;
  46. }
  47. return { previous };
  48. }
  49. /**
  50. * Find the html elements that are at a specific pixel offset on the page.
  51. *
  52. * @param {Element} parentElement
  53. * @param {number} offset
  54. *
  55. * @returns {{ previous: CodeLineElement, next?: CodeLineElement }}
  56. */
  57. getLineElementsAtPageOffset(parentElement, offset) {
  58. const lines = this.getCodeLineElements(parentElement);
  59. const position = offset - parentElement.scrollTop + this.getParentElementOffset(parentElement);
  60. let lo = -1;
  61. let hi = lines.length - 1;
  62. while (lo + 1 < hi) {
  63. const mid = Math.floor((lo + hi) / 2);
  64. const bounds = lines[mid].element.getBoundingClientRect();
  65. if (bounds.top + bounds.height >= position) {
  66. hi = mid;
  67. }
  68. else {
  69. lo = mid;
  70. }
  71. }
  72. const hiElement = lines[hi];
  73. if (hiElement == null) {
  74. return {};
  75. }
  76. if (hi >= 1 && hiElement.element.getBoundingClientRect().top > position) {
  77. const loElement = lines[lo];
  78. const bounds = loElement.element.getBoundingClientRect();
  79. const previous = { element: loElement.element, line: loElement.line };
  80. if (bounds.height > 0) {
  81. previous.line += (position - bounds.top) / (bounds.height);
  82. }
  83. const next = { element: hiElement.element, line: hiElement.line, fractional: 0 };
  84. return { previous, next };
  85. }
  86. const bounds = hiElement.element.getBoundingClientRect();
  87. const previous = { element: hiElement.element, line: hiElement.line + (position - bounds.top) / (bounds.height) };
  88. return { previous };
  89. }
  90. getEditorLineNumberForPageOffset(parentElement, offset) {
  91. const { previous, next } = this.getLineElementsAtPageOffset(parentElement, offset);
  92. if (previous != null) {
  93. if (next) {
  94. const betweenProgress = (
  95. offset - parentElement.scrollTop - previous.element.getBoundingClientRect().top)
  96. / (next.element.getBoundingClientRect().top - previous.element.getBoundingClientRect().top);
  97. return previous.line + betweenProgress * (next.line - previous.line);
  98. }
  99. return previous.line;
  100. }
  101. return null;
  102. }
  103. /**
  104. * return the sum of the offset position of parent element and paddingTop
  105. * @param {Element} parentElement
  106. */
  107. getParentElementOffset(parentElement) {
  108. const offsetY = parentElement.getBoundingClientRect().top;
  109. // get paddingTop
  110. const style = window.getComputedStyle(parentElement, null);
  111. const paddingTop = +(style.paddingTop.replace('px', ''));
  112. return offsetY + paddingTop;
  113. }
  114. /**
  115. * Attempt to scroll preview element for a source line in the editor.
  116. *
  117. * @param {Element} previewElement
  118. * @param {number} line
  119. */
  120. scrollPreview(previewElement, line) {
  121. const { previous, next } = this.getElementsForSourceLine(previewElement, line);
  122. if (previous) {
  123. let scrollTo = 0;
  124. if (next) {
  125. // Between two elements. Go to percentage offset between them.
  126. const betweenProgress = (line - previous.line) / (next.line - previous.line);
  127. const elementOffset = next.element.getBoundingClientRect().top - previous.element.getBoundingClientRect().top;
  128. scrollTo = previous.element.getBoundingClientRect().top + betweenProgress * elementOffset;
  129. }
  130. else {
  131. scrollTo = previous.element.getBoundingClientRect().top;
  132. }
  133. scrollTo -= this.getParentElementOffset(previewElement);
  134. previewElement.scrollTop += scrollTo;
  135. }
  136. }
  137. /**
  138. * Attempt to reveal the element that is overflowing from previewElement.
  139. *
  140. * @param {Element} previewElement
  141. * @param {number} line
  142. */
  143. scrollPreviewToRevealOverflowing(previewElement, line) {
  144. // eslint-disable-next-line no-unused-vars
  145. const { previous, next } = this.getElementsForSourceLine(previewElement, line);
  146. if (previous) {
  147. const parentElementOffset = this.getParentElementOffset(previewElement);
  148. const prevElmTop = previous.element.getBoundingClientRect().top - parentElementOffset;
  149. const prevElmBottom = previous.element.getBoundingClientRect().bottom - parentElementOffset;
  150. let scrollTo = null;
  151. if (prevElmTop < 0) {
  152. // set the top of 'previous.element' to the top of 'previewElement'
  153. scrollTo = previewElement.scrollTop + prevElmTop;
  154. }
  155. else if (prevElmBottom > previewElement.clientHeight) {
  156. // set the bottom of 'previous.element' to the bottom of 'previewElement'
  157. scrollTo = previewElement.scrollTop + prevElmBottom - previewElement.clientHeight + 20;
  158. }
  159. if (scrollTo == null) {
  160. return;
  161. }
  162. previewElement.scrollTop = scrollTo;
  163. }
  164. }
  165. /**
  166. * Attempt to scroll Editor component for the offset of the element in the Preview component.
  167. *
  168. * @param {Editor} editor
  169. * @param {Element} previewElement
  170. * @param {number} offset
  171. */
  172. scrollEditor(editor, previewElement, offset) {
  173. let line = this.getEditorLineNumberForPageOffset(previewElement, offset);
  174. line = Math.floor(line);
  175. editor.setScrollTopByLine(line);
  176. }
  177. }
  178. // singleton pattern
  179. const instance = new ScrollSyncHelper();
  180. Object.freeze(instance);
  181. export default instance;