autumnnote 1.0.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 +21 -0
- package/README.md +874 -0
- package/dist/autumnnote.css +1 -0
- package/dist/autumnnote.es.js +5888 -0
- package/dist/autumnnote.es.js.map +1 -0
- package/dist/autumnnote.umd.js +74 -0
- package/dist/autumnnote.umd.js.map +1 -0
- package/package.json +55 -0
- package/src/js/Context.js +497 -0
- package/src/js/core/dom.js +315 -0
- package/src/js/core/env.js +25 -0
- package/src/js/core/func.js +153 -0
- package/src/js/core/key.js +66 -0
- package/src/js/core/lists.js +121 -0
- package/src/js/core/markdown.js +294 -0
- package/src/js/core/range.js +194 -0
- package/src/js/core/sanitise.js +78 -0
- package/src/js/editing/History.js +205 -0
- package/src/js/editing/Style.js +329 -0
- package/src/js/editing/Table.js +59 -0
- package/src/js/editing/Typing.js +142 -0
- package/src/js/index.js +126 -0
- package/src/js/module/Buttons.js +300 -0
- package/src/js/module/Clipboard.js +460 -0
- package/src/js/module/CodeTooltip.js +428 -0
- package/src/js/module/Codeview.js +122 -0
- package/src/js/module/ContextMenu.js +470 -0
- package/src/js/module/Editor.js +528 -0
- package/src/js/module/EmojiDialog.js +726 -0
- package/src/js/module/FindReplace.js +440 -0
- package/src/js/module/Fullscreen.js +80 -0
- package/src/js/module/IconDialog.js +620 -0
- package/src/js/module/ImageDialog.js +208 -0
- package/src/js/module/ImageResizer.js +216 -0
- package/src/js/module/ImageTooltip.js +286 -0
- package/src/js/module/LinkDialog.js +204 -0
- package/src/js/module/LinkTooltip.js +242 -0
- package/src/js/module/Placeholder.js +44 -0
- package/src/js/module/ShortcutsDialog.js +141 -0
- package/src/js/module/Statusbar.js +238 -0
- package/src/js/module/TableTooltip.js +568 -0
- package/src/js/module/Toolbar.js +562 -0
- package/src/js/module/VideoDialog.js +263 -0
- package/src/js/module/VideoResizer.js +227 -0
- package/src/js/module/VideoTooltip.js +252 -0
- package/src/js/renderer.js +107 -0
- package/src/js/settings.js +134 -0
- package/src/styles/_variables.scss +48 -0
- package/src/styles/autumnnote.scss +1740 -0
- package/types/index.d.ts +324 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor.js - Core editing command module
|
|
3
|
+
* Wraps all execCommand calls, undo/redo, and fires events via the context.
|
|
4
|
+
* Inspired by Summernote's Editor module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { History } from '../editing/History.js';
|
|
8
|
+
import * as Style from '../editing/Style.js';
|
|
9
|
+
import { insertTable } from '../editing/Table.js';
|
|
10
|
+
import { isModifier } from '../core/key.js';
|
|
11
|
+
import { handleKeydown } from '../editing/Typing.js';
|
|
12
|
+
import { on } from '../core/dom.js';
|
|
13
|
+
import { sanitiseHTML, sanitiseUrl } from '../core/sanitise.js';
|
|
14
|
+
import { markdownToHTML, htmlToMarkdown } from '../core/markdown.js';
|
|
15
|
+
|
|
16
|
+
export class Editor {
|
|
17
|
+
/**
|
|
18
|
+
* @param {import('../Context.js').Context} context
|
|
19
|
+
*/
|
|
20
|
+
constructor(context) {
|
|
21
|
+
this.context = context;
|
|
22
|
+
this.options = context.options;
|
|
23
|
+
/** @type {History|null} */
|
|
24
|
+
this._history = null;
|
|
25
|
+
this._disposers = [];
|
|
26
|
+
/** @type {number|null} Timer handle for debounced undo snapshot */
|
|
27
|
+
this._snapshotTimer = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Lifecycle
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
initialize() {
|
|
35
|
+
const editable = this.context.layoutInfo.editable;
|
|
36
|
+
this._history = new History(editable, this.options.historyLimit || 100);
|
|
37
|
+
this._bindEvents(editable);
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
destroy() {
|
|
42
|
+
this._disposers.forEach((d) => d());
|
|
43
|
+
this._disposers = [];
|
|
44
|
+
this._history = null;
|
|
45
|
+
clearTimeout(this._snapshotTimer);
|
|
46
|
+
this._snapshotTimer = null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Event binding
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
_bindEvents(editable) {
|
|
54
|
+
// Keyboard shortcuts
|
|
55
|
+
const onKeydown = (event) => this._onKeydown(event);
|
|
56
|
+
// Catch ALL content mutations: typing, IME, spellcheck, voice, drag-drop text.
|
|
57
|
+
const onInput = () => this.afterCommand();
|
|
58
|
+
// Hard-enforce maxChars / maxWords before content is mutated
|
|
59
|
+
const onBeforeInput = (event) => this._enforceLimit(event);
|
|
60
|
+
// Refresh toolbar on selection change, scoped to this editor
|
|
61
|
+
const onSelChange = () => {
|
|
62
|
+
if (!this.context._alive) return;
|
|
63
|
+
const sel = window.getSelection();
|
|
64
|
+
if (sel && sel.rangeCount > 0 && editable.contains(sel.anchorNode)) {
|
|
65
|
+
this.context.invoke('toolbar.refresh');
|
|
66
|
+
if (typeof this.options.onSelectionChange === 'function') {
|
|
67
|
+
this.options.onSelectionChange(this.context);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Checklist checkboxes are contenteditable=false so native clicks work;
|
|
73
|
+
// hook afterCommand so the checked state is preserved in undo history.
|
|
74
|
+
const onCheckboxClick = (e) => {
|
|
75
|
+
if (e.target.type === 'checkbox' && e.target.closest('.an-checklist')) {
|
|
76
|
+
this.afterCommand();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
this._disposers.push(
|
|
81
|
+
on(editable, 'keydown', onKeydown),
|
|
82
|
+
on(editable, 'beforeinput', onBeforeInput),
|
|
83
|
+
on(editable, 'input', onInput),
|
|
84
|
+
on(document, 'selectionchange', onSelChange),
|
|
85
|
+
on(editable, 'click', onCheckboxClick),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_onKeydown(event) {
|
|
90
|
+
const editable = this.context.layoutInfo.editable;
|
|
91
|
+
|
|
92
|
+
// Let Typing module handle special keys (Tab, Enter etc.)
|
|
93
|
+
if (handleKeydown(event, editable, this.options)) return;
|
|
94
|
+
|
|
95
|
+
// Built-in shortcuts
|
|
96
|
+
if (isModifier(event, 'z') && !event.shiftKey) {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
this.undo();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if ((isModifier(event, 'z') && event.shiftKey) || isModifier(event, 'y')) {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
this.redo();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (isModifier(event, 'b')) { event.preventDefault(); this.bold(); return; }
|
|
107
|
+
if (isModifier(event, 'i')) { event.preventDefault(); this.italic(); return; }
|
|
108
|
+
if (isModifier(event, 'u')) { event.preventDefault(); this.underline(); return; }
|
|
109
|
+
if (isModifier(event, 'k')) { event.preventDefault(); this.context.invoke('linkDialog.show'); return; }
|
|
110
|
+
|
|
111
|
+
// Ctrl+Shift+V — paste as plain text (signals Clipboard module)
|
|
112
|
+
if (isModifier(event, 'v') && event.shiftKey) {
|
|
113
|
+
this.context.invoke('clipboard.setForcePlain', true);
|
|
114
|
+
return; // let the native paste event fire
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Show keyboard shortcuts dialog: Shift+?
|
|
118
|
+
if (event.key === '?' && event.shiftKey && !event.ctrlKey && !event.metaKey) {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
this.context.invoke('shortcutsDialog.show');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Find: Ctrl+F
|
|
124
|
+
if (isModifier(event, 'f')) {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
this.context.invoke('findReplace.show', 'find');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Ctrl+H — Find & Replace
|
|
130
|
+
if (isModifier(event, 'h')) {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
this.context.invoke('findReplace.show', 'replace');
|
|
133
|
+
}
|
|
134
|
+
// Ctrl+` — Inline Code
|
|
135
|
+
if (isModifier(event, '`')) {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
this.inlineCode();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Limit enforcement
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Called from beforeinput to block typing when char/word limits are reached.
|
|
147
|
+
* Deletions and non-typing input types are always allowed.
|
|
148
|
+
* @param {InputEvent} event
|
|
149
|
+
*/
|
|
150
|
+
_enforceLimit(event) {
|
|
151
|
+
const maxChars = this.options.maxChars || 0;
|
|
152
|
+
const maxWords = this.options.maxWords || 0;
|
|
153
|
+
if (!maxChars && !maxWords) return;
|
|
154
|
+
|
|
155
|
+
const type = event.inputType || '';
|
|
156
|
+
// Allow deletions, undo, redo and non-insert operations
|
|
157
|
+
if (type.startsWith('delete') || type === 'historyUndo' || type === 'historyRedo') return;
|
|
158
|
+
// Allow paste/drop — handled after the fact by Clipboard
|
|
159
|
+
if (type === 'insertFromPaste' || type === 'insertFromDrop') return;
|
|
160
|
+
// Only enforce for keyboard/IME/composition insertions
|
|
161
|
+
if (!type.startsWith('insert')) return;
|
|
162
|
+
|
|
163
|
+
const text = this.context.layoutInfo.editable.innerText || '';
|
|
164
|
+
const chars = text.replace(/\n/g, '').length;
|
|
165
|
+
|
|
166
|
+
if (maxChars && chars >= maxChars) {
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
if (typeof this.options.onCharLimitReached === 'function') {
|
|
169
|
+
this.options.onCharLimitReached(this.context);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Word limit: block space / newline insertion when already at the limit
|
|
175
|
+
if (maxWords && (event.data === ' ' || type === 'insertParagraph' || type === 'insertLineBreak')) {
|
|
176
|
+
const words = text.trim() ? text.trim().split(/\s+/).length : 0;
|
|
177
|
+
if (words >= maxWords) {
|
|
178
|
+
event.preventDefault();
|
|
179
|
+
if (typeof this.options.onWordLimitReached === 'function') {
|
|
180
|
+
this.options.onWordLimitReached(this.context);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Post-command hook — records undo, fires change event
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
afterCommand() {
|
|
191
|
+
// Immediate: keep toolbar and statusbar in sync on every mutation.
|
|
192
|
+
this.context.invoke('toolbar.refresh');
|
|
193
|
+
this.context.invoke('statusbar.update');
|
|
194
|
+
// Debounced: recording an undo snapshot and firing the change event require
|
|
195
|
+
// a full innerHTML serialization. Batching rapid keystrokes prevents the
|
|
196
|
+
// browser from re-serializing large content (e.g. embedded images) on every
|
|
197
|
+
// single key press.
|
|
198
|
+
this._scheduleSnapshot();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Schedules a debounced undo snapshot + change event.
|
|
203
|
+
* Resets the timer on each call so rapid typing produces one snapshot.
|
|
204
|
+
*/
|
|
205
|
+
_scheduleSnapshot() {
|
|
206
|
+
clearTimeout(this._snapshotTimer);
|
|
207
|
+
this._snapshotTimer = setTimeout(() => {
|
|
208
|
+
this._snapshotTimer = null;
|
|
209
|
+
if (this._history) this._history.recordUndo();
|
|
210
|
+
this.context.triggerEvent('change', this.getHTML());
|
|
211
|
+
}, 400);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Focus management
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
focus() {
|
|
219
|
+
const editable = this.context.layoutInfo.editable;
|
|
220
|
+
editable.focus();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Content API
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Returns the editor HTML content.
|
|
229
|
+
* @returns {string}
|
|
230
|
+
*/
|
|
231
|
+
getHTML() {
|
|
232
|
+
// Strip zero-width spaces inserted after icons to allow caret placement.
|
|
233
|
+
const raw = this.context.layoutInfo.editable.innerHTML.replace(/\u200B/g, '');
|
|
234
|
+
// Replace any blob: URLs (lightweight DOM references to pasted/dropped images)
|
|
235
|
+
// with their original data URLs so the returned HTML is fully self-contained.
|
|
236
|
+
return this.context.invoke('clipboard.resolveImages', raw) ?? raw;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Sets the editor HTML content.
|
|
241
|
+
* @param {string} html - HTML string (will be sanitised)
|
|
242
|
+
*/
|
|
243
|
+
setHTML(html) {
|
|
244
|
+
this.context.layoutInfo.editable.innerHTML = sanitiseHTML(html);
|
|
245
|
+
if (this._history) this._history.reset();
|
|
246
|
+
this.afterCommand();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Returns the editor plain text content.
|
|
251
|
+
* @returns {string}
|
|
252
|
+
*/
|
|
253
|
+
getText() {
|
|
254
|
+
return this.context.layoutInfo.editable.innerText || '';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Sets the editor content as plain text.
|
|
259
|
+
* @param {string} text
|
|
260
|
+
*/
|
|
261
|
+
setText(text) {
|
|
262
|
+
this.context.layoutInfo.editable.textContent = text;
|
|
263
|
+
if (this._history) this._history.reset();
|
|
264
|
+
this.afterCommand();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Clears the editor content.
|
|
269
|
+
*/
|
|
270
|
+
clear() {
|
|
271
|
+
this.setHTML('');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resets the undo/redo history stack.
|
|
276
|
+
*/
|
|
277
|
+
clearHistory() {
|
|
278
|
+
if (this._history) this._history.reset();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Returns true when the editor has no meaningful content.
|
|
283
|
+
* @returns {boolean}
|
|
284
|
+
*/
|
|
285
|
+
isEmpty() {
|
|
286
|
+
const text = (this.context.layoutInfo.editable.innerText || '')
|
|
287
|
+
.trim()
|
|
288
|
+
.replace(/\u00a0/g, '');
|
|
289
|
+
const hasMedia = !!this.context.layoutInfo.editable.querySelector('img, video, iframe, table');
|
|
290
|
+
return !text && !hasMedia;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Inserts HTML at the current cursor position.
|
|
295
|
+
* @param {string} html
|
|
296
|
+
*/
|
|
297
|
+
insertHTML(html) {
|
|
298
|
+
if (!html) return;
|
|
299
|
+
Style.execCommand('insertHTML', sanitiseHTML(html));
|
|
300
|
+
this.afterCommand();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Inserts plain text at the current cursor position.
|
|
305
|
+
* @param {string} text
|
|
306
|
+
*/
|
|
307
|
+
insertText(text) {
|
|
308
|
+
if (!text) return;
|
|
309
|
+
Style.execCommand('insertText', text);
|
|
310
|
+
this.afterCommand();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Sets editor content from a Markdown string.
|
|
315
|
+
* @param {string} md
|
|
316
|
+
*/
|
|
317
|
+
setMarkdown(md) {
|
|
318
|
+
this.setHTML(markdownToHTML(md || ''));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Returns the editor content as Markdown.
|
|
323
|
+
* @returns {string}
|
|
324
|
+
*/
|
|
325
|
+
getMarkdown() {
|
|
326
|
+
return htmlToMarkdown(this.getHTML());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Undo / redo
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
undo() {
|
|
334
|
+
if (this._history) {
|
|
335
|
+
clearTimeout(this._snapshotTimer);
|
|
336
|
+
this._snapshotTimer = null;
|
|
337
|
+
this._history.undo();
|
|
338
|
+
this.context.invoke('toolbar.refresh');
|
|
339
|
+
this.context.invoke('statusbar.update');
|
|
340
|
+
this.context.triggerEvent('change', this.getHTML());
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
redo() {
|
|
345
|
+
if (this._history) {
|
|
346
|
+
clearTimeout(this._snapshotTimer);
|
|
347
|
+
this._snapshotTimer = null;
|
|
348
|
+
this._history.redo();
|
|
349
|
+
this.context.invoke('toolbar.refresh');
|
|
350
|
+
this.context.invoke('statusbar.update');
|
|
351
|
+
this.context.triggerEvent('change', this.getHTML());
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
canUndo() {
|
|
356
|
+
return this._history ? this._history.canUndo() : false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
canRedo() {
|
|
360
|
+
return this._history ? this._history.canRedo() : false;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Style commands (delegated to Style module)
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
bold() { Style.bold(); this.afterCommand(); }
|
|
368
|
+
italic() { Style.italic(); this.afterCommand(); }
|
|
369
|
+
underline() { Style.underline(); this.afterCommand(); }
|
|
370
|
+
strikethrough() { Style.strikethrough(); this.afterCommand(); }
|
|
371
|
+
superscript() { Style.superscript(); this.afterCommand(); }
|
|
372
|
+
subscript() { Style.subscript(); this.afterCommand(); }
|
|
373
|
+
justifyLeft() { Style.justifyLeft(); this.afterCommand(); }
|
|
374
|
+
justifyCenter() { Style.justifyCenter(); this.afterCommand(); }
|
|
375
|
+
justifyRight() { Style.justifyRight(); this.afterCommand(); }
|
|
376
|
+
justifyFull() { Style.justifyFull(); this.afterCommand(); }
|
|
377
|
+
indent() { Style.indent(); this.afterCommand(); }
|
|
378
|
+
outdent() { Style.outdent(); this.afterCommand(); }
|
|
379
|
+
insertUL() { Style.insertUnorderedList(); this.afterCommand(); }
|
|
380
|
+
insertOL() { Style.insertOrderedList(); this.afterCommand(); }
|
|
381
|
+
inlineCode() { Style.toggleInlineCode(this.context.layoutInfo.editable); this.afterCommand(); }
|
|
382
|
+
toggleChecklist() { Style.toggleChecklist(); this.afterCommand(); }
|
|
383
|
+
print() { this.context.print(); }
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @param {string} tagName - e.g. 'h1', 'p', 'blockquote'
|
|
387
|
+
*/
|
|
388
|
+
formatBlock(tagName) { Style.formatBlock(tagName); this.afterCommand(); }
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* @param {string} color
|
|
392
|
+
*/
|
|
393
|
+
foreColor(color) { Style.foreColor(color); this.afterCommand(); }
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* @param {string} color
|
|
397
|
+
*/
|
|
398
|
+
backColor(color) { Style.backColor(color); this.afterCommand(); }
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* @param {string} name
|
|
402
|
+
*/
|
|
403
|
+
fontName(name) { Style.fontName(name); this.afterCommand(); }
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* @param {string} size - e.g. '14px'
|
|
407
|
+
*/
|
|
408
|
+
fontSize(size) { Style.fontSize(size, this.context.layoutInfo.editable); this.afterCommand(); }
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// Insert helpers
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Inserts a horizontal rule at the cursor.
|
|
416
|
+
*/
|
|
417
|
+
insertHr() {
|
|
418
|
+
Style.execCommand('insertHorizontalRule');
|
|
419
|
+
this.afterCommand();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Creates a link at the current selection.
|
|
424
|
+
* @param {string} url
|
|
425
|
+
* @param {string} text
|
|
426
|
+
* @param {boolean} [openInNewTab=false]
|
|
427
|
+
*/
|
|
428
|
+
insertLink(url, text, openInNewTab = false) {
|
|
429
|
+
const sel = window.getSelection();
|
|
430
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
431
|
+
const safeUrl = sanitiseUrl(url);
|
|
432
|
+
if (!safeUrl) return;
|
|
433
|
+
|
|
434
|
+
const hasText = sel.toString().trim().length > 0;
|
|
435
|
+
if (!hasText) {
|
|
436
|
+
const displayText = this._escapeAttr(text || safeUrl);
|
|
437
|
+
Style.execCommand('insertHTML', `<a href="${this._escapeAttr(safeUrl)}"${openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : ''}>${displayText}</a>`);
|
|
438
|
+
} else {
|
|
439
|
+
Style.execCommand('createLink', safeUrl);
|
|
440
|
+
if (openInNewTab) {
|
|
441
|
+
const link = this._getClosestAnchor();
|
|
442
|
+
if (link) {
|
|
443
|
+
link.setAttribute('target', '_blank');
|
|
444
|
+
link.setAttribute('rel', 'noopener noreferrer');
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
this.afterCommand();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Removes the link from the selected anchor.
|
|
453
|
+
*/
|
|
454
|
+
unlink() {
|
|
455
|
+
Style.execCommand('unlink');
|
|
456
|
+
this.afterCommand();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Inserts an image.
|
|
461
|
+
* @param {string} src - URL or data-URI
|
|
462
|
+
* @param {string} [alt]
|
|
463
|
+
*/
|
|
464
|
+
insertImage(src, alt = '', align = '') {
|
|
465
|
+
const safeSrc = sanitiseUrl(src, { allowData: true });
|
|
466
|
+
if (!safeSrc) return;
|
|
467
|
+
const styleMap = {
|
|
468
|
+
left: 'float:left;margin:0 1em 1em 0',
|
|
469
|
+
center: 'display:block;margin:0 auto',
|
|
470
|
+
right: 'float:right;margin:0 0 1em 1em',
|
|
471
|
+
};
|
|
472
|
+
const style = styleMap[align] || '';
|
|
473
|
+
const styleAttr = style ? ` style="${style}"` : '';
|
|
474
|
+
Style.execCommand('insertHTML', `<img src="${this._escapeAttr(safeSrc)}" alt="${this._escapeAttr(alt)}" class="an-image"${styleAttr}>`);
|
|
475
|
+
this.afterCommand();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Inserts a video embed (iframe or <video> element).
|
|
480
|
+
* The html string is already validated/built by VideoDialog.
|
|
481
|
+
* @param {string} html
|
|
482
|
+
*/
|
|
483
|
+
insertVideo(html) {
|
|
484
|
+
if (!html) return;
|
|
485
|
+
Style.execCommand('insertHTML', html);
|
|
486
|
+
this.afterCommand();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Inserts a table.
|
|
491
|
+
* @param {number} cols
|
|
492
|
+
* @param {number} rows
|
|
493
|
+
*/
|
|
494
|
+
insertTable(cols, rows) {
|
|
495
|
+
insertTable(cols, rows, { headerRow: this.context.options.tableHeaderRow });
|
|
496
|
+
this.afterCommand();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
// Helpers
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
_getClosestAnchor() {
|
|
504
|
+
const sel = window.getSelection();
|
|
505
|
+
if (!sel || sel.rangeCount === 0) return null;
|
|
506
|
+
let node = sel.getRangeAt(0).startContainer;
|
|
507
|
+
while (node) {
|
|
508
|
+
if (node.nodeName === 'A') return node;
|
|
509
|
+
node = node.parentNode;
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Escapes a string for safe use inside an HTML attribute value.
|
|
516
|
+
* @param {string} str
|
|
517
|
+
* @returns {string}
|
|
518
|
+
*/
|
|
519
|
+
_escapeAttr(str) {
|
|
520
|
+
return String(str ?? '')
|
|
521
|
+
.replace(/&/g, '&')
|
|
522
|
+
.replace(/"/g, '"')
|
|
523
|
+
.replace(/</g, '<')
|
|
524
|
+
.replace(/>/g, '>');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// --- delegated to shared sanitise.js ---
|
|
528
|
+
}
|