Cómo escribí una útil biblioteca de presentaciones web (PPT) en una semana

Recientemente, la comunidad de Nuggets lanzó una plataforma para editar y ejecutar código en línea [Code on Nuggets] Con esta plataforma, no solo podemos compartir código en la plataforma como usar CodePen , sino también usar la demostración de ejecución de código como contenido del artículo. Liberación parcial.

Estaba pensando que con Nuggets on the Code, podemos proporcionar algunas herramientas útiles e interesantes, como la herramienta Diapositivas, que permite a los usuarios insertar sus propias presentaciones en los artículos de Nuggets. Entonces, usé mi tiempo libre y pasé aproximadamente una semana para publicar WebSlides.md , a través del cual puede escribir sus presentaciones en Markdown y HTML, y usar plataformas Playgroud como [Code Nuggets] como su plataforma para compartir presentaciones.

WebSlides.md + Nuggets en código -> Demostración en línea

Selección técnica

Para ejecutar código en cualquier plataforma de Playground que admita HTML, CSS y JavaScript, nuestra biblioteca de presentación debe ser puramente renderizada y no depender de ninguna biblioteca que requiera un entorno de ingeniería (compilación).

Hay muchas soluciones opcionales para presentaciones web, como el NodePPT más maduro del Sr. Sanshui Qing , o el recientemente popular Slidev , pero todos requieren un entorno de ejecución local de Node, y no es fácil editar y editar directamente en un simple Zona de juegos.

Las herramientas de presentación de representación de front-end puras incluyen WebSlides , pero la versión original de WebSlides no es compatible con la sintaxis de Markdown y debe estar escrita a mano en HTML nativo puro, lo cual es muy complicado de escribir y no muy amigable para los estudiantes que no son de front-end.

Entonces, ¿es posible agregar la sintaxis Markdown a WebSlides? La conclusión es factible, solo necesitamos introducir una biblioteca simple que compile Markdown en tiempo real, como la marcada . Por lo tanto, la selección técnica final de este proyecto es combinar las diapositivas marcadas y WebSlides, compilar el texto de Markdown en HTML a través de marcado y luego entregarlo a WebSides para su procesamiento.

Detalles de implementación técnica

Podemos heredar la clase de WebSlides y agregarle la lógica de análisis de 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);
  }
};
复制代码

El código anterior es relativamente simple. El núcleo es que configuramos marcado para analizar la sintaxis de Markdown en cada fragmento de sección, luego arrojamos el HTML analizado nuevamente al elemento de sección y finalmente dejamos que WebSlides se procese.

Hay dos detalles que mencionar aquí. Uno es que la marca admite el resaltado de sintaxis del código de configuración. Usamos PrismJS para manejar el resaltado de sintaxis. Si configuramos un tema de resaltado de sintaxis, el tema correspondiente se puede cargar agregando CSS dinámicamente.

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

Otro detalle es que podemos manejar la sangría del código para que la sangría de Markdown sea relativa al contenedor de la sección.

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;
}
复制代码

añadir extensión

A continuación, podemos agregar algunas extensiones útiles.

Debido a que WebSlides implementa el diseño y los efectos al agregar estilos semánticos a los elementos, y Markdown estándar no admite estilos personalizados, es necesario que implementemos algunas extensiones convenientes.

En primer lugar, podemos conservar las capacidades de HTML nativo de WebSlides.md. Aquí implementamos una sintaxis de extensión que incrusta HTML nativo al escribir extensiones para marcar. Defino esta sintaxis como la sintaxis para :@htmlagregar párrafos de código sangrados.

por ejemplo:

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

Para implementar esta gramática, solo necesitamos escribir una extensión para marcar de la siguiente manera.

// 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 标签。

El procesamiento de mermaid y svgicon se puede realizar cuando se muestra la página actual, por lo que registramos un ws:slide-changeevento y lo procesamos en el evento.

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);
    }
});
复制代码

De esta manera, hemos resuelto básicamente todos los problemas detallados. Para obtener el código completo, consulte el repositorio de código de Rare Earth Nuggets. Para el uso específico, consulte la demostración del código en [Code on Nuggets] .

Si tiene alguna pregunta, bienvenido a discutir en el área de comentarios, y dé la bienvenida a sus amigos para que soliciten o contribuyan con el código de WebSlides.md.

Supongo que te gusta

Origin juejin.im/post/7084102857711960101
Recomendado
Clasificación