@lesjoursfr/edith 2.1.0 → 2.1.3
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/build/edith.css +1 -0
- package/build/edith.js +1 -0
- package/dist/core/dom.d.ts +224 -0
- package/dist/core/dom.js +480 -0
- package/dist/core/edit.d.ts +36 -0
- package/dist/core/edit.js +255 -0
- package/dist/core/events.d.ts +47 -0
- package/dist/core/events.js +100 -0
- package/dist/core/history.d.ts +14 -0
- package/dist/core/history.js +24 -0
- package/dist/core/index.d.ts +7 -0
- package/dist/core/index.js +7 -0
- package/dist/core/mode.d.ts +4 -0
- package/dist/core/mode.js +5 -0
- package/dist/core/range.d.ts +45 -0
- package/dist/core/range.js +86 -0
- package/dist/core/throttle.d.ts +53 -0
- package/dist/core/throttle.js +139 -0
- package/dist/edith-options.d.ts +17 -0
- package/dist/edith-options.js +56 -0
- package/dist/edith.d.ts +30 -0
- package/dist/edith.js +76 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/ui/button.d.ts +25 -0
- package/dist/ui/button.js +165 -0
- package/dist/ui/editor.d.ts +37 -0
- package/dist/ui/editor.js +322 -0
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/index.js +3 -0
- package/dist/ui/modal.d.ts +32 -0
- package/dist/ui/modal.js +145 -0
- package/package.json +49 -32
- package/src/core/dom.ts +584 -0
- package/src/core/{edit.js → edit.ts} +59 -40
- package/src/core/events.ts +148 -0
- package/src/core/history.ts +28 -0
- package/src/core/index.ts +7 -0
- package/src/core/mode.ts +4 -0
- package/src/core/{range.js → range.ts} +32 -22
- package/src/core/{throttle.js → throttle.ts} +37 -23
- package/src/css/edith.scss +283 -0
- package/src/edith-options.ts +75 -0
- package/src/edith.ts +98 -0
- package/src/index.ts +1 -0
- package/src/ui/button.ts +197 -0
- package/src/ui/editor.ts +403 -0
- package/src/ui/index.ts +3 -0
- package/src/ui/modal.ts +180 -0
- package/src/core/dom.js +0 -353
- package/src/core/event.js +0 -4
- package/src/core/history.js +0 -27
- package/src/core/mode.js +0 -4
- package/src/index.js +0 -90
- package/src/ui/button.js +0 -200
- package/src/ui/editor.js +0 -392
- package/src/ui/modal.js +0 -151
- /package/{src/css/main.scss → dist/css/edith.scss} +0 -0
package/src/ui/button.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createPopper } from "@popperjs/core";
|
|
2
|
+
import { EditorModes, Events, createNodeWith } from "../core/index.js";
|
|
3
|
+
import { EdithButtonsOption } from "../edith-options.js";
|
|
4
|
+
import { Edith } from "../edith.js";
|
|
5
|
+
|
|
6
|
+
export type EdithButtonCallback = (ctx: Edith, event: Event) => void;
|
|
7
|
+
|
|
8
|
+
export class EdithButton {
|
|
9
|
+
private el!: HTMLButtonElement;
|
|
10
|
+
private ctx: Edith;
|
|
11
|
+
private icon: string;
|
|
12
|
+
private title: string;
|
|
13
|
+
private onclick: EdithButtonCallback;
|
|
14
|
+
private showOnCodeView: boolean;
|
|
15
|
+
private popperEl: HTMLDivElement | undefined;
|
|
16
|
+
private popper: ReturnType<typeof createPopper> | undefined;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
ctx: Edith,
|
|
20
|
+
options: { icon: string; title: string; onclick: EdithButtonCallback; showOnCodeView?: boolean }
|
|
21
|
+
) {
|
|
22
|
+
this.ctx = ctx;
|
|
23
|
+
this.icon = options.icon;
|
|
24
|
+
this.title = options.title;
|
|
25
|
+
this.onclick = options.onclick;
|
|
26
|
+
this.showOnCodeView = options.showOnCodeView === true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public click(event: Event): void {
|
|
30
|
+
// Prevent default
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
|
|
33
|
+
// Call the callback
|
|
34
|
+
this.onclick(this.ctx, event);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public showTooltip(): void {
|
|
38
|
+
if (this.popper !== undefined) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Add the tooltip content to the DOM
|
|
43
|
+
this.popperEl = createNodeWith("div", {
|
|
44
|
+
textContent: this.title,
|
|
45
|
+
attributes: { class: "edith-tooltip" },
|
|
46
|
+
});
|
|
47
|
+
const arrowEl = createNodeWith("div", {
|
|
48
|
+
attributes: { class: "arrow", "data-popper-arrow": "" },
|
|
49
|
+
});
|
|
50
|
+
this.popperEl.append(arrowEl);
|
|
51
|
+
this.ctx.toolbar.append(this.popperEl);
|
|
52
|
+
|
|
53
|
+
// Create the tooltip
|
|
54
|
+
this.popper = createPopper(this.el, this.popperEl, {
|
|
55
|
+
placement: "bottom",
|
|
56
|
+
modifiers: [
|
|
57
|
+
{
|
|
58
|
+
name: "arrow",
|
|
59
|
+
options: {
|
|
60
|
+
padding: 5, // 5px from the edges of the popper
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "offset",
|
|
65
|
+
options: {
|
|
66
|
+
offset: [0, 8],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public hideTooltip(): void {
|
|
74
|
+
if (this.popper === undefined) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Destroy the tooltip
|
|
79
|
+
this.popper.destroy();
|
|
80
|
+
this.popper = undefined;
|
|
81
|
+
|
|
82
|
+
// Remove the tooltip content from the DOM
|
|
83
|
+
this.popperEl?.remove();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public onEditorModeChange(event: CustomEvent): void {
|
|
87
|
+
if (event.detail.mode === EditorModes.Code) {
|
|
88
|
+
this.el.setAttribute("disabled", "disabled");
|
|
89
|
+
} else {
|
|
90
|
+
this.el.removeAttribute("disabled");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public render(): HTMLButtonElement {
|
|
95
|
+
// Create the button
|
|
96
|
+
this.el = createNodeWith("button", {
|
|
97
|
+
attributes: { class: `edith-btn ${this.icon}`, type: "button" },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Bind events
|
|
101
|
+
this.el.onclick = this.click.bind(this);
|
|
102
|
+
this.el.onmouseenter = this.showTooltip.bind(this);
|
|
103
|
+
this.el.onmouseleave = this.hideTooltip.bind(this);
|
|
104
|
+
|
|
105
|
+
// Check if we have to disable the button on the code view
|
|
106
|
+
if (this.showOnCodeView !== true) {
|
|
107
|
+
this.ctx.on(Events.modeChanged, this.onEditorModeChange.bind(this) as EventListener);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Return the button
|
|
111
|
+
return this.el;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const EdithButtons = Object.freeze({
|
|
116
|
+
bold: (context: Edith) =>
|
|
117
|
+
new EdithButton(context, {
|
|
118
|
+
icon: "fa-solid fa-bold",
|
|
119
|
+
title: "Gras",
|
|
120
|
+
onclick: (ctx: Edith) => {
|
|
121
|
+
ctx.editor.wrapInsideTag("b");
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
italic: (context: Edith) =>
|
|
125
|
+
new EdithButton(context, {
|
|
126
|
+
icon: "fa-solid fa-italic",
|
|
127
|
+
title: "Italique",
|
|
128
|
+
onclick: (ctx: Edith) => {
|
|
129
|
+
ctx.editor.wrapInsideTag("i");
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
underline: (context: Edith) =>
|
|
133
|
+
new EdithButton(context, {
|
|
134
|
+
icon: "fa-solid fa-underline",
|
|
135
|
+
title: "Souligner",
|
|
136
|
+
onclick: (ctx: Edith) => {
|
|
137
|
+
ctx.editor.wrapInsideTag("u");
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
strikethrough: (context: Edith) =>
|
|
141
|
+
new EdithButton(context, {
|
|
142
|
+
icon: "fa-solid fa-strikethrough",
|
|
143
|
+
title: "Barrer",
|
|
144
|
+
onclick: (ctx: Edith) => {
|
|
145
|
+
ctx.editor.wrapInsideTag("s");
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
subscript: (context: Edith) =>
|
|
149
|
+
new EdithButton(context, {
|
|
150
|
+
icon: "fa-solid fa-subscript",
|
|
151
|
+
title: "Indice",
|
|
152
|
+
onclick: (ctx: Edith) => {
|
|
153
|
+
ctx.editor.wrapInsideTag("sub");
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
superscript: (context: Edith) =>
|
|
157
|
+
new EdithButton(context, {
|
|
158
|
+
icon: "fa-solid fa-superscript",
|
|
159
|
+
title: "Exposant",
|
|
160
|
+
onclick: (ctx: Edith) => {
|
|
161
|
+
ctx.editor.wrapInsideTag("sup");
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
nbsp: (context: Edith) =>
|
|
165
|
+
new EdithButton(context, {
|
|
166
|
+
icon: "edith-btn-nbsp",
|
|
167
|
+
title: "Ajouter une espace insécable",
|
|
168
|
+
onclick: (ctx: Edith) => {
|
|
169
|
+
ctx.editor.replaceByHtml('<span class="edith-nbsp" contenteditable="false">¶</span>');
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
clear: (context: Edith) =>
|
|
173
|
+
new EdithButton(context, {
|
|
174
|
+
icon: "fa-solid fa-eraser",
|
|
175
|
+
title: "Effacer la mise en forme",
|
|
176
|
+
onclick: (ctx: Edith) => {
|
|
177
|
+
ctx.editor.clearStyle();
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
link: (context: Edith) =>
|
|
181
|
+
new EdithButton(context, {
|
|
182
|
+
icon: "fa-solid fa-link",
|
|
183
|
+
title: "Lien",
|
|
184
|
+
onclick: (ctx: Edith) => {
|
|
185
|
+
ctx.editor.insertLink();
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
codeview: (context: Edith) =>
|
|
189
|
+
new EdithButton(context, {
|
|
190
|
+
icon: "fa-solid fa-code",
|
|
191
|
+
title: "Afficher le code HTML",
|
|
192
|
+
onclick: (ctx: Edith) => {
|
|
193
|
+
ctx.editor.toggleCodeView();
|
|
194
|
+
},
|
|
195
|
+
showOnCodeView: true,
|
|
196
|
+
}),
|
|
197
|
+
} as EdithButtonsOption);
|
package/src/ui/editor.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { html } from "@codemirror/lang-html";
|
|
2
|
+
import { EditorView, basicSetup } from "codemirror";
|
|
3
|
+
import {
|
|
4
|
+
EditorModes,
|
|
5
|
+
Events,
|
|
6
|
+
History,
|
|
7
|
+
cleanPastedHtml,
|
|
8
|
+
clearSelectionStyle,
|
|
9
|
+
createNodeWith,
|
|
10
|
+
getSelection,
|
|
11
|
+
hasClass,
|
|
12
|
+
hasTagName,
|
|
13
|
+
isHTMLElement,
|
|
14
|
+
isSelectionInsideNode,
|
|
15
|
+
isSelfClosing,
|
|
16
|
+
removeNodesRecursively,
|
|
17
|
+
replaceSelectionByHtml,
|
|
18
|
+
restoreSelection,
|
|
19
|
+
throttle,
|
|
20
|
+
unwrapNode,
|
|
21
|
+
wrapInsideLink,
|
|
22
|
+
wrapInsideTag,
|
|
23
|
+
} from "../core/index.js";
|
|
24
|
+
import { Edith } from "../edith.js";
|
|
25
|
+
import { EdithModal, createCheckboxModalField, createInputModalField } from "./modal.js";
|
|
26
|
+
|
|
27
|
+
export class EdithEditor {
|
|
28
|
+
private el!: HTMLDivElement;
|
|
29
|
+
private ctx: Edith;
|
|
30
|
+
private content: string;
|
|
31
|
+
private height: number;
|
|
32
|
+
private resizable: boolean;
|
|
33
|
+
private mode: EditorModes;
|
|
34
|
+
private visualEditor!: HTMLDivElement;
|
|
35
|
+
private codeEditor!: HTMLDivElement;
|
|
36
|
+
private codeMirror: EditorView | undefined;
|
|
37
|
+
private history: History;
|
|
38
|
+
public throttledSnapshots: ReturnType<typeof throttle>;
|
|
39
|
+
|
|
40
|
+
constructor(ctx: Edith, options: { initialContent: string; height: number; resizable: boolean }) {
|
|
41
|
+
this.ctx = ctx;
|
|
42
|
+
this.content = options.initialContent;
|
|
43
|
+
this.height = options.height;
|
|
44
|
+
this.resizable = options.resizable;
|
|
45
|
+
this.mode = EditorModes.Visual;
|
|
46
|
+
this.history = new History();
|
|
47
|
+
this.throttledSnapshots = throttle(() => this.takeSnapshot(), 3000, { leading: false, trailing: true });
|
|
48
|
+
|
|
49
|
+
// Replace by the string we use as a visual return
|
|
50
|
+
this.content = this.content.replace(/ /g, '<span class="edith-nbsp" contenteditable="false">¶</span>');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public render(): HTMLDivElement {
|
|
54
|
+
// Create a wrapper for the editor
|
|
55
|
+
this.el = createNodeWith("div", {
|
|
56
|
+
attributes: {
|
|
57
|
+
class: "edith-editing-area",
|
|
58
|
+
style: this.resizable ? `min-height: ${this.height}px; resize: vertical` : `height: ${this.height}px`,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Create the visual editor
|
|
63
|
+
this.visualEditor = createNodeWith("div", {
|
|
64
|
+
innerHTML: this.content,
|
|
65
|
+
attributes: {
|
|
66
|
+
class: "edith-visual",
|
|
67
|
+
contenteditable: "true",
|
|
68
|
+
style: this.resizable ? `min-height: ${this.height - 10}px` : `height: ${this.height - 10}px`,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
this.el.append(this.visualEditor);
|
|
72
|
+
|
|
73
|
+
// Create the code editor
|
|
74
|
+
this.codeEditor = createNodeWith("div", {
|
|
75
|
+
attributes: { class: "edith-code edith-hidden" },
|
|
76
|
+
});
|
|
77
|
+
this.el.append(this.codeEditor);
|
|
78
|
+
|
|
79
|
+
// Bind events
|
|
80
|
+
const keyEventsListener = this.onKeyEvent.bind(this);
|
|
81
|
+
this.visualEditor.addEventListener("keydown", keyEventsListener);
|
|
82
|
+
this.visualEditor.addEventListener("keyup", keyEventsListener);
|
|
83
|
+
const pasteEventListener = this.onPasteEvent.bind(this);
|
|
84
|
+
this.visualEditor.addEventListener("paste", pasteEventListener);
|
|
85
|
+
|
|
86
|
+
// Return the wrapper
|
|
87
|
+
return this.el;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public getVisualEditorElement(): HTMLElement {
|
|
91
|
+
return this.visualEditor;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public getCodeEditorElement(): HTMLElement {
|
|
95
|
+
return this.codeEditor;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public setContent(content: string): void {
|
|
99
|
+
// Replace by the string we use as a visual return
|
|
100
|
+
content = content.replace(/ /g, '<span class="edith-nbsp" contenteditable="false">¶</span>');
|
|
101
|
+
|
|
102
|
+
// Check the current mode
|
|
103
|
+
if (this.mode === EditorModes.Visual) {
|
|
104
|
+
// Update the visual editor content
|
|
105
|
+
this.visualEditor.innerHTML = content;
|
|
106
|
+
} else {
|
|
107
|
+
// Update the code editor content
|
|
108
|
+
this.codeMirror!.dispatch({
|
|
109
|
+
changes: { from: 0, to: this.codeMirror!.state.doc.length, insert: content },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public getContent(): string {
|
|
115
|
+
// Get the visual editor content or the code editor content
|
|
116
|
+
const code =
|
|
117
|
+
this.mode === EditorModes.Visual
|
|
118
|
+
? this.visualEditor.innerHTML
|
|
119
|
+
: this.codeMirror!.state.doc.toJSON()
|
|
120
|
+
.map((line) => line.trim())
|
|
121
|
+
.join("\n");
|
|
122
|
+
|
|
123
|
+
// Check if there is something in the editor
|
|
124
|
+
if (code === "<p><br></p>") {
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Remove empty tags
|
|
129
|
+
const placeholder = createNodeWith("div", { innerHTML: code });
|
|
130
|
+
removeNodesRecursively(placeholder, (el) => {
|
|
131
|
+
return (
|
|
132
|
+
isHTMLElement(el) && !isSelfClosing(el.tagName) && (el.textContent === null || el.textContent.length === 0)
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Remove any style attribute
|
|
137
|
+
for (const el of placeholder.querySelectorAll("[style]")) {
|
|
138
|
+
el.removeAttribute("style");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Unwrap span without attributes
|
|
142
|
+
for (const el of placeholder.querySelectorAll("span")) {
|
|
143
|
+
if (el.attributes.length === 0) {
|
|
144
|
+
unwrapNode(el);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Return clean code
|
|
149
|
+
return placeholder.innerHTML
|
|
150
|
+
.replace(/\u200B/gi, "")
|
|
151
|
+
.replace(/<\/p>\s*<p>/gi, "<br>")
|
|
152
|
+
.replace(/(<p>|<\/p>)/gi, "")
|
|
153
|
+
.replace(/<span[^>]+class="edith-nbsp"[^>]*>[^<]*<\/span>/gi, " ")
|
|
154
|
+
.replace(/(?:<br\s?\/?>)+$/gi, "");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public takeSnapshot(): void {
|
|
158
|
+
this.history.push(this.visualEditor.innerHTML);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
public restoreSnapshot(): void {
|
|
162
|
+
this.visualEditor.innerHTML = this.history.pop() ?? "";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
public wrapInsideTag<K extends keyof HTMLElementTagNameMap>(tag: K): void {
|
|
166
|
+
if (isSelectionInsideNode(this.visualEditor)) {
|
|
167
|
+
wrapInsideTag(tag);
|
|
168
|
+
this.takeSnapshot();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
public replaceByHtml(html: string): void {
|
|
173
|
+
if (isSelectionInsideNode(this.visualEditor)) {
|
|
174
|
+
replaceSelectionByHtml(html);
|
|
175
|
+
this.takeSnapshot();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
public clearStyle(): void {
|
|
180
|
+
clearSelectionStyle();
|
|
181
|
+
this.takeSnapshot();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
public insertLink(): void {
|
|
185
|
+
// Get the caret position
|
|
186
|
+
const { sel, range } = getSelection();
|
|
187
|
+
|
|
188
|
+
// Check if the user has selected something
|
|
189
|
+
if (range === undefined) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Show the modal
|
|
194
|
+
const modal = new EdithModal(this.ctx, {
|
|
195
|
+
title: "Insérer un lien",
|
|
196
|
+
fields: [
|
|
197
|
+
createInputModalField("Texte à afficher", "text", range.toString()),
|
|
198
|
+
createInputModalField("URL du lien", "href"),
|
|
199
|
+
createCheckboxModalField("Ouvrir dans une nouvelle fenêtre", "openInNewTab", true),
|
|
200
|
+
],
|
|
201
|
+
callback: (data) => {
|
|
202
|
+
// Check if we have something
|
|
203
|
+
if (data === null) {
|
|
204
|
+
// Nothing to do
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Restore the selection
|
|
209
|
+
restoreSelection({ sel, range });
|
|
210
|
+
|
|
211
|
+
// Insert a link
|
|
212
|
+
wrapInsideLink(data.text as string, data.href as string, data.openInNewTab as boolean);
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
modal.show();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
public toggleCodeView(): void {
|
|
219
|
+
// Check the current mode
|
|
220
|
+
if (this.mode === EditorModes.Visual) {
|
|
221
|
+
// Switch mode
|
|
222
|
+
this.mode = EditorModes.Code;
|
|
223
|
+
|
|
224
|
+
// Hide the visual editor
|
|
225
|
+
this.visualEditor.classList.add("edith-hidden");
|
|
226
|
+
|
|
227
|
+
// Display the code editor
|
|
228
|
+
this.codeEditor.classList.remove("edith-hidden");
|
|
229
|
+
const codeMirrorEl = document.createElement("div");
|
|
230
|
+
this.codeEditor.append(codeMirrorEl);
|
|
231
|
+
this.codeMirror = new EditorView({
|
|
232
|
+
doc: this.visualEditor.innerHTML,
|
|
233
|
+
extensions: [basicSetup, EditorView.lineWrapping, html({ matchClosingTags: true, autoCloseTags: true })],
|
|
234
|
+
parent: codeMirrorEl,
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
// Switch mode
|
|
238
|
+
this.mode = EditorModes.Visual;
|
|
239
|
+
|
|
240
|
+
// Hide the code editor
|
|
241
|
+
this.codeEditor.classList.add("edith-hidden");
|
|
242
|
+
|
|
243
|
+
// Display the visual editor
|
|
244
|
+
this.visualEditor.classList.remove("edith-hidden");
|
|
245
|
+
this.visualEditor.innerHTML = this.codeMirror!.state.doc.toJSON()
|
|
246
|
+
.map((line) => line.trim())
|
|
247
|
+
.join("\n");
|
|
248
|
+
this.codeMirror!.destroy();
|
|
249
|
+
this.codeMirror = undefined;
|
|
250
|
+
this.codeEditor.innerHTML = "";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Trigger an event with the new mode
|
|
254
|
+
this.ctx.trigger(Events.modeChanged, { mode: this.mode });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
public onKeyEvent(e: KeyboardEvent): void {
|
|
258
|
+
// Check if a Meta key is pressed
|
|
259
|
+
const prevent = e.metaKey || e.ctrlKey ? this._processKeyEventWithMeta(e) : this._processKeyEvent(e);
|
|
260
|
+
|
|
261
|
+
// Check if we must stop the event here
|
|
262
|
+
if (prevent) {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
e.stopPropagation();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private _processKeyEvent(e: KeyboardEvent): boolean {
|
|
269
|
+
// Check the key code
|
|
270
|
+
switch (e.keyCode) {
|
|
271
|
+
case 13: // Enter : 13
|
|
272
|
+
if (e.type === "keydown") {
|
|
273
|
+
this.replaceByHtml("<br />"); // Insert a line break
|
|
274
|
+
}
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Save the editor content
|
|
279
|
+
this.throttledSnapshots();
|
|
280
|
+
|
|
281
|
+
// Return false
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private _processKeyEventWithMeta(e: KeyboardEvent): boolean {
|
|
286
|
+
// Check the key code
|
|
287
|
+
switch (e.keyCode) {
|
|
288
|
+
case 13: // Enter : 13
|
|
289
|
+
if (e.type === "keydown") {
|
|
290
|
+
this.replaceByHtml("<br />"); // Insert a line break
|
|
291
|
+
}
|
|
292
|
+
return true;
|
|
293
|
+
|
|
294
|
+
case 32: // Space : 32
|
|
295
|
+
if (e.type === "keydown") {
|
|
296
|
+
this.replaceByHtml('<span class="edith-nbsp" contenteditable="false">¶</span>'); // Insert a non-breaking space
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
|
|
300
|
+
case 66: // b : 66
|
|
301
|
+
if (e.type === "keydown") {
|
|
302
|
+
this.wrapInsideTag("b"); // Toggle bold
|
|
303
|
+
}
|
|
304
|
+
return true;
|
|
305
|
+
|
|
306
|
+
case 73: // i : 73
|
|
307
|
+
if (e.type === "keydown") {
|
|
308
|
+
this.wrapInsideTag("i"); // Toggle italic
|
|
309
|
+
}
|
|
310
|
+
return true;
|
|
311
|
+
|
|
312
|
+
case 85: // u : 85
|
|
313
|
+
if (e.type === "keydown") {
|
|
314
|
+
this.wrapInsideTag("u"); // Toggle underline
|
|
315
|
+
}
|
|
316
|
+
return true;
|
|
317
|
+
|
|
318
|
+
case 83: // s : 83
|
|
319
|
+
if (e.type === "keydown") {
|
|
320
|
+
this.wrapInsideTag("s"); // Toggle strikethrough
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
323
|
+
|
|
324
|
+
case 90: // z : 90
|
|
325
|
+
if (e.type === "keydown") {
|
|
326
|
+
this.restoreSnapshot(); // Undo
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Return false
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
public onPasteEvent(e: ClipboardEvent): void {
|
|
336
|
+
// Prevent default
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
e.stopPropagation();
|
|
339
|
+
|
|
340
|
+
// Get the caret position
|
|
341
|
+
const { sel, range } = getSelection();
|
|
342
|
+
|
|
343
|
+
// Check if the user has selected something
|
|
344
|
+
if (range === undefined || e.clipboardData === null) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Create the fragment to insert
|
|
349
|
+
const frag = document.createDocumentFragment();
|
|
350
|
+
|
|
351
|
+
// Check if we try to paste HTML content
|
|
352
|
+
if (!e.clipboardData.types.includes("text/html")) {
|
|
353
|
+
// Get the content as a plain text & split it by lines
|
|
354
|
+
const lines = e.clipboardData.getData("text/plain").split(/[\r\n]+/g);
|
|
355
|
+
|
|
356
|
+
// Add the content as text nodes with a <br> node between each line
|
|
357
|
+
for (let i = 0; i < lines.length; i++) {
|
|
358
|
+
if (i !== 0) {
|
|
359
|
+
frag.append(document.createElement("br"));
|
|
360
|
+
}
|
|
361
|
+
frag.append(document.createTextNode(lines[i]));
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
// Detect style blocs in parents
|
|
365
|
+
let dest = sel.anchorNode as HTMLElement;
|
|
366
|
+
const style = { B: false, I: false, U: false, S: false, Q: false };
|
|
367
|
+
while (dest !== null && !hasClass(dest, "edith-visual")) {
|
|
368
|
+
// Check if it's a style tag
|
|
369
|
+
if (hasTagName(dest, ["b", "i", "u", "s", "q"])) {
|
|
370
|
+
// Update the style
|
|
371
|
+
style[(dest.tagName as "B", "I", "U", "S", "Q")] = true;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Get the parent
|
|
375
|
+
dest = dest.parentNode as HTMLElement;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// We have HTML content
|
|
379
|
+
let html = e.clipboardData.getData("text/html").replace(/[\r\n]+/g, " ");
|
|
380
|
+
|
|
381
|
+
// Wrap the HTML content into <html><body></body></html>
|
|
382
|
+
if (!/^<html>\s*<body>/.test(html)) {
|
|
383
|
+
html = "<html><body>" + html + "</body></html>";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Clean the content
|
|
387
|
+
const contents = cleanPastedHtml(html, style);
|
|
388
|
+
|
|
389
|
+
// Add the content to the frgament
|
|
390
|
+
frag.append(...contents.childNodes);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Replace the current selection by the pasted content
|
|
394
|
+
sel.deleteFromDocument();
|
|
395
|
+
range.insertNode(frag);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
public destroy(): void {
|
|
399
|
+
this.codeMirror?.destroy();
|
|
400
|
+
this.codeMirror = undefined;
|
|
401
|
+
this.el.remove();
|
|
402
|
+
}
|
|
403
|
+
}
|
package/src/ui/index.ts
ADDED