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,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown.js - Lightweight Markdown → HTML converter for paste handling.
|
|
3
|
+
*
|
|
4
|
+
* Handles: headings H1–H6, bold/italic/strikethrough/inline-code, fenced code
|
|
5
|
+
* blocks (with language), blockquotes, unordered/ordered lists, horizontal
|
|
6
|
+
* rules, links, images, and plain paragraphs.
|
|
7
|
+
*
|
|
8
|
+
* The HTML output MUST be passed through sanitiseHTML() before insertion.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Converts an HTML string to Markdown.
|
|
13
|
+
* Handles: headings, paragraphs, bold/italic/del/code, links, images,
|
|
14
|
+
* unordered/ordered lists, blockquote, pre/code blocks, tables, hr.
|
|
15
|
+
* @param {string} html
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function htmlToMarkdown(html) {
|
|
19
|
+
const doc = new DOMParser().parseFromString(`<body>${html || ''}</body>`, 'text/html');
|
|
20
|
+
return _domToMd(doc.body).replace(/\n{3,}/g, '\n\n').trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _domToMd(node) {
|
|
24
|
+
if (node.nodeType === 3) {
|
|
25
|
+
return node.textContent.replace(/\s+/g, ' ');
|
|
26
|
+
}
|
|
27
|
+
if (node.nodeType !== 1) return '';
|
|
28
|
+
|
|
29
|
+
const tag = node.nodeName.toLowerCase();
|
|
30
|
+
const inner = () => Array.from(node.childNodes).map(_domToMd).join('');
|
|
31
|
+
|
|
32
|
+
switch (tag) {
|
|
33
|
+
case 'p':
|
|
34
|
+
case 'div': return `\n\n${inner()}\n\n`;
|
|
35
|
+
case 'br': return ' \n';
|
|
36
|
+
case 'h1': return `\n\n# ${inner()}\n\n`;
|
|
37
|
+
case 'h2': return `\n\n## ${inner()}\n\n`;
|
|
38
|
+
case 'h3': return `\n\n### ${inner()}\n\n`;
|
|
39
|
+
case 'h4': return `\n\n#### ${inner()}\n\n`;
|
|
40
|
+
case 'h5': return `\n\n##### ${inner()}\n\n`;
|
|
41
|
+
case 'h6': return `\n\n###### ${inner()}\n\n`;
|
|
42
|
+
case 'strong':
|
|
43
|
+
case 'b': return `**${inner()}**`;
|
|
44
|
+
case 'em':
|
|
45
|
+
case 'i': return `*${inner()}*`;
|
|
46
|
+
case 'del':
|
|
47
|
+
case 's':
|
|
48
|
+
case 'strike': return `~~${inner()}~~`;
|
|
49
|
+
case 'sup': return `^${inner()}`;
|
|
50
|
+
case 'sub': return `~${inner()}`;
|
|
51
|
+
case 'code': {
|
|
52
|
+
// Inside <pre> we emit raw text; outside we wrap in backticks
|
|
53
|
+
if (node.closest('pre')) return inner();
|
|
54
|
+
return `\`${inner()}\``;
|
|
55
|
+
}
|
|
56
|
+
case 'pre': {
|
|
57
|
+
const codeEl = node.querySelector('code');
|
|
58
|
+
const langMatch = ((codeEl && codeEl.className) || '').match(/language-(\S+)/);
|
|
59
|
+
const lang = langMatch ? langMatch[1] : '';
|
|
60
|
+
const content = (codeEl || node).textContent || '';
|
|
61
|
+
return `\n\n\`\`\`${lang}\n${content}\n\`\`\`\n\n`;
|
|
62
|
+
}
|
|
63
|
+
case 'blockquote': {
|
|
64
|
+
const lines = inner().trim().split('\n');
|
|
65
|
+
return `\n\n${lines.map((l) => `> ${l}`).join('\n')}\n\n`;
|
|
66
|
+
}
|
|
67
|
+
case 'a': {
|
|
68
|
+
const href = node.getAttribute('href') || '';
|
|
69
|
+
return `[${inner()}](${href})`;
|
|
70
|
+
}
|
|
71
|
+
case 'img': {
|
|
72
|
+
const src = node.getAttribute('src') || '';
|
|
73
|
+
const alt = node.getAttribute('alt') || '';
|
|
74
|
+
return ``;
|
|
75
|
+
}
|
|
76
|
+
case 'ul': {
|
|
77
|
+
const items = Array.from(node.querySelectorAll(':scope > li'));
|
|
78
|
+
if (!items.length) return inner();
|
|
79
|
+
return `\n\n${items.map((li) => `- ${_domToMd(li).trim()}`).join('\n')}\n\n`;
|
|
80
|
+
}
|
|
81
|
+
case 'ol': {
|
|
82
|
+
const items = Array.from(node.querySelectorAll(':scope > li'));
|
|
83
|
+
if (!items.length) return inner();
|
|
84
|
+
return `\n\n${items.map((li, i) => `${i + 1}. ${_domToMd(li).trim()}`).join('\n')}\n\n`;
|
|
85
|
+
}
|
|
86
|
+
case 'li': return inner();
|
|
87
|
+
case 'hr': return '\n\n---\n\n';
|
|
88
|
+
case 'table': {
|
|
89
|
+
const rows = Array.from(node.querySelectorAll('tr'));
|
|
90
|
+
if (!rows.length) return inner();
|
|
91
|
+
const cellTexts = rows.map((tr) =>
|
|
92
|
+
Array.from(tr.querySelectorAll('th, td')).map((c) => c.textContent.trim().replace(/\|/g, '\\|')),
|
|
93
|
+
);
|
|
94
|
+
const cols = Math.max(...cellTexts.map((r) => r.length));
|
|
95
|
+
const padRow = (row) => { const r = [...row]; while (r.length < cols) r.push(''); return r; };
|
|
96
|
+
let md = '\n\n';
|
|
97
|
+
md += `| ${padRow(cellTexts[0]).join(' | ')} |\n`;
|
|
98
|
+
md += `| ${Array(cols).fill('---').join(' | ')} |\n`;
|
|
99
|
+
for (let r = 1; r < cellTexts.length; r++) {
|
|
100
|
+
md += `| ${padRow(cellTexts[r]).join(' | ')} |\n`;
|
|
101
|
+
}
|
|
102
|
+
return md + '\n';
|
|
103
|
+
}
|
|
104
|
+
default: return inner();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Returns true if the text contains recognisable Markdown patterns.
|
|
110
|
+
* @param {string} text
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
export function isMarkdown(text) {
|
|
114
|
+
return /^#{1,6} \S|^\s*[-*+] \S|^\s*\d+\. \S|^> \S|\*{2}.+?\*{2}|^```/m.test(text);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Converts a Markdown string to an HTML string.
|
|
119
|
+
* @param {string} text
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
export function markdownToHTML(text) {
|
|
123
|
+
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
|
124
|
+
const out = [];
|
|
125
|
+
let i = 0;
|
|
126
|
+
|
|
127
|
+
while (i < lines.length) {
|
|
128
|
+
const line = lines[i];
|
|
129
|
+
|
|
130
|
+
// ---- Fenced code block ``` lang ... ``` -----------------------------------
|
|
131
|
+
const fenceMatch = line.match(/^```(\S*)$/);
|
|
132
|
+
if (fenceMatch) {
|
|
133
|
+
const lang = fenceMatch[1];
|
|
134
|
+
const codeLines = [];
|
|
135
|
+
i++;
|
|
136
|
+
while (i < lines.length && !lines[i].startsWith('```')) {
|
|
137
|
+
codeLines.push(_esc(lines[i]));
|
|
138
|
+
i++;
|
|
139
|
+
}
|
|
140
|
+
const langAttr = lang ? ` class="language-${_escAttr(lang)}"` : '';
|
|
141
|
+
out.push(`<pre><code${langAttr}>${codeLines.join('\n')}</code></pre>`);
|
|
142
|
+
i++; // skip closing ```
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- Horizontal rule --- / *** / _________________________________________
|
|
147
|
+
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
148
|
+
out.push('<hr>');
|
|
149
|
+
i++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- ATX Headings # – ###### -------------------------------------------
|
|
154
|
+
const hMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
155
|
+
if (hMatch) {
|
|
156
|
+
const level = hMatch[1].length;
|
|
157
|
+
out.push(`<h${level}>${_inline(hMatch[2])}</h${level}>`);
|
|
158
|
+
i++;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- Blockquote > text --------------------------------------------------
|
|
163
|
+
if (line.startsWith('> ')) {
|
|
164
|
+
const bqLines = [];
|
|
165
|
+
while (i < lines.length && lines[i].startsWith('> ')) {
|
|
166
|
+
bqLines.push(lines[i].slice(2));
|
|
167
|
+
i++;
|
|
168
|
+
}
|
|
169
|
+
out.push(`<blockquote>${bqLines.map(_inline).join('<br>')}</blockquote>`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- Unordered list - / * / + item ------------------------------------
|
|
174
|
+
if (/^[-*+] /.test(line)) {
|
|
175
|
+
const items = [];
|
|
176
|
+
while (i < lines.length && /^[-*+] /.test(lines[i])) {
|
|
177
|
+
items.push(`<li>${_inline(lines[i].slice(2))}</li>`);
|
|
178
|
+
i++;
|
|
179
|
+
}
|
|
180
|
+
out.push(`<ul>${items.join('')}</ul>`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- Ordered list 1. item ----------------------------------------------
|
|
185
|
+
if (/^\d+\. /.test(line)) {
|
|
186
|
+
const items = [];
|
|
187
|
+
while (i < lines.length && /^\d+\. /.test(lines[i])) {
|
|
188
|
+
items.push(`<li>${_inline(lines[i].replace(/^\d+\. /, ''))}</li>`);
|
|
189
|
+
i++;
|
|
190
|
+
}
|
|
191
|
+
out.push(`<ol>${items.join('')}</ol>`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- Blank line ----------------------------------------------------------
|
|
196
|
+
if (line.trim() === '') {
|
|
197
|
+
i++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- GFM Table | col | col | -------------------------------------------
|
|
202
|
+
// A table starts with a pipe-prefixed or pipe-containing line followed by
|
|
203
|
+
// a separator row (| --- | --- |). We detect and collect all rows.
|
|
204
|
+
if (/^\|.+\|/.test(line) && i + 1 < lines.length && /^\|[\s|:-]+\|/.test(lines[i + 1])) {
|
|
205
|
+
const headerCells = _parseTableRow(line);
|
|
206
|
+
i += 2; // skip header + separator
|
|
207
|
+
const bodyRows = [];
|
|
208
|
+
while (i < lines.length && /^\|.+\|/.test(lines[i])) {
|
|
209
|
+
bodyRows.push(_parseTableRow(lines[i]));
|
|
210
|
+
i++;
|
|
211
|
+
}
|
|
212
|
+
const thead = `<thead><tr>${headerCells.map((c) => `<th>${_inline(c)}</th>`).join('')}</tr></thead>`;
|
|
213
|
+
const tbody = bodyRows.length
|
|
214
|
+
? `<tbody>${bodyRows.map((row) => `<tr>${row.map((c) => `<td>${_inline(c)}</td>`).join('')}</tr>`).join('')}</tbody>`
|
|
215
|
+
: '';
|
|
216
|
+
out.push(`<table>${thead}${tbody}</table>`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- Paragraph: collect consecutive non-block lines ---------------------
|
|
221
|
+
const paraLines = [];
|
|
222
|
+
while (
|
|
223
|
+
i < lines.length &&
|
|
224
|
+
lines[i].trim() !== '' &&
|
|
225
|
+
!/^(#{1,6} |> |[-*+] |\d+\. |```|---\s*$|\*{3}\s*$|_{3}\s*$)/.test(lines[i]) &&
|
|
226
|
+
!/^\|.+\|/.test(lines[i])
|
|
227
|
+
) {
|
|
228
|
+
paraLines.push(lines[i]);
|
|
229
|
+
i++;
|
|
230
|
+
}
|
|
231
|
+
if (paraLines.length) {
|
|
232
|
+
out.push(`<p>${_inline(paraLines.join(' '))}</p>`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return out.join('');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Inline formatting
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Splits a GFM table row string into trimmed cell strings.
|
|
245
|
+
* '| a | b | c |' → ['a', 'b', 'c']
|
|
246
|
+
* @param {string} row
|
|
247
|
+
* @returns {string[]}
|
|
248
|
+
*/
|
|
249
|
+
function _parseTableRow(row) {
|
|
250
|
+
return row
|
|
251
|
+
.replace(/^\|/, '')
|
|
252
|
+
.replace(/\|$/, '')
|
|
253
|
+
.split('|')
|
|
254
|
+
.map((c) => c.trim());
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function _inline(text) {
|
|
258
|
+
// Images before links (they share [] syntax)
|
|
259
|
+
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) =>
|
|
260
|
+
`<img src="${_escAttr(src)}" alt="${_escAttr(alt)}" class="an-image">`);
|
|
261
|
+
// Links
|
|
262
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) =>
|
|
263
|
+
`<a href="${_escAttr(href)}">${_esc(label)}</a>`);
|
|
264
|
+
// Bold + italic ***text***
|
|
265
|
+
text = text.replace(/\*{3}(.+?)\*{3}/g, (_, c) => `<strong><em>${_esc(c)}</em></strong>`);
|
|
266
|
+
text = text.replace(/_{3}(.+?)_{3}/g, (_, c) => `<strong><em>${_esc(c)}</em></strong>`);
|
|
267
|
+
// Bold **text**
|
|
268
|
+
text = text.replace(/\*{2}(.+?)\*{2}/g, (_, c) => `<strong>${_esc(c)}</strong>`);
|
|
269
|
+
text = text.replace(/_{2}(.+?)_{2}/g, (_, c) => `<strong>${_esc(c)}</strong>`);
|
|
270
|
+
// Italic *text* _text_
|
|
271
|
+
text = text.replace(/\*([^*\n]+?)\*/g, (_, c) => `<em>${_esc(c)}</em>`);
|
|
272
|
+
text = text.replace(/_([^_\n]+?)_/g, (_, c) => `<em>${_esc(c)}</em>`);
|
|
273
|
+
// Strikethrough ~~text~~
|
|
274
|
+
text = text.replace(/~~(.+?)~~/g, (_, c) => `<del>${_esc(c)}</del>`);
|
|
275
|
+
// Inline code `code`
|
|
276
|
+
text = text.replace(/`([^`]+)`/g, (_, c) => `<code>${_esc(c)}</code>`);
|
|
277
|
+
return text;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function _esc(v) {
|
|
281
|
+
return String(v)
|
|
282
|
+
.replace(/&/g, '&')
|
|
283
|
+
.replace(/</g, '<')
|
|
284
|
+
.replace(/>/g, '>');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function _escAttr(v) {
|
|
288
|
+
return String(v)
|
|
289
|
+
.replace(/&/g, '&')
|
|
290
|
+
.replace(/"/g, '"')
|
|
291
|
+
.replace(/'/g, ''')
|
|
292
|
+
.replace(/</g, '<')
|
|
293
|
+
.replace(/>/g, '>');
|
|
294
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* range.js - Selection and Range utilities
|
|
3
|
+
* Inspired by Summernote's range.js — rewritten as vanilla JS
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { isElement, closest } from './dom.js';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// WrappedRange — a convenience wrapper over the native Range API
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export class WrappedRange {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Node} sc - start container
|
|
15
|
+
* @param {number} so - start offset
|
|
16
|
+
* @param {Node} ec - end container
|
|
17
|
+
* @param {number} eo - end offset
|
|
18
|
+
*/
|
|
19
|
+
constructor(sc, so, ec, eo) {
|
|
20
|
+
this.sc = sc;
|
|
21
|
+
this.so = so;
|
|
22
|
+
this.ec = ec;
|
|
23
|
+
this.eo = eo;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @returns {boolean} */
|
|
27
|
+
isCollapsed() {
|
|
28
|
+
return this.sc === this.ec && this.so === this.eo;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @returns {Range} */
|
|
32
|
+
toNativeRange() {
|
|
33
|
+
const range = document.createRange();
|
|
34
|
+
try {
|
|
35
|
+
range.setStart(this.sc, this.so);
|
|
36
|
+
range.setEnd(this.ec, this.eo);
|
|
37
|
+
} catch (_e) {
|
|
38
|
+
// Guard against detached nodes
|
|
39
|
+
}
|
|
40
|
+
return range;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Select this wrapped range in the window.
|
|
45
|
+
*/
|
|
46
|
+
select() {
|
|
47
|
+
const sel = window.getSelection();
|
|
48
|
+
if (!sel) return;
|
|
49
|
+
sel.removeAllRanges();
|
|
50
|
+
sel.addRange(this.toNativeRange());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns the common ancestor element of this range.
|
|
55
|
+
* @returns {Element|null}
|
|
56
|
+
*/
|
|
57
|
+
commonAncestor() {
|
|
58
|
+
const native = this.toNativeRange();
|
|
59
|
+
const ancestor = native.commonAncestorContainer;
|
|
60
|
+
return isElement(ancestor) ? ancestor : ancestor.parentElement;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns the nearest paragraph/block ancestor within the editable area.
|
|
65
|
+
* @param {HTMLElement} editable
|
|
66
|
+
* @returns {Element|null}
|
|
67
|
+
*/
|
|
68
|
+
blockNode(editable) {
|
|
69
|
+
return closest(this.sc, (n) => isElement(n) && n !== editable, editable);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns either the selected text string or empty string.
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
toString() {
|
|
77
|
+
return this.toNativeRange().toString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Returns the bounding DOMRect of the range (or null).
|
|
82
|
+
* @returns {DOMRect|null}
|
|
83
|
+
*/
|
|
84
|
+
getClientRects() {
|
|
85
|
+
const rects = this.toNativeRange().getClientRects();
|
|
86
|
+
return rects.length > 0 ? rects[rects.length - 1] : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Inserts a node at the start of this range.
|
|
91
|
+
* @param {Node} node
|
|
92
|
+
*/
|
|
93
|
+
insertNode(node) {
|
|
94
|
+
const native = this.toNativeRange();
|
|
95
|
+
native.insertNode(node);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Factory helpers
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Creates a WrappedRange from a native Range object.
|
|
106
|
+
* @param {Range} range
|
|
107
|
+
* @returns {WrappedRange}
|
|
108
|
+
*/
|
|
109
|
+
export function fromNativeRange(range) {
|
|
110
|
+
return new WrappedRange(
|
|
111
|
+
range.startContainer,
|
|
112
|
+
range.startOffset,
|
|
113
|
+
range.endContainer,
|
|
114
|
+
range.endOffset,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Returns a WrappedRange for the current window selection,
|
|
120
|
+
* optionally restricted to a given editable element.
|
|
121
|
+
* @param {HTMLElement} [editable]
|
|
122
|
+
* @returns {WrappedRange|null}
|
|
123
|
+
*/
|
|
124
|
+
export function currentRange(editable) {
|
|
125
|
+
const sel = window.getSelection();
|
|
126
|
+
if (!sel || sel.rangeCount === 0) return null;
|
|
127
|
+
const native = sel.getRangeAt(0);
|
|
128
|
+
// Optionally check that the selection is inside the editable element
|
|
129
|
+
if (editable && !editable.contains(native.commonAncestorContainer)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return fromNativeRange(native);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Creates a WrappedRange that covers the entire content of an element.
|
|
137
|
+
* @param {HTMLElement} el
|
|
138
|
+
* @returns {WrappedRange}
|
|
139
|
+
*/
|
|
140
|
+
export function rangeFromElement(el) {
|
|
141
|
+
return new WrappedRange(el, 0, el, el.childNodes.length);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Creates a collapsed range (cursor) at the given node / offset.
|
|
146
|
+
* @param {Node} node
|
|
147
|
+
* @param {number} offset
|
|
148
|
+
* @returns {WrappedRange}
|
|
149
|
+
*/
|
|
150
|
+
export function collapsedRange(node, offset = 0) {
|
|
151
|
+
return new WrappedRange(node, offset, node, offset);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Utility helpers
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Returns true if the current selection is inside the given element.
|
|
160
|
+
* @param {HTMLElement} el
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
export function isSelectionInside(el) {
|
|
164
|
+
const sel = window.getSelection();
|
|
165
|
+
if (!sel || sel.rangeCount === 0) return false;
|
|
166
|
+
return el.contains(sel.getRangeAt(0).commonAncestorContainer);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Saves the current selection, executes fn, then restores the selection.
|
|
171
|
+
* @param {Function} fn
|
|
172
|
+
*/
|
|
173
|
+
export function withSavedRange(fn) {
|
|
174
|
+
const sel = window.getSelection();
|
|
175
|
+
if (!sel || sel.rangeCount === 0) {
|
|
176
|
+
fn(null);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const saved = sel.getRangeAt(0).cloneRange();
|
|
180
|
+
fn(fromNativeRange(saved));
|
|
181
|
+
sel.removeAllRanges();
|
|
182
|
+
sel.addRange(saved);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Splits the text node at the given offset and returns the two halves.
|
|
187
|
+
* @param {Text} textNode
|
|
188
|
+
* @param {number} offset
|
|
189
|
+
* @returns {[Text, Text]}
|
|
190
|
+
*/
|
|
191
|
+
export function splitText(textNode, offset) {
|
|
192
|
+
const after = textNode.splitText(offset);
|
|
193
|
+
return [textNode, after];
|
|
194
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sanitise.js - Shared HTML and URL sanitisation utilities
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth used by Editor, Clipboard, Codeview, and renderer.
|
|
5
|
+
* DOM-parser based — no regex-based stripping of HTML (avoids bypass tricks).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Tags that are unconditionally removed from editor content. */
|
|
9
|
+
const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'];
|
|
10
|
+
|
|
11
|
+
/** Attributes whose values must be sanitised as URLs. */
|
|
12
|
+
const URL_ATTRS = ['href', 'src', 'action', 'formaction'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sanitises an HTML string by removing dangerous elements and attributes.
|
|
16
|
+
* Uses DOMParser so the sanitisation follows normal browser parsing rules —
|
|
17
|
+
* no regex shortcuts that can be bypassed by encoding tricks.
|
|
18
|
+
*
|
|
19
|
+
* - Strips PROHIBITED_TAGS (script, style, iframe, object, embed, form, input, button)
|
|
20
|
+
* - Removes all on* event-handler attributes
|
|
21
|
+
* - Rejects javascript: and vbscript: URLs in URL attributes
|
|
22
|
+
* - Rejects data: URIs everywhere except img[src] (base64 uploads)
|
|
23
|
+
*
|
|
24
|
+
* @param {string} html
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function sanitiseHTML(html, { allowIframes = false } = {}) {
|
|
28
|
+
const doc = new DOMParser().parseFromString(`<body>${html || ''}</body>`, 'text/html');
|
|
29
|
+
|
|
30
|
+
// Remove outright dangerous elements (optionally preserve iframes for video embeds)
|
|
31
|
+
const tags = allowIframes ? PROHIBITED_TAGS.filter((t) => t !== 'iframe') : PROHIBITED_TAGS;
|
|
32
|
+
tags.forEach((tag) => {
|
|
33
|
+
doc.querySelectorAll(tag).forEach((el) => el.remove());
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Strip dangerous attributes from remaining elements
|
|
37
|
+
doc.querySelectorAll('*').forEach((el) => {
|
|
38
|
+
Array.from(el.attributes).forEach((attr) => {
|
|
39
|
+
// Remove all event handlers (onclick, onload, onerror, …)
|
|
40
|
+
if (attr.name.startsWith('on')) {
|
|
41
|
+
el.removeAttribute(attr.name);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// Sanitise URL attributes
|
|
45
|
+
if (URL_ATTRS.includes(attr.name)) {
|
|
46
|
+
const val = attr.value.trim();
|
|
47
|
+
// Block javascript: and vbscript: protocols
|
|
48
|
+
if (/^(javascript|vbscript):/i.test(val)) {
|
|
49
|
+
el.removeAttribute(attr.name);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Allow data: URIs only on img[src] (base64 image uploads); block elsewhere
|
|
53
|
+
if (/^data:/i.test(val) && !(attr.name === 'src' && el.tagName === 'IMG')) {
|
|
54
|
+
el.removeAttribute(attr.name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return doc.body.innerHTML;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Sanitises a URL string, rejecting dangerous protocols.
|
|
65
|
+
*
|
|
66
|
+
* Blocked protocols: javascript:, vbscript:
|
|
67
|
+
* Optionally blocked: data: (safe to allow for img/src base64 embeds)
|
|
68
|
+
*
|
|
69
|
+
* @param {string} url
|
|
70
|
+
* @param {{ allowData?: boolean }} [opts]
|
|
71
|
+
* @returns {string|null} The original URL if safe, null if rejected.
|
|
72
|
+
*/
|
|
73
|
+
export function sanitiseUrl(url, { allowData = false } = {}) {
|
|
74
|
+
const trimmed = (url || '').trim();
|
|
75
|
+
if (/^(javascript|vbscript):/i.test(trimmed)) return null;
|
|
76
|
+
if (!allowData && /^data:/i.test(trimmed)) return null;
|
|
77
|
+
return url;
|
|
78
|
+
}
|