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