autumnnote 1.0.9 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autumnnote",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
4
4
  "description": "A modern, lightweight WYSIWYG editor — built with vanilla JavaScript, no jQuery required.",
5
5
  "main": "dist/autumnnote.umd.js",
6
6
  "module": "dist/autumnnote.es.js",
@@ -23,7 +23,8 @@
23
23
  "typecheck": "tsc --noEmit",
24
24
  "build:cdn": "vite build --config vite.cdn.config.js",
25
25
  "analyze": "cross-env ANALYZE=1 vite build",
26
- "bench": "vitest bench"
26
+ "bench": "vitest bench",
27
+ "test:coverage": "vitest run --coverage"
27
28
  },
28
29
  "keywords": [
29
30
  "wysiwyg",
package/src/js/Context.js CHANGED
@@ -34,6 +34,10 @@ import { ContextMenu } from './module/ContextMenu.js';
34
34
  import { ShortcutsDialog } from './module/ShortcutsDialog.js';
35
35
  import { FindReplace } from './module/FindReplace.js';
36
36
  import { ImageCropOverlay } from './module/ImageCropOverlay.js';
37
+ import { AutoSaveRestore } from './module/AutoSaveRestore.js';
38
+ import { MarkdownShortcuts } from './module/MarkdownShortcuts.js';
39
+ import { BubbleToolbar } from './module/BubbleToolbar.js';
40
+ import { Mention } from './module/Mention.js';
37
41
 
38
42
  /** Module registry shared across all Context instances (populated via AutumnNote.registerModule). */
39
43
  export const _customModules = new Map();
@@ -139,6 +143,10 @@ export class Context {
139
143
  register('shortcutsDialog', ShortcutsDialog);
140
144
  register('findReplace', FindReplace);
141
145
  register('imageCropOverlay', ImageCropOverlay);
146
+ register('autoSaveRestore', AutoSaveRestore);
147
+ register('markdownShortcuts', MarkdownShortcuts);
148
+ register('bubbleToolbar', BubbleToolbar);
149
+ register('mention', Mention);
142
150
 
143
151
  // Custom modules registered via AutumnNote.registerModule()
144
152
  for (const [name, ModuleClass] of _customModules) {
@@ -181,10 +189,14 @@ export class Context {
181
189
  const d3 = this.on('change', () => this._syncToTarget());
182
190
  this._disposers.push(d0, d1, d2, d3);
183
191
 
184
- // Auto-save to localStorage on every change
192
+ // Auto-save to localStorage on every change (also writes :asrmeta for restore banner)
185
193
  if (this.options.autoSave && this.options.autoSaveKey) {
186
194
  const d4 = this.on('change', () => {
187
- try { localStorage.setItem(this.options.autoSaveKey, this.getHTML()); } catch (_) {}
195
+ try {
196
+ const key = this.options.autoSaveKey;
197
+ localStorage.setItem(key, this.getHTML());
198
+ localStorage.setItem(key + ':asrmeta', JSON.stringify({ savedAt: Date.now() }));
199
+ } catch (_) {}
188
200
  });
189
201
  this._disposers.push(d4);
190
202
  }
@@ -110,11 +110,11 @@ export function mergeDeep(target, source) {
110
110
  if (isPlainObject(target) && isPlainObject(source)) {
111
111
  for (const key of Object.keys(source)) {
112
112
  if (isPlainObject(source[key])) {
113
- if (!(key in target)) {
114
- output[key] = mergeDeep({}, source[key]);
115
- } else {
116
- output[key] = mergeDeep(target[key], source[key]);
117
- }
113
+ // When target[key] is null / undefined / a non-object (e.g. the `mention: null`
114
+ // default), merge into an empty object instead of passing null to the next
115
+ // recursive call — which would silently drop all source properties.
116
+ const base = isPlainObject(target[key]) ? target[key] : {};
117
+ output[key] = mergeDeep(base, source[key]);
118
118
  } else if (Array.isArray(source[key])) {
119
119
  output[key] = [...source[key]];
120
120
  } else {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * AutoSaveRestore.js — Detects a previously auto-saved draft and offers the
3
+ * user a banner to restore or discard it.
4
+ *
5
+ * Activated when both `autoSave` and `autoSaveRestore` options are true.
6
+ * On initialize it checks localStorage for a draft that is within the
7
+ * `autoSaveRestoreTimeout` day window. If one is found a dismissible banner
8
+ * is prepended to the editor container.
9
+ */
10
+
11
+ export class AutoSaveRestore {
12
+ /** @param {import('../Context.js').Context} context */
13
+ constructor(context) {
14
+ this.context = context;
15
+ this.options = context.options;
16
+
17
+ /** @type {HTMLElement|null} */
18
+ this._banner = null;
19
+ }
20
+
21
+ initialize() {
22
+ if (!this.options.autoSave || !this.options.autoSaveRestore) return this;
23
+
24
+ const key = this.options.autoSaveKey;
25
+ const metaKey = key + ':asrmeta';
26
+
27
+ let saved;
28
+ let meta;
29
+ try {
30
+ saved = localStorage.getItem(key);
31
+ meta = JSON.parse(localStorage.getItem(metaKey) || '{}');
32
+ } catch (_) {
33
+ return this;
34
+ }
35
+
36
+ if (!saved) return this;
37
+
38
+ const timeout = this.options.autoSaveRestoreTimeout;
39
+ if (timeout > 0) {
40
+ const ageMs = Date.now() - (meta.savedAt || 0);
41
+ if (ageMs > timeout * 86400000) {
42
+ try { localStorage.removeItem(key); localStorage.removeItem(metaKey); } catch (_) {}
43
+ return this;
44
+ }
45
+ }
46
+
47
+ this._showBanner(saved, meta);
48
+ return this;
49
+ }
50
+
51
+ destroy() {
52
+ this._removeBanner();
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Internal
57
+ // ---------------------------------------------------------------------------
58
+
59
+ _showBanner(draftHtml, meta) {
60
+ const locale = this.context.locale;
61
+ const t = locale.autoSaveRestore || {};
62
+ const label = t.found || 'Draft found. Restore?';
63
+ const restoreLabel = t.restore || 'Restore';
64
+ const discardLabel = t.discard || 'Discard';
65
+
66
+ const banner = document.createElement('div');
67
+ banner.className = 'an-asr-banner';
68
+ banner.setAttribute('role', 'alert');
69
+
70
+ const msg = document.createElement('span');
71
+ msg.className = 'an-asr-msg';
72
+ if (meta.savedAt) {
73
+ const d = new Date(meta.savedAt);
74
+ const formatted = d.toLocaleString();
75
+ msg.textContent = (t.foundAt || 'Draft from {date}. Restore?').replace('{date}', formatted);
76
+ } else {
77
+ msg.textContent = label;
78
+ }
79
+
80
+ const restoreBtn = document.createElement('button');
81
+ restoreBtn.type = 'button';
82
+ restoreBtn.className = 'an-btn an-asr-btn-restore';
83
+ restoreBtn.textContent = restoreLabel;
84
+ restoreBtn.addEventListener('click', () => this._restore(draftHtml));
85
+
86
+ const discardBtn = document.createElement('button');
87
+ discardBtn.type = 'button';
88
+ discardBtn.className = 'an-btn an-asr-btn-discard';
89
+ discardBtn.textContent = discardLabel;
90
+ discardBtn.addEventListener('click', () => this._discard());
91
+
92
+ banner.appendChild(msg);
93
+ banner.appendChild(restoreBtn);
94
+ banner.appendChild(discardBtn);
95
+
96
+ this._banner = banner;
97
+ const container = this.context.layoutInfo.container;
98
+ container.insertBefore(banner, container.firstChild);
99
+ }
100
+
101
+ _restore(draftHtml) {
102
+ this.context.setHTML(draftHtml);
103
+ this.context.clearHistory();
104
+ this._removeBanner();
105
+
106
+ if (typeof this.options.onAutoSaveRestore === 'function') {
107
+ this.options.onAutoSaveRestore(draftHtml, this.context);
108
+ }
109
+ }
110
+
111
+ _discard() {
112
+ const key = this.options.autoSaveKey;
113
+ try {
114
+ localStorage.removeItem(key);
115
+ localStorage.removeItem(key + ':asrmeta');
116
+ } catch (_) {}
117
+ this._removeBanner();
118
+ }
119
+
120
+ _removeBanner() {
121
+ if (this._banner && this._banner.parentNode) {
122
+ this._banner.parentNode.removeChild(this._banner);
123
+ }
124
+ this._banner = null;
125
+ }
126
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * BubbleToolbar.js — A mini floating toolbar that appears above the current
3
+ * text selection, allowing quick access to common formatting actions without
4
+ * reaching for the main toolbar.
5
+ *
6
+ * Activated when `bubbleToolbar: true`. The set of visible buttons is
7
+ * controlled by `bubbleToolbarItems` (array of button name strings).
8
+ *
9
+ * Built-in names: 'bold', 'italic', 'underline', 'strikethrough',
10
+ * 'link', 'foreColor', 'removeFormat', 'inlineCode'.
11
+ * Any name not found in the built-in map is silently ignored.
12
+ */
13
+
14
+ import { on } from '../core/dom.js';
15
+
16
+ // Minimal SVG icon set — only what the bubble toolbar needs.
17
+ const _S = 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
18
+ const _svg = (p) =>
19
+ `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" ${_S} style="display:block">${p}</svg>`;
20
+
21
+ const _ICONS = {
22
+ bold: _svg('<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/>'),
23
+ italic: _svg('<line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/>'),
24
+ underline: _svg('<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/>'),
25
+ strikethrough: _svg('<path d="M17.3 12H6.7"/><path d="M10 6.5C10 5.1 11.1 4 12.5 4c1.4 0 2.5 1.1 2.5 2.5 0 .8-.4 1.5-1 2"/><path d="M14 17.5C14 19 12.9 20 11.5 20 10.1 20 9 18.9 9 17.5c0-.8.4-1.5 1-2"/>'),
26
+ link: _svg('<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>'),
27
+ foreColor: _svg('<path d="M4 20L12 4L20 20"/><line x1="7.5" y1="14" x2="16.5" y2="14"/>'),
28
+ removeFormat: _svg('<path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/>'),
29
+ inlineCode: _svg('<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5c0 1.1.9 2 2 2a2 2 0 0 1-2 2v5a2 2 0 0 1-2 2h-1"/>'),
30
+ };
31
+
32
+ const _ACTIONS = {
33
+ bold: (ctx) => ctx.invoke('editor.bold'),
34
+ italic: (ctx) => ctx.invoke('editor.italic'),
35
+ underline: (ctx) => ctx.invoke('editor.underline'),
36
+ strikethrough: (ctx) => ctx.invoke('editor.strikethrough'),
37
+ link: (ctx) => ctx.invoke('linkDialog.show'),
38
+ foreColor: (ctx) => {
39
+ const color = window.prompt('Text color (hex or name):', '#000000');
40
+ if (color) ctx.invoke('editor.foreColor', color);
41
+ },
42
+ removeFormat: (ctx) => ctx.invoke('editor.removeFormat'),
43
+ inlineCode: (ctx) => ctx.invoke('editor.inlineCode'),
44
+ };
45
+
46
+ const _ACTIVE = {
47
+ bold: () => document.queryCommandState('bold'),
48
+ italic: () => document.queryCommandState('italic'),
49
+ underline: () => document.queryCommandState('underline'),
50
+ strikethrough: () => document.queryCommandState('strikeThrough'),
51
+ };
52
+
53
+ export class BubbleToolbar {
54
+ /** @param {import('../Context.js').Context} context */
55
+ constructor(context) {
56
+ this.context = context;
57
+ this.options = context.options;
58
+
59
+ /** @type {HTMLElement|null} */
60
+ this._el = null;
61
+ this._visible = false;
62
+ this._rafId = null;
63
+ this._contextMenuOpen = false;
64
+ this._disposers = [];
65
+
66
+ this._onSelectionChange = this._onSelectionChange.bind(this);
67
+ this._onMousedown = this._onMousedown.bind(this);
68
+ this._onKeydown = this._onKeydown.bind(this);
69
+ this._onContextMenu = this._onContextMenu.bind(this);
70
+ }
71
+
72
+ initialize() {
73
+ if (!this.options.bubbleToolbar) return this;
74
+ this._build();
75
+ const d1 = on(document, 'selectionchange', this._onSelectionChange);
76
+ const d2 = on(document, 'mousedown', this._onMousedown);
77
+ const d3 = on(document, 'keydown', this._onKeydown);
78
+ const d4 = on(document, 'contextmenu', this._onContextMenu);
79
+ const d5 = this.context.on('contextMenu:show', () => {
80
+ this._contextMenuOpen = true;
81
+ this._hide();
82
+ });
83
+ const d6 = this.context.on('contextMenu:hide', () => {
84
+ this._contextMenuOpen = false;
85
+ });
86
+ this._disposers.push(d1, d2, d3, d4, d5, d6);
87
+ return this;
88
+ }
89
+
90
+ destroy() {
91
+ if (this._el && this._el.parentNode) this._el.parentNode.removeChild(this._el);
92
+ this._el = null;
93
+ this._disposers.forEach((d) => d());
94
+ this._disposers = [];
95
+ cancelAnimationFrame(this._rafId);
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Build
100
+ // ---------------------------------------------------------------------------
101
+
102
+ _build() {
103
+ const el = document.createElement('div');
104
+ el.className = 'an-bubble-toolbar';
105
+ el.setAttribute('role', 'toolbar');
106
+ el.setAttribute('aria-label', 'Formatting');
107
+
108
+ const items = this.options.bubbleToolbarItems || ['bold', 'italic', 'underline', 'link', 'foreColor', 'removeFormat'];
109
+ for (const name of items) {
110
+ if (!_ICONS[name] || !_ACTIONS[name]) continue;
111
+ const btn = document.createElement('button');
112
+ btn.type = 'button';
113
+ btn.className = 'an-bubble-btn';
114
+ btn.dataset.name = name;
115
+ btn.setAttribute('aria-label', name);
116
+ btn.innerHTML = _ICONS[name];
117
+ btn.addEventListener('mousedown', (e) => {
118
+ // Prevent mousedown from collapsing selection before click fires
119
+ e.preventDefault();
120
+ });
121
+ btn.addEventListener('click', (e) => {
122
+ e.preventDefault();
123
+ e.stopPropagation();
124
+ this.context.invoke('editor.focus');
125
+ _ACTIONS[name](this.context);
126
+ this.context.invoke('editor.afterCommand');
127
+ this._syncActive();
128
+ });
129
+ el.appendChild(btn);
130
+ }
131
+
132
+ document.body.appendChild(el);
133
+ this._el = el;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Show / hide
138
+ // ---------------------------------------------------------------------------
139
+
140
+ _show(rect) {
141
+ if (!this._el) return;
142
+ const el = this._el;
143
+
144
+ // Measure while invisible — position:fixed so no scroll offset needed
145
+ el.style.visibility = 'hidden';
146
+ el.style.display = 'flex';
147
+
148
+ const bw = el.offsetWidth;
149
+ const bh = el.offsetHeight;
150
+ const gap = 8;
151
+
152
+ // Center horizontally above the selection; flip below if no room above
153
+ let left = rect.left + rect.width / 2 - bw / 2;
154
+ let top = rect.top - bh - gap;
155
+
156
+ left = Math.max(8, Math.min(left, window.innerWidth - bw - 8));
157
+
158
+ if (top < 8) {
159
+ top = rect.bottom + gap;
160
+ }
161
+
162
+ el.style.top = `${top}px`;
163
+ el.style.left = `${left}px`;
164
+ el.style.visibility = '';
165
+
166
+ this._syncActive();
167
+ this._visible = true;
168
+ }
169
+
170
+ _hide() {
171
+ if (!this._el) return;
172
+ this._el.style.display = 'none';
173
+ this._visible = false;
174
+ }
175
+
176
+ _syncActive() {
177
+ if (!this._el) return;
178
+ this._el.querySelectorAll('.an-bubble-btn').forEach((btn) => {
179
+ const name = btn.dataset.name;
180
+ const activeFn = _ACTIVE[name];
181
+ btn.classList.toggle('an-active', !!(activeFn && activeFn()));
182
+ });
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Events
187
+ // ---------------------------------------------------------------------------
188
+
189
+ _onSelectionChange() {
190
+ cancelAnimationFrame(this._rafId);
191
+ this._rafId = requestAnimationFrame(() => {
192
+ if (this._contextMenuOpen) return;
193
+
194
+ const sel = window.getSelection();
195
+ if (!sel || sel.isCollapsed || !sel.rangeCount) {
196
+ this._hide();
197
+ return;
198
+ }
199
+
200
+ const editable = this.context.layoutInfo.editable;
201
+ if (!editable.contains(sel.anchorNode)) {
202
+ this._hide();
203
+ return;
204
+ }
205
+
206
+ // Don't show inside code view or read-only
207
+ if (this.context.options.readOnly) {
208
+ this._hide();
209
+ return;
210
+ }
211
+
212
+ const range = sel.getRangeAt(0);
213
+ const rect = range.getBoundingClientRect();
214
+ if (!rect || rect.width === 0) {
215
+ this._hide();
216
+ return;
217
+ }
218
+
219
+ this._show(rect);
220
+ });
221
+ }
222
+
223
+ _onMousedown(e) {
224
+ // Hide when clicking outside both the editable and the bubble toolbar
225
+ if (!this._visible) return;
226
+ if (this._el && this._el.contains(e.target)) return;
227
+ const editable = this.context.layoutInfo.editable;
228
+ if (editable.contains(e.target)) return;
229
+ this._hide();
230
+ }
231
+
232
+ _onKeydown(e) {
233
+ if (e.key === 'Escape' && this._visible) {
234
+ this._hide();
235
+ }
236
+ }
237
+
238
+ _onContextMenu() {
239
+ // Hide immediately on right-click — contextMenu:show event will also set the flag,
240
+ // but firing here prevents a one-frame flicker before the event propagates.
241
+ this._hide();
242
+ }
243
+ }
@@ -377,12 +377,30 @@ export class ContextMenu {
377
377
  if (!editable.contains(event.target)) return;
378
378
  if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
379
379
  event.preventDefault();
380
- this._lastX = event.clientX;
381
- this._lastY = event.clientY;
380
+
382
381
  const winSel = window.getSelection();
383
382
  this._savedRange = (winSel && winSel.rangeCount > 0) ? winSel.getRangeAt(0).cloneRange() : null;
384
383
  this._renderItems(this._items);
385
- this.showAt(event.clientX, event.clientY);
384
+
385
+ // Open below the selected text so the selection stays visible.
386
+ // Only apply when there is a real (non-collapsed) selection — a collapsed range
387
+ // (cursor only) also has height > 0, which would misplace the menu relative
388
+ // to the actual click point.
389
+ let openX = event.clientX;
390
+ let openY = event.clientY;
391
+ if (this._savedRange && !this._savedRange.collapsed) {
392
+ try {
393
+ const selRect = this._savedRange.getBoundingClientRect();
394
+ if (selRect.width > 0 && selRect.height > 0) {
395
+ // Keep X at click position (feels natural); Y just below the selection.
396
+ openY = selRect.bottom + 4;
397
+ }
398
+ } catch (_) {}
399
+ }
400
+
401
+ this._lastX = openX;
402
+ this._lastY = openY;
403
+ this.showAt(openX, openY);
386
404
  }
387
405
 
388
406
  _maybeHide(event) {
@@ -395,39 +413,22 @@ export class ContextMenu {
395
413
  this.el.style.display = 'block';
396
414
  this._reposition(x, y);
397
415
  this.el.setAttribute('aria-hidden', 'false');
416
+ this.context.triggerEvent('contextMenu:show');
398
417
  }
399
418
 
400
419
  _reposition(x, y) {
401
420
  if (!this.el) return;
402
421
  const rx = x !== undefined ? x : this._lastX;
403
422
  const ry = y !== undefined ? y : this._lastY;
404
- const rect = this.el.getBoundingClientRect();
423
+ const w = this.el.offsetWidth;
424
+ const h = this.el.offsetHeight;
405
425
  let left = rx;
406
426
  let top = ry;
407
- if (left + rect.width > window.innerWidth) left = window.innerWidth - rect.width - 8;
427
+ // Clamp to viewport so the menu never overflows the screen edge
428
+ if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8;
408
429
  if (left < 8) left = 8;
409
- if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 8;
430
+ if (top + h > window.innerHeight - 8) top = window.innerHeight - h - 8;
410
431
  if (top < 8) top = 8;
411
-
412
- // Avoid covering the saved selection range
413
- if (this._savedRange) {
414
- try {
415
- const sel = this._savedRange.getBoundingClientRect();
416
- if (sel.width > 0 || sel.height > 0) {
417
- const overlaps = top < sel.bottom && (top + rect.height) > sel.top &&
418
- left < sel.right && (left + rect.width) > sel.left;
419
- if (overlaps) {
420
- const belowTop = sel.bottom + 6;
421
- if (belowTop + rect.height <= window.innerHeight - 8) {
422
- top = belowTop;
423
- } else {
424
- top = Math.max(8, sel.top - rect.height - 6);
425
- }
426
- }
427
- }
428
- } catch (_) { /* stale range — ignore */ }
429
- }
430
-
431
432
  this.el.style.left = `${left}px`;
432
433
  this.el.style.top = `${top}px`;
433
434
  }
@@ -436,6 +437,7 @@ export class ContextMenu {
436
437
  if (!this.el) return;
437
438
  this.el.style.display = 'none';
438
439
  this.el.setAttribute('aria-hidden', 'true');
440
+ this.context.triggerEvent('contextMenu:hide');
439
441
  }
440
442
 
441
443
  // ---------------------------------------------------------------------------