我是如何用一周时间写出个好用的Web演示文稿(PPT)库的

最近,掘金社区发布了一个在线编辑运行代码的平台【码上掘金】,有了这个平台,不仅我们可以像使用 CodePen 一样,在平台上分享代码,还可以将代码运行演示作为文章内容的一部分发布。

我在想,有了码上掘金,我们可以提供一些有用和有趣的工具,比如Slides工具,让用户可以在掘金文章中嵌入自己的演示文稿。所以,我利用空余时间,大概花了一周,发布了 WebSlides.md,通过它,你可以用Markdown和HTML编写你的演示文稿,并用【码上掘金】这样的Playgroud平台当做你的文稿分享平台!

WebSlides.md + 码上掘金 -> 在线演示

技术选型

为了在任何支持HTML、CSS和JavaScript的Playground平台运行代码,我们的演示文稿库必须是纯前端渲染的,不依赖任何需要工程(编译)环境的库。

Web演示文稿有许多可选的解决方案,比较成熟的比如三水清老师的 NodePPT,或者最近比较火的 Slidev,但是它们都需要本地的 Node 运行环境,不太好直接在简单的 Playground 上编辑和运行。

纯粹前端渲染的演示文稿工具,有 WebSlides,但是原版的 WebSlides 不支持 Markdown 语法,必须用纯原生的 HTML 手写,这样的话书写起来非常麻烦,而且对非前端的同学不太友好。

那么如果把 Markdown 语法加入到 WebSlides 中是否可以呢?结论是可行的,我们只需要引入一个简单的实时编译 Markdown 的库,比如 marked。因此,最终这个项目的技术选型就是结合 marked 和 WebSlides,通过 marked 将 Markdown 文本编译成 HTML,再交给 WebSides 进行渲染。

技术实现细节

我们可以继承 WebSlides 的类,在这里面加入解析 Markdown 的逻辑。

const defaultOptions = {
  loop: false,
  autoslide: false,
  changeOnClick: false,
  showIndex: true,
  navigateOnScroll: true,
  minWheelDelta: 40,
  scrollWait: 450,
  slideOffset: 50,
  marked: {
    renderer: new Renderer(),
    highlight: function(code, lang) {
      const Prism = require('prismjs');
      const language = Prism.languages[lang];
      if(language) {
        return Prism.highlight(code, language, lang);
      }
      return code;
    },
    pedantic: false,
    gfm: true,
    breaks: false,
    sanitize: false,
    smartLists: true,
    smartypants: false,
    xhtml: false,
    headerIds: false,
  },
};


export default {
  CDN: '//cdn.jsdelivr.net/npm',
  indent: true,
  codeTheme: "default"
};

window.WebSlides = class MDSlides extends WebSlides {
  static get marked() {
    return marked;
  }
  static get config() {
    return config;
  }
  constructor({marked: markedOptions = {}, ...options} = {}) {
    const container = document.querySelector('#webslides:not([done="done"])');
    const {marked: defaultMarkedOptions, ...defaultOpts} = defaultOptions;
    options = Object.assign({}, defaultOpts, options);
    if(container) {
      const sections = container.querySelectorAll('section');
      if(sections.length) {
        const markedOpts = Object.assign({}, defaultMarkedOptions, markedOptions);
        marked.setOptions(markedOpts);

        sections.forEach((section) => {
          let content = htmlDecode(section.innerHTML);
          if(WebSlides.config.indent) {
            content = trimIndent(content);
          }

          section.innerHTML = marked.parse(content);
        });
      }
      container.setAttribute('done', 'done');
    }
    let {codeTheme} = config;
    if(codeTheme && codeTheme !== 'default') {
      if(!/^http(s?):\/\//.test(codeTheme)) {
        codeTheme = `${WebSlides.config.CDN}/[email protected]/themes/${codeTheme}.css`;
      }
      addCSS(codeTheme);
    }
    super(options);
  }
};
复制代码

上面的代码比较简单,核心就是我们通过配置 marked 来解析每个 section 片段里的 Markdown 语法,然后将解析好的HTML丢回 section 元素里,最终让 WebSlides 去渲染。

这里有两个细节提一下,一个是 marked 支持配置代码语法高亮,我们使用 PrismJS 来处理语法高亮,如果我们配置了语法高亮的主题,通过动态添加CSS吧对应的主题加载下来。

export function addCSS(url) {
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.type = 'text/css';
  link.href = url;
  document.documentElement.appendChild(link);
}
复制代码

另一个细节是,我们可以处理一下代码缩进,让Markdown的缩进相对于section容器。

export function trimIndent(input) {
  const lines = input.split(/\n/g).filter(l => l.trim());
  let spaces = Infinity;
  lines.forEach((line) => {
    spaces = Math.min(spaces, line.match(/^\s*/)[0].length);
  });
  if(spaces > 0) {
    input = input.replace(new RegExp(`^[^\\S\\n]{${spaces}}`, 'mg'), '');
  }
  return input;
}
复制代码

添加扩展

接着,我们可以添加一些有用的扩展。

因为 WebSlides 是通过给元素添加语义化样式的方式来实现布局和效果的,而标准的Markdown不支持自定义的样式,因此我们有必要实现一些方便的扩展。

首先,我们可以保留 WebSlides.md 的原生 HTML 能力,在这里我们通过给 marked 编写扩展的方式,实现一个嵌入原生 HTML 的扩展语法。我把这个语法定义为 :@html 加上缩进代码段落的语法形式。

比如:

:@html
    <h1>标题</h1>
    <p>正文</p>
    
## 其他内容
复制代码

要实现这个语法,我们只需要给 marked 写一个如下的扩展。

// Override function
const tokenizer = {
  html(src) {
    const match = src.match(/^:\@html\s*?((?:\n(?:[^\S\n]+[^\n]+)?)+)/i);
    if (match) {
      return {
        type: 'html',
        raw: match[0],
        text: match[1].trim()
      };
    }

    // return false to use original codespan tokenizer
    return false;
  }
};

export default { tokenizer };
复制代码

在 marked 中实现扩展并不复杂,对于已有 token,我们可以通过重写 Tokenizer 和 Renderer 来覆盖对它的默认处理,在 marked 官方文档 中有比较详细的介绍。

上面的代码里,我们通过正则表达式匹配出段落,将它作为 html token 传给 Parser 处理即可。

其次,我们可以添加给 Markdown 片段附加 HTML 元素容器的语法,我定义为 :tag?.class?[attr]? 加缩进代码段落的语法形式。

比如:

:.wrap
    ## Markdown 标题
    Markdown 内容

## 其他内容
复制代码

上面的代码给缩进的片段添加<div class="wrap">的容器。

这个扩展的实现也不复杂,代码如下:

export default {
  name: 'wrapper',
  level: 'block',
  tokenizer(src) {
    const match = src.match(/^:([\w-_]*)(\.[^\[\]\s]+)?((?:\[[^\[\]]+\])*)[^\S\n]*((?:[^\S\n]*[^\s@][^\n]*)?)\s*?((?:\n(?:[^\S\n]+[^\n]+)?)*)/i);
    if(match) {
      if(match[0] === ':') return; // none match
      return {
        type: 'wrapper',
        raw: match[0],
        tagName: match[1] ? match[1] : 'div',
        className: match[2] ? match[2].replace(/\./g, ' ').trim() : null,
        attributes: match[3] ? match[3].replace(/[\[\]]+/g, ' ').trim() : null,
        text: match[4],
        body: trimIndent(match[5]).trim(),
      };
    }渲染
  },
  renderer(token) {
    const {tagName, text, body} = token;
    const attrs = getAttrs(token);

    return `<${tagName}${attrs}>${text}${marked.parse(body)}</${tagName}>\n`;
  }
};
复制代码

我们给扩展对象定义对应的 tokenizer 和 renderer,就可以让 marked 实现转换和渲染。这一块在 marked 官方文档里也有详细的介绍

第三,我们给 Markdown 内容添加属性和样式扩展,我们定义{^.class[attrs]}{$.class[attrs]}语法,前者表示将属性和样式添加到后一个兄弟节点元素上,后者表示将属性和样式添加到前一个兄弟节点元素或父元素上。

所以我们就可以如下使用:

{^.flexblock}
- 列表
  - [子列](https://juejin.cn){$[target="blank"]}
  - [子列](https://juejin.cn){$[target="blank"]}
- 列表
  - [子列](https://juejin.cn){$[target="blank"]}
  - [子列](https://juejin.cn){$[target="blank"]}

<!-- 相当于如下 HTML 代码 -->
<ul class="flexblock">
  <ul>
    <li><a href="https://juejin.cn" target="blank">掘金</a></li>
    <li><a href="https://juejin.cn" target="blank">掘金</a></li>
  </ul>
  <ul>
    <li><a href="https://juejin.cn" target="blank">掘金</a></li>
    <li><a href="https://juejin.cn" target="blank">掘金</a></li>
  </ul>
</ul>
复制代码

这个扩展的具体实现如下:

export default {
  name: 'attr',
  level: 'inline',
  start(src) {
    const match = src.match(/{[\^\$][^\^\$]/);
    if(match) return match.index;
  },
  tokenizer(src) {
    match = src.match(/^{([\^\$])(\.[^\[\]\s]+)?((?:\[[^\[\]\n]+\])*)}/i);
    if (match) {
      const [b, c, d] = match.slice(1);
      const className = c ? c.replace(/\./g, ' ').trim() : null;
      const attrsJson = {};
      if(className) attrsJson.className = className;
      d.split(/[\[\]]+/g).forEach((f) => {
        if(f) {
          const [k, v] = f.split('=');
          attrsJson[k] = v.replace(/^\s*"(.*)"$/i, "$1");
        }
      });
      
      const attrs = JSON.stringify(attrsJson);
      return {
        type: 'attr',
        raw: match[0],
        text: `<script type="text/webslides-attrs" position="${b}">${attrs}</script>`
      };
    }    
  },
  renderer(token) {
    return token.text;
  }
};
复制代码

这个代码的解析稍稍有些tricky,我们其实是将它解析成一段JSON数据,然后放到一个<script>标签里面,然后我们再在WebSlides的构造函数里面添加如下代码段,在 marked 编译后,再来处理元素插入属性和样式的问题。

const preattrs = section.querySelectorAll('script[type="text/webslides-attrs"]');
preattrs.forEach((el) => {
    const parent = el.parentElement;
    if(parent && parent.tagName.toLowerCase() === 'p' && parent.childNodes.length === 1) {
      parent.setAttribute('position', el.getAttribute('position'));
      el = parent;
    }
    const node = findSibling(el);
    if(node) {
      const attrs = JSON.parse(el.textContent);
      for(const [k, v] of Object.entries(attrs)) {
        if(k === 'className') {
          node.className = node.className ? `${node.className} ${v}` : v;
        }
        else node.setAttribute(k, v);
      }
      el.remove();
    }
});
复制代码

以上三个扩展就实现了 HTML 和 Markdown 兼容的问题,有了这些,就可以简便快捷地用 Markdown 来书些 WebSlides 支持的文稿效果了。

其他扩展

为了提升 WebSlides 的演示能力,我们还可以写其他的扩展来支持更多功能,WebSlides.md 实现了三个附加的扩展,分别是支持 SVG 图标的 svgicon 扩展,支持数学公式的 katex 扩展和支持渲染流程图的 mermaid 扩展。

在这里我不赘述了,可以看一下代码:

svgicon

// https://github.com/simple-icons/simple-icons

export default {
  name: 'icon',
  level: 'inline',
  start(src) {
    const match = src.match(/{@[^@\n]/);
    if(match) return match.index;
  },
  tokenizer(src) {
    const match = src.match(/^{@\s*([\w_][\w-_]*)(?:\?([^\s]+))?\s*?}/i);
    if(match) {
      return {
        type: 'icon',
        raw: match[0],
        file: match[1],
        query: match[2],
      };
    }
  },
  renderer(token) {
    const {file, query} = token;
    let className = "svgicon";
    let attrs = '';
    if(query) {
      const {searchParams} = new URL(`svgicon://svgicon?${query}`);
      for(let [key, value] of searchParams.entries()) {
        attrs = `${attrs} ${key}="${value}"`;
        if(key === 'style') {
          attrs = `${attrs} data-style=${value}`;
        }
      }
    } else {
      className = `${className} small`;
    }
    return `<img class="${className}" src="${WebSlides.config.CDN}/bootstrap-icons/icons/${file}.svg"${attrs}>`;
  }
};
复制代码

katex

import katex from 'katex';
import {trimIndent} from '../utils';

function renderer(token) {
  const {text:code, macros} = token;
  let ret = code;
  try {
    return katex.renderToString(code, {
      macros
    });
  } catch(ex) {
    console.error(ex.message);
    return ret;
  }
}

export default [{
  name: 'katex',
  level: 'block',
  tokenizer(src) {
    const match = src.match(/^:@katex\s*?((?:\n(?:[^\S\n]+[^\n]+)?)+)/i);
    if (match) {
      const body = trimIndent(match[1]).trim();
      const m = body.match(/^(\{[\s\S]*?\})?([\s\S]*)/i);

      let macros = m[1];

      if(macros) {
        try {
          macros = JSON.parse(m[1]);
        } catch(ex) {
          console.error(ex.message);
        }
      }
      return {
        type: 'katex',
        raw: match[0],
        macros,
        text: m[2].trim()
      };
    }
  },
  renderer,
}, {
  name: 'katex-inline',
  level: 'inline',
  tokenizer(src) {
    const match = src.match(/^\$\$([^\n]+?)\$\$/);
    if (match) {
      return {
        type: 'katex',
        raw: match[0],
        text: match[1].trim()
      };
    }
  },
  renderer,
}];
复制代码

mermaid

const state = {};

import {trimIndent} from '../utils';

export default {
  name: 'mermaid',
  level: 'block',
  tokenizer(src) {
    const match = src.match(/^:\@mermaid\s*?((?:\n(?:[^\S\n]+[^\n]+)?)+)/i);
    if (match) {
      return {
        type: 'mermaid',
        raw: match[0],
        text: trimIndent(match[1]).trim(),
      };
    }
  },
  renderer(token) {
    let code = `<div class="mermaid aligncenter">
${token.text}
</div>`;
    if(!state.hasMermaid) {
      state.hasMermaid = true;
      const scriptEl = document.createElement('script');
      scriptEl.src = `${WebSlides.config.CDN}/mermaid/dist/mermaid.min.js`;
      scriptEl.crossorigin = "anonymous";
      document.documentElement.appendChild(scriptEl);
      scriptEl.onload = () => {
        mermaid.startOnLoad = false;
        mermaid.initialize({});
        mermaid.parseError = function(err){
          console.error(err);
        };
        const mermaidGraphs = document.querySelectorAll('.slide.current .mermaid');
        mermaid.init(mermaidGraphs);
      };
    }
    return code;
  },
  state,
};
复制代码

踩坑和细节处理

做任何项目,免不了会踩一些坑,WebSlides.md 也不例外。因此要针对踩的坑,做一些细节处理。

首先,在 Markdown 和 HTML 标签混合使用的时候,marked 不会在 HTML 块级标签后添加回车,这样会影响后续 Markdown 的解析和处理,比如:

<h1>标题1</h1>
## 标题2
复制代码

上面这段代码,第一行的 HTML 标签会影响第二行 Markdown 代码的解析,处理办法是在所有 HTML 的块级标签之后添加一个回车。

const blockTags = 'address|article|aside|base|basefont|blockquote|body|caption'
+ '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption'
+ '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe'
+ '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option'
+ '|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr'
+ '|track|ul';

const blockReg = new RegExp(`(<\\s*(?:(?:\\/\\s*(?:${blockTags})\\s*)|(?:(?:${blockTags})\\s*\\/\\s*)|hr)>\\s*?)\\n`, 'ig');

sections.forEach((section) => {
  let content = htmlDecode(section.innerHTML);
  if(WebSlides.config.indent) {
    content = trimIndent(content);
  }

  content = content
    .replace(blockReg,(a) => {
      return `${a}\n`;
    }); //需要在Block元素后补一个回车,不然解析会有问题
  ...
});
复制代码

但是这段代码又会导致另一个问题,也就是如果在 ``` 片段之内的代码段落里如果有HTML块级标签,也会被插入回车,所以还得把这部分多余插入的回车去掉,办法是继承一下 marked 的 Renderer 对象,处理一下 code 片段里的回车。

class Renderer extends marked.Renderer {
  code(code, infostring, escaped) {
    code = code.replace(blockReg, "$1"); // 代码中去掉在Block元素后补的回车
    return super.code(code, infostring, escaped);
  }
}
复制代码

这样就把回车问题处理好了。

然后我们可以在构造函数中处理一下 svgicon 和 mermaid,尤其是 mermaid,因为我们插件里面只是给 mermaid 渲染成一个 <div class="mermaid"> 的标签,里面的内容是 mermaid 处理的,当我们的 Slides 没有在当前页面时,mermaid 处理的时候,由于 section 是 display:none 的,所以拿不到容器真正的宽高,这样会导致页面渲染出一个宽高为0的不可见流程图。而 svgicon 的问题是我们默认使用 img 标签加载 svg 文件的方式渲染,这种方式渲染是没办法指定icon的颜色的,如果我们要改变颜色,可以重新下载和渲染 svg 标签。

mermaid 和 svgicon 的处理都可以放在当前页被显示的时候进行,所以我们给容器注册一个 ws:slide-change 事件,在事件中进行处理。

container.addEventListener('ws:slide-change', () => {
    const section = document.querySelector('#webslides section.current');
    // load svgicon
    const svgicons = section.querySelectorAll('img.svgicon[fill],img.svgicon[stroke]');
    svgicons.forEach(async (el) => {
      if(el.clientHeight > 0) {
        loadSvgIcon(el);
      } else {
        el.onload = loadSvgIcon.bind(null, el);
      }
    });
    if(window.mermaid && window.mermaid.init) {
      const mermaidGraphs = document.querySelectorAll('.slide.current .mermaid');
      window.mermaid.init(mermaidGraphs);
    }
});
复制代码

这样,我们基本上所有的细节问题都解决了,完整的代码详见稀土掘金的代码仓库,具体使用方式可以参考【码上掘金】上的代码演示

有问题欢迎评论区讨论,也欢迎朋友们为 WebSlides.md 提需求或贡献代码。

猜你喜欢

转载自juejin.im/post/7084102857711960101