autumnnote 2.1.0 → 2.3.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/README.md +74 -6
- package/dist/autumnnote.cjs +27 -22
- package/dist/autumnnote.es.js +474 -591
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.min.js +27 -22
- package/dist/autumnnote.umd.js +27 -22
- package/dist/autumnnote.umd.js.map +1 -1
- package/dist/icon-data-V0Xqv-wX.js +255 -0
- package/dist/icon-data-V0Xqv-wX.js.map +1 -0
- package/package.json +12 -3
- package/types/index.d.ts +8 -0
- package/src/js/Context.js +0 -854
- package/src/js/core/detectLang.js +0 -98
- package/src/js/core/dom.js +0 -372
- package/src/js/core/env.js +0 -38
- package/src/js/core/key.js +0 -66
- package/src/js/core/lists.js +0 -121
- package/src/js/core/markdown.js +0 -695
- package/src/js/core/range.js +0 -194
- package/src/js/core/sanitise.js +0 -304
- package/src/js/editing/History.js +0 -266
- package/src/js/editing/Style.js +0 -812
- package/src/js/editing/Table.js +0 -105
- package/src/js/editing/Typing.js +0 -397
- package/src/js/index.js +0 -193
- package/src/js/index.umd.js +0 -17
- package/src/js/module/AutoSaveRestore.js +0 -125
- package/src/js/module/BaseDialog.js +0 -133
- package/src/js/module/BaseMediaTooltip.js +0 -142
- package/src/js/module/BaseResizer.js +0 -322
- package/src/js/module/BubbleToolbar.js +0 -483
- package/src/js/module/Buttons.js +0 -399
- package/src/js/module/Clipboard.js +0 -579
- package/src/js/module/CodeTooltip.js +0 -493
- package/src/js/module/Codeview.js +0 -125
- package/src/js/module/ContextMenu.js +0 -621
- package/src/js/module/Editor.js +0 -747
- package/src/js/module/EmojiDialog.js +0 -254
- package/src/js/module/FindReplace.js +0 -512
- package/src/js/module/Fullscreen.js +0 -80
- package/src/js/module/IconDialog.js +0 -618
- package/src/js/module/ImageCropOverlay.js +0 -586
- package/src/js/module/ImageDialog.js +0 -193
- package/src/js/module/ImageResizer.js +0 -42
- package/src/js/module/ImageTooltip.js +0 -285
- package/src/js/module/LinkDialog.js +0 -145
- package/src/js/module/LinkTooltip.js +0 -250
- package/src/js/module/MarkdownShortcuts.js +0 -250
- package/src/js/module/Mention.js +0 -365
- package/src/js/module/Placeholder.js +0 -51
- package/src/js/module/ShortcutsDialog.js +0 -111
- package/src/js/module/SlashMenu.js +0 -376
- package/src/js/module/Statusbar.js +0 -246
- package/src/js/module/TableTooltip.js +0 -1392
- package/src/js/module/Toolbar.js +0 -855
- package/src/js/module/VideoDialog.js +0 -193
- package/src/js/module/VideoResizer.js +0 -66
- package/src/js/module/VideoTooltip.js +0 -248
- package/src/js/module/emoji-data.js +0 -496
- package/src/js/module/table-grid.js +0 -102
- package/src/js/module/table-icons.js +0 -33
- package/src/js/renderer.js +0 -120
- package/src/js/settings.js +0 -214
- package/src/styles/_variables.scss +0 -48
- package/src/styles/autumnnote.scss +0 -2877
package/src/js/module/Editor.js
DELETED
|
@@ -1,747 +0,0 @@
|
|
|
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
|
-
import { detectLang } from '../core/detectLang.js';
|
|
16
|
-
|
|
17
|
-
export class Editor {
|
|
18
|
-
/**
|
|
19
|
-
* @param {import('../Context.js').Context} context
|
|
20
|
-
*/
|
|
21
|
-
constructor(context) {
|
|
22
|
-
this.context = context;
|
|
23
|
-
this.options = context.options;
|
|
24
|
-
/** @type {History|null} */
|
|
25
|
-
this._history = null;
|
|
26
|
-
this._disposers = [];
|
|
27
|
-
/** @type {number|null} Timer handle for debounced undo snapshot */
|
|
28
|
-
this._snapshotTimer = null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
// Lifecycle
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
initialize() {
|
|
36
|
-
const editable = this.context.layoutInfo.editable;
|
|
37
|
-
this._history = new History(
|
|
38
|
-
editable,
|
|
39
|
-
this.options.historyLimit || 100,
|
|
40
|
-
this.options.historyMaxBytes || 10 * 1024 * 1024,
|
|
41
|
-
);
|
|
42
|
-
this._bindEvents(editable);
|
|
43
|
-
return this;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
destroy() {
|
|
47
|
-
this._disposers.forEach((d) => d());
|
|
48
|
-
this._disposers = [];
|
|
49
|
-
this._history = null;
|
|
50
|
-
clearTimeout(this._snapshotTimer);
|
|
51
|
-
this._snapshotTimer = null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
// Event binding
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
_bindEvents(editable) {
|
|
59
|
-
// Keyboard shortcuts
|
|
60
|
-
const onKeydown = (event) => this._onKeydown(event);
|
|
61
|
-
// Catch ALL content mutations: typing, IME, spellcheck, voice, drag-drop text.
|
|
62
|
-
const onInput = () => this.afterCommand();
|
|
63
|
-
// Hard-enforce maxChars / maxWords before content is mutated
|
|
64
|
-
const onBeforeInput = (event) => this._enforceLimit(event);
|
|
65
|
-
// Refresh toolbar on selection change, scoped to this editor
|
|
66
|
-
const onSelChange = () => {
|
|
67
|
-
if (!this.context._alive) return;
|
|
68
|
-
const sel = globalThis.getSelection();
|
|
69
|
-
if (sel?.rangeCount > 0 && editable.contains(sel.anchorNode)) {
|
|
70
|
-
this.context.invoke('toolbar.refresh');
|
|
71
|
-
if (typeof this.options.onSelectionChange === 'function') {
|
|
72
|
-
this.options.onSelectionChange(this.context);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
// Checklist checkboxes are contenteditable=false so native clicks work;
|
|
78
|
-
// hook afterCommand so the checked state is preserved in undo history.
|
|
79
|
-
const onCheckboxClick = (e) => {
|
|
80
|
-
if (e.target.type === 'checkbox' && e.target.closest('.an-checklist')) {
|
|
81
|
-
this.afterCommand();
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
// Guard: when cursor lands at the <li> element node of a checklist item
|
|
86
|
-
// (before the checkbox), nudge it to the correct text position.
|
|
87
|
-
//
|
|
88
|
-
// mouseup: use caretRangeFromPoint / caretPositionFromPoint so the cursor
|
|
89
|
-
// lands WHERE the user actually clicked (middle, end of text…).
|
|
90
|
-
// keyup : arrow-key navigation may land at <li>[0]; move to start-of-text.
|
|
91
|
-
const fixChecklistCursor = (event) => {
|
|
92
|
-
const sel = globalThis.getSelection();
|
|
93
|
-
if (!sel?.rangeCount) return;
|
|
94
|
-
const r = sel.getRangeAt(0);
|
|
95
|
-
if (!r.collapsed) return;
|
|
96
|
-
const sc = r.startContainer;
|
|
97
|
-
|
|
98
|
-
// Only act when cursor is at the <li> element node itself (not inside
|
|
99
|
-
// a text node — the browser already placed it correctly in that case).
|
|
100
|
-
if (sc.nodeType !== Node.ELEMENT_NODE) return;
|
|
101
|
-
const scEl = /** @type {Element} */ (sc);
|
|
102
|
-
const li = scEl.matches('.an-checklist li') ? scEl : null;
|
|
103
|
-
if (!li) return;
|
|
104
|
-
const cb = li.querySelector('input[type="checkbox"]');
|
|
105
|
-
if (!cb) return;
|
|
106
|
-
|
|
107
|
-
// For mouse events: ask the browser where the pointer landed so the
|
|
108
|
-
// cursor respects the actual click position inside the text.
|
|
109
|
-
if (event?.type === 'mouseup') {
|
|
110
|
-
let caret = null;
|
|
111
|
-
if (document.caretRangeFromPoint) {
|
|
112
|
-
caret = document.caretRangeFromPoint(event.clientX, event.clientY);
|
|
113
|
-
} else if (document.caretPositionFromPoint) {
|
|
114
|
-
const cp = document.caretPositionFromPoint(event.clientX, event.clientY);
|
|
115
|
-
if (cp) {
|
|
116
|
-
caret = document.createRange();
|
|
117
|
-
caret.setStart(cp.offsetNode, cp.offset);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
// If the caret from point landed inside a text node of this li, use it
|
|
121
|
-
if (caret && editable.contains(caret.startContainer) &&
|
|
122
|
-
caret.startContainer !== li) {
|
|
123
|
-
caret.collapse(true);
|
|
124
|
-
sel.removeAllRanges();
|
|
125
|
-
sel.addRange(caret);
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Fallback (keyboard nav, or caretRangeFromPoint not available / landed
|
|
131
|
-
// at li again): prefer the first text node after the checkbox so the
|
|
132
|
-
// cursor renders at the padding-left edge (after the visual checkbox)
|
|
133
|
-
// rather than at element-level where the browser may place it at x=0.
|
|
134
|
-
const nr = document.createRange();
|
|
135
|
-
let anchorNode = null;
|
|
136
|
-
for (const child of li.childNodes) {
|
|
137
|
-
if (child !== cb && child.nodeType === Node.TEXT_NODE) {
|
|
138
|
-
anchorNode = child;
|
|
139
|
-
break;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
if (anchorNode) {
|
|
143
|
-
nr.setStart(anchorNode, 0);
|
|
144
|
-
} else {
|
|
145
|
-
nr.setStartAfter(cb);
|
|
146
|
-
}
|
|
147
|
-
nr.collapse(true);
|
|
148
|
-
sel.removeAllRanges();
|
|
149
|
-
sel.addRange(nr);
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const isReadOnly = () => this.context.layoutInfo.container.classList.contains('an-disabled');
|
|
153
|
-
|
|
154
|
-
this._disposers.push(
|
|
155
|
-
on(editable, 'keydown', onKeydown),
|
|
156
|
-
on(editable, 'beforeinput', onBeforeInput),
|
|
157
|
-
on(editable, 'input', onInput),
|
|
158
|
-
on(document, 'selectionchange', onSelChange),
|
|
159
|
-
on(editable, 'click', onCheckboxClick),
|
|
160
|
-
on(editable, 'mouseup', fixChecklistCursor),
|
|
161
|
-
on(editable, 'keyup', fixChecklistCursor),
|
|
162
|
-
// Block drag-out and external drops in read-only mode.
|
|
163
|
-
// D-1: Also block dragging of iframes and .an-video-wrapper elements in
|
|
164
|
-
// edit mode — a user can inadvertently drag the iframe out of its wrapper
|
|
165
|
-
// (making it playable/removable from contenteditable protection) by holding
|
|
166
|
-
// the mouse and moving outside the wrapper before releasing.
|
|
167
|
-
on(editable, 'dragstart', (e) => {
|
|
168
|
-
if (isReadOnly()) { e.preventDefault(); return; }
|
|
169
|
-
const target = /** @type {Element} */ (e.target);
|
|
170
|
-
if (target && (target.nodeName === 'IFRAME' ||
|
|
171
|
-
target.closest('.an-video-wrapper'))) {
|
|
172
|
-
e.preventDefault();
|
|
173
|
-
}
|
|
174
|
-
}),
|
|
175
|
-
on(editable, 'drop', (e) => { if (isReadOnly()) e.preventDefault(); }),
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
// B-V: Re-apply superscript / subscript after IME composition ends.
|
|
179
|
-
// Vietnamese and other IME-based inputs fire compositionstart/end around
|
|
180
|
-
// the inserted characters. During composition the browser may place the
|
|
181
|
-
// provisional text outside the current <sup>/<sub> element. When
|
|
182
|
-
// compositionend fires we detect whether the cursor escaped the sup/sub
|
|
183
|
-
// context and re-apply the command so the composed character stays inside.
|
|
184
|
-
/** @type {string|null} 'superscript' | 'subscript' | null */
|
|
185
|
-
let _compositionSupSub = null;
|
|
186
|
-
const onCompositionStart = () => {
|
|
187
|
-
const sel = globalThis.getSelection();
|
|
188
|
-
if (!sel?.rangeCount) { _compositionSupSub = null; return; }
|
|
189
|
-
let node = sel.getRangeAt(0).startContainer;
|
|
190
|
-
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
|
191
|
-
if (node) {
|
|
192
|
-
const el = /** @type {Element} */ (node);
|
|
193
|
-
if (el.closest('sup')) _compositionSupSub = 'superscript';
|
|
194
|
-
else if (el.closest('sub')) _compositionSupSub = 'subscript';
|
|
195
|
-
else _compositionSupSub = null;
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
const onCompositionEnd = () => {
|
|
199
|
-
const tag = _compositionSupSub;
|
|
200
|
-
_compositionSupSub = null;
|
|
201
|
-
if (!tag) return;
|
|
202
|
-
const sel = globalThis.getSelection();
|
|
203
|
-
if (!sel?.rangeCount) return;
|
|
204
|
-
let node = sel.getRangeAt(0).startContainer;
|
|
205
|
-
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
|
206
|
-
const el = /** @type {Element} */ (node);
|
|
207
|
-
const inContext = tag === 'superscript' ? el?.closest('sup') : el?.closest('sub');
|
|
208
|
-
if (!inContext) {
|
|
209
|
-
// The composed character escaped the sup/sub — re-apply the format.
|
|
210
|
-
document.execCommand(tag);
|
|
211
|
-
}
|
|
212
|
-
};
|
|
213
|
-
this._disposers.push(
|
|
214
|
-
on(editable, 'compositionstart', onCompositionStart),
|
|
215
|
-
on(editable, 'compositionend', onCompositionEnd),
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
_onKeydown(event) {
|
|
220
|
-
const editable = this.context.layoutInfo.editable;
|
|
221
|
-
|
|
222
|
-
// Let Typing module handle special keys (Tab, Enter etc.)
|
|
223
|
-
if (handleKeydown(event, editable, this.options)) return;
|
|
224
|
-
|
|
225
|
-
// Built-in shortcuts
|
|
226
|
-
if (isModifier(event, 'z') && !event.shiftKey) {
|
|
227
|
-
event.preventDefault();
|
|
228
|
-
this.undo();
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
if ((isModifier(event, 'z') && event.shiftKey) || isModifier(event, 'y')) {
|
|
232
|
-
event.preventDefault();
|
|
233
|
-
this.redo();
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
if (isModifier(event, 'b')) { event.preventDefault(); this.bold(); return; }
|
|
237
|
-
if (isModifier(event, 'i')) { event.preventDefault(); this.italic(); return; }
|
|
238
|
-
if (isModifier(event, 'u')) { event.preventDefault(); this.underline(); return; }
|
|
239
|
-
if (isModifier(event, 'k')) { event.preventDefault(); this.context.invoke('linkDialog.show'); return; }
|
|
240
|
-
|
|
241
|
-
// Ctrl+Shift+V — paste as plain text (signals Clipboard module)
|
|
242
|
-
if (isModifier(event, 'v') && event.shiftKey) {
|
|
243
|
-
this.context.invoke('clipboard.setForcePlain', true);
|
|
244
|
-
return; // let the native paste event fire
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Show keyboard shortcuts dialog: Ctrl+Shift+/
|
|
248
|
-
if (event.key === '/' && event.shiftKey && event.ctrlKey && !event.metaKey) {
|
|
249
|
-
event.preventDefault();
|
|
250
|
-
this.context.invoke('shortcutsDialog.show');
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
// Find: Ctrl+F
|
|
254
|
-
if (isModifier(event, 'f')) {
|
|
255
|
-
event.preventDefault();
|
|
256
|
-
this.context.invoke('findReplace.show', 'find');
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
// Ctrl+H — Find & Replace
|
|
260
|
-
if (isModifier(event, 'h')) {
|
|
261
|
-
event.preventDefault();
|
|
262
|
-
this.context.invoke('findReplace.show', 'replace');
|
|
263
|
-
}
|
|
264
|
-
// Ctrl+` — Inline Code
|
|
265
|
-
if (isModifier(event, '`')) {
|
|
266
|
-
event.preventDefault();
|
|
267
|
-
this.inlineCode();
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
|
-
// Limit enforcement
|
|
273
|
-
// ---------------------------------------------------------------------------
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Called from beforeinput to block typing when char/word limits are reached.
|
|
277
|
-
* Deletions and non-typing input types are always allowed.
|
|
278
|
-
* @param {InputEvent} event
|
|
279
|
-
*/
|
|
280
|
-
_enforceLimit(event) {
|
|
281
|
-
const maxChars = this.options.maxChars || 0;
|
|
282
|
-
const maxWords = this.options.maxWords || 0;
|
|
283
|
-
if (!maxChars && !maxWords) return;
|
|
284
|
-
|
|
285
|
-
const type = event.inputType || '';
|
|
286
|
-
// Allow deletions, undo, redo and non-insert operations
|
|
287
|
-
if (type.startsWith('delete') || type === 'historyUndo' || type === 'historyRedo') return;
|
|
288
|
-
// Allow paste/drop — handled after the fact by Clipboard
|
|
289
|
-
if (type === 'insertFromPaste' || type === 'insertFromDrop') return;
|
|
290
|
-
// Only enforce for keyboard/IME/composition insertions
|
|
291
|
-
if (!type.startsWith('insert')) return;
|
|
292
|
-
|
|
293
|
-
const text = this.context.layoutInfo.editable.innerText || '';
|
|
294
|
-
const chars = text.replaceAll('\n', '').length;
|
|
295
|
-
|
|
296
|
-
if (maxChars && chars >= maxChars) {
|
|
297
|
-
event.preventDefault();
|
|
298
|
-
if (typeof this.options.onCharLimitReached === 'function') {
|
|
299
|
-
this.options.onCharLimitReached(this.context);
|
|
300
|
-
}
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Word limit: block space / newline insertion when already at the limit
|
|
305
|
-
if (maxWords && (event.data === ' ' || type === 'insertParagraph' || type === 'insertLineBreak')) {
|
|
306
|
-
const words = text.trim() ? text.trim().split(/\s+/).length : 0;
|
|
307
|
-
if (words >= maxWords) {
|
|
308
|
-
event.preventDefault();
|
|
309
|
-
if (typeof this.options.onWordLimitReached === 'function') {
|
|
310
|
-
this.options.onWordLimitReached(this.context);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// ---------------------------------------------------------------------------
|
|
317
|
-
// Post-command hook — records undo, fires change event
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
|
|
320
|
-
afterCommand() {
|
|
321
|
-
// C4: Remove figure.an-figure elements whose <img> has been deleted so
|
|
322
|
-
// orphaned figcaptions do not accumulate in the DOM.
|
|
323
|
-
this._cleanOrphanedFigures();
|
|
324
|
-
// Ensure the editable always ends with a paragraph so the user can click
|
|
325
|
-
// and type after block elements that trap the cursor (pre, table, etc.).
|
|
326
|
-
this._ensureTrailingParagraph();
|
|
327
|
-
// Immediate: keep toolbar and statusbar in sync on every mutation.
|
|
328
|
-
this.context.invoke('toolbar.refresh');
|
|
329
|
-
this.context.invoke('statusbar.update');
|
|
330
|
-
// Debounced: recording an undo snapshot and firing the change event require
|
|
331
|
-
// a full innerHTML serialization. Batching rapid keystrokes prevents the
|
|
332
|
-
// browser from re-serializing large content (e.g. embedded images) on every
|
|
333
|
-
// single key press.
|
|
334
|
-
this._scheduleSnapshot();
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Schedules a debounced undo snapshot + change event.
|
|
339
|
-
* Resets the timer on each call so rapid typing produces one snapshot.
|
|
340
|
-
*/
|
|
341
|
-
_scheduleSnapshot() {
|
|
342
|
-
clearTimeout(this._snapshotTimer);
|
|
343
|
-
this._snapshotTimer = setTimeout(() => {
|
|
344
|
-
this._snapshotTimer = null;
|
|
345
|
-
if (this._history) this._history.recordUndo();
|
|
346
|
-
this.context.triggerEvent('change', this.getHTML());
|
|
347
|
-
}, 400);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* C4: Removes figure.an-figure elements that no longer contain an <img>.
|
|
352
|
-
* This happens when a user selects only the image (not the whole figure)
|
|
353
|
-
* and deletes or replaces it, leaving a dangling figcaption.
|
|
354
|
-
*/
|
|
355
|
-
_cleanOrphanedFigures() {
|
|
356
|
-
const editable = this.context.layoutInfo.editable;
|
|
357
|
-
editable.querySelectorAll('figure.an-figure').forEach((fig) => {
|
|
358
|
-
if (!fig.querySelector('img')) {
|
|
359
|
-
fig.remove();
|
|
360
|
-
}
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Ensures the editable always ends with a plain paragraph so the cursor can
|
|
366
|
-
* be placed after block elements that do not naturally allow it
|
|
367
|
-
* (pre, blockquote, table, figure, ul, ol, hr).
|
|
368
|
-
* Without this, clicking below the last such element does nothing.
|
|
369
|
-
*/
|
|
370
|
-
_ensureTrailingParagraph() {
|
|
371
|
-
const editable = this.context.layoutInfo.editable;
|
|
372
|
-
if (!editable) return;
|
|
373
|
-
const last = editable.lastElementChild;
|
|
374
|
-
if (!last) return;
|
|
375
|
-
const TRAPPING = new Set(['PRE', 'BLOCKQUOTE', 'TABLE', 'FIGURE', 'UL', 'OL', 'HR']);
|
|
376
|
-
if (TRAPPING.has(last.nodeName)) {
|
|
377
|
-
const p = document.createElement('p');
|
|
378
|
-
p.innerHTML = '<br>';
|
|
379
|
-
editable.appendChild(p);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// ---------------------------------------------------------------------------
|
|
384
|
-
// Focus management
|
|
385
|
-
// ---------------------------------------------------------------------------
|
|
386
|
-
|
|
387
|
-
focus() {
|
|
388
|
-
const editable = this.context.layoutInfo.editable;
|
|
389
|
-
editable.focus();
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// ---------------------------------------------------------------------------
|
|
393
|
-
// Content API
|
|
394
|
-
// ---------------------------------------------------------------------------
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Returns the editor HTML content.
|
|
398
|
-
* @returns {string}
|
|
399
|
-
*/
|
|
400
|
-
getHTML() {
|
|
401
|
-
// Strip zero-width spaces inserted after icons to allow caret placement.
|
|
402
|
-
const raw = this.context.layoutInfo.editable.innerHTML.replaceAll('\u200B', '');
|
|
403
|
-
// Replace any blob: URLs (lightweight DOM references to pasted/dropped images)
|
|
404
|
-
// with their original data URLs so the returned HTML is fully self-contained.
|
|
405
|
-
return this.context.invoke('clipboard.resolveImages', raw) ?? raw;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Sets the editor HTML content.
|
|
410
|
-
* @param {string} html - HTML string (will be sanitised)
|
|
411
|
-
*/
|
|
412
|
-
setHTML(html) {
|
|
413
|
-
this.context.layoutInfo.editable.innerHTML = sanitiseHTML(html, { allowIframes: true });
|
|
414
|
-
if (this._history) this._history.reset();
|
|
415
|
-
this.afterCommand();
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Returns the editor plain text content.
|
|
420
|
-
* @returns {string}
|
|
421
|
-
*/
|
|
422
|
-
getText() {
|
|
423
|
-
return this.context.layoutInfo.editable.innerText || '';
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Sets the editor content as plain text.
|
|
428
|
-
* @param {string} text
|
|
429
|
-
*/
|
|
430
|
-
setText(text) {
|
|
431
|
-
this.context.layoutInfo.editable.textContent = text;
|
|
432
|
-
if (this._history) this._history.reset();
|
|
433
|
-
this.afterCommand();
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* Clears the editor content.
|
|
438
|
-
*/
|
|
439
|
-
clear() {
|
|
440
|
-
this.setHTML('');
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* Resets the undo/redo history stack.
|
|
445
|
-
*/
|
|
446
|
-
clearHistory() {
|
|
447
|
-
if (this._history) this._history.reset();
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* Returns true when the editor has no meaningful content.
|
|
452
|
-
* @returns {boolean}
|
|
453
|
-
*/
|
|
454
|
-
isEmpty() {
|
|
455
|
-
const text = (this.context.layoutInfo.editable.innerText || '')
|
|
456
|
-
.trim()
|
|
457
|
-
.replaceAll('\u00a0', '');
|
|
458
|
-
const hasMedia = !!this.context.layoutInfo.editable.querySelector('img, video, iframe, table');
|
|
459
|
-
return !text && !hasMedia;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Inserts HTML at the current cursor position.
|
|
464
|
-
* @param {string} html
|
|
465
|
-
*/
|
|
466
|
-
insertHTML(html) {
|
|
467
|
-
if (!html) return;
|
|
468
|
-
Style.execCommand('insertHTML', sanitiseHTML(html));
|
|
469
|
-
this.afterCommand();
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* Inserts plain text at the current cursor position.
|
|
474
|
-
* @param {string} text
|
|
475
|
-
*/
|
|
476
|
-
insertText(text) {
|
|
477
|
-
if (!text) return;
|
|
478
|
-
Style.execCommand('insertText', text);
|
|
479
|
-
this.afterCommand();
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* Sets editor content from a Markdown string.
|
|
484
|
-
* @param {string} md
|
|
485
|
-
*/
|
|
486
|
-
setMarkdown(md) {
|
|
487
|
-
this.setHTML(markdownToHTML(md || ''));
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* Returns the editor content as Markdown.
|
|
492
|
-
* @returns {string}
|
|
493
|
-
*/
|
|
494
|
-
getMarkdown() {
|
|
495
|
-
return htmlToMarkdown(this.getHTML());
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// ---------------------------------------------------------------------------
|
|
499
|
-
// Undo / redo
|
|
500
|
-
// ---------------------------------------------------------------------------
|
|
501
|
-
|
|
502
|
-
undo() {
|
|
503
|
-
if (this._history) {
|
|
504
|
-
this._flushPendingSnapshot();
|
|
505
|
-
this._history.undo();
|
|
506
|
-
this.context.invoke('toolbar.refresh');
|
|
507
|
-
this.context.invoke('statusbar.update');
|
|
508
|
-
this.context.triggerEvent('change', this.getHTML());
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
redo() {
|
|
513
|
-
if (this._history) {
|
|
514
|
-
this._flushPendingSnapshot();
|
|
515
|
-
this._history.redo();
|
|
516
|
-
this.context.invoke('toolbar.refresh');
|
|
517
|
-
this.context.invoke('statusbar.update');
|
|
518
|
-
this.context.triggerEvent('change', this.getHTML());
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/** Records a debounced change before an immediate undo/redo command. */
|
|
523
|
-
_flushPendingSnapshot() {
|
|
524
|
-
if (this._snapshotTimer === null) return;
|
|
525
|
-
clearTimeout(this._snapshotTimer);
|
|
526
|
-
this._snapshotTimer = null;
|
|
527
|
-
this._history?.recordUndo();
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
canUndo() {
|
|
531
|
-
return this._history ? this._history.canUndo() : false;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
canRedo() {
|
|
535
|
-
return this._history ? this._history.canRedo() : false;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
getUndoCount() {
|
|
539
|
-
return this._history ? this._history.getUndoCount() : 0;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
getRedoCount() {
|
|
543
|
-
return this._history ? this._history.getRedoCount() : 0;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
getSelectionBookmark() {
|
|
547
|
-
return this._history?._serializeSelection() ?? null;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
restoreSelectionBookmark(bookmark) {
|
|
551
|
-
if (!bookmark || !this._history) return false;
|
|
552
|
-
this._history._restoreSelection(bookmark);
|
|
553
|
-
return true;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// ---------------------------------------------------------------------------
|
|
557
|
-
// Style commands (delegated to Style module)
|
|
558
|
-
// ---------------------------------------------------------------------------
|
|
559
|
-
|
|
560
|
-
bold() { Style.bold(); this.afterCommand(); }
|
|
561
|
-
italic() { Style.italic(); this.afterCommand(); }
|
|
562
|
-
underline() { Style.underline(); this.afterCommand(); }
|
|
563
|
-
strikethrough() { Style.strikethrough(); this.afterCommand(); }
|
|
564
|
-
superscript() { Style.superscript(); this.afterCommand(); }
|
|
565
|
-
subscript() { Style.subscript(); this.afterCommand(); }
|
|
566
|
-
justifyLeft() { Style.justifyLeft(); this.afterCommand(); }
|
|
567
|
-
justifyCenter() { Style.justifyCenter(); this.afterCommand(); }
|
|
568
|
-
justifyRight() { Style.justifyRight(); this.afterCommand(); }
|
|
569
|
-
justifyFull() { Style.justifyFull(); this.afterCommand(); }
|
|
570
|
-
indent() { Style.indent(); this.afterCommand(); }
|
|
571
|
-
outdent() { Style.outdent(); this.afterCommand(); }
|
|
572
|
-
insertUL() { Style.insertUnorderedList(); this.afterCommand(); }
|
|
573
|
-
insertOL() { Style.insertOrderedList(); this.afterCommand(); }
|
|
574
|
-
inlineCode() { Style.toggleInlineCode(this.context.layoutInfo.editable); this.afterCommand(); }
|
|
575
|
-
toggleChecklist() { Style.toggleChecklist(); this.afterCommand(); }
|
|
576
|
-
print() { this.context.print(); }
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
* @param {string} tagName - e.g. 'h1', 'p', 'blockquote', 'pre'
|
|
580
|
-
*/
|
|
581
|
-
formatBlock(tagName) {
|
|
582
|
-
Style.formatBlock(tagName);
|
|
583
|
-
|
|
584
|
-
// Auto-detect the programming language when the user formats a code block.
|
|
585
|
-
// Only runs when converting TO <pre> and the block has no language yet.
|
|
586
|
-
if (tagName === 'pre') {
|
|
587
|
-
const sel = globalThis.getSelection();
|
|
588
|
-
if (sel?.rangeCount > 0) {
|
|
589
|
-
const container = sel.getRangeAt(0).commonAncestorContainer;
|
|
590
|
-
const pre = /** @type {Element|null} */ (
|
|
591
|
-
container.nodeType === 1
|
|
592
|
-
? /** @type {Element} */ (container).closest('pre')
|
|
593
|
-
: (/** @type {Element|null} */ (container.parentElement))?.closest('pre')
|
|
594
|
-
);
|
|
595
|
-
if (pre && !/** @type {HTMLElement} */ (pre).dataset.language) {
|
|
596
|
-
const code = pre.textContent || '';
|
|
597
|
-
const lang = detectLang(code);
|
|
598
|
-
if (lang) {
|
|
599
|
-
this.context.invoke('codeTooltip.applyLanguage', pre, lang);
|
|
600
|
-
return; // applyLanguage already calls afterCommand internally
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
this.afterCommand();
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* @param {string} color
|
|
611
|
-
*/
|
|
612
|
-
foreColor(color) { Style.foreColor(color); this.afterCommand(); }
|
|
613
|
-
|
|
614
|
-
/**
|
|
615
|
-
* @param {string} color
|
|
616
|
-
*/
|
|
617
|
-
backColor(color) { Style.backColor(color); this.afterCommand(); }
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* @param {string} name
|
|
621
|
-
*/
|
|
622
|
-
fontName(name) { Style.fontName(name); this.afterCommand(); }
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* @param {string} size - e.g. '14px'
|
|
626
|
-
*/
|
|
627
|
-
fontSize(size) { Style.fontSize(size, this.context.layoutInfo.editable); this.afterCommand(); }
|
|
628
|
-
|
|
629
|
-
// ---------------------------------------------------------------------------
|
|
630
|
-
// Insert helpers
|
|
631
|
-
// ---------------------------------------------------------------------------
|
|
632
|
-
|
|
633
|
-
/**
|
|
634
|
-
* Inserts a horizontal rule at the cursor.
|
|
635
|
-
*/
|
|
636
|
-
insertHr() {
|
|
637
|
-
Style.execCommand('insertHorizontalRule');
|
|
638
|
-
this.afterCommand();
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/**
|
|
642
|
-
* Creates a link at the current selection.
|
|
643
|
-
* @param {string} url
|
|
644
|
-
* @param {string} text
|
|
645
|
-
* @param {boolean} [openInNewTab=false]
|
|
646
|
-
*/
|
|
647
|
-
insertLink(url, text, openInNewTab = false) {
|
|
648
|
-
const sel = globalThis.getSelection();
|
|
649
|
-
if (!sel || sel.rangeCount === 0) return;
|
|
650
|
-
const safeUrl = sanitiseUrl(url);
|
|
651
|
-
if (!safeUrl) return;
|
|
652
|
-
|
|
653
|
-
const hasText = sel.toString().trim().length > 0;
|
|
654
|
-
if (hasText) {
|
|
655
|
-
Style.execCommand('createLink', safeUrl);
|
|
656
|
-
if (openInNewTab) {
|
|
657
|
-
const link = this._getClosestAnchor();
|
|
658
|
-
if (link) {
|
|
659
|
-
/** @type {Element} */ (link).setAttribute('target', '_blank');
|
|
660
|
-
/** @type {Element} */ (link).setAttribute('rel', 'noopener noreferrer');
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
} else {
|
|
664
|
-
const displayText = this._escapeAttr(text || safeUrl);
|
|
665
|
-
Style.execCommand('insertHTML', `<a href="${this._escapeAttr(safeUrl)}"${openInNewTab ? ' target="_blank" rel="noopener noreferrer"' : ''}>${displayText}</a>`);
|
|
666
|
-
}
|
|
667
|
-
this.afterCommand();
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* Removes the link from the selected anchor.
|
|
672
|
-
*/
|
|
673
|
-
unlink() {
|
|
674
|
-
Style.execCommand('unlink');
|
|
675
|
-
this.afterCommand();
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Inserts an image.
|
|
680
|
-
* @param {string} src - URL or data-URI
|
|
681
|
-
* @param {string} [alt]
|
|
682
|
-
*/
|
|
683
|
-
insertImage(src, alt = '', align = '') {
|
|
684
|
-
const safeSrc = sanitiseUrl(src, { allowData: true });
|
|
685
|
-
if (!safeSrc) return;
|
|
686
|
-
const styleMap = {
|
|
687
|
-
left: 'float:left;margin:0 1em 1em 0',
|
|
688
|
-
center: 'display:block;margin:0 auto',
|
|
689
|
-
right: 'float:right;margin:0 0 1em 1em',
|
|
690
|
-
};
|
|
691
|
-
const style = styleMap[align] || '';
|
|
692
|
-
const styleAttr = style ? ` style="${style}"` : '';
|
|
693
|
-
Style.execCommand('insertHTML', `<img src="${this._escapeAttr(safeSrc)}" alt="${this._escapeAttr(alt)}" class="an-image"${styleAttr}>`);
|
|
694
|
-
this.afterCommand();
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/**
|
|
698
|
-
* Inserts a video embed (iframe or <video> element).
|
|
699
|
-
* The html string is already validated/built by VideoDialog.
|
|
700
|
-
* @param {string} html
|
|
701
|
-
*/
|
|
702
|
-
insertVideo(html) {
|
|
703
|
-
if (!html) return;
|
|
704
|
-
Style.execCommand('insertHTML', html);
|
|
705
|
-
this.afterCommand();
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* Inserts a table.
|
|
710
|
-
* @param {number} cols
|
|
711
|
-
* @param {number} rows
|
|
712
|
-
*/
|
|
713
|
-
insertTable(cols, rows) {
|
|
714
|
-
insertTable(cols, rows, { headerRow: this.context.options.tableHeaderRow });
|
|
715
|
-
this.afterCommand();
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// ---------------------------------------------------------------------------
|
|
719
|
-
// Helpers
|
|
720
|
-
// ---------------------------------------------------------------------------
|
|
721
|
-
|
|
722
|
-
_getClosestAnchor() {
|
|
723
|
-
const sel = globalThis.getSelection();
|
|
724
|
-
if (!sel || sel.rangeCount === 0) return null;
|
|
725
|
-
let node = sel.getRangeAt(0).startContainer;
|
|
726
|
-
while (node) {
|
|
727
|
-
if (node.nodeName === 'A') return node;
|
|
728
|
-
node = node.parentNode;
|
|
729
|
-
}
|
|
730
|
-
return null;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
/**
|
|
734
|
-
* Escapes a string for safe use inside an HTML attribute value.
|
|
735
|
-
* @param {string} str
|
|
736
|
-
* @returns {string}
|
|
737
|
-
*/
|
|
738
|
-
_escapeAttr(str) {
|
|
739
|
-
return String(str ?? '')
|
|
740
|
-
.replaceAll('&', '&')
|
|
741
|
-
.replaceAll('"', '"')
|
|
742
|
-
.replaceAll('<', '<')
|
|
743
|
-
.replaceAll('>', '>');
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// --- delegated to shared sanitise.js ---
|
|
747
|
-
}
|