基于 editorjs 块编辑器开发及跨平台数据渲染

editorjs 块编辑器的开发核心是插件系统,本文主要介绍 inlineTool、blockTool 插件编写,以及将富文本解析为 JSON,实现跨平台渲染。

inlineTool 插件

每个内联工具都必须提供一个按钮(带有图标或某些布局的 HTML 元素)。按下按钮时,内联工具接收选定的文本范围作为页面上的 JavaScript 对象引用。某些工具还可能提供与用户进行其他交互的操作。

内联工具必须提供三种方法才能使用编辑器:render、surround、checkState

Render 方法必须返回内联工具栏按钮的 HTML 元素。当用户选择某些文本时,编辑器调用每个具有当前功能的内联工具的方法,以更新状态(如果所选文本包含一些内联标记)。

插件示例:
inlineTool示例

插件清单

目前支持 B(加粗)、U(下划线)、A(链接)、I(斜体),
本次增加了 FBGC(背景色) 插件。
未来将增加 FC(字体颜色)、FS(字体大小)、LH(行高)、LS(字间距)等插件。

MY富文本编辑器可配置项较多,但实际应用的功能确很少。
如图所示:
MY

后期可根据具体需求增加块编辑器插件。

开发示例

官方文档:
https://editorjs.io/inline-tool-basics/

核心函数:

renderRequired创建交互按钮
surroundRequired所选范围添加或移除标签
checkStateRequired按选定范围获取工具的激活状态
renderActions Optional在按钮下方创建交互界面
clearOptional清除内联工具内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
export default class MarkerTool {
static get shortcut() {
return "CMD+M";
}

static get sanitize() {
return {
mark: {
class: "cdx-marker",
},
};
}

static get isInline() {
return true;
}

get state() {
return this._state;
}

set state(state) {
this._state = state;

this.button.classList.toggle(this.api.styles.inlineToolButtonActive, state);
}

constructor({ api }) {
this.api = api;
this.button = null;
this._state = false;

this.tag = "MARK";
this.class = "cdx-marker";
}

clear() {
this.hideActions();
}

render() {
this.button = document.createElement("button");
this.button.type = "button";
this.button.innerHTML =
'<svg width="20" height="18"><path d="M10.458 12.04l2.919 1.686-.781 1.417-.984-.03-.974 1.687H8.674l1.49-2.583-.508-.775.802-1.401zm.546-.952l3.624-6.327a1.597 1.597 0 0 1 2.182-.59 1.632 1.632 0 0 1 .615 2.201l-3.519 6.391-2.902-1.675zm-7.73 3.467h3.465a1.123 1.123 0 1 1 0 2.247H3.273a1.123 1.123 0 1 1 0-2.247z"/></svg>';
this.button.classList.add(this.api.styles.inlineToolButton);

return this.button;
}

surround(range) {
if (this.state) {
this.unwrap(range);
return;
}

this.wrap(range);
}

wrap(range) {
const selectedText = range.extractContents();
const mark = document.createElement(this.tag);

mark.classList.add(this.class);
mark.appendChild(selectedText);
range.insertNode(mark);

this.api.selection.expandToTag(mark);
}

unwrap(range) {
const mark = this.api.selection.findParentTag(this.tag, this.class);
const text = range.extractContents();

mark.remove();

range.insertNode(text);
}

checkState() {
const mark = this.api.selection.findParentTag(this.tag);

this.state = !!mark;

if (this.state) {
this.showActions(mark);
} else {
this.hideActions();
}
}

renderActions() {
this.colorPicker = document.createElement("input");
this.colorPicker.type = "color";
this.colorPicker.value = "#f5f1cc";
this.colorPicker.hidden = true;

return this.colorPicker;
}

showActions(mark) {
const { backgroundColor } = mark.style;
this.colorPicker.value = backgroundColor
? this.convertToHex(backgroundColor)
: "#f5f1cc";

this.colorPicker.onchange = () => {
mark.style.backgroundColor = this.colorPicker.value;
};
this.colorPicker.hidden = false;
}

hideActions() {
this.colorPicker.onchange = null;
this.colorPicker.hidden = true;
}

convertToHex(color) {
const rgb = color.match(/(\d+)/g);

let hexr = parseInt(rgb[0]).toString(16);
let hexg = parseInt(rgb[1]).toString(16);
let hexb = parseInt(rgb[2]).toString(16);

hexr = hexr.length === 1 ? "0" + hexr : hexr;
hexg = hexg.length === 1 ? "0" + hexg : hexg;
hexb = hexb.length === 1 ? "0" + hexb : hexb;

return "#" + hexr + hexg + hexb;
}
}

blockTool 插件

该插件包含 BlockAPI、MenuConfig、BlockTunesAPI 三个部分,

  • BlockAPI 提供了区块的渲染、保存、验证等功能。
  • MenuConfig 提供了区块的菜单配置,包括图标、名称等。
  • BlockTunesAPI 提供了区块的配置,包括是否可拉伸、是否可选中等。

插件示例:
api示例

插件清单

目前支持 Text(普通文本)、Heading(标题)、List(列表)、Delimiter(分隔)、Table(表格),
本次增加了 Image(图片) 插件。
未来将增加 音频、视频、融合出版应用 等插件。

blockTool示例

开发示例

官方文档:
https://editorjs.io/the-first-plugin/

核心函数:

call(methodName:string,param?:object):void调用工具的实例方法 save, validate, render, etc.) 
save(): Promise<void|SavedData>保存块数据
validate(data: BlockToolData): Promise<boolean>验证块数据call('validate',data)
dispatchChange()块已更改触发编辑器的回调onChange callback. 
config:ToolConfig块初始化配置
holder:HTMLElement块是否包换html标签
isEmpty: boolean块的内容是否为空
selected:boolean块是否被选中
stretched:boolean块是否被拉伸
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
export default class SimpleImage {
static get toolbox() {
return {
title: "Image",
icon: '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"/></svg>',
};
}

static get pasteConfig() {
return {
tags: ["IMG"],
files: {
mimeTypes: ["image/*"],
extensions: ["gif", "jpg", "png", "jpeg"],
},
patterns: {
image: /https?:\/\/\S+\.(gif|jpe?g|tiff|png)$/i,
},
};
}

static get sanitize() {
return {
url: false,
caption: {},
};
}

constructor({ data, api, config }) {
this.api = api;
this.config = config || {};
this.data = {
url: data.url || "",
caption: data.caption || "",
withBorder: data.withBorder !== undefined ? data.withBorder : false,
withBackground:
data.withBackground !== undefined ? data.withBackground : false,
stretched: data.stretched !== undefined ? data.stretched : false,
};

this.wrapper = undefined;
this.settings = [
{
name: "withBorder",
icon: `<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M15.8 10.592v2.043h2.35v2.138H15.8v2.232h-2.25v-2.232h-2.4v-2.138h2.4v-2.28h2.25v.237h1.15-1.15zM1.9 8.455v-3.42c0-1.154.985-2.09 2.2-2.09h4.2v2.137H4.15v3.373H1.9zm0 2.137h2.25v3.325H8.3v2.138H4.1c-1.215 0-2.2-.936-2.2-2.09v-3.373zm15.05-2.137H14.7V5.082h-4.15V2.945h4.2c1.215 0 2.2.936 2.2 2.09v3.42z"/></svg>`,
},
{
name: "stretched",
icon: `<svg width="17" height="10" viewBox="0 0 17 10" xmlns="http://www.w3.org/2000/svg"><path d="M13.568 5.925H4.056l1.703 1.703a1.125 1.125 0 0 1-1.59 1.591L.962 6.014A1.069 1.069 0 0 1 .588 4.26L4.38.469a1.069 1.069 0 0 1 1.512 1.511L4.084 3.787h9.606l-1.85-1.85a1.069 1.069 0 1 1 1.512-1.51l3.792 3.791a1.069 1.069 0 0 1-.475 1.788L13.514 9.16a1.125 1.125 0 0 1-1.59-1.591l1.644-1.644z"/></svg>`,
},
{
name: "withBackground",
icon: `<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.043 8.265l3.183-3.183h-2.924L4.75 10.636v2.923l4.15-4.15v2.351l-2.158 2.159H8.9v2.137H4.7c-1.215 0-2.2-.936-2.2-2.09v-8.93c0-1.154.985-2.09 2.2-2.09h10.663l.033-.033.034.034c1.178.04 2.12.96 2.12 2.089v3.23H15.3V5.359l-2.906 2.906h-2.35zM7.951 5.082H4.75v3.201l3.201-3.2zm5.099 7.078v3.04h4.15v-3.04h-4.15zm-1.1-2.137h6.35c.635 0 1.15.489 1.15 1.092v5.13c0 .603-.515 1.092-1.15 1.092h-6.35c-.635 0-1.15-.489-1.15-1.092v-5.13c0-.603.515-1.092 1.15-1.092z"/></svg>`,
},
];
}
render() {
this.wrapper = document.createElement("div");
this.wrapper.classList.add("simple-image");

if (this?.data?.url) {
this._createImage(this.data.url, this.data.caption);
return this.wrapper;
}

const input = document.createElement("input");

input.placeholder = this.config.placeholder || "Paste an image URL...";
input.addEventListener("paste", (event) => {
this._createImage(event.clipboardData.getData("text"));
});

this.wrapper.appendChild(input);

return this.wrapper;
}

_createImage(url, captionText) {
console.log(url);
const image = document.createElement("img");
const caption = document.createElement("div");

image.src = url;
caption.contentEditable = true;
caption.innerHTML = captionText || "";

this.wrapper.innerHTML = "";
this.wrapper.appendChild(image);
this.wrapper.appendChild(caption);

this._acceptTuneView();
}

save(blockContent) {
console.log(blockContent);
const image = blockContent.querySelector("img");
const caption = blockContent.querySelector("[contenteditable]");

return Object.assign(this.data, {
url: image.src,
caption: caption.innerHTML || "",
});

// const sanitizerConfig = {
// b: true,
// a: {
// href: true,
// },
// i: true
// }

// return Object.assign(this.data, {
// url: image.src,
// caption: this.api.sanitizer.clean(caption.innerHTML || '', sanitizerConfig)
// });
}
validate(saveData) {
// 验证url
return !!saveData.url.trim();
}

renderSettings() {
const wrapper = document.createElement("dev");

this.settings.forEach((tune) => {
let button = document.createElement("div");

button.classList.add(this.api.styles.settingsButton);
button.classList.toggle(
this.api.styles.settingsButtonActive,
this.data[tune.name]
);
button.innerHTML = tune.icon;
wrapper.appendChild(button);

button.addEventListener("click", () => {
this._toggleTune(tune.name);
button.classList.toggle(this.api.styles.settingsButtonActive);
});
});

return wrapper;
}

_toggleTune(tune) {
// console.log('Image tune clicked', tune)
this.data[tune] = !this.data[tune];
this._acceptTuneView();
}

_acceptTuneView() {
this.settings.forEach((tune) => {
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);

if (tune.name === "stretched") {
this.api.blocks.stretchBlock(
this.api.blocks.getCurrentBlockIndex(),
!!this.data.stretched
);
}
});
}

onPaste(event) {
switch (event.type) {
case "tag":
const imgTag = event.detail.data;

this._createImage(imgTag.src);
break;

case "file":
const file = event.detail.file;
const reader = new FileReader();

reader.onload = (loadEvent) => {
this._createImage(loadEvent.target.result);
};

reader.readAsDataURL(file);

break;

case "pattern":
const src = event.detail.data;

this._createImage(src);

break;
}
}
}

数据转换

editorjs 的数据格式为:

1
2
3
4
5
6
7
8
9
10
11
12
{
time: 1693200794419,
blocks: [
{
id: "mhTl6ghSkV",
type: "paragraph",
data: {
text: 'Hey. <b>Meet the new Editor.</b> <b>On this <i>picture <mark class=\"cdx-marker\">you can </mark>see</i> it in action.</b> <i>Then, </i><u class=\"cdx-underline\">try a demo</u> 🤓"'
}
}
]
}

实际上 text 字段为富文本,需要将其解析为 JSON 格式,以便跨平台渲染。

解析后的数据结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{text: 'Hey. '},
{tags: [{tag: 'B'}], text: 'Meet the new Editor.'},
{text: ' '},
{tags: [{tag: 'B'},], text: 'On this '},
{tags: [{tag: 'B'}, {tag: 'I'}], text: 'picture '},
{tags: [{tag: 'B'}, {tag: 'I'}, {tag: 'FBGC', class: ['cdx-fbgc'], style: {backgroundColor: 'rgb(208, 189, 22)'}}], text: 'you can '},
{tags: [{tag: 'B'}, {tag: 'I'}], text: 'see'},
{tags: [{tag: 'B'},], text: ' it in action.'},
{text: ' '},
{tags: [{tag: 'I'}], text: 'Then, '},
{tags: ['U'], text: 'try a demo'},
{text: ' 🤓'}
]

这个数据结构清晰,在标签数量较少的情况下使用较为合适

还有一种将html转json数据结构,由于html标签很多,用树形结构较为合适
MY

转换基本原理

节点常量类型:

常量 描述
Node.ELEMENT_NODE 1 一个 元素 节点,例如 <p><div>
Node.ATTRIBUTE_NODE 2 元素 的耦合 属性
Node.TEXT_NODE 3 Element 或者 Attr 中实际的 文字 (en-US)
Node.CDATA_SECTION_NODE 4 一个 CDATASection,例如 <!CDATA[[ … ]]>
Node.PROCESSING_INSTRUCTION_NODE 7 一个用于 XML 文档的 ProcessingInstruction (en-US) ,例如 <?xml-stylesheet ... ?> 声明。
Node.COMMENT_NODE 8 一个 Comment 节点。
Node.DOCUMENT_NODE 9 一个 Document 节点。
Node.DOCUMENT_TYPE_NODE 10 描述文档类型的 DocumentType 节点。例如 <!DOCTYPE html> 就是用于 HTML5 的。
Node.DOCUMENT_FRAGMENT_NODE 11 一个 DocumentFragment 节点

在项目中我们只需处理 ELEMENT_NODE 和 TEXT_NODE

参考文档:
https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType#%E8%8A%82%E7%82%B9%E7%B1%BB%E5%9E%8B%E5%B8%B8%E9%87%8F

递归函数解析各 childNode

使用递归函数将节点解析为JSON格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function childNodesToJson(richText: string) {
const dom = document.createElement("div");
const recursive = (dom: any, arr: any[] = [], tags: any[] = []) => {
dom.childNodes.forEach((item: any) => {
console.log(item.nodeName, item.nodeType, item.textContent, item.nodeValue);
if (item.nodeType === 1) {
const getClass = item.className.split(' ').filter((item: string) => !!item)
let newTag: any = {
node: item.nodeName,
class: getClass.length ? getClass : undefined,
}
// TODO
// 不同插件需配置不同tag属性。例如:fontBackgroundColor插件需配置backgroundColor属性
if (item.nodeName === 'FBGC') {
newTag.style = {
backgroundColor: item.style.backgroundColor
}
}
recursive(item, arr, [...tags, newTag]);
} else if (item.nodeType === 3) {
arr.push({
tags: tags.length ? tags : undefined,
text: item.textContent,
});
}
});
dom.remove();
return arr
}
dom.innerHTML = richText;
return recursive(dom);
}

跨平台数据渲染

Web

可以直接使用 text 字段及已定义的css样式进行渲染

Uniapp

开发 json-to-dom 组件,根据 ‘B’、’I’、’MARK’ 等 tag 定义不同view样式,实现Uniapp跨端渲染。

如果是自定义块,通过BlockType找到对应 标签或应用组件
例如:
BlockType = ‘audio’ 音频组件
BlockType = ‘video’ 视频组件
BlockType = ‘container_game’ 容器游戏组件
答题、电子书组件等等等等

其它平台(Android、Ios)

开发各自渲染组件

全部代码

初始化、数据解析、保存

初始化 editorjs 编辑器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import "./simple-image/simple-image.css";
import "./marker/marker.css";
import EditorJS from "@editorjs/editorjs";
import Header from "@editorjs/header";
import Paragraph from "@editorjs/paragraph";
import Delimiter from "@editorjs/delimiter";
import Table from "@editorjs/table";
import RawTool from "@editorjs/raw";
import NestedList from "@editorjs/nested-list";
import Underline from "@editorjs/underline";
import CodeTool from "@editorjs/code";
import FootnotesTune from "@editorjs/footnotes";
import TextVariantTune from "@editorjs/text-variant-tune";
import SimpleImage from "./simple-image/simple-image.js";
import MarkerTool from "./marker/marker.js";
import data from "./data";
const editor = new EditorJS({
onReady: () => {
console.log("Editor.js is ready to work!");
},
onChange: (api, event) => {
console.log("Now I know that Editor's content changed!", api, event);
},
placeholder: "Let`s write an awesome story!",
autofocus: true,
holder: "editorjs",
data: data,
inlineToolbar: true,
tools: {
paragraph: {
class: Paragraph,
inlineToolbar: true,
tunes: ["textVariant", "footnotes"],
},
header: {
class: Header,
inlineToolbar: true,
config: {
placeholder: "Enter a header",
levels: [2, 3, 4],
defaultLevel: 3,
},
},
raw: RawTool,
image: {
class: SimpleImage,
inlineToolbar: true,
config: {
placeholder: "Paste image URL",
},
},
list: {
class: NestedList,
inlineToolbar: true,
config: {
defaultStyle: "unordered",
},
tunes: ["textVariant", "footnotes"],
},
delimiter: Delimiter,
table: {
class: Table,
inlineToolbar: true,
config: {
rows: 2,
cols: 3,
},
},
underline: Underline,
code: CodeTool,
marker: {
class: MarkerTool,
shortcut: "CMD+M",
},
footnotes: {
class: FootnotesTune,
config: {
placeholder: "Your placeholder for footnotes popover",
shortcut: "CMD+SHIFT+F",
},
},
textVariant: TextVariantTune,
},
tunes: ["textVariant"],
});

editor.isReady
.then(() => {
console.log("Editor.js is ready to work!");
})
.catch((reason) => {
console.log(`Editor.js initialization failed because of ${reason}`);
});

保存数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 节点解析为JSON
function childNodesToJson(richText: string) {
const dom = document.createElement("div");
const recursive = (dom: any, arr: any[] = [], tags: any[] = []) => {
dom.childNodes.forEach((item: any) => {
console.log(item.nodeName, item.nodeType, item.textContent, item.nodeValue);
if (item.nodeType === 1) {
const getClass = item.className.split(' ').filter((item: string) => !!item)
let newTag: any = {
node: item.nodeName,
class: getClass.length ? getClass : undefined,
}
// TODO
// 不同插件需配置不同tag属性。例如:fontBackgroundColor插件需配置backgroundColor属性
if (item.nodeName === 'FBGC') {
newTag.style = {
backgroundColor: item.style.backgroundColor
}
}
recursive(item, arr, [...tags, newTag]);
} else if (item.nodeType === 3) {
arr.push({
tags: tags.length ? tags : undefined,
text: item.textContent,
});
}
});
dom.remove();
return arr
}
dom.innerHTML = richText;
return recursive(dom);
}

// 保存数据
const onSave = () => {
editor
.save()
.then((outputData) => {
outputData.blocks.forEach((item: any) => {
if (["paragraph", "heading"].includes(item.type)) {
item.data.json = childNodesToJson(item.data.text);
}
});
console.log("Article data: ", outputData);
})
.catch((error) => {
console.log("Saving failed: ", error);
});
};

(document.getElementById("saveBtn") as any).addEventListener("click", onSave);

开源项目

https://github.com/codex-team/editor.js

https://github.com/TypeCellOS/BlockNote


基于 editorjs 块编辑器开发及跨平台数据渲染
http://example.com/20230822-块编辑器/
作者
csorz
发布于
2023年8月22日
许可协议