├── .gitignore ├── LICENSE ├── README.md ├── build ├── dev.config.js └── prod.config.js ├── dev ├── article.js ├── index.html ├── index.js └── index.styl ├── package.json ├── postcss.config.js ├── src ├── components │ ├── article.ts │ ├── block.ts │ ├── character.ts │ ├── code-block.ts │ ├── collection.ts │ ├── component.ts │ ├── content-collection.ts │ ├── custom-collection.ts │ ├── heading.ts │ ├── index.ts │ ├── inline-image.ts │ ├── inline.ts │ ├── list.ts │ ├── media.ts │ ├── paragraph.ts │ ├── plain-text.ts │ ├── structure-collection.ts │ └── table.ts ├── const │ ├── component-type.ts │ ├── direction-type.ts │ └── structure-type.ts ├── decorate │ └── index.ts ├── editor │ ├── create-editor.ts │ ├── event.ts │ ├── index.ts │ └── manage │ │ ├── article-manage.ts │ │ ├── history-manage.ts │ │ └── store-manage.ts ├── factory │ └── index.ts ├── index.ts ├── operator │ ├── backspace.ts │ ├── delete-selection.ts │ ├── enter.ts │ ├── index.ts │ ├── input.ts │ └── paste.ts ├── record │ └── index.ts ├── selection │ ├── exchange.ts │ ├── focus-at.ts │ ├── get-selected-id-list.ts │ ├── get-selection.ts │ ├── insert-block.ts │ ├── insert-inline.ts │ ├── modify-decorate.ts │ ├── modify-indent.ts │ ├── modify-selection-decorate.ts │ ├── modify-table.ts │ └── util.ts ├── util │ ├── editor-style.ts │ ├── index.ts │ ├── text-util.ts │ └── update-component.ts └── view │ ├── base-view.ts │ ├── dom-view.ts │ └── html-view.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | /lib 14 | /example 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (C) 2020-present, aco yang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included 13 | in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN 19 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH 20 | THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZebraEditorCore 2 | 3 | ZebraEditorCore 是[斑码编辑器](https://zebrastudio.tech)剔除 `UI` 框架,纯粹的富文本编辑器,该项目将焦点关注于编辑器最为关键的部分! 4 | 5 | ## 使用 6 | 7 | ``` 8 | yarn add zebra-editor-core 9 | 10 | # or 11 | 12 | npm i zebra-editor-core 13 | ``` 14 | 15 | ``` 16 | import { mount } from "zebra-editor-core" 17 | 18 | mount('root'); 19 | ``` 20 | 21 | ## 为什么? 22 | 23 | 目前,市面上流行的富文本编辑器主要有三大类: 24 | 25 | 1. `Markdown` 编辑器:结构清晰,但功能有限,比如不能给文字加颜色,设置段落的样式等等。 26 | 27 | 2. 基于 `contenteditable` 的 `Html` 富文本编辑器,如 `CKEditor` 。功能强大,但不受控,生成的 `Html` 过于混乱,掌控不了文章内容,虽能获取 `Html`,但却控制不了 `Html` 的结构,不能直接生成非 `Html` 结构,局限性很大,只能做 `Html` 相关的操作,却掌控不了文章的内容。 28 | 29 | 3. 基于 `contenteditable` 的 `JS` 富文本编辑器,与第二类的区别主要在于:文章结构保存在 `JS` 中,`Html` 是文章结构的映射,所有的编辑行为实际操作的是 `JS` 内存中的模型,如 `DraftJs` ,但是目前这类的编辑器,功能简单,可操作性不够。 30 | 31 | 该项目为第三类的富文本编辑器,相较于其他第三类富文本编辑器,它功能丰富,理论上支持所有 `Css` 属性,支持 `Markdown` 中所有的类型,包括但不限于 标题、表格、列表、引用、图片等,同时表格、列表、支持多层级嵌套,内容由 `JS` 表示,很容易就能生成别的类型:如 `Markdown`。 32 | -------------------------------------------------------------------------------- /build/dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | 4 | module.exports = { 5 | target: "web", 6 | mode: "development", 7 | entry: "./dev/index.js", 8 | devtool: "source-map", 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.ts?$/, 13 | use: "ts-loader", 14 | exclude: /node_modules/, 15 | }, 16 | { 17 | test: /\.styl(us)?$/, 18 | use: ["style-loader", "css-loader", "stylus-loader"], 19 | }, 20 | ], 21 | }, 22 | devServer: { 23 | port: 9000, 24 | open: true, 25 | hot: true, 26 | clientLogLevel: "warn", 27 | }, 28 | plugins: [ 29 | new HtmlWebpackPlugin({ 30 | filename: "index.html", 31 | template: "dev/index.html", 32 | }), 33 | ], 34 | resolve: { 35 | extensions: [".ts", ".js"], 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /build/prod.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 3 | 4 | module.exports = { 5 | mode: "production", 6 | entry: "./src/index.ts", 7 | stats: "errors-only", 8 | output: { 9 | filename: "index.js", 10 | path: path.resolve(__dirname, "../dist"), 11 | library: "ZebraEditorCore", 12 | libraryTarget: "umd", 13 | }, 14 | plugins: [new CleanWebpackPlugin()], 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.ts$/, 19 | loader: "eslint-loader", 20 | enforce: "pre", 21 | include: [path.resolve(__dirname, "src")], 22 | options: { 23 | formatter: require("eslint-friendly-formatter"), 24 | }, 25 | }, 26 | { 27 | test: /\.tsx?$/, 28 | use: "ts-loader", 29 | exclude: /node_modules/, 30 | }, 31 | ], 32 | }, 33 | resolve: { 34 | extensions: [".tsx", ".ts", ".js"], 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /dev/article.js: -------------------------------------------------------------------------------- 1 | const createArticle = (factory) => { 2 | let article = factory.buildArticle(); 3 | 4 | let pledge = [ 5 | "Night gathers, and now my watch begins.", 6 | "It shall not end until my death.", 7 | "I shall take no wife, hold no lands, father no children.", 8 | "I shall wear no crowns and win no glory.", 9 | "I shall live and die at my post.", 10 | "I am the sword in the darkness.", 11 | "I am the watcher on the walls.", 12 | "I am the fire that burns against the cold, the light that brings the dawn, the horn that wakes the sleepers, the shield that guards the realms of men.", 13 | "I pledge my life and honor to the Night's Watch, for this night and all the nights to come.", 14 | ]; 15 | 16 | let pledgeCn = [ 17 | "长夜将至,我从今开始守望。", 18 | "至死方休!", 19 | "不娶妻、不封地、不生子。", 20 | "不戴宝冠,不争荣宠。", 21 | "尽忠职守,生死於斯。", 22 | "我是黑暗中的利剑。", 23 | "是长城中的守卫。", 24 | "是抵御寒冷的烈焰,破晓时分的光线,唤醒死者的号角,守护王国的铁卫。", 25 | "我将生命与荣耀献给守夜人,今夜如此,夜夜皆然。", 26 | ]; 27 | 28 | factory.buildHeading("h1", "A Song of Ice and Fire").addInto(article); 29 | factory.buildHeading("h2", "冰与火之歌").addInto(article); 30 | 31 | factory.buildHeading("h3", "图片").addInto(article); 32 | 33 | factory.buildMedia("image", "https://zebrastudio.tech/img/demo/img-1.jpg").addInto(article); 34 | 35 | factory.buildHeading("h3", "代码块").addInto(article); 36 | factory.buildCode(`document.body.innerHTML = greeter(user);`, "javascript").addInto(article); 37 | 38 | factory.buildHeading("h3", "列表").addInto(article); 39 | factory 40 | .buildList("ol", ["权力的游戏", "列王的纷争", "冰雨的风暴", "群鸦的盛宴", "魔龙的狂舞"]) 41 | .addInto(article); 42 | 43 | factory 44 | .buildList("ul", ["琼恩·雪诺", "丹妮莉丝·坦格利安", "艾莉亚·史塔克", "提利昂·兰尼斯特"]) 45 | .addInto(article); 46 | 47 | factory.buildHeading("h3", "段落").addInto(article); 48 | pledge.forEach((item) => { 49 | factory.buildParagraph(item).addInto(article); 50 | }); 51 | 52 | pledgeCn.forEach((item) => { 53 | factory.buildParagraph(item).addInto(article); 54 | }); 55 | 56 | factory.buildHeading("h3", "图文混排").addInto(article); 57 | 58 | let para = factory.buildParagraph(""); 59 | factory.buildInlineImage("https://zebrastudio.tech/img/demo/emoji-1.png").addInto(para); 60 | para.addText(" Valar Morghulis "); 61 | factory.buildInlineImage("https://zebrastudio.tech/img/demo/emoji-2.png").addInto(para); 62 | para.addText(" 凡人皆有一死 "); 63 | factory.buildInlineImage("https://zebrastudio.tech/img/demo/emoji-3.png").addInto(para); 64 | para.addInto(article); 65 | 66 | factory.buildHeading("h3", "表格").addInto(article); 67 | factory 68 | .buildTable( 69 | 3, 70 | 3, 71 | ["表头一", "表头二", "表头三"], 72 | [ 73 | ["1-1", "1-2", "1-3"], 74 | ["2-1", "2-2", "2-3"], 75 | ["3-1", "3-2", "3-3"], 76 | ], 77 | ) 78 | .addInto(article); 79 | 80 | factory.buildParagraph().addInto(article); 81 | 82 | return article; 83 | }; 84 | 85 | export default createArticle; 86 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Editor Demo 7 | 8 | 9 | 138 | 139 | 140 |
141 |
142 |
143 |
144 | 145 | 146 | -------------------------------------------------------------------------------- /dev/index.js: -------------------------------------------------------------------------------- 1 | import "./index.styl"; 2 | import createArticle from "./article"; 3 | import { 4 | ComponentFactory, 5 | DomView, 6 | HtmlView, 7 | Operator, 8 | exchange, 9 | modifySelectionDecorate, 10 | modifyDecorate, 11 | insertBlock, 12 | insertInline, 13 | modifyTable, 14 | modifyIndent, 15 | focusAt, 16 | updateComponent, 17 | } from "../src"; 18 | import Editor from "../src/editor"; 19 | 20 | const editor = new Editor("root", { 21 | componentFactory: ComponentFactory, 22 | operator: Operator, 23 | contentView: DomView, 24 | updateComponent, 25 | afterCreate() { 26 | editor.init(createArticle(editor.componentFactory)); 27 | }, 28 | }); 29 | 30 | window.editor = editor; 31 | 32 | new Vue({ 33 | el: "#operator", 34 | template: "#operator-template", 35 | data() { 36 | return { 37 | inlineStyle: "font-size", 38 | inlineStyleValue: "20px", 39 | blockStyle: "line-height", 40 | blockStyleValue: "2em", 41 | inlineImage: "https://zebrastudio.tech/img/demo/emoji-1.png", 42 | image: "https://zebrastudio.tech/img/demo/img-1.jpg", 43 | link: "https://zebrastudio.tech", 44 | tableRow: 5, 45 | tableCol: 4, 46 | tableHead: true, 47 | }; 48 | }, 49 | methods: { 50 | toHump(text) { 51 | return text.replace(/\_(\w)/g, (all, letter) => letter.toUpperCase()); 52 | }, 53 | 54 | undo() { 55 | editor.historyManage.undo(); 56 | }, 57 | redo() { 58 | editor.historyManage.redo(); 59 | }, 60 | 61 | showArticle() { 62 | editor.articleManage.flush(); 63 | }, 64 | logHtml() { 65 | console.log(editor.article.render(new HtmlView())); 66 | }, 67 | logRawData() { 68 | console.log(JSON.stringify(editor.article.getRaw())); 69 | }, 70 | newArticle() { 71 | editor.articleManage.newArticle(); 72 | }, 73 | 74 | modifyType(tag) { 75 | if (tag === "normal") { 76 | exchange(editor, editor.componentFactory.typeMap.PARAGRAPH); 77 | } else if (tag === "codeBlock") { 78 | exchange(editor, editor.componentFactory.typeMap.CODEBLOCK); 79 | } else if (tag === "ul" || tag === "ol" || tag === "nl") { 80 | exchange(editor, editor.componentFactory.typeMap.LIST, tag); 81 | } else { 82 | exchange(editor, editor.componentFactory.typeMap.HEADING, tag); 83 | } 84 | }, 85 | 86 | bold() { 87 | modifySelectionDecorate(editor, {}, { toggle: "bold" }); 88 | }, 89 | deleteText() { 90 | modifySelectionDecorate(editor, {}, { toggle: "deleteText" }); 91 | }, 92 | italic() { 93 | modifySelectionDecorate(editor, {}, { toggle: "italic" }); 94 | }, 95 | underline() { 96 | modifySelectionDecorate(editor, {}, { toggle: "underline" }); 97 | }, 98 | code() { 99 | modifySelectionDecorate(editor, {}, { toggle: "code" }); 100 | }, 101 | clearStyle() { 102 | modifySelectionDecorate(editor, { remove: "all" }, { remove: "all" }); 103 | }, 104 | customerInlineStyle() { 105 | if (this.inlineStyle && this.inlineStyleValue) { 106 | let key = this.toHump(this.inlineStyle); 107 | modifySelectionDecorate(editor, { [key]: this.inlineStyleValue }); 108 | } 109 | }, 110 | 111 | addLink() { 112 | if (this.link) { 113 | modifySelectionDecorate(editor, {}, { link: this.link }); 114 | } 115 | }, 116 | unLink() { 117 | modifySelectionDecorate(editor, {}, { remove: "link" }); 118 | }, 119 | 120 | modifyStyle(name, value) { 121 | modifyDecorate(editor, { [name]: value }); 122 | }, 123 | customerBlockStyle() { 124 | if (this.blockStyle && this.blockStyleValue) { 125 | let key = this.toHump(this.blockStyle); 126 | modifyDecorate(editor, { [key]: this.blockStyleValue }); 127 | } 128 | }, 129 | 130 | addTable() { 131 | let table = editor.componentFactory.buildTable(3, 3, []); 132 | insertBlock(editor, table); 133 | focusAt(editor, [table.getChild(0).getChild(0).getChild(0), 0, 0]); 134 | }, 135 | modifyTable() { 136 | modifyTable(editor, { 137 | row: Number(this.tableRow), 138 | col: Number(this.tableCol), 139 | head: this.tableHead, 140 | }); 141 | }, 142 | 143 | indent() { 144 | modifyIndent(editor); 145 | }, 146 | outdent() { 147 | modifyIndent(editor, true); 148 | }, 149 | 150 | insertInlineImage() { 151 | let index = Math.floor(Math.random() * 3 + 1); 152 | insertInline( 153 | editor, 154 | editor.componentFactory.buildInlineImage( 155 | `https://zebrastudio.tech/img/demo/emoji-${index}.png`, 156 | ), 157 | ); 158 | }, 159 | customerInlineImage() { 160 | insertInline(editor, editor.componentFactory.buildInlineImage(this.inlineImage)); 161 | }, 162 | 163 | insertImage() { 164 | let index = Math.floor(Math.random() * 3 + 1); 165 | insertBlock( 166 | editor, 167 | editor.componentFactory.buildMedia( 168 | "image", 169 | `https://zebrastudio.tech/img/demo/img-${index}.jpg`, 170 | ), 171 | ); 172 | }, 173 | customerImage() { 174 | insertBlock(editor, editor.componentFactory.buildMedia("image", this.image)); 175 | }, 176 | }, 177 | }); 178 | -------------------------------------------------------------------------------- /dev/index.styl: -------------------------------------------------------------------------------- 1 | * 2 | box-sizing border-box 3 | 4 | html, 5 | body 6 | margin 0 7 | height 100% 8 | 9 | body 10 | padding 10px 11 | 12 | .flex 13 | display flex 14 | height 100% 15 | 16 | .operator 17 | height 100% 18 | overflow auto 19 | min-width 350px 20 | flex 0 0 30% 21 | font-size 14px 22 | 23 | button, 24 | input, 25 | select 26 | margin 2px 27 | height 2em 28 | 29 | .key 30 | display inline-block 31 | width 60px 32 | text-align right 33 | 34 | > div 35 | border 1px solid #a5a5a5 36 | border-radius 4px 37 | margin 0 10px 10px 0 38 | &:last-child 39 | margin-bottom 0 40 | padding 10px 41 | > div 42 | display flex 43 | flex-wrap wrap 44 | align-items center 45 | p 46 | margin 10px 0 0 0 47 | 48 | .editor-wrap 49 | height 100% 50 | flex auto 51 | border 1px solid #a5a5a5 52 | border-radius 4px 53 | overflow hidden -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zebra-editor-core", 3 | "version": "0.1.3", 4 | "license": "MIT", 5 | "description": "ZebraEditorCore - modern rich text editor.", 6 | "keywords": [ 7 | "draft", 8 | "editor", 9 | "editor", 10 | "richtext", 11 | "typescript" 12 | ], 13 | "files": [ 14 | "lib", 15 | "dist" 16 | ], 17 | "author": "aco ", 18 | "main": "dist/index.js", 19 | "module": "lib/index.js", 20 | "types": "lib/index.d.ts", 21 | "dependencies": { 22 | "immutable": "^4.0.0-rc.12", 23 | "lodash": "^4.17.21", 24 | "uuid": "^8.3.2", 25 | "webpack-dev-server": "^3.11.2" 26 | }, 27 | "devDependencies": { 28 | "@types/lodash": "^4.14.169", 29 | "@types/shortid": "^0.0.29", 30 | "@types/uuid": "^8.3.0", 31 | "clean-webpack-plugin": "^4.0.0-alpha.0", 32 | "css-loader": "^5.2.4", 33 | "eslint-friendly-formatter": "^4.0.1", 34 | "html-webpack-plugin": "^5.3.1", 35 | "style-loader": "^2.0.0", 36 | "stylus": "^0.54.8", 37 | "stylus-loader": "^6.0.0", 38 | "ts-loader": "^9.1.2", 39 | "typescript": "~4.2.4", 40 | "webpack": "^5.37.0", 41 | "webpack-cli": "^4.7.0" 42 | }, 43 | "scripts": { 44 | "start": "webpack serve --config build/dev.config.js", 45 | "build": "npm run build:umd && npm run build:mjs", 46 | "build:example": "webpack --config build/dev.config.js", 47 | "build:umd": "webpack --config build/prod.config.js", 48 | "build:mjs": "tsc --sourceMap false" 49 | }, 50 | "browserslist": [ 51 | "last 1 chrome version", 52 | "last 1 firefox version", 53 | "last 1 safari version" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /src/components/article.ts: -------------------------------------------------------------------------------- 1 | import AbstractView from "../view/base-view"; 2 | import { OperatorType, JSONType } from "./component"; 3 | import Block from "./block"; 4 | import StructureCollection from "./structure-collection"; 5 | import ComponentType from "../const/component-type"; 6 | import StructureType from "../const/structure-type"; 7 | import ComponentFactory from "../factory"; 8 | 9 | class Article extends StructureCollection { 10 | type = ComponentType.article; 11 | structureType = StructureType.structure; 12 | 13 | static create(componentFactory: ComponentFactory, json: JSONType): Article { 14 | let children = (json.children || []).map((each) => { 15 | return componentFactory.typeMap[each.type].create(componentFactory, each); 16 | }); 17 | 18 | let article = componentFactory.buildArticle(); 19 | article.modifyDecorate(json.style, json.data); 20 | if (json.id) { 21 | article.id = json.id; 22 | } 23 | article.add(0, ...children); 24 | return article; 25 | } 26 | 27 | isEmpty() { 28 | return this.getSize() === 1 && this.getChild(0)?.getSize() === 0; 29 | } 30 | 31 | childHeadDelete(block: Block): OperatorType { 32 | let prev = this.getPrev(block); 33 | if (prev) { 34 | return block.sendTo(prev); 35 | } 36 | 37 | // 若在 Article 的第一个 38 | if (block.type !== ComponentType.paragraph) { 39 | let exchanged = block.exchangeTo(this.getComponentFactory().typeMap.PARAGRAPH, []); 40 | return [{ id: exchanged[0].id, offset: 0 }]; 41 | } 42 | 43 | // 首位删除,若修饰器有内容,则清空 44 | if (!block.decorate.isEmpty()) { 45 | block.modifyDecorate({ remove: "all" }, { remove: "all" }); 46 | } 47 | 48 | return [{ id: block.id, offset: 0 }]; 49 | } 50 | 51 | remove(start: number, end?: number): OperatorType { 52 | let operator = super.remove(start, end); 53 | // 确保文章至少有一个空白行 54 | if (this.getSize() === 0) { 55 | return this.add(0, this.getComponentFactory().buildParagraph()); 56 | } 57 | return operator; 58 | } 59 | 60 | getJSON(): JSONType { 61 | let raw = super.getJSON(); 62 | raw.id = this.id; 63 | return raw; 64 | } 65 | 66 | render(contentView: AbstractView) { 67 | return contentView.buildArticle( 68 | this.id, 69 | () => this.children.toArray().map((each) => each.render(contentView)), 70 | this.decorate.getStyle(), 71 | this.decorate.getData(), 72 | ); 73 | } 74 | } 75 | 76 | export default Article; 77 | -------------------------------------------------------------------------------- /src/components/block.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | import Editor from "../editor"; 3 | import Component, { OperatorType, JSONType, Snapshoot } from "./component"; 4 | import ComponentFactory from "../factory"; 5 | import { AnyObject } from "../decorate/index"; 6 | import StructureCollection from "./structure-collection"; 7 | import { createError } from "../util"; 8 | 9 | export type BlockType = typeof Block; 10 | export interface BlockSnapshoot extends Snapshoot { 11 | active: boolean; 12 | } 13 | 14 | abstract class Block extends Component { 15 | active: boolean = false; 16 | parent?: StructureCollection; 17 | editor?: Editor; 18 | 19 | static create(componentFactory: ComponentFactory, json: JSONType): Component { 20 | throw createError("component need implement static create method", this); 21 | } 22 | 23 | // 将别的组件转换为当前组件类型 24 | static exchange(componentFactory: ComponentFactory, component: Component, args?: any[]): Block[] { 25 | throw createError("component need implement static exchange method", this); 26 | } 27 | 28 | constructor(editor?: Editor) { 29 | super(); 30 | this.editor = editor; 31 | 32 | this.init(); 33 | } 34 | 35 | init(): void {} 36 | 37 | exchangeTo(builder: BlockType, args: any[] = []): Block[] { 38 | if (builder === this.constructor) { 39 | return [this]; 40 | } 41 | 42 | return builder.exchange(this.getComponentFactory(), this, args); 43 | } 44 | 45 | addInto(collection: StructureCollection, index: number = -1): OperatorType { 46 | return collection.add(index, this); 47 | } 48 | 49 | removeSelf(): OperatorType { 50 | let parent = this.getParent(); 51 | let index = parent.findChildrenIndex(this); 52 | return parent.remove(index, index + 1); 53 | } 54 | 55 | replaceSelf(...blockList: Block[]): OperatorType { 56 | let parent = this.getParent(); 57 | parent.replaceChild(blockList, this); 58 | return [{ id: blockList[0].id, offset: 0 }]; 59 | } 60 | 61 | modifyDecorate(style?: AnyObject, data?: AnyObject) { 62 | this.componentWillChange(); 63 | super.modifyDecorate(style, data); 64 | this.updateComponent([this]); 65 | return; 66 | } 67 | 68 | modifyContentDecorate(start: number, end: number, style?: AnyObject, data?: AnyObject) { 69 | return; 70 | } 71 | 72 | addEmptyParagraph(bottom: boolean): OperatorType { 73 | let parent = this.getParent(); 74 | let index = parent.findChildrenIndex(this); 75 | let paragraph = this.getComponentFactory().buildParagraph(); 76 | parent.add(index + (bottom ? 1 : 0), paragraph); 77 | return [{ id: paragraph.id, offset: 0 }]; 78 | } 79 | 80 | add(index: number, ...children: (string | Component)[]): OperatorType { 81 | return; 82 | } 83 | 84 | remove(start: number, end?: number): OperatorType { 85 | return; 86 | } 87 | 88 | split(index: number, ...componentList: Component[]): OperatorType { 89 | return; 90 | } 91 | 92 | sendTo(component: Component): OperatorType { 93 | return; 94 | } 95 | 96 | receive(component: Component): OperatorType { 97 | return; 98 | } 99 | 100 | destory() { 101 | this.active = false; 102 | this.parent = undefined; 103 | super.destory(); 104 | } 105 | 106 | setEditor(editor?: Editor) { 107 | this.editor = editor; 108 | } 109 | 110 | isEmpty(): boolean { 111 | return true; 112 | } 113 | 114 | createEmpty(): Block { 115 | throw createError("component need implement createEmpty method.", this); 116 | } 117 | 118 | clone(): Block { 119 | let newBlock = { ...this }; 120 | newBlock.style = { 121 | ...this.style, 122 | }; 123 | newBlock.data = { 124 | ...this.data, 125 | }; 126 | 127 | Object.setPrototypeOf(newBlock, Object.getPrototypeOf(this)); 128 | newBlock.id = uuidv4(); 129 | return newBlock; 130 | } 131 | 132 | getParent() { 133 | let parent = this.parent; 134 | if (!parent) throw createError("the node has expired.", this); 135 | return parent; 136 | } 137 | 138 | getSize(): number { 139 | return 0; 140 | } 141 | 142 | componentWillChange() { 143 | this.editor?.$emit("componentWillChange"); 144 | } 145 | 146 | updateComponent(componentList: Component[]) { 147 | this.editor?.$emit("updateComponent", componentList); 148 | } 149 | 150 | getComponentFactory() { 151 | return this.editor ? this.editor.componentFactory : ComponentFactory.getInstance(); 152 | } 153 | } 154 | 155 | export default Block; 156 | -------------------------------------------------------------------------------- /src/components/character.ts: -------------------------------------------------------------------------------- 1 | import Inline from "./inline"; 2 | import ComponentType from "../const/component-type"; 3 | 4 | class Character extends Inline { 5 | type = ComponentType.character; 6 | content: string; 7 | 8 | constructor(content: string) { 9 | super(); 10 | this.content = content; 11 | } 12 | 13 | getJSON() { 14 | return { 15 | type: this.type, 16 | content: this.content, 17 | }; 18 | } 19 | 20 | render() { 21 | return this.content; 22 | } 23 | } 24 | 25 | export default Character; 26 | -------------------------------------------------------------------------------- /src/components/code-block.ts: -------------------------------------------------------------------------------- 1 | import AbstractView from "../view/base-view"; 2 | import { JSONType } from "./component"; 3 | import PlainText from "./plain-text"; 4 | import ContentCollection from "./content-collection"; 5 | import ComponentType from "../const/component-type"; 6 | import Block from "./block"; 7 | import ComponentFactory from "../factory"; 8 | import { Editor } from ".."; 9 | 10 | class CodeBlock extends PlainText { 11 | type = ComponentType.codeBlock; 12 | language: string; 13 | 14 | static create(componentFactory: ComponentFactory, json: JSONType): CodeBlock { 15 | const code = componentFactory.buildCode(json.content, json.language); 16 | code.modifyDecorate(json.style, json.data); 17 | return code; 18 | } 19 | 20 | static exchange(componentFactory: ComponentFactory, block: Block, args: any[] = []): CodeBlock[] { 21 | let parent = block.getParent(); 22 | let prev = parent.getPrev(block); 23 | 24 | if (prev instanceof CodeBlock) { 25 | prev.receive(block); 26 | return [prev]; 27 | } else { 28 | let code = componentFactory.buildCode(); 29 | if (block instanceof ContentCollection) { 30 | code.add(0, block.children.map((each) => each.content).join("")); 31 | } 32 | block.replaceSelf(code); 33 | return [code]; 34 | } 35 | } 36 | 37 | constructor(content: string = "", language: string = "", editor?: Editor) { 38 | super(content, editor); 39 | this.language = language; 40 | } 41 | 42 | setLanguage(language: string) { 43 | this.componentWillChange(); 44 | this.language = language; 45 | this.updateComponent([this]); 46 | } 47 | 48 | createEmpty() { 49 | const code = this.getComponentFactory().buildCode("\n", this.language); 50 | code.modifyDecorate(this.decorate.copyStyle(), this.decorate.copyData()); 51 | return code; 52 | } 53 | 54 | getJSON(): JSONType { 55 | let json = super.getJSON(); 56 | json.language = this.language; 57 | return json; 58 | } 59 | 60 | render(contentView: AbstractView) { 61 | return contentView.buildCodeBlock( 62 | this.id, 63 | this.content.join(""), 64 | this.language, 65 | this.decorate.getStyle(), 66 | this.decorate.getData(), 67 | ); 68 | } 69 | } 70 | 71 | export default CodeBlock; 72 | -------------------------------------------------------------------------------- /src/components/collection.ts: -------------------------------------------------------------------------------- 1 | import { List } from "immutable"; 2 | import Component from "./component"; 3 | import Block, { BlockSnapshoot } from "./block"; 4 | import { createError } from "../util"; 5 | 6 | export interface CollectionSnapshoot extends BlockSnapshoot { 7 | children: List; 8 | } 9 | 10 | abstract class Collection extends Block { 11 | children: List = List(); 12 | 13 | addChildren(index: number, componentList: T[]): T[] { 14 | componentList.forEach((each) => { 15 | each.parent = this; 16 | }); 17 | this.children = this.children.splice(index, 0, ...componentList); 18 | return componentList; 19 | } 20 | 21 | removeChildren(start: number, end: number = -1): T[] { 22 | if (start < 0) { 23 | throw createError(`error position start: ${start}`, this); 24 | } 25 | 26 | if (end < 0) { 27 | end = this.getSize() + 1 + end; 28 | } 29 | 30 | if (start > end) { 31 | throw createError(`error position start: ${start} end: ${end}.`, this); 32 | } 33 | 34 | let removedComponent = this.children.toArray().slice(start, end); 35 | 36 | removedComponent.forEach((each) => { 37 | each.parent = undefined; 38 | }); 39 | 40 | this.children = this.children.splice(start, end - start); 41 | return removedComponent; 42 | } 43 | 44 | snapshoot(): CollectionSnapshoot { 45 | let snap = super.snapshoot() as CollectionSnapshoot; 46 | snap.children = this.children; 47 | return snap; 48 | } 49 | 50 | restore(state: CollectionSnapshoot) { 51 | this.children = state.children; 52 | super.restore(state); 53 | } 54 | 55 | isEmpty(): boolean { 56 | return this.children.size === 0; 57 | } 58 | 59 | getChild(index: number): T { 60 | let child = this.children.get(index); 61 | if (!child) throw createError(`error child position index: ${index}`, this); 62 | return child; 63 | } 64 | 65 | getSize(): number { 66 | return this.children.size; 67 | } 68 | } 69 | 70 | export default Collection; 71 | -------------------------------------------------------------------------------- /src/components/component.ts: -------------------------------------------------------------------------------- 1 | import { Map } from "immutable"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | import Decorate, { AnyObject } from "../decorate"; 4 | import Record from "../record"; 5 | import Collection from "./collection"; 6 | import AbstractView from "../view/base-view"; 7 | import ComponentType from "../const/component-type"; 8 | import StructureType from "../const/structure-type"; 9 | import { Cursor } from "../selection/util"; 10 | import { ListType } from "./list"; 11 | import { HeadingEnum } from "./heading"; 12 | import { TableCellEnum } from "./table"; 13 | 14 | export type OperatorType = [Cursor?, Cursor?] | undefined; 15 | 16 | export interface JSONType { 17 | id?: string; 18 | type: ComponentType | string; 19 | children?: JSONType[]; 20 | style?: AnyObject; 21 | data?: AnyObject; 22 | // for CharacterList 23 | content?: string; 24 | // for Media or InlineImage 25 | src?: string; 26 | // for Media 27 | mediaType?: string; 28 | // fro Heading 29 | headingType?: HeadingEnum; 30 | // for List 31 | listType?: ListType; 32 | // for TableRow 33 | cellType?: TableCellEnum; 34 | size?: number; 35 | // for CodeBlock 36 | language?: string; 37 | // for CustomCollection 38 | tag?: string; 39 | } 40 | 41 | export interface Snapshoot { 42 | style: Map; 43 | data: Map; 44 | } 45 | 46 | abstract class Component { 47 | id: string = uuidv4(); 48 | parent?: Collection; 49 | decorate: Decorate; 50 | record: Record; 51 | abstract type: ComponentType | string; 52 | abstract structureType: StructureType; 53 | data: AnyObject = {}; 54 | style: AnyObject = {}; 55 | 56 | constructor() { 57 | this.decorate = new Decorate(this); 58 | this.record = new Record(this); 59 | } 60 | 61 | modifyDecorate(style?: AnyObject, data?: AnyObject) { 62 | this.decorate.mergeStyle(style); 63 | this.decorate.mergeData(data); 64 | } 65 | 66 | snapshoot(): Snapshoot { 67 | return { 68 | style: this.decorate.style, 69 | data: this.decorate.data, 70 | }; 71 | } 72 | 73 | restore(state: Snapshoot) { 74 | this.decorate.style = state.style; 75 | this.decorate.data = state.data; 76 | } 77 | 78 | getType(): string { 79 | return this.type; 80 | } 81 | 82 | getJSON(): JSONType { 83 | let json: JSONType = { 84 | type: this.type, 85 | }; 86 | if (!this.decorate.styleIsEmpty()) { 87 | json.style = this.decorate.copyStyle(); 88 | } 89 | if (!this.decorate.dataIsEmpty()) { 90 | json.data = this.decorate.copyData(); 91 | } 92 | return json; 93 | } 94 | 95 | destory() {} 96 | 97 | abstract render(contentView: AbstractView): any; 98 | } 99 | 100 | export default Component; 101 | -------------------------------------------------------------------------------- /src/components/content-collection.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import Decorate, { AnyObject } from "../decorate"; 3 | import { OperatorType, JSONType } from "./component"; 4 | import Block from "./block"; 5 | import Collection from "./collection"; 6 | import Inline from "./inline"; 7 | import Character from "./character"; 8 | import ComponentType from "../const/component-type"; 9 | import AbstractView from "../view/base-view"; 10 | import StructureType from "../const/structure-type"; 11 | import { createError } from "../util"; 12 | import ComponentFactory from "../factory"; 13 | 14 | abstract class ContentCollection extends Collection { 15 | structureType = StructureType.content; 16 | 17 | static createChildren(componentFactory: ComponentFactory, json: JSONType): Inline[] { 18 | if (!json.children) { 19 | return []; 20 | } 21 | 22 | let children: Inline[] = []; 23 | json.children.forEach((each: JSONType) => { 24 | if (componentFactory.typeMap[each.type]) { 25 | children.push(componentFactory.typeMap[each.type].create(each)); 26 | return; 27 | } 28 | 29 | if (!each.content) return; 30 | for (let char of each.content) { 31 | const chart = new Character(char); 32 | chart.modifyDecorate(each.style, each.data); 33 | children.push(); 34 | } 35 | }); 36 | 37 | return children; 38 | } 39 | 40 | constructor(text: string = "", editor?: Editor) { 41 | super(editor); 42 | if (text) { 43 | this.addText(text, 0); 44 | } 45 | } 46 | 47 | modifyContentDecorate( 48 | start: number = 0, 49 | end: number = -1, 50 | style?: AnyObject, 51 | data?: AnyObject, 52 | ): OperatorType { 53 | end = end < 0 ? this.getSize() + end : end; 54 | 55 | if (start > end || (!style && !data)) { 56 | return [{ id: this.id, offset: start }]; 57 | } 58 | 59 | this.componentWillChange(); 60 | for (let i = start; i <= end; i++) { 61 | this.getChild(i)?.modifyDecorate(style, data); 62 | } 63 | this.updateComponent([this]); 64 | 65 | return [ 66 | { id: this.id, offset: start }, 67 | { id: this.id, offset: end }, 68 | ]; 69 | } 70 | 71 | add(index: number, ...inline: Inline[] | string[]): OperatorType { 72 | index = index < 0 ? this.getSize() + 1 + index : index; 73 | let needAddInline: Inline[] = []; 74 | 75 | inline.forEach((each: string | Inline) => { 76 | if (typeof each === "string") { 77 | let decorate = this.children.get(index === 0 ? 0 : index - 1)?.decorate; 78 | for (let char of each) { 79 | const chart = new Character(char); 80 | chart.modifyDecorate(decorate?.copyStyle(), decorate?.copyData()); 81 | needAddInline.push(chart); 82 | } 83 | } else { 84 | needAddInline.push(each); 85 | } 86 | }); 87 | 88 | this.componentWillChange(); 89 | this.addChildren(index, needAddInline); 90 | this.updateComponent([this]); 91 | return [{ id: this.id, offset: index + inline.length }]; 92 | } 93 | 94 | remove(start: number, end: number = start + 1): OperatorType { 95 | let parent = this.getParent(); 96 | 97 | // first of pharagraph 98 | if (start === -1 && end === 0) { 99 | return parent.childHeadDelete(this); 100 | } 101 | 102 | if (start < 0) { 103 | throw createError(`error position start: ${start} end: ${end}.`, this); 104 | } 105 | 106 | this.componentWillChange(); 107 | this.removeChildren(start, end); 108 | this.updateComponent([this]); 109 | return [{ id: this.id, offset: start }]; 110 | } 111 | 112 | splitChild(index: number): ContentCollection { 113 | let isTail = index === this.getSize(); 114 | 115 | if (isTail) { 116 | return this.getComponentFactory().buildParagraph(); 117 | } 118 | 119 | let tail = this.children.toArray().slice(index); 120 | this.removeChildren(index); 121 | let newCollection = this.createEmpty() as ContentCollection; 122 | newCollection.add(0, ...tail); 123 | return newCollection; 124 | } 125 | 126 | split(index: number, ...blockList: Block[]): OperatorType { 127 | let parent = this.getParent(); 128 | let blockIndex = parent.findChildrenIndex(this); 129 | 130 | this.componentWillChange(); 131 | let splitBlock = this.splitChild(index); 132 | let needAddBlockList: Block[] = []; 133 | 134 | if (blockList.length) { 135 | needAddBlockList.push(...blockList); 136 | if (this.getSize() === 0) { 137 | this.removeSelf(); 138 | blockIndex -= 1; 139 | } 140 | } 141 | 142 | if (blockList.length === 0 || splitBlock.getSize() !== 0) { 143 | needAddBlockList.push(splitBlock); 144 | } 145 | 146 | parent.add(blockIndex + 1, ...needAddBlockList); 147 | this.updateComponent([this]); 148 | return [{ id: needAddBlockList[0].id, offset: 0 }]; 149 | } 150 | 151 | addText(text: string, index?: number): OperatorType { 152 | index = index ? index : this.getSize(); 153 | let charList: Character[] = []; 154 | 155 | for (let char of text) { 156 | charList.push(new Character(char)); 157 | } 158 | 159 | this.addChildren(index, charList); 160 | return [{ id: this.id, offset: index + charList.length }]; 161 | } 162 | 163 | addEmptyParagraph(bottom: boolean): OperatorType { 164 | let parent = this.getParent(); 165 | 166 | if (parent.type === ComponentType.article) { 167 | return super.addEmptyParagraph(bottom); 168 | } 169 | 170 | return parent.addEmptyParagraph(bottom); 171 | } 172 | 173 | sendTo(block: Block): OperatorType { 174 | return block.receive(this); 175 | } 176 | 177 | receive(block: Block): OperatorType { 178 | let size = this.getSize(); 179 | 180 | if (!(block instanceof ContentCollection)) { 181 | return; 182 | } 183 | 184 | this.componentWillChange(); 185 | block.removeSelf(); 186 | 187 | this.children = this.children.push(...block.children); 188 | this.updateComponent([this]); 189 | 190 | return [{ id: this.id, offset: size }]; 191 | } 192 | 193 | formatChildren() { 194 | let formated: { 195 | inlines: Inline[]; 196 | type: string; 197 | decorate: Decorate; 198 | }[] = []; 199 | 200 | this.children.forEach((each) => { 201 | let lastFormated = formated[formated.length - 1]; 202 | 203 | if ( 204 | lastFormated === undefined || 205 | lastFormated.type !== each.type || 206 | !lastFormated.decorate.isSame(each.decorate) 207 | ) { 208 | formated.push({ 209 | type: each.type, 210 | inlines: [each], 211 | decorate: each.decorate, 212 | }); 213 | return; 214 | } 215 | 216 | lastFormated.inlines.push(each); 217 | }); 218 | 219 | return formated; 220 | } 221 | 222 | getJSON(): JSONType { 223 | let raw: JSONType = { 224 | type: this.type, 225 | children: [], 226 | }; 227 | 228 | this.formatChildren().forEach((each) => { 229 | if (each.type === ComponentType.character) { 230 | let charRaw = each.inlines[0].getJSON(); 231 | charRaw.content = each.inlines.map((each) => each.content).join(""); 232 | raw.children!.push(charRaw); 233 | } else { 234 | raw.children!.push(...each.inlines.map((each) => each.getJSON())); 235 | } 236 | }); 237 | 238 | return raw; 239 | } 240 | 241 | getChildren(contentView: AbstractView) { 242 | let childrenRenderList: any[] = []; 243 | 244 | this.formatChildren().forEach((each, index) => { 245 | if (each.type === ComponentType.character) { 246 | childrenRenderList.push( 247 | contentView.buildCharacterList( 248 | `${this.id}__${index}`, 249 | each.inlines.map((each) => each.render(contentView)).join(""), 250 | each.decorate.getStyle(), 251 | each.decorate.getData(), 252 | ), 253 | ); 254 | } else { 255 | childrenRenderList.push(...each.inlines.map((each) => each.render(contentView))); 256 | } 257 | }); 258 | 259 | return childrenRenderList; 260 | } 261 | } 262 | 263 | export default ContentCollection; 264 | -------------------------------------------------------------------------------- /src/components/custom-collection.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import { OperatorType, JSONType } from "./component"; 3 | import { CollectionSnapshoot } from "./collection"; 4 | import StructureCollection from "./structure-collection"; 5 | import Block from "./block"; 6 | import AbstractView from "../view/base-view"; 7 | import ComponentType from "../const/component-type"; 8 | import { AnyObject } from "../decorate"; 9 | import ComponentFactory from "../factory"; 10 | 11 | export interface CustomCollectionSnapshoot extends CollectionSnapshoot { 12 | tag: string; 13 | } 14 | 15 | class CustomCollection extends StructureCollection { 16 | type: string = ComponentType.customerCollection; 17 | tag: string; 18 | 19 | static create(componentFactory: ComponentFactory, raw: JSONType): CustomCollection { 20 | let children = (raw.children || []).map((each) => { 21 | return componentFactory.typeMap[each.type].create(each); 22 | }); 23 | 24 | let collection = componentFactory.buildCustomCollection(raw.tag); 25 | collection.add(0, ...children); 26 | return collection; 27 | } 28 | 29 | static exchange(componentFactory: ComponentFactory, block: Block, args: any[] = []): Block[] { 30 | let parent = block.getParent(); 31 | let prev = parent.getPrev(block); 32 | let index = parent.findChildrenIndex(block); 33 | block.removeSelf(); 34 | 35 | // 当前一块内容为 CustomCollection,并且 tag 一致,直接添加 36 | if (prev instanceof CustomCollection && prev.tag === args[0]) { 37 | prev.add(-1, block); 38 | } else { 39 | // 否则新生成一个 CustomCollection 40 | let newList = componentFactory.buildCustomCollection(args[0]); 41 | newList.add(0, block); 42 | parent.add(index, newList); 43 | } 44 | return [block]; 45 | } 46 | 47 | constructor(tag: string = "div", children: (string | Block)[] = [], editor?: Editor) { 48 | super(editor); 49 | this.tag = tag; 50 | this.add( 51 | 0, 52 | ...children.map((each) => { 53 | if (typeof each === "string") { 54 | return this.getComponentFactory().buildParagraph(each); 55 | } else { 56 | return each; 57 | } 58 | }), 59 | ); 60 | } 61 | 62 | add(index: number, ...blockList: Block[]): OperatorType { 63 | index = index < 0 ? this.getSize() + 1 + index : index; 64 | 65 | // 连续输入空行,截断列表 66 | if ( 67 | index > 1 && 68 | blockList.length === 1 && 69 | blockList[0].isEmpty() && 70 | this.getChild(index - 1).isEmpty() 71 | ) { 72 | let operator = this.split(index, ...blockList); 73 | this.getChild(index - 1).removeSelf(); 74 | return operator; 75 | } 76 | 77 | return super.add(index, ...blockList); 78 | } 79 | 80 | childHeadDelete(block: Block): OperatorType { 81 | let parent = this.getParent(); 82 | let index = this.getParent().findChildrenIndex(this); 83 | 84 | // 不是第一项时,将其发送到前一项 85 | if (index !== 0) { 86 | let prev = this.getPrev(block); 87 | if (!prev) return; 88 | return block.sendTo(prev); 89 | } 90 | 91 | // 第一项时,直接将该列表项添加到父元素上 92 | block.removeSelf(); 93 | return parent.add(index, block); 94 | } 95 | 96 | sendTo(block: Block): OperatorType { 97 | return block.receive(this); 98 | } 99 | 100 | receive(block: Block): OperatorType { 101 | if (block.isEmpty()) { 102 | block.removeSelf(); 103 | let last = this.getChild(this.getSize() - 1); 104 | let lastSize = last.getSize(); 105 | return [{ id: last.id, offset: lastSize }]; 106 | } 107 | 108 | return this.add(-1, block); 109 | } 110 | 111 | snapshoot(): CustomCollectionSnapshoot { 112 | let snap = super.snapshoot() as CustomCollectionSnapshoot; 113 | snap.tag = this.tag; 114 | return snap; 115 | } 116 | 117 | restore(state: CustomCollectionSnapshoot) { 118 | this.tag = state.tag; 119 | super.restore(state); 120 | } 121 | 122 | createEmpty(): CustomCollection { 123 | const collection = this.getComponentFactory().buildCustomCollection(this.tag, []); 124 | collection.modifyDecorate(this.decorate.copyStyle(), this.decorate.copyData()); 125 | return collection; 126 | } 127 | 128 | getType(): string { 129 | return `${this.type}>${this.tag}`; 130 | } 131 | 132 | getJSON(): JSONType { 133 | let raw = super.getJSON(); 134 | raw.tag = this.tag; 135 | return raw; 136 | } 137 | 138 | render(contentView: AbstractView) { 139 | let content = contentView.buildCustomCollection( 140 | this.id, 141 | this.tag, 142 | () => this.children.toArray().map((each) => each.render(contentView)), 143 | this.decorate.getStyle(), 144 | this.decorate.getData(), 145 | ); 146 | return content; 147 | } 148 | } 149 | 150 | export default CustomCollection; 151 | -------------------------------------------------------------------------------- /src/components/heading.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import { JSONType } from "./component"; 3 | import Inline from "./inline"; 4 | import Block, { BlockType } from "./block"; 5 | import PlainText from "./plain-text"; 6 | import ContentCollection from "./content-collection"; 7 | import AbstractView from "../view/base-view"; 8 | import ComponentType from "../const/component-type"; 9 | import { CollectionSnapshoot } from "./collection"; 10 | import ComponentFactory from "../factory"; 11 | 12 | export enum HeadingEnum { 13 | h1 = "h1", 14 | h2 = "h2", 15 | h3 = "h3", 16 | h4 = "h4", 17 | h5 = "h5", 18 | h6 = "h6", 19 | } 20 | 21 | export interface HeadingSnapshoot extends CollectionSnapshoot { 22 | headingType: HeadingEnum; 23 | } 24 | 25 | const styleMap = { 26 | h1: { 27 | fontSize: "32px", 28 | }, 29 | h2: { 30 | fontSize: "24px", 31 | }, 32 | h3: { 33 | fontSize: "20px", 34 | }, 35 | h4: { 36 | fontSize: "16px", 37 | }, 38 | h5: { 39 | fontSize: "14px", 40 | }, 41 | h6: { 42 | fontSize: "12px", 43 | }, 44 | }; 45 | 46 | class Heading extends ContentCollection { 47 | type = ComponentType.heading; 48 | headingType: HeadingEnum; 49 | data = { 50 | bold: true, 51 | }; 52 | 53 | static create(componentFactory: ComponentFactory, json: JSONType): Heading { 54 | let children = super.createChildren(componentFactory, json); 55 | 56 | let heading = componentFactory.buildHeading(json.headingType || HeadingEnum.h1, ""); 57 | heading.add(0, ...children); 58 | heading.modifyDecorate(json.style, json.data); 59 | return heading; 60 | } 61 | 62 | static exchange(componentFactory: ComponentFactory, block: Block, args: any[] = []): Heading[] { 63 | let headingType = args[0] || "h1"; 64 | if (block instanceof Heading && block.headingType === headingType) { 65 | return [block]; 66 | } 67 | 68 | let newHeadingList = []; 69 | if (block instanceof ContentCollection) { 70 | let newHeading = componentFactory.buildHeading(headingType, ""); 71 | newHeading.modifyDecorate(block.decorate.copyStyle(), block.decorate.copyData()); 72 | newHeading.style = styleMap[newHeading.headingType]; 73 | newHeading.add(0, ...block.children); 74 | newHeadingList.push(newHeading); 75 | } else if (block instanceof PlainText) { 76 | let stringList = block.content.join("").split("\n"); 77 | if (!stringList[stringList.length - 1]) { 78 | stringList.pop(); 79 | } 80 | stringList.forEach((each) => { 81 | newHeadingList.push(componentFactory.buildHeading(headingType, each)); 82 | }); 83 | } 84 | 85 | block.replaceSelf(...newHeadingList); 86 | return newHeadingList; 87 | } 88 | 89 | constructor(type: HeadingEnum, text?: string, editor?: Editor) { 90 | super(text, editor); 91 | this.headingType = type; 92 | this.style = styleMap[type]; 93 | } 94 | 95 | setHeading(type: HeadingEnum = HeadingEnum.h1) { 96 | if (this.headingType === type) return; 97 | this.componentWillChange(); 98 | this.headingType = type; 99 | this.style = styleMap[type]; 100 | this.updateComponent([this]); 101 | } 102 | 103 | exchangeTo(builder: BlockType, args: any[]): Block[] { 104 | if (builder === (this.constructor as BlockType)) { 105 | this.setHeading(args[0]); 106 | return [this]; 107 | } 108 | 109 | return builder.exchange(this.getComponentFactory(), this, args); 110 | } 111 | 112 | snapshoot(): HeadingSnapshoot { 113 | let snap = super.snapshoot() as HeadingSnapshoot; 114 | snap.headingType = this.headingType; 115 | return snap; 116 | } 117 | 118 | restore(state: HeadingSnapshoot) { 119 | this.headingType = state.headingType; 120 | this.style = styleMap[state.headingType]; 121 | super.restore(state); 122 | } 123 | 124 | createEmpty() { 125 | const heading = this.getComponentFactory().buildHeading(this.headingType, ""); 126 | heading.modifyDecorate(this.decorate.copyStyle(), this.decorate.copyData()); 127 | return heading; 128 | } 129 | 130 | getType(): string { 131 | return `${this.type}>${this.headingType}`; 132 | } 133 | 134 | getJSON(): JSONType { 135 | let raw = super.getJSON(); 136 | raw.headingType = this.headingType; 137 | return raw; 138 | } 139 | 140 | render(contentView: AbstractView) { 141 | return contentView.buildHeading( 142 | this.id, 143 | this.headingType, 144 | () => this.getChildren(contentView), 145 | this.decorate.getStyle(), 146 | this.decorate.getData(), 147 | ); 148 | } 149 | } 150 | 151 | export default Heading; 152 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import Article from "./article"; 2 | import List, { ListType } from "./list"; 3 | import Table from "./table"; 4 | import Heading, { HeadingEnum } from "./heading"; 5 | import Paragraph from "./paragraph"; 6 | import Media, { MediaType } from "./media"; 7 | import CodeBlock from "./code-block"; 8 | import InlineImage from "./inline-image"; 9 | import ComponentType from "../const/component-type"; 10 | import CustomCollection from "./custom-collection"; 11 | import { OperatorType, JSONType } from "./component"; 12 | import Block from "./block"; 13 | import Inline from "./inline"; 14 | import ContentCollection from "./content-collection"; 15 | import StructureCollection from "./structure-collection"; 16 | 17 | export { 18 | Block, 19 | Inline, 20 | ContentCollection, 21 | StructureCollection, 22 | Article, 23 | List, 24 | ListType as ListEnum, 25 | Table, 26 | Heading, 27 | HeadingEnum, 28 | Paragraph, 29 | Media, 30 | MediaType, 31 | CodeBlock, 32 | InlineImage, 33 | ComponentType, 34 | CustomCollection, 35 | JSONType as RawType, 36 | OperatorType, 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/inline-image.ts: -------------------------------------------------------------------------------- 1 | import Inline from "./inline"; 2 | import AbstractView from "../view/base-view"; 3 | import ComponentType from "../const/component-type"; 4 | import { AnyObject } from "../decorate/index"; 5 | import { JSONType } from "./component"; 6 | import ComponentFactory from "../factory"; 7 | 8 | class InlineImage extends Inline { 9 | type = ComponentType.inlineImage; 10 | content = ""; 11 | src: string; 12 | 13 | static create(componentFactory: ComponentFactory, json: JSONType): InlineImage { 14 | const inlineImage = componentFactory.buildInlineImage(json.src || ""); 15 | inlineImage.modifyDecorate(json.style, json.data); 16 | return inlineImage; 17 | } 18 | 19 | constructor(src: string = "") { 20 | super(); 21 | this.src = src; 22 | } 23 | 24 | getJSON() { 25 | let json = super.getJSON(); 26 | json.src = this.src; 27 | return json; 28 | } 29 | 30 | render(contentView: AbstractView) { 31 | return contentView.buildInlineImage( 32 | this.id, 33 | this.src, 34 | this.decorate.getStyle(), 35 | this.decorate.getData(), 36 | ); 37 | } 38 | } 39 | 40 | export default InlineImage; 41 | -------------------------------------------------------------------------------- /src/components/inline.ts: -------------------------------------------------------------------------------- 1 | import Component, { OperatorType } from "./component"; 2 | import StructureType from "../const/structure-type"; 3 | import ContentCollection from "./content-collection"; 4 | 5 | abstract class Inline extends Component { 6 | abstract content: string; 7 | parent?: ContentCollection; 8 | structureType = StructureType.unit; 9 | 10 | // 添加到某个组件内,被添加的组件必须为 ContentCollection 类型 11 | addInto(collection: ContentCollection, index: number = -1): OperatorType { 12 | return collection.add(index, this); 13 | } 14 | } 15 | 16 | export default Inline; 17 | -------------------------------------------------------------------------------- /src/components/list.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import { OperatorType, JSONType } from "./component"; 3 | import { CollectionSnapshoot } from "./collection"; 4 | import StructureCollection from "./structure-collection"; 5 | import Block from "./block"; 6 | import ComponentType from "../const/component-type"; 7 | import { AnyObject } from "../decorate"; 8 | import AbstractView from "../view/base-view"; 9 | import ComponentFactory from "../factory"; 10 | 11 | export enum ListType { 12 | ol = "ol", 13 | ul = "ul", 14 | } 15 | 16 | export interface ListSnapshoot extends CollectionSnapshoot { 17 | listType: ListType; 18 | } 19 | 20 | class List extends StructureCollection { 21 | type = ComponentType.list; 22 | listType: ListType; 23 | 24 | static create(componentFactory: ComponentFactory, json: JSONType): List { 25 | let children = (json.children || []).map((each: JSONType) => 26 | componentFactory.typeMap[each.type].create(each), 27 | ); 28 | 29 | let list = componentFactory.buildList(json.listType); 30 | list.add(0, ...children); 31 | return list; 32 | } 33 | 34 | static exchange(componentFactory: ComponentFactory, block: Block, args: any[] = []): Block[] { 35 | let parent = block.getParent(); 36 | if (parent.type === ComponentType.list) { 37 | (parent as List).setListType(args[0]); 38 | return [block]; 39 | } 40 | 41 | let prev = parent.getPrev(block); 42 | let index = parent.findChildrenIndex(block); 43 | block.removeSelf(); 44 | 45 | if (parent.type === ComponentType.list && (prev as List).listType === args[0]) { 46 | (prev as List).add(-1, block); 47 | } else { 48 | let newList = componentFactory.buildList(args[0]); 49 | newList.add(0, block); 50 | parent.add(index, newList); 51 | } 52 | return [block]; 53 | } 54 | 55 | constructor(type: ListType = ListType.ul, children: string[] = [], editor?: Editor) { 56 | super(editor); 57 | this.listType = type; 58 | this.add(0, ...children.map((each) => this.getComponentFactory().buildParagraph(each))); 59 | } 60 | 61 | setListType(type: ListType = ListType.ul) { 62 | if (type === this.listType) return; 63 | this.componentWillChange(); 64 | this.listType = type; 65 | this.updateComponent([this]); 66 | } 67 | 68 | add(index: number, ...blockList: Block[]): OperatorType { 69 | index = index < 0 ? this.getSize() + 1 + index : index; 70 | 71 | // 连续输入空行,截断列表 72 | if ( 73 | index > 1 && 74 | blockList.length === 1 && 75 | blockList[0].isEmpty() && 76 | this.getChild(index - 1).isEmpty() 77 | ) { 78 | this.getChild(index - 1).removeSelf(); 79 | let operator = this.split(index - 1, ...blockList); 80 | return operator; 81 | } 82 | 83 | return super.add(index, ...blockList); 84 | } 85 | 86 | childHeadDelete(block: Block): OperatorType { 87 | let index = this.findChildrenIndex(block); 88 | 89 | if (index !== 0) { 90 | let prev = this.getPrev(block); 91 | if (!prev) return; 92 | return block.sendTo(prev); 93 | } 94 | 95 | block.removeSelf(); 96 | let parent = this.getParent(); 97 | let parentIndex = parent.findChildrenIndex(this); 98 | return parent.add(parentIndex, block); 99 | } 100 | 101 | sendTo(block: Block): OperatorType { 102 | return block.receive(this); 103 | } 104 | 105 | receive(block: Block): OperatorType { 106 | block.removeSelf(); 107 | 108 | if (block instanceof List) { 109 | return this.add(-1, ...block.children); 110 | } else { 111 | if (block.isEmpty()) { 112 | let last = this.getChild(this.getSize() - 1); 113 | let lastSize = last.getSize(); 114 | return [{ id: last.id, offset: lastSize }]; 115 | } 116 | 117 | return this.add(-1, block); 118 | } 119 | } 120 | 121 | snapshoot(): ListSnapshoot { 122 | let snap = super.snapshoot() as ListSnapshoot; 123 | snap.listType = this.listType; 124 | return snap; 125 | } 126 | 127 | restore(state: ListSnapshoot) { 128 | this.listType = state.listType; 129 | super.restore(state); 130 | } 131 | 132 | createEmpty(): List { 133 | const list = this.getComponentFactory().buildList(this.listType, []); 134 | list.modifyDecorate(this.decorate.copyStyle(), this.decorate.copyData()); 135 | return list; 136 | } 137 | 138 | getType(): string { 139 | return `${this.type}>${this.listType}`; 140 | } 141 | 142 | getJSON(): JSONType { 143 | let json = super.getJSON(); 144 | json.listType = this.listType; 145 | return json; 146 | } 147 | 148 | render(contentView: AbstractView) { 149 | let content = contentView.buildList( 150 | this.id, 151 | this.listType, 152 | () => { 153 | return this.children.toArray().map((each) => { 154 | return contentView.buildListItem(each.render(contentView), each.structureType); 155 | }); 156 | }, 157 | this.decorate.getStyle(), 158 | this.decorate.getData(), 159 | ); 160 | return content; 161 | } 162 | } 163 | 164 | export default List; 165 | -------------------------------------------------------------------------------- /src/components/media.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import { OperatorType, JSONType } from "./component"; 3 | import Block, { BlockType, BlockSnapshoot } from "./block"; 4 | import AbstractView from "../view/base-view"; 5 | import ComponentType from "../const/component-type"; 6 | import StructureType from "../const/structure-type"; 7 | import { AnyObject } from "../decorate/index"; 8 | import ComponentFactory from "../factory"; 9 | 10 | export type MediaType = "image" | "audio" | "video"; 11 | 12 | export interface MediaSnapshoot extends BlockSnapshoot { 13 | mediaType: MediaType; 14 | src: string; 15 | } 16 | 17 | class Media extends Block { 18 | src: string; 19 | mediaType: MediaType; 20 | type = ComponentType.media; 21 | structureType = StructureType.media; 22 | 23 | static create(componentFactory: ComponentFactory, json: JSONType): Media { 24 | const media = componentFactory.buildMedia(json.mediaType as MediaType, json.src || ""); 25 | media.modifyDecorate(json.style, json.data); 26 | return media; 27 | } 28 | 29 | constructor(mediaType: MediaType, src: string, editor?: Editor) { 30 | super(editor); 31 | this.mediaType = mediaType; 32 | this.src = src; 33 | } 34 | 35 | setSrc(src: string) { 36 | if (this.src === src) return; 37 | this.componentWillChange(); 38 | this.src = src; 39 | this.updateComponent([this]); 40 | } 41 | 42 | exchangeTo(builder: BlockType, args: any[]): Block[] { 43 | return [this]; 44 | } 45 | 46 | modifyContentDecorate( 47 | start: number, 48 | end: number, 49 | style?: AnyObject, 50 | data?: AnyObject, 51 | ): OperatorType { 52 | this.modifyDecorate(style, data); 53 | return [ 54 | { id: this.id, offset: start }, 55 | { id: this.id, offset: end }, 56 | ]; 57 | } 58 | 59 | remove(): OperatorType { 60 | let paragraph = this.getComponentFactory().buildParagraph(); 61 | this.replaceSelf(paragraph); 62 | 63 | return [{ id: paragraph.id, offset: 0 }]; 64 | } 65 | 66 | split(index: number, ...blockList: Block[]): OperatorType { 67 | if (blockList.length === 0) { 68 | blockList.push(this.getComponentFactory().buildParagraph()); 69 | } 70 | 71 | let parent = this.getParent(); 72 | let componentIndex = parent.findChildrenIndex(this); 73 | 74 | if (index === 0) { 75 | parent.add(componentIndex, ...blockList); 76 | } 77 | 78 | if (index === 1) { 79 | parent.add(componentIndex + 1, ...blockList); 80 | } 81 | 82 | return [{ id: blockList[0].id, offset: 0 }]; 83 | } 84 | 85 | receive(block: Block): OperatorType { 86 | if (block.isEmpty()) { 87 | block.removeSelf(); 88 | return [{ id: this.id, offset: 1 }]; 89 | } 90 | 91 | super.removeSelf(); 92 | return [{ id: block.id, offset: 0 }]; 93 | } 94 | 95 | snapshoot(): MediaSnapshoot { 96 | let snap = super.snapshoot() as MediaSnapshoot; 97 | snap.mediaType = this.mediaType; 98 | snap.src = this.src; 99 | return snap; 100 | } 101 | 102 | restore(state: MediaSnapshoot) { 103 | this.mediaType = state.mediaType; 104 | this.src = state.src; 105 | super.restore(state); 106 | } 107 | 108 | getSize(): number { 109 | return 1; 110 | } 111 | 112 | getType(): string { 113 | return `${this.type}>${this.mediaType}`; 114 | } 115 | 116 | getJSON(): JSONType { 117 | let json = super.getJSON(); 118 | json.src = this.src; 119 | json.mediaType = this.mediaType; 120 | return json; 121 | } 122 | 123 | render(contentView: AbstractView) { 124 | let map = { 125 | image: "buildeImage", 126 | audio: "buildeAudio", 127 | video: "buildeVideo", 128 | }; 129 | 130 | return contentView[map[this.mediaType]]( 131 | this.id, 132 | this.src, 133 | this.decorate.getStyle(), 134 | this.decorate.getData(), 135 | ); 136 | } 137 | } 138 | 139 | export default Media; 140 | -------------------------------------------------------------------------------- /src/components/paragraph.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import { JSONType } from "./component"; 3 | import Block from "./block"; 4 | import PlainText from "./plain-text"; 5 | import ContentCollection from "./content-collection"; 6 | import AbstractView from "../view/base-view"; 7 | import ComponentType from "../const/component-type"; 8 | import ComponentFactory from "../factory"; 9 | 10 | class Paragraph extends ContentCollection { 11 | type = ComponentType.paragraph; 12 | 13 | static create(componentFactory: ComponentFactory, json: JSONType): Paragraph { 14 | let children = super.createChildren(componentFactory, json); 15 | 16 | let paragraph = componentFactory.buildParagraph(); 17 | paragraph.modifyDecorate(json.style, json.data); 18 | paragraph.add(0, ...children); 19 | return paragraph; 20 | } 21 | 22 | static exchange(componentFactory: ComponentFactory, block: Block): Paragraph[] { 23 | if (block instanceof Paragraph) { 24 | return [block]; 25 | } 26 | 27 | let newParagraphList: Paragraph[] = []; 28 | if (block instanceof ContentCollection) { 29 | let newParagraph = componentFactory.buildParagraph(); 30 | newParagraph.modifyDecorate(block.decorate.copyStyle(), block.decorate.copyData()); 31 | newParagraph.add(0, ...block.children); 32 | newParagraphList.push(newParagraph); 33 | } else if (block instanceof PlainText) { 34 | let stringList = block.content.join("").split("\n"); 35 | if (!stringList[stringList.length - 1]) { 36 | stringList.pop(); 37 | } 38 | stringList.forEach((each) => { 39 | newParagraphList.push(componentFactory.buildParagraph(each)); 40 | }); 41 | } 42 | 43 | block.replaceSelf(...newParagraphList); 44 | return newParagraphList; 45 | } 46 | 47 | constructor(text: string = "", editor?: Editor) { 48 | super(text, editor); 49 | } 50 | 51 | createEmpty() { 52 | const paragraph = this.getComponentFactory().buildParagraph(); 53 | paragraph.modifyDecorate(this.decorate.copyStyle(), this.decorate.copyData()); 54 | return paragraph; 55 | } 56 | 57 | render(contentView: AbstractView) { 58 | return contentView.buildParagraph( 59 | this.id, 60 | () => this.getChildren(contentView), 61 | this.decorate.getStyle(), 62 | this.decorate.getData(), 63 | ); 64 | } 65 | } 66 | 67 | export default Paragraph; 68 | -------------------------------------------------------------------------------- /src/components/plain-text.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import { OperatorType, JSONType } from "./component"; 3 | import Block, { BlockSnapshoot } from "./block"; 4 | import ContentCollection from "./content-collection"; 5 | import StructureType from "../const/structure-type"; 6 | import { createError } from "../util"; 7 | import Character from "./character"; 8 | import ComponentType from "../const/component-type"; 9 | import { getUtf8TextLengthFromJsOffset } from "../util/text-util"; 10 | 11 | export interface PlainTextSnapshoot extends BlockSnapshoot { 12 | content: string; 13 | } 14 | 15 | abstract class PlainText extends Block { 16 | content: string[]; 17 | structureType = StructureType.plainText; 18 | 19 | constructor(content: string = "", editor?: Editor) { 20 | super(editor); 21 | // fix emoji length 22 | this.content = [...content]; 23 | if (this.content[this.content.length - 1] !== "\n") { 24 | this.content.push("\n"); 25 | } 26 | } 27 | 28 | add(index: number, string: string): OperatorType { 29 | if (typeof string !== "string") { 30 | throw createError("only text can be added", this); 31 | } 32 | 33 | index = index === undefined ? this.content.length : index; 34 | this.componentWillChange(); 35 | this.content.splice(index, 0, ...string); 36 | this.updateComponent([this]); 37 | 38 | return [{ id: this.id, offset: index + getUtf8TextLengthFromJsOffset(string) }]; 39 | } 40 | 41 | remove(start: number, end: number = start + 1): OperatorType { 42 | this.componentWillChange(); 43 | 44 | if (start < 0 && end === 0) { 45 | let block = this.exchangeTo(this.getComponentFactory().typeMap[ComponentType.paragraph]); 46 | return [{ id: block[0].id, offset: 0 }]; 47 | } 48 | 49 | this.content.splice(start, end - start); 50 | this.updateComponent([this]); 51 | return [{ id: this.id, offset: start }]; 52 | } 53 | 54 | split(index: number, ...blockList: Block[]): OperatorType { 55 | if (blockList.length) { 56 | throw createError("only text can be split", this); 57 | } 58 | 59 | return this.add(index, "\n"); 60 | } 61 | 62 | receive(block: Block): OperatorType { 63 | block.removeSelf(); 64 | let size = this.content.length; 65 | 66 | if (block instanceof ContentCollection) { 67 | this.add( 68 | -1, 69 | block.children 70 | .filter((each) => each instanceof Character) 71 | .map((each) => each.content) 72 | .join("") + "\n", 73 | ); 74 | } else if (block instanceof PlainText) { 75 | this.content.push(...block.content); 76 | } 77 | 78 | return [{ id: this.id, offset: size }]; 79 | } 80 | 81 | snapshoot(): PlainTextSnapshoot { 82 | let snap = super.snapshoot() as PlainTextSnapshoot; 83 | snap.content = this.content.join(""); 84 | return snap; 85 | } 86 | 87 | restore(state: PlainTextSnapshoot) { 88 | this.content = [...state.content]; 89 | super.restore(state); 90 | } 91 | 92 | getSize() { 93 | return this.content.length - 1; 94 | } 95 | 96 | getJSON(): JSONType { 97 | let json = super.getJSON(); 98 | json.content = this.content.join(""); 99 | return json; 100 | } 101 | } 102 | 103 | export default PlainText; 104 | -------------------------------------------------------------------------------- /src/components/structure-collection.ts: -------------------------------------------------------------------------------- 1 | import { OperatorType, JSONType } from "./component"; 2 | import Block from "./block"; 3 | import Collection, { CollectionSnapshoot } from "./collection"; 4 | import StructureType from "../const/structure-type"; 5 | import { createError } from "../util"; 6 | 7 | abstract class StructureCollection extends Collection { 8 | structureType = StructureType.structure; 9 | 10 | createEmpty(): StructureCollection { 11 | throw createError("component need implement createEmpty method", this); 12 | } 13 | 14 | findChildrenIndex(idOrBlock: string | Block): number { 15 | let blockId = typeof idOrBlock === "string" ? idOrBlock : idOrBlock.id; 16 | let index = this.children.findIndex((each) => each.id === blockId); 17 | return index; 18 | } 19 | 20 | addChildren(index: number, componentList: T[]): T[] { 21 | componentList.forEach((each) => { 22 | each.parent = this; 23 | each.active = true; 24 | this.editor?.$emit("blockCreated", each); 25 | }); 26 | this.componentWillChange(); 27 | let newBlockList = super.addChildren(index, componentList); 28 | this.updateComponent([...newBlockList, this]); 29 | return newBlockList; 30 | } 31 | 32 | add(index: number, ...blockList: T[]): OperatorType { 33 | index = index < 0 ? this.getSize() + 1 + index : index; 34 | this.addChildren(index, blockList); 35 | return; 36 | } 37 | 38 | removeChildren(start: number, end: number = -1) { 39 | this.componentWillChange(); 40 | let removed = super.removeChildren(start, end); 41 | 42 | removed.forEach((each) => each.destory()); 43 | 44 | this.updateComponent([...removed, this]); 45 | 46 | if (this.getSize() === 0) { 47 | this.removeSelf(); 48 | } 49 | 50 | return removed; 51 | } 52 | 53 | remove(start: number, end: number = start + 1): OperatorType { 54 | if (start < 0) { 55 | throw createError(`error position start: ${start} end: ${end}.`, this); 56 | } 57 | 58 | this.removeChildren(start, end); 59 | return; 60 | } 61 | 62 | replaceChild(blockList: T[], oldComponent: T): Block[] { 63 | let index = this.findChildrenIndex(oldComponent); 64 | if (index === -1) { 65 | throw createError(`cannot replace child at ${index}`, blockList); 66 | } 67 | oldComponent.destory(); 68 | 69 | blockList.forEach((each) => { 70 | each.parent = this; 71 | each.active = true; 72 | }); 73 | 74 | this.componentWillChange(); 75 | this.children = this.children.splice(index, 1, ...blockList); 76 | this.updateComponent([oldComponent, ...blockList, this]); 77 | return blockList; 78 | } 79 | 80 | splitChild(index: number): StructureCollection { 81 | if (index > this.getSize()) { 82 | throw createError(`cannot split child at ${index}`, this); 83 | } 84 | 85 | let tail = this.removeChildren(index); 86 | let newCollection = this.createEmpty(); 87 | newCollection.add(0, ...tail); 88 | return newCollection; 89 | } 90 | 91 | split(index: number, ...blockList: Block[]): OperatorType { 92 | let parent = this.getParent(); 93 | let componentIndex = parent.findChildrenIndex(this); 94 | let changedBlock = []; 95 | 96 | if (index !== 0) { 97 | let newCollection = this.splitChild(index); 98 | if (newCollection.getSize() !== 0) { 99 | changedBlock.push(newCollection); 100 | parent.add(componentIndex + 1, newCollection); 101 | } 102 | } else { 103 | componentIndex -= 1; 104 | } 105 | 106 | if (blockList.length) { 107 | changedBlock.push(...blockList); 108 | return parent.add(componentIndex + 1, ...blockList); 109 | } 110 | 111 | return; 112 | } 113 | 114 | childHeadDelete(block: T): OperatorType { 115 | return; 116 | } 117 | 118 | restore(state: CollectionSnapshoot) { 119 | this.children.forEach((each) => { 120 | if (each.parent === this) { 121 | each.active = false; 122 | each.parent = undefined; 123 | } 124 | }); 125 | state.children.forEach((each) => { 126 | each.active = true; 127 | each.parent = this; 128 | }); 129 | super.restore(state); 130 | } 131 | 132 | getPrev(idOrBlock: string | T): T | undefined { 133 | let index = this.findChildrenIndex(idOrBlock); 134 | 135 | if (index === 0) { 136 | return; 137 | } 138 | return this.getChild(index - 1); 139 | } 140 | 141 | getNext(idOrBlock: string | T): T | undefined { 142 | let index = this.findChildrenIndex(idOrBlock); 143 | 144 | if (index === this.getSize() - 1) { 145 | return; 146 | } 147 | return this.getChild(index + 1); 148 | } 149 | 150 | getJSON(): JSONType { 151 | let json = super.getJSON(); 152 | json.children = this.children.toArray().map((each) => each.getJSON()); 153 | return json; 154 | } 155 | 156 | destory() { 157 | this.children.forEach((each) => each.destory()); 158 | super.destory(); 159 | } 160 | } 161 | 162 | export default StructureCollection; 163 | -------------------------------------------------------------------------------- /src/components/table.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import { AnyObject } from "../decorate"; 3 | import { OperatorType, JSONType } from "./component"; 4 | import Block from "./block"; 5 | import ContentCollection from "./content-collection"; 6 | import StructureCollection from "./structure-collection"; 7 | import AbstractView from "../view/base-view"; 8 | import ComponentType from "../const/component-type"; 9 | import { CollectionSnapshoot } from "./collection"; 10 | import { createError } from "../util"; 11 | import ComponentFactory from "../factory"; 12 | import { nextTick } from "../util"; 13 | 14 | type tableCellType = string | string[]; 15 | 16 | interface TableSnapshoot extends CollectionSnapshoot { 17 | col: number; 18 | needHead: boolean; 19 | } 20 | 21 | class Table extends StructureCollection { 22 | type: ComponentType = ComponentType.table; 23 | 24 | static getTable(block: Block): Table | undefined { 25 | let table: Table | undefined; 26 | if (block instanceof TableItem) { 27 | table = block.parent?.parent?.parent; 28 | } else if (block instanceof TableCell) { 29 | table = block.parent?.parent; 30 | } else if (block instanceof TableRow) { 31 | table = block.parent; 32 | } else if (block instanceof Table) { 33 | table = block; 34 | } 35 | return table; 36 | } 37 | 38 | static create(componentFactory: ComponentFactory, json: JSONType): Table { 39 | let children = (json.children || []).map((each) => { 40 | return TableRow.create(componentFactory, each); 41 | }); 42 | 43 | let table = componentFactory.buildTable(0, 0, [], []); 44 | table.modifyDecorate(json.style, json.data); 45 | table.add(0, ...children); 46 | return table; 47 | } 48 | 49 | constructor( 50 | row: number, 51 | col: number, 52 | head: tableCellType[] | boolean = true, 53 | rows: tableCellType[][] = [], 54 | editor?: Editor, 55 | ) { 56 | super(editor); 57 | 58 | let tableRows = []; 59 | if (head) { 60 | if (head === true) { 61 | head = []; 62 | } 63 | tableRows.push(new TableRow(col, TableCellEnum.th, head)); 64 | } 65 | 66 | for (let i = 0; i < row; i++) { 67 | tableRows.push(new TableRow(col, TableCellEnum.td, rows[i])); 68 | } 69 | 70 | this.add(0, ...tableRows); 71 | } 72 | 73 | getRowSize() { 74 | return this.getChild(0).getSize(); 75 | } 76 | 77 | addRow(index: number) { 78 | let cellSize = this.getChild(0).getSize(); 79 | let newTableRow = new TableRow(cellSize); 80 | this.add(index, newTableRow); 81 | } 82 | 83 | getColSize() { 84 | return this.getChild(0).getChild(0).getSize(); 85 | } 86 | 87 | addCol(index: number) { 88 | this.children.forEach((each) => each.addCell(index)); 89 | } 90 | 91 | removeRow(start: number, end: number = start + 1) { 92 | this.remove(start, end); 93 | } 94 | 95 | removeCol(start: number, end: number = start + 1) { 96 | this.children.forEach((each) => each.remove(start, end)); 97 | } 98 | 99 | setRow(row: number) { 100 | let size = this.getSize(); 101 | let cellSize = this.getChild(0).getSize(); 102 | let hasHead = this.getChild(0).cellType === "th"; 103 | let rowSize = hasHead ? size - 1 : size; 104 | 105 | if (row === rowSize) return; 106 | 107 | if (row > rowSize) { 108 | let list = []; 109 | for (let i = rowSize; i < row; i++) { 110 | let each = new TableRow(cellSize); 111 | list.push(each); 112 | } 113 | this.add(-1, ...list); 114 | } else { 115 | this.remove(row, size); 116 | } 117 | } 118 | 119 | setCol(col: number) { 120 | this.children.forEach((each) => each.setSize(col)); 121 | } 122 | 123 | hasHead(): boolean { 124 | return this.getChild(0).cellType === TableCellEnum.th; 125 | } 126 | 127 | setHead(head: boolean) { 128 | let hasHead = this.getChild(0).cellType === TableCellEnum.th; 129 | 130 | if (head === hasHead) return; 131 | 132 | if (head) { 133 | let colNumber = this.getChild(0).getSize(); 134 | this.add(0, new TableRow(colNumber, TableCellEnum.th, [])); 135 | } else { 136 | this.remove(0, 1); 137 | } 138 | } 139 | 140 | receive(block: Block): OperatorType { 141 | this.removeSelf(); 142 | return [{ id: block.id, offset: 0 }]; 143 | } 144 | 145 | restore(state: TableSnapshoot) { 146 | super.restore(state); 147 | } 148 | 149 | render(contentView: AbstractView) { 150 | return contentView.buildTable( 151 | this.id, 152 | () => this.children.toArray().map((each) => each.render(contentView)), 153 | this.decorate.getStyle(), 154 | this.decorate.getData(), 155 | ); 156 | } 157 | } 158 | 159 | export enum TableCellEnum { 160 | th = "th", 161 | td = "td", 162 | } 163 | 164 | class TableRow extends StructureCollection { 165 | type: ComponentType = ComponentType.tableRow; 166 | parent?: Table; 167 | cellType: TableCellEnum; 168 | 169 | static create(componentFactory: ComponentFactory, json: JSONType): TableRow { 170 | let tableRow = new TableRow(json.children!.length, json.cellType, []); 171 | tableRow.modifyDecorate(json.style, json.data); 172 | let children = (json.children || []).map((each) => TableCell.create(componentFactory, each)); 173 | tableRow.add(0, ...children); 174 | return tableRow; 175 | } 176 | 177 | constructor( 178 | size: number, 179 | cellType: TableCellEnum = TableCellEnum.td, 180 | children: tableCellType[] = [], 181 | editor?: Editor, 182 | ) { 183 | super(editor); 184 | this.cellType = cellType; 185 | 186 | let cells = []; 187 | for (let i = 0; i < size; i++) { 188 | if (children[i]) { 189 | cells.push(new TableCell(this.cellType, children[i])); 190 | } else { 191 | cells.push(new TableCell(this.cellType)); 192 | } 193 | } 194 | 195 | super.add(0, ...cells); 196 | } 197 | 198 | addCell(index: number) { 199 | let newTableCell = new TableCell(this.cellType); 200 | this.add(index, newTableCell); 201 | return newTableCell; 202 | } 203 | 204 | setSize(size: number) { 205 | let cellSize = this.getSize(); 206 | 207 | if (size === cellSize) return; 208 | 209 | if (size > cellSize) { 210 | let list = []; 211 | for (let i = cellSize; i < size; i++) { 212 | let each = new TableCell(this.cellType); 213 | list.push(each); 214 | } 215 | this.add(-1, ...list); 216 | } else { 217 | this.remove(size, cellSize); 218 | } 219 | } 220 | 221 | getJSON() { 222 | let raw = super.getJSON(); 223 | raw.cellType = this.cellType; 224 | return raw; 225 | } 226 | 227 | addEmptyParagraph(bottom: boolean): OperatorType { 228 | let parent = this.getParent(); 229 | return parent.addEmptyParagraph(bottom); 230 | } 231 | 232 | render(contentView: AbstractView) { 233 | return contentView.buildTableRow( 234 | this.id, 235 | () => this.children.toArray().map((each) => each.render(contentView)), 236 | this.decorate.getStyle(), 237 | this.decorate.getData(), 238 | ); 239 | } 240 | } 241 | 242 | class TableCell extends StructureCollection { 243 | type: ComponentType = ComponentType.tableCell; 244 | parent?: TableRow; 245 | cellType: TableCellEnum; 246 | 247 | static create(componentFactory: ComponentFactory, raw: JSONType): TableCell { 248 | let children = (raw.children || []).map((each) => TableItem.create(componentFactory, each)); 249 | 250 | let tableCell = new TableCell(raw.cellType, ""); 251 | tableCell.modifyDecorate(raw.style, raw.data); 252 | tableCell.add(0, ...children); 253 | 254 | return tableCell; 255 | } 256 | 257 | constructor( 258 | cellType: TableCellEnum = TableCellEnum.td, 259 | children: tableCellType = "", 260 | editor?: Editor, 261 | ) { 262 | super(editor); 263 | this.cellType = cellType; 264 | 265 | if (!Array.isArray(children)) { 266 | children = [children]; 267 | } 268 | 269 | this.add(0, ...children.map((each) => new TableItem(each))); 270 | } 271 | 272 | isEmpty() { 273 | return this.getSize() === 1 && this.getChild(0).getSize() === 0; 274 | } 275 | 276 | removeChildren(start: number, end: number = -1) { 277 | let removed = super.removeChildren(start, end); 278 | // 若删除后仍在 active 状态,则至少保证有一个空行 279 | nextTick(() => { 280 | if (this.active && this.getSize() === 0) { 281 | this.add(0, new TableItem()); 282 | } 283 | }); 284 | return removed; 285 | } 286 | 287 | childHeadDelete(tableItem: TableItem): OperatorType { 288 | let prev = this.getPrev(tableItem); 289 | if (!prev) { 290 | return [{ id: tableItem.id, offset: 0 }]; 291 | } 292 | 293 | return tableItem.sendTo(prev); 294 | } 295 | 296 | addEmptyParagraph(bottom: boolean): OperatorType { 297 | let parent = this.getParent(); 298 | return parent.addEmptyParagraph(bottom); 299 | } 300 | 301 | getJSON() { 302 | let raw = super.getJSON(); 303 | raw.cellType = this.cellType; 304 | return raw; 305 | } 306 | 307 | render(contentView: AbstractView) { 308 | return contentView.buildTableCell( 309 | this.id, 310 | this.cellType, 311 | () => this.children.toArray().map((each) => each.render(contentView)), 312 | this.decorate.getStyle(), 313 | this.decorate.getData(), 314 | ); 315 | } 316 | } 317 | 318 | class TableItem extends ContentCollection { 319 | type = ComponentType.tableItem; 320 | parent?: TableCell; 321 | style: AnyObject = { 322 | textAlign: "center", 323 | }; 324 | 325 | static create(componentFactory: ComponentFactory, raw: JSONType): TableItem { 326 | let tableItem = new TableItem(); 327 | tableItem.modifyDecorate(raw.style, raw.data); 328 | tableItem.add(0, ...this.createChildren(componentFactory, raw)); 329 | return tableItem; 330 | } 331 | 332 | static exchange(): TableItem[] { 333 | throw createError("不允许切换表格内段落"); 334 | } 335 | 336 | exchangeTo(): Block[] { 337 | throw createError("表格内段落不允许切换类型", this); 338 | } 339 | 340 | createEmpty() { 341 | const tableImem = new TableItem(); 342 | tableImem.modifyDecorate(this.decorate.copyStyle(), this.decorate.copyData()); 343 | return tableImem; 344 | } 345 | 346 | split(index: number, ...tableItem: TableItem[]): OperatorType { 347 | if (tableItem.length) { 348 | throw createError("表格组件不允许添加其他组件", this); 349 | } 350 | 351 | return super.split(index); 352 | } 353 | 354 | render(contentView: AbstractView) { 355 | return contentView.buildParagraph( 356 | this.id, 357 | () => this.getChildren(contentView), 358 | this.decorate.getStyle(), 359 | this.decorate.getData(), 360 | ); 361 | } 362 | } 363 | 364 | export default Table; 365 | -------------------------------------------------------------------------------- /src/const/component-type.ts: -------------------------------------------------------------------------------- 1 | enum ComponentType { 2 | inlineImage = "INLINEIMAGE", 3 | character = "CHARACTER", 4 | media = "MEDIA", 5 | codeBlock = "CODEBLOCK", 6 | paragraph = "PARAGRAPH", 7 | heading = "HEADING", 8 | list = "LIST", 9 | table = "TABLE", 10 | tableRow = "TABLEROW", 11 | tableCell = "TABLECELL", 12 | tableItem = "TABLECELLITEM", 13 | customerCollection = "CUSTOMERCOLLECTION", 14 | article = "ARTICLE", 15 | } 16 | 17 | export default ComponentType; 18 | -------------------------------------------------------------------------------- /src/const/direction-type.ts: -------------------------------------------------------------------------------- 1 | enum DirectionType { 2 | up = "UP", 3 | down = "DOWN", 4 | left = "LEFT", 5 | right = "RIGHT", 6 | } 7 | 8 | export default DirectionType; 9 | -------------------------------------------------------------------------------- /src/const/structure-type.ts: -------------------------------------------------------------------------------- 1 | enum StructureType { 2 | // 结构,由 media/plainText/content 组成 3 | structure = "STRUCTURE", 4 | // 内容,由 unit 组成 5 | content = "CONTENT", 6 | // 多媒体 7 | media = "MEDIA", 8 | // 纯文本 9 | plainText = "PLAINTEXT", 10 | // 不可分割的最小单元 11 | unit = "UNIT", 12 | } 13 | 14 | export default StructureType; 15 | -------------------------------------------------------------------------------- /src/decorate/index.ts: -------------------------------------------------------------------------------- 1 | import { Map } from "immutable"; 2 | import Component from "../components/component"; 3 | 4 | export interface AnyObject { 5 | [key: string]: any; 6 | } 7 | 8 | class Decorate { 9 | component: Component; 10 | style: Map = Map(); 11 | data: Map = Map(); 12 | 13 | constructor(component: Component) { 14 | this.component = component; 15 | } 16 | 17 | copyStyle() { 18 | return this.style.toObject(); 19 | } 20 | getStyle() { 21 | return { ...this.component.style, ...this.style.toObject() }; 22 | } 23 | mergeStyle(style?: AnyObject) { 24 | if (!style) return; 25 | 26 | // 移除逻辑 27 | if (style.remove) { 28 | if (style.remove === "all") { 29 | this.style = this.style.clear(); 30 | } else { 31 | this.style = this.style.removeAll(style.remove.split(",")); 32 | } 33 | return; 34 | } 35 | 36 | this.style = this.style.merge(style); 37 | } 38 | styleIsEmpty(): boolean { 39 | return this.style.size === 0; 40 | } 41 | 42 | copyData() { 43 | return this.data.toObject(); 44 | } 45 | getData() { 46 | return { ...this.component.data, ...this.data.toObject() }; 47 | } 48 | mergeData(data?: AnyObject) { 49 | if (!data) return; 50 | 51 | // 移除逻辑 52 | if (data.remove) { 53 | if (data.remove === "all") { 54 | this.data = this.data.clear(); 55 | } else { 56 | this.data = this.data.removeAll(data.remove.split(",")); 57 | } 58 | return; 59 | } 60 | 61 | // 切换逻辑 62 | if (data.toggle) { 63 | let keyList = (data.toggle as string).split(","); 64 | let toggleMap: AnyObject = {}; 65 | keyList.forEach((each) => { 66 | toggleMap[each] = !this.data.get(each); 67 | }); 68 | this.data = this.data.merge(toggleMap); 69 | return; 70 | } 71 | 72 | this.data = this.data.merge(data); 73 | } 74 | dataIsEmpty(): boolean { 75 | return this.data.size === 0; 76 | } 77 | 78 | isSame(decorate?: Decorate): boolean { 79 | if ( 80 | decorate === undefined || 81 | decorate.style.size !== this.style.size || 82 | decorate.data.size !== this.data.size 83 | ) { 84 | return false; 85 | } 86 | 87 | for (const key in this.style) { 88 | if (decorate.style.get(key) !== this.style.get(key)) { 89 | return false; 90 | } 91 | } 92 | 93 | for (const key in this.data) { 94 | if (decorate.data.get(key) !== this.data.get(key)) { 95 | return false; 96 | } 97 | } 98 | 99 | return true; 100 | } 101 | 102 | isEmpty(): boolean { 103 | return this.style.size === 0 && this.data.size === 0; 104 | } 105 | 106 | clear() { 107 | this.style = this.style.clear(); 108 | this.data = this.data.clear(); 109 | } 110 | } 111 | 112 | export default Decorate; 113 | -------------------------------------------------------------------------------- /src/editor/create-editor.ts: -------------------------------------------------------------------------------- 1 | import Editor from "."; 2 | import editorStyle from "../util/editor-style"; 3 | import StructureType from "../const/structure-type"; 4 | import getSelection from "../selection/get-selection"; 5 | import deleteSelection from "../operator/delete-selection"; 6 | import { getUtf8TextLengthFromJsOffset } from "../util/text-util"; 7 | import { nextTick } from "../util"; 8 | 9 | // 将组件挂载到某个节点上 10 | const createEditor = ( 11 | root: HTMLElement, 12 | editor: Editor, 13 | beforeCreate?: (document: Document, window: Window) => void, 14 | afterCreate?: (document: Document, window: Window) => void, 15 | ) => { 16 | let operator = editor.userOperator; 17 | 18 | // 生成 iframe 并获取 document 与 window 对象 19 | root.innerHTML = ""; 20 | let iframe = document.createElement("iframe"); 21 | iframe.width = "100%"; 22 | iframe.height = "100%"; 23 | iframe.style.border = "none"; 24 | root.appendChild(iframe); 25 | 26 | const loadIframe = () => { 27 | if (!iframe.contentWindow || !iframe.contentDocument) { 28 | return; 29 | } 30 | 31 | let style = iframe.contentDocument.createElement("style"); 32 | style.textContent = editorStyle; 33 | iframe.contentDocument.head.appendChild(style); 34 | 35 | if (beforeCreate) { 36 | beforeCreate(iframe.contentDocument, iframe.contentWindow); 37 | } 38 | 39 | // 生成容器 40 | let editorDom = iframe.contentDocument.createElement("div"); 41 | editorDom.id = "zebra-editor-contain"; 42 | editorDom.contentEditable = "true"; 43 | iframe.contentDocument.body.appendChild(editorDom); 44 | 45 | // placeholder 46 | if (editor.placeholder) { 47 | const placeholderStyle = iframe.contentDocument.createElement("style"); 48 | placeholderStyle.textContent = `.zebra-editor-article > :first-child.zebra-editor-empty::before {content:'${editor.placeholder}';}`; 49 | iframe.contentDocument.head.appendChild(placeholderStyle); 50 | } 51 | 52 | document.addEventListener("editorChange", (e) => { 53 | let article = editor.article; 54 | if (!article) return; 55 | let size = article.getSize(); 56 | if (size === 0) { 57 | article.add(0, editor.componentFactory.buildParagraph()); 58 | } else { 59 | let lastTyle = article.getChild(size - 1).structureType; 60 | if (lastTyle !== StructureType.content) { 61 | article.add(-1, editor.componentFactory.buildParagraph()); 62 | } 63 | } 64 | }); 65 | 66 | nextTick(() => { 67 | document.dispatchEvent(new Event("editorChange")); 68 | }); 69 | 70 | // 监听事件 71 | editorDom.addEventListener("blur", (event) => { 72 | try { 73 | operator.onBlur(event); 74 | } catch (e) { 75 | console.warn(e); 76 | } 77 | }); 78 | 79 | editorDom.addEventListener("click", (event) => { 80 | try { 81 | operator.onClick(event); 82 | } catch (e) { 83 | console.warn(e); 84 | } 85 | }); 86 | 87 | editorDom.addEventListener("dblclick", (event) => { 88 | try { 89 | operator.onDoubleClick(event); 90 | } catch (e) { 91 | console.warn(e); 92 | } 93 | }); 94 | 95 | editorDom.addEventListener("cut", (event) => { 96 | try { 97 | operator.onCut(event); 98 | } catch (e) { 99 | console.warn(e); 100 | } 101 | }); 102 | 103 | editorDom.addEventListener("paste", (event) => { 104 | console.info("仅可复制文本内容"); 105 | try { 106 | operator.onPaste(event); 107 | } catch (e) { 108 | console.warn(e); 109 | } 110 | }); 111 | 112 | editorDom.addEventListener("compositionstart", (event) => { 113 | let selection = getSelection(editor); 114 | if (!selection.isCollapsed) { 115 | deleteSelection(editor, selection.range[0], selection.range[1]); 116 | } 117 | }); 118 | 119 | editorDom.addEventListener("compositionend", (event) => { 120 | try { 121 | let selection = getSelection(editor); 122 | let start = { 123 | id: selection.range[0].id, 124 | offset: selection.range[0].offset - getUtf8TextLengthFromJsOffset(event.data), 125 | }; 126 | 127 | operator.onInput(event.data, start, event); 128 | } catch (e) { 129 | console.warn(e); 130 | } 131 | }); 132 | 133 | editorDom.addEventListener("beforeinput", (event: any) => { 134 | try { 135 | // 排除已经处理的输入 136 | if ( 137 | event.inputType === "insertCompositionText" || 138 | event.inputType === "deleteContentBackward" || 139 | !event.data 140 | ) { 141 | return; 142 | } 143 | 144 | let selection = getSelection(editor); 145 | if (!selection.isCollapsed) { 146 | deleteSelection(editor, selection.range[0], selection.range[1]); 147 | } 148 | } catch (e) { 149 | console.warn(e); 150 | } 151 | }); 152 | 153 | editorDom.addEventListener("input", (event: any) => { 154 | try { 155 | // 排除已经处理的输入 156 | if ( 157 | event.inputType === "insertCompositionText" || 158 | event.inputType === "deleteContentBackward" || 159 | !event.data 160 | ) { 161 | return; 162 | } 163 | 164 | let selection = getSelection(editor); 165 | let start = { 166 | id: selection.range[0].id, 167 | offset: selection.range[0].offset - getUtf8TextLengthFromJsOffset(event.data), 168 | }; 169 | 170 | operator.onInput(event.data, start, event); 171 | } catch (e) { 172 | console.warn(e); 173 | } 174 | }); 175 | 176 | editorDom.addEventListener("keydown", (event) => { 177 | try { 178 | operator.onKeyDown(event); 179 | } catch (e) { 180 | console.warn(e); 181 | } 182 | }); 183 | 184 | // 撤回和取消撤回,单独设置,设置在 iframe 上。 185 | iframe.contentDocument.addEventListener("keydown", (event) => { 186 | try { 187 | const key = event.key.toLowerCase(); 188 | if ("z" === key && (event.ctrlKey || event.metaKey)) { 189 | event.preventDefault(); 190 | if (event.shiftKey) { 191 | editor.historyManage.redo(); 192 | } else { 193 | editor.historyManage.undo(); 194 | } 195 | } 196 | } catch (e) { 197 | console.warn(e); 198 | } 199 | }); 200 | 201 | // 如果先前有选区控制选中 202 | editorDom.addEventListener("mousedown", (event) => { 203 | let focus = iframe.contentDocument?.hasFocus(); 204 | let selection = iframe.contentWindow?.getSelection(); 205 | 206 | if (!focus && !selection?.isCollapsed && selection?.anchorNode) { 207 | let editedDom = selection.anchorNode.parentElement; 208 | while (editedDom && editedDom?.contentEditable !== "true") { 209 | editedDom = editedDom.parentElement; 210 | } 211 | if (editedDom) { 212 | event.preventDefault(); 213 | editedDom.focus(); 214 | } 215 | } 216 | }); 217 | 218 | // 暂不支持拖拽 219 | editorDom.addEventListener("dragstart", (event) => { 220 | event.preventDefault(); 221 | }); 222 | 223 | editorDom.addEventListener("drop", (event) => { 224 | event.preventDefault(); 225 | }); 226 | 227 | if (afterCreate) { 228 | afterCreate(iframe.contentDocument, iframe.contentWindow); 229 | } 230 | }; 231 | 232 | iframe.addEventListener("load", loadIframe); 233 | return root; 234 | }; 235 | 236 | export default createEditor; 237 | -------------------------------------------------------------------------------- /src/editor/event.ts: -------------------------------------------------------------------------------- 1 | interface EventGroup { 2 | [eventName: string]: Function[]; 3 | } 4 | 5 | export default class Event { 6 | _events: EventGroup = {}; 7 | 8 | $on(eventName: string, fn: (event: T, ...rest: any[]) => void): this { 9 | if (!this._events[eventName]) { 10 | this._events[eventName] = []; 11 | } 12 | this._events[eventName].push(fn); 13 | return this; 14 | } 15 | 16 | $off(eventName?: string, fn?: Function) { 17 | if (!eventName) { 18 | this._events = {}; 19 | return this; 20 | } 21 | const cbs = this._events[eventName]; 22 | if (!cbs) { 23 | return this; 24 | } 25 | if (!fn) { 26 | this._events[eventName] = []; 27 | return this; 28 | } 29 | if (fn) { 30 | let cb; 31 | let i = cbs.length; 32 | while (i--) { 33 | cb = cbs[i]; 34 | // @ts-ignore 35 | if (cb === fn || cb.fn === fn) { 36 | cbs.splice(i, 1); 37 | break; 38 | } 39 | } 40 | } 41 | return this; 42 | } 43 | 44 | $emit(eventName: string, event?: T, ...rest: any[]) { 45 | let cbs = this._events[eventName]; 46 | if (cbs) { 47 | cbs.forEach((func) => func.call(this, event, ...rest)); 48 | } 49 | return this; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/editor/index.ts: -------------------------------------------------------------------------------- 1 | import Article from "../components/article"; 2 | import ContentView from "../view/dom-view"; 3 | import Operator from "../operator"; 4 | import ArticleManage from "./manage/article-manage"; 5 | import HistoryManage from "./manage/history-manage"; 6 | import StoreManage from "./manage/store-manage"; 7 | import createEditor from "./create-editor"; 8 | import ComponentFactory from "../factory"; 9 | import Component from "../components/component"; 10 | import Event from "./event"; 11 | 12 | export interface EditorOption { 13 | placeholder?: string; 14 | operator: typeof Operator; 15 | contentView: typeof ContentView; 16 | componentFactory: typeof ComponentFactory; 17 | updateComponent: (editor: Editor, ...componentList: Component[]) => void; 18 | beforeCreate?: (document: Document, window: Window | null) => void; 19 | afterCreate?: (document: Document, window: Window | null) => void; 20 | onError?: (error: Error) => void; 21 | } 22 | 23 | class Editor extends Event { 24 | mountedElement: HTMLElement; 25 | article!: Article; 26 | placeholder: string; 27 | 28 | mountedWindow!: Window; 29 | mountedDocument!: Document; 30 | 31 | userOperator: Operator; 32 | componentFactory: ComponentFactory; 33 | contentView: ContentView; 34 | 35 | storeManage: StoreManage; 36 | historyManage: HistoryManage; 37 | articleManage: ArticleManage; 38 | updateComponent: (editor: Editor, ...componentList: Component[]) => void; 39 | 40 | constructor(idOrElement: string | HTMLElement, option: EditorOption) { 41 | super(); 42 | 43 | if (typeof idOrElement === "string") { 44 | let dom = document.getElementById(idOrElement); 45 | if (!dom) { 46 | throw Error("请传入正确的节点或节点 id"); 47 | } 48 | this.mountedElement = dom; 49 | } else { 50 | this.mountedElement = idOrElement; 51 | } 52 | 53 | this.placeholder = option.placeholder || ""; 54 | this.updateComponent = option.updateComponent; 55 | 56 | this.userOperator = new option.operator(this); 57 | this.contentView = new option.contentView(this); 58 | this.componentFactory = new option.componentFactory(this); 59 | 60 | this.storeManage = new StoreManage(this); 61 | this.historyManage = new HistoryManage(this); 62 | this.articleManage = new ArticleManage(this); 63 | 64 | createEditor( 65 | this.mountedElement, 66 | this, 67 | (document: Document, window: Window) => { 68 | this.mountedDocument = document; 69 | this.mountedWindow = window; 70 | option.beforeCreate?.(document, window); 71 | }, 72 | option.afterCreate, 73 | ); 74 | } 75 | 76 | init(article: Article) { 77 | article.active = true; 78 | article.setEditor(this); 79 | this.article = article; 80 | this.articleManage.init(); 81 | this.historyManage.init(); 82 | this.storeManage.init(); 83 | } 84 | } 85 | 86 | export default Editor; 87 | -------------------------------------------------------------------------------- /src/editor/manage/article-manage.ts: -------------------------------------------------------------------------------- 1 | import Editor from ".."; 2 | import Component, { JSONType } from "../../components/component"; 3 | import Article from "../../components/article"; 4 | import { nextTick } from "../../util"; 5 | 6 | class ArticleManage { 7 | editor: Editor; 8 | update: boolean; 9 | 10 | constructor(editor: Editor) { 11 | this.editor = editor; 12 | this.update = false; 13 | } 14 | 15 | init() { 16 | this.update = true; 17 | let editorDom = this.editor.mountedDocument.getElementById("zebra-editor-contain"); 18 | editorDom?.appendChild(this.editor.article.render(this.editor.contentView)); 19 | nextTick(() => { 20 | document.dispatchEvent(new Event("editorChange")); 21 | }); 22 | 23 | this.editor.$on("updateComponent", (componentList: Component[]) => { 24 | if (this.update) { 25 | this.editor.updateComponent(this.editor, ...componentList); 26 | } 27 | }); 28 | } 29 | 30 | stopUpdate() { 31 | this.update = false; 32 | } 33 | 34 | startUpdate() { 35 | this.update = true; 36 | } 37 | 38 | createEmpty() { 39 | let article = this.editor.componentFactory.buildArticle(); 40 | article.add(0, this.editor.componentFactory.buildParagraph()); 41 | return article; 42 | } 43 | 44 | createByJSON(json: JSONType) { 45 | if (!json.type) return this.createEmpty(); 46 | return this.editor.componentFactory.typeMap[json.type].create( 47 | this.editor.componentFactory, 48 | json, 49 | ); 50 | } 51 | 52 | newArticle(article?: JSONType | Article) { 53 | let editorDom = this.editor.mountedDocument.getElementById("zebra-editor-contain"); 54 | if (!editorDom) return; 55 | editorDom.innerHTML = ""; 56 | this.editor?.article.destory(); 57 | let newArticle: Article; 58 | 59 | if (typeof article === "string") { 60 | newArticle = this.createByJSON(article); 61 | } else if (!article) { 62 | newArticle = this.createEmpty(); 63 | } else { 64 | newArticle = article as Article; 65 | } 66 | 67 | this.editor.init(newArticle); 68 | 69 | editorDom.appendChild(newArticle.render(this.editor.contentView)); 70 | nextTick(() => { 71 | document.dispatchEvent(new Event("editorChange")); 72 | }); 73 | } 74 | 75 | save() { 76 | const article = this.editor.article; 77 | // 空文章不做存储,示例文章不做存储 78 | if (article.isEmpty() || /^demo/.test(article.id)) return; 79 | 80 | localStorage.setItem("zebra-editor-article-temp", JSON.stringify(article.getJSON())); 81 | } 82 | 83 | flush() { 84 | let editorDom = this.editor.mountedDocument.getElementById("zebra-editor-contain"); 85 | if (!editorDom) return; 86 | editorDom.innerHTML = ""; 87 | editorDom.appendChild(this.editor.article.render(this.editor.contentView)); 88 | } 89 | } 90 | 91 | export default ArticleManage; 92 | -------------------------------------------------------------------------------- /src/editor/manage/history-manage.ts: -------------------------------------------------------------------------------- 1 | import Editor from ".."; 2 | import Component from "../../components/component"; 3 | import { Cursor } from "../../selection/util"; 4 | import focusAt from "../../selection/focus-at"; 5 | import getSelection from "../../selection/get-selection"; 6 | import { walkTree } from "../../util"; 7 | 8 | interface recoreType { 9 | componentList: Map; 10 | startSelection: { 11 | start: Cursor; 12 | end: Cursor; 13 | }; 14 | endSelection: { 15 | start: Cursor; 16 | end: Cursor; 17 | }; 18 | } 19 | 20 | class HistoryManage { 21 | editor: Editor; 22 | // 历史栈 23 | recordStack: recoreType[] = []; 24 | // 当前编辑态栈的位置 25 | nowStackIndex: number = 0; 26 | // 最新的历史栈 27 | nowRecordStack!: recoreType; 28 | // 是否在一次 eventLoop 中 29 | inLoop = false; 30 | 31 | constructor(editor: Editor) { 32 | this.editor = editor; 33 | } 34 | 35 | init() { 36 | this.recordStack = []; 37 | this.nowStackIndex = -1; 38 | this.createRecordStack(); 39 | 40 | walkTree(this.editor.article, (each) => { 41 | each.record.store(this.nowStackIndex); 42 | }); 43 | 44 | this.editor.$on("componentWillChange", () => { 45 | this.createRecord(); 46 | }); 47 | 48 | this.editor.$on("updateComponent", (componentList: Component[]) => { 49 | componentList.forEach((each) => this.recordSnapshoot(each)); 50 | }); 51 | } 52 | 53 | createRecordStack() { 54 | this.nowRecordStack = { 55 | componentList: new Map(), 56 | startSelection: { 57 | start: { id: "", offset: -1 }, 58 | end: { id: "", offset: -1 }, 59 | }, 60 | endSelection: { 61 | start: { id: "", offset: -1 }, 62 | end: { id: "", offset: -1 }, 63 | }, 64 | }; 65 | this.recordStack.push(this.nowRecordStack); 66 | this.nowStackIndex += 1; 67 | } 68 | 69 | createRecord() { 70 | if (this.inLoop) return; 71 | 72 | // 清除历史快照,重做后在进行编辑会触发该情况 73 | if (this.nowStackIndex < this.recordStack.length) { 74 | // 清除组件内无效的历史快照 75 | for (let i = this.nowStackIndex + 1; i < this.recordStack.length; i++) { 76 | let componentList = this.recordStack[i].componentList; 77 | componentList.forEach((each) => { 78 | each.record.clear(this.nowStackIndex + 1); 79 | }); 80 | } 81 | // 清除全局历史栈中无效的历史 82 | this.recordStack.splice(this.nowStackIndex + 1); 83 | } 84 | 85 | this.createRecordStack(); 86 | this.inLoop = true; 87 | let selection = getSelection(this.editor); 88 | this.nowRecordStack.startSelection = { 89 | start: selection.range[0], 90 | end: selection.range[1], 91 | }; 92 | 93 | setTimeout(() => { 94 | this.inLoop = false; 95 | let selection = getSelection(this.editor); 96 | this.nowRecordStack.endSelection = { 97 | start: selection.range[0], 98 | end: selection.range[1], 99 | }; 100 | }); 101 | } 102 | 103 | recordSnapshoot(component: Component) { 104 | component.record.store(this.nowStackIndex); 105 | this.nowRecordStack.componentList.set(component.id, component); 106 | } 107 | 108 | canUndo() { 109 | return this.nowStackIndex !== 0; 110 | } 111 | 112 | canRedo() { 113 | return this.nowStackIndex !== this.recordStack.length - 1; 114 | } 115 | 116 | undo() { 117 | if (!this.canUndo()) return; 118 | let nowRecord = this.recordStack[this.nowStackIndex]; 119 | nowRecord.componentList.forEach((each) => { 120 | each.record.restore(this.nowStackIndex - 1); 121 | }); 122 | this.editor.updateComponent(this.editor, ...nowRecord.componentList.values()); 123 | this.nowStackIndex -= 1; 124 | focusAt(this.editor, nowRecord.startSelection.start, nowRecord.startSelection.end); 125 | } 126 | 127 | redo() { 128 | if (!this.canRedo()) return; 129 | let nowRecord = this.recordStack[this.nowStackIndex + 1]; 130 | nowRecord.componentList.forEach((each) => { 131 | each.record.restore(this.nowStackIndex + 1); 132 | }); 133 | this.editor.updateComponent(this.editor, ...nowRecord.componentList.values()); 134 | this.nowStackIndex += 1; 135 | focusAt(this.editor, nowRecord.endSelection.start, nowRecord.endSelection.end); 136 | } 137 | } 138 | 139 | export default HistoryManage; 140 | -------------------------------------------------------------------------------- /src/editor/manage/store-manage.ts: -------------------------------------------------------------------------------- 1 | import Editor from ".."; 2 | import Block from "../../components/block"; 3 | import { walkTree } from "../../util"; 4 | 5 | class StoreManage { 6 | editor: Editor; 7 | blockStore: { [key: string]: Block } = {}; 8 | 9 | constructor(editor: Editor) { 10 | this.editor = editor; 11 | } 12 | 13 | init() { 14 | this.blockStore = {}; 15 | walkTree(this.editor.article, (each) => { 16 | this.blockStore[each.id] = each; 17 | }); 18 | 19 | this.editor.$on("blockCreated", (block: Block) => { 20 | this.blockStore[block.id] = block; 21 | }); 22 | } 23 | 24 | getBlockById(id: string): Block { 25 | return this.blockStore[id]; 26 | } 27 | } 28 | 29 | export default StoreManage; 30 | -------------------------------------------------------------------------------- /src/factory/index.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import { 3 | ComponentType, 4 | Article, 5 | Table, 6 | Heading, 7 | Paragraph, 8 | Media, 9 | CodeBlock, 10 | InlineImage, 11 | ListEnum, 12 | HeadingEnum, 13 | MediaType, 14 | CustomCollection, 15 | List, 16 | } from "../components"; 17 | 18 | class ComponentFactory { 19 | static bulider: ComponentFactory; 20 | static getInstance(editor?: Editor) { 21 | if (!this.bulider) { 22 | this.bulider = new ComponentFactory(editor); 23 | } 24 | return this.bulider; 25 | } 26 | 27 | typeMap: { [key: string]: any }; 28 | 29 | editor?: Editor; 30 | 31 | constructor(editor?: Editor) { 32 | this.editor = editor; 33 | this.typeMap = { 34 | [ComponentType.article]: Article, 35 | [ComponentType.list]: List, 36 | [ComponentType.table]: Table, 37 | [ComponentType.heading]: Heading, 38 | [ComponentType.paragraph]: Paragraph, 39 | [ComponentType.media]: Media, 40 | [ComponentType.codeBlock]: CodeBlock, 41 | [ComponentType.inlineImage]: InlineImage, 42 | [ComponentType.customerCollection]: CustomCollection, 43 | }; 44 | } 45 | 46 | buildArticle() { 47 | return new Article(this.editor); 48 | } 49 | 50 | buildCustomCollection(tag: string = "div", children: string[] = []) { 51 | return new CustomCollection(tag, children, this.editor); 52 | } 53 | 54 | buildList(type: ListEnum = ListEnum.ul, children: string[] = []) { 55 | return new List(type, children, this.editor); 56 | } 57 | 58 | buildTable( 59 | row: number, 60 | col: number, 61 | head: string[] = [], 62 | children: (string[] | string)[][] = [], 63 | ) { 64 | return new Table(row, col, head, children, this.editor); 65 | } 66 | 67 | buildHeading(type: HeadingEnum, text?: string) { 68 | return new Heading(type, text, this.editor); 69 | } 70 | 71 | buildParagraph(text?: string) { 72 | return new Paragraph(text, this.editor); 73 | } 74 | 75 | buildMedia(mediaType: MediaType, src: string) { 76 | return new Media(mediaType, src, this.editor); 77 | } 78 | 79 | buildCode(content: string = "", language: string = "") { 80 | return new CodeBlock(content, language, this.editor); 81 | } 82 | 83 | buildInlineImage(src: string) { 84 | return new InlineImage(src); 85 | } 86 | } 87 | 88 | export default ComponentFactory; 89 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import ComponentFactory from "./factory"; 2 | 3 | import DomView from "./view/dom-view"; 4 | import HtmlView from "./view/html-view"; 5 | 6 | import Operator from "./operator"; 7 | 8 | import focusAt from "./selection/focus-at"; 9 | import getSelection from "./selection/get-selection"; 10 | import getSelectedIdList from "./selection/get-selected-id-list"; 11 | import insertBlock from "./selection/insert-block"; 12 | import insertInline from "./selection/insert-inline"; 13 | import modifyDecorate from "./selection/modify-decorate"; 14 | import modifySelectionDecorate from "./selection/modify-selection-decorate"; 15 | import modifyTable from "./selection/modify-table"; 16 | import modifyIndent from "./selection/modify-indent"; 17 | import exchange from "./selection/exchange"; 18 | 19 | import updateComponent from "./util/update-component"; 20 | import { nextTick } from "./util"; 21 | 22 | import Editor from "./editor"; 23 | import StructureType from "./const/structure-type"; 24 | import { Cursor } from "./selection/util"; 25 | import deleteSelection from "./operator/delete-selection"; 26 | 27 | export * from "./components"; 28 | 29 | export { 30 | Editor, 31 | ComponentFactory, 32 | DomView, 33 | HtmlView, 34 | Operator, 35 | StructureType, 36 | Cursor, 37 | focusAt, 38 | getSelection, 39 | deleteSelection, 40 | getSelectedIdList, 41 | insertBlock, 42 | insertInline, 43 | modifyDecorate, 44 | modifySelectionDecorate, 45 | modifyTable, 46 | modifyIndent, 47 | exchange, 48 | nextTick, 49 | updateComponent, 50 | }; 51 | -------------------------------------------------------------------------------- /src/operator/backspace.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import StructureType from "../const/structure-type"; 3 | import focusAt from "../selection/focus-at"; 4 | import { Cursor } from "../selection/util"; 5 | import deleteSelection from "./delete-selection"; 6 | 7 | // 删除:删除 start 到 end 的内容 8 | const backspace = ( 9 | editor: Editor, 10 | start: Cursor, 11 | end: Cursor = start, 12 | event?: KeyboardEvent | CompositionEvent, 13 | ) => { 14 | // 光标状态时处理,默认删除前一个字符 15 | if (start.id === end.id && start.offset === end.offset) { 16 | let block = editor.storeManage.getBlockById(start.id); 17 | 18 | // 优化段落内删除逻辑,不需要整段更新 19 | if (event && [StructureType.content, StructureType.plainText].includes(block.structureType)) { 20 | // 当删除发生在首位时,需要强制更新 21 | if (start.offset <= 1) { 22 | event?.preventDefault(); 23 | let operator = block.remove(start.offset - 1, start.offset); 24 | return focusAt(editor, operator?.[0] || start, operator?.[1]); 25 | } 26 | 27 | editor.articleManage.stopUpdate(); 28 | block.remove(start.offset - 1, start.offset); 29 | editor.articleManage.startUpdate(); 30 | return; 31 | } 32 | 33 | // 非文字组件删除需要强制更新 34 | event?.preventDefault(); 35 | let operator = block.remove(start.offset, start.offset + 1); 36 | return focusAt(editor, operator?.[0] || start, operator?.[1]); 37 | } 38 | 39 | // 选区状态时处理,需要阻止默认行为 40 | event?.preventDefault(); 41 | deleteSelection(editor, start, end); 42 | }; 43 | 44 | export default backspace; 45 | -------------------------------------------------------------------------------- /src/operator/delete-selection.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import focusAt from "../selection/focus-at"; 3 | import getSelectedIdList from "../selection/get-selected-id-list"; 4 | import { Cursor } from "../selection/util"; 5 | 6 | // 删除 start 到 end 的内容 7 | const deleteSelection = (editor: Editor, start: Cursor, end?: Cursor) => { 8 | // 没有选区不操作 9 | if (!end || (start.id === end.id && start.offset === end.offset)) { 10 | return; 11 | } 12 | 13 | let idList = getSelectedIdList(editor.article, start.id, end.id); 14 | // 选中多行 15 | if (idList.length === 0) return; 16 | 17 | if (idList.length === 1) { 18 | let block = editor.storeManage.getBlockById(idList[0]); 19 | let operator = block.remove(start.offset, end.offset); 20 | return focusAt(editor, operator?.[0] || start, operator?.[1]); 21 | } 22 | 23 | let headBlock = editor.storeManage.getBlockById(idList[0]); 24 | let tailBlock = editor.storeManage.getBlockById(idList[idList.length - 1]); 25 | 26 | // 删除选中内容 27 | headBlock.remove(start.offset, -1); 28 | for (let i = 1; i < idList.length - 1; i++) { 29 | editor.storeManage.getBlockById(idList[i]).removeSelf(); 30 | } 31 | tailBlock.remove(0, end.offset); 32 | 33 | // 首尾行合并 34 | tailBlock.sendTo(headBlock); 35 | 36 | return focusAt(editor, { 37 | id: headBlock.id, 38 | offset: start.offset, 39 | }); 40 | }; 41 | 42 | export default deleteSelection; 43 | -------------------------------------------------------------------------------- /src/operator/enter.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import focusAt from "../selection/focus-at"; 3 | import getSelectedIdList from "../selection/get-selected-id-list"; 4 | import { Cursor } from "../selection/util"; 5 | 6 | // 在 start - end 处换行 7 | const enter = (editor: Editor, start: Cursor, end: Cursor = start, event?: KeyboardEvent) => { 8 | event?.preventDefault(); 9 | let idList = getSelectedIdList(editor.article, start.id, end.id); 10 | if (idList.length === 0) return; 11 | 12 | if (idList.length === 1) { 13 | let component = editor.storeManage.getBlockById(idList[0]); 14 | component.remove(start.offset, end.offset); 15 | let operator = component.split(start.offset); 16 | return focusAt(editor, operator?.[0] || start, operator?.[1]); 17 | } 18 | 19 | // 选中多行 20 | let firstComponent = editor.storeManage.getBlockById(idList[0]); 21 | let lastComponent = editor.storeManage.getBlockById(idList[idList.length - 1]); 22 | firstComponent.remove(start.offset); 23 | lastComponent.remove(0, end.offset); 24 | for (let i = 1; i < idList.length - 1; i++) { 25 | editor.storeManage.getBlockById(idList[i]).removeSelf(); 26 | } 27 | focusAt(editor, { 28 | id: lastComponent.id, 29 | offset: 0, 30 | }); 31 | return; 32 | }; 33 | 34 | export default enter; 35 | -------------------------------------------------------------------------------- /src/operator/index.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import DirectionType from "../const/direction-type"; 3 | import getSelection from "../selection/get-selection"; 4 | import { Cursor } from "../selection/util"; 5 | import input from "./input"; 6 | import paste from "./paste"; 7 | import enter from "./enter"; 8 | import backspace from "./backspace"; 9 | import focusAt from "../selection/focus-at"; 10 | import { nextTick } from "../util"; 11 | 12 | class Operator { 13 | isFireFox: boolean = navigator.userAgent.indexOf("Firefox") > -1; 14 | 15 | editor: Editor; 16 | 17 | constructor(editor: Editor) { 18 | this.editor = editor; 19 | } 20 | 21 | onBlur(event: FocusEvent) {} 22 | 23 | onClick(event: MouseEvent) { 24 | // 修复点击图片未选中图片的问题 25 | let section = this.editor.mountedWindow.getSelection(); 26 | let target = event.target as HTMLElement; 27 | if (target.nodeName === "IMG") { 28 | section?.removeAllRanges(); 29 | let range = new Range(); 30 | range.selectNode(target); 31 | section?.addRange(range); 32 | } 33 | 34 | nextTick(() => { 35 | document.dispatchEvent(new Event("editorChange")); 36 | }); 37 | } 38 | 39 | onDoubleClick(event: MouseEvent) {} 40 | 41 | onInput(text: string, start: Cursor, event: KeyboardEvent | CompositionEvent | InputEvent) { 42 | input(this.editor, text, start, event); 43 | } 44 | 45 | onCut(event: ClipboardEvent) { 46 | let selection = getSelection(this.editor); 47 | setTimeout(() => { 48 | backspace(this.editor, selection.range[0], selection.range[1]); 49 | }, 30); 50 | } 51 | 52 | onPaste(event: ClipboardEvent) { 53 | paste(this.editor, event); 54 | } 55 | 56 | onSave() { 57 | this.editor.articleManage.save(); 58 | } 59 | 60 | onTab(start: Cursor, end: Cursor, event: KeyboardEvent) {} 61 | 62 | onEnter(start: Cursor, end: Cursor, event: KeyboardEvent) { 63 | enter(this.editor, start, end, event); 64 | } 65 | 66 | onBackspace(start: Cursor, end: Cursor, event: KeyboardEvent) { 67 | backspace(this.editor, start, end, event); 68 | } 69 | 70 | onArraw(direction: DirectionType, event: KeyboardEvent) { 71 | nextTick(() => { 72 | document.dispatchEvent(new Event("editorChange")); 73 | }); 74 | } 75 | 76 | onCommand(start: Cursor, end: Cursor, key: string, shift: boolean, event: KeyboardEvent) { 77 | if (key === "enter") { 78 | let component = this.editor.storeManage.getBlockById(start.id); 79 | let operator = component.addEmptyParagraph(!shift); 80 | focusAt(this.editor, operator?.[0] || start, operator?.[1]); 81 | return; 82 | } 83 | 84 | // 撤销与取消撤销代理到 iframe 上,避免工具栏撤销失效 85 | if (key === "z") { 86 | return; 87 | } 88 | 89 | // 全选、复制、剪切、黏贴无需控制 90 | if (!shift && ["a", "c", "x", "v"].includes(key)) { 91 | return; 92 | } 93 | 94 | event.preventDefault(); 95 | 96 | // 保存 97 | if (key === "s") { 98 | this.onSave(); 99 | return; 100 | } 101 | } 102 | 103 | onKeyDown(event: KeyboardEvent) { 104 | const key = event.key.toLowerCase(); 105 | let { 106 | range: [start, end], 107 | } = getSelection(this.editor); 108 | 109 | // 非功能按键放过 110 | if (event.ctrlKey || event.metaKey) { 111 | this.onCommand(start, end, key, event.shiftKey, event); 112 | return; 113 | } 114 | 115 | // tab 116 | if (key === "tab") { 117 | this.onTab(start, end, event); 118 | return; 119 | } 120 | 121 | // 换行 122 | if (key === "enter") { 123 | this.onEnter(start, end, event); 124 | return; 125 | } 126 | 127 | // 删除 128 | if (key === "backspace") { 129 | this.onBackspace(start, end, event); 130 | return; 131 | } 132 | 133 | // 方向键 134 | if (/^arrow/i.test(event.key)) { 135 | let map = { 136 | ArrowUp: DirectionType.up, 137 | ArrowDown: DirectionType.down, 138 | ArrowLeft: DirectionType.left, 139 | ArrowRight: DirectionType.right, 140 | }; 141 | this.onArraw(map[event.key], event); 142 | return; 143 | } 144 | } 145 | } 146 | 147 | export default Operator; 148 | -------------------------------------------------------------------------------- /src/operator/input.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import Inline from "../components/inline"; 3 | import Character from "../components/character"; 4 | import focusAt from "../selection/focus-at"; 5 | import { getCursorPosition, Cursor } from "../selection/util"; 6 | import ContentCollection from "../components/content-collection"; 7 | import { getUtf8TextLengthFromJsOffset } from "../util/text-util"; 8 | 9 | const input = ( 10 | editor: Editor, 11 | charOrInline: string | Inline, 12 | start: Cursor, 13 | event?: KeyboardEvent | CompositionEvent | InputEvent, 14 | ) => { 15 | try { 16 | let block = editor.storeManage.getBlockById(start.id); 17 | let offset = start.offset; 18 | let startPosition = getCursorPosition(editor.mountedWindow, start); 19 | if (!startPosition) return; 20 | let startNode = startPosition.node; 21 | 22 | // 样式边缘的空格,逃脱默认样式,优化体验 23 | if ( 24 | block instanceof ContentCollection && 25 | charOrInline === " " && 26 | (startPosition.index <= 0 || 27 | startPosition.index >= getUtf8TextLengthFromJsOffset(startNode.nodeValue) - 1) 28 | ) { 29 | charOrInline = new Character(charOrInline); 30 | } 31 | 32 | // 强制更新 33 | if ( 34 | start.offset === 0 || 35 | startNode.nodeName === "BR" || 36 | startNode.nodeName === "IMG" || 37 | typeof charOrInline !== "string" || 38 | startPosition.index === 0 || 39 | startPosition.index >= getUtf8TextLengthFromJsOffset(startNode.nodeValue) - 1 || 40 | (!event && typeof charOrInline === "string") || 41 | event?.defaultPrevented 42 | ) { 43 | event?.preventDefault(); 44 | let operator = block.add(offset, charOrInline); 45 | focusAt(editor, operator?.[0] || start, operator?.[1]); 46 | return; 47 | } 48 | 49 | // 普通的文字输入,不需要强制更新,默认行为不会破坏文档结构 50 | charOrInline = 51 | typeof charOrInline === "string" ? charOrInline.replace(/\n/g, "") : charOrInline; 52 | 53 | editor.articleManage.stopUpdate(); 54 | block.add(offset, charOrInline); 55 | editor.articleManage.startUpdate(); 56 | } catch (e) { 57 | console.warn(e); 58 | } 59 | }; 60 | 61 | export default input; 62 | -------------------------------------------------------------------------------- /src/operator/paste.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import StructureType from "../const/structure-type"; 3 | import getSelection from "../selection/get-selection"; 4 | import deleteSelection from "./delete-selection"; 5 | import focusAt from "../selection/focus-at"; 6 | 7 | // 复制内容 8 | const paste = (editor: Editor, event: ClipboardEvent) => { 9 | event.preventDefault(); 10 | // 获取复制的内容 11 | let copyInData = event.clipboardData?.getData("text/plain"); 12 | if (!copyInData) return; 13 | let rowData = copyInData.split("\n"); 14 | if (rowData.length === 0) return; 15 | 16 | // 移除选中区域 17 | let selection = getSelection(editor); 18 | if (!selection.isCollapsed) { 19 | deleteSelection(editor, selection.range[0], selection.range[1]); 20 | selection = getSelection(editor); 21 | } 22 | 23 | let nowComponent = editor.storeManage.getBlockById(selection.range[0].id); 24 | let start = selection.range[0]; 25 | 26 | // 纯文本组件直接输入即可 27 | if (nowComponent.type === StructureType.plainText) { 28 | let operator = nowComponent.add(start.offset, rowData.join("\n")); 29 | focusAt(editor, operator?.[0] || start, operator?.[1]); 30 | return; 31 | } 32 | 33 | // 过滤掉空行 34 | rowData = rowData.filter((each) => each.trim().length !== 0); 35 | 36 | let operator = nowComponent.add(start.offset, rowData[0]); 37 | if (rowData.length === 1) { 38 | return focusAt(editor, operator?.[0] || start, operator?.[1]); 39 | } 40 | 41 | let list = []; 42 | for (let i = 1; i < rowData.length; i++) { 43 | list.push(editor.componentFactory.buildParagraph(rowData[i])); 44 | } 45 | operator = nowComponent.split(start.offset + rowData[0].length, ...list); 46 | focusAt(editor, { 47 | id: list[0].id, 48 | offset: rowData[rowData.length - 1].length, 49 | }); 50 | return; 51 | }; 52 | 53 | export default paste; 54 | -------------------------------------------------------------------------------- /src/record/index.ts: -------------------------------------------------------------------------------- 1 | import Component, { Snapshoot } from "../components/component"; 2 | 3 | class Record { 4 | component: Component; 5 | recordMap: Snapshoot[] = []; 6 | 7 | constructor(component: Component) { 8 | this.component = component; 9 | this.recordMap = []; 10 | } 11 | 12 | store(stepId: number) { 13 | this.recordMap[stepId] = this.component.snapshoot(); 14 | } 15 | 16 | restore(stepId: number) { 17 | // 找到最近的一个节点解析更新 18 | while (!this.recordMap[stepId] && stepId >= 0) { 19 | stepId--; 20 | } 21 | if (stepId < 0) return; 22 | 23 | this.component.restore(this.recordMap[stepId]); 24 | } 25 | 26 | clear(stepId: number) { 27 | this.recordMap.splice(stepId); 28 | } 29 | } 30 | 31 | export default Record; 32 | -------------------------------------------------------------------------------- /src/selection/exchange.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import Block, { BlockType } from "../components/block"; 3 | import getSelection from "./get-selection"; 4 | import focusAt from "./focus-at"; 5 | import getSelectedIdList from "./get-selected-id-list"; 6 | 7 | // 修改选区中整块内容的呈现 8 | const exchange = (editor: Editor, newBlock: BlockType, ...args: any[]) => { 9 | let selection = getSelection(editor); 10 | let start = selection.range[0]; 11 | let end = selection.range[1]; 12 | try { 13 | let idList = getSelectedIdList(editor.article, start.id, end.id); 14 | let endToTailSize = 15 | editor.storeManage.getBlockById(idList[idList.length - 1]).getSize() - end.offset; 16 | 17 | let exchangeList: Block[] = []; 18 | let idMap: { [key: string]: number } = {}; 19 | 20 | // 获取转换后的组件 21 | idList.forEach((id) => { 22 | editor.storeManage 23 | .getBlockById(id) 24 | .exchangeTo(newBlock, args) 25 | .forEach((each) => { 26 | if (!idMap[each.id]) { 27 | idMap[each.id] = 1; 28 | exchangeList.push(each); 29 | } 30 | }); 31 | }); 32 | 33 | let nowStart = { id: "", offset: start.offset }; 34 | let nowEnd = { id: "", offset: endToTailSize }; 35 | 36 | // 获得光标开始位置 37 | let index = 0; 38 | while (index < exchangeList.length) { 39 | let component = exchangeList[index]; 40 | let size = component.getSize(); 41 | if (nowStart.offset <= size) { 42 | nowStart.id = component.id; 43 | break; 44 | } 45 | nowStart.offset -= size; 46 | index += 1; 47 | } 48 | 49 | // 获得光标结束位置 50 | let tailIndex = exchangeList.length - 1; 51 | while (tailIndex >= 0) { 52 | let component = exchangeList[tailIndex]; 53 | let size = component.getSize(); 54 | if (nowEnd.offset <= size) { 55 | nowEnd.id = component.id; 56 | nowEnd.offset = size - nowEnd.offset; 57 | break; 58 | } 59 | nowEnd.offset -= size; 60 | tailIndex -= 1; 61 | } 62 | 63 | focusAt(editor, nowStart, nowEnd); 64 | } catch (err) { 65 | console.warn(err); 66 | } 67 | }; 68 | 69 | export default exchange; 70 | -------------------------------------------------------------------------------- /src/selection/focus-at.ts: -------------------------------------------------------------------------------- 1 | import { Cursor, getCursorPosition } from "./util"; 2 | import { getJsTextLengthFromUtf8Offset } from "../util/text-util"; 3 | import Editor from "../editor"; 4 | import { nextTick } from "../util"; 5 | 6 | // 选中 start 到 end 的内容 7 | const focusAt = (editor: Editor, start?: Cursor, end?: Cursor) => { 8 | if (!start) return; 9 | let contentWindow = editor.mountedWindow; 10 | try { 11 | // id 为空字符,说明刚初始化,不进行 focus 12 | if (start.id === "") return; 13 | if (!end) { 14 | end = { id: start.id, offset: start.offset }; 15 | } 16 | 17 | let block = contentWindow.document.getElementById(start.id); 18 | if (!block) return; 19 | 20 | block?.scrollIntoView({ 21 | behavior: "smooth", 22 | block: "nearest", 23 | }); 24 | 25 | start.offset = start.offset === -1 ? 0 : start.offset; 26 | end.offset = end.offset === -1 ? 0 : end.offset; 27 | let startPosition = getCursorPosition(contentWindow, start); 28 | if (!startPosition) return; 29 | let endPosition = startPosition; 30 | if (end) { 31 | let temp = getCursorPosition(contentWindow, end); 32 | if (temp) { 33 | endPosition = temp; 34 | } 35 | } 36 | focusNode(contentWindow, startPosition, endPosition); 37 | } catch (e) { 38 | console.warn(e); 39 | let rootDom = contentWindow.document.getElementById("zebra-editor-contain"); 40 | rootDom?.blur(); 41 | } 42 | }; 43 | 44 | type FocusNodeType = { 45 | node: Element | Node; 46 | index: number; 47 | }; 48 | 49 | // 从开始节点的某处,选到接收节点的某处 50 | const focusNode = (contentWindow: Window, start: FocusNodeType, end: FocusNodeType = start) => { 51 | let doc = contentWindow.document; 52 | let section = contentWindow.getSelection(); 53 | section?.removeAllRanges(); 54 | let range = doc.createRange(); 55 | 56 | if ( 57 | start.node.nodeName === "IMG" || 58 | start.node.nodeName === "AUDIO" || 59 | start.node.nodeName === "VIDEO" || 60 | (start.node as HTMLElement).contentEditable === "false" 61 | ) { 62 | if (start.index === 0) { 63 | range.setStartBefore(start.node); 64 | } 65 | if (start.index === 1) { 66 | range.setStartAfter(start.node); 67 | } 68 | } else { 69 | range.setStart(start.node, getJsTextLengthFromUtf8Offset(start.node.textContent, start.index)); 70 | } 71 | 72 | if ( 73 | end.node.nodeName === "IMG" || 74 | end.node.nodeName === "AUDIO" || 75 | end.node.nodeName === "VIDEO" || 76 | (end.node as HTMLElement).contentEditable === "false" 77 | ) { 78 | if (end.index === 0) { 79 | range.setEndBefore(end.node); 80 | } 81 | if (end.index === 1) { 82 | range.setEndAfter(end.node); 83 | } 84 | } else { 85 | range.setEnd(end.node, getJsTextLengthFromUtf8Offset(end.node.textContent, end.index)); 86 | } 87 | 88 | section?.addRange(range); 89 | if (!doc.hasFocus()) { 90 | let contentEdit = start.node.parentElement; 91 | while (contentEdit && contentEdit?.contentEditable !== "true") { 92 | contentEdit = contentEdit?.parentElement; 93 | } 94 | if (contentEdit) { 95 | contentEdit.focus(); 96 | } 97 | } 98 | 99 | nextTick(() => { 100 | document.dispatchEvent(new Event("editorChange")); 101 | }); 102 | }; 103 | 104 | export default focusAt; 105 | 106 | export { focusNode }; 107 | -------------------------------------------------------------------------------- /src/selection/get-selected-id-list.ts: -------------------------------------------------------------------------------- 1 | import Article from "../components/article"; 2 | import Block from "../components/block"; 3 | import StructureCollection from "../components/structure-collection"; 4 | 5 | const walkCollection = ( 6 | structureCollection: StructureCollection, 7 | callback: (block: Block) => boolean, 8 | ) => { 9 | for (let i = 0, len = structureCollection.getSize(); i < len; i++) { 10 | let each = structureCollection.getChild(i); 11 | if (each instanceof StructureCollection) { 12 | if (walkCollection(each, callback)) { 13 | return true; 14 | } 15 | } else { 16 | if (callback(each)) { 17 | return true; 18 | } 19 | } 20 | } 21 | return false; 22 | }; 23 | 24 | const getSelectedIdList = ( 25 | article: Article, 26 | startId: string, 27 | endId: string = startId, 28 | ): string[] => { 29 | if (startId === "") return []; 30 | if (startId === endId) return [startId]; 31 | let findStart = false; 32 | let selectedId: string[] = []; 33 | walkCollection(article, (block: Block) => { 34 | if (findStart || startId === block.id) { 35 | selectedId.push(block.id); 36 | findStart = true; 37 | return endId === block.id; 38 | } 39 | return false; 40 | }); 41 | return selectedId; 42 | }; 43 | 44 | export default getSelectedIdList; 45 | -------------------------------------------------------------------------------- /src/selection/get-selection.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from "lodash"; 2 | import Editor from "../editor"; 3 | import { getUtf8TextLengthFromJsOffset } from "../util/text-util"; 4 | import { getElememtSize, getContainer, Cursor, getCursorElement, getOffset } from "./util"; 5 | 6 | export interface selectionType { 7 | isCollapsed: boolean; 8 | range: [Cursor, Cursor]; 9 | } 10 | 11 | let selectionStore: selectionType = { 12 | isCollapsed: true, 13 | range: [ 14 | { 15 | id: "", 16 | offset: 0, 17 | }, 18 | { 19 | id: "", 20 | offset: 0, 21 | }, 22 | ], 23 | }; 24 | 25 | // 获取选区信息,从 range[0].id 组件的 offset 位置开始,到 range[1].id 的 offset 位置结束 26 | const getSelection = (editor: Editor) => { 27 | let contentWindow = editor.mountedWindow; 28 | let section = contentWindow.getSelection(); 29 | // 无选区:直接返回保存的选区内容 30 | if (!section || !section.anchorNode || !section.focusNode || section?.type === "None") { 31 | return cloneDeep(selectionStore); 32 | } 33 | let anchorNode = section.anchorNode as HTMLElement; 34 | 35 | // 当选区不在生成的文章中时,直接返回之前的选区对象 36 | let rootDom = contentWindow.document.getElementById("zebra-editor-contain"); 37 | if (!rootDom?.contains(anchorNode)) { 38 | return cloneDeep(selectionStore); 39 | } 40 | 41 | // 选中 Article 节点 42 | if (rootDom === anchorNode || (anchorNode.dataset && anchorNode.dataset.type === "ARTICLE")) { 43 | let startBlock = rootDom; 44 | let endBlock = rootDom; 45 | while (startBlock.dataset && startBlock.dataset.structure !== "CONTENT") { 46 | startBlock = startBlock.children[0] as HTMLElement; 47 | } 48 | while (endBlock.dataset && endBlock.dataset.structure !== "CONTENT") { 49 | endBlock = endBlock.children[endBlock.children.length - 1] as HTMLElement; 50 | } 51 | selectionStore = { 52 | isCollapsed: section.isCollapsed, 53 | range: [ 54 | { 55 | id: startBlock.id, 56 | offset: 0, 57 | }, 58 | { 59 | id: endBlock.id, 60 | offset: -1, 61 | }, 62 | ], 63 | }; 64 | return cloneDeep(selectionStore); 65 | } 66 | 67 | // 选中 content 节点 68 | if (anchorNode.dataset && anchorNode.dataset.structure === "CONTENT") { 69 | let startParent: HTMLElement = getCursorElement(anchorNode); 70 | let offset = 0; 71 | for (let i = 0; i < section.anchorOffset; i++) { 72 | offset += getElememtSize(anchorNode.children[i] as HTMLElement); 73 | } 74 | selectionStore = { 75 | isCollapsed: true, 76 | range: [ 77 | { 78 | id: startParent.id, 79 | offset: offset, 80 | }, 81 | { 82 | id: startParent.id, 83 | offset: offset, 84 | }, 85 | ], 86 | }; 87 | return cloneDeep(selectionStore); 88 | } 89 | 90 | // 判断开始节点和结束节点的位置关系, 91 | // 0:同一节点 92 | // 2:focusNode 在 anchorNode 节点前 93 | // 4:anchorNode 在 focusNode 节点前 94 | // 获得选区的开始节点和结束节点(文档顺序) 95 | let posiType = section.anchorNode.compareDocumentPosition(section.focusNode); 96 | let anchorOffset = section.anchorOffset; 97 | let focusOffset = section.focusOffset; 98 | // EMOJI 标签会导致获取的光标的位置错误 99 | if (section.anchorNode.nodeType === 3) { 100 | anchorOffset = getUtf8TextLengthFromJsOffset(section.anchorNode.textContent, anchorOffset); 101 | } 102 | if (section.focusNode.nodeType === 3) { 103 | focusOffset = getUtf8TextLengthFromJsOffset(section.focusNode.textContent, focusOffset); 104 | } 105 | let startOffset; 106 | let startNode; 107 | let endOffset; 108 | let endNode; 109 | if (posiType === 0) { 110 | if (anchorOffset > focusOffset) { 111 | startOffset = focusOffset; 112 | startNode = section?.focusNode; 113 | endOffset = anchorOffset; 114 | endNode = section?.anchorNode; 115 | } else { 116 | startOffset = anchorOffset; 117 | startNode = section?.anchorNode; 118 | endOffset = focusOffset; 119 | endNode = section?.focusNode; 120 | } 121 | } else if (posiType === 2) { 122 | startOffset = focusOffset; 123 | startNode = section?.focusNode; 124 | endOffset = anchorOffset; 125 | endNode = section?.anchorNode; 126 | } else { 127 | startOffset = anchorOffset; 128 | startNode = section?.anchorNode; 129 | endOffset = focusOffset; 130 | endNode = section?.focusNode; 131 | } 132 | 133 | // 修正光标节点的位置 134 | // 获得光标距离所在段落的位置 135 | let startParagtaph: HTMLElement = getCursorElement(startNode as HTMLElement); 136 | let startContainer: HTMLElement = getContainer(startNode as HTMLElement); 137 | let endParagraph: HTMLElement = getCursorElement(endNode as HTMLElement); 138 | let endContainer: HTMLElement = getContainer(endNode as HTMLElement); 139 | startOffset = getOffset(startParagtaph, startContainer, startOffset); 140 | endOffset = getOffset(endParagraph, endContainer, endOffset); 141 | 142 | selectionStore = { 143 | isCollapsed: section?.isCollapsed === undefined ? true : section.isCollapsed, 144 | range: [ 145 | { 146 | id: startParagtaph.id, 147 | offset: startOffset, 148 | }, 149 | { 150 | id: endParagraph.id, 151 | offset: endOffset, 152 | }, 153 | ], 154 | }; 155 | 156 | return cloneDeep(selectionStore); 157 | }; 158 | 159 | export default getSelection; 160 | -------------------------------------------------------------------------------- /src/selection/insert-block.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import Block from "../components/block"; 3 | import getSelection from "./get-selection"; 4 | import deleteSelection from "../operator/delete-selection"; 5 | import focusAt from "./focus-at"; 6 | 7 | // 在光标处插入一个内容块 8 | const insertBlock = (editor: Editor, ...blockList: Block[]) => { 9 | let selection = getSelection(editor); 10 | if (!selection.isCollapsed) { 11 | deleteSelection(editor, selection.range[0], selection.range[1]); 12 | selection = getSelection(editor); 13 | } 14 | 15 | try { 16 | let nowComponent = editor.storeManage.getBlockById(selection.range[0].id); 17 | let start = selection.range[0]; 18 | let operator = nowComponent.split(start.offset, ...blockList); 19 | focusAt(editor, operator?.[0] || start, operator?.[1]); 20 | } catch (e) { 21 | console.warn(e); 22 | } 23 | }; 24 | 25 | export default insertBlock; 26 | -------------------------------------------------------------------------------- /src/selection/insert-inline.ts: -------------------------------------------------------------------------------- 1 | import Inline from "../components/inline"; 2 | import getSelection from "./get-selection"; 3 | import deleteSelection from "../operator/delete-selection"; 4 | import input from "../operator/input"; 5 | import Editor from "../editor"; 6 | 7 | // 在光标处插入一个内容块 8 | const insertInline = (editor: Editor, component: string | Inline) => { 9 | let selection = getSelection(editor); 10 | if (!selection.isCollapsed) { 11 | deleteSelection(editor, selection.range[0], selection.range[1]); 12 | selection = getSelection(editor); 13 | } 14 | input(editor, component, selection.range[0]); 15 | }; 16 | 17 | export default insertInline; 18 | -------------------------------------------------------------------------------- /src/selection/modify-decorate.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import getSelection from "./get-selection"; 3 | import { AnyObject } from "../decorate"; 4 | import focusAt from "./focus-at"; 5 | import getSelectedIdList from "./get-selected-id-list"; 6 | 7 | // 修改选中组件的样式 8 | const modifyDecorate = ( 9 | editor: Editor, 10 | style?: AnyObject, 11 | data?: AnyObject, 12 | focus: boolean = true, 13 | ) => { 14 | let selection = getSelection(editor); 15 | let start = selection.range[0]; 16 | let end = selection.range[1]; 17 | let idList = getSelectedIdList(editor.article, start.id, end.id); 18 | idList.forEach((id) => { 19 | let block = editor.storeManage.getBlockById(id); 20 | block.modifyDecorate(style, data); 21 | }); 22 | if (focus) { 23 | focusAt(editor, selection.range[0], selection.range[1]); 24 | } 25 | }; 26 | 27 | export default modifyDecorate; 28 | -------------------------------------------------------------------------------- /src/selection/modify-indent.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import getSelection from "./get-selection"; 3 | import focusAt from "./focus-at"; 4 | import getSelectedIdList from "./get-selected-id-list"; 5 | import Block from "../components/block"; 6 | import ComponentType from "../const/component-type"; 7 | import { ListType } from "../components/list"; 8 | 9 | const indent = (editor: Editor, block: Block) => { 10 | while ( 11 | block.parent?.type !== ComponentType.list && 12 | block.parent?.type !== ComponentType.article 13 | ) { 14 | block = block.getParent(); 15 | } 16 | 17 | let parent = block.getParent(); 18 | let prev = parent.getPrev(block); 19 | let next = parent.getNext(block); 20 | if (prev?.type === ComponentType.list) { 21 | block.sendTo(prev); 22 | if (next?.type === ComponentType.list) { 23 | next.sendTo(prev); 24 | } 25 | } else if (next?.type === ComponentType.list) { 26 | block.removeSelf(); 27 | next.add(0, block); 28 | } else { 29 | let newList = editor.componentFactory.buildList(ListType.ul); 30 | block.replaceSelf(newList); 31 | newList.add(0, block); 32 | } 33 | return block; 34 | }; 35 | 36 | // 取消缩进 37 | const outdent = (editor: Editor, block: Block) => { 38 | while ( 39 | block.parent?.type !== ComponentType.list && 40 | block.parent?.type !== ComponentType.article 41 | ) { 42 | block = block.getParent(); 43 | } 44 | 45 | let parent = block.getParent(); 46 | if (parent.type === ComponentType.article) { 47 | return block; 48 | } 49 | if (parent.getSize() === 1) { 50 | parent.replaceSelf(block); 51 | return block; 52 | } 53 | let index = parent.findChildrenIndex(block); 54 | block.removeSelf(); 55 | parent.split(index, block); 56 | return block; 57 | }; 58 | 59 | // 修改选中组件的缩进 60 | const modifyIndent = (editor: Editor, isOutdent: boolean = false) => { 61 | let selection = getSelection(editor); 62 | let start = selection.range[0]; 63 | let end = selection.range[1]; 64 | let idList = getSelectedIdList(editor.article, start.id, end.id); 65 | let newStartId: string = ""; 66 | let newEndId: string = ""; 67 | 68 | idList.forEach((each, index) => { 69 | let block = editor.storeManage.getBlockById(each); 70 | if (isOutdent) { 71 | block = outdent(editor, block); 72 | } else { 73 | block = indent(editor, block); 74 | } 75 | if (index === 0) { 76 | newStartId = block.id; 77 | } 78 | if (index === idList.length - 1) { 79 | newEndId = block.id; 80 | } 81 | }); 82 | 83 | focusAt(editor, { id: newStartId, offset: start.offset }, { id: newEndId, offset: end.offset }); 84 | }; 85 | 86 | export default modifyIndent; 87 | -------------------------------------------------------------------------------- /src/selection/modify-selection-decorate.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import { AnyObject } from "../decorate"; 3 | import getSelection from "./get-selection"; 4 | import focusAt from "./focus-at"; 5 | import getSelectedIdList from "./get-selected-id-list"; 6 | 7 | // 修改选区内的文字 8 | const modifySelectionDecorate = (editor: Editor, style?: AnyObject, data?: AnyObject) => { 9 | let selection = getSelection(editor); 10 | // 为光标时,不需要处理 11 | if (selection.isCollapsed) { 12 | return; 13 | } 14 | 15 | let start = selection.range[0]; 16 | let end = selection.range[1]; 17 | 18 | let idList = getSelectedIdList(editor.article, start.id, end.id); 19 | // 未选中内容,不需要处理 20 | if (idList.length === 0) return; 21 | 22 | // 选中一行 23 | if (idList.length === 1) { 24 | let component = editor.storeManage.getBlockById(idList[0]); 25 | component.modifyContentDecorate(start.offset, end.offset - 1, style, data); 26 | return focusAt(editor, start, end); 27 | } 28 | 29 | // 其他情况 30 | let firstComponent = editor.storeManage.getBlockById(idList[0]); 31 | let lastComponent = editor.storeManage.getBlockById(idList[idList.length - 1]); 32 | firstComponent.modifyContentDecorate(start.offset, -1, style, data); 33 | lastComponent.modifyContentDecorate(0, end.offset - 1, style, data); 34 | for (let i = 1; i < idList.length - 1; i++) { 35 | let component = editor.storeManage.getBlockById(idList[i]); 36 | component.modifyContentDecorate(0, -1, style, data); 37 | } 38 | focusAt(editor, start, end); 39 | }; 40 | 41 | export default modifySelectionDecorate; 42 | -------------------------------------------------------------------------------- /src/selection/modify-table.ts: -------------------------------------------------------------------------------- 1 | import Editor from "../editor"; 2 | import Table from "../components/table"; 3 | import getSelection from "./get-selection"; 4 | 5 | // 修改表格内容 6 | const modifyTable = ( 7 | editor: Editor, 8 | option: { 9 | row?: number; 10 | col?: number; 11 | head?: boolean; 12 | }, 13 | ) => { 14 | let selection = getSelection(editor); 15 | if (!selection.isCollapsed) return; 16 | let id = selection.range[0].id; 17 | let component = editor.storeManage.getBlockById(id); 18 | let table = Table.getTable(component); 19 | if (!table) return; 20 | if (option.row) { 21 | table.setRow(option.row); 22 | } 23 | if (option.col) { 24 | table.setCol(option.col); 25 | } 26 | if (option.head !== undefined) { 27 | table.setHead(option.head); 28 | } 29 | }; 30 | 31 | export default modifyTable; 32 | -------------------------------------------------------------------------------- /src/selection/util.ts: -------------------------------------------------------------------------------- 1 | import ComponentType from "../const/component-type"; 2 | import StructureType from "../const/structure-type"; 3 | import { getUtf8TextLengthFromJsOffset } from "../util/text-util"; 4 | import { createError } from "../util"; 5 | 6 | export interface Cursor { 7 | id: string; 8 | offset: number; 9 | } 10 | 11 | // 获取光标所在的组件 12 | export const getCursorElement = (element?: HTMLElement | null): HTMLElement => { 13 | if (!element) throw createError("获取光标所在节点失败", undefined, "selection"); 14 | // 文本节点处理 15 | if (element.nodeType === 3) { 16 | return getCursorElement(element.parentElement); 17 | } 18 | 19 | if (element.dataset && element.dataset.structure === StructureType.structure) { 20 | return getCursorElement(element.children[0] as HTMLElement); 21 | } 22 | 23 | if ( 24 | element.dataset && 25 | (element.dataset.structure === StructureType.content || 26 | element.dataset.structure === StructureType.plainText) 27 | ) { 28 | return element; 29 | } 30 | 31 | return getCursorElement(element.parentElement); 32 | }; 33 | 34 | // 获取光标所在的文本节点 35 | export const getContainer = (element?: HTMLElement | null): HTMLElement => { 36 | if (element === null || element === undefined) 37 | throw createError("容器节点获取失败", undefined, "selection"); 38 | // 文本节点处理 39 | if ( 40 | element.nodeType === 3 || 41 | element.nodeName === "IMG" || 42 | element.nodeName === "AUDIO" || 43 | element.nodeName === "VIDEO" 44 | ) { 45 | return getContainer(element.parentElement); 46 | } 47 | 48 | return element as HTMLElement; 49 | }; 50 | 51 | // 获取元素的长度,修正图片长度为 0 的错误 52 | export const getElememtSize = (element?: HTMLElement): number => { 53 | if (element === undefined) return 0; 54 | 55 | if ( 56 | element.nodeName === "IMG" || 57 | element.nodeName === "AUDIO" || 58 | element.nodeName === "VIDEO" || 59 | element.contentEditable === "false" 60 | ) { 61 | return 1; 62 | } 63 | 64 | if (element.children && element.children.length) { 65 | let size = 0; 66 | for (let i = 0; i < element.children.length; i++) { 67 | let each = element.children[i] as HTMLElement; 68 | size += getElememtSize(each); 69 | } 70 | return size; 71 | } 72 | 73 | return getUtf8TextLengthFromJsOffset(element.textContent); 74 | }; 75 | 76 | const findFocusNode = (element: HTMLElement, index: number): [boolean, Node, number] => { 77 | if ( 78 | element.nodeName === "IMG" || 79 | element.nodeName === "AUDIO" || 80 | element.nodeName === "VIDEO" || 81 | element.contentEditable === "false" 82 | ) { 83 | if (index <= 1) { 84 | return [true, element, index]; 85 | } 86 | 87 | return [false, element, 1]; 88 | } 89 | 90 | if (element.children && element.children.length !== 0) { 91 | let consume = 0; 92 | 93 | for (let i = 0; i < element.children.length; i++) { 94 | let each = element.children[i] as HTMLElement; 95 | let res = findFocusNode(each, index - consume); 96 | if (res[0]) { 97 | return res; 98 | } 99 | consume += res[2]; 100 | } 101 | 102 | return [false, element, consume]; 103 | } 104 | 105 | let charLength = getElememtSize(element); 106 | if (index > charLength) { 107 | return [false, element.childNodes[0], charLength]; 108 | } 109 | 110 | return [true, element.childNodes[0], index]; 111 | }; 112 | 113 | // 将某个组件的某个位置,转换为某个 dom 节点中的某个位置,方便 rang 对象使用 114 | export const getCursorPosition = ( 115 | contentWindow: Window, 116 | cursor: Cursor, 117 | ): { 118 | node: Node; 119 | index: number; 120 | } => { 121 | let dom = contentWindow.document.getElementById(cursor.id); 122 | if (!dom) throw createError("该节点已失效", undefined, "selection"); 123 | 124 | if (dom.dataset && dom.dataset.type === ComponentType.media) { 125 | return { 126 | node: dom.children[0], 127 | index: cursor.offset === 0 ? 0 : 1, 128 | }; 129 | } 130 | 131 | let focusInfo = findFocusNode(dom, cursor.offset); 132 | return { 133 | node: focusInfo[1], 134 | index: focusInfo[2], 135 | }; 136 | }; 137 | 138 | // 获取光标在 parent 中的偏移量 139 | export const getOffset = (parent: HTMLElement, wrap: HTMLElement, offset: number): number => { 140 | const countSize = (parent: HTMLElement, node: HTMLElement) => { 141 | if (parent === node) return 0; 142 | let size = 0; 143 | for (let i = 0; i < parent.children.length; i++) { 144 | let each = parent.children[i] as HTMLElement; 145 | 146 | if (each === node) { 147 | break; 148 | } 149 | 150 | if (each.contains(node)) { 151 | size += countSize(each, node); 152 | break; 153 | } else { 154 | size += getElememtSize(each); 155 | } 156 | } 157 | return size; 158 | }; 159 | 160 | return countSize(parent, wrap) + offset; 161 | }; 162 | -------------------------------------------------------------------------------- /src/util/editor-style.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | html, body { 3 | height: 100%; 4 | } 5 | body { 6 | margin: 0; 7 | padding: 10px; 8 | box-sizing: border-box; 9 | font-size: 16px; 10 | line-height: 1.15; 11 | } 12 | * { 13 | box-sizing: border-box; 14 | } 15 | article { 16 | overflow: auto 17 | } 18 | [contenteditable="true"] { 19 | outline: none; 20 | } 21 | 22 | #zebra-editor-contain { 23 | white-space: pre-wrap; 24 | } 25 | 26 | /** loading 态的图片显示效果 */ 27 | .zebra-editor-image-loading { 28 | position: relative; 29 | background: rgba(248, 248, 248, 1); 30 | } 31 | .zebra-editor-image-loading img { 32 | height: 40px; 33 | } 34 | .zebra-editor-image-loading:before { 35 | content: "···图片加载中···"; 36 | position: absolute; 37 | top: 0; 38 | bottom: 0; 39 | left: 0; 40 | right: 0; 41 | margin: auto; 42 | width: 100%; 43 | line-height: 40px; 44 | text-align: center; 45 | color: #ccc; 46 | } 47 | 48 | /** placeholder */ 49 | .zebra-editor-article > :first-child { 50 | position: relative; 51 | } 52 | .zebra-editor-article > :first-child.zebra-editor-empty::before { 53 | content: '开始你的故事 ...'; 54 | color: #ccc; 55 | position: absolute; 56 | width: 100%; 57 | top: 0; 58 | left: 0; 59 | z-index: -1; 60 | } 61 | 62 | pre { 63 | padding: 10px; 64 | border-radius: 4px; 65 | font-size: .9em; 66 | background-color: rgba(248, 248, 248, 1); 67 | } 68 | 69 | table { 70 | min-width: 100%; 71 | border-collapse: collapse 72 | } 73 | 74 | th, td { 75 | border: 1px solid #ccc; 76 | min-width: 2em; 77 | } 78 | 79 | p:not(.zebra-editor-code-block) code { 80 | padding: 2px 2px; 81 | border-radius: 2px; 82 | font-size: .9em; 83 | background-color: rgba(248, 248, 248, 1); 84 | } 85 | `; 86 | -------------------------------------------------------------------------------- /src/util/index.ts: -------------------------------------------------------------------------------- 1 | import Block from "../components/block"; 2 | import StructureCollection from "../components/structure-collection"; 3 | 4 | export const nextTick = (func: () => void) => { 5 | Promise.resolve().then(func); 6 | }; 7 | 8 | export const walkTree = ( 9 | structureCollection: StructureCollection, 10 | callback: (block: Block) => boolean | void, 11 | ) => { 12 | if (callback(structureCollection)) { 13 | return true; 14 | } 15 | 16 | for (let i = 0, len = structureCollection.getSize(); i < len; i++) { 17 | let each = structureCollection.getChild(i); 18 | if (each instanceof StructureCollection) { 19 | if (walkTree(each, callback)) { 20 | return true; 21 | } 22 | } 23 | if (callback(each)) { 24 | return true; 25 | } 26 | } 27 | return false; 28 | }; 29 | 30 | export const createError = (message: string, block?: any, type?: string) => { 31 | let error = new Error(message); 32 | // @ts-ignore 33 | error.type = type || "component"; 34 | // @ts-ignore 35 | error.blockInfo = block; 36 | return error; 37 | }; -------------------------------------------------------------------------------- /src/util/text-util.ts: -------------------------------------------------------------------------------- 1 | export const getJsTextLengthFromUtf8Offset = ( 2 | text: string | null | undefined, 3 | index: number, 4 | ): number => { 5 | let sureList = [...(text || "")]; 6 | return sureList.slice(0, index).join("").length; 7 | }; 8 | 9 | export const getUtf8TextLengthFromJsOffset = ( 10 | text: string | null | undefined, 11 | offset?: number, 12 | ): number => { 13 | if (!text) return 0; 14 | return [...text.substr(0, offset)].filter((each) => each.charCodeAt(0) !== 8203).length; 15 | }; 16 | -------------------------------------------------------------------------------- /src/util/update-component.ts: -------------------------------------------------------------------------------- 1 | import Component from "../components/component"; 2 | import Block from "../components/block"; 3 | import Editor from "../editor"; 4 | import StructureType from "../const/structure-type"; 5 | import Inline from "../components/inline"; 6 | import ComponentType from "../const/component-type"; 7 | import { nextTick } from ".."; 8 | 9 | const getWrapDom = (containDocument: Document, id: string): HTMLElement | null => { 10 | let element = containDocument.getElementById(id); 11 | if (element?.dataset.inList) { 12 | return element!.parentElement!; 13 | } 14 | return element; 15 | }; 16 | 17 | const getParentDom = (containDocument: Document, id: string): HTMLElement | null => { 18 | let element = containDocument.getElementById(id); 19 | if (element?.dataset.type === ComponentType.table) { 20 | return element!.children[0] as HTMLElement; 21 | } 22 | return element; 23 | }; 24 | 25 | const recallQueue: [string, HTMLElement, HTMLElement][] = []; 26 | 27 | const handleRecallQueue = (editor: Editor) => { 28 | let containDocument = editor.mountedDocument; 29 | 30 | while (recallQueue.length) { 31 | const [afterComId, newDom, parentDom] = recallQueue.pop()!; 32 | 33 | let afterDom = getWrapDom(containDocument, afterComId); 34 | if (afterDom) { 35 | parentDom.insertBefore(newDom, afterDom); 36 | } else { 37 | recallQueue.unshift([afterComId, newDom, parentDom]); 38 | } 39 | } 40 | }; 41 | 42 | let inLoop = false; 43 | 44 | // 更新组件 45 | const updateComponent = (editor: Editor, ...componentList: Component[]) => { 46 | if (!editor.mountedDocument) return; 47 | 48 | componentList 49 | .sort((each) => (each.structureType === StructureType.structure ? -1 : 1)) 50 | .forEach((each) => update(editor, each)); 51 | 52 | handleRecallQueue(editor); 53 | 54 | // 避免过度触发 editorChange 事件 55 | if (!inLoop) { 56 | inLoop = true; 57 | nextTick(() => { 58 | inLoop = false; 59 | document.dispatchEvent(new Event("editorChange")); 60 | }); 61 | } 62 | }; 63 | 64 | const update = (editor: Editor, component: Component) => { 65 | if (component.type === ComponentType.article) return; 66 | 67 | let containDocument = editor.mountedDocument; 68 | let oldDom = getWrapDom(containDocument, component.id); 69 | 70 | // 失效节点清除已存在的 DOM 71 | if (!component.parent) { 72 | if (oldDom) { 73 | oldDom.remove(); 74 | } 75 | return; 76 | } 77 | 78 | let inList = component.parent.type === ComponentType.list; 79 | let newDom: HTMLElement = component.render(editor.contentView); 80 | if (inList) { 81 | newDom = editor.contentView.buildListItem( 82 | component.render(editor.contentView), 83 | component.structureType, 84 | ); 85 | } 86 | 87 | if (oldDom === newDom) { 88 | return; 89 | } 90 | 91 | if (component instanceof Inline) { 92 | if (oldDom) { 93 | oldDom.replaceWith(newDom); 94 | } else if (component.parent) { 95 | update(editor, component.parent); 96 | } 97 | return; 98 | } 99 | 100 | if (component instanceof Block) { 101 | if (oldDom) { 102 | if (component.active) { 103 | oldDom?.replaceWith(newDom); 104 | } else { 105 | oldDom?.remove(); 106 | } 107 | return; 108 | } 109 | 110 | let parentComponent = component.parent; 111 | let parentDom = getParentDom(containDocument, parentComponent.id); 112 | 113 | // 未找到父组件对应的元素时,由父组件控制 114 | if (!parentDom) { 115 | return; 116 | } 117 | 118 | // 将该组件插入到合适的位置 119 | let index = parentComponent.findChildrenIndex(component); 120 | if (index === parentComponent.getSize() - 1) { 121 | parentDom.appendChild(newDom); 122 | } else { 123 | let afterComId = parentComponent.getChild(index + 1).id; 124 | let afterDom = getWrapDom(containDocument, afterComId); 125 | if (afterDom) { 126 | parentDom.insertBefore(newDom, afterDom); 127 | } else { 128 | recallQueue.push([afterComId, newDom, parentDom]); 129 | } 130 | } 131 | } 132 | }; 133 | 134 | export default updateComponent; 135 | -------------------------------------------------------------------------------- /src/view/base-view.ts: -------------------------------------------------------------------------------- 1 | import StructureType from "../const/structure-type"; 2 | import { HeadingEnum } from "../components/heading"; 3 | import { ListType } from "../components/list"; 4 | import { AnyObject } from "../decorate"; 5 | 6 | abstract class AbstractView { 7 | constructor() { 8 | this.init(); 9 | } 10 | 11 | init() {} 12 | 13 | abstract buildArticle(id: string, getChildren: () => T[], style: AnyObject, data: AnyObject): T; 14 | 15 | abstract buildCustomCollection( 16 | id: string, 17 | tag: string, 18 | getChildren: () => T[], 19 | style: AnyObject, 20 | data: AnyObject, 21 | ): T; 22 | 23 | abstract buildTable(id: string, getChildren: () => T[], style: AnyObject, data: AnyObject): T; 24 | 25 | abstract buildTableRow(id: string, getChildren: () => T[], style: AnyObject, data: AnyObject): T; 26 | 27 | abstract buildTableCell( 28 | id: string, 29 | cellType: "th" | "td", 30 | getChildren: () => T[], 31 | style: AnyObject, 32 | data: AnyObject, 33 | ): T; 34 | 35 | abstract buildList( 36 | id: string, 37 | listType: ListType, 38 | getChildren: () => T[], 39 | style: AnyObject, 40 | data: AnyObject, 41 | ): T; 42 | 43 | abstract buildListItem(list: T, structureType: StructureType): T; 44 | 45 | abstract buildParagraph(id: string, getChildren: () => T[], style: AnyObject, data: AnyObject): T; 46 | 47 | abstract buildHeading( 48 | id: string, 49 | type: HeadingEnum, 50 | getChildren: () => T[], 51 | style: AnyObject, 52 | data: AnyObject, 53 | ): T; 54 | 55 | abstract buildCodeBlock( 56 | id: string, 57 | content: string, 58 | language: string, 59 | style: AnyObject, 60 | data: AnyObject, 61 | ): T; 62 | 63 | abstract buildeImage(id: string, src: string, style: AnyObject, data: AnyObject): T; 64 | 65 | abstract buildeAudio(id: string, src: string, style: AnyObject, data: AnyObject): T; 66 | 67 | abstract buildeVideo(id: string, src: string, style: AnyObject, data: AnyObject): T; 68 | 69 | abstract buildCharacterList(id: string, text: string, style: AnyObject, data: AnyObject): T; 70 | 71 | abstract buildInlineImage(id: string, src: string, style: AnyObject, data: AnyObject): T; 72 | } 73 | 74 | export default AbstractView; 75 | -------------------------------------------------------------------------------- /src/view/dom-view.ts: -------------------------------------------------------------------------------- 1 | import AbstractView from "./base-view"; 2 | import Editor from "../editor"; 3 | import ComponentType from "../const/component-type"; 4 | import StructureType from "../const/structure-type"; 5 | import { HeadingEnum } from "../components/heading"; 6 | import { ListType } from "../components/list"; 7 | import { AnyObject } from "../decorate"; 8 | 9 | class ContentBuilder extends AbstractView { 10 | editor: Editor; 11 | 12 | constructor(editor: Editor) { 13 | super(); 14 | this.editor = editor; 15 | } 16 | 17 | addStyle(dom: HTMLElement, style?: AnyObject, data?: AnyObject) { 18 | dom.setAttribute("style", ""); 19 | if (style) { 20 | for (let key in style) { 21 | dom.style[key] = style[key]; 22 | } 23 | } 24 | } 25 | 26 | buildArticle( 27 | id: string, 28 | getChildren: () => HTMLElement[], 29 | style: AnyObject, 30 | data: AnyObject, 31 | ): HTMLElement { 32 | let containDocument = this.editor.mountedDocument; 33 | let article = containDocument.getElementById(id); 34 | if (article) { 35 | this.addStyle(article, style, data); 36 | return article; 37 | } 38 | article = containDocument.createElement("article"); 39 | article.id = id; 40 | article.classList.add("zebra-editor-article"); 41 | article.dataset.type = ComponentType.article; 42 | article.dataset.structure = StructureType.structure; 43 | getChildren().forEach((component) => { 44 | article?.appendChild(component); 45 | }); 46 | this.addStyle(article, style, data); 47 | return article; 48 | } 49 | 50 | buildCustomCollection( 51 | id: string, 52 | tag: string, 53 | getChildren: () => HTMLElement[], 54 | style: AnyObject, 55 | data: AnyObject, 56 | ): HTMLElement { 57 | let containDocument = this.editor.mountedDocument; 58 | let collection = containDocument.getElementById(id); 59 | if (collection) { 60 | this.addStyle(collection, style, data); 61 | return collection; 62 | } 63 | collection = containDocument.createElement(tag); 64 | collection.id = id; 65 | collection.classList.add("zebra-editor-customer"); 66 | collection.dataset.type = ComponentType.customerCollection; 67 | collection.dataset.structure = StructureType.structure; 68 | getChildren().forEach((component) => { 69 | collection?.appendChild(component); 70 | }); 71 | this.addStyle(collection, style, data); 72 | return collection; 73 | } 74 | 75 | buildTable( 76 | id: string, 77 | getChildren: () => HTMLElement[], 78 | style: AnyObject, 79 | data: AnyObject, 80 | ): HTMLElement { 81 | let containDocument = this.editor.mountedDocument; 82 | let figure = containDocument.getElementById(id); 83 | if (figure) { 84 | this.addStyle(figure, style, data); 85 | return figure; 86 | } 87 | figure = containDocument.createElement("figure"); 88 | figure.id = id; 89 | figure.classList.add("zebra-editor-table"); 90 | figure.dataset.type = ComponentType.table; 91 | figure.dataset.structure = StructureType.structure; 92 | figure.contentEditable = "false"; 93 | const table = containDocument.createElement("table"); 94 | getChildren().forEach((each) => table.appendChild(each)); 95 | figure.appendChild(table); 96 | this.addStyle(figure, style, data); 97 | return figure; 98 | } 99 | 100 | buildTableRow( 101 | id: string, 102 | getChildren: () => HTMLElement[], 103 | style: AnyObject, 104 | data: AnyObject, 105 | ): HTMLElement { 106 | let containDocument = this.editor.mountedDocument; 107 | let tr = containDocument.getElementById(id); 108 | if (tr) { 109 | this.addStyle(tr, style, data); 110 | return tr; 111 | } 112 | tr = containDocument.createElement("tr"); 113 | tr.id = id; 114 | tr.dataset.structure = StructureType.structure; 115 | getChildren().forEach((each) => tr?.appendChild(each)); 116 | this.addStyle(tr, style, data); 117 | return tr; 118 | } 119 | 120 | buildTableCell( 121 | id: string, 122 | cellType: "th" | "td", 123 | getChildren: () => HTMLElement[], 124 | style: AnyObject, 125 | data: AnyObject, 126 | ): HTMLElement { 127 | let containDocument = this.editor.mountedDocument; 128 | let cell = containDocument.getElementById(id); 129 | if (cell) { 130 | this.addStyle(cell, style, data); 131 | return cell; 132 | } 133 | cell = containDocument.createElement(cellType); 134 | cell.id = id; 135 | cell.dataset.structure = StructureType.structure; 136 | cell.contentEditable = "true"; 137 | getChildren().forEach((each) => cell?.appendChild(each)); 138 | this.addStyle(cell, style, data); 139 | return cell; 140 | } 141 | 142 | buildList( 143 | id: string, 144 | listType: ListType, 145 | getChildren: () => HTMLElement[], 146 | style: AnyObject, 147 | data: AnyObject, 148 | ): HTMLElement { 149 | let containDocument = this.editor.mountedDocument; 150 | let list = containDocument.getElementById(id); 151 | if (list && list.tagName.toLowerCase() === listType) { 152 | this.addStyle(list, style, data); 153 | return list; 154 | } 155 | list = containDocument.createElement(listType); 156 | list.id = id; 157 | list.classList.add("zebra-editor-list"); 158 | list.dataset.type = ComponentType.list; 159 | list.dataset.structure = StructureType.structure; 160 | getChildren().forEach((component) => { 161 | list?.appendChild(component); 162 | }); 163 | this.addStyle(list, style, data); 164 | return list; 165 | } 166 | 167 | buildListItem(list: HTMLElement, structureType: StructureType): HTMLElement { 168 | let containDocument = this.editor.mountedDocument; 169 | let li = containDocument.createElement("li"); 170 | list.dataset.inList = "true"; 171 | li.appendChild(list); 172 | let style: AnyObject = {}; 173 | if (structureType !== StructureType.content) { 174 | style.display = "block"; 175 | } 176 | this.addStyle(li, style); 177 | return li; 178 | } 179 | 180 | buildParagraph( 181 | id: string, 182 | getChildren: () => HTMLElement[], 183 | style: AnyObject, 184 | data: AnyObject, 185 | ): HTMLElement { 186 | let containDocument = this.editor.mountedDocument; 187 | const tag: string = data.tag || "p"; 188 | let parapraph = containDocument.createElement(tag); 189 | parapraph.id = id; 190 | parapraph.classList.add(`zebra-editor-${tag}`); 191 | parapraph.dataset.type = ComponentType.paragraph; 192 | parapraph.dataset.structure = StructureType.content; 193 | let children = getChildren(); 194 | if (children.length) { 195 | children.forEach((component) => { 196 | parapraph?.appendChild(component); 197 | }); 198 | } else { 199 | parapraph.classList.add(`zebra-editor-empty`); 200 | parapraph.appendChild(containDocument.createTextNode("\u200b")); 201 | } 202 | this.addStyle(parapraph, style, data); 203 | return parapraph; 204 | } 205 | 206 | buildHeading( 207 | id: string, 208 | type: HeadingEnum, 209 | getChildren: () => HTMLElement[], 210 | style: AnyObject, 211 | data: AnyObject, 212 | ): HTMLElement { 213 | let containDocument = this.editor.mountedDocument; 214 | let heading = containDocument.createElement(type); 215 | heading.id = id; 216 | heading.classList.add(`zebra-editor-${type}`); 217 | heading.dataset.type = ComponentType.heading; 218 | heading.dataset.structure = StructureType.content; 219 | let children = getChildren(); 220 | if (children.length) { 221 | children.forEach((component) => { 222 | heading?.appendChild(component); 223 | }); 224 | } else { 225 | heading.classList.add(`zebra-editor-empty`); 226 | heading.appendChild(containDocument.createTextNode("\u200b")); 227 | } 228 | this.addStyle(heading, style, data); 229 | return heading; 230 | } 231 | 232 | buildCodeBlock( 233 | id: string, 234 | content: string, 235 | language: string, 236 | style: AnyObject, 237 | data: AnyObject, 238 | ): HTMLElement { 239 | let containDocument = this.editor.mountedDocument; 240 | let pre = containDocument.createElement("pre"); 241 | pre.classList.add("zebra-editor-warp-fixed", "zebra-editor-code-block"); 242 | pre.id = id; 243 | pre.dataset.type = ComponentType.codeBlock; 244 | pre.dataset.structure = StructureType.plainText; 245 | const code = containDocument.createElement("code"); 246 | code.textContent = content; 247 | pre.appendChild(code); 248 | pre.dataset.language = language; 249 | this.addStyle(pre, style, data); 250 | return pre; 251 | } 252 | 253 | buildeImage(id: string, src: string, style: AnyObject, data: AnyObject): HTMLElement { 254 | let containDocument = this.editor.mountedDocument; 255 | let figure = containDocument.createElement("figure"); 256 | figure.id = id; 257 | figure.classList.add("zebra-editor-image", "zebra-editor-image-loading"); 258 | figure.dataset.type = ComponentType.media; 259 | figure.dataset.structure = StructureType.content; 260 | figure.dataset.src = src; 261 | let child; 262 | let image = containDocument.createElement("img"); 263 | image.src = src; 264 | image.alt = data.alt || ""; 265 | image.style.maxWidth = "100%"; 266 | image.style.display = "block"; 267 | image.addEventListener("load", () => { 268 | figure?.classList.remove("zebra-editor-image-loading"); 269 | }); 270 | 271 | if (data.link) { 272 | figure.dataset.link = data.link; 273 | let link = containDocument.createElement("a"); 274 | link.href = data.link; 275 | link.appendChild(image); 276 | child = link; 277 | } else { 278 | child = image; 279 | } 280 | figure.appendChild(child); 281 | this.addStyle(figure, style, data); 282 | return figure; 283 | } 284 | 285 | buildeAudio(id: string, src: string, style: AnyObject, data: AnyObject): HTMLElement { 286 | let containDocument = this.editor.mountedDocument; 287 | let figure = containDocument.createElement("figure"); 288 | figure.id = id; 289 | figure.classList.add("zebra-editor-image"); 290 | figure.dataset.type = ComponentType.media; 291 | figure.dataset.structure = StructureType.content; 292 | let audio = containDocument.createElement("audio"); 293 | audio.src = src; 294 | figure.appendChild(audio); 295 | this.addStyle(figure, style, data); 296 | return figure; 297 | } 298 | 299 | buildeVideo(id: string, src: string, style: AnyObject, data: AnyObject): HTMLElement { 300 | let containDocument = this.editor.mountedDocument; 301 | let figure = containDocument.createElement("figure"); 302 | figure.id = id; 303 | figure.classList.add("zebra-editor-image"); 304 | figure.dataset.type = ComponentType.media; 305 | figure.dataset.structure = StructureType.content; 306 | let video = containDocument.createElement("video"); 307 | video.src = src; 308 | figure.appendChild(video); 309 | this.addStyle(figure, style, data); 310 | return figure; 311 | } 312 | 313 | buildCharacterList(id: string, text: string, style: AnyObject, data: AnyObject): HTMLElement { 314 | let containDocument = this.editor.mountedDocument; 315 | let charWrap; 316 | let root = containDocument.createElement(data.code ? "code" : "span"); 317 | charWrap = root; 318 | 319 | if (data.bold) { 320 | let strong = containDocument.createElement("strong"); 321 | charWrap.appendChild(strong); 322 | charWrap = strong; 323 | } else if (data.bold === false) { 324 | style.fontWeight = "normal"; 325 | } 326 | if (data.italic) { 327 | let em = containDocument.createElement("em"); 328 | charWrap.appendChild(em); 329 | charWrap = em; 330 | } 331 | if (data.deleteText) { 332 | let del = containDocument.createElement("del"); 333 | charWrap.appendChild(del); 334 | charWrap = del; 335 | } 336 | if (data.underline) { 337 | let u = containDocument.createElement("u"); 338 | charWrap.appendChild(u); 339 | charWrap = u; 340 | } 341 | if (data.link) { 342 | let link = containDocument.createElement("a"); 343 | link.href = data.link; 344 | link.title = data.title || ""; 345 | charWrap.appendChild(link); 346 | charWrap = link; 347 | link.addEventListener("click", (event: MouseEvent) => { 348 | if (event.metaKey || event.ctrlKey) { 349 | window.open(link.href); 350 | } else { 351 | event.preventDefault(); 352 | } 353 | }); 354 | } 355 | 356 | charWrap.innerText = text; 357 | this.addStyle(root, style, data); 358 | root.id = id; 359 | return root; 360 | } 361 | 362 | buildInlineImage(id: string, src: string, style: AnyObject, data: AnyObject): HTMLElement { 363 | let containDocument = this.editor.mountedDocument; 364 | let span = containDocument.getElementById(id); 365 | if (!span || span.dataset.src !== src || (data.link && span.dataset.link !== data.link)) { 366 | span = containDocument.createElement("span"); 367 | span.contentEditable = "false"; 368 | span.id = id; 369 | span.dataset.src = src; 370 | span.dataset.link = data.link || ""; 371 | span.dataset.type = ComponentType.inlineImage; 372 | span.classList.add("zebra-editor-inline-image"); 373 | let child; 374 | let image = containDocument.createElement("img"); 375 | image.src = src; 376 | image.alt = data.alt || ""; 377 | if (data.link) { 378 | const link = containDocument.createElement("a"); 379 | link.href = data.link; 380 | link.appendChild(image); 381 | child = link; 382 | } else { 383 | child = image; 384 | } 385 | span.appendChild(child); 386 | } 387 | this.addStyle(span, style, data); 388 | return span; 389 | } 390 | } 391 | 392 | export default ContentBuilder; 393 | -------------------------------------------------------------------------------- /src/view/html-view.ts: -------------------------------------------------------------------------------- 1 | import AbstractView from "./base-view"; 2 | import StructureType from "../const/structure-type"; 3 | import { HeadingEnum } from "../components/heading"; 4 | import { ListType } from "../components/list"; 5 | import { AnyObject } from "../decorate"; 6 | 7 | class HtmlView extends AbstractView { 8 | formatStyle(styleName: string) { 9 | return styleName.replace(/([A-Z])/, "-$1").toLocaleLowerCase(); 10 | } 11 | 12 | getStyle(style: AnyObject) { 13 | let styleFormat = Object.keys(style).map((key) => `${this.formatStyle(key)}:${style[key]};`); 14 | if (styleFormat.length) { 15 | return ` style="${styleFormat.join("")}"`; 16 | } 17 | return ""; 18 | } 19 | 20 | getData(data: AnyObject) { 21 | let format = Object.keys(data).map((key) => `data-${key}=${data[key]};`); 22 | if (format.length) { 23 | return ` ${format.join(" ")}"`; 24 | } 25 | return ""; 26 | } 27 | 28 | buildHtml( 29 | tag: string, 30 | className: string, 31 | style: AnyObject, 32 | children: string, 33 | data: AnyObject = {}, 34 | ): string { 35 | return `<${tag}${className ? ` class=${className}` : ""}${this.getStyle(style)}${this.getData( 36 | data, 37 | )}>${children}`; 38 | } 39 | 40 | buildArticle(id: string, getChildren: () => string[], style: AnyObject, data: AnyObject): string { 41 | return this.buildHtml("article", "zebra-editor-article", style, getChildren().join("")); 42 | } 43 | 44 | buildCustomCollection( 45 | id: string, 46 | tag: string, 47 | getChildren: () => string[], 48 | style: AnyObject, 49 | data: AnyObject, 50 | ): string { 51 | return this.buildHtml("div", "zebra-editor-customer-collection", style, getChildren().join("")); 52 | } 53 | 54 | buildTable(id: string, getChildren: () => string[], style: AnyObject, data: AnyObject) { 55 | let table = this.buildHtml( 56 | "table", 57 | "", 58 | { 59 | minWidth: "100%", 60 | borderCollapse: "collapse", 61 | }, 62 | getChildren().join(""), 63 | ); 64 | return this.buildHtml("figure", "zebra-editor-table", style, table); 65 | } 66 | 67 | buildTableRow(id: string, getChildren: () => string[], style: AnyObject, data: AnyObject) { 68 | return this.buildHtml("tr", "zebra-editor-tr", style, getChildren().join("")); 69 | } 70 | 71 | buildTableCell( 72 | id: string, 73 | cellType: "th" | "td", 74 | getChildren: () => string[], 75 | style: AnyObject, 76 | data: AnyObject, 77 | ) { 78 | return this.buildHtml(cellType, `zebra-editor-${cellType}`, style, getChildren().join("")); 79 | } 80 | 81 | buildList( 82 | id: string, 83 | listType: ListType, 84 | getChildren: () => string[], 85 | style: AnyObject, 86 | data: AnyObject, 87 | ): string { 88 | return this.buildHtml(listType, "zebra-editor-list", style, getChildren().join("")); 89 | } 90 | 91 | buildListItem(list: string, structureType: StructureType): string { 92 | let style: AnyObject = {}; 93 | if (structureType !== StructureType.content) { 94 | style.display = "block"; 95 | } 96 | return this.buildHtml("li", "zebra-editor-list-item", style, list); 97 | } 98 | 99 | buildParagraph( 100 | id: string, 101 | getChildren: () => string[], 102 | style: AnyObject, 103 | data: AnyObject, 104 | ): string { 105 | let tag = data.tag || "p"; 106 | return this.buildHtml( 107 | tag, 108 | `zebra-editor-${tag}`, 109 | style, 110 | `${getChildren().join("") || "
"}
`, 111 | ); 112 | } 113 | 114 | buildHeading( 115 | id: string, 116 | type: HeadingEnum, 117 | getChildren: () => string[], 118 | style: AnyObject, 119 | data: AnyObject, 120 | ): string { 121 | return this.buildHtml( 122 | type, 123 | `zebra-editor-${type}`, 124 | style, 125 | `${getChildren().join("") || "
"}
`, 126 | ); 127 | } 128 | 129 | buildCodeBlock( 130 | id: string, 131 | content: string, 132 | language: string, 133 | style: AnyObject, 134 | data: AnyObject, 135 | ): string { 136 | return `
${content}
`; 139 | } 140 | 141 | buildeImage(id: string, src: string, style: AnyObject, data: AnyObject): string { 142 | let image = `${data.alt}`; 143 | if (data.link) { 144 | image = `${image}`; 145 | } 146 | let className = `zebra-editor-image`; 147 | return `
${image}
`; 148 | } 149 | 150 | buildeAudio(id: string, src: string, style: AnyObject, data: AnyObject): string { 151 | let image = `