CKEditor 4 font 插件的字号/字体样式保持机制:preservestyle 手动测试场景与源码级实现剖析
【免费下载链接】ckeditor4
The best enterprise-grade WYSIWYG editor. Fully customizable with countless features and plugins.
本文围绕 CKEditor 4 仓库中 preservestyle.md 这份手动测试用例展开,完整还原它对 Font family(字体)与 Font size(字号)下拉框的"重复应用不丢失、(Default) 一键重置"验收场景,并结合 font 插件源码 与 自动化测试 深入讲解其背后的命令执行链、样式匹配(checkElementMatch)判定与折叠选区切分逻辑,帮助读者理解这一行为从 4.8.0(issue #584)起的设计动机与实现细节。
1. 背景:#584 带来的交互行为变更
这份手动测试文件位于 bender 手动测试目录 tests/plugins/font/manual/ 下,文件头部的元数据声明了它的归属:
@bender-tags: 4.8.0, feature, 584
@bender-ui: collapsed
@bender-ckeditor-plugins: wysiwygarea, toolbar, font, elementspath, sourcearea
即该场景对应 CKEditor 4.8.0 中修复的 issue #584,需要启用 wysiwygarea、toolbar、font、elementspath、sourcearea 插件。CHANGES.md 的 CKEditor 4.8 章节对此有明确记载:
#584: [Font size and Family] and [Format] drop-downs are not toggleable anymore. Default option to reset styles added.
换言之,4.8.0 之前,点击一个已经处于激活状态的下拉项会把样式"切换"掉(toggleable);4.8.0 之后,重复点击同一项不再撤销样式,而是保持原状,同时下拉列表顶部新增了 (Default) 选项作为唯一的重置入口。preservestyle.md 正是针对这一行为变更编写的验收脚本。配套的验证页面是 preservestyle.html,其中仅有一个 textarea 和 CKEDITOR.replace( 'editor' ) 初始化调用,属于默认配置的编辑器实例。
2. 手动测试场景全文(Font size 与 Font family 均需执行)
原文档要求:"Note: Perform below scenario for both Font size and Font family drop-downs.",即下述步骤要对字号下拉框和字体下拉框分别完整执行一遍。原文给出的操作步骤如下:
- 选中一段文字(Select some text)。
- 修改
Font size(例如选24)。 - 重新选中这段文字。
- 预期:
Font size下拉框应回显上一步选择的值。
- 预期:
- 再次选择相同的字号(例如还是
24)。- 预期:选中文本应保持其字号不变(preserve its font size)。
- 选择
(Default)选项以重置Font size。- 预期:字号恢复默认,承载字号样式的
<span>元素应消失。
- 预期:字号恢复默认,承载字号样式的
原文档的 Expected 结论为:
Choosen font size/name will remain the same, after click on its corresponding item in the drop-down menu. The item will remain marked as active. Selected text preserves chosen style after reapplying it multiple times.
即:点击下拉菜单中已对应当前样式的条目后,所选字号/字体名保持不变,条目继续保持"激活"标记,且多次重复应用后选区仍保留所选样式。
3. 插件实现:两个 RichCombo 与六个配置项
plugins/font/plugin.js 在 init 阶段通过 addCombo() 注册两个下拉框,二者共用同一套代码,仅参数不同:
| 参数 | Font(字体) | FontSize(字号) |
|---|---|---|
| comboName | Font | FontSize |
| commandName | font | fontSize |
| styleVariable | family | size |
| entries | config.font_names | config.fontSize_sizes |
| defaultLabel | config.font_defaultLabel | config.fontSize_defaultLabel |
| styleDefinition | config.font_style | config.fontSize_style |
| order(工具栏排序) | 30 | 40 |
插件依赖 richcombo(requires: 'richcombo')。相关的六个配置项均在 plugin.js 底部声明默认值:
-
config.font_names:字体列表,分号分隔,支持显示名/实际CSS值语法。默认值:CKEDITOR.config.font_names = 'Arial/Arial, Helvetica, sans-serif;' + 'Comic Sans MS/Comic Sans MS, cursive;' + 'Courier New/Courier New, Courier, monospace;' + 'Georgia/Georgia, serif;' + 'Lucida Sans Unicode/Lucida Sans Unicode, Lucida Grande, sans-serif;' + 'Tahoma/Tahoma, Geneva, sans-serif;' + 'Times New Roman/Times New Roman, Times, serif;' + 'Trebuchet MS/Trebuchet MS, Helvetica, sans-serif;' + 'Verdana/Verdana, Geneva, sans-serif'; -
config.font_defaultLabel:当光标处没有任何字体匹配时显示在字体下拉框中的文本,默认为空字符串''。 -
config.font_style:应用字体时使用的样式定义,默认:CKEDITOR.config.font_style = { element: 'span', styles: { 'font-family': '#(family)' }, overrides: [ { element: 'font', attributes: { 'face': null } } ] }; -
config.fontSize_sizes:字号列表,同样支持显示名/CSS值语法(如'Bigger Font/14px'),支持12px、2.3em、130%、larger、x-small等任意 CSS 尺寸。默认值覆盖8/8px到72/72px共 15 档,其中就包含手动测试场景里用到的24/24px:CKEDITOR.config.fontSize_sizes = '8/8px;9/9px;10/10px;11/11px;12/12px;14/14px;16/16px;18/18px;20/20px;22/22px;24/24px;26/26px;28/28px;36/36px;48/48px;72/72px'; -
config.fontSize_defaultLabel:字号下拉框在无匹配时的显示文本,默认为''。 -
config.fontSize_style:应用字号的样式定义,默认:CKEDITOR.config.fontSize_style = { element: 'span', styles: { 'font-size': '#(size)' }, overrides: [ { element: 'font', attributes: { 'size': null } } ] };
overrides 中的 face/size 属性覆盖,以及 addCombo 中注册的 contentTransformations,共同保证旧式 <font face="..." size="..."> 元素能被自动转换为 <span style="...">(size 属性会映射到 sizes 数组:x-small … 48px,其中索引 6 的 '48px' 注释为 "Closest value to what size="7" might mean")。
4. 命令执行链:为什么重复点击同项会"保持"而非"切换"
手动测试第 4 步(再次选择 24)的正确性取决于 exec 的逻辑。addCombo 中通过 editor.addCommand( definition.commandName, {...} ) 注册的命令,其 exec 流程如下(plugin.js):
-
从
data.newStyle/data.oldStyle取出目标样式与旧样式;newStyle === undefined即视为"移除"(对应点击(Default))。 -
若
oldStyle与newStyle均为空,直接返回,什么都不做。 -
若存在
oldStyle且当前选区是折叠选区(collapsed range),先调用splitElementOnCollapsedRange()把元素切开(见第 6 节),因为直接removeStyle会删掉整个元素而不是只作用于光标位置。 -
若是移除操作:
editor.removeStyle( oldStyle )。 -
若是应用操作:仅当
oldStyle与newStyle不相等时才先editor.removeStyle( oldStyle ),随后总是editor.applyStyle( newStyle ):if ( isRemove ) { editor.removeStyle( oldStyle ); } else { if ( oldStyle && !isEqualStyle( oldStyle, newStyle ) ) { editor.removeStyle( oldStyle ); } editor.applyStyle( newStyle ); }
这里就是"#584 不再 toggleable"的落点:旧逻辑下再次点击同项会走向 removeStyle 分支;新逻辑下 isEqualStyle( oldStyle, newStyle ) 返回 true,跳过了移除步骤,只再做一次 applyStyle,而 CKEditor 的样式系统对已存在的同一样式会合并而非嵌套包裹,因此 <span style="font-size:24px"> 原样保留。isEqualStyle 通过比较两个样式的 attributes(CKEDITOR.tools.objectCompare)与 CKEDITOR.style.getStyleText() 序列化结果来判定"同一样式"。
(Default) 选项则走移除分支:combo 的 defaultValue 被固定为 'cke-default'(plugin.js),而 StyleData._.data 中不存在该键,故 stylesData.getStyle( 'cke-default' ) 返回 undefined,触发 isRemove = true,最终 removeStyle 把承载样式的 <span> 从 DOM 中移除——正对应手动测试第 5 步"<span> 元素应消失"的预期。自动化测试 font.js 中的 #584 test remove font size from text 用 <p><span style="font-size:12px">[foo]</span></p> 点击默认值后断言结果为 <p>foo@</p>,与手动场景第 5 步一一对应。
onClickHandler(plugin.js)是 UI 与命令之间的桥梁:
function onClickHandler( newValue ) {
var oldValue = this.getValue();
editor.focus();
editor.fire( 'saveSnapshot' );
editor.execCommand( definition.commandName, {
newStyle: stylesData.getStyle( newValue ),
oldStyle: stylesData.getStyle( oldValue )
} );
editor.fire( 'saveSnapshot' );
}
注意 oldValue 取自 this.getValue()——即 combo 当前回显的值,而不是"光标处 DOM 上的实际样式"。这也解释了为什么手动测试第 3 步(重新选中文字后下拉框回显之前选的值)必须先发生:combo 的显示值本身就是 oldStyle 的来源。此外前后各有一次 saveSnapshot,保证 undo 可整体回退这次操作。
5. 下拉框回显机制:selectionChange 与 checkElementMatch
手动测试第 3 步"Font size 下拉框显示上一步选中的值"以及 Expected 中的"item will remain marked as active",由 combo 的 onRender 中的 selectionChange 监听实现(plugin.js):
editor.on( 'selectionChange', function( ev ) {
var currentValue = this.getValue(),
elementPath = ev.data.path,
value = stylesData.getMatchingValue( editor, elementPath );
if ( value ) {
if ( value != currentValue ) {
this.setValue( value );
}
return;
}
// If no styles match, just empty it.
this.setValue( '', definition.defaultLabel );
}, this );
其调用链为:选区变化 → 取当前 elementPath → StyleData.getMatchingValue() 遍历 path 上的元素,对每个元素调用 findMatchingStyleName()(plugin.js),后者用 CKEDITOR.tools.array.find 在全部样式名中寻找第一个满足 style.checkElementMatch( element, true, editor ) 的名字(fullMatch = true 要求严格匹配)。core/style.js 的 checkElementMatch 会比对元素名与样式定义的 element(均为 span),并用 compareCssText 逐条比对 style 属性中的 CSS 声明(如 font-size:24px)。匹配成功则 setValue( value ) 让对应条目高亮;没有任何样式匹配时回落到 defaultLabel(即空字符串或自定义的 font_defaultLabel 文本)。
另外,命令还注册了 refresh 钩子(plugin.js):当 defaultContentStyle.checkApplicable( path, editor, editor.activeFilter ) 为假时把命令置为 CKEDITOR.TRISTATE_DISABLED,例如样式被过滤或在表格等不允许的位置时,整个 combo 会被禁用。
6. 折叠选区下的元素切分:splitElementOnCollapsedRange
重复应用样式时最棘手的场景是光标处于样式元素中间(折叠选区)。若直接 removeStyle,会把整个 <span> 连同未选中的文本一起删掉。splitElementOnCollapsedRange 为此专门实现了三分支切分:
- 空元素(光标同时位于匹配元素的起止边界,如
<span style="font-size:48px"><em>[]</em></span>):用createBookmark保位,逐个insertBefore子节点后移除该元素,结果为<em>[]</em>,不丢失内部其他空行内元素; - 光标恰在元素边界上:把选区移出该元素(
POSITION_BEFORE_START或POSITION_AFTER_END),再由cloneSubtreeIntoRange()(plugin.js)递归克隆元素路径上的中间层,使后续输入继承原有嵌套结构; - 光标在元素中间:
range.splitElement( matching )将<span>一分为二,再把startContainer与匹配元素之间的路径元素克隆进新位置。
配套的自动化测试在 font.js 中覆盖了这些分支,例如:
test apply font size over another font size (collapsed selection):x<span style="font-size:48px">f{}oo</span>x应用 24 后得到x<span ...48px>f</span><span ...24px>bar</span><span ...48px>oo</span>x;test apply font size over another font size (deeply nested collapsed selection):嵌套<em><u>场景下同样保留内层结构(对应 ticket 12690)。
7. 自动化测试对手动场景的直接印证
tests/plugins/font/font.js 顶部有一条关键注释,直接记录了 #584 的行为边界(font.js):
// Click again to exit the style.
// Since 4.8.0, 2nd click on the same menu item does not unselect it.
// It is required to click on the '(Default)' option to reset style (#584).
与 preservestyle.md 各步骤直接对应的断言测试包括:
- 第 4 步(重复应用保持不变):
test reapply this same font size twice对foo连续两次combo.onClick( 24 ),断言最终仍为<p><span style="font-size:24px">foo</span>@</p>;字体侧的test reapply this same font family twice则断言连续两次点击Arial后结果为<span style="font-family:Arial,Helvetica,sans-serif">foo</span>。 - 第 5 步((Default) 重置):
test remove font size from text与test remove font family from text使用fontTools.defaultValue(即'cke-default',定义于 tests/plugins/font/_helpers/tools.js)点击后断言<span>消失。 - UI 状态联动:commandandui.js 验证了
Font/FontSizecombo 会跟随font/fontSize命令的state同步 ON/OFF/DISABLED,且在只读模式下整体禁用。
命令层面的等价验证见 fontcommand.js,其中 test should do nothing when the same font style is applied 与 test should do nothing when the same fontSize style is applied 以 oldStyle === newStyle 的方式直接调用 editor.execCommand,断言 DOM 不变,进一步佐证了第 4 节中 isEqualStyle 分支的效果。
8. 如何执行该手动测试及相关场景
preservestyle 属于 bender 框架下的手动测试:每个场景由一对 .html + .md 文件构成(页面提供编辑器,md 提供步骤与预期),可结合仓库根目录的 bender.js 与 bender.ci.js 配置,通过 bender 的手动测试界面运行,md 文件头部声明的 @bender-ckeditor-plugins 会控制该页面加载的插件集。同一 manual/ 目录下还有多个相关验收场景,可对照阅读:
- reapplystyles.md:重复应用样式的更多排列;
- partialstyles.md、nested...(见目录) 中的
collapsedandnestedspans.md、nestingclasses.md:部分选区与嵌套 span 场景; - preservefonttag.md:旧式
<font>标签的保留/转换行为; - changingfontsize.md:字号变更的更多细节;
- command/applystyle.md、command/removestyle.md:命令层 apply/remove 行为。
9. 小结
preservestyle.md 虽只描述了 5 步手动操作,但其验收点精确对应 font 插件实现中的三处关键逻辑:exec 中基于 isEqualStyle 的"同样式跳过移除"分支、combo onRender 中基于 checkElementMatch 的选区回显与高亮,以及 (Default)(cke-default)触发 removeStyle 的重置路径。对于在 CKEditor 4(当前仓库版本 4.25.1-lts,见 package.json)上定制字体/字号下拉框的开发者,理解这条链路后可以安全地覆写 font_names、fontSize_sizes 与两个 *_style 配置项——它们的解析全部发生在 StyleData 构造函数 中(分号切分条目、斜杠切分显示名与 CSS 值、#(variable) 模板填充),行为不会因配置变化而偏离上述保持/重置语义。
【免费下载链接】ckeditor4
The best enterprise-grade WYSIWYG editor. Fully customizable with countless features and plugins.
更多推荐
所有评论(0)