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,440 @@
1
+ /**
2
+ * FindReplace.js - Find & Replace dialog
3
+ *
4
+ * Opens via Ctrl+F (find only) or Ctrl+H (find + replace).
5
+ * Uses TreeWalker to locate text matches and wraps them with <mark> elements
6
+ * for highlighting. Supports case-sensitive search, Prev/Next navigation and
7
+ * single / replace-all replacement.
8
+ */
9
+
10
+ import { createElement, on, trapFocus } from '../core/dom.js';
11
+
12
+ export class FindReplace {
13
+ /** @param {import('../Context.js').Context} context */
14
+ constructor(context) {
15
+ this.context = context;
16
+
17
+ /** @type {HTMLElement|null} */
18
+ this._dialog = null;
19
+ /** @type {HTMLInputElement|null} */
20
+ this._findInput = null;
21
+ /** @type {HTMLInputElement|null} */
22
+ this._replaceInput = null;
23
+ /** @type {HTMLInputElement|null} */
24
+ this._caseCheckbox = null;
25
+ /** @type {HTMLElement|null} */
26
+ this._counterEl = null;
27
+ /** @type {HTMLElement|null} */
28
+ this._closeBtn = null;
29
+
30
+ /** Live matches — each entry is { mark: HTMLElement } after highlighting */
31
+ this._matches = [];
32
+ this._currentIndex = -1;
33
+ this._caseSensitive = false;
34
+ /** @type {'find'|'replace'} */
35
+ this._mode = 'find';
36
+
37
+ this._disposers = [];
38
+ this._removeTrap = null;
39
+ this._focusTimer = null;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Lifecycle
44
+ // ---------------------------------------------------------------------------
45
+
46
+ initialize() {
47
+ this._dialog = this._buildDialog();
48
+ document.body.appendChild(this._dialog);
49
+ return this;
50
+ }
51
+
52
+ destroy() {
53
+ clearTimeout(this._focusTimer);
54
+ this._focusTimer = null;
55
+ this._clearHighlights();
56
+ this._disposers.forEach((d) => d());
57
+ this._disposers = [];
58
+ if (this._dialog && this._dialog.parentNode) {
59
+ this._dialog.parentNode.removeChild(this._dialog);
60
+ }
61
+ this._dialog = null;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Public API
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Opens the dialog in 'find' or 'replace' mode.
70
+ * @param {'find'|'replace'} [mode='find']
71
+ */
72
+ show(mode = 'find') {
73
+ this._mode = mode;
74
+ this._updateMode();
75
+ this._open();
76
+ // Pre-select whatever was previously typed so the user can retype immediately
77
+ clearTimeout(this._focusTimer);
78
+ this._focusTimer = setTimeout(() => {
79
+ if (this._findInput) {
80
+ this._findInput.select();
81
+ this._findInput.focus();
82
+ }
83
+ }, 50);
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Open / close
88
+ // ---------------------------------------------------------------------------
89
+
90
+ _open() {
91
+ if (!this._dialog) return;
92
+ // If already visible, just focus the search input — don't re-trap.
93
+ if (this._dialog.style.display === 'flex') {
94
+ if (this._findInput) this._findInput.focus();
95
+ return;
96
+ }
97
+ this._dialog.style.display = 'flex';
98
+ // Release any previous trap before installing a new one.
99
+ if (this._removeTrap) { this._removeTrap(); this._removeTrap = null; }
100
+ this._removeTrap = trapFocus(this._dialog, () => this._close());
101
+ }
102
+
103
+ _close() {
104
+ this._clearHighlights();
105
+ if (this._dialog) this._dialog.style.display = 'none';
106
+ if (this._removeTrap) { this._removeTrap(); this._removeTrap = null; }
107
+ // Return focus to the editor
108
+ this.context.invoke('editor.focus');
109
+ }
110
+
111
+ _updateMode() {
112
+ if (!this._dialog) return;
113
+ const replaceRow = this._dialog.querySelector('.an-fr-replace-row');
114
+ const replaceActions = this._dialog.querySelector('.an-fr-replace-actions');
115
+ const title = this._dialog.querySelector('.an-dialog-title');
116
+
117
+ const isReplace = this._mode === 'replace';
118
+ if (replaceRow) replaceRow.style.display = isReplace ? '' : 'none';
119
+ if (replaceActions) replaceActions.style.display = isReplace ? '' : 'none';
120
+ if (title) title.textContent = isReplace ? 'Find & Replace' : 'Find';
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Build dialog
125
+ // ---------------------------------------------------------------------------
126
+
127
+ _buildDialog() {
128
+ const overlay = createElement('div', {
129
+ class: 'an-dialog-overlay an-fr-dialog',
130
+ role: 'dialog',
131
+ 'aria-modal': 'true',
132
+ 'aria-label': 'Find and Replace',
133
+ });
134
+ const box = createElement('div', { class: 'an-dialog-box' });
135
+
136
+ // ---- Title row ----
137
+ const titleRow = createElement('div', { class: 'an-icon-title-row' });
138
+ const title = createElement('h3', { class: 'an-dialog-title' });
139
+ title.textContent = 'Find';
140
+ const closeBtn = createElement('button', {
141
+ type: 'button',
142
+ class: 'an-icon-close',
143
+ 'aria-label': 'Close',
144
+ });
145
+ closeBtn.textContent = '×';
146
+ this._closeBtn = closeBtn;
147
+ titleRow.append(title, closeBtn);
148
+ box.appendChild(titleRow);
149
+
150
+ // ---- Find row ----
151
+ const findRow = createElement('div', { class: 'an-fr-find-row' });
152
+ const findInput = createElement('input', {
153
+ type: 'text',
154
+ class: 'an-input',
155
+ placeholder: 'Find…',
156
+ 'aria-label': 'Search text',
157
+ });
158
+ this._findInput = findInput;
159
+ findRow.appendChild(findInput);
160
+ box.appendChild(findRow);
161
+
162
+ // ---- Options row (case-sensitive toggle + match counter) ----
163
+ const optRow = createElement('div', { class: 'an-fr-options-row' });
164
+ const caseLabel = createElement('label', { class: 'an-label an-label-inline' });
165
+ const caseCheckbox = createElement('input', {
166
+ type: 'checkbox',
167
+ 'aria-label': 'Case sensitive',
168
+ });
169
+ this._caseCheckbox = caseCheckbox;
170
+ caseLabel.append(caseCheckbox, document.createTextNode('\u00a0Case sensitive'));
171
+ const counter = createElement('span', { class: 'an-fr-counter' });
172
+ this._counterEl = counter;
173
+ optRow.append(caseLabel, counter);
174
+ box.appendChild(optRow);
175
+
176
+ // ---- Find actions ----
177
+ const findActions = createElement('div', { class: 'an-dialog-actions an-fr-find-actions' });
178
+ const prevBtn = createElement('button', { type: 'button', class: 'an-btn' });
179
+ prevBtn.textContent = '\u2190 Prev';
180
+ const nextBtn = createElement('button', { type: 'button', class: 'an-btn an-btn-primary' });
181
+ nextBtn.textContent = 'Next \u2192';
182
+ findActions.append(prevBtn, nextBtn);
183
+ box.appendChild(findActions);
184
+
185
+ // ---- Replace row (hidden by default) ----
186
+ const replaceRow = createElement('div', { class: 'an-fr-replace-row' });
187
+ replaceRow.style.display = 'none';
188
+ const replaceInput = createElement('input', {
189
+ type: 'text',
190
+ class: 'an-input',
191
+ placeholder: 'Replace with\u2026',
192
+ 'aria-label': 'Replace with',
193
+ });
194
+ this._replaceInput = replaceInput;
195
+ replaceRow.appendChild(replaceInput);
196
+ box.appendChild(replaceRow);
197
+
198
+ // ---- Replace actions (hidden by default) ----
199
+ const replaceActions = createElement('div', { class: 'an-dialog-actions an-fr-replace-actions' });
200
+ replaceActions.style.display = 'none';
201
+ const replaceBtn = createElement('button', { type: 'button', class: 'an-btn' });
202
+ replaceBtn.textContent = 'Replace';
203
+ const replaceAllBtn = createElement('button', { type: 'button', class: 'an-btn an-btn-primary' });
204
+ replaceAllBtn.textContent = 'Replace All';
205
+ replaceActions.append(replaceBtn, replaceAllBtn);
206
+ box.appendChild(replaceActions);
207
+
208
+ overlay.appendChild(box);
209
+
210
+ // ---- Event bindings ----
211
+ const d1 = on(closeBtn, 'click', () => this._close());
212
+ const d2 = on(overlay, 'click', (e) => { if (e.target === overlay) this._close(); });
213
+ const d3 = on(findInput, 'input', () => this._onSearch());
214
+ const d4 = on(caseCheckbox, 'change', () => {
215
+ this._caseSensitive = caseCheckbox.checked;
216
+ this._onSearch();
217
+ });
218
+ const d5 = on(nextBtn, 'click', () => this._next());
219
+ const d6 = on(prevBtn, 'click', () => this._prev());
220
+ const d7 = on(replaceBtn, 'click', () => this._replace());
221
+ const d8 = on(replaceAllBtn, 'click', () => this._replaceAll());
222
+ const d9 = on(findInput, 'keydown', (e) => {
223
+ if (e.key === 'Enter') {
224
+ e.preventDefault();
225
+ e.shiftKey ? this._prev() : this._next();
226
+ }
227
+ });
228
+ const d10 = on(replaceInput, 'keydown', (e) => {
229
+ if (e.key === 'Enter') {
230
+ e.preventDefault();
231
+ this._replace();
232
+ }
233
+ });
234
+ this._disposers.push(d1, d2, d3, d4, d5, d6, d7, d8, d9, d10);
235
+
236
+ return overlay;
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Search logic
241
+ // ---------------------------------------------------------------------------
242
+
243
+ _onSearch() {
244
+ this._clearHighlights();
245
+ const query = this._findInput ? this._findInput.value : '';
246
+ if (!query) {
247
+ this._updateCounter();
248
+ return;
249
+ }
250
+ this._findAndHighlight(query);
251
+ this._updateCounter();
252
+ }
253
+
254
+ /**
255
+ * Finds all occurrences of `query` in the editable area text nodes,
256
+ * then wraps each match in a <mark class="an-highlight"> element.
257
+ * Iterates text nodes in reverse so earlier offsets remain valid.
258
+ * @param {string} query
259
+ */
260
+ _findAndHighlight(query) {
261
+ const editable = this.context.layoutInfo.editable;
262
+ if (!editable) return;
263
+
264
+ // Collect raw matches: { node, start, end }
265
+ const rawMatches = this._findRawMatches(query, editable);
266
+ if (rawMatches.length === 0) return;
267
+
268
+ this._currentIndex = 0;
269
+
270
+ // Wrap in reverse order so earlier offsets in the same text node stay valid
271
+ for (let i = rawMatches.length - 1; i >= 0; i--) {
272
+ const { node, start, end } = rawMatches[i];
273
+ try {
274
+ const range = document.createRange();
275
+ range.setStart(node, start);
276
+ range.setEnd(node, end);
277
+ const mark = document.createElement('mark');
278
+ mark.className = 'an-highlight';
279
+ range.surroundContents(mark);
280
+ this._matches.unshift({ mark });
281
+ } catch (_) {
282
+ // surroundContents fails when the range crosses element boundaries.
283
+ // This can happen with <br> inside matched text — skip safely.
284
+ this._matches.unshift({ mark: null });
285
+ }
286
+ }
287
+
288
+ // Drop entries where wrapping failed so the counter and navigation are accurate
289
+ this._matches = this._matches.filter((m) => m.mark);
290
+ if (this._matches.length === 0) return;
291
+
292
+ // Highlight the first (current) match
293
+ if (this._matches[0] && this._matches[0].mark) {
294
+ this._matches[0].mark.className = 'an-highlight an-highlight-current';
295
+ this._matches[0].mark.scrollIntoView({ block: 'center', behavior: 'smooth' });
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Walks all text nodes under `root` and returns positional match descriptors.
301
+ * @param {string} query
302
+ * @param {HTMLElement} root
303
+ * @returns {{ node: Text, start: number, end: number }[]}
304
+ */
305
+ _findRawMatches(query, root) {
306
+ const results = [];
307
+ const flags = this._caseSensitive ? 'g' : 'gi';
308
+ // Escape regex special characters in the literal query string
309
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
310
+ const re = new RegExp(escaped, flags);
311
+
312
+ const walker = document.createTreeWalker(root, 0x4 /* NodeFilter.SHOW_TEXT */);
313
+ let node;
314
+ while ((node = walker.nextNode())) {
315
+ re.lastIndex = 0;
316
+ let m;
317
+ while ((m = re.exec(node.textContent)) !== null) {
318
+ results.push({ node, start: m.index, end: m.index + m[0].length });
319
+ }
320
+ }
321
+ return results;
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Navigation
326
+ // ---------------------------------------------------------------------------
327
+
328
+ _next() {
329
+ if (this._matches.length === 0) return;
330
+ this._currentIndex = (this._currentIndex + 1) % this._matches.length;
331
+ this._scrollToMatch(this._currentIndex);
332
+ this._updateCounter();
333
+ }
334
+
335
+ _prev() {
336
+ if (this._matches.length === 0) return;
337
+ this._currentIndex = (this._currentIndex - 1 + this._matches.length) % this._matches.length;
338
+ this._scrollToMatch(this._currentIndex);
339
+ this._updateCounter();
340
+ }
341
+
342
+ _scrollToMatch(index) {
343
+ const match = this._matches[index];
344
+ if (!match || !match.mark) return;
345
+ // Update CSS classes
346
+ this._matches.forEach((m, i) => {
347
+ if (m.mark) {
348
+ m.mark.className = i === index
349
+ ? 'an-highlight an-highlight-current'
350
+ : 'an-highlight';
351
+ }
352
+ });
353
+ match.mark.scrollIntoView({ block: 'center', behavior: 'smooth' });
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Replace
358
+ // ---------------------------------------------------------------------------
359
+
360
+ _replace() {
361
+ if (this._matches.length === 0 || this._currentIndex < 0) return;
362
+ const match = this._matches[this._currentIndex];
363
+ if (!match || !match.mark || !match.mark.parentNode) return;
364
+
365
+ const replacement = this._replaceInput ? this._replaceInput.value : '';
366
+ const parent = match.mark.parentNode;
367
+ const textNode = document.createTextNode(replacement);
368
+ parent.insertBefore(textNode, match.mark);
369
+ parent.removeChild(match.mark);
370
+ parent.normalize();
371
+ this.context.invoke('editor.afterCommand');
372
+ const savedIndex = this._currentIndex;
373
+ this._onSearch();
374
+ if (this._matches.length > 0) {
375
+ this._currentIndex = Math.min(savedIndex, this._matches.length - 1);
376
+ this._scrollToMatch(this._currentIndex);
377
+ this._updateCounter();
378
+ }
379
+ }
380
+
381
+ _replaceAll() {
382
+ if (this._matches.length === 0) return;
383
+ const replacement = this._replaceInput ? this._replaceInput.value : '';
384
+
385
+ this._matches.forEach(({ mark }) => {
386
+ if (!mark || !mark.parentNode) return;
387
+ const textNode = document.createTextNode(replacement);
388
+ mark.parentNode.insertBefore(textNode, mark);
389
+ mark.parentNode.removeChild(mark);
390
+ });
391
+
392
+ if (this.context.layoutInfo.editable) {
393
+ this.context.layoutInfo.editable.normalize();
394
+ }
395
+ this._matches = [];
396
+ this._currentIndex = -1;
397
+ this.context.invoke('editor.afterCommand');
398
+ this._onSearch();
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Highlight management
403
+ // ---------------------------------------------------------------------------
404
+
405
+ /**
406
+ * Removes all <mark class="an-highlight"> elements from the editable area,
407
+ * restoring the original text nodes via normalization.
408
+ */
409
+ _clearHighlights() {
410
+ const editable = this.context.layoutInfo.editable;
411
+ if (!editable) return;
412
+
413
+ editable.querySelectorAll('mark.an-highlight').forEach((mark) => {
414
+ const parent = mark.parentNode;
415
+ if (!parent) return;
416
+ while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
417
+ parent.removeChild(mark);
418
+ });
419
+ // Merge adjacent text nodes after unwrapping
420
+ editable.normalize();
421
+
422
+ this._matches = [];
423
+ this._currentIndex = -1;
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // Counter display
428
+ // ---------------------------------------------------------------------------
429
+
430
+ _updateCounter() {
431
+ if (!this._counterEl) return;
432
+ const total = this._matches.length;
433
+ if (total === 0) {
434
+ const query = this._findInput ? this._findInput.value : '';
435
+ this._counterEl.textContent = query ? 'No results' : '';
436
+ } else {
437
+ this._counterEl.textContent = `${this._currentIndex + 1} / ${total}`;
438
+ }
439
+ }
440
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Fullscreen.js - Toggles the editor in/out of fullscreen mode
3
+ * Inspired by Summernote's Fullscreen module
4
+ */
5
+
6
+ import { on } from '../core/dom.js';
7
+ import { key, isKey } from '../core/key.js';
8
+
9
+ export class Fullscreen {
10
+ /**
11
+ * @param {import('../Context.js').Context} context
12
+ */
13
+ constructor(context) {
14
+ this.context = context;
15
+ this._active = false;
16
+ this._disposers = [];
17
+ /** @type {string} cached editable height before fullscreen */
18
+ this._prevHeight = '';
19
+ }
20
+
21
+ initialize() {
22
+ // Press Escape to exit fullscreen
23
+ const d = on(document, 'keydown', (event) => {
24
+ if (this._active && isKey(event, key.ESCAPE)) {
25
+ this.deactivate();
26
+ }
27
+ });
28
+ this._disposers.push(d);
29
+ return this;
30
+ }
31
+
32
+ destroy() {
33
+ this._disposers.forEach((d) => d());
34
+ this._disposers = [];
35
+ if (this._active) this.deactivate();
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Public API
40
+ // ---------------------------------------------------------------------------
41
+
42
+ toggle() {
43
+ if (this._active) {
44
+ this.deactivate();
45
+ } else {
46
+ this.activate();
47
+ }
48
+ }
49
+
50
+ isActive() {
51
+ return this._active;
52
+ }
53
+
54
+ activate() {
55
+ if (this._active) return;
56
+ const container = this.context.layoutInfo.container;
57
+ // Save the container's explicit height (set by the resize handle) so it
58
+ // can be restored when exiting fullscreen.
59
+ this._prevHeight = container.style.height;
60
+
61
+ container.classList.add('an-fullscreen');
62
+ // Clear any resize-imposed height so the fullscreen CSS (inset:0) takes over.
63
+ container.style.height = '';
64
+ document.body.style.overflow = 'hidden';
65
+ this._active = true;
66
+ this.context.invoke('toolbar.refresh');
67
+ }
68
+
69
+ deactivate() {
70
+ if (!this._active) return;
71
+ const container = this.context.layoutInfo.container;
72
+
73
+ container.classList.remove('an-fullscreen');
74
+ // Restore whatever explicit height the user had set via the resize handle.
75
+ container.style.height = this._prevHeight;
76
+ document.body.style.overflow = '';
77
+ this._active = false;
78
+ this.context.invoke('toolbar.refresh');
79
+ }
80
+ }