├── .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 |
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}${tag}>`;
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 = `
`;
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 = ``;
152 | let className = `zebra-editor-image`;
153 | return `${image}`;
154 | }
155 |
156 | buildeVideo(id: string, src: string, style: AnyObject, data: AnyObject): string {
157 | let image = ``;
158 | let className = `zebra-editor-image`;
159 | return `${image}`;
160 | }
161 |
162 | buildCharacterList(id: string, text: string, style: AnyObject, data: AnyObject): string {
163 | let content = text;
164 | if (data.link) {
165 | content = `${content}`;
166 | }
167 | if (data.bold) {
168 | content = `${content}`;
169 | }
170 | if (data.italic) {
171 | content = `${content}`;
172 | }
173 | if (data.deleteText) {
174 | content = `${content}`;
175 | }
176 | if (data.underline) {
177 | content = `${content}`;
178 | }
179 | if (data.code) {
180 | content = `${content}`;
181 | }
182 | content = `${content}`;
183 | return content;
184 | }
185 |
186 | buildInlineImage(id: string, src: string, style: AnyObject, data: AnyObject): string {
187 | let image = `
`;
188 | if (data.link) {
189 | image = `${image}`;
190 | }
191 | let className = `zebra-editor-inline-image`;
192 | return `${image}`;
193 | }
194 | }
195 |
196 | export default HtmlView;
197 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./lib",
4 | "target": "es6",
5 | "module": "esnext",
6 | "moduleResolution": "node",
7 | "strict": true,
8 | "declaration": true,
9 | "experimentalDecorators": true,
10 | "downlevelIteration": true,
11 | "lib": ["dom", "dom.iterable", "esnext"],
12 | "types": ["shortid"],
13 | "skipLibCheck": true,
14 | "esModuleInterop": true,
15 | "allowSyntheticDefaultImports": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "suppressImplicitAnyIndexErrors": true,
18 | "allowJs": true,
19 | "sourceMap": true
20 | },
21 | "include": ["src"]
22 | }
23 |
--------------------------------------------------------------------------------