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
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "autumnnote",
3
+ "version": "1.0.0",
4
+ "description": "A modern, lightweight WYSIWYG editor — built with vanilla JavaScript, no jQuery required.",
5
+ "main": "dist/autumnnote.umd.js",
6
+ "module": "dist/autumnnote.es.js",
7
+ "types": "types/index.d.ts",
8
+ "style": "dist/autumnnote.css",
9
+ "files": [
10
+ "dist",
11
+ "src",
12
+ "types"
13
+ ],
14
+ "scripts": {
15
+ "dev": "vite",
16
+ "build": "vite build",
17
+ "build:demo": "vite build --config vite.demo.config.js",
18
+ "preview": "vite preview",
19
+ "prepublishOnly": "npm run build",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "lint": "eslint src --ext .js"
23
+ },
24
+ "keywords": [
25
+ "wysiwyg",
26
+ "editor",
27
+ "rich-text",
28
+ "rich-text-editor",
29
+ "vanilla-js",
30
+ "vanilla-javascript",
31
+ "contenteditable",
32
+ "summernote",
33
+ "text-editor",
34
+ "html-editor",
35
+ "no-jquery",
36
+ "lightweight",
37
+ "toolbar",
38
+ "javascript-editor"
39
+ ],
40
+ "author": "Minh Pham",
41
+ "license": "MIT",
42
+ "devDependencies": {
43
+ "@vitest/browser": "^1.0.0",
44
+ "eslint": "^8.57.0",
45
+ "jsdom": "^29.0.1",
46
+ "sass": "^1.77.0",
47
+ "vite": "^5.2.0",
48
+ "vitest": "^1.6.0"
49
+ },
50
+ "browserslist": [
51
+ "last 2 versions",
52
+ "not dead",
53
+ "> 0.5%"
54
+ ]
55
+ }
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Context.js - Central hub for the editor instance
3
+ * Holds references to all sub-modules and manages inter-module communication.
4
+ * Inspired by Summernote's Context.js
5
+ */
6
+
7
+ import { mergeDeep } from './core/func.js';
8
+ import { defaultOptions } from './settings.js';
9
+ import { renderLayout } from './renderer.js';
10
+ import { on } from './core/dom.js';
11
+
12
+ // Modules
13
+ import { Editor } from './module/Editor.js';
14
+ import { Toolbar } from './module/Toolbar.js';
15
+ import { Statusbar } from './module/Statusbar.js';
16
+ import { Clipboard } from './module/Clipboard.js';
17
+ import { Placeholder } from './module/Placeholder.js';
18
+ import { Codeview } from './module/Codeview.js';
19
+ import { Fullscreen } from './module/Fullscreen.js';
20
+ import { LinkDialog } from './module/LinkDialog.js';
21
+ import { ImageDialog } from './module/ImageDialog.js';
22
+ import { VideoDialog } from './module/VideoDialog.js';
23
+ import { ImageResizer } from './module/ImageResizer.js';
24
+ import { VideoResizer } from './module/VideoResizer.js';
25
+ import { LinkTooltip } from './module/LinkTooltip.js';
26
+ import { ImageTooltip } from './module/ImageTooltip.js';
27
+ import { VideoTooltip } from './module/VideoTooltip.js';
28
+ import { TableTooltip } from './module/TableTooltip.js';
29
+ import { CodeTooltip } from './module/CodeTooltip.js';
30
+ import { EmojiDialog } from './module/EmojiDialog.js';
31
+ import { IconDialog } from './module/IconDialog.js';
32
+ import { ContextMenu } from './module/ContextMenu.js';
33
+ import { ShortcutsDialog } from './module/ShortcutsDialog.js';
34
+ import { FindReplace } from './module/FindReplace.js';
35
+
36
+ /** Module registry shared across all Context instances (populated via AutumnNote.registerModule). */
37
+ export const _customModules = new Map();
38
+
39
+ export class Context {
40
+ /**
41
+ * @param {HTMLElement} targetEl - The element to replace with the editor
42
+ * @param {import('./settings.js').AsnOptions} [userOptions]
43
+ */
44
+ constructor(targetEl, userOptions = {}) {
45
+ this.targetEl = targetEl;
46
+ this.options = mergeDeep(defaultOptions, userOptions);
47
+
48
+ /** @type {{ container: HTMLElement, editable: HTMLElement, toolbar?: HTMLElement, statusbar?: HTMLElement }} */
49
+ this.layoutInfo = {};
50
+
51
+ /** @type {Map<string, Function[]>} */
52
+ this._listeners = new Map();
53
+
54
+ /** @type {Map<string, object>} */
55
+ this._modules = new Map();
56
+
57
+ this._disposers = [];
58
+ this._alive = false;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Initialisation
63
+ // ---------------------------------------------------------------------------
64
+
65
+ initialize() {
66
+ // 1. Render the DOM skeleton
67
+ const { container, editable } = renderLayout(this.targetEl, this.options);
68
+ this.layoutInfo.container = container;
69
+ this.layoutInfo.editable = editable;
70
+
71
+ // 2. Register core modules
72
+ this._registerModules();
73
+
74
+ // 3. Attach toolbar/statusbar to container
75
+ const toolbar = this._modules.get('toolbar');
76
+ if (toolbar && toolbar.el) {
77
+ container.insertBefore(toolbar.el, editable);
78
+ this.layoutInfo.toolbar = toolbar.el;
79
+ }
80
+
81
+ const statusbar = this._modules.get('statusbar');
82
+ if (statusbar && statusbar.el) {
83
+ container.appendChild(statusbar.el);
84
+ this.layoutInfo.statusbar = statusbar.el;
85
+ }
86
+
87
+ // 4. Bind editor-level events
88
+ this._bindEditorEvents(editable);
89
+
90
+ // 5. Auto-focus if requested
91
+ if (this.options.focus) {
92
+ editable.focus();
93
+ }
94
+
95
+ this._alive = true;
96
+
97
+ // Initial toolbar sync so dropdowns show the correct font on load
98
+ this.invoke('toolbar.refresh');
99
+
100
+ if (typeof this.options.onInit === 'function') {
101
+ this.options.onInit(this);
102
+ }
103
+
104
+ return this;
105
+ }
106
+
107
+ _registerModules() {
108
+ const register = (name, ModuleClass) => {
109
+ const instance = new ModuleClass(this);
110
+ instance.initialize();
111
+ this._modules.set(name, instance);
112
+ };
113
+
114
+ register('editor', Editor);
115
+ register('toolbar', Toolbar);
116
+ register('statusbar', Statusbar);
117
+ register('clipboard', Clipboard);
118
+ register('contextMenu', ContextMenu);
119
+ register('placeholder', Placeholder);
120
+ register('codeview', Codeview);
121
+ register('fullscreen', Fullscreen);
122
+ register('linkDialog', LinkDialog);
123
+ register('imageDialog', ImageDialog);
124
+ register('videoDialog', VideoDialog);
125
+ register('imageResizer', ImageResizer);
126
+ register('videoResizer', VideoResizer);
127
+ register('linkTooltip', LinkTooltip);
128
+ register('imageTooltip', ImageTooltip);
129
+ register('videoTooltip', VideoTooltip);
130
+ register('tableTooltip', TableTooltip);
131
+ register('codeTooltip', CodeTooltip);
132
+ register('emojiDialog', EmojiDialog);
133
+ register('iconDialog', IconDialog);
134
+ register('shortcutsDialog', ShortcutsDialog);
135
+ register('findReplace', FindReplace);
136
+
137
+ // Custom modules registered via AutumnNote.registerModule()
138
+ for (const [name, ModuleClass] of _customModules) {
139
+ register(name, ModuleClass);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Registers and initialises a custom module on this instance.
145
+ * @param {string} name
146
+ * @param {Function} ModuleClass
147
+ * @returns {this}
148
+ */
149
+ registerModule(name, ModuleClass) {
150
+ if (this._modules.has(name)) return this;
151
+ const instance = new ModuleClass(this);
152
+ instance.initialize();
153
+ this._modules.set(name, instance);
154
+ return this;
155
+ }
156
+
157
+ _bindEditorEvents(editable) {
158
+ const d1 = on(editable, 'focus', () => {
159
+ this.layoutInfo.container.classList.add('an-focused');
160
+ if (typeof this.options.onFocus === 'function') {
161
+ this.options.onFocus(this);
162
+ }
163
+ });
164
+ const d2 = on(editable, 'blur', () => {
165
+ this.layoutInfo.container.classList.remove('an-focused');
166
+ this._syncToTarget();
167
+ if (typeof this.options.onBlur === 'function') {
168
+ this.options.onBlur(this);
169
+ }
170
+ });
171
+ // Sync textarea/input value on every change so form.submit() always gets fresh content
172
+ const d3 = this.on('change', () => this._syncToTarget());
173
+ this._disposers.push(d1, d2, d3);
174
+
175
+ // Auto-save to localStorage on every change
176
+ if (this.options.autoSave && this.options.autoSaveKey) {
177
+ const d4 = this.on('change', () => {
178
+ try { localStorage.setItem(this.options.autoSaveKey, this.getHTML()); } catch (_) {}
179
+ });
180
+ this._disposers.push(d4);
181
+ }
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Module invocation
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /**
189
+ * Invokes a method on a registered module.
190
+ * Format: 'moduleName.methodName'
191
+ * @param {string} path - e.g. 'editor.bold'
192
+ * @param {...*} args
193
+ * @returns {*}
194
+ */
195
+ invoke(path, ...args) {
196
+ const [moduleName, methodName] = path.split('.');
197
+ const module = this._modules.get(moduleName);
198
+ if (!module) {
199
+ if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') {
200
+ console.warn(`[AutumnNote] invoke: module "${moduleName}" not found (path: "${path}")`);
201
+ }
202
+ return undefined;
203
+ }
204
+ if (typeof module[methodName] !== 'function') {
205
+ if (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production') {
206
+ console.warn(`[AutumnNote] invoke: method "${methodName}" not found on module "${moduleName}" (path: "${path}")`);
207
+ }
208
+ return undefined;
209
+ }
210
+ return module[methodName](...args);
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Event system
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /**
218
+ * Subscribes to an editor event.
219
+ * @param {string} eventName
220
+ * @param {Function} handler
221
+ * @returns {() => void} unsubscribe
222
+ */
223
+ on(eventName, handler) {
224
+ if (!this._listeners.has(eventName)) {
225
+ this._listeners.set(eventName, []);
226
+ }
227
+ this._listeners.get(eventName).push(handler);
228
+ return () => this.off(eventName, handler);
229
+ }
230
+
231
+ /**
232
+ * Unsubscribes from an editor event.
233
+ * @param {string} eventName
234
+ * @param {Function} handler
235
+ */
236
+ off(eventName, handler) {
237
+ const handlers = this._listeners.get(eventName);
238
+ if (!handlers) return;
239
+ const idx = handlers.indexOf(handler);
240
+ if (idx !== -1) handlers.splice(idx, 1);
241
+ }
242
+
243
+ /**
244
+ * Triggers an editor event.
245
+ * @param {string} eventName
246
+ * @param {...*} args
247
+ */
248
+ triggerEvent(eventName, ...args) {
249
+ const handlers = this._listeners.get(eventName) || [];
250
+ handlers.forEach((h) => h(...args));
251
+
252
+ // Also call options callback if present (e.g. onChange)
253
+ const cbName = 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
254
+ if (typeof this.options[cbName] === 'function') {
255
+ this.options[cbName](...args);
256
+ }
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Public editor API
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /**
264
+ * Returns the current HTML content of the editor.
265
+ * @returns {string}
266
+ */
267
+ getHTML() {
268
+ return this.invoke('editor.getHTML');
269
+ }
270
+
271
+ /**
272
+ * Sets the HTML content of the editor.
273
+ * @param {string} html
274
+ */
275
+ setHTML(html) {
276
+ this.invoke('editor.setHTML', html);
277
+ }
278
+
279
+ /**
280
+ * Returns the plain text content of the editor.
281
+ * @returns {string}
282
+ */
283
+ getText() {
284
+ return this.invoke('editor.getText');
285
+ }
286
+
287
+ /**
288
+ * Sets the editor content as plain text (HTML-escaped).
289
+ * @param {string} text
290
+ */
291
+ setText(text) {
292
+ this.invoke('editor.setText', text);
293
+ }
294
+
295
+ /**
296
+ * Clears the editor content.
297
+ */
298
+ clear() {
299
+ this.invoke('editor.clear');
300
+ }
301
+
302
+ /**
303
+ * Resets the undo/redo history stack.
304
+ * Useful after programmatically loading a new document via setHTML() / setMarkdown()
305
+ * so that Ctrl+Z cannot undo back to the previous document.
306
+ */
307
+ clearHistory() {
308
+ this.invoke('editor.clearHistory');
309
+ }
310
+
311
+ /**
312
+ * Returns true when the editor has no meaningful content.
313
+ * @returns {boolean}
314
+ */
315
+ isEmpty() {
316
+ return this.invoke('editor.isEmpty');
317
+ }
318
+
319
+ /**
320
+ * Inserts HTML at the current cursor position.
321
+ * @param {string} html
322
+ */
323
+ insertHTML(html) {
324
+ this.invoke('editor.insertHTML', html);
325
+ }
326
+
327
+ /**
328
+ * Inserts plain text at the current cursor position.
329
+ * @param {string} text
330
+ */
331
+ insertText(text) {
332
+ this.invoke('editor.insertText', text);
333
+ }
334
+
335
+ /**
336
+ * Sets editor content from a Markdown string.
337
+ * @param {string} md
338
+ */
339
+ setMarkdown(md) {
340
+ this.invoke('editor.setMarkdown', md);
341
+ }
342
+
343
+ /**
344
+ * Returns the editor content as Markdown.
345
+ * @returns {string}
346
+ */
347
+ getMarkdown() {
348
+ return this.invoke('editor.getMarkdown');
349
+ }
350
+
351
+ /**
352
+ * Returns the current word count of the editor content.
353
+ * @returns {number}
354
+ */
355
+ getWordCount() {
356
+ return this.invoke('statusbar.getWordCount') ?? 0;
357
+ }
358
+
359
+ /**
360
+ * Returns the current character count of the editor content.
361
+ * @returns {number}
362
+ */
363
+ getCharCount() {
364
+ return this.invoke('statusbar.getCharCount') ?? 0;
365
+ }
366
+
367
+ /**
368
+ * Downloads the editor content as an HTML file.
369
+ * @param {string} [filename='document.html']
370
+ */
371
+ downloadHTML(filename = 'document.html') {
372
+ this._download(this.getHTML(), filename, 'text/html');
373
+ }
374
+
375
+ /**
376
+ * Downloads the editor content as a plain-text file.
377
+ * @param {string} [filename='document.txt']
378
+ */
379
+ downloadText(filename = 'document.txt') {
380
+ this._download(this.getText(), filename, 'text/plain');
381
+ }
382
+
383
+ /**
384
+ * Downloads the editor content as a Markdown file.
385
+ * @param {string} [filename='document.md']
386
+ */
387
+ downloadMarkdown(filename = 'document.md') {
388
+ this._download(this.getMarkdown(), filename, 'text/markdown');
389
+ }
390
+
391
+ /**
392
+ * Creates a temporary Blob URL and triggers a browser file download.
393
+ * @param {string} content
394
+ * @param {string} filename
395
+ * @param {string} mimeType
396
+ */
397
+ _download(content, filename, mimeType) {
398
+ const blob = new Blob([content], { type: mimeType });
399
+ const url = URL.createObjectURL(blob);
400
+ const a = document.createElement('a');
401
+ a.href = url;
402
+ a.download = filename;
403
+ a.style.display = 'none';
404
+ document.body.appendChild(a);
405
+ a.click();
406
+ document.body.removeChild(a);
407
+ URL.revokeObjectURL(url);
408
+ }
409
+
410
+ /**
411
+ * Opens the editor content in a new window and triggers the browser print dialog.
412
+ * @param {string} [title='']
413
+ */
414
+ print(title = '') {
415
+ const content = this.getHTML();
416
+ const safeTitle = (title || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
417
+ const w = window.open('', '_blank');
418
+ if (!w) return; // popup blocked by browser
419
+ w.document.write(
420
+ '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">' +
421
+ `<title>${safeTitle}</title>` +
422
+ '<style>' +
423
+ 'body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;font-size:14px;line-height:1.6;padding:20mm;color:#111827;}' +
424
+ 'ul.an-checklist{list-style:none;padding-left:0;}' +
425
+ 'ul.an-checklist li{padding-left:24px;position:relative;margin:2px 0;}' +
426
+ 'ul.an-checklist li input[type="checkbox"]{position:absolute;left:0;top:3px;}' +
427
+ 'code{background:#f3f4f6;border-radius:3px;padding:.1em .35em;font-family:monospace;}' +
428
+ 'pre{background:#f3f4f6;padding:.75em 1em;border-radius:4px;overflow-x:auto;}' +
429
+ 'table{border-collapse:collapse;}td,th{border:1px solid #d1d5db;padding:4px 8px;}' +
430
+ '</style>' +
431
+ `</head><body>${content}</body></html>`,
432
+ );
433
+ w.document.close();
434
+ w.onload = () => { w.print(); };
435
+ }
436
+
437
+ /**
438
+ * Sets whether the editor is disabled (readonly).
439
+ * @param {boolean} disabled
440
+ */
441
+ setDisabled(disabled) {
442
+ const editable = this.layoutInfo.editable;
443
+ if (disabled) {
444
+ editable.setAttribute('contenteditable', 'false');
445
+ this.layoutInfo.container.classList.add('an-disabled');
446
+ } else {
447
+ editable.setAttribute('contenteditable', 'true');
448
+ this.layoutInfo.container.classList.remove('an-disabled');
449
+ }
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Destroy
454
+ // ---------------------------------------------------------------------------
455
+
456
+ /**
457
+ * Completely removes the editor and restores the original element.
458
+ */
459
+ destroy() {
460
+ if (!this._alive) return;
461
+
462
+ this._modules.forEach((module) => {
463
+ if (typeof module.destroy === 'function') module.destroy();
464
+ });
465
+ this._modules.clear();
466
+
467
+ this._disposers.forEach((d) => d());
468
+ this._disposers = [];
469
+
470
+ const container = this.layoutInfo.container;
471
+ if (container && container.parentNode) {
472
+ // Restore original element
473
+ this.targetEl.style.display = '';
474
+ container.parentNode.removeChild(container);
475
+ }
476
+
477
+ if (typeof this.options.onDestroy === 'function') {
478
+ this.options.onDestroy(this);
479
+ }
480
+
481
+ this._alive = false;
482
+ this._listeners.clear();
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // Helpers
487
+ // ---------------------------------------------------------------------------
488
+
489
+ /**
490
+ * Syncs editor HTML back into the original textarea/input for form submission.
491
+ */
492
+ _syncToTarget() {
493
+ if (this.targetEl.tagName === 'TEXTAREA' || this.targetEl.tagName === 'INPUT') {
494
+ this.targetEl.value = this.getHTML();
495
+ }
496
+ }
497
+ }