autumnnote 2.1.0 → 2.3.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/README.md +74 -6
- package/dist/autumnnote.cjs +27 -22
- package/dist/autumnnote.es.js +474 -591
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.min.js +27 -22
- package/dist/autumnnote.umd.js +27 -22
- package/dist/autumnnote.umd.js.map +1 -1
- package/dist/icon-data-V0Xqv-wX.js +255 -0
- package/dist/icon-data-V0Xqv-wX.js.map +1 -0
- package/package.json +12 -3
- package/types/index.d.ts +8 -0
- package/src/js/Context.js +0 -854
- package/src/js/core/detectLang.js +0 -98
- package/src/js/core/dom.js +0 -372
- package/src/js/core/env.js +0 -38
- package/src/js/core/key.js +0 -66
- package/src/js/core/lists.js +0 -121
- package/src/js/core/markdown.js +0 -695
- package/src/js/core/range.js +0 -194
- package/src/js/core/sanitise.js +0 -304
- package/src/js/editing/History.js +0 -266
- package/src/js/editing/Style.js +0 -812
- package/src/js/editing/Table.js +0 -105
- package/src/js/editing/Typing.js +0 -397
- package/src/js/index.js +0 -193
- package/src/js/index.umd.js +0 -17
- package/src/js/module/AutoSaveRestore.js +0 -125
- package/src/js/module/BaseDialog.js +0 -133
- package/src/js/module/BaseMediaTooltip.js +0 -142
- package/src/js/module/BaseResizer.js +0 -322
- package/src/js/module/BubbleToolbar.js +0 -483
- package/src/js/module/Buttons.js +0 -399
- package/src/js/module/Clipboard.js +0 -579
- package/src/js/module/CodeTooltip.js +0 -493
- package/src/js/module/Codeview.js +0 -125
- package/src/js/module/ContextMenu.js +0 -621
- package/src/js/module/Editor.js +0 -747
- package/src/js/module/EmojiDialog.js +0 -254
- package/src/js/module/FindReplace.js +0 -512
- package/src/js/module/Fullscreen.js +0 -80
- package/src/js/module/IconDialog.js +0 -618
- package/src/js/module/ImageCropOverlay.js +0 -586
- package/src/js/module/ImageDialog.js +0 -193
- package/src/js/module/ImageResizer.js +0 -42
- package/src/js/module/ImageTooltip.js +0 -285
- package/src/js/module/LinkDialog.js +0 -145
- package/src/js/module/LinkTooltip.js +0 -250
- package/src/js/module/MarkdownShortcuts.js +0 -250
- package/src/js/module/Mention.js +0 -365
- package/src/js/module/Placeholder.js +0 -51
- package/src/js/module/ShortcutsDialog.js +0 -111
- package/src/js/module/SlashMenu.js +0 -376
- package/src/js/module/Statusbar.js +0 -246
- package/src/js/module/TableTooltip.js +0 -1392
- package/src/js/module/Toolbar.js +0 -855
- package/src/js/module/VideoDialog.js +0 -193
- package/src/js/module/VideoResizer.js +0 -66
- package/src/js/module/VideoTooltip.js +0 -248
- package/src/js/module/emoji-data.js +0 -496
- package/src/js/module/table-grid.js +0 -102
- package/src/js/module/table-icons.js +0 -33
- package/src/js/renderer.js +0 -120
- package/src/js/settings.js +0 -214
- package/src/styles/_variables.scss +0 -48
- package/src/styles/autumnnote.scss +0 -2877
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ImageDialog.js - Dialog for inserting images (by URL or file upload)
|
|
3
|
-
* Inspired by Summernote's ImageDialog — rewritten without jQuery
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { createElement, on } from '../core/dom.js';
|
|
7
|
-
import { BaseDialog } from './BaseDialog.js';
|
|
8
|
-
|
|
9
|
-
const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
|
|
10
|
-
|
|
11
|
-
export class ImageDialog extends BaseDialog {
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
// Public API
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* @param {{ beforeInsert?: () => void }} [hooks]
|
|
18
|
-
*/
|
|
19
|
-
show({ beforeInsert } = {}) {
|
|
20
|
-
this._beforeInsert = typeof beforeInsert === 'function' ? beforeInsert : null;
|
|
21
|
-
this._saveRange();
|
|
22
|
-
this._urlInput.value = '';
|
|
23
|
-
this._altInput.value = '';
|
|
24
|
-
if (this._fileInput) this._fileInput.value = '';
|
|
25
|
-
this._open();
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
// Build dialog
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
|
|
32
|
-
_buildDialog() {
|
|
33
|
-
const L = this.context.locale.imageDialog;
|
|
34
|
-
const { overlay, box } = this._buildDialogShell(L.ariaLabel, ICON_SVG, L.title);
|
|
35
|
-
|
|
36
|
-
// URL field
|
|
37
|
-
const urlLabel = createElement('label', { class: 'an-label' });
|
|
38
|
-
urlLabel.textContent = L.imageUrl;
|
|
39
|
-
const urlInput = /** @type {HTMLInputElement} */ (createElement('input', {
|
|
40
|
-
type: 'url',
|
|
41
|
-
class: 'an-input',
|
|
42
|
-
placeholder: L.urlPlaceholder,
|
|
43
|
-
autocomplete: 'off',
|
|
44
|
-
}));
|
|
45
|
-
this._urlInput = urlInput;
|
|
46
|
-
this._firstInput = urlInput;
|
|
47
|
-
|
|
48
|
-
// Alt text
|
|
49
|
-
const altLabel = createElement('label', { class: 'an-label' });
|
|
50
|
-
altLabel.textContent = L.altText;
|
|
51
|
-
const altInput = /** @type {HTMLInputElement} */ (createElement('input', {
|
|
52
|
-
type: 'text',
|
|
53
|
-
class: 'an-input',
|
|
54
|
-
placeholder: L.altPlaceholder,
|
|
55
|
-
autocomplete: 'off',
|
|
56
|
-
}));
|
|
57
|
-
this._altInput = altInput;
|
|
58
|
-
|
|
59
|
-
box.append(urlLabel, urlInput, altLabel, altInput);
|
|
60
|
-
|
|
61
|
-
// Alignment
|
|
62
|
-
const alignLabel = createElement('label', { class: 'an-label' });
|
|
63
|
-
alignLabel.textContent = L.alignment;
|
|
64
|
-
const alignRow = createElement('div', { class: 'an-align-row' });
|
|
65
|
-
const alignOptions = [
|
|
66
|
-
{ value: '', label: L.alignNone },
|
|
67
|
-
{ value: 'left', label: L.alignLeft },
|
|
68
|
-
{ value: 'center', label: L.alignCenter },
|
|
69
|
-
{ value: 'right', label: L.alignRight },
|
|
70
|
-
];
|
|
71
|
-
alignOptions.forEach(({ value, label }) => {
|
|
72
|
-
const radioId = `an-align-${value || 'none'}`;
|
|
73
|
-
const radio = /** @type {HTMLInputElement} */ (createElement('input', { type: 'radio', name: 'an-img-align', id: radioId, value }));
|
|
74
|
-
if (value === '') radio.checked = true;
|
|
75
|
-
const lbl = createElement('label', { for: radioId, class: 'an-align-label' });
|
|
76
|
-
lbl.textContent = label;
|
|
77
|
-
alignRow.append(radio, lbl);
|
|
78
|
-
});
|
|
79
|
-
this._alignRow = alignRow;
|
|
80
|
-
box.append(alignLabel, alignRow);
|
|
81
|
-
|
|
82
|
-
if (this.options.allowImageUpload !== false) {
|
|
83
|
-
const fileLabel = createElement('label', { class: 'an-label' });
|
|
84
|
-
fileLabel.textContent = L.uploadLabel;
|
|
85
|
-
const fileInput = /** @type {HTMLInputElement} */ (createElement('input', {
|
|
86
|
-
type: 'file',
|
|
87
|
-
class: 'an-input',
|
|
88
|
-
accept: 'image/jpeg,image/png,image/gif,image/webp,image/svg+xml,image/avif',
|
|
89
|
-
}));
|
|
90
|
-
this._fileInput = fileInput;
|
|
91
|
-
// Hint line shown below the file input for format errors
|
|
92
|
-
const fileHint = createElement('p', { class: 'an-dialog-hint' });
|
|
93
|
-
this._fileHint = fileHint;
|
|
94
|
-
const d = on(fileInput, 'change', () => this._onFileChange());
|
|
95
|
-
this._disposers.push(d);
|
|
96
|
-
box.append(fileLabel, fileInput, fileHint);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const btnRow = this._buildButtonRow(L.insertBtn, L.cancelBtn, () => this._onInsert());
|
|
100
|
-
box.append(btnRow);
|
|
101
|
-
|
|
102
|
-
const d4 = on(urlInput, 'keydown', (e) => { if (/** @type {KeyboardEvent} */ (e).key === 'Enter') { e.preventDefault(); this._onInsert(); } });
|
|
103
|
-
const d5 = on(altInput, 'keydown', (e) => { if (/** @type {KeyboardEvent} */ (e).key === 'Enter') { e.preventDefault(); this._onInsert(); } });
|
|
104
|
-
this._disposers.push(d4, d5);
|
|
105
|
-
|
|
106
|
-
return overlay;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ---------------------------------------------------------------------------
|
|
110
|
-
// Actions
|
|
111
|
-
// ---------------------------------------------------------------------------
|
|
112
|
-
|
|
113
|
-
_onFileChange() {
|
|
114
|
-
if (this.context._alive === false) return;
|
|
115
|
-
const file = this._fileInput?.files?.[0];
|
|
116
|
-
if (!file?.type?.startsWith('image/')) return;
|
|
117
|
-
|
|
118
|
-
// C2: Only allow web-displayable formats. TIFF, BMP, RAW and similar
|
|
119
|
-
// formats are not rendered by browsers — reject them with a clear message.
|
|
120
|
-
const SUPPORTED = new Set([
|
|
121
|
-
'image/jpeg', 'image/png', 'image/gif',
|
|
122
|
-
'image/webp', 'image/svg+xml', 'image/avif',
|
|
123
|
-
]);
|
|
124
|
-
if (!SUPPORTED.has(file.type)) {
|
|
125
|
-
const message = this.context.locale.errors.imageFormat(file.type);
|
|
126
|
-
if (this._fileHint) this._fileHint.textContent = message;
|
|
127
|
-
this.context.triggerEvent('imageError', { file, message });
|
|
128
|
-
this._fileInput.value = '';
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
if (this._fileHint) this._fileHint.textContent = '';
|
|
132
|
-
|
|
133
|
-
// Validate file size (max 5 MB by default)
|
|
134
|
-
const maxSize = (this.options.maxImageSize || 5) * 1024 * 1024;
|
|
135
|
-
if (file.size > maxSize) {
|
|
136
|
-
const message = this.context.locale.errors.imageSize(this.options.maxImageSize || 5);
|
|
137
|
-
if (this._fileHint) this._fileHint.textContent = message;
|
|
138
|
-
console.warn('[AutumnNote] ImageDialog:', message);
|
|
139
|
-
this.context.triggerEvent('imageError', { file, message });
|
|
140
|
-
this._fileInput.value = '';
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Resize/compress via Clipboard's shared canvas pipeline so a file chosen
|
|
145
|
-
// through this dialog gets the same treatment as a pasted/dropped image
|
|
146
|
-
// instead of embedding the original (potentially multi-MB) file untouched.
|
|
147
|
-
this.context.invoke('clipboard.compressAndRegister', file).then((blobUrl) => {
|
|
148
|
-
if (this.context._alive === false) return;
|
|
149
|
-
this._urlInput.value = blobUrl;
|
|
150
|
-
}).catch((err) => {
|
|
151
|
-
if (this.context._alive === false) return;
|
|
152
|
-
// Compression failed (e.g. canvas unavailable) — fall back to embedding
|
|
153
|
-
// the original file directly.
|
|
154
|
-
console.warn('[AutumnNote] ImageDialog: image compression failed, using original file.', err);
|
|
155
|
-
const reader = new FileReader();
|
|
156
|
-
reader.onload = (e) => {
|
|
157
|
-
this._urlInput.value = /** @type {string} */ (e.target.result);
|
|
158
|
-
};
|
|
159
|
-
reader.readAsDataURL(file);
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
_onInsert() {
|
|
164
|
-
const src = this._urlInput.value.trim();
|
|
165
|
-
const alt = this._altInput.value.trim();
|
|
166
|
-
const alignRadio = this._alignRow?.querySelector('input[name="an-img-align"]:checked');
|
|
167
|
-
const align = alignRadio ? /** @type {HTMLInputElement} */ (alignRadio).value : '';
|
|
168
|
-
|
|
169
|
-
if (!src) {
|
|
170
|
-
this._urlInput.focus();
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (this._savedRange) {
|
|
175
|
-
this._savedRange.select();
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Let callers make a deferred DOM change only after the user commits the
|
|
179
|
-
// dialog. SlashMenu uses this to keep "/image" intact when the dialog is
|
|
180
|
-
// cancelled and remove it atomically immediately before image insertion.
|
|
181
|
-
const beforeInsert = this._beforeInsert;
|
|
182
|
-
this._beforeInsert = null;
|
|
183
|
-
beforeInsert?.();
|
|
184
|
-
|
|
185
|
-
this.context.invoke('editor.insertImage', src, alt, align);
|
|
186
|
-
this._close();
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
_close() {
|
|
190
|
-
this._beforeInsert = null;
|
|
191
|
-
super._close();
|
|
192
|
-
}
|
|
193
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// ImageResizer.js - Interactive resize handles for selected images in the editor
|
|
2
|
-
import { BaseResizer } from './BaseResizer.js';
|
|
3
|
-
|
|
4
|
-
export class ImageResizer extends BaseResizer {
|
|
5
|
-
get _overlayClass() { return 'an-image-resizer'; }
|
|
6
|
-
get _selectedClass() { return 'an-image-selected'; }
|
|
7
|
-
|
|
8
|
-
/** @param {EventTarget|null} el @returns {HTMLImageElement|null} */
|
|
9
|
-
_findTarget(el) {
|
|
10
|
-
return /** @type {HTMLImageElement|null} */ (
|
|
11
|
-
el instanceof Element ? el.closest('img') : null
|
|
12
|
-
);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** @param {HTMLImageElement} img */
|
|
16
|
-
_getStartSize(img) {
|
|
17
|
-
return {
|
|
18
|
-
w: img.offsetWidth || img.naturalWidth || 100,
|
|
19
|
-
h: img.offsetHeight || img.naturalHeight || 100,
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
_getMinSize() {
|
|
24
|
-
const min = this.context.options?.minImageSize ?? 20;
|
|
25
|
-
return { minW: min, minH: min };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** @param {HTMLImageElement} img @param {number} w @param {number} h */
|
|
29
|
-
_applySize(img, w, h) {
|
|
30
|
-
img.style.width = `${w}px`;
|
|
31
|
-
img.style.height = `${h}px`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Public API used by other modules
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
/** @returns {HTMLImageElement|null} */
|
|
39
|
-
getActiveImage() {
|
|
40
|
-
return /** @type {HTMLImageElement|null} */ (this._active);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
// ImageTooltip.js - Hover tooltip for images inside the editor
|
|
2
|
-
// Displays a horizontal action bar below (or above) the selected image,
|
|
3
|
-
// similar in appearance and interaction to LinkTooltip.
|
|
4
|
-
import { createElement, on } from '../core/dom.js';
|
|
5
|
-
import { BaseMediaTooltip } from './BaseMediaTooltip.js';
|
|
6
|
-
|
|
7
|
-
const ICONS = {
|
|
8
|
-
floatLeft: `<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="2" y="4" width="8" height="8" rx="1"/><line x1="12" y1="6" x2="22" y2="6"/><line x1="12" y1="9" x2="22" y2="9"/><line x1="12" y1="12" x2="22" y2="12"/><line x1="2" y1="16" x2="22" y2="16"/><line x1="2" y1="20" x2="18" y2="20"/></svg>`,
|
|
9
|
-
floatRight: `<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="14" y="4" width="8" height="8" rx="1"/><line x1="2" y1="6" x2="12" y2="6"/><line x1="2" y1="9" x2="12" y2="9"/><line x1="2" y1="12" x2="12" y2="12"/><line x1="2" y1="16" x2="22" y2="16"/><line x1="2" y1="20" x2="18" y2="20"/></svg>`,
|
|
10
|
-
floatNone: `<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="3" y="3" width="8" height="8" rx="1"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="3" y1="19" x2="17" y2="19"/></svg>`,
|
|
11
|
-
alignCenter: `<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="7" y="4" width="10" height="8" rx="1"/><line x1="3" y1="16" x2="21" y2="16"/><line x1="6" y1="20" x2="18" y2="20"/></svg>`,
|
|
12
|
-
originalSize:`<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"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>`,
|
|
13
|
-
deleteImg: `<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"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
|
|
14
|
-
caption: `<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="3" y="3" width="18" height="11" rx="2"/><line x1="6" y1="18" x2="18" y2="18"/><line x1="9" y1="21" x2="15" y2="21"/></svg>`,
|
|
15
|
-
rotateLeft: `<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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><polyline points="3 3 3 8 8 8"/></svg>`,
|
|
16
|
-
rotateRight: `<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="M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><polyline points="21 3 21 8 16 8"/></svg>`,
|
|
17
|
-
crop: `<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"><polyline points="6 2 6 18 22 18"/><polyline points="2 6 18 6 18 22"/></svg>`,
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export class ImageTooltip extends BaseMediaTooltip {
|
|
21
|
-
initialize() {
|
|
22
|
-
this._el = this._buildTooltip();
|
|
23
|
-
document.body.appendChild(this._el);
|
|
24
|
-
|
|
25
|
-
const editable = this.context.layoutInfo.editable;
|
|
26
|
-
|
|
27
|
-
this._disposers.push(
|
|
28
|
-
on(editable, 'mouseover', (e) => {
|
|
29
|
-
if (this.context.layoutInfo.container.classList.contains('an-disabled')) return;
|
|
30
|
-
const img = /** @type {Element} */ (e.target)?.closest('img');
|
|
31
|
-
// Skip when the image is inside a link — LinkTooltip takes priority there
|
|
32
|
-
if (img && editable.contains(img) && !img.closest('a[href]')) {
|
|
33
|
-
this._scheduleShow(img);
|
|
34
|
-
}
|
|
35
|
-
}, { passive: true }),
|
|
36
|
-
on(editable, 'mouseout', (e) => {
|
|
37
|
-
const to = /** @type {Node|null} */ (/** @type {MouseEvent} */ (e).relatedTarget);
|
|
38
|
-
if (!to || (!editable.contains(to) && !this._el.contains(to))) {
|
|
39
|
-
this._scheduleHide();
|
|
40
|
-
}
|
|
41
|
-
}, { passive: true }),
|
|
42
|
-
// Hide when image is deselected by clicking elsewhere
|
|
43
|
-
on(document, 'click', (e) => {
|
|
44
|
-
const et = /** @type {Node} */ (e.target);
|
|
45
|
-
if (this._active && !this._active.contains(et) && !this._el.contains(et)) {
|
|
46
|
-
this._hide();
|
|
47
|
-
}
|
|
48
|
-
}),
|
|
49
|
-
// Hide when the page scrolls or resizes — the tooltip position becomes stale
|
|
50
|
-
on(globalThis, 'scroll', () => this._hide(), { passive: true }),
|
|
51
|
-
on(globalThis, 'resize', () => this._hide(), { passive: true }),
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
return this;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
destroy() {
|
|
58
|
-
this._clearTimers();
|
|
59
|
-
this._disposers.forEach((d) => d());
|
|
60
|
-
this._disposers = [];
|
|
61
|
-
this._el?.remove();
|
|
62
|
-
this._el = null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
// Build
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
|
|
69
|
-
_buildTooltip() {
|
|
70
|
-
const L = this.context.locale.tooltips.image;
|
|
71
|
-
const el = createElement('div', {
|
|
72
|
-
class: 'an-link-tooltip an-image-tooltip',
|
|
73
|
-
role: 'toolbar',
|
|
74
|
-
'aria-label': L.ariaLabel,
|
|
75
|
-
});
|
|
76
|
-
el.style.display = 'none';
|
|
77
|
-
|
|
78
|
-
// Label showing "Image"
|
|
79
|
-
this._label = createElement('span', { class: 'an-link-tooltip-url' });
|
|
80
|
-
this._label.textContent = L.label;
|
|
81
|
-
el.appendChild(this._label);
|
|
82
|
-
|
|
83
|
-
el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
|
|
84
|
-
|
|
85
|
-
this._floatLeftBtn = this._makeBtn(ICONS.floatLeft, L.floatLeft, () => this._setFloat('left'));
|
|
86
|
-
this._floatNoneBtn = this._makeBtn(ICONS.floatNone, L.noFloat, () => this._setFloat(''));
|
|
87
|
-
this._alignCenterBtn = this._makeBtn(ICONS.alignCenter, L.alignCenter, () => this._setCenter());
|
|
88
|
-
this._floatRightBtn = this._makeBtn(ICONS.floatRight, L.floatRight, () => this._setFloat('right'));
|
|
89
|
-
|
|
90
|
-
el.appendChild(this._floatLeftBtn);
|
|
91
|
-
el.appendChild(this._floatNoneBtn);
|
|
92
|
-
el.appendChild(this._alignCenterBtn);
|
|
93
|
-
el.appendChild(this._floatRightBtn);
|
|
94
|
-
|
|
95
|
-
el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
|
|
96
|
-
|
|
97
|
-
this._originalBtn = this._makeBtn(ICONS.originalSize, L.originalSize, () => this._resetSize());
|
|
98
|
-
|
|
99
|
-
el.appendChild(this._originalBtn);
|
|
100
|
-
|
|
101
|
-
el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
|
|
102
|
-
|
|
103
|
-
el.appendChild(this._makeBtn(ICONS.rotateLeft, L.rotateLeft, () => this._rotate(-90)));
|
|
104
|
-
el.appendChild(this._makeBtn(ICONS.rotateRight, L.rotateRight, () => this._rotate(90)));
|
|
105
|
-
|
|
106
|
-
el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
|
|
107
|
-
|
|
108
|
-
this._cropBtn = this._makeBtn(ICONS.crop, L.cropImage, () => this._crop());
|
|
109
|
-
el.appendChild(this._cropBtn);
|
|
110
|
-
|
|
111
|
-
el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
|
|
112
|
-
|
|
113
|
-
this._captionBtn = this._makeBtn(ICONS.caption, L.addCaption, () => this._toggleCaption());
|
|
114
|
-
el.appendChild(this._captionBtn);
|
|
115
|
-
|
|
116
|
-
el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
|
|
117
|
-
|
|
118
|
-
this._deleteBtn = this._makeBtn(ICONS.deleteImg, L.deleteImage, () => this._delete(), true);
|
|
119
|
-
|
|
120
|
-
el.appendChild(this._deleteBtn);
|
|
121
|
-
|
|
122
|
-
// Keep tooltip alive while hovering it
|
|
123
|
-
this._disposers.push(
|
|
124
|
-
on(el, 'mouseenter', () => this._clearTimers()),
|
|
125
|
-
on(el, 'mouseleave', () => this._scheduleHide()),
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
return el;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
// Actions
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
_setFloat(value) {
|
|
136
|
-
const img = this._active;
|
|
137
|
-
if (!img) return;
|
|
138
|
-
const target = /** @type {HTMLElement} */ (img.closest('figure.an-figure') || img);
|
|
139
|
-
target.style.float = value;
|
|
140
|
-
target.style.display = '';
|
|
141
|
-
// Clear explicit width set for centering so the figure is free to float
|
|
142
|
-
if (target !== img) target.style.width = '';
|
|
143
|
-
target.style.marginLeft = value === 'right' ? '12px' : '';
|
|
144
|
-
target.style.marginRight = value === 'left' ? '12px' : '';
|
|
145
|
-
this.context.invoke('editor.afterCommand');
|
|
146
|
-
this.context.invoke('imageResizer.updateOverlay');
|
|
147
|
-
requestAnimationFrame(() => { if (this._active) this._positionNear(this._active); });
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
_setCenter() {
|
|
151
|
-
const img = this._active;
|
|
152
|
-
if (!img) return;
|
|
153
|
-
const target = /** @type {HTMLElement} */ (img.closest('figure.an-figure') || img);
|
|
154
|
-
target.style.float = '';
|
|
155
|
-
target.style.display = 'block';
|
|
156
|
-
// C1/C3: use fit-content so the block figure shrinks to the image width;
|
|
157
|
-
// without an explicit width, margin:auto has no effect on a 100%-wide block.
|
|
158
|
-
if (target !== img) target.style.width = 'fit-content';
|
|
159
|
-
target.style.marginLeft = 'auto';
|
|
160
|
-
target.style.marginRight = 'auto';
|
|
161
|
-
this.context.invoke('editor.afterCommand');
|
|
162
|
-
this.context.invoke('imageResizer.updateOverlay');
|
|
163
|
-
requestAnimationFrame(() => { if (this._active) this._positionNear(this._active); });
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
_resetSize() {
|
|
167
|
-
const img = this._active;
|
|
168
|
-
if (!img) return;
|
|
169
|
-
img.style.width = '';
|
|
170
|
-
img.style.height = '';
|
|
171
|
-
this.context.invoke('editor.afterCommand');
|
|
172
|
-
this.context.invoke('imageResizer.updateOverlay');
|
|
173
|
-
requestAnimationFrame(() => { if (this._active) this._positionNear(this._active); });
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Rotate the active image by `delta` degrees (±90).
|
|
178
|
-
* Reads the existing rotate() value from the transform style so
|
|
179
|
-
* repeated clicks accumulate correctly.
|
|
180
|
-
* @param {number} delta
|
|
181
|
-
*/
|
|
182
|
-
_rotate(delta) {
|
|
183
|
-
const img = this._active;
|
|
184
|
-
if (!img) return;
|
|
185
|
-
// Parse current rotation angle from inline transform
|
|
186
|
-
const current = img.style.transform || '';
|
|
187
|
-
const match = /rotate\((-?[\d.]+)deg\)/.exec(current);
|
|
188
|
-
const prev = match ? Number.parseFloat(match[1]) : 0;
|
|
189
|
-
const next = (prev + delta + 360) % 360; // normalise to [0, 360)
|
|
190
|
-
// Preserve any other transform functions (e.g. scale), replace only rotate()
|
|
191
|
-
const cleaned = current.replace(/rotate\(-?[\d.]+deg\)/, '').trim();
|
|
192
|
-
if (cleaned) {
|
|
193
|
-
img.style.transform = `${cleaned} rotate(${next}deg)`;
|
|
194
|
-
} else {
|
|
195
|
-
img.style.transform = next === 0 ? '' : `rotate(${next}deg)`;
|
|
196
|
-
}
|
|
197
|
-
this.context.invoke('editor.afterCommand');
|
|
198
|
-
this.context.invoke('imageResizer.updateOverlay');
|
|
199
|
-
requestAnimationFrame(() => { if (this._active) this._positionNear(this._active); });
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
_delete() {
|
|
203
|
-
const img = this._active;
|
|
204
|
-
if (!img) return;
|
|
205
|
-
this._hide();
|
|
206
|
-
this.context.invoke('imageResizer.deselect');
|
|
207
|
-
const figure = img.closest('figure.an-figure');
|
|
208
|
-
if (figure) {
|
|
209
|
-
figure.remove();
|
|
210
|
-
} else {
|
|
211
|
-
img.remove();
|
|
212
|
-
}
|
|
213
|
-
this.context.invoke('editor.afterCommand');
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
_crop() {
|
|
217
|
-
const img = this._active;
|
|
218
|
-
if (!img) return;
|
|
219
|
-
// Hide the tooltip while the crop overlay is active
|
|
220
|
-
this._hide();
|
|
221
|
-
// Delegate to imageCropOverlay module
|
|
222
|
-
this.context.invoke('imageCropOverlay.open', img);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
_toggleCaption() {
|
|
226
|
-
const img = this._active;
|
|
227
|
-
if (!img) return;
|
|
228
|
-
|
|
229
|
-
// If already inside a figure, move focus to the figcaption
|
|
230
|
-
const existing = img.closest('figure.an-figure');
|
|
231
|
-
if (existing) {
|
|
232
|
-
const cap = existing.querySelector('figcaption.an-figcaption');
|
|
233
|
-
if (cap) {
|
|
234
|
-
this._hide();
|
|
235
|
-
const range = document.createRange();
|
|
236
|
-
range.selectNodeContents(cap);
|
|
237
|
-
const sel = globalThis.getSelection();
|
|
238
|
-
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
|
|
239
|
-
}
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Wrap image in <figure><figcaption>
|
|
244
|
-
const figure = document.createElement('figure');
|
|
245
|
-
figure.className = 'an-figure';
|
|
246
|
-
|
|
247
|
-
// Transfer existing alignment from <img> to <figure> (C4 fix)
|
|
248
|
-
if (img.style.float) {
|
|
249
|
-
figure.style.float = img.style.float;
|
|
250
|
-
figure.style.marginLeft = img.style.marginLeft;
|
|
251
|
-
figure.style.marginRight = img.style.marginRight;
|
|
252
|
-
img.style.float = '';
|
|
253
|
-
img.style.marginLeft = '';
|
|
254
|
-
img.style.marginRight = '';
|
|
255
|
-
} else if (img.style.display === 'block' && img.style.marginLeft === 'auto') {
|
|
256
|
-
// C1/C3: use fit-content so the centered block figure tightly wraps the
|
|
257
|
-
// image, making margin:auto actually centre it regardless of image width.
|
|
258
|
-
figure.style.display = 'block';
|
|
259
|
-
figure.style.width = 'fit-content';
|
|
260
|
-
figure.style.marginLeft = 'auto';
|
|
261
|
-
figure.style.marginRight = 'auto';
|
|
262
|
-
img.style.display = '';
|
|
263
|
-
img.style.marginLeft = '';
|
|
264
|
-
img.style.marginRight = '';
|
|
265
|
-
}
|
|
266
|
-
const figcaption = document.createElement('figcaption');
|
|
267
|
-
figcaption.className = 'an-figcaption';
|
|
268
|
-
figcaption.textContent = 'Caption';
|
|
269
|
-
|
|
270
|
-
img.parentNode.insertBefore(figure, img);
|
|
271
|
-
figure.appendChild(img);
|
|
272
|
-
figure.appendChild(figcaption);
|
|
273
|
-
|
|
274
|
-
// Select caption text immediately
|
|
275
|
-
const range = document.createRange();
|
|
276
|
-
range.selectNodeContents(figcaption);
|
|
277
|
-
const sel = globalThis.getSelection();
|
|
278
|
-
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
|
|
279
|
-
|
|
280
|
-
this.context.invoke('editor.afterCommand');
|
|
281
|
-
// Re-sync the resize overlay: wrapping img in <figure> changes its layout position.
|
|
282
|
-
this.context.invoke('imageResizer.updateOverlay');
|
|
283
|
-
this._hide();
|
|
284
|
-
}
|
|
285
|
-
}
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LinkDialog.js - Dialog for inserting / editing hyperlinks
|
|
3
|
-
* Inspired by Summernote's LinkDialog — rewritten without jQuery
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { createElement, on } from '../core/dom.js';
|
|
7
|
-
import { BaseDialog } from './BaseDialog.js';
|
|
8
|
-
|
|
9
|
-
const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`;
|
|
10
|
-
|
|
11
|
-
export class LinkDialog extends BaseDialog {
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
// Public API
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Opens the link dialog.
|
|
18
|
-
* Pre-fills with the currently selected link if present.
|
|
19
|
-
*/
|
|
20
|
-
show() {
|
|
21
|
-
this._saveRange();
|
|
22
|
-
this._prefill();
|
|
23
|
-
this._open();
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Build dialog
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
_buildDialog() {
|
|
31
|
-
const L = this.context.locale.linkDialog;
|
|
32
|
-
const { overlay, box } = this._buildDialogShell(L.ariaLabel, ICON_SVG, L.title);
|
|
33
|
-
|
|
34
|
-
// URL field
|
|
35
|
-
const urlLabel = createElement('label', { class: 'an-label' });
|
|
36
|
-
urlLabel.textContent = L.url;
|
|
37
|
-
const urlInput = /** @type {HTMLInputElement} */ (createElement('input', {
|
|
38
|
-
type: 'url',
|
|
39
|
-
class: 'an-input',
|
|
40
|
-
placeholder: L.urlPlaceholder,
|
|
41
|
-
id: 'an-link-url',
|
|
42
|
-
name: 'url',
|
|
43
|
-
autocomplete: 'off',
|
|
44
|
-
}));
|
|
45
|
-
this._urlInput = urlInput;
|
|
46
|
-
this._firstInput = urlInput;
|
|
47
|
-
|
|
48
|
-
// Text field
|
|
49
|
-
const textLabel = createElement('label', { class: 'an-label' });
|
|
50
|
-
textLabel.textContent = L.displayText;
|
|
51
|
-
const textInput = /** @type {HTMLInputElement} */ (createElement('input', {
|
|
52
|
-
type: 'text',
|
|
53
|
-
class: 'an-input',
|
|
54
|
-
placeholder: L.textPlaceholder,
|
|
55
|
-
id: 'an-link-text',
|
|
56
|
-
name: 'linkText',
|
|
57
|
-
autocomplete: 'off',
|
|
58
|
-
}));
|
|
59
|
-
this._textInput = textInput;
|
|
60
|
-
|
|
61
|
-
// Open in new tab
|
|
62
|
-
const tabLabel = createElement('label', { class: 'an-label an-label-inline' });
|
|
63
|
-
const tabCheckbox = /** @type {HTMLInputElement} */ (createElement('input', {
|
|
64
|
-
type: 'checkbox',
|
|
65
|
-
id: 'an-link-newtab',
|
|
66
|
-
name: 'openInNewTab',
|
|
67
|
-
}));
|
|
68
|
-
this._tabCheckbox = tabCheckbox;
|
|
69
|
-
tabLabel.appendChild(tabCheckbox);
|
|
70
|
-
tabLabel.appendChild(document.createTextNode(' ' + L.openInNewTab));
|
|
71
|
-
|
|
72
|
-
const btnRow = this._buildButtonRow(L.insertBtn, L.cancelBtn, () => this._onInsert());
|
|
73
|
-
|
|
74
|
-
box.append(urlLabel, urlInput, textLabel, textInput, tabLabel, btnRow);
|
|
75
|
-
|
|
76
|
-
const d4 = on(urlInput, 'keydown', (e) => { if (/** @type {KeyboardEvent} */ (e).key === 'Enter') { e.preventDefault(); this._onInsert(); } });
|
|
77
|
-
const d5 = on(textInput, 'keydown', (e) => { if (/** @type {KeyboardEvent} */ (e).key === 'Enter') { e.preventDefault(); this._onInsert(); } });
|
|
78
|
-
this._disposers.push(d4, d5);
|
|
79
|
-
|
|
80
|
-
return overlay;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// Actions
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
_prefill() {
|
|
88
|
-
// Try to find currently selected anchor
|
|
89
|
-
const sel = globalThis.getSelection();
|
|
90
|
-
let anchor = null;
|
|
91
|
-
if (sel?.rangeCount > 0) {
|
|
92
|
-
let node = sel.getRangeAt(0).startContainer;
|
|
93
|
-
while (node) {
|
|
94
|
-
if (node.nodeName === 'A') { anchor = node; break; }
|
|
95
|
-
node = node.parentNode;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
if (anchor) {
|
|
99
|
-
this._urlInput.value = /** @type {Element} */ (anchor).getAttribute('href') || '';
|
|
100
|
-
this._textInput.value = anchor.textContent || '';
|
|
101
|
-
this._tabCheckbox.checked = /** @type {Element} */ (anchor).getAttribute('target') === '_blank';
|
|
102
|
-
} else {
|
|
103
|
-
this._urlInput.value = '';
|
|
104
|
-
this._textInput.value = sel ? sel.toString() : '';
|
|
105
|
-
this._tabCheckbox.checked = false;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
_onInsert() {
|
|
110
|
-
let url = this._urlInput.value.trim();
|
|
111
|
-
const text = this._textInput.value.trim();
|
|
112
|
-
const newTab = this._tabCheckbox.checked;
|
|
113
|
-
|
|
114
|
-
if (!url) {
|
|
115
|
-
this._urlInput.focus();
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Auto-prefix with https:// if no protocol is present
|
|
120
|
-
if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) {
|
|
121
|
-
url = 'https://' + url;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Block unsafe protocols
|
|
125
|
-
try {
|
|
126
|
-
const parsed = new URL(url);
|
|
127
|
-
if (/^javascript:/i.test(parsed.protocol) || /^vbscript:/i.test(parsed.protocol) || /^data:/i.test(parsed.protocol)) {
|
|
128
|
-
this._urlInput.focus();
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
} catch {
|
|
132
|
-
// URL constructor failed — not a valid URL
|
|
133
|
-
this._urlInput.focus();
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Restore selection then insert
|
|
138
|
-
if (this._savedRange) {
|
|
139
|
-
this._savedRange.select();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
this.context.invoke('editor.insertLink', url, text, newTab);
|
|
143
|
-
this._close();
|
|
144
|
-
}
|
|
145
|
-
}
|