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,263 @@
1
+ /**
2
+ * VideoDialog.js - Dialog for inserting videos (YouTube, Vimeo, or direct file)
3
+ * Supports:
4
+ * • YouTube watch URLs → <iframe> embed
5
+ * • YouTube short URLs → <iframe> embed
6
+ * • Vimeo URLs → <iframe> embed
7
+ * • Direct video URLs → <video> element (.mp4 / .webm / .ogg)
8
+ */
9
+
10
+ import { createElement, on, trapFocus } from '../core/dom.js';
11
+ import { withSavedRange } from '../core/range.js';
12
+
13
+ export class VideoDialog {
14
+ /** @param {import('../Context.js').Context} context */
15
+ constructor(context) {
16
+ this.context = context;
17
+ this.options = context.options;
18
+ /** @type {HTMLElement|null} */
19
+ this._dialog = null;
20
+ this._disposers = [];
21
+ this._savedRange = null;
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Lifecycle
26
+ // ---------------------------------------------------------------------------
27
+
28
+ initialize() {
29
+ this._dialog = this._buildDialog();
30
+ document.body.appendChild(this._dialog);
31
+ return this;
32
+ }
33
+
34
+ destroy() {
35
+ this._disposers.forEach((d) => d());
36
+ this._disposers = [];
37
+ if (this._dialog && this._dialog.parentNode) {
38
+ this._dialog.parentNode.removeChild(this._dialog);
39
+ }
40
+ this._dialog = null;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Public API
45
+ // ---------------------------------------------------------------------------
46
+
47
+ show() {
48
+ withSavedRange((range) => {
49
+ this._savedRange = range;
50
+ });
51
+ this._urlInput.value = '';
52
+ this._widthInput.value = '560';
53
+ this._hintEl.textContent = '';
54
+ this._open();
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Build dialog
59
+ // ---------------------------------------------------------------------------
60
+
61
+ _buildDialog() {
62
+ const overlay = createElement('div', {
63
+ class: 'an-dialog-overlay',
64
+ role: 'dialog',
65
+ 'aria-modal': 'true',
66
+ 'aria-label': 'Insert video',
67
+ });
68
+ const box = createElement('div', { class: 'an-dialog-box' });
69
+
70
+ const title = createElement('h3', { class: 'an-dialog-title' });
71
+ title.textContent = 'Insert Video';
72
+
73
+ // URL input
74
+ const urlLabel = createElement('label', { class: 'an-label' });
75
+ urlLabel.textContent = 'Video URL';
76
+ const urlInput = createElement('input', {
77
+ type: 'url',
78
+ class: 'an-input',
79
+ placeholder: 'YouTube, Vimeo, or direct .mp4 URL',
80
+ autocomplete: 'off',
81
+ });
82
+ this._urlInput = urlInput;
83
+
84
+ // Hint (detected source)
85
+ const hintEl = createElement('p', { class: 'an-dialog-hint' });
86
+ this._hintEl = hintEl;
87
+
88
+ // Width
89
+ const widthLabel = createElement('label', { class: 'an-label' });
90
+ widthLabel.textContent = 'Width (px)';
91
+ const widthInput = createElement('input', {
92
+ type: 'number',
93
+ class: 'an-input',
94
+ placeholder: '560',
95
+ min: '80',
96
+ max: '1920',
97
+ value: '560',
98
+ });
99
+ this._widthInput = widthInput;
100
+
101
+ // Buttons
102
+ const btnRow = createElement('div', { class: 'an-dialog-actions' });
103
+ const insertBtn = createElement('button', { type: 'button', class: 'an-btn an-btn-primary' });
104
+ insertBtn.textContent = 'Insert';
105
+ const cancelBtn = createElement('button', { type: 'button', class: 'an-btn' });
106
+ cancelBtn.textContent = 'Cancel';
107
+ btnRow.appendChild(insertBtn);
108
+ btnRow.appendChild(cancelBtn);
109
+
110
+ box.append(title, urlLabel, urlInput, hintEl, widthLabel, widthInput, btnRow);
111
+ overlay.appendChild(box);
112
+
113
+ // Live URL hint
114
+ const d0 = on(urlInput, 'input', () => {
115
+ const info = this._parseVideoUrl(urlInput.value.trim());
116
+ hintEl.textContent = info ? `Detected: ${info.type}` : (urlInput.value ? 'Unknown format — will try direct video embed' : '');
117
+ });
118
+
119
+ const d1 = on(insertBtn, 'click', () => this._onInsert());
120
+ const d2 = on(cancelBtn, 'click', () => this._close());
121
+ const d3 = on(overlay, 'click', (e) => { if (e.target === overlay) this._close(); });
122
+ const d4 = on(urlInput, 'keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this._onInsert(); } });
123
+ this._disposers.push(d0, d1, d2, d3, d4);
124
+
125
+ return overlay;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Actions
130
+ // ---------------------------------------------------------------------------
131
+
132
+ _onInsert() {
133
+ const rawUrl = this._urlInput.value.trim();
134
+ const width = Math.max(80, parseInt(this._widthInput.value, 10) || 560);
135
+
136
+ if (!rawUrl) {
137
+ this._urlInput.focus();
138
+ return;
139
+ }
140
+
141
+ const html = this._buildEmbedHtml(rawUrl, width);
142
+ if (!html) {
143
+ this._hintEl.textContent = 'Invalid URL — please enter a valid video link.';
144
+ this._urlInput.focus();
145
+ return;
146
+ }
147
+
148
+ if (this._savedRange) this._savedRange.select();
149
+ this.context.invoke('editor.insertVideo', html);
150
+ this._close();
151
+ }
152
+
153
+ _open() {
154
+ if (this._dialog) {
155
+ this._dialog.style.display = 'flex';
156
+ this._removeTrap = trapFocus(this._dialog, () => this._close());
157
+ setTimeout(() => this._urlInput && this._urlInput.focus(), 50);
158
+ }
159
+ }
160
+
161
+ _close() {
162
+ if (this._dialog) this._dialog.style.display = 'none';
163
+ if (this._removeTrap) { this._removeTrap(); this._removeTrap = null; }
164
+ this._savedRange = null;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // URL parsing & HTML building
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Parses a video URL and returns { type, embedUrl } or null.
173
+ * @param {string} url
174
+ * @returns {{ type: string, embedUrl: string }|null}
175
+ */
176
+ _parseVideoUrl(url) {
177
+ if (!url) return null;
178
+
179
+ // Validate — block javascript: and other dangerous protocols
180
+ try {
181
+ const parsed = new URL(url);
182
+ if (/^javascript:/i.test(parsed.protocol) || /^vbscript:/i.test(parsed.protocol)) return null;
183
+ } catch { return null; }
184
+
185
+ // YouTube watch: https://www.youtube.com/watch?v=ID
186
+ const ytWatch = url.match(/(?:youtube\.com\/watch\?(?:.*&)?v=|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/);
187
+ if (ytWatch) return { type: 'YouTube', embedUrl: `https://www.youtube.com/embed/${ytWatch[1]}` };
188
+
189
+ // YouTube short: https://youtu.be/ID
190
+ const ytShort = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/);
191
+ if (ytShort) return { type: 'YouTube', embedUrl: `https://www.youtube.com/embed/${ytShort[1]}` };
192
+
193
+ // YouTube Shorts: https://www.youtube.com/shorts/ID
194
+ const ytShorts = url.match(/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/);
195
+ if (ytShorts) return { type: 'YouTube Shorts', embedUrl: `https://www.youtube.com/embed/${ytShorts[1]}` };
196
+
197
+ // Vimeo: https://vimeo.com/ID
198
+ const vimeo = url.match(/vimeo\.com\/(\d+)/);
199
+ if (vimeo) return { type: 'Vimeo', embedUrl: `https://player.vimeo.com/video/${vimeo[1]}` };
200
+
201
+ // Direct video file
202
+ if (/\.(mp4|webm|ogg|ogv|mov)(#.*|\?.*)?$/i.test(url)) {
203
+ return { type: 'Direct video', embedUrl: url };
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * Builds the HTML string to insert.
211
+ * @param {string} url
212
+ * @param {number} width
213
+ * @returns {string|null}
214
+ */
215
+ _buildEmbedHtml(url, width) {
216
+ const info = this._parseVideoUrl(url);
217
+ const height = Math.round(width * 9 / 16); // 16:9
218
+
219
+ if (info && (info.type === 'YouTube' || info.type === 'YouTube Shorts' || info.type === 'Vimeo')) {
220
+ const iframeTitle = `${info.type} video player`;
221
+ return (
222
+ `<div class="an-video-wrapper" style="position:relative;display:inline-block;max-width:100%">` +
223
+ `<iframe src="${info.embedUrl}" width="${width}" height="${height}" ` +
224
+ `title="${iframeTitle}" ` +
225
+ `frameborder="0" allowfullscreen ` +
226
+ `allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" ` +
227
+ `style="display:block;max-width:100%"></iframe>` +
228
+ `<div class="an-video-shield"></div>` +
229
+ `</div>`
230
+ );
231
+ }
232
+
233
+ if (info && info.type === 'Direct video') {
234
+ const src = info.embedUrl.replace(/"/g, '%22');
235
+ return (
236
+ `<div class="an-video-wrapper" style="position:relative;display:inline-block;max-width:100%">` +
237
+ `<video src="${src}" width="${width}" height="${height}" controls ` +
238
+ `style="display:block;max-width:100%"></video>` +
239
+ `<div class="an-video-shield"></div>` +
240
+ `</div>`
241
+ );
242
+ }
243
+
244
+ // Unknown URL — let the user try as a direct video
245
+ const safeSrc = (() => {
246
+ try {
247
+ const p = new URL(url);
248
+ if (/^javascript:/i.test(p.protocol) || /^vbscript:/i.test(p.protocol)) return null;
249
+ return url;
250
+ } catch { return null; }
251
+ })();
252
+ if (!safeSrc) return null;
253
+
254
+ const escapedSrc = safeSrc.replace(/"/g, '%22');
255
+ return (
256
+ `<div class="an-video-wrapper" style="position:relative;display:inline-block;max-width:100%">` +
257
+ `<video src="${escapedSrc}" width="${width}" height="${height}" controls ` +
258
+ `style="display:block;max-width:100%"></video>` +
259
+ `<div class="an-video-shield"></div>` +
260
+ `</div>`
261
+ );
262
+ }
263
+ }
@@ -0,0 +1,227 @@
1
+ // VideoResizer.js - Interactive resize handles for selected videos in the editor
2
+ // Targets .an-video-wrapper divs (containing <iframe> or <video>)
3
+ import { on } from '../core/dom.js';
4
+
5
+ const HANDLE_DEFS = [
6
+ { pos: 'nw', cursor: 'nw-resize' },
7
+ { pos: 'n', cursor: 'n-resize' },
8
+ { pos: 'ne', cursor: 'ne-resize' },
9
+ { pos: 'e', cursor: 'e-resize' },
10
+ { pos: 'se', cursor: 'se-resize' },
11
+ { pos: 's', cursor: 's-resize' },
12
+ { pos: 'sw', cursor: 'sw-resize' },
13
+ { pos: 'w', cursor: 'w-resize' },
14
+ ];
15
+
16
+ export class VideoResizer {
17
+ /** @param {import('../Context.js').Context} context */
18
+ constructor(context) {
19
+ this.context = context;
20
+ /** @type {HTMLElement|null} — the .an-video-wrapper div */
21
+ this._activeWrapper = null;
22
+ this._overlay = null;
23
+ this._disposers = [];
24
+ }
25
+
26
+ initialize() {
27
+ this._overlay = this._buildOverlay();
28
+ document.body.appendChild(this._overlay);
29
+
30
+ const editable = this.context.layoutInfo.editable;
31
+
32
+ this._disposers.push(
33
+ on(editable, 'click', (e) => this._onEditorClick(e)),
34
+ on(editable, 'contextmenu', (e) => {
35
+ const wrapper = this._findWrapper(e.target);
36
+ if (wrapper) this._select(wrapper);
37
+ }),
38
+ on(document, 'click', (e) => this._onDocClick(e)),
39
+ on(window, 'scroll', () => this._updateOverlayPosition(), { passive: true }),
40
+ on(window, 'resize', () => this._updateOverlayPosition()),
41
+ on(editable, 'scroll', () => this._updateOverlayPosition(), { passive: true }),
42
+ );
43
+
44
+ return this;
45
+ }
46
+
47
+ destroy() {
48
+ this._disposers.forEach((d) => d());
49
+ this._disposers = [];
50
+ if (this._dragDisposers) {
51
+ this._dragDisposers.forEach((d) => d());
52
+ this._dragDisposers = null;
53
+ }
54
+ this._deselect();
55
+ if (this._overlay && this._overlay.parentNode) {
56
+ this._overlay.parentNode.removeChild(this._overlay);
57
+ }
58
+ this._overlay = null;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Public API
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** @returns {HTMLElement|null} */
66
+ getActiveWrapper() {
67
+ return this._activeWrapper;
68
+ }
69
+
70
+ updateOverlay() {
71
+ this._updateOverlayPosition();
72
+ }
73
+
74
+ deselect() {
75
+ this._deselect();
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Internal
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Walk up the DOM from `el` to find the nearest .an-video-wrapper,
84
+ * or an iframe/video whose parent is .an-video-wrapper.
85
+ * @param {EventTarget} el
86
+ * @returns {HTMLElement|null}
87
+ */
88
+ _findWrapper(el) {
89
+ if (!el || !(el instanceof Element)) return null;
90
+ // Direct hit on wrapper
91
+ if (el.classList && el.classList.contains('an-video-wrapper')) return el;
92
+ // Child element (iframe, video, or nested)
93
+ const w = el.closest('.an-video-wrapper');
94
+ if (w) return w;
95
+ return null;
96
+ }
97
+
98
+ _buildOverlay() {
99
+ const overlay = document.createElement('div');
100
+ overlay.className = 'an-video-resizer';
101
+ overlay.style.display = 'none';
102
+
103
+ HANDLE_DEFS.forEach(({ pos }) => {
104
+ const h = document.createElement('div');
105
+ h.className = `an-resize-handle an-resize-${pos}`;
106
+ h.dataset.handle = pos;
107
+ this._disposers.push(
108
+ on(h, 'mousedown', (e) => {
109
+ e.preventDefault();
110
+ e.stopPropagation();
111
+ this._startResize(e, pos);
112
+ }),
113
+ );
114
+ overlay.appendChild(h);
115
+ });
116
+
117
+ return overlay;
118
+ }
119
+
120
+ _onEditorClick(e) {
121
+ const wrapper = this._findWrapper(e.target);
122
+ if (wrapper) {
123
+ e.preventDefault();
124
+ this._select(wrapper);
125
+ }
126
+ }
127
+
128
+ _onDocClick(e) {
129
+ if (!this._activeWrapper) return;
130
+ if (this._activeWrapper.contains(e.target)) return;
131
+ if (this._overlay && this._overlay.contains(e.target)) return;
132
+ if (e.target.closest('.an-contextmenu')) return;
133
+ this._deselect();
134
+ }
135
+
136
+ _select(wrapper) {
137
+ if (this._activeWrapper && this._activeWrapper !== wrapper) {
138
+ this._activeWrapper.classList.remove('an-video-selected');
139
+ }
140
+ this._activeWrapper = wrapper;
141
+ wrapper.classList.add('an-video-selected');
142
+ this._updateOverlayPosition();
143
+ this._overlay.style.display = 'block';
144
+ }
145
+
146
+ _deselect() {
147
+ if (this._activeWrapper) {
148
+ this._activeWrapper.classList.remove('an-video-selected');
149
+ this._activeWrapper = null;
150
+ }
151
+ if (this._overlay) this._overlay.style.display = 'none';
152
+ }
153
+
154
+ _updateOverlayPosition() {
155
+ if (!this._activeWrapper || !this._overlay) return;
156
+ const rect = this._activeWrapper.getBoundingClientRect();
157
+ this._overlay.style.left = `${rect.left}px`;
158
+ this._overlay.style.top = `${rect.top}px`;
159
+ this._overlay.style.width = `${rect.width}px`;
160
+ this._overlay.style.height = `${rect.height}px`;
161
+ }
162
+
163
+ _startResize(e, pos) {
164
+ const wrapper = this._activeWrapper;
165
+ if (!wrapper) return;
166
+
167
+ const embed = wrapper.querySelector('iframe, video');
168
+ const startX = e.clientX;
169
+ const startY = e.clientY;
170
+ const startW = wrapper.offsetWidth || 560;
171
+ const startH = wrapper.offsetHeight || 315;
172
+ const aspectRatio = startW / (startH || 1);
173
+ const isCorner = pos.length === 2;
174
+
175
+ const editable = this.context.layoutInfo.editable;
176
+ const onMove = (me) => {
177
+ const dx = me.clientX - startX;
178
+ const dy = me.clientY - startY;
179
+ const maxW = editable.clientWidth || Infinity;
180
+ let newW = startW;
181
+ let newH = startH;
182
+
183
+ if (pos.includes('e')) newW = Math.max(80, startW + dx);
184
+ if (pos.includes('w')) newW = Math.max(80, startW - dx);
185
+ if (pos.includes('s')) newH = Math.max(45, startH + dy);
186
+ if (pos.includes('n')) newH = Math.max(45, startH - dy);
187
+
188
+ // Clamp to container width
189
+ newW = Math.min(newW, maxW);
190
+
191
+ if (isCorner) {
192
+ if (Math.abs(dx) >= Math.abs(dy)) {
193
+ newH = Math.max(45, Math.round(newW / aspectRatio));
194
+ } else {
195
+ newW = Math.min(Math.max(80, Math.round(newH * aspectRatio)), maxW);
196
+ newH = Math.max(45, Math.round(newW / aspectRatio));
197
+ }
198
+ }
199
+
200
+ // Resize both the wrapper and the inner embed element
201
+ wrapper.style.width = `${newW}px`;
202
+ wrapper.style.height = `${newH}px`;
203
+ if (embed) {
204
+ embed.width = newW;
205
+ embed.height = newH;
206
+ embed.style.width = `${newW}px`;
207
+ embed.style.height = `${newH}px`;
208
+ }
209
+ this._updateOverlayPosition();
210
+ };
211
+
212
+ const onUp = () => {
213
+ document.removeEventListener('mousemove', onMove);
214
+ document.removeEventListener('mouseup', onUp);
215
+ this._dragDisposers = null;
216
+ this.context.invoke('editor.afterCommand');
217
+ };
218
+
219
+ document.addEventListener('mousemove', onMove);
220
+ document.addEventListener('mouseup', onUp);
221
+ // Track these so destroy() can clean them up if called during an active drag
222
+ this._dragDisposers = [
223
+ () => document.removeEventListener('mousemove', onMove),
224
+ () => document.removeEventListener('mouseup', onUp),
225
+ ];
226
+ }
227
+ }