ScrollSyncHelper.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import { type RefObject, useCallback, useRef } from 'react';
  2. import type { GlobalCodeMirrorEditorKey } from '@growi/editor';
  3. import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
  4. let defaultTop = 0;
  5. const padding = 5;
  6. const setDefaultTop = (top: number): void => {
  7. defaultTop = top;
  8. };
  9. const getDefaultTop = (): number => {
  10. return defaultTop + padding;
  11. };
  12. const getDataLine = (element: Element | null): number => {
  13. return element ? +(element.getAttribute('data-line') ?? '0') - 1 : 0;
  14. };
  15. const getEditorElements = (editorRootElement: HTMLElement): Array<Element> => {
  16. return Array.from(editorRootElement.getElementsByClassName('cm-line')).filter(
  17. (element) => {
  18. return !Number.isNaN(element.getAttribute('data-line') ?? Number.NaN);
  19. },
  20. );
  21. };
  22. const getPreviewElements = (
  23. previewRootElement: HTMLElement,
  24. ): Array<Element> => {
  25. return Array.from(
  26. previewRootElement.getElementsByClassName('has-data-line'),
  27. ).filter((element) => {
  28. return !Number.isNaN(element.getAttribute('data-line') ?? Number.NaN);
  29. });
  30. };
  31. // Ref: https://github.com/mikolalysenko/binary-search-bounds/blob/f436a2a8af11bf3208434e18bbac17e18e7a3a30/search-bounds.js
  32. const elementBinarySearch = (
  33. list: Array<Element>,
  34. fn: (index: number) => boolean,
  35. ): number => {
  36. let ok = 0;
  37. let ng = list.length;
  38. while (ok + 1 < ng) {
  39. const mid = Math.floor((ok + ng) / 2);
  40. if (fn(mid)) {
  41. ok = mid;
  42. } else {
  43. ng = mid;
  44. }
  45. }
  46. return ok;
  47. };
  48. const findTopElementIndex = (elements: Array<Element>): number => {
  49. const find = (index: number): boolean => {
  50. return elements[index].getBoundingClientRect().top < getDefaultTop();
  51. };
  52. return elementBinarySearch(elements, find);
  53. };
  54. const findElementIndexFromDataLine = (
  55. previewElements: Array<Element>,
  56. dataline: number,
  57. ): number => {
  58. const find = (index: number): boolean => {
  59. return getDataLine(previewElements[index]) <= dataline;
  60. };
  61. return elementBinarySearch(previewElements, find);
  62. };
  63. type SourceElement = {
  64. start?: DOMRect;
  65. top?: DOMRect;
  66. next?: DOMRect;
  67. };
  68. type TargetElement = {
  69. start?: DOMRect;
  70. next?: DOMRect;
  71. };
  72. const calcScrollElementToTop = (element: Element): number => {
  73. return element.getBoundingClientRect().top - getDefaultTop();
  74. };
  75. const calcScorllElementByRatio = (
  76. sourceElement: SourceElement,
  77. targetElement: TargetElement,
  78. ): number => {
  79. if (sourceElement.start === sourceElement.next) {
  80. return 0;
  81. }
  82. if (
  83. sourceElement.start == null ||
  84. sourceElement.top == null ||
  85. sourceElement.next == null
  86. ) {
  87. return 0;
  88. }
  89. if (targetElement.start == null || targetElement.next == null) {
  90. return 0;
  91. }
  92. const sourceAllHeight = sourceElement.next.top - sourceElement.start.top;
  93. const sourceOutHeight = sourceElement.top.top - sourceElement.start.top;
  94. const sourceTopHeight = getDefaultTop() - sourceElement.top.top;
  95. const sourceRaito = (sourceOutHeight + sourceTopHeight) / sourceAllHeight;
  96. const targetAllHeight = targetElement.next.top - targetElement.start.top;
  97. return targetAllHeight * sourceRaito;
  98. };
  99. const scrollEditor = (
  100. editorRootElement: HTMLElement,
  101. previewRootElement: HTMLElement,
  102. ): void => {
  103. setDefaultTop(editorRootElement.getBoundingClientRect().top);
  104. const editorElements = getEditorElements(editorRootElement);
  105. const previewElements = getPreviewElements(previewRootElement);
  106. const topEditorElementIndex = findTopElementIndex(editorElements);
  107. const topPreviewElementIndex = findElementIndexFromDataLine(
  108. previewElements,
  109. getDataLine(editorElements[topEditorElementIndex]),
  110. );
  111. const startEditorElementIndex = findElementIndexFromDataLine(
  112. editorElements,
  113. getDataLine(previewElements[topPreviewElementIndex]),
  114. );
  115. const nextEditorElementIndex = findElementIndexFromDataLine(
  116. editorElements,
  117. getDataLine(previewElements[topPreviewElementIndex + 1]),
  118. );
  119. let newScrollTop = previewRootElement.scrollTop;
  120. if (previewElements[topPreviewElementIndex] == null) {
  121. return;
  122. }
  123. newScrollTop += calcScrollElementToTop(
  124. previewElements[topPreviewElementIndex],
  125. );
  126. newScrollTop += calcScorllElementByRatio(
  127. {
  128. start: editorElements[startEditorElementIndex]?.getBoundingClientRect(),
  129. top: editorElements[topEditorElementIndex]?.getBoundingClientRect(),
  130. next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
  131. },
  132. {
  133. start: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
  134. next: previewElements[
  135. topPreviewElementIndex + 1
  136. ]?.getBoundingClientRect(),
  137. },
  138. );
  139. previewRootElement.scrollTop = newScrollTop;
  140. };
  141. const scrollPreview = (
  142. editorRootElement: HTMLElement,
  143. previewRootElement: HTMLElement,
  144. ): void => {
  145. setDefaultTop(previewRootElement.getBoundingClientRect().y);
  146. const previewElements = getPreviewElements(previewRootElement);
  147. const editorElements = getEditorElements(editorRootElement);
  148. const topPreviewElementIndex = findTopElementIndex(previewElements);
  149. const startEditorElementIndex = findElementIndexFromDataLine(
  150. editorElements,
  151. getDataLine(previewElements[topPreviewElementIndex]),
  152. );
  153. const nextEditorElementIndex = findElementIndexFromDataLine(
  154. editorElements,
  155. getDataLine(previewElements[topPreviewElementIndex + 1]),
  156. );
  157. if (editorElements[startEditorElementIndex] == null) {
  158. return;
  159. }
  160. let newScrollTop = editorRootElement.scrollTop;
  161. newScrollTop += calcScrollElementToTop(
  162. editorElements[startEditorElementIndex],
  163. );
  164. newScrollTop += calcScorllElementByRatio(
  165. {
  166. start: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
  167. top: previewElements[topPreviewElementIndex]?.getBoundingClientRect(),
  168. next: previewElements[
  169. topPreviewElementIndex + 1
  170. ]?.getBoundingClientRect(),
  171. },
  172. {
  173. start: editorElements[startEditorElementIndex]?.getBoundingClientRect(),
  174. next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
  175. },
  176. );
  177. editorRootElement.scrollTop = newScrollTop;
  178. };
  179. // eslint-disable-next-line max-len
  180. export const useScrollSync = (
  181. codeMirrorKey: GlobalCodeMirrorEditorKey,
  182. previewRef: RefObject<HTMLDivElement | null>,
  183. ): { scrollEditorHandler: () => void; scrollPreviewHandler: () => void } => {
  184. const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(codeMirrorKey);
  185. const isOriginOfScrollSyncEditor = useRef(false);
  186. const isOriginOfScrollSyncPreview = useRef(false);
  187. const scrollEditorHandler = useCallback(() => {
  188. if (
  189. codeMirrorEditor?.view?.scrollDOM == null ||
  190. previewRef.current == null
  191. ) {
  192. return;
  193. }
  194. if (isOriginOfScrollSyncPreview.current) {
  195. isOriginOfScrollSyncPreview.current = false;
  196. return;
  197. }
  198. isOriginOfScrollSyncEditor.current = true;
  199. scrollEditor(codeMirrorEditor.view.scrollDOM, previewRef.current);
  200. }, [codeMirrorEditor, previewRef]);
  201. const scrollPreviewHandler = useCallback(() => {
  202. if (
  203. codeMirrorEditor?.view?.scrollDOM == null ||
  204. previewRef.current == null
  205. ) {
  206. return;
  207. }
  208. if (isOriginOfScrollSyncEditor.current) {
  209. isOriginOfScrollSyncEditor.current = false;
  210. return;
  211. }
  212. isOriginOfScrollSyncPreview.current = true;
  213. scrollPreview(codeMirrorEditor.view.scrollDOM, previewRef.current);
  214. }, [codeMirrorEditor, previewRef]);
  215. return { scrollEditorHandler, scrollPreviewHandler };
  216. };