☰
CKEditor 4 Widget 拖拽(Drag Drop)机制源码解析与手动测试指南
2026/10/17 6:33:40 网站建设 项目流程
  • 前端
  • UI组件

【免费下载链接】ckeditor4

The best enterprise-grade WYSIWYG editor. Fully customizable with countless features and plugins.

项目地址:https://gitcode.com/gh_mirrors/ck/ckeditor4
点击查看免费下载

Widget(部件)是 CKEditor 4 中实现"可编辑区块内的不可编辑富内容"的核心机制,而**拖拽(Drag & Drop)**则是 widget 最常用的交互之一——用户按住拖拽手柄把整个 widget(例如图片、视频、自定义排版块)移动到文档的其他位置。本文以仓库中的手动测试用例 tests/plugins/widget/manual/dnd.md 为主线,先给出可直接照做的测试步骤与判定标准,再从 plugins/widget/plugin.js 出发,逐层剖析拖拽手柄(drag handler)的创建、块级与行内 widget 两套拖拽实现、事件链路与关键边界防护,最后结合 tests/plugins/widget/dnd.js 中的自动化测试与 CHANGES.md 中的历史修复,帮助读者既"会测"又"懂原理"。

一、测试场景概览:这个手动测试在验证什么

该 manual test 位于 tests/plugins/widget/manual/dnd.md,配套的可运行页面是 tests/plugins/widget/manual/dnd.html。它验证的核心结论只有一句话:

Widget 是可拖拽的,且整个拖拽过程不应产生任何控制台错误。

这个场景之所以值得单独测试,是因为它对应的正是历史缺陷 #3926 中 "#3926: Fixed: Dragging and dropping a widget sometimes produces an error")。

测试页头部声明的元信息(Bender 标签)同时给出了运行该用例所需的插件环境:

@bender-tags: 4.14.1, bug, 3926 @bender-ui: collapsed @bender-ckeditor-plugins: widget, wysiwygarea, toolbar, sourcearea, floatingspace, elementspath

即:核心插件widget必须启用,配合经典编辑器外壳wysiwygarea、toolbar(工具栏)、sourcearea(源代码视图)、floatingspace(浮动工具栏空间)与elementspath(元素路径),外加测试自定义插件simplebox。

二、测试步骤与判定标准(可直接照做)

按照原文档,测试分两步:

  1. 打开浏览器开发者控制台(F12 / Cmd+Opt+I)。
  2. 把 widget 拖拽到编辑器的起始位置或结束位置——测试页面 dnd.html 在段落文本之间放置了一个.simplebox块级 widget,可分别尝试拖到首段之前和末段之后。

判定结果:

结果现象
预期(Expected)Widget 可被拖拽,控制台无任何报错
意外(Unexpected)Widget 无法拖拽,且浏览器控制台出现错误

文档还附带一条针对老 IE 的已知限制注意点:由于 #4076,在 IE 8–11 中图片无法被拖放到编辑器末尾(文档结尾处)。这意味着做回归验证时,若在 IE 上发现"拖到末尾失败",需先判断是否属于该已知问题。

三、测试页源码:simplebox 示例 widget 的完整定义

配套页面 tests/plugins/widget/manual/dnd.html 是理解该测试的关键,它同时包含测试内容 HTML与widget 插件定义。

3.1 测试内容结构

<div id="editor"> <p>Sit eos ab tenetur non dolor. ...</p> <div class="simplebox"> <h2 class="simplebox-title">Title</h2> <div class="simplebox-content"> <p>Content...</p> </div> </div> <p>Dolor minima ducimus repudiandae vitae labore ...</p> </div>

widget 位于两段文本之间,正好为"拖到开头 / 拖到结尾"提供两个对称的落点,用于覆盖拖拽的边界场景。

3.2 simplebox 插件定义

CKEDITOR.plugins.add( 'simplebox', { requires: 'widget', icons: 'simplebox', init: function( editor ) { editor.widgets.add( 'simplebox', { allowedContent: 'div(!simplebox); div(!simplebox-content); h2(!simplebox-title)', requiredContent: 'div(simplebox)', editables: { title: { selector: '.simplebox-title', allowedContent: 'br strong em' }, content: { selector: '.simplebox-content', allowedContent: 'p br ul ol li strong em' } }, template: '<div class="simplebox">' + '<h2 class="simplebox-title">Title</h2>' + '<div class="simplebox-content"><p>Content...</p></div>' + '</div>', button: 'Create a simple box', upcast: function( element ) { return element.name == 'div' && element.hasClass( 'simplebox' ); } } ); } } ); CKEDITOR.replace('editor', { extraPlugins: 'simplebox' } );

这个定义展示了 widget 的几个关键配置维度,它们与拖拽行为直接相关:

  • editables:声明title与content两个嵌套可编辑区(nested editables)——拖拽时必须保证 widget 不能被放回自己的嵌套可编辑区内部;
  • upcast:把已有<div class="simplebox">从普通 HTML 提升为 widget;
  • template+button:用于通过工具栏按钮新建 widget;
  • requiredContent:参与嵌套可编辑区过滤判断,决定目标区域的 ACF 过滤器是否允许当前 widget 落入。

四、源码剖析:Widget 拖拽在底层如何实现

manual test 验证的"可拖拽 + 无报错",在源码层由 plugins/widget/plugin.js 中的几个核心函数支撑。

4.1 拖拽手柄(Drag Handler)的创建:setupDragHandler

每个 widget 实例在被创建时,会根据定义中的draggable属性决定是否挂载拖拽手柄(plugin.js):

function setupDragHandler( widget ) { if ( !widget.draggable ) { return; } // ... var container = widget.wrapper.getLast( Widget.isDomDragHandlerContainer ), img; // 复用已存在的手柄(https://dev.ckeditor.com/ticket/11281) if ( container ) { img = container.findOne( 'img' ); } else { container = new CKEDITOR.dom.element( 'span', editor.document ); container.setAttributes( { 'class': 'cke_reset cke_widget_drag_handler_container', style: 'background:rgba(220,220,220,0.5);background-image:url(' + editor.plugins.widget.path + 'images/handle.png);display:none;' } ); img = new CKEDITOR.dom.element( 'img', editor.document ); img.setAttributes( { 'class': 'cke_reset cke_widget_drag_handler', 'data-cke-widget-drag-handler': '1', src: CKEDITOR.tools.transparentImageData, width: DRAG_HANDLER_SIZE, title: editor.lang.widget.move, height: DRAG_HANDLER_SIZE, role: 'presentation' } ); widget.inline && img.setAttribute( 'draggable', 'true' ); container.append( img ); widget.wrapper.append( container ); } // ... widget.dragHandlerContainer = container; }

关键实现事实:

  • 手柄是一个<span class="cke_widget_drag_handler_container">包裹的<img class="cke_widget_drag_handler">,图片为 15×15 像素(常量DRAG_HANDLER_SIZE = 15,见 plugin.js),src用透明占位图,背景以handle.png呈现;
  • 手柄初始隐藏(display:none),只有当其位置被计算出来后才会显示——这是为避免未定位手柄把 widget 的轮廓向左拉宽(对应 ticket #11177 / #12024);
  • 行内(inline)widget 的图片元素设置draggable="true",块级(block)widget 不设置,因为块级 widget 走的是另一套基于 Lineutils 的鼠标拖拽逻辑(见 4.3);
  • 手柄位置通过widget.updateDragHandlerPosition()计算,且该方法会锁定快照(lockSnapshot/unlockSnapshot),避免位置计算触发多余的历史记录(tests 中 'test widget.updateDragHandlerPosition - snapshot locking' 对此有断言)。

对应地,plugin.js 中draggable: widgetDef.draggable !== false表示:只要定义里没有显式写draggable: false,widget 默认就是可拖拽的。API 文档注释(plugin.js)进一步说明:设为false后,悬停 widget 时不再显示拖拽手柄。readonly场景下手柄也会被隐藏(.cke_editable[contenteditable="false"] .cke_widget_drag_handler_container { display:none },对应 #3260)。

4.2 拖拽事件链路:setupDragAndDrop

setupDragAndDrop(plugin.js)为编辑器挂上三个关键监听:

dragstart:当拖拽目标正是拖拽手柄(Widget.isDomDragHandler( target ))时,把 widget 的 id 写入数据通道,并聚焦编辑器与 widget 本体:

editor.on( 'dragstart', function( evt ) { var target = evt.data.target; if ( Widget.isDomDragHandler( target ) ) { var widget = widgetsRepo.getByElement( target ); evt.data.dataTransfer.setData( 'cke/widget-id', widget.id ); editor.focus(); // IE 需要先聚焦 widget.focus(); // widget 在拖拽开始时需要被聚焦(ticket #12172) } } );

drop:从dataTransfer中读出cke/widget-id,然后按传输类型分流:

editor.on( 'drop', function( evt ) { var dataTransfer = evt.data.dataTransfer, id = dataTransfer.getData( 'cke/widget-id' ), transferType = dataTransfer.getTransferType( editor ), // ... // 禁止跨编辑器拖拽(ticket #13599) if ( id !== '' && transferType === CKEDITOR.DATA_TRANSFER_CROSS_EDITORS ) { evt.cancel(); return; } if ( transferType != CKEDITOR.DATA_TRANSFER_INTERNAL ) { return; } // (#3441) 支持一次拖拽包含多个 widget 的选区 if ( id === '' && editor.widgets.selected.length > 0 ) { evt.data.dataTransfer.setData( 'text/html', getClipboardHtml( editor ) ); return; } sourceWidget = widgetsRepo.instances[ id ]; if ( !sourceWidget ) { return; } // 禁止把 widget 拖进自身或嵌套 widget 内部(#4509) if ( isTheSameWidget( sourceWidget, dropWidget ) ) { evt.cancel(); return; } dragRange.setStartBefore( sourceWidget.wrapper ); dragRange.setEndAfter( sourceWidget.wrapper ); evt.data.dragRange = dragRange; // ... evt.data.dataTransfer.setData( 'text/html', sourceWidget.getClipboardHtml() ); editor.widgets.destroy( sourceWidget, true ); } );

这段代码揭示了拖拽完成的本质:drop 本质上是"把源 widget 的 HTML 写入剪贴板数据 → 销毁源 widget → 走 paste 流程在新位置重建"。因此:

  • 拖拽结果默认是可撤销的(自动化测试 'test drag and drop with shadowed clipboard html (multiple widgets)' 中断言undoManager.undoable()为真);
  • 开发者可以通过在 widget 定义中覆写getClipboardHtml()(自 4.13.0 起支持,见 plugin.js)自定义拖拽/复制/剪切时写入剪贴板的 HTML;
  • 从源码注释可以看出dragstart、drop、dragend与paste事件同属 clipboard 事件体系(CHANGES.md 4.4.0 中记载了该整合)。

contentDom:为仓库(repository)注册 Lineutils 的三个工具对象finder(寻找可放置的块级位置)、locator(定位最近行)、liner(绘制放置提示线),并定义放置规则的默认查找器:块级元素且非列表项、不在嵌套可编辑区内部、不能落在正在拖拽的 widget 内部(ticket #13397)、若目标是嵌套可编辑区还需校验其过滤器是否允许被拖 widget 的requiredContent(ticket #12006)。

4.3 块级 widget 的鼠标拖拽:onBlockWidgetDrag

行内 widget 依赖浏览器原生 drag 事件,而块级 widget 则完全由插件自行模拟拖拽。setupDragHandler中为块级 widget 的手柄图片绑定了mousedown处理器onBlockWidgetDrag(plugin.js),流程如下:

  1. 只响应左键:CKEDITOR.tools.getMouseButton( evt ) !== CKEDITOR.MOUSE_BUTTON_LEFT时直接返回(对应 #711,"只有鼠标左键能拖拽 widget");
  2. 记录repository._.draggedWidget = this,供 finder 判断哪些位置不可用;
  3. 调用finder.greedySearch()枚举所有可放置关系,用 50ms 事件缓冲(eventsBuffer)节流处理mousemove:locator.locate()定位 →locator.sort()选出最近行 →liner.placeLine()绘制放置提示线;
  4. 给可编辑区加cke_widget_dragging类,使整个编辑区内光标变为移动光标(对应 CSS 规则.cke_editable.cke_widget_dragging, .cke_editable.cke_widget_dragging *{ cursor:move !important });
  5. 手动触发editor.fire( 'dragstart', { target: evt.sender } ),保持与原生 D&D 一致的事件语义;
  6. 监听mouseup(编辑器文档与外部CKEDITOR.document各挂一次,防止在行线上松手时丢失事件)触发onBlockWidgetDrop(plugin.js):若存在可见放置线,取finder.getRange( sorted[0] )得到落点范围,聚焦 widget,editor.fire( 'drop', ... ),最后清理cke_widget_dragging类、隐藏所有放置线并触发dragend。

由此可以解释手动测试的两个判定点:

  • "Widget is draggable":取决于手柄是否成功创建(draggable未显式关闭)以及 mousedown→dragstart→drop 事件链是否完整;
  • "No console errors":一旦drop处理中sourceWidget找不到、dataTransfer类型判断异常或放置位置落在非法区域,就可能抛错——这正是 #3926 的回归形态。

4.4 边界防护一览

从源码注释可以整理出拖拽机制的完整防护清单:

防护点对应 issue/注释位置
禁止跨编辑器拖拽 widget#13599plugin.js
禁止拖入自身或嵌套 widget#4509plugin.js
禁止拖入正在拖拽的 widget 内部#13397plugin.js
禁止拖入自己的嵌套可编辑区注释 #12006plugin.js
目标嵌套区的 ACF 过滤 requiredContent#12006plugin.js
仅左键触发拖拽#711plugin.js
阻止在 wrapper 上松手触发页面重载#13015plugin.js
拖拽开始前清理选择,避免填充字符阻止拖拽#13284plugin.js

其中 #4509 还有专门的手动测试场景 tests/plugins/widget/manual/parentdnd.md(把最外层 widget 拖进内层 widget 的[drop here]区域应"什么都不发生",切到源码模式后也不应出现多余的img元素);#711 的手动场景见 tests/plugins/widget/manual/dragwidget.md(右键无法拖拽图片,但能正常弹出上下文菜单)。

五、自动化测试佐证:dnd.js 如何覆盖拖拽行为

除了手动测试,仓库还提供了成体系的自动化测试 tests/plugins/widget/dnd.js。它通过dragstart()、drop()、dragend()三个辅助函数模拟完整拖拽周期,覆盖以下断言(节选):

  • 手柄结构:test handler - block widget与test handler - inline widget断言手柄容器存在、类名为cke_widget_drag_handler_container、内含img.cke_widget_drag_handler,且 block widget 的手柄图片不带draggable属性而 inline widget带draggable="true";
  • 手柄定位:test handler - positioning等用例验证mouseenter后手柄获得top/left样式,且初始不预计算位置(避免多余布局重算);
  • 事件完整性:test drag and drop - block widget用 sinon spy 同时断言paste、dragstart、dragend、drop各触发一次,原 widget 被销毁、新 widget 被创建,拖拽后数据符合预期且支持 undo;
  • 非法场景:test drop - no widget id、test drop - wrong widget id、test drop - cross-editor drop、test drop - parent on child分别验证无 id、错误 id、跨编辑器、父拖入子等场景下原 widget 不被销毁;
  • 鼠标按键:test if only left mouse button triggers dragstart验证左键才给 editable 加cke_widget_dragging类,中键、右键均不触发;
  • 回归用例:test drop with initial widget id直接标注 "(#3926)",即手动测试对应的自动化回归;test drag and drop - block widget into widget in nested editable系列覆盖嵌套可编辑区的放置关系计算。

这些用例与手动测试互为补充:手动测试负责真实浏览器交互与"无控制台错误"的整体感受,自动化测试则把每一个环节拆成可重复的断言。

六、如何运行该手动测试

仓库使用 Bender 测试框架管理测试(参见 bender.js、bender.ci.js 与 bender-runner.config.json)。manual test 的.md描述与同名.html页面一一对应,通过 Bender 的 manual tests 模式即可打开 dnd.html 页面进行人工验证。运行前请确认:

  • 需要启用widget核心插件及wysiwygarea、toolbar、sourcearea、floatingspace、elementspath等配套插件(即文档头部的@bender-ckeditor-plugins声明),并在页面中通过extraPlugins: 'simplebox'注入测试插件;
  • 页面开头有移动端跳过逻辑(if ( bender.tools.env.mobile ) { bender.ignore(); }),因此该测试面向桌面浏览器;
  • 若在 IE 8–11 验证,需知悉 #4076 已知限制:图片无法拖到编辑器末尾。

七、总结

一个看似简单的"把 widget 拖到开头/结尾"的手动测试,背后串联了 widget 插件的完整拖拽体系:draggable属性决定手柄是否创建;setupDragHandler负责手柄的 DOM 结构与定位;setupDragAndDrop统一了 inline 与 block 两类 widget 的dragstart/drop/dragend事件语义;block widget 额外依赖 Lineutils 的 finder/locator/liner 三件套实现放置线提示与落点计算;drop 最终退化为"剪贴板 HTML + 销毁重建"并进入 paste 管线,天然获得 undo 能力。同时,#3926、#4509、#711、#13599 等一系列历史缺陷及其回归测试(dnd.js)共同划定了拖拽的边界防护。理解这些实现细节后,无论是排查"widget 拖不动 / 控制台报错",还是开发自定义 widget 并为其定制拖拽行为(如覆写getClipboardHtml()、设置draggable: false),都能有据可依。

  • 前端
  • UI组件

【免费下载链接】ckeditor4

The best enterprise-grade WYSIWYG editor. Fully customizable with countless features and plugins.

项目地址:https://gitcode.com/gh_mirrors/ck/ckeditor4
点击查看免费下载
上一篇:一次装成Arduino ESP32开发环境:3个关键检查点与板载灯实战
下一篇:明日方舟素材库保姆级使用指南:从零获取全部美术资源与游戏数据

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询