reiji-h пре 2 година
родитељ
комит
c2e2453f79

+ 6 - 5
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -61,6 +61,7 @@ import loggerFactory from '~/utils/logger';
 import EditorNavbarBottom from './EditorNavbarBottom';
 import Preview from './Preview';
 import scrollSyncHelper from './ScrollSyncHelper';
+import { scrollEditor, scrollPreview } from './ScrollSyncHelperTest';
 
 import '@growi/editor/dist/style.css';
 import { preview } from 'vite';
@@ -451,8 +452,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const scrollEditorHandler = useCallback(() => {
     console.log('ScrollEditor!');
-    if (codeMirrorEditor.view.scrollDOM != null && previewRef.current != null) {
-      // scrollEditor(codeMirrorEditor.view.scrollDOM, previewRef.current);
+    if (codeMirrorEditor?.view?.scrollDOM != null && previewRef.current != null) {
+      scrollEditor(codeMirrorEditor.view.scrollDOM, previewRef.current);
     }
   }, [codeMirrorEditor, previewRef]);
 
@@ -460,8 +461,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const scrollPreviewHandler = useCallback(() => {
     console.log('ScrollPreview!');
-    if (codeMirrorEditor.view.scrollDOM != null && previewRef.current != null) {
-      // scrollPreview(codeMirrorEditor.view.scrollDOM, previewRef.current);
+    if (codeMirrorEditor?.view?.scrollDOM != null && previewRef.current != null) {
+      scrollPreview(codeMirrorEditor.view.scrollDOM, previewRef.current);
     }
   }, [codeMirrorEditor, previewRef]);
 
@@ -599,7 +600,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             acceptedFileType={acceptedFileType}
           />
         </div>
-        <div ref={previewRef} onScroll={scrollPreviewHandlerThrottle} className="page-editor-preview-container flex-expand-vert d-none d-lg-flex">
+        <div ref={previewRef} className="page-editor-preview-container flex-expand-vert d-none d-lg-flex">
           <Preview
             rendererOptions={rendererOptions}
             markdown={markdownToPreview}

+ 128 - 0
apps/app/src/components/PageEditor/ScrollSyncHelperTest.ts

@@ -0,0 +1,128 @@
+// console.log(previewRootElement.scrollTop);
+// for (const element of previewElements) {
+//   console.log(element.getBoundingClientRect());
+// }
+
+// element.getBoundingClientRect
+// {
+//     "x": 85,
+//     "y": 907.203125,
+//     "width": 650.5,
+//     "height": 22.390625,
+//     "top": 907.203125,
+//     "right": 735.5,
+//     "bottom": 929.59375,
+//     "left": 85
+// }
+
+// fn return true when arg nubmer's comparison is lower.
+// list: [1, 3, 4, 6, 7, 9]
+// fn: (args) => {return list[args] < 5}
+// output: 4
+
+let topY = 0;
+const padding = 0;
+
+const getEditorElements = (editorRootElement: HTMLElement): Array<Element> => {
+  return Array.from(editorRootElement.getElementsByClassName('cm-line'));
+};
+
+const getPreviewElements = (previewRootElement: HTMLElement): Array<Element> => {
+  return Array.from(previewRootElement.getElementsByClassName('has-data-line'))
+    .filter((element) => { return !Number.isNaN(element.getAttribute('data-line')) });
+};
+
+const elementBinarySearch = (list: Array<Element>, fn: (index: number) => boolean): number => {
+  let ok = 0;
+  let ng = list.length;
+  while (ok + 1 < ng) {
+    const mid = Math.floor((ok + ng) / 2);
+    if (fn(mid)) {
+      ok = mid;
+    }
+    else {
+      ng = mid;
+    }
+  }
+  return ok;
+};
+
+const findTopElementIndex = (elements: Array<Element>): number => {
+
+  const find = (index: number): boolean => {
+    return elements[index].getBoundingClientRect().y < topY + padding;
+  };
+
+  return elementBinarySearch(elements, find);
+};
+
+const findTopElement = (elements: Array<Element>): Element => {
+  return elements[findTopElementIndex(elements)];
+};
+
+const findPreviewElement = (previewElements: Array<Element>, editorElementLine: number): Element => {
+
+  const find = (index: number): boolean => {
+    const data = +(previewElements[index].getAttribute('data-line') ?? '0');
+    return data <= editorElementLine;
+  };
+
+  return previewElements[elementBinarySearch(previewElements, find)];
+};
+
+
+const calcScrollElementToTop = (element: Element): number => {
+  return element.getBoundingClientRect().y - (topY + padding);
+};
+
+const calcScorllElementByRatio = (sourceElement: Element, targetElement: Element): number => {
+  // console.log('calc raito');
+  // console.log(sourceElement.getBoundingClientRect());
+  // console.log(targetElement.getBoundingClientRect());
+  return 0;
+};
+
+export const scrollEditor = (editorRootElement: HTMLElement, previewRootElement: HTMLElement): void => {
+
+  topY = editorRootElement.getBoundingClientRect().y;
+
+  const editorElements = getEditorElements(editorRootElement);
+  const previewElements = getPreviewElements(previewRootElement);
+
+  const topEditorElementIndex = findTopElementIndex(editorElements);
+
+  const sourceEditorElement = editorElements[topEditorElementIndex];
+  const targetPreviewElement = findPreviewElement(previewElements, topEditorElementIndex + 1);
+
+  let newScrollTop = previewRootElement.scrollTop;
+  newScrollTop += calcScrollElementToTop(targetPreviewElement);
+  newScrollTop += calcScorllElementByRatio(sourceEditorElement, targetPreviewElement);
+
+
+  console.log(previewRootElement.scrollTop, newScrollTop);
+  previewRootElement.scrollTo({ top: newScrollTop, behavior: 'smooth' });
+
+  // console.log(topEditorElement);
+  // console.log(topEditorElement.getBoundingClientRect());
+  // console.log(editorRootElement.scrollTop);
+
+};
+
+
+export const scrollPreview = (editorRootElement: HTMLElement, previewRootElement: HTMLElement): void => {
+
+  // topY = previewRootElement.getBoundingClientRect().y;
+
+  // const previewElements = getPreviewElements(previewRootElement);
+
+  // const topPreviewElementIndex = findTopElementIndex(previewElements);
+
+  // console.log(previewRootElement.scrollTop);
+  // console.log(previewElements[topPreviewElementIndex]);
+  // console.log(previewElements[topPreviewElementIndex].getBoundingClientRect());
+
+  // console.log(topPreviewElement);
+  // console.log(topPreviewElement.getBoundingClientRect());
+  // console.log(previewRootElement.scrollTop);
+
+};

+ 206 - 0
apps/app/src/components/ScrollSyncHelper.js

@@ -0,0 +1,206 @@
+/**
+ * This class is copied from Microsoft/vscode repository
+ * @see https://github.com/Microsoft/vscode/blob/0532a3429a18688a0c086a4212e7e5b4888b2a48/extensions/markdown/media/main.js
+ */
+class ScrollSyncHelper {
+
+  /**
+   * @typedef {{ element: Element, line: number }} CodeLineElement
+   */
+
+  getCodeLineElements(parentElement) {
+    /** @type {CodeLineElement[]} */
+    let elements;
+    if (!elements) {
+      elements = Array.prototype.map.call(
+        parentElement.getElementsByClassName('has-data-line'),
+        (element) => {
+          const line = +element.getAttribute('data-line');
+          return { element, line };
+        },
+      )
+        .filter((x) => { return !Number.isNaN(x.line) });
+    }
+    return elements;
+  }
+
+  /**
+   * Find the html elements that map to a specific target line in the editor.
+   *
+   * If an exact match, returns a single element. If the line is between elements,
+   * returns the element prior to and the element after the given line.
+   *
+   * @param {Element} element
+   * @param {number} targetLine
+   *
+   * @returns {{ previous: CodeLineElement, next?: CodeLineElement }}
+   */
+  getElementsForSourceLine(element, targetLine) {
+    const lines = this.getCodeLineElements(element);
+    let previous = lines[0] || null;
+    for (const entry of lines) {
+      if (entry.line === targetLine) {
+        return { previous: entry, next: null };
+      }
+      if (entry.line > targetLine) {
+        return { previous, next: entry };
+      }
+      previous = entry;
+    }
+    return { previous };
+  }
+
+  /**
+   * Find the html elements that are at a specific pixel offset on the page.
+   *
+   * @param {Element} parentElement
+   * @param {number} offset
+   *
+   * @returns {{ previous: CodeLineElement, next?: CodeLineElement }}
+   */
+  getLineElementsAtPageOffset(parentElement, offset) {
+    const lines = this.getCodeLineElements(parentElement);
+
+    const position = offset - parentElement.scrollTop + this.getParentElementOffset(parentElement);
+
+    let lo = -1;
+    let hi = lines.length - 1;
+    while (lo + 1 < hi) {
+      const mid = Math.floor((lo + hi) / 2);
+      const bounds = lines[mid].element.getBoundingClientRect();
+      if (bounds.top + bounds.height >= position) {
+        hi = mid;
+      }
+      else {
+        lo = mid;
+      }
+    }
+
+    const hiElement = lines[hi];
+
+    if (hiElement == null) {
+      return {};
+    }
+
+    if (hi >= 1 && hiElement.element.getBoundingClientRect().top > position) {
+      const loElement = lines[lo];
+      const bounds = loElement.element.getBoundingClientRect();
+      const previous = { element: loElement.element, line: loElement.line };
+      if (bounds.height > 0) {
+        previous.line += (position - bounds.top) / (bounds.height);
+      }
+      const next = { element: hiElement.element, line: hiElement.line, fractional: 0 };
+      return { previous, next };
+    }
+
+    const bounds = hiElement.element.getBoundingClientRect();
+    const previous = { element: hiElement.element, line: hiElement.line + (position - bounds.top) / (bounds.height) };
+    return { previous };
+  }
+
+  getEditorLineNumberForPageOffset(parentElement, offset) {
+    const { previous, next } = this.getLineElementsAtPageOffset(parentElement, offset);
+    if (previous != null) {
+      if (next) {
+        const betweenProgress = (
+          offset - parentElement.scrollTop - previous.element.getBoundingClientRect().top)
+          / (next.element.getBoundingClientRect().top - previous.element.getBoundingClientRect().top);
+        return previous.line + betweenProgress * (next.line - previous.line);
+      }
+
+      return previous.line;
+
+    }
+    return null;
+  }
+
+  /**
+   * return the sum of the offset position of parent element and paddingTop
+   * @param {Element} parentElement
+   */
+  getParentElementOffset(parentElement) {
+    const offsetY = parentElement.getBoundingClientRect().top;
+    // get paddingTop
+    const style = window.getComputedStyle(parentElement, null);
+    const paddingTop = +(style.paddingTop.replace('px', ''));
+
+    return offsetY + paddingTop;
+  }
+
+  /**
+   * Attempt to scroll preview element for a source line in the editor.
+   *
+   * @param {Element} previewElement
+   * @param {number} line
+   */
+  scrollPreview(previewElement, line) {
+    const { previous, next } = this.getElementsForSourceLine(previewElement, line);
+    if (previous) {
+      let scrollTo = 0;
+      if (next) {
+        // Between two elements. Go to percentage offset between them.
+        const betweenProgress = (line - previous.line) / (next.line - previous.line);
+        const elementOffset = next.element.getBoundingClientRect().top - previous.element.getBoundingClientRect().top;
+        scrollTo = previous.element.getBoundingClientRect().top + betweenProgress * elementOffset;
+      }
+      else {
+        scrollTo = previous.element.getBoundingClientRect().top;
+      }
+
+      scrollTo -= this.getParentElementOffset(previewElement);
+
+      previewElement.scrollTop += scrollTo;
+    }
+  }
+
+  /**
+   * Attempt to reveal the element that is overflowing from previewElement.
+   *
+   * @param {Element} previewElement
+   * @param {number} line
+   */
+  scrollPreviewToRevealOverflowing(previewElement, line) {
+    // eslint-disable-next-line no-unused-vars
+    const { previous, next } = this.getElementsForSourceLine(previewElement, line);
+    if (previous) {
+      const parentElementOffset = this.getParentElementOffset(previewElement);
+      const prevElmTop = previous.element.getBoundingClientRect().top - parentElementOffset;
+      const prevElmBottom = previous.element.getBoundingClientRect().bottom - parentElementOffset;
+
+      let scrollTo = null;
+      if (prevElmTop < 0) {
+        // set the top of 'previous.element' to the top of 'previewElement'
+        scrollTo = previewElement.scrollTop + prevElmTop;
+      }
+      else if (prevElmBottom > previewElement.clientHeight) {
+        // set the bottom of 'previous.element' to the bottom of 'previewElement'
+        scrollTo = previewElement.scrollTop + prevElmBottom - previewElement.clientHeight + 20;
+      }
+
+      if (scrollTo == null) {
+        return;
+      }
+
+      previewElement.scrollTop = scrollTo;
+    }
+  }
+
+  /**
+   * Attempt to scroll Editor component for the offset of the element in the Preview component.
+   *
+   * @param {Editor} editor
+   * @param {Element} previewElement
+   * @param {number} offset
+   */
+  scrollEditor(editor, previewElement, offset) {
+    let line = this.getEditorLineNumberForPageOffset(previewElement, offset);
+    line = Math.floor(line);
+    editor.setScrollTopByLine(line);
+  }
+
+}
+
+// singleton pattern
+const instance = new ScrollSyncHelper();
+Object.freeze(instance);
+export default instance;

+ 5 - 2
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -104,8 +104,11 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   useEffect(() => {
 
-    const handleScroll = () => {
-      onScroll();
+    const handleScroll = (event: Event) => {
+      event.preventDefault();
+      if (onScroll != null) {
+        onScroll();
+      }
     };
 
     const extension = EditorView.domEventHandlers({