@lesjoursfr/edith 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/license +9 -0
- package/package.json +84 -0
- package/readme.md +34 -0
- package/src/core/dom.js +309 -0
- package/src/core/edit.js +255 -0
- package/src/core/event.js +3 -0
- package/src/core/history.js +27 -0
- package/src/core/mode.js +4 -0
- package/src/core/range.js +64 -0
- package/src/core/throttle.js +158 -0
- package/src/css/main.scss +282 -0
- package/src/index.js +86 -0
- package/src/ui/button.js +197 -0
- package/src/ui/editor.js +348 -0
- package/src/ui/modal.js +151 -0
package/license
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Les Jours (github.com/lesjoursfr)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lesjoursfr/edith",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Simple WYSIWYG editor.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": "lesjoursfr/edith",
|
|
7
|
+
"homepage": "https://github.com/lesjoursfr/edith#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/lesjoursfr/edith/issues"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=v18.3.0"
|
|
16
|
+
},
|
|
17
|
+
"browserslist": [
|
|
18
|
+
"Chrome >= 60",
|
|
19
|
+
"Safari >= 12",
|
|
20
|
+
"iOS >= 12",
|
|
21
|
+
"Firefox >= 60",
|
|
22
|
+
"Edge >= 79"
|
|
23
|
+
],
|
|
24
|
+
"main": "src/index.js",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"freshlock": "rm -rf node_modules/ && rm .yarn/install-state.gz && rm -r .yarn/cache/ && rm yarn.lock && yarn",
|
|
28
|
+
"stylelint-check": "stylelint-config-prettier-check",
|
|
29
|
+
"eslint-check": "eslint-config-prettier src/index.js",
|
|
30
|
+
"check-js": "eslint . --ext .js",
|
|
31
|
+
"check-sass": "stylelint **/*.scss",
|
|
32
|
+
"check-style": "prettier --check .",
|
|
33
|
+
"lint-js": "eslint . --fix --ext .js",
|
|
34
|
+
"lint-sass": "stylelint **/*.scss --fix",
|
|
35
|
+
"format": "prettier --write .",
|
|
36
|
+
"test": "npx ava",
|
|
37
|
+
"builddeps": "webpack --mode production --config server/deps/codemirror.webpack.js --progress",
|
|
38
|
+
"server": "webpack serve --mode development --config server/editor.webpack.js --hot --open"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src/**/*"
|
|
42
|
+
],
|
|
43
|
+
"keywords": [
|
|
44
|
+
"WYSIWYG",
|
|
45
|
+
"editor"
|
|
46
|
+
],
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@babel/core": "^7.18.5",
|
|
49
|
+
"@babel/preset-env": "^7.18.2",
|
|
50
|
+
"@codemirror/lang-html": "^6.0.0",
|
|
51
|
+
"@fortawesome/fontawesome-free": "^6.1.1",
|
|
52
|
+
"@popperjs/core": "^2.11.5",
|
|
53
|
+
"ava": "^4.3.0",
|
|
54
|
+
"babel-loader": "^8.2.5",
|
|
55
|
+
"codemirror": "^6.0.0",
|
|
56
|
+
"css-loader": "^6.7.1",
|
|
57
|
+
"eslint": "^8.18.0",
|
|
58
|
+
"eslint-config-prettier": "^8.5.0",
|
|
59
|
+
"eslint-config-standard": "^17.0.0",
|
|
60
|
+
"eslint-plugin-import": "^2.26.0",
|
|
61
|
+
"eslint-plugin-n": "^15.2.3",
|
|
62
|
+
"eslint-plugin-promise": "^6.0.0",
|
|
63
|
+
"jsdom": "^20.0.0",
|
|
64
|
+
"mini-css-extract-plugin": "^2.6.1",
|
|
65
|
+
"postcss": "^8.4.14",
|
|
66
|
+
"prettier": "^2.7.1",
|
|
67
|
+
"sass": "^1.52.3",
|
|
68
|
+
"sass-loader": "^13.0.0",
|
|
69
|
+
"style-loader": "^3.3.1",
|
|
70
|
+
"stylelint": "^14.9.1",
|
|
71
|
+
"stylelint-config-prettier": "^9.0.3",
|
|
72
|
+
"stylelint-config-sass-guidelines": "^9.0.1",
|
|
73
|
+
"webpack": "^5.73.0",
|
|
74
|
+
"webpack-cli": "^4.10.0",
|
|
75
|
+
"webpack-dev-server": "^4.9.2"
|
|
76
|
+
},
|
|
77
|
+
"peerDependencies": {
|
|
78
|
+
"@codemirror/lang-html": "^6.0.0",
|
|
79
|
+
"@fortawesome/fontawesome-free": "^6.1.1",
|
|
80
|
+
"@popperjs/core": "^2.11.5",
|
|
81
|
+
"codemirror": "^6.0.0"
|
|
82
|
+
},
|
|
83
|
+
"packageManager": "yarn@3.2.1"
|
|
84
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[](https://badge.fury.io/js/@lesjoursfr%2Fedith)
|
|
2
|
+
[](https://travis-ci.org/lesjoursfr/edith)
|
|
3
|
+
|
|
4
|
+
# @lesjoursfr/edith
|
|
5
|
+
|
|
6
|
+
Edith, simple WYSIWYG editor.
|
|
7
|
+
|
|
8
|
+
# Requirements
|
|
9
|
+
|
|
10
|
+
To work this library needs :
|
|
11
|
+
|
|
12
|
+
- [codemirror](https://www.npmjs.com/package/codemirror) **6.x**
|
|
13
|
+
- [@codemirror/lang-html](https://www.npmjs.com/package/@codemirror/lang-html) **6.x**
|
|
14
|
+
- [@popperjs/core](https://www.npmjs.com/package/@popperjs/core) **2.x**
|
|
15
|
+
- [@fortawesome/fontawesome-free](https://www.npmjs.com/package/@fortawesome/fontawesome-free) **6.x**
|
|
16
|
+
|
|
17
|
+
# How to use
|
|
18
|
+
|
|
19
|
+
```javascript
|
|
20
|
+
import { Edith } from "@lesjoursfr/edith";
|
|
21
|
+
|
|
22
|
+
/* Initialize the WYSIWYG Editor */
|
|
23
|
+
new Edith(document.querySelector("#editor"), {
|
|
24
|
+
height: 200,
|
|
25
|
+
toolbar: [
|
|
26
|
+
["style", ["bold", "italic", "underline", "strikethrough", "subscript", "superscript", "nbsp", "clear"]],
|
|
27
|
+
/*** Other toolbar blocs ***/
|
|
28
|
+
],
|
|
29
|
+
buttons: {
|
|
30
|
+
/*** Extra buttons for the toolbar ***/
|
|
31
|
+
},
|
|
32
|
+
initialContent: "Optional initial content",
|
|
33
|
+
});
|
|
34
|
+
```
|
package/src/core/dom.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if the node has the given tag name, or if its tag name is in the given list.
|
|
3
|
+
* @param {Node} node the element to check
|
|
4
|
+
* @param {(string|Array)} tags a tag name or a list of tag name
|
|
5
|
+
* @returns {boolean} true if the node has the given tag name
|
|
6
|
+
*/
|
|
7
|
+
export function hasTagName(node, tags) {
|
|
8
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (typeof tags === "string") {
|
|
13
|
+
return node.tagName === tags.toUpperCase();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return tags.some((tag) => node.tagName === tag.toUpperCase());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if the node has the given class name.
|
|
21
|
+
* @param {Node} node the element to check
|
|
22
|
+
* @param {(string|Array)} className a class name
|
|
23
|
+
* @returns {boolean} true if the node has the given class name
|
|
24
|
+
*/
|
|
25
|
+
export function hasClass(node, className) {
|
|
26
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return node.classList.contains(className);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a new node.
|
|
35
|
+
* @param {string} tag the tag name of the node
|
|
36
|
+
* @param {object} options optional parameters
|
|
37
|
+
* @param {string} options.innerHTML the HTML code of the node
|
|
38
|
+
* @param {string} options.textContent the text content of the node
|
|
39
|
+
* @param {object} options.attributes attributes of the node
|
|
40
|
+
* @returns {Node} the created node
|
|
41
|
+
*/
|
|
42
|
+
export function createNodeWith(tag, { innerHTML, textContent, attributes } = {}) {
|
|
43
|
+
const node = document.createElement(tag);
|
|
44
|
+
|
|
45
|
+
if (attributes) {
|
|
46
|
+
for (const key in attributes) {
|
|
47
|
+
if (Object.hasOwnProperty.call(attributes, key)) {
|
|
48
|
+
node.setAttribute(key, attributes[key]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (typeof innerHTML === "string") {
|
|
54
|
+
node.innerHTML = innerHTML;
|
|
55
|
+
} else if (typeof textContent === "string") {
|
|
56
|
+
node.textContent = textContent;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return node;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Replace a node.
|
|
64
|
+
* @param {Node} node the node to replace
|
|
65
|
+
* @param {Node} replacement the new node
|
|
66
|
+
* @returns {Node} the new node
|
|
67
|
+
*/
|
|
68
|
+
export function replaceNodeWith(node, replacement) {
|
|
69
|
+
node.replaceWith(replacement);
|
|
70
|
+
return replacement;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Replace the node by its child nodes.
|
|
75
|
+
* @param {Node} node the node to replace
|
|
76
|
+
* @returns {Array} its child nodes
|
|
77
|
+
*/
|
|
78
|
+
export function unwrapNode(node) {
|
|
79
|
+
const newNodes = node.childNodes;
|
|
80
|
+
node.replaceWith(...newNodes);
|
|
81
|
+
return newNodes;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Replace the node by its text content.
|
|
86
|
+
* @param {Node} node the node to replace
|
|
87
|
+
* @returns {Text} the created Text node
|
|
88
|
+
*/
|
|
89
|
+
export function textifyNode(node) {
|
|
90
|
+
const newNode = document.createTextNode(node.textContent);
|
|
91
|
+
node.replaceWith(newNode);
|
|
92
|
+
return newNode;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remove all node's child nodes that pass the test implemented by the provided function.
|
|
97
|
+
* @param {Node} node the node to process
|
|
98
|
+
* @param {Function} callbackFn the predicate
|
|
99
|
+
*/
|
|
100
|
+
export function removeNodes(node, callbackFn) {
|
|
101
|
+
for (const el of [...node.childNodes]) {
|
|
102
|
+
if (callbackFn(el)) {
|
|
103
|
+
el.remove();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Remove all node's child nodes that are empty text nodes.
|
|
110
|
+
* @param {Node} node the node to process
|
|
111
|
+
*/
|
|
112
|
+
export function removeEmptyTextNodes(node) {
|
|
113
|
+
removeNodes(node, (el) => el.nodeType === Node.TEXT_NODE && el.textContent.trim().length === 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Remove all node's child nodes that are comment nodes.
|
|
118
|
+
* @param {Node} node the node to process
|
|
119
|
+
*/
|
|
120
|
+
export function removeCommentNodes(node) {
|
|
121
|
+
removeNodes(node, (el) => el.nodeType === Node.COMMENT_NODE);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Reset all node's attributes to the given list.
|
|
126
|
+
* @param {Node} node the node
|
|
127
|
+
* @param {object} targetAttributes the requested node's attributes
|
|
128
|
+
*/
|
|
129
|
+
export function resetAttributesTo(node, targetAttributes) {
|
|
130
|
+
for (const name of node.getAttributeNames()) {
|
|
131
|
+
if (targetAttributes[name] === undefined) {
|
|
132
|
+
node.removeAttribute(name);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const name of Object.keys(targetAttributes)) {
|
|
136
|
+
node.setAttribute(name, targetAttributes[name]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Replace the node's style attribute by some regular nodes (<b>, <i>, <u> or <s>).
|
|
142
|
+
* @param {Node} node the node to process
|
|
143
|
+
* @returns {Node} the new node
|
|
144
|
+
*/
|
|
145
|
+
export function replaceNodeStyleByTag(node) {
|
|
146
|
+
// Get the style
|
|
147
|
+
const styleAttr = node.getAttribute("style") || "";
|
|
148
|
+
|
|
149
|
+
// Check if a tag is override by the style attribute
|
|
150
|
+
if (
|
|
151
|
+
(hasTagName(node, "b") && styleAttr.match(/font-weight\s*:\s*(normal|400);/)) ||
|
|
152
|
+
(hasTagName(node, "i") && styleAttr.match(/font-style\s*:\s*normal;/)) ||
|
|
153
|
+
(hasTagName(node, ["u", "s"]) && styleAttr.match(/text-decoration\s*:\s*none;/))
|
|
154
|
+
) {
|
|
155
|
+
node = replaceNodeWith(
|
|
156
|
+
node,
|
|
157
|
+
createNodeWith("span", { attributes: { style: styleAttr }, innerHTML: node.innerHTML })
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Infer the tag from the style
|
|
162
|
+
if (styleAttr.match(/font-weight\s*:\s*(bold|700|800|900);/)) {
|
|
163
|
+
node = replaceNodeWith(
|
|
164
|
+
node,
|
|
165
|
+
createNodeWith("b", {
|
|
166
|
+
innerHTML: `<span style="${styleAttr.replace(/font-weight\s*:\s*(bold|700|800|900);/, "")}">${
|
|
167
|
+
node.innerHTML
|
|
168
|
+
}</span>`,
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
} else if (styleAttr.match(/font-style\s*:\s*italic;/)) {
|
|
172
|
+
node = replaceNodeWith(
|
|
173
|
+
node,
|
|
174
|
+
createNodeWith("i", {
|
|
175
|
+
innerHTML: `<span style="${styleAttr.replace(/font-style\s*:\s*italic;/, "")}">${node.innerHTML}</span>`,
|
|
176
|
+
})
|
|
177
|
+
);
|
|
178
|
+
} else if (styleAttr.match(/text-decoration\s*:\s*underline;/)) {
|
|
179
|
+
node = replaceNodeWith(
|
|
180
|
+
node,
|
|
181
|
+
createNodeWith("u", {
|
|
182
|
+
innerHTML: `<span style="${styleAttr.replace(/text-decoration\s*:\s*underline;/, "")}">${
|
|
183
|
+
node.innerHTML
|
|
184
|
+
}</span>`,
|
|
185
|
+
})
|
|
186
|
+
);
|
|
187
|
+
} else if (styleAttr.match(/text-decoration\s*:\s*line-through;/)) {
|
|
188
|
+
node = replaceNodeWith(
|
|
189
|
+
node,
|
|
190
|
+
createNodeWith("s", {
|
|
191
|
+
innerHTML: `<span style="${styleAttr.replace(/text-decoration\s*:\s*line-through;/, "")}">${
|
|
192
|
+
node.innerHTML
|
|
193
|
+
}</span>`,
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Return the node
|
|
199
|
+
return node;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Remove all leading & trailing node's child nodes that match the given tag.
|
|
204
|
+
* @param {Node} node the node to process
|
|
205
|
+
* @param {string} tag the tag
|
|
206
|
+
*/
|
|
207
|
+
export function trimTag(node, tag) {
|
|
208
|
+
// Children
|
|
209
|
+
const children = node.childNodes;
|
|
210
|
+
|
|
211
|
+
// Remove Leading
|
|
212
|
+
while (children.length > 0 && hasTagName(children[0], tag)) {
|
|
213
|
+
children[0].remove();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Remove Trailing
|
|
217
|
+
while (children.length > 0 && hasTagName(children[children.length - 1], tag)) {
|
|
218
|
+
children[children.length - 1].remove();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Clean the DOM content of the node
|
|
224
|
+
* @param {Node} root the node to process
|
|
225
|
+
* @param {object} style active styles for the root
|
|
226
|
+
*/
|
|
227
|
+
export function cleanDomContent(root, style) {
|
|
228
|
+
// Iterate through children
|
|
229
|
+
for (let el of [...root.children]) {
|
|
230
|
+
// Check if the span is an edith-nbsp
|
|
231
|
+
if (hasTagName(el, "span") && hasClass(el, "edith-nbsp")) {
|
|
232
|
+
// Ensure that we have a clean element
|
|
233
|
+
resetAttributesTo(el, { class: "edith-nbsp", contenteditable: "false" });
|
|
234
|
+
el.innerHTML = "¶";
|
|
235
|
+
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Check if there is a style attribute on the current node
|
|
240
|
+
if (el.hasAttribute("style")) {
|
|
241
|
+
// Replace the style attribute by tags
|
|
242
|
+
el = replaceNodeStyleByTag(el);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Check if the Tag Match a Parent Tag
|
|
246
|
+
if (style[el.tagName]) {
|
|
247
|
+
el = replaceNodeWith(
|
|
248
|
+
el,
|
|
249
|
+
createNodeWith("span", { attributes: { style: el.getAttribute("style") || "" }, innerHTML: el.innerHTML })
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Save the Current Style Tag
|
|
254
|
+
const newTags = { ...style };
|
|
255
|
+
if (hasTagName(el, ["b", "i", "q", "u", "s"])) {
|
|
256
|
+
newTags[el.tagName] = true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Clean Children
|
|
260
|
+
cleanDomContent(el, newTags);
|
|
261
|
+
|
|
262
|
+
// Keep only href & target attributes for <a> tags
|
|
263
|
+
if (hasTagName(el, "a")) {
|
|
264
|
+
const linkAttributes = {};
|
|
265
|
+
if (el.hasAttribute("href")) {
|
|
266
|
+
linkAttributes.href = el.getAttribute("href");
|
|
267
|
+
}
|
|
268
|
+
if (el.hasAttribute("target")) {
|
|
269
|
+
linkAttributes.target = el.getAttribute("target");
|
|
270
|
+
}
|
|
271
|
+
resetAttributesTo(el, linkAttributes);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Remove all tag attributes for tags in the allowed list
|
|
276
|
+
if (hasTagName(el, ["b", "i", "q", "u", "s", "br"])) {
|
|
277
|
+
resetAttributesTo(el, {});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Remove useless tags
|
|
282
|
+
if (hasTagName(el, ["style", "meta", "link"])) {
|
|
283
|
+
el.remove();
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Check if it's a <p> tag
|
|
288
|
+
if (hasTagName(el, "p")) {
|
|
289
|
+
// Check if the element contains text
|
|
290
|
+
if (el.textContent.trim().length === 0) {
|
|
291
|
+
// Remove the node
|
|
292
|
+
el.remove();
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Remove all tag attributes
|
|
297
|
+
resetAttributesTo(el, {});
|
|
298
|
+
|
|
299
|
+
// Remove leading & trailing <br>
|
|
300
|
+
trimTag(el, "br");
|
|
301
|
+
|
|
302
|
+
// Return
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Unwrap the node
|
|
307
|
+
unwrapNode(el);
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/core/edit.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { getSelection, moveCursorInsideNode, moveCursorAfterNode, selectNodeContents } from "./range.js";
|
|
2
|
+
import {
|
|
3
|
+
hasClass,
|
|
4
|
+
hasTagName,
|
|
5
|
+
cleanDomContent,
|
|
6
|
+
createNodeWith,
|
|
7
|
+
unwrapNode,
|
|
8
|
+
textifyNode,
|
|
9
|
+
removeEmptyTextNodes,
|
|
10
|
+
removeCommentNodes,
|
|
11
|
+
} from "./dom.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Split the node at the caret position.
|
|
15
|
+
* @param {Range} range the caret position
|
|
16
|
+
* @param {Node} node the node to split
|
|
17
|
+
* @returns {Text} the created text node with the caret inside
|
|
18
|
+
*/
|
|
19
|
+
function splitNodeAtCaret(range, node) {
|
|
20
|
+
// Get the node's parent
|
|
21
|
+
const parent = node.parentNode;
|
|
22
|
+
|
|
23
|
+
// Clone the current range & move the starting point to the beginning of the parent's node
|
|
24
|
+
const beforeCaret = range.cloneRange();
|
|
25
|
+
beforeCaret.setStart(parent, 0);
|
|
26
|
+
|
|
27
|
+
// Extract the content before the caret
|
|
28
|
+
const frag = beforeCaret.extractContents();
|
|
29
|
+
|
|
30
|
+
// Add a TextNode
|
|
31
|
+
const textNode = document.createTextNode("\u200B");
|
|
32
|
+
frag.append(textNode);
|
|
33
|
+
|
|
34
|
+
// Add back the content into the node's parent
|
|
35
|
+
parent.prepend(frag);
|
|
36
|
+
|
|
37
|
+
// Move the cursor in the created TextNode
|
|
38
|
+
moveCursorInsideNode(textNode);
|
|
39
|
+
|
|
40
|
+
// Return the inserted TextNode
|
|
41
|
+
return textNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a node at the caret position.
|
|
46
|
+
* @param {Range} range the caret position
|
|
47
|
+
* @param {string} tag the tag name of the node
|
|
48
|
+
* @param {object} options optional parameters
|
|
49
|
+
* @param {string} options.textContent the text content of the node
|
|
50
|
+
* @returns {Text} the created node with the caret inside
|
|
51
|
+
*/
|
|
52
|
+
function insertTagAtCaret(range, tag, options) {
|
|
53
|
+
// Create the tag
|
|
54
|
+
const node = document.createElement(tag);
|
|
55
|
+
|
|
56
|
+
// Add a zero-width char or the word "lien" to create a valid cursor position inside the element
|
|
57
|
+
if (tag === "a") {
|
|
58
|
+
node.textContent = options.textContent || "lien";
|
|
59
|
+
} else {
|
|
60
|
+
node.innerHTML = "\u200B";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Insert the tag at the cursor position
|
|
64
|
+
range.insertNode(node);
|
|
65
|
+
|
|
66
|
+
// Add an extra space after the tag if it's a link
|
|
67
|
+
if (tag === "a") {
|
|
68
|
+
node.insertAdjacentText("afterend", " ");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Move the cursor inside the created tag
|
|
72
|
+
moveCursorInsideNode(node);
|
|
73
|
+
|
|
74
|
+
// Return the inserted tag
|
|
75
|
+
return node;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Replace the current selection by the given HTML code.
|
|
80
|
+
* @param {string} html the HTML code
|
|
81
|
+
*/
|
|
82
|
+
export function replaceSelectionByHtml(html) {
|
|
83
|
+
// Get the caret position
|
|
84
|
+
const { sel, range } = getSelection();
|
|
85
|
+
|
|
86
|
+
// Check if the user has selected something
|
|
87
|
+
if (range === undefined) return false;
|
|
88
|
+
|
|
89
|
+
// Create the fragment to insert
|
|
90
|
+
const frag = document.createDocumentFragment();
|
|
91
|
+
|
|
92
|
+
// Create the nodes to insert
|
|
93
|
+
const el = createNodeWith("div", { innerHTML: html });
|
|
94
|
+
frag.append(...el.childNodes);
|
|
95
|
+
const lastNode = frag.childNodes[frag.childNodes.length - 1];
|
|
96
|
+
|
|
97
|
+
// Replace the current selection by the pasted content
|
|
98
|
+
sel.deleteFromDocument();
|
|
99
|
+
range.insertNode(frag);
|
|
100
|
+
|
|
101
|
+
// Preserve the selection
|
|
102
|
+
moveCursorAfterNode(lastNode);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Wrap the current selection inside a new node.
|
|
107
|
+
* @param {string} tag the tag name of the node
|
|
108
|
+
* @param {object} options optional parameters
|
|
109
|
+
* @param {string} options.textContent the text content of the node
|
|
110
|
+
* @returns {Node} the created node or the root node
|
|
111
|
+
*/
|
|
112
|
+
export function wrapInsideTag(tag, options = {}) {
|
|
113
|
+
// Get the caret position
|
|
114
|
+
const { sel, range } = getSelection();
|
|
115
|
+
|
|
116
|
+
// Check if the user has selected something
|
|
117
|
+
if (range === undefined) return false;
|
|
118
|
+
|
|
119
|
+
// Check if the range is collapsed
|
|
120
|
+
if (range.collapsed) {
|
|
121
|
+
// Check if a parent element has the same tag name
|
|
122
|
+
let parent = sel.anchorNode.parentNode;
|
|
123
|
+
while (!hasClass(parent, "edith-visual")) {
|
|
124
|
+
if (hasTagName(parent, tag)) {
|
|
125
|
+
// One of the parent has the same tag name
|
|
126
|
+
// Split the parent at the caret & insert a TextNode
|
|
127
|
+
return splitNodeAtCaret(range, parent);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Take the parent
|
|
131
|
+
parent = parent.parentNode;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// We just have to insert a new Node at the caret position
|
|
135
|
+
return insertTagAtCaret(range, tag, options);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// There is a selection
|
|
139
|
+
// Check if a parent element has the same tag name
|
|
140
|
+
let parent = range.commonAncestorContainer;
|
|
141
|
+
while (!hasClass(parent, "edith-visual")) {
|
|
142
|
+
if (hasTagName(parent, tag)) {
|
|
143
|
+
// One of the parent has the same tag name : unwrap it
|
|
144
|
+
return unwrapNode(parent);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Take the parent
|
|
148
|
+
parent = parent.parentNode;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Try to replace all elements with the same tag name in the selection
|
|
152
|
+
let replaced = false;
|
|
153
|
+
for (const el of [...parent.getElementsByTagName(tag)]) {
|
|
154
|
+
// Check if the the Element Intersect the Selection
|
|
155
|
+
if (sel.containsNode(el, true)) {
|
|
156
|
+
unwrapNode(el);
|
|
157
|
+
replaced = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (replaced) {
|
|
161
|
+
parent.normalize();
|
|
162
|
+
return parent;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Nothing was replaced
|
|
166
|
+
// Wrap the selection inside the given tag
|
|
167
|
+
const node = document.createElement(tag);
|
|
168
|
+
node.appendChild(range.extractContents());
|
|
169
|
+
range.insertNode(node);
|
|
170
|
+
selectNodeContents(node);
|
|
171
|
+
return node;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Wrap the current selection inside a link.
|
|
176
|
+
* @param {string} text the text of the link
|
|
177
|
+
* @param {string} href the href of the link
|
|
178
|
+
* @param {boolean} targetBlank add target="_blank" attribute or not
|
|
179
|
+
* @returns the created node
|
|
180
|
+
*/
|
|
181
|
+
export function wrapInsideLink(text, href, targetBlank) {
|
|
182
|
+
// Wrap the selection inside a link
|
|
183
|
+
const tag = wrapInsideTag("a", { textContent: text });
|
|
184
|
+
|
|
185
|
+
// Check if we have a tag
|
|
186
|
+
if (tag === undefined) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Add an href Attribute
|
|
191
|
+
tag.setAttribute("href", href);
|
|
192
|
+
|
|
193
|
+
// Create a target="_blank" attribute if required
|
|
194
|
+
if (targetBlank === true) {
|
|
195
|
+
tag.setAttribute("target", "_blank");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Return the tag
|
|
199
|
+
return tag;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Clear the style in the current selection.
|
|
204
|
+
*/
|
|
205
|
+
export function clearSelectionStyle() {
|
|
206
|
+
// Get the caret position
|
|
207
|
+
const { sel, range } = getSelection();
|
|
208
|
+
|
|
209
|
+
// Check if there is something to do
|
|
210
|
+
if (range.commonAncestorContainer.nodeType === Node.TEXT_NODE) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Try to replace all non-text elements by their text
|
|
215
|
+
for (const el of [...range.commonAncestorContainer.children]) {
|
|
216
|
+
// Check if the the Element Intersect the Selection
|
|
217
|
+
if (sel.containsNode(el, true)) {
|
|
218
|
+
// Replace the node by its text
|
|
219
|
+
textifyNode(el);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Clean the given HTML code.
|
|
226
|
+
* @param {string} html the HTML code to clean
|
|
227
|
+
* @param {object} style active styles
|
|
228
|
+
* @returns the cleaned HTML code
|
|
229
|
+
*/
|
|
230
|
+
export function cleanPastedHtml(html, style) {
|
|
231
|
+
// Create a new div with the HTML content
|
|
232
|
+
const result = document.createElement("div");
|
|
233
|
+
result.innerHTML = html;
|
|
234
|
+
|
|
235
|
+
// Clean the HTML content
|
|
236
|
+
cleanDomContent(result, style);
|
|
237
|
+
result.normalize();
|
|
238
|
+
|
|
239
|
+
// Clean empty text nodes
|
|
240
|
+
removeEmptyTextNodes(result);
|
|
241
|
+
|
|
242
|
+
// Fix extra stuff in the HTML code :
|
|
243
|
+
// - Clean spaces
|
|
244
|
+
// - Merge siblings tags
|
|
245
|
+
result.innerHTML = result.innerHTML
|
|
246
|
+
.replace(/\s* \s*/g, " ")
|
|
247
|
+
.replace(/\s+/g, " ")
|
|
248
|
+
.replace(/(<\/b>[\n\r\s]*<b>|<\/i>[\n\r\s]*<i>|<\/u>[\n\r\s]*<u>|<\/s>[\n\r\s]*<s>)/g, " ");
|
|
249
|
+
|
|
250
|
+
// Clean comment nodes
|
|
251
|
+
removeCommentNodes(result);
|
|
252
|
+
|
|
253
|
+
// Return Cleaned HTML
|
|
254
|
+
return result;
|
|
255
|
+
}
|