Repository: wangeditor-team/wangEditor Branch: master Commit: f35d8a73a0a3 Files: 687 Total size: 1.0 MB Directory structure: gitextract_27463ctz/ ├── .browserslistrc ├── .cz-config.js ├── .eslintignore ├── .eslintrc.js ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug.md │ │ ├── feature.md │ │ └── question.md │ └── workflows/ │ ├── deploy-demos.yml │ ├── deploy-examples.yml.bak │ ├── e2e.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc.js ├── .vscode/ │ └── settings.json ├── .yarnrc ├── CHANGELOG.md ├── LICENSE ├── README-en.md ├── README.md ├── babel.config.json ├── build/ │ ├── build-all.sh │ ├── config/ │ │ ├── common.js │ │ ├── dev.js │ │ └── prd.js │ └── create-rollup-config.js ├── commitlint.config.js ├── cypress/ │ ├── cypress.d.ts │ ├── fixtures/ │ │ └── example.json │ ├── integration/ │ │ └── editor.spec.ts │ ├── plugins/ │ │ └── index.ts │ ├── support/ │ │ ├── commands.ts │ │ └── index.ts │ └── tsconfig.json ├── cypress.json ├── docs/ │ ├── README.md │ ├── dev.md │ ├── join.md │ ├── publish.md │ └── test.md ├── jest.config.js ├── lerna.json ├── package.json ├── packages/ │ ├── basic-modules/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── __tests__/ │ │ │ ├── blockquote/ │ │ │ │ ├── blockquote-menu.test.ts │ │ │ │ ├── elem-to-html.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── plugin.test.ts │ │ │ │ └── render-elem.test.ts │ │ │ ├── code-block/ │ │ │ │ ├── code-block-menu.test.ts │ │ │ │ ├── elem-to-html.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── plugin.test.ts │ │ │ │ └── render-elem.test.ts │ │ │ ├── color/ │ │ │ │ ├── color-menus.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── render-text-style.test.tsx │ │ │ │ └── text-style-to-html.test.ts │ │ │ ├── divider/ │ │ │ │ ├── elem-to-html.test.ts │ │ │ │ ├── insert-divider-menu.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── plugin.test.ts │ │ │ │ └── render-elem.test.ts │ │ │ ├── emotion/ │ │ │ │ └── emotion-menu.test.ts │ │ │ ├── font-size-family/ │ │ │ │ ├── menu/ │ │ │ │ │ ├── font-family-menu.test.ts │ │ │ │ │ └── font-size-menu.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── render-text-style.test.tsx │ │ │ │ └── text-style-to-html.test.ts │ │ │ ├── full-screen/ │ │ │ │ └── full-screen-menu.test.ts │ │ │ ├── header/ │ │ │ │ ├── elem-to-html.test.ts │ │ │ │ ├── helper.test.ts │ │ │ │ ├── menu/ │ │ │ │ │ ├── header-select-menu.test.ts │ │ │ │ │ └── header1-menu.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── plugin.test.ts │ │ │ │ └── render-elem.test.ts │ │ │ ├── image/ │ │ │ │ ├── elem-to-html.test.ts │ │ │ │ ├── helper.test.ts │ │ │ │ ├── menu/ │ │ │ │ │ ├── del-image.test.ts │ │ │ │ │ ├── edit-image.test.ts │ │ │ │ │ ├── insert-image.test.ts │ │ │ │ │ ├── view-image-link.test.ts │ │ │ │ │ └── width-menus.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── plugin.test.ts │ │ │ │ └── render-elem.test.ts │ │ │ ├── indent/ │ │ │ │ ├── menu/ │ │ │ │ │ ├── decrease-indent-menu.test.ts │ │ │ │ │ └── increase-indent-menu.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── render-text-style.test.tsx │ │ │ │ └── text-style-to-html.test.ts │ │ │ ├── justify/ │ │ │ │ ├── menus.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── render-text-style.test.tsx │ │ │ │ └── text-style-to-html.test.ts │ │ │ ├── line-height/ │ │ │ │ ├── line-height-menu.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── render-text-style.test.tsx │ │ │ │ └── text-style-to-html.test.ts │ │ │ ├── link/ │ │ │ │ ├── elem-to-html.test.ts │ │ │ │ ├── helper.test.ts │ │ │ │ ├── menu/ │ │ │ │ │ ├── edit-link-menu.test.ts │ │ │ │ │ ├── insert-link-menu.test.ts │ │ │ │ │ ├── unlink-menu.test.ts │ │ │ │ │ └── view-link-menu.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── plugin.test.ts │ │ │ │ └── render-elem.test.ts │ │ │ ├── paragraph/ │ │ │ │ ├── elem-to-html.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── plugin.test.ts │ │ │ │ └── render-elem.test.ts │ │ │ ├── text-style/ │ │ │ │ ├── menu/ │ │ │ │ │ ├── clear-style-menu.test.ts │ │ │ │ │ └── menus.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── parse-style-html.test.ts │ │ │ │ ├── text-style.test.tsx │ │ │ │ └── text-to-html.test.ts │ │ │ ├── todo/ │ │ │ │ ├── elem-to-html.test.ts │ │ │ │ ├── menu/ │ │ │ │ │ └── todo-menu.test.ts │ │ │ │ ├── parse-html.test.ts │ │ │ │ ├── plugin.test.ts │ │ │ │ ├── pre-parse-html.test.ts │ │ │ │ └── render-elem.test.ts │ │ │ └── undo-redo/ │ │ │ ├── redo-menu.test.ts │ │ │ └── undo-menu.test.ts │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── assets/ │ │ │ │ ├── blockquote.less │ │ │ │ ├── code-block.less │ │ │ │ ├── color.less │ │ │ │ ├── divider.less │ │ │ │ ├── emotion.less │ │ │ │ ├── image.less │ │ │ │ ├── index.less │ │ │ │ └── simple-style.less │ │ │ ├── constants/ │ │ │ │ └── icon-svg.ts │ │ │ ├── index.ts │ │ │ ├── locale/ │ │ │ │ ├── en.ts │ │ │ │ ├── index.ts │ │ │ │ └── zh-CN.ts │ │ │ ├── modules/ │ │ │ │ ├── blockquote/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── elem-to-html.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── BlockquoteMenu.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-elem-html.ts │ │ │ │ │ ├── plugin.ts │ │ │ │ │ └── render-elem.tsx │ │ │ │ ├── code-block/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── elem-to-html.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── CodeBlockMenu.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-elem-html.ts │ │ │ │ │ ├── plugin.ts │ │ │ │ │ ├── pre-parse-html.ts │ │ │ │ │ └── render-elem.tsx │ │ │ │ ├── color/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── BaseMenu.ts │ │ │ │ │ │ ├── BgColorMenu.ts │ │ │ │ │ │ ├── ColorMenu.ts │ │ │ │ │ │ ├── config.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-style-html.ts │ │ │ │ │ ├── pre-parse-html.ts │ │ │ │ │ ├── render-style.tsx │ │ │ │ │ └── style-to-html.ts │ │ │ │ ├── common/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── menu/ │ │ │ │ │ ├── EnterMenu.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── divider/ │ │ │ │ │ ├── README.md │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── elem-to-html.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── DeleteDividerMenu.ts.bak │ │ │ │ │ │ ├── InsertDividerMenu.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-elem-html.ts │ │ │ │ │ ├── plugin.ts │ │ │ │ │ └── render-elem.tsx │ │ │ │ ├── emotion/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── menu/ │ │ │ │ │ ├── EmotionMenu.ts │ │ │ │ │ ├── config.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── font-size-family/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── BaseMenu.ts │ │ │ │ │ │ ├── FontFamilyMenu.ts │ │ │ │ │ │ ├── FontSizeMenu.ts │ │ │ │ │ │ ├── config.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-style-html.ts │ │ │ │ │ ├── pre-parse-html.ts │ │ │ │ │ ├── render-style.tsx │ │ │ │ │ └── style-to-html.ts │ │ │ │ ├── full-screen/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── menu/ │ │ │ │ │ ├── FullScreen.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── header/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── elem-to-html.ts │ │ │ │ │ ├── helper.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── Header1ButtonMenu.ts │ │ │ │ │ │ ├── Header2ButtonMenu.ts │ │ │ │ │ │ ├── Header3ButtonMenu.ts │ │ │ │ │ │ ├── Header4ButtonMenu.ts │ │ │ │ │ │ ├── Header5ButtonMenu.ts │ │ │ │ │ │ ├── HeaderButtonMenuBase.ts │ │ │ │ │ │ ├── HeaderSelectMenu.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-elem-html.ts │ │ │ │ │ ├── plugin.ts │ │ │ │ │ └── render-elem.tsx │ │ │ │ ├── image/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── elem-to-html.ts │ │ │ │ │ ├── helper.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── DeleteImage.ts │ │ │ │ │ │ ├── EditImage.ts │ │ │ │ │ │ ├── InsertImage.ts │ │ │ │ │ │ ├── ViewImageLink.ts │ │ │ │ │ │ ├── Width100.ts │ │ │ │ │ │ ├── Width30.ts │ │ │ │ │ │ ├── Width50.ts │ │ │ │ │ │ ├── WidthBase.ts │ │ │ │ │ │ ├── config.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-elem-html.ts │ │ │ │ │ ├── plugin.ts │ │ │ │ │ └── render-elem.tsx │ │ │ │ ├── indent/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── BaseMenu.ts │ │ │ │ │ │ ├── DecreaseIndentMenu.ts │ │ │ │ │ │ ├── IncreaseIndentMenu.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-style-html.ts │ │ │ │ │ ├── pre-parse-html.ts │ │ │ │ │ ├── render-style.tsx │ │ │ │ │ └── style-to-html.ts │ │ │ │ ├── justify/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── BaseMenu.ts │ │ │ │ │ │ ├── JustifyCenterMenu.ts │ │ │ │ │ │ ├── JustifyJustifyMenu.ts │ │ │ │ │ │ ├── JustifyLeftMenu.ts │ │ │ │ │ │ ├── JustifyRightMenu.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-style-html.ts │ │ │ │ │ ├── render-style.tsx │ │ │ │ │ └── style-to-html.ts │ │ │ │ ├── line-height/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── LineHeightMenu.ts │ │ │ │ │ │ ├── config.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-style-html.ts │ │ │ │ │ ├── render-style.tsx │ │ │ │ │ └── style-to-html.ts │ │ │ │ ├── link/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── elem-to-html.ts │ │ │ │ │ ├── helper.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── EditLink.ts │ │ │ │ │ │ ├── InsertLink.ts │ │ │ │ │ │ ├── UnLink.ts │ │ │ │ │ │ ├── ViewLink.ts │ │ │ │ │ │ ├── config.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-elem-html.ts │ │ │ │ │ ├── plugin.ts │ │ │ │ │ └── render-elem.tsx │ │ │ │ ├── paragraph/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── elem-to-html.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── parse-elem-html.ts │ │ │ │ │ ├── plugin.ts │ │ │ │ │ └── render-elem.tsx │ │ │ │ ├── text-style/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── helper.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── BaseMenu.ts │ │ │ │ │ │ ├── BoldMenu.ts │ │ │ │ │ │ ├── ClearStyleMenu.ts │ │ │ │ │ │ ├── CodeMenu.ts │ │ │ │ │ │ ├── ItalicMenu.ts │ │ │ │ │ │ ├── SubMenu.ts │ │ │ │ │ │ ├── SupMenu.ts │ │ │ │ │ │ ├── ThroughMenu.ts │ │ │ │ │ │ ├── UnderlineMenu.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-style-html.ts │ │ │ │ │ ├── render-style.tsx │ │ │ │ │ └── style-to-html.ts │ │ │ │ ├── todo/ │ │ │ │ │ ├── custom-types.ts │ │ │ │ │ ├── elem-to-html.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu/ │ │ │ │ │ │ ├── Todo.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── parse-elem-html.ts │ │ │ │ │ ├── plugin.ts │ │ │ │ │ ├── pre-parse-html.ts │ │ │ │ │ └── render-elem.tsx │ │ │ │ └── undo-redo/ │ │ │ │ ├── index.ts │ │ │ │ └── menu/ │ │ │ │ ├── RedoMenu.ts │ │ │ │ ├── UndoMenu.ts │ │ │ │ └── index.ts │ │ │ └── utils/ │ │ │ ├── dom.ts │ │ │ ├── util.ts │ │ │ └── vdom.ts │ │ └── tsconfig.json │ ├── code-highlight/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── __tests__/ │ │ │ ├── content.ts │ │ │ ├── decorate.test.ts │ │ │ ├── elem-to-html.test.ts │ │ │ ├── parse-html.test.ts │ │ │ ├── render-text-style.test.tsx │ │ │ └── select-lang-menu.test.ts │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── assets/ │ │ │ │ └── index.less │ │ │ ├── constants/ │ │ │ │ └── svg.ts │ │ │ ├── custom-types.ts │ │ │ ├── decorate/ │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── locale/ │ │ │ │ ├── en.ts │ │ │ │ ├── index.ts │ │ │ │ └── zh-CN.ts │ │ │ ├── module/ │ │ │ │ ├── elem-to-html.ts │ │ │ │ ├── index.ts │ │ │ │ ├── menu/ │ │ │ │ │ ├── SelectLangMenu.ts │ │ │ │ │ ├── config.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── parse-style-html.ts │ │ │ │ └── render-style.tsx │ │ │ ├── utils/ │ │ │ │ ├── dom.ts │ │ │ │ └── vdom.ts │ │ │ └── vendor/ │ │ │ └── prism.ts │ │ └── tsconfig.json │ ├── core/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── __tests__/ │ │ │ ├── config/ │ │ │ │ ├── editor-config.test.ts │ │ │ │ ├── menu-config.test.ts │ │ │ │ └── toolbar-config.test.ts │ │ │ ├── create/ │ │ │ │ └── content-to-html.test.ts │ │ │ ├── create-core-editor.ts │ │ │ ├── editor/ │ │ │ │ ├── dom-editor.test.ts │ │ │ │ └── plugins/ │ │ │ │ ├── with-config.test.ts │ │ │ │ ├── with-content.test.ts │ │ │ │ ├── with-dom.test.ts │ │ │ │ ├── with-emitter.test.ts │ │ │ │ └── with-selection.test.ts │ │ │ ├── i18n/ │ │ │ │ └── index.test.ts │ │ │ ├── menus/ │ │ │ │ ├── README.md │ │ │ │ └── register-menus/ │ │ │ │ ├── index.ts │ │ │ │ ├── register-button-menu.ts │ │ │ │ ├── register-modal-menu.ts │ │ │ │ └── register-select-menu.ts │ │ │ ├── parse-html/ │ │ │ │ └── README.md │ │ │ ├── render/ │ │ │ │ └── README.md │ │ │ ├── to-html/ │ │ │ │ └── README.md │ │ │ ├── upload/ │ │ │ │ └── uploader.test.ts │ │ │ └── utils/ │ │ │ ├── util.test.ts │ │ │ └── vdom.test.ts │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── assets/ │ │ │ │ ├── bar-item.less │ │ │ │ ├── bar.less │ │ │ │ ├── common.less │ │ │ │ ├── drop-panel.less │ │ │ │ ├── full-screen.less │ │ │ │ ├── index.less │ │ │ │ ├── modal.less │ │ │ │ ├── progress.less │ │ │ │ ├── select-list.less │ │ │ │ └── textarea.less │ │ │ ├── config/ │ │ │ │ ├── index.ts │ │ │ │ ├── interface.ts │ │ │ │ └── register.ts │ │ │ ├── constants/ │ │ │ │ ├── index.ts │ │ │ │ └── svg.ts │ │ │ ├── create/ │ │ │ │ ├── bind-node-relation.ts │ │ │ │ ├── create-editor.ts │ │ │ │ ├── create-toolbar.ts │ │ │ │ ├── helper.ts │ │ │ │ └── index.ts │ │ │ ├── editor/ │ │ │ │ ├── dom-editor.ts │ │ │ │ ├── interface.ts │ │ │ │ └── plugins/ │ │ │ │ ├── with-config.ts │ │ │ │ ├── with-content.ts │ │ │ │ ├── with-dom.ts │ │ │ │ ├── with-emitter.ts │ │ │ │ ├── with-event-data.ts │ │ │ │ ├── with-max-length.ts │ │ │ │ └── with-selection.ts │ │ │ ├── i18n/ │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── menus/ │ │ │ │ ├── README.md │ │ │ │ ├── bar/ │ │ │ │ │ ├── HoverBar.ts │ │ │ │ │ └── Toolbar.ts │ │ │ │ ├── bar-item/ │ │ │ │ │ ├── BaseButton.ts │ │ │ │ │ ├── DropPanelButton.ts │ │ │ │ │ ├── GroupButton.ts │ │ │ │ │ ├── ModalButton.ts │ │ │ │ │ ├── Select.ts │ │ │ │ │ ├── SimpleButton.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── tooltip.ts │ │ │ │ ├── helpers/ │ │ │ │ │ ├── helpers.ts │ │ │ │ │ └── position.ts │ │ │ │ ├── index.ts │ │ │ │ ├── interface.ts │ │ │ │ ├── panel-and-modal/ │ │ │ │ │ ├── BaseClass.ts │ │ │ │ │ ├── DropPanel.ts │ │ │ │ │ ├── Modal.ts │ │ │ │ │ └── SelectList.ts │ │ │ │ └── register.ts │ │ │ ├── parse-html/ │ │ │ │ ├── README.md │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── parse-common-elem-html.ts │ │ │ │ ├── parse-elem-html.ts │ │ │ │ └── parse-text-elem-html.ts │ │ │ ├── render/ │ │ │ │ ├── README.md │ │ │ │ ├── element/ │ │ │ │ │ ├── getRenderElem.tsx │ │ │ │ │ ├── renderElement.tsx │ │ │ │ │ └── renderStyle.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── node2Vnode.ts │ │ │ │ └── text/ │ │ │ │ ├── genVnode.tsx │ │ │ │ ├── renderStyle.ts │ │ │ │ └── renderText.tsx │ │ │ ├── text-area/ │ │ │ │ ├── TextArea.ts │ │ │ │ ├── event-handlers/ │ │ │ │ │ ├── beforeInput.ts │ │ │ │ │ ├── blur.ts │ │ │ │ │ ├── click.ts │ │ │ │ │ ├── composition.ts │ │ │ │ │ ├── copy.ts │ │ │ │ │ ├── cut.ts │ │ │ │ │ ├── drag.ts │ │ │ │ │ ├── drop.ts │ │ │ │ │ ├── focus.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── keydown.ts │ │ │ │ │ ├── keypress.ts │ │ │ │ │ └── paste.ts │ │ │ │ ├── helpers.ts │ │ │ │ ├── place-holder.ts │ │ │ │ ├── syncSelection.ts │ │ │ │ └── update-view.ts │ │ │ ├── to-html/ │ │ │ │ ├── README.md │ │ │ │ ├── elem2html.ts │ │ │ │ ├── index.ts │ │ │ │ ├── node2html.ts │ │ │ │ └── text2html.ts │ │ │ ├── upload/ │ │ │ │ ├── createUploader.ts │ │ │ │ ├── index.ts │ │ │ │ └── interface.ts │ │ │ └── utils/ │ │ │ ├── dom.ts │ │ │ ├── hotkeys.ts │ │ │ ├── key.ts │ │ │ ├── line.ts │ │ │ ├── ua.ts │ │ │ ├── util.ts │ │ │ ├── vdom.ts │ │ │ └── weak-maps.ts │ │ └── tsconfig.json │ ├── custom-types.d.ts │ ├── editor/ │ │ ├── CHANGELOG.md │ │ ├── README-en.md │ │ ├── README.md │ │ ├── __tests__/ │ │ │ └── create.test.ts │ │ ├── demo/ │ │ │ ├── README.md │ │ │ ├── catalog.html │ │ │ ├── code-highlight.html │ │ │ ├── css/ │ │ │ │ ├── layout.css │ │ │ │ └── view.css │ │ │ ├── extend-menu-drop-panel.html │ │ │ ├── extend-menu-modal.html │ │ │ ├── extend-menu-select.html │ │ │ ├── extend-menu.html │ │ │ ├── get-html.html │ │ │ ├── huge-doc.html │ │ │ ├── index.html │ │ │ ├── js/ │ │ │ │ ├── custom-elem.js │ │ │ │ └── huge-content.js │ │ │ ├── like-qq-doc.html │ │ │ ├── max-length.html │ │ │ ├── multi-editor.html │ │ │ ├── set-html.html │ │ │ └── simple-mode.html │ │ ├── examples/ │ │ │ ├── README.md │ │ │ ├── batch-destroy.html │ │ │ ├── check.html │ │ │ ├── code-highlight.html │ │ │ ├── content-to-html.html │ │ │ ├── css/ │ │ │ │ ├── editor.css │ │ │ │ └── view.css │ │ │ ├── default-mode.html │ │ │ ├── dom7-demo.html │ │ │ ├── headers.html │ │ │ ├── huge-doc.html │ │ │ ├── i18n.html │ │ │ ├── index.html │ │ │ ├── js/ │ │ │ │ ├── huge-content.js │ │ │ │ └── init-content.js │ │ │ ├── like-yuque.html │ │ │ ├── maxlength.html │ │ │ ├── menu.html │ │ │ ├── modal-appendTo-body.html │ │ │ ├── multi-editors.html │ │ │ ├── new-menu.html │ │ │ ├── parse-html.html │ │ │ ├── shadow-dom.html │ │ │ ├── simple-mode.html │ │ │ ├── theme.html │ │ │ ├── todo.html │ │ │ ├── upload-image.html │ │ │ └── upload-video.html │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── Boot.ts │ │ │ ├── assets/ │ │ │ │ └── index.less │ │ │ ├── constants/ │ │ │ │ └── svg.ts │ │ │ ├── create.ts │ │ │ ├── index.ts │ │ │ ├── init-default-config/ │ │ │ │ ├── config/ │ │ │ │ │ ├── hoverbar.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── toolbar.ts │ │ │ │ └── index.ts │ │ │ ├── locale/ │ │ │ │ ├── en.ts │ │ │ │ ├── index.ts │ │ │ │ └── zh-CN.ts │ │ │ ├── register-builtin-modules/ │ │ │ │ ├── index.ts │ │ │ │ └── register.ts │ │ │ └── utils/ │ │ │ ├── browser-polyfill.ts │ │ │ ├── dom.ts │ │ │ └── node-polyfill.ts │ │ └── tsconfig.json │ ├── list-module/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── __tests__/ │ │ │ ├── elem-to-html.test.ts │ │ │ ├── menu/ │ │ │ │ ├── bulleted-list-menu.test.ts │ │ │ │ └── numbered-list-menu.test.ts │ │ │ ├── parse-html.test.ts │ │ │ ├── plugin.test.ts │ │ │ └── render-elem.test.ts │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── assets/ │ │ │ │ └── index.less │ │ │ ├── constants/ │ │ │ │ └── svg.ts │ │ │ ├── index.ts │ │ │ ├── locale/ │ │ │ │ ├── en.ts │ │ │ │ ├── index.ts │ │ │ │ └── zh-CN.ts │ │ │ ├── module/ │ │ │ │ ├── custom-types.ts │ │ │ │ ├── elem-to-html.ts │ │ │ │ ├── index.ts │ │ │ │ ├── menu/ │ │ │ │ │ ├── BaseMenu.ts │ │ │ │ │ ├── BulletedListMenu.ts │ │ │ │ │ ├── NumberedListMenu.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── parse-elem-html.ts │ │ │ │ ├── plugin.ts │ │ │ │ └── render-elem.tsx │ │ │ └── utils/ │ │ │ ├── dom.ts │ │ │ └── maps.ts │ │ └── tsconfig.json │ ├── table-module/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── __tests__/ │ │ │ ├── elem-to-html.test.ts │ │ │ ├── menu/ │ │ │ │ ├── delete-col.test.ts │ │ │ │ ├── delete-row.test.ts │ │ │ │ ├── delete-table.test.ts │ │ │ │ ├── full-width.test.ts │ │ │ │ ├── insert-col.test.ts │ │ │ │ ├── insert-row.test.ts │ │ │ │ ├── insert-table.test.ts │ │ │ │ └── table-header.test.ts │ │ │ ├── parse-html.test.ts │ │ │ ├── plugin.test.ts │ │ │ └── render-elem.test.ts │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── assets/ │ │ │ │ └── index.less │ │ │ ├── constants/ │ │ │ │ └── svg.ts │ │ │ ├── index.ts │ │ │ ├── locale/ │ │ │ │ ├── en.ts │ │ │ │ ├── index.ts │ │ │ │ └── zh-CN.ts │ │ │ ├── module/ │ │ │ │ ├── custom-types.ts │ │ │ │ ├── elem-to-html.ts │ │ │ │ ├── helpers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── menu/ │ │ │ │ │ ├── DeleteCol.ts │ │ │ │ │ ├── DeleteRow.ts │ │ │ │ │ ├── DeleteTable.ts │ │ │ │ │ ├── FullWidth.ts │ │ │ │ │ ├── InsertCol.ts │ │ │ │ │ ├── InsertRow.ts │ │ │ │ │ ├── InsertTable.ts │ │ │ │ │ ├── TableHeader.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── parse-elem-html.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── pre-parse-html.ts │ │ │ │ └── render-elem/ │ │ │ │ ├── index.ts │ │ │ │ ├── render-cell.tsx │ │ │ │ ├── render-row.tsx │ │ │ │ └── render-table.tsx │ │ │ └── utils/ │ │ │ ├── dom.ts │ │ │ └── util.ts │ │ └── tsconfig.json │ ├── upload-image-module/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── __tests__/ │ │ │ ├── config.test.ts │ │ │ ├── plugin.test.ts │ │ │ ├── upload-files.test.ts │ │ │ └── upload-image-menu.test.ts │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── assets/ │ │ │ │ └── index.less │ │ │ ├── constants/ │ │ │ │ └── svg.ts │ │ │ ├── index.ts │ │ │ ├── locale/ │ │ │ │ ├── en.ts │ │ │ │ ├── index.ts │ │ │ │ └── zh-CN.ts │ │ │ ├── module/ │ │ │ │ ├── index.ts │ │ │ │ ├── menu/ │ │ │ │ │ ├── UploadImageMenu.ts │ │ │ │ │ ├── config.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ └── upload-images.ts │ │ │ └── utils/ │ │ │ └── dom.ts │ │ └── tsconfig.json │ ├── vars.less │ └── video-module/ │ ├── CHANGELOG.md │ ├── README.md │ ├── __tests__/ │ │ ├── elem-to-html.test.ts │ │ ├── helpler.test.ts │ │ ├── menu/ │ │ │ ├── delete-video-menu.test.ts.bak │ │ │ ├── insert-video-menu.test.ts │ │ │ └── upload-video-menu.test.ts │ │ ├── parse-html.test.ts │ │ ├── plugin.test.ts │ │ ├── render-elem.test.ts │ │ └── util.test.ts │ ├── package.json │ ├── rollup.config.js │ ├── src/ │ │ ├── assets/ │ │ │ └── index.less │ │ ├── constants/ │ │ │ └── svg.ts │ │ ├── index.ts │ │ ├── locale/ │ │ │ ├── en.ts │ │ │ ├── index.ts │ │ │ └── zh-CN.ts │ │ ├── module/ │ │ │ ├── custom-types.ts │ │ │ ├── elem-to-html.ts │ │ │ ├── helper/ │ │ │ │ ├── insert-video.ts │ │ │ │ └── upload-videos.ts │ │ │ ├── index.ts │ │ │ ├── menu/ │ │ │ │ ├── DeleteVideoMenu.ts.bak │ │ │ │ ├── EditVideoSizeMenu.ts │ │ │ │ ├── InsertVideoMenu.ts │ │ │ │ ├── UploadVideoMenu.ts │ │ │ │ ├── config.ts │ │ │ │ └── index.ts │ │ │ ├── parse-elem-html.ts │ │ │ ├── plugin.ts │ │ │ ├── pre-parse-html.ts │ │ │ └── render-elem.tsx │ │ └── utils/ │ │ ├── dom.ts │ │ └── util.ts │ └── tsconfig.json ├── scripts/ │ └── release-tag.js ├── tests/ │ ├── setup/ │ │ └── index.ts │ └── utils/ │ ├── create-editor.ts │ ├── create-toolbar.ts │ └── stylesMock.js └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .browserslistrc ================================================ defaults not IE 11 maintained node versions ================================================ FILE: .cz-config.js ================================================ module.exports = { types: [ { value: 'WIP', name: '💡 WIP: Work in progress', }, { value: 'feat', name: '🚀 feat: A new feature', }, { value: 'fix', name: '🔧 fix: A bug fix', }, { value: 'refactor', name: '🔨 refactor: A code change that neither fixes a bug nor adds a feature', }, { value: 'release', name: '🛳 release: Bump to a new Semantic version', }, { value: 'docs', name: '📚 docs: Documentation only changes', }, { value: 'test', name: '🔍 test: Add missing tests or correcting existing tests', }, { value: 'perf', name: '⚡️ perf: Changes that improve performance', }, { value: 'chore', name: "🚬 chore: Changes that don't modify src or test files. Such as updating build tasks, package manager", }, { value: 'workflow', name: '📦 workflow: Changes that only affect the workflow. Such as updating build systems or CI etc.', }, { value: 'style', name: '💅 style: Code Style, Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)', }, { value: 'revert', name: '⏱ revert: Revert to a commit', }, ], // Specify the scopes for your particular project scopes: [], allowCustomScopes: true, allowBreakingChanges: ['feat', 'fix'], } ================================================ FILE: .eslintignore ================================================ node_modules/* dist/ lib/ *.html ================================================ FILE: .eslintrc.js ================================================ module.exports = { env: { browser: true, es6: true, mocha: true, jest: true, node: true, }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/eslint-recommended', 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. ], globals: { Atomics: 'readonly', SharedArrayBuffer: 'readonly', }, parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 2018, sourceType: 'module', }, plugins: ['@typescript-eslint', 'prettier'], rules: { 'no-unused-vars': 0, }, } ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] open_collective: wangeditor ================================================ FILE: .github/ISSUE_TEMPLATE/bug.md ================================================ --- name: 提交 bug about: 请大家一定要按照该模板填写,以方便我们更快复现,否则该 issue 将不予受理! --- ## bug 描述 *请输入内容……* ## 你预期的样子是? *请输入内容……* ## 系统和浏览器及版本号 - 操作系统 - 浏览器和版本 ## wangEditor 版本 *请输入内容……* ## demo 能否复现该 bug ? 能/不能 - 中文 demo https://www.wangeditor.com/demo/ - English demo https://www.wangeditor.com/demo/?lang=en ## 在线 demo *请尽量提供在线 demo (推荐以下网站),帮助我们最低成本复现 bug* - https://codesandbox.io/ - https://codepen.io/ - https://stackblitz.com/ ## 最小成本的复现步骤 (请告诉我们,如何最快的复现该 bug) - 步骤一 - 步骤二 - 步骤三 ================================================ FILE: .github/ISSUE_TEMPLATE/feature.md ================================================ --- name: 建议增加新功能 about: 请按照该模板填写,以便我们能真正了解你的需求,否则该 issue 将不予受理! --- ## 功能描述 *请输入内容……* ## 提炼几个功能点 - 功能1 - 功能2 - 功能3 ## 原型图 *涉及到 UI 改动的功能,请一定提供原型图。原型图能表明功能即可,不要求规范和美观* ## 可参考的案例 *是否已有可参考的案例(如其他编辑器),有的话请给出链接* ================================================ FILE: .github/ISSUE_TEMPLATE/question.md ================================================ --- name: 使用时遇到了问题(非 bug) about: 请按照该模板填写,以便我们能真正了解你的问题,否则该 issue 将不予受理! --- ## 问题描述 *请输入遇到的问题...* ## wangEditor 版本 *请输入内容……* ## 是否查阅了文档 ? (文档链接 [www.wangeditor.com](https://www.wangeditor.com/) ) *是/否* ## 最小成本的复现步骤 (请告诉我们,如何**最快的**复现该问题?) - 步骤一 - 步骤二 - 步骤三 ================================================ FILE: .github/workflows/deploy-demos.yml ================================================ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions # github actions 中文文档 https://docs.github.com/cn/actions/getting-started-with-github-actions name: deploy demos on: push: branches: [ deploy-demos ] # 特定分支 jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: set ssh key # 临时设置 ssh key run: | mkdir -p ~/.ssh/ echo "${{secrets.SSH_KEY_FOR_GITHUB_WANGEDITOR}}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan "github.com" >> ~/.ssh/known_hosts echo "---------- set ssh-key ok ----------" - name: download and replace # 下载现有文件,替换 run: | git clone git@github.com:wangEditor/demo.git echo "---------- git clone ok ----------" cp -r ./packages/editor/demo/* ./demo ## 用最新构建出来的文件,替换现有的 echo "---------- replace ok ----------" - name: upload # 上传文件 run: | cd ./demo git config user.name "github-actions" git config user.email "github-actions@github.com" echo "---------- begin git status ----------" echo `git status` echo "---------- end git status ----------" git add . git commit -m "update by github actions" echo "---------- begin git push ----------" git push origin main echo "---------- end git push ----------" - name: delete ssh key # 删除 ssh key run: rm -rf ~/.ssh/id_rsa ================================================ FILE: .github/workflows/deploy-examples.yml.bak ================================================ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions # github actions 中文文档 https://docs.github.com/cn/actions/getting-started-with-github-actions name: deploy to baidu server - example page on: push: branches: - 'main' - 'master' - 'dev' - 'feature-*' - 'fix-*' - 'hotfix-*' - 'refactor-*' paths: - '.github/workflows/*' - 'packages/**' - 'tests/**' - 'build/**' jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout repo uses: actions/checkout@v2 with: fetch-depth: 0 - name: Setup node uses: actions/setup-node@v2 with: node-version: 12.x registry-url: https://registry.npmjs.com - name: Install dependencies run: yarn run bootstrap - name: Build packages run: yarn build - name: Unit test run: yarn run test # 2022.06.07 百度云服务器到期,就不再部署到测试机了 - wangfupeng - name: set ssh key # 临时设置 ssh key run: | mkdir -p ~/.ssh/ echo "${{secrets.WFP_ID_RSA}}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan ${{secrets.BAIDU_SERVER}} >> ~/.ssh/known_hosts - name: scp example files # 拷贝测试页面,到远程服务器 run: | ## 获取当前分支名称,并创建一个同名的文件夹 currentBranchName=`git branch | awk '$1 == "*"{print $2}'` mkdir -p $currentBranchName/dist/css ## 将 dist examples 移到刚创建的文件夹之内 mv packages/editor/dist/css/* $currentBranchName/dist/css/ mv packages/editor/dist/index.js $currentBranchName/dist/index.js mv packages/editor/dist/index.js.map $currentBranchName/dist/index.js.map mv packages/editor/examples/ $currentBranchName/examples/ ## 将该文件夹,及其所有文件,上传到服务器 echo current branch name is: $currentBranchName ssh work@${{secrets.BAIDU_SERVER}} "rm -rf /home/work/wangEditor-team/v5-examples/$currentBranchName" scp -r ./$currentBranchName work@${{secrets.BAIDU_SERVER}}:/home/work/wangEditor-team/v5-examples/$currentBranchName - name: delete ssh key # 删除 ssh key run: rm -rf ~/.ssh/id_rsa ================================================ FILE: .github/workflows/e2e.yml ================================================ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions # github actions 中文文档 https://docs.github.com/cn/actions/getting-started-with-github-actions name: Cypress tests on: push: branches: - 'master' - 'dev' - 'feature-*' - 'fix-*' - 'hotfix-*' - 'refactor-*' - 'test-*' paths: - '.github/workflows/*' - 'packages/**' - 'scripts/**' - 'tests/**' - 'build/**' - 'cypress/**' - 'babel.config.json' - 'cypress.json' jobs: test-e2e: runs-on: ubuntu-latest container: cypress/browsers:node12.13.0-chrome78-ff70 steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - name: Install dependencies run: yarn install - name: Build packages run: yarn build - uses: cypress-io/github-action@v2 with: browser: chrome start: yarn run example wait-on: 'http://localhost:8881/examples/default-mode.html' ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - 'v*' jobs: release: runs-on: ubuntu-latest strategy: max-parallel: 1 steps: - name: Checkout repo uses: actions/checkout@v2 with: fetch-depth: 0 - name: Setup node uses: actions/setup-node@v2 with: node-version: 14.x registry-url: https://registry.npmjs.com - name: Install dependencies run: yarn run bootstrap - name: Build packages run: yarn build - name: Unit test run: yarn run test - name: E2E test uses: cypress-io/github-action@v2 with: browser: chrome start: yarn run example wait-on: 'http://localhost:8881/examples/default-mode.html' - name: Publish npm run: yarn run release:publish env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} ================================================ FILE: .github/workflows/test.yml ================================================ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions # github actions 中文文档 https://docs.github.com/cn/actions/getting-started-with-github-actions name: Build and test on: push: branches: - 'main' - 'master' - 'dev' - 'feature-*' - 'fix-*' - 'hotfix-*' - 'refactor-*' paths: - '.github/workflows/*' - 'packages/**' - 'tests/**' - 'build/**' jobs: test: runs-on: ubuntu-latest steps: - name: Checkout repo uses: actions/checkout@v2 with: fetch-depth: 0 - name: Setup node uses: actions/setup-node@v2 with: node-version: 14.x registry-url: https://registry.npmjs.com - name: Install dependencies run: yarn run bootstrap - name: Build packages run: yarn build - name: Unit test run: yarn run test ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env .env.test # parcel-bundler cache (https://parceljs.org/) .cache # Next.js build output .next # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and *not* Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port package-lock.json .DS_Store ISSUE.md ISSUE1.md .idea/ # cypress */cypress/videos */cypress/screenshots cypress/videos cypress/screenshots cypress/results cypress/logs packages/*/dist packages/*/dist-example bak/ # rollup 分析包体积结果 stats.html ================================================ FILE: .npmignore ================================================ .github/ .vscode ================================================ FILE: .prettierrc.js ================================================ module.exports = { // 箭头函数只有一个参数的时候可以忽略括号 arrowParens: 'avoid', // 括号内部不要出现空格 bracketSpacing: true, // 行结束符使用 Unix 格式 endOfLine: 'lf', // true: Put > on the last line instead of at a new line jsxBracketSameLine: false, // 行宽 printWidth: 100, // 换行方式 proseWrap: 'preserve', // 分号 semi: false, // 使用单引号 singleQuote: true, // 缩进 tabWidth: 2, // 使用 tab 缩进 useTabs: false, // 后置逗号,多行对象、数组在最后一行增加逗号 trailingComma: 'es5', parser: 'typescript', } ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true, "cSpell.words": [ "beforeinput", "bodyparser", "browserslist", "chmod", "clonedeep", "compositionend", "compositionstart", "config", "contenteditable", "elems", "hoverbar", "img", "isequal", "js", "keyscan", "luochao", "middlewares", "mkdir", "next", "nocheck", "prettier", "prettierrc", "prismjs", "snabbdom", "src", "team", "tecent", "toarray", "ts", "uppy", "vdom", "vnode", "wangeditor", "wangfupeng", "we", "write", "yuque" ], "typescript.tsdk": "node_modules/typescript/lib", "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "eslint.validate": ["javascript"], "editor.detectIndentation": false, "editor.tabSize": 2 } ================================================ FILE: .yarnrc ================================================ registry "https://registry.npm.taobao.org" ================================================ FILE: CHANGELOG.md ================================================ # Changelog Link - [basic-modules](./packages/basic-modules/CHANGELOG.md) - [code-highlight](./packages/code-highlight/CHANGELOG.md) - [core](./packages/core/CHANGELOG.md) - [editor](./packages/editor/CHANGELOG.md) - [list-module](./packages/list-module/CHANGELOG.md) - [table-module](./packages/table-module/CHANGELOG.md) - [upload-image-module](./packages/upload-image-module/CHANGELOG.md) - [video-module](./packages/video-module/CHANGELOG.md) ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 - present wangEditor-team Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README-en.md ================================================ # wangEditor 5 [中文](./README.md) ## Introduction Open source web rich text editor, run right out of the box. Support JS Vue React. - [Document](https://www.wangeditor.com/en/) - [Demo](https://www.wangeditor.com/demo/?lang=en) ![](./docs/images/editor-en.png) ## Communication You can [commit an issue]((https://github.com/wangeditor-team/wangEditor/issues)) if you have any question. ## Donation Support wangEditor open-source work https://opencollective.com/wangeditor ================================================ FILE: README.md ================================================ # wangEditor 5 [English](./README-en.md) ## 介绍 开源 Web 富文本编辑器,开箱即用,配置简单。支持 JS Vue React 。 - [文档](https://www.wangeditor.com/) - [demo](https://www.wangeditor.com/demo/) ![](./docs/images/editor.png) ## 交流 - [讨论问题和建议](https://github.com/wangeditor-team/wangEditor/issues) ## 捐赠 支持 wangEditor 开源工作 https://opencollective.com/wangeditor ================================================ FILE: babel.config.json ================================================ { "presets": [ [ "@babel/preset-env", { "modules": false, "useBuiltIns": "usage", "corejs": 3, "targets": "ie 11" } ], "@babel/preset-typescript" ], "plugins": [ [ "@babel/plugin-transform-runtime", { "absoluteRuntime": false, "corejs": 3, "helpers": true, "regenerator": true, "useESModules": false } ] ] } ================================================ FILE: build/build-all.sh ================================================ #!/bin/bash ## 一键打包所有 package # 获取 yarn dev/build 类型 buildType=build if [ -n "$1" ]; then buildType=$1 fi cd ./packages # core 要第一个打包 cd ./core rm -rf dist # 清空 dist 目录 yarn "$buildType" cd ../basic-modules rm -rf dist # 清空 dist 目录 yarn "$buildType" # code-highlight 依赖于 basic-modules 中的 code-block cd ../code-highlight rm -rf dist # 清空 dist 目录 yarn "$buildType" cd ../list-module rm -rf dist # 清空 dist 目录 yarn "$buildType" cd ../table-module rm -rf dist # 清空 dist 目录 yarn "$buildType" # upload-image 依赖于 basic-modules 中的 image cd ../upload-image-module rm -rf dist # 清空 dist 目录 yarn "$buildType" cd ../video-module rm -rf dist # 清空 dist 目录 yarn "$buildType" # editor 依赖于上述的 core + modules cd ../editor rm -rf dist # 清空 dist 目录 yarn "$buildType" ================================================ FILE: build/config/common.js ================================================ /** * @description rollup common config * @author wangfupeng */ import path from 'path' import commonjs from '@rollup/plugin-commonjs' import json from '@rollup/plugin-json' import nodeResolve from '@rollup/plugin-node-resolve' import typescript from 'rollup-plugin-typescript2' import replace from '@rollup/plugin-replace' import peerDepsExternal from 'rollup-plugin-peer-deps-external' // import del from 'rollup-plugin-delete' export const extensions = ['.js', '.jsx', '.ts', '.tsx'] const isProd = process.env.NODE_ENV === 'production' /** * 生成 common conf * @param {string} format 'umd' 'esm' * @returns common conf */ function genCommonConf(format) { return { input: path.resolve(__dirname, './src/index.ts'), output: { // 属性有 file format name sourcemap 等 // https://www.rollupjs.com/guide/big-list-of-options }, plugins: [ peerDepsExternal(), // 打包结果不包含 package.json 的 peerDependencies json({ compact: true, indent: ' ', preferConst: true, }), typescript({ clean: true, tsconfig: path.resolve(__dirname, './tsconfig.json'), }), nodeResolve({ browser: true, // 重要 mainFields: format === 'esm' ? ['module', 'main'] : ['main'], extensions, }), commonjs(), replace({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), preventAssignment: true, }), // del({ targets: 'dist/*' }), ], } } export default genCommonConf ================================================ FILE: build/config/dev.js ================================================ /** * @description rollup dev config * @author wangfupeng */ import postcss from 'rollup-plugin-postcss' import autoprefixer from 'autoprefixer' import genCommonConf from './common' /** * 生成 dev config * @param {string} format 'umd' 'esm' */ function genDevConf(format) { const { input, output = {}, plugins = [], external } = genCommonConf(format) return { input, output, external, plugins: [ ...plugins, postcss({ plugins: [autoprefixer()], extract: 'css/style.css', }), ], } } export default genDevConf ================================================ FILE: build/config/prd.js ================================================ /** * @description rollup prd config * @author wangfupeng */ import babel from '@rollup/plugin-babel' import postcss from 'rollup-plugin-postcss' import autoprefixer from 'autoprefixer' import cssnano from 'cssnano' import { terser } from 'rollup-plugin-terser' import cleanup from 'rollup-plugin-cleanup' import genCommonConf from './common' import { extensions } from './common' /** * 生成 prd config * @param {string} format 'umd' 'esm' */ function genPrdConf(format) { const { input, output = {}, plugins = [], external } = genCommonConf(format) const finalPlugins = [ ...plugins, babel({ rootMode: 'upward', babelHelpers: 'runtime', exclude: 'node_modules/**', include: 'src/**', extensions, }), postcss({ plugins: [ autoprefixer(), cssnano(), // 压缩 css ], extract: 'css/style.css', }), cleanup({ comments: 'none', extensions: ['.ts', '.tsx'], }), terser(), // 压缩 js ] return { input, output: { sourcemap: true, ...output, }, external, plugins: finalPlugins, } } export default genPrdConf ================================================ FILE: build/create-rollup-config.js ================================================ /** * @description 创建 rollup 配置 * @author wangfupeng */ import { merge } from 'lodash' import { visualizer } from 'rollup-plugin-visualizer' import genDevConf from './config/dev' import genPrdConf from './config/prd' // 环境变量 const ENV = process.env.NODE_ENV || 'production' const IS_SIZE_STATS = ENV.indexOf('size_stats') >= 0 // 分析包体积 export const IS_DEV = ENV.indexOf('development') >= 0 export const IS_PRD = ENV.indexOf('production') >= 0 /** * 生成单个 rollup 配置 * @param {object} customConfig { input, output, plugins ... } */ export function createRollupConfig(customConfig = {}) { const { input, output = {}, plugins = [] } = customConfig const { format } = output let baseConfig if (IS_PRD) { baseConfig = genPrdConf(format) } else { baseConfig = genDevConf(format) } if (IS_SIZE_STATS) { // 分析包体积。运行之后可查看 package 下的 `stats.html` plugins.push(visualizer()) } const config = { input: input ? input : baseConfig.input, output, plugins, } const res = merge({}, baseConfig, config) return res } ================================================ FILE: commitlint.config.js ================================================ module.exports = { extends: ['cz'], rules: { 'type-empty': [2, 'never'], }, } ================================================ FILE: cypress/cypress.d.ts ================================================ /// declare namespace Cypress { interface CustomWindow extends Window {} interface Chainable { /** * Window object with additional properties used during test. */ window(options?: Partial): Chainable getByClass(dataTestAttribute: string, args?: any): Chainable } } ================================================ FILE: cypress/fixtures/example.json ================================================ { "name": "Using fixtures to represent data", "email": "hello@cypress.io", "body": "Fixtures are a great way to mock data for responses to routes" } ================================================ FILE: cypress/integration/editor.spec.ts ================================================ describe('Basic Editor', () => { it('create editor', () => { cy.visit('/examples/default-mode.html') cy.get('#btn-create').click() cy.get('#editor-toolbar').should('have.attr', 'data-w-e-toolbar', 'true') cy.get('#editor-text-area').should('have.attr', 'data-w-e-textarea', 'true') cy.get('#w-e-textarea-1').contains('一行标题') }) }) ================================================ FILE: cypress/plugins/index.ts ================================================ /// // *********************************************************** // This example plugins/index.js can be used to load plugins // // You can change the location of this file or turn off loading // the plugins file with the 'pluginsFile' configuration option. // // You can read more here: // https://on.cypress.io/plugins-guide // *********************************************************** // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) /** * @type {Cypress.PluginConfig} */ export default (on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) => { // `on` is used to hook into various events Cypress emits // `config` is the resolved Cypress config // codeCoverageTask(on, config) return config } ================================================ FILE: cypress/support/commands.ts ================================================ Cypress.Commands.add('getByClass', (selector, ...args) => { return cy.get(`.w-e-${selector}`, ...args) }) ================================================ FILE: cypress/support/index.ts ================================================ import './commands' ================================================ FILE: cypress/tsconfig.json ================================================ { "compilerOptions": { "target": "es5", "lib": ["es2015", "dom", "esnext"], "types": ["cypress"], "isolatedModules": false, "allowJs": true, "noEmit": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, }, "include": [ "./**/*.ts" ] } ================================================ FILE: cypress.json ================================================ { "baseUrl": "http://localhost:8881", "defaultCommandTimeout": 8000, "video": false } ================================================ FILE: docs/README.md ================================================ # 文档 - [开发文档](./dev.md) - [发布到 npm](./publish.md) - [加入研发团队](./join.md) ================================================ FILE: docs/dev.md ================================================ # 开发 ## 准备工作 - 了解 slate.js - 了解 vdom 和 snabbdom.js - 了解 lerna - 已安装 yarn ## 本地启动 ### 打包 - 下载代码到本地,进入 `wangEditor` 目录 - 安装所有依赖 `yarn bootstrap` - 打包所有模块 `yarn dev` 或者 `yarn build` ### 运行 demo - 进入 `packages/editor` 目录,运行 `yarn example` ,浏览器打开 `http://localhost:8881/examples/` ## 注意事项 - 修改代码、重新打包后,要**强制刷新**浏览器 - 如果本地包依赖有问题,试试 `lerna link` 关联内部包 ## 记录 全局安装一个插件 `yarn add xxx --dev -W` 注意合理使用 `peerDependencies` 和 `dependencies` ,不要重复打包一个第三方库 执行 `lerna add ...` 之后,需要重新 `lerna link` 建立内部连接 分析包体积 - 命令行,进入某个 package ,如 `cd packages/editor` - 执行 `yarn size-stats` ,等待执行完成 - 结果会记录在 `packages/editor/stats.html` 用浏览器打开 ================================================ FILE: docs/join.md ================================================ # 加入团队 欢迎加入 wangEditor 研发团队~ ## V5 研发人员 - [王福朋](https://github.com/wangfupeng1988/) - wangEditor 创始人,资深前端工程师,PMP,曾就职于百度、滴滴 - [罗超](https://github.com/echoLC) - 天才就是百分之一的灵感加上百分之九十九的努力 - [TGuoW](https://github.com/TGuoW) - [刘庆华(火热) ](https://github.com/liuqh0609) - 热爱着,年轻着 - [haha](https://github.com/hahaaha) ## 加入条件 - 熟悉 typescript ,并实际应用过 - 熟悉 webpack 或者 rollup - 熟悉 React 或者 Vue - 熟悉 vdom 结构,熟悉 [snabbdom.js](https://github.com/snabbdom/snabbdom) (不了解的可以先去学习一下) - 熟悉 [slate.js](https://www.slatejs.org/) 包括:熟悉数据模型,熟悉 API,看过源码(不了解的可以先去学习一下) ## 申请加入 - 首先自我评价,符合上述加入条件 - 加入 QQ 群,私聊群主,发送一份个人简历 ================================================ FILE: docs/publish.md ================================================ # 发布到 NPM 因为我们的项目是使用 `independent` 的方式组织 `muti-packgae`,所以每个包都有单独的版本号,默认使用 `lerna publish` 发布包,我们需要根据包的修改内容选择合适的版本号。**对于没有变动的 `package`,lerna 发布的时候不会算在本次发布的内容里面**。 发布的流程分两步: 第一步:将所有要发版的代码合并到 `master` 分支后,先在本地执行 `yarn release:version` 生成各个本次变动的 `package` 的版本后,自动生成 `changelog`,接着 lerna 会生成 `git tag` 并 `push` 到远程。 第二步:上面步骤完成后, `lerna` push `git tag` 到远程的时候会触发我们配置的 `git action`,走完正常的发版 `action`,具体看 [`action` 配置]('./../.github/workflows/release.yml') 。 因为目前我们还在开发当中,所以为了更加方便发版到 `npm` 进行测试,目前,项目中集成了以下 `release` 的 `script command`: ## 正常发布一个版本 ```bash yarn release:publish ``` ## 发布指定的 dist-tag 版本 发布一个 `experimental` [dist-tag](https://docs.npmjs.com/cli/v7/commands/npm-dist-tag) 的版本: ```bash yarn release:publish:experimental ``` 发布一个 `next` [dist-tag](https://docs.npmjs.com/cli/v7/commands/npm-dist-tag) 的版本: ```bash yarn release:next ``` ## 发布 canary 版本 发布一个 `canary` 版本: ```bash # 1.0.0 => 1.0.1-alpha.0+${SHA} of packages changed since the previous commit lerna publish --canary ``` ================================================ FILE: docs/test.md ================================================ # 测试 目前我们的项目已经集成了基于 `Jest` 的单元测试和基于 `Cypress` 的 `E2E` 测试,下面简单介绍两种测试运行和编写的方式。 ## 单元测试 单元测试是从最底层 `API` 的角度出发,保证编辑功能的质量。虽然我们是基于 `lerna` 的 monorepo 管理方式,为了方便组织,我们的所有测试还是都放在根目录下的 `tests/units` 下,每个 `packages` 下面的包都在 `tests/units` 下对应一个目录。所以如果需要新增 `test`,可以按照这个目录组织方式决定把新的 `test` 放在哪个目录。 ### 运行单元测试 目前单元测试的运行已经集成在 CI 流程中,如果本地开发后,需要自动执行单元测试,运行如下 `scripts` 命令: ```bash yarn run test ``` 查看单元测试的覆盖率: ```bash yarn run test-c ``` ### 注意事项 - **因为各个模块依赖了 `core`,如果修改了 `core` 的代码,增加了 `API`,需要运行 `yarn build` 命令,使得各个模块能读到最新的代码**。 ## E2E 测试 目前我们的项目 `E2E` 测试基于 [Cypress](https://docs.cypress.io/),对于编辑器这种强依赖用户交互运作的产品,通过 `E2E` 保证编辑器交互更加稳定。 目前 `E2E` 测试只写了基本的创建基础编辑器的用例,保证打包后的代码能正常创建编辑器。 **`E2E` 测试用例目前都放在根目录下的 `cypress/integration` 目录下,如果需要增加新的测试用例,应该在此目录下创建文件。** ### 运行 E2E 测试 目前 E2E 测试的同样集成到了 CI 流程中,如果本地开发后,需要编写 E2E 测试,运行如下 `scripts` 命令: ```bash yarn e2e:dev ``` 该命令首先会启动 `packages/editor` 下面的 `example` 服务,然后再启动 Cypress 的命令, Cypress 会在本地调起 UI 界面: ![cypress](images/cypress.jpg) 然后你可以选择想要执行 E2E 的用例,然后执行后 Cypress 会调起浏览器,运行所有的测试用例,你可以直接在受控的浏览器直接调试你的测试: ![cypress-run](images/cypress-run.jpg) 如果不是为了开发新的测试用例,只是想要本地运行所有的 E2E 测试,则执行: ```bash yarn e2e ``` Cypress 则会自己后台运行所有测试,并不会打开 UI 界面和浏览器。 ================================================ FILE: jest.config.js ================================================ module.exports = { roots: ['/packages'], testEnvironment: 'jsdom', testMatch: ['**/(*.)+(spec|test).+(ts|js|tsx)'], transform: { '^.+\\.tsx?$': 'ts-jest', '^.+\\.js$': 'ts-jest', }, globals: { 'ts-jest': { tsconfig: '/tsconfig.json', }, }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleNameMapper: { '^.+\\.(css|less)$': '/tests/utils/stylesMock.js', }, transformIgnorePatterns: ['node_modules/(?!(html-void-elements)/)'], setupFilesAfterEnv: ['/tests/setup/index.ts'], collectCoverageFrom: ['/packages/**/src/**/*.(ts|tsx)'], coveragePathIgnorePatterns: [ 'dist', 'locale', 'index.ts', 'config.ts', 'browser-polyfill.ts', 'node-polyfill.ts', ], } ================================================ FILE: lerna.json ================================================ { "packages": [ "packages/*" ], "version": "independent", "npmClient": "yarn", "useWorkspaces": true, "command": { "publish": { "ignoreChanges": ["ignored-file", "*.md"], "message": "chore(release): publish", "conventionalCommits": true, "registry": "https://npm.pkg.github.com" }, "version": { "message": "chore(release): publish", "allowBranch": "master" } }, "changelog": { "repo": "wangeditor-team/wangEditor", "labels": { "tag: new feature": ":rocket: New Feature", "tag: breaking change": ":boom: Breaking Change", "tag: bug fix": ":bug: Bug Fix", "tag: enhancement": ":nail_care: Enhancement", "tag: documentation": ":memo: Documentation", "tag: internal": ":house: Internal" }, "cacheDir": ".changelog" }, "changelogPreset": "angular" } ================================================ FILE: package.json ================================================ { "name": "@wangeditor-team/wangeditor", "private": true, "scripts": { "test": "cross-env NODE_OPTIONS=--unhandled-rejections=warn jest --detectOpenHandles --passWithNoTests", "test-c": "cross-env NODE_OPTIONS=--unhandled-rejections=warn jest --coverage", "dev": "sh build/build-all.sh dev", "build": "sh build/build-all.sh", "bootstrap": "lerna bootstrap --use-workspaces", "release:version": "git pull origin master && lerna version --conventional-commits && node ./scripts/release-tag.js", "release:publish:experimental": "lerna publish --dist-tag experimental", "release:publish:canary": "lerna publish --canary", "release:next": "yarn prerelease && lerna publish --dist-tag next", "release:publish": "lerna publish from-git --yes", "release:package": "lerna publish from-package --yes", "prerelease": "yarn build", "format": "yarn prettier --write", "lint": "eslint \"packages/*/+(src|__tests__)/**/*.+(ts|tsx)\"", "prettier": "prettier --ignore-path .gitignore \"packages/*/+(src|__tests__)/**/*.+(ts|tsx)\"", "cypress:open": "cypress open", "cypress:run": "cypress run", "e2e:dev": "concurrently \"yarn example\" \"yarn run cypress:open\"", "e2e": "concurrently \"yarn example\" \"yarn run cypress:run\"", "example": "lerna exec --scope @wangeditor/editor -- yarn run example" }, "workspaces": [ "packages/*" ], "lint-staged": { "packages/**/*.{ts,tsx}": [ "yarn lint", "yarn format", "git add ." ] }, "husky": { "hooks": { "pre-commit": "lint-staged", "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" } }, "config": { "commitizen": { "path": "node_modules/cz-customizable" } }, "devDependencies": { "@babel/core": "^7.14.6", "@babel/plugin-proposal-class-properties": "^7.10.4", "@babel/plugin-proposal-object-rest-spread": "^7.11.0", "@babel/plugin-transform-runtime": "^7.14.5", "@babel/preset-env": "^7.14.5", "@babel/preset-typescript": "^7.14.5", "@babel/runtime-corejs3": "^7.14.7", "@rollup/plugin-babel": "^5.3.0", "@rollup/plugin-commonjs": "^17.1.0", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^11.2.0", "@rollup/plugin-replace": "^2.4.2", "@testing-library/jest-dom": "^5.14.1", "@types/jest": "^25.2.1", "@typescript-eslint/eslint-plugin": "^2.31.0", "@typescript-eslint/parser": "^4.4.1", "autoprefixer": "^10.2.5", "babel-core": "^7.0.0-bridge.0", "babel-jest": "^27.0.6", "babel-plugin-istanbul": "^6.0.0", "commitlint": "^11.0.0", "commitlint-config-cz": "^0.13.2", "concurrently": "^6.2.0", "conventional-changelog": "^3.1.24", "cross-env": "^7.0.2", "cssnano": "^5.0.3", "cypress": "^8.6.0", "cz-customizable": "^6.3.0", "eslint": "^7.21.0", "eslint-config-prettier": "^6.11.0", "eslint-plugin-prettier": "^3.1.3", "http-server": "^0.12.3", "husky": "^4.2.5", "jest": "^27.0.6", "lerna": "^3.20.2", "lerna-changelog": "^1.0.1", "less": "^3.11.1", "lint-staged": "^10.2.2", "lodash": "^4.17.21", "nock": "^13.2.4", "nodemon": "^2.0.6", "postcss": "^8.2.15", "prettier": "^2.0.5", "release-it": "^14.2.0", "rollup": "^2.41.0", "rollup-plugin-cleanup": "^3.2.1", "rollup-plugin-copy": "^3.4.0", "rollup-plugin-delete": "^2.0.0", "rollup-plugin-generate-html-template": "^1.7.0", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.0", "rollup-plugin-serve": "^1.1.0", "rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript2": "^0.30.0", "rollup-plugin-visualizer": "^5.5.0", "ts-jest": "^27.0.4", "tslib": "^2.3.0", "typescript": "4.3.2" }, "dependencies": { "@babel/runtime": "^7.14.6" } } ================================================ FILE: packages/basic-modules/CHANGELOG.md ================================================ # Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. ## [1.1.7](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/basic-modules@1.1.6...@wangeditor/basic-modules@1.1.7) (2022-11-14) ### Bug Fixes * **font family menu:** 处理 setHtml 的时候字体样式回显失败的问题 ([b941bab](https://github.com/wangeditor-team/wangEditor/commit/b941babbdc6bd5bf7da0cce826803a8fde011e07)) ## [1.1.6](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/basic-modules@1.1.5...@wangeditor/basic-modules@1.1.6) (2022-09-27) **Note:** Version bump only for package @wangeditor/basic-modules ## [1.1.5](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/basic-modules@1.1.4...@wangeditor/basic-modules@1.1.5) (2022-09-15) ### Bug Fixes * 图片 100% 有横向滚动条 ([d21322a](https://github.com/wangeditor-team/wangEditor/commit/d21322a1a9f2e3172a1bd5e175f5ebbb5f2ed074)) * 插入表格会删掉去掉 issue 4711 ([d4fac4e](https://github.com/wangeditor-team/wangEditor/commit/d4fac4efd06480457a95c2b06e7472cf6204de58)) ## [1.1.4](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/basic-modules@1.1.3...@wangeditor/basic-modules@1.1.4) (2022-09-14) ### Bug Fixes * font-size - 支持配置 name value ([206ebd9](https://github.com/wangeditor-team/wangEditor/commit/206ebd994d2635704d93ef9ebe0022d7d72ddea8)) ## [1.1.3](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/basic-modules@1.1.2...@wangeditor/basic-modules@1.1.3) (2022-07-13) ### Bug Fixes * parse indent style 负数 ([c8d746e](https://github.com/wangeditor-team/wangEditor/commit/c8d746e0464bdda626313c17af4d015681ccc3e8)) * 兼容 word 文字背景色 ([e820b26](https://github.com/wangeditor-team/wangEditor/commit/e820b26730d34480994a343ab262c043c30a4495)) ## [1.1.2](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/basic-modules@1.1.1...@wangeditor/basic-modules@1.1.2) (2022-07-11) ### Bug Fixes * editor.focus() 参数语法错误 ([334fa21](https://github.com/wangeditor-team/wangEditor/commit/334fa217d43fdaa95454e7c85a53526b7b777fda)) * 修复html回显时,部分字体回显问题 ([c83ffa7](https://github.com/wangeditor-team/wangEditor/commit/c83ffa70da655d03bfd639f2d1fd04986440ead2)) ## [1.1.1](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/basic-modules@1.1.0...@wangeditor/basic-modules@1.1.1) (2022-06-02) ### Bug Fixes * issue 4308 - 自定义字号、字体无法回显 ([ad38b8c](https://github.com/wangeditor-team/wangEditor/commit/ad38b8ce6dbcff1d65785c8d6701238ad351f562)) * 修复在空字符上插入 link 报错的问题 ([e838f06](https://github.com/wangeditor-team/wangEditor/commit/e838f069f556a5d3206e48a5ed76f8d1e0ae3d05)) # [1.1.0](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/basic-modules@1.0.1...@wangeditor/basic-modules@1.1.0) (2022-05-25) ### Bug Fixes * 粘贴 HTML 后 font-size font-family line-height 不显示 ([2281957](https://github.com/wangeditor-team/wangEditor/commit/2281957020a30de9cda1c5e9d5e20c6668b7f592)) ### Features * enter menu ([988fc31](https://github.com/wangeditor-team/wangEditor/commit/988fc31f31de3d37dffbf54abb784cceb8e6118d)) ## 1.0.1 (2022-04-18) ### Bug Fixes * 部分菜单 disabled ([87f1233](https://github.com/wangeditor-team/wangEditor/commit/87f12332a087072406c1988dc5cef2eae8335375)) * 插入图片的 < > 替换 ([5721560](https://github.com/wangeditor-team/wangEditor/commit/57215609ada8b9d15f5505d1ba52e49707b5b183)) * 错别字 alwaysEnable ([82c5136](https://github.com/wangeditor-team/wangEditor/commit/82c5136f8496be420dfa26b0f30522e19924a907)) * 分割线后无法输入内容 ([146fd05](https://github.com/wangeditor-team/wangEditor/commit/146fd05108592d50d036d0f37a2e29fcdd2a97be)) * 更新各包之间依赖版本 ([75c552c](https://github.com/wangeditor-team/wangEditor/commit/75c552cc8ed54765bebb86a7ec5329a7fc79e85f)) * 更新了 basic-module 依赖版本 ([20c9543](https://github.com/wangeditor-team/wangEditor/commit/20c9543dc9249af6fc7e3a9895ed7f64709ca6ee)) * 禁用时 image 的样式 ([42c993a](https://github.com/wangeditor-team/wangEditor/commit/42c993a7668d90ce049b88a01df21b28912c679f)) * 全选设置字体报错 ([cdb14d1](https://github.com/wangeditor-team/wangEditor/commit/cdb14d10330b5736534e7aaf3a070df2804a8be2)) * 上下标文案显示 ([0e97da1](https://github.com/wangeditor-team/wangEditor/commit/0e97da18279cee6ea06c217972fee4faf9e4758f)) * 添加链接,空格也会在链接中的问题 ([c656827](https://github.com/wangeditor-team/wangEditor/commit/c65682743bd49eba9ab64be847f1f9527fb6170b)) * 修复 pnpm 安装 @wangeditor/editor 出现警告的问题 ([4087fbe](https://github.com/wangeditor-team/wangEditor/commit/4087fbee01c76bdd55e747a5e86c5e4a8d6a8353)) * 修复多选文字且选择空白行无法修改文字样式 ([99a9150](https://github.com/wangeditor-team/wangEditor/commit/99a91509c6e12220bb105cc6d15a0f0a4b375cea)) * 修复光标状态下设置文字样式,菜单不 active 的问题 ([b1b2dba](https://github.com/wangeditor-team/wangEditor/commit/b1b2dbaaae11f74bd36ec79ff50de336c252fef5)) * 修复清除格式不完全的问题 ([1181a23](https://github.com/wangeditor-team/wangEditor/commit/1181a23e6de71162dc490d9b348379c9b2ef4251)) * 修复设置文字颜色与背景色行为与预期不一致的问题 ([25d3381](https://github.com/wangeditor-team/wangEditor/commit/25d3381aa65ce8fe862617e7b1b03cfa5370715d)) * 选中文字,创建链接(同时修改文字) ([5fdf6ae](https://github.com/wangeditor-team/wangEditor/commit/5fdf6ae33b1bebe9b7373e4b7ee8c568480a3c08)) * 移除了每个包下的 publishConfig directory 配置 ([16559f0](https://github.com/wangeditor-team/wangEditor/commit/16559f052545c111318be760e64291a521bdcc65)) * 优化 custom-types.d.ts 中类型声明,修复测试文件 ts 报错 ([3a6c455](https://github.com/wangeditor-team/wangEditor/commit/3a6c4553245bc734dae1e17d605af389971782a2)) * 有内联元素时对齐失败 ([076c694](https://github.com/wangeditor-team/wangEditor/commit/076c694a4b3474080b89f52692595b84bf4d8207)) * blockquote & header insertBreak ([06678c9](https://github.com/wangeditor-team/wangEditor/commit/06678c963e8c8421ecded448de7510b254117550)) * code node 顶层 ([a927938](https://github.com/wangeditor-team/wangEditor/commit/a9279388f14212319505f6a5da300cd15e81c214)) * code-block 换行 - 自动加入前面的空格 ([c214032](https://github.com/wangeditor-team/wangEditor/commit/c2140327842d803cd18a9acf47ec3225182bf940)) * color toHtml ([2c9718c](https://github.com/wangeditor-team/wangEditor/commit/2c9718cb2feb4dd0a7bf39238598707fa6d2bb21)) * delete divider ([f04cbd6](https://github.com/wangeditor-team/wangEditor/commit/f04cbd6009099629e3cd41be19d20b6788fe7f28)) * disabled - img 和 todo 可编辑 ([cf6a3f2](https://github.com/wangeditor-team/wangEditor/commit/cf6a3f2a1e05b6231f46aa74c422561e4147f7ae)) * divider - 键盘删除 ([31db059](https://github.com/wangeditor-team/wangEditor/commit/31db0593dbc77fba9b4a719bc0f48f1223afd680)) * emptyP toHtml 增加 br ([c347c29](https://github.com/wangeditor-team/wangEditor/commit/c347c2916416edc96a99d1bf53c0e18cd22d80f9)) * getHtml API ([c0b60cf](https://github.com/wangeditor-team/wangEditor/commit/c0b60cf47d8eaae4292265906fbe07875e1564c9)) * header 不禁用 bold ([f4cd3d0](https://github.com/wangeditor-team/wangEditor/commit/f4cd3d0b85725701c3ec650e4d6ae7d8831f5105)) * hoverbar 被点击多次隐藏 ([bf4fc19](https://github.com/wangeditor-team/wangEditor/commit/bf4fc193847e8caba3a67c8dd152eae4f1950c4f)) * hoverbar modal 重复创建 ([70d2b61](https://github.com/wangeditor-team/wangEditor/commit/70d2b618a0662c88cd5e6691f513009726ce1b9b)) * hoverbar show/hide ([c96bc83](https://github.com/wangeditor-team/wangEditor/commit/c96bc8378939fecd78807fea4f2b7e1eec2a9ea0)) * image 拖拽,设置最小值 ([0205023](https://github.com/wangeditor-team/wangEditor/commit/0205023d8c1ec3fafcba3a950afcaef9f5f5170f)) * insert link ([a104682](https://github.com/wangeditor-team/wangEditor/commit/a10468279f730c9a4216474cf3d44d41f124cb6b)) * insertHtml - 空行 ([53a8fbb](https://github.com/wangeditor-team/wangEditor/commit/53a8fbb5cf665ef0d6f7fd1c2fee73dba0d98e32)) * insertHtml - 空行 ([c61f415](https://github.com/wangeditor-team/wangEditor/commit/c61f415c41d393f203ae5e5c17d9167ec60a1824)) * justify - disable ([3a4b24e](https://github.com/wangeditor-team/wangEditor/commit/3a4b24e8e628024de248f0b52bb4066f626e7480)) * justify indent ([5a81e52](https://github.com/wangeditor-team/wangEditor/commit/5a81e527a45e7a92eb36a2aefa50d93e20c4cec2)) * justify menu disabled ([19e2f80](https://github.com/wangeditor-team/wangEditor/commit/19e2f8008a435101c6ecd4d4a7eadd423cb1070f)) * link 无文本 ([af4fb32](https://github.com/wangeditor-team/wangEditor/commit/af4fb3218bd4651763f66c804fec2b872e99e8f3)) * link, text hoverbar 选区问题 ([e0b7438](https://github.com/wangeditor-team/wangEditor/commit/e0b7438c89a347f1b0b940d9c11150b72d595529)) * lodash.throttle 引用 bug ([50aeff9](https://github.com/wangeditor-team/wangEditor/commit/50aeff94859bf328346cb9cfe89d0abd57c3b641)) * maxLength - 拼音 + 粘贴 ([3ac4db6](https://github.com/wangeditor-team/wangEditor/commit/3ac4db6d78cbe7a8d1fe19747deb0a17edd9b552)) * menu active ([10829e2](https://github.com/wangeditor-team/wangEditor/commit/10829e2e9e1d864d4900821ee3d5fa516b8cca2a)) * parse html - 有些 elem children 需要过滤 ([63cbb80](https://github.com/wangeditor-team/wangEditor/commit/63cbb804c8c7a778a4ee1f4ba8717a11b4b6b5a3)) * parse-html - sub sup ([2c15a5f](https://github.com/wangeditor-team/wangEditor/commit/2c15a5f9c9c2de8b34770a6bebfe765d203a03f6)) * parse-html pre/code ([d9bd773](https://github.com/wangeditor-team/wangEditor/commit/d9bd773f9a40f9531d9163700253d0b5f717afb8)) * rename es module filename ([1821d4e](https://github.com/wangeditor-team/wangEditor/commit/1821d4eef49e64efcb41b848849ca7a5e6472044)) * shadow dom 中 modal 输入框异常 ([ef3b199](https://github.com/wangeditor-team/wangEditor/commit/ef3b199a3e74c6b8ba61ed781e1aa13a1c5acfde)) * style-to-html - 输入 a 会删除外部的 标签 ([af1f523](https://github.com/wangeditor-team/wangEditor/commit/af1f523983f2bc4b7eaf9726d4b8a35227ab27dc)) * table - elemToHtml ([e36e609](https://github.com/wangeditor-team/wangEditor/commit/e36e6092ef721723169afc8bf0560a47ac9f4dfc)) * tableCell 中 br 报错 ([8604db7](https://github.com/wangeditor-team/wangEditor/commit/8604db751b622c01fa5391af59328236cf13effc)) ### Features * 两端对齐 ([e5080d3](https://github.com/wangeditor-team/wangEditor/commit/e5080d3dd102f7a951d8e1f370db834778ecbdfa)) * 上标 下标 ([40dab08](https://github.com/wangeditor-team/wangEditor/commit/40dab085a061ea3e838f0cfa86260c6c6f894c69)) * 增加 enable disable API(删除 setConfig setMenuConfig API) ([984fc50](https://github.com/wangeditor-team/wangEditor/commit/984fc50520061fc34ea08f4136bdeb93dee46564)) * 增加 header4 header5 ([cc48734](https://github.com/wangeditor-team/wangEditor/commit/cc4873412ce3f4de1ecc1dbf4c313094dceb5a77)) * 支持 nodejs 环境 ([484f18c](https://github.com/wangeditor-team/wangEditor/commit/484f18c3abc70d19e51c556f48491c18d390b1e1)) * basic text paste ([f0a5b98](https://github.com/wangeditor-team/wangEditor/commit/f0a5b980c95fa1e2fc59a898c6e0d0723c276c28)) * clearStyle menu ([8002f70](https://github.com/wangeditor-team/wangEditor/commit/8002f707ed04b914180ec36fdca0edf48c815e01)) * drag resize image ([cd72028](https://github.com/wangeditor-team/wangEditor/commit/cd72028f1786e2e53079ad5cbef1b8569731ca79)) * editor 生命周期,自定义事件 ([00e9bc2](https://github.com/wangeditor-team/wangEditor/commit/00e9bc2cfcb8b622764db1c76394491d72ffd93e)) * focus支持focus到文档末尾 ([628830e](https://github.com/wangeditor-team/wangEditor/commit/628830ef06ff85b3e67001ce30dd9e0557b0aa28)) * fullScreen ([e7ccd88](https://github.com/wangeditor-team/wangEditor/commit/e7ccd88a7dd58f64b7bd484de428e3a76cc994f7)) * getHeaders & editor.srcollToElem ([2bfb813](https://github.com/wangeditor-team/wangEditor/commit/2bfb813e4957f080c6676ec38f8f051275cdf44a)) * groupButton disabled ([8ffd44c](https://github.com/wangeditor-team/wangEditor/commit/8ffd44c9a44758e951ca7bd02dd46746fcac1c03)) * header button menu ([6413135](https://github.com/wangeditor-team/wangEditor/commit/64131354d54705e11fd6992fcf5a4389371c3560)) * i18n ([c11b244](https://github.com/wangeditor-team/wangEditor/commit/c11b2440f91b99d40bca18b675c66a22b6e160c9)) * image menu - width 50% 100% ([f9b4c68](https://github.com/wangeditor-team/wangEditor/commit/f9b4c68dff3232b50491b07949c20eb4c18baa6b)) * image menu config ([bb18774](https://github.com/wangeditor-team/wangEditor/commit/bb187740e9703b4a76cde4f5e4d32ac714aa793a)) * insertHtml - text style ([6f303c5](https://github.com/wangeditor-team/wangEditor/commit/6f303c5be81dc8763a28bc982928e5bc9f2936d9)) * link menu config ([fe6b6db](https://github.com/wangeditor-team/wangEditor/commit/fe6b6db62086a5014c25ea96aa9308c2028a5b60)) * parse html ([2a5eace](https://github.com/wangeditor-team/wangEditor/commit/2a5eace00f33cded50b68e8164748ec2480213fd)) * parse src (link image video) ([715a841](https://github.com/wangeditor-team/wangEditor/commit/715a841fc6c730ee2b448a1799a07ce778128aad)) * placeholder ([a3e4cdc](https://github.com/wangeditor-team/wangEditor/commit/a3e4cdcd474063e4f436327aaf4074bb2126d941)) * react 组件 ([448fc83](https://github.com/wangeditor-team/wangEditor/commit/448fc838d64dbef52cbcddde0e98eb021d8a9122)) * todo ([9608fef](https://github.com/wangeditor-team/wangEditor/commit/9608fef2ff86368cdcbb950a74af1246a58709de)) * toHtml 机制 ([1c4d872](https://github.com/wangeditor-team/wangEditor/commit/1c4d8729f84aaab6a448f23064b34a20596305e9)) * upload video ([ac8e6f8](https://github.com/wangeditor-team/wangEditor/commit/ac8e6f8b5258e593714676a6f6be359ba525833c)) * video menu config ([7fa3783](https://github.com/wangeditor-team/wangEditor/commit/7fa3783c42aa83f7d53c8be34be3c8b7c8a64754)) ### Performance Improvements * 优化较大的svg图片 ([2c360e7](https://github.com/wangeditor-team/wangEditor/commit/2c360e7628eb655e8df67cc7b764f4981b283a2f)) ================================================ FILE: packages/basic-modules/README.md ================================================ # wangEditor basic-modules Basic modules built in [wangEditor](https://www.wangeditor.com/) by default. ================================================ FILE: packages/basic-modules/__tests__/blockquote/blockquote-menu.test.ts ================================================ /** * @description blockquote menu test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import BlockquoteMenu from '../../src/modules/blockquote/menu/BlockquoteMenu' describe('blockquote menu', () => { let editor: any let startLocation: any const menu = new BlockquoteMenu() beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) // getValue 无逻辑,不用测试 it('is disabled', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'blockquote' }) expect(menu.isDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'header1' }) expect(menu.isDisabled(editor)).toBeTruthy() // 非 p blockquote ,则禁用 }) it('exec and isActive', () => { editor.select(startLocation) menu.exec(editor, '') // 转换为 blockquote const blockquotes1 = editor.getElemsByTypePrefix('blockquote') expect(blockquotes1.length).toBe(1) expect(menu.isActive(editor)).toBeTruthy() menu.exec(editor, '') // 取消 blockquote const blockquotes2 = editor.getElemsByTypePrefix('blockquote') expect(blockquotes2.length).toBe(0) expect(menu.isActive(editor)).toBeFalsy() }) }) ================================================ FILE: packages/basic-modules/__tests__/blockquote/elem-to-html.test.ts ================================================ /** * @description blockquote - elem to html test * @author wangfupeng */ import { quoteToHtmlConf } from '../../src/modules/blockquote/elem-to-html' describe('blockquote elem to html', () => { it('blockquote to html', () => { expect(quoteToHtmlConf.type).toBe('blockquote') const elem = { type: 'blockquote', children: [] } const html = quoteToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe('
hello
') }) }) ================================================ FILE: packages/basic-modules/__tests__/blockquote/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import { BaseElement } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import { parseHtmlConf } from '../../src/modules/blockquote/parse-elem-html' describe('blockquote - parse html', () => { const editor = createEditor() it('without children', () => { const $elem = $('
hello world
') // match selector expect($elem[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse const res = parseHtmlConf.parseElemHtml($elem[0], [], editor) expect(res).toEqual({ type: 'blockquote', children: [{ text: 'hello world' }], }) }) it('with children', () => { const $elem = $('
') const children = [{ text: 'hello ' }, { text: 'world', bold: true }] // parse const res = parseHtmlConf.parseElemHtml($elem[0], children, editor) expect(res).toEqual({ type: 'blockquote', children: [{ text: 'hello ' }, { text: 'world', bold: true }], }) }) it('with inline children', () => { const $elem = $('
') const children: any[] = [ { text: 'hello ' }, { type: 'link', url: 'http://wangeditor.com' }, { type: 'paragraph', children: [] }, ] const isInline = editor.isInline editor.isInline = (element: any) => { if (element.type === 'link') return true return isInline(element) } // parse const res = parseHtmlConf.parseElemHtml($elem[0], children, editor) expect(res).toEqual({ type: 'blockquote', children: [{ text: 'hello ' }, { type: 'link', url: 'http://wangeditor.com' }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/blockquote/plugin.test.ts ================================================ /** * @description blockquote plugin test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import withBlockquote from '../../src/modules/blockquote/plugin' describe('blockquote plugin', () => { let editor = withBlockquote(createEditor()) let startLocation = Editor.start(editor, []) beforeEach(() => { editor = withBlockquote(createEditor()) startLocation = Editor.start(editor, []) }) it('insert break', () => { expect(1).toBeTruthy() // TODO 该测试一直报错(找不到 blockquote path),待定处理 // editor.select(startLocation) // // @ts-ignore // Transforms.setNodes(editor, { type: 'blockquote' }) // 设置 blockquote // const pList1 = editor.getElemsByType('paragraph') // expect(pList1.length).toBe(0) // editor.insertText('hello') // console.log(11, JSON.stringify(editor.children)) // console.log(22, JSON.stringify(editor.selection)) // editor.insertBreak() // 第一次换行,内部插入 \n // const pList2 = editor.getElemsByType('paragraph') // expect(pList2.length).toBe(0) // editor.insertBreak() // 再一次换行,生成 p // const pList3 = editor.getElemsByType('paragraph') // expect(pList3.length).toBe(1) }) }) ================================================ FILE: packages/basic-modules/__tests__/blockquote/render-elem.test.ts ================================================ /** * @description blockquote render elem test * @author wangfupeng */ import createEditor from '../../../../tests/utils/create-editor' import { renderBlockQuoteConf } from '../../src/modules/blockquote/render-elem' describe('blockquote - render elem', () => { const editor = createEditor() it('render blockquote elem', () => { expect(renderBlockQuoteConf.type).toBe('blockquote') const elem = { type: 'blockquote', children: [] } const vnode = renderBlockQuoteConf.renderElem(elem, null, editor) expect(vnode.sel).toBe('blockquote') }) }) ================================================ FILE: packages/basic-modules/__tests__/code-block/code-block-menu.test.ts ================================================ /** * @description code-block menu test * @author wangfupeng */ import { Editor, Transforms, Element } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import CodeBlockMenu from '../../src/modules/code-block/menu/CodeBlockMenu' describe('code-block menu', () => { const menu = new CodeBlockMenu() let editor: any let startLocation: any const codeElem = { type: 'code', language: 'javascript', children: [{ text: 'var' }], } const preElem = { type: 'pre', children: [codeElem], } beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('getValue and isActive', done => { editor.select(startLocation) expect(menu.isActive(editor)).toBeFalsy() expect(menu.getValue(editor)).toBe('') editor.insertNode(preElem) // 插入 code node editor.select({ path: [1, 0, 0], // 选中 code node offset: 3, }) setTimeout(() => { expect(menu.isActive(editor)).toBeTruthy() expect(menu.getValue(editor)).toBe('javascript') done() }) }) it('is disabled', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'header1' } as Partial) expect(menu.isDisabled(editor)).toBeTruthy() // 非 p pre ,则禁用 editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] }) expect(menu.isDisabled(editor)).toBeFalsy() // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code }) it('exec - to code-block', () => { editor.select(startLocation) menu.exec(editor, 'javascript') // 生成 code-block const preList = editor.getElemsByTypePrefix('pre') expect(preList.length).toBe(1) const codeLis = editor.getElemsByTypePrefix('code') expect(codeLis.length).toBe(1) }) it('exec - to paragraph', () => { editor.select(startLocation) editor.insertNode(preElem) // 插入 code node editor.select({ path: [1, 0, 0], // 选中 code node offset: 3, }) menu.exec(editor, '') // 取消 code-block const preList = editor.getElemsByTypePrefix('pre') expect(preList.length).toBe(0) const codeLis = editor.getElemsByTypePrefix('code') expect(codeLis.length).toBe(0) }) }) ================================================ FILE: packages/basic-modules/__tests__/code-block/elem-to-html.test.ts ================================================ /** * @description code-block elem to html test * @author wangfupeng */ import { codeToHtmlConf, preToHtmlConf } from '../../src/modules/code-block/elem-to-html' describe('code-block - elem to html', () => { it('code to html', () => { expect(codeToHtmlConf.type).toBe('code') const elem = { type: 'code', children: [] } const html = codeToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe('hello') }) it('pre to html', () => { expect(preToHtmlConf.type).toBe('pre') const elem = { type: 'pre', children: [] } const html = preToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe('
hello
') }) }) ================================================ FILE: packages/basic-modules/__tests__/code-block/parse-html.test.ts ================================================ /** * @description parse elem html * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseCodeHtmlConf, parsePreHtmlConf } from '../../src/modules/code-block/parse-elem-html' import { preParseHtmlConf } from '../../src/modules/code-block/pre-parse-html' describe('code block - pre parse html', () => { it('pre parse html', () => { const $pre = $('
')
    const $code = $('var a = 100;')
    $pre.append($code)

    // match selector
    expect($code[0].matches(preParseHtmlConf.selector)).toBeTruthy()

    // pre parse
    const res = preParseHtmlConf.preParseHtml($code[0])
    expect(res.innerHTML).toBe('var a = 100;')
  })
})

describe('code block - parse html', () => {
  const editor = createEditor()

  it('parse code html', () => {
    const $pre = $('
')
    const $code = $('var a = 100;')
    $pre.append($code)

    // match selector
    expect($code[0].matches(parseCodeHtmlConf.selector)).toBeTruthy()

    // parse
    const res = parseCodeHtmlConf.parseElemHtml($code[0], [], editor)
    expect(res).toEqual({
      type: 'code',
      language: '',
      children: [{ text: 'var a = 100;' }],
    })
  })
  it('parse pre html', () => {
    const $pre = $('
')
    const children = [
      {
        type: 'code',
        language: '',
        children: [{ text: 'var a = 100;' }],
      },
    ]

    // match selector
    expect($pre[0].matches(parsePreHtmlConf.selector)).toBeTruthy()

    // parse
    const res = parsePreHtmlConf.parseElemHtml($pre[0], children, editor)
    expect(res).toEqual({
      type: 'pre',
      children: [
        {
          type: 'code',
          language: '',
          children: [{ text: 'var a = 100;' }],
        },
      ],
    })
  })
})


================================================
FILE: packages/basic-modules/__tests__/code-block/plugin.test.ts
================================================
/**
 * @description code-block plugin test
 * @author wangfupeng
 */

import { Editor, Transforms } from 'slate'
import createEditor from '../../../../tests/utils/create-editor'
import withCodeBlock from '../../src/modules/code-block/plugin'

// 模拟 DataTransfer
class MyDataTransfer {
  private values: object = {}
  setData(type: string, value: string) {
    this.values[type] = value
  }
  getData(type: string): string {
    return this.values[type]
  }
}

describe('code-block plugin', () => {
  let editor: any
  let startLocation: any

  const codeElem = {
    type: 'code',
    children: [{ text: 'var' }],
  }
  const preElem = {
    type: 'pre',
    children: [codeElem],
  }

  beforeEach(() => {
    editor = withCodeBlock(createEditor())
    startLocation = Editor.start(editor, [])
  })

  afterEach(() => {
    editor = null
    startLocation = null
  })

  it('insert break', () => {
    editor.select(startLocation)
    editor.insertNode(preElem) // 插入 code-block

    // code-block 前后会自动生成两个 p
    const pList1 = editor.getElemsByTypePrefix('paragraph')
    expect(pList1.length).toBe(2)

    editor.select({
      path: [1, 0, 0], // 选中 code-block
      offset: 3,
    })

    // 换行都在 code-block 内部
    editor.insertBreak()
    editor.insertBreak()
    editor.insertBreak()
    expect(editor.getText()).toBe('\nvar\n\n\n\n')

    // 不会再生成新的 p
    const pList2 = editor.getElemsByTypePrefix('paragraph')
    expect(pList2.length).toBe(2)
  })

  it('insert data', () => {
    editor.select(startLocation)
    editor.insertNode(preElem) // 插入 code node
    editor.select({
      path: [1, 0, 0], // 选中 code node
      offset: 3,
    })

    const data = new MyDataTransfer()
    data.setData('text/plain', ' hello')

    editor.insertData(data)
    expect(editor.getText()).toBe('\nvar hello\n')
  })

  it('normalizeNode - code node 不能是顶级元素,否则替换为 p', () => {
    editor.select(startLocation)
    editor.insertNode(codeElem)

    const pList = editor.getElemsByTypePrefix('paragraph')
    expect(pList.length).toBe(2)
  })

  it('normalizeNode - pre node 不能是第一个节点,否则前面插入 p', () => {
    editor.select(startLocation)
    editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] })

    const pList = editor.getElemsByTypePrefix('paragraph')
    expect(pList.length).toBe(2)

    const preList = editor.getElemsByTypePrefix('pre')
    expect(preList.length).toBe(1)
  })
})


================================================
FILE: packages/basic-modules/__tests__/code-block/render-elem.test.ts
================================================
/**
 * @description code-block render elem test
 * @author wangfupeng
 */

import createEditor from '../../../../tests/utils/create-editor'
import { renderPreConf, renderCodeConf } from '../../src/modules/code-block/render-elem'

describe('code-block render elem', () => {
  const editor = createEditor()

  it('render code elem', () => {
    expect(renderCodeConf.type).toBe('code')

    const elem = { type: 'code', children: [] }
    const vnode = renderCodeConf.renderElem(elem, null, editor)
    expect(vnode.sel).toBe('code')
  })

  it('render pre elem', () => {
    expect(renderPreConf.type).toBe('pre')

    const elem = { type: 'pre', children: [] }
    const vnode = renderPreConf.renderElem(elem, null, editor)
    expect(vnode.sel).toBe('pre')
  })
})


================================================
FILE: packages/basic-modules/__tests__/color/color-menus.test.ts
================================================
/**
 * @description color menus test
 * @author wangfupeng
 */

import { Editor, Transforms } from 'slate'
import createEditor from '../../../../tests/utils/create-editor'
import ColorMenu from '../../src/modules/color/menu/ColorMenu'
import BgColorMenu from '../../src/modules/color/menu/BgColorMenu'

describe('color menus', () => {
  let editor: any
  let startLocation: any

  const menus = [
    {
      mark: 'color',
      menu: new ColorMenu(),
    },
    {
      mark: 'bgColor',
      menu: new BgColorMenu(),
    },
  ]

  beforeEach(() => {
    editor = createEditor()
    startLocation = Editor.start(editor, [])
  })

  afterEach(() => {
    editor = null
    startLocation = null
  })

  // exec 无代码,不用测试

  it('getValue and isActive', () => {
    editor.select(startLocation)

    menus.forEach(({ menu }) => {
      expect(menu.getValue(editor)).toBe('')
      expect(menu.isActive(editor)).toBeFalsy()
    })

    editor.insertText('hello') // 插入文字
    editor.select([]) // 全选
    menus.forEach(({ mark, menu }) => {
      editor.addMark(mark, 'rgb(51, 51, 51)') // 添加 color bgColor
      expect(menu.getValue(editor)).toBe('rgb(51, 51, 51)')
      expect(menu.isActive(editor)).toBeTruthy()
    })
  })

  it('is disabled', () => {
    editor.select(startLocation)
    menus.forEach(({ menu }) => {
      expect(menu.isDisabled(editor)).toBeFalsy()
    })

    editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] })
    menus.forEach(({ menu }) => {
      expect(menu.isDisabled(editor)).toBeTruthy()
    })
    // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code
  })

  it('get panel content elem', () => {
    menus.forEach(({ menu }) => {
      const elem = menu.getPanelContentElem(editor)
      expect(elem instanceof HTMLElement).toBeTruthy()
    })
  })
})


================================================
FILE: packages/basic-modules/__tests__/color/parse-html.test.ts
================================================
/**
 * @description parse html test
 * @author wangfupeng
 */

import { $ } from 'dom7'
import createEditor from '../../../../tests/utils/create-editor'
import { parseStyleHtml } from '../../src/modules/color/parse-style-html'
import { preParseHtmlConf } from '../../src/modules/color/pre-parse-html'

describe('color - pre parse html', () => {
  it('pre parse html', () => {
    const $font = $('hello')

    // match selector
    expect($font[0].matches(preParseHtmlConf.selector)).toBeTruthy()

    // pre parse
    const res = preParseHtmlConf.preParseHtml($font[0])
    expect(res.outerHTML).toBe('hello')
  })
})

describe('color - parse style html', () => {
  const editor = createEditor()

  it('parse style html', () => {
    const $span = $(
      ''
    )
    const textNode = { text: 'hello' }

    // parse style
    const res = parseStyleHtml($span[0], textNode, editor)
    expect(res).toEqual({
      text: 'hello',
      color: 'rgb(235, 144, 58)',
      bgColor: 'rgb(231, 246, 213)',
    })
  })
})


================================================
FILE: packages/basic-modules/__tests__/color/render-text-style.test.tsx
================================================
/**
 * @description color - render text style test
 * @author wangfupeng
 */

import { jsx } from 'snabbdom'
import { renderStyle } from '../../src/modules/color/render-style'

describe('color - render text style', () => {
  it('render color style', () => {
    const color = 'rgb(51, 51, 51)'
    const bgColor = 'rgb(204, 204, 204)'
    const textNode = { text: 'hello', color, bgColor }
    const vnode = hello

    // @ts-ignore
    const newVnode = renderStyle(textNode, vnode) as any
    expect(newVnode.sel).toBe('span')
    expect(newVnode.data.style.color).toBe(color)
    expect(newVnode.data.style.backgroundColor).toBe(bgColor)
  })
})


================================================
FILE: packages/basic-modules/__tests__/color/text-style-to-html.test.ts
================================================
/**
 * @description color - text style to html test
 * @author wangfupeng
 */

import { styleToHtml } from '../../src/modules/color/style-to-html'

describe('color - text style to html', () => {
  it('color to html', () => {
    const color = 'rgb(51, 51, 51)'
    const bgColor = 'rgb(204, 204, 204)'
    const textNode = { text: '', color, bgColor }

    const html = styleToHtml(textNode, 'hello')
    expect(html).toBe(`hello`)
  })
})


================================================
FILE: packages/basic-modules/__tests__/divider/elem-to-html.test.ts
================================================
/**
 * @description divider - elem to html test
 * @author wangfupeng
 */

import { dividerToHtmlConf } from '../../src/modules/divider/elem-to-html'

describe('divider - elem to html', () => {
  it('divider to html', () => {
    expect(dividerToHtmlConf.type).toBe('divider')

    const elem = { type: 'divider', children: [{ text: '' }] }
    const html = dividerToHtmlConf.elemToHtml(elem, '')
    expect(html).toBe('
') }) }) ================================================ FILE: packages/basic-modules/__tests__/divider/insert-divider-menu.test.ts ================================================ /** * @description insert divider menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import InsertDividerMenu from '../../src/modules/divider/menu/InsertDividerMenu' describe('divider plugin', () => { const menu = new InsertDividerMenu() let editor: any let startLocation: any beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) // getValue isActive 无逻辑,不用测试 it('is disabled', () => { editor.deselect() expect(menu.isDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() const elem = { type: 'divider', children: [{ text: '' }] } editor.insertNode(elem) // 插入 divider editor.select({ path: [1, 0], // 选中 divider offset: 0, }) expect(menu.isDisabled(editor)).toBeTruthy() }) it('exec', () => { editor.select(startLocation) menu.exec(editor, '') const dividers = editor.getElemsByTypePrefix('divider') expect(dividers.length).toBe(1) }) }) ================================================ FILE: packages/basic-modules/__tests__/divider/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseHtmlConf } from '../../src/modules/divider/parse-elem-html' describe('divider - parse html', () => { const editor = createEditor() it('parse html', () => { const $hr = $('
') // match selector expect($hr[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse const res = parseHtmlConf.parseElemHtml($hr[0], [], editor) expect(res).toEqual({ type: 'divider', children: [{ text: '' }], // void node 有一个空白 text }) }) }) ================================================ FILE: packages/basic-modules/__tests__/divider/plugin.test.ts ================================================ /** * @description divider plugin test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import withDivider from '../../src/modules/divider/plugin' describe('divider plugin', () => { let editor: any let startLocation: any beforeEach(() => { editor = withDivider(createEditor()) startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('divider is void node', () => { const elem = { type: 'divider', children: [{ text: '' }] } expect(editor.isVoid(elem)).toBeTruthy() }) it('normalizeNode - divider 不能是最后一个元素,否则后面追加 p', () => { const elem = { type: 'divider', children: [{ text: '' }] } editor.select(startLocation) editor.insertNode(elem) // 插入 divider const length = editor.children.length expect(length).toBe(3) // 3 个顶级节点:p, divider, p const divider = editor.children[1] // 第 2 个节点应该是 divider expect(divider.type).toBe('divider') const p = editor.children[2] // 第 3 个节点应该是 p expect(p.type).toBe('paragraph') }) }) ================================================ FILE: packages/basic-modules/__tests__/divider/render-elem.test.ts ================================================ /** * @description divider - render elem test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import { renderDividerConf } from '../../src/modules/divider/render-elem' describe('divider - render elem test', () => { const editor = createEditor() const startLocation = Editor.start(editor, []) it('render divider elem', () => { expect(renderDividerConf.type).toBe('divider') const elem = { type: 'divider', children: [{ text: '' }] } const vnode1 = renderDividerConf.renderElem(elem, null, editor) as any expect(vnode1.sel).toBe('div') expect(vnode1.data.props.className).toBe('w-e-textarea-divider') expect(vnode1.data.dataset.selected).toBe('') // 未选中 expect(vnode1.children[0].sel).toBe('hr') editor.select(startLocation) editor.insertNode(elem) // 插入 divider editor.select({ path: [1, 0], // 选中 divider offset: 0, }) const vnode2 = renderDividerConf.renderElem(elem, null, editor) as any expect(vnode2.data.dataset.selected).toBe('true') // 选中 }) }) ================================================ FILE: packages/basic-modules/__tests__/emotion/emotion-menu.test.ts ================================================ /** * @description emotion menu test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import EmotionMenu from '../../src/modules/emotion/menu/EmotionMenu' describe('font family menu', () => { const menu = new EmotionMenu() let editor: any let startLocation: any beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) // exec getValue isActive 无代码逻辑,不用测试 it('is disabled', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] }) expect(menu.isDisabled(editor)).toBeTruthy() // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code }) it('get panel content elem', () => { const elem = menu.getPanelContentElem(editor) expect(elem instanceof HTMLElement).toBeTruthy() }) }) ================================================ FILE: packages/basic-modules/__tests__/font-size-family/menu/font-family-menu.test.ts ================================================ /** * @description font family menu test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import FontFamilyMenu from '../../../src/modules/font-size-family/menu/FontFamilyMenu' describe('font family menu', () => { const menu = new FontFamilyMenu() let editor: any let startLocation: any beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get options', () => { editor.select(startLocation) const options1 = menu.getOptions(editor) const selectedDefault = options1.some(opt => opt.selected && opt.value === '') expect(selectedDefault).toBeTruthy() // 空白 p ,选中“默认” editor.insertText('hello') editor.select([]) // 全选 editor.addMark('fontFamily', '黑体') // 设置字体 const options2 = menu.getOptions(editor) const selectedHeiti = options2.some(opt => opt.selected && opt.value === '黑体') expect(selectedHeiti).toBeTruthy() }) // isActive 无代码逻辑,不用测试 it('is disabled', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] }) expect(menu.isDisabled(editor)).toBeTruthy() // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code }) it('exec and getValue', () => { editor.select(startLocation) expect(menu.getValue(editor)).toBe('') editor.insertText('hello') editor.select([]) // 全选 menu.exec(editor, '黑体') // 设置字体 expect(menu.getValue(editor)).toBe('黑体') menu.exec(editor, '') // 取消字体 expect(menu.getValue(editor)).toBe('') }) }) ================================================ FILE: packages/basic-modules/__tests__/font-size-family/menu/font-size-menu.test.ts ================================================ /** * @description font size menu test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import FontSizeMenu from '../../../src/modules/font-size-family/menu/FontSizeMenu' describe('font family menu', () => { const menu = new FontSizeMenu() let editor: any let startLocation: any beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get options', () => { editor.select(startLocation) const options1 = menu.getOptions(editor) const selectedDefault = options1.some(opt => opt.selected && opt.value === '') expect(selectedDefault).toBeTruthy() // 空白 p ,选中“默认” editor.insertText('hello') editor.select([]) // 全选 editor.addMark('fontSize', '40px') // 设置字号 const options2 = menu.getOptions(editor) const selected = options2.some(opt => opt.selected && opt.value === '40px') expect(selected).toBeTruthy() }) // isActive 无代码逻辑,不用测试 it('is disabled', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] }) expect(menu.isDisabled(editor)).toBeTruthy() // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code }) it('exec and getValue', () => { editor.select(startLocation) expect(menu.getValue(editor)).toBe('') editor.insertText('hello') editor.select([]) // 全选 menu.exec(editor, '40px') // 设置字号 expect(menu.getValue(editor)).toBe('40px') menu.exec(editor, '') // 取消字号 expect(menu.getValue(editor)).toBe('') }) }) ================================================ FILE: packages/basic-modules/__tests__/font-size-family/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseStyleHtml } from '../../src/modules/font-size-family/parse-style-html' import { preParseHtmlConf } from '../../src/modules/font-size-family/pre-parse-html' describe('font size family - pre parse html', () => { it('pre parse html', () => { const $font = $('hello') // match selector expect($font[0].matches(preParseHtmlConf.selector)).toBeTruthy() // pre parse const res = preParseHtmlConf.preParseHtml($font[0]) expect(res.outerHTML).toBe('hello') }) }) describe('font size family - parse style html', () => { const editor = createEditor() it('parse style html', () => { const $span = $('') const textNode = { text: 'hello' } // parse style const res = parseStyleHtml($span[0], textNode, editor) expect(res).toEqual({ text: 'hello', fontSize: '12px', fontFamily: '黑体', }) }) }) ================================================ FILE: packages/basic-modules/__tests__/font-size-family/render-text-style.test.tsx ================================================ /** * @description font size and family - render text style test * @author wangfupeng */ import { jsx } from 'snabbdom' import { renderStyle } from '../../src/modules/font-size-family/render-style' describe('font size and family - render text style', () => { it('render text style', () => { const fontSize = '20px' const fontFamily = '黑体' const textNode = { text: 'hello', fontSize, fontFamily } const vnode = hello // @ts-ignore 忽略 vnode 格式检查 const newVnode = renderStyle(textNode, vnode) as any expect(newVnode.data.style.fontSize).toBe(fontSize) expect(newVnode.data.style.fontFamily).toBe(fontFamily) }) }) ================================================ FILE: packages/basic-modules/__tests__/font-size-family/text-style-to-html.test.ts ================================================ /** * @description font size and family - text style to html test * @author wangfupeng */ import { styleToHtml } from '../../src/modules/font-size-family/style-to-html' describe('font size and family - text style to html', () => { it('text style to html', () => { const fontSize = '20px' const fontFamily = '黑体' const textNode = { text: '', fontSize, fontFamily } const html = styleToHtml(textNode, 'hello') expect(html).toBe( `hello` ) }) }) ================================================ FILE: packages/basic-modules/__tests__/full-screen/full-screen-menu.test.ts ================================================ /** * @description full screen menu test * @author wangfupeng */ import createEditor from '../../../../tests/utils/create-editor' import FullScreen from '../../src/modules/full-screen/menu/FullScreen' describe('full screen menu', () => { const editor = createEditor() const menu = new FullScreen() it('full screen menu', done => { menu.exec(editor, '') // 设置全屏 expect(menu.isActive(editor)).toBeTruthy() menu.exec(editor, '') // 取消全屏(有延迟) setTimeout(() => { expect(menu.isActive(editor)).toBeFalsy() done() }, 500) }) }) ================================================ FILE: packages/basic-modules/__tests__/header/elem-to-html.test.ts ================================================ /** * @description header - elem to html test * @author wangfupeng */ import { header1ToHtmlConf, header2ToHtmlConf, header3ToHtmlConf, header4ToHtmlConf, header5ToHtmlConf, } from '../../src/modules/header/elem-to-html' describe('header - elem to html', () => { const elem = { type: 'header1', children: [{ text: '' }] } it('header1 to html', () => { expect(header1ToHtmlConf.type).toBe('header1') const html = header1ToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe('

hello

') }) it('header2 to html', () => { expect(header2ToHtmlConf.type).toBe('header2') const html = header2ToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe('

hello

') }) it('header3 to html', () => { expect(header3ToHtmlConf.type).toBe('header3') const html = header3ToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe('

hello

') }) it('header4 to html', () => { expect(header4ToHtmlConf.type).toBe('header4') const html = header4ToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe('

hello

') }) it('header5 to html', () => { expect(header5ToHtmlConf.type).toBe('header5') const html = header5ToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe('
hello
') }) }) ================================================ FILE: packages/basic-modules/__tests__/header/helper.test.ts ================================================ /** * @description header helper test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import { getHeaderType, isMenuDisabled, setHeaderType } from '../../src/modules/header/helper' describe('header helper', () => { let editor: any let startLocation: any beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get header type', () => { editor.select(startLocation) expect(getHeaderType(editor)).toBe('paragraph') Transforms.setNodes(editor, { type: 'header1' }) expect(getHeaderType(editor)).toBe('header1') }) it('is menu disabled', () => { editor.select(startLocation) expect(isMenuDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'header1' }) expect(isMenuDisabled(editor)).toBeFalsy() editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] }) expect(isMenuDisabled(editor)).toBeTruthy() // 只能用于 p header // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code }) it('set header type', () => { editor.select(startLocation) setHeaderType(editor, 'header1') const headers = editor.getElemsByTypePrefix('header1') expect(headers.length).toBe(1) }) }) ================================================ FILE: packages/basic-modules/__tests__/header/menu/header-select-menu.test.ts ================================================ /** * @description header select menu test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import HeaderSelectMenu from '../../../src/modules/header/menu/HeaderSelectMenu' describe('header select menu', () => { const editor = createEditor() const startLocation = Editor.start(editor, []) const menu = new HeaderSelectMenu() it('get options', () => { editor.select(startLocation) const options1 = menu.getOptions(editor) const selectedP = options1.some(opt => opt.selected && opt.value === 'paragraph') // 选中“文本” expect(selectedP).toBeTruthy() Transforms.setNodes(editor, { type: 'header1' }) const options2 = menu.getOptions(editor) const selectedHeader = options2.some(opt => opt.selected && opt.value === 'header1') // 选中“h1” expect(selectedHeader).toBeTruthy() }) // isActive 无逻辑,不用测试 // getValue isDisabled exec 已经在 helper.test.ts 中测试过了 }) ================================================ FILE: packages/basic-modules/__tests__/header/menu/header1-menu.test.ts ================================================ /** * @description header1 menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import Header1ButtonMenu from '../../../src/modules/header/menu/Header1ButtonMenu' import Header2ButtonMenu from '../../../src/modules/header/menu/Header2ButtonMenu' import Header3ButtonMenu from '../../../src/modules/header/menu/Header3ButtonMenu' import Header4ButtonMenu from '../../../src/modules/header/menu/Header4ButtonMenu' import Header5ButtonMenu from '../../../src/modules/header/menu/Header5ButtonMenu' describe('header menu', () => { const editor = createEditor() const startLocation = Editor.start(editor, []) describe('header1 menu', () => { const menu = new Header1ButtonMenu() it('exec', () => { editor.select(startLocation) menu.exec(editor, 'paragraph') // 设置 header ( paragraph 是当前选中的 node type ) const headers1 = editor.getElemsByTypePrefix('header1') expect(headers1.length).toBe(1) menu.exec(editor, 'header1') // 取消 header( header1 是当前选中的 node type ) const headers2 = editor.getElemsByTypePrefix('header1') expect(headers2.length).toBe(0) }) }) describe('header2 menu', () => { const menu = new Header2ButtonMenu() it('exec', () => { editor.select(startLocation) menu.exec(editor, 'paragraph') // 设置 header ( paragraph 是当前选中的 node type ) const headers1 = editor.getElemsByTypePrefix('header2') expect(headers1.length).toBe(1) menu.exec(editor, 'header2') // 取消 header( header2 是当前选中的 node type ) const headers2 = editor.getElemsByTypePrefix('header2') expect(headers2.length).toBe(0) }) }) describe('header3 menu', () => { const menu = new Header3ButtonMenu() it('exec', () => { editor.select(startLocation) menu.exec(editor, 'paragraph') // 设置 header ( paragraph 是当前选中的 node type ) const headers1 = editor.getElemsByTypePrefix('header3') expect(headers1.length).toBe(1) menu.exec(editor, 'header3') // 取消 header( header3 是当前选中的 node type ) const headers2 = editor.getElemsByTypePrefix('header3') expect(headers2.length).toBe(0) }) }) describe('header4 menu', () => { const menu = new Header4ButtonMenu() it('exec', () => { editor.select(startLocation) menu.exec(editor, 'paragraph') // 设置 header ( paragraph 是当前选中的 node type ) const headers1 = editor.getElemsByTypePrefix('header4') expect(headers1.length).toBe(1) menu.exec(editor, 'header4') // 取消 header( header4 是当前选中的 node type ) const headers2 = editor.getElemsByTypePrefix('header4') expect(headers2.length).toBe(0) }) }) describe('header5 menu', () => { const menu = new Header5ButtonMenu() it('exec', () => { editor.select(startLocation) menu.exec(editor, 'paragraph') // 设置 header ( paragraph 是当前选中的 node type ) const headers1 = editor.getElemsByTypePrefix('header5') expect(headers1.length).toBe(1) menu.exec(editor, 'header5') // 取消 header( header5 是当前选中的 node type ) const headers2 = editor.getElemsByTypePrefix('header5') expect(headers2.length).toBe(0) }) }) }) ================================================ FILE: packages/basic-modules/__tests__/header/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseHeader1HtmlConf } from '../../src/modules/header/parse-elem-html' describe('header - parse html', () => { const editor = createEditor() it('with children', () => { const $h1 = $(`

`) const children = [{ text: 'hello ' }, { text: 'world', bold: true }] // match selector expect($h1[0].matches(parseHeader1HtmlConf.selector)).toBeTruthy() // parse html const res = parseHeader1HtmlConf.parseElemHtml($h1[0], children, editor) expect(res).toEqual({ type: `header1`, children: [{ text: 'hello ' }, { text: 'world', bold: true }], }) }) it('without children', () => { const $h1 = $(`

hello world

`) // match selector expect($h1[0].matches(parseHeader1HtmlConf.selector)).toBeTruthy() // parse html const res = parseHeader1HtmlConf.parseElemHtml($h1[0], [], editor) expect(res).toEqual({ type: `header1`, children: [{ text: 'hello world' }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/header/plugin.test.ts ================================================ /** * @description header plugin test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import withHeader from '../../src/modules/header/plugin' describe('header plugin', () => { const editor = withHeader(createEditor()) const startLocation = Editor.start(editor, []) it('header break', () => { editor.select(startLocation) Transforms.setNodes(editor, { type: 'header1' }) editor.insertBreak() // 在 header 换行,会生成 p const paragraphs = editor.getElemsByTypePrefix('paragraph') expect(paragraphs.length).toBe(1) }) }) ================================================ FILE: packages/basic-modules/__tests__/header/render-elem.test.ts ================================================ /** * @description header - render elem test * @author wangfupeng */ import createEditor from '../../../../tests/utils/create-editor' import { renderHeader1Conf, renderHeader2Conf, renderHeader3Conf, renderHeader4Conf, renderHeader5Conf, } from '../../src/modules/header/render-elem' describe('render header elem', () => { const editor = createEditor() it('render h1', () => { expect(renderHeader1Conf.type).toBe('header1') const elem = { type: 'header1', children: [] } const vnode = renderHeader1Conf.renderElem(elem, null, editor) expect(vnode.sel).toBe('h1') }) it('render h2', () => { expect(renderHeader2Conf.type).toBe('header2') const elem = { type: 'header2', children: [] } const vnode = renderHeader2Conf.renderElem(elem, null, editor) expect(vnode.sel).toBe('h2') }) it('render h3', () => { expect(renderHeader3Conf.type).toBe('header3') const elem = { type: 'header3', children: [] } const vnode = renderHeader3Conf.renderElem(elem, null, editor) expect(vnode.sel).toBe('h3') }) it('render h4', () => { expect(renderHeader4Conf.type).toBe('header4') const elem = { type: 'header4', children: [] } const vnode = renderHeader4Conf.renderElem(elem, null, editor) expect(vnode.sel).toBe('h4') }) it('render h5', () => { expect(renderHeader5Conf.type).toBe('header5') const elem = { type: 'header5', children: [] } const vnode = renderHeader5Conf.renderElem(elem, null, editor) expect(vnode.sel).toBe('h5') }) }) ================================================ FILE: packages/basic-modules/__tests__/image/elem-to-html.test.ts ================================================ /** * @description image - elem to html test * @author wangfupeng */ import { imageToHtmlConf } from '../../src/modules/image/elem-to-html' describe('image to html', () => { it('to html', () => { expect(imageToHtmlConf.type).toBe('image') const src = 'https://www.wangeditor.com/imgs/logo.png' const href = 'https://www.wangeditor.com/' const elem = { type: 'image', src, alt: 'logo', href, style: { width: '100', height: '80' }, children: [{ text: '' }], // void node 必须包含一个空 text } const html = imageToHtmlConf.elemToHtml(elem, '') expect(html).toBe( `logo` ) }) }) ================================================ FILE: packages/basic-modules/__tests__/image/helper.test.ts ================================================ /** * @description image helper test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import { DomEditor } from '@wangeditor/core' import createEditor from '../../../../tests/utils/create-editor' import { insertImageNode, updateImageNode, isInsertImageMenuDisabled, } from '../../src/modules/image/helper' describe('image helper', () => { let editor: any let startLocation: any const src = 'https://www.wangeditor.com/imgs/logo.png' const alt = 'logo' const href = 'https://www.wangeditor.com/' beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('insert image node', async () => { editor.select(startLocation) await insertImageNode(editor, src, alt, href) const images = editor.getElemsByTypePrefix('image') expect(images.length).toBe(1) }) it('update image node', async () => { editor.select(startLocation) const elem = { type: 'image', src, alt, href, style: { width: '100', height: '80' }, children: [{ text: '' }], // void node 必须包含一个空 text } editor.insertNode(elem) // 插入图片 editor.select({ path: [0, 1, 0], // 选中图片 offset: 0, }) const newSrc = 'https://www.baidu.com/logo.png' const newAlt = 'baidu' const newHref = 'https://www.baidu.com/' await updateImageNode(editor, newSrc, newAlt, newHref, {}) // 更新图片信息 const imageNode = DomEditor.getSelectedNodeByType(editor, 'image') expect(imageNode).not.toBeNull() }) it('is menu disable', async () => { editor.deselect() expect(isInsertImageMenuDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(isInsertImageMenuDisabled(editor)).toBeFalsy() editor.insertText('hello') editor.select([]) expect(isInsertImageMenuDisabled(editor)).toBeTruthy() editor.select(startLocation) Transforms.setNodes(editor, { type: 'header1' }) expect(isInsertImageMenuDisabled(editor)).toBeTruthy() }) }) ================================================ FILE: packages/basic-modules/__tests__/image/menu/del-image.test.ts ================================================ /** * @description delete image menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import DeleteImage from '../../../src/modules/image/menu/DeleteImage' describe('delete image menu', () => { const menu = new DeleteImage() let editor: any let startLocation: any const src = 'https://www.wangeditor.com/imgs/logo.png' const alt = 'logo' const href = 'https://www.wangeditor.com/' beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) // getValue isActive 无逻辑,不用测试 it('is disabled', () => { editor.deselect() expect(menu.isDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(menu.isDisabled(editor)).toBeTruthy() const elem = { type: 'image', src, alt, href, children: [{ text: '' }], // void node 必须包含一个空 text } editor.insertNode(elem) // 插入图片 editor.select({ path: [0, 1, 0], // 选中图片 offset: 0, }) expect(menu.isDisabled(editor)).toBeFalsy() }) it('exec', () => { editor.select(startLocation) const elem = { type: 'image', src, alt, href, children: [{ text: '' }], // void node 必须包含一个空 text } editor.insertNode(elem) // 插入图片 editor.select({ path: [0, 1, 0], // 选中图片 offset: 0, }) menu.exec(editor, '') const images = editor.getElemsByTypePrefix('image') expect(images.length).toBe(0) }) }) ================================================ FILE: packages/basic-modules/__tests__/image/menu/edit-image.test.ts ================================================ /** * @description edit image menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import EditImage from '../../../src/modules/image/menu/EditImage' describe('edit image menu', () => { const menu = new EditImage() let editor: any let startLocation: any const src = 'https://www.wangeditor.com/imgs/logo.png' const alt = 'logo' const href = 'https://www.wangeditor.com/' beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) // getValue isActive exec 无逻辑,不用测试 it('is disabled', () => { editor.deselect() expect(menu.isDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(menu.isDisabled(editor)).toBeTruthy() const elem = { type: 'image', src, alt, href, style: { width: '100', height: '80' }, children: [{ text: '' }], // void node 必须包含一个空 text } editor.insertNode(elem) // 插入图片 editor.select({ path: [0, 1, 0], // 选中图片 offset: 0, }) expect(menu.isDisabled(editor)).toBeFalsy() }) it('get modal position node', () => { editor.select(startLocation) expect(menu.getModalPositionNode(editor)).toBeNull() const elem = { type: 'image', src, alt, href, style: { width: '100', height: '80' }, children: [{ text: '' }], // void node 必须包含一个空 text } editor.insertNode(elem) // 插入图片 editor.select({ path: [0, 1, 0], // 选中图片 offset: 0, }) const imageNode = menu.getModalPositionNode(editor) expect((imageNode as any).src).toBe(src) }) it('get modal content elem', () => { editor.select(startLocation) const imageElem = { type: 'image', src, alt, href, style: { width: '100', height: '80' }, children: [{ text: '' }], // void node 必须包含一个空 text } editor.insertNode(imageElem) // 插入图片 editor.select({ path: [0, 1, 0], // 选中图片 offset: 0, }) const elem = menu.getModalContentElem(editor) expect(elem.tagName).toBe('DIV') // updateImage 在 helper.test.ts 中测试 }) }) ================================================ FILE: packages/basic-modules/__tests__/image/menu/insert-image.test.ts ================================================ /** * @description insert image menu test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import InsertImage from '../../../src/modules/image/menu/InsertImage' describe('insert image menu', () => { const menu = new InsertImage() let editor: any let startLocation: any beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) // getValue isActive exec 无逻辑,不用测试 it('is disabled', () => { editor.deselect() expect(menu.isDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() editor.insertText('xxx') editor.select([]) // 全选文字 expect(menu.isDisabled(editor)).toBeTruthy() // 非折叠选区,则不可用 editor.select(startLocation) Transforms.setNodes(editor, { type: 'header1' }) expect(menu.isDisabled(editor)).toBeTruthy() // header 中不可用 Transforms.setNodes(editor, { type: 'blockquote' }) expect(menu.isDisabled(editor)).toBeTruthy() // blockquote 中不可用 }) // getModalPositionNode 无逻辑,不用测试 it('get modal content elem', () => { const elem = menu.getModalContentElem(editor) expect(elem.tagName).toBe('DIV') // insertImage 在 helper.test.ts 中测试 }) }) ================================================ FILE: packages/basic-modules/__tests__/image/menu/view-image-link.test.ts ================================================ /** * @description view image link menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import ViewImageLink from '../../../src/modules/image/menu/ViewImageLink' describe('view image link menu', () => { const menu = new ViewImageLink() let editor: any let startLocation: any const src = 'https://www.wangeditor.com/imgs/logo.png' const alt = 'logo' const href = 'https://www.wangeditor.com/' beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('getValue and isDisabled', () => { editor.select(startLocation) expect(menu.getValue(editor)).toBe('') expect(menu.isDisabled(editor)).toBeTruthy() const elem = { type: 'image', src, alt, href, style: { width: '100', height: '80' }, children: [{ text: '' }], // void node 必须包含一个空 text } editor.insertNode(elem) // 插入图片 editor.select({ path: [0, 1, 0], // 选中图片 offset: 0, }) expect(menu.getValue(editor)).toBe(href) expect(menu.isDisabled(editor)).toBeFalsy() }) // isActive 无逻辑,不用测试 // exec 逻辑简单,不用测试 }) ================================================ FILE: packages/basic-modules/__tests__/image/menu/width-menus.test.ts ================================================ /** * @description image width menus test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import Width30 from '../../../src/modules/image/menu/Width30' import Width50 from '../../../src/modules/image/menu/Width50' import Width100 from '../../../src/modules/image/menu/Width100' describe('image width menus', () => { const width30Menu = new Width30() const width50Menu = new Width50() const width100Menu = new Width100() let editor: any let startLocation: any const src = 'https://www.wangeditor.com/imgs/logo.png' const alt = 'logo' const href = 'https://www.wangeditor.com/' beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) // getValue isActive 无逻辑,不用测试 it('is disabled', () => { editor.deselect() expect(width30Menu.isDisabled(editor)).toBeTruthy() expect(width50Menu.isDisabled(editor)).toBeTruthy() expect(width100Menu.isDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(width30Menu.isDisabled(editor)).toBeTruthy() expect(width50Menu.isDisabled(editor)).toBeTruthy() expect(width100Menu.isDisabled(editor)).toBeTruthy() const elem = { type: 'image', src, alt, href, style: { width: '100', height: '80' }, children: [{ text: '' }], // void node 必须包含一个空 text } editor.insertNode(elem) // 插入图片 editor.select({ path: [0, 1, 0], // 选中图片 offset: 0, }) expect(width30Menu.isDisabled(editor)).toBeFalsy() expect(width50Menu.isDisabled(editor)).toBeFalsy() expect(width100Menu.isDisabled(editor)).toBeFalsy() }) it('exec', () => { editor.select(startLocation) const elem = { type: 'image', src, alt, href, style: { width: '100', height: '80' }, children: [{ text: '' }], // void node 必须包含一个空 text } editor.insertNode(elem) // 插入图片 editor.select({ path: [0, 1, 0], // 选中图片 offset: 0, }) width30Menu.exec(editor, '') const image1 = editor.getElemsByTypePrefix('image')[0] expect(image1.style.width).toBe('30%') expect(image1.style.height).toBe('') width50Menu.exec(editor, '') const image2 = editor.getElemsByTypePrefix('image')[0] expect(image2.style.width).toBe('50%') expect(image2.style.height).toBe('') width100Menu.exec(editor, '') const image3 = editor.getElemsByTypePrefix('image')[0] expect(image3.style.width).toBe('100%') expect(image3.style.height).toBe('') }) }) ================================================ FILE: packages/basic-modules/__tests__/image/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseHtmlConf } from '../../src/modules/image/parse-elem-html' describe('image - parse html', () => { const editor = createEditor() it('parse html', () => { const $img = $( 'hello' ) // match selector expect($img[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse const res = parseHtmlConf.parseElemHtml($img[0], [], editor) expect(res).toEqual({ type: 'image', src: 'hello.png', alt: 'hello', href: 'http://localhost/', style: { width: '10px', height: '5px', }, children: [{ text: '' }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/image/plugin.test.ts ================================================ /** * @description image plugin test * @author wangfupeng */ import createEditor from '../../../../tests/utils/create-editor' import withImage from '../../src/modules/image/plugin' describe('image plugin', () => { const editor = withImage(createEditor()) const elem = { type: 'image', children: [{ text: '' }] } it('image is inline', () => { expect(editor.isInline(elem)).toBeTruthy() }) it('image is void', () => { expect(editor.isVoid(elem)).toBeTruthy() }) }) ================================================ FILE: packages/basic-modules/__tests__/image/render-elem.test.ts ================================================ /** * @description image - render elem test * @author wangfupeng */ import { Editor } from 'slate' import { renderImageConf } from '../../src/modules/image/render-elem' import createEditor from '../../../../tests/utils/create-editor' describe('image render elem', () => { let editor: any let startLocation: any beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor.clear() editor.destroy() editor = null startLocation = null }) it('render image - unselected image', () => { expect(renderImageConf.type).toBe('image') const src = 'https://www.wangeditor.com/imgs/logo.png' const href = 'https://www.wangeditor.com/' const elem = { type: 'image', src, alt: 'logo', href, style: { width: '100', height: '80' }, children: [{ text: '' }], // void node 必须包含一个空 text } const containerVnode = renderImageConf.renderElem(elem, null, editor) as any expect(containerVnode.sel).toBe('div') expect(containerVnode.data.className).toBe('w-e-image-container') expect(containerVnode.data.style.width).toBe('100') expect(containerVnode.data.style.height).toBe('80') const imageVnode = containerVnode.children[0] as any expect(imageVnode.sel).toBe('img') expect(imageVnode.data.src).toBe(src) expect(imageVnode.data['data-href']).toBe(href) }) it('render image - selected image', () => { const src = 'https://www.wangeditor.com/imgs/logo.png' const href = 'https://www.wangeditor.com/' const elem = { type: 'image', src, alt: 'logo', href, style: { width: '100', height: '80' }, children: [{ text: '' }], // void node 必须包含一个空 text } editor.select(startLocation) editor.insertNode(elem) // 插入图片 editor.select({ path: [0, 1, 0], // 选中图片 offset: 0, }) const containerVnode = renderImageConf.renderElem(elem, null, editor) as any expect(containerVnode.sel).toBe('div') expect(containerVnode.data.className.indexOf('w-e-selected-image-container')).toBeGreaterThan(0) expect(containerVnode.children.length).toBe(5) // image + 4 个拖拽触手 }) }) ================================================ FILE: packages/basic-modules/__tests__/indent/menu/decrease-indent-menu.test.ts ================================================ /** * @description decrease indent menu test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import DecreaseIndentMenu from '../../../src/modules/indent/menu/DecreaseIndentMenu' describe('decrease indent menu', () => { let editor: any let startLocation: any const menu = new DecreaseIndentMenu() beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('is disabled', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeTruthy() // 没有 indent 则 disabled Transforms.setNodes(editor, { type: 'header1', children: [] }) expect(menu.isDisabled(editor)).toBeTruthy() // 没有 indent 则 disabled editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] }) expect(menu.isDisabled(editor)).toBeTruthy() // 除了 p header 之外,其他 type 不可用 indent // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code }) // isActive 不用测试 // getValue 在 increase menu 已测试过 it('exec', () => { editor.select(startLocation) Transforms.setNodes(editor, { type: 'paragraph', indent: '2em', children: [] }) expect(menu.isDisabled(editor)).toBeFalsy() // 有 indent 则取消 disabled menu.exec(editor, '') expect(menu.getValue(editor)).toBe('') }) }) ================================================ FILE: packages/basic-modules/__tests__/indent/menu/increase-indent-menu.test.ts ================================================ /** * @description increase indent menu test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import IncreaseIndentMenu from '../../../src/modules/indent/menu/IncreaseIndentMenu' describe('increase indent menu', () => { let editor: any let startLocation: any const menu = new IncreaseIndentMenu() beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('is disabled', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'header1', children: [] }) expect(menu.isDisabled(editor)).toBeFalsy() editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] }) expect(menu.isDisabled(editor)).toBeTruthy() // 除了 p header 之外,其他 type 不可用 indent // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code }) // isActive 不用测试 it('exec and getValue', () => { editor.select(startLocation) expect(menu.getValue(editor)).toBe('') menu.exec(editor, '') expect(menu.getValue(editor)).toBe('2em') }) it('indent value', () => { editor.insertNode({ type: 'paragraph', children: [{ fontSize: '18px', text: 'text1' } as any], }) menu.exec(editor, '') expect(menu.getValue(editor)).toBe('36px') }) }) ================================================ FILE: packages/basic-modules/__tests__/indent/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseStyleHtml } from '../../src/modules/indent/parse-style-html' import { preParseHtmlConf } from '../../src/modules/indent/pre-parse-html' describe('indent - parse style', () => { const editor = createEditor() it('parse style', () => { const $p = $('

') const paragraph = { type: 'paragraph', children: [{ text: 'hello' }] } // parse const res = parseStyleHtml($p[0], paragraph, editor) expect(res).toEqual({ type: 'paragraph', indent: '2em', children: [{ text: 'hello' }], }) }) }) describe('indent - pre parse html', () => { it('pre parse', () => { expect(preParseHtmlConf.selector).toBe('p,h1,h2,h3,h4,h5') const $p = $('

') // parse const res = preParseHtmlConf.preParseHtml($p[0]) expect((res as HTMLParagraphElement).style.textIndent).toBe('2em') }) it('pre parse with px unit', () => { expect(preParseHtmlConf.selector).toBe('p,h1,h2,h3,h4,h5') const $p = $('

') // parse const res = preParseHtmlConf.preParseHtml($p[0]) expect((res as HTMLParagraphElement).style.textIndent).toBe('2em') }) }) ================================================ FILE: packages/basic-modules/__tests__/indent/render-text-style.test.tsx ================================================ /** * @description indent - render text style * @author wangfupeng */ import { jsx } from 'snabbdom' import { renderStyle } from '../../src/modules/indent/render-style' describe('indent - render text style', () => { it('render text style', () => { const indent = '2em' const elem = { type: 'paragraph', indent, children: [] } const vnode =

hello

// @ts-ignore const newVnode = renderStyle(elem, vnode) // @ts-ignore expect(newVnode.data.style.textIndent).toBe(indent) }) }) ================================================ FILE: packages/basic-modules/__tests__/indent/text-style-to-html.test.ts ================================================ /** * @description indent - text style to html test * @author wangfupeng */ import { styleToHtml } from '../../src/modules/indent/style-to-html' describe('indent - text style to html', () => { it('text style to html', () => { const indent = '2em' const elem = { type: 'paragraph', indent, children: [] } const html = styleToHtml(elem, '

hello

') expect(html).toBe(`

hello

`) }) }) ================================================ FILE: packages/basic-modules/__tests__/justify/menus.test.ts ================================================ /** * @description justify menus test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import JustifyCenterMenu from '../../src/modules/justify/menu/JustifyCenterMenu' import JustifyJustifyMenu from '../../src/modules/justify/menu/JustifyJustifyMenu' import JustifyLeftMenu from '../../src/modules/justify/menu/JustifyLeftMenu' import JustifyRightMenu from '../../src/modules/justify/menu/JustifyRightMenu' describe('justify menus', () => { let editor: any let startLocation: any const centerMenu = new JustifyCenterMenu() const justifyMenu = new JustifyJustifyMenu() const leftMenu = new JustifyLeftMenu() const rightMenu = new JustifyRightMenu() beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) // getValue getActive 不需要测试 it('is disabled', () => { editor.deselect() expect(centerMenu.isDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(centerMenu.isDisabled(editor)).toBeFalsy() editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] }) expect(centerMenu.isDisabled(editor)).toBeTruthy() // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code }) it('exec', () => { editor.select(startLocation) centerMenu.exec(editor, '') const p1 = editor.getElemsByTypePrefix('paragraph')[0] expect(p1.textAlign).toBe('center') justifyMenu.exec(editor, '') const p2 = editor.getElemsByTypePrefix('paragraph')[0] expect(p2.textAlign).toBe('justify') leftMenu.exec(editor, '') const p3 = editor.getElemsByTypePrefix('paragraph')[0] expect(p3.textAlign).toBe('left') rightMenu.exec(editor, '') const p4 = editor.getElemsByTypePrefix('paragraph')[0] expect(p4.textAlign).toBe('right') }) }) ================================================ FILE: packages/basic-modules/__tests__/justify/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseStyleHtml } from '../../src/modules/justify/parse-style-html' describe('text align - parse style', () => { const editor = createEditor() it('parse style', () => { const $p = $('

') const paragraph = { type: 'paragraph', children: [{ text: 'hello' }] } // parse const res = parseStyleHtml($p[0], paragraph, editor) expect(res).toEqual({ type: 'paragraph', textAlign: 'center', children: [{ text: 'hello' }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/justify/render-text-style.test.tsx ================================================ /** * @description justify - render text style test * @author wangfupeng */ import { jsx } from 'snabbdom' import { renderStyle } from '../../src/modules/justify/render-style' describe('justify - render text style', () => { it('render text style', () => { const elem = { type: 'paragraph', textAlign: 'center', children: [] } const vnode = hello // @ts-ignore 忽略 vnode 格式 const newVnode = renderStyle(elem, vnode) // @ts-ignore 忽略 vnode 格式 expect(newVnode.data.style?.textAlign).toBe('center') }) }) ================================================ FILE: packages/basic-modules/__tests__/justify/text-style-to-html.test.ts ================================================ /** * @description justify - text style to html test * @author wangfupeng */ import { styleToHtml } from '../../src/modules/justify/style-to-html' describe('justify text-style-to-html', () => { it('text style to html', () => { const elem = { type: 'paragraph', textAlign: 'center', children: [] } const html = styleToHtml(elem, 'hello') expect(html).toBe('hello') }) }) ================================================ FILE: packages/basic-modules/__tests__/line-height/line-height-menu.test.ts ================================================ /** * @description line-height menu test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import LineHeightMenu from '../../src/modules/line-height/menu/LineHeightMenu' describe('line-height menu', () => { let editor: any let startLocation: any const menu = new LineHeightMenu() beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get options', () => { editor.select(startLocation) const options = menu.getOptions(editor) expect(options.length).toBeGreaterThan(0) // 默认选中 空 const selectedEmptyOne = options.some(opt => opt.value === '' && opt.selected) expect(selectedEmptyOne).toBe(true) }) // isActive 返回 false ,不用测试 it('get value', () => { editor.select(startLocation) expect(menu.getValue(editor)).toBe('') // 设置 lineHeight Transforms.setNodes(editor, { lineHeight: '1.5' }, { mode: 'highest' }) expect(menu.getValue(editor)).toBe('1.5') }) it('is disable', () => { editor.deselect() expect(menu.isDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'header1' }) expect(menu.isDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'blockquote' }) expect(menu.isDisabled(editor)).toBeFalsy() Transforms.setNodes(editor, { type: 'list-item' }) expect(menu.isDisabled(editor)).toBeFalsy() editor.insertNode({ type: 'pre', children: [{ type: 'code', children: [{ text: 'var' }] }] }) expect(menu.isDisabled(editor)).toBeTruthy() // Transforms.removeNodes(editor, { mode: 'highest' }) // 移除 pre/code }) it('exec', () => { editor.select(startLocation) menu.exec(editor, '1.5') expect(menu.getValue(editor)).toBe('1.5') }) }) ================================================ FILE: packages/basic-modules/__tests__/line-height/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseStyleHtml } from '../../src/modules/line-height/parse-style-html' describe('line height - parse style', () => { const editor = createEditor() it('parse style', () => { const $p = $('

') const paragraph = { type: 'paragraph', children: [{ text: 'hello' }] } // parse const res = parseStyleHtml($p[0], paragraph, editor) expect(res).toEqual({ type: 'paragraph', lineHeight: '2.5', children: [{ text: 'hello' }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/line-height/render-text-style.test.tsx ================================================ /** * @description line-height render text style test * @author wangfupeng */ import { jsx } from 'snabbdom' import { renderStyle } from '../../src/modules/line-height/render-style' describe('line-height render-text-style', () => { it('render text style', () => { const elem = { type: 'paragraph', lineHeight: '1.5', children: [] } const vnode = hello // @ts-ignore 忽略 vnode 格式检查 const newVnode = renderStyle(elem, vnode) // @ts-ignore 忽略 vnode 格式检查 expect(newVnode.data.style.lineHeight).toBe('1.5') }) }) ================================================ FILE: packages/basic-modules/__tests__/line-height/text-style-to-html.test.ts ================================================ /** * @description line-height text-style-to-html test * @author wangfupeng */ import { styleToHtml } from '../../src/modules/line-height/style-to-html' describe('line-height text-style-to-html', () => { it('text style to html', () => { const elem = { type: 'paragraph', lineHeight: '1.5', children: [] } const html = styleToHtml(elem, 'hello') expect(html).toBe('hello') }) }) ================================================ FILE: packages/basic-modules/__tests__/link/elem-to-html.test.ts ================================================ /** * @description link - elem to html test * @author wangfupeng */ import { linkToHtmlConf } from '../../src/modules/link/elem-to-html' describe('link elem to html', () => { it('link to html', () => { expect(linkToHtmlConf.type).toBe('link') const url = 'https://www.wangeditor.com/' const target = '_blank' const elem = { type: 'link', url, target, children: [] } const html = linkToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe(`
hello`) }) }) ================================================ FILE: packages/basic-modules/__tests__/link/helper.test.ts ================================================ /** * @description link module helper test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import { isMenuDisabled, insertLink, updateLink } from '../../src/modules/link/helper' describe('link module helper', () => { let editor: any let startLocation: any beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('menu disable', () => { editor.deselect() expect(isMenuDisabled(editor)).toBeTruthy() editor.select(startLocation) expect(isMenuDisabled(editor)).toBeFalsy() editor.insertNode({ type: 'link', url: 'https://www.wangeditor.com/', children: [{ text: 'xxx' }], }) expect(isMenuDisabled(editor)).toBeTruthy() // 选中 link ,则禁用 editor.clear() editor.insertNode({ type: 'pre', children: [ { type: 'code', children: [{ text: 'var' }], }, ], }) expect(isMenuDisabled(editor)).toBeTruthy() // 选中 code-block ,则禁用 }) it('insert link with collapsed selection', async () => { editor.select(startLocation) const url = 'https://www.wangeditor.com/' await insertLink(editor, 'hello', url) const links = editor.getElemsByTypePrefix('link') expect(links.length).toBe(1) const linkElem = links[0] expect(linkElem.url).toBe(url) }) it('insert link with expand selection', async () => { editor.select(startLocation) editor.insertText('hello') Transforms.move(editor, { distance: 3, // 选中 3 个字母 unit: 'character', }) editor.select([]) // 全选 const url = 'https://www.wangeditor.com/' await insertLink(editor, 'hello', url) const links = editor.getElemsByTypePrefix('link') expect(links.length).toBe(1) const linkElem = links[0] expect(linkElem.url).toBe(url) }) it('update link', async () => { editor.select(startLocation) const url = 'https://www.wangeditor.com/' await insertLink(editor, 'hello', url) // 选区移动到 link 内部 editor.select({ path: [0, 1, 0], offset: 3, }) // 更新链接 const newUrl = 'https://www.wangeditor.com/123' await updateLink(editor, '', newUrl) const links = editor.getElemsByTypePrefix('link') expect(links.length).toBe(1) const linkElem = links[0] expect(linkElem.url).toBe(newUrl) }) }) ================================================ FILE: packages/basic-modules/__tests__/link/menu/edit-link-menu.test.ts ================================================ /** * @description edit link menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import EditLink from '../../../src/modules/link/menu/EditLink' describe('edit link menu', () => { let editor: any let startLocation: any const menu = new EditLink() const linkNode = { type: 'link', url: 'https://www.wangeditor.com/', children: [{ text: 'xxx' }], } beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get value', () => { editor.select(startLocation) expect(menu.getValue(editor)).toBe('') editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) expect(menu.getValue(editor)).toBe(linkNode.url) }) it('is active', () => { expect(menu.isActive(editor)).toBeFalsy() }) it('is disable', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeTruthy() editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) expect(menu.isDisabled(editor)).toBeFalsy() }) it('get modal position node', () => { editor.select(startLocation) expect(menu.getModalPositionNode(editor)).toBeNull() editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) const node = menu.getModalPositionNode(editor) as any expect(node.type).toBe('link') expect(node.url).toBe(linkNode.url) }) it('get modal content elem', () => { editor.select(startLocation) const elem = menu.getModalContentElem(editor) expect(elem.tagName).toBe('DIV') }) }) ================================================ FILE: packages/basic-modules/__tests__/link/menu/insert-link-menu.test.ts ================================================ /** * @description insert link menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import InsertLinkMenu from '../../../src/modules/link/menu/InsertLink' describe('insert link menu', () => { const editor = createEditor() const menu = new InsertLinkMenu() const startLocation = Editor.start(editor, []) afterEach(() => { editor.select(startLocation) editor.clear() editor.deselect() }) it('get value', () => { expect(menu.getValue(editor)).toBe('') }) it('is active', () => { expect(menu.isActive(editor)).toBeFalsy() }) it('get modal position node', () => { expect(menu.getModalPositionNode(editor)).toBeNull() }) it('is disable', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() }) it('get modal content elem', () => { const elem = menu.getModalContentElem(editor) expect(elem.tagName).toBe('DIV') }) }) ================================================ FILE: packages/basic-modules/__tests__/link/menu/unlink-menu.test.ts ================================================ /** * @description unlink menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import UnLink from '../../../src/modules/link/menu/UnLink' describe('unlink menu test', () => { let editor: any let startLocation: any const menu = new UnLink() const linkNode = { type: 'link', url: 'https://www.wangeditor.com/', children: [{ text: 'xxx' }], } beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get value', () => { expect(menu.getValue(editor)).toBe('') }) it('is active', () => { expect(menu.isActive(editor)).toBe(false) }) it('is disable', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeTruthy() editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) expect(menu.isDisabled(editor)).toBeFalsy() }) it('exec', () => { editor.select(startLocation) editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) menu.exec(editor, '') const links = editor.getElemsByTypePrefix('link') expect(links.length).toBe(0) }) }) ================================================ FILE: packages/basic-modules/__tests__/link/menu/view-link-menu.test.ts ================================================ /** * @description view link menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import ViewLink from '../../../src/modules/link/menu/ViewLink' describe('view link menu', () => { let editor: any let startLocation: any const menu = new ViewLink() const linkNode = { type: 'link', url: 'https://www.wangeditor.com/', children: [{ text: 'xxx' }], } beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get value', () => { editor.select(startLocation) expect(menu.getValue(editor)).toBe('') editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) expect(menu.getValue(editor)).toBe(linkNode.url) }) it('is active', () => { expect(menu.isActive(editor)).toBe(false) }) it('is disable', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeTruthy() editor.insertNode(linkNode) editor.select({ path: [0, 1, 0], // 选区定位到 link 内部 offset: 1, }) expect(menu.isDisabled(editor)).toBeFalsy() }) }) ================================================ FILE: packages/basic-modules/__tests__/link/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseHtmlConf } from '../../src/modules/link/parse-elem-html' describe('link - parse html', () => { const editor = createEditor() it('without children', () => { const $link = $('hello world') // match selector expect($link[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse const res = parseHtmlConf.parseElemHtml($link[0], [], editor) expect(res).toEqual({ type: 'link', url: 'http://localhost/', target: '_blank', children: [{ text: 'hello world' }], }) }) it('with children', () => { const $link = $('') const children = [{ text: 'hello ' }, { text: 'world', bold: true }] // match selector expect($link[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse const res = parseHtmlConf.parseElemHtml($link[0], children, editor) expect(res).toEqual({ type: 'link', url: 'http://localhost/', target: '_blank', children: [{ text: 'hello ' }, { text: 'world', bold: true }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/link/plugin.test.ts ================================================ /** * @description link plugin test * @author wangfupeng */ import { Editor } from 'slate' import withLink from '../../src/modules/link/plugin' import createEditor from '../../../../tests/utils/create-editor' // 模拟 DataTransfer class MyDataTransfer { private values: object = {} setData(type: string, value: string) { this.values[type] = value } getData(type: string): string { return this.values[type] } } describe('link plugin', () => { const editor = withLink(createEditor()) const startLocation = Editor.start(editor, []) it('link is inline elem', () => { const elem = { type: 'link', children: [] } expect(editor.isInline(elem)).toBeTruthy() }) it('link insert data', done => { const url = 'https://www.wangeditor.com/' const data = new MyDataTransfer() data.setData('text/plain', url) editor.select(startLocation) // @ts-ignore editor.insertData(data) setTimeout(() => { const links = editor.getElemsByTypePrefix('link') expect(links.length).toBe(1) const linkElem = links[0] as any expect(linkElem.url).toBe(url) done() }) }) }) ================================================ FILE: packages/basic-modules/__tests__/link/render-elem.test.ts ================================================ /** * @description link - render elem test * @author wangfupeng */ import createEditor from '../../../../tests/utils/create-editor' import { renderLinkConf } from '../../src/modules/link/render-elem' describe('link render elem', () => { const editor = createEditor() it('render elem', () => { expect(renderLinkConf.type).toBe('link') const url = 'https://www.wangeditor.com/' const target = '_blank' const elem = { type: 'link', url, target, children: [] } const vnode = renderLinkConf.renderElem(elem, null, editor) as any expect(vnode.sel).toBe('a') expect(vnode.data.href).toBe(url) expect(vnode.data.target).toBe(target) }) }) ================================================ FILE: packages/basic-modules/__tests__/paragraph/elem-to-html.test.ts ================================================ import { html } from 'dom7' /** * @description paragraph - elem to html test * @author wangfupeng */ import { pToHtmlConf } from '../../src/modules/paragraph/elem-to-html' describe('paragraph - elem to html', () => { it('paragraph to html', () => { expect(pToHtmlConf.type).toBe('paragraph') const elem = { type: 'paragraph', children: [] } const html = pToHtmlConf.elemToHtml(elem, 'hello') expect(html).toBe('

hello

') }) it('paragraph to html with empty children', () => { expect(pToHtmlConf.type).toBe('paragraph') const elem = { type: 'paragraph', children: [] } const html = pToHtmlConf.elemToHtml(elem, '') expect(html).toBe('


') }) }) ================================================ FILE: packages/basic-modules/__tests__/paragraph/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseParagraphHtmlConf } from '../../src/modules/paragraph/parse-elem-html' describe('paragraph - parse html', () => { const editor = createEditor() it('without children', () => { const $elem = $('

hello world

') // match selector expect($elem[0].matches(parseParagraphHtmlConf.selector)).toBeTruthy() // parse const res = parseParagraphHtmlConf.parseElemHtml($elem[0], [], editor) expect(res).toEqual({ type: 'paragraph', children: [{ text: 'hello world' }], }) }) it('with children', () => { const $elem = $('

') const children = [{ text: 'hello ' }, { text: 'world', bold: true }] // parse const res = parseParagraphHtmlConf.parseElemHtml($elem[0], children, editor) expect(res).toEqual({ type: 'paragraph', children: [{ text: 'hello ' }, { text: 'world', bold: true }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/paragraph/plugin.test.ts ================================================ /** * @description paragraph plugin test * @author wangfupeng */ import { Editor, Transforms, Point } from 'slate' import { DomEditor, IDomEditor } from '@wangeditor/core' import createEditor from '../../../../tests/utils/create-editor' import withParagraph from '../../src/modules/paragraph/plugin' let editor: IDomEditor let startLocation: Point describe('paragraph plugin', () => { beforeEach(() => { editor = withParagraph(createEditor()) startLocation = Editor.start(editor, []) }) it('delete to clear text', () => { editor.select(startLocation) Transforms.setNodes(editor, { type: 'header1' }) // 设置 header editor.deleteBackward('character') // 向后删除 const selectedParagraph1 = DomEditor.getSelectedNodeByType(editor, 'paragraph') expect(selectedParagraph1).not.toBeNull() // 执行删除后,header 变为 paragraph Transforms.setNodes(editor, { type: 'blockquote' }) // 设置 blockquote editor.deleteForward('character') // 向前删除 const selectedParagraph2 = DomEditor.getSelectedNodeByType(editor, 'paragraph') expect(selectedParagraph2).not.toBeNull() // 执行删除后,header 变为 paragraph }) }) ================================================ FILE: packages/basic-modules/__tests__/paragraph/render-elem.test.ts ================================================ /** * @description paragraph render elem test * @author wangfupeng */ import createEditor from '../../../../tests/utils/create-editor' import { renderParagraphConf } from '../../src/modules/paragraph/render-elem' describe('paragraph - render elem', () => { const editor = createEditor() it('render paragraph', () => { expect(renderParagraphConf.type).toBe('paragraph') const elem = { type: 'paragraph', children: [] } const vnode = renderParagraphConf.renderElem(elem, null, editor) expect(vnode.sel).toBe('p') }) }) ================================================ FILE: packages/basic-modules/__tests__/text-style/menu/clear-style-menu.test.ts ================================================ /** * @description clear style menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import ClearStyleMenu from '../../../src/modules/text-style/menu/ClearStyleMenu' describe('clear style menu', () => { let editor = createEditor() const startLocation = Editor.start(editor, []) const menu = new ClearStyleMenu() afterEach(() => { editor.select(startLocation) editor.clear() }) it('exec', () => { editor.select(startLocation) editor.insertText('hello') editor.select([]) editor.addMark('bold', true) editor.addMark('italic', true) menu.exec(editor, '') // 清空样式 const marks = Editor.marks(editor) as any expect(marks.bold).toBeUndefined() expect(marks.italic).toBeUndefined() }) }) ================================================ FILE: packages/basic-modules/__tests__/text-style/menu/menus.test.ts ================================================ /** * @description style menus test * @author wangfupeng */ import { Editor, Transforms, Element } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import BoldMenu from '../../../src/modules/text-style/menu/BoldMenu' import CodeMenu from '../../../src/modules/text-style/menu/CodeMenu' import ItalicMenu from '../../../src/modules/text-style/menu/ItalicMenu' import SubMenu from '../../../src/modules/text-style/menu/SubMenu' import SupMenu from '../../../src/modules/text-style/menu/SupMenu' import ThroughMenu from '../../../src/modules/text-style/menu/ThroughMenu' import UnderlineMenu from '../../../src/modules/text-style/menu/UnderlineMenu' const MENU_INFO_LIST = [ { mark: 'bold', menu: new BoldMenu() }, { mark: 'code', menu: new CodeMenu() }, { mark: 'italic', menu: new ItalicMenu() }, { mark: 'sub', menu: new SubMenu() }, { mark: 'sup', menu: new SupMenu() }, { mark: 'through', menu: new ThroughMenu() }, { mark: 'underline', menu: new UnderlineMenu() }, ] describe('text style menus', () => { let editor = createEditor() const startLocation = Editor.start(editor, []) afterEach(() => { editor.select(startLocation) editor.clear() }) // getValue 已经被 isActive 覆盖 it('is active', () => { MENU_INFO_LIST.forEach(info => { const { mark, menu } = info editor.select(startLocation) editor.clear() editor.insertText('hello') expect(menu.isActive(editor)).toBeFalsy() editor.select([]) editor.addMark(mark, true) expect(menu.isActive(editor)).toBeTruthy() }) }) it('is disable', () => { MENU_INFO_LIST.forEach(info => { const { mark, menu } = info editor.select(startLocation) editor.clear() editor.insertText('hello') expect(menu.isDisabled(editor)).toBeFalsy() // 正常文字,不禁用 editor.insertNode({ type: 'pre', children: [ { type: 'code', children: [{ text: 'var' }], } as Element, ], } as Element) expect(menu.isDisabled(editor)).toBeTruthy() // 选中代码块,禁用各个 menu }) }) it('exec', () => { MENU_INFO_LIST.forEach(info => { const { mark, menu } = info editor.select(startLocation) editor.clear() editor.insertText('hello') editor.select([]) // 增加 mark menu.exec(editor, false) const marks1 = Editor.marks(editor) as any expect(marks1[mark]).toBeTruthy() // 取消 mark editor.select([]) menu.exec(editor, true) const marks2 = Editor.marks(editor) as any expect(marks2[mark]).toBeUndefined() }) }) }) ================================================ FILE: packages/basic-modules/__tests__/text-style/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ // import { $ } from 'dom7' // import { parseStyleHtml } from '../../../../packages/basic-modules/src/modules/text-style/parse-style-html' describe('text style - parse style html', () => { it('占位', () => { expect(1 + 1).toBe(2) }) // TODO 执行以下代码会有 Dom7 一个怪异的 bug ,先暂且注释,后面再解决 wangfupeng 2022.01.17 // it('bold', () => { // const $text = $('') // const textNode = { text: 'hello' } // // parse style // const res = parseStyleHtml($text[0], textNode) // expect(res).toEqual({ // text: 'hello', // bold: true, // }) // }) // // italic underline... 等 }) ================================================ FILE: packages/basic-modules/__tests__/text-style/parse-style-html.test.ts ================================================ import { parseStyleHtml } from '../../src/modules/text-style/parse-style-html' import $ from '../../src/utils/dom' import createEditor from '../../../../tests/utils/create-editor' describe('parse style html', () => { const editor = createEditor() it('it should return directly if give node that type is not text', () => { const element = $('

') const node = { type: 'paragraph', children: [] } expect(parseStyleHtml(element[0], node, editor)).toEqual(node) }) it('it should do nothing if give not exist element', () => { const element = $('#text') const node = { type: 'paragraph', children: [] } expect(parseStyleHtml(element[0], node, editor)).toEqual(node) }) it('it should set bold property for node if give strong element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, bold: true }) }) it('it should set bold property for node if give b element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, bold: true }) }) it('it should set italic property for node if give i element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, italic: true }) }) it('it should set italic property for node if give em element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, italic: true }) }) it('it should set underline property for node if give u element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, underline: true }) }) it('it should set through property for node if give s element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, through: true }) }) it('it should set through property for node if give strike element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, through: true }) }) it('it should set sub property for node if give sub element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, sub: true }) }) it('it should set sup property for node if give sup element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, sup: true }) }) it('it should set code property for node if give code element', () => { const element = $('') const node = { text: 'text' } expect(parseStyleHtml(element[0], node, editor)).toEqual({ ...node, code: true }) }) }) ================================================ FILE: packages/basic-modules/__tests__/text-style/text-style.test.tsx ================================================ /** * @description text style test * @author wangfupeng */ import { jsx } from 'snabbdom' import { renderStyle } from '../../src/modules/text-style/render-style' import { StyledText } from '../../src/modules/text-style/custom-types' describe('text style - render text style', () => { it('render text style', () => { const vnode = hello let newVnode const textNode: StyledText = { text: '' } textNode.bold = true // @ts-ignore 忽略 vnode 格式 newVnode = renderStyle(textNode, vnode) expect(newVnode.sel).toBe('strong') textNode.code = true // @ts-ignore 忽略 vnode 格式 newVnode = renderStyle(textNode, vnode) expect(newVnode.sel).toBe('code') textNode.italic = true // @ts-ignore 忽略 vnode 格式 newVnode = renderStyle(textNode, vnode) expect(newVnode.sel).toBe('em') textNode.underline = true // @ts-ignore 忽略 vnode 格式 newVnode = renderStyle(textNode, vnode) expect(newVnode.sel).toBe('u') textNode.through = true // @ts-ignore 忽略 vnode 格式 newVnode = renderStyle(textNode, vnode) expect(newVnode.sel).toBe('s') textNode.sub = true // @ts-ignore 忽略 vnode 格式 newVnode = renderStyle(textNode, vnode) expect(newVnode.sel).toBe('sub') textNode.sup = true // @ts-ignore 忽略 vnode 格式 newVnode = renderStyle(textNode, vnode) expect(newVnode.sel).toBe('sup') }) }) ================================================ FILE: packages/basic-modules/__tests__/text-style/text-to-html.test.ts ================================================ /** * @description text to html test * @author wangfupeng */ import { styleToHtml } from '../../src/modules/text-style/style-to-html' describe('text style - text to html', () => { it('text to html', () => { const textNode = { text: '', bold: true, italic: true, underline: true, code: true, through: true, sub: true, sup: true, } const html1 = styleToHtml(textNode, 'hello') expect(html1).toBe( 'hello' ) const html2 = styleToHtml(textNode, 'world') expect(html2).toBe( 'world' ) }) }) ================================================ FILE: packages/basic-modules/__tests__/todo/elem-to-html.test.ts ================================================ /** * @description todo elem to html test * @author wangfupeng */ import { todoToHtmlConf } from '../../src/modules/todo/elem-to-html' describe('todo - elem to html', () => { it('todo elem to html', () => { expect(todoToHtmlConf.type).toBe('todo') const todoNode1 = { type: 'todo', checked: true, children: [{ text: '' }], } const html1 = todoToHtmlConf.elemToHtml(todoNode1, 'hello') expect(html1).toBe( `
hello
` ) const todoNode2 = { type: 'todo', checked: false, children: [{ text: '' }], } const html2 = todoToHtmlConf.elemToHtml(todoNode2, 'hello') expect(html2).toBe(`
hello
`) }) }) ================================================ FILE: packages/basic-modules/__tests__/todo/menu/todo-menu.test.ts ================================================ /** * @description todo-menu test * @author wangfupeng */ import { Editor, Transforms } from 'slate' import createEditor from '../../../../../tests/utils/create-editor' import TodoMenu from '../../../src/modules/todo/menu/Todo' describe('todo-menu', () => { let editor: any let startLocation: any const menu = new TodoMenu() beforeEach(() => { editor = createEditor() startLocation = Editor.start(editor, []) }) afterEach(() => { editor = null startLocation = null }) it('get value', () => { expect(menu.getValue(editor)).toBe('') }) it('is active', () => { editor.select(startLocation) expect(menu.isActive(editor)).toBeFalsy() // @ts-ignore Transforms.setNodes(editor, { type: 'todo' }) expect(menu.isActive(editor)).toBeTruthy() }) it('is disable - paragraph and todo', () => { editor.select(startLocation) expect(menu.isDisabled(editor)).toBeFalsy() // @ts-ignore Transforms.setNodes(editor, { type: 'todo' }) expect(menu.isDisabled(editor)).toBeFalsy() }) it('is disable - list', () => { editor.select(startLocation) editor.insertNode({ type: 'bulleted-list', children: [ { type: 'list-item', children: [{ text: 'hello' }], }, ], }) expect(menu.isDisabled(editor)).toBeTruthy() }) it('is disable - table', () => { editor.select(startLocation) editor.insertNode({ type: 'table', children: [ { type: 'table-row', children: [ { type: 'table-cell', children: [{ text: 'hello' }], }, ], }, ], }) expect(menu.isDisabled(editor)).toBeTruthy() }) it('is disable - pre/code', () => { editor.select(startLocation) editor.insertNode({ type: 'pre', children: [ { type: 'code', children: [{ text: 'hello' }], }, ], }) expect(menu.isDisabled(editor)).toBeTruthy() }) it('exec - paragraph to todo', () => { editor.select(startLocation) menu.exec(editor, '') const todoElems = editor.getElemsByType('todo') expect(todoElems.length).toBe(1) }) it('exec - todo to paragraph', () => { editor.select(startLocation) // @ts-ignore Transforms.setNodes(editor, { type: 'todo' }) menu.exec(editor, '') const todoElems = editor.getElemsByType('todo') expect(todoElems.length).toBe(0) }) }) ================================================ FILE: packages/basic-modules/__tests__/todo/parse-html.test.ts ================================================ /** * @description todo parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../../tests/utils/create-editor' import { parseHtmlConf } from '../../src/modules/todo/parse-elem-html' describe('todo - parse html', () => { const editor = createEditor() it('with children, checked', () => { const $todo = $('
hello
') // match selector expect($todo[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse const res = parseHtmlConf.parseElemHtml($todo[0], [], editor) expect(res).toEqual({ type: 'todo', checked: true, children: [{ text: 'hello' }], }) }) it('without children, unchecked', () => { const $todo = $('
') const children = [{ text: 'hello ' }, { text: 'world', bold: true }] // match selector expect($todo[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse const res = parseHtmlConf.parseElemHtml($todo[0], children, editor) expect(res).toEqual({ type: 'todo', checked: false, children: [{ text: 'hello ' }, { text: 'world', bold: true }], }) }) }) ================================================ FILE: packages/basic-modules/__tests__/todo/plugin.test.ts ================================================ /** * @description todo plugin test * @author wangfupeng */ import withTodo from '../../src/modules/todo/plugin' import createEditor from '../../../../tests/utils/create-editor' describe('todo - plugin', () => { it('delete backward', () => { const editor = withTodo( createEditor({ content: [{ type: 'todo', children: [{ text: '' }] }], }) ) editor.select({ path: [0, 0], offset: 0, }) const todoElems1 = editor.getElemsByType('todo') expect(todoElems1.length).toBe(1) editor.deleteBackward('character') const todoElems2 = editor.getElemsByType('todo') expect(todoElems2.length).toBe(0) }) }) ================================================ FILE: packages/basic-modules/__tests__/todo/pre-parse-html.test.ts ================================================ /** * @description todo pre-parse html * @author wangfupeng */ import { $ } from 'dom7' import { preParseHtmlConf } from '../../src/modules/todo/pre-parse-html' describe('todo - pre-parse html', () => { it('pre-parse html', () => { // v4 todo html 格式 const $ul = $( '
  • hello world
' ) // match selector expect($ul[0].matches(preParseHtmlConf.selector)).toBeTruthy() // parse const res = preParseHtmlConf.preParseHtml($ul[0]) expect(res.outerHTML).toBe( '
hello world
' ) }) }) ================================================ FILE: packages/basic-modules/__tests__/todo/render-elem.test.ts ================================================ /** * @description todo render elem * @author wangfupeng */ import createEditor from '../../../../tests/utils/create-editor' import { renderTodoConf } from '../../src/modules/todo/render-elem' describe('todo - render elem', () => { const editor = createEditor() it('render elem', () => { expect(renderTodoConf.type).toBe('todo') const todo = { type: 'todo', checked: true, children: [{ text: '' }] } const vnode = renderTodoConf.renderElem(todo, null, editor) as any expect(vnode.sel).toBe('div') expect(vnode.children.length).toBe(2) const spanForInput = vnode.children[0] expect(spanForInput.sel).toBe('span') expect(spanForInput.data.contentEditable).toBe(false) const input = spanForInput.children[0] expect(input.sel).toBe('input') expect(input.data.type).toBe('checkbox') expect(input.data.checked).toBe(true) expect(typeof input.data.on.change).toBe('function') }) }) ================================================ FILE: packages/basic-modules/__tests__/undo-redo/redo-menu.test.ts ================================================ /** * @description redo menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import RedoMenu from '../../src/modules/undo-redo/menu/RedoMenu' describe('redo menu', () => { const editor = createEditor() const menu = new RedoMenu() const location = Editor.start(editor, []) // 选区位置 it('tag', () => { expect(menu.tag).toBe('button') }) it('get value', () => { expect(menu.getValue(editor)).toBe('') }) it('is active', () => { expect(menu.isActive(editor)).toBeFalsy() }) it('is disable', () => { // 有选区 editor.select(location) expect(menu.isDisabled(editor)).toBeFalsy() // 无选区 editor.deselect() expect(menu.isDisabled(editor)).toBeTruthy() }) it('exec', () => { const text = editor.getText() editor.select(location) editor.insertText('xxx') if (typeof editor.undo === 'function') { editor.undo() } menu.exec(editor, '') const newText = editor.getText() expect(newText).toBe(text + 'xxx') }) }) ================================================ FILE: packages/basic-modules/__tests__/undo-redo/undo-menu.test.ts ================================================ /** * @description undo menu test * @author wangfupeng */ import { Editor } from 'slate' import createEditor from '../../../../tests/utils/create-editor' import UndoMenu from '../../src/modules/undo-redo/menu/UndoMenu' describe('undo menu', () => { const editor = createEditor() const menu = new UndoMenu() const location = Editor.start(editor, []) // 选区位置 it('tag', () => { expect(menu.tag).toBe('button') }) it('get value', () => { expect(menu.getValue(editor)).toBe('') }) it('is active', () => { expect(menu.isActive(editor)).toBeFalsy() }) it('is disable', () => { // 有选区 editor.select(location) expect(menu.isDisabled(editor)).toBeFalsy() // 无选区 editor.deselect() expect(menu.isDisabled(editor)).toBeTruthy() }) it('exec', () => { const text = editor.getText() editor.select(location) editor.insertText('xxx') menu.exec(editor, '') const newText = editor.getText() expect(newText).toBe(text) }) }) ================================================ FILE: packages/basic-modules/package.json ================================================ { "name": "@wangeditor/basic-modules", "version": "1.1.7", "description": "wangEditor basic modules", "author": "wangfupeng1988 ", "contributors": [], "homepage": "https://github.com/wangeditor-team/wangEditor#readme", "license": "MIT", "types": "dist/basic-modules/src/index.d.ts", "main": "dist/index.js", "module": "dist/index.esm.js", "browser": { "./dist/index.js": "./dist/index.js", "./dist/index.esm.js": "./dist/index.esm.js" }, "directories": { "lib": "dist", "test": "__tests__" }, "files": [ "dist" ], "publishConfig": { "access": "public", "registry": "https://registry.npmjs.com/" }, "repository": { "type": "git", "url": "git+https://github.com/wangeditor-team/wangEditor.git" }, "scripts": { "test": "jest", "test-c": "jest --coverage", "dev": "cross-env NODE_ENV=development rollup -c rollup.config.js", "dev-watch": "cross-env NODE_ENV=development rollup -c rollup.config.js -w", "build": "cross-env NODE_ENV=production rollup -c rollup.config.js", "dev-size-stats": "cross-env NODE_ENV=development:size_stats rollup -c rollup.config.js", "size-stats": "cross-env NODE_ENV=production:size_stats rollup -c rollup.config.js" }, "bugs": { "url": "https://github.com/wangeditor-team/wangEditor/issues" }, "peerDependencies": { "@wangeditor/core": "1.x", "dom7": "^3.0.0", "lodash.throttle": "^4.1.1", "nanoid": "^3.2.0", "slate": "^0.72.0", "snabbdom": "^3.1.0" }, "dependencies": { "is-url": "^1.2.4" }, "devDependencies": { "@types/is-url": "^1.2.29" } } ================================================ FILE: packages/basic-modules/rollup.config.js ================================================ import { createRollupConfig, IS_PRD } from '../../build/create-rollup-config' import pkg from './package.json' const name = 'WangEditorBasicModules' const configList = [] // esm const esmConf = createRollupConfig({ output: { file: pkg.module, format: 'esm', name, }, }) configList.push(esmConf) // umd const umdConf = createRollupConfig({ output: { file: pkg.main, format: 'umd', name, }, }) configList.push(umdConf) export default configList ================================================ FILE: packages/basic-modules/src/assets/blockquote.less ================================================ @import "../../../vars.less"; .w-e-text-container [data-slate-editor] blockquote { display: block; border-left: 8px solid @textarea-selected-border-color; padding: 10px 10px; margin: 10px 0; line-height: 1.5; font-size: 100%; background-color: @textarea-slight-bg-color; } ================================================ FILE: packages/basic-modules/src/assets/code-block.less ================================================ @import "../../../vars.less"; .w-e-text-container [data-slate-editor] pre>code { display: block; border: 1px solid @textarea-slight-border-color; border-radius: 4px 4px; text-indent: 0; background-color: @textarea-slight-bg-color; padding: 10px; font-size: @size; } ================================================ FILE: packages/basic-modules/src/assets/color.less ================================================ @import "../../../vars.less"; .w-e-panel-content-color { list-style: none; text-align: left; width: 230px; li { display: inline-block; padding: 2px; cursor: pointer; border-radius: 3px 3px; border: 1px solid @toolbar-bg-color; &:hover { border-color: @toolbar-color; } .color-block { width: 17px; height: 17px; border: 1px solid @toolbar-border-color; border-radius: 3px 3px; } } .active { border-color: @toolbar-color; } .clear { width: 100%; line-height: 1.5; margin-bottom: 5px; svg { width: 16px; height: 16px; margin-bottom: -4px; } } } ================================================ FILE: packages/basic-modules/src/assets/divider.less ================================================ @import "../../../vars.less"; .w-e-textarea-divider { padding: 20px 20px; margin: 20px auto; border-radius: 3px; // &:hover { // background-color: @textarea-slight-bg-color; // } hr { display: block; border: 0; height: 1px; background-color: @textarea-border-color; } } ================================================ FILE: packages/basic-modules/src/assets/emotion.less ================================================ @import "../../../vars.less"; .w-e-panel-content-emotion { list-style: none; text-align: left; width: 300px; font-size: 20px; li { display: inline-block; padding: 0 5px; cursor: pointer; border-radius: 3px 3px; &:hover { background-color: @textarea-slight-bg-color; } } } ================================================ FILE: packages/basic-modules/src/assets/image.less ================================================ @import "../../../vars.less"; // 拖拽,修改图片尺寸 .w-e-text-container [data-slate-editor] { .w-e-image-container { display: inline-block; margin: 0 3px; // 从 10px 改为 3px ,可规避 issue 4523 &:hover { box-shadow: 0 0 0 2px @textarea-selected-border-color; } } .w-e-selected-image-container { position: relative; overflow: hidden; .w-e-image-dragger { width: 7px; height: 7px; background-color: @textarea-handler-bg-color; position: absolute; } .left-top { top: 0; left: 0; cursor: nwse-resize; } .right-top { top: 0; right: 0; cursor: nesw-resize; } .left-bottom { left: 0; bottom: 0; cursor: nesw-resize; } .right-bottom { right: 0; bottom: 0; cursor: nwse-resize; } // 选中之后,不需要 hover 效果 &:hover { box-shadow: none; } } } // 禁用编辑器时,img hover 不要有样式 .w-e-text-container [contenteditable="false"] { .w-e-image-container { &:hover { box-shadow: none; } } } ================================================ FILE: packages/basic-modules/src/assets/index.less ================================================ @import "simple-style.less"; @import "color.less"; @import "blockquote.less"; @import "emotion.less"; @import "divider.less"; @import "blockquote.less"; @import "code-block.less"; @import "image.less"; ================================================ FILE: packages/basic-modules/src/assets/simple-style.less ================================================ @import "../../../vars.less"; .w-e-text-container [data-slate-editor] code { font-family: monospace; background-color: @textarea-slight-bg-color; padding: 3px; border-radius: 3px; } ================================================ FILE: packages/basic-modules/src/constants/icon-svg.ts ================================================ /** * @description icon svg * @author wangfupeng */ /** * 【注意】svg 字符串的长度 ,否则会导致代码体积过大 * 尽量选择 https://www.iconfont.cn/collections/detail?spm=a313x.7781069.0.da5a778a4&cid=20293 * 找不到再从 iconfont.com 搜索 */ // 加粗 export const BOLD_SVG = '' // 下划线 export const UNDER_LINE_SVG = '' // 斜体 export const ITALIC_SVG = '' // 删除线 export const THROUGH_SVG = '' // 代码 export const CODE_SVG = '' // 清除格式 export const ERASER_SVG = '' // 链接 export const LINK_SVG = '' // 取消链接 export const UN_LINK_SVG = '' // 编辑 export const PENCIL_SVG = '' // 外部(链接) export const EXTERNAL_SVG = '' // 标题 export const HEADER_SVG = '' // 字体颜色 export const FONT_COLOR_SVG = '' // 背景颜色 export const BG_COLOR_SVG = '' // 清空(颜色) export const CLEAN_SVG = '' // 图片 export const IMAGE_SVG = '' // 垃圾桶(删除) export const TRASH_SVG = '' // 引用 export const QUOTE_SVG = '' // 表情 export const EMOTION_SVG = '' // fontSize export const FONT_SIZE_SVG = '' // 字体 export const FONT_FAMILY_SVG = '' // 缩进 left export const INDENT_LEFT_SVG = '' // 缩进 right export const INDENT_RIGHT_SVG = '' // 左对齐 export const JUSTIFY_LEFT_SVG = '' // 右对齐 export const JUSTIFY_RIGHT_SVG = '' // 居中对齐 export const JUSTIFY_CENTER_SVG = '' // 两端对齐 export const JUSTIFY_JUSTIFY_SVG = '' // 行高 export const LINE_HEIGHT_SVG = '' // 撤销 export const UNDO_SVG = '' // 重做 export const REDO_SVG = '' // 分割线 export const DIVIDER_SVG = '' // 代码块 export const CODE_BLOCK_SVG = '' // 全屏 export const FULL_SCREEN_SVG = '' // 上标 export const SUP_SVG = '' // 下标 export const SUB_SVG = '' // checkbox export const CHECK_BOX_SVG = '' // 回车 export const ENTER_SVG = '' ================================================ FILE: packages/basic-modules/src/index.ts ================================================ /** * @description basic index * @author wangfupeng */ import './assets/index.less' // 配置多语言 import './locale/index' import wangEditorParagraphModule from './modules/paragraph' import wangEditorTextStyleModule from './modules/text-style' import wangEditorHeaderModule from './modules/header' import wangEditorColorModule from './modules/color' import wangEditorLinkModule from './modules/link' import wangEditorImageModule from './modules/image' import wangEditorTodoModule from './modules/todo' import wangEditorBlockQuoteModule from './modules/blockquote' import wangEditorEmotionModule from './modules/emotion' import wangEditorFontSizeAndFamilyModule from './modules/font-size-family' import wangEditorIndentModule from './modules/indent' import wangEditorJustifyModule from './modules/justify' import wangEditorLineHeightModule from './modules/line-height' import wangEditorUndoRedoModule from './modules/undo-redo' import wangEditorDividerModule from './modules/divider' import wangEditorCodeBlockModule from './modules/code-block' import wangEditorFullScreenModule from './modules/full-screen' import wangEditorCommonModule from './modules/common' export default [ // text style wangEditorTextStyleModule, wangEditorColorModule, wangEditorFontSizeAndFamilyModule, // elem style wangEditorIndentModule, wangEditorJustifyModule, wangEditorLineHeightModule, // void node wangEditorImageModule, wangEditorDividerModule, // inline node wangEditorEmotionModule, wangEditorLinkModule, // block node —— 【注意】要放在 void-node 和 inline-node 后面!!! wangEditorCodeBlockModule, wangEditorBlockQuoteModule, wangEditorHeaderModule, wangEditorParagraphModule, wangEditorTodoModule, // command wangEditorUndoRedoModule, wangEditorFullScreenModule, wangEditorCommonModule, ] // 输出 image 操作,供 updateImageModule 使用 export * from './modules/image/helper' ================================================ FILE: packages/basic-modules/src/locale/en.ts ================================================ /** * @description i18n en * @author wangfupeng */ export default { // 通用的词 common: { ok: 'OK', delete: 'Delete', enter: 'Enter', }, blockQuote: { title: 'Quote', }, codeBlock: { title: 'Code block', }, color: { color: 'Font color', bgColor: 'Back color', default: 'Default color', clear: 'Clear back color', }, divider: { title: 'Divider', }, emotion: { title: 'Emotion', }, fontSize: { title: 'Font size', default: 'Default', }, fontFamily: { title: 'Font family', default: 'Default', }, fullScreen: { title: 'Full screen', }, header: { title: 'Header', text: 'Text', }, image: { netImage: 'Net image', delete: 'Delete image', edit: 'Edit image', viewLink: 'View link', src: 'Image src', desc: 'Description', link: 'Image link', }, indent: { decrease: 'Decrease', increase: 'Increase', }, justify: { left: 'Left', right: 'Right', center: 'Center', justify: 'Justify', }, lineHeight: { title: 'Line height', default: 'Default', }, link: { insert: 'Insert link', text: 'Link text', url: 'Link source', unLink: 'Unlink', edit: 'Edit link', view: 'View link', }, textStyle: { bold: 'Bold', clear: 'Clear styles', code: 'Inline code', italic: 'Italic', sub: 'Sub', sup: 'Sup', through: 'Through', underline: 'Underline', }, undo: { undo: 'undo', redo: 'Redo', }, todo: { todo: 'Todo', }, } ================================================ FILE: packages/basic-modules/src/locale/index.ts ================================================ /** * @description i18n entry * @author wangfupeng */ import { i18nAddResources } from '@wangeditor/core' import enResources from './en' import zhResources from './zh-CN' i18nAddResources('en', enResources) i18nAddResources('zh-CN', zhResources) ================================================ FILE: packages/basic-modules/src/locale/zh-CN.ts ================================================ /** * @description i18n zh-CN * @author wangfupeng */ export default { // 通用的词 common: { ok: '确定', delete: '删除', enter: '回车', }, blockQuote: { title: '引用', }, codeBlock: { title: '代码块', }, color: { color: '文字颜色', bgColor: '背景色', default: '默认颜色', clear: '清除背景色', }, divider: { title: '分割线', }, emotion: { title: '表情', }, fontSize: { title: '字号', default: '默认字号', }, fontFamily: { title: '字体', default: '默认字体', }, fullScreen: { title: '全屏', }, header: { title: '标题', text: '正文', }, image: { netImage: '网络图片', delete: '删除图片', edit: '编辑图片', viewLink: '查看链接', src: '图片地址', desc: '图片描述', link: '图片链接', }, indent: { decrease: '减少缩进', increase: '增加缩进', }, justify: { left: '左对齐', right: '右对齐', center: '居中对齐', justify: '两端对齐', }, lineHeight: { title: '行高', default: '默认行高', }, link: { insert: '插入链接', text: '链接文本', url: '链接地址', unLink: '取消链接', edit: '修改链接', view: '查看链接', }, textStyle: { bold: '粗体', clear: '清除格式', code: '行内代码', italic: '斜体', sub: '下标', sup: '上标', through: '删除线', underline: '下划线', }, undo: { undo: '撤销', redo: '重做', }, todo: { todo: '待办', }, } ================================================ FILE: packages/basic-modules/src/modules/blockquote/custom-types.ts ================================================ /** * @description 自定义 element * @author wangfupeng */ import { Text } from 'slate' //【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts export type BlockQuoteElement = { type: 'blockquote' children: Text[] } ================================================ FILE: packages/basic-modules/src/modules/blockquote/elem-to-html.ts ================================================ /** * @description to html * @author wangfupeng */ import { Element } from 'slate' function quoteToHtml(elem: Element, childrenHtml: string): string { return `
${childrenHtml}
` } export const quoteToHtmlConf = { type: 'blockquote', elemToHtml: quoteToHtml, } ================================================ FILE: packages/basic-modules/src/modules/blockquote/index.ts ================================================ /** * @description blockquote entry * @author wangfupeng */ import { IModuleConf } from '@wangeditor/core' import { renderBlockQuoteConf } from './render-elem' import { quoteToHtmlConf } from './elem-to-html' import { parseHtmlConf } from './parse-elem-html' import { blockquoteMenuConf } from './menu/index' import withBlockquote from './plugin' const blockquote: Partial = { renderElems: [renderBlockQuoteConf], elemsToHtml: [quoteToHtmlConf], parseElemsHtml: [parseHtmlConf], menus: [blockquoteMenuConf], editorPlugin: withBlockquote, } export default blockquote ================================================ FILE: packages/basic-modules/src/modules/blockquote/menu/BlockquoteMenu.ts ================================================ /** * @description blockquote menu class * @author wangfupeng */ import { Editor, Transforms } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core' import { QUOTE_SVG } from '../../../constants/icon-svg' class BlockquoteMenu implements IButtonMenu { readonly title = t('blockQuote.title') readonly iconSvg = QUOTE_SVG readonly tag = 'button' getValue(editor: IDomEditor): string | boolean { // 用不到 getValue return '' } isActive(editor: IDomEditor): boolean { const node = DomEditor.getSelectedNodeByType(editor, 'blockquote') return !!node } isDisabled(editor: IDomEditor): boolean { if (editor.selection == null) return true const [nodeEntry] = Editor.nodes(editor, { match: n => { const type = DomEditor.getNodeType(n) // 只可用于 p 和 blockquote if (type === 'paragraph') return true if (type === 'blockquote') return true return false }, universal: true, mode: 'highest', // 匹配最高层级 }) // 匹配到 p blockquote ,不禁用 if (nodeEntry) { return false } // 未匹配到,则禁用 return true } /** * 执行命令 * @param editor editor * @param value node.type */ exec(editor: IDomEditor, value: string | boolean) { if (this.isDisabled(editor)) return const active = this.isActive(editor) const newType = active ? 'paragraph' : 'blockquote' // 执行命令 Transforms.setNodes(editor, { type: newType }, { mode: 'highest' }) } } export default BlockquoteMenu ================================================ FILE: packages/basic-modules/src/modules/blockquote/menu/index.ts ================================================ /** * @description block quote menu * @author wangfupeng */ import BlockquoteMenu from './BlockquoteMenu' export const blockquoteMenuConf = { key: 'blockquote', factory() { return new BlockquoteMenu() }, } ================================================ FILE: packages/basic-modules/src/modules/blockquote/parse-elem-html.ts ================================================ /** * @description parse html * @author wangfupeng */ import { Descendant, Text } from 'slate' import $, { DOMElement } from '../../utils/dom' import { IDomEditor } from '@wangeditor/core' import { BlockQuoteElement } from './custom-types' function parseHtml( elem: DOMElement, children: Descendant[], editor: IDomEditor ): BlockQuoteElement { const $elem = $(elem) children = children.filter(child => { if (Text.isText(child)) return true if (editor.isInline(child)) return true return false }) // 无 children ,则用纯文本 if (children.length === 0) { children = [{ text: $elem.text().replace(/\s+/gm, ' ') }] } return { type: 'blockquote', // @ts-ignore children, } } export const parseHtmlConf = { selector: 'blockquote:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性 parseElemHtml: parseHtml, } ================================================ FILE: packages/basic-modules/src/modules/blockquote/plugin.ts ================================================ /** * @description editor 插件,重写 editor API * @author wangfupeng */ import { Editor, Transforms, Node, Point } from 'slate' import { IDomEditor, DomEditor } from '@wangeditor/core' function withBlockquote(editor: T): T { const { insertBreak, insertText } = editor const newEditor = editor // 重写 insertBreak - 换行时插入 p newEditor.insertBreak = () => { const { selection } = newEditor if (selection == null) return insertBreak() const [nodeEntry] = Editor.nodes(editor, { match: n => DomEditor.checkNodeType(n, 'blockquote'), universal: true, }) if (!nodeEntry) return insertBreak() const quoteElem = nodeEntry[0] const quotePath = DomEditor.findPath(editor, quoteElem) const quoteEndLocation = Editor.end(editor, quotePath) if (Point.equals(quoteEndLocation, selection.focus)) { // 光标位于 blockquote 最后 const str = Node.string(quoteElem) if (str && str.slice(-1) === '\n') { // blockquote 文本最后一个是 \n editor.deleteBackward('character') // 删除最后一个 \n // 则插入一个 paragraph const p = { type: 'paragraph', children: [{ text: '' }] } Transforms.insertNodes(newEditor, p, { mode: 'highest' }) return } } // 情况情况,插入换行符 insertText('\n') } // 返回 editor ,重要! return newEditor } export default withBlockquote ================================================ FILE: packages/basic-modules/src/modules/blockquote/render-elem.tsx ================================================ /** * @description render elem * @author wangfupeng */ import { Element as SlateElement } from 'slate' import { jsx, VNode } from 'snabbdom' import { IDomEditor } from '@wangeditor/core' /** * render block quote elem * @param elemNode slate elem * @param children children * @param editor editor * @returns vnode */ function renderBlockQuote( elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor ): VNode { const vnode =
{children}
return vnode } export const renderBlockQuoteConf = { type: 'blockquote', renderElem: renderBlockQuote, } ================================================ FILE: packages/basic-modules/src/modules/code-block/custom-types.ts ================================================ /** * @description 自定义 element * @author wangfupeng */ //【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts type PureText = { text: string } export type PreElement = { type: 'pre' children: CodeElement[] } export type CodeElement = { type: 'code' language: string children: PureText[] } ================================================ FILE: packages/basic-modules/src/modules/code-block/elem-to-html.ts ================================================ /** * @description to html * @author wangfupeng */ import { Element } from 'slate' function codeToHtml(elem: Element, childrenHtml: string): string { // 代码高亮 `class="language-xxx"` 在 code-highlight 中实现 return `${childrenHtml}` } export const codeToHtmlConf = { type: 'code', elemToHtml: codeToHtml, } function preToHtml(elem: Element, childrenHtml: string): string { return `
${childrenHtml}
` } export const preToHtmlConf = { type: 'pre', elemToHtml: preToHtml, } ================================================ FILE: packages/basic-modules/src/modules/code-block/index.ts ================================================ /** * @description code block module * @author wangfupeng */ import { IModuleConf } from '@wangeditor/core' import { codeBlockMenuConf } from './menu/index' import withCodeBlock from './plugin' import { renderPreConf, renderCodeConf } from './render-elem' import { preParseHtmlConf } from './pre-parse-html' import { parseCodeHtmlConf, parsePreHtmlConf } from './parse-elem-html' import { codeToHtmlConf, preToHtmlConf } from './elem-to-html' const codeBlockModule: Partial = { menus: [codeBlockMenuConf], editorPlugin: withCodeBlock, renderElems: [renderPreConf, renderCodeConf], elemsToHtml: [codeToHtmlConf, preToHtmlConf], preParseHtml: [preParseHtmlConf], parseElemsHtml: [parseCodeHtmlConf, parsePreHtmlConf], } export default codeBlockModule ================================================ FILE: packages/basic-modules/src/modules/code-block/menu/CodeBlockMenu.ts ================================================ /** * @description insert code-block menu * @author wangfupeng */ import { Editor, Element, Transforms, Node, Range } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core' import { CODE_BLOCK_SVG } from '../../../constants/icon-svg' import { CodeElement } from '../custom-types' class CodeBlockMenu implements IButtonMenu { readonly title = t('codeBlock.title') readonly iconSvg = CODE_BLOCK_SVG readonly tag = 'button' private getSelectCodeElem(editor: IDomEditor): CodeElement | null { const codeNode = DomEditor.getSelectedNodeByType(editor, 'code') if (codeNode == null) return null const preNode = DomEditor.getParentNode(editor, codeNode) if (preNode == null) return null if (DomEditor.getNodeType(preNode) !== 'pre') return null return codeNode as CodeElement } /** * 获取语言类型 * @param editor editor */ getValue(editor: IDomEditor): string | boolean { const elem = this.getSelectCodeElem(editor) if (elem == null) return '' return elem.language || '' } isActive(editor: IDomEditor): boolean { const elem = this.getSelectCodeElem(editor) return !!elem } isDisabled(editor: IDomEditor): boolean { const { selection } = editor if (selection == null) return true const selectedElems = DomEditor.getSelectedElems(editor) const hasVoid = selectedElems.some(elem => editor.isVoid(elem)) if (hasVoid) return true const isMatch = selectedElems.some(elem => { const type = DomEditor.getNodeType(elem) if (type === 'pre' || type === 'paragraph') return true // 匹配 pre 或 paragraph }) if (isMatch) return false // 匹配到,则 enable return true // 否则 disable } exec(editor: IDomEditor, value: string | boolean) { const active = this.isActive(editor) if (active) { // 当前处于 code-block ,需要转换为普通文本 this.changeToPlainText(editor) } else { // 当前未处于 code-block ,需要转换为 code-block this.changeToCodeBlock(editor, value.toString()) } } private changeToPlainText(editor: IDomEditor) { const elem = this.getSelectCodeElem(editor) if (elem == null) return // 获取 code 文本 const str = Node.string(elem) // 删除当前最高层级的节点,即 pre 节点 Transforms.removeNodes(editor, { mode: 'highest' }) // 插入 p 节点 const pList = str.split('\n').map(s => { return { type: 'paragraph', children: [{ text: s }] } }) Transforms.insertNodes(editor, pList, { mode: 'highest' }) } private changeToCodeBlock(editor: IDomEditor, language: string) { // 汇总选中的最高层级节点的字符串 const strArr: string[] = [] const nodeEntries = Editor.nodes(editor, { match: n => editor.children.includes(n as Element), // 匹配选中的最高层级的节点 universal: true, }) for (let nodeEntry of nodeEntries) { const [n] = nodeEntry if (n) strArr.push(Node.string(n)) } // 删除选中的最高层级的节点 Transforms.removeNodes(editor, { mode: 'highest' }) // 插入 pre 节点 const newPreNode = { type: 'pre', children: [ { type: 'code', language, children: [ { text: strArr.join('\n') }, // 选中节点的纯文本 ], }, ], } Transforms.insertNodes(editor, newPreNode, { mode: 'highest' }) } } export default CodeBlockMenu ================================================ FILE: packages/basic-modules/src/modules/code-block/menu/index.ts ================================================ /** * @description code-block menu * @author wangfupeng */ import CodeBlockMenu from './CodeBlockMenu' export const codeBlockMenuConf = { key: 'codeBlock', factory() { return new CodeBlockMenu() }, } ================================================ FILE: packages/basic-modules/src/modules/code-block/parse-elem-html.ts ================================================ /** * @description parse html * @author wangfupeng */ import { Descendant } from 'slate' import $, { DOMElement } from '../../utils/dom' import { IDomEditor, DomEditor } from '@wangeditor/core' import { PreElement, CodeElement } from './custom-types' function parseCodeHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): CodeElement { const $elem = $(elem) return { type: 'code', language: '', // language 在 code-highlight 中实现 children: [{ text: $elem[0].textContent || '' }], } } export const parseCodeHtmlConf = { selector: 'pre:not([data-w-e-type])>code', // 匹配
 下的 
  parseElemHtml: parseCodeHtml,
}

function parsePreHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): PreElement {
  const $elem = $(elem)

  children = children.filter(child => DomEditor.getNodeType(child) === 'code')
  if (children.length === 0) {
    children = [{ type: 'code', language: '', children: [{ text: $elem[0].textContent || '' }] }]
  }

  return {
    type: 'pre',
    // @ts-ignore
    children: children.filter(child => DomEditor.getNodeType(child) === 'code'),
  }
}

export const parsePreHtmlConf = {
  selector: 'pre:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
  parseElemHtml: parsePreHtml,
}


================================================
FILE: packages/basic-modules/src/modules/code-block/plugin.ts
================================================
/**
 * @description editor 插件,重写 editor API
 * @author wangfupeng
 */

import { Editor, Transforms, Node as SlateNode, Element as SlateElement } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'

function getLastTextLineBeforeSelection(codeNode: SlateNode, editor: IDomEditor): string {
  const selection = editor.selection
  if (selection == null) return ''

  const codeText = SlateNode.string(codeNode)
  const anchorOffset = selection.anchor.offset
  const textBeforeAnchor = codeText.slice(0, anchorOffset) // 选区前的 text
  const arr = textBeforeAnchor.split('\n') // 选区前的 text ,按换行拆分
  const length = arr.length
  if (length === 0) return ''

  return arr[length - 1]
}

function withCodeBlock(editor: T): T {
  const { insertBreak, normalizeNode, insertData, insertNode } = editor
  const newEditor = editor

  // 重写换行操作
  newEditor.insertBreak = () => {
    const codeNode = DomEditor.getSelectedNodeByType(newEditor, 'code')
    if (codeNode == null) {
      insertBreak() // 执行默认的换行
      return
    }

    // 回车时,根据当前行的空格,自动插入空格
    const lastLineBeforeSelection = getLastTextLineBeforeSelection(codeNode, newEditor)
    if (lastLineBeforeSelection) {
      const arr = lastLineBeforeSelection.match(/^\s+/) // 行开始的空格
      if (arr != null && arr[0] != null) {
        const spaces = arr[0]
        newEditor.insertText(`\n${spaces}`) // 换行后插入空格
        return
      }
    }

    // 普通换行
    newEditor.insertText('\n')
  }

  // 重写 normalizeNode
  newEditor.normalizeNode = ([node, path]) => {
    const type = DomEditor.getNodeType(node)

    // -------------- code node 不能是顶层,否则替换为 p --------------
    if (type === 'code' && path.length <= 1) {
      Transforms.setNodes(newEditor, { type: 'paragraph' }, { at: path })
    }

    if (type === 'pre') {
      // -------------- pre 是 editor 最后一个节点,需要后面插入 p --------------
      const isLast = DomEditor.isLastNode(newEditor, node)
      if (isLast) {
        Transforms.insertNodes(newEditor, DomEditor.genEmptyParagraph(), { at: [path[0] + 1] })
      }

      // -------------- pre 下面必须是 code --------------
      if (DomEditor.getNodeType((node as SlateElement).children[0]) !== 'code') {
        Transforms.unwrapNodes(newEditor)
        Transforms.setNodes(newEditor, { type: 'paragraph' }, { mode: 'highest' })
      }
    }

    // 执行默认行为
    return normalizeNode([node, path])
  }

  // 重写 insertData - 粘贴文本
  newEditor.insertData = (data: DataTransfer) => {
    const codeNode = DomEditor.getSelectedNodeByType(newEditor, 'code')
    if (codeNode == null) {
      insertData(data) // 执行默认的 insertData
      return
    }

    // 获取文本,并插入到代码块
    const text = data.getData('text/plain')
    Editor.insertText(newEditor, text)
  }

  // 返回 editor ,重要!
  return newEditor
}

export default withCodeBlock


================================================
FILE: packages/basic-modules/src/modules/code-block/pre-parse-html.ts
================================================
/**
 * @description pre parse html
 * @author wangfupeng
 */

import $, { DOMElement } from '../../utils/dom'
import { getTagName } from '../../utils/dom'

/**
 * pre-prase  ,去掉其中的  (兼容 V4)
 * @param codeElem codeElem
 */
function preParse(codeElem: DOMElement): DOMElement {
  const $code = $(codeElem)
  const tagName = getTagName($code)
  if (tagName !== 'code') return codeElem

  const $xmp = $code.find('xmp')
  if ($xmp.length === 0) return codeElem // 不是 V4 格式

  const codeText = $xmp.text()
  $xmp.remove()
  $code.text(codeText)

  return $code[0]
}

export const preParseHtmlConf = {
  selector: 'pre>code', // 匹配 <pre> 下的 <code>
  preParseHtml: preParse,
}


================================================
FILE: packages/basic-modules/src/modules/code-block/render-elem.tsx
================================================
/**
 * @description render elem
 * @author wangfupeng
 */

import { Element as SlateElement } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor } from '@wangeditor/core'

function renderPre(elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
  const vnode = <pre>{children}</pre>
  return vnode
}

function renderCode(elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
  // 和 basic/simple-style module 的“行内代码”并不冲突。一个是根据 mark 渲染,一个是根据 node.type 渲染
  const vnode = <code>{children}</code>
  return vnode
}

export const renderPreConf = {
  type: 'pre',
  renderElem: renderPre,
}

export const renderCodeConf = {
  type: 'code',
  renderElem: renderCode,
}


================================================
FILE: packages/basic-modules/src/modules/color/custom-types.ts
================================================
/**
 * @description 自定义 element
 * @author wangfupeng
 */

//【注意】需要把自定义的 Text 引入到最外层的 custom-types.d.ts

export type ColorText = {
  text: string
  color?: string
  bgColor?: string
}


================================================
FILE: packages/basic-modules/src/modules/color/index.ts
================================================
/**
 * @description color bgColor
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { preParseHtmlConf } from './pre-parse-html'
import { parseStyleHtml } from './parse-style-html'
import { colorMenuConf, bgColorMenuConf } from './menu/index'

const color: Partial<IModuleConf> = {
  renderStyle,
  styleToHtml,
  preParseHtml: [preParseHtmlConf],
  parseStyleHtml,
  menus: [colorMenuConf, bgColorMenuConf],
}

export default color


================================================
FILE: packages/basic-modules/src/modules/color/menu/BaseMenu.ts
================================================
/**
 * @description color base menu
 * @author wangfupeng
 */

import { Editor, Range } from 'slate'
import { IDropPanelMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { CLEAN_SVG } from '../../../constants/icon-svg'

abstract class BaseMenu implements IDropPanelMenu {
  abstract readonly title: string
  abstract readonly iconSvg: string
  readonly tag = 'button'
  readonly showDropPanel = true // 点击 button 时显示 dropPanel
  protected abstract readonly mark: string
  private $content: Dom7Array | null = null

  exec(editor: IDomEditor, value: string | boolean) {
    // 点击菜单时,弹出 droPanel 之前,不需要执行其他代码
    // 此处空着即可
  }

  getValue(editor: IDomEditor): string | boolean {
    const mark = this.mark
    const curMarks = Editor.marks(editor)
    // @ts-ignore
    if (curMarks && curMarks[mark]) return curMarks[mark]
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    const color = this.getValue(editor)
    return !!color
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const [match] = Editor.nodes(editor, {
      match: n => {
        const type = DomEditor.getNodeType(n)

        if (type === 'pre') return true // 代码块
        if (Editor.isVoid(editor, n)) return true // void node

        return false
      },
      universal: true,
    })

    // 命中,则禁用
    if (match) return true
    return false
  }

  getPanelContentElem(editor: IDomEditor): DOMElement {
    const mark = this.mark

    if (this.$content == null) {
      // 第一次渲染
      const $content = $('<ul class="w-e-panel-content-color"></ul>')

      // 绑定事件(只在第一次绑定,不要重复绑定)
      $content.on('click', 'li', (e: Event) => {
        const { target } = e
        if (target == null) return
        e.preventDefault()

        const { selection } = editor
        if (selection == null) return

        const $li = $(target)
        const val = $li.attr('data-value')

        // 修改文本样式
        if (val === '0') {
          Editor.removeMark(editor, mark)
        } else {
          Editor.addMark(editor, mark, val)
        }
      })

      this.$content = $content
    }
    const $content = this.$content
    if ($content == null) return document.createElement('ul')
    $content.empty() // 清空之后再重置内容

    // 当前选中文本的颜色之
    const selectedColor = this.getValue(editor)

    // 获取菜单配置
    const colorConf = editor.getMenuConfig(mark)
    const { colors = [] } = colorConf
    // 根据菜单配置生成 panel content
    colors.forEach((color: string) => {
      const $block = $(`<div class="color-block" data-value="${color}"></div>`)
      $block.css('background-color', color)

      const $li = $(`<li data-value="${color}"></li>`)
      if (selectedColor === color) {
        $li.addClass('active')
      }
      $li.append($block)

      $content.append($li)
    })

    // 清除颜色
    let clearText = ''
    if (mark === 'color') clearText = t('color.default')
    if (mark === 'bgColor') clearText = t('color.clear')
    const $clearLi = $(`
      <li data-value="0" class="clear">
        ${CLEAN_SVG}
        ${clearText}
      </li>
    `)
    $content.prepend($clearLi)

    return $content[0]
  }
}

export default BaseMenu


================================================
FILE: packages/basic-modules/src/modules/color/menu/BgColorMenu.ts
================================================
/**
 * @description bg color menu
 * @author wangfupeng
 */

import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { BG_COLOR_SVG } from '../../../constants/icon-svg'

class BgColorMenu extends BaseMenu {
  readonly title = t('color.bgColor')
  readonly iconSvg = BG_COLOR_SVG
  readonly mark = 'bgColor'
}

export default BgColorMenu


================================================
FILE: packages/basic-modules/src/modules/color/menu/ColorMenu.ts
================================================
/**
 * @description color menu
 * @author wangfupeng
 */

import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { FONT_COLOR_SVG } from '../../../constants/icon-svg'

class ColorMenu extends BaseMenu {
  readonly title = t('color.color')
  readonly iconSvg = FONT_COLOR_SVG
  readonly mark = 'color'
}

export default ColorMenu


================================================
FILE: packages/basic-modules/src/modules/color/menu/config.ts
================================================
/**
 * @description menu config
 * @author wangfupeng
 */

const COLORS = [
  'rgb(0, 0, 0)',
  'rgb(38, 38, 38)',
  'rgb(89, 89, 89)',
  'rgb(140, 140, 140)',
  'rgb(191, 191, 191)',
  'rgb(217, 217, 217)',
  'rgb(233, 233, 233)',
  'rgb(245, 245, 245)',
  'rgb(250, 250, 250)',
  'rgb(255, 255, 255)', // 10
  'rgb(225, 60, 57)',
  'rgb(231, 95, 51)',
  'rgb(235, 144, 58)',
  'rgb(245, 219, 77)',
  'rgb(114, 192, 64)',
  'rgb(89, 191, 192)',
  'rgb(66, 144, 247)',
  'rgb(54, 88, 226)',
  'rgb(106, 57, 201)',
  'rgb(216, 68, 147)', // 10
  'rgb(251, 233, 230)',
  'rgb(252, 237, 225)',
  'rgb(252, 239, 212)',
  'rgb(252, 251, 207)',
  'rgb(231, 246, 213)',
  'rgb(218, 244, 240)',
  'rgb(217, 237, 250)',
  'rgb(224, 232, 250)',
  'rgb(237, 225, 248)',
  'rgb(246, 226, 234)', // 10
  'rgb(255, 163, 158)',
  'rgb(255, 187, 150)',
  'rgb(255, 213, 145)',
  'rgb(255, 251, 143)',
  'rgb(183, 235, 143)',
  'rgb(135, 232, 222)',
  'rgb(145, 213, 255)',
  'rgb(173, 198, 255)',
  'rgb(211, 173, 247)',
  'rgb(255, 173, 210)', // 10
  'rgb(255, 77, 79)',
  'rgb(255, 122, 69)',
  'rgb(255, 169, 64)',
  'rgb(255, 236, 61)',
  'rgb(115, 209, 61)',
  'rgb(54, 207, 201)',
  'rgb(64, 169, 255)',
  'rgb(89, 126, 247)',
  'rgb(146, 84, 222)',
  'rgb(247, 89, 171)', // 10
  'rgb(207, 19, 34)',
  'rgb(212, 56, 13)',
  'rgb(212, 107, 8)',
  'rgb(212, 177, 6)',
  'rgb(56, 158, 13)',
  'rgb(8, 151, 156)',
  'rgb(9, 109, 217)',
  'rgb(29, 57, 196)',
  'rgb(83, 29, 171)',
  'rgb(196, 29, 127)', // 10
  'rgb(130, 0, 20)',
  'rgb(135, 20, 0)',
  'rgb(135, 56, 0)',
  'rgb(97, 71, 0)',
  'rgb(19, 82, 0)',
  'rgb(0, 71, 79)',
  'rgb(0, 58, 140)',
  'rgb(6, 17, 120)',
  'rgb(34, 7, 94)',
  'rgb(120, 6, 80)', // 10
]

export function genColors() {
  return COLORS
}

export function genBgColors() {
  return COLORS
}


================================================
FILE: packages/basic-modules/src/modules/color/menu/index.ts
================================================
/**
 * @description menu entry
 * @author wangfupeng
 */

import ColorMenu from './ColorMenu'
import BgColorMenu from './BgColorMenu'
import { genColors, genBgColors } from './config'

export const colorMenuConf = {
  key: 'color',
  factory() {
    return new ColorMenu()
  },

  // 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
  // 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
  config: {
    colors: genColors(),
  },
}

export const bgColorMenuConf = {
  key: 'bgColor',
  factory() {
    return new BgColorMenu()
  },
  config: {
    colors: genBgColors(),
  },
}


================================================
FILE: packages/basic-modules/src/modules/color/parse-style-html.ts
================================================
/**
 * @description parse style html
 * @author wangfupeng
 */

import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { ColorText } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'

export function parseStyleHtml(text: DOMElement, node: Descendant, editor: IDomEditor): Descendant {
  const $text = $(text)
  if (!Text.isText(node)) return node

  const textNode = node as ColorText

  const color = getStyleValue($text, 'color')
  if (color) {
    textNode.color = color
  }

  let bgColor = getStyleValue($text, 'background-color')
  if (!bgColor) bgColor = getStyleValue($text, 'background') // word 背景色
  if (bgColor) {
    textNode.bgColor = bgColor
  }

  return textNode
}


================================================
FILE: packages/basic-modules/src/modules/color/pre-parse-html.ts
================================================
/**
 * @description pre-parse html
 * @author wangfupeng
 */

import $, { DOMElement, getTagName } from '../../utils/dom'

/**
 * pre-prase font ,兼容 V4
 * @param fontElem fontElem
 */
function preParse(fontElem: DOMElement): DOMElement {
  const $font = $(fontElem)
  const tagName = getTagName($font)
  if (tagName !== 'font') return fontElem

  // 处理 color (V4 使用 <font color="#ccc">xx</font> 格式)
  const color = $font.attr('color') || ''
  if (color) {
    $font.removeAttr('color')
    $font.css('color', color)
  }

  return $font[0]
}

export const preParseHtmlConf = {
  selector: 'font',
  preParseHtml: preParse,
}


================================================
FILE: packages/basic-modules/src/modules/color/render-style.tsx
================================================
/**
 * @description render color style
 * @author wangfupeng
 */

import { Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeStyle } from '../../utils/vdom'
import { ColorText } from './custom-types'

/**
 * 添加样式
 * @param node text node
 * @param vnode vnode
 * @returns vnode
 */
export function renderStyle(node: Descendant, vnode: VNode): VNode {
  const { color, bgColor } = node as ColorText
  let styleVnode: VNode = vnode

  if (color) {
    addVnodeStyle(styleVnode, { color })
  }
  if (bgColor) {
    addVnodeStyle(styleVnode, { backgroundColor: bgColor })
  }

  return styleVnode
}


================================================
FILE: packages/basic-modules/src/modules/color/style-to-html.ts
================================================
/**
 * @description textStyle to html
 * @author wangfupeng
 */

import { Text, Descendant } from 'slate'
import $, { getOuterHTML, getTagName, isPlainText } from '../../utils/dom'
import { ColorText } from './custom-types'

/**
 * style to html
 * @param textNode slate text node
 * @param textHtml text html
 * @returns styled html
 */
export function styleToHtml(textNode: Descendant, textHtml: string): string {
  if (!Text.isText(textNode)) return textHtml

  const { color, bgColor } = textNode as ColorText
  if (!color && !bgColor) return textHtml

  let $text

  if (isPlainText(textHtml)) {
    // textHtml 是纯文本,不是 html tag
    $text = $(`<span>${textHtml}</span>`)
  } else {
    // textHtml 是 html tag
    $text = $(textHtml)
    const tagName = getTagName($text)
    if (tagName !== 'span') {
      // 如果不是 span ,则包裹一层,接下来要设置 css
      $text = $(`<span>${textHtml}</span>`)
    }
  }

  // 设置样式
  if (color) $text.css('color', color)
  if (bgColor) $text.css('background-color', bgColor)

  // 输出 html
  return getOuterHTML($text)
}


================================================
FILE: packages/basic-modules/src/modules/common/index.ts
================================================
/**
 * @description common module
 * @author wangfupeng
 */
import { IModuleConf } from '@wangeditor/core'
import { enterMenuConf } from './menu/index'

const commonModule: Partial<IModuleConf> = {
  menus: [enterMenuConf],
}

export default commonModule


================================================
FILE: packages/basic-modules/src/modules/common/menu/EnterMenu.ts
================================================
/**
 * @description enter menu
 * @author wangfupeng
 */

import { Range, Transforms, Editor } from 'slate'
import { IButtonMenu, IDomEditor, t } from '@wangeditor/core'
import { ENTER_SVG } from '../../../constants/icon-svg'

class EnterMenu implements IButtonMenu {
  title = t('common.enter')
  iconSvg = ENTER_SVG
  tag = 'button'

  getValue(editor: IDomEditor): string | boolean {
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    return false
  }

  isDisabled(editor: IDomEditor): boolean {
    const { selection } = editor
    if (selection == null) return true
    if (Range.isExpanded(selection)) return true
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    const { selection } = editor
    if (selection == null) return
    const { anchor } = selection
    const { path } = anchor

    // 在当前位置插入空行,当前元素下移
    const newElem = { type: 'paragraph', children: [{ text: '' }] }
    const newPath = [path[0]]
    Transforms.insertNodes(editor, newElem, { at: newPath })
    editor.select(Editor.start(editor, newPath))
  }
}

export default EnterMenu


================================================
FILE: packages/basic-modules/src/modules/common/menu/index.ts
================================================
/**
 * @description common menu config
 * @author wangfupeng
 */

import EnterMenu from './EnterMenu'

export const enterMenuConf = {
  key: 'enter',
  factory() {
    return new EnterMenu()
  },
}


================================================
FILE: packages/basic-modules/src/modules/divider/README.md
================================================
# 分割线

================================================
FILE: packages/basic-modules/src/modules/divider/custom-types.ts
================================================
/**
 * @description divider element
 * @author wangfupeng
 */

//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts

type EmptyText = {
  text: ''
}

export type DividerElement = {
  type: 'divider'
  children: EmptyText[]
}


================================================
FILE: packages/basic-modules/src/modules/divider/elem-to-html.ts
================================================
/**
 * @description to html
 * @author wangfupeng
 */

import { Element } from 'slate'

function dividerToHtml(elem: Element, childrenHtml: string): string {
  return `<hr/>`
}

export const dividerToHtmlConf = {
  type: 'divider',
  elemToHtml: dividerToHtml,
}


================================================
FILE: packages/basic-modules/src/modules/divider/index.ts
================================================
/**
 * @description divider module
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import withDivider from './plugin'
import { renderDividerConf } from './render-elem'
import { dividerToHtmlConf } from './elem-to-html'
import { parseHtmlConf } from './parse-elem-html'
import { insertDividerMenuConf } from './menu/index'

const image: Partial<IModuleConf> = {
  renderElems: [renderDividerConf],
  elemsToHtml: [dividerToHtmlConf],
  parseElemsHtml: [parseHtmlConf],
  menus: [insertDividerMenuConf],
  editorPlugin: withDivider,
}

export default image


================================================
FILE: packages/basic-modules/src/modules/divider/menu/DeleteDividerMenu.ts.bak
================================================
/**
 * @description delete divider menu
 * @author wangfupeng
 */

import { Transforms } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { TRASH_SVG } from '../../../constants/icon-svg'

class DeleteDividerMenu implements IButtonMenu {
  readonly title = t('common.delete')
  readonly iconSvg = TRASH_SVG
  readonly tag = 'button'

  getValue(editor: IDomEditor): string | boolean {
    // 无需获取 val
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 无需 active
    return false
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const dividerNode = DomEditor.getSelectedNodeByType(editor, 'divider')
    if (dividerNode == null) {
      // 选区未处于 divider node ,则禁用
      return true
    }
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    if (this.isDisabled(editor)) return

    // 删除
    Transforms.removeNodes(editor, {
      match: n => DomEditor.checkNodeType(n, 'divider'),
    })
  }
}

export default DeleteDividerMenu


================================================
FILE: packages/basic-modules/src/modules/divider/menu/InsertDividerMenu.ts
================================================
/**
 * @description insert divider menu
 * @author wangfupeng
 */

import { Transforms } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { DIVIDER_SVG } from '../../../constants/icon-svg'
import { DividerElement } from '../custom-types'

class InsertDividerMenu implements IButtonMenu {
  readonly title = t('divider.title')
  readonly iconSvg = DIVIDER_SVG
  readonly tag = 'button'

  getValue(editor: IDomEditor): string | boolean {
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 不需要 active
    return false
  }

  isDisabled(editor: IDomEditor): boolean {
    const { selection } = editor
    if (selection == null) return true

    const selectedElems = DomEditor.getSelectedElems(editor)
    const hasVoidOrTableOrPre = selectedElems.some(elem => {
      if (editor.isVoid(elem)) return true
      const type = DomEditor.getNodeType(elem)
      if (type === 'table') return true
      if (type === 'pre') return true
    })
    if (hasVoidOrTableOrPre) return true // 匹配,则 disable

    return false
  }

  exec(editor: IDomEditor, value: string | boolean): void {
    const node: DividerElement = {
      type: 'divider',
      children: [{ text: '' }], // 【注意】void node 需要一个空 text 作为 children
    }

    Transforms.insertNodes(editor, node, { mode: 'highest' })
  }
}

export default InsertDividerMenu


================================================
FILE: packages/basic-modules/src/modules/divider/menu/index.ts
================================================
/**
 * @description divider menu
 * @author wangfupeng
 */

import InsertDividerMenu from './InsertDividerMenu'
// import DeleteDividerMenu from './DeleteDividerMenu.ts'

export const insertDividerMenuConf = {
  key: 'divider',
  factory() {
    return new InsertDividerMenu()
  },
}

// export const deleteDividerMenuConf = {
//   key: 'deleteDivider',
//   factory() {
//     return new DeleteDividerMenu()
//   },
// }
// divider 可用键盘删除了,所以注释掉该菜单 wangfupeng 22.02.23


================================================
FILE: packages/basic-modules/src/modules/divider/parse-elem-html.ts
================================================
/**
 * @description parse html
 * @author wangfupeng
 */

import { Descendant } from 'slate'
import $, { DOMElement } from '../../utils/dom'
import { IDomEditor } from '@wangeditor/core'
import { DividerElement } from './custom-types'

function parseHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): DividerElement {
  return {
    type: 'divider',
    children: [{ text: '' }], // void node 有一个空白 text
  }
}

export const parseHtmlConf = {
  selector: 'hr:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
  parseElemHtml: parseHtml,
}


================================================
FILE: packages/basic-modules/src/modules/divider/plugin.ts
================================================
/**
 * @description editor 插件,重写 editor API
 * @author wangfupeng
 */

import { Transforms, Element } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'

function withDivider<T extends IDomEditor>(editor: T): T {
  const { isVoid, normalizeNode } = editor
  const newEditor = editor

  // 重写 isVoid
  newEditor.isVoid = elem => {
    const { type } = elem

    if (type === 'divider') {
      return true
    }

    return isVoid(elem)
  }

  // 重新 normalize
  newEditor.normalizeNode = ([node, path]) => {
    const type = DomEditor.getNodeType(node)
    if (type !== 'divider') {
      // 未命中 divider ,执行默认的 normalizeNode
      return normalizeNode([node, path])
    }

    // -------------- divider 是 editor 最后一个节点,需要后面插入 p --------------
    const isLast = DomEditor.isLastNode(newEditor, node)
    if (isLast) {
      Transforms.insertNodes(newEditor, DomEditor.genEmptyParagraph(), { at: [path[0] + 1] })
    }
  }

  // 返回 editor ,重要!
  return newEditor
}

export default withDivider


================================================
FILE: packages/basic-modules/src/modules/divider/render-elem.tsx
================================================
/**
 * @description render divider elem
 * @author wangfupeng
 */

import { Element as SlateElement } from 'slate'
import { h, VNode } from 'snabbdom'
import { IDomEditor, DomEditor } from '@wangeditor/core'

function renderDivider(
  elemNode: SlateElement,
  children: VNode[] | null,
  editor: IDomEditor
): VNode {
  const renderStyle: any = {}

  // 是否选中
  const selected = DomEditor.isNodeSelected(editor, elemNode)

  const vnode = h(
    'div',
    {
      props: {
        contentEditable: false,
        className: 'w-e-textarea-divider',
      },
      dataset: {
        selected: selected ? 'true' : '',
      },
      style: renderStyle,
      on: {
        mousedown: event => event.preventDefault(),
      },
    },
    [h('hr')]
  )
  // 【注意】void node 中,renderElem 不用处理 children 。core 会统一处理。

  return vnode
}

const renderDividerConf = {
  type: 'divider', // 和 elemNode.type 一致
  renderElem: renderDivider,
}

export { renderDividerConf }


================================================
FILE: packages/basic-modules/src/modules/emotion/index.ts
================================================
/**
 * @description emotion entry
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { emotionMenuConf } from './menu/index'

const emotion: Partial<IModuleConf> = {
  menus: [emotionMenuConf],
}

export default emotion


================================================
FILE: packages/basic-modules/src/modules/emotion/menu/EmotionMenu.ts
================================================
/**
 * @description emotion menu
 * @author wangfupeng
 */

import { Editor } from 'slate'
import { IDropPanelMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { EMOTION_SVG } from '../../../constants/icon-svg'

class EmotionMenu implements IDropPanelMenu {
  readonly title = t('emotion.title')
  readonly iconSvg = EMOTION_SVG
  readonly tag = 'button'
  readonly showDropPanel = true // 点击 button 时显示 dropPanel
  private $content: Dom7Array | null = null

  exec(editor: IDomEditor, value: string | boolean) {
    // 点击菜单时,弹出 droPanel 之前,不需要执行其他代码
    // 此处空着即可
  }

  getValue(editor: IDomEditor): string | boolean {
    // 不需要 getValue
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 不需要 active
    return false
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const [match] = Editor.nodes(editor, {
      match: n => {
        const type = DomEditor.getNodeType(n)

        if (type === 'pre') return true // 代码块
        if (Editor.isVoid(editor, n)) return true // void node

        return false
      },
      universal: true,
    })

    if (match) return true
    return false
  }

  getPanelContentElem(editor: IDomEditor): DOMElement {
    if (this.$content == null) {
      // 第一次渲染
      const $content = $('<ul class="w-e-panel-content-emotion"></ul>')

      // 绑定事件(仅第一次绑定,不可重复绑定)
      $content.on('click', 'li', (e: Event) => {
        const { target } = e
        if (target == null) return
        e.preventDefault()

        const $li = $(target)
        const emotionStr = $li.text()
        editor.insertText(emotionStr)
      })

      this.$content = $content
    }

    const $content = this.$content
    if ($content == null) return document.createElement('ul')
    $content.empty() // 清空之后再重置内容

    // 获取菜单配置
    const colorConf = editor.getMenuConfig('emotion')
    const { emotions = [] } = colorConf
    // 根据菜单配置生成 panel content
    emotions.forEach((emotion: string) => {
      const $li = $(`<li>${emotion}</li>`)
      $content.append($li)
    })

    return $content[0]
  }
}

export default EmotionMenu


================================================
FILE: packages/basic-modules/src/modules/emotion/menu/config.ts
================================================
/**
 * @description menu config
 * @author wangfupeng
 */

export function genConfig() {
  const emotions =
    '😀 😃 😄 😁 😆 😅 😂 🤣 😊 😇 🙂 🙃 😉 😌 😍 😘 😗 😙 😚 😋 😛 😝 😜 🤓 😎 😏 😒 😞 😔 😟 😕 🙁 😣 😖 😫 😩 😢 😭 😤 😠 😡 😳 😱 😨 🤗 🤔 😶 😑 😬 🙄 😯 😴 😷 🤑 😈 🤡 💩 👻 💀 👀 👣 👐 🙌 👏 🤝 👍 👎 👊 ✊ 🤛 🤜 🤞 ✌️ 🤘 👌 👈 👉 👆 👇 ☝️ ✋ 🤚 🖐 🖖 👋 🤙 💪 🖕 ✍️ 🙏'
  return emotions.split(' ')
}


================================================
FILE: packages/basic-modules/src/modules/emotion/menu/index.ts
================================================
/**
 * @description emotion menu
 * @author wangfupeng
 */

import EmotionMenu from './EmotionMenu'
import { genConfig } from './config'

export const emotionMenuConf = {
  key: 'emotion',
  factory() {
    return new EmotionMenu()
  },

  // 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
  // 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
  config: {
    emotions: genConfig(),
  },
}


================================================
FILE: packages/basic-modules/src/modules/font-size-family/custom-types.ts
================================================
/**
 * @description 自定义 element
 * @author wangfupeng
 */

//【注意】需要把自定义的 Text 引入到最外层的 custom-types.d.ts

export type FontSizeAndFamilyText = {
  text: string
  fontSize?: string
  fontFamily?: string
}


================================================
FILE: packages/basic-modules/src/modules/font-size-family/index.ts
================================================
/**
 * @description font-size font-family
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { preParseHtmlConf } from './pre-parse-html'
import { parseStyleHtml } from './parse-style-html'
import { fontSizeMenuConf, fontFamilyMenuConf } from './menu/index'

const fontSizeAndFamily: Partial<IModuleConf> = {
  renderStyle,
  styleToHtml,
  preParseHtml: [preParseHtmlConf],
  parseStyleHtml,
  menus: [fontSizeMenuConf, fontFamilyMenuConf],
}

export default fontSizeAndFamily


================================================
FILE: packages/basic-modules/src/modules/font-size-family/menu/BaseMenu.ts
================================================
/**
 * @description header menu
 * @author wangfupeng
 */

import { Editor } from 'slate'
import { ISelectMenu, IDomEditor, DomEditor, IOption } from '@wangeditor/core'

abstract class BaseMenu implements ISelectMenu {
  abstract readonly title: string
  abstract readonly iconSvg: string
  abstract readonly mark: string // 'fontSize'/'fontFamily'
  readonly tag = 'select'
  readonly width = 80

  abstract getOptions(editor: IDomEditor): IOption[]

  isActive(editor: IDomEditor): boolean {
    // select menu 会显示 selected value ,用不到 active
    return false
  }

  getValue(editor: IDomEditor): string | boolean {
    const mark = this.mark
    const curMarks = Editor.marks(editor)
    // @ts-ignore
    if (curMarks && curMarks[mark]) return curMarks[mark]
    return ''
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const mark = this.mark
    const [match] = Editor.nodes(editor, {
      match: n => {
        const type = DomEditor.getNodeType(n)
        if (type === 'pre') return true // 代码块
        if (Editor.isVoid(editor, n)) return true // void node

        return false
      },
      universal: true,
    })

    // 匹配到,则禁用
    if (match) return true
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    const mark = this.mark
    if (value) {
      editor.addMark(mark, value)
    } else {
      editor.removeMark(mark)
    }
  }
}

export default BaseMenu


================================================
FILE: packages/basic-modules/src/modules/font-size-family/menu/FontFamilyMenu.ts
================================================
/**
 * @description font-family menu
 * @author wangfupeng
 */

import { IDomEditor, IOption, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { FONT_FAMILY_SVG } from '../../../constants/icon-svg'

class FontFamilyMenu extends BaseMenu {
  readonly title = t('fontFamily.title')
  readonly iconSvg = FONT_FAMILY_SVG
  readonly mark = 'fontFamily'
  readonly selectPanelWidth = 150

  getOptions(editor: IDomEditor): IOption[] {
    const options: IOption[] = []

    // 获取配置,参考 './config.ts'
    const { fontFamilyList = [] } = editor.getMenuConfig(this.mark)

    // 生成 options
    options.push({
      text: t('fontFamily.default'),
      value: '', // this.getValue(editor) 未找到结果时,会返回 '' ,正好对应到这里
    })
    fontFamilyList.forEach((family: string | { name: string; value: string }) => {
      if (typeof family === 'string') {
        options.push({
          text: family,
          value: family,
          styleForRenderMenuList: { 'font-family': family },
        })
      } else if (typeof family === 'object') {
        const { name, value } = family
        options.push({
          text: name,
          value,
          styleForRenderMenuList: { 'font-family': value },
        })
      }
    })

    // 设置 selected
    const curValue = this.getValue(editor)
    options.forEach(opt => {
      if (opt.value === curValue) {
        opt.selected = true
      } else {
        delete opt.selected
      }
    })

    return options
  }
}

export default FontFamilyMenu


================================================
FILE: packages/basic-modules/src/modules/font-size-family/menu/FontSizeMenu.ts
================================================
/**
 * @description font-size menu
 * @author wangfupeng
 */

import { IDomEditor, IOption, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { FONT_SIZE_SVG } from '../../../constants/icon-svg'

class FontSizeMenu extends BaseMenu {
  readonly title = t('fontSize.title')
  readonly iconSvg = FONT_SIZE_SVG
  readonly mark = 'fontSize'

  getOptions(editor: IDomEditor): IOption[] {
    const options: IOption[] = []

    // 获取配置,参考 './config.ts'
    const { fontSizeList = [] } = editor.getMenuConfig(this.mark)

    // 生成 options
    options.push({
      text: t('fontSize.default'),
      value: '', // this.getValue(editor) 未找到结果时,会返回 '' ,正好对应到这里
    })
    fontSizeList.forEach((size: string | { name: string; value: string }) => {
      if (typeof size === 'string') {
        options.push({
          text: size,
          value: size,
        })
      } else if (typeof size === 'object') {
        const { name, value } = size
        options.push({
          text: name,
          value: value,
        })
      }
    })

    // 设置 selected
    const curValue = this.getValue(editor)
    options.forEach(opt => {
      if (opt.value === curValue) {
        opt.selected = true
      } else {
        delete opt.selected
      }
    })

    return options
  }
}

export default FontSizeMenu


================================================
FILE: packages/basic-modules/src/modules/font-size-family/menu/config.ts
================================================
/**
 * @description font-size font-family config
 * @author wangfupeng
 */

export function genFontSizeConfig() {
  const fontSizeList: Array<string | { name: string; value: string }> = [
    // 元素支持两种形式:1. 字符串;2. { name: 'xxx', value: 'xxx' }
    '12px',
    { name: '13px', value: '13px' },
    '14px',
    '15px',
    '16px',
    '19px',
    { name: '22px', value: '22px' },
    '24px',
    '29px',
    '32px',
    '40px',
    '48px',
  ]

  return fontSizeList
}

export function getFontFamilyConfig() {
  let fontFamilyList: Array<string | { name: string; value: string }> = [
    // 元素支持两种形式:1. 字符串;2. { name: 'xxx', value: 'xxx' }
    '黑体',
    { name: '仿宋', value: '仿宋' },
    '楷体',
    '标楷体',
    '华文仿宋',
    '华文楷体',
    { name: '宋体', value: '宋体' },
    '微软雅黑',
    'Arial',
    'Tahoma',
    'Verdana',
    'Times New Roman',
    'Courier New',
  ]

  return fontFamilyList
}


================================================
FILE: packages/basic-modules/src/modules/font-size-family/menu/index.ts
================================================
/**
 * @description font-size font-family menu entry
 * @author wangfupeng
 */

import FontSizeMenu from './FontSizeMenu'
import FontFamilyMenu from './FontFamilyMenu'
import { genFontSizeConfig, getFontFamilyConfig } from './config'

export const fontSizeMenuConf = {
  key: 'fontSize',
  factory() {
    return new FontSizeMenu()
  },

  // 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
  // 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
  config: {
    fontSizeList: genFontSizeConfig(),
  },
}

export const fontFamilyMenuConf = {
  key: 'fontFamily',
  factory() {
    return new FontFamilyMenu()
  },
  config: {
    fontFamilyList: getFontFamilyConfig(),
  },
}


================================================
FILE: packages/basic-modules/src/modules/font-size-family/parse-style-html.ts
================================================
/**
 * @description parse style html
 * @author wangfupeng
 */

import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { FontSizeAndFamilyText } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'

export function parseStyleHtml(text: DOMElement, node: Descendant, editor: IDomEditor): Descendant {
  const $text = $(text)
  if (!Text.isText(node)) return node

  const textNode = node as FontSizeAndFamilyText

  // -------- 处理 font-size --------
  const { fontSizeList = [] } = editor.getMenuConfig('fontSize')
  const fontSize = getStyleValue($text, 'font-size')

  const includesSize =
    fontSizeList.find(item => item.value && item.value === fontSize) ||
    fontSizeList.includes(fontSize)

  if (fontSize && includesSize) {
    textNode.fontSize = fontSize
  }

  // -------- 处理 font-family --------
  const { fontFamilyList = [] } = editor.getMenuConfig('fontFamily')
  // 这里需要替换掉 ", css 设置 font-family,会将有空格的字体使用 " 包裹
  const fontFamily = getStyleValue($text, 'font-family').replace(/"/g, '')

  // getFontFamilyConfig 配置支持对象形式
  const includesFamily =
    fontFamilyList.find(item => item.value && item.value === fontFamily) ||
    fontFamilyList.includes(fontFamily)

  if (fontFamily && includesFamily) {
    textNode.fontFamily = fontFamily
  }

  return textNode
}


================================================
FILE: packages/basic-modules/src/modules/font-size-family/pre-parse-html.ts
================================================
/**
 * @description pre-parse html
 * @author wangfupeng
 */

import $, { DOMElement, getTagName } from '../../utils/dom'

// V4 font-size 对应关系(V4 使用 <font size="1">xxx</font> 格式)
const FONT_SIZE_MAP_FOR_V4 = {
  '1': '12px',
  '2': '14px',
  '3': '16px',
  '4': '19px',
  '5': '24px',
  '6': '32px',
  '7': '48px',
}

/**
 * pre-prase font ,兼容 V4
 * @param fontElem fontElem
 */
function preParse(fontElem: DOMElement): DOMElement {
  const $font = $(fontElem)
  const tagName = getTagName($font)
  if (tagName !== 'font') return fontElem

  // 处理 size (V4 使用 <font size="1">xxx</font> 格式)
  const size = $font.attr('size') || ''
  if (size) {
    $font.removeAttr('size')
    $font.css('font-size', FONT_SIZE_MAP_FOR_V4[size])
  }

  // 处理 face (V4 使用 <font face="黑体">xx</font> 格式)
  const face = $font.attr('face') || ''
  if (face) {
    $font.removeAttr('face')
    $font.css('font-family', face)
  }

  return $font[0]
}

export const preParseHtmlConf = {
  selector: 'font',
  preParseHtml: preParse,
}


================================================
FILE: packages/basic-modules/src/modules/font-size-family/render-style.tsx
================================================
/**
 * @description render font-size font-family style
 * @author wangfupeng
 */

import { Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeStyle } from '../../utils/vdom'
import { FontSizeAndFamilyText } from './custom-types'

/**
 * 添加样式
 * @param node slate elem
 * @param vnode vnode
 * @returns vnode
 */
export function renderStyle(node: Descendant, vnode: VNode): VNode {
  const { fontSize, fontFamily } = node as FontSizeAndFamilyText
  let styleVnode: VNode = vnode

  if (fontSize) {
    addVnodeStyle(styleVnode, { fontSize })
  }
  if (fontFamily) {
    addVnodeStyle(styleVnode, { fontFamily })
  }

  return styleVnode
}


================================================
FILE: packages/basic-modules/src/modules/font-size-family/style-to-html.ts
================================================
/**
 * @description textStyle to html
 * @author wangfupeng
 */

import { Text, Descendant } from 'slate'
import $, { getOuterHTML, getTagName, isPlainText } from '../../utils/dom'
import { FontSizeAndFamilyText } from './custom-types'

/**
 * style to html
 * @param textNode slate text node
 * @param textHtml text html
 * @returns styled html
 */
export function styleToHtml(textNode: Descendant, textHtml: string): string {
  if (!Text.isText(textNode)) return textHtml

  const { fontSize, fontFamily } = textNode as FontSizeAndFamilyText
  if (!fontSize && !fontFamily) return textHtml

  let $text

  if (isPlainText(textHtml)) {
    // textHtml 是纯文本,不是 html tag
    $text = $(`<span>${textHtml}</span>`)
  } else {
    // textHtml 是 html tag
    $text = $(textHtml)
    const tagName = getTagName($text)
    if (tagName !== 'span') {
      // 如果不是 span ,则包裹一层,接下来要设置 css
      $text = $(`<span>${textHtml}</span>`)
    }
  }

  if (fontSize) $text.css('font-size', fontSize)
  if (fontFamily) $text.css('font-family', fontFamily)

  return getOuterHTML($text)
}


================================================
FILE: packages/basic-modules/src/modules/full-screen/index.ts
================================================
/**
 * @description 全屏
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { fullScreenConf } from './menu/index'

const fullScreen: Partial<IModuleConf> = {
  menus: [fullScreenConf],
}

export default fullScreen


================================================
FILE: packages/basic-modules/src/modules/full-screen/menu/FullScreen.ts
================================================
/**
 * @description redo menu
 * @author wangfupeng
 */

import { IButtonMenu, IDomEditor, t } from '@wangeditor/core'
import { FULL_SCREEN_SVG } from '../../../constants/icon-svg'

class FullScreen implements IButtonMenu {
  title = t('fullScreen.title')
  iconSvg = FULL_SCREEN_SVG
  tag = 'button'
  alwaysEnable = true

  getValue(editor: IDomEditor): string | boolean {
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    return editor.isFullScreen
  }

  isDisabled(editor: IDomEditor): boolean {
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    if (editor.isFullScreen) {
      editor.unFullScreen()
    } else {
      editor.fullScreen()
    }
  }
}

export default FullScreen


================================================
FILE: packages/basic-modules/src/modules/full-screen/menu/index.ts
================================================
/**
 * @description menu entry
 * @author wangfupeng
 */

import FullScreen from './FullScreen'

export const fullScreenConf = {
  key: 'fullScreen',
  factory() {
    return new FullScreen()
  },
}


================================================
FILE: packages/basic-modules/src/modules/header/custom-types.ts
================================================
/**
 * @description 自定义 element
 * @author wangfupeng
 */

import { Text } from 'slate'

//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts

export type Header1Element = {
  type: 'header1'
  children: Text[]
}

export type Header2Element = {
  type: 'header2'
  children: Text[]
}

export type Header3Element = {
  type: 'header3'
  children: Text[]
}

export type Header4Element = {
  type: 'header4'
  children: Text[]
}

export type Header5Element = {
  type: 'header5'
  children: Text[]
}


================================================
FILE: packages/basic-modules/src/modules/header/elem-to-html.ts
================================================
/**
 * @description to html
 * @author wangfupeng
 */

import { Element } from 'slate'

function genToHtmlFn(level: number) {
  function headerToHtml(elem: Element, childrenHtml: string): string {
    return `<h${level}>${childrenHtml}</h${level}>`
  }
  return headerToHtml
}

export const header1ToHtmlConf = {
  type: 'header1',
  elemToHtml: genToHtmlFn(1),
}

export const header2ToHtmlConf = {
  type: 'header2',
  elemToHtml: genToHtmlFn(2),
}

export const header3ToHtmlConf = {
  type: 'header3',
  elemToHtml: genToHtmlFn(3),
}

export const header4ToHtmlConf = {
  type: 'header4',
  elemToHtml: genToHtmlFn(4),
}

export const header5ToHtmlConf = {
  type: 'header5',
  elemToHtml: genToHtmlFn(5),
}


================================================
FILE: packages/basic-modules/src/modules/header/helper.ts
================================================
/**
 * @description header helper
 * @author wangfupeng
 */

import { Editor, Transforms } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'

/**
 * 获取 node type('header1' 'header2' 等),未匹配则返回 'paragraph'
 */
export function getHeaderType(editor: IDomEditor): string {
  const [match] = Editor.nodes(editor, {
    match: n => {
      const type = DomEditor.getNodeType(n)
      return type.startsWith('header') // 匹配 node.type 是 header 开头的 node
    },
    universal: true,
  })

  // 未匹配到 header
  if (match == null) return 'paragraph'

  // 匹配到 header
  const [n] = match

  return DomEditor.getNodeType(n)
}

export function isMenuDisabled(editor: IDomEditor): boolean {
  if (editor.selection == null) return true

  const [nodeEntry] = Editor.nodes(editor, {
    match: n => {
      const type = DomEditor.getNodeType(n)

      // 只可用于 p 和 header
      if (type === 'paragraph') return true
      if (type.startsWith('header')) return true

      return false
    },
    universal: true,
    mode: 'highest', // 匹配最高层级
  })

  // 匹配到 p header ,不禁用
  if (nodeEntry) {
    return false
  }
  // 未匹配到 p header ,则禁用
  return true
}

/**
 * 设置 node type ('header1' 'header2' 'paragraph' 等)
 */
export function setHeaderType(editor: IDomEditor, type: string) {
  if (!type) return

  // 执行命令
  Transforms.setNodes(editor, {
    type: type,
  })
}


================================================
FILE: packages/basic-modules/src/modules/header/index.ts
================================================
/**
 * @description header entry
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import {
  renderHeader1Conf,
  renderHeader2Conf,
  renderHeader3Conf,
  renderHeader4Conf,
  renderHeader5Conf,
} from './render-elem'
import {
  HeaderSelectMenuConf,
  Header1ButtonMenuConf,
  Header2ButtonMenuConf,
  Header3ButtonMenuConf,
  Header4ButtonMenuConf,
  Header5ButtonMenuConf,
} from './menu/index'
import {
  header1ToHtmlConf,
  header2ToHtmlConf,
  header3ToHtmlConf,
  header4ToHtmlConf,
  header5ToHtmlConf,
} from './elem-to-html'
import {
  parseHeader1HtmlConf,
  parseHeader2HtmlConf,
  parseHeader3HtmlConf,
  parseHeader4HtmlConf,
  parseHeader5HtmlConf,
} from './parse-elem-html'
import withHeader from './plugin'

const header: Partial<IModuleConf> = {
  renderElems: [
    renderHeader1Conf,
    renderHeader2Conf,
    renderHeader3Conf,
    renderHeader4Conf,
    renderHeader5Conf,
  ],
  elemsToHtml: [
    header1ToHtmlConf,
    header2ToHtmlConf,
    header3ToHtmlConf,
    header4ToHtmlConf,
    header5ToHtmlConf,
  ],
  parseElemsHtml: [
    parseHeader1HtmlConf,
    parseHeader2HtmlConf,
    parseHeader3HtmlConf,
    parseHeader4HtmlConf,
    parseHeader5HtmlConf,
  ],
  menus: [
    HeaderSelectMenuConf,
    Header1ButtonMenuConf,
    Header2ButtonMenuConf,
    Header3ButtonMenuConf,
    Header4ButtonMenuConf,
    Header5ButtonMenuConf,
  ],
  editorPlugin: withHeader,
}

export default header


================================================
FILE: packages/basic-modules/src/modules/header/menu/Header1ButtonMenu.ts
================================================
/**
 * @description header1 button menu
 * @author wangfupeng
 */

import HeaderButtonMenuBase from './HeaderButtonMenuBase'

class Header1ButtonMenu extends HeaderButtonMenuBase {
  title = 'H1'
  type = 'header1'
}

export default Header1ButtonMenu


================================================
FILE: packages/basic-modules/src/modules/header/menu/Header2ButtonMenu.ts
================================================
/**
 * @description header2 button menu
 * @author wangfupeng
 */

import HeaderButtonMenuBase from './HeaderButtonMenuBase'

class Header2ButtonMenu extends HeaderButtonMenuBase {
  title = 'H2'
  type = 'header2'
}

export default Header2ButtonMenu


================================================
FILE: packages/basic-modules/src/modules/header/menu/Header3ButtonMenu.ts
================================================
/**
 * @description header3 button menu
 * @author wangfupeng
 */

import HeaderButtonMenuBase from './HeaderButtonMenuBase'

class Header3ButtonMenu extends HeaderButtonMenuBase {
  title = 'H3'
  type = 'header3'
}

export default Header3ButtonMenu


================================================
FILE: packages/basic-modules/src/modules/header/menu/Header4ButtonMenu.ts
================================================
/**
 * @description header4 button menu
 * @author wangfupeng
 */

import HeaderButtonMenuBase from './HeaderButtonMenuBase'

class Header4ButtonMenu extends HeaderButtonMenuBase {
  title = 'H4'
  type = 'header4'
}

export default Header4ButtonMenu


================================================
FILE: packages/basic-modules/src/modules/header/menu/Header5ButtonMenu.ts
================================================
/**
 * @description header5 button menu
 * @author wangfupeng
 */

import HeaderButtonMenuBase from './HeaderButtonMenuBase'

class Header5ButtonMenu extends HeaderButtonMenuBase {
  title = 'H5'
  type = 'header5'
}

export default Header5ButtonMenu


================================================
FILE: packages/basic-modules/src/modules/header/menu/HeaderButtonMenuBase.ts
================================================
/**
 * @description button menu base
 * @author wangfupeng
 */

import { IButtonMenu, IDomEditor } from '@wangeditor/core'
import { getHeaderType, isMenuDisabled, setHeaderType } from '../helper'

abstract class HeaderButtonMenuBase implements IButtonMenu {
  abstract readonly title: string
  abstract readonly type: string // 'header1' 'header2' 等
  readonly tag = 'button'

  /**
   * 获取选中节点的 node.type
   * @param editor editor
   */
  getValue(editor: IDomEditor): string | boolean {
    return getHeaderType(editor)
  }

  isActive(editor: IDomEditor): boolean {
    return this.getValue(editor) === this.type
  }

  isDisabled(editor: IDomEditor): boolean {
    return isMenuDisabled(editor)
  }

  exec(editor: IDomEditor, value: string | boolean) {
    const { type } = this
    let newType
    if (value === type) {
      // 选中的 node.type 和当前 type 一样,则取消
      newType = 'paragraph'
    } else {
      // 否则,则设置
      newType = type
    }

    setHeaderType(editor, newType)
  }
}

export default HeaderButtonMenuBase


================================================
FILE: packages/basic-modules/src/modules/header/menu/HeaderSelectMenu.ts
================================================
/**
 * @description header menu
 * @author wangfupeng
 */

import { ISelectMenu, IDomEditor, IOption, t } from '@wangeditor/core'
import { HEADER_SVG } from '../../../constants/icon-svg'
import { getHeaderType, isMenuDisabled, setHeaderType } from '../helper'

class HeaderSelectMenu implements ISelectMenu {
  readonly title = t('header.title')
  readonly iconSvg = HEADER_SVG
  readonly tag = 'select'
  readonly width = 60

  getOptions(editor: IDomEditor): IOption[] {
    // 基本的 options 列表
    const options = [
      // value 和 elemNode.type 对应
      {
        value: 'header1',
        text: 'H1',
        styleForRenderMenuList: { 'font-size': '32px', 'font-weight': 'bold' },
      },
      {
        value: 'header2',
        text: 'H2',
        styleForRenderMenuList: { 'font-size': '24px', 'font-weight': 'bold' },
      },
      {
        value: 'header3',
        text: 'H3',
        styleForRenderMenuList: { 'font-size': '18px', 'font-weight': 'bold' },
      },
      {
        value: 'header4',
        text: 'H4',
        styleForRenderMenuList: { 'font-size': '16px', 'font-weight': 'bold' },
      },
      {
        value: 'header5',
        text: 'H5',
        styleForRenderMenuList: { 'font-size': '13px', 'font-weight': 'bold' },
      },
      { value: 'paragraph', text: t('header.text') },
    ]

    // 获取 value ,设置 selected
    const curValue = this.getValue(editor).toString()
    options.forEach((opt: IOption) => {
      if (opt.value === curValue) {
        opt.selected = true
      } else {
        delete opt.selected
      }
    })

    return options
  }

  isActive(editor: IDomEditor): boolean {
    // select menu 会显示 selected value ,用不到 active
    return false
  }

  /**
   * 获取选中节点的 node.type
   * @param editor editor
   */
  getValue(editor: IDomEditor): string | boolean {
    return getHeaderType(editor)
  }

  isDisabled(editor: IDomEditor): boolean {
    return isMenuDisabled(editor)
  }

  /**
   * 执行命令
   * @param editor editor
   * @param value node.type
   */
  exec(editor: IDomEditor, value: string | boolean) {
    //【注意】value 是 select change 时获取的,并不是 this.getValue 的值
    setHeaderType(editor, value.toString())
  }
}

export default HeaderSelectMenu


================================================
FILE: packages/basic-modules/src/modules/header/menu/index.ts
================================================
/**
 * @description menu entry
 * @author wangfupeng
 */

import HeaderSelectMenu from './HeaderSelectMenu'
import Header1ButtonMenu from './Header1ButtonMenu'
import Header2ButtonMenu from './Header2ButtonMenu'
import Header3ButtonMenu from './Header3ButtonMenu'
import Header4ButtonMenu from './Header4ButtonMenu'
import Header5ButtonMenu from './Header5ButtonMenu'

export const HeaderSelectMenuConf = {
  key: 'headerSelect',
  factory() {
    return new HeaderSelectMenu()
  },
}

export const Header1ButtonMenuConf = {
  key: 'header1',
  factory() {
    return new Header1ButtonMenu()
  },
}

export const Header2ButtonMenuConf = {
  key: 'header2',
  factory() {
    return new Header2ButtonMenu()
  },
}

export const Header3ButtonMenuConf = {
  key: 'header3',
  factory() {
    return new Header3ButtonMenu()
  },
}

export const Header4ButtonMenuConf = {
  key: 'header4',
  factory() {
    return new Header4ButtonMenu()
  },
}

export const Header5ButtonMenuConf = {
  key: 'header5',
  factory() {
    return new Header5ButtonMenu()
  },
}


================================================
FILE: packages/basic-modules/src/modules/header/parse-elem-html.ts
================================================
/**
 * @description parse html
 * @author wangfupeng
 */

import { Descendant, Text } from 'slate'
import $, { DOMElement } from '../../utils/dom'
import { IDomEditor } from '@wangeditor/core'
import {
  Header1Element,
  Header2Element,
  Header3Element,
  Header4Element,
  Header5Element,
} from './custom-types'

function genParser<T>(level: number) {
  function parseHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): T {
    const $elem = $(elem)
    children = children.filter(child => {
      if (Text.isText(child)) return true
      if (editor.isInline(child)) return true
      return false
    })

    // 无 children ,则用纯文本
    if (children.length === 0) {
      children = [{ text: $elem.text().replace(/\s+/gm, ' ') }]
    }

    const headerNode = {
      type: `header${level}`,
      children,
    } as unknown as T

    return headerNode
  }
  return parseHtml
}

export const parseHeader1HtmlConf = {
  selector: 'h1:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
  parseElemHtml: genParser<Header1Element>(1),
}

export const parseHeader2HtmlConf = {
  selector: 'h2:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
  parseElemHtml: genParser<Header2Element>(2),
}

export const parseHeader3HtmlConf = {
  selector: 'h3:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
  parseElemHtml: genParser<Header3Element>(3),
}

export const parseHeader4HtmlConf = {
  selector: 'h4:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
  parseElemHtml: genParser<Header4Element>(4),
}

export const parseHeader5HtmlConf = {
  selector: 'h5:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
  parseElemHtml: genParser<Header5Element>(5),
}


================================================
FILE: packages/basic-modules/src/modules/header/plugin.ts
================================================
/**
 * @description editor 插件,重写 editor API
 * @author wangfupeng
 */

import { Editor, Transforms } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'

function withHeader<T extends IDomEditor>(editor: T): T {
  const { insertBreak, insertNode } = editor
  const newEditor = editor

  // 重写 insertBreak - header 末尾回车时要插入 paragraph
  newEditor.insertBreak = () => {
    const [match] = Editor.nodes(newEditor, {
      match: n => {
        const type = DomEditor.getNodeType(n)
        return type.startsWith('header') // 匹配 node.type 是 header 开头的 node
      },
      universal: true,
    })

    if (!match) {
      // 未匹配到
      insertBreak()
      return
    }

    const isAtLineEnd = DomEditor.isSelectionAtLineEnd(editor, match[1])

    // 如果在行末插入一个空 p,否则正常换行
    if (isAtLineEnd) {
      const p = { type: 'paragraph', children: [{ text: '' }] }
      Transforms.insertNodes(newEditor, p, { mode: 'highest' })
    } else {
      insertBreak()
    }
  }

  // 返回 editor ,重要!
  return newEditor
}

export default withHeader


================================================
FILE: packages/basic-modules/src/modules/header/render-elem.tsx
================================================
/**
 * @description render header
 * @author wangfupeng
 */

import { Element as SlateElement } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor } from '@wangeditor/core'

function genRenderElem(level: number) {
  /**
   * render header elem
   * @param elemNode slate elem
   * @param children children
   * @param editor editor
   * @returns vnode
   */
  function renderHeader(
    elemNode: SlateElement,
    children: VNode[] | null,
    editor: IDomEditor
  ): VNode {
    const Tag = `h${level}`
    const vnode = <Tag>{children}</Tag>
    return vnode
  }

  return renderHeader
}

const renderHeader1Conf = {
  type: 'header1', // 和 elemNode.type 一致
  renderElem: genRenderElem(1),
}
const renderHeader2Conf = {
  type: 'header2',
  renderElem: genRenderElem(2),
}
const renderHeader3Conf = {
  type: 'header3',
  renderElem: genRenderElem(3),
}
const renderHeader4Conf = {
  type: 'header4',
  renderElem: genRenderElem(4),
}
const renderHeader5Conf = {
  type: 'header5',
  renderElem: genRenderElem(5),
}

export {
  renderHeader1Conf,
  renderHeader2Conf,
  renderHeader3Conf,
  renderHeader4Conf,
  renderHeader5Conf,
}


================================================
FILE: packages/basic-modules/src/modules/image/custom-types.ts
================================================
/**
 * @description image element
 * @author wangfupeng
 */

//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts

type EmptyText = {
  text: ''
}

export type ImageStyle = {
  width?: string
  height?: string
}

export type ImageElement = {
  type: 'image'
  src: string
  alt?: string
  href?: string
  style?: ImageStyle
  children: EmptyText[]
}


================================================
FILE: packages/basic-modules/src/modules/image/elem-to-html.ts
================================================
/**
 * @description to html
 * @author wangfupeng
 */

import { Element } from 'slate'
import { ImageElement } from './custom-types'

function imageToHtml(elemNode: Element, childrenHtml: string): string {
  const { src, alt = '', href = '', style = {} } = elemNode as ImageElement
  const { width = '', height = '' } = style

  let styleStr = ''
  if (width) styleStr += `width: ${width};`
  if (height) styleStr += `height: ${height};`
  return `<img src="${src}" alt="${alt}" data-href="${href}" style="${styleStr}"/>`
}

export const imageToHtmlConf = {
  type: 'image',
  elemToHtml: imageToHtml,
}


================================================
FILE: packages/basic-modules/src/modules/image/helper.ts
================================================
/**
 * @description image menu helper
 * @author wangfupeng
 */

import { Transforms, Range, Editor } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'
import { ImageElement, ImageStyle } from './custom-types'
import { replaceSymbols } from '../../utils/util'

async function check(
  menuKey: string,
  editor: IDomEditor,
  src: string,
  alt: string = '',
  href: string = ''
): Promise<boolean> {
  const { checkImage } = editor.getMenuConfig(menuKey)
  if (checkImage) {
    const res = await checkImage(src, alt, href)
    if (typeof res === 'string') {
      // 检验未通过,提示信息
      editor.alert(res, 'error')
      return false
    }
    if (res == null) {
      // 检验未通过,不提示信息
      return false
    }
  }

  return true
}

async function parseSrc(menuKey: string, editor: IDomEditor, src: string): Promise<string> {
  const { parseImageSrc } = editor.getMenuConfig(menuKey)
  if (parseImageSrc) {
    const newSrc = await parseImageSrc(src)
    return newSrc
  }
  return src
}

export async function insertImageNode(
  editor: IDomEditor,
  src: string,
  alt: string = '',
  href: string = ''
) {
  const res = await check('insertImage', editor, src, alt, href)
  if (!res) return // 检查失败,终止操作

  const parsedSrc = await parseSrc('insertImage', editor, src)

  // 新建一个 image node
  const image: ImageElement = {
    type: 'image',
    src: replaceSymbols(parsedSrc),
    href,
    alt,
    style: {},
    children: [{ text: '' }], // 【注意】void node 需要一个空 text 作为 children
  }

  // 如果 blur ,则恢复选区
  if (editor.selection === null) editor.restoreSelection()

  // 如果当前正好选中了图片,则 move 一下(如:连续上传多张图片时)
  if (DomEditor.getSelectedNodeByType(editor, 'image')) {
    editor.move(1)
  }

  if (isInsertImageMenuDisabled(editor)) return

  // 插入图片
  Transforms.insertNodes(editor, image)

  // 回调
  const { onInsertedImage } = editor.getMenuConfig('insertImage')
  if (onInsertedImage) onInsertedImage(image)
}

export async function updateImageNode(
  editor: IDomEditor,
  src: string,
  alt: string = '',
  href: string = '',
  style: ImageStyle = {}
) {
  const res = await check('editImage', editor, src, alt, href)
  if (!res) return // 检查失败,终止操作

  const parsedSrc = await parseSrc('editImage', editor, src)

  const selectedImageNode = DomEditor.getSelectedNodeByType(editor, 'image')
  if (selectedImageNode == null) return
  const { style: curStyle = {} } = selectedImageNode as ImageElement

  // 修改图片
  const nodeProps: Partial<ImageElement> = {
    src: parsedSrc,
    alt,
    href,
    style: {
      ...curStyle,
      ...style,
    },
  }
  Transforms.setNodes(editor, nodeProps, {
    match: n => DomEditor.checkNodeType(n, 'image'),
  })

  // 回调
  const imageNode = DomEditor.getSelectedNodeByType(editor, 'image')
  const { onUpdatedImage } = editor.getMenuConfig('editImage')
  if (onUpdatedImage) onUpdatedImage(imageNode)
}

/**
 * 判断菜单是否要 disabled
 * @param editor editor
 */
export function isInsertImageMenuDisabled(editor: IDomEditor): boolean {
  const { selection } = editor
  if (selection == null) return true
  if (!Range.isCollapsed(selection)) return true // 选区非折叠,禁用

  const [match] = Editor.nodes(editor, {
    match: n => {
      const type = DomEditor.getNodeType(n)

      if (type === 'code') return true // 代码块
      if (type === 'pre') return true // 代码块
      if (type === 'link') return true // 链接
      if (type === 'list-item') return true // list
      if (type.startsWith('header')) return true // 标题
      if (type === 'blockquote') return true // 引用
      if (Editor.isVoid(editor, n)) return true // void

      return false
    },
    universal: true,
  })

  if (match) return true
  return false
}


================================================
FILE: packages/basic-modules/src/modules/image/index.ts
================================================
/**
 * @description image module entry
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import withImage from './plugin'
import { renderImageConf } from './render-elem'
import { imageToHtmlConf } from './elem-to-html'
import { parseHtmlConf } from './parse-elem-html'
import {
  insertImageMenuConf,
  deleteImageMenuConf,
  editImageMenuConf,
  viewImageLinkMenuConf,
  imageWidth30MenuConf,
  imageWidth50MenuConf,
  imageWidth100MenuConf,
} from './menu/index'

const image: Partial<IModuleConf> = {
  renderElems: [renderImageConf],
  elemsToHtml: [imageToHtmlConf],
  parseElemsHtml: [parseHtmlConf],
  menus: [
    insertImageMenuConf,
    deleteImageMenuConf,
    editImageMenuConf,
    viewImageLinkMenuConf,
    imageWidth30MenuConf,
    imageWidth50MenuConf,
    imageWidth100MenuConf,
  ],
  editorPlugin: withImage,
}

export default image


================================================
FILE: packages/basic-modules/src/modules/image/menu/DeleteImage.ts
================================================
/**
 * @description delete image menu
 * @author wangfupeng
 */

import { Transforms } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { TRASH_SVG } from '../../../constants/icon-svg'

class DeleteImage implements IButtonMenu {
  readonly title = t('image.delete')
  readonly iconSvg = TRASH_SVG
  readonly tag = 'button'

  getValue(editor: IDomEditor): string | boolean {
    // 无需获取 val
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 无需 active
    return false
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const imageNode = DomEditor.getSelectedNodeByType(editor, 'image')
    if (imageNode == null) {
      // 选区未处于 image node ,则禁用
      return true
    }
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    if (this.isDisabled(editor)) return

    // 删除图片
    Transforms.removeNodes(editor, {
      match: n => DomEditor.checkNodeType(n, 'image'),
    })
  }
}

export default DeleteImage


================================================
FILE: packages/basic-modules/src/modules/image/menu/EditImage.ts
================================================
/**
 * @description editor image menu
 * @author wangfupeng
 */

import { Node, Range } from 'slate'
import {
  IModalMenu,
  IDomEditor,
  DomEditor,
  genModalInputElems,
  genModalButtonElems,
  t,
} from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { genRandomStr } from '../../../utils/util'
import { PENCIL_SVG } from '../../../constants/icon-svg'
import { updateImageNode } from '../helper'
import { ImageElement, ImageStyle } from '../custom-types'

/**
 * 生成唯一的 DOM ID
 */
function genDomID(): string {
  return genRandomStr('w-e-edit-image')
}

class EditImage implements IModalMenu {
  readonly title = t('image.edit')
  readonly iconSvg = PENCIL_SVG
  readonly tag = 'button'
  readonly showModal = true // 点击 button 时显示 modal
  readonly modalWidth = 300
  private $content: Dom7Array | null = null
  private readonly srcInputId = genDomID()
  private readonly altInputId = genDomID()
  private readonly hrefInputId = genDomID()
  private readonly buttonId = genDomID()

  getValue(editor: IDomEditor): string | boolean {
    // 编辑图片,用不到 getValue
    return ''
  }

  private getImageNode(editor: IDomEditor): Node | null {
    return DomEditor.getSelectedNodeByType(editor, 'image')
  }

  isActive(editor: IDomEditor): boolean {
    // 无需 active
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    // 点击菜单时,弹出 modal 之前,不需要执行其他代码
    // 此处空着即可
  }

  isDisabled(editor: IDomEditor): boolean {
    const { selection } = editor
    if (selection == null) return true
    if (!Range.isCollapsed(selection)) return true // 选区非折叠,禁用

    const imageNode = DomEditor.getSelectedNodeByType(editor, 'image')

    // 未匹配到 image node 则禁用
    if (imageNode == null) return true
    return false
  }

  getModalPositionNode(editor: IDomEditor): Node | null {
    return this.getImageNode(editor)
  }

  getModalContentElem(editor: IDomEditor): DOMElement {
    const { srcInputId, altInputId, hrefInputId, buttonId } = this

    const selectedImageNode = this.getImageNode(editor)
    if (selectedImageNode == null) {
      throw new Error('Not found selected image node')
    }

    // 获取 input button elem
    const [srcContainerElem, inputSrcElem] = genModalInputElems(t('image.src'), srcInputId)
    const $inputSrc = $(inputSrcElem)
    const [altContainerElem, inputAltElem] = genModalInputElems(t('image.desc'), altInputId)
    const $inputAlt = $(inputAltElem)
    const [hrefContainerElem, inputHrefElem] = genModalInputElems(t('image.link'), hrefInputId)
    const $inputHref = $(inputHrefElem)
    const [buttonContainerElem] = genModalButtonElems(buttonId, t('common.ok'))

    if (this.$content == null) {
      // 第一次渲染
      const $content = $('<div></div>')

      // 绑定事件(第一次渲染时绑定,不要重复绑定)
      $content.on('click', `#${buttonId}`, e => {
        e.preventDefault()

        const src = $content.find(`#${srcInputId}`).val()
        const alt = $content.find(`#${altInputId}`).val()
        const href = $content.find(`#${hrefInputId}`).val()
        this.updateImage(editor, src, alt, href)
        editor.hidePanelOrModal() // 隐藏 modal
      })

      // 记录属性,重要
      this.$content = $content
    }

    const $content = this.$content
    $content.empty() // 先清空内容

    // append inputs and button
    $content.append(srcContainerElem)
    $content.append(altContainerElem)
    $content.append(hrefContainerElem)
    $content.append(buttonContainerElem)

    // 设置 input val
    const { src, alt = '', href = '' } = selectedImageNode as ImageElement
    $inputSrc.val(src)
    $inputAlt.val(alt)
    $inputHref.val(href)

    // focus 一个 input(异步,此时 DOM 尚未渲染)
    setTimeout(() => {
      $inputSrc.focus()
    })

    return $content[0]
  }

  private updateImage(
    editor: IDomEditor,
    src: string,
    alt: string = '',
    href: string = '',
    style: ImageStyle = {}
  ) {
    if (!src) return

    // 还原选区
    editor.restoreSelection()

    if (this.isDisabled(editor)) return

    // 修改图片信息
    updateImageNode(editor, src, alt, href, style)
  }
}

export default EditImage


================================================
FILE: packages/basic-modules/src/modules/image/menu/InsertImage.ts
================================================
/**
 * @description insert image menu
 * @author wangfupeng
 */

import { Node } from 'slate'
import {
  IModalMenu,
  IDomEditor,
  genModalInputElems,
  genModalButtonElems,
  t,
} from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { genRandomStr } from '../../../utils/util'
import { IMAGE_SVG } from '../../../constants/icon-svg'
import { insertImageNode, isInsertImageMenuDisabled } from '../helper'

/**
 * 生成唯一的 DOM ID
 */
function genDomID(): string {
  return genRandomStr('w-e-insert-image')
}

class InsertImage implements IModalMenu {
  readonly title = t('image.netImage')
  readonly iconSvg = IMAGE_SVG
  readonly tag = 'button'
  readonly showModal = true // 点击 button 时显示 modal
  readonly modalWidth = 300
  private $content: Dom7Array | null = null
  private readonly srcInputId = genDomID()
  private readonly altInputId = genDomID()
  private readonly hrefInputId = genDomID()
  private readonly buttonId = genDomID()

  getValue(editor: IDomEditor): string | boolean {
    // 插入菜单,不需要 value
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 任何时候,都不用激活 menu
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    // 点击菜单时,弹出 modal 之前,不需要执行其他代码
    // 此处空着即可
  }

  isDisabled(editor: IDomEditor): boolean {
    return isInsertImageMenuDisabled(editor)
  }

  getModalPositionNode(editor: IDomEditor): Node | null {
    return null // modal 依据选区定位
  }

  getModalContentElem(editor: IDomEditor): DOMElement {
    const { srcInputId, altInputId, hrefInputId, buttonId } = this

    // 获取 input button elem
    const [srcContainerElem, inputSrcElem] = genModalInputElems(t('image.src'), srcInputId)
    const $inputSrc = $(inputSrcElem)
    const [altContainerElem, inputAltElem] = genModalInputElems(t('image.desc'), altInputId)
    const $inputAlt = $(inputAltElem)
    const [hrefContainerElem, inputHrefElem] = genModalInputElems(t('image.link'), hrefInputId)
    const $inputHref = $(inputHrefElem)
    const [buttonContainerElem] = genModalButtonElems(buttonId, t('common.ok'))

    if (this.$content == null) {
      // 第一次渲染
      const $content = $('<div></div>')

      // 绑定事件(第一次渲染时绑定,不要重复绑定)
      $content.on('click', `#${buttonId}`, e => {
        e.preventDefault()
        const src = $content.find(`#${srcInputId}`).val().trim()
        const alt = $content.find(`#${altInputId}`).val().trim()
        const href = $content.find(`#${hrefInputId}`).val().trim()
        this.insertImage(editor, src, alt, href)
        editor.hidePanelOrModal() // 隐藏 modal
      })

      // 记录属性,重要
      this.$content = $content
    }

    const $content = this.$content
    $content.empty() // 先清空内容

    // append inputs and button
    $content.append(srcContainerElem)
    $content.append(altContainerElem)
    $content.append(hrefContainerElem)
    $content.append(buttonContainerElem)

    // 设置 input val
    $inputSrc.val('')
    $inputAlt.val('')
    $inputHref.val('')

    // focus 一个 input(异步,此时 DOM 尚未渲染)
    setTimeout(() => {
      $inputSrc.focus()
    })

    return $content[0]
  }

  private insertImage(editor: IDomEditor, src: string, alt: string = '', href: string = '') {
    if (!src) return

    // 还原选区
    editor.restoreSelection()

    if (this.isDisabled(editor)) return

    // 插入图片
    insertImageNode(editor, src, alt, href)
  }
}

export default InsertImage


================================================
FILE: packages/basic-modules/src/modules/image/menu/ViewImageLink.ts
================================================
/**
 * @description view image link menu
 * @author wangfupeng
 */

import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { EXTERNAL_SVG } from '../../../constants/icon-svg'
import { ImageElement } from '../custom-types'

class ViewImageLink implements IButtonMenu {
  readonly title = t('image.viewLink')
  readonly iconSvg = EXTERNAL_SVG
  readonly tag = 'button'

  getValue(editor: IDomEditor): string | boolean {
    const imageNode = DomEditor.getSelectedNodeByType(editor, 'image')
    if (imageNode) {
      // 选区处于 image node
      return (imageNode as ImageElement).href || ''
    }
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 无需 active
    return false
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const href = this.getValue(editor)
    if (href) {
      // 有 image href ,则不禁用
      return false
    }
    return true
  }

  exec(editor: IDomEditor, value: string | boolean) {
    if (this.isDisabled(editor)) return

    if (!value || typeof value !== 'string') {
      throw new Error(`View image link failed, image.href is '${value}'`)
      return
    }

    // 查看链接
    window.open(value, '_blank')
  }
}

export default ViewImageLink


================================================
FILE: packages/basic-modules/src/modules/image/menu/Width100.ts
================================================
/**
 * @description image width 100%
 * @author wangfupeng
 */

import ImageWidthBaseClass from './WidthBase'

class ImageWidth100 extends ImageWidthBaseClass {
  readonly title = '100%' // 菜单标题
  readonly value = '100%' // css width 的值
}

export default ImageWidth100


================================================
FILE: packages/basic-modules/src/modules/image/menu/Width30.ts
================================================
/**
 * @description image width 30%
 * @author wangfupeng
 */

import ImageWidthBaseClass from './WidthBase'

class ImageWidth30 extends ImageWidthBaseClass {
  readonly title = '30%' // 菜单标题
  readonly value = '30%' // css width 的值
}

export default ImageWidth30


================================================
FILE: packages/basic-modules/src/modules/image/menu/Width50.ts
================================================
/**
 * @description image width 50%
 * @author wangfupeng
 */

import ImageWidthBaseClass from './WidthBase'

class ImageWidth50 extends ImageWidthBaseClass {
  readonly title = '50%' // 菜单标题
  readonly value = '50%' // css width 的值
}

export default ImageWidth50


================================================
FILE: packages/basic-modules/src/modules/image/menu/WidthBase.ts
================================================
/**
 * @description image width base class
 * @author wangfupeng
 */

import { Transforms, Node } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor } from '@wangeditor/core'
import { ImageElement } from '../custom-types'

abstract class ImageWidthBaseClass implements IButtonMenu {
  abstract readonly title: string // 菜单标题
  readonly tag = 'button'
  abstract readonly value: string // css width 的值

  getValue(editor: IDomEditor): string | boolean {
    // 无需获取 val
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 无需 active
    return false
  }

  private getSelectedNode(editor: IDomEditor): Node | null {
    return DomEditor.getSelectedNodeByType(editor, 'image')
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const imageNode = this.getSelectedNode(editor)
    if (imageNode == null) {
      // 选区未处于 image node ,则禁用
      return true
    }
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    if (this.isDisabled(editor)) return

    const imageNode = this.getSelectedNode(editor)
    if (imageNode == null) return

    // 隐藏 hoverbar
    const hoverbar = DomEditor.getHoverbar(editor)
    if (hoverbar) hoverbar.hideAndClean()

    const { style = {} } = imageNode as ImageElement
    const props: Partial<ImageElement> = {
      style: {
        ...style,
        width: this.value, // 修改 width
        height: '', // 清空 height
      },
    }

    Transforms.setNodes(editor, props, {
      match: n => DomEditor.checkNodeType(n, 'image'),
    })
  }
}

export default ImageWidthBaseClass


================================================
FILE: packages/basic-modules/src/modules/image/menu/config.ts
================================================
/**
 * @description 图片菜单配置
 * @author wangfupeng
 */

import { ImageElement } from '../custom-types'

export function genImageMenuConfig() {
  return {
    /**
     * 插入图片之后的回调
     * @param imageElem ImageElement
     */
    onInsertedImage(imageElem: ImageElement) {
      /*自定义*/
    },

    /**
     * 更新图片之后的回调
     * @param node image node
     */
    onUpdatedImage(node: ImageElement | null) {
      /*自定义*/
    },

    /**
     * 检查图片信息,支持 async fn
     * @param src image src
     * @param alt image alt
     * @param href image href
     */
    checkImage(src: string, alt: string, href: string): boolean | string | undefined {
      // 1. 返回 true ,说明检查通过
      // 2. 返回一个字符串,说明检查未通过,编辑器会阻止图片插入。会 alert 出错误信息(即返回的字符串)
      // 3. 返回 undefined(即没有任何返回),说明检查未通过,编辑器会阻止图片插入
      return true
    },

    /**
     * parse image src
     * @param src image src
     * @returns new src
     */
    parseImageSrc(src: string): string {
      return src
    },
  }
}


================================================
FILE: packages/basic-modules/src/modules/image/menu/index.ts
================================================
/**
 * @description image menu entry
 * @author wangfupeng
 */

import InsertImage from './InsertImage'
import DeleteImage from './DeleteImage'
import EditImage from './EditImage'
import ViewImageLink from './ViewImageLink'
import ImageWidth30 from './Width30'
import ImageWidth50 from './Width50'
import ImageWidth100 from './Width100'
import { genImageMenuConfig } from './config'

const config = genImageMenuConfig() // menu config

export const insertImageMenuConf = {
  key: 'insertImage',
  factory() {
    return new InsertImage()
  },

  // 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
  // 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
  config,
}

export const deleteImageMenuConf = {
  key: 'deleteImage',
  factory() {
    return new DeleteImage()
  },
}

export const editImageMenuConf = {
  key: 'editImage',
  factory() {
    return new EditImage()
  },
  config,
}

export const viewImageLinkMenuConf = {
  key: 'viewImageLink',
  factory() {
    return new ViewImageLink()
  },
}

export const imageWidth30MenuConf = {
  key: 'imageWidth30',
  factory() {
    return new ImageWidth30()
  },
}

export const imageWidth50MenuConf = {
  key: 'imageWidth50',
  factory() {
    return new ImageWidth50()
  },
}

export const imageWidth100MenuConf = {
  key: 'imageWidth100',
  factory() {
    return new ImageWidth100()
  },
}


================================================
FILE: packages/basic-modules/src/modules/image/parse-elem-html.ts
================================================
/**
 * @description parse html
 * @author wangfupeng
 */

import { Descendant } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { ImageElement } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'

function parseHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): ImageElement {
  const $elem = $(elem)
  let href = $elem.attr('data-href') || ''
  href = decodeURIComponent(href) // 兼容 V4

  return {
    type: 'image',
    src: $elem.attr('src') || '',
    alt: $elem.attr('alt') || '',
    href,
    style: {
      width: getStyleValue($elem, 'width'),
      height: getStyleValue($elem, 'height'),
    },
    children: [{ text: '' }], // void node 有一个空白 text
  }
}

export const parseHtmlConf = {
  selector: 'img:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
  parseElemHtml: parseHtml,
}


================================================
FILE: packages/basic-modules/src/modules/image/plugin.ts
================================================
/**
 * @description editor 插件,重写 editor API
 * @author wangfupeng
 */

// import { Editor, Path, Operation } from 'slate'
import { IDomEditor } from '@wangeditor/core'

function withImage<T extends IDomEditor>(editor: T): T {
  const { isInline, isVoid, insertNode } = editor
  const newEditor = editor

  // 重写 isInline
  newEditor.isInline = elem => {
    const { type } = elem

    if (type === 'image') {
      return true
    }

    return isInline(elem)
  }

  // 重写 isVoid
  newEditor.isVoid = elem => {
    const { type } = elem

    if (type === 'image') {
      return true
    }

    return isVoid(elem)
  }

  // 返回 editor ,重要!
  return newEditor
}

export default withImage


================================================
FILE: packages/basic-modules/src/modules/image/render-elem.tsx
================================================
/**
 * @description image render elem
 * @author wangfupeng
 */

import throttle from 'lodash.throttle'
import { Element as SlateElement, Transforms } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor, DomEditor } from '@wangeditor/core'
import $, { Dom7Array } from '../../utils/dom'
import { ImageElement } from './custom-types'

interface IImageSize {
  width?: string
  height?: string
}

function genContainerId(editor: IDomEditor, elemNode: SlateElement) {
  const { id } = DomEditor.findKey(editor, elemNode) // node 唯一 id
  return `w-e-image-container-${id}`
}

/**
 * 未选中时,渲染 image container
 */
function renderContainer(
  editor: IDomEditor,
  elemNode: SlateElement,
  imageVnode: VNode,
  imageInfo: IImageSize
): VNode {
  const { width, height } = imageInfo

  const style: any = {}
  if (width) style.width = width
  if (height) style.height = height

  const containerId = genContainerId(editor, elemNode)

  return (
    <div id={containerId} style={style} className="w-e-image-container">
      {imageVnode}
    </div>
  )
}

/**
 * 选中状态下,渲染 image container(渲染拖拽容器,修改图片尺寸)
 */
function renderResizeContainer(
  editor: IDomEditor,
  elemNode: SlateElement,
  imageVnode: VNode,
  imageInfo: IImageSize
) {
  const $body = $('body')
  const containerId = genContainerId(editor, elemNode)
  const { width, height } = imageInfo

  let originalX = 0
  let originalWith = 0
  let originalHeight = 0
  let revers = false // 是否反转。如向右拖拽 right-top 需增加宽度(非反转),但向右拖拽 left-top 则需要减少宽度(反转)
  let $container: Dom7Array | null = null

  function getContainerElem(): Dom7Array {
    const $container = $(`#${containerId}`)
    if ($container.length === 0) throw new Error('Cannot find image container elem')
    return $container
  }

  /**
   * 初始化。监听事件,记录原始数据
   */
  function init(clientX: number) {
    $container = getContainerElem()

    // 记录当前 x 坐标值
    originalX = clientX

    // 记录 img 原始宽高
    const $img = $container.find('img')
    if ($img.length === 0) throw new Error('Cannot find image elem')
    originalWith = $img.width()
    originalHeight = $img.height()

    // 监听 mousemove
    $body.on('mousemove', onMousemove)

    // 监听 mouseup
    $body.on('mouseup', onMouseup)

    // 隐藏 hoverbar
    const hoverbar = DomEditor.getHoverbar(editor)
    if (hoverbar) hoverbar.hideAndClean()
  }

  // mouseover callback (节流)
  const onMousemove = throttle((e: Event) => {
    e.preventDefault()

    const { clientX } = e as MouseEvent
    const gap = revers ? originalX - clientX : clientX - originalX // 考虑是否反转
    const newWidth = originalWith + gap
    const newHeight = originalHeight * (newWidth / originalWith) // 根据 width ,按比例计算 height

    // 实时修改 img 宽高 -【注意】这里只修改 DOM ,mouseup 时再统一不修改 node
    if ($container == null) return
    if (newWidth <= 15 || newHeight <= 15) return // 最小就是 15px

    $container.css('width', `${newWidth}px`)
    $container.css('height', `${newHeight}px`)
  }, 100)

  function onMouseup(e: Event) {
    // 取消监听 mousemove
    $body.off('mousemove', onMousemove)

    if ($container == null) return
    const newWidth = $container.width().toFixed(2)
    const newHeight = $container.height().toFixed(2)

    // 修改 node
    const props: Partial<ImageElement> = {
      style: {
        ...(elemNode as ImageElement).style,
        width: `${newWidth}px`,
        height: `${newHeight}px`,
      },
    }
    Transforms.setNodes(editor, props, { at: DomEditor.findPath(editor, elemNode) })

    // 取消监听 mouseup
    $body.off('mouseup', onMouseup)
  }

  const style: any = {}
  if (width) style.width = width
  if (height) style.height = height
  // style.boxShadow = '0 0 0 1px #B4D5FF' // 自定义 selected 样式,因为有拖拽触手

  return (
    <div
      id={containerId}
      style={style}
      className="w-e-image-container w-e-selected-image-container"
      on={{
        // 统一绑定拖拽触手的 mousedown 事件
        mousedown: (e: MouseEvent) => {
          const $target = $(e.target as Element)
          if (!$target.hasClass('w-e-image-dragger')) {
            // target 不是 .w-e-image-dragger 拖拽触手,则忽略
            return
          }
          e.preventDefault()

          if ($target.hasClass('left-top') || $target.hasClass('left-bottom')) {
            revers = true // 反转。向右拖拽,减少宽度
          }
          init(e.clientX) // 初始化
        },
      }}
    >
      {imageVnode}

      {/* 拖拽的触手,会统一在上级 DOM 绑定拖拽事件 */}
      <div className="w-e-image-dragger left-top"></div>
      <div className="w-e-image-dragger right-top"></div>
      <div className="w-e-image-dragger left-bottom"></div>
      <div className="w-e-image-dragger right-bottom"></div>
    </div>
  )
}

function renderImage(elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
  const { src, alt = '', href = '', style = {} } = elemNode as ImageElement
  const { width = '', height = '' } = style
  const selected = DomEditor.isNodeSelected(editor, elemNode) // 图片是否选中

  const imageStyle: any = {}
  if (width) imageStyle.width = '100%'
  if (height) imageStyle.height = '100%'

  // 【注意】void node 中,renderElem 不用处理 children 。core 会统一处理。
  const vnode = <img style={imageStyle} src={src} alt={alt} data-href={href} />

  const isDisabled = editor.isDisabled()

  if (selected && !isDisabled) {
    // 选中,未禁用 - 渲染 resize container
    return renderResizeContainer(editor, elemNode, vnode, { width, height })
  }

  // 其他,渲染普通 image container
  return renderContainer(editor, elemNode, vnode, { width, height })
}

const renderImageConf = {
  type: 'image', // 和 elemNode.type 一致
  renderElem: renderImage,
}

export { renderImageConf }


================================================
FILE: packages/basic-modules/src/modules/indent/custom-types.ts
================================================
/**
 * @description 自定义 element
 * @author wangfupeng
 */

import { Text } from 'slate'

//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts

export type IndentElement = {
  type: string
  indent?: string | null
  children: Text[]
}


================================================
FILE: packages/basic-modules/src/modules/indent/index.ts
================================================
/**
 * @description indent entry
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { preParseHtmlConf } from './pre-parse-html'
import { parseStyleHtml } from './parse-style-html'
import { indentMenuConf, delIndentMenuConf } from './menu/index'

const indent: Partial<IModuleConf> = {
  renderStyle,
  styleToHtml,
  preParseHtml: [preParseHtmlConf],
  parseStyleHtml,
  menus: [indentMenuConf, delIndentMenuConf],
}

export default indent


================================================
FILE: packages/basic-modules/src/modules/indent/menu/BaseMenu.ts
================================================
/**
 * @description indent base menu
 * @author wangfupeng
 */

import { Editor, Node } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor } from '@wangeditor/core'

abstract class BaseMenu implements IButtonMenu {
  abstract readonly title: string
  abstract readonly iconSvg: string
  readonly tag = 'button'

  /**
   * 获取 node.indent 的值,如 `2em`
   * @param editor editor
   */
  getValue(editor: IDomEditor): string | boolean {
    const [nodeEntry] = Editor.nodes(editor, {
      // @ts-ignore
      match: n => !!n.indent,
      universal: true,
    })

    if (nodeEntry == null) return ''
    const [n] = nodeEntry
    // @ts-ignore
    return n.indent || ''
  }

  isActive(editor: IDomEditor): boolean {
    // 不需要 active
    return false
  }

  /**
   * 获取 node 节点
   * @param editor editor
   */
  protected getMatchNode(editor: IDomEditor): Node | null {
    const [nodeEntry] = Editor.nodes(editor, {
      match: n => {
        const type = DomEditor.getNodeType(n)

        // 只可用于 p 和 header
        if (type === 'paragraph') return true
        if (type.startsWith('header')) return true

        return false
      },
      universal: true,
      mode: 'highest', // 匹配最高层级
    })

    if (nodeEntry == null) return null
    return nodeEntry[0]
  }

  abstract isDisabled(editor: IDomEditor): boolean

  abstract exec(editor: IDomEditor, value: string | boolean): void
}

export default BaseMenu


================================================
FILE: packages/basic-modules/src/modules/indent/menu/DecreaseIndentMenu.ts
================================================
/**
 * @description 减少缩进
 * @author wangfupeng
 */

import { Transforms, Element } from 'slate'
import { IDomEditor, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { INDENT_LEFT_SVG } from '../../../constants/icon-svg'
import { IndentElement } from '../custom-types'

class DecreaseIndentMenu extends BaseMenu {
  readonly title = t('indent.decrease')
  readonly iconSvg = INDENT_LEFT_SVG

  isDisabled(editor: IDomEditor): boolean {
    const matchNode = this.getMatchNode(editor)
    if (matchNode == null) return true // 未匹配 p header 等,则禁用

    const { indent } = matchNode as IndentElement
    if (!indent) {
      // 没有 indent ,则禁用
      return true
    }

    return false // 其他情况,不禁用
  }

  exec(editor: IDomEditor, value: string | boolean): void {
    Transforms.setNodes(
      editor,
      {
        indent: null,
      },
      { match: n => Element.isElement(n) }
    )
  }
}

export default DecreaseIndentMenu


================================================
FILE: packages/basic-modules/src/modules/indent/menu/IncreaseIndentMenu.ts
================================================
/**
 * @description 增加缩进
 * @author wangfupeng
 */

import { Transforms, Element, Editor, Text } from 'slate'
import { IDomEditor, t, DomEditor } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { INDENT_RIGHT_SVG } from '../../../constants/icon-svg'
import { IndentElement } from '../custom-types'
import type { FontSizeAndFamilyText } from '../../font-size-family/custom-types'

class IncreaseIndentMenu extends BaseMenu {
  readonly title = t('indent.increase')
  readonly iconSvg = INDENT_RIGHT_SVG
  private DEFAULT_INDENT_VALUE = '2em'

  isDisabled(editor: IDomEditor): boolean {
    const matchNode = this.getMatchNode(editor)
    if (matchNode == null) return true // 未匹配 p header 等,则禁用

    const { indent } = matchNode as IndentElement
    if (indent) {
      // 有 indent ,则禁用
      return true
    }

    return false
  }

  private getIndentValue(editor: IDomEditor) {
    const matchNode = this.getMatchNode(editor)

    if (!matchNode) return this.DEFAULT_INDENT_VALUE
    const textChildren = (matchNode as Element).children.filter(Text.isText)

    const lastTextNode = textChildren[0] as FontSizeAndFamilyText

    if (!lastTextNode || !lastTextNode.fontSize) return this.DEFAULT_INDENT_VALUE

    // 如果段落的第一个 Text 节点 设置了 fontSize 样式,indent 值需要根据 fontSize 进行计算
    const fontSize = lastTextNode.fontSize
    const value = parseInt(lastTextNode.fontSize)
    const unit = fontSize.replace(`${value}`, '')

    return `${value * 2}${unit}`
  }

  exec(editor: IDomEditor, value: string | boolean): void {
    const indent = this.getIndentValue(editor)

    Transforms.setNodes(
      editor,
      {
        indent,
      },
      {
        match: n => Element.isElement(n),
        mode: 'highest',
      }
    )
  }
}

export default IncreaseIndentMenu


================================================
FILE: packages/basic-modules/src/modules/indent/menu/index.ts
================================================
/**
 * @description indent menu entry
 * @author wangfupeng
 */

import DecreaseIndentMenu from './DecreaseIndentMenu'
import IncreaseIndentMenu from './IncreaseIndentMenu'

export const indentMenuConf = {
  key: 'indent',
  factory() {
    return new IncreaseIndentMenu()
  },
}

export const delIndentMenuConf = {
  key: 'delIndent',
  factory() {
    return new DecreaseIndentMenu()
  },
}


================================================
FILE: packages/basic-modules/src/modules/indent/parse-style-html.ts
================================================
/**
 * @description parse style html
 * @author wangfupeng
 */

import { Descendant, Element } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { IndentElement } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'

export function parseStyleHtml(elem: DOMElement, node: Descendant, editor: IDomEditor): Descendant {
  const $elem = $(elem)
  if (!Element.isElement(node)) return node

  const elemNode = node as IndentElement

  const indent = getStyleValue($elem, 'text-indent')
  const indentNumber = parseInt(indent, 10)
  if (indent && indentNumber > 0) {
    elemNode.indent = indent
  }

  return elemNode
}


================================================
FILE: packages/basic-modules/src/modules/indent/pre-parse-html.ts
================================================
/**
 * @description pre-parse html
 * @author wangfupeng
 */

import $, { DOMElement, getStyleValue } from '../../utils/dom'

/**
 * pre-prase text-indent 兼容 V4 和 V5 早期格式(都使用 padding-left)
 * @param elem elem
 */
function preParse(elem: DOMElement): DOMElement {
  const $elem = $(elem)
  const paddingLeft = getStyleValue($elem, 'padding-left')

  if (/\dem/.test(paddingLeft)) {
    // 如 '2em' ,V4 格式
    $elem.css('text-indent', '2em')
  }

  if (/\dpx/.test(paddingLeft)) {
    // px 单位
    const num = parseInt(paddingLeft, 10)
    if (num % 32 === 0) {
      // 如 32px 64px ,V5 早期格式
      $elem.css('text-indent', '2em')
    }
  }

  return $elem[0]
}

export const preParseHtmlConf = {
  selector: 'p,h1,h2,h3,h4,h5',
  preParseHtml: preParse,
}


================================================
FILE: packages/basic-modules/src/modules/indent/render-style.tsx
================================================
/**
 * @description render indent style
 * @author wangfupeng
 */

import { Element, Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeStyle } from '../../utils/vdom'
import { IndentElement } from './custom-types'

/**
 * 添加样式
 * @param node slate elem
 * @param vnode vnode
 * @returns vnode
 */
export function renderStyle(node: Descendant, vnode: VNode): VNode {
  if (!Element.isElement(node)) return vnode

  const { indent } = node as IndentElement // 如 '2em'
  let styleVnode: VNode = vnode

  if (indent) {
    addVnodeStyle(styleVnode, { textIndent: indent })
  }

  return styleVnode
}


================================================
FILE: packages/basic-modules/src/modules/indent/style-to-html.ts
================================================
/**
 * @description textStyle to html
 * @author wangfupeng
 */

import { Element, Descendant } from 'slate'
import $, { getOuterHTML } from '../../utils/dom'
import { IndentElement } from './custom-types'

export function styleToHtml(node: Descendant, elemHtml: string): string {
  if (!Element.isElement(node)) return elemHtml

  const { indent } = node as IndentElement // 如 '2em'
  if (!indent) return elemHtml

  // 设置样式
  const $elem = $(elemHtml)
  $elem.css('text-indent', indent)

  // 输出 html
  return getOuterHTML($elem)
}


================================================
FILE: packages/basic-modules/src/modules/justify/custom-types.ts
================================================
/**
 * @description 自定义 element
 * @author wangfupeng
 */

import { Text } from 'slate'

//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts

export type JustifyElement = {
  type: string
  textAlign?: string
  children: Text[]
}


================================================
FILE: packages/basic-modules/src/modules/justify/index.ts
================================================
/**
 * @description justify module entry
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { parseStyleHtml } from './parse-style-html'
import {
  justifyLeftMenuConf,
  justifyRightMenuConf,
  justifyCenterMenuConf,
  justifyJustifyMenuConf,
} from './menu/index'

const justify: Partial<IModuleConf> = {
  renderStyle,
  styleToHtml,
  parseStyleHtml,
  menus: [justifyLeftMenuConf, justifyRightMenuConf, justifyCenterMenuConf, justifyJustifyMenuConf],
}

export default justify


================================================
FILE: packages/basic-modules/src/modules/justify/menu/BaseMenu.ts
================================================
/**
 * @description justify base menu
 * @author wangfupeng
 */

import { Editor, Node, Element } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor } from '@wangeditor/core'

abstract class BaseMenu implements IButtonMenu {
  abstract readonly title: string
  abstract readonly iconSvg: string
  readonly tag = 'button'

  getValue(editor: IDomEditor): string | boolean {
    // 不需要 value
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 不需要 active
    return false
  }

  /**
   * 获取 node 节点
   * @param editor editor
   */
  protected getMatchNode(editor: IDomEditor): Node | null {
    const [nodeEntry] = Editor.nodes(editor, {
      match: n => {
        const type = DomEditor.getNodeType(n)

        // 只可用于 p blockquote header
        if (type === 'paragraph') return true
        if (type === 'blockquote') return true
        if (type.startsWith('header')) return true

        return false
      },
      universal: true,
      mode: 'highest', // 匹配最高层级
    })

    if (nodeEntry == null) return null
    return nodeEntry[0]
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const selectedElems = DomEditor.getSelectedElems(editor)
    const notMatch = selectedElems.some((elem: Node) => {
      if (Editor.isVoid(editor, elem) && Editor.isBlock(editor, elem)) return true

      const { type } = elem as unknown as Element
      if (['pre', 'code'].includes(type)) return true
    })
    if (notMatch) return true

    return false
  }

  abstract exec(editor: IDomEditor, value: string | boolean): void
}

export default BaseMenu


================================================
FILE: packages/basic-modules/src/modules/justify/menu/JustifyCenterMenu.ts
================================================
/**
 * @description justify center menu
 * @author wangfupeng
 */

import { Transforms, Element } from 'slate'
import { IDomEditor, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { JUSTIFY_CENTER_SVG } from '../../../constants/icon-svg'

class JustifyCenterMenu extends BaseMenu {
  readonly title = t('justify.center')
  readonly iconSvg = JUSTIFY_CENTER_SVG

  exec(editor: IDomEditor, value: string | boolean): void {
    Transforms.setNodes(
      editor,
      {
        textAlign: 'center',
      },
      { match: n => Element.isElement(n) && !editor.isInline(n) } // inline 元素设置text-align 是没作用的
    )
  }
}

export default JustifyCenterMenu


================================================
FILE: packages/basic-modules/src/modules/justify/menu/JustifyJustifyMenu.ts
================================================
/**
 * @description 两端对齐
 * @author wangfupeng
 */

import { Transforms, Element } from 'slate'
import { IDomEditor, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { JUSTIFY_JUSTIFY_SVG } from '../../../constants/icon-svg'

class JustifyJustifyMenu extends BaseMenu {
  readonly title = t('justify.justify')
  readonly iconSvg = JUSTIFY_JUSTIFY_SVG

  exec(editor: IDomEditor, value: string | boolean): void {
    Transforms.setNodes(
      editor,
      {
        textAlign: 'justify',
      },
      { match: n => Element.isElement(n) && !editor.isInline(n) }
    )
  }
}

export default JustifyJustifyMenu


================================================
FILE: packages/basic-modules/src/modules/justify/menu/JustifyLeftMenu.ts
================================================
/**
 * @description justify left menu
 * @author wangfupeng
 */

import { Transforms, Element } from 'slate'
import { IDomEditor, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { JUSTIFY_LEFT_SVG } from '../../../constants/icon-svg'

class JustifyLeftMenu extends BaseMenu {
  readonly title = t('justify.left')
  readonly iconSvg = JUSTIFY_LEFT_SVG

  exec(editor: IDomEditor, value: string | boolean): void {
    Transforms.setNodes(
      editor,
      {
        textAlign: 'left',
      },
      { match: n => Element.isElement(n) && !editor.isInline(n) }
    )
  }
}

export default JustifyLeftMenu


================================================
FILE: packages/basic-modules/src/modules/justify/menu/JustifyRightMenu.ts
================================================
/**
 * @description justify right menu
 * @author wangfupeng
 */

import { Transforms, Element } from 'slate'
import { IDomEditor, t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { JUSTIFY_RIGHT_SVG } from '../../../constants/icon-svg'

class JustifyRightMenu extends BaseMenu {
  readonly title = t('justify.right')
  readonly iconSvg = JUSTIFY_RIGHT_SVG

  exec(editor: IDomEditor, value: string | boolean): void {
    Transforms.setNodes(
      editor,
      {
        textAlign: 'right',
      },
      { match: n => Element.isElement(n) && !editor.isInline(n) }
    )
  }
}

export default JustifyRightMenu


================================================
FILE: packages/basic-modules/src/modules/justify/menu/index.ts
================================================
/**
 * @description justify menu entry
 * @author wangfupeng
 */

import JustifyLeftMenu from './JustifyLeftMenu'
import JustifyRightMenu from './JustifyRightMenu'
import JustifyCenterMenu from './JustifyCenterMenu'
import JustifyJustifyMenu from './JustifyJustifyMenu'

export const justifyLeftMenuConf = {
  key: 'justifyLeft',
  factory() {
    return new JustifyLeftMenu()
  },
}

export const justifyRightMenuConf = {
  key: 'justifyRight',
  factory() {
    return new JustifyRightMenu()
  },
}

export const justifyCenterMenuConf = {
  key: 'justifyCenter',
  factory() {
    return new JustifyCenterMenu()
  },
}

export const justifyJustifyMenuConf = {
  key: 'justifyJustify',
  factory() {
    return new JustifyJustifyMenu()
  },
}


================================================
FILE: packages/basic-modules/src/modules/justify/parse-style-html.ts
================================================
/**
 * @description parse style html
 * @author wangfupeng
 */

import { Descendant, Element } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { JustifyElement } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'

export function parseStyleHtml(elem: DOMElement, node: Descendant, editor: IDomEditor): Descendant {
  const $elem = $(elem)
  if (!Element.isElement(node)) return node

  const elemNode = node as JustifyElement

  const textAlign = getStyleValue($elem, 'text-align')
  if (textAlign) {
    elemNode.textAlign = textAlign
  }

  return elemNode
}


================================================
FILE: packages/basic-modules/src/modules/justify/render-style.tsx
================================================
/**
 * @description render justify style
 * @author wangfupeng
 */

import { Descendant, Element } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeStyle } from '../../utils/vdom'
import { JustifyElement } from './custom-types'

/**
 * 添加样式
 * @param node slate elem
 * @param vnode vnode
 * @returns vnode
 */
export function renderStyle(node: Descendant, vnode: VNode): VNode {
  if (!Element.isElement(node)) return vnode

  const { textAlign } = node as JustifyElement // 如 'left'/'right'/'center' 等
  let styleVnode: VNode = vnode

  if (textAlign) {
    addVnodeStyle(styleVnode, { textAlign })
  }

  return styleVnode
}


================================================
FILE: packages/basic-modules/src/modules/justify/style-to-html.ts
================================================
/**
 * @description textStyle to html
 * @author wangfupeng
 */

import { Element, Descendant } from 'slate'
import $, { getOuterHTML } from '../../utils/dom'
import { JustifyElement } from './custom-types'

export function styleToHtml(node: Descendant, elemHtml: string): string {
  if (!Element.isElement(node)) return elemHtml

  const { textAlign } = node as JustifyElement // 如 'left'/'right'/'center' 等
  if (!textAlign) return elemHtml

  // 设置样式
  const $elem = $(elemHtml)
  $elem.css('text-align', textAlign)

  // 输出 html
  const outerHtml = getOuterHTML($elem)
  return outerHtml
}


================================================
FILE: packages/basic-modules/src/modules/line-height/custom-types.ts
================================================
/**
 * @description 自定义 element
 * @author wangfupeng
 */

import { Text } from 'slate'

//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts

export type LineHeightElement = {
  type: string
  lineHeight?: string
  children: Text[]
}


================================================
FILE: packages/basic-modules/src/modules/line-height/index.ts
================================================
/**
 * @description line-height module entry
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { lineHeightMenuConf } from './menu/index'
import { parseStyleHtml } from './parse-style-html'

const lineHeight: Partial<IModuleConf> = {
  renderStyle,
  styleToHtml,
  parseStyleHtml,
  menus: [lineHeightMenuConf],
}

export default lineHeight


================================================
FILE: packages/basic-modules/src/modules/line-height/menu/LineHeightMenu.ts
================================================
/**
 * @description header menu
 * @author wangfupeng
 */

import { Editor, Node, Element, Transforms } from 'slate'
import { ISelectMenu, IDomEditor, DomEditor, IOption, t } from '@wangeditor/core'
import { LINE_HEIGHT_SVG } from '../../../constants/icon-svg'
import { LineHeightElement } from '../custom-types'

class LineHeightMenu implements ISelectMenu {
  readonly title = t('lineHeight.title')
  readonly iconSvg = LINE_HEIGHT_SVG
  readonly tag = 'select'
  readonly width = 80

  getOptions(editor: IDomEditor): IOption[] {
    const options: IOption[] = []

    // 获取配置,参考 './config.ts'
    const { lineHeightList = [] } = editor.getMenuConfig('lineHeight')

    // 生成 options
    options.push({
      text: t('lineHeight.default'),
      value: '', // this.getValue(editor) 未找到结果时,会返回 '' ,正好对应到这里
    })
    lineHeightList.forEach((height: string) => {
      options.push({
        text: height,
        value: height,
      })
    })

    // 设置 selected
    const curValue = this.getValue(editor)
    options.forEach(opt => {
      if (opt.value === curValue) {
        opt.selected = true
      } else {
        delete opt.selected
      }
    })

    return options
  }

  /**
   * 获取匹配的 node 节点
   * @param editor editor
   */
  private getMatchNode(editor: IDomEditor): Node | null {
    const [nodeEntry] = Editor.nodes(editor, {
      match: n => {
        const type = DomEditor.getNodeType(n)

        // line-height 匹配如下类型的 node
        if (type.startsWith('header')) return true
        if (['paragraph', 'blockquote', 'list-item'].includes(type)) {
          return true
        }

        return false
      },
      universal: true,
      mode: 'highest', // 匹配最高层级
    })

    if (nodeEntry == null) return null
    return nodeEntry[0]
  }

  isActive(editor: IDomEditor): boolean {
    // select menu 会显示 selected value ,用不到 active
    return false
  }

  /**
   * 获取 node.lineHeight 的值(如 '1' '1.5'),没有则返回 ''
   * @param editor editor
   */
  getValue(editor: IDomEditor): string | boolean {
    const node = this.getMatchNode(editor)
    if (node == null) return ''
    if (!Element.isElement(node)) return ''

    return (node as LineHeightElement).lineHeight || ''
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true // 禁用

    const node = this.getMatchNode(editor)
    if (node == null) return true // 未匹配到指定 node ,禁用

    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    Transforms.setNodes(
      editor,
      {
        lineHeight: value.toString(),
      },
      { mode: 'highest' }
    )
  }
}

export default LineHeightMenu


================================================
FILE: packages/basic-modules/src/modules/line-height/menu/config.ts
================================================
/**
 * @description line-height config
 * @author wangfupeng
 */

export function genLineHeightConfig() {
  return ['1', '1.15', '1.5', '2', '2.5', '3']
}


================================================
FILE: packages/basic-modules/src/modules/line-height/menu/index.ts
================================================
/**
 * @description line-height menu entry
 * @author wangfupeng
 */

import LineHeightMenu from './LineHeightMenu'
import { genLineHeightConfig } from './config'

export const lineHeightMenuConf = {
  key: 'lineHeight',
  factory() {
    return new LineHeightMenu()
  },

  // 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
  // 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
  config: {
    lineHeightList: genLineHeightConfig(),
  },
}


================================================
FILE: packages/basic-modules/src/modules/line-height/parse-style-html.ts
================================================
/**
 * @description parse style html
 * @author wangfupeng
 */

import { Descendant, Element } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { LineHeightElement } from './custom-types'
import $, { DOMElement, getStyleValue } from '../../utils/dom'

export function parseStyleHtml(elem: DOMElement, node: Descendant, editor: IDomEditor): Descendant {
  const $elem = $(elem)
  if (!Element.isElement(node)) return node

  const elemNode = node as LineHeightElement

  const { lineHeightList = [] } = editor.getMenuConfig('lineHeight')
  const lineHeight = getStyleValue($elem, 'line-height')
  if (lineHeight && lineHeightList.includes(lineHeight)) {
    elemNode.lineHeight = lineHeight
  }

  return elemNode
}


================================================
FILE: packages/basic-modules/src/modules/line-height/render-style.tsx
================================================
/**
 * @description render line-height style
 * @author wangfupeng
 */

import { Element, Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeStyle } from '../../utils/vdom'
import { LineHeightElement } from './custom-types'

/**
 * 添加样式
 * @param node slate elem
 * @param vnode vnode
 * @returns vnode
 */
export function renderStyle(node: Descendant, vnode: VNode): VNode {
  if (!Element.isElement(node)) return vnode

  const { lineHeight } = node as LineHeightElement // 如 '1' '1.5'
  let styleVnode: VNode = vnode

  if (lineHeight) {
    addVnodeStyle(styleVnode, { lineHeight })
  }

  return styleVnode
}


================================================
FILE: packages/basic-modules/src/modules/line-height/style-to-html.ts
================================================
/**
 * @description textStyle to html
 * @author wangfupeng
 */

import { Element, Descendant } from 'slate'
import $, { getOuterHTML } from '../../utils/dom'
import { LineHeightElement } from './custom-types'

export function styleToHtml(node: Descendant, elemHtml: string): string {
  if (!Element.isElement(node)) return elemHtml

  const { lineHeight } = node as LineHeightElement // 如 '1' '1.5'
  if (!lineHeight) return elemHtml

  // 设置样式
  const $elem = $(elemHtml)
  $elem.css('line-height', lineHeight)

  // 输出 html
  return getOuterHTML($elem)
}


================================================
FILE: packages/basic-modules/src/modules/link/custom-types.ts
================================================
/**
 * @description 自定义 element
 * @author wangfupeng
 */

import { Text } from 'slate'

//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts

export type LinkElement = {
  type: 'link'
  url: string
  target?: string
  children: Text[]
}


================================================
FILE: packages/basic-modules/src/modules/link/elem-to-html.ts
================================================
/**
 * @description to html
 * @author wangfupeng
 */

import { Element } from 'slate'
import { LinkElement } from './custom-types'

function linkToHtml(elem: Element, childrenHtml: string): string {
  const { url, target = '_blank' } = elem as LinkElement

  return `<a href="${url}" target="${target}">${childrenHtml}</a>`
}

export const linkToHtmlConf = {
  type: 'link',
  elemToHtml: linkToHtml,
}


================================================
FILE: packages/basic-modules/src/modules/link/helper.ts
================================================
/**
 * @description link helper
 * @author wangfupeng
 */

import { Editor, Range, Transforms } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'
import { LinkElement } from './custom-types'
import { replaceSymbols } from '../../utils/util'

/**
 * 校验 link
 * @param menuKey menu key
 * @param editor editor
 * @param text menu text
 * @param url menu url
 */
async function check(
  menuKey: string,
  editor: IDomEditor,
  text: string,
  url: string
): Promise<boolean> {
  const { checkLink } = editor.getMenuConfig(menuKey)
  if (checkLink) {
    const res = await checkLink(text, url)
    if (typeof res === 'string') {
      // 检验未通过,提示信息
      editor.alert(res, 'error')
      return false
    }
    if (res == null) {
      // 检验未通过,不提示信息
      return false
    }
  }

  return true // 校验通过
}

/**
 * 转换链接 url
 * @param menuKey menu key
 * @param editor editor
 * @param url url
 * @returns parsedUrl
 */
async function parse(menuKey: string, editor: IDomEditor, url: string): Promise<string> {
  const { parseLinkUrl } = editor.getMenuConfig(menuKey)
  if (parseLinkUrl) {
    const newUrl = await parseLinkUrl(url)
    return newUrl
  }
  return url
}

export function isMenuDisabled(editor: IDomEditor): boolean {
  if (editor.selection == null) return true

  const selectedElems = DomEditor.getSelectedElems(editor)
  const notMatch = selectedElems.some(elem => {
    const { type } = elem
    if (editor.isVoid(elem)) return true
    if (['pre', 'code', 'link'].includes(type)) return true
  })
  if (notMatch) return true // disabled
  return false // enable
}

/**
 * 生成 link node
 * @param url url
 * @param text text
 */
function genLinkNode(url: string, text?: string): LinkElement {
  const linkNode: LinkElement = {
    type: 'link',
    url: replaceSymbols(url),
    children: text ? [{ text }] : [],
  }
  return linkNode
}

/**
 * 插入 link
 * @param editor editor
 * @param text text
 * @param url url
 */
export async function insertLink(editor: IDomEditor, text: string, url: string) {
  if (!url) return
  if (!text) text = url // 无 text 则用 url 代替

  // 还原选区
  editor.restoreSelection()

  if (isMenuDisabled(editor)) return

  // 校验
  const checkRes = await check('insertLink', editor, text, url)
  if (!checkRes) return // 校验未通过

  // 转换 url
  const parsedUrl = await parse('insertLink', editor, url)

  // 判断选区是否折叠
  const { selection } = editor
  if (selection == null) return
  const isCollapsed = Range.isCollapsed(selection)

  // 执行:插入链接
  if (isCollapsed) {
    // 链接前后插入空格,方便操作
    editor.insertText(' ')

    const linkNode = genLinkNode(parsedUrl, text)
    Transforms.insertNodes(editor, linkNode)

    // https://github.com/wangeditor-team/wangEditor/issues/332
    // 不能直接使用 insertText, 会造成添加的空格被添加到链接文本中,参考上面 issue,替换为 insertFragment 方式添加空格
    editor.insertFragment([{ text: ' ' }])
  } else {
    const selectedText = Editor.string(editor, selection) // 选中的文字
    if (selectedText !== text) {
      // 选中的文字和输入的文字不一样,则删掉文字,插入链接
      editor.deleteFragment()
      const linkNode = genLinkNode(parsedUrl, text)
      Transforms.insertNodes(editor, linkNode)
    } else {
      // 选中的文字和输入的文字一样,则只包裹链接即可
      const linkNode = genLinkNode(parsedUrl)
      Transforms.wrapNodes(editor, linkNode, { split: true })
      Transforms.collapse(editor, { edge: 'end' })
    }
  }
}

/**
 * 修改 link url
 * @param editor editor
 * @param text text
 * @param url link url
 */
export async function updateLink(editor: IDomEditor, text: string, url: string) {
  if (!url) return

  // 校验
  const checkRes = await check('editLink', editor, text, url)
  if (!checkRes) return // 校验未通过

  // 转换 url
  const parsedUrl = await parse('editLink', editor, url)

  // 修改链接
  const props: Partial<LinkElement> = { url: replaceSymbols(parsedUrl) }
  Transforms.setNodes(editor, props, {
    match: n => DomEditor.checkNodeType(n, 'link'),
  })
}


================================================
FILE: packages/basic-modules/src/modules/link/index.ts
================================================
/**
 * @description link entry
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import withLink from './plugin'
import { renderLinkConf } from './render-elem'
import { linkToHtmlConf } from './elem-to-html'
import { parseHtmlConf } from './parse-elem-html'
import {
  insertLinkMenuConf,
  editLinkMenuConf,
  unLinkMenuConf,
  viewLinkMenuConf,
} from './menu/index'

const link: Partial<IModuleConf> = {
  renderElems: [renderLinkConf],
  elemsToHtml: [linkToHtmlConf],
  parseElemsHtml: [parseHtmlConf],
  menus: [insertLinkMenuConf, editLinkMenuConf, unLinkMenuConf, viewLinkMenuConf],
  editorPlugin: withLink,
}

export default link


================================================
FILE: packages/basic-modules/src/modules/link/menu/EditLink.ts
================================================
/**
 * @description update link menu
 * @author wangfupeng
 */

import { Node } from 'slate'
import {
  IModalMenu,
  IDomEditor,
  DomEditor,
  genModalInputElems,
  genModalButtonElems,
  t,
} from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { genRandomStr } from '../../../utils/util'
import { PENCIL_SVG } from '../../../constants/icon-svg'
import { updateLink } from '../helper'
import { LinkElement } from '../custom-types'

/**
 * 生成唯一的 DOM ID
 */
function genDomID(): string {
  return genRandomStr('w-e-update-link')
}

class EditLinkMenu implements IModalMenu {
  readonly title = t('link.edit')
  readonly iconSvg = PENCIL_SVG
  readonly tag = 'button'
  readonly showModal = true // 点击 button 时显示 modal
  readonly modalWidth = 300

  private $content: Dom7Array | null = null
  private urlInputId = genDomID()
  private buttonId = genDomID()

  private getSelectedLinkElem(editor: IDomEditor): LinkElement | null {
    const node = DomEditor.getSelectedNodeByType(editor, 'link')
    if (node == null) return null
    return node as LinkElement
  }

  /**
   * 获取 node.url
   * @param editor editor
   */
  getValue(editor: IDomEditor): string | boolean {
    const linkElem = this.getSelectedLinkElem(editor)
    if (linkElem) {
      return linkElem.url || ''
    }
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 无需 active
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    // 点击菜单时,弹出 modal 之前,不需要执行其他代码
    // 此处空着即可
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const linkElem = this.getSelectedLinkElem(editor)

    // 未匹配到 link node 则禁用
    if (linkElem == null) return true
    return false
  }

  // modal 定位
  getModalPositionNode(editor: IDomEditor): Node | null {
    return DomEditor.getSelectedNodeByType(editor, 'link')
  }

  getModalContentElem(editor: IDomEditor): DOMElement {
    const { urlInputId, buttonId } = this

    // 获取 input button elem
    const [urlContainerElem, inputUrlElem] = genModalInputElems(t('link.url'), urlInputId)
    const $inputUrl = $(inputUrlElem)
    const [buttonContainerElem] = genModalButtonElems(buttonId, t('common.ok'))

    if (this.$content == null) {
      // 第一次渲染
      const $content = $('<div></div>')

      // 绑定事件(第一次渲染时绑定,不要重复绑定)
      $content.on('click', 'button', e => {
        e.preventDefault()
        editor.restoreSelection() // 还原选区

        const n = DomEditor.getSelectedNodeByType(editor, 'link')
        const text = n ? Node.string(n) : ''
        const url = $content.find(`#${urlInputId}`).val()
        updateLink(editor, text, url) // 修改链接

        editor.hidePanelOrModal() // 隐藏 modal
      })

      // 记录属性,重要
      this.$content = $content
    }

    const $content = this.$content
    $content.empty() // 先清空内容

    // append input and button
    $content.append(urlContainerElem)
    $content.append(buttonContainerElem)

    // 设置 input val
    const url = this.getValue(editor)
    $inputUrl.val(url)

    // focus 一个 input(异步,此时 DOM 尚未渲染)
    setTimeout(() => {
      $inputUrl.focus()
    })

    return $content[0]
  }
}

export default EditLinkMenu


================================================
FILE: packages/basic-modules/src/modules/link/menu/InsertLink.ts
================================================
/**
 * @description insert link menu
 * @author wangfupeng
 */

import { Editor, Range, Node } from 'slate'
import {
  IModalMenu,
  IDomEditor,
  genModalInputElems,
  genModalButtonElems,
  t,
} from '@wangeditor/core'
import $, { Dom7Array, DOMElement } from '../../../utils/dom'
import { genRandomStr } from '../../../utils/util'
import { LINK_SVG } from '../../../constants/icon-svg'
import { isMenuDisabled, insertLink } from '../helper'

/**
 * 生成唯一的 DOM ID
 */
function genDomID(): string {
  return genRandomStr('w-e-insert-link')
}

class InsertLinkMenu implements IModalMenu {
  readonly title = t('link.insert')
  readonly iconSvg = LINK_SVG
  readonly tag = 'button'
  readonly showModal = true // 点击 button 时显示 modal
  readonly modalWidth = 300
  private $content: Dom7Array | null = null
  private readonly textInputId = genDomID()
  private readonly urlInputId = genDomID()
  private readonly buttonId = genDomID()

  getValue(editor: IDomEditor): string | boolean {
    // 插入菜单,不需要 value
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 任何时候,都不用激活 menu
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    // 点击菜单时,弹出 modal 之前,不需要执行其他代码
    // 此处空着即可
  }

  isDisabled(editor: IDomEditor): boolean {
    return isMenuDisabled(editor)
  }

  getModalPositionNode(editor: IDomEditor): Node | null {
    return null // modal 依据选区定位
  }

  getModalContentElem(editor: IDomEditor): DOMElement {
    const { selection } = editor
    const { textInputId, urlInputId, buttonId } = this

    // 获取 input button elem
    const [textContainerElem, inputTextElem] = genModalInputElems(t('link.text'), textInputId)
    const $inputText = $(inputTextElem)
    const [urlContainerElem, inputUrlElem] = genModalInputElems(t('link.url'), urlInputId)
    const $inputUrl = $(inputUrlElem)
    const [buttonContainerElem] = genModalButtonElems(buttonId, t('common.ok'))

    if (this.$content == null) {
      // 第一次渲染
      const $content = $('<div></div>')

      // 绑定事件(第一次渲染时绑定,不要重复绑定)
      $content.on('click', `#${buttonId}`, e => {
        e.preventDefault()
        const text = $content.find(`#${textInputId}`).val()
        const url = $content.find(`#${urlInputId}`).val()
        insertLink(editor, text, url) // 插入链接
        editor.hidePanelOrModal() // 隐藏 modal
      })

      // 记录属性,重要
      this.$content = $content
    }

    const $content = this.$content
    $content.empty() // 先清空内容

    // append inputs and button
    $content.append(textContainerElem)
    $content.append(urlContainerElem)
    $content.append(buttonContainerElem)

    // 设置 input val
    if (selection == null || Range.isCollapsed(selection)) {
      // 选区无内容
      $inputText.val('')
    } else {
      // 选区有内容
      const selectionText = Editor.string(editor, selection)
      $inputText.val(selectionText)
    }
    $inputUrl.val('')

    // focus 一个 input(异步,此时 DOM 尚未渲染)
    setTimeout(() => {
      $inputText.focus()
    })

    return $content[0]
  }
}

export default InsertLinkMenu


================================================
FILE: packages/basic-modules/src/modules/link/menu/UnLink.ts
================================================
/**
 * @description unlink menu
 * @author wangfupeng
 */

import { Transforms } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { UN_LINK_SVG } from '../../../constants/icon-svg'

class UnLink implements IButtonMenu {
  readonly title = t('link.unLink')
  readonly iconSvg = UN_LINK_SVG
  readonly tag = 'button'

  getValue(editor: IDomEditor): string | boolean {
    // 无需获取 val
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 无需 active
    return false
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const linkNode = DomEditor.getSelectedNodeByType(editor, 'link')
    if (linkNode == null) {
      // 选区未处于 link node ,则禁用
      return true
    }
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    if (this.isDisabled(editor)) return

    // 取消链接
    Transforms.unwrapNodes(editor, {
      match: n => DomEditor.checkNodeType(n, 'link'),
    })
  }
}

export default UnLink


================================================
FILE: packages/basic-modules/src/modules/link/menu/ViewLink.ts
================================================
/**
 * @description view link menu
 * @author wangfupeng
 */

import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { EXTERNAL_SVG } from '../../../constants/icon-svg'
import { LinkElement } from '../custom-types'

class ViewLink implements IButtonMenu {
  readonly title = t('link.view')
  readonly iconSvg = EXTERNAL_SVG
  readonly tag = 'button'

  private getSelectedLinkElem(editor: IDomEditor): LinkElement | null {
    const node = DomEditor.getSelectedNodeByType(editor, 'link')
    if (node == null) return null
    return node as LinkElement
  }

  getValue(editor: IDomEditor): string | boolean {
    const linkElem = this.getSelectedLinkElem(editor)
    if (linkElem) {
      return linkElem.url || ''
    }
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    // 无需 active
    return false
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const linkElem = this.getSelectedLinkElem(editor)
    if (linkElem == null) {
      // 选区未处于 link node ,则禁用
      return true
    }
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    if (this.isDisabled(editor)) return

    if (!value || typeof value !== 'string') {
      throw new Error(`View link failed, link url is '${value}'`)
    }

    // 查看链接
    window.open(value, '_blank')
  }
}

export default ViewLink


================================================
FILE: packages/basic-modules/src/modules/link/menu/config.ts
================================================
/**
 * @description link menu config
 * @author wangfupeng
 */

export function genLinkMenuConfig() {
  return {
    /**
     * 检查链接,支持 async fn
     * @param text link text
     * @param url link url
     */
    checkLink(text: string, url: string): boolean | string | undefined {
      // 1. 返回 true ,说明检查通过
      // 2. 返回一个字符串,说明检查未通过,编辑器会阻止插入。会 alert 出错误信息(即返回的字符串)
      // 3. 返回 undefined(即没有任何返回),说明检查未通过,编辑器会阻止插入
      return true
    },

    /**
     * parse link url
     * @param url url
     * @returns newUrl
     */
    parseLinkUrl(url: string): string {
      return url
    },
  }
}


================================================
FILE: packages/basic-modules/src/modules/link/menu/index.ts
================================================
/**
 * @description link menu entry
 * @author wangfupeng
 */

import InsertLink from './InsertLink'
import EditLink from './EditLink'
import UnLink from './UnLink'
import ViewLink from './ViewLink'
import { genLinkMenuConfig } from './config'

const config = genLinkMenuConfig() // menu config

const insertLinkMenuConf = {
  key: 'insertLink',
  factory() {
    return new InsertLink()
  },

  // 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中
  // 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改
  config,
}

const editLinkMenuConf = {
  key: 'editLink',
  factory() {
    return new EditLink()
  },
  config,
}

const unLinkMenuConf = {
  key: 'unLink',
  factory() {
    return new UnLink()
  },
}

const viewLinkMenuConf = {
  key: 'viewLink',
  factory() {
    return new ViewLink()
  },
}

export { insertLinkMenuConf, editLinkMenuConf, unLinkMenuConf, viewLinkMenuConf }


================================================
FILE: packages/basic-modules/src/modules/link/parse-elem-html.ts
================================================
/**
 * @description parse html
 * @author wangfupeng
 */

import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { LinkElement } from './custom-types'
import $, { DOMElement } from '../../utils/dom'

function parseHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): LinkElement {
  const $elem = $(elem)
  children = children.filter(child => {
    if (Text.isText(child)) return true
    if (editor.isInline(child)) return true
    return false
  })

  // 无 children ,则用纯文本
  if (children.length === 0) {
    children = [{ text: $elem.text().replace(/\s+/gm, ' ') }]
  }

  return {
    type: 'link',
    url: $elem.attr('href') || '',
    target: $elem.attr('target') || '',
    // @ts-ignore
    children,
  }
}

export const parseHtmlConf = {
  selector: 'a:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
  parseElemHtml: parseHtml,
}


================================================
FILE: packages/basic-modules/src/modules/link/plugin.ts
================================================
/**
 * @description editor 插件,重写 editor API
 * @author wangfupeng
 */

import { Editor, Node, Transforms } from 'slate'
import { DomEditor, IDomEditor } from '@wangeditor/core'
import isUrl from 'is-url'
import { isMenuDisabled, insertLink } from './helper'

function withLink<T extends IDomEditor>(editor: T): T {
  const { isInline, insertData, normalizeNode, insertNode, insertText } = editor
  const newEditor = editor

  // 重写 isInline
  newEditor.isInline = elem => {
    const { type } = elem

    if (type === 'link') {
      return true
    }

    return isInline(elem)
  }

  // 重写 insertData ,粘贴插入链接
  newEditor.insertData = (data: DataTransfer) => {
    const text = data.getData('text/plain')
    if (!isUrl(text)) {
      // 非链接
      insertData(data)
      return
    }

    // 插入链接
    if (isMenuDisabled(newEditor)) return // disabled
    const { selection } = newEditor
    if (selection == null) return
    const selectedText = Editor.string(newEditor, selection) // 获取选中的文字
    insertLink(newEditor, selectedText, text)
  }

  newEditor.normalizeNode = ([node, path]) => {
    const type = DomEditor.getNodeType(node)
    if (type !== 'link') {
      // 未命中 link ,执行默认的 normalizeNode
      return normalizeNode([node, path])
    }

    // 如果链接内容为空,则删除
    const str = Node.string(node)
    if (str === '') {
      return Transforms.removeNodes(newEditor, { at: path })
    }

    return normalizeNode([node, path])
  }

  // 返回 editor ,重要!
  return newEditor
}

export default withLink


================================================
FILE: packages/basic-modules/src/modules/link/render-elem.tsx
================================================
/**
 * @description render link elem
 * @author wangfupeng
 */

import { Element as SlateElement } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor } from '@wangeditor/core'
import { LinkElement } from './custom-types'

/**
 * render link elem
 * @param elemNode slate elem
 * @param children children
 * @param editor editor
 * @returns vnode
 */
function renderLink(elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
  const { url, target = '_blank' } = elemNode as LinkElement
  const vnode = (
    <a href={url} target={target}>
      {children}
    </a>
  )

  return vnode
}

const renderLinkConf = {
  type: 'link', // 和 elemNode.type 一致
  renderElem: renderLink,
}

export { renderLinkConf }


================================================
FILE: packages/basic-modules/src/modules/paragraph/custom-types.ts
================================================
/**
 * @description 自定义 element
 * @author wangfupeng
 */

import { Text } from 'slate'

//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts

export type ParagraphElement = {
  type: 'paragraph'
  children: Text[]
}


================================================
FILE: packages/basic-modules/src/modules/paragraph/elem-to-html.ts
================================================
/**
 * @description to html
 * @author wangfupeng
 */

import { Element } from 'slate'

function pToHtml(elem: Element, childrenHtml: string): string {
  if (childrenHtml === '') {
    return '<p><br></p>'
  }
  return `<p>${childrenHtml}</p>`
}

export const pToHtmlConf = {
  type: 'paragraph',
  elemToHtml: pToHtml,
}


================================================
FILE: packages/basic-modules/src/modules/paragraph/index.ts
================================================
/**
 * @description paragraph entry
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { renderParagraphConf } from './render-elem'
import { pToHtmlConf } from './elem-to-html'
import { parseParagraphHtmlConf } from './parse-elem-html'
import withParagraph from './plugin'

const p: Partial<IModuleConf> = {
  renderElems: [renderParagraphConf],
  elemsToHtml: [pToHtmlConf],
  parseElemsHtml: [parseParagraphHtmlConf],
  editorPlugin: withParagraph,
}

export default p


================================================
FILE: packages/basic-modules/src/modules/paragraph/parse-elem-html.ts
================================================
/**
 * @description parse html
 * @author wangfupeng
 */

import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { ParagraphElement } from './custom-types'
import $, { DOMElement } from '../../utils/dom'

function parseParagraphHtml(
  elem: DOMElement,
  children: Descendant[],
  editor: IDomEditor
): ParagraphElement {
  const $elem = $(elem)

  children = children.filter(child => {
    if (Text.isText(child)) return true
    if (editor.isInline(child)) return true
    return false
  })

  // 无 children ,则用纯文本
  if (children.length === 0) {
    children = [{ text: $elem.text().replace(/\s+/gm, ' ') }]
  }

  return {
    type: 'paragraph',
    // @ts-ignore
    children,
  }
}

export const parseParagraphHtmlConf = {
  selector: 'p:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性
  parseElemHtml: parseParagraphHtml,
}


================================================
FILE: packages/basic-modules/src/modules/paragraph/plugin.ts
================================================
/**
 * @description editor 插件,重写 editor API
 * @author wangfupeng
 */

import {
  Editor,
  Element as SlateElement,
  Transforms,
  Node as SlateNode,
  Text as SlateText,
} from 'slate'
import { IDomEditor } from '@wangeditor/core'

function deleteHandler(newEditor: IDomEditor): boolean {
  const [nodeEntry] = Editor.nodes(newEditor, {
    match: n => newEditor.children[0] === n, // editor 第一个节点
    mode: 'highest', // 最高层级
  })
  if (nodeEntry == null) return false

  const n = nodeEntry[0]
  if (!SlateElement.isElement(n)) return false
  if (n.type === 'paragraph') return false // 命中了 paragraph ,则不再继续判断
  if (SlateNode.string(n) !== '') return false // 未删除全部内容,则不再继续判断

  const { children = [] } = n
  if (!SlateText.isText(children[0])) return false // n.children 不是 text (如 table),则不再继续判断

  // 至此,就命中了一个(非 paragraph)+(children 都是 text)+(内容为空)的顶级 node ,如 header blockQuote 等
  // 然后,将其却换为 paragraph
  Transforms.setNodes(newEditor, {
    type: 'paragraph',
  })
  return true
}

function withParagraph<T extends IDomEditor>(editor: T): T {
  const { deleteBackward, deleteForward, insertText, insertBreak } = editor
  const newEditor = editor

  // 删除非 p 的文本 elem(如 header blockQuote 等),删除没有内容时,切换为 p
  newEditor.deleteBackward = unit => {
    const res = deleteHandler(newEditor)
    if (res) return // 命中结果,则 return

    // 执行默认的删除
    deleteBackward(unit)
  }
  newEditor.deleteForward = unit => {
    const res = deleteHandler(newEditor)
    if (res) return // 命中结果,则 return

    // 执行默认的删除
    deleteForward(unit)
  }

  // 返回 editor ,重要!
  return newEditor
}

export default withParagraph


================================================
FILE: packages/basic-modules/src/modules/paragraph/render-elem.tsx
================================================
/**
 * @description render paragraph elem
 * @author wangfupeng
 */

import { Element as SlateElement } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor } from '@wangeditor/core'

/**
 * render paragraph elem
 * @param elemNode slate elem
 * @param children children
 * @param editor editor
 * @returns vnode
 */
function renderParagraph(
  elemNode: SlateElement,
  children: VNode[] | null,
  editor: IDomEditor
): VNode {
  const vnode = <p>{children}</p>
  return vnode
}

export const renderParagraphConf = {
  type: 'paragraph',
  renderElem: renderParagraph,
}


================================================
FILE: packages/basic-modules/src/modules/text-style/custom-types.ts
================================================
/**
 * @description 自定义 element
 * @author wangfupeng
 */

//【注意】需要把自定义的 Text 引入到最外层的 custom-types.d.ts

export type StyledText = {
  text: string
  bold?: boolean
  code?: boolean
  italic?: boolean
  through?: boolean
  underline?: boolean
  sup?: boolean
  sub?: boolean
}


================================================
FILE: packages/basic-modules/src/modules/text-style/helper.ts
================================================
/**
 * @description helper
 * @author wangfupeng
 */

import { Editor, Node } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'

export function isMenuDisabled(editor: IDomEditor, mark?: string): boolean {
  if (editor.selection == null) return true

  const [match] = Editor.nodes(editor, {
    match: n => {
      const type = DomEditor.getNodeType(n)

      if (type === 'pre') return true // 代码块
      if (Editor.isVoid(editor, n)) return true // void node

      return false
    },
    universal: true,
  })

  // 命中,则禁用
  if (match) return true
  return false
}

export function removeMarks(editor: IDomEditor, textNode: Node) {
  // 遍历 text node 属性,清除样式
  const keys = Object.keys(textNode as object)
  keys.forEach(key => {
    if (key === 'text') {
      // 保留 text 属性,text node 必须的
      return
    }
    // 其他属性,全部清除
    Editor.removeMark(editor, key)
  })
}


================================================
FILE: packages/basic-modules/src/modules/text-style/index.ts
================================================
/**
 * @description text style entry
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { styleToHtml } from './style-to-html'
import { parseStyleHtml } from './parse-style-html'
import {
  boldMenuConf,
  underlineMenuConf,
  italicMenuConf,
  throughMenuConf,
  codeMenuConf,
  subMenuConf,
  supMenuConf,
  clearStyleMenuConf,
} from './menu/index'

const textStyle: Partial<IModuleConf> = {
  renderStyle,
  menus: [
    boldMenuConf,
    underlineMenuConf,
    italicMenuConf,
    throughMenuConf,
    codeMenuConf,
    subMenuConf,
    supMenuConf,
    clearStyleMenuConf,
  ],
  styleToHtml,
  parseStyleHtml,
}

export default textStyle


================================================
FILE: packages/basic-modules/src/modules/text-style/menu/BaseMenu.ts
================================================
/**
 * @description simply style base menu
 * @author wangfupeng
 */

import { Editor } from 'slate'
import { IButtonMenu, IDomEditor } from '@wangeditor/core'
import { isMenuDisabled } from '../helper'

abstract class BaseMenu implements IButtonMenu {
  abstract readonly mark: string
  protected readonly marksNeedToRemove: string[] = [] // 增加 mark 的同时,需要移除哪些 mark (互斥,不能共存的)
  abstract readonly title: string
  abstract readonly iconSvg: string
  abstract readonly hotkey: string
  readonly tag = 'button'

  /**
   * 获取:是否有 mark
   * @param editor editor
   */
  getValue(editor: IDomEditor): string | boolean {
    const mark = this.mark
    const curMarks = Editor.marks(editor)

    // 当 curMarks 存在时,说明用户手动设置,以 curMarks 为准
    if (curMarks) {
      return curMarks[mark]
    } else {
      const [match] = Editor.nodes(editor, {
        // @ts-ignore
        match: n => n[mark] === true,
      })
      return !!match
    }
  }

  isActive(editor: IDomEditor): boolean {
    const isMark = this.getValue(editor)
    return !!isMark
  }

  isDisabled(editor: IDomEditor): boolean {
    return isMenuDisabled(editor, this.mark)
  }

  /**
   * 执行命令
   * @param editor editor
   * @param value 是否有 mark
   */
  exec(editor: IDomEditor, value: string | boolean) {
    const { mark, marksNeedToRemove } = this
    if (value) {
      // 已,则取消
      editor.removeMark(mark)
    } else {
      // 没有,则执行
      editor.addMark(mark, true)

      // 移除互斥、不能共存的 marks
      if (marksNeedToRemove) {
        marksNeedToRemove.forEach(m => editor.removeMark(m))
      }
    }
  }
}

export default BaseMenu


================================================
FILE: packages/basic-modules/src/modules/text-style/menu/BoldMenu.ts
================================================
/**
 * @description bold menu
 * @author wangfupeng
 */

import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { BOLD_SVG } from '../../../constants/icon-svg'

class BoldMenu extends BaseMenu {
  readonly mark = 'bold'
  readonly title = t('textStyle.bold')
  readonly iconSvg = BOLD_SVG
  readonly hotkey = 'mod+b'
}

export default BoldMenu


================================================
FILE: packages/basic-modules/src/modules/text-style/menu/ClearStyleMenu.ts
================================================
/**
 * @description clear style menu
 * @author wangfupeng
 */

import { Editor, Text } from 'slate'
import { IButtonMenu, IDomEditor, t } from '@wangeditor/core'
import { ERASER_SVG } from '../../../constants/icon-svg'
import { isMenuDisabled, removeMarks } from '../helper'

class ClearStyleMenu implements IButtonMenu {
  readonly title = t('textStyle.clear')
  readonly iconSvg = ERASER_SVG
  readonly tag = 'button'

  getValue(editor: IDomEditor): string | boolean {
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    return false
  }

  isDisabled(editor: IDomEditor): boolean {
    return isMenuDisabled(editor)
  }

  /**
   * 执行命令
   * @param editor editor
   * @param value 是否有 mark
   */
  exec(editor: IDomEditor, value: string | boolean) {
    // 获取所有 text node
    const nodeEntries = Editor.nodes(editor, {
      match: n => Text.isText(n),
      universal: true,
    })
    for (const nodeEntry of nodeEntries) {
      // 单个 text node
      const n = nodeEntry[0]
      removeMarks(editor, n)
    }
  }
}

export default ClearStyleMenu


================================================
FILE: packages/basic-modules/src/modules/text-style/menu/CodeMenu.ts
================================================
/**
 * @description code menu
 * @author wangfupeng
 */

import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { CODE_SVG } from '../../../constants/icon-svg'

class CodeMenu extends BaseMenu {
  readonly mark = 'code'
  readonly title = t('textStyle.code')
  readonly iconSvg = CODE_SVG
  readonly hotkey = 'mod+e'
}

export default CodeMenu


================================================
FILE: packages/basic-modules/src/modules/text-style/menu/ItalicMenu.ts
================================================
/**
 * @description italic menu
 * @author wangfupeng
 */

import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { ITALIC_SVG } from '../../../constants/icon-svg'

class ItalicMenu extends BaseMenu {
  readonly mark = 'italic'
  readonly title = t('textStyle.italic')
  readonly iconSvg = ITALIC_SVG
  readonly hotkey = 'mod+i'
}

export default ItalicMenu


================================================
FILE: packages/basic-modules/src/modules/text-style/menu/SubMenu.ts
================================================
/**
 * @description sub menu
 * @author wangfupeng
 */

import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { SUB_SVG } from '../../../constants/icon-svg'

class SubMenu extends BaseMenu {
  readonly mark = 'sub'
  readonly marksNeedToRemove = ['sup'] // sub 和 sup 不能共存
  readonly title = t('textStyle.sub')
  readonly iconSvg = SUB_SVG
  readonly hotkey = ''
}

export default SubMenu


================================================
FILE: packages/basic-modules/src/modules/text-style/menu/SupMenu.ts
================================================
/**
 * @description sup menu
 * @author wangfupeng
 */

import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { SUP_SVG } from '../../../constants/icon-svg'

class SupMenu extends BaseMenu {
  readonly mark = 'sup'
  readonly marksNeedToRemove = ['sub'] // sup 和 sub 不能共存
  readonly title = t('textStyle.sup')
  readonly iconSvg = SUP_SVG
  readonly hotkey = ''
}

export default SupMenu


================================================
FILE: packages/basic-modules/src/modules/text-style/menu/ThroughMenu.ts
================================================
/**
 * @description through menu
 * @author wangfupeng
 */

import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { THROUGH_SVG } from '../../../constants/icon-svg'

class ThroughMenu extends BaseMenu {
  readonly mark = 'through'
  readonly title = t('textStyle.through')
  readonly iconSvg = THROUGH_SVG
  readonly hotkey = 'mod+shift+x'
}

export default ThroughMenu


================================================
FILE: packages/basic-modules/src/modules/text-style/menu/UnderlineMenu.ts
================================================
/**
 * @description underline menu
 * @author wangfupeng
 */

import { t } from '@wangeditor/core'
import BaseMenu from './BaseMenu'
import { UNDER_LINE_SVG } from '../../../constants/icon-svg'

class UnderlineMenu extends BaseMenu {
  readonly mark = 'underline'
  readonly title = t('textStyle.underline')
  readonly iconSvg = UNDER_LINE_SVG
  readonly hotkey = 'mod+u'
}

export default UnderlineMenu


================================================
FILE: packages/basic-modules/src/modules/text-style/menu/index.ts
================================================
/**
 * @description menu entry
 * @author wangfupeng
 */

import BoldMenu from './BoldMenu'
import CodeMenu from './CodeMenu'
import ItalicMenu from './ItalicMenu'
import ThroughMenu from './ThroughMenu'
import UnderlineMenu from './UnderlineMenu'
import SubMenu from './SubMenu'
import SupMenu from './SupMenu'
import ClearStyleMenu from './ClearStyleMenu'

export const boldMenuConf = {
  key: 'bold',
  factory() {
    return new BoldMenu()
  },
}

export const codeMenuConf = {
  key: 'code',
  factory() {
    return new CodeMenu()
  },
}

export const italicMenuConf = {
  key: 'italic',
  factory() {
    return new ItalicMenu()
  },
}

export const throughMenuConf = {
  key: 'through',
  factory() {
    return new ThroughMenu()
  },
}

export const underlineMenuConf = {
  key: 'underline',
  factory() {
    return new UnderlineMenu()
  },
}

export const supMenuConf = {
  key: 'sup',
  factory() {
    return new SupMenu()
  },
}

export const subMenuConf = {
  key: 'sub',
  factory() {
    return new SubMenu()
  },
}

export const clearStyleMenuConf = {
  key: 'clearStyle',
  factory() {
    return new ClearStyleMenu()
  },
}


================================================
FILE: packages/basic-modules/src/modules/text-style/parse-style-html.ts
================================================
/**
 * @description parse style html
 * @author wangfupeng
 */

import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { StyledText } from './custom-types'
import $, { Dom7Array, DOMElement } from '../../utils/dom'

/**
 * $text 是否匹配 tags
 * @param $text $text
 * @param selector selector 如 'b,strong' 或 'sub'
 */
function isMatch($text: Dom7Array, selector: string): boolean {
  if ($text.length === 0) return false

  if ($text[0].matches(selector)) return true

  if ($text.find(selector).length > 0) return true

  return false
}

export function parseStyleHtml(
  textElem: DOMElement,
  node: Descendant,
  editor: IDomEditor
): Descendant {
  const $text = $(textElem)

  if (!Text.isText(node)) return node

  const textNode = node as StyledText

  // bold
  if (isMatch($text, 'b,strong')) {
    textNode.bold = true
  }

  // italic
  if (isMatch($text, 'i,em')) {
    textNode.italic = true
  }

  // underline
  if (isMatch($text, 'u')) {
    textNode.underline = true
  }

  // through
  if (isMatch($text, 's,strike')) {
    textNode.through = true
  }

  // sub
  if (isMatch($text, 'sub')) {
    textNode.sub = true
  }

  // sup
  if (isMatch($text, 'sup')) {
    textNode.sup = true
  }

  // code
  if (isMatch($text, 'code')) {
    textNode.code = true
  }

  return textNode
}


================================================
FILE: packages/basic-modules/src/modules/text-style/render-style.tsx
================================================
/**
 * @description render text style
 * @author wangfupeng
 */

import { Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { StyledText } from './custom-types'

/**
 * 添加样式
 * @param node slate text
 * @param vnode vnode
 * @returns vnode
 */
export function renderStyle(node: Descendant, vnode: VNode): VNode {
  const { bold, italic, underline, code, through, sub, sup } = node as StyledText
  let styleVnode: VNode = vnode

  // color bgColor 在另外的菜单

  if (bold) {
    styleVnode = <strong>{styleVnode}</strong>
  }
  if (code) {
    styleVnode = <code>{styleVnode}</code>
  }
  if (italic) {
    styleVnode = <em>{styleVnode}</em>
  }
  if (underline) {
    styleVnode = <u>{styleVnode}</u>
  }
  if (through) {
    styleVnode = <s>{styleVnode}</s>
  }
  if (sub) {
    styleVnode = <sub>{styleVnode}</sub>
  }
  if (sup) {
    styleVnode = <sup>{styleVnode}</sup>
  }

  return styleVnode
}


================================================
FILE: packages/basic-modules/src/modules/text-style/style-to-html.ts
================================================
/**
 * @description text to html
 * @author wangfupeng
 */

import { Text, Descendant } from 'slate'
import { StyledText } from './custom-types'
import $, { getOuterHTML, getTagName, isPlainText } from '../../utils/dom'

//【注意】color bgColor fontSize fontFamily 在另外的菜单

/**
 * 生成加了样式的 text html
 * @param textNode textNode
 * @param html text html
 */
function genStyledHtml(textNode: Descendant, html: string): string {
  let styledHtml = html
  const { bold, italic, underline, code, through, sub, sup } = textNode as StyledText
  if (bold) styledHtml = `<strong>${styledHtml}</strong>`
  if (code) styledHtml = `<code>${styledHtml}</code>`
  if (italic) styledHtml = `<em>${styledHtml}</em>`
  if (underline) styledHtml = `<u>${styledHtml}</u>`
  if (through) styledHtml = `<s>${styledHtml}</s>`
  if (sub) styledHtml = `<sub>${styledHtml}</sub>`
  if (sup) styledHtml = `<sup>${styledHtml}</sup>`
  return styledHtml
}

/**
 * style to html
 * @param textNode slate text node
 * @param textHtml text html
 * @returns styled html
 */
export function styleToHtml(textNode: Descendant, textHtml: string): string {
  if (!Text.isText(textNode)) return textHtml

  if (isPlainText(textHtml)) {
    // textHtml 是纯文本,而不是 html tag
    return genStyledHtml(textNode, textHtml)
  }

  // textHtml 是 html tag
  const $text = $(textHtml)
  const tagName = getTagName($text)

  if (tagName === 'br') {
    return genStyledHtml(textNode, '<br>')
  }

  let innerHtml = $text.html()
  innerHtml = genStyledHtml(textNode, innerHtml)
  $text.html(innerHtml)
  return getOuterHTML($text)
}


================================================
FILE: packages/basic-modules/src/modules/todo/custom-types.ts
================================================
/**
 * @description 自定义 element
 * @author wangfupeng
 */

import { Text } from 'slate'

//【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts

export type TodoElement = {
  type: 'todo'
  checked: boolean
  children: Text[]
}


================================================
FILE: packages/basic-modules/src/modules/todo/elem-to-html.ts
================================================
/**
 * @description to html
 * @author wangfupeng
 */

import { Element } from 'slate'
import { TodoElement } from './custom-types'

function todoToHtml(elem: Element, childrenHtml: string): string {
  const { checked } = elem as TodoElement
  const checkedAttr = checked ? 'checked' : ''
  return `<div data-w-e-type="todo"><input type="checkbox" disabled ${checkedAttr}>${childrenHtml}</div>`
}

export const todoToHtmlConf = {
  type: 'todo',
  elemToHtml: todoToHtml,
}


================================================
FILE: packages/basic-modules/src/modules/todo/index.ts
================================================
/**
 * @description todo entry
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { renderTodoConf } from './render-elem'
import withTodo from './plugin'
import { todoMenuConf } from './menu/index'
import { todoToHtmlConf } from './elem-to-html'
import { parseHtmlConf } from './parse-elem-html'
import { preParseHtmlConf } from './pre-parse-html'

const todo: Partial<IModuleConf> = {
  renderElems: [renderTodoConf],
  elemsToHtml: [todoToHtmlConf],
  preParseHtml: [preParseHtmlConf],
  parseElemsHtml: [parseHtmlConf],
  menus: [todoMenuConf],
  editorPlugin: withTodo,
}

export default todo


================================================
FILE: packages/basic-modules/src/modules/todo/menu/Todo.ts
================================================
/**
 * @description Todo menu
 * @author wangfupeng
 */

import { Editor, Element, Transforms } from 'slate'
import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core'
import { CHECK_BOX_SVG } from '../../../constants/icon-svg'

class TodoMenu implements IButtonMenu {
  readonly title = t('todo.todo')
  readonly iconSvg = CHECK_BOX_SVG
  readonly tag = 'button'

  getValue(editor: IDomEditor): string | boolean {
    // 无需获取 val
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    return !!DomEditor.getSelectedNodeByType(editor, 'todo')
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true

    const selectedElems = DomEditor.getSelectedElems(editor)
    const notMatch = selectedElems.some((elem: Element) => {
      if (Editor.isVoid(editor, elem) && Editor.isBlock(editor, elem)) return true

      const { type } = elem as Element
      if (['pre', 'table', 'list-item'].includes(type)) return true
    })
    if (notMatch) return true

    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    const active = this.isActive(editor)
    Transforms.setNodes(editor, { type: active ? 'paragraph' : 'todo' })
  }
}

export default TodoMenu


================================================
FILE: packages/basic-modules/src/modules/todo/menu/index.ts
================================================
/**
 * @description todo menu entry
 * @author wangfupeng
 */

import TodoMenu from './Todo'

export const todoMenuConf = {
  key: 'todo',
  factory() {
    return new TodoMenu()
  },
}


================================================
FILE: packages/basic-modules/src/modules/todo/parse-elem-html.ts
================================================
/**
 * @description parse html
 * @author wangfupeng
 */

import { Descendant, Text } from 'slate'
import { IDomEditor } from '@wangeditor/core'
import { TodoElement } from './custom-types'
import $, { DOMElement } from '../../utils/dom'

function parseHtml(elem: DOMElement, children: Descendant[], editor: IDomEditor): TodoElement {
  const $elem = $(elem)

  children = children.filter(child => {
    if (Text.isText(child)) return true
    if (editor.isInline(child)) return true
    return false
  })

  // 无 children ,则用纯文本
  if (children.length === 0) {
    children = [{ text: $elem.text().replace(/\s+/gm, ' ') }]
  }

  // 获取 checked
  let checked = false
  const $input = $elem.find('input[type="checkbox"]')
  if ($input.attr('checked') != null) {
    checked = true
  }

  return {
    type: 'todo',
    checked,
    // @ts-ignore
    children,
  }
}

export const parseHtmlConf = {
  selector: 'div[data-w-e-type="todo"]',
  parseElemHtml: parseHtml,
}


================================================
FILE: packages/basic-modules/src/modules/todo/plugin.ts
================================================
/**
 * @description editor 插件,重写 editor API
 * @author wangfupeng
 */

import { Node, Transforms, Range } from 'slate'
import { DomEditor, IDomEditor } from '@wangeditor/core'

function withTodo<T extends IDomEditor>(editor: T): T {
  const { deleteBackward } = editor
  const newEditor = editor

  /**
   * 删除 todo 无内容时,变为 paragraph
   */
  newEditor.deleteBackward = unit => {
    const { selection } = editor

    if (selection && Range.isCollapsed(selection)) {
      // 获取选中的 todo
      const selectedTodo = DomEditor.getSelectedNodeByType(editor, 'todo')
      if (selectedTodo) {
        if (Node.string(selectedTodo).length === 0) {
          // 当前 todo 已经没有文字,则转换为 paragraph
          Transforms.setNodes(editor, { type: 'paragraph' }, { mode: 'highest' })
          return
        }
      }
    }

    deleteBackward(unit)
  }

  return newEditor
}

export default withTodo


================================================
FILE: packages/basic-modules/src/modules/todo/pre-parse-html.ts
================================================
/**
 * @description pre parse html
 * @author wangfupeng
 */

import $, { DOMElement } from '../../utils/dom'

/**
 * pre-prase todo ,兼容 V4
 * @param elem elem
 */
function preParse(elem: DOMElement): DOMElement {
  const $elem = $(elem)

  // $elem 格式如
  // <ul class="w-e-todo"><li><span contenteditable="false"><input type="checkbox"/></span>hello <b>world</b></li></ul>
  const $li = $elem.find('li')

  const $container = $('<div data-w-e-type="todo"></div>')

  // 1. 把 input 移动到 $container
  const $input = $li.find('input[type]')
  $container.append($input)

  // 2. 删除之前包裹 input 的 span
  const $spanForInput = $li.children()[0]
  $spanForInput.remove()

  // 3. 再把剩余的内容移动到 $container (有纯文本内容,不能用 children ,得用 innerHTML)
  $container[0].innerHTML = $container[0].innerHTML + $li[0].innerHTML

  return $container[0]
}

export const preParseHtmlConf = {
  selector: 'ul.w-e-todo', // 匹配 v4 todo
  preParseHtml: preParse,
}


================================================
FILE: packages/basic-modules/src/modules/todo/render-elem.tsx
================================================
/**
 * @description render todo
 * @author wangfupeng
 */

import { Element as SlateElement, Transforms } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor, DomEditor } from '@wangeditor/core'
import { TodoElement } from './custom-types'

/**
 * render todo elem
 * @param elemNode slate elem
 * @param children children
 * @param editor editor
 * @returns vnode
 */
function renderTodo(elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
  // 判断 disabled
  let disabled = false
  if (editor.isDisabled()) disabled = true

  const { checked } = elemNode as TodoElement
  const vnode = (
    <div style={{ margin: '5px 0' }}>
      <span contentEditable={false} style={{ marginRight: '0.5em' }}>
        <input
          type="checkbox"
          checked={checked}
          disabled={disabled}
          on={{
            change: event => {
              const path = DomEditor.findPath(editor, elemNode)
              const newProps: Partial<TodoElement> = {
                // @ts-ignore
                checked: event.target.checked,
              }
              Transforms.setNodes(editor, newProps, { at: path })
            },
          }}
        />
      </span>
      <span>{children}</span>
    </div>
  )

  return vnode
}

const renderTodoConf = {
  type: 'todo', // 和 elemNode.type 一致
  renderElem: renderTodo,
}

export { renderTodoConf }


================================================
FILE: packages/basic-modules/src/modules/undo-redo/index.ts
================================================
/**
 * @description undo redo
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { redoMenuConf, undoMenuConf } from './menu/index'

const undoRedo: Partial<IModuleConf> = {
  menus: [redoMenuConf, undoMenuConf],
}

export default undoRedo


================================================
FILE: packages/basic-modules/src/modules/undo-redo/menu/RedoMenu.ts
================================================
/**
 * @description redo menu
 * @author wangfupeng
 */

import { IButtonMenu, IDomEditor, t } from '@wangeditor/core'
import { REDO_SVG } from '../../../constants/icon-svg'

class RedoMenu implements IButtonMenu {
  title = t('undo.redo')
  iconSvg = REDO_SVG
  tag = 'button'

  getValue(editor: IDomEditor): string | boolean {
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    return false
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    if (typeof editor.redo === 'function') {
      editor.redo()
    }
  }
}

export default RedoMenu


================================================
FILE: packages/basic-modules/src/modules/undo-redo/menu/UndoMenu.ts
================================================
/**
 * @description undo menu
 * @author wangfupeng
 */

import { IButtonMenu, IDomEditor, t } from '@wangeditor/core'
import { UNDO_SVG } from '../../../constants/icon-svg'

class UndoMenu implements IButtonMenu {
  title = t('undo.undo')
  iconSvg = UNDO_SVG
  tag = 'button'

  getValue(editor: IDomEditor): string | boolean {
    return ''
  }

  isActive(editor: IDomEditor): boolean {
    return false
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true
    return false
  }

  exec(editor: IDomEditor, value: string | boolean) {
    if (typeof editor.undo === 'function') {
      editor.undo()
    }
  }
}

export default UndoMenu


================================================
FILE: packages/basic-modules/src/modules/undo-redo/menu/index.ts
================================================
/**
 * @description menu entry
 * @author wangfupeng
 */

import RedoMenu from './RedoMenu'
import UndoMenu from './UndoMenu'

export const undoMenuConf = {
  key: 'undo',
  factory() {
    return new UndoMenu()
  },
}

export const redoMenuConf = {
  key: 'redo',
  factory() {
    return new RedoMenu()
  },
}


================================================
FILE: packages/basic-modules/src/utils/dom.ts
================================================
/**
 * @description DOM 操作
 * @author wangfupeng
 */

import $, {
  css,
  append,
  prepend,
  addClass,
  removeClass,
  hasClass,
  on,
  off,
  focus,
  attr,
  hide,
  show,
  parents,
  dataset,
  val,
  text,
  removeAttr,
  children,
  html,
  remove,
  find,
  width,
  height,
  Dom7Array,
  filter,
  empty,
} from 'dom7'
export { Dom7Array } from 'dom7'

if (css) $.fn.css = css
if (append) $.fn.append = append
if (prepend) $.fn.prepend = prepend
if (addClass) $.fn.addClass = addClass
if (removeClass) $.fn.removeClass = removeClass
if (hasClass) $.fn.hasClass = hasClass
if (on) $.fn.on = on
if (off) $.fn.off = off
if (focus) $.fn.focus = focus
if (attr) $.fn.attr = attr
if (removeAttr) $.fn.removeAttr = removeAttr
if (hide) $.fn.hide = hide
if (show) $.fn.show = show
if (parents) $.fn.parents = parents
if (dataset) $.fn.dataset = dataset
if (val) $.fn.val = val
if (text) $.fn.text = text
if (html) $.fn.html = html
if (children) $.fn.children = children
if (remove) $.fn.remove = remove
if (find) $.fn.find = find
if (width) $.fn.width = width
if (height) $.fn.height = height
if (filter) $.fn.filter = filter
if (empty) $.fn.empty = empty

export default $

/**
 * 判断 str 是不是纯字符串,而不是 html tag
 * @param str str
 */
export function isPlainText(str: string) {
  const $container = $(`<div>${str}</div>`)

  // 获取 children length (过滤 `<br>`)
  const childrenLength = $container.children().filter((child: DOMElement) => {
    if (child.tagName === 'BR') return false
    return true
  }).length

  return childrenLength === 0
}

/**
 * 获取 outerHTML
 * @param $elem dom7 elem
 */
export function getOuterHTML($elem: Dom7Array) {
  if ($elem.length === 0) return ''
  return $elem[0].outerHTML
}

/**
 * 获取 tagName lower-case
 * @param $elem $elem
 */
export function getTagName($elem: Dom7Array): string {
  if ($elem.length) return $elem[0].tagName.toLowerCase()
  return ''
}

/**
 * 获取 $elem 某一个 style 值
 * @param $elem $elem
 * @param styleKey style key
 */
export function getStyleValue($elem: Dom7Array, styleKey: string): string {
  let res = ''

  const styleStr = $elem.attr('style') || '' // 如 'line-height: 2.5; color: red;'
  const styleArr = styleStr.split(';') // 如 ['line-height: 2.5', ' color: red', '']
  const length = styleArr.length
  for (let i = 0; i < length; i++) {
    const styleItemStr = styleArr[i] // 如 'line-height: 2.5'
    if (styleItemStr) {
      const arr = styleItemStr.split(':') // ['line-height', ' 2.5']
      if (arr[0].trim() === styleKey) {
        res = arr[1].trim()
      }
    }
  }

  return res
}

// COMPAT: This is required to prevent TypeScript aliases from doing some very
// weird things for Slate's types with the same name as globals. (2019/11/27)
// https://github.com/microsoft/TypeScript/issues/35002
import DOMNode = globalThis.Node
import DOMComment = globalThis.Comment
import DOMElement = globalThis.Element
import DOMText = globalThis.Text
import DOMRange = globalThis.Range
import DOMSelection = globalThis.Selection
import DOMStaticRange = globalThis.StaticRange
export { DOMNode, DOMComment, DOMElement, DOMText, DOMRange, DOMSelection, DOMStaticRange }


================================================
FILE: packages/basic-modules/src/utils/util.ts
================================================
/**
 * @description 工具函数
 * @author wangfupeng
 */

import { nanoid } from 'nanoid'

/**
 * 获取随机数字符串
 * @param prefix 前缀
 * @returns 随机数字符串
 */
export function genRandomStr(prefix: string = 'r'): string {
  return `${prefix}-${nanoid()}`
}

export function replaceSymbols(str: string) {
  return str.replace(/</g, '&lt;').replace(/>/g, '&gt;')
}


================================================
FILE: packages/basic-modules/src/utils/vdom.ts
================================================
/**
 * @description vdom utils fn
 * @author wangfupeng
 */

import { VNode, VNodeStyle, Dataset } from 'snabbdom'

// /**
//  * 给 vnode 添加 dataset
//  * @param vnode vnode
//  * @param newDataset { key: val }
//  */
// export function addVnodeDataset(vnode: VNode, newDataset: Dataset) {
//   if (vnode.data == null) vnode.data = {}
//   const data = vnode.data
//   if (data.dataset == null) data.dataset = {}

//   Object.assign(data.dataset, newDataset)
// }

/**
 * 给 vnode 添加样式
 * @param vnode vnode
 * @param newStyle { key: val }
 */
export function addVnodeStyle(vnode: VNode, newStyle: VNodeStyle) {
  if (vnode.data == null) vnode.data = {}
  const data = vnode.data
  if (data.style == null) data.style = {}

  Object.assign(data.style, newStyle)
}


================================================
FILE: packages/basic-modules/tsconfig.json
================================================
{
  "compilerOptions": {},
  "extends": "../../tsconfig.json",
  "include": [
    "./src/**/*",
    "../custom-types.d.ts"
  ]
}

================================================
FILE: packages/code-highlight/CHANGELOG.md
================================================
# Change Log

All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

## [1.0.3](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/code-highlight@1.0.2...@wangeditor/code-highlight@1.0.3) (2022-09-14)


### Bug Fixes

* 代码块 - 增加 lua groovy  语言 ([ef4f62a](https://github.com/wangeditor-team/wangEditor/commit/ef4f62a876e95995f7c8f6f41d8d44b2505dd5f6))





## [1.0.2](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/code-highlight@1.0.1...@wangeditor/code-highlight@1.0.2) (2022-06-02)


### Bug Fixes

* issue 4308 - 自定义字号、字体无法回显 ([ad38b8c](https://github.com/wangeditor-team/wangEditor/commit/ad38b8ce6dbcff1d65785c8d6701238ad351f562))





## 1.0.1 (2022-04-18)


### Bug Fixes

* 更新各包之间依赖版本 ([75c552c](https://github.com/wangeditor-team/wangEditor/commit/75c552cc8ed54765bebb86a7ec5329a7fc79e85f))
* 修复 pnpm 安装 @wangeditor/editor 出现警告的问题 ([4087fbe](https://github.com/wangeditor-team/wangEditor/commit/4087fbee01c76bdd55e747a5e86c5e4a8d6a8353))
* 移除了每个包下的 publishConfig directory 配置 ([16559f0](https://github.com/wangeditor-team/wangEditor/commit/16559f052545c111318be760e64291a521bdcc65))
* 粘贴 <code> 代码块出错 ([fc44d9f](https://github.com/wangeditor-team/wangEditor/commit/fc44d9ff36cb9566d9dc5490b4be14f2e5bd3f3c))
* rename es module filename ([1821d4e](https://github.com/wangeditor-team/wangEditor/commit/1821d4eef49e64efcb41b848849ca7a5e6472044))
* table - elemToHtml ([e36e609](https://github.com/wangeditor-team/wangEditor/commit/e36e6092ef721723169afc8bf0560a47ac9f4dfc))


### Features

* code highlight ([42b2f8d](https://github.com/wangeditor-team/wangEditor/commit/42b2f8d192e2433593c11ad0b8424737f6cffb58))
* i18n ([c11b244](https://github.com/wangeditor-team/wangEditor/commit/c11b2440f91b99d40bca18b675c66a22b6e160c9))
* parse html ([2a5eace](https://github.com/wangeditor-team/wangEditor/commit/2a5eace00f33cded50b68e8164748ec2480213fd))
* toHtml 机制 ([1c4d872](https://github.com/wangeditor-team/wangEditor/commit/1c4d8729f84aaab6a448f23064b34a20596305e9))
* upload video ([ac8e6f8](https://github.com/wangeditor-team/wangEditor/commit/ac8e6f8b5258e593714676a6f6be359ba525833c))


================================================
FILE: packages/code-highlight/README.md
================================================
# wangEditor code highlight

Code highlight module built in [wangEditor](https://www.wangeditor.com/) by default.


================================================
FILE: packages/code-highlight/__tests__/content.ts
================================================
/**
 * @description code content
 * @author wangfupeng
 */

export const text = 'const a = 100;'

export const textNode = { text: text }

export const language = 'javascript'

export const codeNode = {
  type: 'code',
  language,
  children: [textNode],
}

export const preNode = {
  type: 'pre',
  children: [codeNode],
}

export const content = [{ type: 'paragraph', children: [{ text: 'hello world' }] }, preNode]

export const textNodePath = [1, 0, 0]

export const codeLocation = {
  anchor: { offset: text.length, path: textNodePath },
  focus: { offset: text.length, path: textNodePath },
}

export const paragraphLocation = {
  anchor: { offset: 0, path: [0, 0] },
  focus: { offset: 0, path: [0, 0] },
}

describe('加一个 case 防止报错~', () => {
  it('1 + 1 = 2', () => {
    expect(1 + 1).toBe(2)
  })
})


================================================
FILE: packages/code-highlight/__tests__/decorate.test.ts
================================================
/**
 * @description code-highlight decorate test
 * @author wangfupeng
 */

import { IDomEditor } from '@wangeditor/core'
import createEditor from '../../../tests/utils/create-editor'
import codeHighLightDecorate from '../src/decorate/index'
import { content, textNode, textNodePath } from './content'

describe('code-highlight decorate', () => {
  let editor: IDomEditor | null = null

  beforeAll(() => {
    // 把 content 创建到一个编辑器中
    editor = createEditor({
      content,
    })
  })

  afterAll(() => {
    // 销毁 editor
    if (editor == null) return
    editor.destroy()
    editor = null
  })

  it('code-highlight decorate 拆分代码字符串', () => {
    const ranges = codeHighLightDecorate([textNode, textNodePath])
    expect(ranges.length).toBe(4) // 把 textNode 内容拆分为 4 段
  })
})


================================================
FILE: packages/code-highlight/__tests__/elem-to-html.test.ts
================================================
/**
 * @description code-hight elem-to-html
 * @author wangfupeng
 */

import { IDomEditor } from '@wangeditor/core'
import createEditor from '../../../tests/utils/create-editor'
import { codeToHtmlConf } from '../src/module/elem-to-html'
import { content, codeNode, language } from './content'

describe('code-highlight elem to html', () => {
  let editor: IDomEditor | null = null

  beforeAll(() => {
    // 把 content 创建到一个编辑器中
    editor = createEditor({
      content,
    })
  })

  afterAll(() => {
    // 销毁 editor
    if (editor == null) return
    editor.destroy()
    editor = null
  })

  it('codeNode to html', () => {
    expect(codeToHtmlConf.type).toBe('code')

    if (editor == null) throw new Error('editor is null')
    const text = 'var n = 100;'
    const html = codeToHtmlConf.elemToHtml(codeNode, text)
    expect(html).toBe(`<code class="language-${language}">${text}</code>`)
  })
})


================================================
FILE: packages/code-highlight/__tests__/parse-html.test.ts
================================================
/**
 * @description parse html test
 * @author wangfupeng
 */

import { $ } from 'dom7'
import { parseCodeStyleHtml } from '../src/module/parse-style-html'
import createEditor from '../../../tests/utils/create-editor'

describe('code highlight - parse style html', () => {
  const editor = createEditor()

  it('v5 format', () => {
    const $code = $('<code class="language-javascript"></code>') // v5 html format
    const code = { type: 'code', children: [{ text: 'var a = 100;' }] }

    const res = parseCodeStyleHtml($code[0], code, editor)
    expect(res).toEqual({
      type: 'code',
      language: 'javascript',
      children: [{ text: 'var a = 100;' }],
    })
  })

  it('v4 format', () => {
    const $code = $('<code class="Javascript"></code>') // v4 html format
    const code = { type: 'code', children: [{ text: 'var a = 100;' }] }

    const res = parseCodeStyleHtml($code[0], code, editor)
    expect(res).toEqual({
      type: 'code',
      language: 'javascript',
      children: [{ text: 'var a = 100;' }],
    })
  })
})


================================================
FILE: packages/code-highlight/__tests__/render-text-style.test.tsx
================================================
/**
 * @description code-highlight render text style test
 * @author wangfupeng
 */

import { renderStyle } from '../src/module/render-style'
import { jsx } from 'snabbdom'

describe('code-highlight render text style', () => {
  it('code text style', () => {
    const leafNode = { text: 'let', keyword: true } // 定义一个 keyword leaf text node
    const vnode = <span>let</span>

    // @ts-ignore 忽略 vnode 格式检查
    const newVnode = renderStyle(leafNode, vnode)
    expect(newVnode.data?.props?.className).toBe('token keyword')
  })
})


================================================
FILE: packages/code-highlight/__tests__/select-lang-menu.test.ts
================================================
/**
 * @description code-highlight select lang menu test
 * @author wangfupeng
 */

import { IDomEditor } from '@wangeditor/core'
import createEditor from '../../../tests/utils/create-editor'
import { content, codeLocation, paragraphLocation, language } from './content'
import SelectLangMenu from '../src/module/menu/SelectLangMenu'

describe('code-highlight select lang menu', () => {
  let editor: IDomEditor | null = null
  let menu: SelectLangMenu | null = null

  beforeAll(() => {
    // 创建 editor
    editor = createEditor({
      content,
    })

    // 创建 menu
    menu = new SelectLangMenu()
  })

  afterAll(() => {
    // 销毁 editor
    if (editor == null) return
    editor.destroy()
    editor = null

    // 销毁 menu
    menu = null
  })

  it('get langs and selected one', () => {
    if (editor == null || menu == null) throw new Error('editor or menu is null')

    // select codeNode
    editor.select(codeLocation)

    const langs = menu.getOptions(editor)

    // 包括多个 lang
    expect(langs.length).toBeGreaterThan(0)

    // 其中有一个 'plain text'
    const hasPlainText = langs.some(lang => lang.text === 'plain text' && lang.value === '')
    expect(hasPlainText).toBeTruthy()

    // 选中的语言
    const selectedLangs = langs.filter(lang => lang.selected)
    expect(selectedLangs.length).toBe(1)
    const selectedLang: any = selectedLangs[0] || {}
    expect(selectedLang.value).toBe(language)
  })

  it('menu active is always false', () => {
    if (editor == null || menu == null) throw new Error('editor or menu is null')

    expect(menu.isActive(editor)).toBeFalsy()
  })

  it('get menu value (selected lang)', () => {
    if (editor == null || menu == null) throw new Error('editor or menu is null')

    // select codeNode
    editor.select(codeLocation)
    expect(menu.getValue(editor)).toBe(language)

    // select paragraph
    editor.select(paragraphLocation)
    expect(menu.getValue(editor)).toBe('')
  })

  it('menu disable', () => {
    if (editor == null || menu == null) throw new Error('editor or menu is null')

    // deselect
    editor.deselect()
    expect(menu.isDisabled(editor)).toBeTruthy()

    // select paragraph
    editor.select(paragraphLocation)
    expect(menu.isDisabled(editor)).toBeTruthy()

    // select codeNode
    editor.select(codeLocation)
    expect(menu.isDisabled(editor)).toBeFalsy()
  })

  it('menu exec (change lang)', done => {
    if (editor == null || menu == null) throw new Error('editor or menu is null')

    // select codeNode
    editor.select(codeLocation)
    menu.exec(editor, 'html') // change lang

    setTimeout(() => {
      if (editor == null || menu == null) return

      editor.select(codeLocation)
      expect(menu.getValue(editor)).toBe('html')
      done()
    })
  })
})


================================================
FILE: packages/code-highlight/package.json
================================================
{
  "name": "@wangeditor/code-highlight",
  "version": "1.0.3",
  "description": "wangEditor code-highlight module",
  "author": "wangfupeng1988 <wangfupeng1988@163.com>",
  "contributors": [],
  "homepage": "https://github.com/wangeditor-team/wangEditor#readme",
  "license": "MIT",
  "types": "dist/code-highlight/src/index.d.ts",
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "browser": {
    "./dist/index.js": "./dist/index.js",
    "./dist/index.esm.js": "./dist/index.esm.js"
  },
  "directories": {
    "lib": "dist",
    "test": "__tests__"
  },
  "files": [
    "dist"
  ],
  "publishConfig": {
    "access": "public",
    "registry": "https://registry.npmjs.com/"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/wangeditor-team/wangEditor.git"
  },
  "scripts": {
    "test": "jest",
    "test-c": "jest --coverage",
    "dev": "cross-env NODE_ENV=development rollup -c rollup.config.js",
    "dev-watch": "cross-env NODE_ENV=development rollup -c rollup.config.js -w",
    "build": "cross-env NODE_ENV=production rollup -c rollup.config.js",
    "dev-size-stats": "cross-env NODE_ENV=development:size_stats rollup -c rollup.config.js",
    "size-stats": "cross-env NODE_ENV=production:size_stats rollup -c rollup.config.js"
  },
  "bugs": {
    "url": "https://github.com/wangeditor-team/wangEditor/issues"
  },
  "peerDependencies": {
    "@wangeditor/core": "1.x",
    "dom7": "^3.0.0",
    "slate": "^0.72.0",
    "snabbdom": "^3.1.0"
  },
  "dependencies": {
    "prismjs": "^1.23.0"
  },
  "devDependencies": {
    "@types/prismjs": "^1.16.5"
  }
}


================================================
FILE: packages/code-highlight/rollup.config.js
================================================
import { createRollupConfig, IS_PRD } from '../../build/create-rollup-config'
import pkg from './package.json'

const name = 'WangEditorCodeHighLight'

const configList = []

// esm
const esmConf = createRollupConfig({
  output: {
    file: pkg.module,
    format: 'esm',
    name,
  },
})
configList.push(esmConf)

// umd
const umdConf = createRollupConfig({
  output: {
    file: pkg.main,
    format: 'umd',
    name,
  },
})
configList.push(umdConf)

export default configList


================================================
FILE: packages/code-highlight/src/assets/index.less
================================================
// 样式参考 https://github.com/PrismJS/prism/blob/master/themes/prism.css
// TODO 开发 themes 主题,可以参考 prismjs 主题 https://github.com/PrismJS/prism/tree/master/themes

.w-e-text-container [data-slate-editor] pre>code {
	text-shadow: 0 1px white;
	font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
	text-align: left;
	white-space: pre;
	word-spacing: normal;
	word-break: normal;
	word-wrap: normal;
	line-height: 1.5;
	tab-size: 4;
	hyphens: none;

  padding: 1em;
	margin: .5em 0;
	overflow: auto;

  .token.comment,
  .token.prolog,
  .token.doctype,
  .token.cdata {
    color: slategray;
  }

  .token.punctuation {
    color: #999;
  }

  .token.namespace {
    opacity: .7;
  }

  .token.property,
  .token.tag,
  .token.boolean,
  .token.number,
  .token.constant,
  .token.symbol,
  .token.deleted {
    color: #905;
  }

  .token.selector,
  .token.attr-name,
  .token.string,
  .token.char,
  .token.builtin,
  .token.inserted {
    color: #690;
  }

  .token.operator,
  .token.entity,
  .token.url,
  .language-css .token.string,
  .style .token.string {
    color: #9a6e3a;
  }

  .token.atrule,
  .token.attr-value,
  .token.keyword {
    color: #07a;
  }

  .token.function,
  .token.class-name {
    color: #DD4A68;
  }

  .token.regex,
  .token.important,
  .token.variable {
    color: #e90;
  }

  .token.important,
  .token.bold {
    font-weight: bold;
  }
  .token.italic {
    font-style: italic;
  }

  .token.entity {
    cursor: help;
  }
}


================================================
FILE: packages/code-highlight/src/constants/svg.ts
================================================
/**
 * @description icon svg
 * @author wangfupeng
 */

/**
 * 【注意】svg 字符串的长度 ,否则会导致代码体积过大
 * 尽量选择 https://www.iconfont.cn/collections/detail?spm=a313x.7781069.0.da5a778a4&cid=20293
 * 找不到再从 iconfont.com 搜索
 */

export const JS_SVG =
  '<svg viewBox="0 0 1024 1024"><path d="M64 64v896h896V64H64z m487.6 698.8c0 87.2-51.2 127-125.8 127-67.4 0-106.4-34.8-126.4-77l68.6-41.4c13.2 23.4 25.2 43.2 54.2 43.2 27.6 0 45.2-10.8 45.2-53V475.4h84.2v287.4z m199.2 127c-78.2 0-128.8-37.2-153.4-86l68.6-39.6c18 29.4 41.6 51.2 83 51.2 34.8 0 57.2-17.4 57.2-41.6 0-28.8-22.8-39-61.4-56l-21-9c-60.8-25.8-101-58.4-101-127 0-63.2 48.2-111.2 123.2-111.2 53.6 0 92 18.6 119.6 67.4L800 580c-14.4-25.8-30-36-54.2-36-24.6 0-40.2 15.6-40.2 36 0 25.2 15.6 35.4 51.8 51.2l21 9c71.6 30.6 111.8 62 111.8 132.4 0 75.6-59.6 117.2-139.4 117.2z"></path></svg>'


================================================
FILE: packages/code-highlight/src/custom-types.ts
================================================
/**
 * @description 自定义 element
 * @author wangfupeng
 */

// 拷贝自 basic-modules/src/modules/code-block/custom-types.ts

type PureText = {
  text: string
}

export type PreElement = {
  type: 'pre'
  children: CodeElement[]
}

export type CodeElement = {
  type: 'code'
  language: string
  children: PureText[]
}


================================================
FILE: packages/code-highlight/src/decorate/index.ts
================================================
/**
 * @description code-highlight decorate
 * @author wangfupeng
 */

import { Node, NodeEntry, Range, Text } from 'slate'
import { DomEditor } from '@wangeditor/core'
import { getPrismTokens, getPrismTokenLength } from '../vendor/prism'
import { CodeElement } from '../custom-types'

/**
 * 获取 code elem
 * @param node text node
 */
function getCodeElem(textNode: Node): CodeElement | null {
  if (!Text.isText(textNode)) return null // 非文本 node

  const codeNode = DomEditor.getParentNode(null, textNode)
  if (codeNode && DomEditor.getNodeType(codeNode) === 'code') {
    const preNode = DomEditor.getParentNode(null, codeNode)
    if (preNode && DomEditor.getNodeType(preNode) === 'pre') {
      return codeNode as CodeElement
    }
  }
  return null
}

const codeHighLightDecorate = (nodeEntry: NodeEntry): Range[] => {
  const [n, path] = nodeEntry
  const ranges: Range[] = []

  // 节点不合法,则不处理
  const codeElem = getCodeElem(n)
  if (codeElem == null) return ranges
  const { language = '' } = codeElem
  if (!language) return ranges

  const textNode = n as Text
  const tokens = getPrismTokens(textNode, language)

  let start = 0
  for (const token of tokens) {
    const length = getPrismTokenLength(token)
    const end = start + length

    if (typeof token !== 'string') {
      // 遇到关键字,则拆分多个 range —— decorate 规则
      ranges.push({
        [token.type]: true, // 记录类型,以便 css 使用不同的颜色
        anchor: { path, offset: start },
        focus: { path, offset: end },
      })
    }

    start = end
  }

  return ranges
}

export default codeHighLightDecorate


================================================
FILE: packages/code-highlight/src/index.ts
================================================
/**
 * @description code-highlight
 * @author wangfupeng
 */

import './assets/index.less'

// 配置多语言
import './locale/index'

import wangEditorCodeHighlightModule from './module/index'
import wangEditorCodeHighLightDecorate from './decorate'

export { wangEditorCodeHighlightModule, wangEditorCodeHighLightDecorate }


================================================
FILE: packages/code-highlight/src/locale/en.ts
================================================
/**
 * @description i18n en
 * @author wangfupeng
 */

export default {
  highLightModule: {
    selectLang: 'Language',
  },
}


================================================
FILE: packages/code-highlight/src/locale/index.ts
================================================
/**
 * @description i18n entry
 * @author wangfupeng
 */

import { i18nAddResources } from '@wangeditor/core'
import enResources from './en'
import zhResources from './zh-CN'

i18nAddResources('en', enResources)
i18nAddResources('zh-CN', zhResources)


================================================
FILE: packages/code-highlight/src/locale/zh-CN.ts
================================================
/**
 * @description i18n zh-CN
 * @author wangfupeng
 */

export default {
  highLightModule: {
    selectLang: '选择语言',
  },
}


================================================
FILE: packages/code-highlight/src/module/elem-to-html.ts
================================================
/**
 * @description to html
 * @author wangfupeng
 */

import { Element } from 'slate'
import { CodeElement } from '../custom-types'

function codeToHtml(elem: Element, childrenHtml: string): string {
  const { language = '' } = elem as CodeElement

  const cssClass = language
    ? `class="language-${language}"` // prism.js 根据 language 代码高亮
    : ''

  return `<code ${cssClass}>${childrenHtml}</code>`
}

// 覆盖 basic-module 中的 code to html
export const codeToHtmlConf = {
  type: 'code',
  elemToHtml: codeToHtml,
}


================================================
FILE: packages/code-highlight/src/module/index.ts
================================================
/**
 * @description code highlight module
 * @author wangfupeng
 */

import { IModuleConf } from '@wangeditor/core'
import { renderStyle } from './render-style'
import { parseCodeStyleHtml } from './parse-style-html'
import { selectLangMenuConf } from './menu/index'
import { codeToHtmlConf } from './elem-to-html'

const codeHighlightModule: Partial<IModuleConf> = {
  renderStyle,
  parseStyleHtml: parseCodeStyleHtml,
  menus: [selectLangMenuConf],
  elemsToHtml: [codeToHtmlConf],
}

export default codeHighlightModule


================================================
FILE: packages/code-highlight/src/module/menu/SelectLangMenu.ts
================================================
/**
 * @description code-highlight select lang
 * @author wangfupeng
 */

import { Transforms, Element } from 'slate'
import { ISelectMenu, IDomEditor, IOption, DomEditor, t } from '@wangeditor/core'
import { JS_SVG } from '../../constants/svg'
import { CodeElement } from '../../custom-types'

class SelectLangMenu implements ISelectMenu {
  readonly title = t('highLightModule.selectLang')
  readonly iconSvg = JS_SVG
  readonly tag = 'select'
  readonly width = 95
  readonly selectPanelWidth = 115

  getOptions(editor: IDomEditor): IOption[] {
    const options: IOption[] = []

    // 获取配置,参考 './config.ts'
    const { codeLangs = [] } = editor.getMenuConfig('codeSelectLang') // 第二个参数 menu key

    options.push({
      text: 'plain text',
      value: '', // getValue 默认会返回 ''
    })
    codeLangs.forEach((lang: { text: string; value: string }) => {
      const { text, value } = lang
      options.push({ text, value })
    })

    // 设置 selected
    const curValue = this.getValue(editor)
    options.forEach(opt => {
      if (opt.value === curValue) {
        opt.selected = true
      } else {
        delete opt.selected
      }
    })

    return options
  }

  isActive(editor: IDomEditor): boolean {
    // select menu 会显示 selected value ,用不到 active
    return false
  }

  /**
   * 获取语言类型
   * @param editor editor
   */
  getValue(editor: IDomEditor): string | boolean {
    const elem = this.getSelectCodeElem(editor)
    if (elem == null) return ''
    if (!Element.isElement(elem)) return ''

    const lang = elem.language.toString()

    // 当前 elem.language 是否在已配置的 langs 中?
    const { codeLangs = [] } = editor.getMenuConfig('codeSelectLang')
    const hasLang = codeLangs.some(item => item.value === lang)

    if (hasLang) return lang
    return ''
  }

  isDisabled(editor: IDomEditor): boolean {
    if (editor.selection == null) return true
    const elem = this.getSelectCodeElem(editor)
    if (elem) return false
    return true
  }

  exec(editor: IDomEditor, value: string | boolean) {
    const elem = this.getSelectCodeElem(editor)
    if (elem == null) return

    // 设置语言
    const props: Partial<CodeElement> = { language: value.toString() }
    Transforms.setNodes(editor, props, {
      match: n => DomEditor.checkNodeType(n, 'code'),
    })
  }

  private getSelectCodeElem(editor: IDomEditor): CodeElement | null {
    const codeNode = DomEditor.getSelectedNodeByType(editor, 'code')
    if (codeNode == null) return null
    const preNode = DomEditor.getParentNode(editor, codeNode)
    if (!Element.isElement(preNode)) return null
    if (preNode.type !== 'pre') return null

    return codeNode as CodeElement
  }
}

export default SelectLangMenu


================================================
FILE: packages/code-highlight/src/module/menu/config.ts
================================================
/**
 * @description menu config
 * @author wangfupeng
 */

export function genCodeLangs() {
  // 1. text value 对应关系参考 prism 官网 https://prismjs.com/#supported-languages
  // 2. 要加入一个新语言时,要引入相应的 js 模块(代码在 `vender/prism.ts`),例如 `import 'prismjs/components/prism-php'`

  return [
    { text: 'CSS', value: 'css' },
    { text: 'HTML', value: 'html' },
    { text: 'XML', value: 'xml' },
    { text: 'Javascript', value: 'javascript' },
    { text: 'Typescript', value: 'typescript' },
    { text: 'JSX', value: 'jsx' },
    { text: 'Go', value: 'go' },
    { text: 'PHP', value: 'php' },
    { text: 'C', value: 'c' },
    { text: 'Python', value: 'python' },
    { text: 'Java', value: 'java' },
    { text: 'C++', value: 'cpp' },
    { text: 'C#', value: 'csharp' },
    { text: 'Visual Basic', value: 'visual-basic' },
    { text: 'SQL', value: 'sql' },
    { text: 'Ruby', value: 'ruby' },
    { text: 'Swift', value: 'swift' },
    { text: 'Bash', value: 'bash' },
    { text: 'Lua', value: 'lua' },
    { text: 'Groovy', value: 'groovy' },
    { text: 'Markdown', value: 'markdown' },
  ]
}


================================================
FILE: packages/code-highlight/src/module/menu/index.ts
================================================
/**
 * @description code-highlight menu
 * @author wangfupeng
 */

import SelectLangMenu from './SelectLangMenu'
import { genCodeLangs } from './config'

export const selectLangMenuConf = {
  key: 'codeSelectLang',
  factory() {
    return new SelectLangMenu()
  },
  config: {
    codeLangs: genCodeLangs(),
  },
}


================================================
FILE: packages/code-highlight/src/module/parse-style-html.ts
================================================
/**
 * @description parse style html
 * @author wangfupeng
 */

import $, { DOMElement } from '../utils/dom'
import { Descendant, Element } from 'slate'
import { DomEditor, IDomEditor } from '@wangeditor/core'
import { CodeElement } from '../custom-types'

export function parseCodeStyleHtml(
  elem: DOMElement,
  node: Descendant,
  editor: IDomEditor
): Descendant {
  const $elem = $(elem)

  if (!Element.isElement(node)) return node
  if (DomEditor.getNodeType(node) !== 'code') return node // 只针对 pre/code 元素

  const elemNode = node as CodeElement

  const langAttr = $elem.attr('class') || ''
  if (langAttr.indexOf('language-') === 0) {
    // V5 版本,格式如 class="language-javascript"
    elemNode.language = langAttr.split('-')[1] || '' // 获取 'javascript'
  } else {
    // 兼容 V4 版本,格式如 class="Javascript"
    elemNode.language = langAttr.toLowerCase()
  }

  return elemNode
}


================================================
FILE: packages/code-highlight/src/module/render-style.tsx
================================================
/**
 * @description render code highlight style
 * @author wangfupeng
 */

import { Text as SlateText, Descendant } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { addVnodeClassName } from '../utils/vdom'
import { prismTokenTypes } from '../vendor/prism'

/**
 * 添加样式
 * @param node slate text
 * @param vnode vnode
 * @returns vnode
 */
export function renderStyle(node: Descendant, vnode: VNode): VNode {
  const leafNode = node as SlateText & { [key: string]: string }
  let styleVnode: VNode = vnode

  let className = ''
  prismTokenTypes.forEach(type => {
    if (leafNode[type]) className = type
  })

  if (className) {
    className = `token ${className}` // 如 'token keyword' - prismjs 渲染的规则
    addVnodeClassName(styleVnode, className)
  }

  return styleVnode
}


================================================
FILE: packages/code-highlight/src/utils/dom.ts
================================================
/**
 * @description DOM 操作
 * @author wangfupeng
 */

import $, { attr } from 'dom7'

if (attr) $.fn.attr = attr

export { Dom7Array } from 'dom7'

export default $

// COMPAT: This is required to prevent TypeScript aliases from doing some very
// weird things for Slate's types with the same name as globals. (2019/11/27)
// https://github.com/microsoft/TypeScript/issues/35002
import DOMNode = globalThis.Node
import DOMComment = globalThis.Comment
import DOMElement = globalThis.Element
import DOMText = globalThis.Text
import DOMRange = globalThis.Range
import DOMSelection = globalThis.Selection
import DOMStaticRange = globalThis.StaticRange
export { DOMNode, DOMComment, DOMElement, DOMText, DOMRange, DOMSelection, DOMStaticRange }


================================================
FILE: packages/code-highlight/src/utils/vdom.ts
================================================
/**
 * @description vdom utils fn
 * @author wangfupeng
 */

import { VNode, VNodeStyle } from 'snabbdom'

/**
 * 给 vnode 添加 className
 * @param vnode vnode
 * @param className css class
 */
export function addVnodeClassName(vnode: VNode, className: string) {
  if (vnode.data == null) vnode.data = {}
  const data = vnode.data
  if (data.props == null) data.props = {}

  Object.assign(data.props, { className })
}

/**
 * 给 vnode 添加样式
 * @param vnode vnode
 * @param newStyle { key: val }
 */
export function addVnodeStyle(vnode: VNode, newStyle: VNodeStyle) {
  if (vnode.data == null) vnode.data = {}
  const data = vnode.data
  if (data.style == null) data.style = {}

  Object.assign(data.style, newStyle)
}


================================================
FILE: packages/code-highlight/src/vendor/prism.ts
================================================
/**
 * @description prismjs
 * @author wangfupeng
 */

import { Text } from 'slate'

import Prism from 'prismjs'
import 'prismjs/components/prism-jsx'
import 'prismjs/components/prism-typescript'
import 'prismjs/components/prism-markup'
import 'prismjs/components/prism-go'
import 'prismjs/components/prism-php'
import 'prismjs/components/prism-c'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-java'
import 'prismjs/components/prism-cpp'
import 'prismjs/components/prism-csharp'
import 'prismjs/components/prism-visual-basic'
import 'prismjs/components/prism-sql'
import 'prismjs/components/prism-ruby'
import 'prismjs/components/prism-swift'
import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-markdown'
import 'prismjs/components/prism-lua'
import 'prismjs/components/prism-groovy'
// 语言模块,参考 https://github.com/PrismJS/prism/tree/master/components

// prismjs 的 token 类型汇总
export const prismTokenTypes = [
  'comment',
  'prolog',
  'doctype',
  'cdata',
  'punctuation',
  'namespace',
  'property',
  'tag',
  'boolean',
  'number',
  'constant',
  'symbol',
  'deleted',
  'selector',
  'attr-name',
  'string',
  'builtin',
  'inserted',
  'operator',
  'entity',
  'url',
  'string',
  'atrule',
  'attr-value',
  'keyword',
  'function',
  'class-name',
  'regex',
  'important',
  'variable',
  'bold',
  'italic',
  'entity',
  'char',
]

/**
 * 获取 prism token 的字符串长度
 * @param token prism token
 */
export function getPrismTokenLength(token: any) {
  if (typeof token === 'string') {
    return token.length
  } else if (typeof token.content === 'string') {
    return token.content.length
  } else {
    // 累加 length
    return token.content.reduce(
      // @ts-ignore
      (l, t) => l + getPrismTokenLength(t),
      0
    )
  }
}

/**
 * 获取 prism 解析的 token 列表
 * @param textNode text node
 * @param language 代码语言
 */
export function getPrismTokens(textNode: Text, language: string) {
  if (!language) return []

  const langGrammar = Prism.languages[language]
  if (!langGrammar) return []

  return Prism.tokenize(textNode.text, langGrammar)

  // tokens 即 Prism 对整个字符串的拆分,有普通文字也有高亮的关键字
  // 例如 `const a = 100;` 的 tokens 是一个数组 [ token, ' a ', token, ' ', token ] ,有对象有字符串,对象就表示关键字
  // 如数组第一个 token 是 { type: "keyword", content: "const" } 。关键字类型不同 type 也不同
}


================================================
FILE: packages/code-highlight/tsconfig.json
================================================
{
  "compilerOptions": {},
  "extends": "../../tsconfig.json",
  "include": [
    "./src/**/*",
    "../custom-types.d.ts"
  ]
}

================================================
FILE: packages/core/CHANGELOG.md
================================================
# Change Log

All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

## [1.1.19](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.18...@wangeditor/core@1.1.19) (2022-11-14)


### Bug Fixes

* **font family menu:** 处理 setHtml 的时候字体样式回显失败的问题 ([b941bab](https://github.com/wangeditor-team/wangEditor/commit/b941babbdc6bd5bf7da0cce826803a8fde011e07))
* **fontFamily menu:** fix font-family value quote symbol ([2c25231](https://github.com/wangeditor-team/wangEditor/commit/2c25231a088de14edbf7516fc448a6483125e3ed))





## [1.1.18](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.17...@wangeditor/core@1.1.18) (2022-10-18)


### Bug Fixes

* mousedown事件添加passive的默认值 ([60229cc](https://github.com/wangeditor-team/wangEditor/commit/60229cc2f9647a5f17dc0fd85c4bb1dc396a5e9c))
* **video menu:** fix invoke clear api can not clear video node when insert video ([68c1f8e](https://github.com/wangeditor-team/wangEditor/commit/68c1f8ee68ab2cb7b202b6d9b4d4db192a927725))





## [1.1.17](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.16...@wangeditor/core@1.1.17) (2022-10-04)


### Bug Fixes

* 修复 compositionend 时错误修改dom的问题 ([1187154](https://github.com/wangeditor-team/wangEditor/commit/1187154aa077594f55211307c00e3493d1ab5676))
* 修复设置 maxlength 后粘贴异常的问题 ([14003d0](https://github.com/wangeditor-team/wangEditor/commit/14003d0ba01eeb9a264d15fac514dd4b4bd89ff7))





## [1.1.16](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.15...@wangeditor/core@1.1.16) (2022-09-27)


### Bug Fixes

* list-item - 遇到 style 是 toHtml 出错 ([9854308](https://github.com/wangeditor-team/wangEditor/commit/98543083a1cb09207aceb2a4d8f3c1ce020b106d))





## [1.1.15](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.14...@wangeditor/core@1.1.15) (2022-09-27)

**Note:** Version bump only for package @wangeditor/core





## [1.1.14](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.13...@wangeditor/core@1.1.14) (2022-09-16)

**Note:** Version bump only for package @wangeditor/core





## [1.1.13](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.12...@wangeditor/core@1.1.13) (2022-09-15)


### Bug Fixes

* focus table 时 isFocused 异常 ([5c52bf3](https://github.com/wangeditor-team/wangEditor/commit/5c52bf33e91b1a4677e7bbc04c5d80698abfeeab))
* snabbdom 增加 attributesModule ([2c597b6](https://github.com/wangeditor-team/wangEditor/commit/2c597b6a52ffa96c820128d63fd84b903a6faebf))





## [1.1.12](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.11...@wangeditor/core@1.1.12) (2022-08-30)


### Bug Fixes

* fix https://github.com/wangeditor-team/wangEditor/issues/4754 ([e0216b9](https://github.com/wangeditor-team/wangEditor/commit/e0216b98b0ea9ebf4f9cc8a8fd820d68fcd230d3))





## [1.1.11](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.10...@wangeditor/core@1.1.11) (2022-07-27)

**Note:** Version bump only for package @wangeditor/core





## [1.1.10](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.9...@wangeditor/core@1.1.10) (2022-07-27)


### Bug Fixes

* setHtml 支持空字符串 ([d438157](https://github.com/wangeditor-team/wangEditor/commit/d43815766320d9cb0548bae0415c54ce7b147efb))
* upload file callback error ([bf20e07](https://github.com/wangeditor-team/wangEditor/commit/bf20e07f12ed242b0ab4bb2290d876153a822972))





## [1.1.9](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.8...@wangeditor/core@1.1.9) (2022-07-22)


### Bug Fixes

* 粘贴 HTML <a> bug ([b935ef6](https://github.com/wangeditor-team/wangEditor/commit/b935ef622b9d4f8f3a9954d26a41c89d4e8042bd))





## [1.1.8](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.7...@wangeditor/core@1.1.8) (2022-07-18)


### Bug Fixes

* 粘贴文字报错 ([a11ea56](https://github.com/wangeditor-team/wangEditor/commit/a11ea56af4f7976f5664232e80a164cd37d84d8c))





## [1.1.7](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.6...@wangeditor/core@1.1.7) (2022-07-16)


### Bug Fixes

* setHtml() 多一个空行 ([994954f](https://github.com/wangeditor-team/wangEditor/commit/994954fcbae72808e3488e0936a5f82253b603f4))
* 图片受 indent 影响 ([3d737f1](https://github.com/wangeditor-team/wangEditor/commit/3d737f11e457c46e1aeee40ebd834a2470198dfd))





## [1.1.6](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.5...@wangeditor/core@1.1.6) (2022-07-14)


### Bug Fixes

* 粘贴网页 HTML 报错 ([939cb22](https://github.com/wangeditor-team/wangEditor/commit/939cb2229a11eea827e1bea4420f7502db1e7eb6))





## [1.1.5](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.4...@wangeditor/core@1.1.5) (2022-07-13)


### Bug Fixes

* setHtml 问题 - table 后面 p 格式错误 ([b525b4a](https://github.com/wangeditor-team/wangEditor/commit/b525b4aaa69b834204232774971367beba7db975))





## [1.1.4](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.3...@wangeditor/core@1.1.4) (2022-07-12)

**Note:** Version bump only for package @wangeditor/core





## [1.1.3](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.2...@wangeditor/core@1.1.3) (2022-07-11)


### Bug Fixes

* scroll 滚动问题 ([bc133e1](https://github.com/wangeditor-team/wangEditor/commit/bc133e1e4ca89ab5042cbc0971578ad144499805))
* 修复选中内容输入时,出现光标位置不对或者输入重复内容的问题 ([9596a4c](https://github.com/wangeditor-team/wangEditor/commit/9596a4ccaca2e2c4eed7ffc16fc4b042f73cef5d))





## [1.1.2](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.1...@wangeditor/core@1.1.2) (2022-07-11)


### Bug Fixes

* editor.focus() 参数语法错误 ([334fa21](https://github.com/wangeditor-team/wangEditor/commit/334fa217d43fdaa95454e7c85a53526b7b777fda))
* focus blur 问题 ([4a1997b](https://github.com/wangeditor-team/wangEditor/commit/4a1997b9f19cdce9d6aa6ff4e8e13d439b12af05))
* 单词之间空格问题 issue 4403 ([2f1d6f5](https://github.com/wangeditor-team/wangEditor/commit/2f1d6f5275c8a9e106b66213bb276c58a70aff79))





## [1.1.1](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.1.0...@wangeditor/core@1.1.1) (2022-06-02)


### Bug Fixes

* issue 4308 - 自定义字号、字体无法回显 ([ad38b8c](https://github.com/wangeditor-team/wangEditor/commit/ad38b8ce6dbcff1d65785c8d6701238ad351f562))





# [1.1.0](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/core@1.0.1...@wangeditor/core@1.1.0) (2022-05-25)


### Bug Fixes

* 修复 readonly 模式下,特定内容下editor初始化报错的问题 ([f3bc8b8](https://github.com/wangeditor-team/wangEditor/commit/f3bc8b8d485765cfa8fa7d19e530aa1a1b4bc4e2))
* 粘贴 HTML 后 font-size font-family line-height 不显示 ([2281957](https://github.com/wangeditor-team/wangEditor/commit/2281957020a30de9cda1c5e9d5e20c6668b7f592))


### Features

* editVideoSize ([375eecb](https://github.com/wangeditor-team/wangEditor/commit/375eecba826eac681268c55c47bcd922f7157d63))
* setHtml ([f4f91b8](https://github.com/wangeditor-team/wangEditor/commit/f4f91b883298091e3679ca6b206ae0d796003772))





## 1.0.1 (2022-04-18)


### Bug Fixes

* 部分菜单 disabled ([87f1233](https://github.com/wangeditor-team/wangEditor/commit/87f12332a087072406c1988dc5cef2eae8335375))
* 错别字 alwaysEnable ([82c5136](https://github.com/wangeditor-team/wangEditor/commit/82c5136f8496be420dfa26b0f30522e19924a907))
* 弹出 modal 时 blur ([53454ef](https://github.com/wangeditor-team/wangEditor/commit/53454ef74b0775391aecf2d745561c9281715934))
* 点击编辑器区域,未关闭 dropPanel ([b23123b](https://github.com/wangeditor-team/wangEditor/commit/b23123bb361ac2acadcacdfeaa78dd7bf878f86e))
* 多余的空行 ([4af6c64](https://github.com/wangeditor-team/wangEditor/commit/4af6c648861c2c56db62fae28e9dfa0d27ca5d51))
* 多余的空行 ([9dde85c](https://github.com/wangeditor-team/wangEditor/commit/9dde85cec5a27be21e0b89c24288d418e1f6d2de))
* 更新各包之间依赖版本 ([75c552c](https://github.com/wangeditor-team/wangEditor/commit/75c552cc8ed54765bebb86a7ec5329a7fc79e85f))
* 获取 activeElement 兼容 Document 和 ShadowRoot ([d904e5d](https://github.com/wangeditor-team/wangEditor/commit/d904e5dc263ce670362779b0cfa51ca9f7a8bd86))
* 拼音输入 bug we-2021/issues/47 ([20b7429](https://github.com/wangeditor-team/wangEditor/commit/20b74298509d9463d6aa1aaffabc21bd33bd7857))
* 拼音隐藏 placeholder ([aec1a9f](https://github.com/wangeditor-team/wangEditor/commit/aec1a9f62af8944b7894beeca953076ec73545d5))
* 全屏边距 ([1acb129](https://github.com/wangeditor-team/wangEditor/commit/1acb12974848af28e2d0f574f85a59145675cdbc))
* 全选 ([3cb8f42](https://github.com/wangeditor-team/wangEditor/commit/3cb8f428a0b94c280b63d42f46c148a9f0e2d9fd))
* 上传图片 - base64 仍触发上传 + 超出 maxSize 的报错提醒 ([a1d469a](https://github.com/wangeditor-team/wangEditor/commit/a1d469accb7f87f8ea0282a1699d002aaaa4e79a))
* 使用了 ts 类型空间导入方式优化 ([5d7b509](https://github.com/wangeditor-team/wangEditor/commit/5d7b5094e561af138b2569c669fd4daad2808f73))
* 图片上传,提示 ([3754012](https://github.com/wangeditor-team/wangEditor/commit/37540129dff1212c5ebfd4ca3f4d4e8def735e73))
* 完善了 isDOMEventHandled ([745f1d7](https://github.com/wangeditor-team/wangEditor/commit/745f1d7b949eb8839cbdb0fb1690c33c386b697f))
* 完善了 metaWithUrl 类型声明 ([3542834](https://github.com/wangeditor-team/wangEditor/commit/3542834b9aa65eba5b1c352d106f6623e5fcdc06))
* 修复 firefox 上全选编辑器内容使用拼音输入异常 ([87dafcb](https://github.com/wangeditor-team/wangEditor/commit/87dafcbe4c51d588ac97d3825a9389571fa16404))
* 修复 modal 中的 input 没有被 focus ([484c51e](https://github.com/wangeditor-team/wangEditor/commit/484c51e4629defe9eac3f2acaf83ccb62a669d5d))
* 修复 modal close 时没有恢复选区的问题 ([16f5a57](https://github.com/wangeditor-team/wangEditor/commit/16f5a57b2815026741249e8b4ef9e7222071353f))
* 修复回车超过视口后没有自动滚动的问题 ([f088b52](https://github.com/wangeditor-team/wangEditor/commit/f088b52ff8c9386ba9efc2d7d3e97f76c702b26d))
* 修复了使用拼音输入法在 safari 上光标位置没有正常更新的问题 ([cb4cf12](https://github.com/wangeditor-team/wangEditor/commit/cb4cf12bcb6448e5964c47674281f37db96069fa))
* 修复连续输入空格滚动条不滚动的bug ([3bd358d](https://github.com/wangeditor-team/wangEditor/commit/3bd358d83969a53f1ed4f3fd349eb186750f9461))
* 修复内容重复和编辑器内容拖动的一些 bug ([5a9c9d0](https://github.com/wangeditor-team/wangEditor/commit/5a9c9d0b0880dc006180a5c4e5828f54cd1905da))
* 修复行间距过小无效 ([5f13a5b](https://github.com/wangeditor-team/wangEditor/commit/5f13a5b3dc859a45ad25f88ad363f408d23bcee1))
* 修复选中内容中文输入时光标定位问题 ([51596a8](https://github.com/wangeditor-team/wangEditor/commit/51596a8b0b920dc1d1a9e39fff7c3624c0aa6f52))
* 修复用户自定义change事件获取html时tabal报错 ([5204f8e](https://github.com/wangeditor-team/wangEditor/commit/5204f8ebf63abdf8a7093e202411b63ce86c2964))
* 修复在 Chrome 和 Safari 中删除内容时,内联空节点被选中 ([a47c73f](https://github.com/wangeditor-team/wangEditor/commit/a47c73fc5fa008096165d5ac9c55d01f4a6b045b))
* 修复在 Safari 下,即使 contenteditable 元素非聚焦状态,并不会删除所选内容 ([3e8ca3c](https://github.com/wangeditor-team/wangEditor/commit/3e8ca3c86074454a75054e5ded03154f6b6544ea))
* 修复在代码块中中文输入会有多余字符的问题 ([a138c3f](https://github.com/wangeditor-team/wangEditor/commit/a138c3f0a2f25d9f89afb912cff45596f99e6b05))
* 修复在destory可能出现editor not find的问题 ([ce60416](https://github.com/wangeditor-team/wangEditor/commit/ce604165527435952b5ac4b011842714ec8cd5dd))
* 修复Safari上table内空行输入报错的问题 ([dae6dc5](https://github.com/wangeditor-team/wangEditor/commit/dae6dc544f714f195989a05970cb6bf272f6eb8b))
* 修复ua正则不支持100+的问题 ([c488ba0](https://github.com/wangeditor-team/wangEditor/commit/c488ba09183cbfcabef223709464c42fac53aea0))
* 选择图片会滚动 ([d2a8762](https://github.com/wangeditor-team/wangEditor/commit/d2a87629cedc3533e268a31ca822f414082bf48d))
* 选中内容输入中文报错 ([890cc68](https://github.com/wangeditor-team/wangEditor/commit/890cc686e566be68227641d5f31b42de66351126))
* 移除了每个包下的 publishConfig directory 配置 ([16559f0](https://github.com/wangeditor-team/wangEditor/commit/16559f052545c111318be760e64291a521bdcc65))
* 优化插入新文本的滚动交互 ([71131a4](https://github.com/wangeditor-team/wangEditor/commit/71131a4355d24b805052fa9bcf1515432e4351ad))
* 优化当父元素有滚动条,插入新文本的滚动交互 ([9275090](https://github.com/wangeditor-team/wangEditor/commit/9275090399f068db14854f2794b9aab996bee22e))
* 优化了 core 类型声明 ([5b5ee1e](https://github.com/wangeditor-team/wangEditor/commit/5b5ee1ee34300748460cedab6fcd46463820f8ef))
* 优化了 deleteFragment  函数调用传参 ([8d8145c](https://github.com/wangeditor-team/wangEditor/commit/8d8145c5e496a28e2d586722101d217ba1be7079))
* 优化了 normalizeDOMPoint 函数 ([31b9999](https://github.com/wangeditor-team/wangEditor/commit/31b99992bdc5bc2cc239320200da7d5ba7d6cfc0))
* 优化了当编辑失焦编辑区域滚动到顶部的问题 ([ebb966b](https://github.com/wangeditor-team/wangEditor/commit/ebb966bce81023c79727bae846920323f733008d))
* 优化了浏览器是否支持 beforeinput 事件的兼容性判断 ([ea221bb](https://github.com/wangeditor-team/wangEditor/commit/ea221bb3e176ace7a99854673fd727dedc0b3ba7))
* 优化选中代码块不应该展示 hoverbar 的交互 ([33dcbd6](https://github.com/wangeditor-team/wangEditor/commit/33dcbd6560dccfbe77e18cfbce8c9f077f19f6cd))
* 在移动 word 之前折叠展开选区 ([6b9b0f3](https://github.com/wangeditor-team/wangEditor/commit/6b9b0f3c9755c1950b0645c34166bd043a9d05f0))
* 增加 EXTEND_CONF 配置扩展能力 ([ff75a16](https://github.com/wangeditor-team/wangEditor/commit/ff75a16643b26d2d0e7a92cfdd827d5f0f56a849))
* 重复创建 ([3682c53](https://github.com/wangeditor-team/wangEditor/commit/3682c53b181b89d2c16b5d9845b381a4813c9e3c))
* autoFucos ([fea2faf](https://github.com/wangeditor-team/wangEditor/commit/fea2faf0af83a3eec67ee7bc7d76328409d2d703))
* beforeinput support ([60e6efc](https://github.com/wangeditor-team/wangEditor/commit/60e6efc3b3d6c31c4834e3b40e02fc8bc4ceaea6))
* blockquote & header insertBreak ([06678c9](https://github.com/wangeditor-team/wangEditor/commit/06678c963e8c8421ecded448de7510b254117550))
* button 增加 type ([37b3390](https://github.com/wangeditor-team/wangEditor/commit/37b33903e0ae5ffe95ab907791ab484facd052d9))
* chrome 链接后输入拼音,js 错误 ([6c04fab](https://github.com/wangeditor-team/wangEditor/commit/6c04fabb2c5ec78e13c1e1583685cf726887dcae))
* clear API ([c188b56](https://github.com/wangeditor-team/wangEditor/commit/c188b567379ae32abcfa879620c995c8d45818c4))
* code-block 选择语言 - 点击拖拽滚动条 ([b8c75e7](https://github.com/wangeditor-team/wangEditor/commit/b8c75e7dc5332c9da622433380802886dedc4344))
* composition-end ([082561d](https://github.com/wangeditor-team/wangEditor/commit/082561dc341b45791933757e2cf6102190004674))
* create - 判断 content length ([c0eadc9](https://github.com/wangeditor-team/wangEditor/commit/c0eadc9bf03edc7576c1d3e957babede4c0b546f))
* dangerouslyInsertHtml - 兼容异常情况 ([8b549f4](https://github.com/wangeditor-team/wangEditor/commit/8b549f480434782107eda3412bf6530d0d7eb9ba))
* droplist 过长 ([1de2a76](https://github.com/wangeditor-team/wangEditor/commit/1de2a76ac802b80c1b45537c129e5833b4d73d33))
* dropPanel 定位 ([e76310a](https://github.com/wangeditor-team/wangEditor/commit/e76310a1c6d4aafb2385faebb005bdddd38f9838))
* editor.blur() api 无效 ([48cbff3](https://github.com/wangeditor-team/wangEditor/commit/48cbff3142d961ff2eaf2f76a3182488de2e5b93))
* firefox下全选输入出现多余字符 ([659b107](https://github.com/wangeditor-team/wangEditor/commit/659b1078e3395ff00ddc0d1792fbf9c4d448ca41))
* fix https://github.com/wangeditor-team/wangEditor-v5/issues/457 ([1d8a46a](https://github.com/wangeditor-team/wangEditor/commit/1d8a46a1b5402c2ecb418db24d9d22532d152cea))
* fullScreen 隐藏 hoverbar ([ec463d3](https://github.com/wangeditor-team/wangEditor/commit/ec463d302cdc527987741ae6208a625af91ea61c))
* getElems 增加 id ([1dcedd9](https://github.com/wangeditor-team/wangEditor/commit/1dcedd9392d2eecef29f9c93e8915a2f2f83b8a5))
* getHtml 死循环 ([4614bfb](https://github.com/wangeditor-team/wangEditor/commit/4614bfb5c3a2658348a59749dd800a349e6c33a9))
* getHtml API ([c0b60cf](https://github.com/wangeditor-team/wangEditor/commit/c0b60cf47d8eaae4292265906fbe07875e1564c9))
* group-menu 考虑 excludeKeys ([ecc29f3](https://github.com/wangeditor-team/wangEditor/commit/ecc29f3b24992c8dc0adf006d81b0d4a252683c5))
* hotkey mod ([d480c20](https://github.com/wangeditor-team/wangEditor/commit/d480c206fd83ecc8d12f36147c210208aa6d6ab3))
* hoverbar - 处于网页下部 ([6cfb3e2](https://github.com/wangeditor-team/wangEditor/commit/6cfb3e2d364f4532cbafe5c8c6e4b3bc13fa2d78))
* hoverbar 被点击多次隐藏 ([bf4fc19](https://github.com/wangeditor-team/wangEditor/commit/bf4fc193847e8caba3a67c8dd152eae4f1950c4f))
* hoverbar active ([ceb3f41](https://github.com/wangeditor-team/wangEditor/commit/ceb3f41deafd8fc2cb8d3e8a498cb8d90ad1c73f))
* hoverbar modal 重复创建 ([70d2b61](https://github.com/wangeditor-team/wangEditor/commit/70d2b618a0662c88cd5e6691f513009726ce1b9b))
* hoverbar show/hide ([c96bc83](https://github.com/wangeditor-team/wangEditor/commit/c96bc8378939fecd78807fea4f2b7e1eec2a9ea0))
* hoverbarKeys - text ([59b4840](https://github.com/wangeditor-team/wangEditor/commit/59b48406b4c373ef029a5f5bdb0d15d925a91a0f))
* html 特殊字符 ([b3eb81b](https://github.com/wangeditor-team/wangEditor/commit/b3eb81bc9c4aa15c2ff7451c173de15d6c4552bc))
* i18n - 获取多语言配置 ([9f81597](https://github.com/wangeditor-team/wangEditor/commit/9f815970f8c3c6dddb6bf846ecb672325e80444b))
* i18n 切换语言 ([b3b4642](https://github.com/wangeditor-team/wangEditor/commit/b3b4642c6e72ab0b13b05657745abb87e71c633d))
* insertHtml - maxLength ([8c7dc8b](https://github.com/wangeditor-team/wangEditor/commit/8c7dc8b8efe1705af9989b040b04e2f98932cb77))
* insertHtml - maxLength ([52d72ec](https://github.com/wangeditor-team/wangEditor/commit/52d72ec4778a7a6c6f31a7e95d82fb91c9384ae8))
* insertHtml - maxLength ([b573359](https://github.com/wangeditor-team/wangEditor/commit/b5733597966b16d876b0c0e18509f04638e1c4df))
* insertKeys ([0a89420](https://github.com/wangeditor-team/wangEditor/commit/0a8942050bd0b39afb5bbc55ca7842461a5b98eb))
* link, text hoverbar 选区问题 ([e0b7438](https://github.com/wangeditor-team/wangEditor/commit/e0b7438c89a347f1b0b940d9c11150b72d595529))
* maxLength - 拼音 + 粘贴 ([3ac4db6](https://github.com/wangeditor-team/wangEditor/commit/3ac4db6d78cbe7a8d1fe19747deb0a17edd9b552))
* maxLength 对于拼音输入无效 ([117faa6](https://github.com/wangeditor-team/wangEditor/commit/117faa635e99667c4762b58757f045c80f949323))
* menu 点击多次才能生效 ([6497e39](https://github.com/wangeditor-team/wangEditor/commit/6497e39225a993c4d87f9ffddf20086446a4fbc2))
* min-height ([460fad5](https://github.com/wangeditor-team/wangEditor/commit/460fad56001e83842786629b1d1f8ed6411f4fd4))
* modal close ([dbfb3b4](https://github.com/wangeditor-team/wangEditor/commit/dbfb3b42504ae97aa0f641ff7fe5eba208b43580))
* normalize when create editor ([2b51962](https://github.com/wangeditor-team/wangEditor/commit/2b5196244a93ad7beb316bfa42e557221967d063))
* parse html - 有些 elem children 需要过滤 ([63cbb80](https://github.com/wangeditor-team/wangEditor/commit/63cbb804c8c7a778a4ee1f4ba8717a11b4b6b5a3))
* parse-html - space 160 ([54e72bc](https://github.com/wangeditor-team/wangEditor/commit/54e72bcb5ed38b8dc77e957ebd5d35881466b5b3))
* parse-html - sub sup ([2c15a5f](https://github.com/wangeditor-team/wangEditor/commit/2c15a5f9c9c2de8b34770a6bebfe765d203a03f6))
* parseHtml - 多空格文本 ([5d4479c](https://github.com/wangeditor-team/wangEditor/commit/5d4479c5d11fc23233ea63f0b69c845fa2ab8630))
* placeholder - 全选输入中文 ([fe4dd2a](https://github.com/wangeditor-team/wangEditor/commit/fe4dd2a85d54d64e2411c3dfc6cb90ac18003e28))
* placeHolder elem ([7d577ac](https://github.com/wangeditor-team/wangEditor/commit/7d577ac4d6003d1b4c8575be1c014cfa6632d248))
* readOnly 时菜单还可操作 ([0d4a29b](https://github.com/wangeditor-team/wangEditor/commit/0d4a29bb5ba8b62ac11a09d3f814abcb1fcf46be))
* readOnly 依然可以 insertText ([096eeaf](https://github.com/wangeditor-team/wangEditor/commit/096eeafd0fc62edf196ed3a9549c04ce19b6b159))
* rename es module filename ([1821d4e](https://github.com/wangeditor-team/wangEditor/commit/1821d4eef49e64efcb41b848849ca7a5e6472044))
* shadowDOM 节点支持问题 ([5eb41f1](https://github.com/wangeditor-team/wangEditor/commit/5eb41f1048ad110003b2ef95e0f22e26b7fd757c))
* shadowDOM 在失焦状态下元素获取失败 ([98aeccc](https://github.com/wangeditor-team/wangEditor/commit/98aeccc5be85513d577397642a9a2d2f730a0406))
* table - 粘贴合并单元格的表格 ([56ecb63](https://github.com/wangeditor-team/wangEditor/commit/56ecb6392510d433e092653f0f08183361778a3d))
* table - elemToHtml ([e36e609](https://github.com/wangeditor-team/wangEditor/commit/e36e6092ef721723169afc8bf0560a47ac9f4dfc))
* table-cell 全选 ([1ef4872](https://github.com/wangeditor-team/wangEditor/commit/1ef48729e6d99e7414bc89bc4ef0d66c172fc566))
* tableCell 中 br 报错 ([8604db7](https://github.com/wangeditor-team/wangEditor/commit/8604db751b622c01fa5391af59328236cf13effc))
* td th 中换行不起作用 ([89c6032](https://github.com/wangeditor-team/wangEditor/commit/89c6032a1c41100b7adaf9927e6bc9c06d0228db))
* textarea height ([873b04a](https://github.com/wangeditor-team/wangEditor/commit/873b04a65a7140afdc2427ac07fce57b3e2c423e))
* tooltip ([7e066d1](https://github.com/wangeditor-team/wangEditor/commit/7e066d1368f1bfaaca21e3385647be2dee6837f9))
* upload progress 0 ([9e660be](https://github.com/wangeditor-team/wangEditor/commit/9e660be126adb969dd8a80166b60d6f62be17b2a))
* url 后面中文输入异常 ([3bcebc7](https://github.com/wangeditor-team/wangEditor/commit/3bcebc78352e05cfec92eed92ee0b05d233feaef))
* void node - 不清理 text ([1bc891c](https://github.com/wangeditor-team/wangEditor/commit/1bc891c46318f5c5ab969752b3ddb8d75ee1faf7))
* vue 组件增加 customPaste ([e764248](https://github.com/wangeditor-team/wangEditor/commit/e76424870c75e09ab6267b604a951444b2e847c5))
* w-e-menu-tooltip 和 v4 冲突 ([762403b](https://github.com/wangeditor-team/wangEditor/commit/762403b2c4e860b3855cbc0caa883b1443d3c862))
* z-index ([02ec2d5](https://github.com/wangeditor-team/wangEditor/commit/02ec2d54605e747b7d4e1377a58fc9e14c9bba7c))


### Features

* 增加 API ([63d6fe8](https://github.com/wangeditor-team/wangEditor/commit/63d6fe85f17fea31c95fec727126799a979ec2f9))
* 增加 enable disable API(删除 setConfig setMenuConfig API) ([984fc50](https://github.com/wangeditor-team/wangEditor/commit/984fc50520061fc34ea08f4136bdeb93dee46564))
* 支持 nodejs 环境 ([484f18c](https://github.com/wangeditor-team/wangEditor/commit/484f18c3abc70d19e51c556f48491c18d390b1e1))
* API - getElemsByType + move + moveReverse ([748ad71](https://github.com/wangeditor-team/wangEditor/commit/748ad710b55d26ade4df1d8caa0a6ea5d2f6f8c7))
* basic text paste ([f0a5b98](https://github.com/wangeditor-team/wangEditor/commit/f0a5b980c95fa1e2fc59a898c6e0d0723c276c28))
* basic text style module ([005b343](https://github.com/wangeditor-team/wangEditor/commit/005b343573ba98f2d0b8480d034ff6807a499aa3))
* bold & header ([8130c23](https://github.com/wangeditor-team/wangEditor/commit/8130c23ad84485a68cf9ca4b53d52fab1cec4e96))
* clear color ([93b1a18](https://github.com/wangeditor-team/wangEditor/commit/93b1a189395ba113dfe9f793c69e136607f9a28f))
* clear editor api ([01b07f2](https://github.com/wangeditor-team/wangEditor/commit/01b07f2a2250661ef121919192d40a4852d50a91))
* clearStyle menu ([8002f70](https://github.com/wangeditor-team/wangEditor/commit/8002f707ed04b914180ec36fdca0edf48c815e01))
* close modal ([b5106f4](https://github.com/wangeditor-team/wangEditor/commit/b5106f4428813cf794c468034c80824b0a4f08db))
* code highlight ([42b2f8d](https://github.com/wangeditor-team/wangEditor/commit/42b2f8d192e2433593c11ad0b8424737f6cffb58))
* code-block - part ([a8bcd63](https://github.com/wangeditor-team/wangEditor/commit/a8bcd63d882832ac05a32878df0f767d145e0fa7))
* create editor ([12d98e4](https://github.com/wangeditor-team/wangEditor/commit/12d98e4bee179e9d277ec3ec2ecb827962ed0e75))
* customPaste ([0f25f5c](https://github.com/wangeditor-team/wangEditor/commit/0f25f5cae3a2cd5ae5832f3fc1026b3ab6d047e0))
* dangerouslyInsertHtml ([4dc3d0c](https://github.com/wangeditor-team/wangEditor/commit/4dc3d0cb403d751ae067a541868e77083c8ce74c))
* drag resize image ([cd72028](https://github.com/wangeditor-team/wangEditor/commit/cd72028f1786e2e53079ad5cbef1b8569731ca79))
* editor 生命周期,自定义事件 ([00e9bc2](https://github.com/wangeditor-team/wangEditor/commit/00e9bc2cfcb8b622764db1c76394491d72ffd93e))
* editor with-selection plugin ([9f0a39f](https://github.com/wangeditor-team/wangEditor/commit/9f0a39fecf6d92888d2a97929820d3be038efb31))
* editor.alert ([f147c8f](https://github.com/wangeditor-team/wangEditor/commit/f147c8f234510959c770860ac2f194e8d720f177))
* editor.isSelectedAll ([960c845](https://github.com/wangeditor-team/wangEditor/commit/960c8455f85a6bc7350f9944be80b3997bc1fea1))
* editor.showProgressBar ([51761d4](https://github.com/wangeditor-team/wangEditor/commit/51761d466ab3ef7c99e872954d4724ab51d8e28c))
* focus支持focus到文档末尾 ([628830e](https://github.com/wangeditor-team/wangEditor/commit/628830ef06ff85b3e67001ce30dd9e0557b0aa28))
* font-size + font-family ([cc649e0](https://github.com/wangeditor-team/wangEditor/commit/cc649e0918ce58e78b4d5ee49a400197b9d04b70))
* fullScreen ([e7ccd88](https://github.com/wangeditor-team/wangEditor/commit/e7ccd88a7dd58f64b7bd484de428e3a76cc994f7))
* getElemsByTypePrefix (删掉 getHeaders) ([c18834b](https://github.com/wangeditor-team/wangEditor/commit/c18834b3ebfd97fb36ccbe0faa84e6fe8c30eb67))
* getHeaders & editor.srcollToElem ([2bfb813](https://github.com/wangeditor-team/wangEditor/commit/2bfb813e4957f080c6676ec38f8f051275cdf44a))
* getSelectionText + maxLength ([58f6648](https://github.com/wangeditor-team/wangEditor/commit/58f66489b65f857238d96b93120f6de7e2750c81))
* groupButton disabled ([8ffd44c](https://github.com/wangeditor-team/wangEditor/commit/8ffd44c9a44758e951ca7bd02dd46746fcac1c03))
* hover bar ([107356e](https://github.com/wangeditor-team/wangEditor/commit/107356eff7bfaf53ce25e39244f8133c80518375))
* i18n ([c11b244](https://github.com/wangeditor-team/wangEditor/commit/c11b2440f91b99d40bca18b675c66a22b6e160c9))
* image menu - width 50% 100% ([f9b4c68](https://github.com/wangeditor-team/wangEditor/commit/f9b4c68dff3232b50491b07949c20eb4c18baa6b))
* image menu config ([bb18774](https://github.com/wangeditor-team/wangEditor/commit/bb187740e9703b4a76cde4f5e4d32ac714aa793a))
* image menus & position ([bf5beba](https://github.com/wangeditor-team/wangEditor/commit/bf5beba7b3014d63f0b9fe0063530c8b101a5011))
* indent menu + groupMenu ([08db901](https://github.com/wangeditor-team/wangEditor/commit/08db901cd3a3f2ddb2173cc4b36d471e4e68237e))
* insert link ([b04242f](https://github.com/wangeditor-team/wangEditor/commit/b04242ffa252d4088f5360c3de45c24d6f493552))
* list menu ([fe6c083](https://github.com/wangeditor-team/wangEditor/commit/fe6c0830b2c43e335e5972f85096f490694bbe19))
* menu color - part ([3a6cc86](https://github.com/wangeditor-team/wangEditor/commit/3a6cc86a7f9133d0862310c408abafb30c531734))
* menu color & dropPanel & menu config ([5d0d41b](https://github.com/wangeditor-team/wangEditor/commit/5d0d41b9a765a7deb583393f129925414c36ef35))
* menu hotkey ([fee05f1](https://github.com/wangeditor-team/wangEditor/commit/fee05f189434d1e57a32ff0dea1a57db6830318a))
* modal appendTo body ([fc0ab06](https://github.com/wangeditor-team/wangEditor/commit/fc0ab06d5c7177eceb04643234a8c301ca4de396))
* onBlur onFocus ([590ab4a](https://github.com/wangeditor-team/wangEditor/commit/590ab4a990048bb22cf15787a5fd4615db5b9ef6))
* parse html ([2a5eace](https://github.com/wangeditor-team/wangEditor/commit/2a5eace00f33cded50b68e8164748ec2480213fd))
* placeholder ([a3e4cdc](https://github.com/wangeditor-team/wangEditor/commit/a3e4cdcd474063e4f436327aaf4074bb2126d941))
* react 组件 ([448fc83](https://github.com/wangeditor-team/wangEditor/commit/448fc838d64dbef52cbcddde0e98eb021d8a9122))
* scroll config ([b4942b4](https://github.com/wangeditor-team/wangEditor/commit/b4942b4334f255b3d537389be3dacf1642dd5441))
* selectList ([b7366ab](https://github.com/wangeditor-team/wangEditor/commit/b7366ab2dafd379145d85881052d6f400bd13c85))
* text and toolbar ([3ae5d0c](https://github.com/wangeditor-team/wangEditor/commit/3ae5d0c4138fec7397ac8629e0012affe6b7dfa4))
* toHtml 机制 ([1c4d872](https://github.com/wangeditor-team/wangEditor/commit/1c4d8729f84aaab6a448f23064b34a20596305e9))
* toolbar config - insertKeys ([a2f3c4b](https://github.com/wangeditor-team/wangEditor/commit/a2f3c4be3762831723495bbc9d50eb6c9b05d195))
* toolbar excludeKeys ([09bd196](https://github.com/wangeditor-team/wangEditor/commit/09bd196ea24c19b04e5e7e38227ca94332847bf8))
* tooltip ([994d875](https://github.com/wangeditor-team/wangEditor/commit/994d875fee81cf01271c2e440c1df202aa067d0e))
* updateLink + unLink + viewLink ([254d554](https://github.com/wangeditor-team/wangEditor/commit/254d55466b3c8527dd9f0bf34681abd801c8c8ce))
* vue2 组件 ([fd7847a](https://github.com/wangeditor-team/wangEditor/commit/fd7847a72db661bbf29cf636d454c075fd331224))


================================================
FILE: packages/core/README.md
================================================
# wangEditor core

[wangEditor](https://www.wangeditor.com/) core.

## Main Functionalities
- View( model -> vdom -> DOM ) + Selection
- Menus + toolbar + hoverbar
- Core editor APIs and events
- Register third-party modules (menus, plugins...)

## Main dependencies
- [slate.js](https://docs.slatejs.org/)
- [snabbdom.js](https://github.com/snabbdom/snabbdom)


================================================
FILE: packages/core/__tests__/config/editor-config.test.ts
================================================
/**
 * @description editor config test
 * @author wangfupeng
 */

import { Editor } from 'slate'
import createCoreEditor from '../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor

describe('editor config', () => {
  function getStartLocation(editor) {
    return Editor.start(editor, [])
  }

  it('if set placeholder option, it will show placeholder element when editor content is empty', () => {
    const container = document.createElement('div')
    createCoreEditor({
      selector: container,
      config: {
        placeholder: 'editor placeholder',
      },
    })
    const el = container.querySelector('.w-e-text-placeholder')
    expect(el!.textContent).toBe('editor placeholder')
  })

  it('if set placeholder option, it will hide placeholder element when editor content is not empty', () => {
    const container = document.createElement('div')
    createCoreEditor({
      selector: container,
      config: {
        placeholder: 'editor placeholder',
      },
      content: [{ type: 'paragraph', children: [{ text: '123' }] }],
    })
    const el = container.querySelector('.w-e-text-placeholder')
    expect(el).toBeNull()
  })

  it('if set readOnly option, isDisabled return true', () => {
    const editor = createCoreEditor({
      config: {
        readOnly: true,
      },
    })
    expect(editor.isDisabled()).toBeTruthy()
  })

  it('if set readOnly option, can not insert text to editor', () => {
    const editor = createCoreEditor({
      config: {
        readOnly: true,
      },
    })

    editor.select(getStartLocation(editor))
    editor.insertText('xxx') // readOnly 时无法插入文本
    expect(editor.getText()).toBe('')
  })

  it('if set maxLength option, the editor can not update content when text length is equal to maxLength', done => {
    const editor = createCoreEditor({
      config: {
        maxLength: 10,
        onMaxLength: () => {
          done() // 触发回调,才能完成该测试
        },
      },
    })
    editor.select(getStartLocation(editor))

    // 插入 9 个字符,小于 maxLength
    editor.insertText('123456789')
    expect(editor.getText()).toBe('123456789')

    // 再插入其他字符,则只能插入一个
    editor.insertText('abc')
    expect(editor.getText()).toBe('123456789a')
  })

  it('if set onCreated option, it will be called when created editor', done => {
    const fn = jest.fn()

    createCoreEditor({
      config: {
        onCreated: fn,
      },
    })

    setTimeout(() => {
      expect(fn).toHaveBeenCalled()
      done()
    })
  })

  it('if set onChange option, it will be called when change editor selection', done => {
    const fn = jest.fn()

    const editor = createCoreEditor({
      config: {
        onChange: fn,
      },
    })

    editor.select(getStartLocation(editor)) // 选区变化,触发 onchange
    setTimeout(() => {
      expect(fn).toHaveBeenCalledWith(editor)
      done()
    })
  })

  it('if set onChange option, it will be called when change editor content', done => {
    const fn = jest.fn()

    const editor = createCoreEditor({
      config: {
        onChange: fn,
      },
    })

    editor.select(getStartLocation(editor))

    // 避免选区干扰
    setTimeout(() => {
      editor.insertText('123')
    }, 50)
    setTimeout(() => {
      expect(fn).toHaveBeenCalledTimes(2)
      done()
    }, 80)
  })

  it('if set onDestroyed option, it will be called when destroy editor', done => {
    const fn = jest.fn()
    const editor = createCoreEditor({
      config: {
        onDestroyed: fn,
      },
    })

    setTimeout(() => {
      editor.destroy()
    })

    setTimeout(() => {
      expect(fn).toHaveBeenCalledWith(editor)
      done()
    }, 20)
  })
})


================================================
FILE: packages/core/__tests__/config/menu-config.test.ts
================================================
/**
 * @description menu config test
 * @author wangfupeng
 */

import createCoreEditor from '../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { registerGlobalMenuConf } from '../../src/config/register'

describe('menu config', () => {
  it('set and get', () => {
    // 先注册一下菜单 key ,再设置配置(专为单元测试,用户使用时不涉及)
    registerGlobalMenuConf('bold', {})

    const menuKey = 'bold' // 必须是一个存在的 menu key
    const menuConfig = {
      x: 100,
    }

    const editor = createCoreEditor({
      config: {
        MENU_CONF: {
          [menuKey]: menuConfig,
        },
      },
    })

    expect(editor.getMenuConfig(menuKey)).toEqual(menuConfig)
  })
})


================================================
FILE: packages/core/__tests__/config/toolbar-config.test.ts
================================================
/**
 * @description toolbar config test
 * @author wangfupeng
 */

import createCoreEditor from '../create-core-editor'
import { IDomEditor } from '../../src/editor/interface'
import createToolbarForSrc from '../../src/create/create-toolbar'

// 注册几个菜单,测试用
import '../menus/register-menus/index'

// 创建 toolbar
function createToolbar(editor: IDomEditor, customConfig = {}) {
  const container = document.createElement('div')
  document.body.appendChild(container)
  return createToolbarForSrc(editor, {
    selector: container,
    config: {
      toolbarKeys: ['myButtonMenu', 'mySelectMenu', 'myModalMenu'], // 已注册的菜单 key
      ...customConfig,
    },
  })
}

describe('toolbar config', () => {
  const editor = createCoreEditor()

  it('default config', () => {
    const toolbar = createToolbar(editor)
    const defaultConfig = toolbar.getConfig()
    const { excludeKeys = [], toolbarKeys = [] } = defaultConfig
    expect(excludeKeys.length).toBe(0)
    expect(toolbarKeys.length).toBeGreaterThan(0)
  })

  it('toolbarKeys', () => {
    const keys = ['mySelectMenu', 'myModalMenu']

    const toolbar = createToolbar(editor, {
      toolbarKeys: keys,
    })

    const { toolbarKeys = [] } = toolbar.getConfig()
    expect(toolbarKeys).toEqual(keys)
  })

  it('excludeKeys', () => {
    const keys = ['myButtonMenu', 'mySelectMenu']
    const toolbar = createToolbar(editor, {
      excludeKeys: keys,
    })
    const { excludeKeys = [] } = toolbar.getConfig()
    expect(excludeKeys).toEqual(keys)
  })

  it('insertKeys', () => {
    const toolbarKeys = ['mySelectMenu', 'myModalMenu']
    const insertKeysInfo = {
      index: 0,
      keys: ['myButtonMenu'],
    }
    const toolbar = createToolbar(editor, {
      toolbarKeys,
      insertKeys: insertKeysInfo,
    })
    const { insertKeys = {} } = toolbar.getConfig()
    expect(insertKeys).toEqual(insertKeysInfo)
  })
})


================================================
FILE: packages/core/__tests__/create/content-to-html.test.ts
================================================
/**
 * @description convert to html test
 * @author wangfupeng
 */

import createEditor from '../../src/create/create-editor'

describe('convert to html or text', () => {
  let container = document.createElement('div')

  beforeEach(() => {
    container = document.createElement('div')
    document.body.appendChild(container)
  })

  afterEach(() => {
    document.body.removeChild(container)
  })

  it('convert to html if give selector option', () => {
    const editor = createEditor({
      selector: container,
      content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
    })
    expect(editor.getHtml()).toBe('<div>hello</div>')
  })

  it('convert to html if not give selector option', () => {
    const editor = createEditor({
      // 不传入 selector ,只有 content
      content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
    })
    expect(editor.getHtml()).toBe('<div>hello</div>')
  })

  it('convert to text if give selector option', () => {
    const editor = createEditor({
      selector: container,
      content: [
        { type: 'paragraph', children: [{ text: 'hello' }] },
        { type: 'paragraph', children: [{ text: 'world' }] },
      ],
    })
    expect(editor.getText()).toBe('hello\nworld')
  })

  it('convert to text if not give selector option', () => {
    const editor = createEditor({
      // 不传入 selector ,只有 content
      content: [
        { type: 'paragraph', children: [{ text: 'hello' }] },
        { type: 'paragraph', children: [{ text: 'world' }] },
      ],
    })
    expect(editor.getText()).toBe('hello\nworld')
  })
})


================================================
FILE: packages/core/__tests__/create-core-editor.ts
================================================
/**
 * @description create editor - 用于 packages/core 单元测试
 * @author wangfupeng
 */

import createEditor from '../src/create/create-editor'

export default function (options: any = {}) {
  const container = document.createElement('div')
  document.body.appendChild(container)

  return createEditor({
    selector: container,
    ...options,
  })
}


================================================
FILE: packages/core/__tests__/editor/dom-editor.test.ts
================================================
/**
 * @description core editor test
 * @author luochao
 */

import { Editor, Range as SlateRange } from 'slate'
import { DomEditor } from '../../src/editor/dom-editor'
import { IDomEditor } from '../../src/editor/interface'
import createCoreEditor from '../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { Key } from '../../src/utils/key'
import { NODE_TO_KEY } from '../../src/utils/weak-maps'

let editor: IDomEditor

describe('Core DomEditor', () => {
  function genStartLocation() {
    return Editor.start(editor, [])
  }

  beforeEach(() => {
    editor = createCoreEditor()
    editor.select(genStartLocation())
  })

  afterEach(() => {
    editor.destroy()
  })

  test('DomEditor getWindow should throw Error', () => {
    try {
      DomEditor.getWindow(editor)
    } catch (err) {
      expect(err.message).toBe('Unable to find a host window element for this editor')
    }
  })

  test('DomEditor findKey should return Key for a node', () => {
    editor.apply({
      type: 'insert_text',
      path: [0, 0],
      text: 'test123',
      offset: 0,
    })

    const node = editor.children[0]

    expect(DomEditor.findKey(editor, node) instanceof Key).toBeTruthy()
  })

  test('DomEditor findKey should return unique Key for different node', () => {
    editor.apply({
      type: 'insert_node',
      path: [0, 0],
      node: {
        type: 'paragraph',
        children: [{ text: 'test123' }],
      },
    })

    editor.apply({
      type: 'insert_node',
      path: [0, 1],
      node: {
        type: 'header1',
        children: [{ text: 'test456' }],
      },
    })

    const [node1, node2] = (editor.children[0] as any).children
    const keyId1 = DomEditor.findKey(editor, node1).id
    const keyId2 = DomEditor.findKey(editor, node2).id

    expect(keyId1).not.toBe(keyId2)
  })

  test('DomEditor findKey should generate new key if node do not exist in NODE_TO_KEY', () => {
    const node = {
      type: 'header2',
      children: [{ text: '123' }],
    }

    // 防卫断言
    expect(NODE_TO_KEY.get(node)).toBeUndefined()

    const newKey = DomEditor.findKey(editor, node)
    expect(NODE_TO_KEY.get(node)).toEqual(newKey)
  })

  test('DomEditor setNewKey should set new value to NODE_TO_KEY', () => {
    const node = {
      type: 'header2',
      children: [{ text: '123' }],
    }

    expect(NODE_TO_KEY.get(node)).toBeUndefined()

    DomEditor.setNewKey(node)

    expect(NODE_TO_KEY.get(node)).not.toBeUndefined()
  })

  test('findPath', () => {
    const p = editor.children[0]
    // @ts-ignore
    const textNode = p.children[0]

    const path = DomEditor.findPath(null, textNode)
    expect(path).toEqual([0, 0])
  })

  test('findDocumentOrShadowRoot', () => {
    const doc = DomEditor.findDocumentOrShadowRoot(editor)
    expect(doc).toBe(document)
  })

  test('getParentNode', () => {
    const p = editor.children[0]
    // @ts-ignore
    const textNode = p.children[0]

    expect(DomEditor.getParentNode(null, textNode)).toBe(p)
    expect(DomEditor.getParentNode(null, p)).toBe(editor)
  })

  test('getParentsNodes', () => {
    const p = editor.children[0]
    // @ts-ignore
    const textNode = p.children[0]

    const parents = DomEditor.getParentsNodes(editor, textNode)
    expect(parents[0]).toBe(p)
    expect(parents[1]).toBe(editor)
  })

  test('getTopNode', () => {
    const p = editor.children[0]
    // @ts-ignore
    const textNode = p.children[0]

    const topNode = DomEditor.getTopNode(editor, textNode)
    expect(topNode).toBe(p)
  })

  test('toDOMNode', () => {
    const p = editor.children[0]

    const key = DomEditor.findKey(editor, p)

    const domNode = DomEditor.toDOMNode(editor, p)
    expect(domNode.tagName).toBe('DIV')
    expect(domNode.id).toBe(`w-e-element-${key.id}`)
  })

  test('hasDOMNode', () => {
    const p = editor.children[0]
    const domNode = DomEditor.toDOMNode(editor, p)

    const res = DomEditor.hasDOMNode(editor, domNode)
    expect(res).toBeTruthy()
  })

  // TODO 待写...
  // test('toDOMRange', () => {})

  // TODO 待写...
  // test('toDOMPoint', () => {})

  test('toSlateNode', () => {
    const p = editor.children[0]
    const domNode = DomEditor.toDOMNode(editor, p)

    const slateNode = DomEditor.toSlateNode(null, domNode)
    expect(slateNode).toBe(p)
  })

  // TODO 待写...
  // test('findEventRange', () => {})

  // TODO 待写...
  // test('toSlateRange', () => {})

  // TODO 待写...
  // test('toSlatePoint', () => {})

  test('hasRange', () => {
    editor.insertText('hello')
    editor.selectAll()

    const res = DomEditor.hasRange(editor, editor.selection as SlateRange)
    expect(res).toBeTruthy()

    // expect(1).toBe(1)
  })

  test('getNodeType', () => {
    const p = editor.children[0]
    // @ts-ignore
    const textNode = p.children[0]

    expect(DomEditor.getNodeType(p)).toBe('paragraph')
    expect(DomEditor.getNodeType(textNode)).toBe('')
  })

  test('checkNodeType', () => {
    const p = editor.children[0]
    expect(DomEditor.checkNodeType(p, 'paragraph')).toBeTruthy()
  })

  test('getSelectedElems', () => {
    editor.insertNode({
      type: 'some-elem',
      children: [{ text: 'hello' }],
    })
    editor.selectAll()

    const selectedElems = DomEditor.getSelectedElems(editor)

    expect(selectedElems.length).toBe(2)
    expect(selectedElems[1].type).toBe('some-elem')
  })

  test('getSelectedNodeByType', () => {
    const p = editor.children[0]
    const selectedNode = DomEditor.getSelectedNodeByType(editor, 'paragraph')
    expect(selectedNode).toBe(p)
  })

  test('getSelectedTextNode', () => {
    const p = editor.children[0]
    // @ts-ignore
    const textNode = p.children[0]

    const selectedTextNode = DomEditor.getSelectedTextNode(editor)
    expect(selectedTextNode).toBe(textNode)
  })

  test('isNodeSelected', () => {
    const p = editor.children[0]
    // @ts-ignore
    const textNode = p.children[0]

    expect(DomEditor.isNodeSelected(editor, p)).toBeTruthy()
    expect(DomEditor.isNodeSelected(editor, textNode)).toBeTruthy()
  })

  test('isSelectionAtLineEnd', () => {
    editor.insertText('hello')
    expect(DomEditor.isSelectionAtLineEnd(editor, [0])).toBeTruthy() // 在第一行的末尾

    editor.select(genStartLocation()) // 选中开始
    expect(DomEditor.isSelectionAtLineEnd(editor, [0])).toBeFalsy() // 在第一行的开头
  })
})


================================================
FILE: packages/core/__tests__/editor/plugins/with-config.test.ts
================================================
/**
 * @description config API test
 * @author wangfupeng
 */

import createCoreEditor from '../../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { withConfig } from '../../../src/editor/plugins/with-config'

function createEditor(...args) {
  return withConfig(createCoreEditor(...args))
}

describe('editor config API', () => {
  it('get config', () => {
    const editor = createEditor()
    const defaultConfig = editor.getConfig()
    expect(defaultConfig).not.toBeNull()
    expect(defaultConfig.autoFocus).toBeTruthy()
    expect(defaultConfig.readOnly).toBeFalsy()
    // 其他 props 不一一写了
  })

  it('get menu config', () => {
    const editor = createEditor()
    const insertLinkConfig = editor.getMenuConfig('insertLink')
    expect(insertLinkConfig).not.toBeNull()
  })

  it('get all menus', () => {
    const editor = createEditor()
    const menuKeys = editor.getAllMenuKeys()
    expect(Array.isArray(menuKeys)).toBeTruthy()
  })
})


================================================
FILE: packages/core/__tests__/editor/plugins/with-content.test.ts
================================================
/**
 * @description content API test
 * @author wangfupeng
 */

import { Editor, Transforms, Node, Selection } from 'slate'
import createCoreEditor from '../../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { withContent } from '../../../src/editor/plugins/with-content'
import { IDomEditor } from '../../../src/editor/interface'

function createEditor(...args) {
  return withContent(createCoreEditor(...args))
}

let editor: IDomEditor

function setEditorSelection(
  editor: IDomEditor,
  selection: Selection = {
    anchor: { path: [0, 0], offset: 0 },
    focus: { path: [0, 0], offset: 0 },
  }
) {
  editor.selection = selection
}

const ignoreTag = [
  'doctype',
  '!doctype',
  'meta',
  'script',
  'style',
  'link',
  'frame',
  'iframe',
  'title',
  'svg',
]

describe('editor content API', () => {
  function getStartLocation(editor) {
    return Editor.start(editor, [])
  }

  it('handleTab', () => {
    const editor = createEditor()
    editor.select(getStartLocation(editor))
    editor.handleTab()
    expect(editor.getText().length).toBe(4) // 默认 tab 键,输入 4 空格
  })

  it('getHtml', () => {
    const editor = createEditor({
      content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
    })

    const html = editor.getHtml()
    expect(html).toBe('<div>hello</div>')
  })

  it('getHtml with void element', () => {
    const editor = createEditor({
      content: [
        { type: 'paragraph', children: [{ text: 'hello' }] },
        { type: 'image', children: [{ text: '' }], src: 'test.jpg' },
      ],
    })

    const html = editor.getHtml()
    expect(html).toBe('<div>hello</div><div></div>')
  })

  it('getText', () => {
    const editor = createEditor({
      content: [
        { type: 'paragraph', children: [{ text: 'hello' }] },
        { type: 'paragraph', children: [{ text: 'world' }] },
      ],
    })
    const text = editor.getText()
    expect(text).toBe('hello\nworld')
  })

  it('isEmpty', () => {
    const editor1 = createEditor()
    expect(editor1.isEmpty()).toBeTruthy()

    const editor2 = createEditor({
      content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
    })
    expect(editor2.isEmpty()).toBeFalsy()
  })

  it('getSelectionText', () => {
    const editor = createEditor({
      content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
    })
    editor.select(getStartLocation(editor)) // 光标在开始位置
    expect(editor.getSelectionText()).toBe('')

    editor.select([]) // 全选
    expect(editor.getSelectionText()).toBe('hello')
  })

  it('getElemsByTypePrefix', () => {
    const editor = createEditor({
      content: [
        { type: 'header1', children: [{ text: 'a' }] },
        { type: 'header2', children: [{ text: 'b' }] },
        { type: 'paragraph', children: [{ text: 'c' }] },
      ],
    })
    const headers = editor.getElemsByTypePrefix('header')
    expect(headers.length).toBe(2)
    const pList = editor.getElemsByTypePrefix('paragraph')
    expect(pList.length).toBe(1)
    const images = editor.getElemsByTypePrefix('image')
    expect(images.length).toBe(0)
  })

  it('getElemsByType', () => {
    const editor = createEditor({
      content: [
        { type: 'header1', children: [{ text: 'a' }] },
        { type: 'header2', children: [{ text: 'b' }] },
        { type: 'paragraph', children: [{ text: 'c' }] },
      ],
    })
    const headers = editor.getElemsByType('header')
    expect(headers.length).toBe(0)
    const pList = editor.getElemsByType('paragraph')
    expect(pList.length).toBe(1)
    const images = editor.getElemsByType('image')
    expect(images.length).toBe(0)
  })

  it('deleteBackward with character', () => {
    const editor = createEditor({
      content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
    })
    editor.select(getStartLocation(editor)) // 光标在开始位置
    Transforms.move(editor, { distance: 2, unit: 'character' }) // 光标移动 2 个字符

    editor.deleteBackward('character') // 向后删除
    expect(editor.getText()).toBe('hllo')
  })

  it('deleteBackward with word', () => {
    const editor = createEditor({
      content: [{ type: 'paragraph', children: [{ text: 'hello world' }] }],
    })
    editor.select(getStartLocation(editor)) // 光标在开始位置
    Transforms.move(editor, { distance: 1, unit: 'word' }) // 光标移动 1 个单词

    editor.deleteBackward('word') // 向后删除
    expect(editor.getText()).toBe(' world')
  })

  it('deleteForward with character', () => {
    const editor = createEditor({
      content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
    })
    editor.select(getStartLocation(editor)) // 光标在开始位置
    Transforms.move(editor, { distance: 1, unit: 'character' }) // 光标移动 1 个字符

    editor.deleteForward('character') // 向前删除
    expect(editor.getText()).toBe('hllo')
  })

  it('deleteForward with word', () => {
    const editor = createEditor({
      content: [{ type: 'paragraph', children: [{ text: 'hello world' }] }],
    })
    editor.select(getStartLocation(editor)) // 光标在开始位置
    Transforms.move(editor, { distance: 1, unit: 'word' }) // 光标移动 1 个 word

    editor.deleteForward('word') // 向前删除
    expect(editor.getText()).toBe('hello')
  })

  it('deleteForward with line', () => {
    const editor = createEditor({
      content: [
        { type: 'paragraph', children: [{ text: 'hello' }] },
        { type: 'paragraph', children: [{ text: 'world' }] },
      ],
    })
    editor.select(getStartLocation(editor)) // 光标在开始位置

    editor.deleteForward('line') // 向前删除
    expect(editor.getText()).toBe('\nworld')
  })

  it('getFragment', () => {
    const editor = createEditor({
      content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
    })
    // 选中 'hel'lo
    editor.select({
      anchor: {
        path: [0, 0],
        offset: 0,
      },
      focus: {
        path: [0, 0],
        offset: 3,
      },
    })

    const fragment = editor.getFragment() // 获取选中内容
    expect(Node.string(fragment[0])).toBe('hel')
  })

  it('deleteFragment', () => {
    const editor = createEditor({
      content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
    })
    // 选中 'hel'lo
    editor.select({
      anchor: {
        path: [0, 0],
        offset: 0,
      },
      focus: {
        path: [0, 0],
        offset: 3,
      },
    })

    editor.deleteFragment() // 删除选中内容
    expect(editor.getText()).toBe('lo')
  })

  it('insertBreak', () => {
    const editor = createEditor()
    editor.select(getStartLocation(editor)) // 光标在开始位置

    editor.insertBreak()
    const pList = editor.getElemsByTypePrefix('paragraph')
    expect(pList.length).toBe(2)
  })

  it('insertText', () => {
    const editor = createEditor()
    editor.select(getStartLocation(editor)) // 光标在开始位置
    editor.insertText('xxx')
    expect(editor.getText()).toBe('xxx')
  })

  it('clear', () => {
    const editor = createEditor({
      content: [{ type: 'paragraph', children: [{ text: 'hello' }] }],
    })
    editor.clear()
    expect(editor.getText()).toBe('')
  })

  it('undo', () => {
    const editor = createEditor()
    editor.select(getStartLocation(editor)) // 光标在开始位置

    editor.insertText('hello')

    // @ts-ignore
    editor.undo()
    expect(editor.getText()).toBe('')
  })

  it('redo', () => {
    const editor = createEditor()
    editor.select(getStartLocation(editor)) // 光标在开始位置

    editor.insertText('hello')

    // @ts-ignore
    editor.undo()
    // @ts-ignore
    editor.redo()
    expect(editor.getText()).toBe('hello')
  })

  describe('dangerouslyInsertHtml API', () => {
    beforeEach(() => {
      editor = createEditor()
    })

    // 现在使用的是 packages/core 的 createEditor ,创建的 editor 没有内置各种 module
    // 所以 dangerouslyInsertHtml 在此测试基本功能即可。其他 tag 在各自的 module 中测试

    test('dangerouslyInsertHtml should insert text with no blank to editor', () => {
      // insertText 必须要设置 selection 才能生效
      setEditorSelection(editor)

      const htmlString = '<div>wangEditor!</div>'
      editor.dangerouslyInsertHtml(htmlString)

      expect(editor.getText().indexOf('wangEditor')).toBeGreaterThan(-1)
    })

    ignoreTag.forEach(tag => {
      test(`insert html string with ${tag} element should to be ignore`, () => {
        setEditorSelection(editor)
        const htmlString = `<${tag}></${tag}>`
        editor.dangerouslyInsertHtml(htmlString)

        expect(editor.getHtml().indexOf(tag)).toBe(-1)
      })
    })
  })

  it('getParentNode', () => {
    const textNode = { text: 'hello' }
    const p = { type: 'paragraph', children: [textNode] }
    const editor = createEditor({
      content: [p],
    })

    const parentNode = editor.getParentNode(textNode) as any
    expect(parentNode).not.toBeNull()
    expect(parentNode.type).toBe('paragraph')
  })

  it('insertNode', () => {
    const editor = createEditor()
    editor.select(getStartLocation(editor))

    const p = { type: 'paragraph', children: [{ text: 'hello' }] }
    editor.insertNode(p)

    const pList = editor.getElemsByTypePrefix('paragraph')
    expect(pList.length).toBe(2)
  })

  describe('setHtml', () => {
    it('setHtml normal', () => {
      const editor = createEditor({ html: '<div>hello</div>' })
      editor.select(getStartLocation(editor))

      const newHtml = '<div>world</div>'
      editor.setHtml(newHtml)

      expect(editor.getHtml()).toBe(newHtml)
    })

    it('setHtml blur', () => {
      const editor = createEditor({
        html: '<div>hello</div>',
        autoFocus: false,
      })
      expect(editor.isFocused()).toBe(false)

      const newHtml = '<div>world</div>'
      editor.setHtml(newHtml)

      expect(editor.getHtml()).toBe(newHtml)
      expect(editor.isFocused()).toBe(false)
    })

    it('setHtml disabled', () => {
      const editor = createEditor({ html: '<div>hello</div>' })
      editor.disable()
      expect(editor.isDisabled()).toBe(true)

      const newHtml = '<div>world</div>'
      editor.setHtml(newHtml)

      expect(editor.getHtml()).toBe(newHtml)
      expect(editor.isDisabled()).toBe(true)
    })
  })
})


================================================
FILE: packages/core/__tests__/editor/plugins/with-dom.test.ts
================================================
/**
 * @description editor DOM API test
 * @author wangfupeng
 */

import { Editor } from 'slate'
import createCoreEditor from '../../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { withDOM } from '../../../src/editor/plugins/with-dom'

function createEditor(...args) {
  return withDOM(createCoreEditor(...args))
}

describe('editor DOM API', () => {
  function getStartLocation(editor) {
    return Editor.start(editor, [])
  }

  it('editor id', () => {
    const editor = createEditor()
    expect(editor.id).not.toBeNull()
  })

  it('isFullScreen fullScreen unFullScreen', done => {
    const editor = createEditor()

    expect(editor.isFullScreen).toBeFalsy()

    editor.fullScreen()
    expect(editor.isFullScreen).toBeTruthy()

    editor.unFullScreen()
    setTimeout(() => {
      expect(editor.isFullScreen).toBeFalsy()
      done()
    }, 1000)
  })

  // TODO focus blur isFocused 用 jest 测试异常,以及 editor-config.test.ts 中的 `onFocus` `onBlur`

  it('disable isDisabled enable', () => {
    const editor = createEditor()
    editor.select(getStartLocation(editor))

    expect(editor.isDisabled()).toBeFalsy()
    editor.insertText('123')
    expect(editor.getText().length).toBe(3)

    editor.disable()
    expect(editor.isDisabled()).toBeTruthy()
    editor.insertText('123') // disabled ,不会插入
    expect(editor.getText().length).toBe(3)

    editor.enable()
    expect(editor.isDisabled()).toBeFalsy()
    editor.insertText('123') // enable ,可以插入
    expect(editor.getText().length).toBe(6)
  })

  it('destroy', done => {
    const editor = createEditor()
    expect(editor.isDestroyed).toBeFalsy()

    setTimeout(() => {
      editor.destroy()
      expect(editor.isDestroyed).toBeTruthy()
      done()
    })
  })

  it('toDOMNode', done => {
    const p = { type: 'paragraph', children: [{ text: 'hello' }] }
    const editor = createEditor({
      content: [p],
    })

    setTimeout(() => {
      const domNode = editor.toDOMNode(p)
      expect(domNode.tagName).toBe('DIV')
      done()
    })
  })
})


================================================
FILE: packages/core/__tests__/editor/plugins/with-emitter.test.ts
================================================
/**
 * @description editor eventBus API test
 * @author wangfupeng
 */

import createCoreEditor from '../../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { withEmitter } from '../../../src/editor/plugins/with-emitter'

function createEditor(...args) {
  return withEmitter(createCoreEditor(...args))
}

describe('eventBus API', () => {
  it('bind and emit', () => {
    const editor = createEditor()

    const fn1 = jest.fn() // jest mock function
    const fn2 = jest.fn()
    const fn3 = jest.fn()

    editor.on('key1', fn1)
    editor.on('key1', fn2)
    editor.on('xxxx', fn3)

    editor.emit('key1', 10, 20)

    expect(fn1).toBeCalledWith(10, 20)
    expect(fn2).toBeCalledWith(10, 20)
    expect(fn3).not.toBeCalled()
  })

  it('off single event', () => {
    const editor = createEditor()

    const fn1 = jest.fn()
    const fn2 = jest.fn()

    editor.on('key1', fn1)
    editor.on('key1', fn2)

    editor.off('key1', fn1)

    editor.emit('key1', 10, 20)

    expect(fn1).not.toBeCalled()
    expect(fn2).toBeCalledWith(10, 20)
  })

  it('once', () => {
    const editor = createEditor()

    let n = 1

    const fn1 = jest.fn(() => n++)
    const fn2 = jest.fn(() => n++)

    editor.once('key1', fn1)
    editor.once('key1', fn2)

    // 无论 emit 多少次,只有一次生效
    editor.emit('key1')
    editor.emit('key1')
    editor.emit('key1')
    editor.emit('key1')
    editor.emit('key1')

    expect(n).toBe(3)
  })
})


================================================
FILE: packages/core/__tests__/editor/plugins/with-selection.test.ts
================================================
/**
 * @description selection API test
 * @author wangfupeng
 */

import { Editor } from 'slate'
import createCoreEditor from '../../create-core-editor' // packages/core 不依赖 packages/editor ,不能使用后者的 createEditor
import { withSelection } from '../../../src/editor/plugins/with-selection'

function createEditor(...args) {
  return withSelection(createCoreEditor(...args))
}

describe('editor selection API', () => {
  function getStartLocation(editor) {
    return Editor.start(editor, [])
  }
  function genParagraph() {
    return { type: 'paragraph', children: [{ text: 'hello' }] }
  }

  // selection select deselect move 是 slate 自带 API 或属性,不测试

  // // TODO 运行报错,看源码有使用 focus ,可能和这个相关???
  // it('restoreSelection', () => {
  //   const editor = createEditor()
  //   editor.select(getStartLocation(editor))

  //   editor.deselect()
  //   expect(editor.selection).toBeNull()

  //   editor.restoreSelection()
  //   expect(editor.selection).not.toBeNull()
  //   // console.log(111, JSON.stringify(editor.selection))
  // })

  it('isSelectedAll', () => {
    const p = genParagraph()
    const editor = createEditor({ content: [p] })
    expect(editor.isSelectedAll()).toBeFalsy()

    editor.select(getStartLocation(editor))
    expect(editor.isSelectedAll()).toBeFalsy()

    editor.select([])
    expect(editor.isSelectedAll()).toBeTruthy()
  })
})


================================================
FILE: packages/core/__tests__/i18n/index.test.ts
================================================
/**
 * @description i18n test
 * @author wangfupeng
 */

import i18next, { i18nAddResources, i18nChangeLanguage, t } from '../../src/i18n'

describe('i18n', () => {
  // 添加语言项
  i18nAddResources('en', {
    module1: {
      hello: 'hello',
    },
  })
  i18nAddResources('zh-CN', {
    module1: {
      hello: '你好',
    },
  })

  it('default lang', () => {
    expect(i18next.language).toBe('zh-CN')
    expect(t('module1.hello')).toBe('你好')
  })

  it('change lang', () => {
    i18nChangeLanguage('en')
    expect(i18next.language).toBe('en')
    expect(t('module1.hello')).toBe('hello')
  })
})


================================================
FILE: packages/core/__tests__/menus/README.md
================================================
# menus test

TODO 各个 modules 中没有这块代码的测试,待编写...


================================================
FILE: packages/core/__tests__/menus/register-menus/index.ts
================================================
/**
 * @description 注册菜单,入口
 * @author wangfupeng
 */

import './register-button-menu'
import './register-select-menu'
import './register-modal-menu'


================================================
FILE: packages/core/__tests__/menus/register-menus/register-button-menu.ts
================================================
/**
 * @description 注册菜单 - button menu
 * @author wangfupeng
 */

import { registerMenu, IButtonMenu } from '../../../src/menus/index'
import { IDomEditor } from '../../../src/editor/interface'

class MyButtonMenu implements IButtonMenu {
  readonly title = 'My Button Menu'
  readonly tag = 'button'
  getValue(editor: IDomEditor) {
    return ''
  }
  isActive(editor: IDomEditor) {
    return false
  }
  isDisabled(editor: IDomEditor) {
    return false
  }
  exec(editor: IDomEditor, value: string | boolean) {
    console.log('do..')
  }
}

registerMenu({
  key: 'myButtonMenu',
  factory() {
    return new MyButtonMenu()
  },
})


================================================
FILE: packages/core/__tests__/menus/register-menus/register-modal-menu.ts
================================================
/**
 * @description 注册菜单 - modal menu
 * @author wangfupeng
 */

import { registerMenu, IModalMenu } from '../../../src/menus/index'
import { IDomEditor } from '../../../src/editor/interface'

class MyModalMenu implements IModalMenu {
  readonly title = 'My Modal Menu'
  readonly tag = 'button'
  readonly showModal = true
  readonly modalWidth = 300
  getValue(editor: IDomEditor) {
    return ''
  }
  isActive(editor: IDomEditor) {
    return false
  }
  isDisabled(editor: IDomEditor) {
    return false
  }
  exec(editor: IDomEditor, value: string | boolean) {
    console.log('do..')
  }
  getModalContentElem(editor: IDomEditor) {
    return document.createElement('div')
  }
  getModalPositionNode(editor: IDomEditor) {
    return null
  }
}

registerMenu({
  key: 'myModalMenu',
  factory() {
    return new MyModalMenu()
  },
})


================================================
FILE: packages/core/__tests__/menus/register-menus/register-select-menu.ts
================================================
/**
 * @description 注册菜单 - select menu
 * @author wangfupeng
 */

import { registerMenu, ISelectMenu, IOption } from '../../../src/menus/index'
import { IDomEditor } from '../../../src/editor/interface'

class MySelectMenu implements ISelectMenu {
  readonly title = 'My Select Menu'
  readonly tag = 'select'
  getValue(editor: IDomEditor) {
    return ''
  }
  isActive(editor: IDomEditor) {
    return false
  }
  isDisabled(editor: IDomEditor) {
    return false
  }
  exec(editor: IDomEditor, value: string | boolean) {
    console.log('do..')
  }
  getOptions(): IOption[] {
    return [
      { value: 'a', text: 'a' },
      { value: 'b', text: 'b' },
    ]
  }
}

registerMenu({
  key: 'mySelectMenu',
  factory() {
    return new MySelectMenu()
  },
})


================================================
FILE: packages/core/__tests__/parse-html/README.md
================================================
# parse-html test

各个 module `parseHtml` 已经测试了该模块的代码。


================================================
FILE: packages/core/__tests__/render/README.md
================================================
# render test

各个 module `renderElem` 已经测试了该模块的代码。


================================================
FILE: packages/core/__tests__/to-html/README.md
================================================
# to-html test

各个 module 中的 `editor.getHtml()` API 会测试到这部分代码。


================================================
FILE: packages/core/__tests__/upload/uploader.test.ts
================================================
/**
 * @description uploader test
 * @author wangfupeng
 */

import createUploader from '../../src/upload/createUploader'
import { IUploadConfig } from '../../src/upload/interface'
import nock from 'nock'

const server = 'https://fake-endpoint.wangeditor-v5.com'

describe('uploader', () => {
  test('if should return Uppy object if invoke createUploader function', () => {
    const uppy = createUploader({
      server: '/upload',
      fieldName: 'file1',
      metaWithUrl: true,
      meta: {
        token: 'xxx',
      },
      onSuccess: (file, res) => {},
      onFailed: (file, res) => {},
      onError: (file, err, res) => {},
    })
    expect(uppy).not.toBeNull()
  })

  test('it should throw can not get address error if not pass server option', () => {
    try {
      createUploader({
        fieldName: 'file1',
        metaWithUrl: false,
        onSuccess: (file, res) => {},
        onFailed: (file, res) => {},
        onError: (file, err, res) => {},
      } as IUploadConfig)
    } catch (err: unknown) {
      expect((err as Error).message).toBe('Cannot get upload server address\n没有配置上传地址')
    }
  })

  test('it should throw can not get fileName error if not pass fileName option', () => {
    try {
      createUploader({
        server: '/upload',
        metaWithUrl: false,
        onSuccess: (file, res) => {},
        onFailed: (file, res) => {},
        onError: (file, err, res) => {},
      } as IUploadConfig)
    } catch (err: unknown) {
      expect((err as Error).message).toBe('Cannot get fieldName\n没有配置 fieldName')
    }
  })

  test('it should invoke success callback if file be uploaded successfully', () => {
    nock(server)
      .defaultReplyHeaders({
        'access-control-allow-method': 'POST',
        'access-control-allow-origin': '*',
      })
      .options('/')
      .reply(200, {})
      .post('/')
      .reply(200, {})

    const fn = jest.fn()
    const uppy = createUploader({
      server,
      fieldName: 'file1',
      metaWithUrl: false,
      onSuccess: fn,
      onFailed: (file, res) => {},
      onError: (file, err, res) => {},
    })

    // reference https://github.com/transloadit/uppy/blob/main/packages/%40uppy/xhr-upload/src/index.test.js
    uppy.addFile({
      source: 'jest',
      name: 'foo.jpg',
      type: 'image/jpeg',
      data: new Blob([Buffer.alloc(8192)]),
    })

    return uppy.upload().then(() => {
      expect(fn).toBeCalled()
    })
  })

  test('it should invoke onProgress callback if file be uploaded successfully', () => {
    nock(server)
      .defaultReplyHeaders({
        'access-control-allow-method': 'POST',
        'access-control-allow-origin': '*',
      })
      .options('/')
      .reply(200, {})
      .post('/')
      .reply(200, {})

    const fn = jest.fn()
    const uppy = createUploader({
      server,
      fieldName: 'file1',
      metaWithUrl: false,
      onSuccess: () => {},
      onProgress: fn,
      onFailed: (file, res) => {},
      onError: (file, err, res) => {},
    })

    // reference https://github.com/transloadit/uppy/blob/main/packages/%40uppy/xhr-upload/src/index.test.js
    uppy.addFile({
      source: 'jest',
      name: 'foo.jpg',
      type: 'image/jpeg',
      data: new Blob([Buffer.alloc(8192)]),
    })

    return uppy.upload().then(() => {
      expect(fn).toBeCalled()
    })
  })

  test('it should invoke error callback if file be uploaded failed', () => {
    nock(server)
      .defaultReplyHeaders({
        'access-control-allow-method': 'POST',
        'access-control-allow-origin': '*',
      })
      .options('/')
      .reply(200, {})
      .post('/')
      .reply(400, {})

    const fn = jest.fn()
    const uppy = createUploader({
      server,
      fieldName: 'file1',
      metaWithUrl: false,
      onSuccess: () => {},
      onFailed: (file, res) => {},
      onError: fn,
    })

    // reference https://github.com/transloadit/uppy/blob/main/packages/%40uppy/xhr-upload/src/index.test.js
    uppy.addFile({
      source: 'jest',
      name: 'foo.jpg',
      type: 'image/jpeg',
      data: new Blob([Buffer.alloc(8192)]),
    })

    return uppy.upload().catch(() => {
      expect(fn).toBeCalled()
    })
  })

  test('it should invoke console.error method if file be uploaded failed and not pass onError option', () => {
    nock(server)
      .defaultReplyHeaders({
        'access-control-allow-method': 'POST',
        'access-control-allow-origin': '*',
      })
      .options('/')
      .reply(200, {})
      .post('/')
      .reply(400, {})

    const fn = jest.fn()
    console.error = fn
    const uppy = createUploader({
      server,
      fieldName: 'file1',
      metaWithUrl: false,
      onSuccess: () => {},
      onFailed: (file, res) => {},
    } as any)

    // reference https://github.com/transloadit/uppy/blob/main/packages/%40uppy/xhr-upload/src/index.test.js
    uppy.addFile({
      source: 'jest',
      name: 'foo.jpg',
      type: 'image/jpeg',
      data: new Blob([Buffer.alloc(8192)]),
    })

    return uppy.upload().catch(() => {
      expect(fn).toBeCalled()
    })
  })

  test('it should invoke error callback if file size over max size', () => {
    const fn = jest.fn()
    const uppy = createUploader({
      server,
      fieldName: 'file1',
      metaWithUrl: false,
      onSuccess: () => {},
      onFailed: (file, res) => {},
      onError: fn,
      maxFileSize: 5,
    })

    try {
      uppy.addFile({
        source: 'jest',
        name: 'foo.jpg',
        type: 'image/jpeg',
        data: new Blob([Buffer.alloc(8192)]),
      })
    } catch (err) {
      expect(fn).toBeCalled()
    }
  })
})


================================================
FILE: packages/core/__tests__/utils/util.test.ts
================================================
/**
 * @description util fns test
 * @author wangfupeng
 */

import {
  genRandomStr,
  addQueryToUrl,
  replaceHtmlSpecialSymbols,
  deReplaceHtmlSpecialSymbols,
} from '../../src/utils/util'

describe('utils', () => {
  it('gen random', () => {
    const r1 = genRandomStr()
    const r2 = genRandomStr()
    expect(r1).not.toBe(r2)
  })

  it('add query to url', () => {
    const params = { a: 10, b: 'hello' }

    const url1 = 'https://wangeditor.com/'
    expect(addQueryToUrl(url1, params)).toBe('https://wangeditor.com/?a=10&b=hello')

    const url2 = 'https://wangeditor.com/?x=1#123'
    expect(addQueryToUrl(url2, params)).toBe('https://wangeditor.com/?x=1&a=10&b=hello#123')
  })

  it('replace html symbol', () => {
    const html = '<p>hello  world</p>'
    const res = replaceHtmlSpecialSymbols(html)
    expect(res).toBe('&lt;p&gt;hello &nbsp;world&lt;/p&gt;')
  })

  it('replace html symbol', () => {
    const html = '&lt;p&gt;hello &nbsp;world&lt;/p&gt;'
    const res = deReplaceHtmlSpecialSymbols(html)
    expect(res).toBe('<p>hello  world</p>')
  })

  it('decode html quote symbol', () => {
    const html = '<p style="font-family:&quot;Times New Roman&quot;;">hello world</p>'
    const res = deReplaceHtmlSpecialSymbols(html)
    expect(res).toBe('<p style="font-family:"Times New Roman";">hello world</p>')
  })
})


================================================
FILE: packages/core/__tests__/utils/vdom.test.ts
================================================
/**
 * @description vdom util fns test
 * @author wangfupeng
 */

import { h, VNode } from 'snabbdom'
import {
  normalizeVnodeData,
  addVnodeProp,
  addVnodeDataset,
  addVnodeStyle,
} from '../../src/utils/vdom'

describe('vdom util fns', () => {
  it('normalize vnode data', () => {
    const vnode = h(
      'div',
      {
        key: 'someKey',
        id: 'div1',
        className: 'someClassName',
        'data-custom-name': 'someCustomName',
      },
      [
        h(
          'p',
          {
            id: 'p1',
          },
          ['hello']
        ),
      ]
    )

    normalizeVnodeData(vnode)

    // 转换 div 自身
    const { data = {}, children = [] } = vnode
    expect(data.key).toBe('someKey')
    const { props = {}, dataset = {} } = data
    expect(props.id).toBe('div1')
    expect(props.className).toBe('someClassName')
    expect(dataset.customName).toBe('someCustomName')

    // 转换 div 子节点 p
    const pVNode = (children[0] || {}) as VNode
    const { props: pProps = {} } = pVNode.data || {}
    expect(pProps.id).toBe('p1')
  })

  it('add vnode props', () => {
    const vnode = h('div', {})
    addVnodeProp(vnode, { k1: 'v1' })

    const { props = {} } = vnode.data || {}
    expect(props.k1).toBe('v1')
  })

  it('add vnode dataset', () => {
    const vnode = h('div', {})
    addVnodeDataset(vnode, { k1: 'v1' })

    const { dataset = {} } = vnode.data || {}
    expect(dataset.k1).toBe('v1')
  })

  it('add vnode style', () => {
    const vnode = h('div', {})
    addVnodeStyle(vnode, { k1: 'v1' })

    const { style = {} } = vnode.data || {}
    expect(style.k1).toBe('v1')
  })
})


================================================
FILE: packages/core/package.json
================================================
{
  "name": "@wangeditor/core",
  "version": "1.1.19",
  "description": "wangEditor core",
  "author": "wangfupeng1988 <wangfupeng1988@163.com>",
  "contributors": [],
  "homepage": "https://github.com/wangeditor-team/wangEditor#readme",
  "license": "MIT",
  "types": "dist/core/src/index.d.ts",
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "browser": {
    "./dist/index.js": "./dist/index.js",
    "./dist/index.esm.js": "./dist/index.esm.js"
  },
  "directories": {
    "lib": "dist",
    "test": "__tests__"
  },
  "files": [
    "dist"
  ],
  "publishConfig": {
    "access": "public",
    "registry": "https://registry.npmjs.com/"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/wangeditor-team/wangEditor.git"
  },
  "scripts": {
    "test": "jest",
    "test-c": "jest --coverage",
    "dev": "cross-env NODE_ENV=development rollup -c rollup.config.js",
    "dev-watch": "cross-env NODE_ENV=development rollup -c rollup.config.js -w",
    "build": "cross-env NODE_ENV=production rollup -c rollup.config.js",
    "dev-size-stats": "cross-env NODE_ENV=development:size_stats rollup -c rollup.config.js",
    "size-stats": "cross-env NODE_ENV=production:size_stats rollup -c rollup.config.js"
  },
  "bugs": {
    "url": "https://github.com/wangeditor-team/wangEditor/issues"
  },
  "peerDependencies": {
    "@uppy/core": "^2.1.1",
    "@uppy/xhr-upload": "^2.0.3",
    "dom7": "^3.0.0",
    "is-hotkey": "^0.2.0",
    "lodash.camelcase": "^4.3.0",
    "lodash.clonedeep": "^4.5.0",
    "lodash.debounce": "^4.0.8",
    "lodash.foreach": "^4.5.0",
    "lodash.isequal": "^4.5.0",
    "lodash.throttle": "^4.1.1",
    "lodash.toarray": "^4.4.0",
    "nanoid": "^3.2.0",
    "slate": "^0.72.0",
    "snabbdom": "^3.1.0"
  },
  "dependencies": {
    "@types/event-emitter": "^0.3.3",
    "event-emitter": "^0.3.5",
    "html-void-elements": "^2.0.0",
    "i18next": "^20.4.0",
    "scroll-into-view-if-needed": "^2.2.28",
    "slate-history": "^0.66.0"
  },
  "devDependencies": {
    "@types/is-hotkey": "^0.1.2"
  }
}


================================================
FILE: packages/core/rollup.config.js
================================================
import { createRollupConfig, IS_PRD } from '../../build/create-rollup-config'
import pkg from './package.json'

const name = 'WangEditorCore'

const configList = []

// esm
const esmConf = createRollupConfig({
  output: {
    file: pkg.module,
    format: 'esm',
    name,
  },
})
configList.push(esmConf)

// umd
const umdConf = createRollupConfig({
  output: {
    file: pkg.main,
    format: 'umd',
    name,
  },
})
configList.push(umdConf)

export default configList


================================================
FILE: packages/core/src/assets/bar-item.less
================================================
@import "../../../vars.less"; // var and mixin

.w-e-bar-divider {
  display: inline-flex;
  width: 1px;
  height: @toolbar-height;
  background-color: @toolbar-border-color; // 分割线 bgColor
  margin: 0 5px;
}

.w-e-bar-item {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 4px;
  height: @toolbar-height;

  button {
    border: none;
    background: transparent;
    height: calc(@toolbar-height - 8px);
    padding: 0 8px;
    cursor: pointer;
    display: inline-flex;
    justify-content: center;
    align-items: center;
    color: @toolbar-color;
    white-space: nowrap; /* 不换行 */
    overflow: hidden;

    &:hover {
      background-color: @toolbar-active-bg-color;
      color: @toolbar-active-color;
    }

    .title {
      margin-left: 5px;
    }
  }

  .active {
    background-color: @toolbar-active-bg-color;
    color: @toolbar-active-color;
  }

  .disabled {
    color: @toolbar-disabled-color;
    cursor: not-allowed;

    svg {
      fill: @toolbar-disabled-color;
    }

    &:hover {
      background-color: @toolbar-bg-color;
      color: @toolbar-disabled-color;
  
      svg {
        fill: @toolbar-disabled-color;
      }
    }
  }
}

// ------------------------------------- 分割线 -------------------------------------

// tooltip - bottom
.w-e-menu-tooltip-v5 {
  &:before {
    content: attr(data-tooltip);
    position: absolute;
    background-color: @toolbar-active-color; // tooltip 颜色反转,黑底白字
    color: @toolbar-bg-color;  // tooltip 颜色反转,黑底白字
    text-align: center;
    padding: 5px 10px;
    border-radius: 5px;
    z-index: 1;
    opacity: 0;
    transition: opacity 0.6s;
    font-size: 0.75em;
    visibility: hidden;
    top: @toolbar-height;
    white-space: pre;
  }
  // arrow
  &:after {
    content: "";
    position: absolute;
    border-width: 5px;
    border-style: solid;
    opacity: 0;
    transition: opacity 0.6s;
    border-color:  transparent transparent @toolbar-active-color transparent;
    visibility: hidden;
    top: 30px;
  }
  &:hover:before,
  &:hover:after {
    opacity: 1;
    visibility: visible;
  }
}
// tooltip - right
.w-e-menu-tooltip-v5.tooltip-right {
  &:before {
    left: 100%;
    top: 10px;
  }
  // arrow
  &:after {
    left: 100%;
    margin-left: -10px;
    top: 16px;
    border-color:  transparent @toolbar-active-color transparent transparent;
  }
}

// ------------------------------------- 分割线 -------------------------------------

// barItem group
.w-e-bar-item-group {
  .w-e-bar-item-menus-container {
    display: none; /* 默认隐藏 */

    z-index: 1;
    background-color: @toolbar-bg-color;
    position: absolute;
    top: 0;
    left: 0;
    margin-top: @toolbar-height;
    .shadowBordered(10px);
  }
  &:hover {
    /* hover 时显示下级菜单 */
    .w-e-bar-item-menus-container {
      display: block;
    }
  }
}



================================================
FILE: packages/core/src/assets/bar.less
================================================
@import "../../../vars.less"; // var and mixin

.w-e-bar {
  background-color: @toolbar-bg-color;
  padding: 0 5px;
  font-size: @size;
  color: @toolbar-color;

  svg {
    width: @size;
    height: @size;
    fill: @toolbar-color;
  }
}
.w-e-bar-show {
  display: flex;
}
.w-e-bar-hidden {
  display: none;
}

.w-e-hover-bar {
  position: absolute;
  .shadowBordered();
}

.w-e-toolbar {
  flex-wrap: wrap;
  position: relative;
}


================================================
FILE: packages/core/src/assets/common.less
================================================
.w-e-text-container *,
.w-e-toolbar * {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
  outline: none;
}

.w-e-text-container {
  p, li, td, th, blockquote {
    line-height: 1.5;
  }
}

.w-e-toolbar * {
  line-height: 1.5;
}

================================================
FILE: packages/core/src/assets/drop-panel.less
================================================
@import "../../../vars.less"; // var and mixin

.w-e-drop-panel {
  z-index: 1;
  background-color: @toolbar-bg-color;
  position: absolute;
  top: 0;
  .shadowBordered(10px);
  margin-top: @toolbar-height;
  min-width: 200px;
  padding: 10px;
}

// 当 bar 处于页面下方,则 dropPanel 要显示在 bar 上方
.w-e-bar-bottom .w-e-drop-panel {
  top: inherit;
  bottom: 0;
  margin-top: 0;
  margin-bottom: @toolbar-height;
}


================================================
FILE: packages/core/src/assets/full-screen.less
================================================
.w-e-full-screen-container {
  position: fixed;
  margin: 0 !important;
  padding: 0 !important;
  top: 0 !important;
  left: 0 !important;
  right: 0 !important;
  bottom: 0 !important;
  height: 100% !important;
  width: 100% !important;
  display: flex !important;
  flex-direction: column !important;

  // [data-w-e-toolbar="true"] {
  // }

  [data-w-e-textarea="true"] {
    flex: 1 !important;
  }
}


================================================
FILE: packages/core/src/assets/index.less
================================================
@import "common.less";
@import "textarea.less";
@import "bar.less";
@import "bar-item.less";
@import "select-list.less";
@import "drop-panel.less";
@import "modal.less";
@import "progress.less";
@import "full-screen.less";


================================================
FILE: packages/core/src/assets/modal.less
================================================
@import "../../../vars.less"; // var and mixin

.w-e-modal {
  z-index: 1;
  background-color: @toolbar-bg-color;
  position: absolute;
  padding: 20px 15px 0 15px;
  min-width: 100px;
  min-height: 40px;
  color: @toolbar-color;
  text-align: left;
  font-size: @size;
  .shadowBordered(10px);

  .btn-close {
    position: absolute;
    right: 8px;
    top: 7px;
    cursor: pointer;
    padding: 5px;
    line-height: 1;

    svg {
      width: 10px;
      height: 10px;
      fill: @toolbar-color;
    }
  }

  .babel-container {
    display: block;
    margin-bottom: 15px;

    span {
      display: block;
      margin-bottom: 10px;
    }
  }

  .button-container {
    margin-bottom: 15px;
  }

  button {
    font-weight: 400;
    white-space: nowrap;
    cursor: pointer;
    transition: all .3s cubic-bezier(.645,.045,.355,1);
    user-select: none;
    touch-action: manipulation;
    height: 32px;
    padding: 4.5px 15px;
    color: @toolbar-color;
    background-color: @modal-button-bg-color;
    text-align: center;
    border: 1px solid @modal-button-border-color;
    border-radius: 4px;
  }

  textarea,
  input[type="text"],
  input[type="number"] {
    font-variant: tabular-nums;
    font-feature-settings: "tnum";
    padding: 4.5px 11px;
    color: @toolbar-color;
    background-color: @toolbar-bg-color;
    border: 1px solid @modal-button-border-color;
    border-radius: 4px;
    transition: all .3s;
    width: 100%;
  }

  textarea {
    min-height: 60px;
  }
}

// modal 有可能直接 append 到 <body> 下面
body .w-e-modal {
  box-sizing: border-box;

  * {
    box-sizing: border-box;
  }
}


================================================
FILE: packages/core/src/assets/progress.less
================================================
@import "../../../vars.less";

.w-e-progress-bar {
  position: absolute;
  width: 0;
  height: 1px;
  background-color: @textarea-handler-bg-color;
  transition: width 0.3s;
}


================================================
FILE: packages/core/src/assets/select-list.less
================================================
@import "../../../vars.less"; // var and mixin


.w-e-select-list {
  z-index: 1;
  position: absolute;
  left: 0;
  top: 0;
  background-color: @toolbar-bg-color;
  margin-top: @toolbar-height;
  min-width: 100px;
  .shadowBordered(10px);

  max-height: 350px;
  overflow-y: auto;

  ul {
    list-style: none;
    line-height: 1;

    .selected {
      background-color: @toolbar-active-bg-color;
    }
    li {
      cursor: pointer;
      padding: 7px 0 7px 25px;
      position: relative;
      text-align: left;
      white-space: nowrap; /* 不换行 */

      &:hover {
        background-color: @toolbar-active-bg-color;
      }

      svg {
        position: absolute;
        left: 0;
        margin-left: 5px;
        top: 50%;
        margin-top: -7px;
      }
    }
  }
}

// 当 bar 处于页面下方,则 selectList 要显示在 bar 上方
.w-e-bar-bottom .w-e-select-list {
  top: inherit;
  bottom: 0;
  margin-top: 0;
  margin-bottom: @toolbar-height;
}


================================================
FILE: packages/core/src/assets/textarea.less
================================================
@import "../../../vars.less"; // var and mixin

.w-e-text-container {
  color: @textarea-color;
  background-color: @textarea-bg-color;
  position: relative;
  height: 100%;
}

.w-e-text-container .w-e-scroll {
  height: 100%;
  // overflow-y: auto; // 在 js 中设置,根据 config 判断是否增加 scroll
  -webkit-overflow-scrolling: touch;
}

.w-e-text-container [data-slate-editor] {
  outline: 0;
  white-space: pre-wrap; /* 【重要】可以显示空格,在连续多空格的情况下 */
  word-wrap: break-word;
  padding: 0 10px;
  border-top: 1px solid transparent; // 防止 margin-top 溢出
  min-height: 100%;

  p {
    margin: 15px 0;
  }
  h1,h2,h3,h4,h5 {
    margin: 20px 0 20px 0;
  }

  img {
    max-width: 100%;
    min-width: 20px;
    min-height: 20px;
    cursor: default;
    display: inline !important;
  }

  span {
    text-indent: 0; // issues#4536
  }

  // 选中的节点
  [data-selected="true"] {
    box-shadow: 0 0 0 2px @textarea-selected-border-color;
  }
}

.w-e-text-placeholder {
  color: @textarea-slight-color;
  position: absolute;
  font-style: italic;
  width: 90%;
  left: 10px;
  top: 17px;
  pointer-events: none; // 忽略鼠标行为,重要
  user-select: none;
}

.w-e-max-length-info {
  position: absolute;
  color: @textarea-slight-color;
  bottom: 0.5em;
  right: 1em;
  pointer-events: none; // 忽略鼠标行为,重要
  user-select: none;
}


================================================
FILE: packages/core/src/config/index.ts
================================================
/**
 * @description editor config
 * @author wangfupeng
 */

import forEach from 'lodash.foreach'
import cloneDeep from 'lodash.clonedeep'
import { IEditorConfig, IMenuConfig, IToolbarConfig } from './interface'
import { GLOBAL_MENU_CONF } from './register'

/**
 * 生成编辑器默认配置
 */
export function genEditorConfig(userConfig: Partial<IEditorConfig> = {}): IEditorConfig {
  const defaultMenuConf = cloneDeep(GLOBAL_MENU_CONF)
  const newMenuConf: IMenuConfig = {}

  // 单独处理 menuConf
  const { MENU_CONF: userMenuConf = {} } = userConfig
  forEach(defaultMenuConf, (menuConf, menuKey) => {
    // 生成新的 menu config
    newMenuConf[menuKey] = {
      ...menuConf,
      ...(userMenuConf[menuKey] || {}),
    }
  })
  delete userConfig.MENU_CONF // 处理完,则删掉 menuConf ,以防下面 merge 时造成干扰

  return {
    // 默认配置
    scroll: true,
    readOnly: false,
    autoFocus: true,
    decorate: () => [],
    maxLength: 0, // 默认不限制
    MENU_CONF: newMenuConf,
    hoverbarKeys: {
      // 'link': { menuKeys: ['editLink', 'unLink', 'viewLink'] },
    },
    customAlert(info: string, type: string) {
      window.alert(`${type}:\n${info}`)
    },

    // 合并用户配置
    ...userConfig,
  }
}

/**
 * 生成 toolbar 默认配置
 */
export function genToolbarConfig(userConfig?: Partial<IToolbarConfig>): IToolbarConfig {
  return {
    // 默认配置
    toolbarKeys: [],
    excludeKeys: [],
    insertKeys: { index: 0, keys: [] },
    modalAppendToBody: false,

    // 合并用户配置
    ...(userConfig || {}),
  }
}


================================================
FILE: packages/core/src/config/interface.ts
================================================
/**
 * @description config interface
 * @author wangfupeng
 */

import { Range, NodeEntry, Node } from 'slate'
import { IDomEditor } from '../editor/interface'
import { IMenuGroup } from '../menus/interface'

interface IHoverbarConf {
  // key 即 element type
  [key: string]: {
    match?: (editor: IDomEditor, n: Node) => boolean // 自定义匹配函数,优先级高于“key 即 element type”
    menuKeys: string[]
  }
}

export type AlertType = 'success' | 'info' | 'warning' | 'error'

export interface ISingleMenuConfig {
  [key: string]: any
}

export interface IMenuConfig {
  [key: string]: ISingleMenuConfig
}

/**
 * editor config
 */
export interface IEditorConfig {
  //【注意】如增加 onXxx 回调函数时,要同步到 vue2/vue3 组件
  customAlert: (info: string, type: AlertType) => void

  onCreated?: (editor: IDomEditor) => void
  onChange?: (editor: IDomEditor) => void
  onDestroyed?: (editor: IDomEditor) => void

  onMaxLength?: (editor: IDomEditor) => void
  onFocus?: (editor: IDomEditor) => void
  onBlur?: (editor: IDomEditor) => void

  /**
   * 自定义粘贴。返回 true 则继续粘贴,返回 false 则自行实现粘贴,阻止默认粘贴
   */
  customPaste?: (editor: IDomEditor, e: ClipboardEvent) => boolean

  // edit state
  scroll: boolean
  placeholder?: string
  readOnly: boolean
  autoFocus: boolean
  decorate?: (nodeEntry: NodeEntry) => Range[]
  maxLength?: number

  // 各个 menu 的配置汇总,可以通过 key 获取单个 menu 的配置
  MENU_CONF?: IMenuConfig

  // 悬浮菜单栏 menu
  hoverbarKeys?: IHoverbarConf

  // 自由扩展其他配置
  EXTEND_CONF?: any
}

/**
 * toolbar config
 */
export interface IToolbarConfig {
  toolbarKeys: Array<string | IMenuGroup>
  insertKeys: { index: number; keys: string | Array<string | IMenuGroup> }
  excludeKeys: Array<string> // 排除哪些菜单
  modalAppendToBody: boolean // modal append 到 body ,而非 $textAreaContainer 内
}


================================================
FILE: packages/core/src/config/register.ts
================================================
/**
 * @description config register
 * @author wangfupeng
 */

import { IMenuConfig, ISingleMenuConfig } from '../config/interface'

// 全局的菜单配置
export const GLOBAL_MENU_CONF: IMenuConfig = {}

/**
 * 注册全局菜单配置
 * @param key menu key
 * @param config config
 */
export function registerGlobalMenuConf(key: string, config?: ISingleMenuConfig) {
  if (config == null) return
  GLOBAL_MENU_CONF[key] = config
}


================================================
FILE: packages/core/src/constants/index.ts
================================================
export const IGNORE_TAGS = new Set([
  'doctype',
  '!doctype',
  'meta',
  'script',
  'style',
  'link',
  'frame',
  'iframe',
  'title',
  'svg', // TODO 暂时忽略
])


================================================
FILE: packages/core/src/constants/svg.ts
================================================
/**
 * @description svg tag
 * @author wangfupeng
 */

/**
 * 【注意】svg 字符串的长度 ,否则会导致代码体积过大
 * 尽量选择 https://www.iconfont.cn/collections/detail?spm=a313x.7781069.0.da5a778a4&cid=20293
 * 找不到再从 iconfont.com 搜索
 */

// 对号
export const SVG_CHECK_MARK =
  '<svg viewBox="0 0 1446 1024"><path d="M574.116299 786.736392 1238.811249 48.517862C1272.390222 11.224635 1329.414799 7.827718 1366.75664 41.450462 1403.840015 74.840484 1406.731043 132.084741 1373.10189 169.433699L655.118888 966.834607C653.072421 969.716875 650.835807 972.514337 648.407938 975.210759 615.017957 1012.29409 558.292155 1015.652019 521.195664 982.250188L72.778218 578.493306C35.910826 545.297758 32.859041 488.584019 66.481825 451.242134 99.871807 414.158803 156.597563 410.800834 193.694055 444.202665L574.116299 786.736392Z"></path></svg>'

// 向下的箭头
export const SVG_DOWN_ARROW =
  '<svg viewBox="0 0 1024 1024"><path d="M498.7 655.8l-197.6-268c-8.1-10.9-0.3-26.4 13.3-26.4h395.2c13.6 0 21.4 15.4 13.3 26.4l-197.6 268c-6.6 9-20 9-26.6 0z"></path></svg>'

// 关闭
export const SVG_CLOSE =
  '<svg viewBox="0 0 1024 1024"><path d="M1024 896.1024l-128 128L512 640 128 1024 0 896 384 512 0 128 128 0 512 384 896.1024 0l128 128L640 512z"></path></svg>'


================================================
FILE: packages/core/src/create/bind-node-relation.ts
================================================
/**
 * @description 绑定 node 的关系
 * @author wangfupeng
 */

import { Element, Editor, Node, Ancestor } from 'slate'
import { IDomEditor } from '../editor/interface'
import { NODE_TO_INDEX, NODE_TO_PARENT } from '../utils/weak-maps'

/**
 * createEditor 未传递 selector 时,绑定 node 的关系( NODE_TO_PARENT, NODE_TO_INDEX 等 )
 * @param node node
 * @param index index
 * @param parent parent node
 * @param editor editor
 */
function bindNodeRelation(node: Node, index: number, parent: Ancestor, editor: IDomEditor) {
  // 设置相关 weakMap 信息
  NODE_TO_INDEX.set(node, index)
  NODE_TO_PARENT.set(node, parent)

  if (Element.isElement(node)) {
    const { children = [] } = node
    children.forEach((child: Node, i: number) => bindNodeRelation(child, i, node, editor)) // 递归子节点

    const isVoid = Editor.isVoid(editor, node)
    if (isVoid) {
      const [[text]] = Node.texts(node)
      // 记录 text 相关 weakMap
      NODE_TO_INDEX.set(text, 0)
      NODE_TO_PARENT.set(text, node)
    }
  }
}

export default bindNodeRelation


================================================
FILE: packages/core/src/create/create-editor.ts
================================================
/**
 * @description create editor
 * @author wangfupeng
 */

import { createEditor, Descendant } from 'slate'
import { withHistory } from 'slate-history'
import { withDOM } from '../editor/plugins/with-dom'
import { withConfig } from '../editor/plugins/with-config'
import { withContent } from '../editor/plugins/with-content'
import { withEventData } from '../editor/plugins/with-event-data'
import { withEmitter } from '../editor/plugins/with-emitter'
import { withSelection } from '../editor/plugins/with-selection'
import { withMaxLength } from '../editor/plugins/with-max-length'
import TextArea from '../text-area/TextArea'
import HoverBar from '../menus/bar/HoverBar'
import { genEditorConfig } from '../config/index'
import { IDomEditor } from '../editor/interface'
import { DomEditor } from '../editor/dom-editor'
import { IEditorConfig } from '../config/interface'
import { promiseResolveThen } from '../utils/util'
import { isRepeatedCreateTextarea, genDefaultContent, htmlToContent } from './helper'
import type { DOMElement } from '../utils/dom'
import {
  EDITOR_TO_TEXTAREA,
  TEXTAREA_TO_EDITOR,
  EDITOR_TO_CONFIG,
  HOVER_BAR_TO_EDITOR,
  EDITOR_TO_HOVER_BAR,
} from '../utils/weak-maps'
import bindNodeRelation from './bind-node-relation'
import $ from '../utils/dom'

type PluginFnType = <T extends IDomEditor>(editor: T) => T

interface ICreateOption {
  selector: string | DOMElement
  config: Partial<IEditorConfig>
  content?: Descendant[]
  html?: string
  plugins: PluginFnType[]
}

/**
 * 创建编辑器
 */
export default function (option: Partial<ICreateOption>) {
  const { selector = '', config = {}, content, html, plugins = [] } = option

  // 创建实例 - 使用插件
  let editor = withHistory(
    withMaxLength(
      withEmitter(withSelection(withContent(withConfig(withDOM(withEventData(createEditor()))))))
    )
  )
  if (selector) {
    // 检查是否对同一个 DOM 重复创建
    if (isRepeatedCreateTextarea(editor, selector)) {
      throw new Error(`Repeated create editor by selector '${selector}'`)
    }
  }

  // 处理配置
  const editorConfig = genEditorConfig(config)
  EDITOR_TO_CONFIG.set(editor, editorConfig)
  const { hoverbarKeys = {} } = editorConfig

  // 注册第三方插件
  plugins.forEach(plugin => {
    editor = plugin(editor)
  })

  // 初始化内容(要在 config 和 plugins 后面)
  if (html != null) {
    // 传入 html ,转换为 JSON content
    editor.children = htmlToContent(editor, html)
  }
  if (content && content.length) {
    editor.children = content // 传入 JSON content
  }
  if (editor.children.length === 0) {
    editor.children = genDefaultContent() // 默认内容
  }
  DomEditor.normalizeContent(editor) // 格式化,用户输入的 content 可能不规范(如两个相连的 text 没有合并)

  if (selector) {
    // 传入了 selector ,则创建 textarea DOM
    const textarea = new TextArea(selector)
    EDITOR_TO_TEXTAREA.set(editor, textarea)
    TEXTAREA_TO_EDITOR.set(textarea, editor)
    textarea.changeViewState() // 初始化时触发一次,以便能初始化 textarea DOM 和 selection

    // 判断 textarea 最小高度,并给出提示
    promiseResolveThen(() => {
      const $scroll = textarea.$scroll
      if ($scroll == null) return
      if ($scroll.height() < 300) {
        let info = '编辑区域高度 < 300px 这可能会导致 modal hoverbar 定位异常'
        info += '\nTextarea height < 300px . This may be cause modal and hoverbar position error'
        console.warn(info, $scroll)
      }
    })

    // 创建 hoverbar DOM
    let hoverbar: HoverBar | null
    if (Object.keys(hoverbarKeys).length > 0) {
      hoverbar = new HoverBar()
      HOVER_BAR_TO_EDITOR.set(hoverbar, editor)
      EDITOR_TO_HOVER_BAR.set(editor, hoverbar)
    }

    // 隐藏 panel and modal
    editor.on('change', () => {
      editor.hidePanelOrModal()
    })
    editor.on('scroll', () => {
      editor.hidePanelOrModal()
    })
  } else {
    // 未传入 selector ,则遍历 content ,绑定一些 WeakMap 关系 ( NODE_TO_PARENT, NODE_TO_INDEX 等 )
    editor.children.forEach((node, i) => bindNodeRelation(node, i, editor, editor))
  }

  // 触发生命周期
  const { onCreated, onDestroyed } = editorConfig
  if (onCreated) {
    editor.on('created', () => onCreated(editor))
  }
  if (onDestroyed) {
    editor.on('destroyed', () => onDestroyed(editor))
  }

  // 创建完毕,异步触发 created
  promiseResolveThen(() => editor.emit('created'))

  return editor
}


================================================
FILE: packages/core/src/create/create-toolbar.ts
================================================
/**
 * @description create toolbar
 * @author wangfupeng
 */

import { IDomEditor } from '../editor/interface'
import Toolbar from '../menus/bar/Toolbar'
import { IToolbarConfig } from '../config/interface'
import { genToolbarConfig } from '../config/index'
import { isRepeatedCreateToolbar } from './helper'
import { DOMElement } from '../utils/dom'
import { TOOLBAR_TO_EDITOR, EDITOR_TO_TOOLBAR } from '../utils/weak-maps'

interface ICreateOption {
  selector: string | DOMElement
  config?: Partial<IToolbarConfig>
}

export default function (editor: IDomEditor | null, option: ICreateOption): Toolbar {
  if (editor == null) {
    throw new Error(`Cannot create toolbar, because editor is null`)
  }
  const { selector, config = {} } = option

  // 避免重复创建
  if (isRepeatedCreateToolbar(editor, selector)) {
    // 对同一个 DOM 重复创建
    throw new Error(`Repeated create toolbar by selector '${selector}'`)
  }

  // 处理配置
  const toolbarConfig = genToolbarConfig(config)

  // 创建 toolbar ,并记录和 editor 关系
  const toolbar = new Toolbar(selector, toolbarConfig)
  TOOLBAR_TO_EDITOR.set(toolbar, editor)
  EDITOR_TO_TOOLBAR.set(editor, toolbar)

  return toolbar
}


================================================
FILE: packages/core/src/create/helper.ts
================================================
/**
 * @description create helper
 * @author wangfupeng
 */

import { Descendant } from 'slate'
import { IDomEditor } from '../editor/interface'
import parseElemHtml from '../parse-html/parse-elem-html'
import $, { DOMElement } from '../utils/dom'

function isRepeatedCreate(
  editor: IDomEditor,
  attrKey: string,
  selector: string | DOMElement
): boolean {
  // @ts-ignore
  const $elem = $(selector)
  if ($elem.attr(attrKey)) {
    return true // 有属性,说明已经创建过
  }

  // 至此,说明未创建过,则记录
  $elem.attr(attrKey, 'true')

  // 销毁时删除属性
  editor.on('destroyed', () => {
    $elem.removeAttr(attrKey)
  })

  return false
}

/**
 * 检查是否重复创建 textarea
 */
export function isRepeatedCreateTextarea(
  editor: IDomEditor,
  selector: string | DOMElement
): boolean {
  return isRepeatedCreate(editor, 'data-w-e-textarea', selector)
}

/**
 * 检查是否重复创建 toolbar
 */
export function isRepeatedCreateToolbar(
  editor: IDomEditor,
  selector: string | DOMElement
): boolean {
  return isRepeatedCreate(editor, 'data-w-e-toolbar', selector)
}

/**
 * 生成默认 content
 */
export function genDefaultContent() {
  return [
    {
      type: 'paragraph',
      children: [{ text: '' }],
    },
  ]
}

/**
 * html 字符串 -> content
 * @param editor editor
 * @param html html 字符串
 */
export function htmlToContent(editor: IDomEditor, html: string = ''): Descendant[] {
  const res: Descendant[] = []

  // 空白内容
  if (html === '') html = '<p><br></p>'

  // 非 HTML 格式,文本格式,用 <p> 包裹
  if (html.indexOf('<') !== 0) {
    html = html
      .split(/\n/)
      .map(line => `<p>${line}</p>`)
      .join('')
  }

  const $content = $(`<div>${html}</div>`)
  const list = Array.from($content.children())
  list.forEach(child => {
    const $child = $(child)
    const parsedRes = parseElemHtml($child, editor)

    if (Array.isArray(parsedRes)) {
      parsedRes.forEach(el => res.push(el))
    } else {
      res.push(parsedRes)
    }
  })

  return res
}


================================================
FILE: packages/core/src/create/index.ts
================================================
/**
 * @description create entry
 * @author wangfupeng
 */

import coreCreateEditor from './create-editor'
import coreCreateToolbar from './create-toolbar'

export { coreCreateEditor, coreCreateToolbar }


================================================
FILE: packages/core/src/editor/dom-editor.ts
================================================
/**
 * @description 扩展 slate Editor(参考 slate-react react-editor.ts )
 * @author wangfupeng
 */

import toArray from 'lodash.toarray'
import { Editor, Node, Element, Path, Point, Range, Ancestor, Text } from 'slate'
import type { IDomEditor } from './interface'
import { Key } from '../utils/key'
import TextArea from '../text-area/TextArea'
import Toolbar from '../menus/bar/Toolbar'
import HoverBar from '../menus/bar/HoverBar'
import {
  EDITOR_TO_ELEMENT,
  ELEMENT_TO_NODE,
  KEY_TO_ELEMENT,
  NODE_TO_INDEX,
  NODE_TO_KEY,
  NODE_TO_PARENT,
  EDITOR_TO_TEXTAREA,
  EDITOR_TO_TOOLBAR,
  EDITOR_TO_HOVER_BAR,
  EDITOR_TO_WINDOW,
} from '../utils/weak-maps'
import $, {
  DOMElement,
  DOMNode,
  DOMPoint,
  DOMRange,
  DOMSelection,
  DOMStaticRange,
  isDOMElement,
  normalizeDOMPoint,
  isDOMSelection,
  hasShadowRoot,
  walkTextNodes,
} from '../utils/dom'
import { IS_CHROME, IS_FIREFOX } from '../utils/ua'

/**
 * 自定义全局 command
 */
export const DomEditor = {
  /**
   * Return the host window of the current editor.
   */
  getWindow(editor: IDomEditor): Window {
    const window = EDITOR_TO_WINDOW.get(editor)
    if (!window) {
      throw new Error('Unable to find a host window element for this editor')
    }
    return window
  },

  /**
   * Find a key for a Slate node.
   * key 即一个累加不重复的 id ,每一个 slate node 都对对应一个 key ,意思相当于 node.id
   */
  findKey(editor: IDomEditor | null, node: Node): Key {
    let key = NODE_TO_KEY.get(node)

    // 如果没绑定,就立马新建一个 key 来绑定
    if (!key) {
      key = new Key()
      NODE_TO_KEY.set(node, key)
    }

    return key
  },

  setNewKey(node: Node) {
    const key = new Key()
    NODE_TO_KEY.set(node, key)
  },

  /**
   * Find the path of Slate node.
   * path 是一个数组,代表 slate node 的位置 https://docs.slatejs.org/concepts/03-locations#path
   */
  findPath(editor: IDomEditor | null, node: Node): Path {
    const path: Path = []
    let child = node

    // eslint-disable-next-line
    while (true) {
      const parent = NODE_TO_PARENT.get(child)

      if (parent == null) {
        if (Editor.isEditor(child)) {
          // 已到达最顶层,返回 patch
          return path
        } else {
          break
        }
      }

      // 获取该节点在父节点中的 index
      const i = NODE_TO_INDEX.get(child)

      if (i == null) {
        break
      }

      // 拼接 patch
      path.unshift(i)

      // 继续向上递归
      child = parent
    }

    throw new Error(`Unable to find the path for Slate node: ${JSON.stringify(node)}`)
  },

  /**
   * Find the DOM node that implements DocumentOrShadowRoot for the editor.
   */
  findDocumentOrShadowRoot(editor: IDomEditor): Document | ShadowRoot {
    if (editor.isDestroyed) {
      return window.document
    }

    const el = DomEditor.toDOMNode(editor, editor)
    const root = el.getRootNode()

    if ((root instanceof Document || root instanceof ShadowRoot) && root.getSelection != null) {
      return root
    }
    return el.ownerDocument
  },

  /**
   * 获取父节点
   * @param editor editor
   * @param node cur node
   */
  getParentNode(editor: IDomEditor | null, node: Node): Ancestor | null {
    return NODE_TO_PARENT.get(node) || null
  },

  /**
   * 获取当前节点的所有父节点
   * @param editor editor
   * @param node cur node
   */
  getParentsNodes(editor: IDomEditor, node: Node): Ancestor[] {
    const nodes: Ancestor[] = []
    let curNode = node
    while (curNode !== editor && curNode != null) {
      const parentNode = DomEditor.getParentNode(editor, curNode)
      if (parentNode == null) {
        break
      } else {
        nodes.push(parentNode)
        curNode = parentNode
      }
    }
    return nodes
  },

  /**
   * 获取当前节点对应的顶级节点
   * @param editor editor
   * @param curNode cur node
   */
  getTopNode(editor: IDomEditor, curNode: Node): Node {
    const path = DomEditor.findPath(editor, curNode)
    const topPath = [path[0]]
    return Node.get(editor, topPath)
  },

  /**
   * Find the native DOM element from a Slate node or editor.
   */
  toDOMNode(editor: IDomEditor, node: Node): HTMLElement {
    let domNode
    const isEditor = Editor.isEditor(node)
    if (isEditor) {
      domNode = EDITOR_TO_ELEMENT.get(editor)
    } else {
      const key = DomEditor.findKey(editor, node)
      domNode = KEY_TO_ELEMENT.get(key)
    }

    if (!domNode) {
      throw new Error(`Cannot resolve a DOM node from Slate node: ${JSON.stringify(node)}`)
    }

    return domNode
  },

  /**
   * Check if a DOM node is within the editor.
   */
  hasDOMNode(editor: IDomEditor, target: DOMNode, options: { editable?: boolean } = {}): boolean {
    const { editable = false } = options
    const editorEl = DomEditor.toDOMNode(editor, editor)
    let targetEl

    // COMPAT: In Firefox, reading `target.nodeType` will throw an error if
    // target is originating from an internal "restricted" element (e.g. a
    // stepper arrow on a number input). (2018/05/04)
    // https://github.com/ianstormtaylor/slate/issues/1819
    try {
      targetEl = (isDOMElement(target) ? target : target.parentElement) as HTMLElement
    } catch (err) {
      if (!err.message.includes('Permission denied to access property "nodeType"')) {
        throw err
      }
    }

    if (!targetEl) {
      return false
    }

    return (
      // 祖先节点中包括 data-slate-editor 属性,即 textarea
      targetEl.closest(`[data-slate-editor]`) === editorEl &&
      // 通过参数 editable 控制开启是否验证是可编辑元素或零宽字符
      (!editable || targetEl.isContentEditable || !!targetEl.getAttribute('data-slate-zero-width'))
    )
  },

  /**
   * Find a native DOM range from a Slate `range`.
   *
   * Notice: the returned range will always be ordinal regardless of the direction of Slate `range` due to DOM API limit.
   *
   * there is no way to create a reverse DOM Range using Range.setStart/setEnd
   * according to https://dom.spec.whatwg.org/#concept-range-bp-set.
   */
  toDOMRange(editor: IDomEditor, range: Range): DOMRange {
    const { anchor, focus } = range
    const isBackward = Range.isBackward(range)
    const domAnchor = DomEditor.toDOMPoint(editor, anchor)
    const domFocus = Range.isCollapsed(range) ? domAnchor : DomEditor.toDOMPoint(editor, focus)

    const window = DomEditor.getWindow(editor)
    const domRange = window.document.createRange()
    const [startNode, startOffset] = isBackward ? domFocus : domAnchor
    const [endNode, endOffset] = isBackward ? domAnchor : domFocus

    // A slate Point at zero-width Leaf always has an offset of 0 but a native DOM selection at
    // zero-width node has an offset of 1 so we have to check if we are in a zero-width node and
    // adjust the offset accordingly.
    const startEl = (isDOMElement(startNode) ? startNode : startNode.parentElement) as HTMLElement
    const isStartAtZeroWidth = !!startEl.getAttribute('data-slate-zero-width')
    const endEl = (isDOMElement(endNode) ? endNode : endNode.parentElement) as HTMLElement
    const isEndAtZeroWidth = !!endEl.getAttribute('data-slate-zero-width')

    domRange.setStart(startNode, isStartAtZeroWidth ? 1 : startOffset)
    domRange.setEnd(endNode, isEndAtZeroWidth ? 1 : endOffset)
    return domRange
  },

  /**
   * Find a native DOM selection point from a Slate point.
   */
  toDOMPoint(editor: IDomEditor, point: Point): DOMPoint {
    const [node] = Editor.node(editor, point.path)
    const el = DomEditor.toDOMNode(editor, node)
    let domPoint: DOMPoint | undefined

    // If we're inside a void node, force the offset to 0, otherwise the zero
    // width spacing character will result in an incorrect offset of 1
    if (Editor.void(editor, { at: point })) {
      // void 节点,offset 必须为 0
      point = { path: point.path, offset: 0 }
    }

    // For each leaf, we need to isolate its content, which means filtering
    // to its direct text and zero-width spans. (We have to filter out any
    // other siblings that may have been rendered alongside them.)
    const selector = `[data-slate-string], [data-slate-zero-width]`
    const texts = Array.from(el.querySelectorAll(selector))
    let start = 0

    for (const text of texts) {
      const domNode = text.childNodes[0] as HTMLElement

      if (domNode == null || domNode.textContent == null) {
        continue
      }

      const { length } = domNode.textContent
      const attr = text.getAttribute('data-slate-length')
      const trueLength = attr == null ? length : parseInt(attr, 10)
      const end = start + trueLength

      if (point.offset <= end) {
        const offset = Math.min(length, Math.max(0, point.offset - start))
        domPoint = [domNode, offset]
        break
      }

      start = end
    }

    if (!domPoint) {
      throw new Error(`Cannot resolve a DOM point from Slate point: ${JSON.stringify(point)}`)
    }

    return domPoint
  },

  /**
   * Find a Slate node from a native DOM `element`.
   */
  toSlateNode(editor: IDomEditor | null, domNode: DOMNode): Node {
    let domEl = isDOMElement(domNode) ? domNode : domNode.parentElement

    if (domEl && !domEl.hasAttribute('data-slate-node')) {
      domEl = domEl.closest(`[data-slate-node]`)
    }

    const node = domEl ? ELEMENT_TO_NODE.get(domEl as HTMLElement) : null

    if (!node) {
      throw new Error(`Cannot resolve a Slate node from DOM node: ${domEl}`)
    }

    return node
  },

  /**
   * Get the target range from a DOM `event`.
   */
  findEventRange(editor: IDomEditor, event: any): Range {
    if ('nativeEvent' in event) {
      // 兼容 react 的合成事件,DOM 事件中没什么用
      event = event.nativeEvent
    }

    const { clientX: x, clientY: y, target } = event

    if (x == null || y == null) {
      throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`)
    }

    const node = DomEditor.toSlateNode(editor, event.target)
    const path = DomEditor.findPath(editor, node)

    // If the drop target is inside a void node, move it into either the
    // next or previous node, depending on which side the `x` and `y`
    // coordinates are closest to.
    if (Editor.isVoid(editor, node)) {
      const rect = target.getBoundingClientRect()
      const isPrev = editor.isInline(node)
        ? x - rect.left < rect.left + rect.width - x
        : y - rect.top < rect.top + rect.height - y

      const edge = Editor.point(editor, path, {
        edge: isPrev ? 'start' : 'end',
      })
      const point = isPrev ? Editor.before(editor, edge) : Editor.after(editor, edge)

      if (point) {
        const range = Editor.range(editor, point)
        return range
      }
    }

    // Else resolve a range from the caret position where the drop occured.
    let domRange
    const { document } = this.getWindow(editor)

    // COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25)
    if (document.caretRangeFromPoint) {
      domRange = document.caretRangeFromPoint(x, y)
    } else {
      const position = document.caretPositionFromPoint(x, y)
      if (position) {
        domRange = document.createRange()
        domRange.setStart(position.offsetNode, position.offset)
        domRange.setEnd(position.offsetNode, position.offset)
      }
    }

    if (!domRange) {
      throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`)
    }

    // Resolve a Slate range from the DOM range.
    const range = DomEditor.toSlateRange(editor, domRange, {
      exactMatch: false,
      suppressThrow: false,
    })
    return range
  },

  /**
   * Find a Slate range from a DOM range or selection.
   */
  toSlateRange<T extends boolean>(
    editor: IDomEditor,
    domRange: DOMRange | DOMStaticRange | DOMSelection,
    options: {
      exactMatch: T
      suppressThrow: T
    }
  ): T extends true ? Range | null : Range {
    const { exactMatch, suppressThrow } = options
    const el = isDOMSelection(domRange) ? domRange.anchorNode : domRange.startContainer
    let anchorNode
    let anchorOffset
    let focusNode
    let focusOffset
    let isCollapsed

    if (el) {
      if (isDOMSelection(domRange)) {
        anchorNode = domRange.anchorNode
        anchorOffset = domRange.anchorOffset
        focusNode = domRange.focusNode
        focusOffset = domRange.focusOffset
        // COMPAT: There's a bug in chrome that always returns `true` for
        // `isCollapsed` for a Selection that comes from a ShadowRoot.
        // (2020/08/08)
        // https://bugs.chromium.org/p/chromium/issues/detail?id=447523
        if (IS_CHROME && hasShadowRoot()) {
          isCollapsed =
            domRange.anchorNode === domRange.focusNode &&
            domRange.anchorOffset === domRange.focusOffset
        } else {
          isCollapsed = domRange.isCollapsed
        }
      } else {
        anchorNode = domRange.startContainer
        anchorOffset = domRange.startOffset
        focusNode = domRange.endContainer
        focusOffset = domRange.endOffset
        isCollapsed = domRange.collapsed
      }
    }

    if (anchorNode == null || focusNode == null || anchorOffset == null || focusOffset == null) {
      throw new Error(`Cannot resolve a Slate range from DOM range: ${domRange}`)
    }

    const anchor = DomEditor.toSlatePoint(editor, [anchorNode, anchorOffset], {
      exactMatch,
      suppressThrow,
    })
    if (!anchor) {
      return null as T extends true ? Range | null : Range
    }

    const focus = isCollapsed
      ? anchor
      : DomEditor.toSlatePoint(editor, [focusNode, focusOffset], { exactMatch, suppressThrow })
    if (!focus) {
      return null as T extends true ? Range | null : Range
    }

    // return { anchor, focus } as unknown as T extends true ? Range | null : Range

    let range: Range = { anchor: anchor as Point, focus: focus as Point }
    // if the selection is a hanging range that ends in a void
    // and the DOM focus is an Element
    // (meaning that the selection ends before the element)
    // unhang the range to avoid mistakenly including the void
    if (
      Range.isExpanded(range) &&
      Range.isForward(range) &&
      isDOMElement(focusNode) &&
      Editor.void(editor, { at: range.focus, mode: 'highest' })
    ) {
      range = Editor.unhangRange(editor, range, { voids: true })
    }

    return range as unknown as T extends true ? Range | null : Range
  },

  /**
   * Find a Slate point from a DOM selection's `domNode` and `domOffset`.
   */
  toSlatePoint<T extends boolean>(
    editor: IDomEditor,
    domPoint: DOMPoint,
    options: {
      exactMatch: T
      suppressThrow: T
    }
  ): T extends true ? Point | null : Point {
    const { exactMatch, suppressThrow } = options
    const [nearestNode, nearestOffset] = exactMatch ? domPoint : normalizeDOMPoint(domPoint)
    const parentNode = nearestNode.parentNode as DOMElement
    let textNode: DOMElement | null = null
    let offset = 0

    if (parentNode) {
      const voidNode = parentNode.closest('[data-slate-void="true"]')
      let leafNode = parentNode.closest('[data-slate-leaf]')
      let domNode: DOMElement | null = null

      // Calculate how far into the text node the `nearestNode` is, so that we
      // can determine what the offset relative to the text node is.
      if (leafNode) {
        textNode = leafNode.closest('[data-slate-node="text"]')!
        const window = DomEditor.getWindow(editor)
        const range = window.document.createRange()
        range.setStart(textNode, 0)
        range.setEnd(nearestNode, nearestOffset)
        const contents = range.cloneContents()
        const removals = [
          ...toArray(contents.querySelectorAll('[data-slate-zero-width]')),
          ...toArray(contents.querySelectorAll('[contenteditable=false]')),
        ]

        removals.forEach(el => {
          el!.parentNode!.removeChild(el)
        })

        // COMPAT: Edge has a bug where Range.prototype.toString() will
        // convert \n into \r\n. The bug causes a loop when slate-react
        // attempts to reposition its cursor to match the native position. Use
        // textContent.length instead.
        // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/10291116/
        offset = contents.textContent!.length
        domNode = textNode
      } else if (voidNode) {
        // For void nodes, the element with the offset key will be a cousin, not an
        // ancestor, so find it by going down from the nearest void parent.
        leafNode = voidNode.querySelector('[data-slate-leaf]')!

        // COMPAT: In read-only editors the leaf is not rendered.
        if (!leafNode) {
          offset = 1
        } else {
          textNode = leafNode.closest('[data-slate-node="text"]')!
          domNode = leafNode
          offset = domNode.textContent!.length
          domNode.querySelectorAll('[data-slate-zero-width]').forEach(el => {
            offset -= el.textContent!.length
          })
        }
      }

      if (
        domNode &&
        offset === domNode.textContent!.length &&
        // COMPAT: If the parent node is a Slate zero-width space, editor is
        // because the text node should have no characters. However, during IME
        // composition the ASCII characters will be prepended to the zero-width
        // space, so subtract 1 from the offset to account for the zero-width
        // space character.
        (parentNode.hasAttribute('data-slate-zero-width') ||
          // COMPAT: In Firefox, `range.cloneContents()` returns an extra trailing '\n'
          // when the document ends with a new-line character. This results in the offset
          // length being off by one, so we need to subtract one to account for this.
          (IS_FIREFOX && domNode.textContent?.endsWith('\n')))
      ) {
        offset--
      }
    }

    if (!textNode) {
      if (suppressThrow) {
        return null as T extends true ? Point | null : Point
      }
      throw new Error(`Cannot resolve a Slate point from DOM point: ${domPoint}`)
    }

    // COMPAT: If someone is clicking from one Slate editor into another,
    // the select event fires twice, once for the old editor's `element`
    // first, and then afterwards for the correct `element`. (2017/03/03)
    const slateNode = DomEditor.toSlateNode(editor, textNode!)
    const path = DomEditor.findPath(editor, slateNode)
    return { path, offset } as T extends true ? Point | null : Point
  },

  hasRange(editor: IDomEditor, range: Range): boolean {
    const { anchor, focus } = range
    return Editor.hasPath(editor, anchor.path) && Editor.hasPath(editor, focus.path)
  },

  getNodeType(node: Node): string {
    if (Element.isElement(node)) {
      return node.type
    }
    return ''
  },

  checkNodeType(node: Node, type: string) {
    return this.getNodeType(node) === type
  },

  getNodesStr(nodes: Node[]): string {
    return nodes.map(node => Node.string(node)).join('')
  },

  getSelectedElems(editor: IDomEditor): Element[] {
    const elems: Element[] = []

    const nodeEntries = Editor.nodes(editor, { universal: true })
    for (let nodeEntry of nodeEntries) {
      const [node] = nodeEntry
      if (Element.isElement(node)) elems.push(node)
    }

    return elems
  },

  getSelectedNodeByType(editor: IDomEditor, type: string): Node | null {
    const [nodeEntry] = Editor.nodes(editor, {
      match: n => this.checkNodeType(n, type),
      universal: true,
    })

    if (nodeEntry == null) return null
    return nodeEntry[0]
  },

  getSelectedTextNode(editor: IDomEditor): Node | null {
    const [nodeEntry] = Editor.nodes(editor, {
      match: n => Text.isText(n),
      universal: true,
    })

    if (nodeEntry == null) return null
    return nodeEntry[0]
  },

  isNodeSelected(editor: IDomEditor, node: Node): boolean {
    const [nodeEntry] = Editor.nodes(editor, {
      match: n => n === node,
      universal: true,
    })
    if (nodeEntry == null) return false

    const [n] = nodeEntry
    if (n === node) return true

    return false
  },

  isSelectionAtLineEnd(editor: IDomEditor, path: Path): boolean {
    const { selection } = editor

    if (!selection) return false

    const isAtLineEnd =
      Editor.isEnd(editor, selection.anchor, path) || Editor.isEnd(editor, selection.focus, path)

    return isAtLineEnd
  },

  // 获取 textarea 实例
  getTextarea(editor: IDomEditor): TextArea {
    const textarea = EDITOR_TO_TEXTAREA.get(editor)
    if (textarea == null) throw new Error('Cannot find textarea instance by editor')
    return textarea
  },

  // 获取 toolbar 实例
  getToolbar(editor: IDomEditor): Toolbar | null {
    return EDITOR_TO_TOOLBAR.get(editor) || null
  },

  // 获取 hoverbar 实例
  getHoverbar(editor: IDomEditor): HoverBar | null {
    return EDITOR_TO_HOVER_BAR.get(editor) || null
  },

  // 格式化 editor content
  normalizeContent(editor: IDomEditor) {
    editor.children.forEach((node, index) => {
      editor.normalizeNode([node, [index]])
    })
  },

  /**
   * 获取:距离触发 maxLength,还可以插入多少字符
   * @param editor editor
   */
  getLeftLengthOfMaxLength(editor: IDomEditor): number {
    const { maxLength, onMaxLength } = editor.getConfig()

    // 未设置 maxLength ,则返回 number 最大值
    if (typeof maxLength !== 'number' || maxLength <= 0) return Infinity

    const editorText = editor.getText().replace(/\r|\n|(\r\n)/g, '') // 去掉换行
    const curLength = editorText.length
    const leftLength = maxLength - curLength

    if (leftLength <= 0) {
      // 触发 maxLength 限制,不再继续插入文字
      if (onMaxLength) onMaxLength(editor)
    }

    return leftLength
  },

  // 清理暴露的 text 节点(拼音输入时经常出现)
  cleanExposedTexNodeInSelectionBlock(editor: IDomEditor) {
    // 有时候全选删除新增的文本节点可能不在段落内,因此遍历textArea删除掉
    const { $textArea } = DomEditor.getTextarea(editor)
    const childNodes = $textArea?.[0].childNodes
    if (childNodes) {
      for (const node of Array.from(childNodes)) {
        if (node.nodeType === 3) {
          node.remove()
        } else {
          break
        }
      }
    }

    const nodeEntries = Editor.nodes(editor, {
      match: n => {
        if (Element.isElement(n)) {
          if (!editor.isInline(n)) {
            // 匹配 block element
            return true
          }
        }
        return false
      },
      universal: true,
    })
    for (let nodeEntry of nodeEntries) {
      if (nodeEntry != null) {
        const n = nodeEntry[0]
        const elem = DomEditor.toDOMNode(editor, n)

        // 只遍历 elem 范围,考虑性能
        walkTextNodes(elem, (textNode, parent) => {
          const $parent = $(parent)
          if ($parent.attr('data-slate-string')) {
            return // 正常的 text
          }
          if ($parent.attr('data-slate-zero-width')) {
            return // 正常的 text
          }
          if ($parent.attr('data-w-e-reserve')) {
            return // 故意保留的节点
          }

          // 暴露的 text node ,删除
          parent.removeChild(textNode)
        })
      }
    }
  },

  /**
   * 是否是编辑器里最后一个元素
   * @param editor editor
   * @param node node
   */
  isLastNode(editor: IDomEditor, node: Node) {
    const editorChildren = editor.children || []
    const editorChildrenLength = editorChildren.length
    return editorChildren[editorChildrenLength - 1] === node
  },

  /**
   * 生成空白 paragraph
   */
  genEmptyParagraph(): Element {
    return { type: 'paragraph', children: [{ text: '' }] }
  },

  /**
   * 是否选中了 void node
   * @param editor editor
   */
  isSelectedVoidNode(editor: IDomEditor): boolean {
    const voidNodes = Editor.nodes(editor, {
      match: n => editor.isVoid(n as Element),
    })
    let len = 0
    for (const n of voidNodes) {
      len++
    }
    return len > 0
  },

  /**
   * 选区是否在一个空行
   * @param editor editor
   */
  isSelectedEmptyParagraph(editor: IDomEditor) {
    const { selection } = editor
    if (selection == null) return false

    if (Range.isExpanded(selection)) return false

    const selectedNode = DomEditor.getSelectedNodeByType(editor, 'paragraph')
    if (selectedNode === null) return false

    const { children } = selectedNode as Element
    if (children.length !== 1) return false

    const { text } = children[0] as Text
    if (text === '') return true
  },

  /**
   * 当前 path 指向的 node ,是否是空的(无内容)
   * @param editor editor
   * @param path path
   */
  isEmptyPath(editor: IDomEditor, path: Path): boolean {
    const entry = Editor.node(editor, path)
    if (entry == null) return false

    const [node] = entry

    const { children } = node as Element
    if (children.length === 1) {
      const { text } = children[0] as Text
      if (text === '') return true // 内容为空
    }

    return false
  },
}


================================================
FILE: packages/core/src/editor/interface.ts
================================================
/**
 * @description editor interface
 * @author wangfupeng
 */

import { Editor, Location, Node, Ancestor, Element } from 'slate'
import ee from 'event-emitter'
import { IEditorConfig, AlertType, ISingleMenuConfig } from '../config/interface'
import { IPositionStyle } from '../menus/interface'
import { DOMElement } from '../utils/dom'

export type ElementWithId = Element & { id: string }

/**
 * 扩展 slate Editor 接口
 */
export interface IDomEditor extends Editor {
  // data 相关(粘贴、拖拽等)
  insertData: (data: DataTransfer) => void
  setFragmentData: (data: Pick<DataTransfer, 'getData' | 'setData'>) => void

  // config
  getConfig: () => IEditorConfig
  getMenuConfig: (menuKey: string) => ISingleMenuConfig
  getAllMenuKeys: () => string[]
  alert: (info: string, type: AlertType) => void

  // 内容处理
  handleTab: () => void
  getHtml: () => string
  getText: () => string
  getSelectionText: () => string // 获取选区文字
  getElemsByTypePrefix: (typePrefix: string) => ElementWithId[]
  getElemsByType: (type: string, isPrefix?: boolean) => ElementWithId[]
  getParentNode: (node: Node) => Ancestor | null
  isEmpty: () => boolean
  clear: () => void
  dangerouslyInsertHtml: (html: string, isRecursive?: boolean) => void
  setHtml: (html: string) => void

  // dom 相关
  id: string
  isDestroyed: boolean
  isFullScreen: boolean
  focus: (isEnd?: boolean) => void
  isFocused: () => boolean
  blur: () => void
  updateView: () => void
  destroy: () => void
  scrollToElem: (id: string) => void
  showProgressBar: (progress: number) => void
  hidePanelOrModal: () => void
  enable: () => void
  disable: () => void
  isDisabled: () => boolean
  toDOMNode: (node: Node) => HTMLElement
  fullScreen: () => void
  unFullScreen: () => void
  getEditableContainer: () => DOMElement

  // selection 相关
  select: (at: Location) => void
  deselect: () => void
  move: (distance: number, reverse?: boolean) => void
  moveReverse: (distance: number) => void
  restoreSelection: () => void
  getSelectionPosition: () => Partial<IPositionStyle>
  getNodePosition: (node: Node) => Partial<IPositionStyle>
  isSelectedAll: () => boolean
  selectAll: () => void

  // 自定义事件
  on: (type: string, listener: ee.EventListener) => void
  off: (type: string, listener: ee.EventListener) => void
  once: (type: string, listener: ee.EventListener) => void
  emit: (type: string, ...args: any[]) => void

  // undo redo - 不用自己实现,使用 slate-history 扩展
  undo?: () => void
  redo?: () => void
}


================================================
FILE: packages/core/src/editor/plugins/with-config.ts
================================================
/**
 * @description slate 插件 - config 相关
 * @author wangfupeng
 */

import { Editor } from 'slate'
import { IDomEditor } from '../..'
import { EDITOR_TO_CONFIG } from '../../utils/weak-maps'
import { IEditorConfig, AlertType, ISingleMenuConfig } from '../../config/interface'
import { MENU_ITEM_FACTORIES } from '../../menus/register'

export const withConfig = <T extends Editor>(editor: T) => {
  const e = editor as T & IDomEditor

  e.getAllMenuKeys = (): string[] => {
    const arr: string[] = []
    for (let key in MENU_ITEM_FACTORIES) {
      arr.push(key)
    }
    return arr
  }

  // 获取 editor 配置信息
  e.getConfig = (): IEditorConfig => {
    const config = EDITOR_TO_CONFIG.get(e)
    if (config == null) throw new Error('Can not get editor config')
    return config
  }

  // 获取 menu config
  e.getMenuConfig = (menuKey: string): ISingleMenuConfig => {
    const { MENU_CONF = {} } = e.getConfig()
    return MENU_CONF[menuKey] || {}
  }

  // alert
  e.alert = (info: string, type: AlertType = 'info') => {
    const { customAlert } = e.getConfig()
    if (customAlert) customAlert(info, type)
  }

  return e
}


================================================
FILE: packages/core/src/editor/plugins/with-content.ts
================================================
/**
 * @description slate 插件 - content
 * @author wangfupeng
 */

import { Editor, Node, Text, Path, Operation, Range, Transforms, Element, Descendant } from 'slate'
import { DomEditor } from '../dom-editor'
import { IDomEditor } from '../..'
import { EDITOR_TO_SELECTION, NODE_TO_KEY } from '../../utils/weak-maps'
import node2html from '../../to-html/node2html'
import { genElemId } from '../../render/helper'
import { Key } from '../../utils/key'
import $, { DOMElement, NodeType } from '../../utils/dom'
import { findCurrentLineRange } from '../../utils/line'
import { ElementWithId } from '../interface'
import { PARSE_ELEM_HTML_CONF, TEXT_TAGS } from '../../parse-html/index'
import parseElemHtml from '../../parse-html/parse-elem-html'
import { htmlToContent } from '../../create/helper'
import { IGNORE_TAGS } from '../../constants'

/**
 * 把 elem 插入到编辑器
 * @param editor editor
 * @param elem slate elem
 */
function insertElemToEditor(editor: IDomEditor, elem: Element) {
  if (editor.isInline(elem)) {
    // inline elem 直接插入
    editor.insertNode(elem)

    // link 特殊处理,否则后面插入的文字全都在 a 里面 issue#4573
    if (elem.type === 'link') editor.insertFragment([{ text: '' }])
  } else {
    // block elem ,另起一行插入 —— 重要
    Transforms.insertNodes(editor, elem, { mode: 'highest' })
  }
}

export const withContent = <T extends Editor>(editor: T) => {
  const e = editor as T & IDomEditor
  const { onChange, insertText, apply, deleteBackward } = e

  e.insertText = (text: string) => {
    const { readOnly } = e.getConfig()
    if (readOnly) return

    insertText(text)
  }

  // 重写 apply 方法
  // apply 方法非常重要,它最终执行 operation https://docs.slatejs.org/concepts/05-operations
  // operation 的接口定义参考 slate src/interfaces/operation.ts
  e.apply = (op: Operation) => {
    const matches: [Path, Key][] = []

    switch (op.type) {
      case 'insert_text':
      case 'remove_text':
      case 'set_node': {
        for (const [node, path] of Editor.levels(e, { at: op.path })) {
          // 在当前节点寻找
          const key = DomEditor.findKey(e, node)
          matches.push([path, key])
        }
        break
      }

      case 'insert_node':
      case 'remove_node':
      case 'merge_node':
      case 'split_node': {
        for (const [node, path] of Editor.levels(e, { at: Path.parent(op.path) })) {
          // 在父节点寻找
          const key = DomEditor.findKey(e, node)
          matches.push([path, key])
        }
        break
      }

      case 'move_node': {
        for (const [node, path] of Editor.levels(e, {
          at: Path.common(Path.parent(op.path), Path.parent(op.newPath)),
        })) {
          const key = DomEditor.findKey(e, node)
          matches.push([path, key])
        }
        break
      }
    }

    // 执行原本的 apply - 重要!!!
    apply(op)

    // 绑定 node 和 key
    for (const [path, key] of matches) {
      const [node] = Editor.node(e, path)
      NODE_TO_KEY.set(node, key)
    }
  }

  e.deleteBackward = unit => {
    if (unit !== 'line') {
      return deleteBackward(unit)
    }

    if (editor.selection && Range.isCollapsed(editor.selection)) {
      const parentBlockEntry = Editor.above(editor, {
        match: n => Editor.isBlock(editor, n),
        at: editor.selection,
      })

      if (parentBlockEntry) {
        const [, parentBlockPath] = parentBlockEntry
        const parentElementRange = Editor.range(editor, parentBlockPath, editor.selection.anchor)

        const currentLineRange = findCurrentLineRange(e, parentElementRange)

        if (!Range.isCollapsed(currentLineRange)) {
          Transforms.delete(editor, { at: currentLineRange })
        }
      }
    }
  }

  // 重写 onchange API
  e.onChange = () => {
    // 记录当前选区
    const { selection } = e
    if (selection != null) {
      EDITOR_TO_SELECTION.set(e, selection)
    }

    // 触发配置的 change 事件
    e.emit('change')

    onChange()
  }

  // tab
  e.handleTab = () => {
    e.insertText('    ')
  }

  // 获取 html (去掉了格式化 2021.12.10)
  e.getHtml = (): string => {
    const { children = [] } = e
    const html = children.map(child => node2html(child, e)).join('')
    return html
  }

  // 获取 text
  e.getText = (): string => {
    const { children = [] } = e
    return children.map(child => Node.string(child)).join('\n')
  }

  // 获取选区文字
  e.getSelectionText = (): string => {
    const { selection } = e
    if (selection == null) return ''
    return Editor.string(editor, selection)
  }

  // 根据 type 获取 elems
  e.getElemsByType = (type: string, isPrefix = false): ElementWithId[] => {
    const elems: ElementWithId[] = []

    // 获取 editor 所有 nodes
    const nodeEntries = Editor.nodes(e, {
      at: [],
      universal: true,
    })
    for (let nodeEntry of nodeEntries) {
      const [node] = nodeEntry
      if (Element.isElement(node)) {
        // 判断 type (前缀 or 全等)
        let flag = isPrefix ? node.type.indexOf(type) >= 0 : node.type === type
        if (flag) {
          const key = DomEditor.findKey(e, node)
          const id = genElemId(key.id)

          // node + id
          elems.push({
            ...node,
            id,
          })
        }
      }
    }

    return elems
  }

  // 根据 type 前缀,获取 elems
  e.getElemsByTypePrefix = (typePrefix: string): ElementWithId[] => {
    return e.getElemsByType(typePrefix, true)
  }

  /**
   * 判断 editor 是否为空(只有一个空 paragraph)
   */
  e.isEmpty = () => {
    const { children = [] } = e
    if (children.length > 1) return false // >1 个顶级节点

    const firstNode = children[0]
    if (firstNode == null) return true // editor.children 空数组

    if (Element.isElement(firstNode) && firstNode.type === 'paragraph') {
      const { children: texts = [] } = firstNode
      if (texts.length > 1) return false // >1 text node

      const t = texts[0]
      if (t == null) return true // 无 text 节点

      if (Text.isText(t) && t.text === '') return true // 只有一个 text 且是空字符串
    }

    return false
  }

  /**
   * 清空内容
   */
  e.clear = () => {
    const initialEditorValue: Node[] = [
      {
        type: 'paragraph',
        children: [{ text: '' }],
      },
    ]

    Transforms.delete(e, {
      at: {
        anchor: Editor.start(e, []),
        focus: Editor.end(e, []),
      },
    })

    if (e.children.length === 0) {
      Transforms.insertNodes(e, initialEditorValue)
    }
  }

  e.getParentNode = (node: Node) => {
    return DomEditor.getParentNode(e, node)
  }

  /**
   * 插入 html (不保证语义完全正确),用于粘贴
   * @param html html string
   * @param isRecursive 是否递归调用(内部使用,使用者不要传参)
   */
  e.dangerouslyInsertHtml = (html: string = '', isRecursive = false) => {
    if (!html) return

    // ------------- 把 html 转换为 DOM nodes -------------
    const div = document.createElement('div')
    div.innerHTML = html
    let domNodes = Array.from(div.childNodes)

    // 过滤一下,只保留 elem 和 text ,并却掉一些无用标签(如 style script 等)
    domNodes = domNodes.filter(n => {
      const { nodeType, nodeName } = n
      // Text Node
      if (nodeType === NodeType.TEXT_NODE) return true

      // Element Node
      if (nodeType === NodeType.ELEMENT_NODE) {
        // 过滤掉忽略的 tag
        if (IGNORE_TAGS.has(nodeName.toLowerCase())) return false
        else return true
      }
      return false
    })
    if (domNodes.length === 0) return

    // ------------- 把 DOM nodes 转换为 slate nodes ,并插入到编辑器 -------------

    const { selection } = e
    if (selection == null) return
    let curEmptyParagraphPath: Path | null = null

    // 是否当前选中了一个空 p (如果是,后面会删掉)
    // 递归调用时不判断
    if (DomEditor.isSelectedEmptyParagraph(e) && !isRecursive) {
      const { focus } = selection
      curEmptyParagraphPath = [focus.path[0]] // 只记录顶级 path 即可
    }

    div.setAttribute('hidden', 'true')
    document.body.appendChild(div)

    let insertedElemNum = 0 // 记录插入 elem 的数量 ( textNode 不算 )
    domNodes.forEach(n => {
      const { nodeType, nodeName, textContent = '' } = n

      // ------ Text node ------
      if (nodeType === NodeType.TEXT_NODE) {
        if (!textContent || !textContent.trim()) return // 无内容的 Text

        // 插入文本
        //【注意】insertNode 和 insertText 有区别:后者会继承光标处的文本样式(如加粗);前者会加入纯文本,无样式;
        e.insertNode({ text: textContent })
        return
      }

      // ------ Element Node ------
      if (nodeName === 'BR') {
        e.insertText('\n') // 换行
        return
      }

      // 判断当前的 el 是否是可识别的 tag
      const el = n as DOMElement
      let isParseMatch = false
      if (TEXT_TAGS.includes(nodeName.toLowerCase())) {
        // text elem,如 <span>
        isParseMatch = true
      } else {
        for (let selector in PARSE_ELEM_HTML_CONF) {
          if (el.matches(selector)) {
            // 普通 elem,如 <p> <a> 等(非 text elem)
            isParseMatch = true
            break
          }
        }
      }

      // 匹配上了,则生成 slate elem 并插入
      if (isParseMatch) {
        // 生成并插入
        const $el = $(el)
        const parsedRes = parseElemHtml($el, e) as Element

        if (Array.isArray(parsedRes)) {
          parsedRes.forEach(el => insertElemToEditor(e, el))
          insertedElemNum++ // 记录数量
        } else {
          insertElemToEditor(e, parsedRes)
          insertedElemNum++ // 记录数量
        }

        // 如果当前选中 void node ,则选区移动一下
        if (DomEditor.isSelectedVoidNode(e)) e.move(1)

        return
      }

      // 没有匹配上(如 div )
      const display = window.getComputedStyle(el).display
      if (!DomEditor.isSelectedEmptyParagraph(e)) {
        // 当前不是空行,且 非 inline - 则换行
        if (display.indexOf('inline') < 0) e.insertBreak()
      }
      e.dangerouslyInsertHtml(el.innerHTML, true) // 继续插入子内容
    })

    // 删除第一个空行
    if (insertedElemNum && curEmptyParagraphPath) {
      if (DomEditor.isEmptyPath(e, curEmptyParagraphPath)) {
        Transforms.removeNodes(e, { at: curEmptyParagraphPath })
      }
    }

    div.remove() // 粘贴完了,移除 div
  }

  /**
   * 重置 HTML 内容
   * @param html html string
   */
  e.setHtml = (html: string = '') => {
    // 记录编辑器当前状态
    const isEditorDisabled = e.isDisabled()
    const isEditorFocused = e.isFocused()
    const editorSelectionStr = JSON.stringify(e.selection)

    // 删除当前内容
    e.enable()
    e.focus()
    // 需要标准的{anchor:xxx, focus: xxxx} 否则无法通过slate history的检查
    // 使用 e.select([]) e.selectAll() 生成的location不是标准的{anchor: xxxx, focus: xxx}形式
    // https://github.com/wangeditor-team/wangEditor/issues/4754
    e.clear()
    // 设置新内容
    const newContent = htmlToContent(e, html)
    Transforms.insertFragment(e, newContent)

    // 恢复编辑器状态和选区
    if (!isEditorFocused) {
      e.deselect()
      e.blur()
    }
    if (isEditorDisabled) {
      e.deselect()
      e.disable()
    }
    if (e.isFocused()) {
      try {
        e.select(JSON.parse(editorSelectionStr)) // 选中原来的位置
      } catch (ex) {
        e.select(Editor.start(e, [])) // 选中开始
      }
    }
  }

  return e
}


================================================
FILE: packages/core/src/editor/plugins/with-dom.ts
================================================
/**
 * @description slate 插件 - dom 相关
 * @author wangfupeng
 */

import { Node, Editor, Transforms } from 'slate'
import { DomEditor } from '../dom-editor'
import { IDomEditor } from '../..'
import $, { Dom7Array } from '../../utils/dom'
import {
  IS_FOCUSED,
  EDITOR_TO_PANEL_AND_MODAL,
  EDITOR_TO_TEXTAREA,
  TEXTAREA_TO_EDITOR,
  EDITOR_TO_TOOLBAR,
  TOOLBAR_TO_EDITOR,
  EDITOR_TO_HOVER_BAR,
  HOVER_BAR_TO_EDITOR,
  EDITOR_TO_SELECTION,
} from '../../utils/weak-maps'

let ID = 1

/**
 * `withDOM` adds DOM specific behaviors to the editor.
 */
export const withDOM = <T extends Editor>(editor: T) => {
  const e = editor as T & IDomEditor

  e.id = `wangEditor-${ID++}`

  e.isDestroyed = false

  e.isFullScreen = false

  // focus
  e.focus = (isEnd?: boolean) => {
    const el = DomEditor.toDOMNode(e, e)
    el.focus({ preventScroll: true })

    IS_FOCUSED.set(e, true)

    // 恢复选区
    if (isEnd) {
      // 选区定位到结尾
      const end = Editor.end(e, [])
      Transforms.select(e, end)
    } else {
      const selection = EDITOR_TO_SELECTION.get(e)
      if (selection) {
        Transforms.select(e, selection) // 选区定位到之前的位置
      } else {
        Transforms.select(e, Editor.start(e, [])) // 选区定位到开始
      }
    }
  }

  // isFocused
  e.isFocused = () => {
    return !!IS_FOCUSED.get(e)
  }

  // blur
  e.blur = () => {
    const el = DomEditor.toDOMNode(e, e)
    el.blur()

    // 手动执行一次光标 deselect, 触发 onchange 回调,改变 Toolbar 的状态
    Transforms.deselect(e)

    IS_FOCUSED.set(e, false)
  }

  // 手动更新试图
  e.updateView = () => {
    const textarea = DomEditor.getTextarea(e)
    textarea.changeViewState()

    const toolbar = DomEditor.getToolbar(e)
    toolbar && toolbar.changeToolbarState()

    const hoverbar = DomEditor.getHoverbar(e)
    hoverbar && hoverbar.changeHoverbarState()
  }

  // destroy
  e.destroy = () => {
    // 销毁相关实例(会销毁 DOM)
    if (e.isDestroyed) return
    // fix https://github.com/wangeditor-team/wangEditor-v5/issues/457
    const textarea = DomEditor.getTextarea(e)
    textarea.destroy()
    EDITOR_TO_TEXTAREA.delete(e)
    TEXTAREA_TO_EDITOR.delete(textarea)

    const toolbar = DomEditor.getToolbar(e)
    if (toolbar) {
      toolbar.destroy()
      EDITOR_TO_TOOLBAR.delete(e)
      TOOLBAR_TO_EDITOR.delete(toolbar)
    }

    const hoverbar = DomEditor.getHoverbar(e)
    if (hoverbar) {
      hoverbar.destroy()
      EDITOR_TO_HOVER_BAR.delete(e)
      HOVER_BAR_TO_EDITOR.delete(hoverbar)
    }

    // 修改属性
    e.isDestroyed = true

    // 触发自定义事件
    e.emit('destroyed')
  }

  // scroll to elem
  e.scrollToElem = (id: string) => {
    const { scroll } = e.getConfig()
    if (!scroll) {
      // 没有设置编辑区域滚动,则不能用
      let info = '编辑器禁用了 scroll ,编辑器内容无法滚动,请自行实现该功能'
      info += '\nYou has disabled editor scroll, please do this yourself'
      console.warn(info)
      return
    }

    const $elem = $(`#${id}`)
    if ($elem.length === 0) return

    // $elem 不在 editor DOM 范围之内
    const elem = $elem[0]
    if (!DomEditor.hasDOMNode(e, elem)) {
      let info = `Element (found by id is '${id}') is not in editor DOM`
      info += `\n 通过 id '${id}' 找到的 element 不在 editor DOM 之内`
      console.error(info, elem)
      return
    }

    const textarea = DomEditor.getTextarea(e)
    const { $textAreaContainer, $scroll } = textarea

    const { top: elemTop } = $elem.offset()
    const { top: containerTop } = $textAreaContainer.offset()

    // 滚动到指定元素
    $scroll[0].scrollBy({ top: elemTop - containerTop, behavior: 'smooth' })
  }

  // showProgressBar
  e.showProgressBar = (progress: number) => {
    // progress 值范围: 0 - 100
    if (progress < 1) return

    // 显示进度条
    const textarea = DomEditor.getTextarea(e)
    textarea.changeProgress(progress)
  }

  // 隐藏 panel 或 modal
  e.hidePanelOrModal = () => {
    const set = EDITOR_TO_PANEL_AND_MODAL.get(e)
    if (set == null) return
    set.forEach(panelOrModal => panelOrModal.hide())
  }

  e.enable = () => {
    const config = e.getConfig()
    config.readOnly = false

    // 更新视图
    e.updateView()
  }

  e.disable = () => {
    const config = e.getConfig()
    config.readOnly = true

    // 更新视图
    e.updateView()
  }

  e.isDisabled = () => {
    const config = e.getConfig()
    return config.readOnly
  }

  e.toDOMNode = (node: Node) => {
    return DomEditor.toDOMNode(e, node)
  }

  e.fullScreen = () => {
    if (e.isFullScreen) return

    let $toolbarBox: Dom7Array | null = null
    const toolbar = DomEditor.getToolbar(e)
    if (toolbar) {
      $toolbarBox = toolbar.$box
    }

    const textarea = DomEditor.getTextarea(e)
    const $textAreaBox = textarea.$box
    const $parent = $textAreaBox.parent()

    if ($toolbarBox && $toolbarBox.parent()[0] !== $parent[0]) {
      // toolbar DOM 父节点,和 editor DOM 父节点不一致,则不能设置全屏
      let info =
        'Can not set full screen, cause toolbar DOM parent is not equal to textarea DOM parent'
      info += '\n不能设置全屏,因为 toolbar DOM 父节点和 textarea DOM 父节点不一致'
      throw new Error(info)
    }

    // 设置全屏
    $parent.addClass('w-e-full-screen-container')

    // 设置 z-index
    const curZIndex = $parent.css('z-index')
    $parent.attr('data-z-index', curZIndex.toString())

    // 记录属性
    e.isFullScreen = true

    // 触发自定义事件
    e.emit('fullScreen')
  }

  e.unFullScreen = () => {
    if (!e.isFullScreen) return

    const textarea = DomEditor.getTextarea(e)
    const $textAreaBox = textarea.$box
    const $parent = $textAreaBox.parent()

    // 解决#issue175, 编辑器取消全屏 - element dialog组件会被隐藏
    setTimeout(() => {
      // 取消全屏
      $parent.removeClass('w-e-full-screen-container')

      // 记录属性
      e.isFullScreen = false

      // 触发自定义事件
      e.emit('unFullScreen')
    }, 200)
  }

  /**
   * 获取编辑区域 DOM 容器
   */
  e.getEditableContainer = () => {
    const textarea = DomEditor.getTextarea(e)
    return textarea.$textAreaContainer[0]
  }

  return e
}


================================================
FILE: packages/core/src/editor/plugins/with-emitter.ts
================================================
/**
 * @description 自定义事件 插件
 * @author wangfupeng
 */

import ee, { Emitter } from 'event-emitter'
import { Editor } from 'slate'
import { IDomEditor } from '../interface'
import { EDITOR_TO_EMITTER } from '../../utils/weak-maps'

/**
 * 获取 editor 的 emitter 实例
 * @param editor editor
 */
function getEmitter(editor: IDomEditor): Emitter {
  let emitter = EDITOR_TO_EMITTER.get(editor)
  if (emitter == null) {
    emitter = ee()
    EDITOR_TO_EMITTER.set(editor, emitter)
  }
  return emitter
}

// 记录下当前 editor 的 destroy listeners
const EDITOR_TO_DESTROY_LISTENERS: WeakMap<IDomEditor, Set<Function>> = new WeakMap()
function recordDestroyListeners(editor: IDomEditor, fn: Function) {
  let listeners = EDITOR_TO_DESTROY_LISTENERS.get(editor)
  if (listeners == null) {
    listeners = new Set<Function>()
    EDITOR_TO_DESTROY_LISTENERS.set(editor, listeners)
  }
  listeners.add(fn)
}
function getDestroyListeners(editor: IDomEditor): Set<Function> {
  return EDITOR_TO_DESTROY_LISTENERS.get(editor) || new Set()
}
function clearDestroyListeners(editor: IDomEditor) {
  EDITOR_TO_DESTROY_LISTENERS.set(editor, new Set())
}

export const withEmitter = <T extends Editor>(editor: T) => {
  const e = editor as T & IDomEditor

  // 自定义事件
  e.on = (type, listener) => {
    const emitter = getEmitter(e)

    // 绑定事件
    emitter.on(type, listener)

    // destroyed 事件需要记录下来,以便最后统一 off 掉
    if (type === 'destroyed') recordDestroyListeners(e, listener)

    // editor 销毁时,取消绑定 - 重要
    if (type !== 'destroyed') {
      const fn = () => emitter.off(type, listener)
      emitter.on('destroyed', fn)
      recordDestroyListeners(e, fn) // 记录下来
    }
  }
  e.once = (type, listener) => {
    const emitter = getEmitter(e)
    emitter.once(type, listener)
  }
  e.off = (type, listener) => {
    const emitter = getEmitter(e)
    emitter.off(type, listener)
  }
  e.emit = (type, ...args: any[]) => {
    const emitter = getEmitter(e)
    emitter.emit(type, ...args)

    // editor 销毁时,off 掉 destroyed listeners
    if (type === 'destroyed') {
      const listeners = getDestroyListeners(e)
      listeners.forEach(fn => emitter.off('destroyed', fn as ee.EventListener))
      clearDestroyListeners(e) // 清空 destroyed listeners
    }
  }

  return e
}


================================================
FILE: packages/core/src/editor/plugins/with-event-data.ts
================================================
/**
 * @description slate 插件 - event data 相关
 * @author wangfupeng
 */

import { Editor, Node, Transforms, Range } from 'slate'
import { DomEditor } from '../dom-editor'
import { IDomEditor } from '../..'

import { isDOMText, getPlainText } from '../../utils/dom'

export const withEventData = <T extends Editor>(editor: T) => {
  const e = editor as T & IDomEditor
  const { insertText, insertFragment } = e

  e.setFragmentData = (data: Pick<DataTransfer, 'getData' | 'setData'>) => {
    const { selection } = e

    if (!selection) {
      return
    }

    // 获取开始、结束两个 point { path, offset }
    const [start, end] = Range.edges(selection)
    // Editor.void - Match a void node in the current branch of the editor.
    const startVoid = Editor.void(e, { at: start.path })
    const endVoid = Editor.void(e, { at: end.path })

    if (Range.isCollapsed(selection) && !startVoid) {
      return
    }

    // Create a fake selection so that we can add a Base64-encoded copy of the
    // fragment to the HTML, to decode on future pastes.
    const domRange = DomEditor.toDOMRange(e, selection)
    let contents = domRange.cloneContents()
    let attach = contents.childNodes[0] as HTMLElement

    // Make sure attach is non-empty, since empty nodes will not get copied.
    contents.childNodes.forEach(node => {
      if (node.textContent && node.textContent.trim() !== '') {
        attach = node as HTMLElement
      }
    })

    // COMPAT: If the end node is a void node, we need to move the end of the
    // range from the void node's spacer span, to the end of the void node's
    // content, since the spacer is before void's content in the DOM.
    if (endVoid) {
      const [voidNode] = endVoid
      const r = domRange.cloneRange()
      const domNode = DomEditor.toDOMNode(e, voidNode)
      r.setEndAfter(domNode)
      contents = r.cloneContents()
    }

    // COMPAT: If the start node is a void node, we need to attach the encoded
    // fragment to the void node's content node instead of the spacer, because
    // attaching it to empty `<div>/<span>` nodes will end up having it erased by
    // most browsers. (2018/04/27)
    if (startVoid) {
      attach = contents.querySelector('[data-slate-spacer]')! as HTMLElement
    }

    // Remove any zero-width space spans from the cloned DOM so that they don't
    // show up elsewhere when pasted.
    Array.from(contents.querySelectorAll('[data-slate-zero-width]')).forEach(zw => {
      const isNewline = zw.getAttribute('data-slate-zero-width') === 'n'
      zw.textContent = isNewline ? '\n' : ''
    })

    // Set a `data-slate-fragment` attribute on a non-empty node, so it shows up
    // in the HTML, and can be used for intra-Slate pasting. If it's a text
    // node, wrap it in a `<span>` so we have something to set an attribute on.
    if (isDOMText(attach)) {
      const span = attach.ownerDocument.createElement('span')
      // COMPAT: In Chrome and Safari, if we don't add the `white-space` style
      // then leading and trailing spaces will be ignored. (2017/09/21)
      span.style.whiteSpace = 'pre'
      span.appendChild(attach)
      contents.appendChild(span)
      attach = span
    }

    const fragment = e.getFragment()
    const string = JSON.stringify(fragment)
    const encoded = window.btoa(encodeURIComponent(string))
    attach.setAttribute('data-slate-fragment', encoded)
    data.setData('application/x-slate-fragment', encoded)

    // Add the content to a <div> so that we can get its inner HTML.
    const div = contents.ownerDocument.createElement('div')
    div.appendChild(contents)
    div.setAttribute('hidden', 'true')
    contents.ownerDocument.body.appendChild(div)
    data.setData('text/html', div.innerHTML)
    data.setData('text/plain', getPlainText(div))
    contents.ownerDocument.body.removeChild(div)

    return data
  }

  e.insertData = (data: DataTransfer) => {
    const fragment = data.getData('application/x-slate-fragment')
    if (fragment) {
      const decoded = decodeURIComponent(window.atob(fragment))
      const parsed = JSON.parse(decoded) as Node[]
      e.insertFragment(parsed)
      return
    }

    const text = data.getData('text/plain')
    const html = data.getData('text/html')
    // const rtf = data.getData('text/rtf')

    if (html) {
      e.dangerouslyInsertHtml(html)
      return
    }

    if (text) {
      const lines = text.split(/\r\n|\r|\n/)
      let split = false

      for (const line of lines) {
        if (split) {
          Transforms.splitNodes(e, { always: true })
        }

        insertText(line)
        split = true
      }
      return
    }
  }

  return e
}


================================================
FILE: packages/core/src/editor/plugins/with-max-length.ts
================================================
/**
 * @description slate 插件 - maxLength
 * @author wangfupeng
 */

//【注意】拼音输入时 maxLength 限制在 CompositionEnd 事件中处理

import { Editor, Node } from 'slate'
import { IDomEditor, DomEditor } from '../..'
import { IGNORE_TAGS } from '../../constants'
import { NodeType } from '../../utils/dom'

export const withMaxLength = <T extends Editor>(editor: T) => {
  const e = editor as T & IDomEditor
  const { insertText, insertNode, insertFragment, dangerouslyInsertHtml } = e

  // 处理 text
  e.insertText = (text: string) => {
    const { maxLength } = e.getConfig()
    if (!maxLength) {
      insertText(text)
      return
    }

    const leftLength = DomEditor.getLeftLengthOfMaxLength(e)
    if (leftLength <= 0) {
      // 已经触发 maxLength ,不再输入文字
      return
    }

    if (leftLength < text.length) {
      // 剩余长度小于 text 长度,则截取 text
      insertText(text.slice(0, leftLength))
      return
    }

    insertText(text)
  }

  // 处理 node
  e.insertNode = (node: Node) => {
    const { maxLength } = e.getConfig()
    if (!maxLength) {
      insertNode(node)
      return
    }

    const leftLength = DomEditor.getLeftLengthOfMaxLength(e)
    if (leftLength <= 0) {
      // 已经触发 maxLength ,不再插入
      return
    }

    const text = Node.string(node)
    if (leftLength < text.length) {
      // 剩余长度,不够 node text 长度,不再插入
      return
    }

    insertNode(node)
  }

  // 处理 fragment
  e.insertFragment = (fragment: Node[]) => {
    const { maxLength } = e.getConfig()
    if (!maxLength) {
      // 无 maxLength
      insertFragment(fragment)
      return
    }

    // 只有一个 node 时,使用 insertFragment ,防止换行
    if (fragment.length === 1) {
      const node = fragment[0]
      const leftLength = DomEditor.getLeftLengthOfMaxLength(e)
      const text = Node.string(node)

      if (leftLength < text.length) {
        // 已经触发 maxLength ,不再插入
        return
      }

      insertFragment(fragment)
      return
    }
    // 有 maxLength ,则分别插入 node
    fragment.forEach(n => {
      e.insertNode(n) //【注意】这里必须使用 `e.insertNode` ,而不是 insertNode
    })
  }

  e.dangerouslyInsertHtml = (html: string = '', isRecursive = false) => {
    if (!html) return

    const { maxLength } = e.getConfig()
    if (!maxLength) {
      // 无 maxLength
      dangerouslyInsertHtml(html, isRecursive)
      return
    }
    const leftLength = DomEditor.getLeftLengthOfMaxLength(e)
    if (leftLength <= 0) {
      // 已经触发 maxLength ,不再输入文字
      return
    }

    // ------------- 把 html 转换为 DOM nodes -------------
    const div = document.createElement('div')
    div.innerHTML = html
    const text = Array.from(div.childNodes).reduce<string>((acc, node) => {
      const { nodeType, nodeName } = node
      if (!node) {
        return acc
      }
      // Text Node
      if (nodeType === NodeType.TEXT_NODE) return acc + (node.textContent || '')

      // Element Node
      if (nodeType === NodeType.ELEMENT_NODE) {
        // 过滤掉忽略的 tag
        if (IGNORE_TAGS.has(nodeName.toLowerCase())) return acc
        else return acc + (node.textContent || '')
      }
      return acc
    }, '')

    if (leftLength < text.length) {
      return
    }

    dangerouslyInsertHtml(html, isRecursive)
  }

  return e // 返回 editor 实例
}


================================================
FILE: packages/core/src/editor/plugins/with-selection.ts
================================================
/**
 * @description slate 插件 - selection 相关
 * @author wangfupeng
 */

import { Editor, Transforms, Location, Node, Range, Point } from 'slate'
import { IDomEditor } from '../interface'
import { DomEditor } from '../dom-editor'
import { getPositionByNode, getPositionBySelection } from '../../menus/helpers/position'
import { EDITOR_TO_SELECTION } from '../../utils/weak-maps'

export const withSelection = <T extends Editor>(editor: T) => {
  const e = editor as T & IDomEditor

  // 选中
  e.select = (at: Location) => {
    Transforms.select(e, at)
  }

  // 取消选中
  e.deselect = () => {
    const { selection } = e
    const root = DomEditor.findDocumentOrShadowRoot(e)
    const domSelection = root.getSelection()

    if (domSelection && domSelection.rangeCount > 0) {
      domSelection.removeAllRanges()
    }

    if (selection) {
      Transforms.deselect(editor)
    }
  }

  // 移动光标
  e.move = (distance: number, reverse = false) => {
    if (!distance) return
    if (distance < 0) return

    Transforms.move(editor, {
      distance,
      unit: 'character',
      reverse,
    })
  }

  // 反向移动光标
  e.moveReverse = (distance: number) => {
    e.move(distance, true)
  }

  /**
   * 还原选区
   */
  e.restoreSelection = () => {
    const selection = EDITOR_TO_SELECTION.get(e)
    if (selection == null) return

    e.focus()
    Transforms.select(e, selection)
  }

  /**
   * 获取选区的 position
   */
  e.getSelectionPosition = () => {
    return getPositionBySelection(e)
  }

  /**
   * 获取 node 的 position
   */
  e.getNodePosition = (node: Node) => {
    return getPositionByNode(e, node)
  }

  /**
   * 是否全选
   */
  e.isSelectedAll = () => {
    const { selection } = e
    if (selection == null) return false

    const [start1, end1] = Range.edges(selection) // 获取当前选取的开始、结束 point
    const [start2, end2] = Editor.edges(e, []) // 获取编辑器全部的开始、结束 point

    if (Point.equals(start1, start2) && Point.equals(end1, end2)) {
      return true
    }
    return false
  }

  /**
   * 全选
   */
  e.selectAll = () => {
    const start = Editor.start(e, [])
    const end = Editor.end(e, [])

    Transforms.select(e, {
      anchor: start,
      focus: end,
    })
  }

  return e
}


================================================
FILE: packages/core/src/i18n/index.ts
================================================
/**
 * @description i18n entry
 * @author wangfupeng
 */

import i18next from 'i18next'

// i18n nameSpace
const NS = 'translation'

i18next.init({
  lng: 'zh-CN',
  // debug: true,
  resources: {}, // 资源为空,随后添加
})

/**
 * 添加多语言配置
 * @param lng 语言
 * @param resources 多语言配置
 */
export function i18nAddResources(lng: string, resources: object) {
  i18next.addResourceBundle(lng, NS, resources, true, true)
}

/**
 * 设置语言
 * @param lng 语言
 */
export function i18nChangeLanguage(lng: string) {
  i18next.changeLanguage(lng)
}

/**
 * 获取多语言配置
 * @param lng lang
 */
export function i18nGetResources(lng: string) {
  return i18next.getResourceBundle(lng, NS)
}

/**
 * 翻译
 */
export const t = i18next.t.bind(i18next)

export default i18next


================================================
FILE: packages/core/src/index.ts
================================================
/**
 * @description core index
 * @author wangfupeng
 */

import './assets/index.less'

import { RenderStyleFnType, IRenderElemConf } from './render/index'
import { styleToHtmlFnType, IElemToHtmlConf } from './to-html/index'
import { IPreParseHtmlConf, ParseStyleHtmlFnType, IParseElemHtmlConf } from './parse-html/index'
import { IRegisterMenuConf } from './menus/index'
import { IDomEditor } from './editor/interface'

// 创建
export * from './create/index'

// config
export { IEditorConfig, IToolbarConfig } from './config/interface'

// editor 接口和 command
export * from './editor/interface'
export * from './editor/dom-editor'

// 注册 render
export * from './render/index'

// 注册 toHtml
export * from './to-html/index'

// 注册 parseHtml
export * from './parse-html/index'

// menu 的接口、注册、方法等
export * from './menus/index'

// upload
export * from './upload/index'

// i18n
export * from './i18n/index'

export interface IModuleConf {
  // 注册菜单
  menus: Array<IRegisterMenuConf>

  // 渲染 modal -> view
  renderStyle: RenderStyleFnType
  renderElems: Array<IRenderElemConf>

  // to html
  styleToHtml: styleToHtmlFnType
  elemsToHtml: Array<IElemToHtmlConf>

  // parse html
  preParseHtml: Array<IPreParseHtmlConf>
  parseStyleHtml: ParseStyleHtmlFnType
  parseElemsHtml: Array<IParseElemHtmlConf>

  // 注册插件
  editorPlugin: <T extends IDomEditor>(editor: T) => T
}


================================================
FILE: packages/core/src/menus/README.md
================================================
# menus

统一注册 menu ,menu 支持
- classic toolbar
- hovering toolbar
- tooltip
- contextMenu


================================================
FILE: packages/core/src/menus/bar/HoverBar.ts
================================================
/**
 * @description hover bar class
 * @author wangfupeng
 */

import debounce from 'lodash.debounce'
import { Editor, Node, Element, Text, Path, Range } from 'slate'
import $ from '../../utils/dom'
import { MENU_ITEM_FACTORIES } from '../register'
import { promiseResolveThen } from '../../utils/util'
import { IDomEditor } from '../../editor/interface'
import { DomEditor } from '../../editor/dom-editor'
import { HOVER_BAR_TO_EDITOR, BAR_ITEM_TO_EDITOR } from '../../utils/weak-maps'
import { IBarItem, createBarItem } from '../bar-item/index'
import { gen$barItemDivider } from '../helpers/helpers'
import { getPositionBySelection, getPositionByNode, correctPosition } from '../helpers/position'
import { IButtonMenu, ISelectMenu, IDropPanelMenu, IModalMenu } from '../interface'
import { CustomElement } from '../../../../custom-types'

type MenuType = IButtonMenu | ISelectMenu | IDropPanelMenu | IModalMenu

/**
 * 是否选中了 text (用于 text hoverbarKeys)
 * @param editor editor
 * @param n node
 */
function isSelectedText(editor: IDomEditor, n: Node) {
  const { selection } = editor
  if (selection == null) return false // 无选区
  if (Range.isCollapsed(selection)) return false // 未选中文字,选区的是折叠的

  const selectedElems = DomEditor.getSelectedElems(editor)
  const notMatch = selectedElems.some((elem: CustomElement) => {
    if (editor.isVoid(elem)) return true

    const { type } = elem
    if (['pre', 'code', 'table'].includes(type)) return true
  })
  if (notMatch) return false

  if (Text.isText(n)) return true // 匹配 text node
  return false
}

class HoverBar {
  private readonly $elem = $('<div class="w-e-bar w-e-bar-hidden w-e-hover-bar"></div>')
  private menus: { [key: string]: MenuType } = {}
  private hoverbarItems: IBarItem[] = []
  private prevSelectedNode: Node | null = null // 上一次选中的 node
  private isShow = false

  constructor() {
    // 异步,否则获取不到 DOM 和 editor
    promiseResolveThen(() => {
      const editor = this.getEditorInstance()

      // 将 elem 渲染为 DOM
      const $elem = this.$elem
      // @ts-ignore
      $elem.on('mousedown', e => e.preventDefault(), { passive: false }) // 防止点击失焦
      const textarea = DomEditor.getTextarea(editor)
      textarea.$textAreaContainer.append($elem)

      // 绑定 editor onchange
      editor.on('change', this.changeHoverbarState)

      // 滚动时隐藏
      const hideAndClean = this.hideAndClean.bind(this)
      editor.on('scroll', hideAndClean)

      // fullScreen 时隐藏
      editor.on('fullScreen', hideAndClean)
      editor.on('unFullScreen', hideAndClean)
    })
  }

  getMenus() {
    return this.menus
  }

  hideAndClean() {
    const $elem = this.$elem
    $elem.removeClass('w-e-bar-show').addClass('w-e-bar-hidden')

    // 及时先清空内容,否则影响下次
    this.hoverbarItems = []
    $elem.empty()

    this.isShow = false
  }

  /**
   * 判断 hoverbar 是否在网页下部?
   * 如果是,SelectList 和 DropPanel 要显示在 hoverbar 上面
   */
  private checkPositionBottom() {
    const $elem = this.$elem

    let isBottom = false
    const { innerHeight } = window
    const minDistance = 360 // 距离底部最小 360px
    if (innerHeight && innerHeight >= minDistance) {
      const { bottom } = $elem[0].getBoundingClientRect()
      if (innerHeight - bottom < minDistance) {
        // hoverbar 距离底部不足 360
        isBottom = true
      }
    }
    if (isBottom) {
      $elem.addClass('w-e-bar-bottom')
    } else {
      $elem.removeClass('w-e-bar-bottom')
    }
  }

  private show() {
    this.$elem.removeClass('w-e-bar-hidden').addClass('w-e-bar-show')
    this.isShow = true

    // 判断 hoverbar 是否在网页下部
    this.checkPositionBottom()
  }

  private changeItemsState() {
    promiseResolveThen(() => {
      this.hoverbarItems.forEach(item => {
        item.changeMenuState()
      })
    })
  }

  private registerItems(menuKeys: string[]) {
    const $elem = this.$elem

    menuKeys.forEach(key => {
      if (key === '|') {
        // 分割线
        const $divider = gen$barItemDivider()
        $elem.append($divider)
        return
      }

      // 正常菜单
      this.registerSingleItem(key)
    })
  }

  // 注册单个 bar item
  private registerSingleItem(key: string) {
    const editor = this.getEditorInstance()

    // 尝试从缓存中获取
    const { menus } = this
    let menu = menus[key]

    if (menu == null) {
      // 缓存获取失败,则重新创建
      const factory = MENU_ITEM_FACTORIES[key]
      if (factory == null) {
        throw new Error(`Not found menu item factory by key '${key}'`)
      }
      if (typeof factory !== 'function') {
        throw new Error(`Menu item factory (key='${key}') is not a function`)
      }

      // 创建 barItem 并记录缓存
      menu = factory()
      menus[key] = menu
    }

    const barItem = createBarItem(key, menu)
    this.hoverbarItems.push(barItem)

    // 保存 barItem 和 editor 的关系
    BAR_ITEM_TO_EDITOR.set(barItem, editor)

    // 添加 DOM
    const $elem = this.$elem
    $elem.append(barItem.$elem)
  }

  private setPosition(node: Node) {
    const editor = this.getEditorInstance()
    const $elem = this.$elem
    $elem.attr('style', '') // 先清空 style ,再重新设置

    if (Element.isElement(node)) {
      // 根据 elem node 定位
      const positionStyle = getPositionByNode(editor, node, 'bar')
      $elem.css(positionStyle)
      correctPosition(editor, $elem) // 修正 position 避免超出 textContainer 边界
      return
    }
    if (Text.isText(node)) {
      // text node ,根据选区定位
      const positionStyle = getPositionBySelection(editor)
      $elem.css(positionStyle)
      correctPosition(editor, $elem) // 修正 position 避免超出 textContainer 边界
      return
    }
    // 其他情况,非 elem 非 text ,不处理
    throw new Error('hoverbar.setPosition error, current selected node is not elem nor text')
  }

  /**
   * 获取选中的 node ,以及对应的 menu keys
   */
  private getSelectedNodeAndMenuKeys(): { node: Node; menuKeys: string[] } | null {
    const editor = this.getEditorInstance()

    if (editor.selection == null) {
      return null
    }

    // 获取 hover bar 配置
    const keysConf = this.getHoverbarKeysConf()
    // 开始匹配
    let matchNode: Node | null = null
    let matchMenuKeys: string[] = []

    for (const elemType in keysConf) {
      const conf = keysConf[elemType]
      const { match, menuKeys = [] } = conf

      // 定义了 match 则用 match 。未定义 match 则用 elemType
      const matchFn = match
        ? match
        : (editor: IDomEditor, n: Node) => DomEditor.checkNodeType(n, elemType)

      const [nodeEntry] = Editor.nodes(editor, {
        match: n => matchFn(editor, n),
        universal: true,
      })

      // 匹配成功(找到第一个就停止,不再继续找了)
      if (nodeEntry != null) {
        matchNode = nodeEntry[0]
        matchMenuKeys = menuKeys
        break
      }
    }

    // 未匹配成功
    if (matchNode == null || matchMenuKeys.length === 0) return null

    // 匹配成功
    return {
      node: matchNode,
      menuKeys: matchMenuKeys,
    }
  }

  /**
   * editor onChange 时触发(涉及 DOM 操作,加防抖)
   */
  changeHoverbarState = debounce(() => {
    // 获取选中的 node ,以及对应的 menu keys
    const { isShow } = this
    const { node = null, menuKeys = [] } = this.getSelectedNodeAndMenuKeys() || {}

    if (node != null) {
      this.changeItemsState() // 更新菜单状态
    }

    if (node && Element.isElement(node)) {
      // 选中了 elem node(不可以是 text node)
      if (isShow) {
        // hoverbar 当前已显示
        const samePath = this.isSamePath(node, this.prevSelectedNode)
        if (samePath) {
          // 和之前选中的 node path 相同 —— 满足这些条件,即终止
          return
        }
      }
    }

    // 选择了新的 node(或选区是 null),先隐藏
    this.hideAndClean()

    if (node != null) {
      // 选中了新的 node
      this.registerItems(menuKeys)
      this.setPosition(node)
      this.show()
    }

    // 最后,重新记录 prevSelectedNode ,重要
    this.prevSelectedNode = node
  }, 200)

  private getEditorInstance(): IDomEditor {
    const editor = HOVER_BAR_TO_EDITOR.get(this)
    if (editor == null) throw new Error('Can not get editor instance')
    return editor
  }

  private getHoverbarKeysConf() {
    const editor = this.getEditorInstance()
    const { hoverbarKeys = {} } = editor.getConfig()

    const textHoverbarKeys = hoverbarKeys.text
    if (textHoverbarKeys && textHoverbarKeys.match == null) {
      // 对 text hoverbarKeys 增加 match 函数(否则无法判断是否选中了 text)
      textHoverbarKeys.match = isSelectedText
    }

    return hoverbarKeys
  }

  /**
   * 检查两个 node 是否 path 相等
   */
  private isSamePath(node1: Node | null, node2: Node | null) {
    if (node1 == null || node2 == null) {
      return false
    }

    const path1 = DomEditor.findPath(null, node1)
    const path2 = DomEditor.findPath(null, node2)
    const res = Path.equals(path1, path2)
    return res
  }

  /**
   * 销毁 hoverbar
   */
  destroy() {
    // fix https://github.com/wangeditor-team/wangEditor-v5/issues/410
    this.changeHoverbarState.cancel()
    // 销毁 DOM
    this.$elem.remove()

    // 清空属性
    this.menus = {}
    this.hoverbarItems = []
    this.prevSelectedNode = null
  }
}

export default HoverBar


================================================
FILE: packages/core/src/menus/bar/Toolbar.ts
================================================
/**
 * @description classic toolbar
 * @author wangfupeng
 */

import debounce from 'lodash.debounce'
import clonedeep from 'lodash.clonedeep'
import $, { Dom7Array, DOMElement } from '../../utils/dom'
import { MENU_ITEM_FACTORIES } from '../register'
import { promiseResolveThen } from '../../utils/util'
import { TOOLBAR_TO_EDITOR, BAR_ITEM_TO_EDITOR } from '../../utils/weak-maps'
import { IDomEditor } from '../../editor/interface'
import { IBarItem, createBarItem, createBarItemGroup } from '../bar-item/index'
import { gen$barItemDivider } from '../helpers/helpers'
import { IMenuGroup, IButtonMenu, ISelectMenu, IDropPanelMenu, IModalMenu } from '../interface'
import GroupButton from '../bar-item/GroupButton'
import { IToolbarConfig } from '../../config/interface'

type MenuType = IButtonMenu | ISelectMenu | IDropPanelMenu | IModalMenu

class Toolbar {
  $box: Dom7Array
  private readonly $toolbar: Dom7Array = $(`<div class="w-e-bar w-e-bar-show w-e-toolbar"></div>`)
  private menus: { [key: string]: MenuType } = {}
  private toolbarItems: IBarItem[] = []
  private config: Partial<IToolbarConfig> = {}

  constructor(boxSelector: string | DOMElement, config: Partial<IToolbarConfig>) {
    this.config = config

    // @ts-ignore 初始化 DOM
    const $box = $(boxSelector)
    if ($box.length === 0) {
      throw new Error(`Cannot find toolbar DOM by selector '${boxSelector}'`)
    }
    this.$box = $box
    const $toolbar = this.$toolbar
    // @ts-ignore
    $toolbar.on('mousedown', e => e.preventDefault(), { passive: false }) // 防止点击失焦
    $box.append($toolbar)

    // 异步,否则拿不到 editor 实例
    promiseResolveThen(() => {
      // 注册 items
      this.registerItems()

      // 创建完,先模拟一次 onchange
      this.changeToolbarState()

      // 监听 editor onchange
      const editor = this.getEditorInstance()
      editor.on('change', this.changeToolbarState)
    })
  }

  getMenus() {
    return this.menus
  }

  getConfig() {
    return this.config
  }

  // 注册 toolbarItems
  private registerItems() {
    let prevKey = ''
    const $toolbar = this.$toolbar
    const { toolbarKeys = [], insertKeys = { index: 0, keys: [] }, excludeKeys = [] } = this.config // 格式如 ['a', '|', 'b', 'c', '|', 'd']

    // 新插入菜单
    const toolbarKeysWithInsertedKeys = clonedeep(toolbarKeys)
    if (insertKeys.keys.length > 0) {
      if (typeof insertKeys.keys === 'string') {
        insertKeys.keys = [insertKeys.keys]
      }

      insertKeys.keys.forEach((k, i) => {
        toolbarKeysWithInsertedKeys.splice(insertKeys.index + i, 0, k)
      })
    }

    // 排除某些菜单
    const filteredKeys = toolbarKeysWithInsertedKeys.filter(key => {
      if (typeof key === 'string') {
        // 普通菜单
        if (excludeKeys.includes(key)) return false
      } else {
        // group
        if (excludeKeys.includes(key.key)) return false
      }
      return true
    })
    const filteredKeysLength = filteredKeys.length

    // 开始注册菜单
    filteredKeys.forEach((key, index) => {
      if (key === '|') {
        // 第一个就是 `|` ,忽略
        if (index === 0) return

        // 最后一个是 `|` ,忽略
        if (index + 1 === filteredKeysLength) return

        // 多个紧挨着的 `|` ,只显示一个
        if (prevKey === '|') return

        // 分割线
        const $divider = gen$barItemDivider()
        $toolbar.append($divider)
        prevKey = key
        return
      }

      // 正常菜单
      if (typeof key === 'string') {
        this.registerSingleItem(key, this)
        prevKey = key
        return
      }

      // 菜单组
      this.registerGroup(key)
      prevKey = 'group'
    })
  }

  // 注册菜单组
  private registerGroup(menu: IMenuGroup) {
    const $toolbar = this.$toolbar
    const group = createBarItemGroup(menu)
    const { menuKeys = [] } = menu
    const { excludeKeys = [] } = this.config

    // 注册子菜单
    menuKeys.forEach(key => {
      if (excludeKeys.includes(key)) return
      this.registerSingleItem(
        key,
        group // 将子菜单,添加到 group
      )
    })

    // 添加到 DOM
    $toolbar.append(group.$elem)
  }

  // 注册单个 toolbarItem
  private registerSingleItem(key: string, container: GroupButton | Toolbar) {
    const editor = this.getEditorInstance()
    const inGroup = container instanceof GroupButton // 要添加到 groupButton

    // 尝试从缓存中获取
    const { menus } = this
    let menu = menus[key]

    if (menu == null) {
      // 缓存中没有,则创建
      const factory = MENU_ITEM_FACTORIES[key]
      if (factory == null) {
        throw new Error(`Not found menu item factory by key '${key}'`)
      }
      if (typeof factory !== 'function') {
        throw new Error(`Menu item factory (key='${key}') is not a function`)
      }

      // 创建 toolbarItem 并记录缓存
      menu = factory()
      menus[key] = menu
    } else {
      console.warn(`Duplicated toolbar menu key '${key}'\n重复注册了菜单栏 menu '${key}'`)
    }

    const toolbarItem = createBarItem(key, menu, inGroup)
    this.toolbarItems.push(toolbarItem)

    // 保存 toolbarItem 和 editor 的关系
    BAR_ITEM_TO_EDITOR.set(toolbarItem, editor)

    // 添加 DOM
    if (inGroup) {
      // barItem 是 groupButton
      const group = container as GroupButton
      group.appendBarItem(toolbarItem)
    } else {
      // barItem 添加到 toolbar
      const toolbar = container as Toolbar
      toolbar.$toolbar.append(toolbarItem.$elem)
    }
  }

  private getEditorInstance(): IDomEditor {
    const editor = TOOLBAR_TO_EDITOR.get(this)
    if (editor == null) throw new Error('Can not get editor instance')
    return editor
  }

  /**
   * editor onChange 时触发(涉及 DOM 操作,加防抖)
   */
  changeToolbarState = debounce(() => {
    this.toolbarItems.forEach(toolbarItem => {
      toolbarItem.changeMenuState()
    })
  }, 200)

  /**
   * 销毁 toolbar
   */
  destroy() {
    // 销毁 DOM
    this.$toolbar.remove()

    // 清空属性
    this.menus = {}
    this.toolbarItems = []
  }
}

export default Toolbar


================================================
FILE: packages/core/src/menus/bar-item/BaseButton.ts
================================================
/**
 * @description base button class
 * @author wangfupeng
 */

import { IButtonMenu, IDropPanelMenu, IModalMenu } from '../interface'
import $, { Dom7Array } from '../../utils/dom'
import { IBarItem, getEditorInstance } from './index'
import { clearSvgStyle } from '../helpers/helpers'
import { promiseResolveThen } from '../../utils/util'
import { addTooltip } from './tooltip'

abstract class BaseButton implements IBarItem {
  readonly $elem: Dom7Array = $(`<div class="w-e-bar-item"></div>`)
  protected readonly $button: Dom7Array = $(`<button type="button"></button>`)
  menu: IButtonMenu | IDropPanelMenu | IModalMenu
  private disabled = false

  constructor(key: string, menu: IButtonMenu | IDropPanelMenu | IModalMenu, inGroup = false) {
    this.menu = menu

    // 验证 tag
    const { tag, width } = menu
    if (tag !== 'button') throw new Error(`Invalid tag '${tag}', expected 'button'`)

    // ----------------- 初始化 dom -----------------
    const { title, hotkey = '', iconSvg = '' } = menu
    const { $button } = this
    if (iconSvg) {
      const $svg = $(iconSvg)
      clearSvgStyle($svg) // 清理 svg 样式(扩展的菜单,svg 是不可控的,所以要清理一下)
      $button.append($svg)
    } else {
      // 无 icon 则显示 title
      $button.text(title)
    }
    addTooltip($button, iconSvg, title, hotkey, inGroup) // 设置 tooltip
    if (inGroup && iconSvg) {
      // in groupButton(且有 icon),显示 menu title
      // 如果没有 icon ,上面已添加 title ,不用重复添加
      $button.append($(`<span class="title">${title}</span>`))
    }
    if (width) {
      $button.css('width', `${width}px`)
    }
    $button.attr('data-menu-key', key) // menu key
    this.$elem.append($button)

    // ----------------- 异步绑定事件 -----------------
    promiseResolveThen(() => this.init())
  }

  private init() {
    // 设置 button 属性
    this.setActive()
    this.setDisabled()

    // button click
    this.$button.on('click', e => {
      e.preventDefault()
      const editor = getEditorInstance(this)

      editor.hidePanelOrModal() // 隐藏当前的各种 panel

      if (this.disabled) return

      this.exec() // 执行 menu.exec
      this.onButtonClick() // 执行其他的逻辑
    })
  }

  /**
   * 执行 menu.exec
   */
  private exec() {
    const editor = getEditorInstance(this)
    const menu = this.menu
    const value = menu.getValue(editor)
    menu.exec(editor, value)
  }

  // 交给子类去扩展
  abstract onButtonClick(): void

  private setActive() {
    const editor = getEditorInstance(this)
    const { $button } = this
    const active = this.menu.isActive(editor)

    const className = 'active'
    if (active) {
      // 设置为 active
      $button.addClass(className)
    } else {
      // 取消 active
      $button.removeClass(className)
    }
  }

  private setDisabled() {
    const editor = getEditorInstance(this)
    const { $button } = this
    let disabled = this.menu.isDisabled(editor)

    if (editor.selection == null || editor.isDisabled()) {
      // 未选中,或者 readOnly ,强行设置为 disabled
      disabled = true
    }

    // 永远 enable
    if (this.menu.alwaysEnable) disabled = false

    const className = 'disabled'
    if (disabled) {
      // 设置为 disabled
      $button.addClass(className)
    } else {
      // 取消 disabled
      $button.removeClass(className)
    }

    this.disabled = disabled // 记录下来
  }

  changeMenuState() {
    this.setActive()
    this.setDisabled()
  }
}

export default BaseButton


================================================
FILE: packages/core/src/menus/bar-item/DropPanelButton.ts
================================================
/**
 * @description dropPanel button class
 * @author wangfupeng
 */

import { IDropPanelMenu } from '../interface'
import BaseButton from './BaseButton'
import DropPanel from '../panel-and-modal/DropPanel'
import { gen$downArrow } from '../helpers/helpers'
import { getEditorInstance } from './index'

class DropPanelButton extends BaseButton {
  private dropPanel: DropPanel | null = null
  menu: IDropPanelMenu

  constructor(key: string, menu: IDropPanelMenu, inGroup = false) {
    super(key, menu, inGroup)
    this.menu = menu

    if (menu.showDropPanel) {
      const $arrow = gen$downArrow()
      this.$button.append($arrow)
    }
  }

  // button 点击之后
  onButtonClick() {
    if (this.menu.showDropPanel) {
      this.handleDropPanel()
    }
  }

  // 显示/隐藏 dropPanel
  private handleDropPanel() {
    const menu = this.menu
    if (menu.getPanelContentElem == null) return
    const editor = getEditorInstance(this)

    if (this.dropPanel == null) {
      // 初次创建
      const dropPanel = new DropPanel(editor)
      const contentElem = menu.getPanelContentElem(editor)
      dropPanel.renderContent(contentElem)
      dropPanel.appendTo(this.$elem)
      dropPanel.show()

      // 记录下来,防止重复创建
      this.dropPanel = dropPanel
    } else {
      // 不是初次创建
      const dropPanel = this.dropPanel
      if (dropPanel.isShow) {
        // 当前处于显示状态,则隐藏
        dropPanel.hide()
      } else {
        // 当前未处于显示状态,则重新渲染内容 ,并显示
        const contentElem = menu.getPanelContentElem(editor)
        dropPanel.renderContent(contentElem)
        dropPanel.show()
      }
    }

    // 判断 dropPanel 的位置:在菜单右侧/左侧
    const dropPanel = this.dropPanel
    if (dropPanel.isShow) {
      const $menu = this.$elem
      const { left } = $menu.offset() // 菜单元素 left

      const $toolbar = $menu.parents('.w-e-bar')
      const { left: toolbarLeft } = $toolbar.offset() // toolbar left
      const toolbarWidth = $toolbar.width() // toolbar width
      const halfToolbarWidth = toolbarWidth / 2 // toolbar width 的 1/2

      if (left - toolbarLeft >= halfToolbarWidth) {
        // 菜单在 toolbar 的右半部分,则 dropPanel 要显示在菜单左侧
        dropPanel.$elem.css({
          left: 'none',
          right: '0',
        })
      } else {
        // 菜单在 toolbar 左半部分,则 dropPanel 显示在菜单右侧
        dropPanel.$elem.css({
          left: '0',
          right: 'none',
        })
      }
    }
  }
}

export default DropPanelButton


================================================
FILE: packages/core/src/menus/bar-item/GroupButton.ts
================================================
/**
 * @description group button class
 * @author wangfupeng
 */

import { gen$downArrow } from '../helpers/helpers'
import $, { Dom7Array } from '../../utils/dom'
import { IMenuGroup } from '../interface'
import { clearSvgStyle } from '../helpers/helpers'
import { IBarItem } from './index'
class GroupButton {
  readonly $elem: Dom7Array = $(`<div class="w-e-bar-item w-e-bar-item-group"></div>`)
  private readonly $container: Dom7Array = $('<div class="w-e-bar-item-menus-container"></div>')
  readonly $button = $(`<button type="button"></button>`)

  constructor(menu: IMenuGroup) {
    const { key, iconSvg, title /*, menuKeys = [] */ } = menu
    const { $elem, $button } = this

    // button
    if (iconSvg) {
      const $svg = $(iconSvg)
      clearSvgStyle($svg) // 清理 svg 样式(扩展的菜单,svg 是不可控的,所以要清理一下)
      $button.append($svg)
    } else {
      // 无 icon 则显示 title
      $button.text(title)
    }
    $button.attr('data-menu-key', key) // menu key

    const $arrow = gen$downArrow()
    $button.append($arrow)
    $elem.append($button)

    // menu container
    const { $container } = this
    $elem.append($container)

    // 监听 container 内容变化,以判断 $button 是否应该禁用
    const observer = this.createObserver()
    this.observe(observer)
  }

  appendBarItem(barItem: IBarItem) {
    const { $elem } = barItem
    this.$container.append($elem)
  }

  private observe(observer: MutationObserver) {
    const { $container } = this
    observer.observe($container[0], { childList: true, subtree: true, attributes: true })
  }

  private createObserver(): MutationObserver {
    const { $container, $button } = this

    const observer = new MutationObserver(() => {
      // 找出 container 下所有的 button
      const $buttons = $container.find('button')
      const buttonsLength = $buttons.length
      if (buttonsLength === 0) return

      // 找出所有 disabled 的 button
      let disabledButtonsLength = 0
      $buttons.each(btn => {
        const $btn = $(btn)
        if ($btn.hasClass('disabled')) {
          disabledButtonsLength++
        }
      })

      // 判断 group button 是否应该被禁用
      observer.disconnect()
      if (disabledButtonsLength === buttonsLength) {
        // 如果 container 所有的 button 都已经 disabled ,则当前的 GroupButton 也需要 disabled
        $button.addClass('disabled')
      } else {
        // 否则,取消当前的 GroupButton disabled
        $button.removeClass('disabled')
      }
      this.observe(observer)
    })

    return observer
  }
}

export default GroupButton


================================================
FILE: packages/core/src/menus/bar-item/ModalButton.ts
================================================
/**
 * @description modal button class
 * @author wangfupeng
 */

import { Element } from 'slate'
import { IModalMenu, IPositionStyle } from '../interface'
import BaseButton from './BaseButton'
import Modal from '../panel-and-modal/Modal'
import { getEditorInstance } from './index'
import { getPositionBySelection, getPositionByNode, correctPosition } from '../helpers/position'
import { DomEditor } from '../../editor/dom-editor'
import $ from '../../utils/dom'

class ModalButton extends BaseButton {
  private $body = $('body')
  private modal: Modal | null = null
  menu: IModalMenu

  constructor(key: string, menu: IModalMenu, inGroup = false) {
    super(key, menu, inGroup)
    this.menu = menu
  }

  onButtonClick() {
    if (this.menu.showModal) {
      this.handleModal()
    }
  }

  /**
   * 获取 modal 定位
   */
  private getPosition(): Partial<IPositionStyle> {
    const editor = getEditorInstance(this)
    const positionNode = this.menu.getModalPositionNode(editor)

    if (Element.isElement(positionNode)) {
      // elem node ,按 node 定位
      return getPositionByNode(editor, positionNode, 'modal')
    }

    // 其他情况(如 positionNode == null 或是 text node)则按选区定位
    return getPositionBySelection(editor)
  }

  // 显示/隐藏 modal
  private handleModal() {
    const editor = getEditorInstance(this)
    const menu = this.menu

    if (this.modal == null) {
      // 初次创建
      const modal = new Modal(editor, menu.modalWidth)
      this.renderAndShowModal(modal, true)

      // 记录下来,防止重复创建
      this.modal = modal
    } else {
      // 不是初次创建
      const modal = this.modal
      if (modal.isShow) {
        // 当前处于显示状态,则隐藏
        modal.hide()
      } else {
        // 当前未处于显示状态,则重新渲染内容 ,并显示
        this.renderAndShowModal(modal, false)
      }
    }
  }

  /**
   * 渲染并显示 modal
   * @param modal modal
   * @param firstTime 是否第一次显示 modal
   */
  private renderAndShowModal(modal: Modal, firstTime: boolean = false) {
    const editor = getEditorInstance(this)
    const menu = this.menu
    if (menu.getModalContentElem == null) return

    const textarea = DomEditor.getTextarea(editor)
    const toolbar = DomEditor.getToolbar(editor)
    const { modalAppendToBody } = toolbar?.getConfig() || {}

    const contentElem = menu.getModalContentElem(editor)
    modal.renderContent(contentElem)

    if (modalAppendToBody) {
      // appendTo body 时,用户自己设置 modal 定位
      modal.setStyle({ left: '0', right: '0' })
    } else {
      // 计算并设置 modal position
      const positionStyle = this.getPosition()
      modal.setStyle(positionStyle)
    }

    if (firstTime) {
      if (modalAppendToBody) {
        modal.appendTo(this.$body)
      } else {
        modal.appendTo(textarea.$textAreaContainer)
      }
    }

    modal.show()

    if (!modalAppendToBody) {
      // 修正 modal 定位,避免超出 textContainer 边界( appendTo body 则不用设置,用户自己设置 )
      correctPosition(editor, modal.$elem)
    }

    // 让 editor 失焦,否则点击 modal 触发 onChange 会导致 modal 隐藏
    setTimeout(() => {
      editor.blur()
    })
  }
}

export default ModalButton


================================================
FILE: packages/core/src/menus/bar-item/Select.ts
================================================
/**
 * @description select
 * @author wangfupeng
 */

import $, { Dom7Array } from '../../utils/dom'
import { IBarItem, getEditorInstance } from './index'
import { IOption, ISelectMenu } from '../interface'
import SelectList from '../panel-and-modal/SelectList'
import { gen$downArrow } from '../helpers/helpers'
import { promiseResolveThen } from '../../utils/util'
import { addTooltip } from './tooltip'

// 根据 option value 获取 text
function getOptionText(options: IOption[], value: string): string {
  const length = options.length
  let text = ''
  for (let i = 0; i < length; i++) {
    const opt = options[i]
    if (opt.value === value) {
      text = opt.text
      break
    }
  }
  return text
}

class BarItemSelect implements IBarItem {
  readonly $elem: Dom7Array = $(`<div class="w-e-bar-item"></div>`)
  private readonly $button: Dom7Array = $(`<button type="button" class="select-button"></button>`)
  menu: ISelectMenu
  private disabled = false
  private selectList: SelectList | null = null

  constructor(key: string, menu: ISelectMenu, inGroup = false) {
    // 验证 tag
    const { tag, title, width, iconSvg = '', hotkey = '' } = menu
    if (tag !== 'select') throw new Error(`Invalid tag '${tag}', expected 'select'`)

    // 初始化 dom
    const $button = this.$button
    if (width) {
      $button.css('width', `${width}px`)
    }
    $button.attr('data-menu-key', key) // menu key
    addTooltip($button, iconSvg, title, hotkey, inGroup) // 设置 tooltip
    this.$elem.append($button)

    this.menu = menu

    // 异步绑定事件
    promiseResolveThen(() => this.init())
  }

  private init() {
    // 设置 select 属性
    this.setSelectedValue()

    // select button click
    this.$button.on('click', (e: Event) => {
      e.preventDefault()
      const editor = getEditorInstance(this)
      editor.hidePanelOrModal() // 隐藏当前的各种 panel
      this.trigger()
    })
  }

  private trigger() {
    const editor = getEditorInstance(this)

    if (editor.isDisabled()) return
    if (this.disabled) return

    const menu = this.menu

    // 显示下拉列表
    if (this.selectList == null) {
      // 初次创建,渲染 list 并显示
      this.selectList = new SelectList(editor, menu.selectPanelWidth)
      const selectList = this.selectList
      const options = menu.getOptions(editor)
      selectList.renderList(options)
      selectList.appendTo(this.$elem)
      selectList.show()

      // 初次创建,绑定事件
      selectList.$elem.on('click', 'li', (e: Event) => {
        const { target } = e
        if (target == null) return

        e.preventDefault()
        const $li = $(target)
        const val = $li.attr('data-value')
        this.onChange(val)
      })
    } else {
      // 不是初次创建
      const selectList = this.selectList
      if (selectList.isShow) {
        // 当前处于显示状态,则隐藏
        selectList.hide()
      } else {
        // 当前未处于显示状态,则重新渲染 list ,并显示
        const options = menu.getOptions(editor) // 每次都要重新获取 options ,因为选中项可能会变化
        selectList.renderList(options)
        selectList.show()
      }
    }
  }

  private onChange(value: string) {
    const editor = getEditorInstance(this)
    const menu = this.menu
    menu.exec && menu.exec(editor, value)
  }

  private setSelectedValue() {
    const editor = getEditorInstance(this)
    const menu = this.menu
    const value = menu.getValue(editor)

    const options = menu.getOptions(editor)
    const optText = getOptionText(options, value.toString())

    const $button = this.$button
    const $downArrow = gen$downArrow() // 向下的箭头图标
    $button.empty()
    $button.text(optText)
    $button.append($downArrow)
  }

  private setDisabled() {
    const editor = getEditorInstance(this)
    const menu = this.menu
    let disabled = menu.isDisabled(editor)
    const $button = this.$button

    if (editor.selection == null || editor.isDisabled()) {
      // 未选中,或者 readOnly ,强行设置为 disabled
      disabled = true
    }

    const className = 'disabled'
    if (disabled) {
      // 设置为 disabled
      $button.addClass(className)
    } else {
      // 取消 disabled
      $button.removeClass(className)
    }

    this.disabled = disabled // 记录下来
  }

  changeMenuState() {
    this.setSelectedValue()
    this.setDisabled()
  }
}

export default BarItemSelect


================================================
FILE: packages/core/src/menus/bar-item/SimpleButton.ts
================================================
/**
 * @description button class
 * @author wangfupeng
 */

import { IButtonMenu } from '../interface'
import BaseButton from './BaseButton'

class SimpleButton extends BaseButton {
  constructor(key: string, menu: IButtonMenu, inGroup = false) {
    super(key, menu, inGroup)
  }
  onButtonClick() {
    // menu.exec 已经在 BaseButton 实现了
    // 所以,此处不用做任何逻辑
  }
}

export default SimpleButton


================================================
FILE: packages/core/src/menus/bar-item/index.ts
================================================
/**
 * @description bar item
 * @author wangfupeng
 */

import { Dom7Array } from '../../utils/dom'
import { IButtonMenu, ISelectMenu, IDropPanelMenu, IModalMenu, IMenuGroup } from '../interface'
import { IDomEditor } from '../../editor/interface'
import { BAR_ITEM_TO_EDITOR } from '../../utils/weak-maps'
import SimpleButton from './SimpleButton'
import DropPanelButton from './DropPanelButton'
import ModalButton from './ModalButton'
import Select from './Select'
import GroupButton from './GroupButton'

type MenuType = IButtonMenu | ISelectMenu | IDropPanelMenu | IModalMenu

export interface IBarItem {
  $elem: Dom7Array
  menu: MenuType
  changeMenuState: () => void
}

// menu -> barItem
const MENU_TO_BAR_ITEM = new WeakMap<MenuType, IBarItem>()

export function getEditorInstance(item: IBarItem): IDomEditor {
  const editor = BAR_ITEM_TO_EDITOR.get(item)
  if (editor == null) throw new Error('Can not get editor instance')
  return editor
}

/**
 * 创建 bar button/select
 * @param key menu key
 * @param menu menu
 * @param inGroup 在 groupButton 中
 */
export function createBarItem(key: string, menu: MenuType, inGroup: boolean = false): IBarItem {
  // 尝试从缓存获取
  let barItem = MENU_TO_BAR_ITEM.get(menu)
  if (barItem) return barItem

  // 缓存没有则创建
  const { tag } = menu
  if (tag === 'button') {
    // @ts-ignore
    const { showDropPanel, showModal } = menu
    if (showDropPanel) {
      barItem = new DropPanelButton(key, menu as IDropPanelMenu, inGroup)
    } else if (showModal) {
      barItem = new ModalButton(key, menu as IModalMenu, inGroup)
    } else {
      barItem = new SimpleButton(key, menu, inGroup)
    }
  }
  if (tag === 'select') {
    barItem = new Select(key, menu as ISelectMenu, inGroup)
  }

  if (barItem == null) throw new Error(`Invalid tag in menu ${JSON.stringify(menu)}`)

  // 记录缓存
  MENU_TO_BAR_ITEM.set(menu, barItem)

  return barItem
}

export function createBarItemGroup(menu: IMenuGroup): GroupButton {
  return new GroupButton(menu)
}


================================================
FILE: packages/core/src/menus/bar-item/tooltip.ts
================================================
/**
 * @description tooltip 功能
 * @author wangfupeng
 */

import { Dom7Array } from '../../utils/dom'
import { IS_APPLE } from '../../utils/ua'

export function addTooltip(
  $button: Dom7Array,
  iconSvg: string,
  title: string,
  hotkey: string,
  inGroup = false
) {
  if (!iconSvg) {
    // 没有 icon 直接显示 title ,不用 tooltip
    return
  }

  if (hotkey) {
    const fnKey = IS_APPLE ? 'cmd' : 'ctrl' // mac OS 转换为 cmd ,windows 转换为 ctrl
    hotkey = hotkey.replace('mod', fnKey)
  }

  if (inGroup) {
    // in groupButton ,tooltip 只显示 快捷键
    if (hotkey) {
      $button.attr('data-tooltip', hotkey)
      $button.addClass('w-e-menu-tooltip-v5')
      $button.addClass('tooltip-right') // tooltip 显示在右侧
    }
  } else {
    // 非 in groupButton ,正常实现 tooltip
    const tooltip = hotkey ? `${title}\n${hotkey}` : title
    $button.attr('data-tooltip', tooltip)
    $button.addClass('w-e-menu-tooltip-v5')
  }
}


================================================
FILE: packages/core/src/menus/helpers/helpers.ts
================================================
/**
 * @description menu helpers
 * @author wangfupeng
 */

import $, { Dom7Array } from '../../utils/dom'
import { SVG_DOWN_ARROW } from '../../constants/svg'

/**
 * 清理 svg 的样式
 * @param $elem svg elem
 */
export function clearSvgStyle($elem: Dom7Array) {
  $elem.removeAttr('width')
  $elem.removeAttr('height')
  $elem.removeAttr('fill')
  $elem.removeAttr('class')
  $elem.removeAttr('t')
  $elem.removeAttr('p-id')

  const children = $elem.children()
  if (children.length) {
    clearSvgStyle(children)
  }
}

/**
 * 向下箭头 icon svg
 */
export function gen$downArrow() {
  const $downArrow = $(SVG_DOWN_ARROW)
  return $downArrow
}

/**
 * bar item 分割线
 */
export function gen$barItemDivider() {
  return $('<div class="w-e-bar-divider"></div>')
}


================================================
FILE: packages/core/src/menus/helpers/position.ts
================================================
/**
 * @description menu position helpers
 * @author wangfupeng
 */

import { Node, Element } from 'slate'
import { Dom7Array, getFirstVoidChild } from '../../utils/dom'
import { IDomEditor } from '../../editor/interface'
import { DomEditor } from '../../editor/dom-editor'
import { NODE_TO_ELEMENT } from '../../utils/weak-maps'
import { IPositionStyle } from '../interface'
import { promiseResolveThen } from '../../utils/util'

/**
 * 获取 textContainer 尺寸和定位
 * @param editor editor
 */
export function getTextContainerRect(editor: IDomEditor): {
  top: number
  left: number
  width: number
  height: number
} | null {
  const textarea = DomEditor.getTextarea(editor)

  // 获取 textareaContainer
  const $textareaContainer = textarea.$textAreaContainer
  const width = $textareaContainer.width()
  const height = $textareaContainer.height()
  const { top, left } = $textareaContainer.offset()

  return { top, left, width, height }
}

/**
 * 根据选区,计算定位(用于 modal hoverbar)
 * @param editor editor
 */
export function getPositionBySelection(editor: IDomEditor): Partial<IPositionStyle> {
  // 默认情况下 { top: 0, left: 0 }
  const defaultStyle = { top: '0', left: '0' }

  const { selection } = editor
  if (selection == null) return defaultStyle // 默认 position

  // 获取 textContainer rect
  const containerRect = getTextContainerRect(editor)
  if (containerRect == null) return defaultStyle // 默认 position
  const {
    top: containerTop,
    left: containerLeft,
    width: containerWidth,
    height: containerHeight,
  } = containerRect

  // 获取当前选区的 rect
  const range = DomEditor.toDOMRange(editor, selection)
  const rangeRect = range.getClientRects()[0]
  if (rangeRect == null) return defaultStyle // 默认 position
  const { width: rangeWidth, height: rangeHeight, top: rangeTop, left: rangeLeft } = rangeRect

  // 存储计算结构
  const positionStyle: Partial<IPositionStyle> = {}

  // 获取 选区 top left 和 container top left 的差值(< 0 则使用 0)
  let relativeTop = rangeTop - containerTop
  let relativeLeft = rangeLeft - containerLeft

  // 判断水平位置: modal/bar 显示在选区左侧,还是右侧?
  if (relativeLeft > containerWidth / 2) {
    // 选区 left 大于 containerWidth/2 (选区在 container 的右侧),则 modal/bar 显示在选区左侧
    let r = containerWidth - relativeLeft
    positionStyle.right = `${r + 5}px` // 5px 间隔
  } else {
    // 否则(选区在 container 的左侧),modal/bar 显示在选区右侧
    positionStyle.left = `${relativeLeft + 5}px` // 5px 间隔
  }

  // 判断垂直的位置: modal/bar 显示在选区上面,还是下面?
  if (relativeTop > containerHeight / 2) {
    // 选区 top  > containerHeight/2 (选区在 container 的下半部分),则 modal/bar 显示在选区的上面
    let b = containerHeight - relativeTop
    positionStyle.bottom = `${b + 5}px` // 5px 间隔
  } else {
    // 否则(选区在 container 的上半部分),则 modal/bar 显示在选区的下面
    let t = relativeTop + rangeHeight
    if (t < 0) t = 0
    positionStyle.top = `${t + 5}px` // 5px 间隔
  }

  return positionStyle
}

/**
 * 根据 node ,计算定位(用于 modal hoverbar)
 * @param editor editor
 * @param node slate node
 * @param type 'modal'/'bar'
 */
export function getPositionByNode(
  editor: IDomEditor,
  node: Node,
  type: string = 'modal'
): Partial<IPositionStyle> {
  // 默认情况下 { top: 0, left: 0 }
  const defaultStyle = { top: '0', left: '0' }

  const { selection } = editor
  if (selection == null) return defaultStyle // 默认 position

  // 根据 node 获取 elem
  const isVoidElem = Element.isElement(node) && editor.isVoid(node)
  const isInlineElem = Element.isElement(node) && editor.isInline(node)
  const elem = NODE_TO_ELEMENT.get(node)
  if (elem == null) return defaultStyle // 默认 position
  let {
    top: elemTop,
    left: elemLeft,
    height: elemHeight,
    width: elemWidth,
  } = elem.getBoundingClientRect()
  if (isVoidElem) {
    // void node ,重新计算 top 和 height
    const voidElem = getFirstVoidChild(elem)
    if (voidElem != null) {
      const { top, height } = voidElem.getBoundingClientRect()
      elemTop = top
      elemHeight = height
    }
  }

  // 获取 textContainer rect
  const containerRect = getTextContainerRect(editor)
  if (containerRect == null) return defaultStyle // 默认 position
  const {
    top: containerTop,
    left: containerLeft,
    width: containerWidth,
    height: containerHeight,
  } = containerRect

  // 存储计算结构
  const positionStyle: Partial<IPositionStyle> = {}

  // 获取 elem top left 和 container top left 的差值(< 0 则使用 0)
  let relativeTop = elemTop - containerTop
  let relativeLeft = elemLeft - containerLeft

  if (type === 'bar') {
    // bar - 1. left 对齐 elem.left ;2. 尽量显示在 elem 上方
    positionStyle.left = `${relativeLeft}px`
    if (relativeTop > 40) {
      // top > 40 则显示在上方
      positionStyle.bottom = `${containerHeight - relativeTop + 5}px` // 5px 间隙
    } else {
      // 否则,显示在下方
      positionStyle.top = `${relativeTop + elemHeight + 5}px` // 5px 间隙
    }

    return positionStyle
  }

  if (type === 'modal') {
    // modal - 1. top 和 elem 需要计算,尽量不遮挡 elem

    // 水平
    if (!isVoidElem) {
      // 非 void node - left 和 elem left 对齐
      positionStyle.left = `${relativeLeft}px`
    } else {
      if (isInlineElem) {
        // inline void node 需要计算
        if (relativeLeft > (containerWidth - elemWidth) / 2) {
          // elem 在 container 的右侧,则 modal 显示在 elem 左侧
          positionStyle.right = `${containerWidth - relativeLeft + 5}px`
        } else {
          // 否则 elem 在 container 左侧,则 modal 显示在 elem 右侧
          positionStyle.left = `${relativeLeft + elemWidth + 5}px`
        }
      } else {
        // block void node 水平靠左即可
        positionStyle.left = `20px`
      }
    }

    // 垂直
    if (isVoidElem) {
      // void node - top 和 elem top 对齐
      let t = relativeTop
      if (t < 0) t = 0 // top 不能小于 0
      positionStyle.top = `${t}px`
    } else {
      // 非 void node ,计算 top
      if (relativeTop > (containerHeight - elemHeight) / 2) {
        // elem 在 container 的下半部分,则 modal 显示在 elem 上方
        positionStyle.bottom = `${containerHeight - relativeTop + 5}px`
      } else {
        // elem 在 container 的上半部分,则 modal 显示在 elem 下方
        let t = relativeTop + elemHeight
        if (t < 0) t = 0
        positionStyle.top = `${t + 5}px`
      }
    }

    return positionStyle
  }

  throw new Error(`type '${type}' is invalid`)
}

/**
 * 异步修正 position ,不能超出 textContainer 边界
 * @param editor editor
 * @param $positionElem modal/bar
 */
export function correctPosition(editor: IDomEditor, $positionElem: Dom7Array) {
  // 异步,否则 DOM 尚未渲染
  promiseResolveThen(() => {
    // 获取 textContainer rect
    const containerRect = getTextContainerRect(editor)
    if (containerRect == null) return
    const {
      top: containerTop,
      left: containerLeft,
      width: containerWidth,
      height: containerHeight,
    } = containerRect

    // 获取 modal bar 的 rect
    const { top: positionElemTop, left: positionElemLeft } = $positionElem.offset()
    const positionElemWidth = $positionElem.width()
    const positionElemHeight = $positionElem.height()
    const relativeTop = positionElemTop - containerTop
    const relativeLeft = positionElemLeft - containerLeft

    // 获取 modal bar 设置的 style
    const styleStr = $positionElem.attr('style')

    if (styleStr.indexOf('top') >= 0) {
      // 设置了 top ,则有可能超过 textContainer 的下边界
      const d = relativeTop + positionElemHeight - containerHeight
      if (d > 0) {
        // 已超过 textContainer 的下边界,则上移
        const curTopStr = $positionElem.css('top')
        const curTop = parseInt(curTopStr.toString())
        let newTop = curTop - d
        if (newTop < 0) newTop = 0 // 不能超过 textContainer 上边界
        $positionElem.css('top', `${newTop}px`)
      }
    }

    if (styleStr.indexOf('bottom') >= 0) {
      // 设置了 bottom ,则有可能超过 textContainer 的上边界
      if (positionElemTop < 0) {
        // 已超出了上边界
        const curBottomStr = $positionElem.css('bottom')
        const curBottom = parseInt(curBottomStr.toString())
        const newBottom = curBottom - Math.abs(positionElemTop) // 保证上边界和 textContainer 对齐即可,下边界不管
        $positionElem.css('bottom', `${newBottom}px`)
      }
    }

    if (styleStr.indexOf('left') >= 0) {
      // 设置了 left ,则有可能超过 textContainer 的右边界
      const d = relativeLeft + positionElemWidth - containerWidth
      if (d > 0) {
        // 已超过 textContainer 的右边界,需左移
        const curLeftStr = $positionElem.css('left')
        const curLeft = parseInt(curLeftStr.toString())
        let newLeft = curLeft - d
        if (newLeft < 0) newLeft = 0 // 不能超过 textContainer 左边界
        $positionElem.css('left', `${newLeft}px`)
      }
    }

    if (styleStr.indexOf('right') >= 0) {
      // 设置了 right ,则有可能超过 textContainer 的左边界
      if (positionElemLeft < 0) {
        // 已超出了左边界
        const curRightStr = $positionElem.css('right')
        const curRight = parseInt(curRightStr.toString())
        const newRight = curRight - Math.abs(positionElemLeft) // 保证左边界和 textContainer 对齐即可,右边界不管
        $positionElem.css('right', `${newRight}px`)
      }
    }
  })
}


================================================
FILE: packages/core/src/menus/index.ts
================================================
/**
 * @description menus entry
 * @author wangfupeng
 */

import Toolbar from './bar/Toolbar'

// 注册
export { registerMenu } from './register'

// menu 相关接口
export {
  IButtonMenu,
  ISelectMenu,
  IDropPanelMenu,
  IModalMenu,
  IRegisterMenuConf,
  IOption,
} from './interface'

// 输出 modal 相关方法
export {
  genModalInputElems,
  genModalButtonElems,
  genModalTextareaElems,
} from './panel-and-modal/Modal'

export { Toolbar }


================================================
FILE: packages/core/src/menus/interface.ts
================================================
/**
 * @description menu interface
 * @author wangfupeng
 */

import { Node } from 'slate'
import { IDomEditor } from '../editor/interface'
import { DOMElement } from '../utils/dom'

export interface IMenuGroup {
  key: string
  title: string
  iconSvg?: string
  menuKeys: string[]
}

export interface IPositionStyle {
  top: string
  left: string
  right: string
  bottom: string
}

export interface IOption {
  value: string
  text: string
  selected?: boolean
  styleForRenderMenuList?: { [key: string]: string } // 渲染菜单 list 时的样式
}

interface IBaseMenu {
  readonly title: string
  readonly iconSvg?: string
  readonly hotkey?: string // 快捷键,使用 https://www.npmjs.com/package/is-hotkey
  readonly alwaysEnable?: boolean // 永远不 disabled ,如“全屏”

  readonly tag: string // 'button' | 'select'
  readonly width?: number // 设置 button 宽度

  getValue: (editor: IDomEditor) => string | boolean // 获取菜单相关的 val 。如是否加粗、颜色值、h1/h2/h3 等
  isActive: (editor: IDomEditor) => boolean // 是否激活菜单,如选区处于加粗文本时,激活 bold
  isDisabled: (editor: IDomEditor) => boolean // 是否禁用菜单,如选区处于 code-block 时,禁用 bold 等样式操作

  exec: (editor: IDomEditor, value: string | boolean) => void // button click 或 select change 时触发
}

export interface IButtonMenu extends IBaseMenu {
  /* 其他属性 */
}

export interface ISelectMenu extends IBaseMenu {
  readonly selectPanelWidth?: number
  getOptions: (editor: IDomEditor) => IOption[] // select -> options
}

export interface IDropPanelMenu extends IBaseMenu {
  readonly showDropPanel: boolean // 点击 'button' 显示 dropPanel
  getPanelContentElem: (editor: IDomEditor) => DOMElement // showDropPanel 情况下,获取 content elem
}

export interface IModalMenu extends IBaseMenu {
  readonly showModal: boolean // 点击 'button' 显示 modal
  readonly modalWidth: number
  getModalContentElem: (editor: IDomEditor) => DOMElement // showModal 情况下,获取 content elem
  getModalPositionNode: (editor: IDomEditor) => Node | null // 获取 modal 定位的 node ,null 即依据选区定位
}

export type MenuFactoryType = () => IButtonMenu | ISelectMenu | IDropPanelMenu | IModalMenu

export interface IRegisterMenuConf {
  key: string
  factory: MenuFactoryType
  config?: { [key: string]: any }
}


================================================
FILE: packages/core/src/menus/panel-and-modal/BaseClass.ts
================================================
/**
 * @description panel modal baseClass
 * @author wangfupeng
 */

import { IDomEditor } from '../../editor/interface'
import { Dom7Array, DOMElement } from '../../utils/dom'
import { EDITOR_TO_PANEL_AND_MODAL, PANEL_OR_MODAL_TO_EDITOR } from '../../utils/weak-maps'

abstract class PanelAndModal {
  abstract readonly type: string
  abstract readonly $elem: Dom7Array
  isShow: boolean = false
  private showTime: number = 0 // 显示时的时间戳

  constructor(editor: IDomEditor) {
    this.record(editor)
  }

  /**
   * 记录下来,以便隐藏,API editor.hidePanelOrModal
   */
  private record(editor: IDomEditor) {
    let set = EDITOR_TO_PANEL_AND_MODAL.get(editor)
    if (set == null) {
      set = new Set()
      EDITOR_TO_PANEL_AND_MODAL.set(editor, set)
    }
    set.add(this)

    PANEL_OR_MODAL_TO_EDITOR.set(this, editor)
  }

  /**
   * 除了 content 之外的其他自己要增加的 elem
   */
  abstract genSelfElem(): Dom7Array | null

  renderContent(contentElem: DOMElement) {
    const { $elem } = this
    $elem.empty() // 先清空,再填充内容
    $elem.append(contentElem)

    // 添加自己额外的 elem
    const $selfElem = this.genSelfElem()
    if ($selfElem) {
      $elem.append($selfElem)
    }
  }

  appendTo($menuElem: Dom7Array) {
    const { $elem } = this
    $menuElem.append($elem)
  }

  show() {
    if (this.isShow) return
    this.showTime = Date.now()

    const { $elem } = this
    $elem.show()
    this.isShow = true

    // 触发事件
    const editor = PANEL_OR_MODAL_TO_EDITOR.get(this)
    if (editor) editor.emit('modalOrPanelShow', this)
  }

  hide() {
    if (!this.isShow) return

    const now = Date.now()
    if (now - this.showTime < 200) {
      // 刚显示的,不要立刻隐藏(避免频繁触发 show/hide )
      return
    }

    const { $elem } = this
    $elem.hide()
    this.isShow = false

    // 触发事件
    const editor = PANEL_OR_MODAL_TO_EDITOR.get(this)
    if (editor) editor.emit('modalOrPanelHide')
  }
}

export default PanelAndModal


================================================
FILE: packages/core/src/menus/panel-and-modal/DropPanel.ts
================================================
/**
 * @description dropPanel class
 * @author wangfupeng
 */

import { IDomEditor } from '../../editor/interface'
import $, { Dom7Array } from '../../utils/dom'
import PanelAndModal from './BaseClass'

class DropPanel extends PanelAndModal {
  type = 'dropPanel'
  readonly $elem: Dom7Array = $(`<div class="w-e-drop-panel"></div>`)

  constructor(editor: IDomEditor) {
    super(editor)
  }

  genSelfElem(): Dom7Array | null {
    return null
  }
}

export default DropPanel


================================================
FILE: packages/core/src/menus/panel-and-modal/Modal.ts
================================================
/**
 * @description modal class
 * @author wangfupeng
 */

import $, { Dom7Array, DOMElement } from '../../utils/dom'
import { IPositionStyle } from '../interface'
import PanelAndModal from './BaseClass'
import { IDomEditor } from '../../editor/interface'
// import { DomEditor } from '../../editor/dom-editor'
import { SVG_CLOSE } from '../../constants/svg'
import { PANEL_OR_MODAL_TO_EDITOR } from '../../utils/weak-maps'

class Modal extends PanelAndModal {
  type = 'modal'
  readonly $elem: Dom7Array = $(`<div class="w-e-modal"></div>`)
  private width: number = 0

  constructor(editor: IDomEditor, width: number = 0) {
    super(editor)
    if (width) this.width = width

    const { $elem } = this

    // mousedown 阻止冒泡,因为在 $textContainer 通过 mousedown 隐藏 panel & modal
    $elem.on('click', e => e.stopPropagation())

    // esc 关闭 modal
    $elem.on('keyup', e => {
      const event = e as KeyboardEvent
      if (event.code === 'Escape') {
        this.hide()
        editor.restoreSelection() // 还原选区
      }
    })
  }

  /**
   * 生成要添加到 modal $elem 的元素
   * 【注意】不要直接 append 到 modal $elem ,因为它每次都会清空 html('')
   */
  genSelfElem(): Dom7Array | null {
    // 关闭按钮
    const $closeButton = $(`<span class="btn-close">${SVG_CLOSE}</span>`)
    const editor = PANEL_OR_MODAL_TO_EDITOR.get(this)

    $closeButton.on('click', () => {
      this.hide()
      editor?.restoreSelection()
    })
    return $closeButton
  }

  setStyle(positionStyle: Partial<IPositionStyle>) {
    const { width, $elem } = this

    $elem.attr('style', '') // 先清空 style ,再重新设置

    if (width) $elem.css('width', `${width}px`)
    $elem.css(positionStyle)
  }
}

export default Modal

// ---------------------------------- 分割线 ----------------------------------

/**
 * 生成 modal input elems
 * @param labelText label text
 * @param inputId input dom id
 * @param placeholder input placeholder
 * @returns [$container, $input]
 */
export function genModalInputElems(
  labelText: string,
  inputId: string,
  placeholder?: string
): DOMElement[] {
  const $container = $('<label class="babel-container"></label>')
  $container.append(`<span>${labelText}</span>`)
  const $input = $(`<input type="text" id="${inputId}" placeholder="${placeholder || ''}">`)
  $container.append($input)

  return [$container[0], $input[0]]
}

/**
 * 生成 modal textarea elems
 * @param labelText label text
 * @param textareaId input dom id
 * @param placeholder input placeholder
 * @returns [$container, $textarea]
 */
export function genModalTextareaElems(
  labelText: string,
  textareaId: string,
  placeholder?: string
): DOMElement[] {
  const $container = $('<label class="babel-container"></label>')
  $container.append(`<span>${labelText}</span>`)
  const $textarea = $(
    `<textarea type="text" id="${textareaId}" placeholder="${placeholder || ''}"></textarea>`
  )
  $container.append($textarea)

  return [$container[0], $textarea[0]]
}

/**
 * 生成 modal button elems
 * @param buttonId button dom id
 * @param buttonText button text
 * @returns [ $container, $button ]
 */
export function genModalButtonElems(buttonId: string, buttonText: string): DOMElement[] {
  const $buttonContainer = $('<div class="button-container"></div>')
  const $button = $(`<button type="button" id="${buttonId}">${buttonText}</button>`)
  $buttonContainer.append($button)

  return [$buttonContainer[0], $button[0]]
}


================================================
FILE: packages/core/src/menus/panel-and-modal/SelectList.ts
================================================
/**
 * @description SelectList class
 * @author wangfupeng
 */

import $, { Dom7Array } from '../../utils/dom'
import { IOption } from '../interface'
import PanelAndModal from './BaseClass'
import { IDomEditor } from '../../editor/interface'
import { SVG_CHECK_MARK } from '../../constants/svg'

// “对号”icon
function gen$SelectedIcon() {
  return $(SVG_CHECK_MARK)
}

class SelectList extends PanelAndModal {
  type = 'selectList'
  readonly $elem: Dom7Array = $(`<div class="w-e-select-list"></div>`)

  constructor(editor: IDomEditor, width?: number) {
    super(editor)

    if (width) {
      this.$elem.css('width', `${width}px`)
    }

    this.$elem.on('click', (e: Event) => {
      // selectList 如有滚动条,可能会点击拖拽,参考 https://github.com/wangeditor-team/wangEditor-v5/issues/325
      // 此时需要阻止冒泡,因为在 $container.on('mousedown', () => editor.hidePanelOrModal()) ,$container 就是 `.w-e-text-container`
      e.stopPropagation()
    })
  }

  /**
   * 渲染 list
   * @param options select options
   */
  renderList(options: IOption[]) {
    const $elem = this.$elem
    $elem.empty() // 先清空内容,再重新渲染

    const $list = $(`<ul></ul>`)
    options.forEach(opt => {
      const { value, text, selected, styleForRenderMenuList } = opt
      const $li = $(`<li data-value="${value}"></li>`) // 【注意】必须用 <li> 必须用 data-value!!!

      if (styleForRenderMenuList) {
        $li.css(styleForRenderMenuList)
      }

      if (selected) {
        const $selectedIcon = gen$SelectedIcon()
        $li.append($selectedIcon)
        $li.addClass('selected')
      }

      $li.append($(`<span data-value="${value}">${text}</span>`))
      $li.attr('title', text)
      $list.append($li)
    })
    $elem.append($list)
  }

  genSelfElem(): Dom7Array | null {
    return null
  }
}

export default SelectList


================================================
FILE: packages/core/src/menus/register.ts
================================================
/**
 * @description register menu
 * @author wangfupeng
 */

import { MenuFactoryType, IRegisterMenuConf } from './interface'
import { registerGlobalMenuConf } from '../config/register'

// menu item 的工厂函数 - 集合
export const MENU_ITEM_FACTORIES: {
  [key: string]: MenuFactoryType
} = {}

/**
 * 注册菜单配置
 * @param registerMenuConf { key, factory, config } ,各个 menu key 不能重复
 * @param customConfig 自定义 menu config
 */
export function registerMenu(
  registerMenuConf: IRegisterMenuConf,
  customConfig?: { [key: string]: any }
) {
  const { key, factory, config } = registerMenuConf

  // 合并 config
  const newConfig = { ...config, ...(customConfig || {}) }

  // 注册 menu
  if (MENU_ITEM_FACTORIES[key] != null) {
    throw new Error(`Duplicated key '${key}' in menu items`)
  }
  MENU_ITEM_FACTORIES[key] = factory

  // 将 config 保存到全局
  registerGlobalMenuConf(key, newConfig)
}


================================================
FILE: packages/core/src/parse-html/README.md
================================================
# parse html

把 html 转换为 JSON content


================================================
FILE: packages/core/src/parse-html/helper.ts
================================================
/**
 * @description parse-html helper fns
 * @author wangfupeng
 */

const REPLACE_SPACE_160_REG = new RegExp(String.fromCharCode(160), 'g')

/**
 * 把 charCode 160 的空格(`&nbsp` 转换的),替换为 charCode 32 的空格(JS 默认的)
 * @param str str
 * @returns str
 */
export function replaceSpace160(str: string): string {
  const res = str.replace(REPLACE_SPACE_160_REG, ' ')
  return res
}


================================================
FILE: packages/core/src/parse-html/index.ts
================================================
/**
 * @description parse html
 * @author wangfupeng
 */

import { DOMElement } from '../utils/dom'
import { Element as SlateElement, Descendant } from 'slate'
import { IDomEditor } from '../editor/interface'

// 常见的 text tag
export const TEXT_TAGS = [
  'span',
  'b',
  'strong',
  'i',
  'em',
  's',
  'strike',
  'u',
  'font',
  'sub',
  'sup',
]

// ------------------------------------ pre-parse html ------------------------------------
export type PreParseHtmlFnType = ($node: DOMElement) => DOMElement

export interface IPreParseHtmlConf {
  selector: string // css 选择器,如 `p` `div[data-type="xxx"]`
  preParseHtml: PreParseHtmlFnType
}

export const PRE_PARSE_HTML_CONF_LIST: IPreParseHtmlConf[] = []

/**
 * 注册 pre-parse html 配置
 * @param conf pre-parse html conf
 */
export function registerPreParseHtmlConf(conf: IPreParseHtmlConf) {
  PRE_PARSE_HTML_CONF_LIST.push(conf)
}

// ------------------------------------ parse style html ------------------------------------

export type ParseStyleHtmlFnType = (
  $node: DOMElement,
  node: Descendant,
  editor: IDomEditor
) => Descendant

export const PARSE_STYLE_HTML_FN_LIST: ParseStyleHtmlFnType[] = []

/**
 * 注册 parseStyleHtml 函数
 * @param fn parse style html 的函数
 */
export function registerParseStyleHtmlHandler(fn: ParseStyleHtmlFnType) {
  PARSE_STYLE_HTML_FN_LIST.push(fn)
}

// ------------------------------------ parse elem html ------------------------------------

export type ParseElemHtmlFnType = (
  $elem: DOMElement,
  children: Descendant[],
  editor: IDomEditor
) => SlateElement | SlateElement[]

export const PARSE_ELEM_HTML_CONF: {
  [key: string]: ParseElemHtmlFnType // key 是 css 选择器,如 `p` `div[data-type="xxx"]`
} = {}

export interface IParseElemHtmlConf {
  selector: string
  parseElemHtml: ParseElemHtmlFnType
}

export function registerParseElemHtmlConf(conf: IParseElemHtmlConf) {
  const { selector, parseElemHtml } = conf
  PARSE_ELEM_HTML_CONF[selector] = parseElemHtml
}


================================================
FILE: packages/core/src/parse-html/parse-common-elem-html.ts
================================================
/**
 * @description parse elem html
 * @author wangfupeng
 */

import $, { Dom7Array } from 'dom7'
import { Editor, Element, Descendant, Text } from 'slate'
import { IDomEditor } from '../editor/interface'
import parseElemHtml from './parse-elem-html'
import { PARSE_ELEM_HTML_CONF, ParseElemHtmlFnType, PARSE_STYLE_HTML_FN_LIST } from './index'
import { NodeType, DOMElement } from '../utils/dom'
import { replaceSpace160 } from './helper'

/**
 * 往 children 最后一个 item(如果是 text node) 插入文字
 * @param children children
 * @param str str
 * @returns 是否插入成功
 */
function tryInsertTextToChildrenLastItem(children: Descendant[], str: string): boolean {
  const len = children.length
  if (len) {
    const lastItem = children[len - 1]
    if (Text.isText(lastItem)) {
      const keys = Object.keys(lastItem)
      if (keys.length === 1 && keys[0] === 'text') {
        // lastItem 必须是纯文本,没有 marks
        lastItem.text = lastItem.text + str
        return true
      }
    }
  }
  return false
}

/**
 * 生成 slate node children
 * @param $elem $elem
 * @param editor editor
 */
function genChildren($elem: Dom7Array, editor: IDomEditor): Descendant[] {
  const children: Descendant[] = []

  // void node( html 中编辑的,如 video 的 html 中会有 data-w-e-is-void 属性 ),不需要生成 children
  const isVoid = $elem.attr('data-w-e-is-void') != null
  if (isVoid) {
    return children
  }

  const childNodes = $elem[0].childNodes

  // 处理空行(只有一个 child ,是 <br>)
  if (childNodes.length === 1) {
    if (childNodes[0].nodeName === 'BR') {
      children.push({ text: '' })
      return children // 直接返回
    }
  }

  // 遍历 DOM 子节点,生成 slate elem node children
  childNodes.forEach(child => {
    if (child.nodeType === NodeType.ELEMENT_NODE) {
      // <br> ,则往 children 最后一个元素(如果是 text )追加 `\n`
      if (child.nodeName === 'BR') {
        // 尝试把 text 插入到最后一个 children
        const res = tryInsertTextToChildrenLastItem(children, '\n')
        if (!res) {
          // 若插入失败,则新建 item
          children.push({ text: '\n' })
        }
        return
      }

      // 其他 elem
      const $child = $(child)
      const parsedRes = parseElemHtml($child, editor)
      if (Array.isArray(parsedRes)) {
        parsedRes.forEach(el => children.push(el))
      } else {
        children.push(parsedRes)
      }
      return
    }
    if (child.nodeType === NodeType.TEXT_NODE) {
      // text
      let text = child.textContent || ''
      if (text.trim() === '' && text.indexOf('\n') >= 0) {
        // 有换行,但无实际内容
        return
      }

      if (text) {
        // 把 charCode 160 的空格(`&nbsp` 转换的),替换为 charCode 32 的空格(JS 默认的)
        text = replaceSpace160(text)

        // 尝试把 text 插入到最后一个 children
        const res = tryInsertTextToChildrenLastItem(children, text)
        if (!res) {
          // 若插入失败,则新建 item
          children.push({ text })
        }
      }
      return
    }
  })
  return children
}

/**
 * 默认的 parseElemHtml ,直接转换为 paragraph
 * @param elem elem
 * @param children children
 */
function defaultParser(elem: DOMElement, children: Descendant[], editor: IDomEditor): Element {
  return {
    type: 'paragraph',
    children: [{ text: $(elem).text().replace(/\s+/gm, ' ') }],
  }
}

/**
 * 获取当前 html 元素的 parseElemHtml 函数
 * @param $elem $elem
 */
function getParser($elem: Dom7Array): ParseElemHtmlFnType {
  for (let selector in PARSE_ELEM_HTML_CONF) {
    if ($elem[0].matches(selector)) {
      return PARSE_ELEM_HTML_CONF[selector]
    }
  }
  return defaultParser
}

/**
 * 处理普通 DOM elem html ,非 span font 等文本 elem
 * @param $elem $elem
 * @param editor editor
 * @returns slate element
 */
function parseCommonElemHtml($elem: Dom7Array, editor: IDomEditor): Element[] {
  const children = genChildren($elem, editor)

  // parse
  const parser = getParser($elem)
  let parsedRes = parser($elem[0], children, editor)

  if (!Array.isArray(parsedRes)) parsedRes = [parsedRes] // 临时处理为数组

  parsedRes.forEach(elem => {
    const isVoid = Editor.isVoid(editor, elem)
    if (!isVoid) {
      // 非 void ,如果没有 children ,则取纯文本
      if (children.length === 0) {
        elem.children = [{ text: $elem.text().replace(/\s+/gm, ' ') }]
      }

      // 处理 style
      PARSE_STYLE_HTML_FN_LIST.forEach(fn => {
        elem = fn($elem[0], elem, editor) as Element
      })
    }
  })

  return parsedRes
}

export default parseCommonElemHtml


================================================
FILE: packages/core/src/parse-html/parse-elem-html.ts
================================================
/**
 * @description parse node html
 * @author wangfupeng
 */

import $, { Dom7Array } from 'dom7'
import { Descendant } from 'slate'
import { IDomEditor } from '../editor/interface'
import parseCommonElemHtml from './parse-common-elem-html'
import parseTextElemHtml from './parse-text-elem-html'
import { getTagName } from '../utils/dom'
import { PRE_PARSE_HTML_CONF_LIST, TEXT_TAGS } from '../index'

/**
 * 处理 DOM Elem html
 * @param $elem $elem
 * @param editor editor
 * @returns slate Descendant
 */
function parseElemHtml($elem: Dom7Array, editor: IDomEditor): Descendant | Descendant[] {
  // pre-parse
  PRE_PARSE_HTML_CONF_LIST.forEach(conf => {
    const { selector, preParseHtml } = conf
    if ($elem[0].matches(selector)) {
      $elem = $(preParseHtml($elem[0]))
    }
  })

  const tagName = getTagName($elem)

  // <span> 判断有没有 data-w-e-type 属性。有则是 elem ,没有则是 text
  if (tagName === 'span') {
    if ($elem.attr('data-w-e-type')) {
      return parseCommonElemHtml($elem, editor)
    } else {
      return parseTextElemHtml($elem, editor)
    }
  }

  // <code> 特殊处理
  if (tagName === 'code') {
    const parentTagName = getTagName($elem.parent())
    if (parentTagName === 'pre') {
      // <code> 在 <pre> 内,则是 elem
      return parseCommonElemHtml($elem, editor)
    } else {
      // <code> 不在 <pre> 内,则是 text
      return parseTextElemHtml($elem, editor)
    }
  }

  // 非 <code> ,正常处理
  if (TEXT_TAGS.includes(tagName)) {
    // text node
    return parseTextElemHtml($elem, editor)
  } else {
    // elem node
    return parseCommonElemHtml($elem, editor)
  }
}

export default parseElemHtml


================================================
FILE: packages/core/src/parse-html/parse-text-elem-html.ts
================================================
/**
 * @description parse text html
 * @author wangfupeng
 */

import { Dom7Array } from 'dom7'
import { Text } from 'slate'
import { IDomEditor } from '../editor/interface'
import { PARSE_STYLE_HTML_FN_LIST } from './index'
import { deReplaceHtmlSpecialSymbols } from '../utils/util'
import { replaceSpace160 } from './helper'

/**
 * 处理 text elem ,如 <span> <strong> <em> 等(并不是 DOM Text Node)
 * @param $text $text
 * @param editor editor
 * @returns slate text
 */
function parseTextElemHtml($text: Dom7Array, editor: IDomEditor): Text {
  if ($text.parents('pre').length === 0) {
    // 不在 <pre> 内部
    // 1. 替换无用空格、换行; 2. 将 <br> 替换为 `\n`
    $text[0].innerHTML = $text[0].innerHTML.replace(/\s+/gm, ' ').replace(/<br>/g, '\n')
  }

  // 用 textContent ,不能用 .text() 。后者无法识别 text 开头和末尾的 &nbsp;
  let text = $text[0].textContent || ''

  //【翻转】替换 html 特殊字符,如 &lt; 替换为 <
  text = deReplaceHtmlSpecialSymbols(text)

  // 把 charCode 160 的空格(`&nbsp` 转换的),替换为 charCode 32 的空格(JS 默认的)
  text = replaceSpace160(text)

  // 生成 text node
  let textNode = { text }

  // 处理 style
  PARSE_STYLE_HTML_FN_LIST.forEach(fn => {
    textNode = fn($text[0], textNode, editor) as Text
  })

  return textNode
}

export default parseTextElemHtml


================================================
FILE: packages/core/src/render/README.md
================================================
# render

把 JSON content 转换为 vdom


================================================
FILE: packages/core/src/render/element/getRenderElem.tsx
================================================
/**
 * @description 获取 elem render 函数
 * @author wangfupeng
 */

import { Element as SlateElement } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { IDomEditor } from '../../editor/interface'
import { RENDER_ELEM_CONF, RenderElemFnType } from '../index'

/**
 * 默认的 render elem
 * @param elemNode elem
 * @param editor editor
 * @param children children vnode
 * @returns vnode
 */
function defaultRender(
  elemNode: SlateElement,
  children: VNode[] | null,
  editor: IDomEditor
): VNode {
  const Tag = editor.isInline(elemNode) ? 'span' : 'div'

  const vnode = <Tag>{children}</Tag>

  return vnode
}

/**
 * 根据 elemNode.type 获取 renderElement 函数
 * @param type elemNode.type
 */
function getRenderElem(type: string): RenderElemFnType {
  const fn = RENDER_ELEM_CONF[type]
  return fn || defaultRender
}

export default getRenderElem


================================================
FILE: packages/core/src/render/element/renderElement.tsx
================================================
/**
 * @description render element node
 * @author wangfupeng
 */

import { Editor, Node, Element as SlateElement } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { node2Vnode } from '../node2Vnode'
import { DomEditor } from '../../editor/dom-editor'
import { IDomEditor } from '../../editor/interface'
import {
  KEY_TO_ELEMENT,
  NODE_TO_ELEMENT,
  ELEMENT_TO_NODE,
  NODE_TO_INDEX,
  NODE_TO_PARENT,
} from '../../utils/weak-maps'
import getRenderElem from './getRenderElem'
import renderStyle from './renderStyle'
import { promiseResolveThen } from '../../utils/util'
import { genElemId } from '../helper'
import { getElementById } from '../../utils/dom'

interface IAttrs {
  id: string
  key: string | number
  'data-slate-node': 'element'
  'data-slate-inline'?: boolean
  'data-slate-void'?: boolean
  contentEditable?: Boolean
}

function renderElement(elemNode: SlateElement, editor: IDomEditor): VNode {
  const key = DomEditor.findKey(editor, elemNode)
  // const readOnly = editor.isDisabled()
  const isInline = editor.isInline(elemNode)
  const isVoid = Editor.isVoid(editor, elemNode)
  const domId = genElemId(key.id)
  const attrs: IAttrs = {
    id: domId,
    key: key.id,
    'data-slate-node': 'element',
    'data-slate-inline': isInline,
  }

  // 根据 type 生成 vnode 的函数
  const { type, children = [] } = elemNode
  let renderElem = getRenderElem(type)

  let childrenVnode
  if (isVoid) {
    childrenVnode = null // void 节点 render elem 时不传入 children
  } else {
    childrenVnode = children.map((child: Node, index: number) => {
      return node2Vnode(child, index, elemNode, editor)
    })
  }

  // 创建 vnode
  let vnode = renderElem(elemNode, childrenVnode, editor)

  // void node 要特殊处理
  if (isVoid) {
    attrs['data-slate-void'] = true

    // 如果这里设置 contentEditable = false ,那图片就无法删除了 ???
    // if (!readOnly && isInline) {
    //     attrs.contentEditable = false
    // }

    const Tag = isInline ? 'span' : 'div'
    const [[text]] = Node.texts(elemNode)

    const textVnode = node2Vnode(text, 0, elemNode, editor)
    const textWrapperVnode = (
      <Tag
        data-slate-spacer
        style={{
          height: '0',
          color: 'transparent',
          outline: 'none',
          position: 'absolute',
        }}
      >
        {textVnode}
      </Tag>
    )

    // 重写 vnode
    vnode = (
      // 设置 position: relative,保证 absolute 的 textWrapperVnode 不乱跑
      <Tag style={{ position: 'relative' }}>
        {vnode}
        {textWrapperVnode}
      </Tag>
    )

    // 记录 text 相关 weakMap
    NODE_TO_INDEX.set(text, 0)
    NODE_TO_PARENT.set(text, elemNode)
  }

  // 添加 element 属性
  if (vnode.data == null) vnode.data = {}
  Object.assign(vnode.data, attrs)

  // 添加文本相关的样式,如 text-align
  if (!isVoid && !isInline) {
    // 非 void + 非 inline
    vnode = renderStyle(elemNode, vnode)
  }

  // 更新 element 相关的 weakMap
  promiseResolveThen(() => {
    // 异步,否则拿不到 DOM 节点
    const dom = getElementById(domId)
    if (dom == null) return
    KEY_TO_ELEMENT.set(key, dom)
    NODE_TO_ELEMENT.set(elemNode, dom)
    ELEMENT_TO_NODE.set(dom, elemNode)
  })

  return vnode
}

export default renderElement


================================================
FILE: packages/core/src/render/element/renderStyle.ts
================================================
/**
 * @description 添加文本相关的样式
 * @author wangfupeng
 */

import { Element as SlateElement } from 'slate'
import { VNode } from 'snabbdom'
import { RENDER_STYLE_HANDLER_LIST } from '../index'

/**
 * 渲染样式
 * @param elem slate elem node
 * @param vnode elem Vnode
 */
function renderStyle(elem: SlateElement, vnode: VNode): VNode {
  let newVnode = vnode

  RENDER_STYLE_HANDLER_LIST.forEach(styleHandler => {
    newVnode = styleHandler(elem, vnode)
  })

  return newVnode
}

export default renderStyle


================================================
FILE: packages/core/src/render/helper.ts
================================================
/**
 * @description formats helper
 * @author wangfupeng
 */

export function genElemId(id: string) {
  return `w-e-element-${id}`
}

export function genTextId(id: string) {
  return `w-e-text-${id}`
}


================================================
FILE: packages/core/src/render/index.ts
================================================
/**
 * @description formats entry
 * @author wangfupeng
 */

import { Element as SlateElement, Descendant } from 'slate'
import { VNode } from 'snabbdom'
import { IDomEditor } from '../editor/interface'

// ------------------------------------ render style ------------------------------------

export type RenderStyleFnType = (node: Descendant, vnode: VNode) => VNode

// 存储:处理文本样式的函数,如 b u color 等
export const RENDER_STYLE_HANDLER_LIST: RenderStyleFnType[] = []

/**
 * 注册处理文本样式的函数
 * @param fn 处理文本样式的函数
 */
export function registerStyleHandler(fn: RenderStyleFnType) {
  RENDER_STYLE_HANDLER_LIST.push(fn)
}

// ------------------------------------ render elem ------------------------------------

export type RenderElemFnType = (
  elemNode: SlateElement,
  children: VNode[] | null,
  editor: IDomEditor
) => VNode

// 注册 render element 配置
export const RENDER_ELEM_CONF: {
  [key: string]: RenderElemFnType // key 要和 node.type 对应 !!!
} = {}

export interface IRenderElemConf {
  type: string
  renderElem: RenderElemFnType
}

/**
 * 注册 render elem 函数
 * @param conf { type, renderElem } ,type 即 node.type
 */
export function registerRenderElemConf(conf: IRenderElemConf) {
  const { type, renderElem } = conf
  const key = type || ''

  // 如果 key 重复了,就后者覆盖前者
  RENDER_ELEM_CONF[key] = renderElem
}


================================================
FILE: packages/core/src/render/node2Vnode.ts
================================================
/**
 * @description slate node to vnode
 * @author wangfupeng
 */

import { Element, Text, Node, Ancestor } from 'slate'
import { VNode } from 'snabbdom'
import { IDomEditor } from '../editor/interface'
import renderElement from './element/renderElement'
import renderText from './text/renderText'
import { NODE_TO_INDEX, NODE_TO_PARENT } from '../utils/weak-maps'

/**
 * 根据 slate node 生成 snabbdom vnode
 * @param node node
 * @param index node index in parent.children
 * @param parent parent node
 * @param editor editor
 */
export function node2Vnode(node: Node, index: number, parent: Ancestor, editor: IDomEditor): VNode {
  // 设置相关 weakMap 信息
  NODE_TO_INDEX.set(node, index)
  NODE_TO_PARENT.set(node, parent)

  let vnode: VNode
  if (Element.isElement(node)) {
    // element
    vnode = renderElement(node as Element, editor)
  } else {
    // text
    vnode = renderText(node as Text, parent, editor)
  }

  return vnode
}


================================================
FILE: packages/core/src/render/text/genVnode.tsx
================================================
/**
 * @description 生成 text vnode
 * @author wangfupeng
 */

import { Editor, Path, Node, Text as SlateText, Ancestor } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { DomEditor } from '../../editor/dom-editor'
import { IDomEditor } from '../../editor/interface'

function str(text: string, isTrailing = false): VNode {
  return <span data-slate-string>{isTrailing ? text + '\n' : text}</span>
}

function zeroWidthStr(length = 0, isLineBreak = false): VNode {
  return (
    <span data-slate-zero-width={isLineBreak ? 'n' : 'z'} data-slate-length={length}>
      {'\uFEFF'}
      {isLineBreak ? <br /> : null}
    </span>
  )
}

function genTextVnode(
  leafNode: SlateText,
  isLast: boolean = false,
  textNode: SlateText,
  parent: Ancestor,
  editor: IDomEditor
): VNode {
  const { text } = leafNode
  const path = DomEditor.findPath(editor, textNode)
  const parentPath = Path.parent(path)

  if (Editor.isEditor(parent)) {
    throw new Error(`Text node ${JSON.stringify(textNode)} parent is Editor`)
  }

  // COMPAT: Render text inside void nodes with a zero-width space.
  // So the node can contain selection but the text is not visible.
  if (editor.isVoid(parent)) {
    return zeroWidthStr(Node.string(parent).length)
  }

  // COMPAT: If this is the last text node in an empty block, render a zero-
  // width space that will convert into a line break when copying and pasting
  // to support expected plain text.
  if (
    text === '' &&
    parent.children[parent.children.length - 1] === textNode &&
    !editor.isInline(parent) &&
    Editor.string(editor, parentPath) === ''
  ) {
    return zeroWidthStr(0, true)
  }

  // COMPAT: If the text is empty, it's because it's on the edge of an inline
  // node, so we render a zero-width space so that the selection can be
  // inserted next to it still.
  if (text === '') {
    return zeroWidthStr()
  }

  // COMPAT: Browsers will collapse trailing new lines at the end of blocks,
  // so we need to add an extra trailing new lines to prevent that.
  if (isLast && text.slice(-1) === '\n') {
    return str(text, true)
  }

  return str(text)
}

export default genTextVnode


================================================
FILE: packages/core/src/render/text/renderStyle.ts
================================================
/**
 * @description text 样式
 * @author wangfupeng
 */

import { Text as SlateText } from 'slate'
import { VNode } from 'snabbdom'
import { RENDER_STYLE_HANDLER_LIST } from '../index'

/**
 * 给字符串增加样式
 * @param leafNode slate text leaf node
 * @param textVnode textVnode
 */
function addTextVnodeStyle(leafNode: SlateText, textVnode: VNode): VNode {
  let newTextVnode = textVnode

  RENDER_STYLE_HANDLER_LIST.forEach(styleHandler => {
    newTextVnode = styleHandler(leafNode, newTextVnode)
  })

  return newTextVnode
}

export default addTextVnodeStyle


================================================
FILE: packages/core/src/render/text/renderText.tsx
================================================
/**
 * @description render text node
 * @author wangfupeng
 */

import { Text as SlateText, Ancestor } from 'slate'
import { jsx, VNode } from 'snabbdom'
import { DomEditor } from '../../editor/dom-editor'
import { IDomEditor } from '../../editor/interface'
import { KEY_TO_ELEMENT, NODE_TO_ELEMENT, ELEMENT_TO_NODE } from '../../utils/weak-maps'
import genTextVnode from './genVnode'
import addTextVnodeStyle from './renderStyle'
import { promiseResolveThen } from '../../utils/util'
import { genTextId } from '../helper'
import { getElementById } from '../../utils/dom'

function renderText(textNode: SlateText, parent: Ancestor, editor: IDomEditor): VNode {
  if (textNode.text == null)
    throw new Error(`Current node is not slate Text ${JSON.stringify(textNode)}`)
  const key = DomEditor.findKey(editor, textNode)

  // 根据 decorate 将 text 拆分为多个叶子节点 text[]
  const { decorate } = editor.getConfig()
  if (decorate == null) throw new Error(`Can not get config.decorate`)
  const path = DomEditor.findPath(editor, textNode)
  const ds = decorate([textNode, path])
  const leaves = SlateText.decorations(textNode, ds)

  // 生成 leaves vnode
  const leavesVnode = leaves.map((leafNode, index) => {
    // 文字和样式
    const isLast = index === leaves.length - 1
    let strVnode = genTextVnode(leafNode, isLast, textNode, parent, editor)
    strVnode = addTextVnodeStyle(leafNode, strVnode)
    // 生成每一个 leaf 节点
    return <span data-slate-leaf>{strVnode}</span>
  })

  // 生成 text vnode
  const textId = genTextId(key.id)
  const vnode = (
    <span data-slate-node="text" id={textId} key={key.id}>
      {leavesVnode /* 一个 text 可能包含多个 leaf */}
    </span>
  )

  // 更新 weak-map
  promiseResolveThen(() => {
    // 异步,否则拿不到 DOM
    const dom = getElementById(textId)
    if (dom == null) return
    KEY_TO_ELEMENT.set(key, dom)
    NODE_TO_ELEMENT.set(textNode, dom)
    ELEMENT_TO_NODE.set(dom, textNode)
  })

  return vnode
}

export default renderText


================================================
FILE: packages/core/src/text-area/TextArea.ts
================================================
/**
 * @description text-area class
 * @author wangfupeng
 */

import { Range } from 'slate'
import throttle from 'lodash.throttle'
import forEach from 'lodash.foreach'
import $, { Dom7Array, DOMElement } from '../utils/dom'
import { TEXTAREA_TO_EDITOR } from '../utils/weak-maps'
import { IDomEditor } from '../editor/interface'
import { DomEditor } from '../editor/dom-editor'
import updateView from './update-view'
import { handlePlaceholder } from './place-holder'
import { editorSelectionToDOM, DOMSelectionToEditor } from './syncSelection'
import { promiseResolveThen } from '../utils/util'
import eventHandlerConf from './event-handlers/index'

let ID = 1

class TextArea {
  readonly id = ID++
  $box: Dom7Array
  $textAreaContainer: Dom7Array
  $scroll: Dom7Array
  $textArea: Dom7Array | null = null
  private readonly $progressBar = $('<div class="w-e-progress-bar"></div>')
  private readonly $maxLengthInfo = $('<div class="w-e-max-length-info"></div>')
  isComposing: boolean = false
  isUpdatingSelection: boolean = false
  isDraggingInternally: boolean = false
  latestElement: DOMElement | null = null
  showPlaceholder = false
  $placeholder: Dom7Array | null = null
  private latestEditorSelection: Range | null = null

  constructor(boxSelector: string | DOMElement) {
    // @ts-ignore 初始化 dom
    const $box = $(boxSelector)
    if ($box.length === 0) {
      throw new Error(`Cannot find textarea DOM by selector '${boxSelector}'`)
    }
    this.$box = $box
    const $container = $(`<div class="w-e-text-container"></div>`)
    $container.append(this.$progressBar) // 进度条
    $container.append(this.$maxLengthInfo) // max length 提示信息
    $box.append($container)
    const $scroll = $(`<div class="w-e-scroll"></div>`)
    $container.append($scroll)
    this.$scroll = $scroll
    this.$textAreaContainer = $container

    // 异步,否则获取不到 editor 和 DOM
    promiseResolveThen(() => {
      const editor = this.editorInstance
      const window = DomEditor.getWindow(editor)

      // 监听 selection change
      window.document.addEventListener('selectionchange', this.onDOMSelectionChange)
      // editor 销毁时,解绑 selection change
      editor.on('destroyed', () => {
        window.document.removeEventListener('selectionchange', this.onDOMSelectionChange)
      })

      // 点击编辑区域,关闭 panel
      $container.on('click', () => editor.hidePanelOrModal())

      // editor onchange 时更新视图
      editor.on('change', this.changeViewState.bind(this))

      // editor onchange 时触发用户配置的 onChange (需要在 changeViewState 后执行)
      const { onChange } = editor.getConfig()
      if (onChange) {
        editor.on('change', () => onChange(editor))
      }

      // 监听 onfocus onblur
      this.onFocusAndOnBlur()

      // 实时修改 maxLength 提示信息
      editor.on('change', this.changeMaxLengthInfo.bind(this))

      // 绑定 DOM 事件
      this.bindEvent()
    })
  }

  private get editorInstance(): IDomEditor {
    const editor = TEXTAREA_TO_EDITOR.get(this)
    if (editor == null) throw new Error('Can not get editor instance')
    return editor
  }

  private onDOMSelectionChange = throttle(() => {
    const editor = this.editorInstance
    DOMSelectionToEditor(this, editor)
  }, 100)

  /**
   * 绑定事件,如 beforeinput onblur onfocus keydown click copy/paste drag/drop 等
   */
  private bindEvent() {
    const { $textArea, $scroll } = this
    const editor = this.editorInstance

    if ($textArea == null) return

    // 遍历所有事件类型,绑定
    forEach(eventHandlerConf, (fn, eventType) => {
      $textArea.on(eventType, event => {
        fn(event, this, editor)
      })
    })

    // 设置 scroll
    const { scroll } = editor.getConfig()
    if (scroll) {
      $scroll.css('overflow-y', 'auto')
      // scroll 自定义事件
      $scroll.on(
        'scroll',
        throttle(() => {
          editor.emit('scroll')
        }, 100)
      )
    }
  }

  private onFocusAndOnBlur() {
    const editor = this.editorInstance
    const { onBlur, onFocus } = editor.getConfig()
    this.latestEditorSelection = editor.selection

    editor.on('change', () => {
      if (this.latestEditorSelection == null && editor.selection != null) {
        // 异步触发 focus
        setTimeout(() => onFocus && onFocus(editor))
      } else if (this.latestEditorSelection != null && editor.selection == null) {
        // 异步触发 blur
        setTimeout(() => onBlur && onBlur(editor))
      }

      this.latestEditorSelection = editor.selection // 重新记录 selection
    })
  }

  /**
   * 修改 maxLength 提示信息
   */
  private changeMaxLengthInfo() {
    const editor = this.editorInstance
    const { maxLength } = editor.getConfig()
    if (maxLength) {
      const leftLength = DomEditor.getLeftLengthOfMaxLength(editor)
      const curLength = maxLength - leftLength
      this.$maxLengthInfo[0].innerHTML = `${curLength}/${maxLength}`
    }
  }

  /**
   * 修改进度条
   * @param progress 进度
   */
  changeProgress(progress: number) {
    const $progressBar = this.$progressBar
    $progressBar.css('width', `${progress}%`)

    // 进度 100% 之后,定时隐藏
    if (progress >= 100) {
      setTimeout(() => {
        $progressBar.hide()
        $progressBar.css('width', '0')
        $progressBar.show()
      }, 1000)
    }
  }

  /**
   * 修改 view 状态
   */
  changeViewState() {
    const editor = this.editorInstance

    // 更新 DOM
    // TODO 注意这里是否会有性能瓶颈?因为每次键盘输入,都会触发这里 —— 可单独测试大文件、多内容,如几万个字
    updateView(this, editor)

    // 处理 placeholder
    handlePlaceholder(this, editor)

    // 同步选区(异步,否则拿不到 DOM 渲染结果,vdom)
    promiseResolveThen(() => {
      editorSelectionToDOM(this, editor)
    })
  }

  /**
   * 销毁 textarea
   */
  destroy() {
    // 销毁 DOM (只销毁最外层 DOM 即可)
    this.$textAreaContainer.remove()
  }
}

export default TextArea


================================================
FILE: packages/core/src/text-area/event-handlers/beforeInput.ts
================================================
/**
 * @description 处理 beforeInput 事件
 * @author wangfupeng
 */

import { Editor, Transforms, Range } from 'slate'
import { DomEditor } from '../../editor/dom-editor'
import { IDomEditor } from '../../editor/interface'
import TextArea from '../TextArea'
import { hasEditableTarget } from '../helpers'
import { DOMStaticRange } from '../../utils/dom'
import { HAS_BEFORE_INPUT_SUPPORT } from '../../utils/ua'
import { EDITOR_TO_CAN_PASTE } from '../../utils/weak-maps'

// 补充 beforeInput event 的属性
interface BeforeInputEventType {
  data: string | null
  dataTransfer: DataTransfer | null
  getTargetRanges(): DOMStaticRange[]
  inputType: string
  isComposing: boolean
}

function handleBeforeInput(e: Event, textarea: TextArea, editor: IDomEditor) {
  const event = e as Event & BeforeInputEventType
  const { readOnly } = editor.getConfig()

  if (!HAS_BEFORE_INPUT_SUPPORT) return // 有些浏览器完全不支持 beforeInput ,会用 keypress 和 keydown 兼容
  if (readOnly) return
  if (!hasEditableTarget(editor, event.target)) return

  const { selection } = editor
  const { inputType: type } = event
  const data = event.dataTransfer || event.data || undefined

  // These two types occur while a user is composing text and can't be
  // cancelled. Let them through and wait for the composition to end.
  if (type === 'insertCompositionText' || type === 'deleteCompositionText') {
    return
  }

  // 阻止默认行为,劫持所有的富文本输入
  event.preventDefault()

  // COMPAT: For the deleting forward/backward input types we don't want
  // to change the selection because it is the range that will be deleted,
  // and those commands determine that for themselves.
  if (!type.startsWith('delete') || type.startsWith('deleteBy')) {
    const [targetRange] = event.getTargetRanges()

    if (targetRange) {
      const range = DomEditor.toSlateRange(editor, targetRange, {
        exactMatch: false,
        suppressThrow: false,
      })
      if (!selection || !Range.equals(selection, range)) {
        Transforms.select(editor, range)
      }
    }
  }

  // COMPAT: If the selection is expanded, even if the command seems like
  // a delete forward/backward command it should delete the selection.
  if (selection && Range.isExpanded(selection) && type.startsWith('delete')) {
    const direction = type.endsWith('Backward') ? 'backward' : 'forward'
    Editor.deleteFragment(editor, { direction })
    return
  }

  // 根据 beforeInput 的 event.inputType
  switch (type) {
    case 'deleteByComposition':
    case 'deleteByCut':
    case 'deleteByDrag': {
      Editor.deleteFragment(editor)
      break
    }

    case 'deleteContent':
    case 'deleteContentForward': {
      Editor.deleteForward(editor)
      break
    }

    case 'deleteContentBackward': {
      Editor.deleteBackward(editor)
      break
    }

    case 'deleteEntireSoftLine': {
      Editor.deleteBackward(editor, { unit: 'line' })
      Editor.deleteForward(editor, { unit: 'line' })
      break
    }

    case 'deleteHardLineBackward': {
      Editor.deleteBackward(editor, { unit: 'block' })
      break
    }

    case 'deleteSoftLineBackward': {
      Editor.deleteBackward(editor, { unit: 'line' })
      break
    }

    case 'deleteHardLineForward': {
      Editor.deleteForward(editor, { unit: 'block' })
      break
    }

    case 'deleteSoftLineForward': {
      Editor.deleteForward(editor, { unit: 'line' })
      break
    }

    case 'deleteWordBackward': {
      Editor.deleteBackward(editor, { unit: 'word' })
      break
    }

    case 'deleteWordForward': {
      Editor.deleteForward(editor, { unit: 'word' })
      break
    }

    case 'insertLineBreak':
    case 'insertParagraph': {
      Editor.insertBreak(editor)
      break
    }

    case 'insertFromDrop':
    case 'insertFromPaste':
    case 'insertFromYank':
    case 'insertReplacementText':
    case 'insertText': {
      if (type === 'insertFromPaste') {
        if (!EDITOR_TO_CAN_PASTE.get(editor)) break // 不可默认粘贴
      }

      if (data instanceof DataTransfer) {
        // 这里处理非纯文本(如 html 图片文件等)的粘贴。对于纯文本的粘贴,使用 paste 事件
        editor.insertData(data)
      } else if (typeof data === 'string') {
        Editor.insertText(editor, data)
      }
      break
    }
  }
}

export default handleBeforeInput


================================================
FILE: packages/core/src/text-area/event-handlers/blur.ts
================================================
/**
 * @description 处理 onblur 事件
 * @author wangfupeng
 */

import { Element } from 'slate'
import { DomEditor } from '../../editor/dom-editor'
import { IDomEditor } from '../../editor/interface'
import TextArea from '../TextArea'
import { hasEditableTarget } from '../helpers'
import { isDOMElement, isDOMNode } from '../../utils/dom'
import { IS_FOCUSED } from '../../utils/weak-maps'
import { IS_SAFARI } from '../../utils/ua'

function handleOnBlur(e: Event, textarea: TextArea, editor: IDomEditor) {
  const event = e as FocusEvent

  const { isUpdatingSelection, latestElement } = textarea
  const { readOnly } = editor.getConfig()

  if (readOnly) return
  if (isUpdatingSelection) return
  if (!hasEditableTarget(editor, event.target)) return
  const root = DomEditor.findDocumentOrShadowRoot(editor)

  // COMPAT: If the current `activeElement` is still the previous
  // one, this is due to the window being blurred when the tab
  // itself becomes unfocused, so we want to abort early to allow to
  // editor to stay focused when the tab becomes focused again.
  if (latestElement === root.activeElement) return

  // relatedTarget 即 blur 之后又 focus 到了哪个元素,如果没有则是 null
  const { relatedTarget } = event
  const el = DomEditor.toDOMNode(editor, editor)

  // COMPAT: The event should be ignored if the focus is returning
  // to the editor from an embedded editable element (eg. an <input>
  // element inside a void node).
  if (relatedTarget === el) {
    return
  }

  // COMPAT: The event should be ignored if the focus is moving from
  // the editor to inside a void node's spacer element.
  if (isDOMElement(relatedTarget) && relatedTarget.hasAttribute('data-slate-spacer')) {
    return
  }

  // COMPAT: The event should be ignored if the focus is moving to a
  // non- editable section of an element that isn't a void node (eg.
  // a list item of the check list example).
  if (
    relatedTarget != null &&
    isDOMNode(relatedTarget) &&
    DomEditor.hasDOMNode(editor, relatedTarget)
  ) {
    const node = DomEditor.toSlateNode(editor, relatedTarget)
    if (Element.isElement(node) && !editor.isVoid(node)) {
      return
    }
  }

  // COMPAT: Safari doesn't always remove the selection even if the content-
  // editable element no longer has focus. Refer to:
  // https://stackoverflow.com/questions/12353247/force-contenteditable-div-to-stop-accepting-input-after-it-loses-focus-under-web
  // 修复在 Safari 下,即使 contenteditable 元素非聚焦状态,并不会删除所选内容
  if (IS_SAFARI) {
    const domSelection = root.getSelection()
    domSelection?.removeAllRanges()
  }

  // 检验完毕,可正式触发 onblur
  IS_FOCUSED.delete(editor)
}

export default handleOnBlur


================================================
FILE: packages/core/src/text-area/event-handlers/click.ts
================================================
/**
 * @description 处理 click 事件
 * @author wangfupeng
 */

import { Editor, Path, Transforms, Node } from 'slate'
import { IDomEditor } from '../../editor/interface'
import { DomEditor } from '../../editor/dom-editor'
import TextArea from '../TextArea'
import { hasTarget } from '../helpers'
import { isDOMNode } from '../../utils/dom'

function handleOnClick(event: Event, textarea: TextArea, editor: IDomEditor) {
  const { readOnly } = editor.getConfig()

  if (readOnly) return
  if (!hasTarget(editor, event.target)) return
  if (!isDOMNode(event.target)) return

  const node = DomEditor.toSlateNode(editor, event.target)
  const path = DomEditor.findPath(editor, node)

  // At this time, the Slate document may be arbitrarily different,
  // because onClick handlers can change the document before we get here.
  // Therefore we must check that this path actually exists,
  // and that it still refers to the same node.
  if (Editor.hasPath(editor, path)) {
    const lookupNode = Node.get(editor, path)
    if (lookupNode === node) {
      const start = Editor.start(editor, path)
      const end = Editor.end(editor, path)

      const startVoid = Editor.void(editor, { at: start })
      const endVoid = Editor.void(editor, { at: end })

      if (startVoid && endVoid && Path.equals(startVoid[1], endVoid[1])) {
        const range = Editor.range(editor, start)
        Transforms.select(editor, range)
      }
    }
  }
}

export default handleOnClick


================================================
FILE: packages/core/src/text-area/event-handlers/composition.ts
================================================
/**
 * @description 监听 composition 事件
 * @author wangfupeng
 */

import { Editor, Range, Element } from 'slate'
import { IDomEditor } from '../../editor/interface'
import { DomEditor } from '../../editor/dom-editor'
import TextArea from '../TextArea'
import { hasEditableTarget } from '../helpers'
import { IS_SAFARI, IS_CHROME, IS_FIREFOX } from '../../utils/ua'
import { DOMNode } from '../../utils/dom'
import { hidePlaceholder } from '../place-holder'
import { editorSelectionToDOM } from '../syncSelection'

const EDITOR_TO_TEXT: WeakMap<IDomEditor, string> = new WeakMap()
const EDITOR_TO_START_CONTAINER: WeakMap<IDomEditor, DOMNode> = new WeakMap()

/**
 * composition start 事件
 * @param e event
 * @param textarea textarea
 * @param editor editor
 */
export function handleCompositionStart(e: Event, textarea: TextArea, editor: IDomEditor) {
  const event = e as CompositionEvent

  if (!hasEditableTarget(editor, event.target)) return

  const { selection } = editor
  if (selection && Range.isExpanded(selection)) {
    Editor.deleteFragment(editor)

    Promise.resolve().then(() => {
      // deleteFragment 会在一个 Promise 后更新 dom,导致浏览器选区不正确
      // 因此这里延迟一下再设置选区,使选区在正确位置
      // 这里 model 选区没有发生变化,不能使用 editor.restoreSelection
      // restoreSelection 会对比前后 model 选区是否相同,相同就不更新了
      editorSelectionToDOM(textarea, editor, true)
    })
  }

  if (selection && Range.isCollapsed(selection)) {
    // 记录下 dom text ,以便触发 maxLength 时使用
    const domRange = DomEditor.toDOMRange(editor, selection)
    const startContainer = domRange.startContainer
    const curText = startContainer.textContent || ''
    EDITOR_TO_TEXT.set(editor, curText)

    // 记录下 dom range startContainer
    EDITOR_TO_START_CONTAINER.set(editor, startContainer)
  }
  textarea.isComposing = true

  // 隐藏 placeholder
  hidePlaceholder(textarea, editor)
}

/**
 * composition update 事件
 * @param e event
 * @param textarea textarea
 * @param editor editor
 */
export function handleCompositionUpdate(event: Event, textarea: TextArea, editor: IDomEditor) {
  if (!hasEditableTarget(editor, event.target)) return

  textarea.isComposing = true
}

/**
 * composition end 事件
 * @param e event
 * @param textarea textarea
 * @param editor editor
 */
export function handleCompositionEnd(e: Event, textarea: TextArea, editor: IDomEditor) {
  const event = e as CompositionEvent

  if (!hasEditableTarget(editor, event.target)) return
  textarea.isComposing = false

  const { selection } = editor
  if (selection == null) return

  // 清理可能暴露的 text 节点
  // 例如 chrome 在链接后面,输入拼音,就会出现有暴露出来的 text node
  if (IS_CHROME || IS_FIREFOX) {
    DomEditor.cleanExposedTexNodeInSelectionBlock(editor)
  }

  // 在中文输入法下,浏览器的默认行为会使一些dom产生不可逆的变化
  // 比如在 Safari 中 url 后面输入,初始是 a > span > spans
  // 输入后变成 span > span > a
  // 因此需要设置新的 key 来强刷整行
  const start = Range.isBackward(selection) ? selection.focus : selection.anchor
  const [paragraph] = Editor.node(editor, [start.path[0]])

  for (let i = 0; i < start.path.length; i++) {
    const [node] = Editor.node(editor, start.path.slice(0, i + 1))
    if (Element.isElement(node)) {
      if (((IS_SAFARI || IS_FIREFOX) && node.type === 'link') || node.type === 'code') {
        DomEditor.setNewKey(paragraph)
        break
      }
    }
  }

  const { data } = event
  if (!data) return

  // 检查 maxLength -【注意】这里只处理拼音输入的 maxLength 限制。其他限制,在插件 with-max-length.ts 中处理
  const { maxLength } = editor.getConfig()
  if (maxLength) {
    const leftLengthOfMaxLength = DomEditor.getLeftLengthOfMaxLength(editor)
    if (leftLengthOfMaxLength < data.length) {
      const domRange = DomEditor.toDOMRange(editor, selection)
      domRange.startContainer.textContent = EDITOR_TO_TEXT.get(editor) || ''
      if (leftLengthOfMaxLength > 0) {
        // 剩余长度 >0 ,但小于 data 长度,截取一部分插入
        Editor.insertText(editor, data.slice(0, leftLengthOfMaxLength))
      }
      textarea.changeViewState() // 重新定位光标
    } else {
      Editor.insertText(editor, data)
    }
  } else {
    Editor.insertText(editor, data)
  }

  // 检查拼音输入是否夸 DOM 节点了,解决 wangEditor-v5/issues/47
  if (!IS_SAFARI) {
    setTimeout(() => {
      const { selection } = editor
      if (selection == null) return
      const oldStartContainer = EDITOR_TO_START_CONTAINER.get(editor) // 拼音输入开始时的 text node
      if (oldStartContainer == null) return
      const curStartContainer = DomEditor.toDOMRange(editor, selection).startContainer // 拼音输入结束时的 text node
      if (curStartContainer === oldStartContainer) {
        // 拼音输入的开始和结束,都在同一个 text node ,则不做处理
        return
      }
      // 否则,拼音输入的开始和结束,不是同一个 text node ,则将第一个 text node 重新设置 text
      oldStartContainer.textContent = EDITOR_TO_TEXT.get(editor) || ''
    })
  }
}


================================================
FILE: packages/core/src/text-area/event-handlers/copy.ts
================================================
/**
 * @description 处理 copy 事件
 * @author wangfupeng
 */

import { IDomEditor } from '../../editor/interface'
// import { DomEditor } from '../../editor/dom-editor'
import TextArea from '../TextArea'
import { hasEditableTarget } from '../helpers'

function handleOnCopy(e: Event, textarea: TextArea, editor: IDomEditor) {
  const event = e as ClipboardEvent

  if (!hasEditableTarget(editor, event.target)) return
  event.preventDefault()

  const data = event.clipboardData
  if (data == null) return
  editor.setFragmentData(data)
}

export default handleOnCopy


================================================
FILE: packages/core/src/text-area/event-handlers/cut.ts
================================================
/**
 * @description 处理 cut 事件
 * @author wangfupeng
 */

import { Editor, Range, Node, Transforms } from 'slate'
import { IDomEditor } from '../../editor/interface'
import TextArea from '../TextArea'
import { hasEditableTarget } from '../helpers'

function handleOnCut(e: Event, textarea: TextArea, editor: IDomEditor) {
  const event = e as ClipboardEvent
  const { selection } = editor
  const { readOnly } = editor.getConfig()

  if (readOnly) return
  if (!hasEditableTarget(editor, event.target)) return

  event.preventDefault()

  const data = event.clipboardData
  if (data == null) return
  editor.setFragmentData(data)

  if (selection) {
    if (Range.isExpanded(selection)) {
      Editor.deleteFragment(editor)
    } else {
      const node = Node.parent(editor, selection.anchor.path)
      if (Editor.isVoid(editor, node)) {
        Transforms.delete(editor)
      }
    }
  }
}

export default handleOnCut


================================================
FILE: packages/core/src/text-area/event-handlers/drag.ts
================================================
/**
 * @description 处理 dragover 事件
 * @author wangfupeng
 */

import { Editor, Transforms } from 'slate'
import { DomEditor } from '../../editor/dom-editor'
import { IDomEditor } from '../../editor/interface'
import TextArea from '../TextArea'
import { hasTarget } from '../helpers'

export function handleOnDragstart(e: Event, textarea: TextArea, editor: IDomEditor) {
  const event = e as DragEvent
  if (!hasTarget(editor, event.target)) return

  const { readOnly } = editor.getConfig()
  if (readOnly) return

  const node = DomEditor.toSlateNode(editor, event.target)
  const path = DomEditor.findPath(editor, node)
  const voidMatch = Editor.isVoid(editor, node) || Editor.void(editor, { at: path, voids: true })

  // If starting a drag on a void node, make sure it is selected
  // so that it shows up in the selection's fragment.
  if (voidMatch) {
    const range = Editor.range(editor, path)
    Transforms.select(editor, range)
  }

  const data = event.dataTransfer
  if (data == null) return

  textarea.isDraggingInternally = true

  editor.setFragmentData(data)
}

export function handleOnDragover(event: Event, textarea: TextArea, editor: IDomEditor) {
  if (!hasTarget(editor, event.target)) return

  // Only when the target is void, call `preventDefault` to signal
  // that drops are allowed. Editable content is droppable by
  // default, and calling `preventDefault` hides the cursor.
  const node = DomEditor.toSlateNode(editor, event.target)
  if (Editor.isVoid(editor, node)) {
    event.preventDefault()
  }
}

export function handleOnDragend(e: Event, textarea: TextArea, editor: IDomEditor) {
  const event = e as DragEvent
  const { readOnly } = editor.getConfig()

  if (readOnly) return
  if (!textarea.isDraggingInternally) return
  if (!hasTarget(editor, event.target)) return

  textarea.isDraggingInternally = false
}


================================================
FILE: packages/core/src/text-area/event-handlers/drop.ts
================================================
/**
 * @description 处理 drop 事件
 * @author wangfupeng
 */

import { Transforms } from 'slate'
import { IDomEditor } from '../../editor/interface'
import { DomEditor } from '../../editor/dom-editor'
import TextArea from '../TextArea'
import { hasTarget } from '../helpers'
import { HAS_BEFORE_INPUT_SUPPORT, IS_SAFARI } from '../../utils/ua'

function handleOnDrop(e: Event, textarea: TextArea, editor: IDomEditor) {
  const event = e as DragEvent
  const data = event.dataTransfer
  const { readOnly } = editor.getConfig()

  if (readOnly) return
  if (!hasTarget(editor, event.target)) return
  if (data == null) return

  if (HAS_BEFORE_INPUT_SUPPORT) {
    if (IS_SAFARI) {
      // safari 不支持拖拽文件
      if (data.files.length > 0) return
    }
  }

  event.preventDefault()

  // Keep a reference to the dragged range before updating selection
  const draggedRange = editor.selection
  const range = DomEditor.findEventRange(editor, event)
  Transforms.select(editor, range)

  if (textarea.isDraggingInternally) {
    if (draggedRange) {
      Transforms.delete(editor, {
        at: draggedRange,
      })
    }

    textarea.isDraggingInternally = false
  }

  editor.insertData(data)

  // When dragging from another source into the editor, it's possible
  // that the current editor does not have focus.
  if (!editor.isFocused()) {
    editor.focus()
  }
}

export default handleOnDrop


================================================
FILE: packages/core/src/text-area/event-handlers/focus.ts
================================================
/**
 * @description 处理 onfocus 事件
 * @author wangfupeng
 */

import { IDomEditor } from '../../editor/interface'
import { DomEditor } from '../../editor/dom-editor'
import TextArea from '../TextArea'
import { IS_FIREFOX } from '../../utils/ua'
import { IS_FOCUSED } from '../../utils/weak-maps'

function handleOnFocus(event: Event, textarea: TextArea, editor: IDomEditor) {
  const el = DomEditor.toDOMNode(editor, editor)
  const root = DomEditor.findDocumentOrShadowRoot(editor)
  textarea.latestElement = root.activeElement

  // COMPAT: If the editor has nested editable elements, the focus
  // can go to them. In Firefox, this must be prevented because it
  // results in issues with keyboard navigation. (2017/03/30)
  if (IS_FIREFOX && event.target !== el) {
    el.focus()
    return
  }

  IS_FOCUSED.set(editor, true)
}

export default handleOnFocus


================================================
FILE: packages/core/src/text-area/event-handlers/index.ts
================================================
/**
 * @description textarea event handlers entry
 * @author wangfupeng
 */

import handleBeforeInput from './beforeInput'
import handleOnBlur from './blur'
import handleOnFocus from './focus'
import handleOnClick from './click'
import {
  handleCompositionStart,
  handleCompositionEnd,
  handleCompositionUpdate,
} from './composition'
import handleOnKeydown from './keydown'
import handleKeypress from './keypress'
import handleOnCopy from './copy'
import handleOnCut from './cut'
import handleOnPaste from './paste'
import { handleOnDragover, handleOnDragstart, handleOnDragend } from './drag'
import handleOnDrop from './drop'

const eventConf = {
  beforeinput: handleBeforeInput,
  blur: handleOnBlur,
  focus: handleOnFocus,
  click: handleOnClick,
  compositionstart: handleCompositionStart,
  compositionend: handleCompositionEnd,
  compositionupdate: handleCompositionUpdate,
  keydown: handleOnKeydown,
  keypress: handleKeypress,
  copy: handleOnCopy,
  cut: handleOnCut,
  paste: handleOnPaste,
  dragover: handleOnDragover,
  dragstart: handleOnDragstart,
  dragend: handleOnDragend,
  drop: handleOnDrop,
}

export default eventConf


================================================
FILE: packages/core/src/text-area/event-handlers/keydown.ts
================================================
/**
 * @description 监听 onKeydown 事件
 * @author wangfupeng
 */

import { isHotkey } from 'is-hotkey'
import { Editor, Transforms, Range, Node, Element } from 'slate'
import { IDomEditor } from '../../editor/interface'
import TextArea from '../TextArea'
import Hotkeys from '../../utils/hotkeys'
import { hasEditableTarget } from '../helpers'
import { HAS_BEFORE_INPUT_SUPPORT, IS_CHROME, IS_SAFARI } from '../../utils/ua'
import { EDITOR_TO_TOOLBAR, EDITOR_TO_HOVER_BAR } from '../../utils/weak-maps'

function preventDefault(event: Event) {
  event.preventDefault()
}

// 触发 menu 快捷键
function triggerMenuHotKey(editor: IDomEditor, event: KeyboardEvent) {
  const toolbar = EDITOR_TO_TOOLBAR.get(editor)
  const toolbarMenus = toolbar && toolbar.getMenus()
  const hoverbar = EDITOR_TO_HOVER_BAR.get(editor)
  const hoverbarMenus = hoverbar && hoverbar.getMenus()

  // 合并所有 menus
  const allMenus = { ...toolbarMenus, ...hoverbarMenus }
  for (let key in allMenus) {
    const menu = allMenus[key]
    const { hotkey } = menu
    if (hotkey && isHotkey(hotkey, event)) {
      const disabled = menu.isDisabled(editor)
      if (!disabled) {
        const val = menu.getValue(editor)
        menu.exec(editor, val) // 执行 menu 命令
      }
    }
  }
}

function handleOnKeydown(e: Event, textarea: TextArea, editor: IDomEditor) {
  const event = e as KeyboardEvent
  const { selection } = editor
  const { readOnly } = editor.getConfig()

  if (readOnly) return
  if (textarea.isComposing) return
  if (!hasEditableTarget(editor, event.target)) return

  // 触发 menu 快捷键
  triggerMenuHotKey(editor, event)

  // tab
  if (Hotkeys.isTab(event)) {
    preventDefault(event)
    editor.handleTab()
    return
  }

  // COMPAT: Since we prevent the default behavior on
  // `beforeinput` events, the browser doesn't think there's ever
  // any history stack to undo or redo, so we have to manage these
  // hotkeys ourselves. (2019/11/06)
  if (Hotkeys.isRedo(event)) {
    preventDefault(event)
    if (typeof editor.redo === 'function') {
      editor.redo()
    }
    return
  }
  if (Hotkeys.isUndo(event)) {
    preventDefault(event)
    if (typeof editor.undo === 'function') {
      editor.undo()
    }
    return
  }

  // COMPAT: Certain browsers don't handle the selection updates
  // properly. In Chrome, the selection isn't properly extended.
  // And in Firefox, the selection isn't properly collapsed.
  // (2017/10/17)
  if (Hotkeys.isMoveLineBackward(event)) {
    preventDefault(event)
    Transforms.move(editor, { unit: 'line', reverse: true }) // Transforms.move 修改 selection
    return
  }
  if (Hotkeys.isMoveLineForward(event)) {
    preventDefault(event)
    Transforms.move(editor, { unit: 'line' })
    return
  }

  if (Hotkeys.isExtendLineBackward(event)) {
    preventDefault(event)
    Transforms.move(editor, { unit: 'line', edge: 'focus', reverse: true })
    return
  }
  if (Hotkeys.isExtendLineForward(event)) {
    preventDefault(event)
    Transforms.move(editor, { unit: 'line', edge: 'focus' })
    return
  }

  // COMPAT: If a void node is selected, or a zero-width text node
  // adjacent to an inline is selected, we need to handle these
  // hotkeys manually because browsers won't be able to skip over
  // the void node with the zero-width space not being an empty
  // string.
  // todo 移动 word 考虑 Node 排版模式是否为 rtl 的情况
  if (Hotkeys.isMoveBackward(event)) {
    preventDefault(event)

    if (selection && Range.isCollapsed(selection)) {
      Transforms.move(editor, { reverse: true })
    } else {
      Transforms.collapse(editor, { edge: 'start' })
    }
    return
  }
  if (Hotkeys.isMoveForward(event)) {
    preventDefault(event)

    if (selection && Range.isCollapsed(selection)) {
      Transforms.move(editor)
    } else {
      Transforms.collapse(editor, { edge: 'end' })
    }
    return
  }

  if (Hotkeys.isMoveWordBackward(event)) {
    preventDefault(event)

    if (selection && Range.isExpanded(selection)) {
      Transforms.collapse(editor, { edge: 'focus' })
    }

    Transforms.move(editor, { unit: 'word', reverse: true })
    return
  }
  if (Hotkeys.isMoveWordForward(event)) {
    preventDefault(event)

    if (selection && Range.isExpanded(selection)) {
      Transforms.collapse(editor, { edge: 'focus' })
    }

    Transforms.move(editor, { unit: 'word' })
    return
  }

  if (Hotkeys.isSelectAll(event)) {
    preventDefault(event)
    editor.selectAll()
    return
  }

  // COMPAT: Certain browsers don't support the `beforeinput` event, so we
  // fall back to guessing at the input intention for hotkeys.
  // COMPAT: In iOS, some of these hotkeys are handled in the
  if (!HAS_BEFORE_INPUT_SUPPORT) {
    // 这里是兼容不完全支持 beforeInput 的浏览器。对于支持 beforeInput 的浏览器,会用 beforeinput 事件处理
    // 这里兼容了 beforeInput 的一些功能键(如回车、删除等)没有文本输入。文本输入使用 keypress 兼容。

    // We don't have a core behavior for these, but they change the
    // DOM if we don't prevent them, so we have to.
    if (Hotkeys.isBold(event) || Hotkeys.isItalic(event) || Hotkeys.isTransposeCharacter(event)) {
      preventDefault(event)
      return
    }

    if (Hotkeys.isSplitBlock(event)) {
      preventDefault(event)
      Editor.insertBreak(editor)
      return
    }

    if (Hotkeys.isDeleteBackward(event)) {
      preventDefault(event)
      if (selection && Range.isExpanded(selection)) {
        Editor.deleteFragment(editor, { direction: 'backward' })
      } else {
        Editor.deleteBackward(editor)
      }
      return
    }
    if (Hotkeys.isDeleteForward(event)) {
      preventDefault(event)
      if (selection && Range.isExpanded(selection)) {
        Editor.deleteFragment(editor, { direction: 'forward' })
      } else {
        Editor.deleteForward(editor)
      }
      return
    }

    if (Hotkeys.isDeleteLineBackward(event)) {
      preventDefault(event)
      if (selection && Range.isExpanded(selection)) {
        Editor.deleteFragment(editor, { direction: 'backward' })
      } else {
        Editor.deleteBackward(editor, { unit: 'line' })
      }
      return
    }
    if (Hotkeys.isDeleteLineForward(event)) {
      preventDefault(event)
      if (selection && Range.isExpanded(selection)) {
        Editor.deleteFragment(editor, { direction: 'forward' })
      } else {
        Editor.deleteForward(editor, { unit: 'line' })
      }
      return
    }

    if (Hotkeys.isDeleteWordBackward(event)) {
      preventDefault(event)
      if (selection && Range.isExpanded(selection)) {
        Editor.deleteFragment(editor, { direction: 'backward' })
      } else {
        Editor.deleteBackward(editor, { unit: 'word' })
      }
      return
    }
    if (Hotkeys.isDeleteWordForward(event)) {
      preventDefault(event)
      if (selection && Range.isExpanded(selection)) {
        Editor.deleteFragment(editor, { direction: 'forward' })
      } else {
        Editor.deleteForward(editor, { unit: 'word' })
      }
      return
    }
  } else {
    if (IS_CHROME || IS_SAFARI) {
      // COMPAT: Chrome and Safari support `beforeinput` event but do not fire
      // an event when deleting backwards in a selected void inline node
      // 修复在 Chrome 和 Safari 中删除内容时,内联空节点被选中
      if (
        selection &&
        (Hotkeys.isDeleteBackward(event) || Hotkeys.isDeleteForward(event)) &&
        Range.isCollapsed(selection)
      ) {
        const currentNode = Node.parent(editor, selection.anchor.path)

        if (
          Element.isElement(currentNode) &&
          Editor.isVoid(editor, currentNode) &&
          Editor.isInline(editor, currentNode)
        ) {
          event.preventDefault()
          Transforms.delete(editor, { unit: 'block' })

          return
        }
      }
    }
  }
}

export default handleOnKeydown


================================================
FILE: packages/core/src/text-area/event-handlers/keypress.ts
================================================
/**
 * @description 监听 keypress 事件
 * @author wangfupeng
 */

import { Editor } from 'slate'
import { IDomEditor } from '../../editor/interface'
import TextArea from '../TextArea'
import { HAS_BEFORE_INPUT_SUPPORT } from '../../utils/ua'
import { hasEditableTarget } from '../helpers'

// 【注意】虽然 keypress 事件已经过时(建议用 keydown 取代),但这里是为了兼容 beforeinput ,所以不会在高级浏览器生效,不用升级 keydown

function handleKeypress(event: Event, textarea: TextArea, editor: IDomEditor) {
  // 这里是兼容不完全支持 beforeInput 的浏览器。对于支持 beforeInput 的浏览器,会用 beforeinput 事件处理
  if (HAS_BEFORE_INPUT_SUPPORT) return

  const { readOnly } = editor.getConfig()
  if (readOnly) return
  if (!hasEditableTarget(editor, event.target)) return

  event.preventDefault()

  const text = (event as any).key as string

  // 这里只兼容 beforeInput 的 insertText 类型,其他的(如删除、换行)使用 keydown 来兼容
  Editor.insertText(editor, text)
}

export default handleKeypress


================================================
FILE: packages/core/src/text-area/event-handlers/paste.ts
================================================
/**
 * @description 处理 paste 事件
 * @author wangfupeng
 */

import { IDomEditor } from '../../editor/interface'
import { DomEditor } from '../../editor/dom-editor'
import TextArea from '../TextArea'
import { hasEditableTarget } from '../helpers'
import { isPlainTextOnlyPaste } from '../../utils/dom'
import { HAS_BEFORE_INPUT_SUPPORT } from '../../utils/ua'
import { EDITOR_TO_CAN_PASTE } from '../../utils/weak-maps'

function handleOnPaste(e: Event, textarea: TextArea, editor: IDomEditor) {
  EDITOR_TO_CAN_PASTE.set(editor, true) // 标记为:可执行默认粘贴

  const event = e as ClipboardEvent
  const { readOnly } = editor.getConfig()

  if (readOnly) return
  if (!hasEditableTarget(editor, event.target)) return

  const { customPaste } = editor.getConfig()
  if (customPaste) {
    const res = customPaste(editor, event)
    if (res === false) {
      // 自行实现粘贴,不执行默认粘贴
      EDITOR_TO_CAN_PASTE.set(editor, false) // 标记为:不可执行默认粘贴
      return
    }
  }

  // 如果支持 beforeInput 且不是纯粘贴文本(如 html、图片文件),则使用 beforeInput 来实现
  // 这里只处理:不支持 beforeInput 或者 粘贴纯文本
  if (HAS_BEFORE_INPUT_SUPPORT && !isPlainTextOnlyPaste(event)) return

  event.preventDefault()

  const data = event.clipboardData
  if (data == null) return
  editor.insertData(data)
}

export default handleOnPaste


================================================
FILE: packages/core/src/text-area/helpers.ts
================================================
/**
 * @description textarea helper fns
 * @author wangfupeng
 */

import { Editor } from 'slate'
import { DOMRange, DOMNode, isDOMNode } from '../utils/dom'
import { IDomEditor } from '../editor/interface'
import { DomEditor } from '../editor/dom-editor'

/**
 * Check if two DOM range objects are equal.
 */
export const isRangeEqual = (a: DOMRange, b: DOMRange) => {
  return (
    (a.startContainer === b.startContainer &&
      a.startOffset === b.startOffset &&
      a.endContainer === b.endContainer &&
      a.endOffset === b.endOffset) ||
    (a.startContainer === b.endContainer &&
      a.startOffset === b.endOffset &&
      a.endContainer === b.startContainer &&
      a.endOffset === b.startOffset)
  )
}

/**
 * Check if the target is editable and in the editor.
 */
export function hasEditableTarget(
  editor: IDomEditor,
  target: EventTarget | null
): target is DOMNode {
  return isDOMNode(target) && DomEditor.hasDOMNode(editor, target, { editable: true })
}

/**
 * Check if the target is inside void and in an non-readonly editor.
 */
export function isTargetInsideNonReadonlyVoid(
  editor: IDomEditor,
  target: EventTarget | null
): boolean {
  const { readOnly } = editor.getConfig()
  if (readOnly) return false

  const slateNode = hasTarget(editor, target) && DomEditor.toSlateNode(editor, target)
  return Editor.isVoid(editor, slateNode)
}

/**
 * Check if the target is in the editor.
 */
export function hasTarget(editor: IDomEditor, target: EventTarget | null): target is DOMNode {
  return isDOMNode(target) && DomEditor.hasDOMNode(editor, target)
}

/**
 * Check if a DOM event is overrode by a handler.
 */
export function isDOMEventHandled(event: Event, handler?: (event: Event) => void | boolean) {
  if (!handler) {
    return false
  }

  // The custom event handler may return a boolean to specify whether the event
  // shall be treated as being handled or not.
  const shouldTreatEventAsHandled = handler(event)

  if (shouldTreatEventAsHandled != null) {
    return shouldTreatEventAsHandled
  }

  return event.defaultPrevented
}


================================================
FILE: packages/core/src/text-area/place-holder.ts
================================================
/**
 * @description 显示/隐藏 placeholder
 * @author wangfupeng
 */

import { IDomEditor } from '../editor/interface'
import TextArea from './TextArea'
import $ from '../utils/dom'

/**
 * 处理 placeholder
 * @param textarea textarea
 * @param editor editor
 */
export function handlePlaceholder(textarea: TextArea, editor: IDomEditor) {
  const { placeholder } = editor.getConfig()
  if (!placeholder) return

  const isEmpty = editor.isEmpty()

  // 内容为空,且目前未显示 placeholder ,则显示
  if (isEmpty && !textarea.showPlaceholder && !textarea.isComposing) {
    if (textarea.$placeholder == null) {
      const $placeholder = $(`<div class="w-e-text-placeholder">${placeholder}</div>`)
      textarea.$textAreaContainer.append($placeholder)
      textarea.$placeholder = $placeholder
    }
    textarea.$placeholder.show()
    textarea.showPlaceholder = true // 记录
    return
  }

  // 内容不是空,且目前显示着 placeholder ,则隐藏
  if (!isEmpty && textarea.showPlaceholder) {
    textarea.$placeholder?.hide()
    textarea.showPlaceholder = false // 记录
    return
  }
}

/**
 * 隐藏 placeholder (如拼音输入 compositionStart 时,要先隐藏,等 compositionEnd 时再判断是否显示)
 * @param textarea textarea
 * @param editor editor
 */
export function hidePlaceholder(textarea: TextArea, editor: IDomEditor) {
  const { placeholder } = editor.getConfig()
  if (!placeholder) return

  const isEmpty = editor.isEmpty()
  if (!isEmpty) return

  if (textarea.showPlaceholder) {
    textarea.$placeholder?.hide()
    textarea.showPlaceholder = false // 记录
  }
}


================================================
FILE: packages/core/src/text-area/syncSelection.ts
================================================
/**
 * @description 同步 selection
 * @author wangfupeng
 */

import { Range, Transforms } from 'slate'
import scrollIntoView from 'scroll-into-view-if-needed'

import { IDomEditor } from '../editor/interface'
import { DomEditor } from '../editor/dom-editor'
import TextArea from './TextArea'
import { EDITOR_TO_ELEMENT, IS_FOCUSED } from '../utils/weak-maps'
import { IS_FIREFOX } from '../utils/ua'
import { hasEditableTarget, isTargetInsideNonReadonlyVoid } from './helpers'
import { DOMElement } from '../utils/dom'

/**
 * editor onchange 时,将 editor selection 同步给 DOM
 * @param textarea textarea
 * @param editor editor
 * @param focus 是否强制更新选区
 */
export function editorSelectionToDOM(textarea: TextArea, editor: IDomEditor, focus = false): void {
  const { selection } = editor
  const config = editor.getConfig()
  const root = DomEditor.findDocumentOrShadowRoot(editor)
  const domSelection = root.getSelection()

  if (!domSelection) return
  if (textarea.isComposing && !focus) return
  if (!editor.isFocused()) return

  const hasDomSelection = domSelection.type !== 'None'

  // If the DOM selection is properly unset, we're done.
  if (!selection && !hasDomSelection) return

  // verify that the dom selection is in the editor
  const editorElement = EDITOR_TO_ELEMENT.get(editor)!
  let hasDomSelectionInEditor = false
  if (
    editorElement.contains(domSelection.anchorNode) &&
    editorElement.contains(domSelection.focusNode)
  ) {
    hasDomSelectionInEditor = true
  }

  // If the DOM selection is in the editor and the editor selection is already correct, we're done.
  if (hasDomSelection && hasDomSelectionInEditor && selection) {
    const slateRange = DomEditor.toSlateRange(editor, domSelection, {
      exactMatch: true,

      // domSelection is not necessarily a valid Slate range
      // (e.g. when clicking on contentEditable:false element)
      suppressThrow: true,
    })
    if (slateRange && Range.equals(slateRange, selection)) {
      let canReturn = true

      // 选区在 table 时,需要特殊处理
      if (Range.isCollapsed(selection)) {
        const { anchorNode, anchorOffset } = domSelection
        if (anchorNode === editorElement) {
          const childNodes = editorElement.childNodes
          let tableElem

          // 光标在 table 前面时
          tableElem = childNodes[anchorOffset] as DOMElement
          if (tableElem && tableElem.matches('table')) {
            canReturn = false // 不能就此结束,需要重置光标
          }

          // 光标在 table 后面时
          tableElem = childNodes[anchorOffset - 1] as DOMElement
          if (tableElem && tableElem.matches('table')) {
            canReturn = false // 不能就此结束,需要重置光标
          }
        }
      }

      // 其他情况,就此结束
      if (canReturn) return
    }
  }

  // when <Editable/> is being controlled through external value
  // then its children might just change - DOM responds to it on its own
  // but Slate's value is not being updated through any operation
  // and thus it doesn't transform selection on its own
  if (selection && !DomEditor.hasRange(editor, selection)) {
    editor.selection = DomEditor.toSlateRange(editor, domSelection, {
      exactMatch: false,
      suppressThrow: false,
    })
    return
  }

  // Otherwise the DOM selection is out of sync, so update it.
  textarea.isUpdatingSelection = true

  const newDomRange = selection && DomEditor.toDOMRange(editor, selection)
  if (newDomRange) {
    if (Range.isBackward(selection!)) {
      domSelection.setBaseAndExtent(
        newDomRange.endContainer,
        newDomRange.endOffset,
        newDomRange.startContainer,
        newDomRange.startOffset
      )
    } else {
      domSelection.setBaseAndExtent(
        newDomRange.startContainer,
        newDomRange.startOffset,
        newDomRange.endContainer,
        newDomRange.endOffset
      )
    }

    // 滚动到选区
    let leafEl = newDomRange.startContainer.parentElement! as Element
    const spacer = leafEl.closest('[data-slate-spacer]')

    // 这个 if 防止选中图片时发生滚动
    if (!spacer) {
      leafEl.getBoundingClientRect = newDomRange.getBoundingClientRect.bind(newDomRange)
      const body = document.body
      scrollIntoView(leafEl, {
        scrollMode: 'if-needed',
        boundary: config.scroll ? editorElement.parentElement : body, // issue 4215
        block: 'end',
        behavior: 'smooth',
      })
      // @ts-ignore
      delete leafEl.getBoundingClientRect
    }
  } else {
    domSelection.removeAllRanges()
  }

  setTimeout(() => {
    // COMPAT: In Firefox, it's not enough to create a range, you also need
    // to focus the contenteditable element too. (2016/11/16)
    if (newDomRange && IS_FIREFOX) {
      editorElement.focus()
    }

    textarea.isUpdatingSelection = false
  })
}

/**
 * DOM selection change 时,把 DOM selection 同步给 slate
 * @param textarea textarea
 * @param editor editor
 */
export function DOMSelectionToEditor(textarea: TextArea, editor: IDomEditor) {
  const { isComposing, isUpdatingSelection, isDraggingInternally } = textarea
  const config = editor.getConfig()

  if (config.readOnly) return
  if (isComposing) return
  if (isUpdatingSelection) return
  if (isDraggingInternally) return

  const root = DomEditor.findDocumentOrShadowRoot(editor)
  const { activeElement } = root
  const el = DomEditor.toDOMNode(editor, editor)
  const domSelection = root.getSelection()

  if (activeElement === el) {
    textarea.latestElement = activeElement
    IS_FOCUSED.set(editor, true)
  } else {
    IS_FOCUSED.delete(editor)
  }

  if (!domSelection) {
    return Transforms.deselect(editor)
  }

  const { anchorNode, focusNode } = domSelection

  const anchorNodeSelectable =
    hasEditableTarget(editor, anchorNode) || isTargetInsideNonReadonlyVoid(editor, anchorNode)
  const focusNodeSelectable =
    hasEditableTarget(editor, focusNode) || isTargetInsideNonReadonlyVoid(editor, focusNode)

  if (anchorNodeSelectable && focusNodeSelectable) {
    const range = DomEditor.toSlateRange(editor, domSelection, {
      exactMatch: false,
      suppressThrow: false,
    })
    Transforms.select(editor, range)
  } else {
    Transforms.deselect(editor)
  }
}


================================================
FILE: packages/core/src/text-area/update-view.ts
================================================
/**
 * @description patch textarea view
 * @author wangfupeng
 */

import { h, VNode } from 'snabbdom'
import { IDomEditor } from '../editor/interface'
import TextArea from './TextArea'
import { genPatchFn, normalizeVnodeData } from '../utils/vdom'
import $, { Dom7Array, getDefaultView, getElementById } from '../utils/dom'
import { node2Vnode } from '../render/node2Vnode'
import {
  IS_FIRST_PATCH,
  TEXTAREA_TO_PATCH_FN,
  TEXTAREA_TO_VNODE,
  EDITOR_TO_ELEMENT,
  NODE_TO_ELEMENT,
  ELEMENT_TO_NODE,
  EDITOR_TO_WINDOW,
} from '../utils/weak-maps'

function genElemId(id: number) {
  return `w-e-textarea-${id}`
}

/**
 * 生成编辑区域节点的 vnode
 * @param elemId elemId
 * @param readOnly readOnly
 */
function genRootVnode(elemId: string, readOnly = false): VNode {
  return h(`div#${elemId}`, {
    props: {
      contentEditable: readOnly ? false : true,
    },
  })
  // 其他属性在 genRootElem 中定,这里不用重复写
}

/**
 * 生成编辑区域的 elem
 * @param elemId elemId
 * @param readOnly readOnly
 */
function genRootElem(elemId: string, readOnly = false): Dom7Array {
  const $elem = $(`<div
        id="${elemId}"
        data-slate-editor
        data-slate-node="value"
        suppressContentEditableWarning
        role="textarea"
        spellCheck="true"
        autoCorrect="true"
        autoCapitalize="true"
    ></div>`)

  // role="textarea" - 增强语义,div 语义太弱

  return $elem
}

/**
 * 获取 editor.children 渲染 DOM
 * @param textarea textarea
 * @param editor editor
 */
function updateView(textarea: TextArea, editor: IDomEditor) {
  const $scroll = textarea.$scroll
  const elemId = genElemId(textarea.id)
  const { readOnly, autoFocus } = editor.getConfig()

  // 生成 newVnode
  const newVnode = genRootVnode(elemId, readOnly)
  const content = editor.children || []
  newVnode.children = content.map((node, i) => {
    let vnode = node2Vnode(node, i, editor, editor)
    normalizeVnodeData(vnode) // 整理 vnode.data 以符合 snabbdom 的要求
    return vnode
  })

  let textareaElem
  let isFirstPatch = IS_FIRST_PATCH.get(textarea)
  if (isFirstPatch == null) isFirstPatch = true // 尚未赋值,也是第一次
  if (isFirstPatch) {
    // 第一次 patch ,先生成 elem
    const $textArea = genRootElem(elemId, readOnly)
    $scroll.append($textArea)
    textarea.$textArea = $textArea // 存储下编辑区域的 DOM 节点
    textareaElem = $textArea[0]

    // 再生成 patch 函数,并执行
    const patchFn = genPatchFn()
    patchFn(textareaElem, newVnode)

    // 存储相关信息
    IS_FIRST_PATCH.set(textarea, false) // 不再是第一次 patch
    TEXTAREA_TO_PATCH_FN.set(textarea, patchFn) // 存储 patch 函数
  } else {
    // 不是第一次 patch
    const curVnode = TEXTAREA_TO_VNODE.get(textarea)
    const patchFn = TEXTAREA_TO_PATCH_FN.get(textarea)
    if (curVnode == null || patchFn == null) return
    textareaElem = curVnode.elm

    patchFn(curVnode, newVnode)
  }

  if (textareaElem == null) {
    textareaElem = getElementById(elemId)

    // 通过 getElementById 获取的有可能是 null (销毁、重建时,可能会发生这种情况)
    if (textareaElem == null) return
  }

  // focus
  let isFocused
  if (isFirstPatch) {
    // 初次渲染
    isFocused = autoFocus
  } else {
    // 非初次渲染
    isFocused = editor.isFocused()
  }
  if (isFocused) {
    textareaElem.focus({
      preventScroll: true, // 必须添加 preventScroll 选项,否则弹窗或者编辑器失焦会导致编辑区域自动滚动到顶部
    })
  }

  // 存储相关信息
  if (isFirstPatch) {
    const window = getDefaultView(textareaElem)
    window && EDITOR_TO_WINDOW.set(editor, window)
  }

  EDITOR_TO_ELEMENT.set(editor, textareaElem) // 存储 editor -> elem 对应关系
  NODE_TO_ELEMENT.set(editor, textareaElem)
  ELEMENT_TO_NODE.set(textareaElem, editor)
  TEXTAREA_TO_VNODE.set(textarea, newVnode) // 存储 vnode
}

export default updateView


================================================
FILE: packages/core/src/to-html/README.md
================================================
# to html

把 content 为 html


================================================
FILE: packages/core/src/to-html/elem2html.ts
================================================
/**
 * @description elem -> html
 * @author wangfupeng
 */

import { Editor, Element } from 'slate'
import { IDomEditor } from '../editor/interface'
import node2html from './node2html'
import { ElemToHtmlFnType, ELEM_TO_HTML_CONF, STYLE_TO_HTML_FN_LIST } from './index'

/**
 * 默认的 toHtml 函数
 * @param elemNode elem node
 * @param childrenHtml children html
 * @param editor editor
 */
function defaultParser(elemNode: Element, childrenHtml: string, editor: IDomEditor) {
  const isInline = editor.isInline(elemNode)
  const tag = isInline ? 'span' : 'div'
  return `<${tag}>${childrenHtml}</${tag}>`
}

/**
 * 根据 type 获取 toHtml 函数
 * @param type node.type
 */
function getParser(type: string): ElemToHtmlFnType {
  const fn = ELEM_TO_HTML_CONF[type]
  return fn || defaultParser
}

function elemToHtml(elemNode: Element, editor: IDomEditor): string {
  const { type = '', children = [] } = elemNode
  const isVoid = Editor.isVoid(editor, elemNode)

  // 计算 children html
  let childrenHtml = ''
  if (!isVoid) {
    // 非 void node
    childrenHtml = children.map(child => node2html(child, editor)).join('')
  }

  // 生成 html
  const toHtmlFn = getParser(type)
  const res = toHtmlFn(elemNode, childrenHtml, editor)

  let elemHtml = ''
  if (typeof res === 'string') elemHtml = res
  else elemHtml = res.html || ''

  // 添加样式(如 text-align line-height 等)
  if (!isVoid) {
    STYLE_TO_HTML_FN_LIST.forEach(fn => (elemHtml = fn(elemNode, elemHtml)))
  }

  // 直接返回 html 字符串
  if (typeof res === 'string') return elemHtml

  // 解析 prefix suffix (如 list-item)
  const { prefix = '', suffix = '' } = res
  if (prefix) elemHtml = prefix + elemHtml
  if (suffix) elemHtml = elemHtml + suffix
  return elemHtml
}

export default elemToHtml


================================================
FILE: packages/core/src/to-html/index.ts
================================================
/**
 * @description to-html entry
 * @author wangfupeng
 */

import { Element as SlateElement, Descendant } from 'slate'
import { IDomEditor } from '../editor/interface'

// ------------------------------------ style to html ------------------------------------

export type styleToHtmlFnType = (node: Descendant, elemHtml: string) => string

export const STYLE_TO_HTML_FN_LIST: styleToHtmlFnType[] = []

/**
 * 注册 toHtml 处理文本样式的函数
 * @param fn 处理 toHtml 文本样式的函数
 */
export function registerStyleToHtmlHandler(fn: styleToHtmlFnType) {
  STYLE_TO_HTML_FN_LIST.push(fn)
}

// ------------------------------------ elem node to html ------------------------------------

interface IElemToHtmlRes {
  html: string
  prefix?: string
  suffix?: string
}

export type ElemToHtmlFnType = (
  elemNode: SlateElement,
  childrenHtml: string,
  editor?: IDomEditor
) => string | IElemToHtmlRes

// 注册 element->html 配置
export const ELEM_TO_HTML_CONF: {
  [key: string]: ElemToHtmlFnType // key 要和 node.type 对应 !!!
} = {}

export interface IElemToHtmlConf {
  type: string
  elemToHtml: ElemToHtmlFnType
}

/**
 * 注册 elem to html 函数
 * @param conf { type, elemToHtml } ,type 即 node.type
 */
export function registerElemToHtmlConf(conf: IElemToHtmlConf) {
  const { type, elemToHtml } = conf
  const key = type || ''

  // key 如果重复了,就后者覆盖前者
  ELEM_TO_HTML_CONF[key] = elemToHtml
}


================================================
FILE: packages/core/src/to-html/node2html.ts
================================================
/**
 * @description node -> html
 * @author wangfupeng
 */

import { Element, Descendant } from 'slate'
import { IDomEditor } from '../editor/interface'
import elemToHtml from './elem2html'
import textToHtml from './text2html'

function node2html(node: Descendant, editor: IDomEditor): string {
  if (Element.isElement(node)) {
    // elem node
    return elemToHtml(node, editor)
  } else {
    // text node
    return textToHtml(node, editor)
  }
}

export default node2html


================================================
FILE: packages/core/src/to-html/text2html.ts
================================================
/**
 * @description text -> html
 * @author wangfupeng
 */

import { Text } from 'slate'
import { IDomEditor } from '../editor/interface'
import { DomEditor } from '../editor/dom-editor'
import { STYLE_TO_HTML_FN_LIST } from './index'
import { replaceHtmlSpecialSymbols } from '../utils/util'

function textToHtml(textNode: Text, editor: IDomEditor): string {
  const { text } = textNode
  if (text == null) throw new Error(`Current node is not slate Text ${JSON.stringify(textNode)}`)
  let textHtml = text

  // 替换 html 特殊字符
  textHtml = replaceHtmlSpecialSymbols(textHtml)

  // 替换 \n 为 <br> (一定要在替换特殊字符之后)
  const parents = DomEditor.getParentsNodes(editor, textNode)
  const hasPre = parents.some(p => DomEditor.getNodeType(p) === 'pre') // 上级节点中,是否存在 <pre>
  // 在 <pre> 标签不替换,其他都替换
  if (!hasPre) {
    textHtml = textHtml.replace(/\r\n|\r|\n/g, '<br>')
  }

  // 在 <pre> 内部,&nbsp; 替换为空格
  if (hasPre) {
    textHtml = textHtml.replace(/&nbsp;/g, ' ')
  }

  // 处理空字符串
  if (textHtml === '') {
    const parentNode = DomEditor.getParentNode(null, textNode)
    if (parentNode && parentNode.children.length === 0) {
      // textNode 是唯一的子节点,则改为 <br>
      textHtml = '<br>'
    } else {
      // 其他情况的 空字符串 ,直接返回
      return textHtml
    }
  }

  // 增加文本样式,如 color bgColor
  STYLE_TO_HTML_FN_LIST.forEach(fn => (textHtml = fn(textNode, textHtml)))

  return textHtml
}

export default textToHtml


================================================
FILE: packages/core/src/upload/createUploader.ts
================================================
/**
 * @description gen uploader
 * @author wangfupeng
 */

import Uppy from '@uppy/core'
import XHRUpload from '@uppy/xhr-upload'
import { IUploadConfig } from './interface'
import { addQueryToUrl } from '../utils/util'

function createUploader(config: IUploadConfig): Uppy {
  // 获取配置
  const {
    server = '',
    fieldName = '',
    maxFileSize = 10 * 1024 * 1024, // 10M
    maxNumberOfFiles = 100, // 最多多少个文件
    meta = {},
    metaWithUrl = false,
    headers = {},
    withCredentials = false,
    timeout = 10 * 1000, // 10s
    onBeforeUpload = files => files,
    onSuccess = (file, res) => {
      /* on success */
    },
    onError = (file, err, res?) => {
      console.error(`${file.name} upload error`, err, res)
    },
    onProgress = progress => {
      /* on progress */
    },
  } = config

  // 判断配置项
  if (!server) {
    throw new Error('Cannot get upload server address\n没有配置上传地址')
  }
  if (!fieldName) {
    throw new Error('Cannot get fieldName\n没有配置 fieldName')
  }

  // 是否要追加 url 参数
  let url = server
  if (metaWithUrl) {
    url = addQueryToUrl(url, meta)
  }

  // 生成 uppy 实例,参考文档 https://uppy.io/docs/uppy/
  const uppy = new Uppy({
    onBeforeUpload,
    restrictions: {
      maxFileSize,
      maxNumberOfFiles,
    },
    meta, // 自定义添加到 formData 中的参数
  }).use(XHRUpload, {
    endpoint: url, // 服务端 url
    headers, // 自定义 headers
    formData: true,
    fieldName,
    bundle: true,
    withCredentials,
    timeout,
  })

  // 各个 callback
  uppy.on('upload-success', (file, response) => {
    const { body = {} } = response
    try {
      // 有用户传入的第三方代码,得用 try catch 包裹
      onSuccess(file, body)
    } catch (err) {
      console.error('wangEditor upload file - onSuccess error', err)
    }
    uppy.removeFile(file.id) // 清空文件
  })

  uppy.on('progress', progress => {
    // progress 值范围: 0 - 100
    if (progress < 1) return
    onProgress(progress)
  })

  // uppy.on('error', error => {
  //   console.error('wangEditor file upload error', error.stack)
  // })

  uppy.on('upload-error', (file, error, response) => {
    try {
      // 有用户传入的第三方代码,得用 try catch 包裹
      onError(file, error, response)
    } catch (err) {
      console.error('wangEditor upload file - onError error', err)
    }
    uppy.removeFile(file.id) // 清空文件
  })

  uppy.on('restriction-failed', (file, error) => {
    try {
      // 有用户传入的第三方代码,得用 try catch 包裹
      onError(file, error)
    } catch (err) {
      console.error('wangEditor upload file - onError error', err)
    }
    uppy.removeFile(file.id) // 清空文件
  })

  // 返回实例
  return uppy
}

export default createUploader


================================================
FILE: packages/core/src/upload/index.ts
================================================
/**
 * @description upload entry
 * @author wangfupeng
 */

import createUploader from './createUploader'
import { IUploadConfig } from './interface'

export { createUploader, IUploadConfig }

// TODO upload 能力,写到文档中,二次开发使用


================================================
FILE: packages/core/src/upload/interface.ts
================================================
/**
 * @description upload interface
 * @author wangfupeng
 */

import { UppyFile } from '@uppy/core'

type FilesType = { [key: string]: UppyFile<{}, {}> }

/**
 * 配置参考 https://uppy.io/docs/uppy/
 */
export interface IUploadConfig {
  server: string
  fieldName?: string
  maxFileSize?: number
  maxNumberOfFiles?: number
  meta?: Record<string, unknown>
  metaWithUrl: boolean
  headers?:
    | Headers
    | ((file: UppyFile<Record<string, unknown>, Record<string, unknown>>) => Headers)
    | undefined
  withCredentials?: boolean
  timeout?: number
  onBeforeUpload?: (files: FilesType) => boolean | FilesType
  onSuccess: (file: UppyFile<{}, {}>, response: any) => void
  onProgress?: (progress: number) => void
  onFailed: (file: UppyFile<{}, {}>, response: any) => void
  onError: (file: UppyFile<{}, {}>, error: any, res: any) => void
}


================================================
FILE: packages/core/src/utils/dom.ts
================================================
/**
 * @description DOM 操作 part1 - DOM7 文档 https://framework7.io/docs/dom7.html
 * @author wangfupeng
 */

import { htmlVoidElements } from 'html-void-elements'
import $, {
  css,
  append,
  addClass,
  removeClass,
  hasClass,
  on,
  focus,
  attr,
  hide,
  show,
  // scrollTop,
  // scrollLeft,
  offset,
  width,
  height,
  parent,
  parents,
  is,
  dataset,
  val,
  text,
  removeAttr,
  children,
  html,
  remove,
  find,
  each,
  empty,
  Dom7Array,
} from 'dom7'
export { Dom7Array } from 'dom7'

if (css) $.fn.css = css
if (append) $.fn.append = append
if (addClass) $.fn.addClass = addClass
if (removeClass) $.fn.removeClass = removeClass
if (hasClass) $.fn.hasClass = hasClass
if (on) $.fn.on = on
if (focus) $.fn.focus = focus
if (attr) $.fn.attr = attr
if (removeAttr) $.fn.removeAttr = removeAttr
if (hide) $.fn.hide = hide
if (show) $.fn.show = show
// if (scrollTop) $.fn.scrollTop = scrollTop
// if (scrollLeft) $.fn.scrollLeft = scrollLeft
if (offset) $.fn.offset = offset
if (width) $.fn.width = width
if (height) $.fn.height = height
if (parent) $.fn.parent = parent
if (parents) $.fn.parents = parents
if (is) $.fn.is = is
if (dataset) $.fn.dataset = dataset
if (val) $.fn.val = val
if (text) $.fn.text = text
if (html) $.fn.html = html
if (children) $.fn.children = children
if (remove) $.fn.remove = remove
if (find) $.fn.find = find
if (each) $.fn.each = each
if (empty) $.fn.empty = empty

export default $

// ------------------------------- 分割线,以下内容参考 slate-react dom.ts -------------------------------

// COMPAT: This is required to prevent TypeScript aliases from doing some very
// weird things for Slate's types with the same name as globals. (2019/11/27)
// https://github.com/microsoft/TypeScript/issues/35002
import DOMNode = globalThis.Node
import DOMComment = globalThis.Comment
import DOMElement = globalThis.Element
import DOMText = globalThis.Text
import DOMRange = globalThis.Range
import DOMSelection = globalThis.Selection
import DOMStaticRange = globalThis.StaticRange
export { DOMNode, DOMComment, DOMElement, DOMText, DOMRange, DOMSelection, DOMStaticRange }

export type DOMPoint = [Node, number]

/**
 * Returns the host window of a DOM node
 */
export const getDefaultView = (value: any): Window | null => {
  return (value && value.ownerDocument && value.ownerDocument.defaultView) || null
}

/**
 * Check if a DOM node is a comment node.
 */
export const isDOMComment = (value: any): value is DOMComment => {
  return isDOMNode(value) && value.nodeType === 8
}

/**
 * Check if a DOM node is an element node.
 */
export const isDOMElement = (value: any): value is DOMElement => {
  return isDOMNode(value) && value.nodeType === 1
}

/**
 * Check if a value is a DOM node.
 */
export const isDOMNode = (value: any): value is DOMNode => {
  const window = getDefaultView(value)
  return (
    !!window &&
    // @ts-ignore
    value instanceof window.Node
  )
}

/**
 * Check if a value is a DOM selection.
 */
export const isDOMSelection = (value: any): value is DOMSelection => {
  const window = value && value.anchorNode && getDefaultView(value.anchorNode)
  return !!window && value instanceof window.Selection
}

/**
 * Check if a DOM node is an element node.
 */
export const isDOMText = (value: any): value is DOMText => {
  return isDOMNode(value) && value.nodeType === 3
}

/**
 * Checks whether a paste event is a plaintext-only event.
 */
export const isPlainTextOnlyPaste = (event: ClipboardEvent) => {
  return (
    event.clipboardData &&
    event.clipboardData.getData('text/plain') !== '' &&
    event.clipboardData.types.length === 1
  )
}

/**
 * Normalize a DOM point so that it always refers to a text node.
 */
export const normalizeDOMPoint = (domPoint: DOMPoint): DOMPoint => {
  let [node, offset] = domPoint

  // If it's an element node, its offset refers to the index of its children
  // including comment nodes, so try to find the right text child node.
  if (isDOMElement(node) && node.childNodes.length) {
    let isLast = offset === node.childNodes.length
    let index = isLast ? offset - 1 : offset
    ;[node, index] = getEditableChildAndIndex(node, index, isLast ? 'backward' : 'forward')

    // If the editable child found is in front of input offset, we instead seek to its end
    // 如果编辑区域的内容被发现在输入光标位置前面,也就是光标位置不正常,则修正光标的位置到结尾
    isLast = index < offset

    // If the node has children, traverse until we have a leaf node. Leaf nodes
    // can be either text nodes, or other void DOM nodes.
    while (isDOMElement(node) && node.childNodes.length) {
      const i = isLast ? node.childNodes.length - 1 : 0
      node = getEditableChild(node, i, isLast ? 'backward' : 'forward')
    }

    // Determine the new offset inside the text node.
    offset = isLast && node.textContent != null ? node.textContent.length : 0
  }

  // Return the node and offset.
  return [node, offset]
}

/**
 * Determines wether the active element is nested within a shadowRoot
 */
export const hasShadowRoot = () => {
  return !!(window.document.activeElement && window.document.activeElement.shadowRoot)
}

/**
 * Get the element with the specified id
 */
export const getElementById = (id: string): null | HTMLElement => {
  return (
    window.document.getElementById(id) ??
    (window.document.activeElement?.shadowRoot?.getElementById(id) || null)
  )
}

/**
 * Get the nearest editable child and index at `index` in a `parent`, preferring `direction`.
 */
export const getEditableChildAndIndex = (
  parent: DOMElement,
  index: number,
  direction: 'forward' | 'backward'
): [DOMNode, number] => {
  const { childNodes } = parent
  let child = childNodes[index]
  let i = index
  let triedForward = false
  let triedBackward = false

  // While the child is a comment node, or an element node with no children,
  // keep iterating to find a sibling non-void, non-comment node.
  while (
    isDOMComment(child) ||
    (isDOMElement(child) && child.childNodes.length === 0) ||
    (isDOMElement(child) && child.getAttribute('contenteditable') === 'false')
  ) {
    if (triedForward && triedBackward) {
      break
    }

    if (i >= childNodes.length) {
      triedForward = true
      i = index - 1
      direction = 'backward'
      continue
    }

    if (i < 0) {
      triedBackward = true
      i = index + 1
      direction = 'forward'
      continue
    }

    child = childNodes[i]
    index = i
    i += direction === 'forward' ? 1 : -1
  }

  return [child, index]
}

/**
 * Get the nearest editable child at `index` in a `parent`, preferring
 * `direction`.
 */

export const getEditableChild = (
  parent: DOMElement,
  index: number,
  direction: 'forward' | 'backward'
): DOMNode => {
  const [child] = getEditableChildAndIndex(parent, index, direction)
  return child
}

/**
 * Get a plaintext representation of the content of a node, accounting for block
 * elements which get a newline appended.
 *
 * The domNode must be attached to the DOM.
 */
export const getPlainText = (domNode: DOMNode) => {
  let text = ''

  if (isDOMText(domNode) && domNode.nodeValue) {
    return domNode.nodeValue
  }

  if (isDOMElement(domNode)) {
    for (const childNode of Array.from(domNode.childNodes)) {
      text += getPlainText(childNode)
    }

    const display = getComputedStyle(domNode).getPropertyValue('display')

    if (
      display === 'block' ||
      display === 'list' ||
      display === 'table-row' ||
      domNode.tagName === 'BR'
    ) {
      text += '\n'
    }
  }

  return text
}

/**
 * 在下级节点中找到第一个 void elem
 * @param elem elem
 */
export function getFirstVoidChild(elem: DOMElement): DOMElement | null {
  // 深度优先遍历
  const stack: Array<DOMElement> = []
  stack.push(elem)

  let num = 0

  // 开始遍历
  while (stack.length > 0) {
    const curElem = stack.pop()
    if (curElem == null) break

    num++
    if (num > 10000) break

    const { nodeName, nodeType } = curElem
    if (nodeType === 1) {
      const name = nodeName.toLowerCase()
      if (
        htmlVoidElements.includes(name) ||
        // 补充一些
        name === 'iframe' ||
        name === 'video'
      ) {
        return curElem // 得到 void elem 并返回
      }

      // 继续遍历子节点
      const children = curElem.children || []
      const length = children.length
      if (length) {
        for (let i = length - 1; i >= 0; i--) {
          // 注意,需要**逆序**追加自节点
          stack.push(children[i])
        }
      }
    }
  }

  // 未找到结果,返回 null
  return null
}

/**
 * 遍历一个 elem 内所有的 text node ,执行函数
 * @param elem elem
 * @param handler handler
 */
export function walkTextNodes(
  elem: DOMElement,
  handler: (textNode: DOMNode, parent: DOMElement) => void
) {
  // void elem 内部的 text 不处理
  if (elem instanceof HTMLElement && elem.dataset.slateVoid === 'true') return

  for (let nodes = elem.childNodes, i = nodes.length; i--; ) {
    const node = nodes[i]
    const nodeType = node.nodeType
    if (nodeType == 3) {
      // 匹配到 text node ,执行函数
      handler(node, elem)
    } else if (nodeType == 1 || nodeType == 9 || nodeType == 11) {
      // 继续遍历子节点
      walkTextNodes(node as DOMElement, handler)
    }
  }
}

export enum NodeType {
  ELEMENT_NODE = 1,
  TEXT_NODE = 3,
  CDATA_SECTION_NODE = 4,
  PROCESSING_INSTRUCTION_NODE = 7,
  COMMENT_NODE = 8,
  DOCUMENT_NODE = 9,
  DOCUMENT_TYPE_NODE = 10,
  DOCUMENT_FRAGMENT_NODE = 11,
}

/**
 * 获取 tagName lower-case
 * @param $elem $elem
 */
export function getTagName($elem: Dom7Array): string {
  if ($elem.length === 0) return ''
  const elem = $elem[0]
  if (elem.nodeType !== NodeType.ELEMENT_NODE) return ''
  return elem.tagName.toLowerCase()
}


================================================
FILE: packages/core/src/utils/hotkeys.ts
================================================
/**
 * @description 快捷键
 * @author wangfupeng
 */

import { isKeyHotkey } from 'is-hotkey'
import { IS_APPLE } from './ua'

interface KEYS {
  [key: string]: string | string[]
}

/**
 * Hotkey mappings for each platform.
 */
const HOTKEYS: KEYS = {
  bold: 'mod+b',
  compose: ['down', 'left', 'right', 'up', 'backspace', 'enter'],
  moveBackward: 'left',
  moveForward: 'right',
  moveWordBackward: 'ctrl+left',
  moveWordForward: 'ctrl+right',
  deleteBackward: 'shift?+backspace',
  deleteForward: 'shift?+delete',
  extendBackward: 'shift+left',
  extendForward: 'shift+right',
  italic: 'mod+i',
  splitBlock: 'shift?+enter',
  undo: 'mod+z',
  tab: 'tab',
  selectAll: 'mod+a',
}

const APPLE_HOTKEYS: KEYS = {
  moveLineBackward: 'opt+up',
  moveLineForward: 'opt+down',
  moveWordBackward: 'opt+left',
  moveWordForward: 'opt+right',
  deleteBackward: ['ctrl+backspace', 'ctrl+h'],
  deleteForward: ['ctrl+delete', 'ctrl+d'],
  deleteLineBackward: 'cmd+shift?+backspace',
  deleteLineForward: ['cmd+shift?+delete', 'ctrl+k'],
  deleteWordBackward: 'opt+shift?+backspace',
  deleteWordForward: 'opt+shift?+delete',
  extendLineBackward: 'opt+shift+up',
  extendLineForward: 'opt+shift+down',
  redo: 'cmd+shift+z',
  transposeCharacter: 'ctrl+t',
}

const WINDOWS_HOTKEYS: KEYS = {
  deleteWordBackward: 'ctrl+shift?+backspace',
  deleteWordForward: 'ctrl+shift?+delete',
  redo: ['ctrl+y', 'ctrl+shift+z'],
}

/**
 * Create a platform-aware hotkey checker.
 */
const create = (key: string) => {
  const generic = HOTKEYS[key]
  const apple = APPLE_HOTKEYS[key]
  const windows = WINDOWS_HOTKEYS[key]
  const isGeneric = generic && isKeyHotkey(generic)
  const isApple = apple && isKeyHotkey(apple)
  const isWindows = windows && isKeyHotkey(windows)

  return (event: KeyboardEvent) => {
    if (isGeneric && isGeneric(event)) return true
    if (IS_APPLE && isApple && isApple(event)) return true
    if (!IS_APPLE && isWindows && isWindows(event)) return true
    return false
  }
}

/**
 * Hotkeys.
 */
export default {
  isBold: create('bold'),
  isCompose: create('compose'),
  isMoveBackward: create('moveBackward'),
  isMoveForward: create('moveForward'),
  isDeleteBackward: create('deleteBackward'),
  isDeleteForward: create('deleteForward'),
  isDeleteLineBackward: create('deleteLineBackward'),
  isDeleteLineForward: create('deleteLineForward'),
  isDeleteWordBackward: create('deleteWordBackward'),
  isDeleteWordForward: create('deleteWordForward'),
  isExtendBackward: create('extendBackward'),
  isExtendForward: create('extendForward'),
  isExtendLineBackward: create('extendLineBackward'),
  isExtendLineForward: create('extendLineForward'),
  isItalic: create('italic'),
  isMoveLineBackward: create('moveLineBackward'),
  isMoveLineForward: create('moveLineForward'),
  isMoveWordBackward: create('moveWordBackward'),
  isMoveWordForward: create('moveWordForward'),
  isRedo: create('redo'),
  isSplitBlock: create('splitBlock'),
  isTransposeCharacter: create('transposeCharacter'),
  isUndo: create('undo'),
  isTab: create('tab'),
  isSelectAll: create('selectAll'),
}


================================================
FILE: packages/core/src/utils/key.ts
================================================
/**
 * An auto-incrementing identifier for keys.
 */

let n = 0

/**
 * A class that keeps track of a key string. We use a full class here because we
 * want to be able to use them as keys in `WeakMap` objects.
 */
export class Key {
  id: string

  constructor() {
    this.id = `${n++}`
  }
}


================================================
FILE: packages/core/src/utils/line.ts
================================================
/**
 * @description Utilities for single-line deletion
 */

import { Range, Editor } from 'slate'
import { IDomEditor } from '../editor/interface'
import { DomEditor } from '../editor/dom-editor'

const doRectsIntersect = (rect: DOMRect, compareRect: DOMRect) => {
  const middle = (compareRect.top + compareRect.bottom) / 2
  return rect.top <= middle && rect.bottom >= middle
}

const areRangesSameLine = (editor: IDomEditor, range1: Range, range2: Range) => {
  const rect1 = DomEditor.toDOMRange(editor, range1).getBoundingClientRect()
  const rect2 = DomEditor.toDOMRange(editor, range2).getBoundingClientRect()
  return doRectsIntersect(rect1, rect2) && doRectsIntersect(rect2, rect1)
}

/**
 * A helper utility that returns the end portion of a `Range`
 * which is located on a single line.
 *
 * @param {Editor} editor The editor object to compare against
 * @param {Range} parentRange The parent range to compare against
 * @returns {Range} A valid portion of the parentRange which is one a single line
 */
export const findCurrentLineRange = (editor: IDomEditor, parentRange: Range): Range => {
  const parentRangeBoundary = Editor.range(editor, Range.end(parentRange))
  const positions = Array.from(Editor.positions(editor, { at: parentRange }))

  let left = 0
  let right = positions.length
  let middle = Math.floor(right / 2)

  if (areRangesSameLine(editor, Editor.range(editor, positions[left]), parentRangeBoundary)) {
    return Editor.range(editor, positions[left], parentRangeBoundary)
  }

  if (positions.length < 2) {
    return Editor.range(editor, positions[positions.length - 1], parentRangeBoundary)
  }

  while (middle !== positions.length && middle !== left) {
    if (areRangesSameLine(editor, Editor.range(editor, positions[middle]), parentRangeBoundary)) {
      right = middle
    } else {
      left = middle
    }

    middle = Math.floor((left + right) / 2)
  }

  return Editor.range(editor, positions[right], parentRangeBoundary)
}


================================================
FILE: packages/core/src/utils/ua.ts
================================================
/**
 * @description 通过 UA 判断浏览器
 * @author wangfupeng
 */

export const IS_IOS =
  typeof globalThis.navigator !== 'undefined' &&
  typeof globalThis.window !== 'undefined' &&
  /iPad|iPhone|iPod/.test(navigator.userAgent) &&
  !globalThis.window.MSStream

export const IS_APPLE = typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent)

export const IS_FIREFOX =
  typeof navigator !== 'undefined' && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent)

export const IS_FIREFOX_LEGACY =
  typeof navigator !== 'undefined' &&
  /^(?!.*Seamonkey)(?=.*Firefox\/(?:[0-7][0-9]|[0-8][0-6])(?:\.)).*/i.test(navigator.userAgent)

export const IS_SAFARI =
  typeof navigator !== 'undefined' && /Version\/[\d\.]+.*Safari/.test(navigator.userAgent) // eslint-disable-line

// "modern" Edge was released at 79.x
export const IS_EDGE_LEGACY =
  typeof navigator !== 'undefined' &&
  /Edge?\/(?:[0-6][0-9]|[0-7][0-8])(?:\.)/i.test(navigator.userAgent)

// Native beforeInput events don't work well with react on Chrome 75 and older, Chrome 76+ can use beforeInput
export const IS_CHROME_LEGACY =
  typeof navigator !== 'undefined' &&
  /Chrome?\/(?:[0-7][0-5]|[0-6][0-9])(?:\.)/i.test(navigator.userAgent)

export const IS_CHROME = typeof navigator !== 'undefined' && /Chrome/i.test(navigator.userAgent)

// qq browser
export const IS_QQBROWSER =
  typeof navigator !== 'undefined' && /.*QQBrowser/.test(navigator.userAgent)

// @ts-ignore 判断浏览器是否支持 beforeinput 事件 https://www.caniuse.com/?search=beforeinput
// COMPAT: Firefox/Edge Legacy don't support the `beforeinput` event
// Chrome Legacy doesn't support `beforeinput` correctly
export const HAS_BEFORE_INPUT_SUPPORT =
  !IS_CHROME_LEGACY &&
  !IS_EDGE_LEGACY &&
  // globalThis is undefined in older browsers
  typeof globalThis !== 'undefined' &&
  globalThis.InputEvent &&
  // @ts-ignore The `getTargetRanges` property isn't recognized.
  typeof globalThis.InputEvent.prototype.getTargetRanges === 'function'


================================================
FILE: packages/core/src/utils/util.ts
================================================
/**
 * @description 工具函数
 * @author wangfupeng
 */

import forEach from 'lodash.foreach'
import { nanoid } from 'nanoid'

type PromiseCallback = (value: void) => void | PromiseLike<void>

/**
 * 获取随机数字符串
 * @param prefix 前缀
 * @returns 随机数字符串
 */
export function genRandomStr(prefix: string = 'r'): string {
  return `${prefix}-${nanoid()}`
}

export function promiseResolveThen(fn: Function) {
  Promise.resolve().then(fn as PromiseCallback)
}

/**
 * 追加 url query 参数
 * @param url url
 * @param data data
 */
export function addQueryToUrl(url: string, data: object): string {
  let [urlWithoutHash, hash] = url.split('#')

  // 拼接 query string
  const queryArr: string[] = []
  forEach(data, (val, key) => {
    queryArr.push(`${key}=${val}`)
  })
  const queryStr = queryArr.join('&')

  // 拼接到 url
  if (urlWithoutHash.indexOf('?') > 0) {
    // 已有 query
    urlWithoutHash = `${urlWithoutHash}&${queryStr}`
  } else {
    // 没有 query
    urlWithoutHash = `${urlWithoutHash}?${queryStr}`
  }

  // 返回拼接好的 url
  if (hash) {
    return `${urlWithoutHash}#${hash}`
  } else {
    return urlWithoutHash
  }
}

/**
 * 替换 html 特殊字符,如 > 替换为 &gt;
 * @param str html str
 */
export function replaceHtmlSpecialSymbols(str: string) {
  return (
    str
      /**
       * 遇到两个空格时才替换,一个空格不替换
       * 两个英文单词之间有一个空格,就不用替换,否则无法默认换行 issue #4403
       */
      .replace(/ {2}/g, ' &nbsp;')

      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/®/g, '&reg;')
      .replace(/©/g, '&copy;')
      .replace(/™/g, '&trade;')
  )
}

/**
 *【反转】替换 html 特殊字符,如 &gt; 替换为 >
 * @param str html str
 */
export function deReplaceHtmlSpecialSymbols(str: string) {
  return str
    .replace(/&nbsp;/g, ' ')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&reg;/g, '®')
    .replace(/&copy;/g, '©')
    .replace(/&trade;/g, '™')
    .replace(/&quot;/g, '"')
}


================================================
FILE: packages/core/src/utils/vdom.ts
================================================
/**
 * @description vdom 相关方法
 * @author wangfupeng
 */

import camelCase from 'lodash.camelcase'
import {
  VNode,
  init,
  classModule,
  propsModule,
  styleModule,
  datasetModule,
  VNodeStyle,
  Props,
  Dataset,
  eventListenersModule,
  attributesModule,
} from 'snabbdom'

export type PatchFn = (oldVnode: VNode | Element, vnode: VNode) => VNode

/**
 * 创建 snabbdom patch
 * @returns snabbdom patch 函数
 */
export function genPatchFn(): PatchFn {
  const patch = init([
    // Init patch function with chosen modules
    classModule, // makes it easy to toggle classes
    propsModule, // for setting properties on DOM elements
    styleModule, // handles styling on elements with support for animations
    datasetModule,
    eventListenersModule, // attaches event listeners
    attributesModule,
  ])
  return patch
}

// vnode.data 保留属性,参考 snabbdom VNodeData
const DATA_PRESERVE_KEYS = ['props', 'attrs', 'style', 'dataset', 'on', 'hook']

/**
 * 整理 vnode.data ,将暴露出来的零散属性(如 id className data-xxx)放在 data.props 或 data.dataset
 * @param vnode vnode
 */
export function normalizeVnodeData(vnode: VNode) {
  const { data = {}, children = [] } = vnode
  const dataKeys = Object.keys(data)
  dataKeys.forEach((key: string) => {
    const value = data[key]

    // 赋值 key
    if (key === 'key') {
      vnode.key = value
      return
    }

    // 忽略 data 保留属性
    if (DATA_PRESERVE_KEYS.includes(key)) return

    // dataset
    if (key.startsWith('data-')) {
      let datasetKey = key.slice(5) // 截取掉最前面的 'data-'
      datasetKey = camelCase(datasetKey) // 转为驼峰写法

      // 存储到 data.dataset
      addVnodeDataset(vnode, { [datasetKey]: value })

      delete data[key] // 删掉原有的属性
      return
    }

    // 其他的,都算 props ,存储到 props
    addVnodeProp(vnode, { [key]: value })

    delete data[key] // 删掉原有的属性
  })

  // 遍历 children
  if (children.length > 0) {
    children.forEach(child => {
      if (typeof child === 'string') return
      normalizeVnodeData(child)
    })
  }
}

/**
 * 给 vnode 添加 prop
 * @param vnode vnode
 * @param newProp { key: val }
 */
export function addVnodeProp(vnode: VNode, newProp: Props) {
  if (vnode.data == null) vnode.data = {}
  const data = vnode.data
  if (data.props == null) data.props = {}

  Object.assign(data.props, newProp)
}

/**
 * 给 vnode 添加 dataset
 * @param vnode vnode
 * @param newDataset { key: val }
 */
export function addVnodeDataset(vnode: VNode, newDataset: Dataset) {
  if (vnode.data == null) vnode.data = {}
  const data = vnode.data
  if (data.dataset == null) data.dataset = {}

  Object.assign(data.dataset, newDataset)
}

/**
 * 给 vnode 添加样式
 * @param vnode vnode
 * @param newStyle { key: val }
 */
export function addVnodeStyle(vnode: VNode, newStyle: VNodeStyle) {
  if (vnode.data == null) vnode.data = {}
  const data = vnode.data
  if (data.style == null) data.style = {}

  Object.assign(data.style, newStyle)
}


================================================
FILE: packages/core/src/utils/weak-maps.ts
================================================
/**
 * @description 对象关联关系(部分参考 slate-react weak-maps.ts)
 * @author wangfupeng
 */

import { Emitter } from 'event-emitter'
import { VNode } from 'snabbdom'
import { Node, Ancestor, Editor, Path, Range } from 'slate'
import { IDomEditor } from '../editor/interface'
import TextArea from '../text-area/TextArea'
import Toolbar from '../menus/bar/Toolbar'
import HoverBar from '../menus/bar/HoverBar'
import { IBarItem } from '../menus/bar-item/index'
import { Key } from './key'
import { PatchFn } from '../utils/vdom'
import { IEditorConfig } from '../config/interface'
import PanelAndModal from '../menus/panel-and-modal/BaseClass'

// textarea - editor
export const EDITOR_TO_TEXTAREA = new WeakMap<IDomEditor, TextArea>()
export const TEXTAREA_TO_EDITOR = new WeakMap<TextArea, IDomEditor>()

// bar - editor
export const TOOLBAR_TO_EDITOR = new WeakMap<Toolbar, IDomEditor>()
export const EDITOR_TO_TOOLBAR = new WeakMap<IDomEditor, Toolbar>()
export const HOVER_BAR_TO_EDITOR = new WeakMap<HoverBar, IDomEditor>()
export const EDITOR_TO_HOVER_BAR = new WeakMap<IDomEditor, HoverBar>()
export const BAR_ITEM_TO_EDITOR = new WeakMap<IBarItem, IDomEditor>()
export const EDITOR_TO_PANEL_AND_MODAL = new WeakMap<IDomEditor, Set<PanelAndModal>>()
export const PANEL_OR_MODAL_TO_EDITOR = new WeakMap<PanelAndModal, IDomEditor>()

// config
export const EDITOR_TO_CONFIG = new WeakMap<IDomEditor, IEditorConfig>()

// vdom 相关的属性
export const IS_FIRST_PATCH = new WeakMap<TextArea, boolean>()
export const TEXTAREA_TO_PATCH_FN = new WeakMap<TextArea, PatchFn>()
export const TEXTAREA_TO_VNODE = new WeakMap<TextArea, VNode>()

/**
 * Two weak maps that allow us rebuild a path given a node. They are populated
 * at render time such that after a render occurs we can always backtrack.
 */
export const NODE_TO_INDEX: WeakMap<Node, number> = new WeakMap()
export const NODE_TO_PARENT: WeakMap<Node, Ancestor> = new WeakMap()

/**
 * Weak maps that allow us to go between Slate nodes and DOM nodes. These
 * are used to resolve DOM event-related logic into Slate actions.
 */
export const EDITOR_TO_ELEMENT: WeakMap<Editor, HTMLElement> = new WeakMap()
export const EDITOR_TO_PLACEHOLDER: WeakMap<Editor, string> = new WeakMap()
export const ELEMENT_TO_NODE: WeakMap<HTMLElement, Node> = new WeakMap()
export const KEY_TO_ELEMENT: WeakMap<Key, HTMLElement> = new WeakMap()
export const NODE_TO_ELEMENT: WeakMap<Node, HTMLElement> = new WeakMap()
export const NODE_TO_KEY: WeakMap<Node, Key> = new WeakMap()
export const EDITOR_TO_WINDOW: WeakMap<Editor, Window> = new WeakMap()

/**
 * Weak maps for storing editor-related state.
 */
export const IS_FOCUSED: WeakMap<Editor, boolean> = new WeakMap()
export const IS_DRAGGING: WeakMap<Editor, boolean> = new WeakMap()
export const IS_CLICKING: WeakMap<Editor, boolean> = new WeakMap()

// /**
//  * Weak map for associating the context `onChange` context with the plugin.
//  */
// export const EDITOR_TO_ON_CHANGE = new WeakMap<Editor, () => void>()

// 正在更新,但尚未更新完的节点 path ,临时记录下
// 例如,table 插入 col ,需要一行一行的插入,在更新期间,不能收到其他的(如 normalize)干扰
export const CHANGING_NODE_PATH: WeakMap<Editor, Path> = new WeakMap()

// 保存 editor -> selection ,用于还原 editor 选区
export const EDITOR_TO_SELECTION: WeakMap<Editor, Range> = new WeakMap()

// editor -> eventEmitter 自定义事件
export const EDITOR_TO_EMITTER: WeakMap<Editor, Emitter> = new WeakMap()

// editor 是否可执行粘贴
export const EDITOR_TO_CAN_PASTE: WeakMap<Editor, boolean> = new WeakMap()


================================================
FILE: packages/core/tsconfig.json
================================================
{
  "compilerOptions": {},
  "extends": "../../tsconfig.json",
  "include": [
    "./src/**/*",
    "../custom-types.d.ts"
  ]
}

================================================
FILE: packages/custom-types.d.ts
================================================
/**
 * @description 自定义扩展 slate 接口属性
 * @author wangfupeng
 */
import { StyledText } from './basic-modules/src/modules/text-style/custom-types'
import { ColorText } from './basic-modules/src/modules/color/custom-types'
import { FontSizeAndFamilyText } from './basic-modules/src/modules/font-size-family/custom-types'
import { LineHeightElement } from './basic-modules/src/modules/line-height/custom-types'
import { JustifyElement } from './basic-modules/src/modules/justify/custom-types'
import { IndentElement } from './basic-modules/src/modules/indent/custom-types'
import { ParagraphElement } from './basic-modules/src/modules/paragraph/custom-types'
import { LinkElement } from './basic-modules/src/modules/link/custom-types'
import { BlockQuoteElement } from './basic-modules/src/modules/blockquote/custom-types'
import {
  Header1Element,
  Header2Element,
  Header3Element,
  Header4Element,
  Header5Element,
} from './basic-modules/src/modules/header/custom-types'
import { DividerElement } from './basic-modules/src/modules/divider/custom-types'
import { ImageElement } from './basic-modules/src/modules/image/custom-types'
import { TodoElement } from './basic-modules/src/modules/todo/custom-types'
import { PreElement, CodeElement } from './basic-modules/src/modules/code-block/custom-types'
import { VideoElement } from './video-module/src/module/custom-types'
import {
  TableCellElement,
  TableRowElement,
  TableElement,
} from './table-module/src/module/custom-types'
import { ListItemElement } from './list-module/src/module/custom-types'

type PureText = {
  text: string
}

type CustomText = PureText | StyledText | FontSizeAndFamilyText | ColorText

type BaseElement = {
  type: string
  children: Array<CustomElement | CustomText>
}

type CustomElement =
  | BaseElement
  | LineHeightElement
  | JustifyElement
  | IndentElement
  | ParagraphElement
  | LinkElement
  | BlockQuoteElement
  | Header1Element
  | Header2Element
  | Header3Element
  | Header4Element
  | Header5Element
  | DividerElement
  | ImageElement
  | TodoElement
  | PreElement
  | CodeElement
  | VideoElement
  | TableCellElement
  | TableRowElement
  | TableElement
  | ListItemElement

declare module 'slate' {
  interface CustomTypes {
    // 扩展 Text
    Text: CustomText

    // 扩展 Element
    Element: CustomElement
  }
}


================================================
FILE: packages/editor/CHANGELOG.md
================================================
# Change Log

All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

## [5.1.23](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.22...@wangeditor/editor@5.1.23) (2022-11-14)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.22](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.21...@wangeditor/editor@5.1.22) (2022-10-18)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.21](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.20...@wangeditor/editor@5.1.21) (2022-10-04)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.20](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.19...@wangeditor/editor@5.1.20) (2022-09-27)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.19](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.18...@wangeditor/editor@5.1.19) (2022-09-27)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.18](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.17...@wangeditor/editor@5.1.18) (2022-09-16)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.17](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.16...@wangeditor/editor@5.1.17) (2022-09-15)


### Bug Fixes

* customInsert 不触发 onSuccess ([d6f4a1b](https://github.com/wangeditor-team/wangEditor/commit/d6f4a1b1494864b116a1310cce2d9e8632c92c6f))
* focus table 时 isFocused 异常 ([5c52bf3](https://github.com/wangeditor-team/wangEditor/commit/5c52bf33e91b1a4677e7bbc04c5d80698abfeeab))
* 上传视频 - customBrowseAndUpload 缺少 poster ([c24627a](https://github.com/wangeditor-team/wangEditor/commit/c24627aaa4c173c5d435e3077dfe8f6b4a9a87b1))





## [5.1.16](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.15...@wangeditor/editor@5.1.16) (2022-09-14)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.15](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.14...@wangeditor/editor@5.1.15) (2022-08-30)


### Bug Fixes

* checkVideo 增加 poster 参数 ([c0402e1](https://github.com/wangeditor-team/wangEditor/commit/c0402e155470233d256e037d863dab74c026b7f6))





## [5.1.14](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.13...@wangeditor/editor@5.1.14) (2022-07-27)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.13](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.12...@wangeditor/editor@5.1.13) (2022-07-27)


### Bug Fixes

* setHtml 支持空字符串 ([d438157](https://github.com/wangeditor-team/wangEditor/commit/d43815766320d9cb0548bae0415c54ce7b147efb))
* upload file callback error ([bf20e07](https://github.com/wangeditor-team/wangEditor/commit/bf20e07f12ed242b0ab4bb2290d876153a822972))





## [5.1.12](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.11...@wangeditor/editor@5.1.12) (2022-07-22)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.11](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.10...@wangeditor/editor@5.1.11) (2022-07-18)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.10](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.9...@wangeditor/editor@5.1.10) (2022-07-16)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.9](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.8...@wangeditor/editor@5.1.9) (2022-07-14)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.8](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.7...@wangeditor/editor@5.1.8) (2022-07-14)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.7](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.6...@wangeditor/editor@5.1.7) (2022-07-13)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.6](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.5...@wangeditor/editor@5.1.6) (2022-07-12)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.5](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.3...@wangeditor/editor@5.1.5) (2022-07-11)


### Bug Fixes

* 尝试修复 nuxt 报错 issue[#4409](https://github.com/wangeditor-team/wangEditor/issues/4409) ([912f888](https://github.com/wangeditor-team/wangEditor/commit/912f8889a11d962b3ac2c65cb5835f4e8c58c372))





## [5.1.4](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.3...@wangeditor/editor@5.1.4) (2022-07-11)


### Bug Fixes

* 尝试修复 nuxt 报错 issue[#4409](https://github.com/wangeditor-team/wangEditor/issues/4409) ([912f888](https://github.com/wangeditor-team/wangEditor/commit/912f8889a11d962b3ac2c65cb5835f4e8c58c372))





## [5.1.3](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.2...@wangeditor/editor@5.1.3) (2022-07-11)


### Bug Fixes

* scroll 滚动问题 ([bc133e1](https://github.com/wangeditor-team/wangEditor/commit/bc133e1e4ca89ab5042cbc0971578ad144499805))





## [5.1.2](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.1...@wangeditor/editor@5.1.2) (2022-07-11)

**Note:** Version bump only for package @wangeditor/editor





## [5.1.1](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.1.0...@wangeditor/editor@5.1.1) (2022-06-02)

**Note:** Version bump only for package @wangeditor/editor





# [5.1.0](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/editor@5.0.1...@wangeditor/editor@5.1.0) (2022-05-25)


### Features

* editVideoSize ([375eecb](https://github.com/wangeditor-team/wangEditor/commit/375eecba826eac681268c55c47bcd922f7157d63))
* enter menu ([988fc31](https://github.com/wangeditor-team/wangEditor/commit/988fc31f31de3d37dffbf54abb784cceb8e6118d))
* setHtml ([f4f91b8](https://github.com/wangeditor-team/wangEditor/commit/f4f91b883298091e3679ca6b206ae0d796003772))





## 5.0.1 (2022-04-18)


### Bug Fixes

* 不支持 IE 浏览器的提醒 ([70c5cae](https://github.com/wangeditor-team/wangEditor/commit/70c5caefd8f6f663225b7a0b796a035d274ef4e1))
* 打包问题 ([c4e87cc](https://github.com/wangeditor-team/wangEditor/commit/c4e87ccac82bcf90d20b7304aff83745e52fb1b1))
* 更新各包之间依赖版本 ([75c552c](https://github.com/wangeditor-team/wangEditor/commit/75c552cc8ed54765bebb86a7ec5329a7fc79e85f))
* 兼容 AggregateError ([0cbd82d](https://github.com/wangeditor-team/wangEditor/commit/0cbd82d30d350b2313f6373e2b5f6d168e47e1bc))
* 兼容next.js及nuxt.js ([233728e](https://github.com/wangeditor-team/wangEditor/commit/233728eb984f541437c62a1390fa0542b2cc6227))
* 开放几个第三方用的 API ([bdf3e70](https://github.com/wangeditor-team/wangEditor/commit/bdf3e70c52bac71e2056e21237fe4ac9e2b0818f))
* 拼音隐藏 placeholder ([aec1a9f](https://github.com/wangeditor-team/wangEditor/commit/aec1a9f62af8944b7894beeca953076ec73545d5))
* 上传图片 - base64 仍触发上传 + 超出 maxSize 的报错提醒 ([a1d469a](https://github.com/wangeditor-team/wangEditor/commit/a1d469accb7f87f8ea0282a1699d002aaaa4e79a))
* 添加QQ浏览器polyfill ([a1b476a](https://github.com/wangeditor-team/wangEditor/commit/a1b476a0bed52315f3e398c586d73f85996f9431))
* 图片上传,提示 ([3754012](https://github.com/wangeditor-team/wangEditor/commit/37540129dff1212c5ebfd4ca3f4d4e8def735e73))
* 修复 node 环境下报错问题 ([5a635a5](https://github.com/wangeditor-team/wangEditor/commit/5a635a5e8fac942ee214dd22b097e09057abc69c))
* 修复取消链接后撤销再重做报错的问题 ([9b233a9](https://github.com/wangeditor-team/wangEditor/commit/9b233a92c95571235248623a6ca5212eb4237f2a))
* 移除了每个包下的 publishConfig directory 配置 ([16559f0](https://github.com/wangeditor-team/wangEditor/commit/16559f052545c111318be760e64291a521bdcc65))
* 优化选中代码块不应该展示 hoverbar 的交互 ([33dcbd6](https://github.com/wangeditor-team/wangEditor/commit/33dcbd6560dccfbe77e18cfbce8c9f077f19f6cd))
* delete divider ([f04cbd6](https://github.com/wangeditor-team/wangEditor/commit/f04cbd6009099629e3cd41be19d20b6788fe7f28))
* divider - 键盘删除 ([31db059](https://github.com/wangeditor-team/wangEditor/commit/31db0593dbc77fba9b4a719bc0f48f1223afd680))
* example/code-hightlight ([7885988](https://github.com/wangeditor-team/wangEditor/commit/78859884cefc18d15ce2f87507380a78c2ad65e5))
* globalThis 兼容性 ([7a47f4b](https://github.com/wangeditor-team/wangEditor/commit/7a47f4b904815516d3b5749ab652ff80478411bc))
* group-menu 考虑 excludeKeys ([ecc29f3](https://github.com/wangeditor-team/wangEditor/commit/ecc29f3b24992c8dc0adf006d81b0d4a252683c5))
* hoverbar config - 同时选中文字和 table ([8f6b4d1](https://github.com/wangeditor-team/wangEditor/commit/8f6b4d1a20e3b1b75da69b20bd5893ce08a27185))
* hoverbarKeys - text ([59b4840](https://github.com/wangeditor-team/wangEditor/commit/59b48406b4c373ef029a5f5bdb0d15d925a91a0f))
* html 特殊字符 ([b3eb81b](https://github.com/wangeditor-team/wangEditor/commit/b3eb81bc9c4aa15c2ff7451c173de15d6c4552bc))
* i18n - 获取多语言配置 ([9f81597](https://github.com/wangeditor-team/wangEditor/commit/9f815970f8c3c6dddb6bf846ecb672325e80444b))
* i18n 切换语言 ([b3b4642](https://github.com/wangeditor-team/wangEditor/commit/b3b4642c6e72ab0b13b05657745abb87e71c633d))
* insertKeys ([0a89420](https://github.com/wangeditor-team/wangEditor/commit/0a8942050bd0b39afb5bbc55ca7842461a5b98eb))
* link, text hoverbar 选区问题 ([e0b7438](https://github.com/wangeditor-team/wangEditor/commit/e0b7438c89a347f1b0b940d9c11150b72d595529))
* menu 点击多次才能生效 ([6497e39](https://github.com/wangeditor-team/wangEditor/commit/6497e39225a993c4d87f9ffddf20086446a4fbc2))
* normalize when create editor ([2b51962](https://github.com/wangeditor-team/wangEditor/commit/2b5196244a93ad7beb316bfa42e557221967d063))
* parse html - v4 video ([8dca822](https://github.com/wangeditor-team/wangEditor/commit/8dca822f9f1b52fd71dd6e17f0954d6aa016324b))
* qq 浏览器报错 ([8a09ed5](https://github.com/wangeditor-team/wangEditor/commit/8a09ed5d810fc1e2c4d0c529aa1269ed0c06425e))
* readOnly 时菜单还可操作 ([0d4a29b](https://github.com/wangeditor-team/wangEditor/commit/0d4a29bb5ba8b62ac11a09d3f814abcb1fcf46be))
* registerModule ([189981c](https://github.com/wangeditor-team/wangEditor/commit/189981c73db07d5b15ee4c46b1639f76f6f63ba1))
* rename es module filename ([1821d4e](https://github.com/wangeditor-team/wangEditor/commit/1821d4eef49e64efcb41b848849ca7a5e6472044))
* shadow dom 样式缺失 ([2fcb69c](https://github.com/wangeditor-team/wangEditor/commit/2fcb69c866266cc5b0265cff031ae9279d368b84))
* style-to-html - 输入 a 会删除外部的 <a> 标签 ([af1f523](https://github.com/wangeditor-team/wangEditor/commit/af1f523983f2bc4b7eaf9726d4b8a35227ab27dc))
* table - elemToHtml ([e36e609](https://github.com/wangeditor-team/wangEditor/commit/e36e6092ef721723169afc8bf0560a47ac9f4dfc))
* tableCell 中 br 报错 ([8604db7](https://github.com/wangeditor-team/wangEditor/commit/8604db751b622c01fa5391af59328236cf13effc))
* text hoverbar ([c7de4f8](https://github.com/wangeditor-team/wangEditor/commit/c7de4f815d6f5b9e009a3149ed042052576c424e))
* text hoverbar ([efe9a34](https://github.com/wangeditor-team/wangEditor/commit/efe9a34d85f8baaeced27543a7bcd508b50f6bca))
* video - 键盘删除 ([5a6bedd](https://github.com/wangeditor-team/wangEditor/commit/5a6bedd80fa0d758270731f62115637ad7f313d0))


### Features

* 两端对齐 ([e5080d3](https://github.com/wangeditor-team/wangEditor/commit/e5080d3dd102f7a951d8e1f370db834778ecbdfa))
* 上标 下标 ([40dab08](https://github.com/wangeditor-team/wangEditor/commit/40dab085a061ea3e838f0cfa86260c6c6f894c69))
* 上传图片 metaWithUrl ([2485157](https://github.com/wangeditor-team/wangEditor/commit/24851576a1dcc07b1a8931d17a147c3640222e85))
* 增加 enable disable API(删除 setConfig setMenuConfig API) ([984fc50](https://github.com/wangeditor-team/wangEditor/commit/984fc50520061fc34ea08f4136bdeb93dee46564))
* 支持 nodejs 环境 ([484f18c](https://github.com/wangeditor-team/wangEditor/commit/484f18c3abc70d19e51c556f48491c18d390b1e1))
* basic text style module ([005b343](https://github.com/wangeditor-team/wangEditor/commit/005b343573ba98f2d0b8480d034ff6807a499aa3))
* block quote ([c3c87a5](https://github.com/wangeditor-team/wangEditor/commit/c3c87a5c09b311eb14c799df94fc4826aa3f4262))
* bold & header ([8130c23](https://github.com/wangeditor-team/wangEditor/commit/8130c23ad84485a68cf9ca4b53d52fab1cec4e96))
* clearStyle menu ([8002f70](https://github.com/wangeditor-team/wangEditor/commit/8002f707ed04b914180ec36fdca0edf48c815e01))
* code highlight ([42b2f8d](https://github.com/wangeditor-team/wangEditor/commit/42b2f8d192e2433593c11ad0b8424737f6cffb58))
* code-block - part ([a8bcd63](https://github.com/wangeditor-team/wangEditor/commit/a8bcd63d882832ac05a32878df0f767d145e0fa7))
* create editor ([12d98e4](https://github.com/wangeditor-team/wangEditor/commit/12d98e4bee179e9d277ec3ec2ecb827962ed0e75))
* create mode ([63c2eef](https://github.com/wangeditor-team/wangEditor/commit/63c2eef9a9a0a2838dfadd23483de35a76f88b0b))
* customPaste ([0f25f5c](https://github.com/wangeditor-team/wangEditor/commit/0f25f5cae3a2cd5ae5832f3fc1026b3ab6d047e0))
* divider menu ([5262634](https://github.com/wangeditor-team/wangEditor/commit/526263445616725541bf374b80260e73b1d4c6ec))
* drag resize image ([cd72028](https://github.com/wangeditor-team/wangEditor/commit/cd72028f1786e2e53079ad5cbef1b8569731ca79))
* editor 生命周期,自定义事件 ([00e9bc2](https://github.com/wangeditor-team/wangEditor/commit/00e9bc2cfcb8b622764db1c76394491d72ffd93e))
* editor with-selection plugin ([9f0a39f](https://github.com/wangeditor-team/wangEditor/commit/9f0a39fecf6d92888d2a97929820d3be038efb31))
* editor.isSelectedAll ([960c845](https://github.com/wangeditor-team/wangEditor/commit/960c8455f85a6bc7350f9944be80b3997bc1fea1))
* editor.showProgressBar ([51761d4](https://github.com/wangeditor-team/wangEditor/commit/51761d466ab3ef7c99e872954d4724ab51d8e28c))
* emotion ([736f955](https://github.com/wangeditor-team/wangEditor/commit/736f955211287bafca2375de3c8193cd0aa0856f))
* font-size + font-family ([cc649e0](https://github.com/wangeditor-team/wangEditor/commit/cc649e0918ce58e78b4d5ee49a400197b9d04b70))
* fullScreen ([e7ccd88](https://github.com/wangeditor-team/wangEditor/commit/e7ccd88a7dd58f64b7bd484de428e3a76cc994f7))
* getElemsByTypePrefix (删掉 getHeaders) ([c18834b](https://github.com/wangeditor-team/wangEditor/commit/c18834b3ebfd97fb36ccbe0faa84e6fe8c30eb67))
* header button menu ([6413135](https://github.com/wangeditor-team/wangEditor/commit/64131354d54705e11fd6992fcf5a4389371c3560))
* hover bar ([107356e](https://github.com/wangeditor-team/wangEditor/commit/107356eff7bfaf53ce25e39244f8133c80518375))
* i18n ([c11b244](https://github.com/wangeditor-team/wangEditor/commit/c11b2440f91b99d40bca18b675c66a22b6e160c9))
* image menu - width 50% 100% ([f9b4c68](https://github.com/wangeditor-team/wangEditor/commit/f9b4c68dff3232b50491b07949c20eb4c18baa6b))
* image menus & position ([bf5beba](https://github.com/wangeditor-team/wangEditor/commit/bf5beba7b3014d63f0b9fe0063530c8b101a5011))
* indent menu + groupMenu ([08db901](https://github.com/wangeditor-team/wangEditor/commit/08db901cd3a3f2ddb2173cc4b36d471e4e68237e))
* insert link ([b04242f](https://github.com/wangeditor-team/wangEditor/commit/b04242ffa252d4088f5360c3de45c24d6f493552))
* justify ([2ed7b88](https://github.com/wangeditor-team/wangEditor/commit/2ed7b883ca759dc4a9e0eefbd23cfe603a0f46fd))
* line-height menu ([755a752](https://github.com/wangeditor-team/wangEditor/commit/755a752d76803423f2794b85004d75055c9b54ec))
* list menu ([fe6c083](https://github.com/wangeditor-team/wangEditor/commit/fe6c0830b2c43e335e5972f85096f490694bbe19))
* menu color - part ([3a6cc86](https://github.com/wangeditor-team/wangEditor/commit/3a6cc86a7f9133d0862310c408abafb30c531734))
* menu color & dropPanel & menu config ([5d0d41b](https://github.com/wangeditor-team/wangEditor/commit/5d0d41b9a765a7deb583393f129925414c36ef35))
* modal appendTo body ([fc0ab06](https://github.com/wangeditor-team/wangEditor/commit/fc0ab06d5c7177eceb04643234a8c301ca4de396))
* parse html ([2a5eace](https://github.com/wangeditor-team/wangEditor/commit/2a5eace00f33cded50b68e8164748ec2480213fd))
* parse src (link image video) ([715a841](https://github.com/wangeditor-team/wangEditor/commit/715a841fc6c730ee2b448a1799a07ce778128aad))
* selectList ([b7366ab](https://github.com/wangeditor-team/wangEditor/commit/b7366ab2dafd379145d85881052d6f400bd13c85))
* shadow dom example ([c55f38d](https://github.com/wangeditor-team/wangEditor/commit/c55f38dab7886b9115c4353118d1f182885878ae))
* table module ([a397116](https://github.com/wangeditor-team/wangEditor/commit/a397116de73e088232d9c41828f30f8d56a22dd4))
* table module - header + fullWidth ([9a8a0e0](https://github.com/wangeditor-team/wangEditor/commit/9a8a0e093af944ee7deab674f47c2ec7baae0e63))
* text and toolbar ([3ae5d0c](https://github.com/wangeditor-team/wangEditor/commit/3ae5d0c4138fec7397ac8629e0012affe6b7dfa4))
* todo ([9608fef](https://github.com/wangeditor-team/wangEditor/commit/9608fef2ff86368cdcbb950a74af1246a58709de))
* toHtml 机制 ([1c4d872](https://github.com/wangeditor-team/wangEditor/commit/1c4d8729f84aaab6a448f23064b34a20596305e9))
* toolbar config - insertKeys ([a2f3c4b](https://github.com/wangeditor-team/wangEditor/commit/a2f3c4be3762831723495bbc9d50eb6c9b05d195))
* toolbar excludeKeys ([09bd196](https://github.com/wangeditor-team/wangEditor/commit/09bd196ea24c19b04e5e7e38227ca94332847bf8))
* tooltip ([994d875](https://github.com/wangeditor-team/wangEditor/commit/994d875fee81cf01271c2e440c1df202aa067d0e))
* undo redo - menu ([bfb3014](https://github.com/wangeditor-team/wangEditor/commit/bfb3014791cfcb2d7897f916bf280a57b1906e4d))
* updateLink + unLink + viewLink ([254d554](https://github.com/wangeditor-team/wangEditor/commit/254d55466b3c8527dd9f0bf34681abd801c8c8ce))
* upload image ([0a0564b](https://github.com/wangeditor-team/wangEditor/commit/0a0564bf14edd4dea6eb958e653272a9a216cec1))
* upload video ([ac8e6f8](https://github.com/wangeditor-team/wangEditor/commit/ac8e6f8b5258e593714676a6f6be359ba525833c))
* video menu ([c1faa1c](https://github.com/wangeditor-team/wangEditor/commit/c1faa1cfa896e1d240f5a2a100e1fd9b89dbef0b))


================================================
FILE: packages/editor/README-en.md
================================================
# wangEditor editor

[中文](./README.md)

Open source web rich text editor, run right out of the box. Support JS Vue React.

- [Document](https://www.wangeditor.com/en/)
- [Demo](https://www.wangeditor.com/demo/?lang=en)

![](../../docs/images/editor-en.png)

You can [commit an issue]((https://github.com/wangeditor-team/wangEditor/issues)) if you have any question.


================================================
FILE: packages/editor/README.md
================================================
# wangEditor editor

[English](./README-en.md)

开源 Web 富文本编辑器,开箱即用,配置简单。支持 JS Vue React 。

- [文档](https://www.wangeditor.com/)
- [demo](https://www.wangeditor.com/demo/)

![](../../docs/images/editor.png)

交流
- [提交问题和建议](https://github.com/wangeditor-team/wangEditor/issues)
- 加入 QQ 群([官网](https://www.wangeditor.com/)有群号)


================================================
FILE: packages/editor/__tests__/create.test.ts
================================================
/**
 * @description create editor and toolbar test
 * @author wangfupeng
 */

import { createEditor, createToolbar } from '../../../packages/editor/src/index'
import { ICreateEditorOption, ICreateToolbarOption } from '../../../packages/editor/src/create'

function customCreateEditor(config: Partial<ICreateEditorOption> = {}) {
  const editorContainer = document.createElement('div')
  document.body.appendChild(editorContainer)

  // create editor
  const editor = createEditor({
    selector: editorContainer,
    ...config,
  })

  return editor
}

function customCreateToolbar(config: Partial<ICreateToolbarOption> = {}) {
  const toolbarContainer = document.createElement('div')
  document.body.appendChild(toolbarContainer)

  // create editor
  const editor = customCreateEditor()

  // create toolbar
  const toolbar = createToolbar({
    editor,
    selector: toolbarContainer,
    ...config,
  })

  return toolbar
}

describe('create editor and toolbar', () => {
  test('create editor with default mode', () => {
    const editor = customCreateEditor()

    expect(editor.id).not.toBeNull()
  })

  test('create editor with default mode that has text hoverbar', () => {
    const editor = customCreateEditor()
    const config = editor.getConfig()

    expect(config.hoverbarKeys!.text).not.toBeNull()
  })

  test('create editor with simple mode', () => {
    const editor = customCreateEditor({
      mode: 'simple',
    })
    expect(editor.id).not.toBeNull()
  })

  test('create editor with simple mode that does not has text hoverbar', () => {
    const editor = customCreateEditor({
      mode: 'simple',
    })
    const config = editor.getConfig()

    expect(config.hoverbarKeys!.text).toBeUndefined()
  })

  test('create editor can not be called twice with same container', () => {
    const editorContainer = document.createElement('div')
    document.body.appendChild(editorContainer)
    // create editor
    customCreateEditor({
      selector: editorContainer,
    })

    try {
      customCreateEditor({
        selector: editorContainer,
      })
    } catch (ex) {
      expect(ex.message.indexOf('Repeated create editor by selector')).not.toBe(-1)
    }
  })

  test('create toolbar with default mode', () => {
    const toolbar = customCreateToolbar()
    expect(toolbar.$box).not.toBeNull()
  })

  test('create toolbar with simple mode', () => {
    const toolbar = customCreateToolbar({
      mode: 'simple',
    })
    expect(toolbar.$box).not.toBeNull()
  })

  test('create toolbar with simple mode that the config hoverbarKeys is different from default mode', () => {
    const simpleToolbar = customCreateToolbar({
      mode: 'simple',
    })
    const defaultToolbar = customCreateToolbar()
    expect(simpleToolbar.getConfig().toolbarKeys).not.toEqual(
      defaultToolbar.getConfig().toolbarKeys
    )
  })

  test('create toolbar can not be called twice with same container', () => {
    const toolbarContainer = document.createElement('div')
    document.body.appendChild(toolbarContainer)

    customCreateToolbar({
      selector: toolbarContainer,
    })
    try {
      customCreateToolbar({
        selector: toolbarContainer,
      })
    } catch (ex) {
      expect(ex.message.indexOf('Repeated create toolbar by selector')).not.toBe(-1)
    }
  })

  test('create editor with html', () => {
    const html = `<h1>header</h1>
<p>hello&nbsp;<strong>world</strong>
</p><p><br></p>`

    const editor = customCreateEditor({ html })
    expect(editor.children).toEqual([
      { type: 'header1', children: [{ text: 'header' }] },
      {
        type: 'paragraph',
        children: [{ text: 'hello ' }, { text: 'world', bold: true }],
      },
      { type: 'paragraph', children: [{ text: '' }] },
    ])
  })
})


================================================
FILE: packages/editor/demo/README.md
================================================
# wangEditor demo

修改左侧目录,在 demo 目录搜索 `MENU_CONF`

demo 部署参考 `deploy-demos.yml` 配置


================================================
FILE: packages/editor/demo/catalog.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor catalog</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>

  <style>
    #header-container {
      list-style-type: none;
      padding-left: 20px;
    }

    #header-container li {
      color: #333;
      margin: 10px 0;
      cursor: pointer;
    }

    #header-container li:hover {
      text-decoration: underline;
    }

    #header-container li[type="header1"] {
      font-size: 20px;
      font-weight: bold;
    }

    #header-container li[type="header2"] {
      font-size: 16px;
      padding-left: 15px;
      font-weight: bold;
    }

    #header-container li[type="header3"] {
      font-size: 14px;
      padding-left: 30px;
    }

    #header-container li[type="header4"] {
      font-size: 12px;
      padding-left: 45px;
    }

    #header-container li[type="header5"] {
      font-size: 12px;
      padding-left: 60px;
    }
  </style>
</head>

<body>
  <demo-nav title="wangEditor catalog"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right" style="display: flex;">
      <!-- 编辑器 DOM -->
      <div style="border: 1px solid #ccc; flex: 1; width: calc(100vw - 370px);">
        <div id="editor-toolbar" style="border-bottom: 1px solid #ccc;"></div>
        <div id="editor-text-area" style="height: 600px"></div>
      </div>

      <!-- 标题目录 -->
      <div style="width: 200px; background-color: #f1f1f1;">
        <ul id="header-container"></ul>
      </div>
    </div>
  </div>

  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // 切换语言
    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG)

    // 标题 DOM 容器
    const headerContainer = document.getElementById('header-container')
    headerContainer.addEventListener('mousedown', event => {
      if (event.target.tagName !== 'LI') return
      event.preventDefault()
      const id = event.target.id
      editor.scrollToElem(id) // 滚动到标题
    })

    window.editor = E.createEditor({
      selector: '#editor-text-area',
      html: '<h1>标题</h1><h2>标题A</h2><p>文本</p><p>文本</p><p>文本</p><h3>标题A1</h3><p>文本</p><p>文本</p><p>文本</p><h3>标题A2</h3><p>文本</p><p>文本</p><p>文本</p><h2>标题B</h2><p>文本</p><p>文本</p><p>文本</p><h3>标题B1</h3><p>文本</p><p>文本</p><p>文本</p><h3>标题B2</h3><p>文本</p><p>文本</p><p>文本</p>',
      config: {
        placeholder: 'Type here...',
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-fileName',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          const headers = editor.getElemsByTypePrefix('header')
          headerContainer.innerHTML = headers.map(header => {
            const text = E.SlateNode.string(header)
            const { id, type } = header
            return `<li id="${id}" type="${type}">${text}</li>`
          }).join('')
        }
      }
    })

    window.toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {}
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/code-highlight.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor code highlight</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>

  <!-- 引入 prism css -->
  <!-- <link href="https://cdn.jsdelivr.net/npm/prismjs@latest/themes/prism.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/prismjs@latest/themes/prism.css" rel="stylesheet">
</head>

<body>
  <demo-nav title="wangEditor code highlight"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">

      <!-- 静态 -->
      <div style="border: 1px solid #ccc; padding: 0 10px; border-radius: 5px;">
        <p>wangEditor 输出的 Javascript 代码:</p>
        <pre><code class="language-javascript">const a = 100;
function fn(x) { return x + 10 };
// 注释
</code></pre>

        <p>wangEditor 输出的 HTML 代码:</p>
        <pre><code class="language-html">&lt;div&gt;text1&lt;/div&gt;</code></pre>
      </div>

      <!-- 动态 -->
      <div id="content-container"
        style="border: 1px solid #ccc; padding: 0 10px; margin-top: 15px; border-radius: 5px;"></div>
      <div style="margin-top: 15px;">
        <button id="btn-render">Dynamic render HTML</button>
      </div>
    </div>
  </div>

  <!-- 引入 prism js -->
  <!-- <script src="https://cdn.jsdelivr.net/npm/prismjs@latest/prism.min.js"></script> -->
  <script src="https://unpkg.com/prismjs@latest/prism.js"></script>
  <!-- <script src="https://cdn.jsdelivr.net/npm/prismjs@latest/components/prism-core.min.js"></script> -->
  <script src="https://unpkg.com/prismjs@latest/components/prism-core.js"></script>
  <!-- <script src="https://cdn.jsdelivr.net/npm/prismjs@latest/plugins/autoloader/prism-autoloader.min.js"></script> -->
  <script src="https://unpkg.com/prismjs@latest/plugins/autoloader/prism-autoloader.js"></script>

  <script>
    const html = `<p>wangEditor&nbsp;输入的 Javascript 代码:</p><pre><code class="language-javascript">function fn(a) {
  if (typeof a === 'number') {
      return a + 100 // comments
  }
  return 0
}</code></pre><p><br></p>`

    document.getElementById('btn-render').addEventListener('click', () => {
      document.getElementById('content-container').innerHTML = html
      Prism.highlightAll()
    })

  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/css/layout.css
================================================
/* body {
  margin: 20px;
} */

.page-container {
  margin-top: 15px;
  display: flex;
}

.page-left {
  width: 150px;
  padding: 0 10px;
}

.page-right {
  padding: 0 10px;
  flex: 1;
  width: calc(100vw - 170px);
}

================================================
FILE: packages/editor/demo/css/view.css
================================================
.editor-content-view {
  border: 3px solid #ccc;
  border-radius: 5px;
  padding: 0 10px;
  margin-top: 20px;
  overflow-x: auto;
}

.editor-content-view p,
.editor-content-view li {
  white-space: pre-wrap; /* 保留空格 */
}

.editor-content-view blockquote {
  border-left: 8px solid #d0e5f2;
  padding: 10px 10px;
  margin: 10px 0;
  background-color: #f1f1f1;
}

.editor-content-view code {
  font-family: monospace;
  background-color: #eee;
  padding: 3px;
  border-radius: 3px;
}
.editor-content-view pre>code {
  display: block;
  padding: 10px;
}

.editor-content-view table {
  border-collapse: collapse;
}
.editor-content-view td,
.editor-content-view th {
  border: 1px solid #ccc;
  min-width: 50px;
  height: 20px;
}
.editor-content-view th {
  background-color: #f1f1f1;
}

.editor-content-view ul,
.editor-content-view ol {
  padding-left: 20px;
}

.editor-content-view input[type="checkbox"] {
  margin-right: 5px;
}

================================================
FILE: packages/editor/demo/extend-menu-drop-panel.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor extend dropPanel menu</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">
  <style>
    .w-e-panel-my-list {
      text-align: left;
    }

    .w-e-panel-my-list li {
      display: inline;
      cursor: pointer;
      padding: 3px 5px;
    }

    .w-e-panel-my-list li:hover {
      background-color: #f1f1f1;
    }
  </style>

  <script src="./js/custom-elem.js"></script>
</head>

<body>
  <demo-nav title="wangEditor extend dropPanel menu"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">
      <!-- 编辑器 DOM -->
      <div style="border: 1px solid #ccc;">
        <div id="editor-toolbar" style="border-bottom: 1px solid #ccc;"></div>
        <div id="editor-text-area" style="height: 500px"></div>
      </div>

      <!-- 内容状态 -->
      <p style="background-color: #f1f1f1;">
        Text length: <span id="total-length"></span>;
        Selected text length: <span id="selected-length"></span>;
      </p>
    </div>
  </div>

  <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG) // 切换语言



    // Extend menu
    class MyMenu {
      constructor() {
        this.title = 'My menu'
        // this.iconSvg = '<svg >...</svg>'
        this.tag = 'button'
        this.showDropPanel = true
      }
      getValue(editor) {
        return ''
      }
      isActive(editor) {
        return false // or true
      }
      isDisabled(editor) {
        return false // or true
      }
      exec(editor, value) {
        // do nothing 什么都不用做
      }
      getPanelContentElem(editor) {
        const $list = $(`<ul class="w-e-panel-my-list">
            <li>北京</li>
            <li>上海</li>
            <li>深圳</li>
            <li>广州</li>
            <li>天津</li>
            <li>成都</li>
            <li>南京</li>
            <li>郑州</li>
          </ul>`)

        $list.on('click', 'li', function () {
          editor.insertText(this.innerHTML)
          editor.insertText(' ')
        })

        return $list[0]

        // PS:也可以把 $list 缓存下来,这样不用每次重复创建、重复绑定事件,优化性能
      }
    }
    const myMenuConf = {
      key: 'myMenu',
      factory() {
        return new MyMenu()
      }
    }
    E.Boot.registerMenu(myMenuConf)



    window.editor = E.createEditor({
      selector: '#editor-text-area',
      html: '<p><br></p>',
      config: {
        placeholder: 'Type here...',
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-fileName',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          console.log(editor.getHtml())

          // 选中文字
          const selectionText = editor.getSelectionText()
          document.getElementById('selected-length').innerHTML = selectionText.length
          // 全部文字
          const text = editor.getText().replace(/\n|\r/mg, '')
          document.getElementById('total-length').innerHTML = text.length
        }
      }
    })

    window.toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {
        insertKeys: {
          index: 0,
          keys: ['myMenu'], // show menu in toolbar
        }
      }
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/extend-menu-modal.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor extend modal menu</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>
</head>

<body>
  <demo-nav title="wangEditor extend modal menu"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">
      <!-- 编辑器 DOM -->
      <div style="border: 1px solid #ccc;">
        <div id="editor-toolbar" style="border-bottom: 1px solid #ccc;"></div>
        <div id="editor-text-area" style="height: 500px"></div>
      </div>

      <!-- 内容状态 -->
      <p style="background-color: #f1f1f1;">
        Text length: <span id="total-length"></span>;
        Selected text length: <span id="selected-length"></span>;
      </p>
    </div>
  </div>

  <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG) // 切换语言



    // Extend menu
    class MyMenu {
      constructor() {
        this.title = 'My menu'
        // this.iconSvg = '<svg >...</svg>'
        this.tag = 'button'
        this.showModal = true
        this.modalWidth = 300
      }
      getValue(editor) {
        return ''
      }
      isActive(editor) {
        return false // or true
      }
      isDisabled(editor) {
        return false // or true
      }
      exec(editor, value) {
        // do nothing 什么都不用做
      }
      getModalPositionNode(editor) {
        return null // modal 依据选区定位
      }
      getModalContentElem(editor) {
        const $container = $('<div></div>')

        const inputId = `input-${Math.random().toString(16).slice(-8)}`
        const buttonId = `button-${Math.random().toString(16).slice(-8)}`

        const $inputContainer = $(`<label class="babel-container">
            <span>Text</span>
            <input type="text" id="${inputId}" value="hello world">
          </label>`)
        const $buttonContainer = $(`<div class="button-container">
            <button id="${buttonId}">insert text</button>
          </div>`)

        $container.append($inputContainer).append($buttonContainer)

        $container.on('click', `#${buttonId}`, e => {
          e.preventDefault()

          const text = $(`#${inputId}`).val()
          if (!text) return

          editor.restoreSelection() // 恢复选区
          editor.insertText(text)
          editor.insertText(' ')
        })

        setTimeout(() => {
          $(`#${inputId}`).focus()
        })

        return $container[0]

        // PS:也可以把 $container 缓存下来,这样不用每次重复创建、重复绑定事件,优化性能
      }
    }
    const myMenuConf = {
      key: 'myMenu',
      factory() {
        return new MyMenu()
      }
    }
    E.Boot.registerMenu(myMenuConf)



    window.editor = E.createEditor({
      selector: '#editor-text-area',
      html: '<p><br></p>',
      config: {
        placeholder: 'Type here...',
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-fileName',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          console.log(editor.getHtml())

          // 选中文字
          const selectionText = editor.getSelectionText()
          document.getElementById('selected-length').innerHTML = selectionText.length
          // 全部文字
          const text = editor.getText().replace(/\n|\r/mg, '')
          document.getElementById('total-length').innerHTML = text.length
        }
      }
    })

    window.toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {
        insertKeys: {
          index: 0,
          keys: ['myMenu'], // show menu in toolbar
        }
      }
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/extend-menu-select.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor extend select menu</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>
</head>

<body>
  <demo-nav title="wangEditor extend select menu"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">
      <!-- 编辑器 DOM -->
      <div style="border: 1px solid #ccc;">
        <div id="editor-toolbar" style="border-bottom: 1px solid #ccc;"></div>
        <div id="editor-text-area" style="height: 500px"></div>
      </div>

      <!-- 内容状态 -->
      <p style="background-color: #f1f1f1;">
        Text length: <span id="total-length"></span>;
        Selected text length: <span id="selected-length"></span>;
      </p>
    </div>
  </div>

  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG) // 切换语言



    class MyMenuClass {
      constructor() {
        this.title = 'My Select Menu',
          // this.iconSvg = '<svg>...</svg>'
          this.tag = 'select'
        this.width = 60
      }

      getOptions(editor) {
        const options = [
          { value: 'beijing', text: '北京', styleForRenderMenuList: { 'font-size': '32px', 'font-weight': 'bold' } },
          { value: 'shanghai', text: '上海', selected: true },
          { value: 'shenzhen', text: '深圳' }
        ]
        return options
      }

      getValue(editor) {
        return 'shanghai' // 匹配 options 其中一个 value
      }
      isActive(editor) {
        return false // or true
      }
      isDisabled(editor) {
        return false // or true
      }
      exec(editor, value) {
        editor.insertText(value) // value 即 this.getValue(editor) 的返回值
        editor.insertText(' ')
      }
    }

    const myMenuConf = {
      key: 'myMenu',
      factory() {
        return new MyMenuClass()
      }
    }
    E.Boot.registerMenu(myMenuConf)



    window.editor = E.createEditor({
      selector: '#editor-text-area',
      html: '<p><br></p>',
      config: {
        placeholder: 'Type here...',
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-fileName',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          console.log(editor.getHtml())

          // 选中文字
          const selectionText = editor.getSelectionText()
          document.getElementById('selected-length').innerHTML = selectionText.length
          // 全部文字
          const text = editor.getText().replace(/\n|\r/mg, '')
          document.getElementById('total-length').innerHTML = text.length
        }
      }
    })

    window.toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {
        insertKeys: {
          index: 0,
          keys: ['myMenu'], // show menu in toolbar
        }
      }
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/extend-menu.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor extend menu</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>
</head>

<body>
  <demo-nav title="wangEditor extend menu"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">
      <!-- 编辑器 DOM -->
      <div style="border: 1px solid #ccc;">
        <div id="editor-toolbar" style="border-bottom: 1px solid #ccc;"></div>
        <div id="editor-text-area" style="height: 500px"></div>
      </div>

      <!-- 内容状态 -->
      <p style="background-color: #f1f1f1;">
        Text length: <span id="total-length"></span>;
        Selected text length: <span id="selected-length"></span>;
      </p>
    </div>
  </div>

  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG) // 切换语言



    // Extend menu
    class MyMenu {
      constructor() {
        this.title = 'My menu'
        // this.iconSvg = '<svg >...</svg>'
        this.tag = 'button'
      }
      getValue(editor) {
        return ' hello '
      }
      isActive(editor) {
        return false // or true
      }
      isDisabled(editor) {
        return false // or true
      }
      exec(editor, value) {
        editor.insertText(value) // value 即 this.getValue(editor) 的返回值
      }
    }
    const myMenuConf = {
      key: 'myMenu',
      factory() {
        return new MyMenu()
      }
    }
    E.Boot.registerMenu(myMenuConf)



    window.editor = E.createEditor({
      selector: '#editor-text-area',
      html: '<p><br></p>',
      config: {
        placeholder: 'Type here...',
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-fileName',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          console.log(editor.getHtml())

          // 选中文字
          const selectionText = editor.getSelectionText()
          document.getElementById('selected-length').innerHTML = selectionText.length
          // 全部文字
          const text = editor.getText().replace(/\n|\r/mg, '')
          document.getElementById('total-length').innerHTML = text.length
        }
      }
    })

    window.toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {
        insertKeys: {
          index: 0,
          keys: ['myMenu'], // show menu in toolbar
        }
      }
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/get-html.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor get HTML</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">
  <link href="./css/view.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>
</head>

<body>
  <demo-nav title="wangEditor get HTML"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">
      <!-- 编辑器 DOM -->
      <div style="border: 1px solid #ccc;">
        <div id="editor-toolbar" style="border-bottom: 1px solid #ccc;"></div>
        <div id="editor-text-area" style="height: 350px"></div>
      </div>

      <!-- 显示内容 -->
      <div style="margin-top: 20px;">
        <textarea id="editor-content-textarea" style="width: 100%; height: 100px; outline: none;" readonly></textarea>
      </div>
      <div id="editor-content-view" class="editor-content-view"></div>
    </div>
  </div>

  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // 切换语言
    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG)

    window.editor = E.createEditor({
      selector: '#editor-text-area',
      html: '<p>hello&nbsp;world</p><p><br></p>',
      config: {
        placeholder: 'Type here...',
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-fileName',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          const html = editor.getHtml()
          document.getElementById('editor-content-view').innerHTML = html
          document.getElementById('editor-content-textarea').value = html
        }
      }
    })

    window.toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {}
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/huge-doc.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor huge doc</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>
  <script src="./js/huge-content.js"></script>
</head>

<body>
  <demo-nav title="wangEditor huge doc"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">
      <!-- 编辑器 DOM -->
      <div style="border: 1px solid #ccc;">
        <div id="editor-toolbar" style="border-bottom: 1px solid #ccc;"></div>
        <div id="editor-text-area" style="height: 700px"></div>
      </div>

      <!-- 内容状态 -->
      <p style="background-color: #f1f1f1;">
        Text length: <span id="total-length"></span>
      </p>
    </div>
  </div>

  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // 切换语言
    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG)

    window.editor = E.createEditor({
      selector: '#editor-text-area',
      content: window.HUGE_CONTENT,
      config: {
        placeholder: 'Type here...',
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-fileName',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          const text = editor.getText().replace(/\n|\r/mg, '')
          document.getElementById('total-length').innerHTML = text.length
        }
      }
    })

    window.toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {}
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/index.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor default mode</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>
</head>

<body>
  <demo-nav title="wangEditor default mode"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">
      <!-- 编辑器 DOM -->
      <div style="border: 1px solid #ccc;">
        <div id="editor-toolbar" style="border-bottom: 1px solid #ccc;"></div>
        <div id="editor-text-area" style="height: 500px"></div>
      </div>

      <!-- 内容状态 -->
      <p style="background-color: #f1f1f1;">
        Text length: <span id="total-length"></span>;
        Selected text length: <span id="selected-length"></span>;
      </p>
    </div>
  </div>

  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // 切换语言
    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG)

    window.editor = E.createEditor({
      selector: '#editor-text-area',
      html: '<p><br></p>',
      config: {
        placeholder: 'Type here...',
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-fileName',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          console.log(editor.getHtml())

          // 选中文字
          const selectionText = editor.getSelectionText()
          document.getElementById('selected-length').innerHTML = selectionText.length
          // 全部文字
          const text = editor.getText().replace(/\n|\r/mg, '')
          document.getElementById('total-length').innerHTML = text.length
        }
      }
    })

    window.toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {}
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/js/custom-elem.js
================================================
/**
 * @description 自定义 elem
 * @author wangfupeng
 */

// ------------------------------------------ native-shim start ------------------------------------------

// 参考 https://github.com/webcomponents/custom-elements/blob/master/src/native-shim.js
/**
 * @license
 * Copyright (c) 2016 The Polymer Project Authors. All rights reserved.
 * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
 * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
 * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
 * Code distributed by Google as part of the polymer project is also
 * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
 */

/**
 * This shim allows elements written in, or compiled to, ES5 to work on native
 * implementations of Custom Elements v1. It sets new.target to the value of
 * this.constructor so that the native HTMLElement constructor can access the
 * current under-construction element's definition.
 */
;(function () {
  if (
    // No Reflect, no classes, no need for shim because native custom elements
    // require ES2015 classes or Reflect.
    window.Reflect === undefined ||
    window.customElements === undefined ||
    // The webcomponentsjs custom elements polyfill doesn't require
    // ES2015-compatible construction (`super()` or `Reflect.construct`).
    window.customElements.polyfillWrapFlushCallback
  ) {
    return
  }
  const BuiltInHTMLElement = HTMLElement
  /**
   * With jscompiler's RECOMMENDED_FLAGS the function name will be optimized away.
   * However, if we declare the function as a property on an object literal, and
   * use quotes for the property name, then closure will leave that much intact,
   * which is enough for the JS VM to correctly set Function.prototype.name.
   */
  const wrapperForTheName = {
    HTMLElement: /** @this {!Object} */ function HTMLElement() {
      return Reflect.construct(BuiltInHTMLElement, [], /** @type {!Function} */ this.constructor)
    },
  }
  window.HTMLElement = wrapperForTheName['HTMLElement']
  HTMLElement.prototype = BuiltInHTMLElement.prototype
  HTMLElement.prototype.constructor = HTMLElement
  Object.setPrototypeOf(HTMLElement, BuiltInHTMLElement)
})()
// ------------------------------------------ native-shim end ------------------------------------------

// ------------------------------------------ 顶部导航 start ------------------------------------------
!(function () {
  // 当前语言
  const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'

  // 自定义组件
  class MyNav extends HTMLElement {
    constructor() {
      super()

      const shadow = this.attachShadow({ mode: 'open' })
      const document = shadow.ownerDocument

      const style = document.createElement('style')
      style.innerHTML = `
      .container {
        display: flex;
        padding: 10px;
        background-color: #4474c8;
        color: #fff;
      }
      .container a {
        color: #fff;
        text-decoration: none;
      }
      .container h1 {
        flex: 1;
        margin: 0;
        font-size: 26px;
      }
      .container .right-container {
        width: 200px;
        text-align: right;
        line-height: 26px;
      }
    `
      shadow.appendChild(style)

      // 容器
      const container = document.createElement('div')
      container.className = 'container'

      // 标题
      const header = document.createElement('h1')
      header.textContent = ''
      this.header = header

      // 右侧链接
      const rightContainer = document.createElement('div')
      rightContainer.className = 'right-container'
      if (LANG === 'en') {
        rightContainer.innerHTML = `
        <a href="https://www.wangeditor.com/en/">Document</a>
        &nbsp;
        <a href="https://github.com/wangeditor-team/wangEditor/tree/master/packages/editor/demo">Source</a>
      `
      } else {
        rightContainer.innerHTML = `
        <a href="https://www.wangeditor.com/">文档</a>
        &nbsp;
        <a href="https://github.com/wangeditor-team/wangEditor/tree/master/packages/editor/demo">源码</a>
      `
      }

      container.appendChild(header)
      container.appendChild(rightContainer)

      shadow.appendChild(container)
    }

    attributeChangedCallback(name, oldValue, newValue) {
      if (name === 'title') {
        if (oldValue == newValue) return
        this.header.textContent = newValue
      }
    }
  }
  MyNav.observedAttributes = ['title']
  window.customElements.define('demo-nav', MyNav)
})()
// ------------------------------------------ 顶部导航 end ------------------------------------------

// ------------------------------------------ 左侧菜单 start ------------------------------------------
// 菜单配置
const MENU_CONF = [
  {
    'zh-CN': { text: '默认模式', link: './index.html' },
    en: { text: 'Default mode', link: './index.html?lang=en' },
  },
  {
    'zh-CN': { text: '简洁模式', link: './simple-mode.html' },
    en: { text: 'Simple mode', link: './simple-mode.html?lang=en' },
  },
  {
    'zh-CN': { text: '获取 HTML', link: './get-html.html' },
    en: { text: 'Get HTML', link: './get-html.html?lang=en' },
  },
  {
    'zh-CN': { text: '设置 HTML', link: './set-html.html' },
    en: { text: 'Set HTML', link: './set-html.html?lang=en' },
  },
  {
    'zh-CN': { text: '模拟腾讯文档', link: './like-qq-doc.html' },
    en: { text: 'Like QQ doc', link: './like-qq-doc.html?lang=en' },
  },
  {
    'zh-CN': {
      text: '上传图片',
      link: 'https://github.com/wangeditor-team/server',
    },
    en: {
      text: 'Upload Image',
      link: 'https://github.com/wangeditor-team/server',
    },
  },
  {
    'zh-CN': {
      text: '上传视频',
      link: 'https://github.com/wangeditor-team/server',
    },
    en: {
      text: 'Upload Video',
      link: 'https://github.com/wangeditor-team/server',
    },
  },
  {
    'zh-CN': { text: '代码高亮', link: './code-highlight.html' },
    en: { text: 'Code highlight', link: './code-highlight.html?lang=en' },
  },
  {
    'zh-CN': { text: '多个编辑器', link: './multi-editor.html' },
    en: { text: 'Multi editor', link: './multi-editor.html?lang=en' },
  },
  {
    'zh-CN': { text: '标题目录', link: './catalog.html' },
    en: { text: 'Catalog', link: './catalog.html?lang=en' },
  },
  {
    'zh-CN': { text: 'Max Length', link: './max-length.html' },
    en: { text: 'Max Length', link: './max-length.html?lang=en' },
  },
  {
    'zh-CN': { text: '大文件 10w 字', link: './huge-doc.html' },
    en: { text: 'Huge doc', link: './huge-doc.html?lang=en' },
  },
  {
    'zh-CN': {
      text: 'Shadow DOM',
      link: 'https://github.com/wangeditor-team/wangEditor/blob/master/packages/editor/examples/shadow-dom.html',
    },
    en: {
      text: 'Shadow DOM',
      link: 'https://github.com/wangeditor-team/wangEditor/blob/master/packages/editor/examples/shadow-dom.html',
    },
  },
  {
    'zh-CN': { text: '扩展菜单 Button', link: './extend-menu.html' },
    en: { text: 'Extend Button menu', link: './extend-menu.html?lang=en' },
  },
  {
    'zh-CN': { text: '扩展菜单 select', link: './extend-menu-select.html' },
    en: { text: 'Extend select menu', link: './extend-menu-select.html?lang=en' },
  },
  {
    'zh-CN': { text: '扩展菜单 dropPanel', link: './extend-menu-drop-panel.html' },
    en: { text: 'Extend dropPanel menu', link: './extend-menu-drop-panel.html?lang=en' },
  },
  {
    'zh-CN': { text: '扩展菜单 modal', link: './extend-menu-modal.html' },
    en: { text: 'Extend modal menu', link: './extend-menu-modal.html?lang=en' },
  },
  {
    'zh-CN': { text: 'Vue2 demo', link: 'https://www.wangeditor.com/v5/for-frame.html#vue2' },
    en: { text: 'Vue2 demo', link: 'https://www.wangeditor.com/en/v5/for-frame.html#vue2' },
  },
  {
    'zh-CN': { text: 'Vue3 demo', link: 'https://www.wangeditor.com/v5/for-frame.html#vue3' },
    en: { text: 'Vue3 demo', link: 'https://www.wangeditor.com/en/v5/for-frame.html#vue3' },
  },
  {
    'zh-CN': {
      text: 'React demo',
      link: 'https://www.wangeditor.com/v5/for-frame.html#react',
    },
    en: {
      text: 'React demo',
      link: 'https://www.wangeditor.com/en/v5/for-frame.html#react',
    },
  },
  {
    'zh-CN': {
      text: 'Webpack demo',
      link: 'https://github.com/wangfupeng1988/webpack-wangeditor-demo',
    },
    en: { text: 'Webpack demo', link: 'https://github.com/wangfupeng1988/webpack-wangeditor-demo' },
  },
]

!(function () {
  // 当前语言
  const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'

  // 自定义组件
  class MyMenu extends HTMLElement {
    constructor() {
      super()

      const shadow = this.attachShadow({ mode: 'open' })
      const document = shadow.ownerDocument

      const style = document.createElement('style')
      style.innerHTML = `
        ul {
          list-style-type: none;
          margin: 0;
          padding: 0;
        }
        ul li {
          margin: 0;
          margin-bottom: 18px;
        }
        a {
          color: #333;
          text-decoration: none;
        }
        a:hover {
          text-decoration: underline;
        }
      `
      shadow.appendChild(style)

      const container = document.createElement('div')
      container.innerHTML = `<ul>
        ${MENU_CONF.map(item => {
          const { link, text } = item[LANG]
          return `<li><a href="${link}">${text}</a></li>`
        }).join('')}
      </ul>`

      shadow.appendChild(container)
    }
  }
  window.customElements.define('demo-menu', MyMenu)
})()
// ------------------------------------------ 左侧菜单 end ------------------------------------------


================================================
FILE: packages/editor/demo/js/huge-content.js
================================================
;(function () {
  function deepClone(obj) {
    const str = JSON.stringify(obj)
    return JSON.parse(str)
  }

  const header = {
    type: 'header1',
    children: [
      {
        text: '水浒传简介',
      },
    ],
  }
  const text1 =
    '全书通过描写梁山好汉反抗欺压、水泊梁山壮大和受宋朝招安,以及受招安后为宋朝征战,最终消亡的宏大故事,艺术地反映了中国历史上宋江起义从发生、发展直至失败的全过程,深刻揭示了起义的社会根源,满腔热情地歌颂了起义英雄的反抗斗争和他们的社会理想,也具体揭示了起义失败的内在历史原因。'
  const text2 =
    '《水浒传》是中国古典四大名著之一,问世后,在社会上产生了巨大的影响,成了后世中国小说创作的典范。《水浒传》是中国历史上最早用白话文写成的章回小说之一,流传极广,脍炙人口;同时也是汉语言文学中具备史诗特征的作品之一,对中国乃至东亚的叙事文学都有深远的影响。'
  const p1 = {
    type: 'paragraph',
    children: [{ text: text1 }],
  }
  const p2 = {
    type: 'paragraph',
    children: [{ text: text2 }],
  }
  // const code = {
  //   type: 'pre',
  //   children: [
  //     {
  //       type: 'code',
  //       language: 'javascript',
  //       children: [{ text: 'const a = 100;' }],
  //     },
  //   ],
  // }

  // 拼接大文件
  window.HUGE_CONTENT = []
  for (let i = 0; i < 370; i++) {
    window.HUGE_CONTENT.push(deepClone(header))
    window.HUGE_CONTENT.push(deepClone(p1))
    window.HUGE_CONTENT.push(deepClone(p2))
    // window.HUGE_CONTENT.push(deepClone(code))
  }
})()


================================================
FILE: packages/editor/demo/like-qq-doc.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor 仿腾讯文档</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <style>
    html,
    body {
      background-color: #fff;
      height: 100%;
      overflow: hidden;
      color: #333;
    }

    #top-container {
      border-bottom: 1px solid #e8e8e8;
      padding-left: 30px;
    }

    #editor-toolbar {
      width: 1350px;
      background-color: #FCFCFC;
      margin: 0 auto;
    }

    #content {
      height: calc(100% - 40px);
      background-color: rgb(245, 245, 245);
      overflow-y: auto;
      position: relative;
    }

    #editor-container {
      width: 850px;
      margin: 30px auto 150px auto;
      background-color: #fff;
      padding: 20px 50px 50px 50px;
      border: 1px solid #e8e8e8;
      box-shadow: 0 2px 10px rgb(0 0 0 / 12%);
    }

    #title-container {
      padding: 20px 0;
      border-bottom: 1px solid #e8e8e8;
    }

    #title-container input {
      font-size: 30px;
      border: 0;
      outline: none;
      width: 100%;
      line-height: 1;
    }

    #editor-text-area {
      min-height: 900px;
      margin-top: 20px;
    }
  </style>
</head>

<body>
  <div id="top-container">
    <p>
      <a href="./index.html">&lt;&lt; 返回 Back to demo</a>
    </p>
  </div>
  <div style="border-bottom: 1px solid #e8e8e8;">
    <div id="editor-toolbar"></div>
  </div>
  <div id="content">
    <div id="editor-container">
      <div id="title-container">
        <input placeholder="Page title...">
      </div>
      <div id="editor-text-area"></div>
    </div>
  </div>

  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // 切换语言
    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG)

    const editorConfig = {
      placeholder: 'Type here...',
      scroll: false, // 禁止编辑器滚动
      MENU_CONF: {
        uploadImage: {
          fieldName: 'your-fileName',
          base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
        }
      },
      onChange(editor) {
        console.log(editor.getHtml())
      }
    }

    // 先创建 editor
    const editor = E.createEditor({
      selector: '#editor-text-area',
      content: [],
      // html: '',
      config: editorConfig
    })

    // 创建 toolbar
    const toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {
        excludeKeys: 'fullScreen',
      }
    })

    // 点击空白处 focus 编辑器
    document.getElementById('editor-text-area').addEventListener('click', e => {
      if (e.target.id === 'editor-text-area') {
        editor.blur()
        editor.focus(true) // focus 到末尾
      }
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/max-length.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor maxlength</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>
</head>

<body>
  <demo-nav title="wangEditor maxlength"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">
      <!-- 编辑器 DOM -->
      <div style="border: 1px solid #ccc;">
        <div id="editor-toolbar" style="border-bottom: 1px solid #ccc;"></div>
        <div id="editor-text-area" style="height: 500px"></div>
      </div>

      <!-- 内容状态 -->
      <p style="background-color: #f1f1f1;">
        Text length: <span id="total-length"></span>;
        Selected text length: <span id="selected-length"></span>;
      </p>
    </div>
  </div>

  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // 切换语言
    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG)

    // 定义最大长度
    const MAX_LENGTH = 30

    window.editor = E.createEditor({
      selector: '#editor-text-area',
      html: `<p>MaxLength: ${MAX_LENGTH}</p><p><br></p>`,
      config: {
        placeholder: 'Type here...',
        maxLength: MAX_LENGTH,
        onMaxLength(editor) {
          alert('Trigger maxlength callback')
        },
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-fileName',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          console.log(editor.getHtml())

          // 选中文字
          const selectionText = editor.getSelectionText()
          document.getElementById('selected-length').innerHTML = selectionText.length
          // 全部文字
          const text = editor.getText().replace(/\n|\r/mg, '')
          document.getElementById('total-length').innerHTML = text.length
        }
      }
    })

    window.toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {}
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/multi-editor.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor multi editor</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">
  <link href="./css/view.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>
</head>

<body>
  <demo-nav title="wangEditor multi editor"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">

      <div style="display: flex;">
        <div style="flex: 1">
          <div style="border: 1px solid #ccc; margin-right: 5px;">
            <div id="editor-toolbar-1" style="border-bottom: 1px solid #ccc;"></div>
            <div id="editor-text-area-1" style="height: 400px;"></div>
          </div>
          <div id="content-view-1" class="editor-content-view"></div>
        </div>
        <div style="flex: 1">
          <div style="border: 1px solid #ccc; margin-left: 5px;">
            <div id="editor-toolbar-2" style="border-bottom: 1px solid #ccc;"></div>
            <div id="editor-text-area-2" style="height: 400px;"></div>
          </div>
          <div id="content-view-2" class="editor-content-view"></div>
        </div>
      </div>

    </div>
  </div>

  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // 切换语言
    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG)

    // 第一个编辑器
    const editor1 = E.createEditor({
      selector: '#editor-text-area-1',
      config: {
        placeholder: 'Type here...',
        autoFocus: false,
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-file-name1',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          document.getElementById('content-view-1').innerHTML = editor.getHtml()
        }
      },
      html: '<p>editor1</p><p><br></p>'
    })
    const toolbar1 = E.createToolbar({
      editor: editor1,
      selector: '#editor-toolbar-1',
      config: {}
    })

    // 第二个编辑器
    const editor2 = E.createEditor({
      selector: '#editor-text-area-2',
      config: {
        placeholder: 'Type here...',
        autoFocus: false,
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-file-name2',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          document.getElementById('content-view-2').innerHTML = editor.getHtml()
        }
      },
      html: '<p>editor2</p><p><br></p>',
      mode: 'simple'
    })
    const toolbar2 = E.createToolbar({
      editor: editor2,
      selector: '#editor-toolbar-2',
      config: {}
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/set-html.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor set HTML</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">
  <link href="./css/view.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>
</head>

<body>
  <demo-nav title="wangEditor set HTML"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">
      <textarea id="editor-content-textarea" style="width: 100%; height: 100px; outline: none;"></textarea>
      <div style="margin-top: 10px;">
        <button id="btn-set-html">Set HTML</button>
      </div>

      <!-- 编辑器 DOM -->
      <div style="border: 1px solid #ccc; margin-top: 20px;">
        <div id="editor-toolbar" style="border-bottom: 1px solid #ccc;"></div>
        <div id="editor-text-area" style="height: 350px"></div>
      </div>
    </div>
  </div>

  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // 切换语言
    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG)

    window.editor = E.createEditor({
      selector: '#editor-text-area',
      html: '<p>编辑器创建时的默认内容。</p><p>Default content set when editor created.</p><p><br></p>',
      config: {
        placeholder: 'Type here...',
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-fileName',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange(editor) {
          const html = editor.getHtml()
          console.log(html)
        }
      }
    })

    window.toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {}
    })

    // textarea 初始化值
    const textarea = document.getElementById('editor-content-textarea')
    textarea.value = '<p>wangEditor 只识别 editor.getHtml() 生成的 html 格式,不可以随意自定义 html 代码(html 格式太灵活了,不会全部兼容)</p>\n<p>wangEditor can only understand the HTML format from editor.getHtml() , but not all HTML formats.</p>\n<p><br></p>'

    // Set HTML
    document.getElementById('btn-set-html').addEventListener('click', () => {
      editor.setHtml(textarea.value)
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/demo/simple-mode.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor simple mode</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <!-- <link href="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet"> -->
  <link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet">
  <link href="./css/layout.css" rel="stylesheet">

  <script src="./js/custom-elem.js"></script>
</head>

<body>
  <demo-nav title="wangEditor simple mode"></demo-nav>
  <div class="page-container">
    <div class="page-left">
      <demo-menu></demo-menu>
    </div>
    <div class="page-right">
      <!-- 编辑器 DOM -->
      <div style="border: 1px solid #ccc;">
        <div id="editor-toolbar" style="border-bottom: 1px solid #ccc;"></div>
        <div id="editor-text-area" style="height: 500px"></div>
      </div>

      <!-- 内容状态 -->
      <p style="background-color: #f1f1f1;">
        Text length: <span id="total-length"></span>;
        Selected text length: <span id="selected-length"></span>;
      </p>
    </div>
  </div>

  <!-- <script src="https://cdn.jsdelivr.net/npm/@wangeditor/editor@latest/dist/index.min.js"></script> -->
  <script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // 切换语言
    const LANG = location.href.indexOf('lang=en') > 0 ? 'en' : 'zh-CN'
    E.i18nChangeLanguage(LANG)

    // 默认内容
    let html = `<h1>简洁模式:</h1><ol><li>简化工具栏菜单</li><li>取消选中文字的悬浮菜单</li></ol><p><br></p>`
    if (LANG === 'en') html = `<h1>Simple&nbsp;mode.</h1><ol><li>Simplify&nbsp;toolbar&nbsp;menus</li><li>Hide&nbsp;hover-bar&nbsp;when&nbsp;selected&nbsp;text</li></ol><p><br></p>`

    window.editor = E.createEditor({
      selector: '#editor-text-area',
      html,
      mode: 'simple',
      config: {
        placeholder: 'Type here...',
        MENU_CONF: {
          uploadImage: {
            fieldName: 'your-fileName',
            base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
          }
        },
        onChange() {
          console.log(editor.getHtml())

          // 选中文字
          const selectionText = editor.getSelectionText()
          document.getElementById('selected-length').innerHTML = selectionText.length
          // 全部文字
          // 全部文字
          const text = editor.getText().replace(/\n|\r/mg, '')
          document.getElementById('total-length').innerHTML = text.length
        }
      }
    })

    window.toolbar = E.createToolbar({
      editor,
      mode: 'simple',
      selector: '#editor-toolbar',
      config: {}
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/examples/README.md
================================================
# examples

- 本地测试
- 提交 `master` 会发布到测试机


================================================
FILE: packages/editor/examples/batch-destroy.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>destroy demo</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/view.css" rel="stylesheet">
  <link href="./css/editor.css" rel="stylesheet">

  <link href="../dist/css/style.css" rel="stylesheet">
</head>

<body>
  <p>执行大量循环,频繁执行 创建/销毁 ,监控内存是否明显增加</p>
  <p>循环次数 <input id="input-num" type="number" value="100" /> <button id="btn-go">开始</button></p>
  <p>可使用 Chrome devTools 的 Performance 和 Memory 工具来检测 js 内存</p>
  <!-- 编辑器 -->
  <div style="width: 950px; margin: 0 auto;">
    <div id="editor-toolbar" class="editor-toolbar"></div>
    <div id="editor-text-area" class="editor-text-area"></div>
  </div>
</body>

<script src="js/init-content.js"></script>
<script src="../dist/index.js"></script>
<script>
  (function () {
    const E = window.wangEditor

    document.getElementById('btn-go')?.addEventListener('click', () => {
      let editor, toolbar

      // ----------------------------- editor config -----------------------------
      const editorConfig = {}
      editorConfig.placeholder = '请输入内容'
      editorConfig.onCreated = (editor) => {
        console.log('on created', editor.id)
      }
      editorConfig.onDestroyed = (editor) => {
        console.log('on destroyed', editor.id)
      }

      // ----------------------------- 获取 循环次数 -----------------------------
      const maxNum = parseInt(document.getElementById('input-num').value)
      if (!maxNum) {
        console.error('循环次数,值非法 ', maxNum)
        return
      }
      if (maxNum >= 1000) {
        console.error('循环次数太多,耗时会比较长!')
        return
      }

      // ----------------------------- 开始循环 -----------------------------
      console.log('go...')
      let num = 0
      const intervalId = setInterval(() => {
        if (num >= maxNum) {
          clearInterval(intervalId)
          return
        }

        if (editor == null) {
          console.log('createCore', num)

          // 先创建 editor 再创建 toolbar
          editor = E.createEditor({
            selector: '#editor-text-area',
            config: editorConfig,
            content: window.content1
          })
          toolbar = E.createToolbar({
            editor,
            selector: '#editor-toolbar'
          })
        } else {
          console.log('destroyCore', num)
          editor.destroy()
          editor = null
          num++ // 计数
        }
      }, 100)
      console.log('end...')
    })
  })()
</script>

</html>

================================================
FILE: packages/editor/examples/check.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>check demo</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/view.css" rel="stylesheet">
  <link href="./css/editor.css" rel="stylesheet">

  <link href="../dist/css/style.css" rel="stylesheet">
</head>
<body>
  <p>视频、图片等资源校验,自定义 alert,XSS 预防</p>

  <!-- 编辑器 -->
  <div style="width: 950px; margin: 0 auto;">
    <div id="editor-toolbar" class="editor-toolbar"></div>
    <div id="editor-text-area" class="editor-text-area"></div>
  </div>

  <script src="js/init-content.js"></script>
  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor

    const editorConfig = { MENU_CONF: {} }
    editorConfig.placeholder = '请输入内容'
    
    editorConfig.customAlert = (info, type) => {
      alert(`customAlert: \n${type}:\n${info}`)
    }

    editorConfig.MENU_CONF['uploadImage'] = {
      fieldName: 'your-fileName',
      base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
    }

    editorConfig.MENU_CONF['insertImage'] = {
      onInsertedImage(imageNode) {
        console.log('inserted image', imageNode)
      },
      checkImage(src, alt, url) {
        if (src.indexOf('http') !== 0) {
          return '图片网址必须以 http/https 开头'
        }
        return true
      },

      // // 异步检查
      // async checkImage(src, alt, url) {
      //   return new Promise((resolve, reject) => {
      //     setTimeout(() => {
      //       if (src.indexOf('http') !== 0) {
      //         resolve('图片网址必须以 http/https 开头')
      //         return
      //       }
      //       resolve(true)
      //     }, 1000)
      //   })
      // },

      parseImageSrc(src) {
        return src + '#123'
      },

      // // 异步转换
      // async parseImageSrc(src) {
      //   return new Promise((resolve, reject) => {
      //     setTimeout(() => {
      //       resolve(src + '#abc')
      //     }, 1000)
      //   })
      // },
    }
    editorConfig.MENU_CONF['editImage'] = {
      onUpdatedImage(imageNode) {
        console.log('updated image', imageNode)
      },
      checkImage(src, alt, url) {
        if (src.indexOf('http') !== 0) {
          return '图片网址必须以 http/https 开头'
        }
        return true
      },

      // // 异步检查
      // async checkImage(src, alt, url) {
      //   return new Promise((resolve, reject) => {
      //     setTimeout(() => {
      //       if (src.indexOf('http') !== 0) {
      //         resolve('图片网址必须以 http/https 开头')
      //         return
      //       }
      //       resolve(true)
      //     }, 1000)
      //   })
      // },

      parseImageSrc(src) {
        return src + '#123'
      },
    }
    editorConfig.MENU_CONF['insertLink'] = {
      checkLink(text, url) {
        console.log('check insert link - ', text, url)

        if (url.indexOf('http') !== 0) {
          return '链接必须以 http/https 开头'
        }
        return true
      },

      // // 异步检查
      // async checkLink(text, url) {
      //   console.log('check insert link - ', text, url)

      //   return new Promise((resolve, reject) => {
      //     setTimeout(() => {
      //       if (url.indexOf('http') !== 0) {
      //         resolve('链接必须以 http/https 开头')
      //         return
      //       }
      //       resolve(true)
      //     }, 1000)
      //   })
      // },

      parseLinkUrl(url) {
        return url + '#123'
      },

      // // 异步转换
      // async parseLinkUrl(url) {
      //   console.log('parse insert link - ', url)

      //   return new Promise((resolve, reject) => {
      //     setTimeout(() => {
      //       resolve(url + '#123')
      //     }, 1000)
      //   })
      // },
    }
    editorConfig.MENU_CONF['editLink'] = {
      checkLink(text, url) {
        if (url.indexOf('http') !== 0) {
          return '链接必须以 http/https 开头'
        }
        return true
      },

      // // 异步检查
      // async checkLink(text, url) {
      //   console.log('check insert link - ', text, url)

      //   return new Promise((resolve, reject) => {
      //     setTimeout(() => {
      //       if (url.indexOf('http') !== 0) {
      //         resolve('链接必须以 http/https 开头')
      //         return
      //       }
      //       resolve(true)
      //     }, 1000)
      //   })
      // },

      parseLinkUrl(url) {
        return url + '#123'
      },
    }
    editorConfig.MENU_CONF['insertVideo'] = {
      onInsertedVideo(videoNode) {
        console.log('inserted video', videoNode)
      },

      checkVideo(src, poster) {
        console.log('video src', src)
        console.log('video poster', poster)
        if (src.indexOf('http') !== 0) {
          return '视频地址必须以 http/https 开头'
        }
        return true
      },

      // // 异步检查
      // async checkVideo(src) {
      //   return new Promise((resolve, reject) => {
      //     setTimeout(() => {
      //       if (src.indexOf('http') !== 0) {
      //         resolve('视频地址必须以 http/https 开头')
      //         return
      //       }
      //       resolve(true)
      //     }, 1000)
      //   })
      // },

      // 也支持 promise
      parseVideoSrc(src) {
        if (src.includes('.bilibili.com')) {
          // 转换 bilibili url 为 iframe
          const arr = location.pathname.split('/')
          const vid = arr[arr.length - 1]
          return `<iframe src="//player.bilibili.com/player.html?aid=421814407&bvid=${vid}" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"> </iframe>`
        }
        return src
      }
    }

    const editor = E.createEditor({
      selector: '#editor-text-area',
      content: window.content1,
      config: editorConfig
    })
    const toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
    })
  </script>
</body>
</html>

================================================
FILE: packages/editor/examples/code-highlight.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>code-highlight demo</title>

  <!-- 引入 prism css -->
  <link href="https://unpkg.com/prismjs@latest/themes/prism.css" rel="stylesheet">
</head>
<body>
  <p>由 wangEditor 生成的代码,可支持代码高亮显示。使用 <a href="https://prismjs.com/" target="_blank">prism.js</a> ,支持多主题</p>
  <p>【注意】异步设置 html 内容时,需要执行 <code>Prism.highlightAll()</code> <button id="btn-test">测试一下</button></p>

  <div>
    <p>javascript</p>
    <pre>
      <code id="code1" class="language-javascript">const a = 100;
      function fn(x) { return x + 10 };
      // 注释
      </code>
    </pre>

    <p>html</p>
    <pre>
      <code id="code2" class="language-html">&lt;div&gt;text1&lt;/div&gt;</code>
    </pre>
  </div>

  <!-- 引入 prism js -->
  <script src="https://unpkg.com/prismjs@latest/prism.js"></script>
  <script src="https://unpkg.com/prismjs@latest/components/prism-core.js"></script>
  <script src="https://unpkg.com/prismjs@latest/plugins/autoloader/prism-autoloader.js"></script>
  <script>
    document.getElementById('btn-test').addEventListener('click', () => {
      document.getElementById('code1').innerHTML = 'const b = 200;\nfunction fn(y) { return y + 20 };\n// comment'
      document.getElementById('code2').innerHTML = '&lt;p&gt;text2&lt;/p&gt;'
      Prism.highlightAll()
    })
  </script>
</body>
</html>

================================================
FILE: packages/editor/examples/content-to-html.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>content to html</title>
  <link href="../dist/css/style.css" rel="stylesheet">
</head>
<body>
  <p>content to html</p>

  <div style="margin-top: 10px;">
    <textarea id="text-content" style="width: 100%; height: 300px;">[
  {
    "type": "paragraph",
    "children": [
      {
        "text": "你好"
      }
    ]
  }
]</textarea>
  </div>

  <button id="btn-convert">convert</button>

  <div style="margin-top: 20px;">
    <textarea id="text-html" readonly style="width: 100%; height: 300px;"></textarea>
  </div>

  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor

    document.getElementById('btn-convert').addEventListener('click', () => {
      const contentStr = document.getElementById('text-content').value
      const content = JSON.parse(contentStr)
      const editor = E.createEditor({ content })
      document.getElementById('text-html').innerHTML = editor.getHtml()
    })
  </script>
</body>
</html>

================================================
FILE: packages/editor/examples/css/editor.css
================================================
body {
  margin: 0 10px;
}

.editor-toolbar {
  border: 1px solid #ccc;
}

.editor-text-area {
  border: 1px solid #ccc;
  border-top: 0;
  height: 400px;
}

================================================
FILE: packages/editor/examples/css/view.css
================================================
.editor-content-view {
  border: 1px solid #ccc;
  padding: 10px;
  margin-top: 30px;
  overflow-x: auto;
}

.editor-content-view p,
.editor-content-view li {
  white-space: pre-wrap; /* 保留空格 */
}

.editor-content-view blockquote {
  border-left: 8px solid #d0e5f2;
  padding: 10px 10px;
  margin: 10px 0;
  background-color: #f1f1f1;
}

.editor-content-view code {
  font-family: monospace;
  background-color: #eee;
  padding: 3px;
  border-radius: 3px;
}
.editor-content-view pre>code {
  display: block;
  padding: 10px;
}

.editor-content-view table {
  border-collapse: collapse;
}
.editor-content-view td,
.editor-content-view th {
  border: 1px solid #ccc;
  min-width: 50px;
  height: 20px;
}
.editor-content-view th {
  background-color: #f1f1f1;
}

================================================
FILE: packages/editor/examples/default-mode.html
================================================
<!DOCTYPE html>
<html lang="en">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>default mode</title>
<link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
<link href="./css/view.css" rel="stylesheet">
<link href="./css/editor.css" rel="stylesheet">

<link href="../dist/css/style.css" rel="stylesheet">
</head>

<body>
  <div style="width: 1000px; margin: 0 auto;">
    <p>
      <button id="btn-create">create editor</button>
      <button id="btn-toggle-enable">disable/enable</button>
      <button id="btn-destroy">destroy editor</button>
    </p>
  
    <!-- 编辑器 -->
    <div>
      <div id="editor-toolbar" class="editor-toolbar"></div>
      <div id="editor-text-area" class="editor-text-area"></div>
    </div>
  
    <!-- 内容状态 -->
    <p style="background-color: #f1f1f1;">
      当前文字数量:<span id="total-length"></span>;
      选中文字数量:<span id="selected-length"></span>;
      选中文字:"<span id="selected-text"></span>"
    </p>
  
    <!-- 显示内容 -->
    <div id="editor-content-view" class="editor-content-view"></div>
  </div>

  <script src="js/init-content.js"></script>
  <script src="../dist/index.js?t=14"></script>
  <script>
    const E = window.wangEditor

    const editorConfig = { MENU_CONF: {} }
    // editorConfig.autoFocus = false
    // editorConfig.readOnly = true
    // editorConfig.scroll = false
    editorConfig.placeholder = '请输入内容'
    editorConfig.MENU_CONF['uploadImage'] = {
      fieldName: 'your-fileName',
      base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
    }
    console.log('测试上传图片,请使用 upload-image.html')
    editorConfig.onCreated = (editor) => {
      console.log('on created', editor)
    }
    editorConfig.onChange = (editor) => {
      // console.log(editor.children)

      const html = editor.getHtml()
      document.getElementById('editor-content-view').innerHTML = html

      // 选中文字
      const selectionText = editor.getSelectionText()
      document.getElementById('selected-text').innerHTML = selectionText
      document.getElementById('selected-length').innerHTML = selectionText.length
      // 全部文字
      document.getElementById('total-length').innerHTML = editor.getText().length

      // // isSelectedAll
      // console.log('isSelectedAll', editor.isSelectedAll())
    }
    editorConfig.onDestroyed = (editor) => {
      console.log('on destroyed', editor)
    }
    editorConfig.onFocus = (editor) => {
      console.log('onFocus', editor.isFocused())
    }
    editorConfig.onBlur = (editor) => {
      console.log('onBlur', editor.isFocused())
    }

    // editorConfig.customPaste = (editor, event) => {
    //   console.log('customPage')
    //   // editor.insertText('xxx------') // 同步
    //   setTimeout(() => {
    //     editor.insertText('yyy------') // 异步
    //   }, 1000)
    //   return false // 阻止默认粘贴,自定义实现粘贴

    //   // return true // 执行默认粘贴
    // }

    const toolbarConfig = {
      // excludeKeys: ['uploadVideo'],
    }

    // create
    let editor, toolbar
    document.getElementById('btn-create').addEventListener('click', () => {
      editor = E.createEditor({
        selector: '#editor-text-area',
        // selector: document.getElementById('editor-text-area'),
        content: window.content1,
        config: editorConfig
      })

      toolbar = E.createToolbar({
        editor,
        selector: '#editor-toolbar',
        // selector: document.getElementById('editor-toolbar')
        config: toolbarConfig
      })
    })
    console.log(`如果页面没有编辑器,点击 'create' 按钮创建`)

    // toggle enable
    document.getElementById('btn-toggle-enable').addEventListener('mousedown', (e) => {
      // 使用 mousedown ,且 preventDefault ,否则触发编辑器 blur ,导致无法正确 focus
      // TODO 文档中说明(以及其他的 editState)
      e.preventDefault()

      if (editor.getConfig().readOnly) {
        editor.enable()
      } else {
        editor.disable()
      }
    })

    // destroy
    document.getElementById('btn-destroy').addEventListener('click', () => {
      editor.destroy()
      editor = undefined
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/examples/dom7-demo.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>dom7 demo</title>
</head>
<body>
  <p>dom7 demo</p>

  <script src="https://unpkg.com/dom7@latest/dom7.js"></script>
  <script>
    const $text = Dom7('<span data-a><b>hello</b></span>')
    const $p = Dom7('<p style="line-height: 2.5; color: red;"><span>行高文字 line-height</span></p>')
    // Dom7('body').append(p)

    const tableStr = `
      <table border="0" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><th></th><th></th><th></th><th></th><th></th></tr><tr><td></td><td></td><td></td><td></td><td></td></tr><tr><td></td><td></td><td></td><td></td><td></td></tr><tr><td></td><td></td><td></td><td></td><td></td></tr><tr><td></td><td></td><td></td><td></td><td></td></tr></tbody></table>
    `
    const $table = Dom7(tableStr)
    const $tbody = $table.find('tbody')
    const $tr = $table.find('tr')
    $table.append($tr)
    $tbody.remove()
  </script>
</body>
</html>

================================================
FILE: packages/editor/examples/headers.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>headers demo</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/editor.css" rel="stylesheet">
  <link href="../dist/css/style.css" rel="stylesheet">
</head>

<body>
  <p id="p1">获取标题、滚动到标题</p>

  <!-- 编辑器 -->
  <div>
    <div id="editor-toolbar" class="editor-toolbar"></div>
    <div id="editor-text-area" class="editor-text-area"></div>
  </div>

  <!-- 显示 headers -->
  <div style="margin-top: 20px;">
    <div style="margin-bottom: 10px;">
      <input id="input-id" placeholder="header id" />
      <button id="btn-scroll-to">scrollTo</button>
    </div>
    <textarea readonly id="text-headers" style="width: 800px; height: 400px;"></textarea>
  </div>

  <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
  <script src="js/init-content.js"></script>
  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor

    const editorConfig = {}
    editorConfig.onCreated = (editor) => {
      console.log('on created', editor)
    }
    editorConfig.onChange = (editor) => {
      // 获取并展示 headers
      const headers = editor.getElemsByTypePrefix('header')
      $('#text-headers').val(JSON.stringify(headers, null, 4))
    }

    // 先创建 editor ,再创建 toolbar
    const editor = E.createEditor({
      selector: '#editor-text-area',
      config: editorConfig,
      content: window.content1
    })
    const toolbar =E.createToolbar({
      editor,
      selector: '#editor-toolbar'
    })
    
    // scrollTo
    const $inputId = $('#input-id')
    $('#btn-scroll-to').on('click', () => {
      const id = $inputId.val()
      editor.scrollToElem(id)
      $inputId.val('')
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/examples/huge-doc.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>huge doc</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/editor.css" rel="stylesheet">

  <link href="../dist/css/style.css" rel="stylesheet">
</head>
<body>
  <p>huge doc 大文件</p>

  <!-- 编辑器 -->
  <div>
    <div id="editor-toolbar" class="editor-toolbar"></div>
    <div id="editor-text-area" class="editor-text-area" style="height: 800px;"></div>
  </div>

  <!-- 内容状态 -->
  <p style="background-color: #f1f1f1;">
    当前文字数量:<span id="total-length"></span>;
    选中文字数量:<span id="selected-length"></span>;
    选中文字:"<span id="selected-text"></span>"
  </p>

  <script src="./js/huge-content.js"></script>
  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor

    const editorConfig = { MENU_CONF: {} }
    editorConfig.placeholder = '请输入内容'
    editorConfig.onChange = (editor) => {
      // 选中文字
      const selectionText = editor.getSelectionText()
      document.getElementById('selected-text').innerHTML = selectionText
      document.getElementById('selected-length').innerHTML = selectionText.length
      // 全部文字
      document.getElementById('total-length').innerHTML = editor.getText().length
    }

    const editor = E.createEditor({
      selector: '#editor-text-area',
      content: window.content2,
      config: editorConfig
    })
    const toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
    })
  </script>
</body>
</html>

================================================
FILE: packages/editor/examples/i18n.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>i18n</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/view.css" rel="stylesheet">
  <link href="./css/editor.css" rel="stylesheet">
  <link href="../dist/css/style.css" rel="stylesheet">
</head>
<body>
  <p>i18n demo</p>

  <!-- 编辑器 -->
  <div>
    <div id="editor-toolbar" class="editor-toolbar"></div>
    <div id="editor-text-area" class="editor-text-area"></div>
  </div>

  <script src="js/init-content.js"></script>
  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // // 添加新语言,如日语 ja
    // E.i18nAddResources('ja', {
    //     // 标题
    //     header: {
    //         title: 'ヘッダー',
    //         text: 'テキスト',
    //     },
    //     // ... 其他语言词汇,下文说明 ...
    // })
    // // 切换为日语 ja
    // E.i18nChangeLanguage('ja')

    // 切换语言 'en' 'zh-CN'
    E.i18nChangeLanguage('en')

    // 使用多语言
    console.log('使用多语言', E.t('editor.image'))

    // editor 配置
    const editorConfig = {}
    editorConfig.placeholder = '请输入内容...'

    const editor = E.createEditor({
      selector: '#editor-text-area',
      config: editorConfig,
      content: window.content1,
    })

    const toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {},
    })
  </script>
</body>
</html>

================================================
FILE: packages/editor/examples/index.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>wangEditor examples</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <style>
    body {
      margin: 20px;
    }
    ul {
      padding-left: 20px;
    }
    li {
      margin: 10px 0;
    }
  </style>
</head>
<body>
  <h1>wangEditor examples</h1>
  <ul>
    <li><a href="./default-mode.html">Default mode 默认模式</a></li>
    <li><a href="./simple-mode.html">Simple mode 简洁模式</a></li>
    <li><a href="./parse-html.html">Parse html 回显使用 html</a></li>
    <li><a href="./menu.html">Menu config 菜单配置</a></li>
    <li><a href="./like-yuque.html">Like QQ Doc 模仿腾讯文档编辑器</a></li>
    <li><a href="./simple-mode.html">Sync to textarea 同步到 textarea</a></li>
    <li><a href="./maxlength.html">Maxlength</a></li>
    <li><a href="./upload-image.html">Upload images 上传图片</a></li>
    <li><a href="./upload-video.html">Upload videos 上传视频</a></li>
    <li><a href="./check.html">Check callback and custom alert 资源校验、回调、自定义 alert</a></li>
    <li><a href="./multi-editors.html">Multi editors 多个编辑器</a></li>
    <li><a href="./headers.html">Get headers and scroll 获取标题/滚动到标题</a></li>
    <li><a href="./huge-doc.html">Huge doc 大文件(几万个字)</a></li>
    <li><a href="./modal-appendTo-body.html">Modal appendTo body</a></li>
    <li><a href="./i18n.html">i18n 国际化</a></li>
    <li><a href="./theme.html">Theme 主题</a></li>
    <li><a href="./code-highlight.html">Code highlight 代码高亮</a></li>
    <li><a href="./shadow-dom.html">Shadow DOM</a></li>
    <li><a href="./batch-destroy.html">Batch destroy, test memory leak 批量销毁,测试内存泄漏</a></li>
    <li><a href="./content-to-html.html">Content to html</a></li>
    <li><a href="./new-menu.html">New menu 新注册菜单</a></li>
  </ul>
</body>
</html>


================================================
FILE: packages/editor/examples/js/huge-content.js
================================================
;(function () {
  function deepClone(obj) {
    const str = JSON.stringify(obj)
    return JSON.parse(str)
  }

  const header = {
    type: 'header1',
    children: [
      {
        text: '水浒传简介',
      },
    ],
  }
  const text1 =
    '全书通过描写梁山好汉反抗欺压、水泊梁山壮大和受宋朝招安,以及受招安后为宋朝征战,最终消亡的宏大故事,艺术地反映了中国历史上宋江起义从发生、发展直至失败的全过程,深刻揭示了起义的社会根源,满腔热情地歌颂了起义英雄的反抗斗争和他们的社会理想,也具体揭示了起义失败的内在历史原因。'
  const text2 =
    '《水浒传》是中国古典四大名著之一,问世后,在社会上产生了巨大的影响,成了后世中国小说创作的典范。《水浒传》是中国历史上最早用白话文写成的章回小说之一,流传极广,脍炙人口;同时也是汉语言文学中具备史诗特征的作品之一,对中国乃至东亚的叙事文学都有深远的影响。'
  const p1 = {
    type: 'paragraph',
    children: [{ text: text1 }],
  }
  const p2 = {
    type: 'paragraph',
    children: [{ text: text2 }],
  }
  // const code = {
  //   type: 'pre',
  //   children: [
  //     {
  //       type: 'code',
  //       language: 'javascript',
  //       children: [{ text: 'const a = 100;' }],
  //     },
  //   ],
  // }

  // 拼接大文件
  window.content2 = []
  for (let i = 0; i < 370; i++) {
    window.content2.push(deepClone(header))
    window.content2.push(deepClone(p1))
    window.content2.push(deepClone(p2))
    // window.content2.push(deepClone(code))
  }
})()


================================================
FILE: packages/editor/examples/js/init-content.js
================================================
/**
 * @description demo 页,初始化内容
 * @author wangfupeng
 */

window.content1 = [
  {
    type: 'header1',
    textAlign: 'center',
    children: [
      {
        text: '一行标题',
      },
    ],
  },

  {
    type: 'paragraph',
    children: [
      { text: 'hello world ~~~ ' },
      {
        type: 'link',
        url: 'https://www.slatejs.org/examples/links',
        children: [{ text: 'slate examples' }],
      },
      { text: '!' },
    ],
  },
  {
    type: 'pre',
    children: [
      {
        type: 'code',
        language: 'javascript',
        children: [{ text: 'const a = 100;' }],
      },
    ],
  },
  {
    type: 'paragraph',
    children: [
      { text: '图片' },
      {
        type: 'image',
        src: 'https://www.wangeditor.com/imgs/logo.png',
        children: [{ text: '' }], // void node 要有一个空 text
      },
      { text: 'image' },
    ],
  },
  {
    type: 'paragraph',
    children: [{ text: '结束' }],
  },
  {
    type: 'paragraph',
    children: [{ text: '一行文字' }],
  },
  {
    type: 'header2',
    children: [
      {
        text: '二级标题',
      },
    ],
  },
  {
    type: 'pre',
    children: [
      {
        type: 'code',
        language: 'html',
        children: [{ text: '<div>text</div>' }],
      },
    ],
  },
  {
    type: 'paragraph',
    children: [{ text: '一行文字' }],
  },
  {
    type: 'paragraph',
    children: [
      { text: '一行文字' },
      {
        type: 'image',
        src: 'https://www.baidu.com/img/flexible/logo/pc/result@2.png',
        alt: '百度',
        url: 'https://www.baidu.com/',
        style: { width: '101px', height: '33px' },
        children: [{ text: '' }], // void node 要有一个空 text
      },
      { text: '一行文字' },
    ],
  },
  {
    type: 'blockquote',
    children: [{ text: '一行文字' }],
  },
  {
    type: 'paragraph',
    children: [{ text: '一行文字' }],
  },
  {
    type: 'divider',
    children: [{ text: '' }],
  },
  {
    type: 'header3',
    children: [
      {
        text: '三级标题',
      },
    ],
  },
  {
    type: 'paragraph',
    children: [{ text: '一行文字' }],
  },
  {
    type: 'paragraph',
    children: [{ text: '一行文字' }],
  },
]


================================================
FILE: packages/editor/examples/like-yuque.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>like yuque</title>

  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="../dist/css/style.css" rel="stylesheet">
  <style>
    html,
    body {
      background-color: #fff;
      height: 100%;
      overflow: hidden;
      color: #333;
    }

    #top-container {
      border-bottom: 1px solid #e8e8e8;
      padding-left: 50px;
    }

    #editor-toolbar {
      width: 1300px;
      background-color: #FCFCFC;
      margin: 0 auto;
    }

    #content {
      height: calc(100% - 40px);
      background-color: rgb(245, 245, 245);
      overflow-y: auto;
      position: relative;
    }

    #editor-container {
      width: 850px;
      margin: 30px auto 150px auto;
      background-color: #fff;
      padding: 20px 50px 50px 50px;
      border: 1px solid #e8e8e8;
      box-shadow: 0 2px 10px rgb(0 0 0 / 12%);
    }

    #title-container {
      padding: 20px 0;
      border-bottom: 1px solid #e8e8e8;
    }

    #title-container input {
      font-size: 30px;
      border: 0;
      outline: none;
      width: 100%;
      line-height: 1;
    }

    #editor-text-area {
      min-height: 900px;
      margin-top: 20px;
    }
  </style>

</head>

<body>
  <div id="top-container">
    <p>
      <svg width="16" height="16" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
        <path
          d="M648.533333 85.333333L853.333333 290.133333V938.666667H170.666667V85.333333h477.866666m34.133334-85.333333H85.333333v1024h853.333334V256l-256-256z">
        </path>
        <path d="M256 341.333333h512v85.333334H256zM256 512h512v85.333333H256zM256 682.666667h512v85.333333H256z">
        </path>
      </svg>
      文章标题信息
    </p>
  </div>
  <div style="border-bottom: 1px solid #e8e8e8;">
    <div id="editor-toolbar"></div>
  </div>
  <div id="content">
    <div id="editor-container">
      <div id="title-container">
        <input value="请输入标题">
      </div>
      <div id="editor-text-area"></div>
    </div>
  </div>

  <script src="js/init-content.js"></script>
  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor

    const editorConfig = { MENU_CONF: {} }
    editorConfig.placeholder = '请输入内容'
    editorConfig.scroll = false // 禁止编辑器滚动
    editorConfig.MENU_CONF['uploadImage'] = {
      fieldName: 'your-fileName',
      base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
    }
    editorConfig.onChange = (editor) => {
      // console.log('content', editor.children)
    }

    // 先创建 editor
    const editor = E.createEditor({
      selector: '#editor-text-area',
      content: window.content1,
      config: editorConfig
    })

    // 创建 toolbar
    const toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {
        excludeKeys: 'fullScreen',
      }
    })

    // 点击空白处 focus 编辑器
    document.getElementById('editor-text-area').addEventListener('click', e => {
      if (e.target.id === 'editor-text-area') {
        editor.blur()
        editor.focus(true) // focus 到末尾
      }
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/examples/maxlength.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>maxLength demo</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/view.css" rel="stylesheet">
  <link href="./css/editor.css" rel="stylesheet">

  <link href="../dist/css/style.css" rel="stylesheet">
</head>
<body>
  <p>maxLength(慎用,可能影响性能)</p>

  <!-- 编辑器 -->
  <div>
    <div id="editor-toolbar" class="editor-toolbar"></div>
    <div id="editor-text-area" class="editor-text-area"></div>
  </div>

  <!-- 内容状态 -->
  <p style="background-color: #f1f1f1;">
    当前文字数量:<span id="total-length"></span>;
    选中文字数量:<span id="selected-length"></span>;
    选中文字:"<span id="selected-text"></span>"
  </p>

  <!-- 显示内容 -->
  <div id="editor-content-view" class="editor-content-view"></div>

  <script src="js/init-content.js"></script>
  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor

    const editorConfig = { MENU_CONF: {} }
    editorConfig.placeholder = '请输入内容'

    editorConfig.maxLength = 100 // 慎用,可能影响性能,需在文档中说明
    editorConfig.onMaxLength = (editor) => {
      console.log('onMaxLength')
    }

    editorConfig.onChange = (editor) => {
      const html = editor.getHtml()
      document.getElementById('editor-content-view').innerHTML = html

      // 选中文字
      const selectionText = editor.getSelectionText()
      document.getElementById('selected-text').innerHTML = selectionText
      document.getElementById('selected-length').innerHTML = selectionText.length
      // 全部文字
      document.getElementById('total-length').innerHTML = editor.getText().length
    }

    const editor = E.createEditor({
      selector: '#editor-text-area',
      // content: window.content1,
      config: editorConfig
    })
    const toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
    })
  </script>
</body>
</html>

================================================
FILE: packages/editor/examples/menu.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>menu config demo</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/view.css" rel="stylesheet">
  <link href="./css/editor.css" rel="stylesheet">

  <link href="../dist/css/style.css" rel="stylesheet">
</head>
<body>
  <p>menu 配置,包括:hoverbar toolbar</p>

  <!-- 编辑器 -->
  <div>
    <div id="editor-toolbar" class="editor-toolbar"></div>
    <div id="editor-text-area" class="editor-text-area"></div>
  </div>

  <script src="js/init-content.js"></script>
  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor
    const DomEditor = window.wangEditor.DomEditor

    const editorConfig = { MENU_CONF: {} }
    editorConfig.placeholder = '请输入内容'

    editorConfig.hoverbarKeys = {
      text: {
        menuKeys: ['bold', 'insertLink'],
      },
      'link': {
        menuKeys: ['editLink', 'unLink', 'viewLink'],
      },
      'image': {
        menuKeys: [
          'imageWidth30',
          'imageWidth50',
          'imageWidth100',
          'editImage',
          'viewImageLink',
          'deleteImage',
        ],
      }
      // 其他参考 https://github.com/wangeditor-team/wangEditor/blob/master/packages/editor/src/init-default-config/config/hoverbar.ts
    }

    // 各个菜单的配置
    editorConfig.MENU_CONF['color'] = {
      colors: ['#000', '#333', '#999', '#ccc']
    }
    editorConfig.MENU_CONF['fontSize'] = {
      fontSizeList: ['12px', '16px', '24px', '40px']
    }
    // 其他菜单配置项可通过 editor.getMenuConfig(menuKey) 查询,然后使用 editorConfig.MENU_CONF[menuKey] 修改

    const editor = E.createEditor({
      selector: '#editor-text-area',
      content: window.content1,
      config: editorConfig
    })
    const toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: {
        toolbarKeys: [
          '|', // 第一个是 `|` 不显示
          'headerSelect',
          'blockquote',
          '|', '|', '|', // 多个紧挨者的 `|` 只显示一个
          'bold',
          'underline',
          'italic',
          {
            key: 'group-more-style', // 以 group 开头
            title: '更多样式',
            iconSvg: '<svg viewBox="0 0 1024 1024"><path d="M204.8 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path><path d="M505.6 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path><path d="M806.4 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path></svg>',
            menuKeys: ['through', 'code'],
          },
          'color',
          'bgColor',
          'clearStyle',
          '|',
          'fontSize',
          'fontFamily',
          'lineHeight',
          '|',
          'undo',
          'redo',
          '|', // 最后一个是 `|` 不显示
        ],
        // insertKeys: {
        //   index: 5,
        //   keys: ['insertImage', 'insertVideo']
        // },
        // excludeKeys: ['headerSelect', 'underline', 'clearStyle', 'fontFamily', 'group-image']
      },
    })
  </script>
</body>
</html>

================================================
FILE: packages/editor/examples/modal-appendTo-body.html
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>modal appendTo body - demo</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/view.css" rel="stylesheet">
  <link href="./css/editor.css" rel="stylesheet">
  <link href="../dist/css/style.css" rel="stylesheet">

  <style>
    #mask {
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      z-index: 999;
      background-color: #00000073;
      display: none;
    }
  </style>
</head>

<body>
  <p>
    modal appendTo body
  </p>

  <div>
    <div id="editor-toolbar" class="editor-toolbar"></div>
    <div id="editor-text-area" class="editor-text-area"></div>
  </div>

  <!-- mask div 蒙层 -->
  <div id="mask"></div>

  <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
  <script src="js/init-content.js"></script>
  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor

    const editorConfig = { MENU_CONF: {} }

    const toolbarConfig = {
      modalAppendToBody: true
    }

    const editor = E.createEditor({
      selector: '#editor-text-area',
      content: window.content1,
      config: editorConfig
    })

    const toolbar = E.createToolbar({
      editor,
      selector: '#editor-toolbar',
      config: toolbarConfig
    })

    editor.on('modalOrPanelShow', modalOrPanel => {
      if (modalOrPanel.type !== 'modal') return

      const { $elem } = modalOrPanel // modal element
      const width = $elem.width()
      const height = $elem.height()

      // set modal position z-index
      $elem.css({
        left: '50%',
        top: '50%',
        marginLeft: `-${width / 2}px`,
        marginTop: `-${height / 2}px`,
        zIndex: 1000
      })

      // show mask div
      document.getElementById('mask').style.display = 'block'
    })
    editor.on('modalOrPanelHide', () => {
      console.log('hide')

      // hide mask div
      document.getElementById('mask').style.display = 'none'
    })
    // click mask div to hide modal
    document.getElementById('mask').addEventListener('click', () => {
      editor.hidePanelOrModal()
    })
  </script>
</body>

</html>

================================================
FILE: packages/editor/examples/multi-editors.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>多编辑器 demo</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/view.css" rel="stylesheet">
  <link href="./css/editor.css" rel="stylesheet">
  <style>
    .container {
      display: flex;
    }
    .container-item {
      flex: 1;
      margin: 0 5px;
      max-width: 50%;
    }
  </style>
  <link href="../dist/css/style.css" rel="stylesheet">
</head>
<body>
  <p>多编辑器 demo</p>

  <div class="container">
    <div class="container-item">
      <div id="editor-toolbar-1" class="editor-toolbar"></div>
      <div id="editor-text-area-1" class="editor-text-area"></div>
      <div id="content-view-1" class="editor-content-view"></div>
    </div>
    <div class="container-item">
      <div id="editor-toolbar-2" class="editor-toolbar"></div>
      <div id="editor-text-area-2" class="editor-text-area"></div>
      <div id="content-view-2" class="editor-content-view"></div>
    </div>
  </div>

  <script src="js/init-content.js"></script>
  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // --------------------- editor1 ---------------------
    const editorConfig1 = { MENU_CONF: {} }
    editorConfig1.placeholder = '请输入内容1...'
    editorConfig1.MENU_CONF['uploadImage'] = {
      base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
    }
    editorConfig1.onChange = (editor) => {
      Promise.resolve().then(() => {
        document.getElementById('content-view-1').innerHTML = editor1.getHtml()
      })
    }

    const editor1 = E.createEditor({
      selector: '#editor-text-area-1',
      config: editorConfig1,
      content: [{ type: 'paragraph', children: [{ text: '编辑器1' }] }]
    })
    const toolbar1 = E.createToolbar({
      editor: editor1,
      selector: '#editor-toolbar-1',
      config: {}
    })


    // --------------------- editor2 ---------------------
    const editorConfig2 = { MENU_CONF: {} }
    editorConfig2.placeholder = '请输入内容2...'
    editorConfig2.hoverbarKeys = {
      // 禁用部分 hoverbar
      'link': [],
      'table': []
    }
    editorConfig2.MENU_CONF['uploadImage'] = {
      fieldName: 'your-fileName',
      base64LimitSize: 10 * 1024 * 1024 // 10M 以下插入 base64
    }
    editorConfig2.onChange = (editor) => {
      Promise.resolve().then(() => {
        document.getElementById('content-view-2').innerHTML = editor2.getHtml()
      })
    }

    const editor2 = E.createEditor({
      selector: '#editor-text-area-2',
      config: editorConfig2,
      content: [{ type: 'paragraph', children: [{ text: '编辑器2' }] }],
      mode: 'simple'
    })
    const toolbar2 = E.createToolbar({
      editor: editor2,
      selector: '#editor-toolbar-2',
      mode: 'simple'
    })
  </script>
</body>
</html>

================================================
FILE: packages/editor/examples/new-menu.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>New menu</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/editor.css" rel="stylesheet">
  <link href="../dist/css/style.css" rel="stylesheet">
</head>
<body>
  <p>New menu</p>
  <p>
    <button id="btn-create">create editor</button>
    <button id="btn-destroy">destroy editor</button>
  </p>

  <div>
    <div id="editor-toolbar" class="editor-toolbar"></div>
    <div id="editor-text-area" class="editor-text-area"></div>
  </div>

  <script src="js/init-content.js"></script>
  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor

    // ---------- 注册新菜单 start ----------
    class MyButtonMenu {
        constructor() {
            this.title = 'menu1',
            this.tag = 'button'
        }
        getValue() { return '' }
        isActive() { return false }
        isDisabled() { return false }
        exec(editor) {
            console.log(editor)
            alert('menu1 exec')
        }
    }
    const menuConf = {
      key: 'my-menu-1', // menu key ,唯一。注册之后,需通过 toolbarKeys 配置到工具栏
      factory() {
        return new MyButtonMenu()
      },
    }
    E.Boot.registerMenu(menuConf)
    // ---------- 注册新菜单 end ----------

    // editor 配置
    const editorConfig = {
      placeholder: '请输入内容...',
      onChange(editor) {}
    }

    // toolbar 配置
    const toolbarConfig = {
        // toolbarKeys: ['headerSelect', 'bold', 'my-menu-1'],
        // excludeKeys: [],
        insertKeys: {
            index: 3,
            keys: 'my-menu-1'
        }
    }

    let editor, toolbar

    // create
    document.getElementById('btn-create').addEventListener('click', () => {
      editor = E.createEditor({
        selector: '#editor-text-area',
        config: editorConfig
      })

      toolbar = E.createToolbar({
        editor,
        selector: '#editor-toolbar',
        config: toolbarConfig,
      })
    })

    // destroy
    document.getElementById('btn-destroy').addEventListener('click', () => {
      editor.destroy()
      editor = null
    })
  </script>
</body>
</html>

================================================
FILE: packages/editor/examples/parse-html.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>回显 html</title>
  <link href="https://cdn.bootcdn.net/ajax/libs/normalize/8.0.1/normalize.min.css" rel="stylesheet">
  <link href="./css/editor.css" rel="stylesheet">
  <link href="../dist/css/style.css" rel="stylesheet">
</head>
<body>
  <p style="background-color: #ccc; padding: 5px 0;"><b>回显 html</b> - 使用 html 初始化编辑器内容</p>

  <div style="margin: 10px 0;">
    <button id="btn-v4-html">填入 V4 html 示例</button>
    <button id="btn-v5-html">填入 V5 html 示例</button>
    <span style="color: red;"><b>【注意】只识别从 wangEditor 生成的 html</b> ,不可以随意自定义 html 代码(html 格式太灵活了,不会全部兼容)</span>
  </div>
  <textarea id="text-html" style="width: 100%; height: 300px; font-size: 12px;" placeholder="输入 html 然后创建编辑器">&lt;p&gt;hello world&lt;/p&gt;</textarea>

  <div style="margin: 10px 0;">
    <button id="btn-create">创建编辑器</button>
    <button id="btn-set-html">setHtml</button>
    (如有 JS 报错,再次创建时要刷新页面)
  </div>
  <div>
    <div id="editor-toolbar" class="editor-toolbar"></div>
    <div id="editor-text-area" class="editor-text-area"></div>
  </div>

  <script src="../dist/index.js"></script>
  <script>
    const E = window.wangEditor
    const textarea = document.getElementById('text-html')
    let editor

    // 创建编辑器
    document.getElementById('btn-create').addEventListener('click', () => {
      if (editor) editor.destroy()

      const html = textarea.value
      editor = E.createEditor({
        selector: '#editor-text-area',
        html,
      })
      E.createToolbar({
        editor,
        selector: '#editor-toolbar',
      })
    })

      // setHtml
      document.getElementById('btn-set-html').addEventListener('click', () => {
        if (!editor) alert('editor 尚未创建')

        const html = textarea.value
        editor.setHtml(html)
      })

    // 填入 v4 html
    document.getElementById('btn-v4-html').addEventListener('click', () => {
      textarea.value = `<p>你好&nbsp;<font size="6">世界</font> <font face="黑体">黑体文字</font>!</p>
<p>欢迎<font color="#eeece0">使用</font> <b>wangEditor</b> <span style="background-color: rgb(139, 170, 74);">富文本</span>编辑器</p>
<h1 id="94fco">标题</h1>
<blockquote><p>欢迎使用 <b>wangEditor</b> 富文本编辑器</p></blockquote>
<p>欢迎<i>使用</i> <b>wangEditor</b><u>富文本</u><strike>编辑器</strike>~</p>
<p style="padding-left:2em;">缩进&nbsp;<a href="https://github.com/wangeditor-team" target="_blank">链接</a></p>
<p data-we-empty-p="" style="line-height:3;">行高&nbsp;</p><p>
  <img src="https://www.wangeditor.com/imgs/logo.png"/></p>
<p style="text-align:center;">欢迎使用 <b>wangEditor</b> 富文本编辑器</p>
<ol>
  <li>abc</li>
  <li>def</li>
</ol>
<ul><li>123</li><li>123</li></ul>
<p>
  <img src="https://www.wangeditor.com/imgs/logo.png" alt="111" data-href="https%3A%2F%2Fgithub.com%2Fwangeditor-team" style="max-width:100%;" contenteditable="false"/>
</p>
<table border="0" width="100%" cellpadding="0" cellspacing="0">
  <tbody>
    <tr><th>是的</th><th>你好</th><th>世界</th></tr><tr><td>aaa</td><td></td><td></td></tr>
    <tr><td>bb</td><td>33</td><td></td></tr><tr><td></td><td></td><td>55</td></tr>
    <tr><td></td><td></td><td></td></tr>
  </tbody>
</table>
<p>100</p>
<p><video src="https://media.w3.org/2010/05/sintel/trailer.mp4" controls=""></video></p>
<p>200</p>
<p><iframe src="//player.bilibili.com/player.html?aid=250348909&amp;bvid=BV1Pv411w7Xr&amp;cid=401518678&amp;page=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"> </iframe></p>
<p>123123 🤣😎</p>
<pre><code class="JavaScript"><xmp>const a = 100;
function fn() {
  return a;
}


  • 吃饭
  • 睡觉11
  • 打豆豆


` }) // 填入 v5 html document.getElementById('btn-v5-html').addEventListener('click', () => { textarea.value = `

标题1

标题3
-换行1
-换行2
-换行3


引用文字123
换行

hello 多样式 word~😊😬

span包裹1  span包裹2  span包裹3


百度   var

行高文字 line-height

对齐方式 text-align

增加缩进 indent 1

增加缩进 indent 2

google

设置字号   设置字体

颜色 颜色 颜色

const a = 10;
function fn() {
  // 代码1
}


const a = 10;
function fn() {
  // 代码2
}
  • 项目A
  • 项目B
  • 项目C word~
  1. 项目1 百度
  2. 项目2
  3. 项目3


a
a1
a2
bc
1
2
3
23


吃饭
睡觉
看电影
其他标签
` }) ================================================ FILE: packages/editor/examples/shadow-dom.html ================================================ shadow dom

当前文字数量:; 选中文字数量:; 选中文字:""

================================================ FILE: packages/editor/examples/simple-mode.html ================================================ simple mode

simple mode

bold default mode

================================================ FILE: packages/editor/examples/theme.html ================================================ Theme demo

Theme

================================================ FILE: packages/editor/examples/todo.html ================================================ todo demo

todo

================================================ FILE: packages/editor/examples/upload-image.html ================================================ upload image demo

Upload image

本地下载、启动 server 服务端 http://127.0.0.1:3000/api/upload-img

================================================ FILE: packages/editor/examples/upload-video.html ================================================ upload video demo

Upload video

本地下载、启动 server 服务端 http://127.0.0.1:3000/api/upload-video

================================================ FILE: packages/editor/package.json ================================================ { "name": "@wangeditor/editor", "version": "5.1.23", "description": "Web rich text editor, Web 富文本编辑器", "keywords": [ "wangeditor", "rich text", "editor", "富文本", "编辑器" ], "author": "wangfupeng1988 ", "contributors": [], "homepage": "https://www.wangeditor.com/", "license": "MIT", "types": "dist/editor/src/index.d.ts", "main": "dist/index.js", "module": "dist/index.esm.js", "browser": { "./dist/index.js": "./dist/index.js", "./dist/index.esm.js": "./dist/index.esm.js" }, "directories": { "lib": "dist", "test": "__tests__" }, "files": [ "dist" ], "publishConfig": { "access": "public", "registry": "https://registry.npmjs.com/" }, "repository": { "type": "git", "url": "git+https://github.com/wangeditor-team/wangEditor.git" }, "scripts": { "test": "jest", "test-c": "jest --coverage", "example": "concurrently \"yarn dev-watch\" \"http-server -p 8881 -c-1\" ", "dev": "cross-env NODE_ENV=development rollup -c rollup.config.js", "dev-watch": "cross-env NODE_ENV=development rollup -c rollup.config.js -w", "build": "cross-env NODE_ENV=production rollup -c rollup.config.js", "dev-size-stats": "cross-env NODE_ENV=development:size_stats rollup -c rollup.config.js", "size-stats": "cross-env NODE_ENV=production:size_stats rollup -c rollup.config.js" }, "bugs": { "url": "https://github.com/wangeditor-team/wangEditor/issues" }, "dependencies": { "@uppy/core": "^2.1.1", "@uppy/xhr-upload": "^2.0.3", "@wangeditor/basic-modules": "^1.1.7", "@wangeditor/code-highlight": "^1.0.3", "@wangeditor/core": "^1.1.19", "@wangeditor/list-module": "^1.0.5", "@wangeditor/table-module": "^1.1.4", "@wangeditor/upload-image-module": "^1.0.2", "@wangeditor/video-module": "^1.1.4", "dom7": "^3.0.0", "is-hotkey": "^0.2.0", "lodash.camelcase": "^4.3.0", "lodash.clonedeep": "^4.5.0", "lodash.debounce": "^4.0.8", "lodash.foreach": "^4.5.0", "lodash.isequal": "^4.5.0", "lodash.throttle": "^4.1.1", "lodash.toarray": "^4.4.0", "nanoid": "^3.2.0", "slate": "^0.72.0", "snabbdom": "^3.1.0" } } ================================================ FILE: packages/editor/rollup.config.js ================================================ import { createRollupConfig, IS_PRD, IS_DEV } from '../../build/create-rollup-config' import pkg from './package.json' const name = 'wangEditor' const configList = [] // umd const umdConf = createRollupConfig({ output: { file: pkg.main, format: 'umd', name, }, }) configList.push(umdConf) // esm const esmConf = createRollupConfig({ output: { file: pkg.module, format: 'esm', name, }, }) configList.push(esmConf) export default configList ================================================ FILE: packages/editor/src/Boot.ts ================================================ /** * @description Editor View class * @author wangfupeng */ import { IDomEditor, // 配置 IEditorConfig, IToolbarConfig, IModuleConf, // 注册菜单 IRegisterMenuConf, registerMenu, // 渲染 modal -> view IRenderElemConf, RenderStyleFnType, registerStyleHandler, registerRenderElemConf, // to html IElemToHtmlConf, styleToHtmlFnType, registerStyleToHtmlHandler, registerElemToHtmlConf, // parseHtml PreParseHtmlFnType, IPreParseHtmlConf, registerPreParseHtmlConf, ParseStyleHtmlFnType, IParseElemHtmlConf, registerParseElemHtmlConf, registerParseStyleHtmlHandler, } from '@wangeditor/core' import registerModule from './register-builtin-modules/register' type PluginType = (editor: T) => T class Boot { constructor() { throw new Error('不能实例化\nCan not construct a instance') } // editor 配置 static editorConfig: Partial = {} static setEditorConfig(newConfig: Partial = {}) { this.editorConfig = { ...this.editorConfig, ...newConfig, } } static simpleEditorConfig: Partial = {} static setSimpleEditorConfig(newConfig: Partial = {}) { this.simpleEditorConfig = { ...this.simpleEditorConfig, ...newConfig, } } //toolbar 配置 static toolbarConfig: Partial = {} static setToolbarConfig(newConfig: Partial = {}) { this.toolbarConfig = { ...this.toolbarConfig, ...newConfig, } } static simpleToolbarConfig: Partial = {} static setSimpleToolbarConfig(newConfig: Partial = {}) { this.simpleToolbarConfig = { ...this.simpleToolbarConfig, ...newConfig, } } // 注册插件 static plugins: PluginType[] = [] static registerPlugin(plugin: PluginType) { this.plugins.push(plugin) } // 注册 menu // TODO 可在注册时传入配置,在开发文档中说明 static registerMenu(menuConf: IRegisterMenuConf, customConfig?: { [key: string]: any }) { registerMenu(menuConf, customConfig) } // 注册 renderElem static registerRenderElem(renderElemConf: IRenderElemConf) { registerRenderElemConf(renderElemConf) } // 注册 renderStyle static registerRenderStyle(fn: RenderStyleFnType) { registerStyleHandler(fn) } // 注册 elemToHtml static registerElemToHtml(elemToHtmlConf: IElemToHtmlConf) { registerElemToHtmlConf(elemToHtmlConf) } // 注册 styleToHtml static registerStyleToHtml(fn: styleToHtmlFnType) { registerStyleToHtmlHandler(fn) } // 注册 preParseHtml static registerPreParseHtml(preParseHtmlConf: IPreParseHtmlConf) { registerPreParseHtmlConf(preParseHtmlConf) } // 注册 parseElemHtml static registerParseElemHtml(parseElemHtmlConf: IParseElemHtmlConf) { registerParseElemHtmlConf(parseElemHtmlConf) } // 注册 parseStyleHtml static registerParseStyleHtml(fn: ParseStyleHtmlFnType) { registerParseStyleHtmlHandler(fn) } // 注册 module static registerModule(module: Partial) { registerModule(module) } } export default Boot ================================================ FILE: packages/editor/src/assets/index.less ================================================ // 集中定义 css vars ,否则会被重复定义多次 :root, :host { // textarea - css vars --w-e-textarea-bg-color: #fff; --w-e-textarea-color: #333; --w-e-textarea-border-color: #ccc; --w-e-textarea-slight-border-color: #e8e8e8; --w-e-textarea-slight-color: #d4d4d4; --w-e-textarea-slight-bg-color: #f5f2f0; --w-e-textarea-selected-border-color: #B4D5FF; // 选中的元素,如选中了分割线 --w-e-textarea-handler-bg-color: #4290f7; // 工具,如图片拖拽按钮 // toolbar - css vars --w-e-toolbar-color: #595959; --w-e-toolbar-bg-color: #fff; --w-e-toolbar-active-color: #333; --w-e-toolbar-active-bg-color: #f1f1f1; --w-e-toolbar-disabled-color: #999; --w-e-toolbar-border-color: #e8e8e8; // modal - css vars --w-e-modal-button-bg-color: #fafafa; --w-e-modal-button-border-color: #d9d9d9; } ================================================ FILE: packages/editor/src/constants/svg.ts ================================================ /** * @description svg tag * @author wangfupeng */ /** * 【注意】svg 字符串的长度 ,否则会导致代码体积过大 * 尽量选择 https://www.iconfont.cn/collections/detail?spm=a313x.7781069.0.da5a778a4&cid=20293 * 找不到再从 iconfont.com 搜索 */ // 缩进 right export const INDENT_RIGHT_SVG = '' // 左对齐 export const JUSTIFY_LEFT_SVG = '' // 图片 export const IMAGE_SVG = '' // plus export const MORE_SVG = '' // 视频 export const VIDEO_SVG = '' ================================================ FILE: packages/editor/src/create.ts ================================================ /** * @description create * @author wangfupeng */ import { Descendant } from 'slate' import Boot from './Boot' import { DOMElement } from './utils/dom' import { IEditorConfig, IDomEditor, IToolbarConfig, coreCreateEditor, coreCreateToolbar, Toolbar, } from '@wangeditor/core' export interface ICreateEditorOption { selector: string | DOMElement config: Partial content?: Descendant[] html?: string mode: string } export interface ICreateToolbarOption { editor: IDomEditor | null selector: string | DOMElement config?: Partial mode?: string } /** * 创建 editor 实例 */ export function createEditor(option: Partial = {}): IDomEditor { const { selector = '', content = [], html, config = {}, mode = 'default' } = option let globalConfig = mode === 'simple' ? Boot.simpleEditorConfig : Boot.editorConfig // 单独处理 hoverbarKeys const newHoverbarKeys = { ...(globalConfig.hoverbarKeys || {}), ...(config.hoverbarKeys || {}), } const editor = coreCreateEditor({ selector, config: { ...globalConfig, // 全局配置 ...config, hoverbarKeys: newHoverbarKeys, }, content, html, plugins: Boot.plugins, }) return editor } /** * 创建 toolbar 实例 */ export function createToolbar(option: ICreateToolbarOption): Toolbar { const { selector, editor, config = {}, mode = 'default' } = option if (!selector) { throw new Error(`Cannot find 'selector' when create toolbar`) } let globalConfig = mode === 'simple' ? Boot.simpleToolbarConfig : Boot.toolbarConfig const toolbar = coreCreateToolbar(editor, { selector, config: { ...globalConfig, // 全局配置 ...config, }, }) return toolbar } ================================================ FILE: packages/editor/src/index.ts ================================================ /** * @description editor entry * @author wangfupeng */ import './assets/index.less' import '@wangeditor/core/dist/css/style.css' // 兼容性(要放在最开始就执行) import './utils/browser-polyfill' import './utils/node-polyfill' // 配置多语言 import './locale/index' // 注册内置模块 import './register-builtin-modules/index' // 初始化默认配置 import './init-default-config' // 全局注册 import Boot from './Boot' export { Boot } // 导出 core API 和接口(注意,此处按需导出,不可直接用 `*` ) export { DomEditor, IDomEditor, IEditorConfig, IToolbarConfig, Toolbar, // 第三方模块 - 接口 IModuleConf, IButtonMenu, ISelectMenu, IDropPanelMenu, IModalMenu, // 第三方模块 - 多语言 i18nChangeLanguage, i18nAddResources, i18nGetResources, t, // 第三方模块 - modal 中用到的 API genModalTextareaElems, genModalInputElems, genModalButtonElems, // 第三方模块 - 上传时用到 createUploader, IUploadConfig, } from '@wangeditor/core' // 导出 slate API 和接口 (需重命名,加 `Slate` 前缀) export { Transforms as SlateTransforms, Descendant as SlateDescendant, Editor as SlateEditor, Node as SlateNode, Element as SlateElement, Text as SlateText, Path as SlatePath, Range as SlateRange, Location as SlateLocation, Point as SlatePoint, } from 'slate' // 导出 create 函数 export { createEditor, createToolbar } from './create' export default {} ================================================ FILE: packages/editor/src/init-default-config/config/hoverbar.ts ================================================ /** * @description hoverbar 配置 * @author wangfupeng */ const COMMON_HOVERBAR_KEYS = { // key 即 element type link: { menuKeys: ['editLink', 'unLink', 'viewLink'], }, image: { menuKeys: [ 'imageWidth30', 'imageWidth50', 'imageWidth100', 'editImage', 'viewImageLink', 'deleteImage', ], }, pre: { menuKeys: ['enter', 'codeBlock', 'codeSelectLang'], }, table: { menuKeys: [ 'enter', 'tableHeader', 'tableFullWidth', 'insertTableRow', 'deleteTableRow', 'insertTableCol', 'deleteTableCol', 'deleteTable', ], }, divider: { menuKeys: ['enter'], }, video: { menuKeys: ['enter', 'editVideoSize'], }, } export function genDefaultHoverbarKeys() { return { ...COMMON_HOVERBAR_KEYS, // 也可以自定义 match 来匹配元素,此时 key 就随意了 text: { menuKeys: [ 'headerSelect', 'insertLink', 'bulletedList', '|', 'bold', 'through', 'color', 'bgColor', 'clearStyle', ], }, // other hover bar ... } } export function genSimpleHoverbarKeys() { return COMMON_HOVERBAR_KEYS } ================================================ FILE: packages/editor/src/init-default-config/config/index.ts ================================================ /** * @description 获取编辑器默认配置 * @author wangfupeng */ import { genDefaultToolbarKeys, genSimpleToolbarKeys } from './toolbar' import { genDefaultHoverbarKeys, genSimpleHoverbarKeys } from './hoverbar' export function getDefaultEditorConfig() { return { hoverbarKeys: genDefaultHoverbarKeys(), } } export function getSimpleEditorConfig() { return { hoverbarKeys: genSimpleHoverbarKeys(), } } export function getDefaultToolbarConfig() { return { toolbarKeys: genDefaultToolbarKeys(), } } export function getSimpleToolbarConfig() { return { toolbarKeys: genSimpleToolbarKeys(), } } ================================================ FILE: packages/editor/src/init-default-config/config/toolbar.ts ================================================ /** * @description toolbar 配置 * @author wangfupeng */ import { t } from '@wangeditor/core' import { INDENT_RIGHT_SVG, JUSTIFY_LEFT_SVG, IMAGE_SVG, MORE_SVG, VIDEO_SVG, } from '../../constants/svg' export function genDefaultToolbarKeys() { return [ 'headerSelect', // 'header1', // 'header2', // 'header3', 'blockquote', '|', 'bold', 'underline', 'italic', { key: 'group-more-style', // 以 group 开头 title: t('editor.more'), iconSvg: MORE_SVG, menuKeys: ['through', 'code', 'sup', 'sub', 'clearStyle'], }, 'color', 'bgColor', '|', 'fontSize', 'fontFamily', 'lineHeight', '|', 'bulletedList', 'numberedList', 'todo', { key: 'group-justify', // 以 group 开头 title: t('editor.justify'), iconSvg: JUSTIFY_LEFT_SVG, menuKeys: ['justifyLeft', 'justifyRight', 'justifyCenter', 'justifyJustify'], }, { key: 'group-indent', // 以 group 开头 title: t('editor.indent'), iconSvg: INDENT_RIGHT_SVG, menuKeys: ['indent', 'delIndent'], }, '|', 'emotion', 'insertLink', // 'editLink', // 'unLink', // 'viewLink', { key: 'group-image', // 以 group 开头 title: t('editor.image'), iconSvg: IMAGE_SVG, menuKeys: ['insertImage', 'uploadImage'], }, // 'deleteImage', // 'editImage', // 'viewImageLink', { key: 'group-video', // 以 group 开头 title: t('editor.video'), iconSvg: VIDEO_SVG, menuKeys: ['insertVideo', 'uploadVideo'], }, // 'deleteVideo', 'insertTable', 'codeBlock', // 'codeSelectLang', 'divider', // 'deleteTable', '|', 'undo', 'redo', '|', 'fullScreen', ] } export function genSimpleToolbarKeys() { return [ 'blockquote', 'header1', 'header2', 'header3', '|', 'bold', 'underline', 'italic', 'through', 'color', 'bgColor', 'clearStyle', '|', 'bulletedList', 'numberedList', 'todo', 'justifyLeft', 'justifyRight', 'justifyCenter', '|', 'insertLink', { key: 'group-image', // 以 group 开头 title: t('editor.image'), iconSvg: IMAGE_SVG, menuKeys: ['insertImage', 'uploadImage'], }, 'insertVideo', 'insertTable', 'codeBlock', '|', 'undo', 'redo', '|', 'fullScreen', ] } ================================================ FILE: packages/editor/src/init-default-config/index.ts ================================================ /** * @description set default config * @author wangfupeng */ import Boot from '../Boot' import { getDefaultEditorConfig, getDefaultToolbarConfig, getSimpleEditorConfig, getSimpleToolbarConfig, } from './config' import { wangEditorCodeHighLightDecorate } from '@wangeditor/code-highlight' const defaultEditorConfig = getDefaultEditorConfig() Boot.setEditorConfig({ ...defaultEditorConfig, decorate: wangEditorCodeHighLightDecorate, // 代码高亮 }) const simpleEditorConfig = getSimpleEditorConfig() Boot.setSimpleEditorConfig({ ...simpleEditorConfig, decorate: wangEditorCodeHighLightDecorate, // 代码高亮 }) const defaultToolbarConfig = getDefaultToolbarConfig() Boot.setToolbarConfig(defaultToolbarConfig) const simpleToolbarConfig = getSimpleToolbarConfig() Boot.setSimpleToolbarConfig(simpleToolbarConfig) ================================================ FILE: packages/editor/src/locale/en.ts ================================================ /** * @description i18n en * @author wangfupeng */ export default { editor: { more: 'More', justify: 'Justify', indent: 'Indent', image: 'Image', video: 'Video', }, } ================================================ FILE: packages/editor/src/locale/index.ts ================================================ /** * @description i18n entry * @author wangfupeng */ import { i18nAddResources } from '@wangeditor/core' import enResources from './en' import zhResources from './zh-CN' i18nAddResources('en', enResources) i18nAddResources('zh-CN', zhResources) ================================================ FILE: packages/editor/src/locale/zh-CN.ts ================================================ /** * @description i18n zh-CN * @author wangfupeng */ export default { editor: { more: '更多', justify: '对齐', indent: '缩进', image: '图片', video: '视频', }, } ================================================ FILE: packages/editor/src/register-builtin-modules/index.ts ================================================ /** * @description register builtin modules * @author wangfupeng */ // basic-modules import '@wangeditor/basic-modules/dist/css/style.css' import basicModules from '@wangeditor/basic-modules' import '@wangeditor/list-module/dist/css/style.css' import wangEditorListModule from '@wangeditor/list-module' // table-module import '@wangeditor/table-module/dist/css/style.css' import wangEditorTableModule from '@wangeditor/table-module' // video-module import '@wangeditor/video-module/dist/css/style.css' import wangEditorVideoModule from '@wangeditor/video-module' // upload-image-module import '@wangeditor/upload-image-module/dist/css/style.css' import wangEditorUploadImageModule from '@wangeditor/upload-image-module' // code-highlight import '@wangeditor/code-highlight/dist/css/style.css' import { wangEditorCodeHighlightModule } from '@wangeditor/code-highlight' import registerModule from './register' basicModules.forEach(module => registerModule(module)) registerModule(wangEditorListModule) registerModule(wangEditorTableModule) registerModule(wangEditorVideoModule) registerModule(wangEditorUploadImageModule) registerModule(wangEditorCodeHighlightModule) ================================================ FILE: packages/editor/src/register-builtin-modules/register.ts ================================================ /** * @description 注册 module * @author wangfupeng */ import Boot from '../Boot' import { IModuleConf } from '@wangeditor/core' function registerModule(module: Partial) { const { menus, renderElems, renderStyle, elemsToHtml, styleToHtml, preParseHtml, parseElemsHtml, parseStyleHtml, editorPlugin, } = module if (menus) { menus.forEach(menu => Boot.registerMenu(menu)) } if (renderElems) { renderElems.forEach(renderElemConf => Boot.registerRenderElem(renderElemConf)) } if (renderStyle) { Boot.registerRenderStyle(renderStyle) } if (elemsToHtml) { elemsToHtml.forEach(elemToHtmlConf => Boot.registerElemToHtml(elemToHtmlConf)) } if (styleToHtml) { Boot.registerStyleToHtml(styleToHtml) } if (preParseHtml) { preParseHtml.forEach(conf => Boot.registerPreParseHtml(conf)) } if (parseElemsHtml) { parseElemsHtml.forEach(parseElemHtmlConf => Boot.registerParseElemHtml(parseElemHtmlConf)) } if (parseStyleHtml) { Boot.registerParseStyleHtml(parseStyleHtml) } if (editorPlugin) { Boot.registerPlugin(editorPlugin) } } export default registerModule ================================================ FILE: packages/editor/src/utils/browser-polyfill.ts ================================================ /** * @description browser polyfill * @author wangfupeng */ // @ts-nocheck // 必须是浏览器环境 if (typeof global === 'undefined') { // 检查 IE 浏览器 if ('ActiveXObject' in window) { let info = '抱歉,wangEditor V5+ 版本开始,不在支持 IE 浏览器' info += '\n Sorry, wangEditor V5+ versions do not support IE browser.' console.error(info) } globalThisPolyfill() AggregateErrorPolyfill() } else if (global && global.navigator?.userAgent.match('QQBrowser')) { // 兼容 QQ 浏览器 AggregateError 报错 globalThisPolyfill() AggregateErrorPolyfill() } function globalThisPolyfill() { // 部分浏览器不支持 globalThis if (typeof globalThis === 'undefined') { // @ts-ignore window.globalThis = window } } function AggregateErrorPolyfill() { if (typeof AggregateError === 'undefined') { window.AggregateError = function (errors, msg) { const err = new Error(msg) err.errors = errors return err } } } ================================================ FILE: packages/editor/src/utils/dom.ts ================================================ /** * @description dom utils * @author wangfupeng */ import DOMElement = globalThis.Element export { DOMElement } ================================================ FILE: packages/editor/src/utils/node-polyfill.ts ================================================ /** * @description node polyfill * @author wangfupeng */ // @ts-nocheck // 必须是 node 环境 if (typeof global === 'object') { // 用于 nodejs ,避免报错 const globalProperty = Object.getOwnPropertyDescriptor(global, 'window') // global.window 为空则直接写入 // 部分框架下已经定义了global.window且是不可写属性 if (!global.window || globalProperty.set) { global.window = global global.requestAnimationFrame = () => {} global.navigator = { userAgent: '', } global.location = { hostname: '0.0.0.0', port: 0, protocol: 'http:', } global.btoa = () => {} global.crypto = { getRandomValues: function (buffer: any) { return nodeCrypto.randomFillSync(buffer) }, } } if (global.document != null) { // SSR 环境下可能会报错 (issue 4409) if (global.document.getElementsByTagName == null) { global.document.getElementsByTagName = () => [] } } } ================================================ FILE: packages/editor/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "include": [ "./src/**/*", "../custom-types.d.ts" ] } ================================================ FILE: packages/list-module/CHANGELOG.md ================================================ # Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. ## [1.0.5](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/list-module@1.0.4...@wangeditor/list-module@1.0.5) (2022-09-27) ### Bug Fixes * list-item - 遇到 style 是 toHtml 出错 ([9854308](https://github.com/wangeditor-team/wangEditor/commit/98543083a1cb09207aceb2a4d8f3c1ce020b106d)) ## [1.0.4](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/list-module@1.0.3...@wangeditor/list-module@1.0.4) (2022-09-27) **Note:** Version bump only for package @wangeditor/list-module ================================================ FILE: packages/list-module/README.md ================================================ # wangEditor list-module List module built in [wangEditor](https://www.wangeditor.com/) by default. ================================================ FILE: packages/list-module/__tests__/elem-to-html.test.ts ================================================ /** * @description list toHtml test * @author wangfupeng */ import createEditor from '../../../tests/utils/create-editor' import { ELEM_TO_EDITOR } from '../src/utils/maps' import listItemToHtmlConf from '../src/module/elem-to-html' describe('module elem-to-html', () => { const childrenHtml = 'hello' const orderedElem1 = { type: 'list-item', ordered: true, children: [{ text: '' }] } const orderedElem2 = { type: 'list-item', ordered: true, children: [{ text: '' }] } const unOrderedItem1 = { type: 'list-item', children: [{ text: '' }] } const unOrderedItem2 = { type: 'list-item', children: [{ text: '' }] } const unOrderedItem21 = { type: 'list-item', level: 1, children: [{ text: '' }] } const editor = createEditor({ content: [orderedElem1, orderedElem2, unOrderedItem1, unOrderedItem2, unOrderedItem21], }) // elem 绑定 editor ELEM_TO_EDITOR.set(orderedElem1, editor) ELEM_TO_EDITOR.set(orderedElem2, editor) ELEM_TO_EDITOR.set(unOrderedItem1, editor) ELEM_TO_EDITOR.set(unOrderedItem2, editor) ELEM_TO_EDITOR.set(unOrderedItem21, editor) test(`toHtml conf type`, () => { expect(listItemToHtmlConf.type).toBe('list-item') }) test(`ordered item toHtml`, () => { const { elemToHtml } = listItemToHtmlConf // first item const firstHtml = elemToHtml(orderedElem1, childrenHtml) expect(firstHtml).toEqual({ html: '
  • hello
  • ', prefix: '
      ', // 第一个 item ,前面会有
        suffix: '', }) // last item const lastHtml = elemToHtml(orderedElem2, childrenHtml) expect(lastHtml).toEqual({ html: '
      1. hello
      2. ', prefix: '', suffix: '
      ', // 最后一个 item ,后面会有
    }) }) test(`unOrdered item toHtml`, () => { const { elemToHtml } = listItemToHtmlConf // first item const firstHtml = elemToHtml(unOrderedItem1, childrenHtml) expect(firstHtml).toEqual({ html: '
  • hello
  • ', prefix: '
      ', // 第一个 item ,前面会有
        suffix: '', }) // second item const secondHtml = elemToHtml(unOrderedItem2, childrenHtml) expect(secondHtml).toEqual({ html: '
      • hello
      • ', // 第二个 item ,不应该有
          prefix: '', suffix: '', }) // last item - leveled const lastHtml = elemToHtml(unOrderedItem21, childrenHtml) expect(lastHtml).toEqual({ html: '
        • hello
        • ', // 最后一个 item ( leveled ) ,包裹
            prefix: '
              ', suffix: '
          ', }) }) }) ================================================ FILE: packages/list-module/__tests__/menu/bulleted-list-menu.test.ts ================================================ /** * @description bulletedList menu test * @author wangfupeng */ import BulletedListMenu from '../../src/module/menu/BulletedListMenu' import createEditor from '../../../../tests/utils/create-editor' describe('list BulletedListMenu', () => { const menu = new BulletedListMenu() it('getValue', () => { const editor = createEditor() expect(menu.getValue(editor)).toBe('') }) it('isActive', () => { const editor = createEditor({ content: [ { type: 'paragraph', children: [{ text: 'hello' }] }, { type: 'list-item', children: [{ text: 'a' }] }, ], }) editor.deselect() expect(menu.isActive(editor)).toBeFalsy() editor.select({ path: [0, 0], offset: 0 }) // 选中 p expect(menu.isActive(editor)).toBeFalsy() editor.select({ path: [1, 0], offset: 0 }) // 选中 li expect(menu.isActive(editor)).toBeTruthy() }) it('isDisabled', () => { const editor = createEditor({ content: [ { type: 'paragraph', children: [{ text: 'hello' }] }, { type: 'list-item', children: [{ text: 'a' }] }, { type: 'table', width: 'auto', children: [ { type: 'table-row', children: [{ type: 'table-cell', children: [{ text: '' }], isHeader: true }], }, ], }, { type: 'pre', children: [{ type: 'code', language: '', children: [{ text: 'a' }] }], }, ], }) editor.deselect() expect(menu.isDisabled(editor)).toBeTruthy() editor.select({ path: [0, 0], offset: 0 }) // 选中 p expect(menu.isDisabled(editor)).toBeFalsy() editor.select({ path: [1, 0], offset: 0 }) // 选中 li expect(menu.isDisabled(editor)).toBeFalsy() editor.select({ path: [2, 0, 0, 0], offset: 0 }) // 选中 table 单元格 expect(menu.isDisabled(editor)).toBeTruthy() editor.select({ path: [3, 0, 0], offset: 0 }) // 选中 code expect(menu.isDisabled(editor)).toBeTruthy() }) it('exec', () => { const pElem = { type: 'paragraph', children: [{ text: 'hello' }] } const editor = createEditor({ content: [pElem], }) editor.select({ path: [0, 0], offset: 0 }) // 选中 p menu.exec(editor, '') // p 转 li expect(editor.children).toEqual([ { type: 'list-item', ordered: false, children: [{ text: 'hello' }], }, ]) menu.exec(editor, '') // li 转 p expect(editor.children).toEqual([pElem]) }) }) ================================================ FILE: packages/list-module/__tests__/menu/numbered-list-menu.test.ts ================================================ /** * @description list NumberedListMenu test * @author wangfupeng */ import NumberedListMenu from '../../src/module/menu/NumberedListMenu' import createEditor from '../../../../tests/utils/create-editor' describe('list NumberedListMenu', () => { const menu = new NumberedListMenu() it('getValue', () => { const editor = createEditor() expect(menu.getValue(editor)).toBe('') }) it('isActive', () => { const editor = createEditor({ content: [ { type: 'paragraph', children: [{ text: 'hello' }] }, { type: 'list-item', ordered: true, children: [{ text: 'a' }] }, ], }) editor.deselect() expect(menu.isActive(editor)).toBeFalsy() editor.select({ path: [0, 0], offset: 0 }) // 选中 p expect(menu.isActive(editor)).toBeFalsy() editor.select({ path: [1, 0], offset: 0 }) // 选中 li expect(menu.isActive(editor)).toBeTruthy() }) it('isDisabled', () => { const editor = createEditor({ content: [ { type: 'paragraph', children: [{ text: 'hello' }] }, { type: 'list-item', ordered: true, children: [{ text: 'a' }] }, { type: 'table', width: 'auto', children: [ { type: 'table-row', children: [{ type: 'table-cell', children: [{ text: '' }], isHeader: true }], }, ], }, { type: 'pre', children: [{ type: 'code', language: '', children: [{ text: 'a' }] }], }, ], }) editor.deselect() expect(menu.isDisabled(editor)).toBeTruthy() editor.select({ path: [0, 0], offset: 0 }) // 选中 p expect(menu.isDisabled(editor)).toBeFalsy() editor.select({ path: [1, 0], offset: 0 }) // 选中 li expect(menu.isDisabled(editor)).toBeFalsy() editor.select({ path: [2, 0, 0, 0], offset: 0 }) // 选中 table 单元格 expect(menu.isDisabled(editor)).toBeTruthy() editor.select({ path: [3, 0, 0], offset: 0 }) // 选中 code expect(menu.isDisabled(editor)).toBeTruthy() }) it('exec', () => { const pElem = { type: 'paragraph', children: [{ text: 'hello' }] } const editor = createEditor({ content: [pElem], }) editor.select({ path: [0, 0], offset: 0 }) // 选中 p menu.exec(editor, '') // p 转 li expect(editor.children).toEqual([ { type: 'list-item', ordered: true, children: [{ text: 'hello' }], }, ]) menu.exec(editor, '') // li 转 p expect(editor.children).toEqual([pElem]) }) }) ================================================ FILE: packages/list-module/__tests__/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../tests/utils/create-editor' import { parseItemHtmlConf, parseListHtmlConf } from '../src/module/parse-elem-html' describe('list - parse html', () => { const editor = createEditor() it('parse unOrdered list item', () => { const $ul = $('
            ') const $li = $('
          • ') $ul.append($li) const children = [{ text: 'hello' }] const elem = parseItemHtmlConf.parseElemHtml($li[0], children, editor) expect(elem).toEqual({ type: 'list-item', ordered: false, level: 0, children, }) }) it('parse ordered list item', () => { const $ol = $('
              ') const $li = $('
            1. ') $ol.append($li) const children = [{ text: 'hello' }] const elem = parseItemHtmlConf.parseElemHtml($li[0], children, editor) expect(elem).toEqual({ type: 'list-item', ordered: true, level: 0, children, }) }) it('parse leveled list item', () => { const $ul = $('
                ') const $ol = $('
                  ') const $li = $('
                1. ') $ul.append($ol) $ol.append($li) const children = [{ text: 'hello' }] const elem = parseItemHtmlConf.parseElemHtml($li[0], children, editor) expect(elem).toEqual({ type: 'list-item', ordered: true, level: 1, children, }) }) it('parse list', () => { const $ol = $('
                    ') const children = [ { type: 'list-item', ordered: true, children: [{ text: 'a' }], }, { type: 'list-item', ordered: true, children: [{ text: 'b' }], }, // 嵌套列表 [ { type: 'list-item', level: 1, children: [{ text: 'x' }], }, { type: 'list-item', level: 1, children: [{ text: 'y' }], }, ], ] // @ts-ignore const listElems = parseListHtmlConf.parseElemHtml($ol[0], children, editor) expect(listElems.length).toBe(4) // parse list 时,会把输出的结果(数组)flatten ,把嵌套的平铺开 }) }) ================================================ FILE: packages/list-module/__tests__/plugin.test.ts ================================================ /** * @description list plugin test * @author wangfupeng */ import withList from '../src/module/plugin' import createEditor from '../../../tests/utils/create-editor' describe('list plugin test', () => { it('insert tab - increase level', () => { const listItem = { type: 'list-item', children: [{ text: 'hello' }] } let editor = createEditor({ content: [listItem], }) editor = withList(editor) // 使用插件 editor.select({ path: [0, 0], offset: 0 }) // 选中 list-item 开头 editor.handleTab() // tab const children = editor.children expect(children).toEqual([ { ...listItem, level: 1, // 增加 level }, ]) }) it('insert delete - decrease level', () => { const listItem = { type: 'list-item', children: [{ text: 'hello' }], level: 2 } let editor = createEditor({ content: [listItem], }) editor = withList(editor) // 使用插件 editor.select({ path: [0, 0], offset: 0 }) // 选中 list-item 开头 editor.deleteBackward('character') // delete expect(editor.children).toEqual([ { ...listItem, level: 1, // 减少 level }, ]) editor.deleteBackward('character') // delete expect(editor.children).toEqual([ { ...listItem, level: 0, // 减少 level }, ]) }) it('兼容之前的 JSON 格式', () => { const listItem = { type: 'list-item', children: [{ text: 'hello' }] } let editor = createEditor({ // 之前的 JSON 格式 content: [ { type: 'bulleted-list', children: [listItem], }, ], }) editor = withList(editor) // 使用插件 expect(editor.children).toEqual([listItem]) }) }) ================================================ FILE: packages/list-module/__tests__/render-elem.test.ts ================================================ /** * @description list render elem test * @author wangfupeng */ import createEditor from '../../../tests/utils/create-editor' import renderListItemConf from '../src/module/render-elem' describe('list module - render elem', () => { const unOrderedItem = { type: 'list-item', children: [{ text: '' }] } const orderedItem = { type: 'list-item', ordered: true, children: [{ text: '' }] } const leveledItem = { type: 'list-item', level: 3, children: [{ text: '' }] } const editor = createEditor({ content: [unOrderedItem, orderedItem, leveledItem], }) it('render conf type', () => { expect(renderListItemConf.type).toBe('list-item') }) it('render ordered list item elem', () => { const vnode: any = renderListItemConf.renderElem(orderedItem, null, editor) expect(vnode.sel).toBe('div') // render-elem 使用
                    模拟
                  1. const prefixVnode = vnode.children[0] || {} expect(prefixVnode.text).toBe('1.') // ordered list-item 有序号 }) it('render unOrdered list item elem', () => { const vnode: any = renderListItemConf.renderElem(unOrderedItem, null, editor) expect(vnode.sel).toBe('div') // render-elem 使用
                    模拟
                  2. const prefixVnode = vnode.children[0] || {} expect(prefixVnode.text).toBe('•') // unOrdered list-item 点号 }) it('render leveled list item elem', () => { const vnode: any = renderListItemConf.renderElem(leveledItem, null, editor) const style = vnode.data.style expect(style).toEqual({ margin: '5px 0 5px 60px' }) // margin-left 60px }) }) ================================================ FILE: packages/list-module/package.json ================================================ { "name": "@wangeditor/list-module", "version": "1.0.5", "description": "wangEditor list module", "author": "wangfupeng1988 ", "contributors": [], "homepage": "https://github.com/wangeditor-team/wangEditor#readme", "license": "MIT", "types": "dist/list-module/src/index.d.ts", "main": "dist/index.js", "module": "dist/index.esm.js", "browser": { "./dist/index.js": "./dist/index.js", "./dist/index.esm.js": "./dist/index.esm.js" }, "directories": { "lib": "dist", "test": "__tests__" }, "files": [ "dist" ], "publishConfig": { "access": "public", "registry": "https://registry.npmjs.com/" }, "repository": { "type": "git", "url": "git+https://github.com/wangeditor-team/wangEditor.git" }, "scripts": { "test": "jest", "test-c": "jest --coverage", "dev": "cross-env NODE_ENV=development rollup -c rollup.config.js", "dev-watch": "cross-env NODE_ENV=development rollup -c rollup.config.js -w", "build": "cross-env NODE_ENV=production rollup -c rollup.config.js", "dev-size-stats": "cross-env NODE_ENV=development:size_stats rollup -c rollup.config.js", "size-stats": "cross-env NODE_ENV=production:size_stats rollup -c rollup.config.js" }, "bugs": { "url": "https://github.com/wangeditor-team/wangEditor/issues" }, "peerDependencies": { "@wangeditor/core": "1.x", "dom7": "^3.0.0", "slate": "^0.72.0", "snabbdom": "^3.1.0" } } ================================================ FILE: packages/list-module/rollup.config.js ================================================ import { createRollupConfig, IS_PRD } from '../../build/create-rollup-config' import pkg from './package.json' const name = 'WangEditorListModule' const configList = [] // esm - 开发环境不需要 CDN 引入,只需要 npm 引入,所以优先输出 esm const esmConf = createRollupConfig({ output: { file: pkg.module, format: 'esm', name, }, }) configList.push(esmConf) // umd const umdConf = createRollupConfig({ output: { file: pkg.main, format: 'umd', name, }, }) configList.push(umdConf) export default configList ================================================ FILE: packages/list-module/src/assets/index.less ================================================ ================================================ FILE: packages/list-module/src/constants/svg.ts ================================================ /** * @description icon svg * @author wangfupeng */ /** * 【注意】svg 字符串的长度 ,否则会导致代码体积过大 * 尽量选择 https://www.iconfont.cn/collections/detail?spm=a313x.7781069.0.da5a778a4&cid=20293 * 找不到再从 iconfont.com 搜索 */ // 无序列表 export const BULLETED_LIST_SVG = '' // 有序列表 export const NUMBERED_LIST_SVG = '' ================================================ FILE: packages/list-module/src/index.ts ================================================ /** * @description list module * @author wangfupeng */ import './assets/index.less' // 配置多语言 import './locale/index' // 导出 module import wangEditorListModule from './module/index' export default wangEditorListModule ================================================ FILE: packages/list-module/src/locale/en.ts ================================================ /** * @description i18n en * @author wangfupeng */ export default { listModule: { unOrderedList: 'Unordered list', orderedList: 'Ordered list', }, } ================================================ FILE: packages/list-module/src/locale/index.ts ================================================ /** * @description i18n entry * @author wangfupeng */ import { i18nAddResources } from '@wangeditor/core' import enResources from './en' import zhResources from './zh-CN' i18nAddResources('en', enResources) i18nAddResources('zh-CN', zhResources) ================================================ FILE: packages/list-module/src/locale/zh-CN.ts ================================================ /** * @description i18n zh-CN * @author wangfupeng */ export default { listModule: { unOrderedList: '无序列表', orderedList: '有序列表', }, } ================================================ FILE: packages/list-module/src/module/custom-types.ts ================================================ /** * @description list element * @author wangfupeng */ import { Text } from 'slate' //【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts export type ListItemElement = { type: 'list-item' ordered: boolean // 有序/无序 level: number // 层级:0 1 2 ... children: Text[] } ================================================ FILE: packages/list-module/src/module/elem-to-html.ts ================================================ /** * @description to html * @author wangfupeng */ import { Element, Path, Editor } from 'slate' import { DomEditor } from '@wangeditor/core' import { ListItemElement } from './custom-types' import { ELEM_TO_EDITOR } from '../utils/maps' /** * 当前 list-item 前面需要拼接几个
                        * @param elem elem */ function getStartContainerTagNumber(elem: Element): number { const editor = ELEM_TO_EDITOR.get(elem) if (editor == null) return 0 const { type, ordered = false, level = 0 } = elem as ListItemElement const path = DomEditor.findPath(editor, elem) if (path[0] === 0) { // list-item 是第一个元素,再往前没有了。需要拼接
                            return level + 1 } // 获取上一个 elem const prevPath = Path.previous(path) const prevEntry = Editor.node(editor, prevPath) if (!prevEntry) return 0 const [prevElem] = prevEntry const prevType = DomEditor.getNodeType(prevElem) if (prevType !== type) { // 上一个 elem 不是 list-item ,需要拼接
                                return level + 1 } // 上一个 elem 是 list-item const { ordered: prevOrdered = false, level: prevLevel = 0 } = prevElem as ListItemElement if (prevLevel < level) { // 上一个 level 小于当前 level ,需要拼接
                                    return level - prevLevel } if (prevLevel > level) { // 上一个 level 大于当前 level ,不需要拼接
                                        return 0 } if (prevLevel === level) { // 上一个 level 等于当前 level if (prevOrdered === ordered) { // ordered 一致,则不需要拼接
                                            return 0 } else { /// ordered 不一致,则需要拼接
                                                return 1 } } // 其他情况 return 0 } /** * 当前 list-item 后面面需要拼接几个
                                          * @param elem elem */ function getEndContainerTagNumber(elem: Element): number { const editor = ELEM_TO_EDITOR.get(elem) if (editor == null) return 0 const { type, ordered = false, level = 0 } = elem as ListItemElement const path = DomEditor.findPath(editor, elem) if (path[0] === editor.children.length - 1) { // list-item 是最后一个元素,再往后没有了。需要拼接
                                      return level + 1 } // 获取下一个 elem const nextPath = Path.next(path) const nextEntry = Editor.node(editor, nextPath) if (!nextEntry) return 0 const [nextElem] = nextEntry const nextType = DomEditor.getNodeType(nextElem) if (nextType !== type) { // 下一个 elem 不是 list-item ,需要拼接
                                          return level + 1 } // 下一个 elem 是 list-item const { ordered: nextOrdered = false, level: nextLevel = 0 } = nextElem as ListItemElement if (nextLevel < level) { // 下一个 level 小于当前 level ,需要拼接
                                  return level - nextLevel } if (nextLevel > level) { // 下一个 level 大于当前 level ,不需要拼接
                              return 0 } if (nextLevel === level) { // 下一个 level 等于当前 level if (nextOrdered === ordered) { // ordered 一致,则不需要拼接
                          return 0 } else { /// ordered 不一致,则需要拼接
                      return 1 } } // 其他情况 return 0 } // ol ul 栈 const CONTAINER_TAG_STACK: Array = [] function elemToHtml( elem: Element, childrenHtml: string ): { html: string prefix?: string suffix?: string } { let startContainerStr = '' let endContainerStr = '' const { ordered = false } = elem as ListItemElement const containerTag = ordered ? 'ol' : 'ul' // 前面需要拼接几个
                          const startContainerTagNumber = getStartContainerTagNumber(elem) if (startContainerTagNumber > 0) { for (let i = 0; i < startContainerTagNumber; i++) { startContainerStr += `<${containerTag}>` // 记录 start container tag ,如 `
                            ` CONTAINER_TAG_STACK.push(containerTag) // tag 压栈 } } // 后面需要拼接几个
                  const endContainerTagNumber = getEndContainerTagNumber(elem) if (endContainerTagNumber > 0) { for (let i = 0; i < endContainerTagNumber; i++) { const tag = CONTAINER_TAG_STACK.pop() // tag 从栈中获取 endContainerStr += `` // 记录 end container tag ,如 `
                ` } } return { html: `
              • ${childrenHtml}
              • `, prefix: startContainerStr, suffix: endContainerStr, } } const listItemToHtmlConf = { type: 'list-item', elemToHtml: elemToHtml, } export default listItemToHtmlConf ================================================ FILE: packages/list-module/src/module/index.ts ================================================ /** * @description list module entry * @author wangfupeng */ import { IModuleConf } from '@wangeditor/core' import renderListItemConf from './render-elem' import withList from './plugin' import { bulletedListMenuConf, numberedListMenuConf } from './menu/index' import listItemToHtmlConf from './elem-to-html' import { parseItemHtmlConf, parseListHtmlConf } from './parse-elem-html' const list: Partial = { renderElems: [renderListItemConf], editorPlugin: withList, menus: [bulletedListMenuConf, numberedListMenuConf], elemsToHtml: [listItemToHtmlConf], parseElemsHtml: [parseListHtmlConf, parseItemHtmlConf], } export default list ================================================ FILE: packages/list-module/src/module/menu/BaseMenu.ts ================================================ /** * @description base menu * @author wangfupeng */ import { Editor, Node, Transforms, Element } from 'slate' import { IButtonMenu, IDomEditor, DomEditor } from '@wangeditor/core' import { ListItemElement } from '../custom-types' abstract class BaseMenu implements IButtonMenu { readonly type = 'list-item' abstract readonly ordered: boolean abstract readonly title: string abstract readonly iconSvg: string readonly tag = 'button' private getListNode(editor: IDomEditor): Node | null { const { type } = this return DomEditor.getSelectedNodeByType(editor, type) } getValue(editor: IDomEditor): string | boolean { return '' } isActive(editor: IDomEditor): boolean { const node = this.getListNode(editor) if (node == null) return false const { ordered = false } = node as ListItemElement return ordered === this.ordered } isDisabled(editor: IDomEditor): boolean { if (editor.selection == null) return true const selectedElems = DomEditor.getSelectedElems(editor) const notMatch = selectedElems.some((elem: Element) => { if (Editor.isVoid(editor, elem) && Editor.isBlock(editor, elem)) return true const { type } = elem as Element if (['pre', 'code', 'table'].includes(type)) return true }) if (notMatch) return true return false } exec(editor: IDomEditor, value: string | boolean): void { const active = this.isActive(editor) if (active) { // 如果当前 active ,则转换为 p 标签 Transforms.setNodes(editor, { type: 'paragraph', ordered: undefined, level: undefined, }) } else { // 否则,转换为 list-item Transforms.setNodes(editor, { type: 'list-item', ordered: this.ordered, // 有序/无序 indent: undefined, }) } } } export default BaseMenu ================================================ FILE: packages/list-module/src/module/menu/BulletedListMenu.ts ================================================ /** * @description bulleted list menu * @author wangfupeng */ import { t } from '@wangeditor/core' import BaseMenu from './BaseMenu' import { BULLETED_LIST_SVG } from '../../constants/svg' class BulletedListMenu extends BaseMenu { readonly ordered = false readonly title = t('listModule.unOrderedList') readonly iconSvg = BULLETED_LIST_SVG } export default BulletedListMenu ================================================ FILE: packages/list-module/src/module/menu/NumberedListMenu.ts ================================================ /** * @description numbered list menu * @author wangfupeng */ import { t } from '@wangeditor/core' import BaseMenu from './BaseMenu' import { NUMBERED_LIST_SVG } from '../../constants/svg' class NumberedListMenu extends BaseMenu { readonly ordered = true readonly title = t('listModule.orderedList') readonly iconSvg = NUMBERED_LIST_SVG } export default NumberedListMenu ================================================ FILE: packages/list-module/src/module/menu/index.ts ================================================ /** * @description menu entry * @author wangfupeng */ import BulletedListMenu from './BulletedListMenu' import NumberedListMenu from './NumberedListMenu' export const bulletedListMenuConf = { key: 'bulletedList', factory() { return new BulletedListMenu() }, } export const numberedListMenuConf = { key: 'numberedList', factory() { return new NumberedListMenu() }, } ================================================ FILE: packages/list-module/src/module/parse-elem-html.ts ================================================ /** * @description parse elem html * @author wangfupeng */ import { Dom7Array } from 'dom7' import { Descendant, Text } from 'slate' import $, { DOMElement, getTagName } from '../utils/dom' import { IDomEditor } from '@wangeditor/core' import { ListItemElement } from './custom-types' /** * 获取 ordered * @param $elem list $elem */ function getOrdered($elem: Dom7Array): boolean { const $list = $elem.parent() const listTagName = getTagName($list) if (listTagName === 'ol') return true return false } /** * 获取 level * @param $elem list $elem */ function getLevel($elem: Dom7Array): number { let level = 0 let $cur: Dom7Array = $elem.parent() let tagName: string = getTagName($cur) while (tagName === 'ul' || tagName === 'ol') { $cur = $cur.parent() tagName = getTagName($cur) level++ } return level - 1 } function parseItemHtml( elem: DOMElement, children: Descendant[], editor: IDomEditor ): ListItemElement { const $elem = $(elem) children = children.filter(child => { if (Text.isText(child)) return true if (editor.isInline(child)) return true return false }) // 无 children ,则用纯文本 if (children.length === 0) { children = [{ text: $elem.text().replace(/\s+/gm, ' ') }] } const ordered = getOrdered($elem) const level = getLevel($elem) return { type: 'list-item', ordered, level, // @ts-ignore children, } } export const parseItemHtmlConf = { selector: 'li:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性 parseElemHtml: parseItemHtml, } function parseListHtml( elem: DOMElement, children: Descendant[], editor: IDomEditor ): ListItemElement[] { // @ts-ignore flatten 因为可能有 ul/ol 嵌套,重要!!! return children.flat(Infinity) } export const parseListHtmlConf = { selector: 'ul:not([data-w-e-type]),ol:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性 parseElemHtml: parseListHtml, } ================================================ FILE: packages/list-module/src/module/plugin.ts ================================================ /** * @description editor 插件,重写 editor API * @author wangfupeng */ import { Editor, Transforms, Range } from 'slate' import { IDomEditor, DomEditor } from '@wangeditor/core' import { ListItemElement } from './custom-types' /** * 获取选中的 top elems * @param editor editor */ function getTopSelectedElemsBySelection(editor: IDomEditor) { return Editor.nodes(editor, { at: editor.selection || undefined, match: n => DomEditor.findPath(editor, n).length === 1, // 只匹配顶级元素 }) } function withList(editor: T): T { const { deleteBackward, handleTab, normalizeNode } = editor const newEditor = editor // 重写 deleteBackward - 降低 level 或者转换为 p 元素 newEditor.deleteBackward = unit => { const { selection } = newEditor if (selection == null) { deleteBackward(unit) return } if (Range.isExpanded(selection)) { deleteBackward(unit) return } const listItemElem = DomEditor.getSelectedNodeByType(newEditor, 'list-item') if (listItemElem == null) { // 未匹配到 list-item deleteBackward(unit) return } if (selection.focus.offset === 0) { // 选中了当前 list-item 文本的开头,此时按删除键,应该降低 level 或转换为 p 元素 const { level = 0 } = listItemElem as ListItemElement if (level > 0) { // 降低 level Transforms.setNodes(newEditor, { level: level - 1 }) } else { // 转换为 p 元素 Transforms.setNodes(newEditor, { type: 'paragraph', ordered: undefined, level: undefined, }) } return } // 其他情况 deleteBackward(unit) } // 重写 tab - 当选中 list-item 文本开头时,增加 level newEditor.handleTab = () => { const { selection } = newEditor if (selection == null) { handleTab() return } // 选区是合并的,判断单个 list-item 即可 if (Range.isCollapsed(selection)) { const listItemElem = DomEditor.getSelectedNodeByType(newEditor, 'list-item') if (listItemElem == null) { // 未匹配到 list-item handleTab() return } if (selection.focus.offset === 0) { // 选中了当前 list-item 文本的开头,此时按 tab 应该增加 level const { level = 0 } = listItemElem as ListItemElement Transforms.setNodes(newEditor, { level: level + 1 }) return } } // 选区是展开的,要判断多个 list-item if (Range.isExpanded(selection)) { let listItemNum = 0 // 选中的 list-item 有几个 let hasOtherElem = false // 是否有其他元素 for (const entry of getTopSelectedElemsBySelection(newEditor)) { const [elem] = entry const type = DomEditor.getNodeType(elem) if (type === 'list-item') listItemNum++ else hasOtherElem = true } if (hasOtherElem || listItemNum <= 1) { // 选中了其他元素,或者只选中一个 list-item ,则执行默认行为 handleTab() return } // 未选中其他元素,且选中多个 list-item ,则增加 level for (const entry of getTopSelectedElemsBySelection(newEditor)) { const [elem, path] = entry const { level = 0 } = elem as ListItemElement Transforms.setNodes(newEditor, { level: level + 1 }, { at: path }) } return } // 其他情况 handleTab() } // 兼容之前的 JSON 格式 `numbered-list` 和 `bulleted-list` (之前的 list 没有嵌套功能) newEditor.normalizeNode = ([node, path]) => { const type = DomEditor.getNodeType(node) if (type === 'bulleted-list' || type === 'numbered-list') { Transforms.unwrapNodes(newEditor, { at: path }) } // 执行默认行为 return normalizeNode([node, path]) } return newEditor } export default withList ================================================ FILE: packages/list-module/src/module/render-elem.tsx ================================================ /** * @description render list elem * @author wangfupeng */ import { Element as SlateElement, Path, Editor, Text } from 'slate' import { jsx, VNode } from 'snabbdom' import { IDomEditor, DomEditor } from '@wangeditor/core' import { ListItemElement } from './custom-types' import { ELEM_TO_EDITOR } from '../utils/maps' /** * 无序列表:根据 level 获取的前置符号 * @param level 层级 */ function genPreSymbol(level = 0): string { let s = '' switch (level) { case 0: s = '•' // 第一层级 break case 1: s = '◦' // 第一层级 break case 2: s = '▪' // 第三层级 break default: s = '▪' // 其他层级 } return s } /** * 有序列表:获取前缀 number * @param editor editor * @param elem listItem elem */ function getOrderedItemNumber(editor: IDomEditor, elem: SlateElement): number { const { type, level = 0, ordered = false } = elem as ListItemElement if (!ordered) { return -1 // 不是有序列表 } let num = 1 // 默认值 1 let curElem = elem let curPath = DomEditor.findPath(editor, curElem) // 第一个元素,直接返回 1 if (curPath[0] === 0) return 1 while (curPath[0] > 0) { const prevPath = Path.previous(curPath) const prevEntry = Editor.node(editor, prevPath) if (prevEntry == null) break const prevElem = prevEntry[0] as ListItemElement // 上一个节点 const { level: prevLevel = 0, type: prevType, ordered: prevOrdered } = prevElem // type 不一致,退出循环,不再累加 num if (prevType !== type) break // prevLevel 更小,退出循环,不再累加 num if (prevLevel < level) break if (prevLevel === level) { // level 一样,如果 ordered 不一样,则退出循环,不再累加 num if (prevOrdered !== ordered) break // level 一样,order 一样,则累加 num else num++ } // prevLevel 更大,不累加 num ,继续向前 curElem = prevElem curPath = prevPath } return num } /** * 获取第一个 text-node 的颜色 * @param elem elem */ function getListItemColor(elem: SlateElement): string { const children = elem.children || [] const length = children.length if (length === 0) return '' let firstTextNode for (let i = 0; i < length; i++) { if (firstTextNode) break // 已找到第一个 text-node ,则退出 const child = children[i] if (Text.isText(child)) firstTextNode = child } if (firstTextNode == null) return '' return firstTextNode['color'] || '' } function renderListElem( elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor ): VNode { ELEM_TO_EDITOR.set(elemNode, editor) // 记录 elem 和 editor 关系,elem-to-html 时要用 const { level = 0, ordered = false } = elemNode as ListItemElement // 根据 level 增加 margin-left const listStyle = { margin: `5px 0 5px ${level * 20}px` } // list-item 前缀 let prefix = '' if (ordered) { // 有序列表:获取前缀 number const orderedNumber = getOrderedItemNumber(editor, elemNode) prefix = `${orderedNumber}.` } else { // 无序列表:根据层级,使用不同的前缀符号 prefix = genPreSymbol(level) } // 获取前缀颜色 const prefixColor = getListItemColor(elemNode) const vnode = (
                {prefix} {children}
                ) return vnode } const renderListItemConf = { type: 'list-item', renderElem: renderListElem, } export default renderListItemConf ================================================ FILE: packages/list-module/src/utils/dom.ts ================================================ /** * @description DOM 操作 * @author wangfupeng */ import $, { append, on, focus, attr, val, html, parent, hasClass, Dom7Array, empty } from 'dom7' if (append) $.fn.append = append // if (on) $.fn.on = on // if (focus) $.fn.focus = focus if (attr) $.fn.attr = attr // if (val) $.fn.val = val // if (html) $.fn.html = html if (parent) $.fn.parent = parent // if (hasClass) $.fn.hasClass = hasClass // if (empty) $.fn.empty = empty export default $ // COMPAT: This is required to prevent TypeScript aliases from doing some very // weird things for Slate's types with the same name as globals. (2019/11/27) // https://github.com/microsoft/TypeScript/issues/35002 import DOMNode = globalThis.Node import DOMComment = globalThis.Comment import DOMElement = globalThis.Element import DOMText = globalThis.Text import DOMRange = globalThis.Range import DOMSelection = globalThis.Selection import DOMStaticRange = globalThis.StaticRange export { DOMNode, DOMComment, DOMElement, DOMText, DOMRange, DOMSelection, DOMStaticRange } /** * 获取 tagName lower-case * @param $elem $elem */ export function getTagName($elem: Dom7Array): string { if ($elem.length) return $elem[0].tagName.toLowerCase() return '' } ================================================ FILE: packages/list-module/src/utils/maps.ts ================================================ /** * @description maps * @author wangfupeng */ import { Element as SlateElement } from 'slate' import { IDomEditor } from '@wangeditor/core' export const ELEM_TO_EDITOR = new WeakMap() ================================================ FILE: packages/list-module/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": {}, "include": [ "./src/**/*", "../custom-types.d.ts" ] } ================================================ FILE: packages/table-module/CHANGELOG.md ================================================ # Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. ## [1.1.4](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/table-module@1.1.3...@wangeditor/table-module@1.1.4) (2022-09-27) **Note:** Version bump only for package @wangeditor/table-module ## [1.1.3](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/table-module@1.1.2...@wangeditor/table-module@1.1.3) (2022-09-15) ### Bug Fixes * 插入表格会删掉去掉 issue 4711 ([d4fac4e](https://github.com/wangeditor-team/wangEditor/commit/d4fac4efd06480457a95c2b06e7472cf6204de58)) ## [1.1.2](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/table-module@1.1.1...@wangeditor/table-module@1.1.2) (2022-09-14) **Note:** Version bump only for package @wangeditor/table-module ## [1.1.1](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/table-module@1.1.0...@wangeditor/table-module@1.1.1) (2022-07-11) ### Bug Fixes * disabled 时,点击 table 会弹出菜单栏 ([9aa4b80](https://github.com/wangeditor-team/wangEditor/commit/9aa4b80a8c3cd29ca57dd62d69f5811868998f5c)) # [1.1.0](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/table-module@1.0.1...@wangeditor/table-module@1.1.0) (2022-05-25) ### Bug Fixes * 从表格后面删除,删除最后一个单元格 ([b327fcd](https://github.com/wangeditor-team/wangEditor/commit/b327fcd4669b1b1fad0e8b38b7d88db04c300e37)) ### Features * enter menu ([988fc31](https://github.com/wangeditor-team/wangEditor/commit/988fc31f31de3d37dffbf54abb784cceb8e6118d)) * 表格拖拽列宽 ([46ea2c0](https://github.com/wangeditor-team/wangEditor/commit/46ea2c0f831b03ebca5fddfd59d682fed0b3476e)) ## 1.0.1 (2022-04-18) ### Bug Fixes * 部分菜单 disabled ([87f1233](https://github.com/wangeditor-team/wangEditor/commit/87f12332a087072406c1988dc5cef2eae8335375)) * 单元格内包含复杂样式内容时按tab未跳转到下一个单元格 ([db5e6f2](https://github.com/wangeditor-team/wangEditor/commit/db5e6f20c2c081d193fa80077f91d121be98c2a0)) * 更新各包之间依赖版本 ([75c552c](https://github.com/wangeditor-team/wangEditor/commit/75c552cc8ed54765bebb86a7ec5329a7fc79e85f)) * 两个表格不能紧挨着 ([5955b61](https://github.com/wangeditor-team/wangEditor/commit/5955b614cf92f65c9ebea47e6719047f3c0d27ea)) * 修复 pnpm 安装 @wangeditor/editor 出现警告的问题 ([4087fbe](https://github.com/wangeditor-team/wangEditor/commit/4087fbee01c76bdd55e747a5e86c5e4a8d6a8353)) * 移除了每个包下的 publishConfig directory 配置 ([16559f0](https://github.com/wangeditor-team/wangEditor/commit/16559f052545c111318be760e64291a521bdcc65)) * 优化 custom-types.d.ts 中类型声明,修复测试文件 ts 报错 ([3a6c455](https://github.com/wangeditor-team/wangEditor/commit/3a6c4553245bc734dae1e17d605af389971782a2)) * 优化表格 ([f240ca7](https://github.com/wangeditor-team/wangEditor/commit/f240ca71e31ccdea947233a767e3371434af0b6f)) * parse html - 有些 elem children 需要过滤 ([63cbb80](https://github.com/wangeditor-team/wangEditor/commit/63cbb804c8c7a778a4ee1f4ba8717a11b4b6b5a3)) * rename es module filename ([1821d4e](https://github.com/wangeditor-team/wangEditor/commit/1821d4eef49e64efcb41b848849ca7a5e6472044)) * table - 粘贴合并单元格的表格 ([56ecb63](https://github.com/wangeditor-team/wangEditor/commit/56ecb6392510d433e092653f0f08183361778a3d)) * table - disabled ([2b8717c](https://github.com/wangeditor-team/wangEditor/commit/2b8717c9a1c6853a3311fa6a667df6e0e75b61ee)) * table - elemToHtml ([e36e609](https://github.com/wangeditor-team/wangEditor/commit/e36e6092ef721723169afc8bf0560a47ac9f4dfc)) * table 不能是第一个元素 ([9407b79](https://github.com/wangeditor-team/wangEditor/commit/9407b79604163fece99dd96552487d21afd085e7)) * table insertDOMElem ([6c89177](https://github.com/wangeditor-team/wangEditor/commit/6c89177878461fd59f128aa44ac175b2a49c3bd6)) * table insertDOMElem ([3a42c37](https://github.com/wangeditor-team/wangEditor/commit/3a42c37c3bc38343e3a0b245d2bfb2abed0bd720)) * table-cell 全选 ([1ef4872](https://github.com/wangeditor-team/wangEditor/commit/1ef48729e6d99e7414bc89bc4ef0d66c172fc566)) * table内图片拖拽消失问题 ([a700a51](https://github.com/wangeditor-team/wangEditor/commit/a700a512fa7149da304f3d7c0ffaad8548a3def9)) ### Features * basic text paste ([f0a5b98](https://github.com/wangeditor-team/wangEditor/commit/f0a5b980c95fa1e2fc59a898c6e0d0723c276c28)) * i18n ([c11b244](https://github.com/wangeditor-team/wangEditor/commit/c11b2440f91b99d40bca18b675c66a22b6e160c9)) * parse html ([2a5eace](https://github.com/wangeditor-team/wangEditor/commit/2a5eace00f33cded50b68e8164748ec2480213fd)) * table module ([a397116](https://github.com/wangeditor-team/wangEditor/commit/a397116de73e088232d9c41828f30f8d56a22dd4)) * table module - header + fullWidth ([9a8a0e0](https://github.com/wangeditor-team/wangEditor/commit/9a8a0e093af944ee7deab674f47c2ec7baae0e63)) * table内按tab光标换到下一个单元格 ([02421ad](https://github.com/wangeditor-team/wangEditor/commit/02421ad7603d20ce8e0d627a0f046c8992ba4934)) * toHtml 机制 ([1c4d872](https://github.com/wangeditor-team/wangEditor/commit/1c4d8729f84aaab6a448f23064b34a20596305e9)) * upload video ([ac8e6f8](https://github.com/wangeditor-team/wangEditor/commit/ac8e6f8b5258e593714676a6f6be359ba525833c)) ================================================ FILE: packages/table-module/README.md ================================================ # wangEditor table-module Table module built in [wangEditor](https://www.wangeditor.com/) by default. ================================================ FILE: packages/table-module/__tests__/elem-to-html.test.ts ================================================ /** * @description table menu test * @author luochao */ import { tableCellToHtmlConf, tableToHtmlConf, tableRowToHtmlConf, } from '../src/module/elem-to-html' import * as core from '@wangeditor/core' import { Ancestor } from 'slate' describe('TableModule module', () => { describe('module elem-to-html', () => { test('tableCellToHtmlConf should return object that include "type" and "elemToHtml" property', () => { expect(tableCellToHtmlConf.type).toBe('table-cell') expect(typeof tableCellToHtmlConf.elemToHtml).toBe('function') }) test('tableCellToHtmlConf elemToHtml should throw Error if tableCell do not have parent', () => { const element = { type: 'table-cell', children: [], } try { tableCellToHtmlConf.elemToHtml(element, '123') } catch (err) { expect(err.message).toBe( `Cannot get table row node by cell node ${JSON.stringify(element)}` ) } }) test('tableCellToHtmlConf elemToHtml should throw Error if tableRow do not have parent', () => { const element = { type: 'table-cell', children: [], } jest .spyOn(core.DomEditor, 'getParentNode') .mockReturnValue({ type: 'table-row', children: [{ text: '' }] } as any) try { tableCellToHtmlConf.elemToHtml(element, '123') } catch (err) { expect(err.message).toBe(`Cannot get table node by cell node ${JSON.stringify(element)}`) } }) test('tableCellToHtmlConf elemToHtml should return html element td string', () => { const element = { type: 'table-cell', children: [], } jest .spyOn(core.DomEditor, 'getParentNode') .mockReturnValueOnce({ type: 'table-row', children: [{ text: '' }] } as any) .mockReturnValueOnce({ type: 'table', children: [{ text: '' }] } as Ancestor) const res = tableCellToHtmlConf.elemToHtml(element, '123') expect(res).toBe('123') }) test('tableRowToHtmlConf should return object that include "type" and "elemToHtml" property', () => { expect(tableRowToHtmlConf.type).toBe('table-row') expect(typeof tableRowToHtmlConf.elemToHtml).toBe('function') }) test('tableRowToHtmlConf elemToHtml should return html table row string', () => { const element = { type: 'table-row', children: [], } const res = tableRowToHtmlConf.elemToHtml(element, '123') expect(res).toBe('123') }) test('tableToHtmlConf should return object that include "type" and "elemToHtml" property', () => { expect(tableToHtmlConf.type).toBe('table') expect(typeof tableToHtmlConf.elemToHtml).toBe('function') }) test('tableToHtmlConf should return html table string', () => { const element = { type: 'table', children: [], } const res = tableToHtmlConf.elemToHtml(element, '123') expect(res).toBe('
                123
                ') }) test('tableToHtmlConf should return html table string with full width style if element is set fullWith value true', () => { const element = { type: 'table', width: '100%', children: [], } const res = tableToHtmlConf.elemToHtml(element, '123') expect(res).toBe('
                123
                ') }) }) }) ================================================ FILE: packages/table-module/__tests__/menu/delete-col.test.ts ================================================ import DeleteCol from '../../src/module/menu/DeleteCol' import createEditor from '../../../../tests/utils/create-editor' import { DEL_COL_SVG } from '../../src/constants/svg' import locale from '../../src/locale/zh-CN' import * as slate from 'slate' import * as core from '@wangeditor/core' function setEditorSelection( editor: core.IDomEditor, selection: slate.Selection = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 }, } ) { editor.selection = selection } describe('Table Module Delete Col Menu', () => { test('it should create DeleteCol object', () => { const deleteColMenu = new DeleteCol() expect(typeof deleteColMenu).toBe('object') expect(deleteColMenu.tag).toBe('button') expect(deleteColMenu.iconSvg).toBe(DEL_COL_SVG) expect(deleteColMenu.title).toBe(locale.tableModule.deleteCol) }) test('it should get empty string if invoke getValue method', () => { const deleteColMenu = new DeleteCol() const editor = createEditor() expect(deleteColMenu.getValue(editor)).toBe('') }) test('it should get falsy value if invoke isActive method', () => { const deleteColMenu = new DeleteCol() const editor = createEditor() expect(deleteColMenu.isActive(editor)).toBeFalsy() }) test('isDisabled should get truthy value if editor selection is null', () => { const deleteColMenu = new DeleteCol() const editor = createEditor() editor.selection = null expect(deleteColMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor selection is collapsed', () => { const deleteColMenu = new DeleteCol() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => false) expect(deleteColMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor current selected node is not table cell', () => { const deleteColMenu = new DeleteCol() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(deleteColMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get falsy value if editor current selected node is table cell', () => { const deleteColMenu = new DeleteCol() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => ({} as any)) expect(deleteColMenu.isDisabled(editor)).toBeFalsy() }) test('exec should return directly if menu is disabled', () => { const deleteColMenu = new DeleteCol() const editor = createEditor() setEditorSelection(editor, null) expect(deleteColMenu.exec(editor, '')).toBeUndefined() }) test('exec should invoke removeNodes method to remove whole table if menu is not disabled and table col length less than 1', () => { const deleteColMenu = new DeleteCol() const editor = createEditor() jest.spyOn(deleteColMenu, 'isDisabled').mockImplementation(() => false) jest.spyOn(core.DomEditor, 'getParentNode').mockImplementation(() => ({ type: 'table-col', children: [], })) const fn = function* a() { yield [ { type: 'table-cell', children: [], } as slate.Element, [0, 1], ] as slate.NodeEntry } jest.spyOn(slate.Editor, 'nodes').mockReturnValue(fn()) const removeNodesFn = jest.fn() jest.spyOn(slate.Transforms, 'removeNodes').mockImplementation(removeNodesFn) deleteColMenu.exec(editor, '') expect(removeNodesFn).toBeCalled() }) test('exec should invoke removeNodes method to remove all table cells if menu is not disabled and table col length greater than 1', () => { const deleteColMenu = new DeleteCol() const editor = createEditor() jest.spyOn(deleteColMenu, 'isDisabled').mockImplementation(() => false) jest.spyOn(core.DomEditor, 'getParentNode').mockImplementation(() => ({ type: 'table-row', children: [ { type: 'table-col', children: [{ type: 'table-cell', children: [] }], }, { type: 'table-col', children: [{ type: 'table-cell', children: [] }], }, ], })) const fn = function* a() { yield [ { type: 'table-cell', children: [], } as slate.Element, [0, 1], ] as slate.NodeEntry } jest.spyOn(slate.Editor, 'nodes').mockReturnValue(fn()) jest.spyOn(core.DomEditor, 'findPath').mockImplementation(() => [0, 1] as slate.Path) const removeNodesFn = jest.fn() jest.spyOn(slate.Transforms, 'removeNodes').mockImplementation(removeNodesFn) deleteColMenu.exec(editor, '') expect(removeNodesFn).toBeCalledTimes(2) }) }) ================================================ FILE: packages/table-module/__tests__/menu/delete-row.test.ts ================================================ import DeleteRow from '../../src/module/menu/DeleteRow' import createEditor from '../../../../tests/utils/create-editor' import { DEL_ROW_SVG } from '../../src/constants/svg' import locale from '../../src/locale/zh-CN' import * as slate from 'slate' import * as core from '@wangeditor/core' function setEditorSelection( editor: core.IDomEditor, selection: slate.Selection = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 }, } ) { editor.selection = selection } describe('Table Module Delete Row Menu', () => { test('it should create DeleteRow object', () => { const deleteRowMenu = new DeleteRow() expect(typeof deleteRowMenu).toBe('object') expect(deleteRowMenu.tag).toBe('button') expect(deleteRowMenu.iconSvg).toBe(DEL_ROW_SVG) expect(deleteRowMenu.title).toBe(locale.tableModule.deleteRow) }) test('it should get empty string if invoke getValue method', () => { const deleteRowMenu = new DeleteRow() const editor = createEditor() expect(deleteRowMenu.getValue(editor)).toBe('') }) test('it should get falsy value if invoke isActive method', () => { const deleteRowMenu = new DeleteRow() const editor = createEditor() expect(deleteRowMenu.isActive(editor)).toBeFalsy() }) test('isDisabled should get truthy value if editor selection is null', () => { const deleteRowMenu = new DeleteRow() const editor = createEditor() editor.selection = null expect(deleteRowMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor selection is collapsed', () => { const deleteRowMenu = new DeleteRow() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => false) expect(deleteRowMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor current selected node is not table cell', () => { const deleteRowMenu = new DeleteRow() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(deleteRowMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get falsy value if editor current selected node is table cell', () => { const deleteRowMenu = new DeleteRow() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => ({} as any)) expect(deleteRowMenu.isDisabled(editor)).toBeFalsy() }) test('exec should return directly if menu is disabled', () => { const deleteRowMenu = new DeleteRow() const editor = createEditor() setEditorSelection(editor, null) expect(deleteRowMenu.exec(editor, '')).toBeUndefined() }) test('exec should invoke removeNodes method to remove whole table if menu is not disabled and table row length less than 1', () => { const deleteRowMenu = new DeleteRow() const editor = createEditor() jest.spyOn(deleteRowMenu, 'isDisabled').mockImplementation(() => false) jest.spyOn(core.DomEditor, 'getParentNode').mockImplementation(() => ({ type: 'table', children: [ { type: 'table-row', children: [], }, ], })) const path = [0, 1] const fn = function* a() { yield [ { type: 'table-cell', children: [], } as slate.Element, path, ] as slate.NodeEntry } jest.spyOn(slate.Editor, 'nodes').mockReturnValue(fn()) const removeNodesFn = jest.fn() jest.spyOn(slate.Transforms, 'removeNodes').mockImplementation(removeNodesFn) deleteRowMenu.exec(editor, '') expect(removeNodesFn).toBeCalled() }) test('exec should invoke removeNodes method to remove current row if menu is not disabled and table row length greater than 1', () => { const deleteRowMenu = new DeleteRow() const editor = createEditor() jest.spyOn(deleteRowMenu, 'isDisabled').mockImplementation(() => false) jest.spyOn(core.DomEditor, 'getParentNode').mockImplementation(() => ({ type: 'table', children: [ { type: 'table-row', children: [], }, { type: 'table-row', children: [], }, ], })) const path = [0, 1] const fn = function* a() { yield [ { type: 'table-cell', children: [], } as slate.Element, path, ] as slate.NodeEntry } jest.spyOn(slate.Editor, 'nodes').mockReturnValue(fn()) const removeNodesFn = jest.fn() jest.spyOn(slate.Transforms, 'removeNodes').mockImplementation(removeNodesFn) deleteRowMenu.exec(editor, '') expect(removeNodesFn).toBeCalledWith(editor, { at: path }) }) }) ================================================ FILE: packages/table-module/__tests__/menu/delete-table.test.ts ================================================ import DeleteTable from '../../src/module/menu/DeleteTable' import createEditor from '../../../../tests/utils/create-editor' import locale from '../../src/locale/zh-CN' import * as slate from 'slate' import * as core from '@wangeditor/core' function setEditorSelection( editor: core.IDomEditor, selection: slate.Selection = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 }, } ) { editor.selection = selection } describe('Table Module Delete Table Menu', () => { test('it should create DeleteTable object', () => { const deleteTableMenu = new DeleteTable() expect(typeof deleteTableMenu).toBe('object') expect(deleteTableMenu.tag).toBe('button') expect(deleteTableMenu.title).toBe(locale.tableModule.deleteTable) }) test('it should get empty string if invoke getValue method', () => { const deleteTableMenu = new DeleteTable() const editor = createEditor() expect(deleteTableMenu.getValue(editor)).toBe('') }) test('it should get falsy value if invoke isActive method', () => { const deleteTableMenu = new DeleteTable() const editor = createEditor() expect(deleteTableMenu.isActive(editor)).toBeFalsy() }) test('isDisabled should get truthy value if editor selection is null', () => { const deleteTableMenu = new DeleteTable() const editor = createEditor() editor.selection = null expect(deleteTableMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor current selected node is not table cell', () => { const deleteTableMenu = new DeleteTable() const editor = createEditor() setEditorSelection(editor) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(deleteTableMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get falsy value if editor current selected node is table cell', () => { const deleteTableMenu = new DeleteTable() const editor = createEditor() setEditorSelection(editor) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => ({} as any)) expect(deleteTableMenu.isDisabled(editor)).toBeFalsy() }) test('exec should return directly if menu is disabled', () => { const deleteTableMenu = new DeleteTable() const editor = createEditor() setEditorSelection(editor, null) expect(deleteTableMenu.exec(editor, '')).toBeUndefined() }) test('exec should invoke removeNodes method to remove whole table if menu is not disabled', () => { const deleteTableMenu = new DeleteTable() const editor = createEditor() jest.spyOn(deleteTableMenu, 'isDisabled').mockReturnValue(false) const fn = jest.fn() jest.spyOn(slate.Transforms, 'removeNodes').mockImplementation(fn) deleteTableMenu.exec(editor, '') expect(fn).toBeCalled() }) }) ================================================ FILE: packages/table-module/__tests__/menu/full-width.test.ts ================================================ import FullWidth from '../../src/module/menu/FullWidth' import createEditor from '../../../../tests/utils/create-editor' import { FULL_WIDTH_SVG } from '../../src/constants/svg' import locale from '../../src/locale/zh-CN' import * as slate from 'slate' import * as core from '@wangeditor/core' function setEditorSelection( editor: core.IDomEditor, selection: slate.Selection = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 }, } ) { editor.selection = selection } describe('Table Module Full Width Menu', () => { test('it should create FullWidth object', () => { const fullWidthMenu = new FullWidth() expect(typeof fullWidthMenu).toBe('object') expect(fullWidthMenu.tag).toBe('button') expect(fullWidthMenu.iconSvg).toBe(FULL_WIDTH_SVG) expect(fullWidthMenu.title).toBe(locale.tableModule.widthAuto) }) test('getValue should get falsy value if editor selected node is not table', () => { const fullWidthMenu = new FullWidth() const editor = createEditor() jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(fullWidthMenu.getValue(editor)).toBeFalsy() }) test(`getValue should get truthy value if editor selected table's width is 100%`, () => { const fullWidthMenu = new FullWidth() const editor = createEditor() jest .spyOn(core.DomEditor, 'getSelectedNodeByType') .mockImplementation(() => ({ width: '100%' } as any)) expect(fullWidthMenu.getValue(editor)).toBeTruthy() }) test('isActive should get falsy value if editor selected node is not table', () => { const fullWidthMenu = new FullWidth() const editor = createEditor() jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(fullWidthMenu.isActive(editor)).toBeFalsy() }) test('isDisabled should get truthy value if editor selection is null', () => { const fullWidthMenu = new FullWidth() const editor = createEditor() editor.selection = null expect(fullWidthMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor selection is collapsed', () => { const fullWidthMenu = new FullWidth() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => false) expect(fullWidthMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor current selected node is not table cell', () => { const fullWidthMenu = new FullWidth() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(fullWidthMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get falsy value if editor current selected node is table cell', () => { const fullWidthMenu = new FullWidth() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => ({} as any)) expect(fullWidthMenu.isDisabled(editor)).toBeFalsy() }) test('exec should return directly if menu is disabled', () => { const fullWidthMenu = new FullWidth() const editor = createEditor() setEditorSelection(editor, null) expect(fullWidthMenu.exec(editor, '')).toBeUndefined() }) test('exec should invoke setNodes with props if menu is not disabled', () => { const fullWidthMenu = new FullWidth() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => ({} as any)) const fn = jest.fn() jest.spyOn(slate.Transforms, 'setNodes').mockImplementation(fn) fullWidthMenu.exec(editor, true) expect(fn).toBeCalled() }) }) ================================================ FILE: packages/table-module/__tests__/menu/insert-col.test.ts ================================================ import InsertCol from '../../src/module/menu/InsertCol' import createEditor from '../../../../tests/utils/create-editor' import { ADD_COL_SVG } from '../../src/constants/svg' import locale from '../../src/locale/zh-CN' import * as slate from 'slate' import * as core from '@wangeditor/core' function setEditorSelection( editor: core.IDomEditor, selection: slate.Selection = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 }, } ) { editor.selection = selection } describe('Table Module Insert Col Menu', () => { test('it should create InsertCol object', () => { const insertColMenu = new InsertCol() expect(typeof insertColMenu).toBe('object') expect(insertColMenu.tag).toBe('button') expect(insertColMenu.iconSvg).toBe(ADD_COL_SVG) expect(insertColMenu.title).toBe(locale.tableModule.insertCol) }) test('it should get empty string if invoke getValue method', () => { const insertColMenu = new InsertCol() const editor = createEditor() expect(insertColMenu.getValue(editor)).toBe('') }) test('it should get falsy value if invoke isActive method', () => { const insertColMenu = new InsertCol() const editor = createEditor() expect(insertColMenu.isActive(editor)).toBeFalsy() }) test('isDisabled should get truthy value if editor selection is null', () => { const insertColMenu = new InsertCol() const editor = createEditor() editor.selection = null expect(insertColMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor selection is collapsed', () => { const insertColMenu = new InsertCol() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => false) expect(insertColMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor current selected node is not table cell', () => { const insertColMenu = new InsertCol() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(insertColMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get falsy value if editor current selected node is table cell', () => { const insertColMenu = new InsertCol() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => ({} as any)) expect(insertColMenu.isDisabled(editor)).toBeFalsy() }) test('exec should return directly if menu is disabled', () => { const insertColMenu = new InsertCol() const editor = createEditor() setEditorSelection(editor, null) expect(insertColMenu.exec(editor, '')).toBeUndefined() }) test('exec should return directly if current selected node parent is null', () => { const insertColMenu = new InsertCol() const editor = createEditor() jest.spyOn(insertColMenu, 'isDisabled').mockReturnValue(false) const fn = function* a() { yield [ { type: 'table-cell', children: [], } as slate.Element, [0, 1], ] as slate.NodeEntry } jest.spyOn(slate.Editor, 'nodes').mockReturnValue(fn()) jest.spyOn(core.DomEditor, 'getParentNode').mockReturnValue(null) expect(insertColMenu.exec(editor, '')).toBeUndefined() }) test('exec should return directly if current selected table row parent is null', () => { const insertColMenu = new InsertCol() const editor = createEditor() jest.spyOn(insertColMenu, 'isDisabled').mockReturnValue(false) const fn = function* a() { yield [ { type: 'table-cell', children: [], } as slate.Element, [0, 1], ] as slate.NodeEntry } jest.spyOn(slate.Editor, 'nodes').mockReturnValue(fn()) jest .spyOn(core.DomEditor, 'getParentNode') .mockReturnValue({} as any) .mockReturnValue(null) expect(insertColMenu.exec(editor, '')).toBeUndefined() }) test('exec should return directly if current selected table row parent is null', () => { const insertColMenu = new InsertCol() const editor = createEditor() jest.spyOn(insertColMenu, 'isDisabled').mockReturnValue(false) const fn = function* a() { yield [ { type: 'table-cell', children: [], } as slate.Element, [0, 1], ] as slate.NodeEntry } jest.spyOn(slate.Editor, 'nodes').mockReturnValue(fn()) jest .spyOn(core.DomEditor, 'getParentNode') .mockReturnValue({} as any) .mockReturnValue({ type: 'table', children: [ { type: 'table-row', children: [ { type: 'table-cell', children: [], }, { type: 'table-cell', children: [], }, ], }, { type: 'table-row', children: [ { type: 'table-cell', children: [], }, { type: 'table-cell', children: [], }, ], }, ], } as any) jest.spyOn(core.DomEditor, 'findPath').mockReturnValue([0, 1]) const insertNodesFn = jest.fn() jest.spyOn(slate.Transforms, 'insertNodes').mockImplementation(insertNodesFn) insertColMenu.exec(editor, '') expect(insertNodesFn).toBeCalledWith( editor, { type: 'table-cell', children: [{ text: '' }] }, { at: [0, 1] } ) }) }) ================================================ FILE: packages/table-module/__tests__/menu/insert-row.test.ts ================================================ import InsertRow from '../../src/module/menu/InsertRow' import createEditor from '../../../../tests/utils/create-editor' import { ADD_ROW_SVG } from '../../src/constants/svg' import locale from '../../src/locale/zh-CN' import * as slate from 'slate' import * as core from '@wangeditor/core' function setEditorSelection( editor: core.IDomEditor, selection: slate.Selection = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 }, } ) { editor.selection = selection } describe('Table Module Insert Row Menu', () => { test('it should create InsertRow object', () => { const insertRowMenu = new InsertRow() expect(typeof insertRowMenu).toBe('object') expect(insertRowMenu.tag).toBe('button') expect(insertRowMenu.iconSvg).toBe(ADD_ROW_SVG) expect(insertRowMenu.title).toBe(locale.tableModule.insertRow) }) test('it should get empty string if invoke getValue method', () => { const insertRowMenu = new InsertRow() const editor = createEditor() expect(insertRowMenu.getValue(editor)).toBe('') }) test('it should get falsy value if invoke isActive method', () => { const insertRowMenu = new InsertRow() const editor = createEditor() expect(insertRowMenu.isActive(editor)).toBeFalsy() }) test('isDisabled should get truthy value if editor selection is null', () => { const insertRowMenu = new InsertRow() const editor = createEditor() editor.selection = null expect(insertRowMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor selection is collapsed', () => { const insertRowMenu = new InsertRow() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => false) expect(insertRowMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor current selected node is not table cell', () => { const insertRowMenu = new InsertRow() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(insertRowMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get falsy value if editor current selected node is table cell', () => { const insertRowMenu = new InsertRow() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => ({} as any)) expect(insertRowMenu.isDisabled(editor)).toBeFalsy() }) test('exec should return directly if menu is disabled', () => { const insertRowMenu = new InsertRow() const editor = createEditor() setEditorSelection(editor, null) expect(insertRowMenu.exec(editor, '')).toBeUndefined() }) test('exec should invoke insertNodes method to remove whole table if menu is not disabled', () => { const insertRowMenu = new InsertRow() const editor = createEditor() jest.spyOn(insertRowMenu, 'isDisabled').mockReturnValue(false) jest.spyOn(core.DomEditor, 'getParentNode').mockImplementation(() => ({ type: 'table-row', children: [ { type: 'table-cell', children: [], }, { type: 'table-cell', children: [], }, ], })) const fn = function* a() { yield [ { type: 'table-cell', children: [], } as slate.Element, [0, 1], ] as slate.NodeEntry } jest.spyOn(slate.Editor, 'nodes').mockReturnValue(fn()) const insertNodesFn = jest.fn() jest.spyOn(slate.Transforms, 'insertNodes').mockImplementation(insertNodesFn) insertRowMenu.exec(editor, '') expect(insertNodesFn).toBeCalled() }) test('exec should return directly if current selected row that does not has children', () => { const insertRowMenu = new InsertRow() const editor = createEditor() jest.spyOn(insertRowMenu, 'isDisabled').mockReturnValue(false) jest.spyOn(core.DomEditor, 'getParentNode').mockImplementation(() => ({ type: 'table-row', children: [], })) const fn = function* a() { yield [ { type: 'table-cell', children: [], } as slate.Element, [0, 1], ] as slate.NodeEntry } jest.spyOn(slate.Editor, 'nodes').mockReturnValue(fn()) const insertNodesFn = jest.fn() jest.spyOn(slate.Transforms, 'insertNodes').mockImplementation(insertNodesFn) expect(insertRowMenu.exec(editor, '')).toBeUndefined() expect(insertNodesFn).not.toBeCalled() }) }) ================================================ FILE: packages/table-module/__tests__/menu/insert-table.test.ts ================================================ import InsertTable from '../../src/module/menu/InsertTable' import createEditor from '../../../../tests/utils/create-editor' import { TABLE_SVG } from '../../src/constants/svg' import locale from '../../src/locale/zh-CN' import * as slate from 'slate' import * as core from '@wangeditor/core' import $, { DOMElement } from '../../src/utils/dom' function setEditorSelection( editor: core.IDomEditor, selection: slate.Selection = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 }, } ) { editor.selection = selection } describe('Table Module Insert Table Menu', () => { test('it should create InsertTable object', () => { const insertTableMenu = new InsertTable() expect(typeof insertTableMenu).toBe('object') expect(insertTableMenu.tag).toBe('button') expect(insertTableMenu.iconSvg).toBe(TABLE_SVG) expect(insertTableMenu.title).toBe(locale.tableModule.insertTable) }) test('it should get empty string if invoke getValue method', () => { const insertTableMenu = new InsertTable() const editor = createEditor() expect(insertTableMenu.getValue(editor)).toBe('') }) test('it should get falsy value if invoke isActive method', () => { const insertTableMenu = new InsertTable() const editor = createEditor() expect(insertTableMenu.isActive(editor)).toBeFalsy() }) test('isDisabled should get truthy value if editor selection is null', () => { const insertTableMenu = new InsertTable() const editor = createEditor() editor.selection = null expect(insertTableMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor selection is collapsed', () => { const insertTableMenu = new InsertTable() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => false) expect(insertTableMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor current selected node is contains pre node', () => { const insertTableMenu = new InsertTable() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest .spyOn(core.DomEditor, 'getSelectedElems') .mockImplementation(() => [{ type: 'pre', children: [] }]) expect(insertTableMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor current selected node is contains table node', () => { const insertTableMenu = new InsertTable() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest .spyOn(core.DomEditor, 'getSelectedElems') .mockImplementation(() => [{ type: 'table', children: [] }]) expect(insertTableMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor current selected node is contains void node', () => { const insertTableMenu = new InsertTable() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest .spyOn(core.DomEditor, 'getSelectedElems') .mockImplementation(() => [{ type: 'image', children: [] }]) jest.spyOn(editor, 'isVoid').mockImplementation(() => true) expect(insertTableMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get falsy value if editor current selected node is valid', () => { const insertTableMenu = new InsertTable() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest .spyOn(core.DomEditor, 'getSelectedElems') .mockImplementation(() => [{ type: 'paragraph', children: [] }]) expect(insertTableMenu.isDisabled(editor)).toBeFalsy() }) test('getPanelContentElem should return table panel dom', () => { const insertTableMenu = new InsertTable() const editor = createEditor() expect(insertTableMenu.getPanelContentElem(editor) instanceof DOMElement).toBeTruthy() expect(insertTableMenu.getPanelContentElem(editor).className).toBe('w-e-panel-content-table') }) test('it should invoke insertNodes method if click panel td node', () => { const insertTableMenu = new InsertTable() const editor = createEditor() const tablePanel = insertTableMenu.getPanelContentElem(editor) const tdEl = $(tablePanel).find('td')[0] const fn = jest.fn() jest.spyOn(slate.Transforms, 'insertNodes').mockImplementation(fn) tdEl.dispatchEvent( new MouseEvent('click', { view: window, bubbles: true, cancelable: true, }) ) expect(fn).toBeCalled() }) test('it should add active class if mouse enter panel td node', () => { const insertTableMenu = new InsertTable() const editor = createEditor() const tablePanel = insertTableMenu.getPanelContentElem(editor) const tdEl = $(tablePanel).find('td')[0] expect(tdEl.className).toBe('') tdEl.dispatchEvent( new MouseEvent('mouseenter', { view: window, bubbles: true, cancelable: true, }) ) expect(tdEl.className).toBe('active') }) }) ================================================ FILE: packages/table-module/__tests__/menu/table-header.test.ts ================================================ import TableHeader from '../../src/module/menu/TableHeader' import createEditor from '../../../../tests/utils/create-editor' import { TABLE_HEADER_SVG } from '../../src/constants/svg' import locale from '../../src/locale/zh-CN' import * as slate from 'slate' import * as core from '@wangeditor/core' function setEditorSelection( editor: core.IDomEditor, selection: slate.Selection = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 }, } ) { editor.selection = selection } describe('Table Module Table Header Menu', () => { test('it should create TableHeader object', () => { const tableHeaderMenu = new TableHeader() expect(typeof tableHeaderMenu).toBe('object') expect(tableHeaderMenu.tag).toBe('button') expect(tableHeaderMenu.iconSvg).toBe(TABLE_HEADER_SVG) expect(tableHeaderMenu.title).toBe(locale.tableModule.header) }) test('getValue should get falsy value if editor selected node is not table', () => { const tableHeaderMenu = new TableHeader() const editor = createEditor() jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(tableHeaderMenu.getValue(editor)).toBeFalsy() }) test('isActive should get falsy value if editor selected node is not table', () => { const tableHeaderMenu = new TableHeader() const editor = createEditor() jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(tableHeaderMenu.isActive(editor)).toBeFalsy() }) test('isDisabled should get truthy value if editor selection is null', () => { const tableHeaderMenu = new TableHeader() const editor = createEditor() editor.selection = null expect(tableHeaderMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor selection is collapsed', () => { const tableHeaderMenu = new TableHeader() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => false) expect(tableHeaderMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get truthy value if editor current selected node is not table cell', () => { const tableHeaderMenu = new TableHeader() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(tableHeaderMenu.isDisabled(editor)).toBeTruthy() }) test('isDisabled should get falsy value if editor current selected node is table cell', () => { const tableHeaderMenu = new TableHeader() const editor = createEditor() setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockImplementation(() => true) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => ({} as any)) expect(tableHeaderMenu.isDisabled(editor)).toBeFalsy() }) test('exec should return directly if menu is disabled', () => { const tableHeaderMenu = new TableHeader() const editor = createEditor() setEditorSelection(editor, null) expect(tableHeaderMenu.exec(editor, '')).toBeUndefined() }) test('exec should return directly if current selected node is not table', () => { const tableHeaderMenu = new TableHeader() const editor = createEditor() jest.spyOn(tableHeaderMenu, 'isDisabled').mockReturnValue(false) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => null) expect(tableHeaderMenu.exec(editor, '')).toBeUndefined() }) test('exec should invoke setNodes to set table header if current selected node table', () => { const tableHeaderMenu = new TableHeader() const editor = createEditor() jest.spyOn(tableHeaderMenu, 'isDisabled').mockReturnValue(false) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockImplementation(() => ({ type: 'table', children: [ { type: 'table-row', children: [ { type: 'table-cell', children: [] }, { type: 'table-cell', children: [] }, ], }, { type: 'table-row', children: [ { type: 'table-cell', children: [] }, { type: 'table-cell', children: [] }, ], }, ], })) const fn = jest.fn() jest.spyOn(slate.Transforms, 'setNodes').mockImplementation(fn) jest.spyOn(core.DomEditor, 'findPath').mockImplementation(() => [0, 1]) tableHeaderMenu.exec(editor, '') expect(fn).toBeCalledTimes(2) }) }) ================================================ FILE: packages/table-module/__tests__/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../tests/utils/create-editor' import { preParseTableHtmlConf } from '../src/module/pre-parse-html' import { parseCellHtmlConf, parseRowHtmlConf, parseTableHtmlConf, } from '../src/module/parse-elem-html' describe('table - pre parse html', () => { it('pre parse', () => { const $table = $('
                hello
                ') // match selector expect($table[0].matches(preParseTableHtmlConf.selector)).toBeTruthy() // pre parse const res = preParseTableHtmlConf.preParseHtml($table[0]) expect(res.outerHTML).toBe('
                hello
                ') }) it('it should return fake element if pass fake table element', () => { const fakeTable = $('
                hello
                ') // pre parse const res = preParseTableHtmlConf.preParseHtml(fakeTable[0]) expect(res.outerHTML).toBe('
                hello
                ') }) it('it should return directly if pass table element without body', () => { const table = $('
                hello
                ') // pre parse const res = preParseTableHtmlConf.preParseHtml(table[0]) expect(res.outerHTML).toBe('
                hello
                ') }) }) describe('table - parse html', () => { const editor = createEditor() it('table cell', () => { const $cell1 = $('hello world') expect($cell1[0].matches(parseCellHtmlConf.selector)).toBeTruthy() expect(parseCellHtmlConf.parseElemHtml($cell1[0], [], editor)).toEqual({ type: 'table-cell', isHeader: false, colSpan: 1, rowSpan: 1, width: 'auto', children: [{ text: 'hello world' }], }) const $cell2 = $('') const children = [{ text: 'hello ' }, { text: 'world', bold: true }] expect($cell2[0].matches(parseCellHtmlConf.selector)).toBeTruthy() expect(parseCellHtmlConf.parseElemHtml($cell2[0], children, editor)).toEqual({ type: 'table-cell', isHeader: true, colSpan: 1, rowSpan: 1, width: 'auto', children, }) }) it('table row', () => { const $tr = $('') const children = [{ type: 'table-cell', children: [{ text: 'hello world' }] }] expect($tr[0].matches(parseRowHtmlConf.selector)).toBeTruthy() expect(parseRowHtmlConf.parseElemHtml($tr[0], children, editor)).toEqual({ type: 'table-row', children, }) }) it('table', () => { const $table = $('
                ') const children = [ { type: 'table-row', children: [{ type: 'table-cell', children: [{ text: 'hello world' }] }], }, ] expect($table[0].matches(parseTableHtmlConf.selector)).toBeTruthy() expect(parseTableHtmlConf.parseElemHtml($table[0], children, editor)).toEqual({ type: 'table', width: '100%', children, }) }) }) ================================================ FILE: packages/table-module/__tests__/plugin.test.ts ================================================ /** * @description table menu test * @author luochao */ import createEditor from '../../../tests/utils/create-editor' import withTable from '../src/module/plugin' import * as core from '@wangeditor/core' import * as slate from 'slate' describe('TableModule module', () => { describe('module plugin', () => { test('use withTable plugin when break line not split node', () => { const editor = createEditor() const newEditor = withTable(editor) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockReturnValue({ type: 'table', children: [{ text: '' }], } as slate.Element) const mockFn = jest.fn() newEditor.insertText = mockFn newEditor.insertBreak() expect(mockFn).toBeCalledWith('\n') }) test('use withTable plugin when insertData should insertText to cell', () => { const editor = createEditor() const newEditor = withTable(editor) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockReturnValue({ type: 'table', children: [{ text: '' }], } as slate.Element) const mockFn = jest.fn() slate.Editor.insertText = mockFn newEditor.insertData({ getData: () => 'test' } as unknown as DataTransfer) expect(mockFn).toBeCalled() }) test('use withTable plugin when insertData should invoke original insertData if selection not in table node', () => { const editor = createEditor() const mockInsertDataFn = jest.fn() editor.insertData = mockInsertDataFn const newEditor = withTable(editor) jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockReturnValue(null) newEditor.insertData({} as DataTransfer) expect(mockInsertDataFn).toBeCalled() }) }) }) ================================================ FILE: packages/table-module/__tests__/render-elem.test.ts ================================================ import createEditor from '../../../tests/utils/create-editor' import { renderTableConf, renderTableCellConf, renderTableRowConf } from '../src/module/render-elem' describe('table module - render elem', () => { const editor = createEditor() it('render table td elem', () => { expect(renderTableCellConf.type).toBe('table-cell') const elem = { type: 'table-cell', children: [] } const vnode = renderTableCellConf.renderElem(elem, null, editor) expect(vnode.sel).toBe('td') }) // // isHeader 必须在第一行才能生效,该 case 运行报错,暂注释 - wangfupeng 2022.05.20 // it('render table th elem', () => { // const cell = { type: 'table-cell', children: [], isHeader: true } // const row = { type: 'table-row', children: [cell] } // const table = { type: 'table', children: [row] } // editor.insertNode(table) // const vnode = renderTableCellConf.renderElem(cell, null, editor) // expect(vnode.sel).toBe('th') // }) it('render table row elem', () => { expect(renderTableRowConf.type).toBe('table-row') const elem = { type: 'table-row', children: [] } const vnode = renderTableRowConf.renderElem(elem, null, editor) expect(vnode.sel).toBe('tr') }) it('render table elem', () => { expect(renderTableConf.type).toBe('table') const elem = { type: 'table', children: [] } const containerVnode = renderTableConf.renderElem(elem, null, editor) as any expect(containerVnode.sel).toBe('div') const tableVnode = containerVnode.children[0] as any expect(tableVnode.sel).toBe('table') }) it('render table elem with full with', () => { const elem = { type: 'table', children: [], width: '100%' } const containerVnode = renderTableConf.renderElem(elem, null, editor) as any const tableVnode = containerVnode.children[0] as any expect(tableVnode.data.width).toBe('100%') }) }) ================================================ FILE: packages/table-module/package.json ================================================ { "name": "@wangeditor/table-module", "version": "1.1.4", "description": "wangEditor table module", "author": "wangfupeng1988 ", "contributors": [], "homepage": "https://github.com/wangeditor-team/wangEditor#readme", "license": "MIT", "types": "dist/table-module/src/index.d.ts", "main": "dist/index.js", "module": "dist/index.esm.js", "browser": { "./dist/index.js": "./dist/index.js", "./dist/index.esm.js": "./dist/index.esm.js" }, "directories": { "lib": "dist", "test": "__tests__" }, "files": [ "dist" ], "publishConfig": { "access": "public", "registry": "https://registry.npmjs.com/" }, "repository": { "type": "git", "url": "git+https://github.com/wangeditor-team/wangEditor.git" }, "scripts": { "test": "jest", "test-c": "jest --coverage", "dev": "cross-env NODE_ENV=development rollup -c rollup.config.js", "dev-watch": "cross-env NODE_ENV=development rollup -c rollup.config.js -w", "build": "cross-env NODE_ENV=production rollup -c rollup.config.js", "dev-size-stats": "cross-env NODE_ENV=development:size_stats rollup -c rollup.config.js", "size-stats": "cross-env NODE_ENV=production:size_stats rollup -c rollup.config.js" }, "bugs": { "url": "https://github.com/wangeditor-team/wangEditor/issues" }, "peerDependencies": { "@wangeditor/core": "1.x", "dom7": "^3.0.0", "lodash.isequal": "^4.5.0", "lodash.throttle": "^4.1.1", "nanoid": "^3.2.0", "slate": "^0.72.0", "snabbdom": "^3.1.0" } } ================================================ FILE: packages/table-module/rollup.config.js ================================================ import { createRollupConfig, IS_PRD } from '../../build/create-rollup-config' import pkg from './package.json' const name = 'WangEditorTableModule' const configList = [] // esm const esmConf = createRollupConfig({ output: { file: pkg.module, format: 'esm', name, }, }) configList.push(esmConf) // umd const umdConf = createRollupConfig({ output: { file: pkg.main, format: 'umd', name, }, }) configList.push(umdConf) export default configList ================================================ FILE: packages/table-module/src/assets/index.less ================================================ @import "../../../vars.less"; .w-e-text-container [data-slate-editor] { .table-container { width: 100%; overflow-x: auto; border: 1px dashed var(--w-e-textarea-border-color); padding: 10px; border-radius: 5px; margin-top: 10px; } table { border-collapse: collapse; td,th { border: 1px solid @textarea-border-color; padding: 3px 5px; min-width: 30px; text-align: left; line-height: 1.5; } th { background-color: @textarea-slight-bg-color; text-align: center; font-weight: bold; } } } // --------------------------------- 分割线 --------------------------------- .w-e-panel-content-table { background-color: @toolbar-bg-color; table { border-collapse: collapse; } td { border: 1px solid @toolbar-border-color; padding: 3px 5px; width: 20px; height: 15px; cursor: pointer; } td.active { background-color: @toolbar-active-bg-color; } } ================================================ FILE: packages/table-module/src/constants/svg.ts ================================================ /** * @description icon svg * @author wangfupeng */ /** * 【注意】svg 字符串的长度 ,否则会导致代码体积过大 * 尽量选择 https://www.iconfont.cn/collections/detail?spm=a313x.7781069.0.da5a778a4&cid=20293 * 找不到再从 iconfont.com 搜索 */ // 表格 export const TABLE_SVG = '' // 垃圾桶(删除) export const TRASH_SVG = '' // 表格 添加行 export const ADD_ROW_SVG = '' // 表格 删除行 export const DEL_ROW_SVG = '' // 表格 添加列 export const ADD_COL_SVG = '' // 表格 删除列 export const DEL_COL_SVG = '' // 表头 export const TABLE_HEADER_SVG = '' // 宽度 export const FULL_WIDTH_SVG = '' ================================================ FILE: packages/table-module/src/index.ts ================================================ /** * @description table entry * @author wangfupeng */ import './assets/index.less' // 配置多语言 import './locale/index' import wangEditorTableModule from './module/index' export default wangEditorTableModule ================================================ FILE: packages/table-module/src/locale/en.ts ================================================ /** * @description i18n en * @author wangfupeng */ export default { tableModule: { deleteCol: 'Delete column', deleteRow: 'Delete row', deleteTable: 'Delete table', widthAuto: 'Width auto', insertCol: 'Insert column', insertRow: 'Insert row', insertTable: 'Insert table', header: 'Header', }, } ================================================ FILE: packages/table-module/src/locale/index.ts ================================================ /** * @description i18n entry * @author wangfupeng */ import { i18nAddResources } from '@wangeditor/core' import enResources from './en' import zhResources from './zh-CN' i18nAddResources('en', enResources) i18nAddResources('zh-CN', zhResources) ================================================ FILE: packages/table-module/src/locale/zh-CN.ts ================================================ /** * @description i18n zh-CN * @author wangfupeng */ export default { tableModule: { deleteCol: '删除列', deleteRow: '删除行', deleteTable: '删除表格', widthAuto: '宽度自适应', insertCol: '插入列', insertRow: '插入行', insertTable: '插入表格', header: '表头', }, } ================================================ FILE: packages/table-module/src/module/custom-types.ts ================================================ /** * @description 自定义 element * @author wangfupeng */ import { Text } from 'slate' //【注意】需要把自定义的 Element 引入到最外层的 custom-types.d.ts export type TableCellElement = { type: 'table-cell' isHeader?: boolean // td/th 只作用于第一行 colSpan?: number rowSpan?: number width?: string // 只作用于第一行(尚未考虑单元格合并!) children: Text[] } export type TableRowElement = { type: 'table-row' children: TableCellElement[] } export type TableElement = { type: 'table' width: string children: TableRowElement[] } ================================================ FILE: packages/table-module/src/module/elem-to-html.ts ================================================ /** * @description to html * @author wangfupeng */ import { Element } from 'slate' import { TableCellElement, TableRowElement, TableElement } from './custom-types' function tableToHtml(elemNode: Element, childrenHtml: string): string { const { width = 'auto' } = elemNode as TableElement return `${childrenHtml}
                ` } function tableRowToHtml(elem: Element, childrenHtml: string): string { return `${childrenHtml}` } function tableCellToHtml(cellNode: Element, childrenHtml: string): string { const { colSpan = 1, rowSpan = 1, isHeader = false, width = 'auto', } = cellNode as TableCellElement const tag = isHeader ? 'th' : 'td' return `<${tag} colSpan="${colSpan}" rowSpan="${rowSpan}" width="${width}">${childrenHtml}` } export const tableToHtmlConf = { type: 'table', elemToHtml: tableToHtml, } export const tableRowToHtmlConf = { type: 'table-row', elemToHtml: tableRowToHtml, } export const tableCellToHtmlConf = { type: 'table-cell', elemToHtml: tableCellToHtml, } ================================================ FILE: packages/table-module/src/module/helpers.ts ================================================ /** * @description table menu helpers * @author wangfupeng */ import { DomEditor, IDomEditor } from '@wangeditor/core' import { TableElement, TableCellElement } from './custom-types' /** * 获取第一行所有 cells * @param tableNode table node */ export function getFirstRowCells(tableNode: TableElement): TableCellElement[] { const rows = tableNode.children || [] // 所有行 if (rows.length === 0) return [] const firstRow = rows[0] || {} // 第一行 const cells = firstRow.children || [] // 第一行所有 cell return cells } /** * 表格是否带有表头? * @param tableNode table node */ export function isTableWithHeader(tableNode: TableElement): boolean { const firstRowCells = getFirstRowCells(tableNode) return firstRowCells.every(cell => !!cell.isHeader) } /** * 单元格是否在第一行 * @param editor editor * @param cellNode cell node */ export function isCellInFirstRow(editor: IDomEditor, cellNode: TableCellElement): boolean { const rowNode = DomEditor.getParentNode(editor, cellNode) if (rowNode == null) return false const tableNode = DomEditor.getParentNode(editor, rowNode) if (tableNode == null) return false const firstRowCells = getFirstRowCells(tableNode as TableElement) return firstRowCells.some(c => c === cellNode) } ================================================ FILE: packages/table-module/src/module/index.ts ================================================ /** * @description table module * @author wangfupeng */ import { IModuleConf } from '@wangeditor/core' import withTable from './plugin' import { renderTableConf, renderTableRowConf, renderTableCellConf } from './render-elem/index' import { tableToHtmlConf, tableRowToHtmlConf, tableCellToHtmlConf } from './elem-to-html' import { preParseTableHtmlConf } from './pre-parse-html' import { parseCellHtmlConf, parseRowHtmlConf, parseTableHtmlConf } from './parse-elem-html' import { insertTableMenuConf, deleteTableMenuConf, insertTableRowConf, deleteTableRowConf, insertTableColConf, deleteTableColConf, tableHeaderMenuConf, tableFullWidthMenuConf, } from './menu/index' const table: Partial = { renderElems: [renderTableConf, renderTableRowConf, renderTableCellConf], elemsToHtml: [tableToHtmlConf, tableRowToHtmlConf, tableCellToHtmlConf], preParseHtml: [preParseTableHtmlConf], parseElemsHtml: [parseCellHtmlConf, parseRowHtmlConf, parseTableHtmlConf], menus: [ insertTableMenuConf, deleteTableMenuConf, insertTableRowConf, deleteTableRowConf, insertTableColConf, deleteTableColConf, tableHeaderMenuConf, tableFullWidthMenuConf, ], editorPlugin: withTable, } export default table ================================================ FILE: packages/table-module/src/module/menu/DeleteCol.ts ================================================ /** * @description del col menu * @author wangfupeng */ import isEqual from 'lodash.isequal' import { Editor, Element, Transforms, Range, Node } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core' import { DEL_COL_SVG } from '../../constants/svg' class DeleteCol implements IButtonMenu { readonly title = t('tableModule.deleteCol') readonly iconSvg = DEL_COL_SVG readonly tag = 'button' getValue(editor: IDomEditor): string | boolean { // 无需获取 val return '' } isActive(editor: IDomEditor): boolean { // 无需 active return false } isDisabled(editor: IDomEditor): boolean { const { selection } = editor if (selection == null) return true if (!Range.isCollapsed(selection)) return true const cellNode = DomEditor.getSelectedNodeByType(editor, 'table-cell') if (cellNode == null) { // 选区未处于 table cell node ,则禁用 return true } return false } exec(editor: IDomEditor, value: string | boolean) { if (this.isDisabled(editor)) return const [cellEntry] = Editor.nodes(editor, { match: n => DomEditor.checkNodeType(n, 'table-cell'), universal: true, }) const [selectedCellNode, selectedCellPath] = cellEntry // 如果只有一列,则删除整个表格 const rowNode = DomEditor.getParentNode(editor, selectedCellNode) const colLength = rowNode?.children.length || 0 if (!rowNode || colLength <= 1) { Transforms.removeNodes(editor, { mode: 'highest' }) // 删除整个表格 return } // ------------------------- 不只有 1 列,则继续 ------------------------- const tableNode = DomEditor.getParentNode(editor, rowNode) if (tableNode == null) return // 遍历所有 rows ,挨个删除 cell const rows = tableNode.children || [] rows.forEach(row => { if (!Element.isElement(row)) return const cells = row.children || [] // 遍历一个 row 的所有 cells cells.forEach((cell: Node) => { const path = DomEditor.findPath(editor, cell) if ( path.length === selectedCellPath.length && isEqual(path.slice(-1), selectedCellPath.slice(-1)) // 俩数组,最后一位相同 ) { // 如果当前 td 的 path 和选中 td 的 path ,最后一位相同,说明是同一列 // 删除当前的 cell Transforms.removeNodes(editor, { at: path }) } }) }) } } export default DeleteCol ================================================ FILE: packages/table-module/src/module/menu/DeleteRow.ts ================================================ /** * @description del row menu * @author wangfupeng */ import { Editor, Transforms, Range } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core' import { DEL_ROW_SVG } from '../../constants/svg' class DeleteRow implements IButtonMenu { readonly title = t('tableModule.deleteRow') readonly iconSvg = DEL_ROW_SVG readonly tag = 'button' getValue(editor: IDomEditor): string | boolean { // 无需获取 val return '' } isActive(editor: IDomEditor): boolean { // 无需 active return false } isDisabled(editor: IDomEditor): boolean { const { selection } = editor if (selection == null) return true if (!Range.isCollapsed(selection)) return true const rowNode = DomEditor.getSelectedNodeByType(editor, 'table-row') if (rowNode == null) { // 选区未处于 table row node ,则禁用 return true } return false } exec(editor: IDomEditor, value: string | boolean) { if (this.isDisabled(editor)) return const [rowEntry] = Editor.nodes(editor, { match: n => DomEditor.checkNodeType(n, 'table-row'), universal: true, }) const [rowNode, rowPath] = rowEntry const tableNode = DomEditor.getParentNode(editor, rowNode) const rowsLength = tableNode?.children.length || 0 if (rowsLength <= 1) { // row 只有一行,则删掉整个表格 Transforms.removeNodes(editor, { mode: 'highest' }) return } // row > 1 行,则删掉这一行 Transforms.removeNodes(editor, { at: rowPath }) } } export default DeleteRow ================================================ FILE: packages/table-module/src/module/menu/DeleteTable.ts ================================================ /** * @description del table menu * @author wangfupeng */ import { Transforms } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core' import { TRASH_SVG } from '../../constants/svg' class DeleteTable implements IButtonMenu { readonly title = t('tableModule.deleteTable') readonly iconSvg = TRASH_SVG readonly tag = 'button' getValue(editor: IDomEditor): string | boolean { // 无需获取 val return '' } isActive(editor: IDomEditor): boolean { // 无需 active return false } isDisabled(editor: IDomEditor): boolean { if (editor.selection == null) return true const tableNode = DomEditor.getSelectedNodeByType(editor, 'table') if (tableNode == null) { // 选区未处于 table node ,则禁用 return true } return false } exec(editor: IDomEditor, value: string | boolean) { if (this.isDisabled(editor)) return // 删除表格 Transforms.removeNodes(editor, { mode: 'highest' }) } } export default DeleteTable ================================================ FILE: packages/table-module/src/module/menu/FullWidth.ts ================================================ /** * @description table full width menu * @author wangfupeng */ import { Transforms, Range } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core' import { FULL_WIDTH_SVG } from '../../constants/svg' import { TableElement } from '../custom-types' class TableFullWidth implements IButtonMenu { readonly title = t('tableModule.widthAuto') readonly iconSvg = FULL_WIDTH_SVG readonly tag = 'button' // 是否已设置 宽度自适应 getValue(editor: IDomEditor): string | boolean { const tableNode = DomEditor.getSelectedNodeByType(editor, 'table') if (tableNode == null) return false return (tableNode as TableElement).width === '100%' } isActive(editor: IDomEditor): boolean { return !!this.getValue(editor) } isDisabled(editor: IDomEditor): boolean { const { selection } = editor if (selection == null) return true if (!Range.isCollapsed(selection)) return true const tableNode = DomEditor.getSelectedNodeByType(editor, 'table') if (tableNode == null) { // 选区未处于 table node ,则禁用 return true } return false } exec(editor: IDomEditor, value: string | boolean) { if (this.isDisabled(editor)) return const props: Partial = { width: value ? 'auto' : '100%', // 切换 'auto' 和 '100%' } Transforms.setNodes(editor, props, { mode: 'highest' }) } } export default TableFullWidth ================================================ FILE: packages/table-module/src/module/menu/InsertCol.ts ================================================ /** * @description insert col menu * @author wangfupeng */ import isEqual from 'lodash.isequal' import { Editor, Element, Transforms, Range, Node } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core' import { ADD_COL_SVG } from '../../constants/svg' import { TableCellElement, TableElement } from '../custom-types' import { isTableWithHeader } from '../helpers' class InsertCol implements IButtonMenu { readonly title = t('tableModule.insertCol') readonly iconSvg = ADD_COL_SVG readonly tag = 'button' getValue(editor: IDomEditor): string | boolean { // 无需获取 val return '' } isActive(editor: IDomEditor): boolean { // 无需 active return false } isDisabled(editor: IDomEditor): boolean { const { selection } = editor if (selection == null) return true if (!Range.isCollapsed(selection)) return true const tableNode = DomEditor.getSelectedNodeByType(editor, 'table') if (tableNode == null) { // 选区未处于 table cell node ,则禁用 return true } return false } exec(editor: IDomEditor, value: string | boolean) { if (this.isDisabled(editor)) return const [cellEntry] = Editor.nodes(editor, { match: n => DomEditor.checkNodeType(n, 'table-cell'), universal: true, }) const [selectedCellNode, selectedCellPath] = cellEntry const rowNode = DomEditor.getParentNode(editor, selectedCellNode) if (rowNode == null) return const tableNode = DomEditor.getParentNode(editor, rowNode) as TableElement if (tableNode == null) return // 遍历所有 rows ,挨个添加 cell const rows = tableNode.children || [] rows.forEach((row, rowIndex) => { if (!Element.isElement(row)) return const cells = row.children || [] // 遍历一个 row 的所有 cells cells.forEach((cell: Node) => { const path = DomEditor.findPath(editor, cell) if ( path.length === selectedCellPath.length && isEqual(path.slice(-1), selectedCellPath.slice(-1)) // 俩数组,最后一位相同 ) { // 如果当前 td 的 path 和选中 td 的 path ,最后一位相同,说明是同一列 // 则在其后插入一个 cell const newCell: TableCellElement = { type: 'table-cell', children: [{ text: '' }] } if (rowIndex === 0 && isTableWithHeader(tableNode)) { newCell.isHeader = true } Transforms.insertNodes(editor, newCell, { at: path }) } }) }) } } export default InsertCol ================================================ FILE: packages/table-module/src/module/menu/InsertRow.ts ================================================ /** * @description insert row menu * @author wangfupeng */ import { Editor, Transforms, Range, Path } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core' import { ADD_ROW_SVG } from '../../constants/svg' import { TableRowElement, TableCellElement } from '../custom-types' class InsertRow implements IButtonMenu { readonly title = t('tableModule.insertRow') readonly iconSvg = ADD_ROW_SVG readonly tag = 'button' getValue(editor: IDomEditor): string | boolean { // 无需获取 val return '' } isActive(editor: IDomEditor): boolean { // 无需 active return false } isDisabled(editor: IDomEditor): boolean { const { selection } = editor if (selection == null) return true if (!Range.isCollapsed(selection)) return true const tableNode = DomEditor.getSelectedNodeByType(editor, 'table') if (tableNode == null) { // 选区未处于 table cell node ,则禁用 return true } return false } exec(editor: IDomEditor, value: string | boolean) { if (this.isDisabled(editor)) return const [cellEntry] = Editor.nodes(editor, { match: n => DomEditor.checkNodeType(n, 'table-cell'), universal: true, }) const [cellNode, cellPath] = cellEntry // 获取 cell length ,即多少列 const rowNode = DomEditor.getParentNode(editor, cellNode) const cellsLength = rowNode?.children.length || 0 if (cellsLength === 0) return // 拼接新的 row const newRow: TableRowElement = { type: 'table-row', children: [] } for (let i = 0; i < cellsLength; i++) { const cell: TableCellElement = { type: 'table-cell', children: [{ text: '' }], } newRow.children.push(cell) } // 插入 row const rowPath = Path.parent(cellPath) // 获取 tr 的 path const newRowPath = Path.next(rowPath) Transforms.insertNodes(editor, newRow, { at: newRowPath }) } } export default InsertRow ================================================ FILE: packages/table-module/src/module/menu/InsertTable.ts ================================================ /** * @description insert table menu * @author wangfupeng */ import { Editor, Transforms, Range, Node } from 'slate' import { IDropPanelMenu, IDomEditor, DomEditor, t } from '@wangeditor/core' import $, { Dom7Array, DOMElement } from '../../utils/dom' import { genRandomStr } from '../../utils/util' import { TABLE_SVG } from '../../constants/svg' import { TableElement, TableCellElement, TableRowElement } from '../custom-types' function genTableNode(rowNum: number, colNum: number): TableElement { // 拼接 rows const rows: TableRowElement[] = [] for (let i = 0; i < rowNum; i++) { // 拼接 cells const cells: TableCellElement[] = [] for (let j = 0; j < colNum; j++) { const cellNode: TableCellElement = { type: 'table-cell', children: [{ text: '' }], } if (i === 0) { cellNode.isHeader = true // 第一行默认是 th } cells.push(cellNode) } // 生成 row rows.push({ type: 'table-row', children: cells, }) } return { type: 'table', width: 'auto', children: rows, } } /** * 生成唯一的 DOM ID */ function genDomID(): string { return genRandomStr('w-e-insert-table') } class InsertTable implements IDropPanelMenu { title = t('tableModule.insertTable') iconSvg = TABLE_SVG tag = 'button' showDropPanel = true // 点击 button 时显示 dropPanel private $content: Dom7Array | null = null getValue(editor: IDomEditor): string | boolean { // 插入菜单,不需要 value return '' } isActive(editor: IDomEditor): boolean { // 任何时候,都不用激活 menu return false } exec(editor: IDomEditor, value: string | boolean) { // 点击菜单时,弹出 modal 之前,不需要执行其他代码 // 此处空着即可 } isDisabled(editor: IDomEditor): boolean { const { selection } = editor if (selection == null) return true if (!Range.isCollapsed(selection)) return true // 选区非折叠,禁用 const selectedElems = DomEditor.getSelectedElems(editor) const hasVoidOrPreOrTable = selectedElems.some(elem => { const type = DomEditor.getNodeType(elem) if (type === 'pre') return true if (type === 'table') return true if (type === 'list-item') return true if (editor.isVoid(elem)) return true return false }) if (hasVoidOrPreOrTable) return true // 匹配到,禁用 return false } /** * 获取 panel 内容 * @param editor editor */ getPanelContentElem(editor: IDomEditor): DOMElement { // 已有,直接返回 if (this.$content) return this.$content[0] // 初始化 const $content = $('
                ') const $info = $('0 × 0') // 显示行列数量 // 渲染 10 * 10 table ,以快速创建表格 const $table = $('
                ') for (let i = 0; i < 10; i++) { const $tr = $('') for (let j = 0; j < 10; j++) { const $td = $('') $td.attr('data-x', j.toString()) $td.attr('data-y', i.toString()) $tr.append($td) // 绑定 mouseenter $td.on('mouseenter', (e: Event) => { const { target } = e if (target == null) return const $focusTd = $(target) const { x: focusX, y: focusY } = $focusTd.dataset() // 显示行列数量 $info[0].innerHTML = `${focusX + 1} × ${focusY + 1}` // 修改 table td 样式 $table.children().each(tr => { $(tr) .children() .each(td => { const $td = $(td) const { x, y } = $td.dataset() if (x <= focusX && y <= focusY) { $td.addClass('active') } else { $td.removeClass('active') } }) }) }) // 绑定 click $td.on('click', (e: Event) => { e.preventDefault() const { target } = e if (target == null) return const $td = $(target) const { x, y } = $td.dataset() this.insertTable(editor, y + 1, x + 1) }) } $table.append($tr) } $content.append($table) $content.append($info) // 记录,并返回 this.$content = $content return $content[0] } private insertTable(editor: IDomEditor, rowNumStr: string, colNumStr: string) { const rowNum = parseInt(rowNumStr, 10) const colNum = parseInt(colNumStr, 10) if (!rowNum || !colNum) return if (rowNum <= 0 || colNum <= 0) return // 如果当前是空 p ,则删除该 p if (DomEditor.isSelectedEmptyParagraph(editor)) { Transforms.removeNodes(editor, { mode: 'highest' }) } // 插入表格 const tableNode = genTableNode(rowNum, colNum) Transforms.insertNodes(editor, tableNode, { mode: 'highest' }) } } export default InsertTable ================================================ FILE: packages/table-module/src/module/menu/TableHeader.ts ================================================ /** * @description table header menu * @author wangfupeng */ import { Transforms, Range } from 'slate' import { IButtonMenu, IDomEditor, DomEditor, t } from '@wangeditor/core' import { TABLE_HEADER_SVG } from '../../constants/svg' import { TableElement } from '../custom-types' import { getFirstRowCells, isTableWithHeader } from '../helpers' class TableHeader implements IButtonMenu { readonly title = t('tableModule.header') readonly iconSvg = TABLE_HEADER_SVG readonly tag = 'button' // 是否已设置表头 getValue(editor: IDomEditor): string | boolean { const tableNode = DomEditor.getSelectedNodeByType(editor, 'table') as TableElement if (tableNode == null) return false return isTableWithHeader(tableNode) } isActive(editor: IDomEditor): boolean { return !!this.getValue(editor) } isDisabled(editor: IDomEditor): boolean { const { selection } = editor if (selection == null) return true if (!Range.isCollapsed(selection)) return true const tableNode = DomEditor.getSelectedNodeByType(editor, 'table') if (tableNode == null) { // 选区未处于 table node ,则禁用 return true } return false } exec(editor: IDomEditor, value: string | boolean) { if (this.isDisabled(editor)) return // 已经设置了表头,则取消。未设置表头,则设置 const newValue = value ? false : true // 获取第一行所有 cell const tableNode = DomEditor.getSelectedNodeByType(editor, 'table') as TableElement if (tableNode == null) return const firstRowCells = getFirstRowCells(tableNode) // 设置 isHeader 属性 firstRowCells.forEach(cell => Transforms.setNodes( editor, { isHeader: newValue }, { at: DomEditor.findPath(editor, cell), } ) ) } } export default TableHeader ================================================ FILE: packages/table-module/src/module/menu/index.ts ================================================ /** * @description table menu * @author wangfupeng */ import InsertTable from './InsertTable' import DeleteTable from './DeleteTable' import InsertRow from './InsertRow' import DeleteRow from './DeleteRow' import InsertCol from './InsertCol' import DeleteCol from './DeleteCol' import TableHander from './TableHeader' import FullWidth from './FullWidth' export const insertTableMenuConf = { key: 'insertTable', factory() { return new InsertTable() }, } export const deleteTableMenuConf = { key: 'deleteTable', factory() { return new DeleteTable() }, } export const insertTableRowConf = { key: 'insertTableRow', factory() { return new InsertRow() }, } export const deleteTableRowConf = { key: 'deleteTableRow', factory() { return new DeleteRow() }, } export const insertTableColConf = { key: 'insertTableCol', factory() { return new InsertCol() }, } export const deleteTableColConf = { key: 'deleteTableCol', factory() { return new DeleteCol() }, } export const tableHeaderMenuConf = { key: 'tableHeader', factory() { return new TableHander() }, } export const tableFullWidthMenuConf = { key: 'tableFullWidth', factory() { return new FullWidth() }, } ================================================ FILE: packages/table-module/src/module/parse-elem-html.ts ================================================ /** * @description parse html * @author wangfupeng */ import { Descendant, Text } from 'slate' import { IDomEditor, DomEditor } from '@wangeditor/core' import { TableCellElement, TableRowElement, TableElement } from './custom-types' import $, { getTagName, getStyleValue, DOMElement } from '../utils/dom' function parseCellHtml( elem: DOMElement, children: Descendant[], editor: IDomEditor ): TableCellElement { const $elem = $(elem) children = children.filter(child => { if (Text.isText(child)) return true if (editor.isInline(child)) return true return false }) // 无 children ,则用纯文本 if (children.length === 0) { children = [{ text: $elem.text().replace(/\s+/gm, ' ') }] } const colSpan = parseInt($elem.attr('colSpan') || '1') const rowSpan = parseInt($elem.attr('rowSpan') || '1') const width = $elem.attr('width') || 'auto' return { type: 'table-cell', isHeader: getTagName($elem) === 'th', colSpan, rowSpan, width, // @ts-ignore children, } } export const parseCellHtmlConf = { selector: 'td:not([data-w-e-type]),th:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性 parseElemHtml: parseCellHtml, } function parseRowHtml( elem: DOMElement, children: Descendant[], editor: IDomEditor ): TableRowElement { return { type: 'table-row', // @ts-ignore children: children.filter(child => DomEditor.getNodeType(child) === 'table-cell'), } } export const parseRowHtmlConf = { selector: 'tr:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性 parseElemHtml: parseRowHtml, } function parseTableHtml( elem: DOMElement, children: Descendant[], editor: IDomEditor ): TableElement { const $elem = $(elem) // 计算宽度 let width = 'auto' if (getStyleValue($elem, 'width') === '100%') width = '100%' if ($elem.attr('width') === '100%') width = '100%' // 兼容 v4 格式 return { type: 'table', width, // @ts-ignore children: children.filter(child => DomEditor.getNodeType(child) === 'table-row'), } } export const parseTableHtmlConf = { selector: 'table:not([data-w-e-type])', // data-w-e-type 属性,留给自定义元素,保证扩展性 parseElemHtml: parseTableHtml, } ================================================ FILE: packages/table-module/src/module/plugin.ts ================================================ /** * @description editor 插件,重写 editor API * @author wangfupeng */ import { Editor, Transforms, Location, Point, Element as SlateElement, Descendant, NodeEntry, Node, BaseText, Path, } from 'slate' import { IDomEditor, DomEditor } from '@wangeditor/core' // table cell 内部的删除处理 function deleteHandler(newEditor: IDomEditor): boolean { const { selection } = newEditor if (selection == null) return false const [cellNodeEntry] = Editor.nodes(newEditor, { match: n => DomEditor.checkNodeType(n, 'table-cell'), }) if (cellNodeEntry) { const [, cellPath] = cellNodeEntry const start = Editor.start(newEditor, cellPath) if (Point.equals(selection.anchor, start)) { return true // 阻止删除 cell } } return false } /** * 判断该 location 有没有命中 table * @param editor editor * @param location location */ function isTableLocation(editor: IDomEditor, location: Location): boolean { const tables = Editor.nodes(editor, { at: location, match: n => { const type = DomEditor.getNodeType(n) return type === 'table' }, }) let hasTable = false for (const table of tables) { hasTable = true // 找到了 table } return hasTable } function withTable(editor: T): T { const { insertBreak, deleteBackward, deleteForward, normalizeNode, insertData, handleTab, selectAll, } = editor const newEditor = editor // 重写 insertBreak - cell 内换行,只换行文本,不拆分 node newEditor.insertBreak = () => { const selectedNode = DomEditor.getSelectedNodeByType(newEditor, 'table') if (selectedNode != null) { // 选中了 table ,则在 cell 内换行 newEditor.insertText('\n') return } // 未选中 table ,默认的换行 insertBreak() } // 重写 delete - cell 内删除,只删除文字,不删除 node newEditor.deleteBackward = unit => { const res = deleteHandler(newEditor) if (res) return // 命中 table cell ,自己处理删除 // 防止从 table 后面的 p 删除时,删除最后一个 cell - issues/4221 const { selection } = newEditor if (selection) { const before = Editor.before(newEditor, selection) // 前一个 location if (before) { const isTableOnBeforeLocation = isTableLocation(newEditor, before) // before 是否是 table const isTableOnCurSelection = isTableLocation(newEditor, selection) // 当前是否是 table if (isTableOnBeforeLocation && !isTableOnCurSelection) { return // 如果当前不是 table ,前面是 table ,则不执行删除。否则会删除 table 最后一个 cell } } } // 执行默认的删除 deleteBackward(unit) } // 重写 handleTab 在table内按tab时跳到下一个单元格 newEditor.handleTab = () => { const selectedNode = DomEditor.getSelectedNodeByType(newEditor, 'table') if (selectedNode) { const above = Editor.above(editor) as NodeEntry // 常规情况下选中文字外层 table-cell 进行跳转 if (DomEditor.checkNodeType(above[0], 'table-cell')) { Transforms.select(editor, above[1]) } let next = Editor.next(editor) if (next) { if (next[0] && (next[0] as BaseText).text) { // 多个单元格同时选中按 tab 导致错位修复 next = (Editor.above(editor, { at: next[1] }) as NodeEntry) ?? next } Transforms.select(editor, next[1]) } else { const topLevelNodes = newEditor.children || [] const topLevelNodesLength = topLevelNodes.length // 在最后一个单元格按tab时table末尾如果没有p则插入p后光标切到p上 if (DomEditor.checkNodeType(topLevelNodes[topLevelNodesLength - 1], 'table')) { const p = DomEditor.genEmptyParagraph() Transforms.insertNodes(newEditor, p, { at: [topLevelNodesLength] }) // 在表格末尾插入p后再次执行使光标切到p上 newEditor.handleTab() } } return } handleTab() } newEditor.deleteForward = unit => { const res = deleteHandler(newEditor) if (res) return // 命中 table cell ,自己处理删除 // 执行默认的删除 deleteForward(unit) } // 重新 normalize newEditor.normalizeNode = ([node, path]) => { const type = DomEditor.getNodeType(node) if (type !== 'table') { // 未命中 table ,执行默认的 normalizeNode return normalizeNode([node, path]) } // -------------- table 是 editor 最后一个节点,需要后面插入 p -------------- const isLast = DomEditor.isLastNode(newEditor, node) if (isLast) { const p = DomEditor.genEmptyParagraph() Transforms.insertNodes(newEditor, p, { at: [path[0] + 1] }) } } // 重写 insertData - 粘贴文本 newEditor.insertData = (data: DataTransfer) => { const tableNode = DomEditor.getSelectedNodeByType(newEditor, 'table') if (tableNode == null) { insertData(data) // 执行默认的 insertData return } // 获取文本,并插入到 cell const text = data.getData('text/plain') // 单图或图文 插入 if (text === '\n' || /]+>/.test(data.getData('text/html'))) { insertData(data) return } Editor.insertText(newEditor, text) } // 重写 table-cell 中的全选 newEditor.selectAll = () => { const selection = newEditor.selection if (selection == null) { selectAll() return } const cell = DomEditor.getSelectedNodeByType(newEditor, 'table-cell') if (cell == null) { selectAll() return } const { anchor, focus } = selection if (!Path.equals(anchor.path.slice(0, 3), focus.path.slice(0, 3))) { // 选中了多个 cell ,忽略 selectAll() return } const text = Node.string(cell) const textLength = text.length if (textLength === 0) { selectAll() return } const path = DomEditor.findPath(newEditor, cell) const start = Editor.start(newEditor, path) const end = Editor.end(newEditor, path) const newSelection = { anchor: start, focus: end, } newEditor.select(newSelection) // 选中 table-cell 内部的全部文字 } // 可继续修改其他 newEditor API ... // 返回 editor ,重要! return newEditor } export default withTable ================================================ FILE: packages/table-module/src/module/pre-parse-html.ts ================================================ /** * @description pre parse html * @author wangfupeng */ import $, { getTagName, DOMElement } from '../utils/dom' /** * pre-prase table ,去掉 * @param table table elem */ function preParse(tableElem: DOMElement): DOMElement { const $table = $(tableElem) const tagName = getTagName($table) if (tagName !== 'table') return tableElem // 没有 则直接返回 const $tbody = $table.find('tbody') if ($tbody.length === 0) return tableElem // 去掉 ,把 移动到 下面 const $tr = $table.find('tr') $table.append($tr) $tbody.remove() return $table[0] } export const preParseTableHtmlConf = { selector: 'table', preParseHtml: preParse, } ================================================ FILE: packages/table-module/src/module/render-elem/index.ts ================================================ /** * @description render elem * @author wangfupeng */ import renderTable from './render-table' import renderTableRow from './render-row' import renderTableCell from './render-cell' export const renderTableConf = { type: 'table', renderElem: renderTable, } export const renderTableRowConf = { type: 'table-row', renderElem: renderTableRow, } export const renderTableCellConf = { type: 'table-cell', renderElem: renderTableCell, } ================================================ FILE: packages/table-module/src/module/render-elem/render-cell.tsx ================================================ /** * @description render cell * @author wangfupeng */ import throttle from 'lodash.throttle' import { Element as SlateElement, Transforms, Location } from 'slate' import { jsx, VNode } from 'snabbdom' import { IDomEditor, DomEditor } from '@wangeditor/core' import { TableCellElement } from '../custom-types' import { isCellInFirstRow } from '../helpers' import $ from '../../utils/dom' // 拖拽列宽相关信息 let isMouseDownForResize = false let clientXWhenMouseDown = 0 let cellWidthWhenMouseDown = 0 let cellPathWhenMouseDown: Location | null = null let editorWhenMouseDown: IDomEditor | null = null const $body = $('body') function onMouseDown(event: Event) { const elem = event.target as HTMLElement if (elem.tagName !== 'TH' && elem.tagName !== 'TD') return if (elem.style.cursor !== 'col-resize') return elem.style.cursor = 'auto' event.preventDefault() // 记录必要信息 isMouseDownForResize = true const { clientX } = event as MouseEvent clientXWhenMouseDown = clientX const { width } = elem.getBoundingClientRect() cellWidthWhenMouseDown = width // 绑定事件 $body.on('mousemove', onMouseMove) $body.on('mouseup', onMouseUp) } $body.on('mousedown', onMouseDown) // 绑定事件 function onMouseUp(event: Event) { isMouseDownForResize = false editorWhenMouseDown = null cellPathWhenMouseDown = null // 解绑事件 $body.off('mousemove', onMouseMove) $body.off('mouseup', onMouseUp) } const onMouseMove = throttle(function (event: Event) { if (!isMouseDownForResize) return if (editorWhenMouseDown == null || cellPathWhenMouseDown == null) return event.preventDefault() const { clientX } = event as MouseEvent let newWith = cellWidthWhenMouseDown + (clientX - clientXWhenMouseDown) // 计算新宽度 newWith = Math.floor(newWith * 100) / 100 // 保留小数点后两位 if (newWith < 30) newWith = 30 // 最小宽度 // 这是宽度 Transforms.setNodes( editorWhenMouseDown, { width: newWith.toString() }, { at: cellPathWhenMouseDown, } ) }, 100) function renderTableCell( cellNode: SlateElement, children: VNode[] | null, editor: IDomEditor ): VNode { const isFirstRow = isCellInFirstRow(editor, cellNode as TableCellElement) const { colSpan = 1, rowSpan = 1, isHeader = false } = cellNode as TableCellElement // ------------------ 不是第一行,直接渲染 ) } // ------------------ 是第一行:1. 判断 th ;2. 拖拽列宽 ------------------ const Tag = isHeader ? 'th' : 'td' const vnode = ( left + width - 5 && clientX < left + width // X 轴,是否接近 cell 右侧? const matchY = clientY > top && clientY < top + height // Y 轴,是否在 cell 之内 // X Y 轴都接近,则修改鼠标样式 if (matchX && matchY) { elem.style.cursor = 'col-resize' editorWhenMouseDown = editor cellPathWhenMouseDown = DomEditor.findPath(editor, cellNode) } else { if (!isMouseDownForResize) { elem.style.cursor = 'auto' editorWhenMouseDown = null cellPathWhenMouseDown = null } } }, 100), }} > {children} ) return vnode } export default renderTableCell ================================================ FILE: packages/table-module/src/module/render-elem/render-row.tsx ================================================ /** * @description render row * @author wangfupeng */ import { Element as SlateElement } from 'slate' import { jsx, VNode } from 'snabbdom' import { IDomEditor } from '@wangeditor/core' function renderTableRow( elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor ): VNode { const vnode = {children} return vnode } export default renderTableRow ================================================ FILE: packages/table-module/src/module/render-elem/render-table.tsx ================================================ /** * @description render table * @author wangfupeng */ import { Editor, Element as SlateElement, Range, Point, Path } from 'slate' import { jsx, VNode } from 'snabbdom' import { IDomEditor, DomEditor } from '@wangeditor/core' import { TableElement } from '../custom-types' import { getFirstRowCells } from '../helpers' /** * 计算 table 是否可编辑。如果选区跨域 table 和外部内容,删除,会导致 table 结构打乱。所以,有时要让 table 不可编辑 * @param editor editor * @param tableElem table elem */ function getContentEditable(editor: IDomEditor, tableElem: SlateElement): boolean { if (editor.isDisabled()) return false const { selection } = editor if (selection == null) return true if (Range.isCollapsed(selection)) return true const { anchor, focus } = selection const tablePath = DomEditor.findPath(editor, tableElem) const tableStart = Editor.start(editor, tablePath) const tableEnd = Editor.end(editor, tablePath) const isAnchorInTable = Point.compare(anchor, tableEnd) <= 0 && Point.compare(anchor, tableStart) >= 0 const isFocusInTable = Point.compare(focus, tableEnd) <= 0 && Point.compare(focus, tableStart) >= 0 // 选区在 table 内部,且选中了同一个单元格。表格可以编辑 if (isAnchorInTable && isFocusInTable) { if (Path.equals(anchor.path.slice(0, 3), focus.path.slice(0, 3))) { return true } } return false } function renderTable(elemNode: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode { // 是否可编辑 const editable = getContentEditable(editor, elemNode) // 宽度 const { width = 'auto' } = elemNode as TableElement // 是否选中 const selected = DomEditor.isNodeSelected(editor, elemNode) // 第一行的 cells ,以计算列宽 const firstRowCells = getFirstRowCells(elemNode as TableElement) const vnode = (
                { // @ts-ignore 阻止光标定位到 table 后面 if (e.target.tagName === 'DIV') e.preventDefault() if (editor.isDisabled()) return // 是否需要定位到 table 内部 const tablePath = DomEditor.findPath(editor, elemNode) const tableStart = Editor.start(editor, tablePath) const { selection } = editor if (selection == null) { editor.select(tableStart) // 选中 table 内部 return } const { path } = selection.anchor if (path[0] === tablePath[0]) return // 当前选区,就在 table 内部 editor.select(tableStart) // 选中 table 内部 }, }} >
                ------------------ if (!isFirstRow) { return ( {children}
                {firstRowCells.map(cell => { const { width = 'auto' } = cell return })} {children}
                ) return vnode } export default renderTable ================================================ FILE: packages/table-module/src/utils/dom.ts ================================================ /** * @description DOM 操作 * @author wangfupeng */ import $, { append, on, focus, attr, val, html, dataset, addClass, removeClass, children, each, find, Dom7Array, } from 'dom7' export { Dom7Array } from 'dom7' if (append) $.fn.append = append if (on) $.fn.on = on if (focus) $.fn.focus = focus if (attr) $.fn.attr = attr if (val) $.fn.val = val if (html) $.fn.html = html if (dataset) $.fn.dataset = dataset if (addClass) $.fn.addClass = addClass if (removeClass) $.fn.removeClass = removeClass if (children) $.fn.children = children if (each) $.fn.each = each if (find) $.fn.find = find export default $ /** * 获取 tagName lower-case * @param $elem $elem */ export function getTagName($elem: Dom7Array): string { if ($elem.length) return $elem[0].tagName.toLowerCase() return '' } /** * 获取 $elem 某一个 style 值 * @param $elem $elem * @param styleKey style key */ export function getStyleValue($elem: Dom7Array, styleKey: string): string { let res = '' const styleStr = $elem.attr('style') || '' // 如 'line-height: 2.5; color: red;' const styleArr = styleStr.split(';') // 如 ['line-height: 2.5', ' color: red', ''] const length = styleArr.length for (let i = 0; i < length; i++) { const styleItemStr = styleArr[i] // 如 'line-height: 2.5' if (styleItemStr) { const arr = styleItemStr.split(':') // ['line-height', ' 2.5'] if (arr[0].trim() === styleKey) { res = arr[1].trim() } } } return res } // COMPAT: This is required to prevent TypeScript aliases from doing some very // weird things for Slate's types with the same name as globals. (2019/11/27) // https://github.com/microsoft/TypeScript/issues/35002 import DOMNode = globalThis.Node import DOMComment = globalThis.Comment import DOMElement = globalThis.Element import DOMText = globalThis.Text import DOMRange = globalThis.Range import DOMSelection = globalThis.Selection import DOMStaticRange = globalThis.StaticRange export { DOMNode, DOMComment, DOMElement, DOMText, DOMRange, DOMSelection, DOMStaticRange } ================================================ FILE: packages/table-module/src/utils/util.ts ================================================ /** * @description 工具函数 * @author wangfupeng */ import { nanoid } from 'nanoid' /** * 获取随机数字符串 * @param prefix 前缀 * @returns 随机数字符串 */ export function genRandomStr(prefix: string = 'r'): string { return `${prefix}-${nanoid()}` } ================================================ FILE: packages/table-module/tsconfig.json ================================================ { "compilerOptions": {}, "extends": "../../tsconfig.json", "include": [ "./src/**/*", "../custom-types.d.ts" ] } ================================================ FILE: packages/upload-image-module/CHANGELOG.md ================================================ # Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. ## [1.0.2](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/upload-image-module@1.0.1...@wangeditor/upload-image-module@1.0.2) (2022-09-15) ### Bug Fixes * customInsert 不触发 onSuccess ([d6f4a1b](https://github.com/wangeditor-team/wangEditor/commit/d6f4a1b1494864b116a1310cce2d9e8632c92c6f)) ## 1.0.1 (2022-04-18) ### Bug Fixes * 多图片上传 ([53fe915](https://github.com/wangeditor-team/wangEditor/commit/53fe915aa7d40f05e1e9446c7f26606c46832ff3)) * 更新各包之间依赖版本 ([75c552c](https://github.com/wangeditor-team/wangEditor/commit/75c552cc8ed54765bebb86a7ec5329a7fc79e85f)) * 上传图片 - base64 仍触发上传 + 超出 maxSize 的报错提醒 ([a1d469a](https://github.com/wangeditor-team/wangEditor/commit/a1d469accb7f87f8ea0282a1699d002aaaa4e79a)) * 图片上传,提示 ([3754012](https://github.com/wangeditor-team/wangEditor/commit/37540129dff1212c5ebfd4ca3f4d4e8def735e73)) * 修复 pnpm 安装 @wangeditor/editor 出现警告的问题 ([4087fbe](https://github.com/wangeditor-team/wangEditor/commit/4087fbee01c76bdd55e747a5e86c5e4a8d6a8353)) * 移除了每个包下的 publishConfig directory 配置 ([16559f0](https://github.com/wangeditor-team/wangEditor/commit/16559f052545c111318be760e64291a521bdcc65)) * 粘贴 excel ([5382a6e](https://github.com/wangeditor-team/wangEditor/commit/5382a6edab2d362c7be143b62e7dd21bea8a15ab)) * npm install error - basic-modules 相关 ([b85a0dc](https://github.com/wangeditor-team/wangEditor/commit/b85a0dcfaa15d69424d86a20255d6b9e8b28494f)) * rename es module filename ([1821d4e](https://github.com/wangeditor-team/wangEditor/commit/1821d4eef49e64efcb41b848849ca7a5e6472044)) ### Features * 上传图片 metaWithUrl ([2485157](https://github.com/wangeditor-team/wangEditor/commit/24851576a1dcc07b1a8931d17a147c3640222e85)) * 增加 enable disable API(删除 setConfig setMenuConfig API) ([984fc50](https://github.com/wangeditor-team/wangEditor/commit/984fc50520061fc34ea08f4136bdeb93dee46564)) * editor.showProgressBar ([51761d4](https://github.com/wangeditor-team/wangEditor/commit/51761d466ab3ef7c99e872954d4724ab51d8e28c)) * i18n ([c11b244](https://github.com/wangeditor-team/wangEditor/commit/c11b2440f91b99d40bca18b675c66a22b6e160c9)) * image menu - width 50% 100% ([f9b4c68](https://github.com/wangeditor-team/wangEditor/commit/f9b4c68dff3232b50491b07949c20eb4c18baa6b)) * image menu config ([bb18774](https://github.com/wangeditor-team/wangEditor/commit/bb187740e9703b4a76cde4f5e4d32ac714aa793a)) * upload image ([0a0564b](https://github.com/wangeditor-team/wangEditor/commit/0a0564bf14edd4dea6eb958e653272a9a216cec1)) * upload video ([ac8e6f8](https://github.com/wangeditor-team/wangEditor/commit/ac8e6f8b5258e593714676a6f6be359ba525833c)) ================================================ FILE: packages/upload-image-module/README.md ================================================ # wangEditor upload-image-module Upload image module built in [wangEditor](https://www.wangeditor.com/) by default. ================================================ FILE: packages/upload-image-module/__tests__/config.test.ts ================================================ import { genUploadImageConfig } from '../src/module/menu/config' describe('Upload image default config', () => { test('Upload image invoke genUploadImageConfig should generate default config', () => { expect(typeof genUploadImageConfig()).toBe('object') }) test('The option server is "" in default config', () => { expect(genUploadImageConfig().server).toBe('') }) test('The option fieldName is "wangeditor-uploaded-image" in default config', () => { expect(genUploadImageConfig().fieldName).toBe('wangeditor-uploaded-image') }) test('The option maxFileSize is "2M" in default config', () => { expect(genUploadImageConfig().maxFileSize).toBe(2 * 1024 * 1024) }) test('The option maxNumberOfFiles is "100" in default config', () => { expect(genUploadImageConfig().maxNumberOfFiles).toBe(100) }) test('The option allowedFileTypes is "[image/*"]" in default config', () => { expect(genUploadImageConfig().allowedFileTypes).toEqual(['image/*']) }) test('The option metaWithUrl is "false" in default config', () => { expect(genUploadImageConfig().metaWithUrl).toBe(false) }) test('The option withCredentials is "false" in default config', () => { expect(genUploadImageConfig().withCredentials).toBe(false) }) test('The option timeout is "10s" in default config', () => { expect(genUploadImageConfig().timeout).toBe(10 * 1000) }) test('The option base64LimitSize is "0" in default config', () => { expect(genUploadImageConfig().base64LimitSize).toBe(0) }) }) ================================================ FILE: packages/upload-image-module/__tests__/plugin.test.ts ================================================ import { IDomEditor } from '@wangeditor/core' import * as basicModule from '@wangeditor/basic-modules' import createEditor from '../../../tests/utils/create-editor' import withUploadImage from '../src/module/plugin' import * as uploadImage from '../src/module/upload-images' let editor: IDomEditor describe('withUploadImage plugin', () => { beforeEach(() => { editor = createEditor() // mock isInsertImageMenuDisabled jest.spyOn(basicModule, 'isInsertImageMenuDisabled').mockImplementation(() => false) }) test('withUploadImage plugin should invoke insertData directly for insert transfer data if isInsertImageMenuDisabled return truthy value', () => { jest.spyOn(basicModule, 'isInsertImageMenuDisabled').mockImplementation(() => true) const fn = jest.fn() editor.insertData = fn const newEditor = withUploadImage(editor) newEditor.insertData(new DataTransfer()) expect(fn).toBeCalled() }) test('withUploadImage plugin should invoke insertData with text data if transfer data contains plain text ', () => { const fn = jest.fn() editor.insertData = fn const newEditor = withUploadImage(editor) jest.spyOn(DataTransfer.prototype, 'getData').mockImplementation(() => 'plain text') const transfer = new DataTransfer() newEditor.insertData(transfer) expect(transfer.getData('text/plain')).toBe('plain text') expect(fn).toBeCalledWith(transfer) // 不影响后面的测试,需要重置 jest.spyOn(DataTransfer.prototype, 'getData').mockImplementation(() => '') }) test('withUploadImage plugin should invoke insertData with transfer data if transfer data contains empty files', () => { const fn = jest.fn() editor.insertData = fn const newEditor = withUploadImage(editor) jest.spyOn(DataTransfer.prototype, 'files', 'get').mockReturnValue([] as any) newEditor.insertData(new DataTransfer()) expect(fn).toBeCalled() }) test('withUploadImage plugin should invoke uploadImage method with image files if transfer data contains file which mime type is image', () => { const fn = jest.fn() jest.spyOn(uploadImage, 'default').mockImplementation(fn) const newEditor = withUploadImage(editor) jest .spyOn(DataTransfer.prototype, 'files', 'get') .mockReturnValue([{ type: 'image/png', size: 10 }] as any) newEditor.insertData(new DataTransfer()) expect(fn).toBeCalled() }) test('withUploadImage plugin should invoke insertData method with transfer data if transfer data contains file which mime type is not image', () => { const fn = jest.fn() editor.insertData = fn const newEditor = withUploadImage(editor) jest .spyOn(DataTransfer.prototype, 'files', 'get') .mockReturnValue([{ type: 'text/html', size: 10 }] as any) const transfer = new DataTransfer() newEditor.insertData(transfer) expect(fn).toBeCalledWith(transfer) }) }) ================================================ FILE: packages/upload-image-module/__tests__/upload-files.test.ts ================================================ import uploadImages from '../src/module/upload-images' import createEditor from '../../../tests/utils/create-editor' import * as core from '@wangeditor/core' function mockFile(filename: string) { const file = new File(['123'], filename) return file } describe('Upload image menu upload files util', () => { test('uploadImages should do nothing if give null value to fileList argument', async () => { const editor = createEditor() const res = await uploadImages(editor, null) expect(res).toBeUndefined() }) test('uploadImages should invoke customUpload if give customUpload to config', async () => { const fn = jest.fn() const editor = createEditor({ config: { MENU_CONF: { uploadImage: { customUpload: fn, }, }, }, }) await uploadImages(editor, [mockFile('test.jpg')] as unknown as FileList) expect(fn).toBeCalled() }) test('uploadImages should insert image with base64 string if file size less than base64LimitSize config', async () => { const fn = jest.fn() const editor = createEditor({ config: { MENU_CONF: { uploadImage: { customUpload: fn, base64LimitSize: 10, }, }, }, }) const mockReadAsDataURL = jest.spyOn(FileReader.prototype, 'readAsDataURL') await uploadImages(editor, [mockFile('test.jpg')] as unknown as FileList) expect(mockReadAsDataURL).toBeCalled() }) test('uploadImages should invoke core createUploader if not give customUpload to config', async () => { const fn = jest.fn().mockImplementation( () => // 这里需要返回一个 duck 类型的 uppy 对象,防止后面代码执行报错 ({ addFile: jest.fn(), upload: jest.fn(), } as any) ) const editor = createEditor() jest.spyOn(core, 'createUploader').mockImplementation(fn) await uploadImages(editor, [mockFile('test.jpg')] as unknown as FileList) expect(fn).toBeCalled() }) }) ================================================ FILE: packages/upload-image-module/__tests__/upload-image-menu.test.ts ================================================ import { IDomEditor } from '../../../packages/editor/src' import UploadImageMenu from '../src/module/menu/UploadImageMenu' import createEditor from '../../../tests/utils/create-editor' let editor: IDomEditor let menu: UploadImageMenu describe('Upload image menu', () => { beforeEach(() => { editor = createEditor() menu = new UploadImageMenu() }) test('UploadImageMenu instance title is "上传图片" for zhCn locale config', () => { expect(menu.title).toBe('上传图片') }) test('UploadImageMenu invoke getValue return ""', () => { expect(menu.getValue(editor)).toBe('') }) test('UploadImageMenu invoke isActive always return false', () => { expect(menu.isActive(editor)).toBe(false) }) test('UploadImageMenu invoke exec should exec customBrowseAndUpload if config has customBrowseAndUpload option', () => { const jestFn = jest.fn() const editor = createEditor({ config: { MENU_CONF: { uploadImage: { customBrowseAndUpload: jestFn, }, }, }, }) menu.exec(editor, 'test.jpg') expect(jestFn).toBeCalled() }) test('UploadImageMenu invoke exec should insert hidden input element to body', () => { const editor = createEditor({ config: { MENU_CONF: { uploadImage: { allowedFileTypes: ['jpg', 'png'], }, }, }, }) // 防卫断言 expect(document.querySelector('input')).toBeNull() menu.exec(editor, 'test.jpg') expect(document.querySelector('input') instanceof HTMLInputElement).toBeTruthy() }) }) ================================================ FILE: packages/upload-image-module/package.json ================================================ { "name": "@wangeditor/upload-image-module", "version": "1.0.2", "description": "wangEditor upload-image module", "author": "wangfupeng1988 ", "contributors": [], "homepage": "https://github.com/wangeditor-team/wangEditor#readme", "license": "MIT", "types": "dist/upload-image-module/src/index.d.ts", "main": "dist/index.js", "module": "dist/index.esm.js", "browser": { "./dist/index.js": "./dist/index.js", "./dist/index.esm.js": "./dist/index.esm.js" }, "directories": { "lib": "dist", "test": "__tests__" }, "files": [ "dist" ], "publishConfig": { "access": "public", "registry": "https://registry.npmjs.com/" }, "repository": { "type": "git", "url": "git+https://github.com/wangeditor-team/wangEditor.git" }, "scripts": { "test": "jest", "test-c": "jest --coverage", "dev": "cross-env NODE_ENV=development rollup -c rollup.config.js", "dev-watch": "cross-env NODE_ENV=development rollup -c rollup.config.js -w", "build": "cross-env NODE_ENV=production rollup -c rollup.config.js", "dev-size-stats": "cross-env NODE_ENV=development:size_stats rollup -c rollup.config.js", "size-stats": "cross-env NODE_ENV=production:size_stats rollup -c rollup.config.js" }, "bugs": { "url": "https://github.com/wangeditor-team/wangEditor/issues" }, "peerDependencies": { "@uppy/core": "^2.0.3", "@uppy/xhr-upload": "^2.0.3", "@wangeditor/basic-modules": "1.x", "@wangeditor/core": "1.x", "dom7": "^3.0.0", "lodash.foreach": "^4.5.0", "slate": "^0.72.0", "snabbdom": "^3.1.0" } } ================================================ FILE: packages/upload-image-module/rollup.config.js ================================================ import { createRollupConfig, IS_PRD } from '../../build/create-rollup-config' import pkg from './package.json' const name = 'WangEditorUploadImageModule' const configList = [] // esm const esmConf = createRollupConfig({ output: { file: pkg.module, format: 'esm', name, }, }) configList.push(esmConf) // umd const umdConf = createRollupConfig({ output: { file: pkg.main, format: 'umd', name, }, }) configList.push(umdConf) export default configList ================================================ FILE: packages/upload-image-module/src/assets/index.less ================================================ // styles ================================================ FILE: packages/upload-image-module/src/constants/svg.ts ================================================ /** * @description icon svg * @author wangfupeng */ /** * 【注意】svg 字符串的长度 ,否则会导致代码体积过大 * 尽量选择 https://www.iconfont.cn/collections/detail?spm=a313x.7781069.0.da5a778a4&cid=20293 * 找不到再从 iconfont.com 搜索 */ // 上传图片 export const UPLOAD_IMAGE_SVG = '' ================================================ FILE: packages/upload-image-module/src/index.ts ================================================ /** * @description upload image * @author wangfupeng */ import './assets/index.less' // 配置多语言 import './locale/index' import wangEditorUploadImageModule from './module/index' export default wangEditorUploadImageModule ================================================ FILE: packages/upload-image-module/src/locale/en.ts ================================================ /** * @description i18n en * @author wangfupeng */ export default { uploadImgModule: { uploadImage: 'Upload Image', uploadError: '{{fileName}} upload error', }, } ================================================ FILE: packages/upload-image-module/src/locale/index.ts ================================================ /** * @description i18n entry * @author wangfupeng */ import { i18nAddResources } from '@wangeditor/core' import enResources from './en' import zhResources from './zh-CN' i18nAddResources('en', enResources) i18nAddResources('zh-CN', zhResources) ================================================ FILE: packages/upload-image-module/src/locale/zh-CN.ts ================================================ /** * @description i18n zh-CN * @author wangfupeng */ export default { uploadImgModule: { uploadImage: '上传图片', uploadError: '{{fileName}} 上传出错', }, } ================================================ FILE: packages/upload-image-module/src/module/index.ts ================================================ /** * @description uploadImage module * @author wangfupeng */ import { IModuleConf } from '@wangeditor/core' import withUploadImage from './plugin' import { uploadImageMenuConf } from './menu/index' const uploadImage: Partial = { menus: [uploadImageMenuConf], editorPlugin: withUploadImage, } export default uploadImage ================================================ FILE: packages/upload-image-module/src/module/menu/UploadImageMenu.ts ================================================ /** * @description upload image menu * @author wangfupeng */ import { IButtonMenu, IDomEditor, t } from '@wangeditor/core' import { insertImageNode, isInsertImageMenuDisabled } from '@wangeditor/basic-modules' import { UPLOAD_IMAGE_SVG } from '../../constants/svg' import $ from '../../utils/dom' import { IUploadConfigForImage } from './config' import uploadImages from '../upload-images' class UploadImage implements IButtonMenu { readonly title = t('uploadImgModule.uploadImage') readonly iconSvg = UPLOAD_IMAGE_SVG readonly tag = 'button' getValue(editor: IDomEditor): string | boolean { // 插入菜单,不需要 value return '' } isActive(editor: IDomEditor): boolean { // 任何时候,都不用激活 menu return false } isDisabled(editor: IDomEditor): boolean { return isInsertImageMenuDisabled(editor) } private getMenuConfig(editor: IDomEditor): IUploadConfigForImage { // 获取配置,见 `./config.js` return editor.getMenuConfig('uploadImage') as IUploadConfigForImage } exec(editor: IDomEditor, value: string | boolean) { const { allowedFileTypes = [], customBrowseAndUpload } = this.getMenuConfig(editor) // 自定义选择图片,并上传,如图床 if (customBrowseAndUpload) { customBrowseAndUpload((src, alt, href) => insertImageNode(editor, src, alt, href)) return } // 设置选择文件的类型 let acceptAttr = '' if (allowedFileTypes.length > 0) { acceptAttr = `accept="${allowedFileTypes.join(', ')}"` } // 添加 file input(每次重新创建 input) const $body = $('body') const $inputFile = $(``) $inputFile.hide() $body.append($inputFile) $inputFile.click() // 选中文件 $inputFile.on('change', () => { const files = ($inputFile[0] as HTMLInputElement).files uploadImages(editor, files) // 上传文件 }) } } export default UploadImage ================================================ FILE: packages/upload-image-module/src/module/menu/config.ts ================================================ /** * @description upload image config * @author wangfupeng */ import { IUploadConfig } from '@wangeditor/core' type InsertFn = (src: string, alt: string, href: string) => void // 在通用 uploadConfig 上,扩展 image 相关配置 export type IUploadConfigForImage = IUploadConfig & { allowedFileTypes?: string[] // 用户自定义插入图片 customInsert?: (res: any, insertFn: InsertFn) => void // 用户自定义上传图片 customUpload?: (files: File, insertFn: InsertFn) => void // base64 限制(单位 kb) - 小于 xxx 就插入 base64 格式 base64LimitSize: number // 自定义选择图片,如图床 customBrowseAndUpload?: (insertFn: InsertFn) => void } // 生成默认配置 export function genUploadImageConfig(): IUploadConfigForImage { return { server: '', // server API 地址,需用户配置 fieldName: 'wangeditor-uploaded-image', // formData 中,文件的 key maxFileSize: 2 * 1024 * 1024, // 2M maxNumberOfFiles: 100, // 最多上传 xx 张图片 allowedFileTypes: ['image/*'], meta: { // 自定义上传参数,例如传递验证的 token 等。参数会被添加到 formData 中,一起上传到服务端。 // 例如:token: 'xxxxx', x: 100 }, metaWithUrl: false, // headers: { // // 自定义 http headers // // 例如:Accept: 'text/x-json', a: 100, // }, withCredentials: false, timeout: 10 * 1000, // 10s onBeforeUpload: (files: any) => files, // 返回 false 则终止上传 onProgress: (progress: number) => { /* on progress */ }, onSuccess: (file: any, res: any) => { /* on success */ }, onFailed: (file: any, res: any) => { console.error(`'${file.name}' upload failed`, res) }, onError: (file: any, err: any, res: any) => { /* on error */ /* on timeout */ console.error(`'${file.name}' upload error`, res) }, // 自定义插入图片,用户配置 // customInsert: (res, insertFn) => {}, // 自定义上传图片,用户配置 // customUpload: (file, insertFn) => {}, // 小于 xxx 就插入 base64 base64LimitSize: 0, // 自定义选择,并上传图片,如:图床 (用户配置) // customBrowseAndUpload: insertFn => {}, } } ================================================ FILE: packages/upload-image-module/src/module/menu/index.ts ================================================ /** * @description upload image menu * @author wangfupeng */ import UploadImageMenu from './UploadImageMenu' import { genUploadImageConfig } from './config' export const uploadImageMenuConf = { key: 'uploadImage', factory() { return new UploadImageMenu() }, // 默认的菜单菜单配置,将存储在 editorConfig.MENU_CONF[key] 中 // 创建编辑器时,可通过 editorConfig.MENU_CONF[key] = {...} 来修改 config: genUploadImageConfig(), } ================================================ FILE: packages/upload-image-module/src/module/plugin.ts ================================================ /** * @description editor 插件,重写 editor API * @author wangfupeng */ import { IDomEditor } from '@wangeditor/core' import { isInsertImageMenuDisabled } from '@wangeditor/basic-modules' import uploadImages from './upload-images' function withUploadImage(editor: T): T { const { insertData } = editor const newEditor = editor // 重写 insertData - 粘贴图片、拖拽上传图片 newEditor.insertData = (data: DataTransfer) => { if (isInsertImageMenuDisabled(newEditor)) { insertData(data) return } // 如有 text ,则优先粘贴 text const text = data.getData('text/plain') if (text) { insertData(data) return } // 获取文件 const { files } = data if (files.length <= 0) { insertData(data) return } // 判断是否有图片文件(可能是其他类型的文件) const fileList = Array.prototype.slice.call(files) let _hasImageFiles = fileList.some(file => { const [mime] = file.type.split('/') return mime === 'image' }) if (_hasImageFiles) { // 有图片文件,则上传图片 uploadImages(editor, files) } else { // 如果没有, 则继续 insertData insertData(data) } } // 返回 editor ,重要! return newEditor } export default withUploadImage ================================================ FILE: packages/upload-image-module/src/module/upload-images.ts ================================================ /** * @description 上传文件 * @author wangfupeng */ import Uppy, { UppyFile } from '@uppy/core' import { IDomEditor, createUploader } from '@wangeditor/core' import { insertImageNode } from '@wangeditor/basic-modules' import { IUploadConfigForImage } from './menu/config' // 存储 editor uppy 的关系 - 缓存 uppy ,不重复创建 const EDITOR_TO_UPPY_MAP = new WeakMap() /** * 获取 uppy 实例(并通过 editor 缓存) * @param editor editor */ function getUppy(editor: IDomEditor): Uppy { // 从缓存中获取 let uppy = EDITOR_TO_UPPY_MAP.get(editor) if (uppy != null) return uppy const menuConfig = getMenuConfig(editor) const { onSuccess, onProgress, onFailed, customInsert, onError } = menuConfig // 上传完成之后 const successHandler = (file: UppyFile, res: any) => { // 预期 res 格式: // 成功:{ errno: 0, data: { url, alt, href } } —— 注意,旧版的 data 是数组,要兼容一下 // 失败:{ errno: !0, message: '失败信息' } if (customInsert) { // 用户自定义插入图片,此时 res 格式可能不符合预期 customInsert(res, (src, alt, href) => insertImageNode(editor, src, alt, href)) // success 回调 onSuccess(file, res) return } let { errno = 1, data = {} } = res if (errno !== 0) { // failed 回调 onFailed(file, res) return } if (Array.isArray(data)) { // 返回的数组(旧版的,兼容一下) data.forEach((item: { url: string; alt?: string; href?: string }) => { const { url = '', alt = '', href = '' } = item // 使用 basic-module 的 insertImageNode 方法插入图片,其中有用户配置的校验和 callback insertImageNode(editor, url, alt, href) }) } else { // 返回的对象 const { url = '', alt = '', href = '' } = data insertImageNode(editor, url, alt, href) } // success 回调 onSuccess(file, res) } // progress 显示进度条 const progressHandler = (progress: number) => { editor.showProgressBar(progress) // 回调函数 onProgress && onProgress(progress) } // onError 提示错误 const errorHandler = (file: any, err: any, res: any) => { // 回调函数 onError(file, err, res) } // 创建 uppy uppy = createUploader({ ...menuConfig, onProgress: progressHandler, onSuccess: successHandler, onError: errorHandler, }) // 缓存 uppy EDITOR_TO_UPPY_MAP.set(editor, uppy) return uppy } function getMenuConfig(editor: IDomEditor) { return editor.getMenuConfig('uploadImage') as IUploadConfigForImage } /** * 插入 base64 格式 * @param editor editor * @param file file */ async function insertBase64(editor: IDomEditor, file: File) { return new Promise(resolve => { const reader = new FileReader() reader.readAsDataURL(file) reader.onload = () => { const { result } = reader if (!result) return const src = result.toString() let href = src.indexOf('data:image') === 0 ? '' : src // base64 格式则不设置 href insertImageNode(editor, src, file.name, href) resolve('ok') } }) } /** * 上传图片文件 * @param editor editor * @param file file */ async function uploadFile(editor: IDomEditor, file: File) { const uppy = getUppy(editor) const { name, type, size } = file uppy.addFile({ name, type, size, data: file, }) await uppy.upload() } /** * 上传图片 * @param editor editor * @param files files */ export default async function (editor: IDomEditor, files: FileList | null) { if (files == null) return const fileList = Array.prototype.slice.call(files) // 获取菜单配置 const { customUpload, base64LimitSize } = getMenuConfig(editor) // 按顺序上传 for await (const file of fileList) { const size = file.size // size kb if (base64LimitSize && size <= base64LimitSize) { // 允许 base64 ,而且 size 在 base64 限制之内,则插入 base64 格式 await insertBase64(editor, file) } else { // 上传 if (customUpload) { // 自定义上传 await customUpload(file, (src, alt, href) => insertImageNode(editor, src, alt, href)) } else { // 默认上传 await uploadFile(editor, file) } } } } ================================================ FILE: packages/upload-image-module/src/utils/dom.ts ================================================ /** * @description DOM 操作 * @author wangfupeng */ import $, { append, on, remove, val, click, hide } from 'dom7' export { Dom7Array } from 'dom7' if (append) $.fn.append = append if (on) $.fn.on = on if (remove) $.fn.remove = remove if (val) $.fn.val = val if (click) $.fn.click = click if (hide) $.fn.hide = hide export default $ ================================================ FILE: packages/upload-image-module/tsconfig.json ================================================ { "compilerOptions": {}, "extends": "../../tsconfig.json", "include": [ "./src/**/*", "../custom-types.d.ts" ] } ================================================ FILE: packages/vars.less ================================================ // 注意:css vars 全部都定义在 packages/editor/src/assets/index.less @size: 14px; // textarea - less vars @textarea-color: var(--w-e-textarea-color); @textarea-bg-color: var(--w-e-textarea-bg-color); @textarea-selected-border-color: var(--w-e-textarea-selected-border-color); @textarea-slight-color: var(--w-e-textarea-slight-color); @textarea-slight-bg-color: var(--w-e-textarea-slight-bg-color); @textarea-border-color: var(--w-e-textarea-border-color); @textarea-slight-border-color: var( --w-e-textarea-slight-border-color); @textarea-handler-bg-color: var(--w-e-textarea-handler-bg-color); // toolbar - less vars @toolbar-color: var(--w-e-toolbar-color); @toolbar-bg-color: var(--w-e-toolbar-bg-color); @toolbar-active-color: var(--w-e-toolbar-active-color); @toolbar-active-bg-color: var(--w-e-toolbar-active-bg-color); @toolbar-disabled-color: var(--w-e-toolbar-disabled-color); @toolbar-border-color: var(--w-e-toolbar-border-color); @toolbar-height: 40px; // modal - less vars @modal-button-bg-color: var(--w-e-modal-button-bg-color); @modal-button-border-color: var(--w-e-modal-button-border-color); // less mixins .shadowBordered(@shadowRadius: 5px) { border: 1px solid @toolbar-border-color; border-radius: 3px; box-shadow: 0 2px @shadowRadius #0000001f; } ================================================ FILE: packages/video-module/CHANGELOG.md ================================================ # Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. ## [1.1.4](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/video-module@1.1.3...@wangeditor/video-module@1.1.4) (2022-09-27) **Note:** Version bump only for package @wangeditor/video-module ## [1.1.3](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/video-module@1.1.2...@wangeditor/video-module@1.1.3) (2022-09-15) ### Bug Fixes * customInsert 不触发 onSuccess ([d6f4a1b](https://github.com/wangeditor-team/wangEditor/commit/d6f4a1b1494864b116a1310cce2d9e8632c92c6f)) * 上传视频 - customBrowseAndUpload 缺少 poster ([c24627a](https://github.com/wangeditor-team/wangEditor/commit/c24627aaa4c173c5d435e3077dfe8f6b4a9a87b1)) ## [1.1.2](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/video-module@1.1.1...@wangeditor/video-module@1.1.2) (2022-08-30) ### Bug Fixes * checkVideo 增加 poster 参数 ([c0402e1](https://github.com/wangeditor-team/wangEditor/commit/c0402e155470233d256e037d863dab74c026b7f6)) ## [1.1.1](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/video-module@1.1.0...@wangeditor/video-module@1.1.1) (2022-07-14) ### Bug Fixes * video poster(不想升级大版本,所有暂用 fix 不用 feature) ([5a2aff9](https://github.com/wangeditor-team/wangEditor/commit/5a2aff92bc23f240bd249a7294874940cfc9f717)) # [1.1.0](https://github.com/wangeditor-team/wangEditor/compare/@wangeditor/video-module@1.0.1...@wangeditor/video-module@1.1.0) (2022-05-25) ### Features * editVideoSize ([375eecb](https://github.com/wangeditor-team/wangEditor/commit/375eecba826eac681268c55c47bcd922f7157d63)) * enter menu ([988fc31](https://github.com/wangeditor-team/wangEditor/commit/988fc31f31de3d37dffbf54abb784cceb8e6118d)) * setHtml ([f4f91b8](https://github.com/wangeditor-team/wangEditor/commit/f4f91b883298091e3679ca6b206ae0d796003772)) * 表格拖拽列宽 ([46ea2c0](https://github.com/wangeditor-team/wangEditor/commit/46ea2c0f831b03ebca5fddfd59d682fed0b3476e)) ## 1.0.1 (2022-04-18) ### Bug Fixes * 部分菜单 disabled ([87f1233](https://github.com/wangeditor-team/wangEditor/commit/87f12332a087072406c1988dc5cef2eae8335375)) * 插入图片的 < > 替换 ([5721560](https://github.com/wangeditor-team/wangEditor/commit/57215609ada8b9d15f5505d1ba52e49707b5b183)) * 更新各包之间依赖版本 ([75c552c](https://github.com/wangeditor-team/wangEditor/commit/75c552cc8ed54765bebb86a7ec5329a7fc79e85f)) * 修复 pnpm 安装 @wangeditor/editor 出现警告的问题 ([4087fbe](https://github.com/wangeditor-team/wangEditor/commit/4087fbee01c76bdd55e747a5e86c5e4a8d6a8353)) * 修复视频无法被xml-formatter解析的问题 ([e081518](https://github.com/wangeditor-team/wangEditor/commit/e08151863628e0241fe4a3d5858cda4c8ea57949)), closes [#101](https://github.com/wangeditor-team/wangEditor/issues/101) [#95](https://github.com/wangeditor-team/wangEditor/issues/95) [#70](https://github.com/wangeditor-team/wangEditor/issues/70) [#69](https://github.com/wangeditor-team/wangEditor/issues/69) * 移除了每个包下的 publishConfig directory 配置 ([16559f0](https://github.com/wangeditor-team/wangEditor/commit/16559f052545c111318be760e64291a521bdcc65)) * fix插入视频报错的问题 ([f78b06d](https://github.com/wangeditor-team/wangEditor/commit/f78b06d7f75c288f306f04fbfec1dfeb1332a861)) * fix视频插入iframe时报错的问题 ([ad8f9ce](https://github.com/wangeditor-team/wangEditor/commit/ad8f9cea0f7eae1cb0bc51dba64585be05dfda2f)) * menu active ([10829e2](https://github.com/wangeditor-team/wangEditor/commit/10829e2e9e1d864d4900821ee3d5fa516b8cca2a)) * parse html - v4 video ([8dca822](https://github.com/wangeditor-team/wangEditor/commit/8dca822f9f1b52fd71dd6e17f0954d6aa016324b)) * rename es module filename ([1821d4e](https://github.com/wangeditor-team/wangEditor/commit/1821d4eef49e64efcb41b848849ca7a5e6472044)) * shadow dom 中 modal 输入框异常 ([ef3b199](https://github.com/wangeditor-team/wangEditor/commit/ef3b199a3e74c6b8ba61ed781e1aa13a1c5acfde)) * table - elemToHtml ([e36e609](https://github.com/wangeditor-team/wangEditor/commit/e36e6092ef721723169afc8bf0560a47ac9f4dfc)) * video - 键盘删除 ([5a6bedd](https://github.com/wangeditor-team/wangEditor/commit/5a6bedd80fa0d758270731f62115637ad7f313d0)) ### Features * 增加 enable disable API(删除 setConfig setMenuConfig API) ([984fc50](https://github.com/wangeditor-team/wangEditor/commit/984fc50520061fc34ea08f4136bdeb93dee46564)) * i18n ([c11b244](https://github.com/wangeditor-team/wangEditor/commit/c11b2440f91b99d40bca18b675c66a22b6e160c9)) * parse html ([2a5eace](https://github.com/wangeditor-team/wangEditor/commit/2a5eace00f33cded50b68e8164748ec2480213fd)) * parse src (link image video) ([715a841](https://github.com/wangeditor-team/wangEditor/commit/715a841fc6c730ee2b448a1799a07ce778128aad)) * toHtml 机制 ([1c4d872](https://github.com/wangeditor-team/wangEditor/commit/1c4d8729f84aaab6a448f23064b34a20596305e9)) * upload video ([ac8e6f8](https://github.com/wangeditor-team/wangEditor/commit/ac8e6f8b5258e593714676a6f6be359ba525833c)) * video menu config ([7fa3783](https://github.com/wangeditor-team/wangEditor/commit/7fa3783c42aa83f7d53c8be34be3c8b7c8a64754)) ================================================ FILE: packages/video-module/README.md ================================================ # wangEditor video-module Video module built in [wangEditor](https://www.wangeditor.com/) by default. ================================================ FILE: packages/video-module/__tests__/elem-to-html.test.ts ================================================ /** * @description video menu test * @author luochao */ import videoModule from '../src/' const videoToHtmlConf = videoModule.elemsToHtml![0] describe('videoModule module', () => { describe('module elem-to-html', () => { test('videoToHtmlConf should return object that include "type" and "elemToHtml" property', () => { expect(videoToHtmlConf.type).toBe('video') expect(typeof videoToHtmlConf.elemToHtml).toBe('function') }) test('videoToHtmlConf elemToHtml fn should return html video string', () => { const element = { type: 'video', src: 'test.mp4', poster: 'xxx.png', children: [], } const res = videoToHtmlConf.elemToHtml(element, '') expect(res).toEqual( '
                \n\n
                ' ) }) test('videoToHtmlConf elemToHtml should return original string if src is a iframe html string', () => { const element = { type: 'video', src: '', poster: 'xxx.png', width: '500', height: '300', children: [], } const res = videoToHtmlConf.elemToHtml(element, '') expect(res).toEqual( '
                \n\n
                ' ) }) }) }) ================================================ FILE: packages/video-module/__tests__/helpler.test.ts ================================================ import createEditor from '../../../tests/utils/create-editor' import insertVideo from '../src/module/helper/insert-video' import uploadVideos from '../src/module/helper/upload-videos' import * as slate from 'slate' import nock from 'nock' const server = 'https://fake-endpoint.wangeditor-v5.com' let editor: ReturnType describe('Video module helper', () => { beforeEach(() => { editor = createEditor() }) describe('insert-video helper', () => { test('it should return if give empty src', async () => { expect(await insertVideo(editor, '', '')).toBeUndefined() }) test('it should alert result if checkVideo return result that data type is string', async () => { const editor = createEditor({ config: { MENU_CONF: { insertVideo: { checkVideo: (_src: string, _poster: string) => 'check result', }, }, }, }) const fn = jest.fn() editor.alert = fn await insertVideo(editor, 'test.mp4', 'xxx.png') expect(fn).toBeCalledWith('check result', 'error') }) test('it should return if checkVideo return null', async () => { const editor = createEditor({ config: { MENU_CONF: { insertVideo: { checkVideo: (_src: string, _poster: string) => null, }, }, }, }) expect(await insertVideo(editor, 'test.mp4', 'xxx.png')).toBeUndefined() }) test('it should invoke slate insertNodes method if give right src', done => { const fn = jest.fn() jest.spyOn(slate.Transforms, 'insertNodes').mockImplementation(fn) insertVideo(editor, 'test.mp4', 'xxx.png').then(() => { setTimeout(() => { expect(fn).toBeCalled() done() }) }) }) test('it should invoke onInsertedVideo callback if pass the option when create editor', done => { const fn = jest.fn() const editor = createEditor({ config: { MENU_CONF: { insertVideo: { onInsertedVideo: fn, }, }, }, }) insertVideo(editor, 'test.mp4', 'xxx.png').then(() => { expect(fn).toBeCalled() done() }) }) test('it should parse iframe if give iframe element', done => { const fn = jest.fn() jest.spyOn(slate.Transforms, 'insertNodes').mockImplementation(fn) insertVideo(editor, '').then(() => { setTimeout(() => { expect(fn).toBeCalled() done() }) }) }) }) describe('upload-video helper', () => { test('it should return if give null', async () => { expect(await uploadVideos(editor, null)).toBeUndefined() }) test('it should invoke customUpload if give the option when create editor', async () => { const fn = jest.fn() const editor = createEditor({ config: { MENU_CONF: { uploadVideo: { customUpload: fn, }, }, }, }) await uploadVideos(editor, [new File(['123'], 'test.png')] as unknown as FileList) expect(fn).toBeCalled() }) test('it should invoke onSuccess callback if give the option when create editor', async () => { const fn = jest.fn() nock(server) .defaultReplyHeaders({ 'access-control-allow-method': 'POST', 'access-control-allow-origin': '*', }) .options('/') .reply(200, {}) .post('/') .reply(200, { errno: 0 }) const editor = createEditor({ config: { MENU_CONF: { uploadVideo: { server, onSuccess: fn, }, }, }, }) await uploadVideos(editor, [new File(['test123'], 'foo.jpg')] as unknown as FileList) expect(fn).toBeCalled() }) test('it should invoke onProgress callback and show progress bar if uploading', async () => { const mockOnProgress = jest.fn() nock(server) .defaultReplyHeaders({ 'access-control-allow-method': 'POST', 'access-control-allow-origin': '*', }) .options('/') .reply(200, {}) .post('/') .reply(200, { errno: 0 }) const editor = createEditor({ config: { MENU_CONF: { uploadVideo: { server, onProgress: mockOnProgress, }, }, }, }) const mockShowProgressBar = jest.fn() editor.showProgressBar = mockShowProgressBar await uploadVideos(editor, [new File(['test123'], 'foo.jpg')] as unknown as FileList) expect(mockOnProgress).toBeCalled() expect(mockShowProgressBar).toBeCalled() }) test('it should invoke onError callback if upload failed', () => { const fn = jest.fn() nock(server) .defaultReplyHeaders({ 'access-control-allow-method': 'POST', 'access-control-allow-origin': '*', }) .options('/') .reply(200, {}) .post('/') .reply(400, {}) const editor = createEditor({ config: { MENU_CONF: { uploadVideo: { server, onError: fn, }, }, }, }) uploadVideos(editor, [new File(['test123'], 'foo.jpg')] as unknown as FileList).catch(() => { expect(fn).toBeCalled() }) }) test('it should invoke onFail callback if upload result with error', async () => { const fn = jest.fn() nock(server) .defaultReplyHeaders({ 'access-control-allow-method': 'POST', 'access-control-allow-origin': '*', }) .options('/') .reply(200, {}) .post('/') .reply(200, { error: 1 }) const editor = createEditor({ config: { MENU_CONF: { uploadVideo: { server, onFailed: fn, }, }, }, }) await uploadVideos(editor, [new File(['test123'], 'foo.jpg')] as unknown as FileList) expect(fn).toBeCalled() }) test('it should invoke customInsert callback if upload successfully', async () => { const fn = jest.fn() nock(server) .defaultReplyHeaders({ 'access-control-allow-method': 'POST', 'access-control-allow-origin': '*', }) .options('/') .reply(200, {}) .post('/') .reply(200, { error: 0 }) const editor = createEditor({ config: { MENU_CONF: { uploadVideo: { server, customInsert: fn, }, }, }, }) await uploadVideos(editor, [new File(['test123'], 'foo.jpg')] as unknown as FileList) expect(fn).toBeCalled() }) }) }) ================================================ FILE: packages/video-module/__tests__/menu/delete-video-menu.test.ts.bak ================================================ /** * @description video menu test * @author luochao */ import createEditor from '../../../../tests/utils/create-editor' import DeleteVideoMenu from '../../src/module/menu' import * as core from '@wangeditor/core' import * as slate from 'slate' function setEditorSelection( editor: core.IDomEditor, selection: slate.Selection = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 }, } ) { editor.selection = selection } describe('videoModule module', () => { describe('module DeleteVideoMenu', () => { const deleteVideoMenu = new DeleteVideoMenu() const editor = createEditor() test('DeleteVideoMenu invoke getValue function should be empty string', () => { expect(deleteVideoMenu.getValue(editor)).toBe('') }) test('DeleteVideoMenu invoke isActive function should be false', () => { expect(deleteVideoMenu.isActive(editor)).toBe(false) }) test('DeleteVideoMenu invoke isDisabled function if editor selected video element should be false', () => { jest .spyOn(core.DomEditor, 'getSelectedNodeByType') .mockReturnValue({ type: 'video', children: [{ text: '' }] } as any) setEditorSelection(editor) expect(deleteVideoMenu.isDisabled(editor)).toBe(false) }) test('DeleteVideoMenu invoke isDisabled function if editor do not selected video element should be true', () => { jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockReturnValue(null) setEditorSelection(editor) expect(deleteVideoMenu.isDisabled(editor)).toBe(true) }) test('DeleteVideoMenu invoke exec function if video menu is disabled should return directly', () => { jest.spyOn(core.DomEditor, 'getSelectedNodeByType').mockReturnValue(null) const fn = jest.spyOn(slate.Transforms, 'removeNodes') setEditorSelection(editor) deleteVideoMenu.exec(editor, '') expect(fn).not.toBeCalled() }) test('DeleteVideoMenu invoke exec function if video menu is disabled should execute transform removeNodes', () => { jest .spyOn(core.DomEditor, 'getSelectedNodeByType') .mockReturnValue({ type: 'video', children: [{ text: '' }] } as any) const fn = jest.spyOn(slate.Transforms, 'removeNodes') setEditorSelection(editor) deleteVideoMenu.exec(editor, '') expect(fn).toBeCalled() }) }) }) ================================================ FILE: packages/video-module/__tests__/menu/insert-video-menu.test.ts ================================================ /** * @description video menu test * @author luochao */ import createEditor from '../../../../tests/utils/create-editor' import InsertVideoMenu from '../../src/module/menu/InsertVideoMenu' import * as core from '@wangeditor/core' import * as slate from 'slate' function setEditorSelection( editor: core.IDomEditor, selection: slate.Selection = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 }, } ) { editor.selection = selection } describe('videoModule module', () => { describe('module InsertVideoMenu', () => { const insertVideoMenu = new InsertVideoMenu() const editor = createEditor() test('InsertVideoMenu invoke getValue function should be empty string', () => { expect(insertVideoMenu.getValue(editor)).toBe('') }) test('InsertVideoMenu invoke isActive function should be false', () => { expect(insertVideoMenu.isActive(editor)).toBe(false) }) test('InsertVideoMenu invoke isDisabled if editor selection is null that the function return true', () => { setEditorSelection(editor, null) expect(insertVideoMenu.isDisabled(editor)).toBe(true) }) test('InsertVideoMenu invoke isDisabled if editor selection is not collapsed that the function return true', () => { setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockReturnValue(false) expect(insertVideoMenu.isDisabled(editor)).toBe(true) }) test('InsertVideoMenu invoke isDisabled if editor selection is not null and collapsed that the function return false', () => { setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockReturnValue(true) expect(insertVideoMenu.isDisabled(editor)).toBe(false) }) test('InsertVideoMenu invoke getModalPositionNode should return null', () => { expect(insertVideoMenu.getModalPositionNode(editor)).toBeNull() }) test('InsertVideoMenu invoke getModalContentElem should return HTML element', () => { expect(insertVideoMenu.getModalContentElem(editor) instanceof HTMLElement).toBe(true) }) }) }) ================================================ FILE: packages/video-module/__tests__/menu/upload-video-menu.test.ts ================================================ /** * @description video menu test * @author luochao */ import createEditor from '../../../../tests/utils/create-editor' import UploadVideoMenu from '../../src/module/menu/UploadVideoMenu' import * as core from '@wangeditor/core' import * as slate from 'slate' import $ from '../../src/utils/dom' function setEditorSelection( editor: core.IDomEditor, selection: slate.Selection = { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 }, } ) { editor.selection = selection } describe('videoModule module', () => { describe('module UploadVideoMenu', () => { const uploadVideoMenu = new UploadVideoMenu() const editor = createEditor() test('UploadVideoMenu invoke getValue function should be empty string', () => { expect(uploadVideoMenu.getValue(editor)).toBe('') }) test('UploadVideoMenu invoke isActive function should be false', () => { expect(uploadVideoMenu.isActive(editor)).toBe(false) }) test('UploadVideoMenu invoke isDisabled if editor selection is null that the function return true', () => { setEditorSelection(editor, null) expect(uploadVideoMenu.isDisabled(editor)).toBe(true) }) test('UploadVideoMenu invoke isDisabled if editor selection is not collapsed that the function return true', () => { setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockReturnValue(false) expect(uploadVideoMenu.isDisabled(editor)).toBe(true) }) test('UploadVideoMenu invoke isDisabled if editor selection is not null and collapsed that the function return false', () => { setEditorSelection(editor) jest.spyOn(slate.Range, 'isCollapsed').mockReturnValue(true) expect(uploadVideoMenu.isDisabled(editor)).toBe(false) }) test('UploadVideoMenu invoke customBrowseAndUpload if editor give customBrowseAndUpload option', () => { const fn = jest.fn() const editor = createEditor({ config: { MENU_CONF: { uploadVideo: { customBrowseAndUpload: fn, }, }, }, }) uploadVideoMenu.exec(editor, '') expect(fn).toBeCalled() }) test('it should insert input element to body if invoke exec method', () => { const editor = createEditor() expect($('input').length).toBe(0) uploadVideoMenu.exec(editor, '') expect($('input').length).toBeGreaterThan(0) }) test('it should insert input element with accept attr if editor config allowedFileTypes', () => { const editor = createEditor({ config: { MENU_CONF: { uploadVideo: { allowedFileTypes: ['video/*'], }, }, }, }) uploadVideoMenu.exec(editor, '') expect($('input')[0].getAttribute('accept')).toBe('video/*') }) }) }) ================================================ FILE: packages/video-module/__tests__/parse-html.test.ts ================================================ /** * @description parse html test * @author wangfupeng */ import { $ } from 'dom7' import createEditor from '../../../tests/utils/create-editor' import videoModule from '../src' const { parseElemsHtml, preParseHtml } = videoModule const [parseHtmlConf] = parseElemsHtml! const [preParseHtmlConf] = preParseHtml! describe('video - pre parse html', () => { it('iframe', () => { const $iframe = $('') // match selector expect($iframe[0].matches(preParseHtmlConf.selector)).toBeTruthy() // pre parse const res = preParseHtmlConf.preParseHtml($iframe[0]) expect(res.outerHTML).toBe( '
                ' ) }) it('video', () => { const $video = $('') // match selector expect($video[0].matches(preParseHtmlConf.selector)).toBeTruthy() // pre parse const res = preParseHtmlConf.preParseHtml($video[0]) expect(res.outerHTML).toBe( '
                ' ) }) it('it should parse video element which is wrapped by p', () => { const $video = $('

                ') // match selector expect($video[0].matches(preParseHtmlConf.selector)).toBeTruthy() // pre parse const res = preParseHtmlConf.preParseHtml($video[0]) expect(res.outerHTML).toBe( '
                ' ) }) }) describe('video - parse html', () => { const editor = createEditor() it('iframe', () => { const iframeHtml = '' const $container = $(`
                ${iframeHtml}
                `) // match selector expect($container[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse expect(parseHtmlConf.parseElemHtml($container[0], [], editor)).toEqual({ type: 'video', src: iframeHtml, poster: '', width: '500', height: '300', children: [{ text: '' }], // void 元素有一个空 text }) }) it('video', () => { const src = 'xxx.mp4' const poster = 'xxx.png' const videoHtml = `` const $container = $(`
                ${videoHtml}
                `) // match selector expect($container[0].matches(parseHtmlConf.selector)).toBeTruthy() // parse expect(parseHtmlConf.parseElemHtml($container[0], [], editor)).toEqual({ type: 'video', src, poster, width: 'auto', height: 'auto', children: [{ text: '' }], // void 元素有一个空 text }) }) }) ================================================ FILE: packages/video-module/__tests__/plugin.test.ts ================================================ /** * @description video menu test * @author luochao */ import withVideo from '../src/module/plugin' import createEditor from '../../../tests/utils/create-editor' describe('videoModule module', () => { describe('module plugin', () => { test('withVideo should override editor "isVoid" and "normalizeNode" methods', () => { const editor = createEditor() const originalIsVoidFn = editor.isVoid const originalNormalizeNode = editor.normalizeNode const newEditor = withVideo(editor) expect(originalIsVoidFn).not.toEqual(newEditor.isVoid) expect(originalNormalizeNode).not.toEqual(newEditor.normalizeNode) }) test('使用 withVideo 插件后,Editor 会将 Video 元素视为 void 元素', () => { const editor = createEditor() const newEditor = withVideo(editor) const videoElem = { type: 'video', src: 'test.mp4', children: [], } expect(newEditor.isVoid(videoElem)).toBeTruthy() }) test('使用 withVideo 插件后,对于非 video 元素,直接调用 original isVoid 方法', () => { const editor = createEditor() const fn = jest.fn() editor.isVoid = fn const newEditor = withVideo(editor) const videoElem = { type: 'paragraph', children: [{ text: '' }], } newEditor.isVoid(videoElem) expect(fn).toBeCalled() }) test('使用 withVideo 插件后,Editor 调用 normalizeNode 方法确保 Video 元素后面有 paragraph、block、header 等元素', () => { const videoElem = { type: 'video', src: 'test.mp4', children: [], } const editor = createEditor({ content: [videoElem], }) const newEditor = withVideo(editor) newEditor.normalizeNode([videoElem, [0]]) expect(newEditor.children).toEqual([ { type: 'video', src: 'test.mp4', children: [{ text: '' }], }, { type: 'paragraph', children: [{ text: '' }] }, ]) }) }) }) ================================================ FILE: packages/video-module/__tests__/render-elem.test.ts ================================================ /** * @description video render elem test * @author luochao */ import createEditor from '../../../tests/utils/create-editor' import { renderVideoConf } from '../src/module/render-elem' describe('video module - render elem', () => { const editor = createEditor() it('render video elem', () => { expect(renderVideoConf.type).toBe('video') const elem = { type: 'video', src: 'test.mp4', poster: 'xxx.png', children: [] } const vnode = renderVideoConf.renderElem(elem, null, editor) expect(vnode.sel).toBe('div') }) it('render video with iframe', () => { expect(renderVideoConf.type).toBe('video') const elem = { type: 'video', src: '', children: [] } const vnode = renderVideoConf.renderElem(elem, null, editor) expect(vnode.sel).toBe('div') }) }) ================================================ FILE: packages/video-module/__tests__/util.test.ts ================================================ /** * @description video menu test * @author luochao */ import { genRandomStr } from '../src/utils/util' describe('videoModule util', () => { describe('utils util', () => { test('genRandomStr should generate a random string every time', () => { const str1 = genRandomStr() const str2 = genRandomStr() expect(str1).not.toBe(str2) }) test('genRandomStr should generate a random string that specify a prefix string', () => { const str = genRandomStr('wangeditor') expect(str.indexOf('wangeditor-')).toEqual(0) }) }) }) ================================================ FILE: packages/video-module/package.json ================================================ { "name": "@wangeditor/video-module", "version": "1.1.4", "description": "wangEditor video module", "author": "wangfupeng1988 ", "contributors": [], "homepage": "https://github.com/wangeditor-team/wangEditor#readme", "license": "MIT", "types": "dist/video-module/src/index.d.ts", "main": "dist/index.js", "module": "dist/index.esm.js", "browser": { "./dist/index.js": "./dist/index.js", "./dist/index.esm.js": "./dist/index.esm.js" }, "directories": { "lib": "dist", "test": "__tests__" }, "files": [ "dist" ], "publishConfig": { "access": "public", "registry": "https://registry.npmjs.com/" }, "repository": { "type": "git", "url": "git+https://github.com/wangeditor-team/wangEditor.git" }, "scripts": { "test": "jest", "test-c": "jest --coverage", "dev": "cross-env NODE_ENV=development rollup -c rollup.config.js", "dev-watch": "cross-env NODE_ENV=development rollup -c rollup.config.js -w", "build": "cross-env NODE_ENV=production rollup -c rollup.config.js", "dev-size-stats": "cross-env NODE_ENV=development:size_stats rollup -c rollup.config.js", "size-stats": "cross-env NODE_ENV=production:size_stats rollup -c rollup.config.js" }, "bugs": { "url": "https://github.com/wangeditor-team/wangEditor/issues" }, "peerDependencies": { "@uppy/core": "^2.1.4", "@uppy/xhr-upload": "^2.0.7", "@wangeditor/core": "1.x", "dom7": "^3.0.0", "nanoid": "^3.2.0", "slate": "^0.72.0", "snabbdom": "^3.1.0" } } ================================================ FILE: packages/video-module/rollup.config.js ================================================ import { createRollupConfig, IS_PRD } from '../../build/create-rollup-config' import pkg from './package.json' const name = 'WangEditorVideoModule' const configList = [] // esm const esmConf = createRollupConfig({ output: { file: pkg.module, format: 'esm', name, }, }) configList.push(esmConf) // umd const umdConf = createRollupConfig({ output: { file: pkg.main, format: 'umd', name, }, }) configList.push(umdConf) export default configList ================================================ FILE: packages/video-module/src/assets/index.less ================================================ @import "../../../vars.less"; .w-e-textarea-video-container { text-align: center; border: 1px dashed @textarea-border-color; padding: 10px 0; margin: 0 auto; margin-top: 10px; border-radius: 5px; background-position: 0px 0px, 10px 10px; background-size: 20px 20px; background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee 100%),linear-gradient(45deg, #eee 25%, white 25%, white 75%, #eee 75%, #eee 100%); } ================================================ FILE: packages/video-module/src/constants/svg.ts ================================================ /** * @description icon svg * @author wangfupeng */ /** * 【注意】svg 字符串的长度 ,否则会导致代码体积过大 * 尽量选择 https://www.iconfont.cn/collections/detail?spm=a313x.7781069.0.da5a778a4&cid=20293 * 找不到再从 iconfont.com 搜索 */ // 视频 export const VIDEO_SVG = '' // 上传视频 export const UPLOAD_VIDEO_SVG = '' // // 垃圾桶(删除) // export const TRASH_SVG = // '' ================================================ FILE: packages/video-module/src/index.ts ================================================ /** * @description video module * @author wangfupeng */ import './assets/index.less' // 配置多语言 import './locale/index' import wangEditorVideoModule from './module/index' export default wangEditorVideoModule ================================================ FILE: packages/video-module/src/locale/en.ts ================================================ /** * @description i18n en * @author wangfupeng */ export default { videoModule: { delete: 'Delete', uploadVideo: 'Upload video', insertVideo: 'Insert video', videoSrc: 'Video source', videoSrcPlaceHolder: 'Video file url, or third-party