ScrollSyncHelper.tsx 7.1 KB

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