autumnnote 1.0.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/LICENSE +21 -0
- package/README.md +874 -0
- package/dist/autumnnote.css +1 -0
- package/dist/autumnnote.es.js +5888 -0
- package/dist/autumnnote.es.js.map +1 -0
- package/dist/autumnnote.umd.js +74 -0
- package/dist/autumnnote.umd.js.map +1 -0
- package/package.json +55 -0
- package/src/js/Context.js +497 -0
- package/src/js/core/dom.js +315 -0
- package/src/js/core/env.js +25 -0
- package/src/js/core/func.js +153 -0
- package/src/js/core/key.js +66 -0
- package/src/js/core/lists.js +121 -0
- package/src/js/core/markdown.js +294 -0
- package/src/js/core/range.js +194 -0
- package/src/js/core/sanitise.js +78 -0
- package/src/js/editing/History.js +205 -0
- package/src/js/editing/Style.js +329 -0
- package/src/js/editing/Table.js +59 -0
- package/src/js/editing/Typing.js +142 -0
- package/src/js/index.js +126 -0
- package/src/js/module/Buttons.js +300 -0
- package/src/js/module/Clipboard.js +460 -0
- package/src/js/module/CodeTooltip.js +428 -0
- package/src/js/module/Codeview.js +122 -0
- package/src/js/module/ContextMenu.js +470 -0
- package/src/js/module/Editor.js +528 -0
- package/src/js/module/EmojiDialog.js +726 -0
- package/src/js/module/FindReplace.js +440 -0
- package/src/js/module/Fullscreen.js +80 -0
- package/src/js/module/IconDialog.js +620 -0
- package/src/js/module/ImageDialog.js +208 -0
- package/src/js/module/ImageResizer.js +216 -0
- package/src/js/module/ImageTooltip.js +286 -0
- package/src/js/module/LinkDialog.js +204 -0
- package/src/js/module/LinkTooltip.js +242 -0
- package/src/js/module/Placeholder.js +44 -0
- package/src/js/module/ShortcutsDialog.js +141 -0
- package/src/js/module/Statusbar.js +238 -0
- package/src/js/module/TableTooltip.js +568 -0
- package/src/js/module/Toolbar.js +562 -0
- package/src/js/module/VideoDialog.js +263 -0
- package/src/js/module/VideoResizer.js +227 -0
- package/src/js/module/VideoTooltip.js +252 -0
- package/src/js/renderer.js +107 -0
- package/src/js/settings.js +134 -0
- package/src/styles/_variables.scss +48 -0
- package/src/styles/autumnnote.scss +1740 -0
- package/types/index.d.ts +324 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// LinkTooltip.js - Hover tooltip for links inside the editor
|
|
2
|
+
// Displays a small action bar with: visit link, edit link, unlink
|
|
3
|
+
import { createElement, on } from '../core/dom.js';
|
|
4
|
+
|
|
5
|
+
const ICONS = {
|
|
6
|
+
open: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`,
|
|
7
|
+
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
|
|
8
|
+
unlink: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.84 12.25l1.72-1.71h-.02a5.004 5.004 0 0 0-.12-7.07 5.006 5.006 0 0 0-6.95 0l-1.72 1.71"/><path d="M5.17 11.75l-1.71 1.71a5.004 5.004 0 0 0 .12 7.07 5.006 5.006 0 0 0 6.95 0l1.71-1.71"/><line x1="8" y1="2" x2="8" y2="5"/><line x1="2" y1="8" x2="5" y2="8"/><line x1="16" y1="19" x2="16" y2="22"/><line x1="19" y1="16" x2="22" y2="16"/></svg>`,
|
|
9
|
+
copy: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Delay in ms before showing / after hiding to avoid flicker */
|
|
13
|
+
const SHOW_DELAY = 120;
|
|
14
|
+
const HIDE_DELAY = 200;
|
|
15
|
+
|
|
16
|
+
export class LinkTooltip {
|
|
17
|
+
/** @param {import('../Context.js').Context} context */
|
|
18
|
+
constructor(context) {
|
|
19
|
+
this.context = context;
|
|
20
|
+
this._el = null;
|
|
21
|
+
this._activeAnchor = null;
|
|
22
|
+
this._showTimer = null;
|
|
23
|
+
this._hideTimer = null;
|
|
24
|
+
this._disposers = [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
initialize() {
|
|
28
|
+
this._el = this._buildTooltip();
|
|
29
|
+
document.body.appendChild(this._el);
|
|
30
|
+
|
|
31
|
+
const editable = this.context.layoutInfo.editable;
|
|
32
|
+
|
|
33
|
+
this._disposers.push(
|
|
34
|
+
// Detect when pointer enters a link
|
|
35
|
+
on(editable, 'mouseover', (e) => {
|
|
36
|
+
const anchor = e.target.closest('a[href]');
|
|
37
|
+
if (anchor && editable.contains(anchor)) {
|
|
38
|
+
this._scheduleShow(anchor);
|
|
39
|
+
}
|
|
40
|
+
}),
|
|
41
|
+
// Detect when pointer leaves the editable area entirely
|
|
42
|
+
on(editable, 'mouseout', (e) => {
|
|
43
|
+
const to = e.relatedTarget;
|
|
44
|
+
if (!to || (!editable.contains(to) && !this._el.contains(to))) {
|
|
45
|
+
this._scheduleHide();
|
|
46
|
+
}
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
destroy() {
|
|
54
|
+
this._clearTimers();
|
|
55
|
+
this._disposers.forEach((d) => d());
|
|
56
|
+
this._disposers = [];
|
|
57
|
+
if (this._el && this._el.parentNode) this._el.parentNode.removeChild(this._el);
|
|
58
|
+
this._el = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Build
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
_buildTooltip() {
|
|
66
|
+
const el = createElement('div', { class: 'an-link-tooltip', role: 'toolbar', 'aria-label': 'Link actions' });
|
|
67
|
+
el.style.display = 'none';
|
|
68
|
+
|
|
69
|
+
// URL preview text (truncated)
|
|
70
|
+
this._urlLabel = createElement('span', { class: 'an-link-tooltip-url' });
|
|
71
|
+
el.appendChild(this._urlLabel);
|
|
72
|
+
|
|
73
|
+
// Separator
|
|
74
|
+
el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
|
|
75
|
+
|
|
76
|
+
// Action buttons
|
|
77
|
+
this._openBtn = this._makeBtn(ICONS.open, 'Open link', () => this._openLink());
|
|
78
|
+
this._copyBtn = this._makeBtn(ICONS.copy, 'Copy URL', () => this._copyLink());
|
|
79
|
+
this._editBtn = this._makeBtn(ICONS.edit, 'Edit link', () => this._editLink());
|
|
80
|
+
this._unlinkBtn = this._makeBtn(ICONS.unlink, 'Remove link', () => this._unlink());
|
|
81
|
+
|
|
82
|
+
el.appendChild(this._openBtn);
|
|
83
|
+
el.appendChild(this._copyBtn);
|
|
84
|
+
el.appendChild(this._editBtn);
|
|
85
|
+
el.appendChild(this._unlinkBtn);
|
|
86
|
+
|
|
87
|
+
// Keep tooltip alive while hovering it
|
|
88
|
+
this._disposers.push(
|
|
89
|
+
on(el, 'mouseenter', () => this._clearTimers()),
|
|
90
|
+
on(el, 'mouseleave', () => this._scheduleHide()),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return el;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_makeBtn(icon, title, handler) {
|
|
97
|
+
const btn = createElement('button', { type: 'button', class: 'an-link-tooltip-btn', title });
|
|
98
|
+
btn.innerHTML = icon;
|
|
99
|
+
this._disposers.push(on(btn, 'click', (e) => { e.preventDefault(); e.stopPropagation(); handler(); }));
|
|
100
|
+
return btn;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Show / Hide logic
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
_scheduleShow(anchor) {
|
|
108
|
+
if (this._activeAnchor === anchor && this._el.style.display !== 'none') return;
|
|
109
|
+
clearTimeout(this._hideTimer);
|
|
110
|
+
this._hideTimer = null;
|
|
111
|
+
clearTimeout(this._showTimer);
|
|
112
|
+
this._showTimer = setTimeout(() => {
|
|
113
|
+
this._activeAnchor = anchor;
|
|
114
|
+
this._show(anchor);
|
|
115
|
+
}, SHOW_DELAY);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_scheduleHide() {
|
|
119
|
+
clearTimeout(this._showTimer);
|
|
120
|
+
this._showTimer = null;
|
|
121
|
+
if (this._hideTimer) return;
|
|
122
|
+
this._hideTimer = setTimeout(() => {
|
|
123
|
+
this._hide();
|
|
124
|
+
}, HIDE_DELAY);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_show(anchor) {
|
|
128
|
+
const url = anchor.getAttribute('href') || '';
|
|
129
|
+
this._urlLabel.textContent = this._truncateUrl(url);
|
|
130
|
+
this._urlLabel.title = url;
|
|
131
|
+
this._el.style.display = 'flex';
|
|
132
|
+
this._positionNear(anchor);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
_hide() {
|
|
136
|
+
this._el.style.display = 'none';
|
|
137
|
+
this._activeAnchor = null;
|
|
138
|
+
this._clearTimers();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_clearTimers() {
|
|
142
|
+
clearTimeout(this._showTimer);
|
|
143
|
+
clearTimeout(this._hideTimer);
|
|
144
|
+
this._showTimer = null;
|
|
145
|
+
this._hideTimer = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_positionNear(anchor) {
|
|
149
|
+
const rect = anchor.getBoundingClientRect();
|
|
150
|
+
const tipW = this._el.offsetWidth || 260;
|
|
151
|
+
const tipH = this._el.offsetHeight || 34;
|
|
152
|
+
const margin = 6;
|
|
153
|
+
|
|
154
|
+
// Prefer below the link; if no room, put above
|
|
155
|
+
let top = rect.bottom + margin;
|
|
156
|
+
let left = rect.left;
|
|
157
|
+
|
|
158
|
+
if (top + tipH > window.innerHeight - margin) {
|
|
159
|
+
top = rect.top - tipH - margin;
|
|
160
|
+
}
|
|
161
|
+
if (left + tipW > window.innerWidth - margin) {
|
|
162
|
+
left = window.innerWidth - tipW - margin;
|
|
163
|
+
}
|
|
164
|
+
if (left < margin) left = margin;
|
|
165
|
+
|
|
166
|
+
this._el.style.top = `${top}px`;
|
|
167
|
+
this._el.style.left = `${left}px`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_truncateUrl(url) {
|
|
171
|
+
try {
|
|
172
|
+
const u = new URL(url);
|
|
173
|
+
const display = u.host + (u.pathname !== '/' ? u.pathname : '');
|
|
174
|
+
return display.length > 48 ? display.slice(0, 48) + '…' : display;
|
|
175
|
+
} catch {
|
|
176
|
+
return url.length > 48 ? url.slice(0, 48) + '…' : url;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Actions
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
_openLink() {
|
|
185
|
+
const url = this._activeAnchor && this._activeAnchor.getAttribute('href');
|
|
186
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer');
|
|
187
|
+
this._hide();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_copyLink() {
|
|
191
|
+
const url = this._activeAnchor && this._activeAnchor.getAttribute('href');
|
|
192
|
+
if (url) {
|
|
193
|
+
navigator.clipboard.writeText(url).catch(() => {
|
|
194
|
+
// Fallback for environments where clipboard API is unavailable
|
|
195
|
+
const ta = document.createElement('textarea');
|
|
196
|
+
ta.value = url;
|
|
197
|
+
ta.style.position = 'fixed';
|
|
198
|
+
ta.style.opacity = '0';
|
|
199
|
+
document.body.appendChild(ta);
|
|
200
|
+
ta.select();
|
|
201
|
+
document.execCommand('copy');
|
|
202
|
+
document.body.removeChild(ta);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// Brief visual feedback
|
|
206
|
+
if (this._copyBtn) {
|
|
207
|
+
this._copyBtn.classList.add('an-link-tooltip-btn--copied');
|
|
208
|
+
setTimeout(() => this._copyBtn && this._copyBtn.classList.remove('an-link-tooltip-btn--copied'), 1000);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_editLink() {
|
|
213
|
+
const anchor = this._activeAnchor;
|
|
214
|
+
if (!anchor) return;
|
|
215
|
+
this._hide();
|
|
216
|
+
|
|
217
|
+
// Place cursor inside the anchor, then open the link dialog (which pre-fills from selection)
|
|
218
|
+
const sel = window.getSelection();
|
|
219
|
+
const range = document.createRange();
|
|
220
|
+
range.selectNodeContents(anchor);
|
|
221
|
+
sel.removeAllRanges();
|
|
222
|
+
sel.addRange(range);
|
|
223
|
+
|
|
224
|
+
this.context.invoke('linkDialog.show');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_unlink() {
|
|
228
|
+
const anchor = this._activeAnchor;
|
|
229
|
+
if (!anchor) return;
|
|
230
|
+
this._hide();
|
|
231
|
+
|
|
232
|
+
// Select the anchor text, then remove the link
|
|
233
|
+
const sel = window.getSelection();
|
|
234
|
+
const range = document.createRange();
|
|
235
|
+
range.selectNode(anchor);
|
|
236
|
+
sel.removeAllRanges();
|
|
237
|
+
sel.addRange(range);
|
|
238
|
+
|
|
239
|
+
document.execCommand('unlink');
|
|
240
|
+
this.context.invoke('editor.afterCommand');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placeholder.js - Shows placeholder text when the editor is empty
|
|
3
|
+
* Inspired by Summernote's Placeholder module
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { on } from '../core/dom.js';
|
|
7
|
+
|
|
8
|
+
export class Placeholder {
|
|
9
|
+
/**
|
|
10
|
+
* @param {import('../Context.js').Context} context
|
|
11
|
+
*/
|
|
12
|
+
constructor(context) {
|
|
13
|
+
this.context = context;
|
|
14
|
+
this.options = context.options;
|
|
15
|
+
this._disposers = [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
initialize() {
|
|
19
|
+
const editable = this.context.layoutInfo.editable;
|
|
20
|
+
const placeholder = this.options.placeholder || '';
|
|
21
|
+
if (placeholder) {
|
|
22
|
+
editable.dataset.placeholder = placeholder;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const update = () => this._update();
|
|
26
|
+
const d1 = on(editable, 'input', update);
|
|
27
|
+
const d2 = on(editable, 'focus', update);
|
|
28
|
+
const d3 = on(editable, 'blur', update);
|
|
29
|
+
this._disposers.push(d1, d2, d3);
|
|
30
|
+
this._update();
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
destroy() {
|
|
35
|
+
this._disposers.forEach((d) => d());
|
|
36
|
+
this._disposers = [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_update() {
|
|
40
|
+
const editable = this.context.layoutInfo.editable;
|
|
41
|
+
const isEmpty = !editable.textContent.trim() && !editable.querySelector('img, table, hr');
|
|
42
|
+
editable.classList.toggle('an-placeholder', isEmpty);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShortcutsDialog.js - Modal dialog listing all keyboard shortcuts.
|
|
3
|
+
* Opened via the toolbar '?' button or Shift+? inside the editor.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createElement, on, trapFocus } from '../core/dom.js';
|
|
7
|
+
|
|
8
|
+
const SHORTCUTS = [
|
|
9
|
+
{
|
|
10
|
+
category: 'Text Formatting',
|
|
11
|
+
items: [
|
|
12
|
+
{ keys: 'Ctrl + B', action: 'Bold' },
|
|
13
|
+
{ keys: 'Ctrl + I', action: 'Italic' },
|
|
14
|
+
{ keys: 'Ctrl + U', action: 'Underline' },
|
|
15
|
+
{ keys: 'Ctrl + K', action: 'Insert / edit link' },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
category: 'History',
|
|
20
|
+
items: [
|
|
21
|
+
{ keys: 'Ctrl + Z', action: 'Undo' },
|
|
22
|
+
{ keys: 'Ctrl + Y / Ctrl + Shift + Z', action: 'Redo' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
category: 'Selection & Navigation',
|
|
27
|
+
items: [
|
|
28
|
+
{ keys: 'Ctrl + A', action: 'Select all content' },
|
|
29
|
+
{ keys: 'Tab', action: 'Indent list item / insert spaces' },
|
|
30
|
+
{ keys: 'Shift + Tab', action: 'Outdent list item' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
category: 'Clipboard',
|
|
35
|
+
items: [
|
|
36
|
+
{ keys: 'Ctrl + Shift + V', action: 'Paste as plain text' },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
category: 'Find & Replace',
|
|
41
|
+
items: [
|
|
42
|
+
{ keys: 'Ctrl + F', action: 'Find in document' },
|
|
43
|
+
{ keys: 'Ctrl + H', action: 'Find & Replace' },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
category: 'Editor',
|
|
48
|
+
items: [
|
|
49
|
+
{ keys: 'Shift + ?', action: 'Show this keyboard shortcuts dialog' },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export class ShortcutsDialog {
|
|
55
|
+
/** @param {import('../Context.js').Context} context */
|
|
56
|
+
constructor(context) {
|
|
57
|
+
this.context = context;
|
|
58
|
+
this._dialog = null;
|
|
59
|
+
this._closeBtn = null;
|
|
60
|
+
this._disposers = [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
initialize() {
|
|
64
|
+
this._dialog = this._buildDialog();
|
|
65
|
+
document.body.appendChild(this._dialog);
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
destroy() {
|
|
70
|
+
this._disposers.forEach((d) => d());
|
|
71
|
+
this._disposers = [];
|
|
72
|
+
if (this._dialog && this._dialog.parentNode) {
|
|
73
|
+
this._dialog.parentNode.removeChild(this._dialog);
|
|
74
|
+
}
|
|
75
|
+
this._dialog = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
show() {
|
|
79
|
+
if (this._dialog) {
|
|
80
|
+
this._dialog.style.display = 'flex';
|
|
81
|
+
this._removeTrap = trapFocus(this._dialog, () => this._close());
|
|
82
|
+
setTimeout(() => this._closeBtn && this._closeBtn.focus(), 50);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_close() {
|
|
87
|
+
if (this._dialog) this._dialog.style.display = 'none';
|
|
88
|
+
if (this._removeTrap) { this._removeTrap(); this._removeTrap = null; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_buildDialog() {
|
|
92
|
+
const overlay = createElement('div', {
|
|
93
|
+
class: 'an-dialog-overlay',
|
|
94
|
+
role: 'dialog',
|
|
95
|
+
'aria-modal': 'true',
|
|
96
|
+
'aria-label': 'Keyboard Shortcuts',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const box = createElement('div', { class: 'an-dialog-box an-shortcuts-box' });
|
|
100
|
+
|
|
101
|
+
// Title row with close button
|
|
102
|
+
const titleRow = createElement('div', { class: 'an-icon-title-row' });
|
|
103
|
+
const title = createElement('h3', { class: 'an-dialog-title' });
|
|
104
|
+
title.textContent = 'Keyboard Shortcuts';
|
|
105
|
+
const closeBtn = createElement('button', {
|
|
106
|
+
type: 'button',
|
|
107
|
+
class: 'an-icon-close',
|
|
108
|
+
'aria-label': 'Close',
|
|
109
|
+
});
|
|
110
|
+
closeBtn.textContent = '×';
|
|
111
|
+
this._closeBtn = closeBtn;
|
|
112
|
+
titleRow.append(title, closeBtn);
|
|
113
|
+
box.appendChild(titleRow);
|
|
114
|
+
|
|
115
|
+
SHORTCUTS.forEach(({ category, items }) => {
|
|
116
|
+
const catEl = createElement('div', { class: 'an-shortcuts-cat' });
|
|
117
|
+
catEl.textContent = category;
|
|
118
|
+
box.appendChild(catEl);
|
|
119
|
+
|
|
120
|
+
const table = createElement('div', { class: 'an-shortcuts-table' });
|
|
121
|
+
items.forEach(({ keys, action }) => {
|
|
122
|
+
const row = createElement('div', { class: 'an-shortcuts-row' });
|
|
123
|
+
const keyEl = createElement('span', { class: 'an-shortcuts-key' });
|
|
124
|
+
keyEl.textContent = keys;
|
|
125
|
+
const actEl = createElement('span', { class: 'an-shortcuts-action' });
|
|
126
|
+
actEl.textContent = action;
|
|
127
|
+
row.append(keyEl, actEl);
|
|
128
|
+
table.appendChild(row);
|
|
129
|
+
});
|
|
130
|
+
box.appendChild(table);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
overlay.appendChild(box);
|
|
134
|
+
|
|
135
|
+
const d1 = on(closeBtn, 'click', () => this._close());
|
|
136
|
+
const d2 = on(overlay, 'click', (e) => { if (e.target === overlay) this._close(); });
|
|
137
|
+
this._disposers.push(d1, d2);
|
|
138
|
+
|
|
139
|
+
return overlay;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
import { debounce } from '../core/func.js';
|
|
8
|
+
|
|
9
|
+
// Cache the segmenter instance at module level to avoid per-call allocation
|
|
10
|
+
const _segmenter =
|
|
11
|
+
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
|
12
|
+
? new Intl.Segmenter(undefined, { granularity: 'word' })
|
|
13
|
+
: null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Count words in a string, CJK-aware.
|
|
17
|
+
* Uses Intl.Segmenter (Chromium 87+, Firefox 125+, Safari 17+) when available,
|
|
18
|
+
* falling back to a simple whitespace split for older environments.
|
|
19
|
+
* @param {string} text
|
|
20
|
+
* @returns {number}
|
|
21
|
+
*/
|
|
22
|
+
function _countWords(text) {
|
|
23
|
+
const trimmed = text.trim();
|
|
24
|
+
if (!trimmed) return 0;
|
|
25
|
+
if (_segmenter) {
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const seg of _segmenter.segment(trimmed)) {
|
|
28
|
+
if (seg.isWordLike) count++;
|
|
29
|
+
}
|
|
30
|
+
return count;
|
|
31
|
+
}
|
|
32
|
+
// Fallback: split on whitespace
|
|
33
|
+
return trimmed.split(/\s+/).length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Toggles warning/exceeded CSS classes on a count element.
|
|
38
|
+
* @param {HTMLElement} el
|
|
39
|
+
* @param {number} current
|
|
40
|
+
* @param {number} limit 0 = no limit
|
|
41
|
+
*/
|
|
42
|
+
function _applyLimitClass(el, current, limit) {
|
|
43
|
+
if (!limit) {
|
|
44
|
+
el.classList.remove('an-count-warn', 'an-count-exceeded');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (current > limit) {
|
|
48
|
+
el.classList.add('an-count-exceeded');
|
|
49
|
+
el.classList.remove('an-count-warn');
|
|
50
|
+
} else if (current >= limit * 0.9) {
|
|
51
|
+
el.classList.add('an-count-warn');
|
|
52
|
+
el.classList.remove('an-count-exceeded');
|
|
53
|
+
} else {
|
|
54
|
+
el.classList.remove('an-count-warn', 'an-count-exceeded');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class Statusbar {
|
|
59
|
+
/**
|
|
60
|
+
* @param {import('../Context.js').Context} context
|
|
61
|
+
*/
|
|
62
|
+
constructor(context) {
|
|
63
|
+
this.context = context;
|
|
64
|
+
this.options = context.options;
|
|
65
|
+
/** @type {HTMLElement|null} */
|
|
66
|
+
this.el = null;
|
|
67
|
+
this._disposers = [];
|
|
68
|
+
/** @type {HTMLElement|null} */
|
|
69
|
+
this._wordCountEl = null;
|
|
70
|
+
/** @type {HTMLElement|null} */
|
|
71
|
+
this._charCountEl = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Lifecycle
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
initialize() {
|
|
79
|
+
this.el = createElement('div', { class: 'an-statusbar' });
|
|
80
|
+
|
|
81
|
+
// Resize handle
|
|
82
|
+
if (this.options.resizable !== false) {
|
|
83
|
+
const handle = createElement('div', {
|
|
84
|
+
class: 'an-resize-handle',
|
|
85
|
+
title: 'Resize editor',
|
|
86
|
+
'aria-hidden': 'true',
|
|
87
|
+
});
|
|
88
|
+
this._bindResize(handle);
|
|
89
|
+
this.el.appendChild(handle);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Counters
|
|
93
|
+
this._wordCountEl = createElement('span', { class: 'an-word-count' });
|
|
94
|
+
this._charCountEl = createElement('span', { class: 'an-char-count' });
|
|
95
|
+
const info = createElement('div', { class: 'an-status-info' });
|
|
96
|
+
info.appendChild(this._wordCountEl);
|
|
97
|
+
info.appendChild(this._charCountEl);
|
|
98
|
+
this.el.appendChild(info);
|
|
99
|
+
|
|
100
|
+
this._bindContentEvents();
|
|
101
|
+
this.update();
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
destroy() {
|
|
106
|
+
this._disposers.forEach((d) => d());
|
|
107
|
+
this._disposers = [];
|
|
108
|
+
if (this._dragDisposers) {
|
|
109
|
+
this._dragDisposers.forEach((d) => d());
|
|
110
|
+
this._dragDisposers = null;
|
|
111
|
+
}
|
|
112
|
+
if (this.el && this.el.parentNode) {
|
|
113
|
+
this.el.parentNode.removeChild(this.el);
|
|
114
|
+
}
|
|
115
|
+
this.el = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Resize logic
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
_bindResize(handle) {
|
|
123
|
+
let startY = 0;
|
|
124
|
+
let startH = 0;
|
|
125
|
+
// Resize the container (flex column parent) so that the editable — which
|
|
126
|
+
// has flex:1 / flex-basis:0 — automatically fills the remaining space.
|
|
127
|
+
// Setting height directly on a flex:1 item has no effect because the flex
|
|
128
|
+
// algorithm ignores the height property when flex-basis is non-auto.
|
|
129
|
+
const containerEl = this.context.layoutInfo.container;
|
|
130
|
+
|
|
131
|
+
const applyDelta = (clientY) => {
|
|
132
|
+
const delta = clientY - startY;
|
|
133
|
+
const minH = this.options.minHeight || 100;
|
|
134
|
+
containerEl.style.height = `${Math.max(minH, startH + delta)}px`;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Mouse drag
|
|
138
|
+
const onMouseMove = (event) => applyDelta(event.clientY);
|
|
139
|
+
|
|
140
|
+
const onMouseUp = () => {
|
|
141
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
142
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
143
|
+
this._dragDisposers = null;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const onMouseDown = (event) => {
|
|
147
|
+
startY = event.clientY;
|
|
148
|
+
startH = containerEl.offsetHeight;
|
|
149
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
150
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
151
|
+
// Track drag-phase listeners so destroy() can remove them mid-drag
|
|
152
|
+
this._dragDisposers = [
|
|
153
|
+
() => document.removeEventListener('mousemove', onMouseMove),
|
|
154
|
+
() => document.removeEventListener('mouseup', onMouseUp),
|
|
155
|
+
];
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Touch drag
|
|
160
|
+
const onTouchMove = (event) => {
|
|
161
|
+
const touch = event.touches[0];
|
|
162
|
+
if (touch) { event.preventDefault(); applyDelta(touch.clientY); }
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const onTouchEnd = () => {
|
|
166
|
+
document.removeEventListener('touchmove', onTouchMove);
|
|
167
|
+
document.removeEventListener('touchend', onTouchEnd);
|
|
168
|
+
this._dragDisposers = null;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const onTouchStart = (event) => {
|
|
172
|
+
const touch = event.touches[0];
|
|
173
|
+
if (!touch) return;
|
|
174
|
+
startY = touch.clientY;
|
|
175
|
+
startH = containerEl.offsetHeight;
|
|
176
|
+
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
177
|
+
document.addEventListener('touchend', onTouchEnd);
|
|
178
|
+
this._dragDisposers = [
|
|
179
|
+
() => document.removeEventListener('touchmove', onTouchMove),
|
|
180
|
+
() => document.removeEventListener('touchend', onTouchEnd),
|
|
181
|
+
];
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const d1 = on(handle, 'mousedown', onMouseDown);
|
|
185
|
+
const d2 = on(handle, 'touchstart', onTouchStart);
|
|
186
|
+
this._disposers.push(d1, d2);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Counter update
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
_bindContentEvents() {
|
|
194
|
+
const editable = this.context.layoutInfo.editable;
|
|
195
|
+
const updateDebounced = debounce(() => this.update(), 200);
|
|
196
|
+
const d = on(editable, 'input', updateDebounced);
|
|
197
|
+
this._disposers.push(d);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
update() {
|
|
201
|
+
if (!this._wordCountEl || !this._charCountEl) return;
|
|
202
|
+
const editable = this.context.layoutInfo.editable;
|
|
203
|
+
const text = editable.innerText || '';
|
|
204
|
+
const words = _countWords(text);
|
|
205
|
+
const chars = text.replace(/\n/g, '').length;
|
|
206
|
+
const maxWords = this.options.maxWords || 0;
|
|
207
|
+
const maxChars = this.options.maxChars || 0;
|
|
208
|
+
|
|
209
|
+
this._wordCountEl.textContent = maxWords
|
|
210
|
+
? `Words: ${words}/${maxWords}`
|
|
211
|
+
: `Words: ${words}`;
|
|
212
|
+
this._charCountEl.textContent = maxChars
|
|
213
|
+
? `Chars: ${chars}/${maxChars}`
|
|
214
|
+
: `Chars: ${chars}`;
|
|
215
|
+
|
|
216
|
+
// Apply warning / exceeded styles
|
|
217
|
+
_applyLimitClass(this._wordCountEl, words, maxWords);
|
|
218
|
+
_applyLimitClass(this._charCountEl, chars, maxChars);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Returns the current word count of the editor content.
|
|
223
|
+
* @returns {number}
|
|
224
|
+
*/
|
|
225
|
+
getWordCount() {
|
|
226
|
+
const editable = this.context.layoutInfo.editable;
|
|
227
|
+
return _countWords(editable.innerText || '');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Returns the current character count (excluding newlines) of the editor content.
|
|
232
|
+
* @returns {number}
|
|
233
|
+
*/
|
|
234
|
+
getCharCount() {
|
|
235
|
+
const editable = this.context.layoutInfo.editable;
|
|
236
|
+
return ((editable.innerText || '').replace(/\n/g, '')).length;
|
|
237
|
+
}
|
|
238
|
+
}
|