blog开发:markdown编辑器左右同步滚动

codemirrormarkdown


发表于 2019-06-22 18:54


Markdown Plus的滚动

相对于markdown-it的demo中提供的滚动,这种滚动方法更好。
体验地址: https://mdp.tylingsoft.com/
滚动源码:https://github.com/tylingsoft/markdown-plus/blob/master/src/sync_scroll.js

存在的问题

某些情况下无法同步滚动

例如:

原因:nextMarker未定义(请见scroll.js#L48-L65) 可能的一个解决办法:如果nextMarker未定义,那么直接为它设置为编辑器最后一行行号:

if(!isUndefined(lastMarker) && isUndefined(nextMarker)){
    nextMarker = editor.lineCount()-1;
}

setPreviewScroll方法修改如下:

var last = $('article#preview').find('>[data-source-line="' + editorScroll.lastMarker + '"]').get(0);
if (editorScroll.lastMarker) { // no marker at very start
  lastPosition = last.offsetTop
}
if (editorScroll.nextMarker) { // no marker at very end
  var next = $('article#preview').find('>[data-source-line="' + editorScroll.nextMarker + '"]').get(0);
  if(next){
    nextPosition = next.offsetTop
  }else{
    nextPosition = last.clientHeight+last.offsetTop - 10
  }
}

这样就可以解决这个问题

初始文档如果包含代码块,代码块滚动位置不一致

例如:

原因:根据Codemirror官网描述,Codemirror只会渲染可见部分,这也就是视频中为什么滚动条会越来越短,由于渲染改变了高度,所以代码行中同步不准

解决:设置默认文档后,让Codemirror全部渲染(大文档慎用),但Codemirror并没有直接提供渲染整个文档的方法,所以得自己实现一下:

var renderAllDoc = function(editor){
    editor.setOption('readOnly',true);
    var viewport = editor.getViewport();
    var lastLine = editor.lineCount()-1;
    while(viewport.to < lastLine && viewport.to > 0){
        editor.scrollIntoView({line:viewport.to});
        viewport = editor.getViewport();
    }

    editor.scrollIntoView({line:lastLine});
    editor.scrollIntoView({top:0});
    editor.setOption('readOnly',false);
}

Markdown-it demo提供的滚动

实现逻辑

它的解析器会在生成的html元素上加一个data-line的属性,然后获取编辑器栏的文本,用换行分割,然后将行号和行号对应的滚动位置放入一个scrollMap对象,当编辑栏滚动的时候,获取可见的第一行的行号,然后根据行号获取滚动位置,关键代码如下:
构造scrollMap:

function buildScrollMap() {
  var i, offset, nonEmptyList, pos, a, b, lineHeightMap, linesCount,
      acc, sourceLikeDiv, textarea = $('.source'),
      _scrollMap;

  sourceLikeDiv = $('<div />').css({
    position: 'absolute',
    visibility: 'hidden',
    height: 'auto',
    width: textarea[0].clientWidth,
    'font-size': textarea.css('font-size'),
    'font-family': textarea.css('font-family'),
    'line-height': textarea.css('line-height'),
    'white-space': textarea.css('white-space')
  }).appendTo('body');

  offset = $('.result-html').scrollTop() - $('.result-html').offset().top;
  _scrollMap = [];
  nonEmptyList = [];
  lineHeightMap = [];

  acc = 0;
  textarea.val().split('\n').forEach(function (str) {
    var h, lh;

    lineHeightMap.push(acc);

    if (str.length === 0) {
      acc++;
      return;
    }

    sourceLikeDiv.text(str);
    h = parseFloat(sourceLikeDiv.css('height'));
    lh = parseFloat(sourceLikeDiv.css('line-height'));
    acc += Math.round(h / lh);
  });
  sourceLikeDiv.remove();
  lineHeightMap.push(acc);
  linesCount = acc;

  for (i = 0; i < linesCount; i++) { _scrollMap.push(-1); }

  nonEmptyList.push(0);
  _scrollMap[0] = 0;

  $('.line').each(function (n, el) {
    var $el = $(el), t = $el.data('line');
    if (t === '') { return; }
    t = lineHeightMap[t];
    if (t !== 0) { nonEmptyList.push(t); }
    _scrollMap[t] = Math.round($el.offset().top + offset);
  });

  nonEmptyList.push(linesCount);
  _scrollMap[linesCount] = $('.result-html')[0].scrollHeight;

  pos = 0;
  for (i = 1; i < linesCount; i++) {
    if (_scrollMap[i] !== -1) {
      pos++;
      continue;
    }

    a = nonEmptyList[pos];
    b = nonEmptyList[pos + 1];
    _scrollMap[i] = Math.round((_scrollMap[b] * (i - a) + _scrollMap[a] * (b - i)) / (b - a));
  }

  return _scrollMap;
}

滚动:

var syncResultScroll = _.debounce(function () {
  var textarea   = $('.source'),
      lineHeight = parseFloat(textarea.css('line-height')),
      lineNo, posTo;

  lineNo = Math.floor(textarea.scrollTop() / lineHeight);
  if (!scrollMap) { scrollMap = buildScrollMap(); }
  posTo = scrollMap[lineNo];
  $('.result-html').stop(true).animate({
    scrollTop: posTo
  }, 100, 'linear');
}, 50, { maxWait: 50 });

最后就是如何在解析后的html中增加对应源码的行号 在demo中,可以看到如下代码:

  function injectLineNumbers(tokens, idx, options, env, slf) {
    var line;
    if (tokens[idx].map && tokens[idx].level === 0) {
      line = tokens[idx].map[0];
      tokens[idx].attrJoin('class', 'line');
      tokens[idx].attrSet('data-line', String(line));
    }
    return slf.renderToken(tokens, idx, options, env, slf);
  }

  mdHtml.renderer.rules.paragraph_open = mdHtml.renderer.rules.heading_open = injectLineNumbers;

意味者ph标签被加上行号。

为markdown-it生成的html添加源码行号

在实际过程中,只添加ph是不够的,还需要加上其他元素的行号,下面代码会给一般的元素加上行号:

md.renderer.rules.paragraph_open = injectLineNumbers;
md.renderer.rules.heading_open = injectLineNumbers;
md.renderer.rules.blockquote_open = injectLineNumbers;
md.renderer.rules.hr = injectLineNumbers;
md.renderer.rules.ordered_list_open = injectLineNumbers;
md.renderer.rules.bullet_list_open = injectLineNumbers;
md.renderer.rules.table_open = injectLineNumbers;
md.renderer.rules.list_item_open = injectLineNumbers;
md.renderer.rules.link_open = injectLineNumbers;

给代码加上行号:

md.renderer.rules.code_block = function(tokens, idx, options, env, self) {
    var token = tokens[idx];
    if (token.map && token.level === 0) {
        var line = token.map[0];
        var endLine = token.map[1];
        return '<pre' + self.renderAttrs(token) + ' class="line" data-line="' + line + '" data-end-line="' + endLine + '"><code>' +
            md.utils.escapeHtml(tokens[idx].content) +
            '</code></pre>\n';
    }
    return '<pre' + self.renderAttrs(token) + '><code>' +
        md.utils.escapeHtml(tokens[idx].content) +
        '</code></pre>\n';
  };
}

给Katex加上行号(如果使用了这个插件的话)

md.renderer.rules.math_block = function(tokens, idx, options, env, self) {
      var token = tokens[idx];
      var latex = token.content;
      var addLine = false ;
      if (token.map && token.level === 0) {
          addLine = true;
      }
      options.displayMode = true;
      try {
          if (addLine) {
              return "<p class='katex-block line' data-line='" + token.map[0] + "' data-end-line='" + token.map[1] + "'>" + katex.renderToString(latex, options) + "</p>";
          } else {
              return "<p class='katex-block' >" + katex.renderToString(latex, options) + "</p>";
          }
      } catch (error) {
          console.log(error);
          if (addLine) {
              return "<p class='katex-block katex-error line' data-line='" + token.map[0] + "' data-end-line='" + token.map[1] + "' title='${md.utils.escapeHtml(error.toString())}'>${escapeHtml(latex)}</p>";
          } else {
              return "<p class='katex-block katex-error' title='${md.utils.escapeHtml(error.toString())}'>${md.utils.escapeHtml(latex)}</p>";
          }
      }
  }

给代码块加上行号:

md.renderer.rules.fence = function(tokens, idx, options, env, slf) {
    var token = tokens[idx],
        info = token.info ? md.utils.unescapeAll(token.info).trim() : '',
        langName = '',
        highlighted, i, tmpAttrs, tmpToken;

    if (info) {
        langName = info.split(/\s+/g)[0];
    }

    if (options.highlight) {
        highlighted = options.highlight(token.content, langName) || md.utils.escapeHtml(token.content);
    } else {
        highlighted = md.utils.escapeHtml(token.content);
    }

    var addLine = false ;
    if (token.map && token.level === 0) {
        addLine = true;
    }

    if(langName == 'mermaid'){
        if(addLine){
            var div = document.createElement('div');
            div.innerHTML = highlighted;
            var ele = div.firstChild; 
            ele.classList.add("line");
            ele.setAttribute("data-line", token.map[0]);
            ele.setAttribute("data-end-line", token.map[1]);
            return div.innerHTML;
        }else{
            return highlighted;
        }

    }

    if (highlighted.indexOf('<pre') === 0) {
        return highlighted + '\n';
    }


    // If language exists, inject class gently, without modifying original token.
    // May be, one day we will add .clone() for token and simplify this part, but
    // now we prefer to keep things local.
    if (info) {
        i = token.attrIndex('class');
        tmpAttrs = token.attrs ? token.attrs.slice() : [];

        if (i < 0) {
            tmpAttrs.push(['class', options.langPrefix + langName]);
        } else {
            tmpAttrs[i][1] += ' ' + options.langPrefix + langName;
        }

        // Fake token just to render attributes
        tmpToken = {
            attrs: tmpAttrs
        };
        if (addLine) {
            return '<pre class="line" data-line="' + token.map[0] + '"  data-end-line="' + token.map[1] + '"><code' + slf.renderAttrs(tmpToken) + '>' +
                highlighted +
                '</code></pre>\n';
        } else {
            return '<pre><code' + slf.renderAttrs(tmpToken) + '>' +
                highlighted +
                '</code></pre>\n';
        }
    }

    if (addLine) {
        return '<pre class="line" data-line="' + token.map[0] + '" data-end-line="' + token.map[0] + '"><code' + slf.renderAttrs(token) + '>' +
            highlighted +
            '</code></pre>\n';
    } else {
        return '<pre><code' + slf.renderAttrs(token) + '>' +
            highlighted +
            '</code></pre>\n';
    }

};

搜索