ScrollSyncHelper.js 6.5 KB

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