ScrollSyncHelper.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  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')) });
  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')) });
  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 | undefined,
  53. }
  54. type TargetElement = {
  55. start: DOMRect,
  56. next: DOMRect | undefined,
  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 || sourceElement.next == null || targetElement.next == null) {
  63. return 0;
  64. }
  65. const sourceAllHeight = sourceElement.next.top - sourceElement.start.top;
  66. const sourceOutHeight = sourceElement.top.top - sourceElement.start.top;
  67. const sourceTopHeight = getDefaultTop() - sourceElement.top.top;
  68. const sourceRaito = (sourceOutHeight + sourceTopHeight) / sourceAllHeight;
  69. const targetAllHeight = targetElement.next.top - targetElement.start.top;
  70. return targetAllHeight * sourceRaito;
  71. };
  72. const scrollEditor = (editorRootElement: HTMLElement, previewRootElement: HTMLElement): void => {
  73. setDefaultTop(editorRootElement.getBoundingClientRect().top);
  74. const editorElements = getEditorElements(editorRootElement);
  75. const previewElements = getPreviewElements(previewRootElement);
  76. const topEditorElementIndex = findTopElementIndex(editorElements);
  77. const topPreviewElementIndex = findElementIndexFromDataLine(previewElements, getDataLine(editorElements[topEditorElementIndex]));
  78. const startEditorElementIndex = findElementIndexFromDataLine(editorElements, getDataLine(previewElements[topPreviewElementIndex]));
  79. const nextEditorElementIndex = findElementIndexFromDataLine(editorElements, getDataLine(previewElements[topPreviewElementIndex + 1]));
  80. let newScrollTop = previewRootElement.scrollTop;
  81. newScrollTop += calcScrollElementToTop(previewElements[topPreviewElementIndex]);
  82. newScrollTop += calcScorllElementByRatio(
  83. {
  84. start: editorElements[startEditorElementIndex].getBoundingClientRect(),
  85. top: editorElements[topEditorElementIndex].getBoundingClientRect(),
  86. next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
  87. },
  88. {
  89. start: previewElements[topPreviewElementIndex].getBoundingClientRect(),
  90. next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
  91. },
  92. );
  93. previewRootElement.scrollTop = newScrollTop;
  94. };
  95. const scrollPreview = (editorRootElement: HTMLElement, previewRootElement: HTMLElement): void => {
  96. setDefaultTop(previewRootElement.getBoundingClientRect().y);
  97. const previewElements = getPreviewElements(previewRootElement);
  98. const editorElements = getEditorElements(editorRootElement);
  99. const topPreviewElementIndex = findTopElementIndex(previewElements);
  100. const startEditorElementIndex = findElementIndexFromDataLine(editorElements, getDataLine(previewElements[topPreviewElementIndex]));
  101. const nextEditorElementIndex = findElementIndexFromDataLine(editorElements, getDataLine(previewElements[topPreviewElementIndex + 1]));
  102. let newScrollTop = editorRootElement.scrollTop;
  103. newScrollTop += calcScrollElementToTop(editorElements[startEditorElementIndex]);
  104. newScrollTop += calcScorllElementByRatio(
  105. {
  106. start: previewElements[topPreviewElementIndex].getBoundingClientRect(),
  107. top: previewElements[topPreviewElementIndex].getBoundingClientRect(),
  108. next: previewElements[topPreviewElementIndex + 1]?.getBoundingClientRect(),
  109. },
  110. {
  111. start: editorElements[startEditorElementIndex].getBoundingClientRect(),
  112. next: editorElements[nextEditorElementIndex]?.getBoundingClientRect(),
  113. },
  114. );
  115. editorRootElement.scrollTop = newScrollTop;
  116. };
  117. // eslint-disable-next-line max-len
  118. export const useScrollSync = (codeMirrorKey: GlobalCodeMirrorEditorKey, previewRef: RefObject<HTMLDivElement>): { scrollEditorHandler: () => void; scrollPreviewHandler: () => void } => {
  119. const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(codeMirrorKey);
  120. const isOriginOfScrollSyncEditor = useRef(false);
  121. const isOriginOfScrollSyncPreview = useRef(false);
  122. const scrollEditorHandler = useCallback(() => {
  123. if (codeMirrorEditor?.view?.scrollDOM == null || previewRef.current == null) {
  124. return;
  125. }
  126. if (isOriginOfScrollSyncPreview.current) {
  127. isOriginOfScrollSyncPreview.current = false;
  128. return;
  129. }
  130. isOriginOfScrollSyncEditor.current = true;
  131. scrollEditor(codeMirrorEditor.view.scrollDOM, previewRef.current);
  132. }, [codeMirrorEditor, isOriginOfScrollSyncPreview, previewRef]);
  133. const scrollPreviewHandler = useCallback(() => {
  134. if (codeMirrorEditor?.view?.scrollDOM == null || previewRef.current == null) {
  135. return;
  136. }
  137. if (isOriginOfScrollSyncEditor.current) {
  138. isOriginOfScrollSyncEditor.current = false;
  139. return;
  140. }
  141. isOriginOfScrollSyncPreview.current = true;
  142. scrollPreview(codeMirrorEditor.view.scrollDOM, previewRef.current);
  143. }, [codeMirrorEditor, isOriginOfScrollSyncEditor, previewRef]);
  144. return { scrollEditorHandler, scrollPreviewHandler };
  145. };