- 前端
- UI组件
【免费下载链接】ckeditor4
The best enterprise-grade WYSIWYG editor. Fully customizable with countless features and plugins.
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。
二、测试步骤与判定标准(可直接照做)
按照原文档,测试分两步:
- 打开浏览器开发者控制台(F12 / Cmd+Opt+I)。
- 把 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),流程如下:
- 只响应左键:
CKEDITOR.tools.getMouseButton( evt ) !== CKEDITOR.MOUSE_BUTTON_LEFT时直接返回(对应 #711,"只有鼠标左键能拖拽 widget"); - 记录
repository._.draggedWidget = this,供 finder 判断哪些位置不可用; - 调用
finder.greedySearch()枚举所有可放置关系,用 50ms 事件缓冲(eventsBuffer)节流处理mousemove:locator.locate()定位 →locator.sort()选出最近行 →liner.placeLine()绘制放置提示线; - 给可编辑区加
cke_widget_dragging类,使整个编辑区内光标变为移动光标(对应 CSS 规则.cke_editable.cke_widget_dragging, .cke_editable.cke_widget_dragging *{ cursor:move !important }); - 手动触发
editor.fire( 'dragstart', { target: evt.sender } ),保持与原生 D&D 一致的事件语义; - 监听
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 | #13599 | plugin.js |
| 禁止拖入自身或嵌套 widget | #4509 | plugin.js |
| 禁止拖入正在拖拽的 widget 内部 | #13397 | plugin.js |
| 禁止拖入自己的嵌套可编辑区 | 注释 #12006 | plugin.js |
| 目标嵌套区的 ACF 过滤 requiredContent | #12006 | plugin.js |
| 仅左键触发拖拽 | #711 | plugin.js |
| 阻止在 wrapper 上松手触发页面重载 | #13015 | plugin.js |
| 拖拽开始前清理选择,避免填充字符阻止拖拽 | #13284 | plugin.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.
相关推荐
CKEditor 4 Enhanced Image 图片拖拽边界测试用例解析:dndwithcaption 手动测试与 Widget 拖放机制
CKEditor 4 Enhanced Image 图片拖拽边界测试用例解析:dndwithcaption 手动测试与 Widget 拖放机制 导读 本文围绕仓
前端UI组件CKEditor 4 Widget 拖动手柄(Drag Handler)定位机制解析:从 trac14573 手动回归测试说起
CKEditor 4 Widget 拖动手柄(Drag Handler)定位机制解析:从 trac14573 手动回归测试说起 本篇文章以 tests/tick
前端UI组件CKEditor 4 Widget 拖拽复制中的 getClipboardHtml 定制:手动测试 3441 与源码原理全解析
CKEditor 4 Widget 拖拽复制中的 getClipboardHtml 定制:手动测试 3441 与源码原理全解析 本文以仓库内手动测试规范 cli
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考