本教程将指导您在 CKEditor 4 中添加一个自定义插件,允许用户插入和编辑 DOT 语言定义的流程图。该插件会在编辑器中显示图表的 SVG 预览,并在博客详情页中进行实际渲染。本方案无需特殊后端逻辑来处理 DOT 源码的存储和转义,完全依靠前端 JavaScript 进行编码/解码。

最终效果预览

  • 在 CKEditor 编辑器中,您将看到渲染后的流程图,而不是文本占位符。
  • 您可以点击工具栏按钮插入新图表,并提供默认示例。
  • 您可以点击编辑器中的图表或右键(如果配置了右键菜单),再次打开对话框进行编辑。
  • 博客详情页(前端)也能正常显示渲染后的流程图。

前提条件

  • CKEditor 4 已安装并集成到您的项目中。
  • Viz.js 库 (viz.jsfull.render.js) 已引入。
  • 您有 Go Web 后端项目,并使用 HTML 模板。

步骤一:引入 Viz.js 库

Viz.js 是一个将 DOT 语言渲染为 SVG 的 JavaScript 库。您可以通过 CDN 引入它。

在您的 adminpanel\templates\write_blog.htmltemplates\detail.html<head><body> 顶部添加:

<!-- DOT 语言渲染库 Viz.js -->
<script src="https://unpkg.com/viz.js@2.1.2/viz.js"></script>
<script src="https://unpkg.com/viz.js@2.1.2/full.render.js"></script>

步骤二:创建 CKEditor DOT 插件目录结构

在您的 CKEditor 插件目录 (static\ckeditor\ckeditor\plugins\) 下,创建一个名为 dotblock 的新文件夹。

最终结构应如下:

static/
└── ckeditor/
    └── ckeditor/
        └── plugins/
            └── dotblock/
                ├── dialogs/
                │   └── dotblock.js  <-- 对话框定义
                ├── icons/
                │   └── dotblock.png <-- 插件按钮图标 (可选)
                └── plugin.js            <-- 插件主文件

步骤三:编写 dialogs/dotblock.js (对话框定义)

创建或修改 static\ckeditor\ckeditor\plugins\dotblock\dialogs\dotblock.js 文件,内容如下。

这个文件定义了插入/编辑 DOT 图的对话框,并包含默认示例和对渲染函数的调用。

// D:\Nextcloud\go\blog\static\ckeditor\ckeditor\plugins\dotblock\dialogs\dotblock.js

CKEDITOR.dialog.add('dotblock', function (editor) {
    // 定义一个默认的 DOT 源码案例
    var defaultDotExample = `digraph G {
  rankdir=LR; // 从左到右布局
  node [shape=box]; // 节点形状为方框

  Start [label="开始"];
  Process1 [label="处理数据"];
  Process2 [label="计算结果"];
  End [label="结束"];

  Start -> Process1;
  Process1 -> Process2 [label="成功"];
  Process1 -> End [label="失败", color=red];
  Process2 -> End;
}`;

    // 辅助函数:可靠地 HTML 解码 (用于兼容旧数据,如果旧数据仍然以 data-dot 形式存在)
    function decodeHtmlEntitiesRobustly(text) {
        if (!text) return '';
        var tempDiv = document.createElement('textarea');
        let decodedText = text;
        let prevDecodedText = '';

        while (decodedText !== prevDecodedText) {
            prevDecodedText = decodedText;
            tempDiv.innerHTML = decodedText;
            decodedText = tempDiv.value;
        }
        return decodedText;
    }

    return {
        title: '插入 DOT 图',
        minWidth: 400,
        minHeight: 200,
        contents: [
            {
                id: 'tab-dot',
                label: 'DOT 源码',
                elements: [
                    {
                        type: 'textarea',
                        id: 'dotInput',
                        label: 'DOT 流程图代码',
                        rows: 10,
                        validate: CKEDITOR.dialog.validate.notEmpty("DOT 源码不能为空"),
                        // **********************************************
                        // 修正:在 setup 方法中统一处理默认值和加载旧值
                        // **********************************************
                        setup: function(apiWidget) { // apiWidget 是对话框的 API,包含 onShow 中传递的数据
                            var dialog = this.getDialog(); // 获取对话框实例

                            // 优先从 onShow 传递的选中元素获取数据
                            var dotBlock = dialog._.selectedElement;
                            let dotCodeToLoad = '';

                            if (dotBlock) { // 编辑模式
                                console.log('Dot Dialog Field Setup: Loading existing DOT for editing from:', dotBlock.$);
                                // 1. 优先从隐藏的 textarea.dot-code-storage 获取
                                var hiddenTextarea = dotBlock.$.querySelector('textarea.dot-code-storage');
                                if (hiddenTextarea) {
                                    dotCodeToLoad = hiddenTextarea.value;
                                } else if (dotBlock.getAttribute('data-dot')) { // 2. 兼容旧数据,从 data-dot 获取
                                    console.warn('Dot Dialog Field Setup: Fallback to data-dot attribute for old content.');
                                    dotCodeToLoad = decodeHtmlEntitiesRobustly(dotBlock.getAttribute('data-dot'));
                                } else {
                                    console.warn('Dot Dialog Field Setup: Could not find DOT code in existing block. Using default.');
                                    dotCodeToLoad = defaultDotExample; // 如果现有块没有代码,给个默认值
                                }
                            } else { // 插入模式
                                console.log('Dot Dialog Field Setup: Setting default example for new insert.');
                                dotCodeToLoad = defaultDotExample;
                            }
                            this.setValue(dotCodeToLoad); // 设置文本框的值
                        },
                        commit: function(element) { /* 由 onOk 统一处理 */ } // commit 保持不变,因为 onOk 负责保存
                    }
                ]
            }
        ],
        // **********************************************
        // 修正:onShow 仅负责识别模式和传递 selectedElement
        // 不再直接设置值,因为 setup 会处理
        // **********************************************
        onShow: function() {
            var dialog = this;
            var selection = editor.getSelection();
            var element = selection.getSelectedElement(); 
            var dotBlock = null; 

            if (element && element.hasClass('dot-placeholder')) {
                dotBlock = element;
            } else if (element) { 
                var ancestorDiv = element.getAscendant('div', true);
                if (ancestorDiv && ancestorDiv.hasClass('dot-placeholder')) {
                    dotBlock = ancestorDiv;
                }
            }

            if (dotBlock) {
                dialog._.selectedElement = dotBlock; // 存储对该元素的引用
            } else {
                delete dialog._.selectedElement; // 清除引用,确保下次是全新插入
            }
            // setupContent 会触发字段的 setup 方法
            dialog.setupContent(this._.selectedElement); // 传递选中的元素给字段的 setup
        },
        onOk: function () {
            var dialog = this;
            var rawDot = dialog.getValueOf('tab-dot', 'dotInput');
            console.log('Dot Dialog: Committing content with raw DOT:', rawDot); 
            
            var elementToModify = this._.selectedElement; 

            if (elementToModify && elementToModify.hasClass('dot-placeholder')) {
                console.log('Dot Dialog: Updating existing placeholder.');
                
                // 首先,设置占位符的 HTML 内容,确保隐藏 textarea 是其一部分
                // 这样做可以确保无论原来有没有,现在都会有一个新的(或替换旧的)textarea
                elementToModify.setHtml('DOT 流程图占位符 (点击编辑或预览)' + '<textarea class="dot-code-storage" style="display:none;"></textarea>');
                
                // 然后,立即获取新设置的(或已存在的)隐藏 textarea 的引用
                // 必须在 setHtml 之后重新获取,以确保引用是正确的
                var hiddenTextarea = elementToModify.$.querySelector('textarea.dot-code-storage');
                
                if (hiddenTextarea) {
                    hiddenTextarea.value = rawDot; 
                    console.log('Dot Dialog: Successfully set textarea value for updated placeholder.');
                } else {
                    console.error('Dot Dialog: Failed to find hidden textarea after updating placeholder HTML.');
                    // 理论上这里不应该发生,因为我们刚刚 setHtml 进去了
                }
                
                // 确保样式和属性一致
                elementToModify.setAttributes({ 
                    'style': 'width:600px;height:400px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #888; background:#f9f9f9;',
                    'data-width': '600px', 
                    // 'data-height': '400px'
                });
                elementToModify.removeAttribute('data-dot'); // 移除旧的 data-dot 属性,以 textarea 为准

            } else {
                console.log('Dot Dialog: Inserting new placeholder.');
                
                var newPlaceholderElement = new CKEDITOR.dom.element('div');
                newPlaceholderElement.setAttribute('class', 'dot-placeholder');
                newPlaceholderElement.setAttribute('contenteditable', 'false');
                newPlaceholderElement.setAttribute('data-width', '600px'); 
                // newPlaceholderElement.setAttribute('data-height', '400px'); 
                newPlaceholderElement.setAttribute('style', 'width:600px;  display: flex; align-items: center; justify-content: center; font-size: 14px; color: #888; background:#f9f9f9;');
                // 直接在创建时添加隐藏的 textarea
                newPlaceholderElement.setHtml('DOT 流程图占位符 (点击编辑或预览)' + '<textarea class="dot-code-storage" style="display:none;"></textarea>');
                
                editor.insertElement(newPlaceholderElement);

                // 在插入元素并设置其 HTML 后,立即获取内部的 textarea 引用
                var hiddenTextarea = newPlaceholderElement.$.querySelector('textarea.dot-code-storage');
                if (hiddenTextarea) {
                    hiddenTextarea.value = rawDot; 
                    console.log('Dot Dialog: Successfully set textarea value for new placeholder.');
                } else {
                    console.error('Dot Dialog: Newly inserted placeholder is missing hidden textarea.');
                }
            }

            // 重新渲染编辑器中的所有 DOT 图
            if (typeof renderDotInEditor === 'function') {
                renderDotInEditor(editor); 
            } else {
                console.warn('renderDotInEditor 函数未定义或不可用。');
            }
        }
    };
});

步骤四:编写 plugin.js (插件主文件)

创建或修改 static\ckeditor\ckeditor\plugins\dotblock\plugin.js 文件,内容如下。

这个文件负责注册 dotblock 插件本身、定义命令和添加工具栏按钮。

CKEDITOR.plugins.add('dotblock', {
  requires: 'dialog',
  icons: 'dotblock',
  init: function (editor) {
      var pluginName = 'dotblock';
      CKEDITOR.dialog.add(pluginName, this.path + 'dialogs/dotblock.js');

      editor.addCommand(pluginName, new CKEDITOR.dialogCommand(pluginName));

      editor.ui.addButton('dotblock', {
          label: '插入 Dot 图',
          command: pluginName,
          toolbar: 'insert', // 你可以自定义工具栏
          icon: this.path + 'Graphviz.svg'
      });
  }
});

注意: 确保 icons 目录下的图标文件路径和名称与 plugin.jsicon 属性所指的完全一致(例如 Graphviz.svgdotblock.png)。

步骤五:在 write_blog.html 中配置 CKEditor

在您的管理面板的博客写作页面 adminpanel\templates\write_blog.html 中,找到 CKEditor 的初始化代码。

  1. 定义 renderDotInEditor 函数: 在您的 write_blog.html<script> 标签内,$(document).ready 之前(或任何能够被 CKEditor 访问到的全局范围),添加 renderDotInEditor 函数。

 


    <!-- 渲染dot -->
    <script src="https://unpkg.com/viz.js@2.1.2/viz.js"></script>
    <script src="https://unpkg.com/viz.js@2.1.2/full.render.js"></script>
    
<!-- D:\Nextcloud\go\blog\adminpanel\templates\write_blog.html -->
<script>
    // --- 辅助函数:可靠地 HTML 解码 (用于兼容旧数据和通用解码) ---
    // 放在全局作用域,确保所有函数都能访问
    function decodeHtmlEntitiesRobustly(text) {
        if (!text) return '';
        var tempDiv = document.createElement('textarea');
        let decodedText = text;
        let prevDecodedText = '';

        while (decodedText !== prevDecodedText) {
            prevDecodedText = decodedText;
            tempDiv.innerHTML = decodedText;
            decodedText = tempDiv.value;
        }
        return decodedText;
    }
    
    // --- DOT 渲染函数 (编辑器内) ---
    // 放在全局作用域
    function renderDotInEditor(editorInstance) {
      if (!editorInstance || !editorInstance.editable || editorInstance.mode !== 'wysiwyg') {
          console.log('DOT 渲染跳过:编辑器未就绪或不在可视化模式。');
          return;
      }
    
      const editable = editorInstance.editable().$; 
      const placeholders = editable.querySelectorAll('.dot-placeholder');
      console.log('renderDotInEditor: Found ' + placeholders.length + ' .dot-placeholder elements.');
    
      placeholders.forEach(el => {
        // 已有 SVG 或错误提示则跳过,但需绑定双击事件
        if (el.querySelector('svg') || el.querySelector('pre[style*="color:red"]')) {
          // 确保对于已渲染的图表,双击事件仍然可用
          if (!el.hasAttribute('data-dot-editor-bound')) { // 避免重复绑定
              el.ondblclick = function(event) {
                  event.preventDefault(); 
                  editorInstance.getSelection().selectElement(new CKEDITOR.dom.element(el)); 
                  editorInstance.execCommand('dotblock'); 
              };
              el.setAttribute('data-dot-editor-bound', 'true'); // 标记已绑定
          }
          return;
        }
    
        // 强制居中容器样式(如果需要在 JS 中控制)
        el.style.display       = 'flex';
        el.style.justifyContent= 'center';
        el.style.alignItems    = 'center';
        if (el.getAttribute('data-height')) { el.style.height = el.getAttribute('data-height'); }
        el.style.width = el.getAttribute('data-width') || '100%';
        el.style.margin = '0 auto'; 

        // 关键:优先从隐藏 textarea 获取 DOT 源码,其次从 data-dot 获取 (兼容旧数据)
        const hiddenTextarea = el.querySelector('textarea.dot-code-storage');
        let dotCode = '';
        if (hiddenTextarea) {
            dotCode = hiddenTextarea.value;
        } else {
            dotCode = el.getAttribute('data-dot') || '';
            if (dotCode) {
                dotCode = decodeHtmlEntitiesRobustly(dotCode);
            }
        }

        if (!dotCode) {
            console.warn('DOT 渲染跳过:没有找到 DOT 源码。');
            el.innerHTML = '<pre style="color:red;">DOT 源码缺失或无法获取!</pre>';
            // 错误状态下也绑定双击事件,允许用户编辑
            if (!el.hasAttribute('data-dot-editor-bound')) { // 避免重复绑定
            el.ondblclick = function(event) {
                event.preventDefault(); 
                editorInstance.getSelection().selectElement(new CKEDITOR.dom.element(el)); 
                editorInstance.execCommand('dotblock'); 
            };
            el.setAttribute('data-dot-editor-bound', 'true'); // 标记已绑定
        }
            return;
        }

        console.log('DOT 准备渲染。获取代码:', dotCode); 
        
        try {
          var viz = new Viz();
          viz.renderSVGElement(dotCode).then(function(svg){
            el.innerHTML = '';
            svg.style.maxWidth = '100%';
            svg.style.height   = 'auto';
            svg.style.border = 'none'; svg.style.outline = 'none'; svg.style.boxShadow = 'none';
            el.appendChild(svg);
            console.log('DOT 图表渲染成功。');

            // 在渲染成功后,绑定双击事件监听器
            if (!el.hasAttribute('data-dot-editor-bound')) { 
                el.ondblclick = function(event) {
                    event.preventDefault(); 
                    editorInstance.getSelection().selectElement(new CKEDITOR.dom.element(el)); 
                    editorInstance.execCommand('dotblock');
                };
                el.setAttribute('data-dot-editor-bound', 'true');
            }

          }).catch(function(err){
            console.error('DOT 渲染 Viz.js 错误:', err, '原始DOT代码:', dotCode);
            el.innerHTML = '<pre style="color:red;">渲染错误:' + CKEDITOR.tools.htmlEncode(err.message || String(err)) + '</pre>';
            el.style.display = 'flex'; el.style.justifyContent = 'center'; el.style.alignItems = 'center'; el.style.width = '100%'; el.style.margin = '0 auto';
            
            // 错误状态下也绑定双击事件,允许用户编辑
            if (!el.hasAttribute('data-dot-editor-bound')) {
                el.ondblclick = function(event) {
                    event.preventDefault(); 
                    editorInstance.getSelection().selectElement(new CKEDITOR.dom.element(el)); 
                    editorInstance.execCommand('dotblock');
                };
                el.setAttribute('data-dot-editor-bound', 'true');
            }

          });
        } catch (e){
          console.error('DOT 渲染初始化错误:', e, '原始DOT代码:', dotCode);
          el.innerHTML = '<pre style="color:red;">初始化失败:' + CKEDITOR.tools.htmlEncode(e.message || String(e)) + '</pre>';
          el.style.display = 'flex'; el.style.justifyContent = 'center'; el.style.alignItems = 'center'; el.style.width = '100%'; el.style.margin = '0 auto';

          // 错误状态下也绑定双击事件,允许用户编辑
          if (!el.hasAttribute('data-dot-editor-bound')) {
              el.ondblclick = function(event) {
                  event.preventDefault(); 
                  editorInstance.getSelection().selectElement(new CKEDITOR.dom.element(el)); 
                  editorInstance.execCommand('dotblock');
              };
              el.setAttribute('data-dot-editor-bound', 'true');
          }
        }
      });
    }

    // 如需监听内容变化,也可订阅 change 事件
    </script>
  1. 配置 CKEditor 实例:$(document).ready() 块内找到您的 CKEDITOR.replace() 调用,并修改 extraPluginstoolbar_Custom

<script>
    if (document.getElementById('id_content')) {
    CKEDITOR.replace('id_content', {

        toolbar_Custom: [ 
            
            { name: 'customplugins', items: [ 'dotblock'] } 
        ],
        // 额外插件
        extraPlugins: 'dotblock', 
       
    });
} else {
}
    </script>

步骤六:配置 templates\detail.html (前端显示)

确保您的 detail.html 文件正确渲染 DOT 图表,并使用与编辑器中相同的解码逻辑。

<!-- dot -->
<script src="https://unpkg.com/viz.js@2.1.2/viz.js"></script>
<script src="https://unpkg.com/viz.js@2.1.2/full.render.js"></script>
<script>
    window.renderDotPlaceholders = function() {
        document.querySelectorAll('.dot-placeholder').forEach(function(el) {
            var dotCodeEncoded = el.getAttribute('data-dot'); // 获取 HTML 编码后的 DOT 源码
            if (!dotCodeEncoded) return;
    
            // **********************************************
            // 关键修正:强制进行多层 HTML 解码
            // **********************************************
            function decodeHtmlEntities(text) {
                var textArea = document.createElement('textarea');
                textArea.innerHTML = text;
                return textArea.value;
            }
    
            let dotCode = dotCodeEncoded;
            let prevDotCode = '';
    
            // 循环解码,直到字符串不再变化,以应对多层编码
            while (dotCode !== prevDotCode) {
                prevDotCode = dotCode;
                dotCode = decodeHtmlEntities(dotCode);
            }
            
            console.log('DOT Frontend 解码完成。解码后代码:', dotCode); // 调试日志
    
            try {
                var viz = new Viz();
                viz.renderSVGElement(dotCode) // 传递解码后的 DOT 源码
                    .then(function(svg) {
                        el.innerHTML = '';
                        el.appendChild(svg);
                    })
                    .catch(function(err) {
                        el.innerHTML = '<pre style="color:red;">错误:' + err.message + '</pre>';
                    });
            } catch (e) {
                el.innerHTML = '<pre style="color:red;">渲染失败:' + e.message + '</pre>';
            }
        });
    };
    
    // 调用一次(你也可以在 CKEditor 内容变更时再次触发)
    setTimeout(renderDotPlaceholders, 1000);
    </script>

步骤七:可选:准备图标文件

static\ckeditor\ckeditor\plugins\dotblock\icons\ 目录下,放置一个 Graphviz.svg(或 dotblock.png)作为工具栏按钮图标。

部署与测试

  1. 保存所有修改过的文件dialogs/dotblock.jsplugin.jswrite_blog.htmldetail.html
  2. 清除浏览器缓存 (Ctrl+Shift+R 或 Shift+F5 强制刷新)。
  3. 重新启动您的 Go 应用程序(如果需要)。
  4. 打开浏览器开发者工具 (F12),切换到 Console (控制台) 选项卡。

现在,请你执行以下操作并验证功能:

  1. 打开 adminpanel/templates/write_blog.html 页面:
    • 检查控制台,确保没有 editor-element-conflict 错误
    • 在 CKEditor 工具栏上,找到并点击 DOT 图标按钮。对话框应该弹出,并显示默认示例。
    • 点击确定插入图表。图表预览应该会显示在编辑器中。
    • 多次点击保存按钮 (不发布),然后刷新页面,重新编辑该文章。验证图表是否仍然正常显示,箭头是否没有乱码。
    • 保存并发布文章。
  2. 打开 templates/detail.html 页面查看文章,DOT 图表应该能正常渲染。

例子2:在 CKEditor 中集成 ECharts 插件教程