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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +874 -0
  3. package/dist/autumnnote.css +1 -0
  4. package/dist/autumnnote.es.js +5888 -0
  5. package/dist/autumnnote.es.js.map +1 -0
  6. package/dist/autumnnote.umd.js +74 -0
  7. package/dist/autumnnote.umd.js.map +1 -0
  8. package/package.json +55 -0
  9. package/src/js/Context.js +497 -0
  10. package/src/js/core/dom.js +315 -0
  11. package/src/js/core/env.js +25 -0
  12. package/src/js/core/func.js +153 -0
  13. package/src/js/core/key.js +66 -0
  14. package/src/js/core/lists.js +121 -0
  15. package/src/js/core/markdown.js +294 -0
  16. package/src/js/core/range.js +194 -0
  17. package/src/js/core/sanitise.js +78 -0
  18. package/src/js/editing/History.js +205 -0
  19. package/src/js/editing/Style.js +329 -0
  20. package/src/js/editing/Table.js +59 -0
  21. package/src/js/editing/Typing.js +142 -0
  22. package/src/js/index.js +126 -0
  23. package/src/js/module/Buttons.js +300 -0
  24. package/src/js/module/Clipboard.js +460 -0
  25. package/src/js/module/CodeTooltip.js +428 -0
  26. package/src/js/module/Codeview.js +122 -0
  27. package/src/js/module/ContextMenu.js +470 -0
  28. package/src/js/module/Editor.js +528 -0
  29. package/src/js/module/EmojiDialog.js +726 -0
  30. package/src/js/module/FindReplace.js +440 -0
  31. package/src/js/module/Fullscreen.js +80 -0
  32. package/src/js/module/IconDialog.js +620 -0
  33. package/src/js/module/ImageDialog.js +208 -0
  34. package/src/js/module/ImageResizer.js +216 -0
  35. package/src/js/module/ImageTooltip.js +286 -0
  36. package/src/js/module/LinkDialog.js +204 -0
  37. package/src/js/module/LinkTooltip.js +242 -0
  38. package/src/js/module/Placeholder.js +44 -0
  39. package/src/js/module/ShortcutsDialog.js +141 -0
  40. package/src/js/module/Statusbar.js +238 -0
  41. package/src/js/module/TableTooltip.js +568 -0
  42. package/src/js/module/Toolbar.js +562 -0
  43. package/src/js/module/VideoDialog.js +263 -0
  44. package/src/js/module/VideoResizer.js +227 -0
  45. package/src/js/module/VideoTooltip.js +252 -0
  46. package/src/js/renderer.js +107 -0
  47. package/src/js/settings.js +134 -0
  48. package/src/styles/_variables.scss +48 -0
  49. package/src/styles/autumnnote.scss +1740 -0
  50. 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, '&amp;')
522
+ .replace(/"/g, '&quot;')
523
+ .replace(/</g, '&lt;')
524
+ .replace(/>/g, '&gt;');
525
+ }
526
+
527
+ // --- delegated to shared sanitise.js ---
528
+ }