You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

能否创建仅含单个容器的CKEditor 5 Widget?

CKEditor5 单容器结构转Widget解决方案

问题背景

需要将以下单容器HTML转成CKEditor5 Widget,要求保留Widget自带的「前后添加空行」按钮和黄色边框:

<div class="widget-tip">
  <p>
    Tip:<br>
    lorem ipsum
  </p>
</div>

目前已知只有外层widget-tip容器调用toWidget(),同时内层新增容器调用toWidgetEditable()才能实现需求,但现有系统数据库里已有大量无内层容器的旧模板,不想修改数据库结构,尝试自动添加内层容器失败,现有插件代码如下:

import { Plugin } from 'ckeditor5/src/core';
import { Widget, toWidget, toWidgetEditable } from 'ckeditor5/src/widget';

export default class WidgetTip extends Plugin {
  static get requires() {
    return [Widget];
  }

  init() {
    this._defineSchema();
    this._defineConverters();
  }

  _defineSchema() {
    const schema = this.editor.model.schema;

    schema.register('widgetTip', {
      inheritAllFrom: '$blockObject'
    });

    schema.register('widgetTipInner', {
      isLimit: true,
      allowIn: 'widgetTip',
      allowContentOf: '$root'
    });

  }

  _defineConverters() {
    const conversion = this.editor.conversion;

    // <widgetTip> converters
    conversion.for('upcast').elementToElement({
      model: 'widgetTip',
      view: {
        name: 'div',
        classes: 'widget-tip'
      }
    });
    conversion.for('dataDowncast').elementToElement({
      model: 'widgetTip',
      view: {
        name: 'div',
        classes: 'widget-tip'
      }
    });
    conversion.for('editingDowncast').elementToElement({
      model: 'widgetTip',
      view: (modelElement, { writer: viewWriter }) => {
        const div = viewWriter.createContainerElement('div', {
          class: 'widget-tip'
        });

        return toWidget(div, viewWriter, { label: 'simple widgetTip widget' });
      }
    });

    // <widgetTipInner> converters
    conversion.for('upcast').elementToElement({
      model: 'widgetTipInner',
      view: {
        name: 'div',
        classes: 'widget-tip__inner'
      }
    });
    conversion.for('dataDowncast').elementToElement({
      model: 'widgetTipInner',
      view: {
        name: 'div',
        classes: 'widget-tip__inner'
      }
    });
    conversion.for('editingDowncast').elementToElement({
      model: 'widgetTipInner',
      view: (modelElement, { writer: viewWriter }) => {
        const div = viewWriter.createEditableElement('div', {
          class: 'widget-tip__inner'
        });

        return toWidgetEditable(div, viewWriter);
      }
    });
  }
}

解决方案

核心思路是:编辑器内部使用双层结构(外层Widget+内层Editable)以保留交互功能,但数据上下转换时自动处理单/双层结构的映射,无需修改数据库。具体修改如下:

1. 调整Schema定义

确保widgetTip只能包含widgetTipInner,避免结构混乱:

_defineSchema() {
  const schema = this.editor.model.schema;

  schema.register('widgetTip', {
    inheritAllFrom: '$blockObject',
    // 限制只能包含widgetTipInner
    allowChildren: 'widgetTipInner'
  });

  schema.register('widgetTipInner', {
    isLimit: true,
    allowIn: 'widgetTip',
    allowContentOf: '$root'
  });
}

2. 修改转换逻辑

重点处理**upcast(HTML转Model)和data downcast(Model转存储用HTML)**的逻辑:

  • Upcast时,自动将单容器内的所有内容包裹到widgetTipInner节点中
  • Data downcast时,将Model的双层结构还原为单容器结构,保证存储的HTML和旧数据一致
  • Editing downcast保持双层结构,确保编辑器内的Widget交互正常

修改后的完整_defineConverters方法:

_defineConverters() {
  const conversion = this.editor.conversion;

  // --- Upcast: 单容器HTML转Model双层结构 ---
  conversion.for('upcast').elementToStructure({
    view: {
      name: 'div',
      classes: 'widget-tip'
    },
    model: (viewElement, { writer: modelWriter }) => {
      // 创建外层widgetTip节点
      const widgetTip = modelWriter.createElement('widgetTip');
      // 创建内层widgetTipInner节点
      const inner = modelWriter.createElement('widgetTipInner');
      modelWriter.append(inner, widgetTip);

      // 将原单容器内的所有子节点迁移到inner中
      for (const child of viewElement.getChildren()) {
        const modelChild = conversion.convertItem(child);
        if (modelChild) {
          modelWriter.append(modelChild, inner);
        }
      }

      return widgetTip;
    }
  });

  // --- Data Downcast: Model双层结构转单容器HTML(保持存储结构不变) ---
  conversion.for('dataDowncast').elementToElement({
    model: 'widgetTip',
    view: (modelElement, { writer: viewWriter, conversion }) => {
      const outerDiv = viewWriter.createContainerElement('div', { class: 'widget-tip' });
      // 获取内层inner节点
      const innerModel = modelElement.getChild(0);
      // 将inner内的内容直接转换到外层div中
      conversion.convertChildren(innerModel, outerDiv);
      return outerDiv;
    }
  });

  // --- Editing Downcast: Model双层结构转编辑器内的Widget结构 ---
  conversion.for('editingDowncast').elementToElement({
    model: 'widgetTip',
    view: (modelElement, { writer: viewWriter }) => {
      const outerDiv = viewWriter.createContainerElement('div', { class: 'widget-tip' });
      return toWidget(outerDiv, viewWriter, { label: '提示框Widget' });
    }
  });

  // --- widgetTipInner的转换 ---
  // Upcast不需要单独处理(因为单容器的upcast已经创建了inner)
  // Data Downcast不需要生成inner节点(直接合并到外层)
  conversion.for('editingDowncast').elementToElement({
    model: 'widgetTipInner',
    view: (modelElement, { writer: viewWriter }) => {
      const innerDiv = viewWriter.createEditableElement('div', { class: 'widget-tip__inner' });
      return toWidgetEditable(innerDiv, viewWriter);
    }
  });
}

关键说明

  • Upcast阶段:通过elementToStructure手动处理节点,把原单容器的内容全部转移到widgetTipInner里,自动补全Model的双层结构
  • Data Downcast阶段:跳过widgetTipInner节点,直接把内层内容输出到外层widget-tip容器中,确保存储的HTML和旧数据完全一致
  • Editing Downcast阶段:保持双层结构,外层是Widget(带边框和添加空行按钮),内层是可编辑区域,保证编辑器内的交互体验

内容的提问来源于stack exchange,提问作者Hannes Kirsman

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.27 16:05:15