autumnnote 1.14.0 → 1.16.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 +37 -10
- package/dist/autumnnote.cjs +61 -0
- package/dist/autumnnote.css +2 -2579
- package/dist/autumnnote.es.js +4934 -9981
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.min.js +61 -0
- package/dist/autumnnote.umd.js +39 -17545
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +24 -15
- package/src/js/Context.js +154 -15
- package/src/js/core/sanitise.js +47 -11
- package/src/js/editing/History.js +38 -6
- package/src/js/i18n/de.js +15 -0
- package/src/js/i18n/en.js +15 -0
- package/src/js/i18n/es.js +15 -0
- package/src/js/i18n/fr.js +15 -0
- package/src/js/i18n/ja.js +15 -0
- package/src/js/i18n/ko.js +15 -0
- package/src/js/i18n/vi.js +15 -0
- package/src/js/i18n/zh.js +15 -0
- package/src/js/index.js +13 -1
- package/src/js/module/Clipboard.js +25 -7
- package/src/js/module/Editor.js +25 -5
- package/src/js/module/ImageCropOverlay.js +5 -0
- package/src/js/module/ImageDialog.js +34 -6
- package/src/js/module/MarkdownShortcuts.js +2 -2
- package/src/js/module/SlashMenu.js +376 -0
- package/src/js/module/Statusbar.js +3 -9
- package/src/js/module/TableTooltip.js +2 -1
- package/src/js/module/VideoDialog.js +3 -13
- package/src/js/settings.js +30 -0
- package/src/styles/autumnnote.scss +49 -0
- package/types/core/dom.d.ts +32 -0
- package/types/core/env.d.ts +10 -0
- package/types/core/func.d.ts +11 -0
- package/types/core/key.d.ts +3 -0
- package/types/core/lists.d.ts +10 -0
- package/types/core/range.d.ts +22 -0
- package/types/core/sanitise.d.ts +9 -0
- package/types/index.d.ts +102 -8
- package/dist/_headers +0 -5
- package/dist/image/banner.png +0 -0
- package/dist/image/favicon.ico +0 -0
- package/dist/image/og-banner.png +0 -0
- package/dist/robots.txt +0 -4
- package/dist/sitemap.xml +0 -58
package/src/js/index.js
CHANGED
|
@@ -140,11 +140,23 @@ const AutumnNote = {
|
|
|
140
140
|
*/
|
|
141
141
|
registerButton(btnDef) { registerButton(btnDef); return this; },
|
|
142
142
|
|
|
143
|
+
/** Registers a slash-menu command for future editor instances. */
|
|
144
|
+
registerSlashCommand(command) {
|
|
145
|
+
if (!command?.id || typeof command.run !== 'function') {
|
|
146
|
+
throw new TypeError('[AutumnNote] Slash command requires an id and run(context) function.');
|
|
147
|
+
}
|
|
148
|
+
const commands = defaultOptions.slashCommands;
|
|
149
|
+
const index = commands.findIndex((item) => item.id === command.id);
|
|
150
|
+
if (index >= 0) commands[index] = command;
|
|
151
|
+
else commands.push(command);
|
|
152
|
+
return this;
|
|
153
|
+
},
|
|
154
|
+
|
|
143
155
|
/** All pre-built button definitions — accessible in every module format including UMD/CJS. */
|
|
144
156
|
buttons,
|
|
145
157
|
|
|
146
158
|
/** Library version */
|
|
147
|
-
version: '1.
|
|
159
|
+
version: '1.16.0',
|
|
148
160
|
};
|
|
149
161
|
|
|
150
162
|
// ---------------------------------------------------------------------------
|
|
@@ -371,7 +371,11 @@ export class Clipboard {
|
|
|
371
371
|
execCommand('insertHTML', html);
|
|
372
372
|
this.context.invoke('editor.afterCommand');
|
|
373
373
|
};
|
|
374
|
-
reader.onerror = () =>
|
|
374
|
+
reader.onerror = () => {
|
|
375
|
+
const message = `Failed to read dropped markdown file "${file.name}".`;
|
|
376
|
+
console.warn(`[AutumnNote] ${message}`);
|
|
377
|
+
this.context.triggerEvent('pasteError', { message });
|
|
378
|
+
};
|
|
375
379
|
reader.readAsText(file);
|
|
376
380
|
}
|
|
377
381
|
|
|
@@ -412,12 +416,7 @@ export class Clipboard {
|
|
|
412
416
|
}
|
|
413
417
|
|
|
414
418
|
const alt = file.name.replace(/\.[^.]+$/, '');
|
|
415
|
-
this.
|
|
416
|
-
// Keep the large data URL in a JS Map; insert a lightweight blob: URL
|
|
417
|
-
// into the DOM so editable.innerHTML never contains the big base64 string.
|
|
418
|
-
const blob = this._dataUrlToBlob(dataUrl);
|
|
419
|
-
const blobUrl = URL.createObjectURL(blob);
|
|
420
|
-
this._blobRegistry.set(blobUrl, dataUrl);
|
|
419
|
+
this.compressAndRegister(file).then((blobUrl) => {
|
|
421
420
|
this.context.invoke('editor.insertImage', blobUrl, alt);
|
|
422
421
|
}).catch((err) => {
|
|
423
422
|
const message = `Image "${file.name}" could not be processed.`;
|
|
@@ -427,6 +426,25 @@ export class Clipboard {
|
|
|
427
426
|
});
|
|
428
427
|
}
|
|
429
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Compresses an image File via canvas and registers the result behind a
|
|
431
|
+
* lightweight blob: URL (see `resolveImages`), so callers never have to hold
|
|
432
|
+
* the full base64 string in the DOM. Shared by paste/drop and ImageDialog's
|
|
433
|
+
* file picker so every image-insertion path gets the same compression.
|
|
434
|
+
* @param {File} file
|
|
435
|
+
* @returns {Promise<string>} blob: URL usable as an <img src>
|
|
436
|
+
*/
|
|
437
|
+
async compressAndRegister(file) {
|
|
438
|
+
const processor = this.options.imageProcessor;
|
|
439
|
+
const dataUrl = typeof processor === 'function'
|
|
440
|
+
? await processor(file, { context: this.context })
|
|
441
|
+
: await this._compressImage(file);
|
|
442
|
+
const blob = this._dataUrlToBlob(dataUrl);
|
|
443
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
444
|
+
this._blobRegistry.set(blobUrl, dataUrl);
|
|
445
|
+
return blobUrl;
|
|
446
|
+
}
|
|
447
|
+
|
|
430
448
|
/**
|
|
431
449
|
* Replaces any blob: URLs created by this module with their original data URLs.
|
|
432
450
|
* Called by Editor.getHTML() so the returned HTML is fully self-contained.
|
package/src/js/module/Editor.js
CHANGED
|
@@ -34,7 +34,11 @@ export class Editor {
|
|
|
34
34
|
|
|
35
35
|
initialize() {
|
|
36
36
|
const editable = this.context.layoutInfo.editable;
|
|
37
|
-
this._history = new History(
|
|
37
|
+
this._history = new History(
|
|
38
|
+
editable,
|
|
39
|
+
this.options.historyLimit || 100,
|
|
40
|
+
this.options.historyMaxBytes || 10 * 1024 * 1024,
|
|
41
|
+
);
|
|
38
42
|
this._bindEvents(editable);
|
|
39
43
|
return this;
|
|
40
44
|
}
|
|
@@ -497,8 +501,7 @@ export class Editor {
|
|
|
497
501
|
|
|
498
502
|
undo() {
|
|
499
503
|
if (this._history) {
|
|
500
|
-
|
|
501
|
-
this._snapshotTimer = null;
|
|
504
|
+
this._flushPendingSnapshot();
|
|
502
505
|
this._history.undo();
|
|
503
506
|
this.context.invoke('toolbar.refresh');
|
|
504
507
|
this.context.invoke('statusbar.update');
|
|
@@ -508,8 +511,7 @@ export class Editor {
|
|
|
508
511
|
|
|
509
512
|
redo() {
|
|
510
513
|
if (this._history) {
|
|
511
|
-
|
|
512
|
-
this._snapshotTimer = null;
|
|
514
|
+
this._flushPendingSnapshot();
|
|
513
515
|
this._history.redo();
|
|
514
516
|
this.context.invoke('toolbar.refresh');
|
|
515
517
|
this.context.invoke('statusbar.update');
|
|
@@ -517,6 +519,14 @@ export class Editor {
|
|
|
517
519
|
}
|
|
518
520
|
}
|
|
519
521
|
|
|
522
|
+
/** Records a debounced change before an immediate undo/redo command. */
|
|
523
|
+
_flushPendingSnapshot() {
|
|
524
|
+
if (this._snapshotTimer === null) return;
|
|
525
|
+
clearTimeout(this._snapshotTimer);
|
|
526
|
+
this._snapshotTimer = null;
|
|
527
|
+
this._history?.recordUndo();
|
|
528
|
+
}
|
|
529
|
+
|
|
520
530
|
canUndo() {
|
|
521
531
|
return this._history ? this._history.canUndo() : false;
|
|
522
532
|
}
|
|
@@ -533,6 +543,16 @@ export class Editor {
|
|
|
533
543
|
return this._history ? this._history.getRedoCount() : 0;
|
|
534
544
|
}
|
|
535
545
|
|
|
546
|
+
getSelectionBookmark() {
|
|
547
|
+
return this._history?._serializeSelection() ?? null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
restoreSelectionBookmark(bookmark) {
|
|
551
|
+
if (!bookmark || !this._history) return false;
|
|
552
|
+
this._history._restoreSelection(bookmark);
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
|
|
536
556
|
// ---------------------------------------------------------------------------
|
|
537
557
|
// Style commands (delegated to Style module)
|
|
538
558
|
// ---------------------------------------------------------------------------
|
|
@@ -444,6 +444,7 @@ export class ImageCropOverlay {
|
|
|
444
444
|
* @param {(e: MouseEvent) => void} onMove
|
|
445
445
|
*/
|
|
446
446
|
_attachDocDrag(onMove) {
|
|
447
|
+
this._dragDisposers?.();
|
|
447
448
|
const onTouchMove = (e) => {
|
|
448
449
|
e.preventDefault();
|
|
449
450
|
const te3 = /** @type {TouchEvent} */ (e); onMove(/** @type {MouseEvent} */ (/** @type {unknown} */ ({ clientX: te3.touches[0].clientX, clientY: te3.touches[0].clientY })));
|
|
@@ -455,7 +456,9 @@ export class ImageCropOverlay {
|
|
|
455
456
|
document.removeEventListener('touchend', cleanup);
|
|
456
457
|
document.body.style.userSelect = '';
|
|
457
458
|
document.body.style.cursor = '';
|
|
459
|
+
this._dragDisposers = null;
|
|
458
460
|
};
|
|
461
|
+
this._dragDisposers = cleanup;
|
|
459
462
|
document.body.style.userSelect = 'none';
|
|
460
463
|
document.addEventListener('mousemove', onMove);
|
|
461
464
|
document.addEventListener('mouseup', cleanup, { once: true });
|
|
@@ -561,6 +564,8 @@ export class ImageCropOverlay {
|
|
|
561
564
|
* @param {boolean} _committed - reserved for future use
|
|
562
565
|
*/
|
|
563
566
|
_close(_committed) {
|
|
567
|
+
this._dragDisposers?.();
|
|
568
|
+
this._dragDisposers = null;
|
|
564
569
|
this._disposers.forEach((d) => d());
|
|
565
570
|
this._disposers = [];
|
|
566
571
|
|
|
@@ -13,7 +13,11 @@ export class ImageDialog extends BaseDialog {
|
|
|
13
13
|
// Public API
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ beforeInsert?: () => void }} [hooks]
|
|
18
|
+
*/
|
|
19
|
+
show({ beforeInsert } = {}) {
|
|
20
|
+
this._beforeInsert = typeof beforeInsert === 'function' ? beforeInsert : null;
|
|
17
21
|
this._saveRange();
|
|
18
22
|
this._urlInput.value = '';
|
|
19
23
|
this._altInput.value = '';
|
|
@@ -137,11 +141,23 @@ export class ImageDialog extends BaseDialog {
|
|
|
137
141
|
return;
|
|
138
142
|
}
|
|
139
143
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
// Resize/compress via Clipboard's shared canvas pipeline so a file chosen
|
|
145
|
+
// through this dialog gets the same treatment as a pasted/dropped image
|
|
146
|
+
// instead of embedding the original (potentially multi-MB) file untouched.
|
|
147
|
+
this.context.invoke('clipboard.compressAndRegister', file).then((blobUrl) => {
|
|
148
|
+
if (this.context._alive === false) return;
|
|
149
|
+
this._urlInput.value = blobUrl;
|
|
150
|
+
}).catch((err) => {
|
|
151
|
+
if (this.context._alive === false) return;
|
|
152
|
+
// Compression failed (e.g. canvas unavailable) — fall back to embedding
|
|
153
|
+
// the original file directly.
|
|
154
|
+
console.warn('[AutumnNote] ImageDialog: image compression failed, using original file.', err);
|
|
155
|
+
const reader = new FileReader();
|
|
156
|
+
reader.onload = (e) => {
|
|
157
|
+
this._urlInput.value = /** @type {string} */ (e.target.result);
|
|
158
|
+
};
|
|
159
|
+
reader.readAsDataURL(file);
|
|
160
|
+
});
|
|
145
161
|
}
|
|
146
162
|
|
|
147
163
|
_onInsert() {
|
|
@@ -159,7 +175,19 @@ export class ImageDialog extends BaseDialog {
|
|
|
159
175
|
this._savedRange.select();
|
|
160
176
|
}
|
|
161
177
|
|
|
178
|
+
// Let callers make a deferred DOM change only after the user commits the
|
|
179
|
+
// dialog. SlashMenu uses this to keep "/image" intact when the dialog is
|
|
180
|
+
// cancelled and remove it atomically immediately before image insertion.
|
|
181
|
+
const beforeInsert = this._beforeInsert;
|
|
182
|
+
this._beforeInsert = null;
|
|
183
|
+
beforeInsert?.();
|
|
184
|
+
|
|
162
185
|
this.context.invoke('editor.insertImage', src, alt, align);
|
|
163
186
|
this._close();
|
|
164
187
|
}
|
|
188
|
+
|
|
189
|
+
_close() {
|
|
190
|
+
this._beforeInsert = null;
|
|
191
|
+
super._close();
|
|
192
|
+
}
|
|
165
193
|
}
|
|
@@ -167,13 +167,13 @@ export class MarkdownShortcuts {
|
|
|
167
167
|
|
|
168
168
|
_convertToChecklist() {
|
|
169
169
|
this._selectLineAndDelete();
|
|
170
|
-
this.context.invoke('editor.
|
|
170
|
+
this.context.invoke('editor.toggleChecklist');
|
|
171
171
|
this.context.triggerEvent('change', this.context.getHTML());
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
_convertToHr() {
|
|
175
175
|
this._selectLineAndDelete();
|
|
176
|
-
this.context.invoke('editor.
|
|
176
|
+
this.context.invoke('editor.insertHr');
|
|
177
177
|
this.context.triggerEvent('change', this.context.getHTML());
|
|
178
178
|
}
|
|
179
179
|
|
|
@@ -0,0 +1,376 @@
|
|
|
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
|
+
}
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { createElement, on } from '../core/dom.js';
|
|
7
|
-
import { debounce } from '../core/func.js';
|
|
8
7
|
|
|
9
8
|
// Cache the segmenter instance at module level to avoid per-call allocation
|
|
10
9
|
const _segmenter =
|
|
@@ -97,7 +96,6 @@ export class Statusbar {
|
|
|
97
96
|
info.appendChild(this._charCountEl);
|
|
98
97
|
this.el.appendChild(info);
|
|
99
98
|
|
|
100
|
-
this._bindContentEvents();
|
|
101
99
|
this.update();
|
|
102
100
|
return this;
|
|
103
101
|
}
|
|
@@ -202,13 +200,9 @@ export class Statusbar {
|
|
|
202
200
|
// Counter update
|
|
203
201
|
// ---------------------------------------------------------------------------
|
|
204
202
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const d = on(editable, 'input', /** @type {EventListener} */ (updateDebounced));
|
|
209
|
-
this._disposers.push(d);
|
|
210
|
-
}
|
|
211
|
-
|
|
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.
|
|
212
206
|
update() {
|
|
213
207
|
if (!this._wordCountEl || !this._charCountEl) return;
|
|
214
208
|
const editable = this.context.layoutInfo.editable;
|
|
@@ -239,6 +239,7 @@ export class TableTooltip {
|
|
|
239
239
|
!this._activeTable.contains(et) &&
|
|
240
240
|
!this._el.contains(et) &&
|
|
241
241
|
!this._sizePopover?.contains(et) &&
|
|
242
|
+
!this._shadePopover?.contains(et) &&
|
|
242
243
|
!this._borderColorPopover?.contains(et)) {
|
|
243
244
|
this._hide();
|
|
244
245
|
}
|
|
@@ -1058,7 +1059,7 @@ export class TableTooltip {
|
|
|
1058
1059
|
|
|
1059
1060
|
const d1 = on(applyBtn, 'click', () => {
|
|
1060
1061
|
const val = Number.parseInt(this._sizeInputEl.value, 10);
|
|
1061
|
-
if (val
|
|
1062
|
+
if (val >= 0 && typeof this._sizeApply === 'function') this._sizeApply(val);
|
|
1062
1063
|
this._hideSizePopover();
|
|
1063
1064
|
});
|
|
1064
1065
|
const d2 = on(cancelBtn, 'click', () => this._hideSizePopover());
|