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.
- package/LICENSE +21 -0
- package/README.md +874 -0
- package/dist/autumnnote.css +1 -0
- package/dist/autumnnote.es.js +5888 -0
- package/dist/autumnnote.es.js.map +1 -0
- package/dist/autumnnote.umd.js +74 -0
- package/dist/autumnnote.umd.js.map +1 -0
- package/package.json +55 -0
- package/src/js/Context.js +497 -0
- package/src/js/core/dom.js +315 -0
- package/src/js/core/env.js +25 -0
- package/src/js/core/func.js +153 -0
- package/src/js/core/key.js +66 -0
- package/src/js/core/lists.js +121 -0
- package/src/js/core/markdown.js +294 -0
- package/src/js/core/range.js +194 -0
- package/src/js/core/sanitise.js +78 -0
- package/src/js/editing/History.js +205 -0
- package/src/js/editing/Style.js +329 -0
- package/src/js/editing/Table.js +59 -0
- package/src/js/editing/Typing.js +142 -0
- package/src/js/index.js +126 -0
- package/src/js/module/Buttons.js +300 -0
- package/src/js/module/Clipboard.js +460 -0
- package/src/js/module/CodeTooltip.js +428 -0
- package/src/js/module/Codeview.js +122 -0
- package/src/js/module/ContextMenu.js +470 -0
- package/src/js/module/Editor.js +528 -0
- package/src/js/module/EmojiDialog.js +726 -0
- package/src/js/module/FindReplace.js +440 -0
- package/src/js/module/Fullscreen.js +80 -0
- package/src/js/module/IconDialog.js +620 -0
- package/src/js/module/ImageDialog.js +208 -0
- package/src/js/module/ImageResizer.js +216 -0
- package/src/js/module/ImageTooltip.js +286 -0
- package/src/js/module/LinkDialog.js +204 -0
- package/src/js/module/LinkTooltip.js +242 -0
- package/src/js/module/Placeholder.js +44 -0
- package/src/js/module/ShortcutsDialog.js +141 -0
- package/src/js/module/Statusbar.js +238 -0
- package/src/js/module/TableTooltip.js +568 -0
- package/src/js/module/Toolbar.js +562 -0
- package/src/js/module/VideoDialog.js +263 -0
- package/src/js/module/VideoResizer.js +227 -0
- package/src/js/module/VideoTooltip.js +252 -0
- package/src/js/renderer.js +107 -0
- package/src/js/settings.js +134 -0
- package/src/styles/_variables.scss +48 -0
- package/src/styles/autumnnote.scss +1740 -0
- package/types/index.d.ts +324 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// CodeTooltip.js - Hover tooltip for <pre> code blocks inside the editor
|
|
2
|
+
// Shows a horizontal action bar above (or below) the hovered code block,
|
|
3
|
+
// consistent in appearance and interaction with ImageTooltip / TableTooltip.
|
|
4
|
+
import { createElement, on } from '../core/dom.js';
|
|
5
|
+
|
|
6
|
+
const SHOW_DELAY = 100;
|
|
7
|
+
const HIDE_DELAY = 180;
|
|
8
|
+
|
|
9
|
+
const ICONS = {
|
|
10
|
+
copy: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
|
|
11
|
+
wrapOn: `<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"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 0 1 0 6H3"/><polyline points="6 15 3 18 6 21"/></svg>`,
|
|
12
|
+
toParagraph:`<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="M13 4v16"/><path d="M17 4v16"/><path d="M9 4h8a4 4 0 0 1 0 8H9V4z"/></svg>`,
|
|
13
|
+
deleteCode: `<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
|
+
};
|
|
15
|
+
|
|
16
|
+
export class CodeTooltip {
|
|
17
|
+
/** @param {import('../Context.js').Context} context */
|
|
18
|
+
constructor(context) {
|
|
19
|
+
this.context = context;
|
|
20
|
+
this._el = null;
|
|
21
|
+
this._activePre = null;
|
|
22
|
+
this._showTimer = null;
|
|
23
|
+
this._hideTimer = null;
|
|
24
|
+
this._disposers = [];
|
|
25
|
+
this._copyBtn = null;
|
|
26
|
+
this._langSelect = null;
|
|
27
|
+
this._prismScript = null; // script element while Prism is loading
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
initialize() {
|
|
31
|
+
this._el = this._buildTooltip();
|
|
32
|
+
document.body.appendChild(this._el);
|
|
33
|
+
this._ensurePrism();
|
|
34
|
+
|
|
35
|
+
const editable = this.context.layoutInfo.editable;
|
|
36
|
+
|
|
37
|
+
this._disposers.push(
|
|
38
|
+
on(editable, 'mouseover', (e) => {
|
|
39
|
+
const pre = e.target.closest('pre');
|
|
40
|
+
if (pre && editable.contains(pre)) {
|
|
41
|
+
this._scheduleShow(pre);
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
on(editable, 'mouseout', (e) => {
|
|
45
|
+
const to = e.relatedTarget;
|
|
46
|
+
if (!to || (!editable.contains(to) && !this._el.contains(to))) {
|
|
47
|
+
this._scheduleHide();
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
50
|
+
on(document, 'click', (e) => {
|
|
51
|
+
if (this._activePre &&
|
|
52
|
+
!this._activePre.contains(e.target) &&
|
|
53
|
+
!this._el.contains(e.target)) {
|
|
54
|
+
this._hide();
|
|
55
|
+
}
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
destroy() {
|
|
63
|
+
this._clearTimers();
|
|
64
|
+
this._disposers.forEach((d) => d());
|
|
65
|
+
this._disposers = [];
|
|
66
|
+
if (this._el && this._el.parentNode) this._el.parentNode.removeChild(this._el);
|
|
67
|
+
this._el = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Build
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
_buildTooltip() {
|
|
75
|
+
const el = createElement('div', {
|
|
76
|
+
class: 'an-link-tooltip an-code-tooltip',
|
|
77
|
+
role: 'toolbar',
|
|
78
|
+
'aria-label': 'Code block actions',
|
|
79
|
+
});
|
|
80
|
+
el.style.display = 'none';
|
|
81
|
+
|
|
82
|
+
// Label
|
|
83
|
+
this._label = createElement('span', { class: 'an-link-tooltip-url' });
|
|
84
|
+
this._label.textContent = 'Code';
|
|
85
|
+
el.appendChild(this._label);
|
|
86
|
+
|
|
87
|
+
el.appendChild(this._sep());
|
|
88
|
+
|
|
89
|
+
// Language selector
|
|
90
|
+
this._langSelect = createElement('select', {
|
|
91
|
+
class: 'an-code-lang-select',
|
|
92
|
+
title: 'Syntax Language',
|
|
93
|
+
'aria-label': 'Syntax language',
|
|
94
|
+
});
|
|
95
|
+
const LANGUAGES = [
|
|
96
|
+
['', 'Plain text'], ['javascript', 'JavaScript'], ['typescript', 'TypeScript'],
|
|
97
|
+
['python', 'Python'], ['html', 'HTML'], ['css', 'CSS'], ['json', 'JSON'],
|
|
98
|
+
['xml', 'XML'], ['bash', 'Bash / Shell'], ['sql', 'SQL'],
|
|
99
|
+
['java', 'Java'], ['csharp', 'C#'], ['php', 'PHP'], ['ruby', 'Ruby'],
|
|
100
|
+
['go', 'Go'], ['rust', 'Rust'], ['cpp', 'C++'], ['c', 'C'],
|
|
101
|
+
['kotlin', 'Kotlin'], ['swift', 'Swift'],
|
|
102
|
+
];
|
|
103
|
+
LANGUAGES.forEach(([value, label]) => {
|
|
104
|
+
const opt = createElement('option', { value });
|
|
105
|
+
opt.textContent = label;
|
|
106
|
+
this._langSelect.appendChild(opt);
|
|
107
|
+
});
|
|
108
|
+
this._disposers.push(on(this._langSelect, 'change', () => this._onLangChange()));
|
|
109
|
+
el.appendChild(this._langSelect);
|
|
110
|
+
|
|
111
|
+
el.appendChild(this._sep());
|
|
112
|
+
|
|
113
|
+
// Copy code
|
|
114
|
+
this._copyBtn = this._makeBtn(ICONS.copy, 'Copy Code', () => this._copyCode());
|
|
115
|
+
|
|
116
|
+
el.appendChild(this._copyBtn);
|
|
117
|
+
|
|
118
|
+
el.appendChild(this._sep());
|
|
119
|
+
|
|
120
|
+
// Toggle word-wrap
|
|
121
|
+
this._wrapBtn = this._makeBtn(ICONS.wrapOn, 'Toggle Word Wrap', () => this._toggleWrap());
|
|
122
|
+
el.appendChild(this._wrapBtn);
|
|
123
|
+
|
|
124
|
+
el.appendChild(this._sep());
|
|
125
|
+
|
|
126
|
+
// Convert to normal paragraph
|
|
127
|
+
el.appendChild(this._makeBtn(ICONS.toParagraph, 'Convert to Paragraph', () => this._toParagraph()));
|
|
128
|
+
|
|
129
|
+
el.appendChild(this._sep());
|
|
130
|
+
|
|
131
|
+
// Delete block
|
|
132
|
+
el.appendChild(this._makeBtn(ICONS.deleteCode, 'Delete Code Block', () => this._delete(), true));
|
|
133
|
+
|
|
134
|
+
// Keep tooltip alive while hovering it
|
|
135
|
+
this._disposers.push(
|
|
136
|
+
on(el, 'mouseenter', () => this._clearTimers()),
|
|
137
|
+
on(el, 'mouseleave', () => this._scheduleHide()),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return el;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_sep() {
|
|
144
|
+
return createElement('div', { class: 'an-link-tooltip-sep' });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} icon
|
|
149
|
+
* @param {string} title
|
|
150
|
+
* @param {Function} handler
|
|
151
|
+
* @param {boolean} [isDanger]
|
|
152
|
+
*/
|
|
153
|
+
_makeBtn(icon, title, handler, isDanger = false) {
|
|
154
|
+
const btn = createElement('button', {
|
|
155
|
+
type: 'button',
|
|
156
|
+
class: isDanger ? 'an-link-tooltip-btn an-link-tooltip-btn--danger' : 'an-link-tooltip-btn',
|
|
157
|
+
title,
|
|
158
|
+
});
|
|
159
|
+
btn.innerHTML = icon;
|
|
160
|
+
this._disposers.push(on(btn, 'click', (e) => {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
e.stopPropagation();
|
|
163
|
+
handler();
|
|
164
|
+
}));
|
|
165
|
+
return btn;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Show / Hide
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
_scheduleShow(pre) {
|
|
173
|
+
if (this._activePre === pre && this._el.style.display !== 'none') return;
|
|
174
|
+
clearTimeout(this._hideTimer);
|
|
175
|
+
this._hideTimer = null;
|
|
176
|
+
clearTimeout(this._showTimer);
|
|
177
|
+
this._showTimer = setTimeout(() => {
|
|
178
|
+
this._activePre = pre;
|
|
179
|
+
this._syncWrapBtn();
|
|
180
|
+
this._syncLangSelect();
|
|
181
|
+
this._show(pre);
|
|
182
|
+
}, SHOW_DELAY);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_scheduleHide() {
|
|
186
|
+
clearTimeout(this._showTimer);
|
|
187
|
+
this._showTimer = null;
|
|
188
|
+
if (this._hideTimer) return;
|
|
189
|
+
this._hideTimer = setTimeout(() => this._hide(), HIDE_DELAY);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_show(pre) {
|
|
193
|
+
this._el.style.display = 'flex';
|
|
194
|
+
this._positionNear(pre);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_hide() {
|
|
198
|
+
this._el.style.display = 'none';
|
|
199
|
+
this._activePre = null;
|
|
200
|
+
this._clearTimers();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_clearTimers() {
|
|
204
|
+
clearTimeout(this._showTimer);
|
|
205
|
+
clearTimeout(this._hideTimer);
|
|
206
|
+
this._showTimer = null;
|
|
207
|
+
this._hideTimer = null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_positionNear(pre) {
|
|
211
|
+
const rect = pre.getBoundingClientRect();
|
|
212
|
+
const tipW = this._el.offsetWidth || 260;
|
|
213
|
+
const tipH = this._el.offsetHeight || 32;
|
|
214
|
+
const margin = 6;
|
|
215
|
+
|
|
216
|
+
// Prefer above the block; fall back to below
|
|
217
|
+
let top = rect.top - tipH - margin;
|
|
218
|
+
let left = rect.left + (rect.width - tipW) / 2;
|
|
219
|
+
|
|
220
|
+
if (top < margin) top = rect.bottom + margin;
|
|
221
|
+
if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
|
|
222
|
+
if (left < margin) left = margin;
|
|
223
|
+
|
|
224
|
+
this._el.style.top = `${top + window.scrollY}px`;
|
|
225
|
+
this._el.style.left = `${left + window.scrollX}px`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Sync wrap-button active state
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
_syncWrapBtn() {
|
|
233
|
+
if (!this._activePre || !this._wrapBtn) return;
|
|
234
|
+
const wrapped = (this._activePre.style.whiteSpace || '').includes('pre-wrap')
|
|
235
|
+
|| window.getComputedStyle(this._activePre).whiteSpace === 'pre-wrap';
|
|
236
|
+
this._wrapBtn.classList.toggle('active', wrapped);
|
|
237
|
+
this._wrapBtn.title = wrapped ? 'Disable Word Wrap' : 'Enable Word Wrap';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
_syncLangSelect() {
|
|
241
|
+
if (!this._activePre || !this._langSelect) return;
|
|
242
|
+
const codeEl = this._activePre.querySelector('code');
|
|
243
|
+
const fromAttr = this._activePre.getAttribute('data-language') || '';
|
|
244
|
+
const fromClass = codeEl ? (codeEl.className.match(/language-(\S+)/) || [])[1] || '' : '';
|
|
245
|
+
this._langSelect.value = fromAttr || fromClass || '';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Actions
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
_copyCode() {
|
|
253
|
+
const pre = this._activePre;
|
|
254
|
+
if (!pre) return;
|
|
255
|
+
const text = pre.textContent || '';
|
|
256
|
+
if (navigator.clipboard) {
|
|
257
|
+
navigator.clipboard.writeText(text).then(() => this._flashCopied()).catch(() => {});
|
|
258
|
+
} else {
|
|
259
|
+
// Fallback for older browsers
|
|
260
|
+
const ta = document.createElement('textarea');
|
|
261
|
+
ta.value = text;
|
|
262
|
+
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
|
|
263
|
+
document.body.appendChild(ta);
|
|
264
|
+
ta.select();
|
|
265
|
+
try { document.execCommand('copy'); this._flashCopied(); } catch (_) {}
|
|
266
|
+
document.body.removeChild(ta);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_flashCopied() {
|
|
271
|
+
if (!this._copyBtn) return;
|
|
272
|
+
const original = this._copyBtn.innerHTML;
|
|
273
|
+
this._copyBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
|
274
|
+
this._copyBtn.classList.add('an-link-tooltip-btn--copied');
|
|
275
|
+
setTimeout(() => {
|
|
276
|
+
if (this._copyBtn) {
|
|
277
|
+
this._copyBtn.innerHTML = original;
|
|
278
|
+
this._copyBtn.classList.remove('an-link-tooltip-btn--copied');
|
|
279
|
+
}
|
|
280
|
+
}, 1400);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
_toggleWrap() {
|
|
284
|
+
const pre = this._activePre;
|
|
285
|
+
if (!pre) return;
|
|
286
|
+
const isWrapped = pre.style.whiteSpace === 'pre-wrap';
|
|
287
|
+
pre.style.whiteSpace = isWrapped ? 'pre' : 'pre-wrap';
|
|
288
|
+
this._syncWrapBtn();
|
|
289
|
+
this.context.invoke('editor.afterCommand');
|
|
290
|
+
this._positionNear(pre);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
_onLangChange() {
|
|
294
|
+
const pre = this._activePre;
|
|
295
|
+
if (!pre) return;
|
|
296
|
+
const lang = this._langSelect.value;
|
|
297
|
+
|
|
298
|
+
// Ensure a <code> child exists (Prism targets <pre><code class="language-xxx">)
|
|
299
|
+
let codeEl = pre.querySelector('code');
|
|
300
|
+
if (!codeEl) {
|
|
301
|
+
codeEl = document.createElement('code');
|
|
302
|
+
codeEl.innerHTML = pre.innerHTML;
|
|
303
|
+
pre.innerHTML = '';
|
|
304
|
+
pre.appendChild(codeEl);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
codeEl.className = lang ? `language-${lang}` : '';
|
|
308
|
+
// Mirror language class on <pre> so Prism CSS theme targets it (pre[class*='language-'])
|
|
309
|
+
pre.className = lang ? `language-${lang}` : '';
|
|
310
|
+
if (lang) {
|
|
311
|
+
pre.setAttribute('data-language', lang);
|
|
312
|
+
} else {
|
|
313
|
+
pre.removeAttribute('data-language');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Trigger Prism if available.
|
|
317
|
+
// contenteditable stores line breaks as <br> elements; Prism reads textContent
|
|
318
|
+
// which drops <br> entirely, collapsing all lines into one. Convert first.
|
|
319
|
+
const applyPrism = () => {
|
|
320
|
+
codeEl.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
|
|
321
|
+
window.Prism.highlightElement(codeEl);
|
|
322
|
+
this.context.invoke('editor.afterCommand');
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
if (lang) {
|
|
326
|
+
if (typeof window.Prism !== 'undefined') {
|
|
327
|
+
// Grammar already loaded — highlight immediately
|
|
328
|
+
if (window.Prism.languages[lang]) {
|
|
329
|
+
applyPrism();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Grammar not in core bundle — load the component script first
|
|
333
|
+
this._loadPrismComponent(lang, applyPrism);
|
|
334
|
+
return;
|
|
335
|
+
} else if (this._prismScript) {
|
|
336
|
+
// Prism core is still loading — highlight once it arrives, then load grammar if needed
|
|
337
|
+
this._prismScript.addEventListener('load', () => {
|
|
338
|
+
if (window.Prism.languages[lang]) {
|
|
339
|
+
applyPrism();
|
|
340
|
+
} else {
|
|
341
|
+
this._loadPrismComponent(lang, applyPrism);
|
|
342
|
+
}
|
|
343
|
+
}, { once: true });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
this.context.invoke('editor.afterCommand');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Loads Prism.js + CSS from CDN when options.codeHighlight is true.
|
|
353
|
+
* Called once at initialize time. Fire-and-forget; errors are silent.
|
|
354
|
+
*/
|
|
355
|
+
_ensurePrism() {
|
|
356
|
+
if (!this.context.options.codeHighlight || window.Prism) return;
|
|
357
|
+
const cdn = this.context.options.codeHighlightCDN
|
|
358
|
+
|| 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0';
|
|
359
|
+
|
|
360
|
+
const link = document.createElement('link');
|
|
361
|
+
link.rel = 'stylesheet';
|
|
362
|
+
link.href = `${cdn}/themes/prism-tomorrow.min.css`;
|
|
363
|
+
document.head.appendChild(link);
|
|
364
|
+
|
|
365
|
+
const script = document.createElement('script');
|
|
366
|
+
script.dataset.manual = ''; // prevent auto-highlight on load
|
|
367
|
+
script.src = `${cdn}/prism.min.js`;
|
|
368
|
+
this._prismScript = script;
|
|
369
|
+
script.addEventListener('load', () => { this._prismScript = null; }, { once: true });
|
|
370
|
+
document.head.appendChild(script);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Dynamically loads a Prism language component script if not already available.
|
|
375
|
+
* Prism core only bundles: markup, css, clike, javascript.
|
|
376
|
+
* All other grammars (bash, python, etc.) need a component file.
|
|
377
|
+
* @param {string} lang – Prism language slug (e.g. 'bash', 'python')
|
|
378
|
+
* @param {Function} cb – called once the grammar is ready
|
|
379
|
+
*/
|
|
380
|
+
_loadPrismComponent(lang, cb) {
|
|
381
|
+
const cdn = this.context.options.codeHighlightCDN
|
|
382
|
+
|| 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0';
|
|
383
|
+
const src = `${cdn}/components/prism-${lang}.min.js`;
|
|
384
|
+
// Avoid loading the same component twice
|
|
385
|
+
if (document.querySelector(`script[src="${src}"]`)) {
|
|
386
|
+
// Already in DOM — might still be loading; poll briefly then call cb
|
|
387
|
+
const poll = setInterval(() => {
|
|
388
|
+
if (window.Prism && window.Prism.languages[lang]) {
|
|
389
|
+
clearInterval(poll);
|
|
390
|
+
cb();
|
|
391
|
+
}
|
|
392
|
+
}, 50);
|
|
393
|
+
setTimeout(() => clearInterval(poll), 3000); // give up after 3s
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const s = document.createElement('script');
|
|
397
|
+
s.src = src;
|
|
398
|
+
s.addEventListener('load', cb, { once: true });
|
|
399
|
+
document.head.appendChild(s);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_toParagraph() {
|
|
403
|
+
const pre = this._activePre;
|
|
404
|
+
if (!pre) return;
|
|
405
|
+
const editable = this.context.layoutInfo.editable;
|
|
406
|
+
if (!editable) return;
|
|
407
|
+
|
|
408
|
+
// Replace <pre> with <p> preserving lines as <br>
|
|
409
|
+
const lines = (pre.textContent || '').split('\n');
|
|
410
|
+
const p = document.createElement('p');
|
|
411
|
+
lines.forEach((line, i) => {
|
|
412
|
+
if (i > 0) p.appendChild(document.createElement('br'));
|
|
413
|
+
p.appendChild(document.createTextNode(line));
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
pre.parentNode.replaceChild(p, pre);
|
|
417
|
+
this._hide();
|
|
418
|
+
this.context.invoke('editor.afterCommand');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
_delete() {
|
|
422
|
+
const pre = this._activePre;
|
|
423
|
+
if (!pre) return;
|
|
424
|
+
this._hide();
|
|
425
|
+
if (pre.parentNode) pre.parentNode.removeChild(pre);
|
|
426
|
+
this.context.invoke('editor.afterCommand');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codeview.js - HTML source code view / WYSIWYG toggle
|
|
3
|
+
* Inspired by Summernote's Codeview module
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createElement } from '../core/dom.js';
|
|
7
|
+
import { sanitiseHTML } from '../core/sanitise.js';
|
|
8
|
+
|
|
9
|
+
export class Codeview {
|
|
10
|
+
/**
|
|
11
|
+
* @param {import('../Context.js').Context} context
|
|
12
|
+
*/
|
|
13
|
+
constructor(context) {
|
|
14
|
+
this.context = context;
|
|
15
|
+
this.options = context.options;
|
|
16
|
+
this._active = false;
|
|
17
|
+
/** @type {HTMLTextAreaElement|null} */
|
|
18
|
+
this._textarea = null;
|
|
19
|
+
this._disposers = [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
initialize() {
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
destroy() {
|
|
27
|
+
this._disposers.forEach((d) => d());
|
|
28
|
+
this._disposers = [];
|
|
29
|
+
if (this._textarea && this._textarea.parentNode) {
|
|
30
|
+
this._textarea.parentNode.removeChild(this._textarea);
|
|
31
|
+
}
|
|
32
|
+
this._textarea = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Public API
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
toggle() {
|
|
40
|
+
if (this._active) {
|
|
41
|
+
this.deactivate();
|
|
42
|
+
} else {
|
|
43
|
+
this.activate();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
isActive() {
|
|
48
|
+
return this._active;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
activate() {
|
|
52
|
+
if (this._active) return;
|
|
53
|
+
const { editable } = this.context.layoutInfo;
|
|
54
|
+
const html = editable.innerHTML;
|
|
55
|
+
|
|
56
|
+
this._textarea = createElement('textarea', {
|
|
57
|
+
class: 'an-codeview',
|
|
58
|
+
spellcheck: 'false',
|
|
59
|
+
autocomplete: 'off',
|
|
60
|
+
autocorrect: 'off',
|
|
61
|
+
autocapitalize: 'off',
|
|
62
|
+
});
|
|
63
|
+
this._textarea.value = this._prettyPrint(html);
|
|
64
|
+
|
|
65
|
+
editable.style.display = 'none';
|
|
66
|
+
editable.parentNode.insertBefore(this._textarea, editable.nextSibling);
|
|
67
|
+
|
|
68
|
+
this._active = true;
|
|
69
|
+
this.context.invoke('toolbar.refresh');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
deactivate() {
|
|
73
|
+
if (!this._active || !this._textarea) return;
|
|
74
|
+
const { editable } = this.context.layoutInfo;
|
|
75
|
+
// Sanitise the HTML typed in the textarea before applying (allow iframes for video embeds)
|
|
76
|
+
editable.innerHTML = sanitiseHTML(this._textarea.value, { allowIframes: true });
|
|
77
|
+
this._textarea.parentNode.removeChild(this._textarea);
|
|
78
|
+
this._textarea = null;
|
|
79
|
+
editable.style.display = '';
|
|
80
|
+
this._active = false;
|
|
81
|
+
this.context.invoke('toolbar.refresh');
|
|
82
|
+
this.context.invoke('editor.afterCommand');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Helpers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Very simple HTML pretty-printer (indent nested tags).
|
|
91
|
+
* Inline elements (a, span, strong, em, etc.) are kept on one line to
|
|
92
|
+
* avoid injecting whitespace that would change rendered text.
|
|
93
|
+
* @param {string} html
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
_prettyPrint(html) {
|
|
97
|
+
// Tags whose content must NOT be split with newlines (breaks text rendering)
|
|
98
|
+
const INLINE_RE = /^<(a|abbr|b|bdo|br|button|cite|code|dfn|em|i|img|input|kbd|label|output|q|samp|select|small|span|strong|sub|sup|textarea|time|tt|u|var)([\s>/])/i;
|
|
99
|
+
let indent = 0;
|
|
100
|
+
return html
|
|
101
|
+
.replace(/>\s*</g, '>\n<')
|
|
102
|
+
.split('\n')
|
|
103
|
+
.map((line) => {
|
|
104
|
+
const stripped = line.trim();
|
|
105
|
+
if (!stripped) return '';
|
|
106
|
+
if (/^<\//.test(stripped)) indent = Math.max(0, indent - 1);
|
|
107
|
+
const out = ' '.repeat(indent) + stripped;
|
|
108
|
+
if (
|
|
109
|
+
/^<[^/!][^>]*[^/]>/.test(stripped) &&
|
|
110
|
+
!INLINE_RE.test(stripped) &&
|
|
111
|
+
!/^<(br|hr|img|input|link|meta)/.test(stripped)
|
|
112
|
+
) {
|
|
113
|
+
indent++;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
})
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
}
|
|
122
|
+
|