TinyMCE Sugar 9.3.0 版本演进全解:DOM 封装库的核心 API 变更与源码深度解析
2026/9/21 18:16:47 网站建设 项目流程

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.isCursorPositioncontenteditable=false的支持、Ready.image异步资源加载、Remove.unwrapReplication.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 操作,如FocusRemoveReplicationInsert
  • events/:事件处理与就绪检测,如DomEventReady
  • node/:节点类型判断,如SugarNodeSugarElement
  • properties/:属性与样式,如AttributeClassContentEditable
  • search/:遍历与查询,如TraverseSelectorFind
  • selection/:选区与光标位置,如AwarenessWindowSelection
  • view/:视口与尺寸,如WidthHeightWindowVisualViewport

下面按照版本号从新到旧的顺序,逐一解析 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函数的改进:

TheFocus.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)若元素内部尚无焦点则聚焦元素本身

其中activesearch均通过SugarShadowDom.getRootNode支持 Shadow DOM 场景。仓库中的测试 FocusTest.ts 覆盖了普通文档与 ShadowRoot 两种环境下activesearchhasFocusfocusInside的行为,例如验证 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判断一个节点是否可以作为光标位置,共三种情况:

  1. 非空文本节点:文本内容去除空白后非空,或包含&nbsp;(Unicode.nbsp),见isTextNodeWithCursorPosition
  2. 固有光标元素imgbr
  3. contenteditable="false"的 HTML 元素(9.2.0 新增)。

Awareness模块还配套提供getEndisEndisStart等函数,用于计算元素的光标边界(文本节点取其字符长度,img固定为 1,其余取子节点数)。

四、9.1.0:SugarNode.isHTMLElement增加nodeType前置校验(2022-09-08)

变更内容

TheSugarNode.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);

其中isElementisType<Element>(NodeTypes.ELEMENT),即校验nodeType === 1。这一前置检查避免了对文本节点、注释节点等非元素节点调用SandHTMLElement.isPrototypeOf时可能出现的误判或性能开销,使类型守卫更加严谨。isHTMLElementisTagisTextisDocument等共同构成了 Sugar 基于nodeType的节点类型判别体系。

五、9.0.0:破坏性变更与跨浏览器支持收缩(2022-03-03)

9.0.0 是这一系列中变更最密集的版本,包含新增、变更、移除、修复四类改动。

5.1 新增Ready.image:图片加载完成后再继续

NewReady.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同时监听loaderror事件;
  • 若图片已经缓存完成(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

RenamedReady.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.unwrapReplication.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复制全部属性),插入到原节点之后,把原节点的所有子节点搬入新节点,最后删除原节点——例如在表格单元格tdth之间切换时即可复用该逻辑(源码注释中也提到了这一使用场景)。

5.4 升级 Katamari 9.0 与移除旧浏览器支持

Upgraded to Katamari 9.0, which includes breaking changes to theOptionalAPI used in this module. Removed support for Microsoft Internet Explorer and legacy Microsoft Edge.

Katamari 是 Sugar 的基础工具库(OptionalArrObj等均来自@ephox/katamari)。9.0.0 同步升级到 Katamari 9.0,其OptionalAPI 的破坏性变更(如getOrDiefold等签名调整)会传导到 Sugar 的公开接口,因此本次也属于破坏性版本。同时,Sugar 正式移除对 IE 与旧版 Edge(EdgeHTML 内核)的支持,后续代码可以依赖现代浏览器 API(如classListPromise、Shadow DOM)而无需降级兼容——这一点在后续 11.0.0 的 CHANGELOG 中也有呼应("Fallback code which was only required on browsers that are no longer supported")。

5.5 修复Class.toggle的空 class 属性残留

TheClass.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'); } };

removetoggle在操作完成后都会调用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模块还提供ownerparentssiblingsprevSiblingnextSiblingchildrenleaf等遍历原语,parents支持传入isRoot谓词提前终止向上遍历,适用于需要"沿祖先链搜索但不超过某个边界"的场景。

6.2 新增Attribute.setOptionsCss.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.getInnergetRuntime

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误报

Disabledwindow.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 newContentEditablemodule 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.02023-11-22ImprovedFocus.focus新增preventScroll参数
9.2.02023-03-15ChangedAwareness.isCursorPositioncontenteditable=false返回true
9.1.02022-09-08ImprovedSugarNode.isHTMLElement先校验nodeType === 1
9.0.02022-03-03Breaking新增Ready.imageReady.execute更名Ready.documentRemove.unwrap/Replication.mutate改后插;升级 Katamari 9.0;移除 IE/旧 Edge 支持;修复Class.toggle
8.1.02021-10-11Added/Fixed新增Traverse.parentElementAttribute.setOptionsWidth/HeightgetInner/getRuntime;禁用 FirefoxvisualViewport
8.0.02021-08-26Breaking新增ContentEditable模块;升级 Katamari 8.0

针对不同场景的升级建议:

  • 从 8.x 升 9.x:重点关注Ready.executeReady.document的重命名,以及Remove.unwrapReplication.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异步加载、parentElementsetOptions系列、尺寸测量 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),仅供参考

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

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

立即咨询