Просмотр исходного кода

Merge pull request #268 from weseek/imprv/scroll-sync

Imprv/scroll sync
Yuki Takei 8 лет назад
Родитель
Сommit
642c0cd726

+ 97 - 19
resource/js/components/PageEditor.js

@@ -9,6 +9,7 @@ import GrowiRenderer from '../util/GrowiRenderer';
 import { EditorOptions, PreviewOptions } from './PageEditor/OptionsSelector';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
+import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 
 export default class PageEditor extends React.Component {
 
@@ -38,16 +39,24 @@ export default class PageEditor extends React.Component {
     this.onSave = this.onSave.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onEditorScroll = this.onEditorScroll.bind(this);
-    this.getMaxScrollTop = this.getMaxScrollTop.bind(this);
-    this.getScrollTop = this.getScrollTop.bind(this);
+    this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
+    this.onPreviewScroll = this.onPreviewScroll.bind(this);
     this.saveDraft = this.saveDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
     this.pageSavedHandler = this.pageSavedHandler.bind(this);
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
 
+    // for scrolling
+    this.lastScrolledDateWithCursor = null;
+    this.isOriginOfScrollSyncEditor = false;
+    this.isOriginOfScrollSyncEditor = false;
+
     // create throttled function
+    this.scrollPreviewByEditorLineWithThrottle = throttle(20, this.scrollPreviewByEditorLine);
+    this.scrollPreviewByCursorMovingWithThrottle = throttle(20, this.scrollPreviewByCursorMoving);
+    this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
     this.renderWithDebounce = debounce(50, throttle(100, this.renderPreview));
-    this.saveDraftWithDebounce = debounce(300, this.saveDraft);
+    this.saveDraftWithDebounce = debounce(800, this.saveDraft);
   }
 
   componentWillMount() {
@@ -67,6 +76,7 @@ export default class PageEditor extends React.Component {
    */
   setCaretLine(line) {
     this.refs.editor.setCaretLine(line);
+    scrollSyncHelper.scrollPreview(this.previewElement, line);
   }
 
   /**
@@ -181,32 +191,99 @@ export default class PageEditor extends React.Component {
   /**
    * the scroll event handler from codemirror
    * @param {any} data {left, top, width, height, clientWidth, clientHeight} object that represents the current scroll position, the size of the scrollable area, and the size of the visible area (minus scrollbars).
-   *    see https://codemirror.net/doc/manual.html#events
+   *                    And data.line is also available that is added by Editor component
+   * @see https://codemirror.net/doc/manual.html#events
    */
   onEditorScroll(data) {
-    const rate = data.top / (data.height - data.clientHeight)
-    const top = this.getScrollTop(this.previewElement, rate);
+    // prevent scrolling
+    //   if the elapsed time from last scroll with cursor is shorter than 40ms
+    const now = new Date();
+    if (now - this.lastScrolledDateWithCursor < 40) {
+      return;
+    }
 
-    this.previewElement.scrollTop = top;
+    this.scrollPreviewByEditorLineWithThrottle(data.line);
   }
+
   /**
-   * transplanted from crowi-form.js -- 2018.01.21 Yuki Takei
-   * @param {*} dom
+   * the scroll event handler from codemirror
+   * @param {number} line
+   * @see https://codemirror.net/doc/manual.html#events
    */
-  getMaxScrollTop(dom) {
-    var rect = dom.getBoundingClientRect();
-    return dom.scrollHeight - rect.height;
+  onEditorScrollCursorIntoView(line) {
+    // record date
+    this.lastScrolledDateWithCursor = new Date();
+    this.scrollPreviewByCursorMovingWithThrottle(line);
+  }
+
+  /**
+   * scroll Preview element by scroll event
+   * @param {number} line
+   */
+  scrollPreviewByEditorLine(line) {
+    if (this.previewElement == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (this.isOriginOfScrollSyncPreview) {
+      this.isOriginOfScrollSyncPreview = false; // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    this.isOriginOfScrollSyncEditor = true;
+    scrollSyncHelper.scrollPreview(this.previewElement, line);
   };
+
   /**
-   * transplanted from crowi-form.js -- 2018.01.21 Yuki Takei
-   * @param {*} dom
+   * scroll Preview element by cursor moving
+   * @param {number} line
    */
-  getScrollTop(dom, rate) {
-    var maxScrollTop = this.getMaxScrollTop(dom);
-    var top = maxScrollTop * rate;
-    return top;
+  scrollPreviewByCursorMoving(line) {
+    if (this.previewElement == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (this.isOriginOfScrollSyncPreview) {
+      this.isOriginOfScrollSyncPreview = false; // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    this.isOriginOfScrollSyncEditor = true;
+    scrollSyncHelper.scrollPreviewToRevealOverflowing(this.previewElement, line);
   };
 
+  /**
+   * the scroll event handler from Preview component
+   * @param {number} offset
+   */
+  onPreviewScroll(offset) {
+    this.scrollEditorByPreviewScrollWithThrottle(offset);
+  }
+
+  /**
+   * scroll Editor component by scroll event of Preview component
+   * @param {number} offset
+   */
+  scrollEditorByPreviewScroll(offset) {
+    if (this.previewElement == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (this.isOriginOfScrollSyncEditor) {
+      this.isOriginOfScrollSyncEditor = false;  // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    this.isOriginOfScrollSyncPreview = true;
+    scrollSyncHelper.scrollEditor(this.refs.editor, this.previewElement, offset);
+  }
+
   /*
    * methods for draft
    */
@@ -287,7 +364,6 @@ export default class PageEditor extends React.Component {
       .then(() => interceptorManager.process('preRenderPreviewHtml', context))
       .then(() => {
         this.setState({ html: context.parsedHTML });
-
         // set html to the hidden input (for submitting to save)
         $('#form-body').val(this.state.markdown);
       })
@@ -305,6 +381,7 @@ export default class PageEditor extends React.Component {
               isUploadable={this.state.isUploadable}
               isUploadableFile={this.state.isUploadableFile}
               onScroll={this.onEditorScroll}
+              onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
               onChange={this.onMarkdownChanged}
               onSave={this.onSave}
               onUpload={this.onUpload}
@@ -316,6 +393,7 @@ export default class PageEditor extends React.Component {
               isMathJaxEnabled={this.state.isMathJaxEnabled}
               renderMathJaxOnInit={false}
               previewOptions={this.state.previewOptions}
+              onScroll={this.onPreviewScroll}
           />
         </div>
       </div>

+ 35 - 6
resource/js/components/PageEditor/Editor.js

@@ -54,9 +54,11 @@ export default class Editor extends React.Component {
 
     this.getCodeMirror = this.getCodeMirror.bind(this);
     this.setCaretLine = this.setCaretLine.bind(this);
+    this.setScrollTopByLine = this.setScrollTopByLine.bind(this);
     this.forceToFocus = this.forceToFocus.bind(this);
     this.dispatchSave = this.dispatchSave.bind(this);
 
+    this.onScrollCursorIntoView = this.onScrollCursorIntoView.bind(this);
     this.onPaste = this.onPaste.bind(this);
 
     this.onDragEnterForCM = this.onDragEnterForCM.bind(this);
@@ -95,15 +97,30 @@ export default class Editor extends React.Component {
    * @param {string} number
    */
   setCaretLine(line) {
-    const editor = this.getCodeMirror();
+    if (isNaN(line)) {
+      return;
+    }
 
-    // scroll to the bottom for a moment
-    const lastLine = editor.getDoc().lastLine();
-    editor.scrollIntoView(lastLine);
+    const editor = this.getCodeMirror();
+    const linePosition = Math.max(0, line);
 
-    const linePosition = Math.max(0, line - 1);
-    editor.scrollIntoView(linePosition);
     editor.setCursor({line: linePosition});   // leave 'ch' field as null/undefined to indicate the end of line
+    this.setScrollTopByLine(linePosition);
+  }
+
+  /**
+   * scroll
+   * @param {number} line
+   */
+  setScrollTopByLine(line) {
+    if (isNaN(line)) {
+      return;
+    }
+
+    const editor = this.getCodeMirror();
+    // get top position of the line
+    var top = editor.charCoords({line, ch: 0}, 'local').top;
+    editor.scrollTo(null, top);
   }
 
   /**
@@ -143,6 +160,13 @@ export default class Editor extends React.Component {
     }
   }
 
+  onScrollCursorIntoView(editor, event) {
+    if (this.props.onScrollCursorIntoView != null) {
+      const line = editor.getCursor().line;
+      this.props.onScrollCursorIntoView(line);
+    }
+  }
+
   /**
    * CodeMirror paste event handler
    * see: https://codemirror.net/doc/manual.html#events
@@ -296,6 +320,7 @@ export default class Editor extends React.Component {
             editorDidMount={(editor) => {
               // add event handlers
               editor.on('paste', this.onPaste);
+              editor.on('scrollCursorIntoView', this.onScrollCursorIntoView);
             }}
             value={this.state.value}
             options={{
@@ -327,6 +352,9 @@ export default class Editor extends React.Component {
             }}
             onScroll={(editor, data) => {
               if (this.props.onScroll != null) {
+                // add line data
+                const line = editor.lineAtHeight(data.top, 'local');
+                data.line = line;
                 this.props.onScroll(data);
               }
             }}
@@ -363,6 +391,7 @@ Editor.propTypes = {
   isUploadableFile: PropTypes.bool,
   onChange: PropTypes.func,
   onScroll: PropTypes.func,
+  onScrollCursorIntoView: PropTypes.func,
   onSave: PropTypes.func,
   onUpload: PropTypes.func,
 };

+ 7 - 0
resource/js/components/PageEditor/Preview.js

@@ -20,7 +20,13 @@ export default class Preview extends React.Component {
     return (
       <div className="page-editor-preview-body"
           ref={(elm) => {
+            this.previewElement = elm;
             this.props.inputRef(elm);
+          }}
+          onScroll={(event) => {
+            if (this.props.onScroll != null) {
+              this.props.onScroll(event.target.scrollTop);
+            }
           }}>
 
         <RevisionBody
@@ -38,4 +44,5 @@ Preview.propTypes = {
   isMathJaxEnabled: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   previewOptions: PropTypes.instanceOf(PreviewOptions),
+  onScroll: PropTypes.func,
 };

+ 195 - 0
resource/js/components/PageEditor/ScrollSyncHelper.js

@@ -0,0 +1,195 @@
+/**
+ * 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
+	 */
+
+  constructor() {
+  }
+
+  getCodeLineElements(parentElement) {
+    /** @type {CodeLineElement[]} */
+    let elements;
+    if (!elements) {
+      elements = Array.prototype.map.call(
+        parentElement.getElementsByClassName('code-line'),
+        element => {
+          const line = +element.getAttribute('data-line');
+          return { element, line }
+        })
+        .filter(x => !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 };
+			} else 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 (hi >= 1 && hiElement.element.getBoundingClientRect().top > position) {
+			const loElement = lines[lo];
+			const bounds = loElement.element.getBoundingClientRect();
+      let 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) {
+			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);
+			} else {
+				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.scroll(0, previewElement.scrollTop + scrollTo);
+		}
+  }
+
+  /**
+	 * Attempt to reveal the element that is overflowing from previewElement.
+	 *
+   * @param {Element} previewElement
+	 * @param {number} line
+	 */
+  scrollPreviewToRevealOverflowing(previewElement, line) {
+		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.scroll(0, 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;

+ 21 - 31
resource/js/util/markdown-it/header-line-number.js

@@ -2,43 +2,33 @@ export default class HeaderLineNumberConfigurer {
 
   constructor(crowi) {
     this.crowi = crowi;
-
-    this.injectLineNumbers = this.injectLineNumbers.bind(this);
-    this.combineRules = this.combineRules.bind(this);
+    this.firstLine = 0;
   }
 
   configure(md) {
-    const rules = md.renderer.rules;
-    const headingOpenOrg = rules.heading_open;
-    const paragraphOpenOrg = rules.paragraph_open;
-    // combine rule and set
-    rules.heading_open = this.combineRules(this.injectLineNumbers, headingOpenOrg);
-    rules.paragraph_open = this.combineRules(this.injectLineNumbers, paragraphOpenOrg);
+    for (const renderName of ['paragraph_open', 'heading_open', 'image', 'code_block', 'blockquote_open', 'list_item_open']) {
+      this.addLineNumberRenderer(md, renderName);
+    }
   }
 
   /**
-   * Inject line numbers for sync scroll
-   * @see https://github.com/markdown-it/markdown-it/blob/e6f19eab4204122e85e4a342e0c1c8486ff40c2d/support/demo_template/index.js#L169
+   * Add line numbers for sync scroll
+   * @see https://github.com/Microsoft/vscode/blob/6e8d4d057bd1152d49a1e9780ec6db6363593855/extensions/markdown/src/markdownEngine.ts#L118
    */
-  injectLineNumbers(tokens, idx, options, env, slf) {
-    var line;
-    if (tokens[idx].map && tokens[idx].level === 0) {
-      line = tokens[idx].map[0] + 1;    // add 1 to convert to line number
-      tokens[idx].attrJoin('class', 'line');
-      tokens[idx].attrSet('data-line', String(line));
-    }
-    return slf.renderToken(tokens, idx, options, env, slf);
-  }
+  addLineNumberRenderer(md, ruleName) {
+		const original = md.renderer.rules[ruleName];
+		md.renderer.rules[ruleName] = (tokens, idx, options, env, self) => {
+			const token = tokens[idx];
+			if (token.map && token.map.length) {
+				token.attrSet('data-line', this.firstLine + token.map[0]);
+				token.attrJoin('class', 'code-line');
+			}
 
-  combineRules(rule1, rule2) {
-    return (tokens, idx, options, env, slf) => {
-      if (rule1 != null) {
-        rule1(tokens, idx, options, env, slf);
-      }
-      if (rule2 != null) {
-        rule2(tokens, idx, options, env, slf);
-      }
-      return slf.renderToken(tokens, idx, options, env, slf);
-    }
-  }
+			if (original) {
+				return original(tokens, idx, options, env, self);
+			} else {
+				return self.renderToken(tokens, idx, options, env, self);
+			}
+		};
+}
 }