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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +874 -0
  3. package/dist/autumnnote.css +1 -0
  4. package/dist/autumnnote.es.js +5888 -0
  5. package/dist/autumnnote.es.js.map +1 -0
  6. package/dist/autumnnote.umd.js +74 -0
  7. package/dist/autumnnote.umd.js.map +1 -0
  8. package/package.json +55 -0
  9. package/src/js/Context.js +497 -0
  10. package/src/js/core/dom.js +315 -0
  11. package/src/js/core/env.js +25 -0
  12. package/src/js/core/func.js +153 -0
  13. package/src/js/core/key.js +66 -0
  14. package/src/js/core/lists.js +121 -0
  15. package/src/js/core/markdown.js +294 -0
  16. package/src/js/core/range.js +194 -0
  17. package/src/js/core/sanitise.js +78 -0
  18. package/src/js/editing/History.js +205 -0
  19. package/src/js/editing/Style.js +329 -0
  20. package/src/js/editing/Table.js +59 -0
  21. package/src/js/editing/Typing.js +142 -0
  22. package/src/js/index.js +126 -0
  23. package/src/js/module/Buttons.js +300 -0
  24. package/src/js/module/Clipboard.js +460 -0
  25. package/src/js/module/CodeTooltip.js +428 -0
  26. package/src/js/module/Codeview.js +122 -0
  27. package/src/js/module/ContextMenu.js +470 -0
  28. package/src/js/module/Editor.js +528 -0
  29. package/src/js/module/EmojiDialog.js +726 -0
  30. package/src/js/module/FindReplace.js +440 -0
  31. package/src/js/module/Fullscreen.js +80 -0
  32. package/src/js/module/IconDialog.js +620 -0
  33. package/src/js/module/ImageDialog.js +208 -0
  34. package/src/js/module/ImageResizer.js +216 -0
  35. package/src/js/module/ImageTooltip.js +286 -0
  36. package/src/js/module/LinkDialog.js +204 -0
  37. package/src/js/module/LinkTooltip.js +242 -0
  38. package/src/js/module/Placeholder.js +44 -0
  39. package/src/js/module/ShortcutsDialog.js +141 -0
  40. package/src/js/module/Statusbar.js +238 -0
  41. package/src/js/module/TableTooltip.js +568 -0
  42. package/src/js/module/Toolbar.js +562 -0
  43. package/src/js/module/VideoDialog.js +263 -0
  44. package/src/js/module/VideoResizer.js +227 -0
  45. package/src/js/module/VideoTooltip.js +252 -0
  46. package/src/js/renderer.js +107 -0
  47. package/src/js/settings.js +134 -0
  48. package/src/styles/_variables.scss +48 -0
  49. package/src/styles/autumnnote.scss +1740 -0
  50. 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
+ }