TinyMCE Sugar 9.3.0 版本演进全解:DOM 封装库的核心 API 变更与源码深度解析
【免费下载链接】tinymceThe world's #1 JavaScript library for rich text editing. Available for React, Vue and Angular项目地址: https://gitcode.com/gh_mirrors/ti/tinymce
导读
Sugar 是 TinyMCE 开源仓库中的核心 DOM 操作库,它为原生浏览器 DOM API 提供了类型安全、函数式的封装。本文基于仓库内 modules/sugar/CHANGELOG.md 与 .changes/sugar/9.3.0.md 的版本记录,系统梳理 Sugar 从 8.0.0 到 9.3.0 的关键 API 演进:包括Focus.focus的防滚动聚焦、Awareness.isCursorPosition对contenteditable=false的支持、Ready.image异步资源加载、Remove.unwrap与Replication.mutate插入顺序调整,以及ContentEditable新模块的引入。读完本文,你将理解这些变更背后的设计动机与源码实现,并掌握在 TinyMCE 生态中正确使用这些 API 的实战方法。
一、Sugar 库在 TinyMCE 生态中的定位
Sugar(位于 modules/sugar)是 TinyMCE 底层工具链的一部分,同 Katamari(函数式工具集)、Sand(跨浏览器平台检测)、Alloy(UI 组件框架)等模块协同工作。它以SugarElement<T>这一轻量包装结构为核心——内部仅持有原生 DOM 节点的引用,通过函数式 API 完成对节点、属性、样式、事件、选区、尺寸等领域的操作。
Sugar 的源码目录结构清晰地划分了职责领域(见 modules/sugar/src/main/ts/ephox/sugar/api):
dom/:DOM 操作,如Focus、Remove、Replication、Insertevents/:事件处理与就绪检测,如DomEvent、Readynode/:节点类型判断,如SugarNode、SugarElementproperties/:属性与样式,如Attribute、Class、ContentEditablesearch/:遍历与查询,如Traverse、SelectorFindselection/:选区与光标位置,如Awareness、WindowSelectionview/:视口与尺寸,如Width、Height、WindowVisualViewport
下面按照版本号从新到旧的顺序,逐一解析 9.3.0、9.2.0、9.1.0、9.0.0、8.1.0、8.0.0 六个版本的核心变更。
二、9.3.0:Focus.focus新增preventScroll参数(2023-11-22)
变更内容
9.3.0 版本的唯一变更是对Focus.focus函数的改进:
The
Focus.focusfunction now takes an additionalpreventScrollparameter to allow focus on an element without scrolling.
即:Focus.focus现在接受一个额外的preventScroll参数,允许在不滚动页面的情况下将焦点赋予元素。
源码实现
查看 Focus.ts:
const focus = (element: SugarElement<HTMLElement>, preventScroll: boolean = false): void => element.dom.focus({ preventScroll });实现非常简洁:Sugar 将preventScroll直接透传给原生HTMLElement.focus()的 options 对象。默认值为false,因此这是一个完全向后兼容的增强——现有调用Focus.focus(element)的代码行为不变,仍会触发滚动。
实战用法
import * as Focus from 'ephox/sugar/api/dom/Focus'; import { SugarElement } from 'ephox/sugar/api/node/SugarElement'; const input = SugarElement.fromTag('input'); // 聚焦但不滚动页面(例如恢复编辑器光标时避免视口跳动) Focus.focus(input, true);同族 API 一览
Focus模块还提供以下相关函数(Focus.ts):
| 函数 | 说明 |
|---|---|
focus(element, preventScroll?) | 聚焦指定元素 |
blur(element) | 使元素失焦 |
hasFocus(element) | 判断元素是否持有焦点(通过root.activeElement比对) |
active(root?) | 返回当前焦点元素(Optional<SugarElement<T>>),支持传入 ShadowRoot |
search(element) | 查找元素内部已聚焦的后代,优先于:focus选择器(不依赖键盘焦点状态) |
focusInside(element) | 若元素内部尚无焦点则聚焦元素本身 |
其中active与search均通过SugarShadowDom.getRootNode支持 Shadow DOM 场景。仓库中的测试 FocusTest.ts 覆盖了普通文档与 ShadowRoot 两种环境下active、search、hasFocus、focusInside的行为,例如验证 ShadowRoot 的 activeElement 是内部输入框、而 Document 的 activeElement 是 shadow host。
三、9.2.0:Awareness.isCursorPosition支持contenteditable=false(2023-03-15)
变更内容
Awareness.isCursorPositionAPI now returnstruefor passedcontenteditable=falseelements.
即:Awareness.isCursorPosition现在对contenteditable="false"的元素返回true。这意味着非可编辑元素也可以成为合法的光标停靠位置,这对 TinyMCE 中处理图片、嵌入对象等不可编辑内容的光标定位至关重要。
源码实现
awareness.ts 中的判断逻辑:
const isContentEditableFalse = (elem: SugarElement<Node>) => SugarNode.isHTMLElement(elem) && (Attribute.get(elem, 'contenteditable') === 'false'); const elementsWithCursorPosition = [ 'img', 'br' ]; const isCursorPosition = (elem: SugarElement<Node>): boolean => { const hasCursorPosition = isTextNodeWithCursorPosition(elem); return hasCursorPosition || Arr.contains(elementsWithCursorPosition, SugarNode.name(elem)) || isContentEditableFalse(elem); };isCursorPosition判断一个节点是否可以作为光标位置,共三种情况:
- 非空文本节点:文本内容去除空白后非空,或包含
(Unicode.nbsp),见isTextNodeWithCursorPosition; - 固有光标元素:
img与br; contenteditable="false"的 HTML 元素(9.2.0 新增)。
Awareness模块还配套提供getEnd、isEnd、isStart等函数,用于计算元素的光标边界(文本节点取其字符长度,img固定为 1,其余取子节点数)。
四、9.1.0:SugarNode.isHTMLElement增加nodeType前置校验(2022-09-08)
变更内容
The
SugarNode.isHTMLElementfunction now ensures thenodeTypeis1before checking the prototypes.
即:SugarNode.isHTMLElement在检查原型链之前,先确保nodeType === 1(元素节点)。
源码实现
SugarNode.ts:
const isHTMLElement = (element: SugarElement<Node>): element is SugarElement<HTMLElement> => isElement(element) && SandHTMLElement.isPrototypeOf(element.dom);其中isElement是isType<Element>(NodeTypes.ELEMENT),即校验nodeType === 1。这一前置检查避免了对文本节点、注释节点等非元素节点调用SandHTMLElement.isPrototypeOf时可能出现的误判或性能开销,使类型守卫更加严谨。isHTMLElement与isTag、isText、isDocument等共同构成了 Sugar 基于nodeType的节点类型判别体系。
五、9.0.0:破坏性变更与跨浏览器支持收缩(2022-03-03)
9.0.0 是这一系列中变更最密集的版本,包含新增、变更、移除、修复四类改动。
5.1 新增Ready.image:图片加载完成后再继续
New
Ready.imagefunction that returns a promise which will not resolve until the image element has loaded. Errors trigger promise rejection.
Ready.ts 的实现:
const image = (image: SugarElement<HTMLImageElement>): Promise<SugarElement<HTMLImageElement>> => new Promise((resolve, reject) => { const loaded = () => { destroy(); resolve(image); }; const listeners = [ DomEvent.bind(image, 'load', loaded), DomEvent.bind(image, 'error', () => { destroy(); reject('Unable to load data from image: ' + image.dom.src); }), ]; const destroy = () => Arr.each(listeners, (l) => l.unbind()); if (image.dom.complete) { loaded(); } });实现要点:
- 通过
DomEvent.bind同时监听load与error事件; - 若图片已经缓存完成(
image.dom.complete === true),立即 resolve,避免死等; - 加载失败时 reject 并携带图片
src信息,方便排查; - 无论成功失败都会解绑监听器,避免内存泄漏。
实战示例:
import * as Ready from 'ephox/sugar/api/events/Ready'; const img = SugarElement.fromTag('img'); img.dom.src = 'https://example.com/hero.png'; Ready.image(img).then( (loaded) => console.log('图片加载完成', loaded.dom.src), (err) => console.error('加载失败', err) );同文件还提供了Ready.document(即 9.0.0 之前的Ready.execute)与Ready.video。其中Ready.document根据document.readyState判断:若已是'complete'或'interactive'则立即执行回调,否则监听DOMContentLoaded后执行一次并解绑(Ready.ts)。
5.2Ready.execute更名为Ready.document
Renamed
Ready.executetoReady.document, for better clarity on what it does
这是一次破坏性命名变更:旧名称Ready.execute语义模糊,新名称Ready.document明确表达了"等待文档就绪"的意图。迁移时需将Ready.execute(fn)改为Ready.document(fn)。模块导出语句也印证了这一点(Ready.ts):
export { documentReady as document, image, video };5.3Remove.unwrap与Replication.mutate插入顺序调整
Remove.unwrapAPI now inserts children after the current node, instead of before.Replication.mutateAPI now inserts the replacement node after the current node, instead of before.
这两处调整将"解包/替换"操作中原有的前插(before)改为后插(after)。从 DOM 遍历语义看,新顺序更符合直觉:子节点或替换节点紧跟在原节点之后,保持后续兄弟节点的相对位置稳定。
查看 Remove.ts:
const unwrap = (wrapper: SugarElement<Node>): void => { const children = Traverse.children(wrapper); if (children.length > 0) { InsertAll.after(wrapper, children); } remove(wrapper); };以及 Replication.ts:
const mutate = <K extends keyof HTMLElementFullTagNameMap> (original: SugarElement<Element>, tag: K): SugarElement<HTMLElementFullTagNameMap[K]> => { const nu = shallowAs(original, tag); Insert.after(original, nu); const children = Traverse.children(original); InsertAll.append(nu, children); Remove.remove(original); return nu; };unwrap的典型应用是"去掉包裹层":例如将<b><span>text</span></b>中的<b>去掉,子节点span会被移到<b>之后(即原位置),再删除<b>本身。mutate则用于"原地换标签":先创建一个同属性新标签(shallowAs会通过Attribute.clone复制全部属性),插入到原节点之后,把原节点的所有子节点搬入新节点,最后删除原节点——例如在表格单元格td与th之间切换时即可复用该逻辑(源码注释中也提到了这一使用场景)。
5.4 升级 Katamari 9.0 与移除旧浏览器支持
Upgraded to Katamari 9.0, which includes breaking changes to the
OptionalAPI used in this module. Removed support for Microsoft Internet Explorer and legacy Microsoft Edge.
Katamari 是 Sugar 的基础工具库(Optional、Arr、Obj等均来自@ephox/katamari)。9.0.0 同步升级到 Katamari 9.0,其OptionalAPI 的破坏性变更(如getOrDie、fold等签名调整)会传导到 Sugar 的公开接口,因此本次也属于破坏性版本。同时,Sugar 正式移除对 IE 与旧版 Edge(EdgeHTML 内核)的支持,后续代码可以依赖现代浏览器 API(如classList、Promise、Shadow DOM)而无需降级兼容——这一点在后续 11.0.0 的 CHANGELOG 中也有呼应("Fallback code which was only required on browsers that are no longer supported")。
5.5 修复Class.toggle的空 class 属性残留
The
Class.toggleAPI didn't cleanup the class attribute when empty.
修复前,当最后一个 class 被 toggle 掉后,元素上会残留空的class=""属性。修复方式是在 Class.ts 中引入cleanClass:
const cleanClass = (element: SugarElement<Element>): void => { const classList = ClassList.supports(element) ? element.dom.classList : ClassList.get(element); // classList is a "live list", so this is up to date already if (classList.length === 0) { // No more classes left, remove the class attribute as well Attribute.remove(element, 'class'); } };remove与toggle在操作完成后都会调用cleanClass:当classList长度为 0 时,通过Attribute.remove彻底移除class属性。toggler工厂函数的off回调也做了同样处理。这样生成的 DOM 更干净,也避免了一些对空 class 属性敏感的 CSS 选择器或序列化场景出现问题。
六、8.1.0:遍历、批量属性与尺寸 API 扩充(2021-10-11)
6.1 新增Traverse.parentElement
parentElement返回元素的父元素(Optional<SugarElement<HTMLElement>>),与parent/parentNode的区别在于:它基于原生element.parentElement,只会命中元素节点,跳过文本节点等非元素父节点(Traverse.ts):
const parentElement = (element: SugarElement<Node>): Optional<SugarElement<HTMLElement>> => Optional.from(element.dom.parentElement).map(SugarElement.fromDom);Traverse模块还提供owner、parents、siblings、prevSibling、nextSibling、children、leaf等遍历原语,parents支持传入isRoot谓词提前终止向上遍历,适用于需要"沿祖先链搜索但不超过某个边界"的场景。
6.2 新增Attribute.setOptions与Css.setOptions
Attribute.setOptions接受一个值为Optional的批量属性表:值为some(v)时设置属性,为none()时移除该属性(Attribute.ts):
const setOptions = (element: SugarElement<Element>, attrs: Record<string, Optional<string | boolean | number>>): void => { Obj.each(attrs, (v, k) => { v.fold(() => { remove(element, k); }, (value) => { rawSet(element.dom, k, value); }); }); };实战示例——根据条件设置disabled或移除:
import * as Attribute from 'ephox/sugar/api/properties/Attribute'; import { Optional } from '@ephox/katamari'; const disabled = Optional.some('true'); Attribute.setOptions(button, { disabled, title: Optional.none() }); // disabled 被设为 "true",title 被移除与之对应的底层rawSet仅接受 string / boolean / number 三类值,其余类型会console.error并抛错,避免把非法值写入 DOM。Css.setOptions语义相同(值为Optional<string>),用于按条件设置或清除内联样式(Css.ts)。
6.3 新增Width.getInner/Height.getInner与getRuntime
8.1.0 为尺寸 API 增加了四个函数:
Width.getInner/Height.getInner:获取元素的内容区尺寸(不含 padding/border,对应clientWidth/clientHeight);Width.getRuntime/Height.getRuntime:获取运行时实际渲染尺寸。
实现位于 Width.ts 与 Height.ts,内部委托给 impl/RuntimeSize.ts 完成具体测量。这让调用方可以按需选择"文档声明的 CSS 尺寸"与"浏览器实际布局后的尺寸",在计算滚动容器、弹层定位等场景中非常实用。
6.4 修复 Firefox 的window.visualViewport误报
Disabled
window.visualViewportin Mozilla Firefox as it was returning an incorrect value forpageTopwhen usingposition: 'fixed'.
WindowVisualViewport.ts 中通过平台检测禁用了 Firefox 下的visualViewport:
const get = (_win?: Window): Optional<VisualViewport> => { const win = _win === undefined ? window : _win; if (PlatformDetection.detect().browser.isFirefox()) { // TINY-7984: Firefox 91 is returning incorrect values for visualViewport.pageTop, so disable it for now return Optional.none(); } else { return Optional.from(win.visualViewport); } };源码注释引用了内部问题号 TINY-7984:Firefox 91 在position: fixed场景下visualViewport.pageTop返回值不正确,因此 Sugar 对 Firefox 回退到documentElement.clientWidth/clientHeight加滚动偏移的方式计算边界(getBounds)。getBounds中还对 iOS 的pageLeft/pageTop与滚动位置取了最大值(Math.max),以规避scrollIntoView()不更新 pageTop 的兼容性问题。
七、8.0.0:新增ContentEditable模块(2021-08-26)
变更内容
Added new
ContentEditablemodule to determine if an HTML element is content editable.
8.0.0 引入了独立的ContentEditable模块,集中处理"元素是否可编辑"的判断与设置,此前这类逻辑散落在各处。
源码实现
ContentEditable.ts 提供五个函数:
| 函数 | 说明 |
|---|---|
get(element) | 返回元素是否可编辑(布尔值) |
getRaw(element) | 返回原生element.dom.contentEditable字符串('true'/'false'/'inherit') |
set(element, editable) | 设置contentEditable为'true'或'false' |
closest(target) | 沿祖先链查找最近的[contenteditable]元素 |
isEditable(element, assumeEditable?) | 综合判断可编辑性 |
isEditable的实现值得注意(ContentEditable.ts):
const isEditable = (element: SugarElement<HTMLElement>, assumeEditable: boolean = false): boolean => { if (SugarBody.inBody(element)) { return element.dom.isContentEditable; } else { // Find the closest contenteditable element and check if it's editable return closest(element).fold( Fun.constant(assumeEditable), (editable) => getRaw(editable) === 'true' ); } };- 元素已在文档中时,直接使用浏览器计算后的
isContentEditable(会综合继承状态); - 元素尚未挂载到文档时,
isContentEditable不可靠,改为向上查找最近的[contenteditable]祖先,判断其原始属性是否为'true';若找不到任何祖先,则回退到assumeEditable参数(默认false)。
与 9.2.0 变更的呼应
8.0.0 的ContentEditable模块与 9.2.0 的Awareness.isCursorPosition变更形成了完整的能力闭环:ContentEditable负责判断和设置可编辑性,Awareness.isCursorPosition负责在光标定位时承认contenteditable=false元素是合法的光标停靠点。两者共同支撑 TinyMCE 在富文本中处理不可编辑内容(如图片、嵌入对象)时的选区与光标逻辑。
升级注意
8.0.0 同样声明升级了 Katamari 8.0(OptionalAPI 存在破坏性变更),这意味着使用 Sugar 8.0.0+ 时需同步升级依赖的 Katamari 版本。
八、版本演进速查表与升级建议
| 版本 | 日期 | 类型 | 核心内容 |
|---|---|---|---|
| 9.3.0 | 2023-11-22 | Improved | Focus.focus新增preventScroll参数 |
| 9.2.0 | 2023-03-15 | Changed | Awareness.isCursorPosition对contenteditable=false返回true |
| 9.1.0 | 2022-09-08 | Improved | SugarNode.isHTMLElement先校验nodeType === 1 |
| 9.0.0 | 2022-03-03 | Breaking | 新增Ready.image;Ready.execute更名Ready.document;Remove.unwrap/Replication.mutate改后插;升级 Katamari 9.0;移除 IE/旧 Edge 支持;修复Class.toggle |
| 8.1.0 | 2021-10-11 | Added/Fixed | 新增Traverse.parentElement、Attribute.setOptions、Width/Height的getInner/getRuntime;禁用 FirefoxvisualViewport |
| 8.0.0 | 2021-08-26 | Breaking | 新增ContentEditable模块;升级 Katamari 8.0 |
针对不同场景的升级建议:
- 从 8.x 升 9.x:重点关注
Ready.execute→Ready.document的重命名,以及Remove.unwrap、Replication.mutate插入顺序变化对 DOM 结果的潜在影响(若你的代码依赖"子节点被插入到原节点之前"的旧行为,需要调整断言或逻辑);同时确认目标运行环境已不再需要兼容 IE/旧版 Edge。 - 使用
Focus.focus且有滚动副作用困扰:直接升级到 9.3.0,传第二个参数true即可禁止聚焦时滚动。 - 处理不可编辑内容的光标定位:确保使用 9.2.0+,配合 8.0.0 引入的
ContentEditable模块统一管理可编辑状态。
九、总结
从 8.0.0 到 9.3.0,Sugar 的演进脉络清晰可循:一方面持续扩充能力(ContentEditable模块、Ready.image/Ready.video异步加载、parentElement、setOptions系列、尺寸测量 API);另一方面不断打磨健壮性与可用性(nodeType前置校验、空 class 清理、Firefox visualViewport 规避、preventScroll聚焦、光标位置判定增强);同时通过破坏性版本(8.0.0、9.0.0)果断收缩浏览器支持面并重构语义不清晰的 API(Ready.document更名、插入顺序统一为后插)。
这些变更的源码与测试均可直接在仓库中查阅:核心实现在 modules/sugar/src/main/ts/ephox/sugar/api,浏览器行为验证可参考 FocusTest.ts 等测试文件。对于 TinyMCE 的二次开发者或 Sugar 的直接使用者而言,理解这些版本差异,是在升级过程中避免回归、并充分发挥 Sugar 能力的关键。
【免费下载链接】tinymceThe world's #1 JavaScript library for rich text editing. Available for React, Vue and Angular项目地址: https://gitcode.com/gh_mirrors/ti/tinymce
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考