autumnnote 1.0.9 → 1.1.1
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 +90 -10
- package/dist/autumnnote.css +169 -0
- package/dist/autumnnote.es.js +806 -23
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +806 -23
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +24 -4
- package/src/js/Context.js +14 -2
- package/src/js/core/func.js +5 -5
- package/src/js/index.js +1 -1
- 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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MarkdownShortcuts.js — Convert Markdown-style syntax typed directly in the
|
|
3
|
+
* editor into rich HTML elements (input rules / auto-format).
|
|
4
|
+
*
|
|
5
|
+
* Activated when `markdownShortcuts: true` (the default).
|
|
6
|
+
*
|
|
7
|
+
* Block rules — triggered by Space or Enter at the start of a line:
|
|
8
|
+
* #[#[#]]· → H1 / H2 / H3
|
|
9
|
+
* >· → blockquote
|
|
10
|
+
* -· or *· → unordered list item
|
|
11
|
+
* 1.· → ordered list item
|
|
12
|
+
* [ ]· → checklist item
|
|
13
|
+
* --- → horizontal rule (on Enter)
|
|
14
|
+
* ``` → code block (on Enter)
|
|
15
|
+
*
|
|
16
|
+
* Inline rules — triggered when the closing marker is typed:
|
|
17
|
+
* **text** → <strong>
|
|
18
|
+
* *text* → <em>
|
|
19
|
+
* ~~text~~ → <s>
|
|
20
|
+
* `code` → <code>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { on } from '../core/dom.js';
|
|
24
|
+
|
|
25
|
+
export class MarkdownShortcuts {
|
|
26
|
+
/** @param {import('../Context.js').Context} context */
|
|
27
|
+
constructor(context) {
|
|
28
|
+
this.context = context;
|
|
29
|
+
this.options = context.options;
|
|
30
|
+
this._disposers = [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
initialize() {
|
|
34
|
+
if (!this.options.markdownShortcuts) return this;
|
|
35
|
+
const editable = this.context.layoutInfo.editable;
|
|
36
|
+
const d1 = on(editable, 'keydown', /** @param {KeyboardEvent} e */ (e) => this._onKeydown(e));
|
|
37
|
+
const d2 = on(editable, 'input', () => this._onInput());
|
|
38
|
+
this._disposers.push(d1, d2);
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
destroy() {
|
|
43
|
+
this._disposers.forEach((d) => d());
|
|
44
|
+
this._disposers = [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Block rules (Space / Enter)
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
_onKeydown(e) {
|
|
52
|
+
if (e.key === ' ') {
|
|
53
|
+
if (this._applyBlockRule()) e.preventDefault();
|
|
54
|
+
} else if (e.key === 'Enter') {
|
|
55
|
+
if (this._applyEnterRule()) e.preventDefault();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the plain text of the line the cursor is on, up to the cursor.
|
|
61
|
+
* @returns {{ text: string, range: Range, lineNode: Node } | null}
|
|
62
|
+
*/
|
|
63
|
+
_getLineContext() {
|
|
64
|
+
const sel = window.getSelection();
|
|
65
|
+
if (!sel || !sel.rangeCount) return null;
|
|
66
|
+
const range = sel.getRangeAt(0);
|
|
67
|
+
if (!range.collapsed) return null;
|
|
68
|
+
|
|
69
|
+
const editable = this.context.layoutInfo.editable;
|
|
70
|
+
if (!editable.contains(range.startContainer)) return null;
|
|
71
|
+
|
|
72
|
+
// Walk up to find the block-level ancestor inside the editable
|
|
73
|
+
let node = range.startContainer;
|
|
74
|
+
while (node && node !== editable && !this._isBlock(node)) {
|
|
75
|
+
node = node.parentNode;
|
|
76
|
+
}
|
|
77
|
+
if (!node || node === editable) node = range.startContainer;
|
|
78
|
+
|
|
79
|
+
// Collect all text content before the cursor within that block
|
|
80
|
+
const tmpRange = document.createRange();
|
|
81
|
+
tmpRange.setStart(node, 0);
|
|
82
|
+
tmpRange.setEnd(range.startContainer, range.startOffset);
|
|
83
|
+
const text = tmpRange.toString();
|
|
84
|
+
|
|
85
|
+
return { text, range, lineNode: node };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_isBlock(node) {
|
|
89
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
|
90
|
+
const display = window.getComputedStyle(node).display;
|
|
91
|
+
return display === 'block' || display === 'list-item' || display === 'table-cell';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Applies block rule on Space key. Returns true if a rule fired. */
|
|
95
|
+
_applyBlockRule() {
|
|
96
|
+
const ctx = this._getLineContext();
|
|
97
|
+
if (!ctx) return false;
|
|
98
|
+
const { text } = ctx;
|
|
99
|
+
|
|
100
|
+
const blockPatterns = [
|
|
101
|
+
{ re: /^(#{1,3})$/, handler: (m) => this._convertToHeading(m[1].length) },
|
|
102
|
+
{ re: /^>$/, handler: () => this._convertToBlockquote() },
|
|
103
|
+
{ re: /^[-*]$/, handler: () => this._convertToList('ul') },
|
|
104
|
+
{ re: /^1\.$/, handler: () => this._convertToList('ol') },
|
|
105
|
+
{ re: /^\[ \]$/, handler: () => this._convertToChecklist() },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
for (const { re, handler } of blockPatterns) {
|
|
109
|
+
const m = text.match(re);
|
|
110
|
+
if (m) {
|
|
111
|
+
handler(m);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Applies block rule on Enter key (---, ```). Returns true if a rule fired. */
|
|
119
|
+
_applyEnterRule() {
|
|
120
|
+
const ctx = this._getLineContext();
|
|
121
|
+
if (!ctx) return false;
|
|
122
|
+
const { text } = ctx;
|
|
123
|
+
|
|
124
|
+
if (/^-{3,}$/.test(text)) {
|
|
125
|
+
this._convertToHr();
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
if (/^`{3}/.test(text)) {
|
|
129
|
+
this._convertToCodeBlock();
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Block converters
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
_selectLineAndDelete() {
|
|
140
|
+
const sel = window.getSelection();
|
|
141
|
+
if (!sel || !sel.rangeCount) return;
|
|
142
|
+
const range = sel.getRangeAt(0);
|
|
143
|
+
// Select from the start of the block to the cursor and delete
|
|
144
|
+
const startRange = document.createRange();
|
|
145
|
+
startRange.setStart(range.startContainer.parentNode || range.startContainer, 0);
|
|
146
|
+
startRange.setEnd(range.startContainer, range.startOffset);
|
|
147
|
+
startRange.deleteContents();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_convertToHeading(level) {
|
|
151
|
+
this._selectLineAndDelete();
|
|
152
|
+
document.execCommand('formatBlock', false, `h${level}`);
|
|
153
|
+
this.context.triggerEvent('change', this.context.getHTML());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_convertToBlockquote() {
|
|
157
|
+
this._selectLineAndDelete();
|
|
158
|
+
document.execCommand('formatBlock', false, 'blockquote');
|
|
159
|
+
this.context.triggerEvent('change', this.context.getHTML());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_convertToList(type) {
|
|
163
|
+
this._selectLineAndDelete();
|
|
164
|
+
document.execCommand(type === 'ul' ? 'insertUnorderedList' : 'insertOrderedList');
|
|
165
|
+
this.context.triggerEvent('change', this.context.getHTML());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_convertToChecklist() {
|
|
169
|
+
this._selectLineAndDelete();
|
|
170
|
+
this.context.invoke('editor.checklist');
|
|
171
|
+
this.context.triggerEvent('change', this.context.getHTML());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_convertToHr() {
|
|
175
|
+
this._selectLineAndDelete();
|
|
176
|
+
this.context.invoke('editor.insertHR');
|
|
177
|
+
this.context.triggerEvent('change', this.context.getHTML());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_convertToCodeBlock() {
|
|
181
|
+
this._selectLineAndDelete();
|
|
182
|
+
document.execCommand('formatBlock', false, 'pre');
|
|
183
|
+
this.context.triggerEvent('change', this.context.getHTML());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Inline rules (input event)
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
_onInput() {
|
|
191
|
+
const sel = window.getSelection();
|
|
192
|
+
if (!sel || !sel.rangeCount) return;
|
|
193
|
+
const range = sel.getRangeAt(0);
|
|
194
|
+
if (!range.collapsed) return;
|
|
195
|
+
|
|
196
|
+
const editable = this.context.layoutInfo.editable;
|
|
197
|
+
if (!editable.contains(range.startContainer)) return;
|
|
198
|
+
|
|
199
|
+
const node = range.startContainer;
|
|
200
|
+
if (node.nodeType !== Node.TEXT_NODE) return;
|
|
201
|
+
|
|
202
|
+
const text = node.textContent;
|
|
203
|
+
const offset = range.startOffset;
|
|
204
|
+
|
|
205
|
+
const inlineRules = [
|
|
206
|
+
// **bold**
|
|
207
|
+
{ re: /\*\*(.+?)\*\*$/, tag: 'strong' },
|
|
208
|
+
// *italic* (not part of **)
|
|
209
|
+
{ re: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)$/, tag: 'em' },
|
|
210
|
+
// ~~strike~~
|
|
211
|
+
{ re: /~~(.+?)~~$/, tag: 's' },
|
|
212
|
+
// `code`
|
|
213
|
+
{ re: /`([^`]+)`$/, tag: 'code' },
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
const upToCursor = text.slice(0, offset);
|
|
217
|
+
for (const { re, tag } of inlineRules) {
|
|
218
|
+
const m = upToCursor.match(re);
|
|
219
|
+
if (!m) continue;
|
|
220
|
+
|
|
221
|
+
const matchStart = upToCursor.length - m[0].length;
|
|
222
|
+
const matchEnd = offset;
|
|
223
|
+
const innerText = m[1];
|
|
224
|
+
|
|
225
|
+
// Replace matched text with formatted element
|
|
226
|
+
const before = text.slice(0, matchStart);
|
|
227
|
+
const after = text.slice(matchEnd);
|
|
228
|
+
|
|
229
|
+
const el = document.createElement(tag);
|
|
230
|
+
el.textContent = innerText;
|
|
231
|
+
|
|
232
|
+
// Rebuild the text node and insert the element
|
|
233
|
+
const beforeNode = document.createTextNode(before);
|
|
234
|
+
const afterNode = document.createTextNode('' + after);
|
|
235
|
+
|
|
236
|
+
const parent = node.parentNode;
|
|
237
|
+
parent.insertBefore(beforeNode, node);
|
|
238
|
+
parent.insertBefore(el, node);
|
|
239
|
+
parent.insertBefore(afterNode, node);
|
|
240
|
+
parent.removeChild(node);
|
|
241
|
+
|
|
242
|
+
// Place cursor after the element (after the ZWS)
|
|
243
|
+
const newRange = document.createRange();
|
|
244
|
+
newRange.setStart(afterNode, 1);
|
|
245
|
+
newRange.collapse(true);
|
|
246
|
+
sel.removeAllRanges();
|
|
247
|
+
sel.addRange(newRange);
|
|
248
|
+
|
|
249
|
+
this.context.triggerEvent('change', this.context.getHTML());
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mention.js — @mention autocomplete support.
|
|
3
|
+
*
|
|
4
|
+
* Activated when `mention.onSearch` option is provided.
|
|
5
|
+
*
|
|
6
|
+
* When the user types the trigger character (default `@`) followed by at least
|
|
7
|
+
* `mention.minChars` characters, `onSearch(query, callback)` is called.
|
|
8
|
+
* The callback receives an array of `{ id, label, avatar? }` items which are
|
|
9
|
+
* rendered in a floating dropdown. Selecting an item inserts a non-editable
|
|
10
|
+
* mention chip and fires `onInsert` (if provided) to override the default HTML.
|
|
11
|
+
*
|
|
12
|
+
* Options shape (passed as `mention` option object):
|
|
13
|
+
* {
|
|
14
|
+
* trigger: '@',
|
|
15
|
+
* minChars: 1,
|
|
16
|
+
* maxResults: 8,
|
|
17
|
+
* debounce: 200,
|
|
18
|
+
* onSearch: (query, callback) => void,
|
|
19
|
+
* onInsert: (item) => string | null,
|
|
20
|
+
* mentionClass: 'an-mention',
|
|
21
|
+
* allowSpaces: false,
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { on } from '../core/dom.js';
|
|
26
|
+
|
|
27
|
+
export class Mention {
|
|
28
|
+
/** @param {import('../Context.js').Context} context */
|
|
29
|
+
constructor(context) {
|
|
30
|
+
this.context = context;
|
|
31
|
+
|
|
32
|
+
/** @type {HTMLElement|null} */
|
|
33
|
+
this._dropdown = null;
|
|
34
|
+
/** @type {string} */
|
|
35
|
+
this._query = '';
|
|
36
|
+
/** @type {number|null} */
|
|
37
|
+
this._debounceTimer = null;
|
|
38
|
+
/** @type {number} current highlighted index */
|
|
39
|
+
this._activeIndex = -1;
|
|
40
|
+
/** @type {Array<{id, label, avatar?}>} */
|
|
41
|
+
this._items = [];
|
|
42
|
+
/** @type {boolean} */
|
|
43
|
+
this._open = false;
|
|
44
|
+
/** Position of trigger character in the text node */
|
|
45
|
+
this._triggerNode = null;
|
|
46
|
+
this._triggerOffset = 0;
|
|
47
|
+
/** @type {DOMRect|null} Caret rect captured synchronously during input event */
|
|
48
|
+
this._caretRect = null;
|
|
49
|
+
|
|
50
|
+
this._disposers = [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
initialize() {
|
|
54
|
+
const cfg = this.context.options.mention;
|
|
55
|
+
if (!cfg || typeof cfg.onSearch !== 'function') return this;
|
|
56
|
+
this._cfg = {
|
|
57
|
+
trigger: cfg.trigger || '@',
|
|
58
|
+
minChars: cfg.minChars ?? 0,
|
|
59
|
+
maxResults: cfg.maxResults ?? 8,
|
|
60
|
+
debounce: cfg.debounce ?? 200,
|
|
61
|
+
onSearch: cfg.onSearch,
|
|
62
|
+
onInsert: cfg.onInsert || null,
|
|
63
|
+
mentionClass: cfg.mentionClass || 'an-mention',
|
|
64
|
+
allowSpaces: cfg.allowSpaces || false,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
this._buildDropdown();
|
|
68
|
+
|
|
69
|
+
const editable = this.context.layoutInfo.editable;
|
|
70
|
+
const d1 = on(editable, 'keydown', (e) => this._onKeydown(e));
|
|
71
|
+
const d2 = on(editable, 'input', () => this._onInput());
|
|
72
|
+
const d3 = on(document, 'click', (e) => this._onDocClick(e));
|
|
73
|
+
this._disposers.push(d1, d2, d3);
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
destroy() {
|
|
78
|
+
clearTimeout(this._debounceTimer);
|
|
79
|
+
if (this._dropdown && this._dropdown.parentNode) {
|
|
80
|
+
this._dropdown.parentNode.removeChild(this._dropdown);
|
|
81
|
+
}
|
|
82
|
+
this._dropdown = null;
|
|
83
|
+
this._disposers.forEach((d) => d());
|
|
84
|
+
this._disposers = [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Dropdown DOM
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
_buildDropdown() {
|
|
92
|
+
const el = document.createElement('div');
|
|
93
|
+
el.className = 'an-mention-dropdown';
|
|
94
|
+
el.setAttribute('role', 'listbox');
|
|
95
|
+
document.body.appendChild(el);
|
|
96
|
+
this._dropdown = el;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_renderItems(items) {
|
|
100
|
+
const dd = this._dropdown;
|
|
101
|
+
dd.innerHTML = '';
|
|
102
|
+
this._items = items.slice(0, this._cfg.maxResults);
|
|
103
|
+
this._activeIndex = this._items.length > 0 ? 0 : -1;
|
|
104
|
+
|
|
105
|
+
this._items.forEach((item, i) => {
|
|
106
|
+
const li = document.createElement('div');
|
|
107
|
+
li.className = 'an-mention-item';
|
|
108
|
+
li.setAttribute('role', 'option');
|
|
109
|
+
li.dataset.index = i;
|
|
110
|
+
if (item.avatar) {
|
|
111
|
+
const img = document.createElement('img');
|
|
112
|
+
img.src = item.avatar;
|
|
113
|
+
img.className = 'an-mention-avatar';
|
|
114
|
+
img.alt = '';
|
|
115
|
+
li.appendChild(img);
|
|
116
|
+
}
|
|
117
|
+
const label = document.createElement('span');
|
|
118
|
+
label.textContent = item.label;
|
|
119
|
+
li.appendChild(label);
|
|
120
|
+
li.addEventListener('mousedown', (e) => e.preventDefault());
|
|
121
|
+
li.addEventListener('click', () => this._select(i));
|
|
122
|
+
dd.appendChild(li);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
this._highlightItem(this._activeIndex);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_highlightItem(index) {
|
|
129
|
+
if (!this._dropdown) return;
|
|
130
|
+
this._dropdown.querySelectorAll('.an-mention-item').forEach((el, i) => {
|
|
131
|
+
el.classList.toggle('an-mention-active', i === index);
|
|
132
|
+
});
|
|
133
|
+
this._activeIndex = index;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_positionDropdown() {
|
|
137
|
+
const dd = this._dropdown;
|
|
138
|
+
const rect = this._caretRect;
|
|
139
|
+
if (!rect || rect.height === 0) return;
|
|
140
|
+
|
|
141
|
+
// Measure while invisible to avoid layout flash
|
|
142
|
+
dd.style.visibility = 'hidden';
|
|
143
|
+
dd.style.display = 'block';
|
|
144
|
+
|
|
145
|
+
const ddh = dd.offsetHeight;
|
|
146
|
+
const ddw = dd.offsetWidth;
|
|
147
|
+
|
|
148
|
+
// position:fixed — coords are already viewport-relative, no scroll offset needed
|
|
149
|
+
let top = rect.bottom + 4;
|
|
150
|
+
let left = rect.left;
|
|
151
|
+
|
|
152
|
+
if (rect.bottom + ddh + 8 > window.innerHeight) {
|
|
153
|
+
top = rect.top - ddh - 4;
|
|
154
|
+
}
|
|
155
|
+
left = Math.max(8, Math.min(left, window.innerWidth - ddw - 8));
|
|
156
|
+
|
|
157
|
+
dd.style.top = `${top}px`;
|
|
158
|
+
dd.style.left = `${left}px`;
|
|
159
|
+
dd.style.visibility = '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_showDropdown() {
|
|
163
|
+
this._open = true;
|
|
164
|
+
this._positionDropdown();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_hideDropdown() {
|
|
168
|
+
if (this._dropdown) this._dropdown.style.display = 'none';
|
|
169
|
+
this._open = false;
|
|
170
|
+
this._items = [];
|
|
171
|
+
this._activeIndex = -1;
|
|
172
|
+
this._triggerNode = null;
|
|
173
|
+
this._caretRect = null;
|
|
174
|
+
this._query = '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Captures the caret rect synchronously during the input event.
|
|
179
|
+
* Must be called while the DOM event is still live — getClientRects() on a
|
|
180
|
+
* collapsed range is reliable at this point but often empty inside async callbacks.
|
|
181
|
+
*/
|
|
182
|
+
_captureCaretRect() {
|
|
183
|
+
// Prefer a range over the trigger character — it has non-zero width and
|
|
184
|
+
// getBoundingClientRect() reliably returns a valid rect.
|
|
185
|
+
if (this._triggerNode && this._triggerNode.isConnected) {
|
|
186
|
+
try {
|
|
187
|
+
const r = document.createRange();
|
|
188
|
+
const end = Math.min(this._triggerOffset + 1, this._triggerNode.textContent.length);
|
|
189
|
+
r.setStart(this._triggerNode, this._triggerOffset);
|
|
190
|
+
r.setEnd(this._triggerNode, end);
|
|
191
|
+
const candidate = r.getBoundingClientRect();
|
|
192
|
+
if (candidate.height > 0) {
|
|
193
|
+
this._caretRect = candidate;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
} catch (_) {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Fallback: use getClientRects() on the current collapsed selection
|
|
200
|
+
const sel = window.getSelection();
|
|
201
|
+
if (!sel || !sel.rangeCount) return;
|
|
202
|
+
const rects = sel.getRangeAt(0).getClientRects();
|
|
203
|
+
if (rects.length > 0) {
|
|
204
|
+
this._caretRect = rects[rects.length - 1];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Query detection
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
_getQueryAtCursor() {
|
|
213
|
+
const sel = window.getSelection();
|
|
214
|
+
if (!sel || !sel.rangeCount) return null;
|
|
215
|
+
const range = sel.getRangeAt(0);
|
|
216
|
+
if (!range.collapsed) return null;
|
|
217
|
+
|
|
218
|
+
const node = range.startContainer;
|
|
219
|
+
if (node.nodeType !== Node.TEXT_NODE) return null;
|
|
220
|
+
|
|
221
|
+
const text = node.textContent.slice(0, range.startOffset);
|
|
222
|
+
const trigger = this._cfg.trigger;
|
|
223
|
+
|
|
224
|
+
// Find the last occurrence of trigger in the text before cursor
|
|
225
|
+
const triggerIdx = text.lastIndexOf(trigger);
|
|
226
|
+
if (triggerIdx === -1) return null;
|
|
227
|
+
|
|
228
|
+
const afterTrigger = text.slice(triggerIdx + trigger.length);
|
|
229
|
+
|
|
230
|
+
// No spaces allowed unless configured
|
|
231
|
+
if (!this._cfg.allowSpaces && /\s/.test(afterTrigger)) return null;
|
|
232
|
+
|
|
233
|
+
if (afterTrigger.length < this._cfg.minChars) return null;
|
|
234
|
+
|
|
235
|
+
this._triggerNode = node;
|
|
236
|
+
this._triggerOffset = triggerIdx;
|
|
237
|
+
return afterTrigger;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Events
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
_onInput() {
|
|
245
|
+
if (!this._cfg) return;
|
|
246
|
+
const query = this._getQueryAtCursor();
|
|
247
|
+
if (query === null) {
|
|
248
|
+
this._hideDropdown();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Capture caret rect NOW, synchronously while the input event is live.
|
|
253
|
+
// Inside a setTimeout/debounce callback the selection may still be valid
|
|
254
|
+
// but getClientRects() on a collapsed range often returns empty in that context.
|
|
255
|
+
this._captureCaretRect();
|
|
256
|
+
|
|
257
|
+
this._query = query;
|
|
258
|
+
clearTimeout(this._debounceTimer);
|
|
259
|
+
this._debounceTimer = setTimeout(() => {
|
|
260
|
+
this._cfg.onSearch(this._query, (items) => {
|
|
261
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
262
|
+
this._hideDropdown();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
this._renderItems(items);
|
|
266
|
+
this._showDropdown();
|
|
267
|
+
});
|
|
268
|
+
}, this._cfg.debounce);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_onKeydown(e) {
|
|
272
|
+
if (!this._cfg || !this._open) return;
|
|
273
|
+
|
|
274
|
+
if (e.key === 'ArrowDown') {
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
const next = (this._activeIndex + 1) % this._items.length;
|
|
277
|
+
this._highlightItem(next);
|
|
278
|
+
} else if (e.key === 'ArrowUp') {
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
const prev = (this._activeIndex - 1 + this._items.length) % this._items.length;
|
|
281
|
+
this._highlightItem(prev);
|
|
282
|
+
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
283
|
+
if (this._activeIndex >= 0) {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
this._select(this._activeIndex);
|
|
286
|
+
}
|
|
287
|
+
} else if (e.key === 'Escape') {
|
|
288
|
+
this._hideDropdown();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
_onDocClick(e) {
|
|
293
|
+
if (!this._open) return;
|
|
294
|
+
if (this._dropdown && this._dropdown.contains(e.target)) return;
|
|
295
|
+
this._hideDropdown();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Insert
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
_select(index) {
|
|
303
|
+
const item = this._items[index];
|
|
304
|
+
if (!item) return;
|
|
305
|
+
|
|
306
|
+
// Delete the trigger + query text from the DOM
|
|
307
|
+
if (this._triggerNode) {
|
|
308
|
+
const node = this._triggerNode;
|
|
309
|
+
const before = node.textContent.slice(0, this._triggerOffset);
|
|
310
|
+
const after = node.textContent.slice(this._triggerOffset + this._cfg.trigger.length + this._query.length);
|
|
311
|
+
node.textContent = before + after;
|
|
312
|
+
|
|
313
|
+
// Move cursor to where the trigger was
|
|
314
|
+
const sel = window.getSelection();
|
|
315
|
+
const range = document.createRange();
|
|
316
|
+
range.setStart(node, this._triggerOffset);
|
|
317
|
+
range.collapse(true);
|
|
318
|
+
sel.removeAllRanges();
|
|
319
|
+
sel.addRange(range);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Build the chip HTML
|
|
323
|
+
let html;
|
|
324
|
+
if (typeof this._cfg.onInsert === 'function') {
|
|
325
|
+
html = this._cfg.onInsert(item);
|
|
326
|
+
}
|
|
327
|
+
if (!html) {
|
|
328
|
+
const cls = this._cfg.mentionClass;
|
|
329
|
+
html = `<span class="${cls}" data-mention-id="${item.id}" contenteditable="false">@${item.label}</span>`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Insert a trailing space so the cursor lands outside the chip
|
|
333
|
+
this.context.invoke('editor.insertHTML', html + '​');
|
|
334
|
+
this._hideDropdown();
|
|
335
|
+
this.context.triggerEvent('change', this.context.getHTML());
|
|
336
|
+
}
|
|
337
|
+
}
|
package/src/js/settings.js
CHANGED
|
@@ -139,4 +139,23 @@ export const defaultOptions = {
|
|
|
139
139
|
// Built-in values: 'en' (default), 'vi', 'ja', 'zh', 'fr'.
|
|
140
140
|
// Pass a partial or full locale object to override individual strings.
|
|
141
141
|
lang: 'en',
|
|
142
|
+
|
|
143
|
+
// Auto-save restore: show a banner when a draft exists in localStorage.
|
|
144
|
+
// Requires autoSave: true. Set autoSaveRestoreTimeout to the max age in days
|
|
145
|
+
// (0 = no expiry). onAutoSaveRestore(html, context) fires after restore.
|
|
146
|
+
autoSaveRestore: false,
|
|
147
|
+
autoSaveRestoreTimeout: 7,
|
|
148
|
+
onAutoSaveRestore: null,
|
|
149
|
+
|
|
150
|
+
// Markdown input shortcuts: convert markdown syntax typed inline to HTML.
|
|
151
|
+
// e.g. "## " at line start → <h2>, "**bold**" → <strong>
|
|
152
|
+
markdownShortcuts: true,
|
|
153
|
+
|
|
154
|
+
// Bubble toolbar: show a mini floating toolbar above text selections.
|
|
155
|
+
bubbleToolbar: false,
|
|
156
|
+
bubbleToolbarItems: ['bold', 'italic', 'underline', 'link', 'foreColor', 'removeFormat'],
|
|
157
|
+
|
|
158
|
+
// @mention support. mention.onSearch(query, callback) must be provided to activate.
|
|
159
|
+
// mention.minChars defaults to 0 — dropdown opens immediately on trigger character.
|
|
160
|
+
mention: null,
|
|
142
161
|
};
|