react-bootstrap-front 3.10.2 → 3.11.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/CHANGELOG +9 -0
- package/dist/react-bootstrap-front.js +14 -14
- package/doc/editor/feuille-de-route.md +36 -0
- package/package.json +42 -8
- package/src/features/advanced/DropdownWrapper.js +12 -2
- package/src/features/basic/Dropdown.js +112 -31
- package/src/features/basic/computePlacement.js +66 -0
- package/src/features/editor/HtmlEditor.js +681 -0
- package/src/features/editor/HtmlEditor.scss +189 -0
- package/src/features/editor/ImageDialog.js +195 -0
- package/src/features/editor/LinkDialog.js +148 -0
- package/src/features/editor/MergeMenu.js +120 -0
- package/src/features/editor/SourceView.js +61 -0
- package/src/features/editor/Toolbar.js +355 -0
- package/src/features/editor/Toolbar.scss +130 -0
- package/src/features/editor/ToolbarButton.js +59 -0
- package/src/features/editor/ToolbarDropdown.js +46 -0
- package/src/features/editor/ToolbarGroup.js +27 -0
- package/src/features/editor/buildExtensions.js +77 -0
- package/src/features/editor/defaultIcons.js +45 -0
- package/src/features/editor/extensions/CleanPaste.js +44 -0
- package/src/features/editor/extensions/FontSize.js +60 -0
- package/src/features/editor/extensions/LegacyImageAttrs.js +43 -0
- package/src/features/editor/extensions/MergeVariable.js +104 -0
- package/src/features/editor/extensions/index.js +4 -0
- package/src/features/editor/html/cleanHtml.constants.js +279 -0
- package/src/features/editor/html/cleanHtml.js +691 -0
- package/src/features/editor/html/index.js +4 -0
- package/src/features/editor/html/normalizeLegacyHtml.js +196 -0
- package/src/features/editor/html/serialize.js +130 -0
- package/src/features/editor/html/sourceHelpers.js +86 -0
- package/src/features/editor/index.js +20 -0
- package/src/features/editor/style.scss +2 -0
- package/src/features/editor/toolbarPresets.js +74 -0
- package/src/features/page/DefaultHeader.jsx +1 -1
- package/src/index.js +8 -0
- package/src/styles/index.scss +1 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { CLEAN_PASTE, CLEAN_PROFILES } from './cleanHtml.constants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
*******************************************************************************
|
|
5
|
+
* Pré-nettoyage textuel
|
|
6
|
+
*******************************************************************************
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const PRE_CLEAN_PATTERNS = [
|
|
10
|
+
/* Commentaires conditionnels Word : <!--[if gte mso 9]> ... <![endif]--> */
|
|
11
|
+
[/<!--\[if[\s\S]*?<!\[endif\]-->/gi, ''],
|
|
12
|
+
/* Commentaires (StartFragment, EndFragment, tout le reste) */
|
|
13
|
+
[/<!--[\s\S]*?-->/g, ''],
|
|
14
|
+
/* Déclarations */
|
|
15
|
+
[/<!DOCTYPE[^>]*>/gi, ''],
|
|
16
|
+
/* Blocs entiers à jeter avec leur contenu : Word injecte des kilo-octets de <style> */
|
|
17
|
+
[/<(script|style|xml|head|title|noscript)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ''],
|
|
18
|
+
/* Les mêmes, non refermés */
|
|
19
|
+
[/<(script|style|xml|head|title|noscript)\b[^>]*>/gi, ''],
|
|
20
|
+
/* Balises à namespace Office : <o:p>, <v:shape>, <w:sdt> */
|
|
21
|
+
[/<\/?[a-z][a-z0-9]*:[^>]*>/gi, ''],
|
|
22
|
+
/* Marques de position invisibles */
|
|
23
|
+
[/[\u200b\u200c\u200d\ufeff]/g, ''],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function preClean(html) {
|
|
27
|
+
let out = String(html);
|
|
28
|
+
for (let i = 0; i < PRE_CLEAN_PATTERNS.length; i++) {
|
|
29
|
+
out = out.replace(PRE_CLEAN_PATTERNS[i][0], PRE_CLEAN_PATTERNS[i][1]);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
*******************************************************************************
|
|
36
|
+
* Styles : analyse et sérialisation
|
|
37
|
+
*******************************************************************************
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Découpe un attribut style en couples [propriété, valeur].
|
|
42
|
+
* Le découpage respecte les parenthèses et les guillemets, sans quoi un
|
|
43
|
+
* `url(data:image/png;base64,...)` ou un `font-family: "a;b"` casserait tout.
|
|
44
|
+
*/
|
|
45
|
+
export function parseStyle(value) {
|
|
46
|
+
const pairs = [];
|
|
47
|
+
const str = String(value || '');
|
|
48
|
+
let depth = 0;
|
|
49
|
+
let quote = null;
|
|
50
|
+
let current = '';
|
|
51
|
+
for (let i = 0; i < str.length; i++) {
|
|
52
|
+
const c = str.charAt(i);
|
|
53
|
+
if (quote) {
|
|
54
|
+
current += c;
|
|
55
|
+
if (c === quote && str.charAt(i - 1) !== '\\') {
|
|
56
|
+
quote = null;
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (c === '"' || c === "'") {
|
|
61
|
+
quote = c;
|
|
62
|
+
current += c;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (c === '(') {
|
|
66
|
+
depth++;
|
|
67
|
+
} else if (c === ')') {
|
|
68
|
+
depth = Math.max(0, depth - 1);
|
|
69
|
+
}
|
|
70
|
+
if (c === ';' && depth === 0) {
|
|
71
|
+
pushPair(pairs, current);
|
|
72
|
+
current = '';
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
current += c;
|
|
76
|
+
}
|
|
77
|
+
pushPair(pairs, current);
|
|
78
|
+
return pairs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pushPair(pairs, chunk) {
|
|
82
|
+
const trimmed = chunk.trim();
|
|
83
|
+
if (trimmed === '') {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const idx = trimmed.indexOf(':');
|
|
87
|
+
if (idx <= 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const prop = trimmed
|
|
91
|
+
.slice(0, idx)
|
|
92
|
+
.trim()
|
|
93
|
+
.toLowerCase();
|
|
94
|
+
const val = trimmed.slice(idx + 1).trim();
|
|
95
|
+
if (prop === '' || val === '') {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
pairs.push([prop, val]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function serializeStyle(pairs) {
|
|
102
|
+
const parts = [];
|
|
103
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
104
|
+
parts.push(pairs[i][0] + ': ' + pairs[i][1]);
|
|
105
|
+
}
|
|
106
|
+
return parts.join('; ');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Normalise une valeur CSS pour la comparer : minuscules, guillemets retirés,
|
|
111
|
+
* espaces compactés puis supprimés autour des virgules.
|
|
112
|
+
* `"Times New Roman", serif` et `'times new roman',serif` donnent le même résultat.
|
|
113
|
+
*/
|
|
114
|
+
function normalizeCssValue(value) {
|
|
115
|
+
return String(value)
|
|
116
|
+
.toLowerCase()
|
|
117
|
+
.replace(/["']/g, '')
|
|
118
|
+
.replace(/\s+/g, ' ')
|
|
119
|
+
.replace(/\s*,\s*/g, ',')
|
|
120
|
+
.replace(/!\s*important$/, '')
|
|
121
|
+
.trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Normalise une longueur CSS pour la comparer : `11.0pt` et `11pt` désignent la
|
|
126
|
+
* même chose, et Word écrit systématiquement la première forme.
|
|
127
|
+
*/
|
|
128
|
+
function normalizeCssLength(value) {
|
|
129
|
+
const match = /^([0-9]*\.?[0-9]+)\s*([a-z%]*)$/.exec(value);
|
|
130
|
+
if (!match) {
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
return parseFloat(match[1]) + match[2];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
*******************************************************************************
|
|
138
|
+
* Aides DOM
|
|
139
|
+
*******************************************************************************
|
|
140
|
+
*/
|
|
141
|
+
|
|
142
|
+
const BLOCK_TAGS = [
|
|
143
|
+
'p',
|
|
144
|
+
'div',
|
|
145
|
+
'h1',
|
|
146
|
+
'h2',
|
|
147
|
+
'h3',
|
|
148
|
+
'h4',
|
|
149
|
+
'h5',
|
|
150
|
+
'h6',
|
|
151
|
+
'ul',
|
|
152
|
+
'ol',
|
|
153
|
+
'li',
|
|
154
|
+
'blockquote',
|
|
155
|
+
'hr',
|
|
156
|
+
'table',
|
|
157
|
+
'thead',
|
|
158
|
+
'tbody',
|
|
159
|
+
'tfoot',
|
|
160
|
+
'tr',
|
|
161
|
+
'td',
|
|
162
|
+
'th',
|
|
163
|
+
'pre',
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
function isBlockTag(tag) {
|
|
167
|
+
return BLOCK_TAGS.indexOf(tag) !== -1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function remove(node) {
|
|
171
|
+
if (node && node.parentNode) {
|
|
172
|
+
node.parentNode.removeChild(node);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Remplace un élément par ses enfants. Le texte n'est jamais perdu.
|
|
178
|
+
*/
|
|
179
|
+
function unwrap(el) {
|
|
180
|
+
const parent = el.parentNode;
|
|
181
|
+
if (!parent) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
while (el.firstChild) {
|
|
185
|
+
parent.insertBefore(el.firstChild, el);
|
|
186
|
+
}
|
|
187
|
+
parent.removeChild(el);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Renomme un élément en conservant attributs et enfants.
|
|
192
|
+
* Retourne le nouvel élément.
|
|
193
|
+
*/
|
|
194
|
+
function rename(el, tagName) {
|
|
195
|
+
const doc = el.ownerDocument;
|
|
196
|
+
const next = doc.createElement(tagName);
|
|
197
|
+
const attrs = el.attributes;
|
|
198
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
199
|
+
try {
|
|
200
|
+
next.setAttribute(attrs[i].name, attrs[i].value);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
/* nom d'attribut invalide : on le laisse tomber */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
while (el.firstChild) {
|
|
206
|
+
next.appendChild(el.firstChild);
|
|
207
|
+
}
|
|
208
|
+
if (el.parentNode) {
|
|
209
|
+
el.parentNode.replaceChild(next, el);
|
|
210
|
+
}
|
|
211
|
+
return next;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function getScheme(url) {
|
|
215
|
+
const match = /^\s*([a-z][a-z0-9+.-]*:)/i.exec(String(url || ''));
|
|
216
|
+
return match ? match[1].toLowerCase() : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isDataImage(url) {
|
|
220
|
+
return /^\s*data:image\//i.test(String(url || ''));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function hasContent(el) {
|
|
224
|
+
/* Le nbsp compte comme du contenu : c'est une ligne blanche voulue. */
|
|
225
|
+
return el.textContent.replace(/[^\S\u00a0]+/g, '') !== '';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
*******************************************************************************
|
|
230
|
+
* Traitement d'un élément
|
|
231
|
+
*******************************************************************************
|
|
232
|
+
*/
|
|
233
|
+
|
|
234
|
+
function cleanAttributes(el, tag, opts) {
|
|
235
|
+
const attrs = Array.prototype.slice.call(el.attributes);
|
|
236
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
237
|
+
const name = attrs[i].name.toLowerCase();
|
|
238
|
+
if (name === 'style' || name === 'class') {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
/* Gestionnaires d'événements et bruit Office */
|
|
242
|
+
if (name.indexOf('on') === 0 || name.indexOf('mso-') === 0 || name.indexOf(':') !== -1) {
|
|
243
|
+
el.removeAttribute(attrs[i].name);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (opts.allowedAttributes) {
|
|
247
|
+
const allowed = (opts.allowedAttributes['*'] || []).concat(opts.allowedAttributes[tag] || []);
|
|
248
|
+
if (allowed.indexOf(name) === -1) {
|
|
249
|
+
el.removeAttribute(attrs[i].name);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/* Liens : un schéma exotique perd son href, l'élément reste (le texte survit) */
|
|
254
|
+
if (el.hasAttribute('href')) {
|
|
255
|
+
const scheme = getScheme(el.getAttribute('href'));
|
|
256
|
+
if (scheme !== null && opts.allowedHrefSchemes.indexOf(scheme) === -1) {
|
|
257
|
+
el.removeAttribute('href');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/* Images : un src inutilisable fait disparaître l'élément, une image inerte n'a pas de sens */
|
|
261
|
+
if (el.hasAttribute('src')) {
|
|
262
|
+
const src = el.getAttribute('src');
|
|
263
|
+
const scheme = getScheme(src);
|
|
264
|
+
const ok =
|
|
265
|
+
scheme === null ||
|
|
266
|
+
opts.allowedSrcSchemes.indexOf(scheme) !== -1 ||
|
|
267
|
+
(opts.allowDataImages && isDataImage(src));
|
|
268
|
+
if (!ok) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function cleanClasses(el, opts) {
|
|
276
|
+
if (!el.hasAttribute('class')) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (opts.keepClasses === null) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const keep = [];
|
|
283
|
+
const classes = el.getAttribute('class').split(/\s+/);
|
|
284
|
+
for (let i = 0; i < classes.length; i++) {
|
|
285
|
+
if (classes[i] !== '' && opts.keepClasses.indexOf(classes[i]) !== -1) {
|
|
286
|
+
keep.push(classes[i]);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (keep.length > 0) {
|
|
290
|
+
el.setAttribute('class', keep.join(' '));
|
|
291
|
+
} else {
|
|
292
|
+
el.removeAttribute('class');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function cleanStyle(el, opts) {
|
|
297
|
+
if (!el.hasAttribute('style')) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const pairs = parseStyle(el.getAttribute('style'));
|
|
301
|
+
const kept = [];
|
|
302
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
303
|
+
const prop = pairs[i][0];
|
|
304
|
+
const raw = pairs[i][1];
|
|
305
|
+
const value = normalizeCssValue(raw);
|
|
306
|
+
|
|
307
|
+
if (prop.indexOf('mso-') === 0 || prop.indexOf('-webkit-') === 0 || prop.indexOf('-moz-') === 0) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (opts.allowedStyles && opts.allowedStyles.indexOf(prop) === -1) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (opts.dropStyles && opts.dropStyles.indexOf(prop) !== -1) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (opts.neutralStyleValues) {
|
|
317
|
+
const neutral = opts.neutralStyleValues[prop];
|
|
318
|
+
if (neutral && neutral.indexOf(value) !== -1) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (prop === 'font-size' && opts.defaultFontSizes.indexOf(normalizeCssLength(value)) !== -1) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (prop === 'font-family' && opts.defaultFontFamilies.indexOf(value) !== -1) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
kept.push([prop, raw]);
|
|
329
|
+
}
|
|
330
|
+
if (kept.length > 0) {
|
|
331
|
+
el.setAttribute('style', serializeStyle(kept));
|
|
332
|
+
} else {
|
|
333
|
+
el.removeAttribute('style');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Traduit les attributs de présentation de <font> en style.
|
|
339
|
+
*/
|
|
340
|
+
const FONT_SIZE_MAP = { 1: '8pt', 2: '10pt', 3: '12pt', 4: '14pt', 5: '18pt', 6: '24pt', 7: '36pt' };
|
|
341
|
+
|
|
342
|
+
function fontToStyle(el) {
|
|
343
|
+
const pairs = parseStyle(el.getAttribute('style') || '');
|
|
344
|
+
const color = el.getAttribute('color');
|
|
345
|
+
const face = el.getAttribute('face');
|
|
346
|
+
const size = el.getAttribute('size');
|
|
347
|
+
if (color) {
|
|
348
|
+
pairs.push(['color', color]);
|
|
349
|
+
}
|
|
350
|
+
if (face) {
|
|
351
|
+
pairs.push(['font-family', face]);
|
|
352
|
+
}
|
|
353
|
+
if (size && FONT_SIZE_MAP[size]) {
|
|
354
|
+
pairs.push(['font-size', FONT_SIZE_MAP[size]]);
|
|
355
|
+
}
|
|
356
|
+
el.removeAttribute('color');
|
|
357
|
+
el.removeAttribute('face');
|
|
358
|
+
el.removeAttribute('size');
|
|
359
|
+
if (pairs.length > 0) {
|
|
360
|
+
el.setAttribute('style', serializeStyle(pairs));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function processElement(el, opts) {
|
|
365
|
+
let tag = el.tagName.toLowerCase();
|
|
366
|
+
|
|
367
|
+
/* 1. Balises jetées avec leur contenu */
|
|
368
|
+
if (opts.dropTags.indexOf(tag) !== -1) {
|
|
369
|
+
remove(el);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* 2. Signature Google Docs : <b style="font-weight:normal"> enveloppe tout le collage */
|
|
374
|
+
if (tag === 'b' || tag === 'strong') {
|
|
375
|
+
const style = normalizeCssValue(el.getAttribute('style') || '');
|
|
376
|
+
if (/font-weight:\s*(normal|400)/.test(style)) {
|
|
377
|
+
unwrap(el);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* 3. <font> : les attributs de présentation deviennent du style */
|
|
383
|
+
if (tag === 'font') {
|
|
384
|
+
fontToStyle(el);
|
|
385
|
+
el = rename(el, 'span');
|
|
386
|
+
tag = 'span';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* 4. Balises historiques */
|
|
390
|
+
if (opts.tagAliases && opts.tagAliases[tag]) {
|
|
391
|
+
el = rename(el, opts.tagAliases[tag]);
|
|
392
|
+
tag = el.tagName.toLowerCase();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/* 5. Attributs, classes, styles */
|
|
396
|
+
if (!cleanAttributes(el, tag, opts)) {
|
|
397
|
+
remove(el);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
cleanClasses(el, opts);
|
|
401
|
+
cleanStyle(el, opts);
|
|
402
|
+
|
|
403
|
+
/* 6. <div> : bloc s'il contient des blocs, sinon paragraphe */
|
|
404
|
+
if (tag === 'div' && opts.convertDivToP) {
|
|
405
|
+
if (containsBlock(el)) {
|
|
406
|
+
unwrap(el);
|
|
407
|
+
} else {
|
|
408
|
+
el = rename(el, 'p');
|
|
409
|
+
tag = 'p';
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/* 7. Balises inconnues : déballées, jamais supprimées */
|
|
415
|
+
if (opts.allowedTags && opts.allowedTags.indexOf(tag) === -1) {
|
|
416
|
+
unwrap(el);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* 8. <span> devenu inutile : c'est le cas le plus fréquent d'un collage Word */
|
|
421
|
+
if (tag === 'span' && opts.unwrapEmptySpans && el.attributes.length === 0) {
|
|
422
|
+
unwrap(el);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* 9. Marques identiques imbriquées */
|
|
427
|
+
if (opts.mergeNestedMarks && el.parentNode && el.parentNode.nodeType === 1) {
|
|
428
|
+
const parent = el.parentNode;
|
|
429
|
+
if (
|
|
430
|
+
parent.tagName.toLowerCase() === tag &&
|
|
431
|
+
parent.childNodes.length === 1 &&
|
|
432
|
+
(el.getAttribute('style') || '') === (parent.getAttribute('style') || '')
|
|
433
|
+
) {
|
|
434
|
+
unwrap(el);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function containsBlock(el) {
|
|
440
|
+
const children = el.children || [];
|
|
441
|
+
for (let i = 0; i < children.length; i++) {
|
|
442
|
+
if (isBlockTag(children[i].tagName.toLowerCase())) {
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Parcours profondeur d'abord, post-ordre : les enfants sont traités avant leur
|
|
451
|
+
* parent, ce qui permet au parent de constater qu'il est devenu inutile.
|
|
452
|
+
*/
|
|
453
|
+
function walk(node, opts) {
|
|
454
|
+
const children = Array.prototype.slice.call(node.childNodes);
|
|
455
|
+
for (let i = 0; i < children.length; i++) {
|
|
456
|
+
const child = children[i];
|
|
457
|
+
if (child.nodeType === 1) {
|
|
458
|
+
walk(child, opts);
|
|
459
|
+
if (child.parentNode) {
|
|
460
|
+
processElement(child, opts);
|
|
461
|
+
}
|
|
462
|
+
} else if (child.nodeType === 8) {
|
|
463
|
+
remove(child);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
*******************************************************************************
|
|
470
|
+
* Passes de finition
|
|
471
|
+
*******************************************************************************
|
|
472
|
+
*/
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Supprime les éléments devenus vides, en itérant jusqu'au point fixe : sans
|
|
476
|
+
* cela une cascade `<p><span><span></span></span></p>` ne serait purgée que
|
|
477
|
+
* d'un niveau par passe.
|
|
478
|
+
*/
|
|
479
|
+
function removeEmptyElements(root, opts) {
|
|
480
|
+
for (let pass = 0; pass < opts.maxPasses; pass++) {
|
|
481
|
+
const empties = [];
|
|
482
|
+
const all = root.getElementsByTagName('*');
|
|
483
|
+
for (let i = 0; i < all.length; i++) {
|
|
484
|
+
const el = all[i];
|
|
485
|
+
const tag = el.tagName.toLowerCase();
|
|
486
|
+
if (opts.keepEmptyTags.indexOf(tag) !== -1) {
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (el.getElementsByTagName('*').length === 0 && !hasContent(el)) {
|
|
490
|
+
empties.push(el);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (empties.length === 0) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
for (let i = 0; i < empties.length; i++) {
|
|
497
|
+
remove(empties[i]);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Vrai si le texte ne contient que des blancs ordinaires. L'insécable n'en est
|
|
504
|
+
* pas un ici : c'est une espace ou une ligne voulue par le rédacteur.
|
|
505
|
+
* String.trim() ne convient pas, il retire aussi les insécables.
|
|
506
|
+
*/
|
|
507
|
+
function isBlankText(value) {
|
|
508
|
+
return /^[^\S\u00a0]*$/.test(value);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Un nœud texte est « entre blocs » si ses deux voisins significatifs sont
|
|
513
|
+
* absents ou de niveau bloc. Le test préserve l'espace de `a <em>b</em>`.
|
|
514
|
+
*/
|
|
515
|
+
function isBlockBoundary(node) {
|
|
516
|
+
if (!node) {
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
return node.nodeType === 1 && isBlockTag(node.tagName.toLowerCase());
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function isBetweenBlocks(node) {
|
|
523
|
+
return isBlockBoundary(previousMeaningful(node)) && isBlockBoundary(nextMeaningful(node));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function normalizeWhitespace(root) {
|
|
527
|
+
/* Les déballages ont laissé des nœuds texte adjacents : les fusionner
|
|
528
|
+
d'abord, sans quoi un double blanc resterait invisible d'une passe sur
|
|
529
|
+
l'autre et le nettoyage ne serait pas idempotent. */
|
|
530
|
+
root.normalize();
|
|
531
|
+
|
|
532
|
+
const nodes = [];
|
|
533
|
+
collectTextNodes(root, nodes);
|
|
534
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
535
|
+
const node = nodes[i];
|
|
536
|
+
|
|
537
|
+
/* Blanc d'indentation entre deux blocs : il n'apporte rien et son sort
|
|
538
|
+
varie d'une passe à l'autre. */
|
|
539
|
+
if (isBlankText(node.nodeValue) && isBetweenBlocks(node)) {
|
|
540
|
+
remove(node);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
let text = node.nodeValue;
|
|
545
|
+
/* Une suite d'insécables sert d'indentation : un espace suffit */
|
|
546
|
+
text = text.replace(/\u00a0{2,}/g, ' ');
|
|
547
|
+
/* Blancs multiples : le HTML les rend de toute façon comme un seul */
|
|
548
|
+
text = text.replace(/[^\S\u00a0]{2,}/g, ' ');
|
|
549
|
+
if (text !== node.nodeValue) {
|
|
550
|
+
node.nodeValue = text;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function collectTextNodes(node, out) {
|
|
556
|
+
const children = node.childNodes;
|
|
557
|
+
for (let i = 0; i < children.length; i++) {
|
|
558
|
+
const child = children[i];
|
|
559
|
+
if (child.nodeType === 3) {
|
|
560
|
+
out.push(child);
|
|
561
|
+
} else if (child.nodeType === 1) {
|
|
562
|
+
collectTextNodes(child, out);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Normalisation des blocs : paragraphes imbriqués remontés, <br> superflus.
|
|
569
|
+
*/
|
|
570
|
+
function normalizeBlocks(root) {
|
|
571
|
+
/* <p> dans <p> : on remonte l'intérieur */
|
|
572
|
+
let nested = root.querySelectorAll('p p, li p > p');
|
|
573
|
+
for (let i = nested.length - 1; i >= 0; i--) {
|
|
574
|
+
unwrap(nested[i]);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/* <br> en fin de bloc : invisible, mais il ajoute une ligne à l'impression */
|
|
578
|
+
const blocks = root.querySelectorAll('p, li, h1, h2, h3, h4, h5, h6, blockquote');
|
|
579
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
580
|
+
let last = blocks[i].lastChild;
|
|
581
|
+
/* isBlankText et non trim() : une insécable finale est une ligne blanche
|
|
582
|
+
voulue, la retirer viderait le paragraphe qui serait ensuite purgé. */
|
|
583
|
+
while (last && last.nodeType === 3 && isBlankText(last.nodeValue)) {
|
|
584
|
+
const previous = last.previousSibling;
|
|
585
|
+
remove(last);
|
|
586
|
+
last = previous;
|
|
587
|
+
}
|
|
588
|
+
if (last && last.nodeType === 1 && last.tagName.toLowerCase() === 'br') {
|
|
589
|
+
remove(last);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/* Trois <br> ou plus à la suite : deux suffisent */
|
|
594
|
+
const brs = Array.prototype.slice.call(root.getElementsByTagName('br'));
|
|
595
|
+
let run = 0;
|
|
596
|
+
for (let i = 0; i < brs.length; i++) {
|
|
597
|
+
const br = brs[i];
|
|
598
|
+
const previous = previousMeaningful(br);
|
|
599
|
+
if (previous && previous.nodeType === 1 && previous.tagName.toLowerCase() === 'br') {
|
|
600
|
+
run++;
|
|
601
|
+
if (run >= 2) {
|
|
602
|
+
remove(br);
|
|
603
|
+
}
|
|
604
|
+
} else {
|
|
605
|
+
run = 0;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function previousMeaningful(node) {
|
|
611
|
+
let previous = node.previousSibling;
|
|
612
|
+
while (previous && previous.nodeType === 3 && isBlankText(previous.nodeValue)) {
|
|
613
|
+
previous = previous.previousSibling;
|
|
614
|
+
}
|
|
615
|
+
return previous;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function nextMeaningful(node) {
|
|
619
|
+
let next = node.nextSibling;
|
|
620
|
+
while (next && next.nodeType === 3 && isBlankText(next.nodeValue)) {
|
|
621
|
+
next = next.nextSibling;
|
|
622
|
+
}
|
|
623
|
+
return next;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
*******************************************************************************
|
|
628
|
+
* Point d'entrée
|
|
629
|
+
*******************************************************************************
|
|
630
|
+
*/
|
|
631
|
+
|
|
632
|
+
function resolveOptions(options) {
|
|
633
|
+
if (!options) {
|
|
634
|
+
return CLEAN_PASTE;
|
|
635
|
+
}
|
|
636
|
+
if (typeof options === 'string') {
|
|
637
|
+
return CLEAN_PROFILES[options] || CLEAN_PASTE;
|
|
638
|
+
}
|
|
639
|
+
if (options.profile) {
|
|
640
|
+
const base = CLEAN_PROFILES[options.profile] || CLEAN_PASTE;
|
|
641
|
+
return { ...base, ...options };
|
|
642
|
+
}
|
|
643
|
+
return { ...CLEAN_PASTE, ...options };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function createRoot(html) {
|
|
647
|
+
if (typeof DOMParser === 'undefined') {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
/* DOMParser n'exécute aucun script et ne déclenche aucune requête réseau. */
|
|
651
|
+
const doc = new DOMParser().parseFromString('<div id="rbf-clean-root">' + html + '</div>', 'text/html');
|
|
652
|
+
return doc.getElementById('rbf-clean-root');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Nettoie un fragment de HTML.
|
|
657
|
+
*
|
|
658
|
+
* @param {string} html le fragment à nettoyer
|
|
659
|
+
* @param {string|object} options 'load' | 'paste' | 'strict', ou un objet
|
|
660
|
+
* d'options fusionné sur le profil PASTE
|
|
661
|
+
* @returns {string} le HTML nettoyé
|
|
662
|
+
*/
|
|
663
|
+
export function cleanHtml(html, options) {
|
|
664
|
+
if (html === null || html === undefined || html === '') {
|
|
665
|
+
return '';
|
|
666
|
+
}
|
|
667
|
+
const opts = resolveOptions(options);
|
|
668
|
+
const pre = preClean(html);
|
|
669
|
+
const root = createRoot(pre);
|
|
670
|
+
if (!root) {
|
|
671
|
+
/* Pas de DOM disponible : on rend au moins le pré-nettoyage textuel. */
|
|
672
|
+
return pre;
|
|
673
|
+
}
|
|
674
|
+
walk(root, opts);
|
|
675
|
+
/* L'ordre compte : blancs puis blocs, la purge des éléments vides en dernier.
|
|
676
|
+
Les deux premières passes peuvent vider un élément, et c'est la purge qui
|
|
677
|
+
doit en décider — sinon un contenu propre changerait encore au nettoyage
|
|
678
|
+
suivant et la fonction ne serait pas idempotente. */
|
|
679
|
+
if (opts.normalizeWhitespace) {
|
|
680
|
+
normalizeWhitespace(root);
|
|
681
|
+
}
|
|
682
|
+
if (opts.normalizeBlocks) {
|
|
683
|
+
normalizeBlocks(root);
|
|
684
|
+
}
|
|
685
|
+
if (opts.removeEmptyElements) {
|
|
686
|
+
removeEmptyElements(root, opts);
|
|
687
|
+
}
|
|
688
|
+
return root.innerHTML;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export default cleanHtml;
|