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
|
@@ -1,376 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SlashMenu.js — Notion-style "/" command palette for quick block insertion.
|
|
3
|
-
*
|
|
4
|
-
* Typing "/" as the very first character of an otherwise-empty block opens a
|
|
5
|
-
* filterable list of quick-insert commands (headings, lists, table, image, …).
|
|
6
|
-
* Arrow keys navigate, Enter/Tab selects, Escape or deleting back past the
|
|
7
|
-
* "/" closes it. Disabled via `slashMenu: false`.
|
|
8
|
-
*
|
|
9
|
-
* Positioning follows the same synchronous-caret-rect technique as Mention.js:
|
|
10
|
-
* getBoundingClientRect() on a zero-width Range over the trigger character is
|
|
11
|
-
* reliable while the triggering DOM event is still live, but often returns an
|
|
12
|
-
* empty rect once deferred into a callback — so the rect is captured eagerly
|
|
13
|
-
* in `_onInput`, not lazily when the menu is rendered.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { on } from '../core/dom.js';
|
|
17
|
-
|
|
18
|
-
export class SlashMenu {
|
|
19
|
-
/** @param {import('../Context.js').Context} context */
|
|
20
|
-
constructor(context) {
|
|
21
|
-
this.context = context;
|
|
22
|
-
this.options = context.options;
|
|
23
|
-
this._disposers = [];
|
|
24
|
-
|
|
25
|
-
/** @type {HTMLElement|null} */
|
|
26
|
-
this._menu = null;
|
|
27
|
-
/** @type {Array<{id: string, label: string, keywords: string, run: () => void}>} */
|
|
28
|
-
this._filtered = [];
|
|
29
|
-
this._activeIndex = -1;
|
|
30
|
-
this._open = false;
|
|
31
|
-
this._query = '';
|
|
32
|
-
/** @type {Text|null} */
|
|
33
|
-
this._textNode = null;
|
|
34
|
-
this._triggerOffset = 0;
|
|
35
|
-
/** @type {DOMRect|null} */
|
|
36
|
-
this._caretRect = null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
initialize() {
|
|
40
|
-
if (this.options.slashMenu === false) return this;
|
|
41
|
-
const editable = this.context.layoutInfo.editable;
|
|
42
|
-
const d1 = on(editable, 'input', () => this._onInput());
|
|
43
|
-
const d2 = on(editable, 'keydown', (e) => this._onKeydown(e));
|
|
44
|
-
const d3 = on(document, 'click', (e) => this._onDocClick(e));
|
|
45
|
-
this._disposers.push(d1, d2, d3);
|
|
46
|
-
return this;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
destroy() {
|
|
50
|
-
this._menu?.remove();
|
|
51
|
-
this._menu = null;
|
|
52
|
-
this._disposers.forEach((d) => d());
|
|
53
|
-
this._disposers = [];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Command list
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
|
|
60
|
-
_commands() {
|
|
61
|
-
const L = this.context.locale.slashMenu;
|
|
62
|
-
const ctx = this.context;
|
|
63
|
-
const builtIns = [
|
|
64
|
-
{ id: 'h1', label: L.heading1, keywords: 'h1 heading title', run: () => ctx.invoke('editor.formatBlock', 'h1') },
|
|
65
|
-
{ id: 'h2', label: L.heading2, keywords: 'h2 heading subtitle', run: () => ctx.invoke('editor.formatBlock', 'h2') },
|
|
66
|
-
{ id: 'h3', label: L.heading3, keywords: 'h3 heading', run: () => ctx.invoke('editor.formatBlock', 'h3') },
|
|
67
|
-
{ id: 'ul', label: L.bulletList, keywords: 'ul bullet list unordered', run: () => ctx.invoke('editor.insertUL') },
|
|
68
|
-
{ id: 'ol', label: L.numberedList, keywords: 'ol numbered list ordered', run: () => ctx.invoke('editor.insertOL') },
|
|
69
|
-
{ id: 'checklist', label: L.checklist, keywords: 'checklist todo checkbox task', run: () => ctx.invoke('editor.toggleChecklist') },
|
|
70
|
-
{ id: 'quote', label: L.blockquote, keywords: 'quote blockquote', run: () => ctx.invoke('editor.formatBlock', 'blockquote') },
|
|
71
|
-
{ id: 'code', label: L.codeBlock, keywords: 'code pre block', run: () => ctx.invoke('editor.formatBlock', 'pre') },
|
|
72
|
-
{ id: 'hr', label: L.horizontalRule, keywords: 'hr divider rule line', run: () => ctx.invoke('editor.insertHr') },
|
|
73
|
-
{ id: 'table', label: L.table, keywords: 'table grid', run: () => ctx.invoke('editor.insertTable', 3, 3) },
|
|
74
|
-
{ id: 'image', label: L.image, keywords: 'image picture photo upload', run: () => ctx.invoke('imageDialog.show') },
|
|
75
|
-
];
|
|
76
|
-
const custom = (this.options.slashCommands || []).map((command) => ({
|
|
77
|
-
id: command.id,
|
|
78
|
-
label: command.label || command.id,
|
|
79
|
-
keywords: command.keywords || '',
|
|
80
|
-
run: () => command.run(ctx),
|
|
81
|
-
}));
|
|
82
|
-
return [...builtIns, ...custom];
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
refresh() {
|
|
86
|
-
if (this._open) this._filterAndRender();
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
// Menu DOM
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
|
|
93
|
-
_buildMenu() {
|
|
94
|
-
const el = document.createElement('div');
|
|
95
|
-
el.className = 'an-slash-menu';
|
|
96
|
-
el.setAttribute('role', 'listbox');
|
|
97
|
-
el.style.display = 'none';
|
|
98
|
-
|
|
99
|
-
el.addEventListener('mousedown', (e) => e.preventDefault());
|
|
100
|
-
el.addEventListener('click', (e) => {
|
|
101
|
-
const item = /** @type {HTMLElement} */ (/** @type {Element} */ (e.target)?.closest('.an-slash-menu-item'));
|
|
102
|
-
if (item) this._select(+item.dataset.index);
|
|
103
|
-
});
|
|
104
|
-
el.addEventListener('mousemove', (e) => {
|
|
105
|
-
const item = /** @type {HTMLElement} */ (/** @type {Element} */ (e.target)?.closest('.an-slash-menu-item'));
|
|
106
|
-
if (item) this._highlight(+item.dataset.index);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
document.body.appendChild(el);
|
|
110
|
-
this._menu = el;
|
|
111
|
-
return el;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
_renderItems() {
|
|
115
|
-
if (!this._menu) this._buildMenu();
|
|
116
|
-
const menu = this._menu;
|
|
117
|
-
const L = this.context.locale.slashMenu;
|
|
118
|
-
|
|
119
|
-
if (this._filtered.length === 0) {
|
|
120
|
-
menu.innerHTML = '';
|
|
121
|
-
const empty = document.createElement('div');
|
|
122
|
-
empty.className = 'an-slash-menu-empty';
|
|
123
|
-
empty.textContent = L.noResults;
|
|
124
|
-
menu.appendChild(empty);
|
|
125
|
-
this._activeIndex = -1;
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const frag = document.createDocumentFragment();
|
|
130
|
-
this._filtered.forEach((item, i) => {
|
|
131
|
-
const row = document.createElement('div');
|
|
132
|
-
row.className = 'an-slash-menu-item';
|
|
133
|
-
row.setAttribute('role', 'option');
|
|
134
|
-
row.id = `an-slash-option-${item.id}`;
|
|
135
|
-
row.dataset.index = String(i);
|
|
136
|
-
row.textContent = item.label;
|
|
137
|
-
frag.appendChild(row);
|
|
138
|
-
});
|
|
139
|
-
menu.innerHTML = '';
|
|
140
|
-
menu.appendChild(frag);
|
|
141
|
-
this._highlight(0);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
_highlight(index) {
|
|
145
|
-
if (!this._menu) return;
|
|
146
|
-
this._menu.querySelectorAll('.an-slash-menu-item').forEach((el, i) => {
|
|
147
|
-
const active = i === index;
|
|
148
|
-
el.classList.toggle('an-slash-menu-active', active);
|
|
149
|
-
el.setAttribute('aria-selected', String(active));
|
|
150
|
-
if (active) this._menu.setAttribute('aria-activedescendant', el.id);
|
|
151
|
-
});
|
|
152
|
-
this._activeIndex = index;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
_position() {
|
|
156
|
-
const menu = this._menu;
|
|
157
|
-
const rect = this._caretRect;
|
|
158
|
-
if (!menu || !rect || rect.height === 0) return;
|
|
159
|
-
|
|
160
|
-
menu.style.visibility = 'hidden';
|
|
161
|
-
menu.style.display = 'block';
|
|
162
|
-
const mh = menu.offsetHeight;
|
|
163
|
-
const mw = menu.offsetWidth;
|
|
164
|
-
|
|
165
|
-
let top = rect.bottom + 4;
|
|
166
|
-
let left = rect.left;
|
|
167
|
-
if (rect.bottom + mh + 8 > globalThis.innerHeight) {
|
|
168
|
-
top = rect.top - mh - 4;
|
|
169
|
-
}
|
|
170
|
-
left = Math.max(8, Math.min(left, globalThis.innerWidth - mw - 8));
|
|
171
|
-
|
|
172
|
-
menu.style.top = `${top}px`;
|
|
173
|
-
menu.style.left = `${left}px`;
|
|
174
|
-
menu.style.visibility = '';
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
_open_() {
|
|
178
|
-
this._open = true;
|
|
179
|
-
this._filterAndRender();
|
|
180
|
-
this._position();
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
_close() {
|
|
184
|
-
if (this._menu) this._menu.style.display = 'none';
|
|
185
|
-
this._open = false;
|
|
186
|
-
this._filtered = [];
|
|
187
|
-
this._activeIndex = -1;
|
|
188
|
-
this._menu?.removeAttribute('aria-activedescendant');
|
|
189
|
-
this._textNode = null;
|
|
190
|
-
this._caretRect = null;
|
|
191
|
-
this._query = '';
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ---------------------------------------------------------------------------
|
|
195
|
-
// Trigger detection
|
|
196
|
-
// ---------------------------------------------------------------------------
|
|
197
|
-
|
|
198
|
-
_isBlock(node) {
|
|
199
|
-
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
|
200
|
-
const display = globalThis.getComputedStyle(node).display;
|
|
201
|
-
return display === 'block' || display === 'list-item' || display === 'table-cell';
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Detects a "/" trigger at the caret, requiring it to be the only content
|
|
206
|
-
* typed so far in its block (i.e. the block's text is exactly "/" + query,
|
|
207
|
-
* nothing before or after). This deliberately avoids firing mid-sentence
|
|
208
|
-
* (e.g. "10/20", "and/or").
|
|
209
|
-
* @returns {{ query: string, textNode: Text, triggerOffset: number } | null}
|
|
210
|
-
*/
|
|
211
|
-
_getTriggerContext() {
|
|
212
|
-
const sel = globalThis.getSelection();
|
|
213
|
-
if (!sel?.rangeCount) return null;
|
|
214
|
-
const range = sel.getRangeAt(0);
|
|
215
|
-
if (!range.collapsed) return null;
|
|
216
|
-
|
|
217
|
-
const editable = this.context.layoutInfo.editable;
|
|
218
|
-
if (!editable.contains(range.startContainer)) return null;
|
|
219
|
-
if (range.startContainer.nodeType !== Node.TEXT_NODE) return null;
|
|
220
|
-
|
|
221
|
-
let block = range.startContainer.parentNode;
|
|
222
|
-
while (block && block !== editable && !this._isBlock(block)) block = block.parentNode;
|
|
223
|
-
if (!block || block === editable) return null;
|
|
224
|
-
|
|
225
|
-
const fullText = block.textContent || '';
|
|
226
|
-
const beforeRange = document.createRange();
|
|
227
|
-
beforeRange.setStart(block, 0);
|
|
228
|
-
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
229
|
-
const before = beforeRange.toString();
|
|
230
|
-
|
|
231
|
-
const m = /^\/(\S*)$/.exec(before);
|
|
232
|
-
if (!m) return null;
|
|
233
|
-
if (fullText.length > before.length) return null; // content after the caret
|
|
234
|
-
|
|
235
|
-
return {
|
|
236
|
-
query: m[1],
|
|
237
|
-
textNode: /** @type {Text} */ (range.startContainer),
|
|
238
|
-
triggerOffset: range.startOffset - m[0].length,
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Captures the caret rect synchronously — see file header for why this
|
|
244
|
-
* can't be deferred to a later tick.
|
|
245
|
-
*/
|
|
246
|
-
_captureCaretRect(textNode, triggerOffset) {
|
|
247
|
-
try {
|
|
248
|
-
const r = document.createRange();
|
|
249
|
-
const end = Math.min(triggerOffset + 1, textNode.textContent.length);
|
|
250
|
-
r.setStart(textNode, triggerOffset);
|
|
251
|
-
r.setEnd(textNode, end);
|
|
252
|
-
const candidate = r.getBoundingClientRect();
|
|
253
|
-
if (candidate.height > 0) return candidate;
|
|
254
|
-
} catch (_) { void _; }
|
|
255
|
-
|
|
256
|
-
const sel = globalThis.getSelection();
|
|
257
|
-
if (!sel?.rangeCount) return null;
|
|
258
|
-
const rects = sel.getRangeAt(0).getClientRects();
|
|
259
|
-
return rects.length > 0 ? rects[rects.length - 1] : null;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
_filterAndRender() {
|
|
263
|
-
const q = this._query.toLowerCase();
|
|
264
|
-
this._filtered = q
|
|
265
|
-
? this._commands().filter((c) => c.keywords.includes(q) || c.label.toLowerCase().includes(q))
|
|
266
|
-
: this._commands();
|
|
267
|
-
this._renderItems();
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ---------------------------------------------------------------------------
|
|
271
|
-
// Events
|
|
272
|
-
// ---------------------------------------------------------------------------
|
|
273
|
-
|
|
274
|
-
_onInput() {
|
|
275
|
-
const ctx = this._getTriggerContext();
|
|
276
|
-
if (!ctx) {
|
|
277
|
-
if (this._open) this._close();
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
this._textNode = ctx.textNode;
|
|
282
|
-
this._triggerOffset = ctx.triggerOffset;
|
|
283
|
-
this._query = ctx.query;
|
|
284
|
-
this._caretRect = this._captureCaretRect(ctx.textNode, ctx.triggerOffset);
|
|
285
|
-
|
|
286
|
-
if (!this._open) this._open_();
|
|
287
|
-
else { this._filterAndRender(); this._position(); }
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
_onKeydown(e) {
|
|
291
|
-
if (!this._open) return;
|
|
292
|
-
|
|
293
|
-
if (e.key === 'ArrowDown') {
|
|
294
|
-
e.preventDefault();
|
|
295
|
-
if (this._filtered.length === 0) return;
|
|
296
|
-
this._highlight((this._activeIndex + 1) % this._filtered.length);
|
|
297
|
-
} else if (e.key === 'ArrowUp') {
|
|
298
|
-
e.preventDefault();
|
|
299
|
-
if (this._filtered.length === 0) return;
|
|
300
|
-
this._highlight((this._activeIndex - 1 + this._filtered.length) % this._filtered.length);
|
|
301
|
-
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
302
|
-
if (this._activeIndex >= 0) {
|
|
303
|
-
e.preventDefault();
|
|
304
|
-
this._select(this._activeIndex);
|
|
305
|
-
}
|
|
306
|
-
} else if (e.key === 'Escape') {
|
|
307
|
-
e.preventDefault();
|
|
308
|
-
this._close();
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
_onDocClick(e) {
|
|
313
|
-
if (!this._open) return;
|
|
314
|
-
if (this._menu?.contains(e.target)) return;
|
|
315
|
-
this._close();
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
// Selection
|
|
320
|
-
// ---------------------------------------------------------------------------
|
|
321
|
-
|
|
322
|
-
_deleteTriggerText() {
|
|
323
|
-
const node = this._textNode;
|
|
324
|
-
if (!node?.isConnected) return;
|
|
325
|
-
const before = node.textContent.slice(0, this._triggerOffset);
|
|
326
|
-
const after = node.textContent.slice(this._triggerOffset + 1 + this._query.length);
|
|
327
|
-
node.textContent = before + after;
|
|
328
|
-
|
|
329
|
-
const sel = globalThis.getSelection();
|
|
330
|
-
if (!sel) return;
|
|
331
|
-
const range = document.createRange();
|
|
332
|
-
range.setStart(node, this._triggerOffset);
|
|
333
|
-
range.collapse(true);
|
|
334
|
-
sel.removeAllRanges();
|
|
335
|
-
sel.addRange(range);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
_makeTriggerRemover() {
|
|
339
|
-
const node = this._textNode;
|
|
340
|
-
const triggerOffset = this._triggerOffset;
|
|
341
|
-
const query = this._query;
|
|
342
|
-
return () => {
|
|
343
|
-
if (!node?.isConnected) return;
|
|
344
|
-
const before = node.textContent.slice(0, triggerOffset);
|
|
345
|
-
const after = node.textContent.slice(triggerOffset + 1 + query.length);
|
|
346
|
-
node.textContent = before + after;
|
|
347
|
-
|
|
348
|
-
const sel = globalThis.getSelection();
|
|
349
|
-
if (!sel) return;
|
|
350
|
-
const range = document.createRange();
|
|
351
|
-
range.setStart(node, triggerOffset);
|
|
352
|
-
range.collapse(true);
|
|
353
|
-
sel.removeAllRanges();
|
|
354
|
-
sel.addRange(range);
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
_select(index) {
|
|
359
|
-
const item = this._filtered[index];
|
|
360
|
-
if (!item) return;
|
|
361
|
-
|
|
362
|
-
if (item.id === 'image') {
|
|
363
|
-
const beforeInsert = this._makeTriggerRemover();
|
|
364
|
-
this._close();
|
|
365
|
-
this.context.invoke('imageDialog.show', { beforeInsert });
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
this._deleteTriggerText();
|
|
370
|
-
this._close();
|
|
371
|
-
// Editor.* methods invoked by commands already call afterCommand()
|
|
372
|
-
// internally (toolbar/statusbar refresh + debounced undo snapshot + the
|
|
373
|
-
// 'change' event), so no manual triggerEvent here — see Editor.js.
|
|
374
|
-
item.run();
|
|
375
|
-
}
|
|
376
|
-
}
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Statusbar.js - Displays word count, character count and resize handle
|
|
3
|
-
* Inspired by Summernote's Statusbar module — rewritten without jQuery
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { createElement, on } from '../core/dom.js';
|
|
7
|
-
|
|
8
|
-
// Cache the segmenter instance at module level to avoid per-call allocation
|
|
9
|
-
const _segmenter =
|
|
10
|
-
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
|
11
|
-
? new Intl.Segmenter(undefined, { granularity: 'word' })
|
|
12
|
-
: null;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Count words in a string, CJK-aware.
|
|
16
|
-
* Uses Intl.Segmenter (Chromium 87+, Firefox 125+, Safari 17+) when available,
|
|
17
|
-
* falling back to a simple whitespace split for older environments.
|
|
18
|
-
* @param {string} text
|
|
19
|
-
* @returns {number}
|
|
20
|
-
*/
|
|
21
|
-
function _countWords(text) {
|
|
22
|
-
const trimmed = text.trim();
|
|
23
|
-
if (!trimmed) return 0;
|
|
24
|
-
if (_segmenter) {
|
|
25
|
-
let count = 0;
|
|
26
|
-
for (const seg of _segmenter.segment(trimmed)) {
|
|
27
|
-
if (seg.isWordLike) count++;
|
|
28
|
-
}
|
|
29
|
-
return count;
|
|
30
|
-
}
|
|
31
|
-
// Fallback: split on whitespace
|
|
32
|
-
return trimmed.split(/\s+/).length;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Toggles warning/exceeded CSS classes on a count element.
|
|
37
|
-
* @param {HTMLElement} el
|
|
38
|
-
* @param {number} current
|
|
39
|
-
* @param {number} limit 0 = no limit
|
|
40
|
-
*/
|
|
41
|
-
function _applyLimitClass(el, current, limit) {
|
|
42
|
-
if (!limit) {
|
|
43
|
-
el.classList.remove('an-count-warn', 'an-count-exceeded');
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
if (current > limit) {
|
|
47
|
-
el.classList.add('an-count-exceeded');
|
|
48
|
-
el.classList.remove('an-count-warn');
|
|
49
|
-
} else if (current >= limit * 0.9) {
|
|
50
|
-
el.classList.add('an-count-warn');
|
|
51
|
-
el.classList.remove('an-count-exceeded');
|
|
52
|
-
} else {
|
|
53
|
-
el.classList.remove('an-count-warn', 'an-count-exceeded');
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export class Statusbar {
|
|
58
|
-
/**
|
|
59
|
-
* @param {import('../Context.js').Context} context
|
|
60
|
-
*/
|
|
61
|
-
constructor(context) {
|
|
62
|
-
this.context = context;
|
|
63
|
-
this.options = context.options;
|
|
64
|
-
/** @type {HTMLElement|null} */
|
|
65
|
-
this.el = null;
|
|
66
|
-
this._disposers = [];
|
|
67
|
-
/** @type {HTMLElement|null} */
|
|
68
|
-
this._wordCountEl = null;
|
|
69
|
-
/** @type {HTMLElement|null} */
|
|
70
|
-
this._charCountEl = null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
// Lifecycle
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
initialize() {
|
|
78
|
-
this.el = createElement('div', { class: 'an-statusbar' });
|
|
79
|
-
|
|
80
|
-
// Resize handle
|
|
81
|
-
if (this.options.resizable !== false) {
|
|
82
|
-
const handle = createElement('div', {
|
|
83
|
-
class: 'an-resize-handle',
|
|
84
|
-
title: this.context.locale.statusbar.resizeHandle,
|
|
85
|
-
'aria-hidden': 'true',
|
|
86
|
-
});
|
|
87
|
-
this._bindResize(handle);
|
|
88
|
-
this.el.appendChild(handle);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Counters
|
|
92
|
-
this._wordCountEl = createElement('span', { class: 'an-word-count', role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true' });
|
|
93
|
-
this._charCountEl = createElement('span', { class: 'an-char-count', 'aria-live': 'polite', 'aria-atomic': 'true' });
|
|
94
|
-
const info = createElement('div', { class: 'an-status-info', 'aria-label': 'Editor statistics' });
|
|
95
|
-
info.appendChild(this._wordCountEl);
|
|
96
|
-
info.appendChild(this._charCountEl);
|
|
97
|
-
this.el.appendChild(info);
|
|
98
|
-
|
|
99
|
-
this.update();
|
|
100
|
-
return this;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
destroy() {
|
|
104
|
-
this._disposers.forEach((d) => d());
|
|
105
|
-
this._disposers = [];
|
|
106
|
-
if (this._dragDisposers) {
|
|
107
|
-
this._dragDisposers.forEach((d) => d());
|
|
108
|
-
this._dragDisposers = null;
|
|
109
|
-
}
|
|
110
|
-
this.el?.remove();
|
|
111
|
-
this.el = null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
// Resize logic
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
_bindResize(handle) {
|
|
119
|
-
let startY = 0;
|
|
120
|
-
let startH = 0;
|
|
121
|
-
// Resize the container (flex column parent) so that the editable — which
|
|
122
|
-
// has flex:1 / flex-basis:0 — automatically fills the remaining space.
|
|
123
|
-
// Setting height directly on a flex:1 item has no effect because the flex
|
|
124
|
-
// algorithm ignores the height property when flex-basis is non-auto.
|
|
125
|
-
const containerEl = this.context.layoutInfo.container;
|
|
126
|
-
|
|
127
|
-
const applyDelta = (clientY) => {
|
|
128
|
-
const delta = clientY - startY;
|
|
129
|
-
// Compute the true minimum: fixed elements (toolbar + statusbar) must fit
|
|
130
|
-
// inside the container. Sum the offsetHeight of every child that is NOT
|
|
131
|
-
// the editable area, then add a small floor so the editable stays visible.
|
|
132
|
-
const MIN_EDITABLE = 40;
|
|
133
|
-
const fixedH = Array.from(containerEl.children)
|
|
134
|
-
.filter(child => !child.classList.contains('an-editable'))
|
|
135
|
-
.reduce((sum, child) => sum + /** @type {HTMLElement} */ (child).offsetHeight, 0);
|
|
136
|
-
const trueMin = Math.max(this.options.minHeight || 100, fixedH + MIN_EDITABLE);
|
|
137
|
-
containerEl.style.height = `${Math.max(trueMin, startH + delta)}px`;
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
// Mouse drag
|
|
141
|
-
const onMouseMove = (event) => applyDelta(event.clientY);
|
|
142
|
-
|
|
143
|
-
const onMouseUp = () => {
|
|
144
|
-
document.removeEventListener('mousemove', onMouseMove);
|
|
145
|
-
document.removeEventListener('mouseup', onMouseUp);
|
|
146
|
-
this._dragDisposers = null;
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const onMouseDown = (event) => {
|
|
150
|
-
startY = event.clientY;
|
|
151
|
-
startH = containerEl.offsetHeight;
|
|
152
|
-
// Clear the editable's inline min-height so the flex layout can compress
|
|
153
|
-
// it freely once the container has a fixed height. Without this, the
|
|
154
|
-
// editable's min-height (set from options.height) overflows the container
|
|
155
|
-
// when the user drags the handle to a size smaller than that value.
|
|
156
|
-
this.context.layoutInfo.editable.style.minHeight = '';
|
|
157
|
-
document.addEventListener('mousemove', onMouseMove);
|
|
158
|
-
document.addEventListener('mouseup', onMouseUp);
|
|
159
|
-
// Track drag-phase listeners so destroy() can remove them mid-drag
|
|
160
|
-
this._dragDisposers = [
|
|
161
|
-
() => document.removeEventListener('mousemove', onMouseMove),
|
|
162
|
-
() => document.removeEventListener('mouseup', onMouseUp),
|
|
163
|
-
];
|
|
164
|
-
event.preventDefault();
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
// Touch drag
|
|
168
|
-
const onTouchMove = (event) => {
|
|
169
|
-
const touch = event.touches[0];
|
|
170
|
-
if (touch) { event.preventDefault(); applyDelta(touch.clientY); }
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const onTouchEnd = () => {
|
|
174
|
-
document.removeEventListener('touchmove', onTouchMove);
|
|
175
|
-
document.removeEventListener('touchend', onTouchEnd);
|
|
176
|
-
this._dragDisposers = null;
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
const onTouchStart = (event) => {
|
|
180
|
-
const touch = event.touches[0];
|
|
181
|
-
if (!touch) return;
|
|
182
|
-
startY = touch.clientY;
|
|
183
|
-
startH = containerEl.offsetHeight;
|
|
184
|
-
// Same as onMouseDown: clear editable min-height so flex can compress it
|
|
185
|
-
this.context.layoutInfo.editable.style.minHeight = '';
|
|
186
|
-
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
187
|
-
document.addEventListener('touchend', onTouchEnd);
|
|
188
|
-
this._dragDisposers = [
|
|
189
|
-
() => document.removeEventListener('touchmove', onTouchMove),
|
|
190
|
-
() => document.removeEventListener('touchend', onTouchEnd),
|
|
191
|
-
];
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
const d1 = on(handle, 'mousedown', onMouseDown);
|
|
195
|
-
const d2 = on(handle, 'touchstart', onTouchStart);
|
|
196
|
-
this._disposers.push(d1, d2);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// ---------------------------------------------------------------------------
|
|
200
|
-
// Counter update
|
|
201
|
-
// ---------------------------------------------------------------------------
|
|
202
|
-
|
|
203
|
-
// Editor.afterCommand() already invokes 'statusbar.update' on every native
|
|
204
|
-
// 'input' event and after every toolbar/formatting command, so a separate
|
|
205
|
-
// content listener here would just re-run this on the same keystroke.
|
|
206
|
-
update() {
|
|
207
|
-
if (!this._wordCountEl || !this._charCountEl) return;
|
|
208
|
-
const editable = this.context.layoutInfo.editable;
|
|
209
|
-
// textContent is faster than innerText (no layout flush, no CSS visibility check)
|
|
210
|
-
const text = editable.textContent || '';
|
|
211
|
-
const words = _countWords(text);
|
|
212
|
-
const chars = text.replaceAll('\n', '').length;
|
|
213
|
-
const maxWords = this.options.maxWords || 0;
|
|
214
|
-
const maxChars = this.options.maxChars || 0;
|
|
215
|
-
|
|
216
|
-
const LS = this.context.locale.statusbar;
|
|
217
|
-
this._wordCountEl.textContent = maxWords
|
|
218
|
-
? LS.wordsLimit(words, maxWords)
|
|
219
|
-
: LS.words(words);
|
|
220
|
-
this._charCountEl.textContent = maxChars
|
|
221
|
-
? LS.charsLimit(chars, maxChars)
|
|
222
|
-
: LS.chars(chars);
|
|
223
|
-
|
|
224
|
-
// Apply warning / exceeded styles
|
|
225
|
-
_applyLimitClass(this._wordCountEl, words, maxWords);
|
|
226
|
-
_applyLimitClass(this._charCountEl, chars, maxChars);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Returns the current word count of the editor content.
|
|
231
|
-
* @returns {number}
|
|
232
|
-
*/
|
|
233
|
-
getWordCount() {
|
|
234
|
-
const editable = this.context.layoutInfo.editable;
|
|
235
|
-
return _countWords(editable.innerText || '');
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Returns the current character count (excluding newlines) of the editor content.
|
|
240
|
-
* @returns {number}
|
|
241
|
-
*/
|
|
242
|
-
getCharCount() {
|
|
243
|
-
const editable = this.context.layoutInfo.editable;
|
|
244
|
-
return (editable.innerText || '').replaceAll('\n', '').length;
|
|
245
|
-
}
|
|
246
|
-
}
|