给 wangEditor 加一个可配置的「内容框体」

一、需求缘起

写文章时,经常想在正文里放一个提示框或小结卡片,把一段话单独“框”起来。希望它的外观可以随内容调整,具体是四项:

框体可配置项

背景色:框体底色

框体颜色:框内文字颜色

边框颜色:外描边颜色

框体宽度:百分比或像素

站点用的是 wangEditor V5,工具栏里并没有现成的“框体”。好在它提供了扩展能力,于是动手注册了一个自定义元素。

二、实现原理:三件套 + 一个菜单

wangEditor 通过 Boot.registerModule() 注册自定义元素。一个元素要在“编辑区显示、导出 HTML、再把 HTML 导回编辑”三个环节都正确,需要提供三个数组:

  1. renderElems:节点在编辑区如何渲染;
  2. elemsToHtml:节点如何序列化为 HTML;
  3. parseElemsHtml:已有 HTML 如何解析回节点。

工具栏按钮则通过 Boot.registerMenu() 单独注册,在它的 exec 里打开配置弹窗。

三、两个容易踩的坑

这部分最花时间,先记下来:

  1. 数组名是 renderElems / elemsToHtml / parseElemsHtml,网上一些旧文写的 customElems 在这个版本里并不存在。
  2. registerModule 是直接从模块顶层读取这些数组的。我最初把它们嵌套在 editor: {} 下,结果注册“静默失败”、框体毫无反应。

下面是各部分的实际代码。

四、定义元素:编辑区渲染

// 编辑器内渲染:把自定义节点渲染成带样式的 div
function renderContentBox(elem, children) {
    var bg = elem.bgColor  || BOX_DEFAULTS.bgColor;
    var tc = elem.textColor || BOX_DEFAULTS.textColor;
    var bc = elem.borderColor || BOX_DEFAULTS.borderColor;
    var w  = elem.width || BOX_DEFAULTS.width;
    return {
        sel: 'div',
        data: {
            attrs: { 'data-w-e-type': 'contentBox' },
            style: {
                boxSizing: 'border-box',
                backgroundColor: bg,
                color: tc,
                border: '1px solid ' + bc,
                width: w,
                maxWidth: '100%',
                margin: '14px auto',
                borderRadius: '12px',
                padding: '22px 26px',
                textAlign: 'center'
            }
        },
        children: children
    };
}

五、序列化为 HTML

// 序列化为 HTML:内联样式,保证前台 / 换主题也能显示
function contentBoxToHtml(elem, childrenHtml) {
    var bg = elem.bgColor  || BOX_DEFAULTS.bgColor;
    var tc = elem.textColor || BOX_DEFAULTS.textColor;
    var bc = elem.borderColor || BOX_DEFAULTS.borderColor;
    var w  = elem.width || BOX_DEFAULTS.width;
    var style = 'box-sizing:border-box;background-color:' + bg + ';color:' + tc +
        ';border:1px solid ' + bc + ';width:' + w + ';max-width:100%;margin:18px auto;' +
        'border-radius:12px;padding:22px 26px;text-align:center;';
    return '' + childrenHtml + '';
}

六、把 HTML 解析回编辑器

// 解析已有 HTML:编辑旧文 / 源代码模式切回时,还原成自定义节点
function parseContentBoxHtml(domElem, children) {
    var raw = domElem.getAttribute('style') || '';
    var s = ';' + raw.replace(/\s+/g, '');
    function pick(prop) {
        var re = new RegExp(';' + prop + ':([^;]+)', 'i');
        var m = s.match(re);
        return m ? m[1].trim() : '';
    }
    var bm = s.match(/border:[^;]*solid([^;]+)/i);
    return {
        type: 'box',
        children: children,
        bgColor: pick('background-color') || BOX_DEFAULTS.bgColor,
        textColor: pick('color') || BOX_DEFAULTS.textColor,
        borderColor: bm ? bm[1].trim() : BOX_DEFAULTS.borderColor,
        width: pick('width') || BOX_DEFAULTS.width
    };
}

七、注册模块与工具栏菜单

if (Boot) {
    // 注意:三个数组必须写在模块“顶层”,不能再包一层 editor: {}
    Boot.registerModule({
        renderElems: [
            { type: 'box', renderElem: renderContentBox }
        ],
        elemsToHtml: [
            { type: 'box', elemToHtml: contentBoxToHtml }
        ],
        parseElemsHtml: [
            { selector: 'div.content-box', parseElemHtml: parseContentBoxHtml }
        ]
    });

    // 工具栏菜单
    function ContentBoxMenu() {
        this.title = '插入内容框体';
        this.iconSvg = BOX_ICON_SVG;
        this.tag = 'button';
    }
    ContentBoxMenu.prototype.getValue  = function () { return ''; };
    ContentBoxMenu.prototype.isActive  = function () { return false; };
    ContentBoxMenu.prototype.isDisabled = function () { return false; };
    ContentBoxMenu.prototype.exec = function (editor) {
        if (typeof window.__openContentBoxModal === 'function') {
            window.__openContentBoxModal(editor);   // 打开配置弹窗
        }
    };
    Boot.registerMenu({
        key: 'contentBox',
        factory: function () { return new ContentBoxMenu(); }
    });
}

八、配置弹窗

弹窗里三个颜色字段,每个都由“取色器 + 十六进制输入 + 预设色块”组成;宽度支持百分比 / 像素两种单位,配滑块、数值和快捷预设,下方是实时预览。

光标若已在某个框体内,弹窗自动变为“编辑内容框体”并读回当前四项;否则按默认值插入新框。实时预览的刷新逻辑很简单:

// 任一项变化时刷新预览
function refresh() {
    preview.style.backgroundColor = st.bgColor;
    preview.style.color = st.textColor;
    preview.style.border = '1px solid ' + st.borderColor;
    preview.style.width = widthStr();   // 百分比 / 像素
}

点击确定时,区分“更新已有框”和“插入新框”两种情况:

okBtn.addEventListener('click', function () {
    var props = {
        bgColor: st.bgColor, textColor: st.textColor,
        borderColor: st.borderColor, width: widthStr()
    };
    if (st.mode === 'edit' && curRef) {
        // 光标在已有框体内:原地更新
        activeEditor.setNodes(props, { at: curRef.path });
    } else {
        // 否则插入一个含空段落的新框体
        var box = { type: 'box',
                    children: [ { type: 'paragraph', children: [ { text: '' } ] } ] };
        Object.assign(box, props);
        activeEditor.insertNode(box);
        activeEditor.focus();
    }
    close();
});

九、前台样式

框体的颜色和宽度都以内联样式随正文保存,本身即可移植、换主题也不丢。前台 CSS 主要做两件事:把框内段落的首行缩进清零(正文段落默认缩进两字符),再补一层轻投影。

/* 前台:框体已带内联样式,这里主要重置段落首行缩进、加轻投影 */
.article-content .content-box,
.page-content .content-box {
    box-sizing: border-box;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
    text-indent: 0;
}
.article-content .content-box p,
.page-content .content-box p {
    text-indent: 0;
    margin-bottom: 12px;
}
.article-content .content-box p:last-child,
.page-content .content-box p:last-child {
    margin-bottom: 0;
}

十、实际效果

下面两个框体就是用该功能生成的,一个默认样式、一个自定义了颜色与宽度:

这是默认样式的内容框体,文字居中。

适合放小结、提示,或一段想被强调的话。

自定义底色、边框与宽度后的效果。

宽度 80%,整体更暖一些。

十一、使用方式

  1. 编辑文章时,点工具栏的“框体”图标;
  2. 在弹窗里设置背景、文字、边框颜色与宽度,可实时预览;
  3. 确定即插入;光标放在已有框体内再点按钮,则是编辑该框。

一个可配置的小元素,本质上就是把“渲染、序列化、解析”三条链路补齐,再用一个菜单把配置弹窗串起来。理清这三个数组的位置之后,剩下的都是界面功夫了。

发表评论