仓库结构
使用lerna多仓管理,包的目录是modules,整个项目基于ts + gulp 构建
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Gruntfile.js
├── Jenkinsfile
├── LICENSE.TXT
├── README.md
├── SECURITY.md
├── bin
├── build.properties
├── dist -> modules/tinymce/dist
├── js -> modules/tinymce/js
├── lerna.json
├── modules # 多包目录
├── node_modules
├── package.json
├── patches
├── tsconfig.demo.json
├── tsconfig.json
├── tsconfig.shared.json
├── versions.txt
└── yarn.lock
modules 里面除了核心tinymce源码目录,还有其他自包含包,如oxide皮肤与oxide-icons-default图标
./modules
├── acid
├── agar
├── alloy
├── boss
├── boulder
├── bridge
├── darwin
├── dragster
├── jax
├── katamari
├── katamari-assertions
├── mcagar
├── oxide # 图标
├── oxide-icons-default
├── phoenix
├── polaris
├── porkbun
├── robin
├── sand
├── snooker
├── sugar
└── tinymce # tinymce 核心源码与demo
主要指令
yarn build # 构建生产包,如zip,cdn,以及tinymce一些静态依赖,如样式,表情,图标
yarn dev # 图标,皮肤构建,tinymce核心源码ts传js, gulp dev 处理依赖的静态资源
yarn start # 启动本地webpack多页环境
了解源码直接的方式,是看怎么使用,从入口开始
从TinyMceDemo里面看到
declare let tinymce: any;
export default () => {
const textarea = document.createElement("textarea");
textarea.innerHTML = "<p>Bolt</p>";
textarea.classList.add("tinymce");
document.querySelector("#ephox-ui").appendChild(textarea);
tinymce.init({
// imagetools_cors_hosts: ["moxiecode.cachefly.net"],
// imagetools_proxy: "proxy.php",
// imagetools_api_key: '123',
// images_upload_url: 'postAcceptor.php',
// images_upload_base_path: 'base/path',
// images_upload_credentials: true,
skin_url: "../../../../js/tinymce/skins/ui/oxide",
setup: (ed) => {
ed.ui.registry.addButton("demoButton", {
text: "Demo",
onAction: () => {
ed.insertContent("Hello world!");
},
});
},
selector: "textarea.tinymce",
toolbar1: "demoButton bold italic",
menubar: false,
});
};
我们可以发现调用了全局注入的tinymce类进行实例化
查看注入的源代码,会发现这里是针对不同环境进行了模块的暴露: 全局注入,commonjs导出
import { tinymce, TinyMCE } from './Tinymce';
declare const module: any;
declare const window: any;
const exportToModuleLoaders = (tinymce: TinyMCE) => {
if (typeof module === 'object') {
try {
module.exports = tinymce;
} catch (_) {
// It will thrown an error when running this module
// within webpack where the module.exports object is sealed
}
}
};
const exportToWindowGlobal = (tinymce: TinyMCE) => {
window.tinymce = tinymce;
window.tinyMCE = tinymce;
};
exportToWindowGlobal(tinymce);
exportToModuleLoaders(tinymce);
针对暴露的tinymce与TinyMCE,一个是继承了公共API的EditorManager 对象,一个是该对象的类型定义
回到上面的demo代码,我们会看到调用tinymce.int(options) 完成了编辑器的初始化并渲染视图
进入到源码看,就是调用了EditorManager.init(options)
EditorManager
这个类负责了多个Editor实例的创建与管理工作,并基于提供的事件管理将多个Editor类的创建延迟到了视图dom构建完成的时机
init(options: RawEditorOptions) {
const self: EditorManager = this;
let result;
const createId = (elm: HTMLElement & { name?: string }): string => {
let id = elm.id;
if (!id) {
id = Obj.get(elm, 'name').filter((name) => !DOM.get(name)).getOrThunk(DOM.uniqueId);
elm.setAttribute('id', id);
}
return id;
};
const execCallback = (name: string) => {
const callback = options[name];
if (!callback) {
return;
}
return callback.apply(self, []);
};
const findTargets = (options: RawEditorOptions): HTMLElement[] => {
if (Env.browser.isIE() || Env.browser.isEdge()) {
return [];
} else if (isQuirksMode) {
return [];
} else if (Type.isString(options.selector)) {
return DOM.select(options.selector);
} else if (Type.isNonNullable(options.target)) {
return [ options.target ];
} else {
return [];
}
};
let provideResults = (editors) => {
result = editors;
};
const initEditors = () => {
let initCount = 0;
const editors = [];
let targets: HTMLElement[];
const createEditor = (id: string, options: RawEditorOptions, targetElm: HTMLElement) => {
const editor: Editor = new Editor(id, options, self);
editors.push(editor);
editor.on('init', () => {
if (++initCount === targets.length) {
provideResults(editors);
}
});
editor.targetElm = editor.targetElm || targetElm;
editor.render();
};
DOM.unbind(window, 'ready', initEditors);
execCallback('onpageload');
targets = Arr.unique(findTargets(options));
Tools.each(targets, (elm) => {
purgeDestroyedEditor(self.get(elm.id));
});
targets = Tools.grep(targets, (elm) => {
return !self.get(elm.id);
});
if (targets.length === 0) {
provideResults([]);
} else {
each(targets, (elm) => {
createEditor(createId(elm), options, elm);
});
}
};
DOM.bind(window, 'ready', initEditors);
return new Promise((resolve) => {
if (result) {
resolve(result);
} else {
provideResults = (editors) => {
resolve(editors);
};
}
});
},
去除一些针对入参的校验代码,我们会发现初始化很简洁
简单来说:等到dom ready之后,调用了initEditors方法,查找可能存在的dom挂载点(可能有多个),针对每个dom挂载点创建Edtior实例 ,并进行对应的dom挂载,之后调用editor.render() 完成视图渲染,针对脱离文档流的Editor进行销毁
Editor
public constructor(id: string, options: RawEditorOptions, editorManager: EditorManager) {
this.editorManager = editorManager;
// Patch in the EditorObservable functions
extend(this, EditorObservable);
const self = this;
this.id = id;
this.hidden = false;
const normalizedOptions = normalizeOptions(editorManager.defaultOptions, options);
this.options = createOptions(self, normalizedOptions);
Options.register(self);
this.setDirty(false);
this.inline = Options.isInline(self);
this.shortcuts = new Shortcuts(this);
this.editorCommands = new EditorCommands(this);
Commands.registerCommands(this);
this.ui = {
registry: registry(),
styleSheetLoader: undefined,
show: Fun.noop,
hide: Fun.noop,
setEnabled: Fun.noop,
isEnabled: Fun.always
};
this.mode = createMode(self);
// Call setup
editorManager.dispatch('SetupEditor', { editor: this });
const setupCallback = Options.getSetupCallback(self);
if (Type.isFunction(setupCallback)) {
setupCallback.call(self, self);
}
}
去除一些上下文的代码与针对不同环境部署的处理代码,实现编辑器主要能力的为几个模块
Options
对于一个支持丰富插件与配置机制的开源编辑器,如何管理并规范化配置的变更是一个重要的事情,编辑器内部实现了一个Options模块,负责配置的注册,变更与类型校验
Options这个模块,接近1000行的代码,担任起了编辑器上百个开关配置的管理工作
Shortcuts
基于内部封装的事件监听回调,统一处理编辑器的keyup keypress keydown事件,利用策略模式,统一针对注册好的快捷键完成派发,完成自定义的执行逻辑/注册好的command指令
editor.addShortcut('ctrl+a', 'description of the shortcut', () => {
// 执行自定义的逻辑
});
editor.addShortcut('meta+b', '', 'Bold');
// 指定提前注册好的command
editor.on('keyup keypress keydown', (e) => {
if ((self.hasModifier(e) || self.isFunctionKey(e)) && !e.isDefaultPrevented()) {
// 尝试对注册好的快捷键事件描述对象进行匹配
each(self.shortcuts, (shortcut) => {
if (self.matchShortcut(e, shortcut)) {
self.pendingPatterns = shortcut.subpatterns.slice(0);
if (e.type === 'keydown') {
self.executeShortcutAction(shortcut);
}
return true;
}
});
// pendingPatterns生效的前提是shortcut.subpatterns,前提是注册pattern 为 xx > xx 分隔
// 搜索整个仓库并没有在快捷键注册的地方见到带 > 的pattern
if (self.matchShortcut(e, self.pendingPatterns[0])) {
if (self.pendingPatterns.length === 1) {
if (e.type === 'keydown') {
self.executeShortcutAction(self.pendingPatterns[0]);
}
}
self.pendingPatterns.shift();
}
}
});
事件管理
贯穿整个Editor的事件的,包含三个模块,最基础的是EventDispatcher实现了一个事件发布订阅器, 第二个是基于EventDispatcher实现了Observable 监听器,提供给其他模块更加抽象的API,并提供模拟了事件冒泡的能力,第三个是基于Observable的EdtiorObservable扩充了部分支持Editor Dom的方法,为Editor继承
EventDispatcher
常见的事件订阅器,并支持外部通过setting的方式传入首次绑定的触发事件
Observable
Observable一般是作为一个基础对象被其他需要使用到的对象继承,提供了dispatch,on,off,once,hasEventListeners 等被业务常用的方法,每个集成了Observer对象的实例,只有出发相关方法后才会创建独属于它的EventDispatcher 实例,对于外部来说,这个EventDispatcher实例是透明的
EditorObservable
继承自Observable,提供了基于dom(根据事件不同,绑定不同的根结点)原生事件的代理 ,并支持自定义事件的发布订阅,通过pendingEvents,实现了native事件的延迟绑定
Command
该类负责了编辑器核心操作指令与指令状态的注册,执行,管理,并配合Shortcut模块完成快捷键到指令执行的调用
编辑器本身提供了一些内置指令与指令状态的注册,从而丰富了编辑器的各种能力
export const registerCommands = (editor: Editor): void => {
AlignCommands.registerCommands(editor);
ClipboardCommands.registerCommands(editor);
HistoryCommands.registerCommands(editor);
SelectionCommands.registerCommands(editor);
ContentCommands.registerCommands(editor);
LinkCommands.registerCommands(editor);
IndentCommands.registerCommands(editor);
NewlineCommands.registerCommands(editor);
ListCommands.registerCommands(editor);
FormatCommands.registerCommands(editor);
registerExecCommands(editor);
};
const registerExecCommands = (editor: Editor): void => {
editor.editorCommands.addCommands({
mceRemoveNode: (_command, _ui, value) => {
const node = value ?? editor.selection.getNode();
// Make sure that the body node isn't removed
if (node !== editor.getBody()) {
const bm = editor.selection.getBookmark();
editor.dom.remove(node, true);
editor.selection.moveToBookmark(bm);
}
},
mcePrint: () => {
editor.getWin().print();
},
mceFocus: (_command, _ui, value?: boolean) => {
EditorFocus.focus(editor, value);
},
mceToggleVisualAid: () => {
editor.hasVisual = !editor.hasVisual;
editor.addVisual();
}
});
};
做完以上模块的创建与注册之后
调用Render模块,加载该编辑器的资源
Render
渲染这块,分为JS资源与CSS的加载与执行
针对CSS资源,使用StyleSheetLoader进行加载处理
针对JS资源,使用ScriptLoader 进行加载处理
StyleSheetLoader
针对每个挂载点所在的文档模型,如shadowRoot/document ,提供针对外部css的加载与卸载管理,针对传入的css url会创建对应的stylesheetloader并返回成功/失败的promise结果,支持单个跟批量操作
// 传入url返回promise,会在插入stylesheet成功解析后resolve,失败时拒绝
const load = (url: string): Promise<void> =>
new Promise((success, failure) => {
let link: HTMLLinkElement;
const urlWithSuffix = Tools._addCacheSuffix(url);
const state = getOrCreateState(urlWithSuffix);
loadedStates[urlWithSuffix] = state;
state.count++;
const resolve = (callbacks: Array<() => void>, status: number) => {
Arr.each(callbacks, Fun.call);
state.status = status;
state.passed = [];
state.failed = [];
if (link) {
link.onload = null;
link.onerror = null;
link = null;
}
};
const passed = () => resolve(state.passed, 2);
const failed = () => resolve(state.failed, 3);
// Calls the waitCallback until the test returns true or the timeout occurs
const wait = (testCallback: () => boolean, waitCallback: () => void) => {
if (!testCallback()) {
// Wait for timeout
if ((Date.now()) - startTime < maxLoadTime) {
setTimeout(waitCallback);
} else {
failed();
}
}
};
// Workaround for WebKit that doesn't properly support the onload event for link elements
// Or WebKit that fires the onload event before the StyleSheet is added to the document
const waitForWebKitLinkLoaded = () => {
wait(() => {
const styleSheets = documentOrShadowRoot.styleSheets;
let i = styleSheets.length;
while (i--) {
const styleSheet = styleSheets[i];
const owner = styleSheet.ownerNode;
if (owner && (owner as Element).id === link.id) {
passed();
return true;
}
}
return false;
}, waitForWebKitLinkLoaded);
};
if (success) {
state.passed.push(success);
}
if (failure) {
state.failed.push(failure);
}
// Is loading wait for it to pass
if (state.status === 1) {
return;
}
// Has finished loading and was success
if (state.status === 2) {
passed();
return;
}
// Has finished loading and was a failure
if (state.status === 3) {
failed();
return;
}
// Start loading
state.status = 1;
const linkElem = SugarElement.fromTag('link', doc.dom);
Attribute.setAll(linkElem, {
rel: 'stylesheet',
type: 'text/css',
id: state.id
});
const startTime = Date.now();
if (settings.contentCssCors) {
Attribute.set(linkElem, 'crossOrigin', 'anonymous');
}
if (settings.referrerPolicy) {
// Note: Don't use link.referrerPolicy = ... here as it doesn't work on Safari
Attribute.set(linkElem, 'referrerpolicy', settings.referrerPolicy);
}
link = linkElem.dom;
link.onload = waitForWebKitLinkLoaded;
link.onerror = failed;
addStyle(linkElem);
Attribute.set(linkElem, 'href', urlWithSuffix);
});
// 传入url,移除stylesheet
const unload = (url: string) => {
const urlWithSuffix = Tools._addCacheSuffix(url);
Obj.get(loadedStates, urlWithSuffix).each((state) => {
const count = --state.count;
if (count === 0) {
delete loadedStates[urlWithSuffix];
removeStyle(state.id);
}
});
};
ScriptLoader
与styleloader一样,支持多个与单个script url的加载与卸载管理,并对于提供单个或批量接口,返回promise成功/失败代表执行结果。
与styleloader不同的是,script在加载成功后便会执行,会将其移出文档模型(猜测可以减少内存占用 ?),而对于stylesheet要一直存在于文档模型才能生效。
同时,scriptloader支持直接加载script的同时,也支持将其添加进队列,实现延迟加载
AddOnManager
当plugins,model,theme的js静态资源被scriptLoader加入队列加载之后,他们的入口函数会调用对应的addonManager实例,将其初始化函数(指令注册,快捷键注册,options注册)加入注册管理,使用一个name -> fn 的map保存,这有利于针对model,theme,plugins 根据文件级别做动态加载,同时将其暴露的构造函数纳入管理。
同时,在完成scriptLoader相关资源的加载后,保证model跟theme的基础资源被装载进来后,才会进入Init模块的初始化流程。
可以这么认为,插件是拓展了编辑器的基础能力,没有插件编辑器也能跑,但是插件赋予了编辑器更强大更能想象的空间。
如Model的加载
// ./Model.ts
import Editor from 'tinymce/core/api/Editor';
import ModelManager, { Model } from 'tinymce/core/api/ModelManager';
import * as Table from './table/Table';
const DomModel = (editor: Editor): Model => {
const table = Table.setupTable(editor);
return {
table
};
};
// 加载调用ModelManage注册的模块,到Init阶段才进行调用
export default () => {
ModelManager.add('dom', DomModel);
};
// 被scriptLoader加载的模块
import Model from './Model';
Model();
/** *****
* DO NOT EXPORT ANYTHING
*
* IF YOU DO ROLLUP WILL LEAVE A GLOBAL ON THE PAGE
*******/
loadModel -> 调用scriptLoader 加载了 model 的模块代码: Model.ts -> Model.ts 调用ModelManger注册dom的DomDodel方法 -> 加载完其他资源后 -> 调用init -> initModel 注册指令/options/快捷键/添加菜单
这里对于AddOnManager还支持另外一种加载方式,就是在webpack打包环境,考虑自部署环境,不依赖于tinymce的cdn环境,那么可以直接针对plugins,model,theme等模块引入自执行,在init阶段
思考🤔:
借助scriptLoader 与 AddOnManager ,实现加载模块对应的bundle.js并管理对应的注册逻辑 。
如果对于react Plugin组件,有没有好办法去引入呢,目前一个思路,通过单独维护一个 react plugins 数组,来动态import React Plugin组件进行加载,变相实现plugin的按需引入
这么做会不会有加载顺序的问题呢 ? 可以通过一个批量状态管理这些插件初始化后才真正加载编辑器
介绍完三个重要的模块后,针对Render整个流程做一个赘述
在创建完editor实例后,调用Render.render(this)进入加载流程
初始化stylesheetLoader, windowManager,notificationManager,针对不同模式一些兼容处理,将editor纳入editorManager管理,至此你才能从外部通过EdiorManager.get(id)获取到这个editor实例,紧接着调用loadScripts 进入load流程
const loadScripts = (editor: Editor, suffix: string) => {
const scriptLoader = ScriptLoader.ScriptLoader;
const initEditor = () => {
// If the editor has been destroyed or the theme and model haven't loaded then
// don't continue to load the editor
if (!editor.removed && isThemeLoaded(editor) && isModelLoaded(editor)) {
Init.init(editor);
}
};
loadTheme(editor, suffix);
loadModel(editor, suffix);
loadLanguage(scriptLoader, editor);
loadIcons(scriptLoader, editor, suffix);
loadPlugins(editor, suffix);
scriptLoader.loadQueue().then(initEditor, initEditor);
};
每个模块会有对应的manager进行管理,同时都基于scriptLoader 实现了按需加载,当scriptLoader.loadQueue() 承诺被完成的时候,所有模块都已经完成了注册,这下进入了init的逻辑
如果是我们自己实现的icon,plugins等要在这个时机之前完成注册,保证之后在Init能够顺利执行
Init
init 模块在最开始,会调用编辑器派发ScriptsLoaded事件,这方便我们外部得知已经完成了加载,我们可以从这个时候进行模块初始化,会依次执行icons,theme,model,plugins的注册函数,构造UI,注册指令,注册options,并根据是否为inline模式创建真正的容器元素,如普通的div元素/iframe元素,并将其编辑器生成的dom节点插入该容器内,再将容器插入文档,视图真正展示
Init 模块根据外部传入的inline,决定调用不同模式的Render,如Inline 与Iframe,根据各自的特性,实现了tinymce的几个主要模块,如 顶部toolbar,中间编辑器模块(如果是Iframe渲染,则是通过iframe创建的标签实现了编辑器区域,实现了js与样式隔离,如果是inline渲染,则是直接实现了基于div.contenteditable的元素实现各种交互行为),侧边栏,底部状态栏,Sink模块(负责管理popup,如菜单,dialog的显示与行为)
Alloy
用于构建编辑器的整体UI布局与交互的原生UI框架,通过声明式实现了代码的约束下,可维护性。
使得tinymce这个编辑器在多个平台环境如React,Vue,Angular,原生环境都能接入使用
但是alloy 约束的配置与UI,过于抽象与复杂,是专门服务于tinymce的类型系统,以及抽象交互行为的。