如何在 CKEditor 中集成 DOT 语言流程图插件
本教程将指导您在 CKEditor 4 中添加一个自定义插件,允许用户插入和编辑 DOT 语言定义的流程图。该插件会在编辑器中显示图表的 SVG 预览,并在博客详情页中进行实际渲染。本方案无需特殊后端逻辑来处理 DOT 源码的存储和转义,完全依靠前端 JavaScript 进行编码/解码。
最终效果预览
- 在 CKEditor 编辑器中,您将看到渲染后的流程图,而不是文本占位符。
- 您可以点击工具栏按钮插入新图表,并提供默认示例。
- 您可以点击编辑器中的图表或右键(如果配置了右键菜单),再次打开对话框进行编辑。
- 博客详情页(前端)也能正常显示渲染后的流程图。
前提条件
- CKEditor 4 已安装并集成到您的项目中。
- Viz.js 库 (
viz.js
和full.render.js
) 已引入。 - 您有 Go Web 后端项目,并使用 HTML 模板。
步骤一:引入 Viz.js 库
Viz.js 是一个将 DOT 语言渲染为 SVG 的 JavaScript 库。您可以通过 CDN 引入它。
在您的 adminpanel\templates\write_blog.html
和 templates\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.js
中 icon
属性所指的完全一致(例如 Graphviz.svg
或 dotblock.png
)。
步骤五:在 write_blog.html
中配置 CKEditor
在您的管理面板的博客写作页面 adminpanel\templates\write_blog.html
中,找到 CKEditor 的初始化代码。
- 定义
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>
- 配置 CKEditor 实例: 在
$(document).ready()
块内找到您的CKEDITOR.replace()
调用,并修改extraPlugins
和toolbar_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
)作为工具栏按钮图标。
部署与测试
- 保存所有修改过的文件:
dialogs/dotblock.js
、plugin.js
、write_blog.html
、detail.html
。 - 清除浏览器缓存 (Ctrl+Shift+R 或 Shift+F5 强制刷新)。
- 重新启动您的 Go 应用程序(如果需要)。
- 打开浏览器开发者工具 (F12),切换到 Console (控制台) 选项卡。
现在,请你执行以下操作并验证功能:
- 打开
adminpanel/templates/write_blog.html
页面:- 检查控制台,确保没有
editor-element-conflict
错误。 - 在 CKEditor 工具栏上,找到并点击 DOT 图标按钮。对话框应该弹出,并显示默认示例。
- 点击确定插入图表。图表预览应该会显示在编辑器中。
- 多次点击保存按钮 (不发布),然后刷新页面,重新编辑该文章。验证图表是否仍然正常显示,箭头是否没有乱码。
- 保存并发布文章。
- 检查控制台,确保没有
- 打开
templates/detail.html
页面查看文章,DOT 图表应该能正常渲染。
本文作者: 永生
本文链接: https://www.yys.zone/detail/?id=368
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
评论列表 (0 条评论)