引言

CKEditor 是一个非常流行的富文本编辑器,提供了丰富的插件和定制功能。而 Excalidraw 是一个开源的图形绘制工具,能够帮助用户创建漂亮的手绘风格图形,可以导入json(用AI生成json导入,比如copilot)。在本篇文章中,我们将介绍如何将 Excalidraw 集成到 CKEditor 中,允许用户通过双击或右键菜单编辑图形。

步骤一:准备工作

在开始之前,确保你已具备以下条件:

  1. 已经安装并运行了 CKEditor。
  2. 已经下载并准备好 Excalidraw 的相关文件,如 react.development.jsreact-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 中,并实现了双击或右键菜单编辑图形的功能。这将大大提升图形编辑和绘制的灵活性,并能够直接在富文本编辑器中进行操作。

开始结束

 

🕒 时间管理四象限(Eisenhower Matrix)紧急且重要(Urgent & Important)不紧急但重要(Not Urgent but Important)紧急但不重要(Urgent but Not Important)不紧急且不重要(Not Urgent & Not Important)🔥 火灾、紧急任务📅 截止任务、危机处理🎯 计划、学习、目标设定📚 阅读、健康、长期规划📞 打断电话、杂事📧 无关紧要的邮件📺 刷短视频、无意义娱乐🎮 游戏、社交媒体沉迷