@lesjoursfr/edith 2.0.2 → 2.1.2

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.
@@ -1,26 +1,27 @@
1
- import { getSelection, moveCursorInsideNode, moveCursorAfterNode, selectNodeContents } from "./range.js";
2
1
  import {
3
- hasClass,
4
- hasTagName,
5
2
  cleanDomContent,
6
3
  createNodeWith,
7
- unwrapNode,
8
- textifyNode,
4
+ hasClass,
5
+ hasTagName,
6
+ isHTMLElement,
9
7
  isSelfClosing,
10
- removeNodes,
11
- removeEmptyTextNodes,
12
8
  removeCommentNodes,
9
+ removeEmptyTextNodes,
10
+ removeNodes,
11
+ textifyNode,
12
+ unwrapNode,
13
13
  } from "./dom.js";
14
+ import { getSelection, moveCursorAfterNode, moveCursorInsideNode, selectNodeContents, selectNodes } from "./range.js";
14
15
 
15
16
  /**
16
17
  * Split the node at the caret position.
17
18
  * @param {Range} range the caret position
18
- * @param {Node} node the node to split
19
+ * @param {HTMLElement} node the node to split
19
20
  * @returns {Text} the created text node with the caret inside
20
21
  */
21
- function splitNodeAtCaret(range, node) {
22
+ function splitNodeAtCaret(range: Range, node: HTMLElement): Text {
22
23
  // Get the node's parent
23
- const parent = node.parentNode;
24
+ const parent = node.parentNode as HTMLElement;
24
25
 
25
26
  // Clone the current range & move the starting point to the beginning of the parent's node
26
27
  const beforeCaret = range.cloneRange();
@@ -43,15 +44,63 @@ function splitNodeAtCaret(range, node) {
43
44
  return textNode;
44
45
  }
45
46
 
47
+ /**
48
+ * Extract the selection from the node.
49
+ * @param {Range} range the selection to extract
50
+ * @param {HTMLElement} node the node to split
51
+ * @param {string} tag the tag to remove
52
+ * @returns {HTMLElement} the created node
53
+ */
54
+ function extractSelectionFromNode(range: Range, node: HTMLElement): HTMLElement {
55
+ // Get the node's parent
56
+ const parent = node.parentNode as HTMLElement;
57
+
58
+ // Clone the current range & move the starting point to the beginning of the parent's node
59
+ const beforeSelection = new Range();
60
+ beforeSelection.selectNodeContents(parent);
61
+ beforeSelection.setEnd(range.startContainer, range.startOffset);
62
+ const afterSelection = new Range();
63
+ afterSelection.selectNodeContents(parent);
64
+ afterSelection.setStart(range.endContainer, range.endOffset);
65
+
66
+ // Extract the content of the selection
67
+ const fragBefore = beforeSelection.extractContents();
68
+ const fragAfter = afterSelection.extractContents();
69
+
70
+ // Add back the content into the node's parent
71
+ parent.prepend(fragBefore);
72
+ parent.append(fragAfter);
73
+
74
+ // Remove the parent from the selection
75
+ let current = !isHTMLElement(range.commonAncestorContainer)
76
+ ? (range.commonAncestorContainer.parentNode as HTMLElement)
77
+ : range.commonAncestorContainer;
78
+ while (current.tagName !== node.tagName) {
79
+ // Take the parent
80
+ current = current.parentNode as HTMLElement;
81
+ }
82
+ const innerNodes = unwrapNode(current);
83
+
84
+ // Preserve the selection
85
+ selectNodes(innerNodes);
86
+
87
+ // Return the inserted TextNode
88
+ return innerNodes[0].parentNode as HTMLElement;
89
+ }
90
+
46
91
  /**
47
92
  * Create a node at the caret position.
48
93
  * @param {Range} range the caret position
49
94
  * @param {string} tag the tag name of the node
50
95
  * @param {object} options optional parameters
51
96
  * @param {string} options.textContent the text content of the node
52
- * @returns {Text} the created node with the caret inside
97
+ * @returns {HTMLElement} the created node with the caret inside
53
98
  */
54
- function insertTagAtCaret(range, tag, options) {
99
+ function insertTagAtCaret<K extends keyof HTMLElementTagNameMap>(
100
+ range: Range,
101
+ tag: K,
102
+ options: { textContent?: string } = {}
103
+ ): HTMLElementTagNameMap[K] {
55
104
  // Create the tag
56
105
  const node = document.createElement(tag);
57
106
 
@@ -81,12 +130,14 @@ function insertTagAtCaret(range, tag, options) {
81
130
  * Replace the current selection by the given HTML code.
82
131
  * @param {string} html the HTML code
83
132
  */
84
- export function replaceSelectionByHtml(html) {
133
+ export function replaceSelectionByHtml(html: string): void {
85
134
  // Get the caret position
86
135
  const { sel, range } = getSelection();
87
136
 
88
137
  // Check if the user has selected something
89
- if (range === undefined) return false;
138
+ if (range === undefined) {
139
+ return;
140
+ }
90
141
 
91
142
  // Create the fragment to insert
92
143
  const frag = document.createDocumentFragment();
@@ -109,19 +160,24 @@ export function replaceSelectionByHtml(html) {
109
160
  * @param {string} tag the tag name of the node
110
161
  * @param {object} options optional parameters
111
162
  * @param {string} options.textContent the text content of the node
112
- * @returns {Node} the created node or the root node
163
+ * @returns {HTMLElement|Text} the created node or the root node
113
164
  */
114
- export function wrapInsideTag(tag, options = {}) {
165
+ export function wrapInsideTag<K extends keyof HTMLElementTagNameMap>(
166
+ tag: K,
167
+ options: { textContent?: string } = {}
168
+ ): HTMLElement | Text | undefined {
115
169
  // Get the caret position
116
170
  const { sel, range } = getSelection();
117
171
 
118
172
  // Check if the user has selected something
119
- if (range === undefined) return false;
173
+ if (range === undefined) {
174
+ return;
175
+ }
120
176
 
121
- // Check if the range is collapsed
177
+ // Check if there is a Selection
122
178
  if (range.collapsed) {
123
179
  // Check if a parent element has the same tag name
124
- let parent = sel.anchorNode.parentNode;
180
+ let parent = sel.anchorNode!.parentNode as HTMLElement;
125
181
  while (!hasClass(parent, "edith-visual")) {
126
182
  if (hasTagName(parent, tag)) {
127
183
  // One of the parent has the same tag name
@@ -130,7 +186,7 @@ export function wrapInsideTag(tag, options = {}) {
130
186
  }
131
187
 
132
188
  // Take the parent
133
- parent = parent.parentNode;
189
+ parent = parent.parentNode as HTMLElement;
134
190
  }
135
191
 
136
192
  // We just have to insert a new Node at the caret position
@@ -140,29 +196,30 @@ export function wrapInsideTag(tag, options = {}) {
140
196
  // There is a selection
141
197
  // Check if a parent element has the same tag name
142
198
  let parent = range.commonAncestorContainer;
143
- while (!hasClass(parent, "edith-visual")) {
144
- if (hasTagName(parent, tag)) {
145
- // One of the parent has the same tag name : unwrap it
146
- return unwrapNode(parent);
199
+ while (!isHTMLElement(parent) || !hasClass(parent, "edith-visual")) {
200
+ if (isHTMLElement(parent) && hasTagName(parent, tag)) {
201
+ // One of the parent has the same tag name
202
+ // Extract the selection from the parent
203
+ return extractSelectionFromNode(range, parent);
147
204
  }
148
205
 
149
206
  // Take the parent
150
- parent = parent.parentNode;
207
+ parent = parent.parentNode as HTMLElement;
151
208
  }
152
209
 
153
210
  // Try to replace all elements with the same tag name in the selection
154
- let replaced = false;
155
- for (const el of [...parent.getElementsByTagName(tag)]) {
211
+ for (const el of [...parent.getElementsByTagName(tag)] as HTMLElement[]) {
156
212
  // Check if the the Element Intersect the Selection
157
213
  if (sel.containsNode(el, true)) {
158
- unwrapNode(el);
159
- replaced = true;
214
+ // Unwrap the node
215
+ const innerNodes = unwrapNode(el);
216
+
217
+ // Return the node
218
+ selectNodes(innerNodes);
219
+ parent.normalize();
220
+ return parent;
160
221
  }
161
222
  }
162
- if (replaced) {
163
- parent.normalize();
164
- return parent;
165
- }
166
223
 
167
224
  // Nothing was replaced
168
225
  // Wrap the selection inside the given tag
@@ -171,7 +228,9 @@ export function wrapInsideTag(tag, options = {}) {
171
228
  range.insertNode(node);
172
229
 
173
230
  // Remove empty tags
174
- removeNodes(parent, (el) => !isSelfClosing(el.tagName) && el.textContent.length === 0);
231
+ removeNodes(parent, (el) => {
232
+ return isHTMLElement(el) && !isSelfClosing(el.tagName) && (el.textContent === null || el.textContent.length === 0);
233
+ });
175
234
 
176
235
  // Return the node
177
236
  selectNodeContents(node);
@@ -183,9 +242,9 @@ export function wrapInsideTag(tag, options = {}) {
183
242
  * @param {string} text the text of the link
184
243
  * @param {string} href the href of the link
185
244
  * @param {boolean} targetBlank add target="_blank" attribute or not
186
- * @returns the created node
245
+ * @returns {HTMLElement|Text} the created node
187
246
  */
188
- export function wrapInsideLink(text, href, targetBlank) {
247
+ export function wrapInsideLink(text: string, href: string, targetBlank: boolean): HTMLElement | Text | undefined {
189
248
  // Wrap the selection inside a link
190
249
  const tag = wrapInsideTag("a", { textContent: text });
191
250
 
@@ -194,6 +253,11 @@ export function wrapInsideLink(text, href, targetBlank) {
194
253
  return;
195
254
  }
196
255
 
256
+ // Check if it's a Text node
257
+ if (!isHTMLElement(tag)) {
258
+ return tag;
259
+ }
260
+
197
261
  // Add an href Attribute
198
262
  tag.setAttribute("href", href);
199
263
 
@@ -209,17 +273,17 @@ export function wrapInsideLink(text, href, targetBlank) {
209
273
  /**
210
274
  * Clear the style in the current selection.
211
275
  */
212
- export function clearSelectionStyle() {
276
+ export function clearSelectionStyle(): void {
213
277
  // Get the caret position
214
278
  const { sel, range } = getSelection();
215
279
 
216
280
  // Check if there is something to do
217
- if (range.commonAncestorContainer.nodeType === Node.TEXT_NODE) {
281
+ if (range === undefined || !isHTMLElement(range.commonAncestorContainer)) {
218
282
  return;
219
283
  }
220
284
 
221
285
  // Try to replace all non-text elements by their text
222
- for (const el of [...range.commonAncestorContainer.children]) {
286
+ for (const el of [...range.commonAncestorContainer.children] as HTMLElement[]) {
223
287
  // Check if the the Element Intersect the Selection
224
288
  if (sel.containsNode(el, true)) {
225
289
  // Replace the node by its text
@@ -232,9 +296,9 @@ export function clearSelectionStyle() {
232
296
  * Clean the given HTML code.
233
297
  * @param {string} html the HTML code to clean
234
298
  * @param {object} style active styles
235
- * @returns the cleaned HTML code
299
+ * @returns {HTMLElement} the cleaned HTML code
236
300
  */
237
- export function cleanPastedHtml(html, style) {
301
+ export function cleanPastedHtml(html: string, style: { [keyof: string]: boolean }): HTMLElement {
238
302
  // Create a new div with the HTML content
239
303
  const result = document.createElement("div");
240
304
  result.innerHTML = html;
@@ -0,0 +1,148 @@
1
+ const eventsNamespace = "edithEvents";
2
+ let eventsGuid = 0;
3
+
4
+ type EdithEvent = { type: string; ns: Array<string> | null; handler: EventListenerOrEventListenerObject };
5
+
6
+ type EdithEvents = {
7
+ [key: string]: EdithEvent;
8
+ };
9
+
10
+ declare global {
11
+ interface Window {
12
+ edithEvents: EdithEvents;
13
+ }
14
+ interface Document {
15
+ edithEvents: EdithEvents;
16
+ }
17
+ interface HTMLElement {
18
+ edithEvents: EdithEvents;
19
+ }
20
+ }
21
+
22
+ export enum Events {
23
+ modeChanged = "edith-mode-changed",
24
+ initialized = "edith-initialized",
25
+ }
26
+
27
+ /**
28
+ * Parse an event type to separate the type & the namespace
29
+ * @param {string} string
30
+ */
31
+ function parseEventType(string: string): Omit<EdithEvent, "handler"> {
32
+ const [type, ...nsArray] = string.split(".");
33
+ return {
34
+ type,
35
+ ns: nsArray ?? null,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Set an event listener on the node.
41
+ * @param {Window|Document|HTMLElement} node
42
+ * @param {string} events
43
+ * @param {Function} handler
44
+ */
45
+ function addEventListener(
46
+ node: Window | Document | HTMLElement,
47
+ events: string,
48
+ handler: EventListenerOrEventListenerObject
49
+ ): void {
50
+ if (node[eventsNamespace] === undefined) {
51
+ node[eventsNamespace] = {};
52
+ }
53
+
54
+ for (const event of events.split(" ")) {
55
+ const { type, ns } = parseEventType(event);
56
+ const handlerGuid = (++eventsGuid).toString(10);
57
+ node.addEventListener(type, handler);
58
+ node[eventsNamespace][handlerGuid] = { type, ns, handler };
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Remove event listeners from the node.
64
+ * @param {Window|Document|HTMLElement} node
65
+ * @param {string} events
66
+ * @param {Function|undefined} handler
67
+ */
68
+ function removeEventListener(
69
+ node: Window | Document | HTMLElement,
70
+ events: string,
71
+ handler?: EventListenerOrEventListenerObject
72
+ ): void {
73
+ if (node[eventsNamespace] === undefined) {
74
+ node[eventsNamespace] = {};
75
+ }
76
+
77
+ for (const event of events.split(" ")) {
78
+ const { type, ns } = parseEventType(event);
79
+
80
+ for (const [guid, handlerObj] of Object.entries(node[eventsNamespace])) {
81
+ if (handlerObj.type !== type && type !== "*") {
82
+ continue;
83
+ }
84
+
85
+ if (
86
+ (ns === null || handlerObj.ns?.includes(ns[0])) &&
87
+ (handler === undefined || (typeof handler === "function" && handler === handlerObj.handler))
88
+ ) {
89
+ delete node[eventsNamespace][guid];
90
+ node.removeEventListener(handlerObj.type, handlerObj.handler);
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Set an event listener on every node.
98
+ * @param {Window|Document|HTMLElement|NodeList} nodes
99
+ * @param {string} events
100
+ * @param {Function} handler
101
+ */
102
+ export function on(
103
+ nodes: Window | Document | HTMLElement | NodeListOf<HTMLElement>,
104
+ events: string,
105
+ handler: EventListenerOrEventListenerObject
106
+ ): void {
107
+ if (nodes instanceof NodeList) {
108
+ for (const node of nodes) {
109
+ addEventListener(node, events, handler);
110
+ }
111
+ } else {
112
+ addEventListener(nodes, events, handler);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Remove event listeners from the node.
118
+ * @param {Window|Document|HTMLElement|NodeList} node
119
+ * @param {string} events
120
+ * @param {Function|undefined} handler
121
+ */
122
+ export function off(
123
+ nodes: Window | Document | HTMLElement | NodeListOf<HTMLElement>,
124
+ events: string,
125
+ handler?: EventListenerOrEventListenerObject
126
+ ): void {
127
+ if (nodes instanceof NodeList) {
128
+ for (const node of nodes) {
129
+ removeEventListener(node, events, handler);
130
+ }
131
+ } else {
132
+ removeEventListener(nodes, events, handler);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Trigger the EdithEvent on the node.
138
+ * @param {Window|Document|HTMLElement} node
139
+ * @param {string} event
140
+ * @param {Object|undefined} payload
141
+ */
142
+ export function trigger(
143
+ node: Window | Document | HTMLElement,
144
+ event: string,
145
+ payload?: { [key: string]: unknown }
146
+ ): void {
147
+ node.dispatchEvent(new CustomEvent(event, { detail: payload }));
148
+ }
@@ -0,0 +1,28 @@
1
+ export class History {
2
+ private buffer: string[] = [];
3
+
4
+ constructor() {}
5
+
6
+ /**
7
+ * Add a new snapshot to the history.
8
+ * @param {string} doc the element to save
9
+ */
10
+ public push(doc: string): void {
11
+ this.buffer.push(doc);
12
+ if (this.buffer.length > 20) {
13
+ this.buffer.shift();
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Get the last saved element
19
+ * @returns {(string|null)} the last saved element or null
20
+ */
21
+ public pop(): string | null {
22
+ if (this.buffer.length === 0) {
23
+ return null;
24
+ }
25
+
26
+ return this.buffer.pop()!;
27
+ }
28
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./dom.js";
2
+ export * from "./edit.js";
3
+ export * from "./events.js";
4
+ export * from "./history.js";
5
+ export * from "./mode.js";
6
+ export * from "./range.js";
7
+ export * from "./throttle.js";
@@ -0,0 +1,4 @@
1
+ export enum EditorModes {
2
+ Visual = 1,
3
+ Code = 2,
4
+ }
@@ -0,0 +1,105 @@
1
+ import { isHTMLElement, isSelfClosing } from "./dom.js";
2
+
3
+ /**
4
+ * @typedef {Object} CurrentSelection
5
+ * @property {Selection} sel the current selection
6
+ * @property {(Range|undefined)} range the current range
7
+ */
8
+ export type CurrentSelection = {
9
+ sel: Selection;
10
+ range?: Range;
11
+ };
12
+
13
+ /**
14
+ * Get the current selection.
15
+ * @returns {CurrentSelection} the current selection
16
+ */
17
+ export function getSelection(): CurrentSelection {
18
+ const sel = window.getSelection()!;
19
+
20
+ return { sel, range: sel.rangeCount ? sel.getRangeAt(0) : undefined };
21
+ }
22
+
23
+ /**
24
+ * Restore the given selection.
25
+ * @param {CurrentSelection} selection the selection to restore
26
+ */
27
+ export function restoreSelection(selection: CurrentSelection): void {
28
+ const sel = window.getSelection()!;
29
+ sel.removeAllRanges();
30
+ if (selection.range !== undefined) {
31
+ sel.addRange(selection.range);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Move the cursor inside the node.
37
+ * @param {ChildNode} target the targeted node
38
+ */
39
+ export function moveCursorInsideNode(target: ChildNode): void {
40
+ const range = document.createRange();
41
+ const sel = window.getSelection()!;
42
+ range.setStart(target, 1);
43
+ range.collapse(true);
44
+ sel.removeAllRanges();
45
+ sel.addRange(range);
46
+ }
47
+
48
+ /**
49
+ * Move the cursor after the node.
50
+ * @param {ChildNode} target the targeted node
51
+ */
52
+ export function moveCursorAfterNode(target: ChildNode): void {
53
+ const range = document.createRange();
54
+ const sel = window.getSelection()!;
55
+ range.setStartAfter(target);
56
+ range.collapse(true);
57
+ sel.removeAllRanges();
58
+ sel.addRange(range);
59
+ }
60
+
61
+ /**
62
+ * Select the node's content.
63
+ * @param {ChildNode} target the targeted node
64
+ */
65
+ export function selectNodeContents(target: ChildNode): void {
66
+ const range = document.createRange();
67
+ const sel = window.getSelection()!;
68
+ range.selectNodeContents(target);
69
+ range.collapse(false);
70
+ sel.removeAllRanges();
71
+ sel.addRange(range);
72
+ }
73
+
74
+ /**
75
+ * Select the given Nodes.
76
+ * @param {Array<ChildNode>} nodes The list of Nodes to select.
77
+ */
78
+ export function selectNodes(nodes: ChildNode[]): void {
79
+ // Check if we just have a self-closing tag
80
+ if (nodes.length === 1 && isHTMLElement(nodes[0]) && isSelfClosing(nodes[0].tagName)) {
81
+ moveCursorAfterNode(nodes[0]); // Move the cursor after the Node
82
+ return;
83
+ }
84
+ // Select Nodes
85
+ const range = document.createRange();
86
+ const sel = window.getSelection()!;
87
+ range.setStartBefore(nodes[0]);
88
+ range.setEndAfter(nodes[nodes.length - 1]);
89
+ sel.removeAllRanges();
90
+ sel.addRange(range);
91
+ }
92
+
93
+ /**
94
+ * Check if the current selection is inside the given node.
95
+ * @param {ChildNode} node the targeted node
96
+ * @returns {boolean} true if the selection is inside
97
+ */
98
+ export function isSelectionInsideNode(node: ChildNode): boolean {
99
+ const { range } = getSelection();
100
+ if (range === undefined) {
101
+ return false;
102
+ }
103
+
104
+ return node.contains(range.startContainer) && node.contains(range.endContainer);
105
+ }