ScrollSyncHelper.js 6.4 KB

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