Ver código fonte

查看大图

drew 5 anos atrás
pai
commit
af29e626a0

+ 5 - 0
megalo.config.js

@@ -28,6 +28,11 @@ module.exports = {
                 from: path.resolve('src/vant'),
                 to: path.resolve(`dist-${process.env.PLATFORM}/vant`)
             });
+            args[0].push({
+                context: path.resolve('src'),
+                from: path.resolve('src/parser'),
+                to: path.resolve(`dist-${process.env.PLATFORM}/parser`)
+            });
             // let str = /usingComponents:[\W](\{[\w\W]+?\})/
             //     .exec(fs.readFileSync('src/main.js').toString())[1]
             //     .replace(/[\s]/g, '')

+ 2 - 1
src/main.js

@@ -162,7 +162,8 @@ export default {
             'van-steps': '/vant/steps/index',
             'van-rate': '/vant/rate/index',
             'van-popup': '/vant/popup/index',
-            'van-transition': '/vant/transition/index'
+            'van-transition': '/vant/transition/index',
+            parser: '/parser/parser'
         },
         permission: {
             'scope.userLocation': {

+ 8 - 2
src/pages/ArticleDetail.vue

@@ -1,6 +1,6 @@
 <config>
 {
-'navigationBarTitleText': '',
+    "navigationBarTitleText": "",
 }
 </config>
 <template>
@@ -11,7 +11,8 @@
         <div class="time">
             {{ info.createTime }}
         </div>
-        <rich-text :nodes="content" space="nbsp"></rich-text>
+        <!-- <rich-text :nodes="content" space="nbsp" @click="click"></rich-text> -->
+        <parser :html="content" />
     </div>
 </template>
 <script>
@@ -31,6 +32,11 @@ export default {
                 title: res.data.articleType == '1' ? '新闻详情' : '活动详情'
             });
         });
+    },
+    methods: {
+        click(e) {
+            console.log(e);
+        }
     }
 };
 </script>

+ 5 - 1
src/pages/eventList.vue

@@ -57,7 +57,11 @@ export default {
             }
         },
         detail(item) {
-            this.goNext('/pages/ArticleDetail?id=' + item.id);
+            if (item.articleUrl && item.articleUrl.startsWith('skuId:')) {
+                this.goNext('/pages/detail?id=' + item.articleUrl.replace('skuId:', ''));
+            } else {
+                this.goNext('/pages/ArticleDetail?id=' + item.id);
+            }
         }
     }
 };

+ 5 - 1
src/pages/newsList.vue

@@ -57,7 +57,11 @@ export default {
             }
         },
         detail(item) {
-            this.goNext('/pages/ArticleDetail?id=' + item.id);
+            if (item.articleUrl && item.articleUrl.startsWith('skuId:')) {
+                this.goNext('/pages/detail?id=' + item.articleUrl.replace('skuId:', ''));
+            } else {
+                this.goNext('/pages/ArticleDetail?id=' + item.id);
+            }
         }
     }
 };

+ 97 - 0
src/parser/libs/CssHandler.js

@@ -0,0 +1,97 @@
+const cfg = require('./config.js'),
+  isLetter = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+
+function CssHandler(tagStyle) {
+  var styles = Object.assign(Object.create(null), cfg.userAgentStyles);
+  for (var item in tagStyle)
+    styles[item] = (styles[item] ? styles[item] + ';' : '') + tagStyle[item];
+  this.styles = styles;
+}
+CssHandler.prototype.getStyle = function (data) {
+  this.styles = new parser(data, this.styles).parse();
+}
+CssHandler.prototype.match = function (name, attrs) {
+  var tmp, matched = (tmp = this.styles[name]) ? tmp + ';' : '';
+  if (attrs.class) {
+    var items = attrs.class.split(' ');
+    for (var i = 0, item; item = items[i]; i++)
+      if (tmp = this.styles['.' + item])
+        matched += tmp + ';';
+  }
+  if (tmp = this.styles['#' + attrs.id])
+    matched += tmp + ';';
+  return matched;
+}
+module.exports = CssHandler;
+
+function parser(data, init) {
+  this.data = data;
+  this.floor = 0;
+  this.i = 0;
+  this.list = [];
+  this.res = init;
+  this.state = this.Space;
+}
+parser.prototype.parse = function () {
+  for (var c; c = this.data[this.i]; this.i++)
+    this.state(c);
+  return this.res;
+}
+parser.prototype.section = function () {
+  return this.data.substring(this.start, this.i);
+}
+// 状态机
+parser.prototype.Space = function (c) {
+  if (c == '.' || c == '#' || isLetter(c)) {
+    this.start = this.i;
+    this.state = this.Name;
+  } else if (c == '/' && this.data[this.i + 1] == '*')
+    this.Comment();
+  else if (!cfg.blankChar[c] && c != ';')
+    this.state = this.Ignore;
+}
+parser.prototype.Comment = function () {
+  this.i = this.data.indexOf('*/', this.i) + 1;
+  if (!this.i) this.i = this.data.length;
+  this.state = this.Space;
+}
+parser.prototype.Ignore = function (c) {
+  if (c == '{') this.floor++;
+  else if (c == '}' && !--this.floor) this.state = this.Space;
+}
+parser.prototype.Name = function (c) {
+  if (cfg.blankChar[c]) {
+    this.list.push(this.section());
+    this.state = this.NameSpace;
+  } else if (c == '{') {
+    this.list.push(this.section());
+    this.Content();
+  } else if (c == ',') {
+    this.list.push(this.section());
+    this.Comma();
+  } else if (!isLetter(c) && (c < '0' || c > '9') && c != '-' && c != '_')
+    this.state = this.Ignore;
+}
+parser.prototype.NameSpace = function (c) {
+  if (c == '{') this.Content();
+  else if (c == ',') this.Comma();
+  else if (!cfg.blankChar[c]) this.state = this.Ignore;
+}
+parser.prototype.Comma = function () {
+  while (cfg.blankChar[this.data[++this.i]]);
+  if (this.data[this.i] == '{') this.Content();
+  else {
+    this.start = this.i--;
+    this.state = this.Name;
+  }
+}
+parser.prototype.Content = function () {
+  this.start = ++this.i;
+  if ((this.i = this.data.indexOf('}', this.i)) == -1) this.i = this.data.length;
+  var content = this.section();
+  for (var i = 0, item; item = this.list[i++];)
+    if (this.res[item]) this.res[item] += ';' + content;
+    else this.res[item] = content;
+  this.list = [];
+  this.state = this.Space;
+}

+ 525 - 0
src/parser/libs/MpHtmlParser.js

@@ -0,0 +1,525 @@
+/**
+ * html 解析器
+ * @tutorial https://github.com/jin-yufeng/Parser
+ * @version 20200828
+ * @author JinYufeng
+ * @listens MIT
+ */
+const cfg = require('./config.js'),
+  blankChar = cfg.blankChar,
+  CssHandler = require('./CssHandler.js'),
+  windowWidth = wx.getSystemInfoSync().windowWidth;
+var emoji;
+
+function MpHtmlParser(data, options = {}) {
+  this.attrs = {};
+  this.CssHandler = new CssHandler(options.tagStyle, windowWidth);
+  this.data = data;
+  this.domain = options.domain;
+  this.DOM = [];
+  this.i = this.start = this.audioNum = this.imgNum = this.videoNum = 0;
+  options.prot = (this.domain || '').includes('://') ? this.domain.split('://')[0] : 'http';
+  this.options = options;
+  this.state = this.Text;
+  this.STACK = [];
+  // 工具函数
+  this.bubble = () => {
+    for (var i = this.STACK.length, item; item = this.STACK[--i];) {
+      if (cfg.richOnlyTags[item.name]) {
+        if (item.name == 'table' && !Object.hasOwnProperty.call(item, 'c')) item.c = 1;
+        return false;
+      }
+      item.c = 1;
+    }
+    return true;
+  }
+  this.decode = (val, amp) => {
+    var i = -1,
+      j, en;
+    while (1) {
+      if ((i = val.indexOf('&', i + 1)) == -1) break;
+      if ((j = val.indexOf(';', i + 2)) == -1) break;
+      if (val[i + 1] == '#') {
+        en = parseInt((val[i + 2] == 'x' ? '0' : '') + val.substring(i + 2, j));
+        if (!isNaN(en)) val = val.substr(0, i) + String.fromCharCode(en) + val.substr(j + 1);
+      } else {
+        en = val.substring(i + 1, j);
+        if (cfg.entities[en] || en == amp)
+          val = val.substr(0, i) + (cfg.entities[en] || '&') + val.substr(j + 1);
+      }
+    }
+    return val;
+  }
+  this.getUrl = url => {
+    if (url[0] == '/') {
+      if (url[1] == '/') url = this.options.prot + ':' + url;
+      else if (this.domain) url = this.domain + url;
+    } else if (this.domain && url.indexOf('data:') != 0 && !url.includes('://'))
+      url = this.domain + '/' + url;
+    return url;
+  }
+  this.isClose = () => this.data[this.i] == '>' || (this.data[this.i] == '/' && this.data[this.i + 1] == '>');
+  this.section = () => this.data.substring(this.start, this.i);
+  this.parent = () => this.STACK[this.STACK.length - 1];
+  this.siblings = () => this.STACK.length ? this.parent().children : this.DOM;
+}
+MpHtmlParser.prototype.parse = function () {
+  if (emoji) this.data = emoji.parseEmoji(this.data);
+  for (var c; c = this.data[this.i]; this.i++)
+    this.state(c);
+  if (this.state == this.Text) this.setText();
+  while (this.STACK.length) this.popNode(this.STACK.pop());
+  return this.DOM;
+}
+// 设置属性
+MpHtmlParser.prototype.setAttr = function () {
+  var name = this.attrName.toLowerCase(),
+    val = this.attrVal;
+  if (cfg.boolAttrs[name]) this.attrs[name] = 'T';
+  else if (val) {
+    if (name == 'src' || (name == 'data-src' && !this.attrs.src)) this.attrs.src = this.getUrl(this.decode(val, 'amp'));
+    else if (name == 'href' || name == 'style') this.attrs[name] = this.decode(val, 'amp');
+    else if (name.substr(0, 5) != 'data-') this.attrs[name] = val;
+  }
+  this.attrVal = '';
+  while (blankChar[this.data[this.i]]) this.i++;
+  if (this.isClose()) this.setNode();
+  else {
+    this.start = this.i;
+    this.state = this.AttrName;
+  }
+}
+// 设置文本节点
+MpHtmlParser.prototype.setText = function () {
+  var back, text = this.section();
+  if (!text) return;
+  text = (cfg.onText && cfg.onText(text, () => back = true)) || text;
+  if (back) {
+    this.data = this.data.substr(0, this.start) + text + this.data.substr(this.i);
+    let j = this.start + text.length;
+    for (this.i = this.start; this.i < j; this.i++) this.state(this.data[this.i]);
+    return;
+  }
+  if (!this.pre) {
+    // 合并空白符
+    var flag, tmp = [];
+    for (let i = text.length, c; c = text[--i];)
+      if (!blankChar[c]) {
+        tmp.unshift(c);
+        if (!flag) flag = 1;
+      } else {
+        if (tmp[0] != ' ') tmp.unshift(' ');
+        if (c == '\n' && flag == void 0) flag = 0;
+      }
+    if (flag == 0) return;
+    text = tmp.join('');
+  }
+  this.siblings().push({
+    type: 'text',
+    text: this.decode(text)
+  });
+}
+// 设置元素节点
+MpHtmlParser.prototype.setNode = function () {
+  var node = {
+      name: this.tagName.toLowerCase(),
+      attrs: this.attrs
+    },
+    close = cfg.selfClosingTags[node.name];
+  this.attrs = {};
+  if (!cfg.ignoreTags[node.name]) {
+    // 处理属性
+    var attrs = node.attrs,
+      style = this.CssHandler.match(node.name, attrs, node) + (attrs.style || ''),
+      styleObj = {};
+    if (attrs.id) {
+      if (this.options.compress & 1) attrs.id = void 0;
+      else if (this.options.useAnchor) this.bubble();
+    }
+    if ((this.options.compress & 2) && attrs.class) attrs.class = void 0;
+    switch (node.name) {
+      case 'a':
+      case 'ad':
+        this.bubble();
+        break;
+      case 'font':
+        if (attrs.color) {
+          styleObj['color'] = attrs.color;
+          attrs.color = void 0;
+        }
+        if (attrs.face) {
+          styleObj['font-family'] = attrs.face;
+          attrs.face = void 0;
+        }
+        if (attrs.size) {
+          var size = parseInt(attrs.size);
+          if (size < 1) size = 1;
+          else if (size > 7) size = 7;
+          var map = ['xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large'];
+          styleObj['font-size'] = map[size - 1];
+          attrs.size = void 0;
+        }
+        break;
+      case 'embed':
+        var src = node.attrs.src || '',
+          type = node.attrs.type || '';
+        if (type.includes('video') || src.includes('.mp4') || src.includes('.3gp') || src.includes('.m3u8'))
+          node.name = 'video';
+        else if (type.includes('audio') || src.includes('.m4a') || src.includes('.wav') || src.includes('.mp3') || src.includes('.aac'))
+          node.name = 'audio';
+        else break;
+        if (node.attrs.autostart)
+          node.attrs.autoplay = 'T';
+        node.attrs.controls = 'T';
+        // falls through
+      case 'video':
+      case 'audio':
+        if (!attrs.id) attrs.id = node.name + (++this[`${node.name}Num`]);
+        else this[`${node.name}Num`]++;
+        if (node.name == 'video') {
+          if (this.videoNum > 3)
+            node.lazyLoad = 1;
+          if (attrs.width) {
+            styleObj.width = parseFloat(attrs.width) + (attrs.width.includes('%') ? '%' : 'px');
+            attrs.width = void 0;
+          }
+          if (attrs.height) {
+            styleObj.height = parseFloat(attrs.height) + (attrs.height.includes('%') ? '%' : 'px');
+            attrs.height = void 0;
+          }
+        }
+        if (!attrs.controls && !attrs.autoplay) attrs.controls = 'T';
+        attrs.source = [];
+        if (attrs.src) {
+          attrs.source.push(attrs.src);
+          attrs.src = void 0;
+        }
+        this.bubble();
+        break;
+      case 'td':
+      case 'th':
+        if (attrs.colspan || attrs.rowspan)
+          for (var k = this.STACK.length, item; item = this.STACK[--k];)
+            if (item.name == 'table') {
+              item.c = void 0;
+              break;
+            }
+    }
+    if (attrs.align) {
+      styleObj['text-align'] = attrs.align;
+      attrs.align = void 0;
+    }
+    // 压缩 style
+    var styles = style.split(';');
+    style = '';
+    for (var i = 0, len = styles.length; i < len; i++) {
+      var info = styles[i].split(':');
+      if (info.length < 2) continue;
+      let key = info[0].trim().toLowerCase(),
+        value = info.slice(1).join(':').trim();
+      if (value[0] == '-' || value.includes('safe'))
+        style += `;${key}:${value}`;
+      else if (!styleObj[key] || value.includes('import') || !styleObj[key].includes('import'))
+        styleObj[key] = value;
+    }
+    if (node.name == 'img') {
+      if (attrs.src && !attrs.ignore) {
+        if (this.bubble())
+          attrs.i = (this.imgNum++).toString();
+        else attrs.ignore = 'T';
+      }
+      if (attrs.ignore) {
+        style += ';-webkit-touch-callout:none';
+        styleObj['max-width'] = '100%';
+      }
+      var width;
+      if (styleObj.width) width = styleObj.width;
+      else if (attrs.width) width = attrs.width.includes('%') ? attrs.width : parseFloat(attrs.width) + 'px';
+      if (width) {
+        styleObj.width = width;
+        attrs.width = '100%';
+        if (parseInt(width) > windowWidth) {
+          styleObj.height = '';
+          if (attrs.height) attrs.height = void 0;
+        }
+      }
+      if (styleObj.height) {
+        attrs.height = styleObj.height;
+        styleObj.height = '';
+      } else if (attrs.height && !attrs.height.includes('%'))
+        attrs.height = parseFloat(attrs.height) + 'px';
+    }
+    for (var key in styleObj) {
+      var value = styleObj[key];
+      if (!value) continue;
+      if (key.includes('flex') || key == 'order' || key == 'self-align') node.c = 1;
+      // 填充链接
+      if (value.includes('url')) {
+        var j = value.indexOf('(');
+        if (j++ != -1) {
+          while (value[j] == '"' || value[j] == "'" || blankChar[value[j]]) j++;
+          value = value.substr(0, j) + this.getUrl(value.substr(j));
+        }
+      }
+      // 转换 rpx
+      else if (value.includes('rpx'))
+        value = value.replace(/[0-9.]+\s*rpx/g, $ => parseFloat($) * windowWidth / 750 + 'px');
+      else if (key == 'white-space' && value.includes('pre') && !close)
+        this.pre = node.pre = true;
+      style += `;${key}:${value}`;
+    }
+    style = style.substr(1);
+    if (style) attrs.style = style;
+    if (!close) {
+      node.children = [];
+      if (node.name == 'pre' && cfg.highlight) {
+        this.remove(node);
+        this.pre = node.pre = true;
+      }
+      this.siblings().push(node);
+      this.STACK.push(node);
+    } else if (!cfg.filter || cfg.filter(node, this) != false)
+      this.siblings().push(node);
+  } else {
+    if (!close) this.remove(node);
+    else if (node.name == 'source') {
+      var parent = this.parent();
+      if (parent && (parent.name == 'video' || parent.name == 'audio') && node.attrs.src)
+        parent.attrs.source.push(node.attrs.src);
+    } else if (node.name == 'base' && !this.domain) this.domain = node.attrs.href;
+  }
+  if (this.data[this.i] == '/') this.i++;
+  this.start = this.i + 1;
+  this.state = this.Text;
+}
+// 移除标签
+MpHtmlParser.prototype.remove = function (node) {
+  var name = node.name,
+    j = this.i;
+  // 处理 svg
+  var handleSvg = () => {
+    var src = this.data.substring(j, this.i + 1);
+    if (!node.attrs.xmlns) src = ' xmlns="http://www.w3.org/2000/svg"' + src;
+    var i = j;
+    while (this.data[j] != '<') j--;
+    src = this.data.substring(j, i).replace("viewbox", "viewBox") + src;
+    var parent = this.parent();
+    if (node.attrs.width == '100%' && parent && (parent.attrs.style || '').includes('inline'))
+      parent.attrs.style = 'width:300px;max-width:100%;' + parent.attrs.style;
+    this.siblings().push({
+      name: 'img',
+      attrs: {
+        src: 'data:image/svg+xml;utf8,' + src.replace(/#/g, '%23'),
+        style: (/vertical[^;]+/.exec(node.attrs.style) || []).shift(),
+        ignore: 'T'
+      }
+    })
+  }
+  if (node.name == 'svg' && this.data[j] == '/') return handleSvg(this.i++);
+  while (1) {
+    if ((this.i = this.data.indexOf('</', this.i + 1)) == -1) {
+      if (name == 'pre' || name == 'svg') this.i = j;
+      else this.i = this.data.length;
+      return;
+    }
+    this.start = (this.i += 2);
+    while (!blankChar[this.data[this.i]] && !this.isClose()) this.i++;
+    if (this.section().toLowerCase() == name) {
+      // 代码块高亮
+      if (name == 'pre') {
+        this.data = this.data.substr(0, j + 1) + cfg.highlight(this.data.substring(j + 1, this.i - 5), node.attrs) + this.data.substr(this.i - 5);
+        return this.i = j;
+      } else if (name == 'style')
+        this.CssHandler.getStyle(this.data.substring(j + 1, this.i - 7));
+      else if (name == 'title')
+        this.DOM.title = this.data.substring(j + 1, this.i - 7);
+      if ((this.i = this.data.indexOf('>', this.i)) == -1) this.i = this.data.length;
+      if (name == 'svg') handleSvg();
+      return;
+    }
+  }
+}
+// 节点出栈处理
+MpHtmlParser.prototype.popNode = function (node) {
+  // 空白符处理
+  if (node.pre) {
+    node.pre = this.pre = void 0;
+    for (let i = this.STACK.length; i--;)
+      if (this.STACK[i].pre)
+        this.pre = true;
+  }
+  var siblings = this.siblings(),
+    len = siblings.length,
+    childs = node.children;
+  if (node.name == 'head' || (cfg.filter && cfg.filter(node, this) == false))
+    return siblings.pop();
+  var attrs = node.attrs;
+  // 替换一些标签名
+  if (cfg.blockTags[node.name]) node.name = 'div';
+  else if (!cfg.trustTags[node.name]) node.name = 'span';
+  // 处理列表
+  if (node.c && (node.name == 'ul' || node.name == 'ol')) {
+    if ((node.attrs.style || '').includes('list-style:none')) {
+      for (let i = 0, child; child = childs[i++];)
+        if (child.name == 'li')
+          child.name = 'div';
+    } else if (node.name == 'ul') {
+      var floor = 1;
+      for (let i = this.STACK.length; i--;)
+        if (this.STACK[i].name == 'ul') floor++;
+      if (floor != 1)
+        for (let i = childs.length; i--;)
+          childs[i].floor = floor;
+    } else {
+      for (let i = 0, num = 1, child; child = childs[i++];)
+        if (child.name == 'li') {
+          child.type = 'ol';
+          child.num = ((num, type) => {
+            if (type == 'a') return String.fromCharCode(97 + (num - 1) % 26);
+            if (type == 'A') return String.fromCharCode(65 + (num - 1) % 26);
+            if (type == 'i' || type == 'I') {
+              num = (num - 1) % 99 + 1;
+              var one = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'],
+                ten = ['X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'],
+                res = (ten[Math.floor(num / 10) - 1] || '') + (one[num % 10 - 1] || '');
+              if (type == 'i') return res.toLowerCase();
+              return res;
+            }
+            return num;
+          })(num++, attrs.type) + '.';
+        }
+    }
+  }
+  // 处理表格的边框
+  if (node.name == 'table') {
+    var padding = attrs.cellpadding,
+      spacing = attrs.cellspacing,
+      border = attrs.border;
+    if (node.c) {
+      this.bubble();
+      attrs.style = (attrs.style || '') + ';display:table';
+      if (!padding) padding = 2;
+      if (!spacing) spacing = 2;
+    }
+    if (border) attrs.style = `border:${border}px solid gray;${attrs.style || ''}`;
+    if (spacing) attrs.style = `border-spacing:${spacing}px;${attrs.style || ''}`;
+    if (border || padding || node.c)
+      (function f(ns) {
+        for (var i = 0, n; n = ns[i]; i++) {
+          if (n.type == 'text') continue;
+          var style = n.attrs.style || '';
+          if (node.c && n.name[0] == 't') {
+            n.c = 1;
+            style += ';display:table-' + (n.name == 'th' || n.name == 'td' ? 'cell' : (n.name == 'tr' ? 'row' : 'row-group'));
+          }
+          if (n.name == 'th' || n.name == 'td') {
+            if (border) style = `border:${border}px solid gray;${style}`;
+            if (padding) style = `padding:${padding}px;${style}`;
+          } else f(n.children || []);
+          if (style) n.attrs.style = style;
+        }
+      })(childs)
+    if (this.options.autoscroll) {
+      var table = Object.assign({}, node);
+      node.name = 'div';
+      node.attrs = {
+        style: 'overflow:scroll'
+      }
+      node.children = [table];
+    }
+  }
+  this.CssHandler.pop && this.CssHandler.pop(node);
+  // 自动压缩
+  if (node.name == 'div' && !Object.keys(attrs).length && childs.length == 1 && childs[0].name == 'div')
+    siblings[len - 1] = childs[0];
+}
+// 状态机
+MpHtmlParser.prototype.Text = function (c) {
+  if (c == '<') {
+    var next = this.data[this.i + 1],
+      isLetter = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+    if (isLetter(next)) {
+      this.setText();
+      this.start = this.i + 1;
+      this.state = this.TagName;
+    } else if (next == '/') {
+      this.setText();
+      if (isLetter(this.data[++this.i + 1])) {
+        this.start = this.i + 1;
+        this.state = this.EndTag;
+      } else this.Comment();
+    } else if (next == '!' || next == '?') {
+      this.setText();
+      this.Comment();
+    }
+  }
+}
+MpHtmlParser.prototype.Comment = function () {
+  var key;
+  if (this.data.substring(this.i + 2, this.i + 4) == '--') key = '-->';
+  else if (this.data.substring(this.i + 2, this.i + 9) == '[CDATA[') key = ']]>';
+  else key = '>';
+  if ((this.i = this.data.indexOf(key, this.i + 2)) == -1) this.i = this.data.length;
+  else this.i += key.length - 1;
+  this.start = this.i + 1;
+  this.state = this.Text;
+}
+MpHtmlParser.prototype.TagName = function (c) {
+  if (blankChar[c]) {
+    this.tagName = this.section();
+    while (blankChar[this.data[this.i]]) this.i++;
+    if (this.isClose()) this.setNode();
+    else {
+      this.start = this.i;
+      this.state = this.AttrName;
+    }
+  } else if (this.isClose()) {
+    this.tagName = this.section();
+    this.setNode();
+  }
+}
+MpHtmlParser.prototype.AttrName = function (c) {
+  if (c == '=' || blankChar[c] || this.isClose()) {
+    this.attrName = this.section();
+    if (blankChar[c])
+      while (blankChar[this.data[++this.i]]);
+    if (this.data[this.i] == '=') {
+      while (blankChar[this.data[++this.i]]);
+      this.start = this.i--;
+      this.state = this.AttrValue;
+    } else this.setAttr();
+  }
+}
+MpHtmlParser.prototype.AttrValue = function (c) {
+  if (c == '"' || c == "'") {
+    this.start++;
+    if ((this.i = this.data.indexOf(c, this.i + 1)) == -1) return this.i = this.data.length;
+    this.attrVal = this.section();
+    this.i++;
+  } else {
+    for (; !blankChar[this.data[this.i]] && !this.isClose(); this.i++);
+    this.attrVal = this.section();
+  }
+  this.setAttr();
+}
+MpHtmlParser.prototype.EndTag = function (c) {
+  if (blankChar[c] || c == '>' || c == '/') {
+    var name = this.section().toLowerCase();
+    for (var i = this.STACK.length; i--;)
+      if (this.STACK[i].name == name) break;
+    if (i != -1) {
+      var node;
+      while ((node = this.STACK.pop()).name != name) this.popNode(node);
+      this.popNode(node);
+    } else if (name == 'p' || name == 'br')
+      this.siblings().push({
+        name,
+        attrs: {}
+      });
+    this.i = this.data.indexOf('>', this.i);
+    this.start = this.i + 1;
+    if (this.i == -1) this.i = this.data.length;
+    else this.state = this.Text;
+  }
+}
+module.exports = MpHtmlParser;

+ 63 - 0
src/parser/libs/config.js

@@ -0,0 +1,63 @@
+/* 配置文件 */
+const canIUse = wx.canIUse('editor'); // 高基础库标识,用于兼容
+module.exports = {
+  // 出错占位图
+  errorImg: null,
+  // 过滤器函数
+  filter: null,
+  // 代码高亮函数
+  highlight: null,
+  // 文本处理函数
+  onText: null,
+  // 实体编码列表
+  entities: {
+    quot: '"',
+    apos: "'",
+    semi: ';',
+    nbsp: '\xA0',
+    ndash: '–',
+    mdash: '—',
+    middot: '·',
+    lsquo: '‘',
+    rsquo: '’',
+    ldquo: '“',
+    rdquo: '”',
+    bull: '•',
+    hellip: '…'
+  },
+  blankChar: makeMap(' ,\xA0,\t,\r,\n,\f'),
+  boolAttrs: makeMap('autoplay,autostart,controls,ignore,loop,muted'),
+  // 块级标签,将被转为 div
+  blockTags: makeMap('address,article,aside,body,caption,center,cite,footer,header,html,nav,section' + (canIUse ? '' : ',pre')),
+  // 将被移除的标签
+  ignoreTags: makeMap('area,base,canvas,frame,iframe,input,link,map,meta,param,script,source,style,svg,textarea,title,track,wbr' + (canIUse ? ',rp' : '')),
+  // 只能被 rich-text 显示的标签
+  richOnlyTags: makeMap('a,colgroup,fieldset,legend,table' + (canIUse ? ',bdi,bdo,rt,ruby' : '')),
+  // 自闭合的标签
+  selfClosingTags: makeMap('area,base,br,col,circle,ellipse,embed,frame,hr,img,input,line,link,meta,param,path,polygon,rect,source,track,use,wbr'),
+  // 信任的标签
+  trustTags: makeMap('a,abbr,ad,audio,b,blockquote,br,code,col,colgroup,dd,del,dl,dt,div,em,fieldset,h1,h2,h3,h4,h5,h6,hr,i,img,ins,label,legend,li,ol,p,q,source,span,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,title,ul,video' + (canIUse ? ',bdi,bdo,caption,pre,rt,ruby' : '')),
+  // 默认的标签样式
+  userAgentStyles: {
+    address: 'font-style:italic',
+    big: 'display:inline;font-size:1.2em',
+    blockquote: 'background-color:#f6f6f6;border-left:3px solid #dbdbdb;color:#6c6c6c;padding:5px 0 5px 10px',
+    caption: 'display:table-caption;text-align:center',
+    center: 'text-align:center',
+    cite: 'font-style:italic',
+    dd: 'margin-left:40px',
+    mark: 'background-color:yellow',
+    pre: 'font-family:monospace;white-space:pre;overflow:scroll',
+    s: 'text-decoration:line-through',
+    small: 'display:inline;font-size:0.8em',
+    u: 'text-decoration:underline'
+  }
+}
+
+function makeMap(str) {
+  var map = Object.create(null),
+    list = str.split(',');
+  for (var i = list.length; i--;)
+    map[list[i]] = true;
+  return map;
+}

+ 213 - 0
src/parser/parser.js

@@ -0,0 +1,213 @@
+/**
+ * Parser 富文本组件
+ * @tutorial https://github.com/jin-yufeng/Parser
+ * @version 20200828
+ * @author JinYufeng
+ * @listens MIT
+ */
+var cache = {},
+  Parser = require('./libs/MpHtmlParser.js'),
+  fs = wx.getFileSystemManager && wx.getFileSystemManager();
+var dom;
+var search;
+// 计算 cache 的 key
+function hash(str) {
+  for (var i = str.length, val = 5381; i--;)
+    val += (val << 5) + str.charCodeAt(i);
+  return val;
+}
+Component({
+  options: {
+    pureDataPattern: /^[acdgtu]|W/
+  },
+  data: { 
+    nodes: [] 
+  },
+  properties: {
+    html: {
+      type: String,
+      observer(html) {
+        this.setContent(html);
+      }
+    },
+    autopause: {
+      type: Boolean,
+      value: true
+    },
+    autoscroll: Boolean,
+    autosetTitle: {
+      type: Boolean,
+      value: true
+    },
+    compress: Number,
+    domain: String,
+    lazyLoad: Boolean,
+    loadingImg: String,
+    selectable: Boolean,
+    tagStyle: Object,
+    showWithAnimation: Boolean,
+    useAnchor: Boolean,
+    useCache: Boolean
+  },
+  relations: {
+    '../parser-group/parser-group': {
+      type: 'ancestor'
+    }
+  },
+  created() {
+    // 图片数组
+    this.imgList = [];
+    this.imgList.setItem = function(i, src) {
+      if (!i || !src) return;
+      // 去重
+      if (src.indexOf('http') == 0 && this.includes(src)) {
+        var newSrc = '';
+        for (var j = 0, c; c = src[j]; j++) {
+          if (c == '/' && src[j - 1] != '/' && src[j + 1] != '/') break;
+          newSrc += Math.random() > 0.5 ? c.toUpperCase() : c;
+        }
+        newSrc += src.substr(j);
+        return this[i] = newSrc;
+      }
+      this[i] = src;
+      // 暂存 data src
+      if (src.includes('data:image')) {
+        var info = src.match(/data:image\/(\S+?);(\S+?),(.+)/);
+        if (!info) return;
+        var filePath = `${wx.env.USER_DATA_PATH}/${Date.now()}.${info[1]}`;
+        fs && fs.writeFile({
+          filePath,
+          data: info[3],
+          encoding: info[2],
+          success: () => this[i] = filePath
+        })
+      }
+    }
+    this.imgList.each = function(f) {
+      for (var i = 0, len = this.length; i < len; i++)
+        this.setItem(i, f(this[i], i, this));
+    }
+    if (dom) this.document = new dom(this);
+    if (search) this.search = args => search(this, args);
+  },
+  detached() {
+    // 删除暂存
+    this.imgList.each(src => {
+      if (src && src.includes(wx.env.USER_DATA_PATH) && fs)
+        fs.unlink({
+          filePath: src
+        })
+    })
+    clearInterval(this._timer);
+  },
+  methods: {
+    // 锚点跳转
+    in (obj) {
+      if (obj.page && obj.selector && obj.scrollTop) this._in = obj;
+    },
+    navigateTo(obj) {
+      if (!this.data.useAnchor) return obj.fail && obj.fail('Anchor is disabled');
+      var selector = (this._in ? this._in.page : this).createSelectorQuery().select((this._in ? this._in.selector : '.top') + (obj.id ? '>>>#' + obj.id : '')).boundingClientRect();
+      if (this._in) selector.select(this._in.selector).fields({
+        rect: true,
+        scrollOffset: true
+      });
+      else selector.selectViewport().scrollOffset();
+      selector.exec(res => {
+        if (!res[0]) return this.group ? this.group.navigateTo(this.i, obj) : obj.fail && obj.fail('Label not found');
+        var scrollTop = res[1].scrollTop + res[0].top - (res[1].top || 0) + (obj.offset || 0);
+        if (this._in) {
+          var data = {};
+          data[this._in.scrollTop] = scrollTop;
+          this._in.page.setData(data);
+        } else wx.pageScrollTo({
+          scrollTop
+        })
+        obj.success && obj.success();
+      })
+    },
+    // 获取文本
+    getText(ns = this.data.nodes) {
+      var txt = '';
+      for (var i = 0, n; n = ns[i++];) {
+        if (n.type == 'text') txt += n.text.replace(/&nbsp;/g, '\u00A0').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
+        else if (n.type == 'br') txt += '\n';
+        else {
+          // 块级标签前后加换行
+          var br = n.name == 'p' || n.name == 'div' || n.name == 'tr' || n.name == 'li' || (n.name[0] == 'h' && n.name[1] > '0' && n.name[1] < '7');
+          if (br && txt && txt[txt.length - 1] != '\n') txt += '\n';
+          if (n.children) txt += this.getText(n.children);
+          if (br && txt[txt.length - 1] != '\n') txt += '\n';
+          else if (n.name == 'td' || n.name == 'th') txt += '\t';
+        }
+      }
+      return txt;
+    },
+    // 获取视频 context
+    getVideoContext(id) {
+      if (!id) return this.videoContexts;
+      for (var i = this.videoContexts.length; i--;)
+        if (this.videoContexts[i].id == id) return this.videoContexts[i];
+    },
+    // 渲染富文本
+    setContent(html, append) {
+      var nodes, parser = new Parser(html, this.data);
+      // 缓存读取
+      if (this.data.useCache) {
+        var hashVal = hash(html);
+        if (cache[hashVal]) nodes = cache[hashVal];
+        else cache[hashVal] = nodes = parser.parse();
+      } else nodes = parser.parse();
+      this.triggerEvent('parse', nodes);
+      var data = {};
+      if (append)
+        for (let i = this.data.nodes.length, j = nodes.length; j--;)
+          data[`nodes[${i + j}]`] = nodes[j];
+      else data.nodes = nodes;
+      if (this.showWithAnimation) data.showAm = 'animation: show .5s';
+      this.setData(data, () => {
+        this.triggerEvent('load')
+      });
+      // 设置标题
+      if (nodes.title && this.data.autosetTitle)
+        wx.setNavigationBarTitle({
+          title: nodes.title
+        })
+      this.imgList.length = 0;
+      this.videoContexts = [];
+      var ns = this.selectAllComponents('.top,.top>>>._node');
+      for (let i = 0, n; n = ns[i++];) {
+        n.top = this;
+        for (let j = 0, item; item = n.data.nodes[j++];) {
+          if (item.c) continue;
+          // 获取图片列表
+          if (item.name == 'img')
+            this.imgList.setItem(item.attrs.i, item.attrs['original-src'] || item.attrs.src);
+          // 音视频控制
+          else if (item.name == 'video' || item.name == 'audio') {
+            var ctx;
+            if (item.name == 'video') ctx = wx.createVideoContext(item.attrs.id, n);
+            else ctx = n.selectComponent('#' + item.attrs.id);
+            if (ctx) {
+              ctx.id = item.attrs.id;
+              this.videoContexts.push(ctx);
+            }
+          }
+        }
+      }
+      var height;
+      clearInterval(this._timer);
+      this._timer = setInterval(() => {
+        this.createSelectorQuery().select('.top').boundingClientRect(res => {
+          if (!res) return;
+          this.rect = res;
+          if (res.height == height) {
+            this.triggerEvent('ready', res)
+            clearInterval(this._timer);
+          }
+          height = res.height;
+        }).exec();
+      }, 350)
+    }
+  }
+})

+ 6 - 0
src/parser/parser.json

@@ -0,0 +1,6 @@
+{
+  "component": true,
+  "usingComponents": {
+    "trees": "./trees/trees"
+  }
+}

+ 3 - 0
src/parser/parser.wxml

@@ -0,0 +1,3 @@
+<!--parser 主组件-->
+<slot wx:if="{{!nodes.length}}" />
+<trees class="top" style="{{selectable?'user-select:text;-webkit-user-select:text;':''}}{{showAm}}" lazy-load="{{lazyLoad}}" loading="{{loadingImg}}" nodes="{{nodes}}" />

+ 19 - 0
src/parser/parser.wxss

@@ -0,0 +1,19 @@
+:host {
+  display: block;
+  overflow: scroll;
+  -webkit-overflow-scrolling: touch;
+}
+
+.top {
+  display: inherit;
+}
+
+@keyframes show {
+  0% {
+    opacity: 0;
+  }
+
+  100% {
+    opacity: 1;
+  }
+}

+ 122 - 0
src/parser/trees/trees.js

@@ -0,0 +1,122 @@
+const errorImg = require('../libs/config.js').errorImg;
+Component({
+  data: {
+    canIUse: !!wx.chooseMessageFile,
+    placeholder: "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='300' height='225'/>",
+    ctrl: []
+  },
+  properties: {
+    nodes: Array,
+    lazyLoad: Boolean,
+    loading: String
+  },
+  methods: {
+    // 视频播放事件
+    play(e) {
+      this.top.group && this.top.group.pause(this.top.i);
+      if (this.top.videoContexts.length > 1 && this.top.data.autopause)
+        for (var i = this.top.videoContexts.length; i--;)
+          if (this.top.videoContexts[i].id != e.currentTarget.id)
+            this.top.videoContexts[i].pause();
+    },
+    // 图片事件
+    imgtap(e) {
+      var attrs = e.currentTarget.dataset.attrs;
+      if (!attrs.ignore) {
+        var preview = true;
+        this.top.triggerEvent('imgtap', {
+          id: e.currentTarget.id,
+          src: attrs.src,
+          ignore: () => preview = false
+        })
+        if (preview) {
+          if (this.top.group) return this.top.group.preview(this.top.i, attrs.i);
+          var urls = this.top.imgList,
+            current = urls[attrs.i] ? urls[attrs.i] : (urls = [attrs.src], attrs.src);
+          wx.previewImage({
+            current,
+            urls
+          })
+        }
+      }
+    },
+    loadImg(e) {
+      var i = e.target.dataset.i;
+      if (this.data.lazyLoad && !this.data.ctrl[i])
+        this.setData({
+          [`ctrl[${i}]`]: 1
+        })
+      else if (this.data.loading && this.data.ctrl[i] != 2)
+        this.setData({
+          [`ctrl[${i}]`]: 2
+        })
+    },
+    // 链接点击事件
+    linkpress(e) {
+      var jump = true,
+        attrs = e.currentTarget.dataset.attrs;
+      attrs.ignore = () => jump = false;
+      this.top.triggerEvent('linkpress', attrs);
+      if (jump) {
+        if (attrs['app-id'])
+          wx.navigateToMiniProgram({
+            appId: attrs['app-id'],
+            path: attrs.path
+          })
+        else if (attrs.href) {
+          if (attrs.href[0] == '#')
+            this.top.navigateTo({
+              id: attrs.href.substring(1)
+            })
+          else if (attrs.href.indexOf('http') == 0 || attrs.href.indexOf('//') == 0)
+            wx.setClipboardData({
+              data: attrs.href,
+              success: () =>
+                wx.showToast({
+                  title: '链接已复制'
+                })
+            })
+          else
+            wx.navigateTo({
+              url: attrs.href,
+              fail() {
+                wx.switchTab({
+                  url: attrs.href,
+                })
+              }
+            })
+        }
+      }
+    },
+    // 错误事件
+    error(e) {
+      var source = e.target.dataset.source,
+        i = e.target.dataset.i,
+        node = this.data.nodes[i];
+      if (source == 'video' || source == 'audio') {
+        // 加载其他 source
+        var index = (node.i || 0) + 1;
+        if (index < node.attrs.source.length)
+          return this.setData({
+            [`nodes[${i}].i`]: index
+          })
+      } else if (source == 'img' && errorImg) {
+        this.top.imgList.setItem(e.target.dataset.index, errorImg);
+        this.setData({
+          [`nodes[${i}].attrs.src`]: errorImg
+        })
+      }
+      this.top && this.top.triggerEvent('error', {
+        source,
+        target: e.target,
+        errMsg: e.detail.errMsg
+      })
+    },
+    // 加载视频
+    loadVideo(e) {
+      this.setData({
+        [`nodes[${e.target.dataset.i}].attrs.autoplay`]: true
+      })
+    }
+  }
+})

+ 6 - 0
src/parser/trees/trees.json

@@ -0,0 +1,6 @@
+{
+  "component": true,
+  "usingComponents": {
+    "trees": "./trees"
+  }
+}

+ 67 - 0
src/parser/trees/trees.wxml

@@ -0,0 +1,67 @@
+<!--trees 递归子组件-->
+<wxs module="handler">
+var inline = {
+  abbr: 1,
+  b: 1,
+  big: 1,
+  code: 1,
+  del: 1,
+  em: 1,
+  i: 1,
+  ins: 1,
+  label: 1,
+  q: 1,
+  small: 1,
+  span: 1,
+  strong: 1,
+  sub: 1,
+  sup: 1
+}
+module.exports = {
+  visited: function (e, owner) {
+    if (!e.instance.hasClass('_visited'))
+      e.instance.addClass('_visited')
+    owner.callMethod('linkpress', e)
+  },
+  use: function (item) {
+    return !item.c && !inline[item.name] && (item.attrs.style || '').indexOf('display:inline') == -1
+  }
+}
+</wxs>
+<block wx:for="{{nodes}}" wx:for-item="n" wx:for-index="i" wx:key="i">
+  <!--图片-->
+  <view wx:if="{{n.name=='img'}}" id="{{n.attrs.id}}" class="_img {{n.attrs.class}}" style="{{n.attrs.style}}" data-attrs="{{n.attrs}}" bindtap="imgtap">
+    <rich-text nodes="{{[{attrs:{src:loading&&ctrl[i]!=2?loading:(lazyLoad&&!ctrl[i]?placeholder:n.attrs.src||''),alt:n.attrs.alt||'',width:n.attrs.width||'',style:'-webkit-touch-callout:none;max-width:100%;display:block'+(n.attrs.height?';height:'+n.attrs.height:'')},name:'img'}]}}" />
+    <image class="_image" src="{{lazyLoad&&!ctrl[i]?placeholder:n.attrs.src}}" lazy-load="{{lazyLoad}}" show-menu-by-longpress="{{!n.attrs.ignore}}" data-i="{{i}}" data-index="{{n.attrs.i}}" data-source="img" bindload="loadImg" binderror="error" />
+  </view>
+  <!--文本-->
+  <text wx:elif="{{n.type=='text'}}" decode>{{n.text}}</text>
+  <text wx:elif="{{n.name=='br'}}">\n</text>
+  <!--链接-->
+  <view wx:elif="{{n.name=='a'}}" id="{{n.attrs.id}}" class="_a {{n.attrs.class}}" hover-class="_hover" style="{{n.attrs.style}}" data-attrs="{{n.attrs}}" bindtap="{{canIUse?handler.visited:'linkpress'}}">
+    <trees class="_node" nodes="{{n.children}}" />
+  </view>
+  <!--视频-->
+  <block wx:elif="{{n.name=='video'}}">
+    <view wx:if="{{n.lazyLoad&&!n.attrs.autoplay}}" id="{{n.attrs.id}}" class="_video {{n.attrs.class}}" style="{{n.attrs.style}}" data-i="{{i}}" bindtap="loadVideo" />
+    <video wx:else id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" autoplay="{{n.attrs.autoplay}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" muted="{{n.attrs.muted}}" poster="{{n.attrs.poster}}" src="{{n.attrs.source[n.i||0]}}" unit-id="{{n.attrs['unit-id']}}" data-i="{{i}}" data-source="video" binderror="error" bindplay="play" />
+  </block>
+  <!--音频-->
+  <audio wx:elif="{{n.name=='audio'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" author="{{n.attrs.author}}" autoplay="{{n.attrs.autoplay}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" name="{{n.attrs.name}}" poster="{{n.attrs.poster}}" src="{{n.attrs.source[n.i||0]}}" data-i="{{i}}" data-source="audio" binderror="error" bindplay="play" />
+  <!--广告-->
+  <ad wx:elif="{{n.name=='ad'}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" unit-id="{{n.attrs['unit-id']}}" data-source="ad" binderror="error" />
+  <!--列表-->
+  <view wx:elif="{{n.name=='li'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}};display:flex">
+    <view wx:if="{{n.type=='ol'}}" class="_ol-bef">{{n.num}}</view>
+    <view wx:else class="_ul-bef">
+      <view wx:if="{{n.floor%3==0}}" class="_ul-p1">█</view>
+      <view wx:elif="{{n.floor%3==2}}" class="_ul-p2" />
+      <view wx:else class="_ul-p1" style="border-radius:50%">█</view>
+    </view>
+    <trees class="_node _li" lazyLoad="{{lazyLoad}}" loading="{{loading}}" nodes="{{n.children}}" />
+  </view>
+  <!--富文本-->
+  <rich-text wx:elif="{{handler.use(n)}}" id="{{n.attrs.id}}" class="_p __{{n.name}}" nodes="{{[n]}}" />
+  <!--继续递归-->
+  <trees wx:else id="{{n.attrs.id}}" class="_node _{{n.name}} {{n.attrs.class}}" style="{{n.attrs.style}}" lazyLoad="{{lazyLoad}}" loading="{{loading}}" nodes="{{n.children}}" />
+</block>

+ 180 - 0
src/parser/trees/trees.wxss

@@ -0,0 +1,180 @@
+/* 在这里引入自定义样式 */
+
+/* 链接和图片效果 */
+._a {
+  display: inline;
+  padding: 1.5px 0 1.5px 0;
+  color: #366092;
+  word-break: break-all;
+}
+
+._hover {
+  text-decoration: underline;
+  opacity: 0.7;
+}
+
+._visited {
+  color: #551a8b;
+}
+
+._img {
+  display: inline-block;
+  max-width: 100%;
+  overflow: hidden;
+}
+
+/* 内部样式 */
+:host {
+  display: inline;
+}
+
+._blockquote,
+._div,
+._p,
+._ul,
+._ol,
+._li {
+  display: block;
+}
+
+._b,
+._strong {
+  font-weight: bold;
+}
+
+._code {
+  font-family: monospace;
+}
+
+._del {
+  text-decoration: line-through;
+}
+
+._em,
+._i {
+  font-style: italic;
+}
+
+._h1 {
+  font-size: 2em;
+}
+
+._h2 {
+  font-size: 1.5em;
+}
+
+._h3 {
+  font-size: 1.17em;
+}
+
+._h5 {
+  font-size: 0.83em;
+}
+
+._h6 {
+  font-size: 0.67em;
+}
+
+._h1,
+._h2,
+._h3,
+._h4,
+._h5,
+._h6 {
+  display: block;
+  font-weight: bold;
+}
+
+._image {
+  display: block;
+  width: 100%;
+  height: 360px;
+  margin-top: -360px;
+  opacity: 0;
+}
+
+._ins {
+  text-decoration: underline;
+}
+
+._li {
+  flex: 1;
+  width: 0;
+}
+
+._ol-bef {
+  width: 36px;
+  margin-right: 5px;
+  text-align: right;
+}
+
+._ul-bef {
+  margin: 0 12px 0 23px;
+  line-height: normal;
+}
+
+._ol-bef,
+._ul-bef {
+  flex: none;
+  user-select: none;
+}
+
+._ul-p1 {
+  display: inline-block;
+  width: 0.3em;
+  height: 0.3em;
+  overflow: hidden;
+  line-height: 0.3em;
+}
+
+._ul-p2 {
+  display: inline-block;
+  width: 0.23em;
+  height: 0.23em;
+  border: 0.05em solid black;
+  border-radius: 50%;
+}
+
+._q::before {
+  content: '"';
+}
+
+._q::after {
+  content: '"';
+}
+
+._sub {
+  font-size: smaller;
+  vertical-align: sub;
+}
+
+._sup {
+  font-size: smaller;
+  vertical-align: super;
+}
+
+.__bdi,
+.__bdo,
+.__ruby,
+.__rt {
+  display: inline-block;
+}
+
+._video {
+  position: relative;
+  display: inline-block;
+  width: 300px;
+  height: 225px;
+  background-color: black;
+}
+
+._video::after {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  margin: -15px 0 0 -15px;
+  content: '';
+  border-color: transparent transparent transparent white;
+  border-style: solid;
+  border-width: 15px 0 15px 30px;
+}