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/README.md +85 -6
- package/dist/autumnnote.css +169 -0
- package/dist/autumnnote.es.js +805 -22
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +805 -22
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +3 -2
- package/src/js/Context.js +14 -2
- package/src/js/core/func.js +5 -5
- package/src/js/module/AutoSaveRestore.js +126 -0
- package/src/js/module/BubbleToolbar.js +243 -0
- package/src/js/module/ContextMenu.js +28 -26
- package/src/js/module/MarkdownShortcuts.js +253 -0
- package/src/js/module/Mention.js +337 -0
- package/src/js/settings.js +19 -0
- package/src/styles/autumnnote.scss +191 -0
- package/types/index.d.ts +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autumnnote",
|
|
3
|
-
"version": "1.0
|
|
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 {
|
|
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
|
}
|
package/src/js/core/func.js
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
423
|
+
const w = this.el.offsetWidth;
|
|
424
|
+
const h = this.el.offsetHeight;
|
|
405
425
|
let left = rx;
|
|
406
426
|
let top = ry;
|
|
407
|
-
|
|
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 +
|
|
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
|
// ---------------------------------------------------------------------------
|