ScrollSyncHelper.tsx 7.2 KB

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