如何在 CKEditor 中集成 Excalidraw 插件并实现双击或右键编辑图形功能
引言
CKEditor 是一个非常流行的富文本编辑器,提供了丰富的插件和定制功能。而 Excalidraw 是一个开源的图形绘制工具,能够帮助用户创建漂亮的手绘风格图形,可以导入json(用AI生成json导入,比如copilot)。在本篇文章中,我们将介绍如何将 Excalidraw 集成到 CKEditor 中,允许用户通过双击或右键菜单编辑图形。
步骤一:准备工作
在开始之前,确保你已具备以下条件:
- 已经安装并运行了 CKEditor。
- 已经下载并准备好 Excalidraw 的相关文件,如
react.development.js
、react-dom.development.js
和excalidraw.production.min.js
。
可以从 Excalidraw 的 GitHub 仓库下载最新的版本:Excalidraw GitHub。
步骤二:创建 drawblock
插件
在 CKEditor 中,我们将创建一个名为 drawblock
的插件,该插件将用于集成 Excalidraw 图形。
2.1 创建 drawblock.js
在 drawblock.js
中,定义对话框配置、插入图形功能以及编辑现有图形的功能。
CKEDITOR.dialog.add('drawblock', function(editor) {
return {
title: '绘图面板',
minWidth: 900,
minHeight: 700,
onLoad: function() {
var dialog = this;
dialog._.originalSize = null;
dialog._.originalPosition = null;
dialog._.isMaximized = false;
dialog._.resizeListener = null;
// --- 添加最大化按钮 ---
var maximizeButton = CKEDITOR.dom.element.createFromHtml(
'<a title="最大化" class="cke_dialog_page_maximize" href="javascript:void(0)" role="button"></a>'
);
maximizeButton.setStyles({
position: 'relative',
float: 'right',
margin: '5px 5px 0 0',
width: '22px',
height: '22px',
'line-height': '20px',
'font-size': '20px',
'text-align': 'center',
'font-weight': 'bold',
'text-decoration': 'none',
color: '#555',
'border-radius': '2px'
});
maximizeButton.setHtml('<span class="maximize_icon">❑</span>');
maximizeButton.on('mouseover', function() {
this.setStyles({ 'background-color': '#e6e6e6' });
});
maximizeButton.on('mouseout', function() {
this.setStyles({ 'background-color': 'transparent' });
});
var dialogElement = dialog.getElement();
var closeButton = dialogElement.findOne('.cke_dialog_close_button');
if (closeButton) {
closeButton.insertBeforeMe(maximizeButton);
}
function updateDrawContainerSize() {
var container = CKEDITOR.document.getById('draw-container');
var dialogBody = dialog.getElement().findOne('.cke_dialog_body');
if (!container || !dialogBody) return;
if (dialog._.isMaximized) {
// 最大化时,占满整个可视区域减去边距
var viewPane = CKEDITOR.document.getWindow().getViewPaneSize();
var padding = 80; // 根据 CKEditor 头部和按钮实际情况调整
container.setStyles({
width: '100%',
height: (viewPane.height - padding) + 'px'
});
} else {
// ✅ 还原时使用初始化高度
container.setStyles({
width: '860px',
height: '620px'
});
}
}
function toggleMaximize() {
if (!dialog._.isMaximized) {
dialog._.originalSize = dialog.getSize();
dialog._.originalPosition = dialog.getPosition();
var viewPaneSize = CKEDITOR.document.getWindow().getViewPaneSize();
dialog.resize(viewPaneSize.width, viewPaneSize.height);
dialog.move(0, 0);
maximizeButton.setAttribute('title', '还原');
maximizeButton.setHtml('<span class="restore_icon">❐</span>');
dialog._.isMaximized = true;
dialog._.resizeListener = function() {
if (dialog._.isMaximized) {
var size = CKEDITOR.document.getWindow().getViewPaneSize();
dialog.resize(size.width, size.height);
updateDrawContainerSize();
}
};
CKEDITOR.document.getWindow().on('resize', dialog._.resizeListener);
updateDrawContainerSize();
} else {
dialog.resize(dialog._.originalSize.width, dialog._.originalSize.height);
dialog.move(dialog._.originalPosition.x, dialog._.originalPosition.y);
maximizeButton.setAttribute('title', '最大化');
maximizeButton.setHtml('<span class="maximize_icon">❑</span>');
dialog._.isMaximized = false;
if (dialog._.resizeListener) {
CKEDITOR.document.getWindow().removeListener('resize', dialog._.resizeListener);
dialog._.resizeListener = null;
}
updateDrawContainerSize();
}
}
maximizeButton.on('click', function(evt) {
toggleMaximize();
evt.data.preventDefault();
});
var title = dialogElement.findOne('.cke_dialog_title');
if (title) {
title.on('dblclick', function(evt) {
if (evt.data.getTarget().hasClass('cke_dialog_title')) {
toggleMaximize();
}
});
}
dialog.on('resize', updateDrawContainerSize);
dialog.on('hide', function() {
if (dialog._.isMaximized) toggleMaximize();
});
},
onShow: function() {
var selElem = editor.getSelection().getStartElement();
var div = selElem && selElem.getAscendant('div', true);
this._editElement = null;
// 🧩 初始化高度修复
var dialog = this;
setTimeout(function () {
var container = CKEDITOR.document.getById('draw-container');
var body = dialog.getElement().findOne('.cke_dialog_body');
if (container && body) {
container.setStyles({
width: '100%',
height: body.$.offsetHeight + 'px'
});
}
}, 100); // 延迟执行确保 DOM 渲染完成
// 📥 加载绘图内容
if (div && div.hasClass('draw-diagram')) {
this._editElement = div;
var sceneData = this._editElement.getAttribute('data-scene');
if (sceneData) {
var iframe = document.getElementById('drawframe');
setTimeout(function() {
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage({
type: 'loadDrawing',
data: sceneData
}, '*');
}
}, 500);
}
}
},
contents: [{
id: 'tab1',
label: '画图',
elements: [{
type: 'html',
html: `
<div id="draw-container" style="width:100%; height:100%; overflow:hidden;">
<iframe src="${CKEDITOR.getUrl('plugins/drawblock/draw.html')}"
id="drawframe"
style="width:100%; height:100%; border:none; display:block;">
</iframe>
</div>`
}]
}],
onOk: function() {
var dialog = this;
var iframe = document.getElementById('drawframe');
return new Promise(function(resolve, reject) {
if (!iframe || !iframe.contentWindow || !iframe.contentWindow.excalidrawAPI) {
alert('未找到绘图 iframe 或接口尚未初始化');
return reject(new Error('Iframe or API not found.'));
}
Promise.all([
iframe.contentWindow.excalidrawAPI.getSvg(),
iframe.contentWindow.excalidrawAPI.getScene()
]).then(function(results) {
var svgStr = results[0];
var sceneData = results[1];
if (!sceneData || !sceneData.elements || sceneData.elements.length === 0) {
alert('请完成绘图后再导入。');
return reject(new Error('Drawing is empty.'));
}
var sceneStr = JSON.stringify(sceneData);
var encodedScene = encodeURIComponent(sceneStr);
var encodedSvg = encodeURIComponent(svgStr);
if (dialog._editElement) {
dialog._editElement.setHtml(svgStr);
dialog._editElement.setAttribute('data-scene', encodedScene);
dialog._editElement.setAttribute('data-svg', encodedSvg);
} else {
editor.insertHtml(
'<div class="draw-diagram" data-scene="' + encodedScene + '" ' +
' data-svg="' + encodedSvg + '">' +
svgStr +
'</div>'
);
}
resolve();
}).catch(function(err) {
console.error('从 Excalidraw 导出数据时出错:', err);
alert('导出出错,请重试。');
reject(err);
});
});
}
};
});
2.2 创建 plugin.js
在 plugin.js
中,我们将初始化 drawblock
插件并配置按钮、右键菜单等功能。
// static\ckeditor\ckeditor\plugins\drawblock\plugin.js
CKEDITOR.plugins.add('drawblock', {
icons: 'drawblock',
requires: 'dialog,contextmenu', // 依赖 contextmenu 插件
init: function (editor) {
// 添加对话框
CKEDITOR.dialog.add('drawblock', this.path + 'dialogs/drawblock.js');
// 添加命令
editor.addCommand('drawblock', new CKEDITOR.dialogCommand('drawblock', {
// 允许在只读模式下执行命令,这样即使用户在源码模式下也能通过双击打开
readOnly: 1
}));
// 添加工具栏按钮
editor.ui.addButton('drawblock', {
label: '插入或编辑图形', // 更新标签
command: 'drawblock',
toolbar: 'insert',
icon: this.path + 'icon.png'
});
// --- 新增:双击编辑功能 ---
editor.on('instanceReady', function() {
var body = editor.document.getBody();
body.on('dblclick', function(evt) {
var element = evt.data.getTarget();
var drawDiv = element.getAscendant('div', true);
if (drawDiv && drawDiv.hasClass('draw-diagram')) {
// 如果双击的是绘图div或其内部的svg,打开对话框
editor.getSelection().selectElement(drawDiv);
editor.execCommand('drawblock');
}
}, null, null, 10); // 提高优先级
});
// --- 新增:右键菜单编辑功能 ---
if (editor.contextMenu) {
editor.addMenuGroup('drawblockGroup');
editor.addMenuItem('drawblockContext', {
label: '编辑图形',
icon: this.path + 'icon.png',
command: 'drawblock',
group: 'drawblockGroup'
});
editor.contextMenu.addListener(function(element) {
// 寻找父级的 .draw-diagram 元素
var drawDiv = element.getAscendant('div', true);
if (drawDiv && drawDiv.hasClass('draw-diagram')) {
return { drawblockContext: CKEDITOR.TRISTATE_OFF };
}
});
}
}
});
步骤三:修改 CKEditor 配置
在 CKEditor 配置文件中,启用 drawblock
插件并自定义工具栏。
CKEDITOR.replace('editor1', {
toolbar_Custom: [
{ name: 'insert', items: ['drawblock'] }
],
extraPlugins: 'drawblock',
});
步骤四:创建 Excalidraw 编辑器的 draw.html
在 draw.html
文件中,我们加载 Excalidraw 库并实现图形编辑功能。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Excalidraw Iframe</title>
<style>
html, body, #app {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
}
</style>
</head>
<body>
<div id="app"></div>
<script src="/excalidraw/react.development.js"></script>
<script src="/excalidraw/react-dom.development.js"></script>
<script src="/excalidraw/excalidraw.production.min.js"></script>
<script>
const { createElement, useRef, useEffect, useState } = React;
const { Excalidraw, exportToSvg } = window.ExcalidrawLib;
function App() {
const excalidrawRef = useRef(null);
const [initialData, setInitialData] = useState(null);
useEffect(() => {
window.excalidrawAPI = {
getSvg: async () => {
if (!excalidrawRef.current) return null;
const elements = excalidrawRef.current.getSceneElements();
if (elements.length === 0) return null;
const appState = excalidrawRef.current.getAppState();
const result = await exportToSvg({
elements,
appState,
files: excalidrawRef.current.getFiles(),
embedScene: false,
exportPadding: 10
});
return new XMLSerializer().serializeToString(result);
},
getScene: () => {
if (!excalidrawRef.current) return null;
return {
elements: excalidrawRef.current.getSceneElements(),
appState: excalidrawRef.current.getAppState(),
files: excalidrawRef.current.getFiles()
};
}
};
}, []);
useEffect(() => {
const handleMessage = (e) => {
if (e.data?.type === 'loadDrawing' && e.data.data) {
try {
const scene = JSON.parse(decodeURIComponent(e.data.data));
// --- 修正点在这里!---
// 我们只加载持久化的、安全的状态,丢弃临时的UI状态
const cleanScene = {
elements: scene.elements,
files: scene.files,
appState: {
// 这里只包含我们希望恢复的视图状态
viewBackgroundColor: scene.appState.viewBackgroundColor,
gridSize: scene.appState.gridSize,
theme: scene.appState.theme,
// 其他你希望保留的状态也可以在这里添加
}
};
if (excalidrawRef.current) {
// 使用清洗过的 scene 对象进行更新
excalidrawRef.current.updateScene(cleanScene);
} else {
setInitialData(cleanScene);
}
} catch (err) {
console.error('解析或加载绘图数据失败:', err);
alert('加载已有图形失败,请检查控制台获取错误信息。');
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
return createElement(Excalidraw, {
ref: excalidrawRef,
initialData,
style: { width: '100%', height: '100%' }
});
}
ReactDOM.createRoot(document.getElementById('app')).render(createElement(App));
</script>
</body>
</html>
结论
通过本文的步骤,你成功地将 Excalidraw 集成到 CKEditor 中,并实现了双击或右键菜单编辑图形的功能。这将大大提升图形编辑和绘制的灵活性,并能够直接在富文本编辑器中进行操作。
本文作者: 永生
本文链接: https://www.yys.zone/detail/?id=438
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
评论列表 (0 条评论)