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.
Files changed (37) hide show
  1. package/CHANGELOG +9 -0
  2. package/dist/react-bootstrap-front.js +14 -14
  3. package/doc/editor/feuille-de-route.md +36 -0
  4. package/package.json +42 -8
  5. package/src/features/advanced/DropdownWrapper.js +12 -2
  6. package/src/features/basic/Dropdown.js +112 -31
  7. package/src/features/basic/computePlacement.js +66 -0
  8. package/src/features/editor/HtmlEditor.js +681 -0
  9. package/src/features/editor/HtmlEditor.scss +189 -0
  10. package/src/features/editor/ImageDialog.js +195 -0
  11. package/src/features/editor/LinkDialog.js +148 -0
  12. package/src/features/editor/MergeMenu.js +120 -0
  13. package/src/features/editor/SourceView.js +61 -0
  14. package/src/features/editor/Toolbar.js +355 -0
  15. package/src/features/editor/Toolbar.scss +130 -0
  16. package/src/features/editor/ToolbarButton.js +59 -0
  17. package/src/features/editor/ToolbarDropdown.js +46 -0
  18. package/src/features/editor/ToolbarGroup.js +27 -0
  19. package/src/features/editor/buildExtensions.js +77 -0
  20. package/src/features/editor/defaultIcons.js +45 -0
  21. package/src/features/editor/extensions/CleanPaste.js +44 -0
  22. package/src/features/editor/extensions/FontSize.js +60 -0
  23. package/src/features/editor/extensions/LegacyImageAttrs.js +43 -0
  24. package/src/features/editor/extensions/MergeVariable.js +104 -0
  25. package/src/features/editor/extensions/index.js +4 -0
  26. package/src/features/editor/html/cleanHtml.constants.js +279 -0
  27. package/src/features/editor/html/cleanHtml.js +691 -0
  28. package/src/features/editor/html/index.js +4 -0
  29. package/src/features/editor/html/normalizeLegacyHtml.js +196 -0
  30. package/src/features/editor/html/serialize.js +130 -0
  31. package/src/features/editor/html/sourceHelpers.js +86 -0
  32. package/src/features/editor/index.js +20 -0
  33. package/src/features/editor/style.scss +2 -0
  34. package/src/features/editor/toolbarPresets.js +74 -0
  35. package/src/features/page/DefaultHeader.jsx +1 -1
  36. package/src/index.js +8 -0
  37. package/src/styles/index.scss +1 -0
@@ -0,0 +1,681 @@
1
+ import React, { Component } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import classnames from 'classnames';
4
+ import { Editor } from '@tiptap/core';
5
+ import { buildExtensions } from './buildExtensions';
6
+ import { cleanHtml } from './html/cleanHtml';
7
+ import { normalizeLegacyHtml } from './html/normalizeLegacyHtml';
8
+ import { serialize } from './html/serialize';
9
+ import { formatHtml, tagSignature, textSignature, reserialize } from './html/sourceHelpers';
10
+ import { resolveToolbarConfig } from './toolbarPresets';
11
+ import { defaultIcons } from './defaultIcons';
12
+ import Toolbar from './Toolbar';
13
+ import LinkDialog from './LinkDialog';
14
+ import ImageDialog from './ImageDialog';
15
+ import SourceView from './SourceView';
16
+
17
+ /**
18
+ * Champ d'édition HTML.
19
+ *
20
+ * Remplaçant de `features/draft/InputHtml`. Le contrat d'appel est strictement
21
+ * préservé — `label`, `name`, `value`, `onChange({target:{name,value}})`,
22
+ * `required`, `error`, `labelTop`, `disabled`, `size`, `help` — pour qu'aucun
23
+ * formulaire appelant n'ait de code à changer.
24
+ *
25
+ * L'éditeur est instancié via l'API « vanilla » de `@tiptap/core` et non via
26
+ * `@tiptap/react`, qui exige React 17 : la librairie cible React 16.
27
+ */
28
+ export default class HtmlEditor extends Component {
29
+ static propTypes = {
30
+ id: PropTypes.string,
31
+ name: PropTypes.string.isRequired,
32
+ label: PropTypes.string,
33
+ value: PropTypes.string,
34
+ size: PropTypes.string,
35
+ labelTop: PropTypes.bool,
36
+ onChange: PropTypes.func,
37
+ required: PropTypes.bool,
38
+ disabled: PropTypes.bool,
39
+ error: PropTypes.node,
40
+ warning: PropTypes.node,
41
+ help: PropTypes.node,
42
+ placeholder: PropTypes.string,
43
+ /** Icônes de la barre, fusionnées avec le repli textuel. */
44
+ icons: PropTypes.object,
45
+ /** Barre visible d'emblée, contrairement à l'ancien composant. */
46
+ toolbar: PropTypes.bool,
47
+ toolbarPreset: PropTypes.oneOf(['full', 'email', 'simple', 'minimal']),
48
+ toolbarConfig: PropTypes.array,
49
+ /** Autorise la bascule vers le HTML brut. */
50
+ allowSource: PropTypes.bool,
51
+ /** Délai avant remontée de la saisie, en millisecondes. */
52
+ debounce: PropTypes.number,
53
+ minHeight: PropTypes.string,
54
+ maxHeight: PropTypes.string,
55
+ cleanOnPaste: PropTypes.bool,
56
+ /** Variables de fusion, groupées. */
57
+ mergeVars: PropTypes.array,
58
+ /** Signale en rouge une variable absente du catalogue. À n'activer que
59
+ lorsque le catalogue fait autorité, sinon fausses alertes garanties. */
60
+ strictMergeVars: PropTypes.bool,
61
+ /** @deprecated ancienne forme plate, normalisée vers mergeVars. */
62
+ presetTexts: PropTypes.array,
63
+ /** (File) => Promise<url> ; absente, l'insertion d'image se fait par adresse. */
64
+ onUploadImage: PropTypes.func,
65
+ maxImageSize: PropTypes.number,
66
+ legacy: PropTypes.object,
67
+ serializeOptions: PropTypes.object,
68
+ /** Icônes des deux boutons historiques, conservées pour compatibilité. */
69
+ clearIcon: PropTypes.element,
70
+ toolbarIcon: PropTypes.element,
71
+ t: PropTypes.func,
72
+ };
73
+
74
+ static defaultProps = {
75
+ id: '',
76
+ label: '',
77
+ value: '',
78
+ size: '',
79
+ labelTop: true,
80
+ onChange: () => {},
81
+ required: false,
82
+ disabled: false,
83
+ error: null,
84
+ warning: null,
85
+ help: null,
86
+ placeholder: '',
87
+ icons: null,
88
+ toolbar: true,
89
+ toolbarPreset: 'full',
90
+ toolbarConfig: null,
91
+ allowSource: true,
92
+ debounce: 300,
93
+ minHeight: '8rem',
94
+ maxHeight: '30rem',
95
+ cleanOnPaste: true,
96
+ mergeVars: [],
97
+ strictMergeVars: false,
98
+ presetTexts: [],
99
+ onUploadImage: null,
100
+ maxImageSize: 524288,
101
+ legacy: null,
102
+ serializeOptions: null,
103
+ clearIcon: null,
104
+ toolbarIcon: null,
105
+ /* Le défaut historique `() => {}` rendait tous les title indéfinis :
106
+ aucune infobulle n'a jamais été affichée. On rend le message par défaut. */
107
+ t: descriptor => (descriptor && descriptor.defaultMessage) || '',
108
+ };
109
+
110
+ constructor(props) {
111
+ super(props);
112
+ this.contentRef = React.createRef();
113
+ this.editor = null;
114
+ /** Dernier HTML que NOUS avons émis, pour ignorer notre propre écho. */
115
+ this.lastEmitted = null;
116
+ this.debounceId = null;
117
+ this.rafId = null;
118
+ this.state = {
119
+ toolbar: props.toolbar,
120
+ mode: 'wysiwyg',
121
+ source: '',
122
+ sourceWarning: null,
123
+ sourceBackup: null,
124
+ dialog: null,
125
+ linkState: null,
126
+ tick: 0,
127
+ };
128
+
129
+ this.flush = this.flush.bind(this);
130
+ this.scheduleEmit = this.scheduleEmit.bind(this);
131
+ this.scheduleToolbarRefresh = this.scheduleToolbarRefresh.bind(this);
132
+ this.onToolbar = this.onToolbar.bind(this);
133
+ this.onClear = this.onClear.bind(this);
134
+ this.onClean = this.onClean.bind(this);
135
+ this.onGlobalKeydown = this.onGlobalKeydown.bind(this);
136
+ this.onOpenLink = this.onOpenLink.bind(this);
137
+ this.onApplyLink = this.onApplyLink.bind(this);
138
+ this.onRemoveLink = this.onRemoveLink.bind(this);
139
+ this.onOpenImage = this.onOpenImage.bind(this);
140
+ this.onApplyImage = this.onApplyImage.bind(this);
141
+ this.onCloseDialog = this.onCloseDialog.bind(this);
142
+ this.onToggleSource = this.onToggleSource.bind(this);
143
+ this.onSourceChange = this.onSourceChange.bind(this);
144
+ this.onRestoreSource = this.onRestoreSource.bind(this);
145
+ }
146
+
147
+ componentDidMount() {
148
+ this.editor = new Editor({
149
+ element: this.contentRef.current,
150
+ extensions: buildExtensions(this.getEditorOptions()),
151
+ content: normalizeLegacyHtml(this.props.value || '', this.props.legacy),
152
+ editable: !this.props.disabled,
153
+ editorProps: {
154
+ attributes: {
155
+ class: 'rbf-editor-content',
156
+ role: 'textbox',
157
+ 'aria-multiline': 'true',
158
+ },
159
+ },
160
+ onUpdate: this.scheduleEmit,
161
+ onBlur: this.flush,
162
+ onTransaction: this.scheduleToolbarRefresh,
163
+ });
164
+ document.addEventListener('keydown', this.onGlobalKeydown, true);
165
+ /* La barre lit l'état de l'éditeur, qui n'existe pas au premier rendu. */
166
+ this.forceUpdate();
167
+ }
168
+
169
+ componentDidUpdate(prevProps) {
170
+ if (!this.editor) {
171
+ return;
172
+ }
173
+ if (this.props.disabled !== prevProps.disabled) {
174
+ this.editor.setEditable(!this.props.disabled);
175
+ }
176
+ const nameChanged = this.props.name !== prevProps.name;
177
+ const valueChanged = this.props.value !== prevProps.value;
178
+ if (!nameChanged && !valueChanged) {
179
+ return;
180
+ }
181
+ const next = this.props.value || '';
182
+ /* Notre propre écho : le formulaire nous renvoie ce que nous venons
183
+ d'émettre. Recharger le contenu ici perdrait la position du curseur à
184
+ chaque frappe — c'est le piège n°1 de l'intégration. */
185
+ if (!nameChanged && next === this.lastEmitted) {
186
+ return;
187
+ }
188
+ if (!nameChanged && this.isEquivalent(next, this.editor.getHTML())) {
189
+ return;
190
+ }
191
+ /* emitUpdate = false : charger un contenu existant ne doit JAMAIS
192
+ déclencher onChange, sinon ouvrir puis fermer un formulaire réécrirait
193
+ la base en silence avec la conversion de l'éditeur. */
194
+ this.editor.commands.setContent(normalizeLegacyHtml(next, this.props.legacy), false);
195
+ this.lastEmitted = null;
196
+ if (nameChanged && this.state.mode === 'source') {
197
+ this.setState({ mode: 'wysiwyg', sourceWarning: null, sourceBackup: null });
198
+ }
199
+ }
200
+
201
+ componentWillUnmount() {
202
+ document.removeEventListener('keydown', this.onGlobalKeydown, true);
203
+ /* Ne jamais perdre la dernière frappe. */
204
+ this.flush();
205
+ if (this.debounceId) {
206
+ clearTimeout(this.debounceId);
207
+ this.debounceId = null;
208
+ }
209
+ if (this.rafId && typeof cancelAnimationFrame !== 'undefined') {
210
+ cancelAnimationFrame(this.rafId);
211
+ this.rafId = null;
212
+ }
213
+ if (this.editor) {
214
+ this.editor.destroy();
215
+ this.editor = null;
216
+ }
217
+ }
218
+
219
+ getEditorOptions() {
220
+ return {
221
+ placeholder: this.props.placeholder,
222
+ mergeVars: this.getMergeVars(),
223
+ strictMergeVars: this.props.strictMergeVars,
224
+ cleanOnPaste: this.props.cleanOnPaste,
225
+ };
226
+ }
227
+
228
+ getMergeVars() {
229
+ const { mergeVars, presetTexts } = this.props;
230
+ if (mergeVars && mergeVars.length > 0) {
231
+ return mergeVars;
232
+ }
233
+ return presetTexts || [];
234
+ }
235
+
236
+ getIcons() {
237
+ if (!this.icons || this.iconsSource !== this.props.icons) {
238
+ this.iconsSource = this.props.icons;
239
+ this.icons = this.props.icons ? { ...defaultIcons, ...this.props.icons } : { ...defaultIcons };
240
+ /* Compatibilité : les deux icônes historiques alimentent la barre si
241
+ l'application ne fournit pas encore de map complète. */
242
+ if (this.props.clearIcon && !this.icons.formatClear) {
243
+ this.icons.formatClear = this.props.clearIcon;
244
+ }
245
+ }
246
+ return this.icons;
247
+ }
248
+
249
+ /**
250
+ * Comparaison tolérante aux blancs entre balises : évite de recharger
251
+ * l'éditeur — et de perdre le curseur — pour une différence cosmétique.
252
+ */
253
+ isEquivalent(a, b) {
254
+ const normalize = html =>
255
+ String(html || '')
256
+ .replace(/>\s+</g, '><')
257
+ .trim();
258
+ return normalize(a) === normalize(b);
259
+ }
260
+
261
+ scheduleEmit() {
262
+ if (this.debounceId) {
263
+ clearTimeout(this.debounceId);
264
+ }
265
+ this.debounceId = setTimeout(this.flush, this.props.debounce);
266
+ }
267
+
268
+ /**
269
+ * Rafraîchit l'état actif de la barre, au plus une fois par image :
270
+ * sans cela une frappe rapide re-rendrait la barre soixante fois par seconde.
271
+ */
272
+ scheduleToolbarRefresh() {
273
+ if (this.rafId || typeof requestAnimationFrame === 'undefined') {
274
+ return;
275
+ }
276
+ this.rafId = requestAnimationFrame(() => {
277
+ this.rafId = null;
278
+ if (this.editor) {
279
+ this.setState(state => ({ tick: state.tick + 1 }));
280
+ }
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Remonte immédiatement la saisie au formulaire.
286
+ * Appelée au blur, au démontage, sur Ctrl/Cmd+S et après chaque commande.
287
+ */
288
+ flush() {
289
+ if (this.debounceId) {
290
+ clearTimeout(this.debounceId);
291
+ this.debounceId = null;
292
+ }
293
+ if (!this.editor) {
294
+ return;
295
+ }
296
+ const html =
297
+ this.state.mode === 'source' ? this.state.source : serialize(this.editor, this.props.serializeOptions);
298
+ if (html === this.lastEmitted) {
299
+ return;
300
+ }
301
+ this.lastEmitted = html;
302
+ this.props.onChange({
303
+ target: { name: this.props.name, value: html, type: 'text' },
304
+ });
305
+ }
306
+
307
+ /**
308
+ * L'ancien composant ne remontait la saisie qu'au blur : un raccourci de
309
+ * sauvegarde depuis le champ perdait la dernière frappe.
310
+ */
311
+ onGlobalKeydown(ev) {
312
+ if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) {
313
+ this.flush();
314
+ }
315
+ }
316
+
317
+ onToolbar() {
318
+ this.setState(state => ({ toolbar: !state.toolbar }));
319
+ }
320
+
321
+ onClear() {
322
+ if (!this.editor) {
323
+ return;
324
+ }
325
+ if (this.state.mode === 'source') {
326
+ this.setState({ source: '' }, this.flush);
327
+ return;
328
+ }
329
+ this.editor
330
+ .chain()
331
+ .focus()
332
+ .clearContent(true)
333
+ .run();
334
+ this.flush();
335
+ }
336
+
337
+ /**
338
+ * Nettoyage de la mise en forme.
339
+ *
340
+ * Deux commandes distinctes, parce que dans l'immense majorité des cas on
341
+ * veut retirer la couleur et la police SANS perdre le gras :
342
+ * - 'soft' : couleur, police, taille ;
343
+ * - 'all' : en plus, toutes les marques et les blocs.
344
+ *
345
+ * Sans sélection, l'opération porte sur tout le document et passe par le
346
+ * nettoyeur en profil STRICT. C'est une transaction unique : Ctrl+Z l'annule
347
+ * intégralement, ce qui dispense de toute demande de confirmation.
348
+ */
349
+ onClean(mode) {
350
+ if (this.state.mode === 'source') {
351
+ this.setState(state => ({ source: formatHtml(cleanHtml(state.source, 'strict')) }), this.flush);
352
+ return;
353
+ }
354
+ if (!this.editor) {
355
+ return;
356
+ }
357
+ const empty = this.editor.state.selection.empty;
358
+ if (!empty) {
359
+ const chain = this.editor
360
+ .chain()
361
+ .focus()
362
+ .unsetColor()
363
+ .unsetFontFamily()
364
+ .unsetFontSize();
365
+ if (mode === 'all') {
366
+ chain.unsetAllMarks().clearNodes();
367
+ }
368
+ chain.run();
369
+ } else {
370
+ const cleaned = cleanHtml(this.editor.getHTML(), 'strict');
371
+ this.editor
372
+ .chain()
373
+ .focus()
374
+ .setContent(cleaned, false)
375
+ .run();
376
+ }
377
+ this.flush();
378
+ }
379
+
380
+ onOpenLink() {
381
+ if (!this.editor) {
382
+ return;
383
+ }
384
+ const attributes = this.editor.getAttributes('link');
385
+ const { from, to, empty } = this.editor.state.selection;
386
+ this.setState({
387
+ dialog: 'link',
388
+ linkState: {
389
+ url: attributes.href || '',
390
+ target: attributes.target || '',
391
+ text: empty ? '' : this.editor.state.doc.textBetween(from, to, ' '),
392
+ hasSelection: !empty,
393
+ canRemove: this.editor.isActive('link'),
394
+ },
395
+ });
396
+ }
397
+
398
+ onApplyLink(link) {
399
+ if (!this.editor) {
400
+ return;
401
+ }
402
+ const attributes = { href: link.url };
403
+ if (link.target) {
404
+ attributes.target = link.target;
405
+ attributes.rel = 'noopener noreferrer';
406
+ }
407
+ const chain = this.editor.chain().focus();
408
+ if (this.state.linkState && this.state.linkState.hasSelection) {
409
+ chain.extendMarkRange('link').setLink(attributes);
410
+ } else {
411
+ const label = link.text && link.text !== '' ? link.text : link.url;
412
+ chain.insertContent({
413
+ type: 'text',
414
+ text: label,
415
+ marks: [{ type: 'link', attrs: attributes }],
416
+ });
417
+ }
418
+ chain.run();
419
+ this.setState({ dialog: null, linkState: null }, this.flush);
420
+ }
421
+
422
+ onRemoveLink() {
423
+ if (!this.editor) {
424
+ return;
425
+ }
426
+ this.editor
427
+ .chain()
428
+ .focus()
429
+ .extendMarkRange('link')
430
+ .unsetLink()
431
+ .run();
432
+ this.setState({ dialog: null, linkState: null }, this.flush);
433
+ }
434
+
435
+ onOpenImage() {
436
+ this.setState({ dialog: 'image' });
437
+ }
438
+
439
+ onApplyImage(image) {
440
+ if (!this.editor) {
441
+ return;
442
+ }
443
+ const attributes = { src: image.src, alt: image.alt || '' };
444
+ if (image.width) {
445
+ attributes.width = image.width;
446
+ }
447
+ this.editor
448
+ .chain()
449
+ .focus()
450
+ .setImage(attributes)
451
+ .run();
452
+ this.setState({ dialog: null }, this.flush);
453
+ }
454
+
455
+ onCloseDialog() {
456
+ this.setState({ dialog: null, linkState: null });
457
+ if (this.editor) {
458
+ this.editor.commands.focus();
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Bascule entre l'éditeur riche et le HTML brut.
464
+ *
465
+ * L'instance TipTap n'est jamais détruite — son conteneur est simplement
466
+ * masqué — sinon chaque aller-retour effacerait l'historique d'annulation.
467
+ */
468
+ onToggleSource() {
469
+ if (this.state.mode === 'wysiwyg') {
470
+ this.flush();
471
+ this.setState({
472
+ mode: 'source',
473
+ source: formatHtml(this.editor ? this.editor.getHTML() : ''),
474
+ sourceWarning: null,
475
+ });
476
+ return;
477
+ }
478
+ this.applySource();
479
+ }
480
+
481
+ applySource() {
482
+ const source = this.state.source;
483
+ if (!this.editor) {
484
+ this.setState({ mode: 'wysiwyg' });
485
+ return;
486
+ }
487
+ const balanced = tagSignature(source) === tagSignature(reserialize(source));
488
+ this.editor.commands.setContent(source, false);
489
+ const lost = textSignature(reserialize(source)) !== textSignature(this.editor.getHTML());
490
+ this.setState(
491
+ {
492
+ mode: 'wysiwyg',
493
+ sourceWarning: !balanced ? 'unbalanced' : lost ? 'unsupported' : null,
494
+ sourceBackup: !balanced || lost ? source : null,
495
+ },
496
+ this.flush,
497
+ );
498
+ }
499
+
500
+ onSourceChange(ev) {
501
+ this.setState({ source: ev.target.value }, this.scheduleEmit);
502
+ }
503
+
504
+ onRestoreSource() {
505
+ this.setState(state => ({
506
+ mode: 'source',
507
+ source: state.sourceBackup || state.source,
508
+ sourceWarning: null,
509
+ }));
510
+ }
511
+
512
+ renderAppendButton(icon, onClick, title) {
513
+ if (!icon) {
514
+ return null;
515
+ }
516
+ return (
517
+ <button
518
+ type="button"
519
+ title={title}
520
+ className={classnames('btn btn-input btn-outline-ui bg-light', this.props.size === 'sm' && 'btn-sm')}
521
+ onMouseDown={ev => ev.preventDefault()}
522
+ onClick={onClick}
523
+ >
524
+ {icon}
525
+ </button>
526
+ );
527
+ }
528
+
529
+ renderDialog() {
530
+ const { t } = this.props;
531
+ if (this.state.dialog === 'link') {
532
+ const link = this.state.linkState || {};
533
+ return (
534
+ <LinkDialog
535
+ t={t}
536
+ url={link.url}
537
+ text={link.text}
538
+ target={link.target}
539
+ hasSelection={link.hasSelection}
540
+ canRemove={link.canRemove}
541
+ onApply={this.onApplyLink}
542
+ onRemove={this.onRemoveLink}
543
+ onCancel={this.onCloseDialog}
544
+ />
545
+ );
546
+ }
547
+ if (this.state.dialog === 'image') {
548
+ return (
549
+ <ImageDialog
550
+ t={t}
551
+ onUpload={this.props.onUploadImage}
552
+ maxImageSize={this.props.maxImageSize}
553
+ onApply={this.onApplyImage}
554
+ onCancel={this.onCloseDialog}
555
+ />
556
+ );
557
+ }
558
+ return null;
559
+ }
560
+
561
+ render() {
562
+ const {
563
+ t,
564
+ label,
565
+ labelTop,
566
+ required,
567
+ error,
568
+ warning,
569
+ help,
570
+ id,
571
+ minHeight,
572
+ maxHeight,
573
+ disabled,
574
+ allowSource,
575
+ toolbarPreset,
576
+ toolbarConfig,
577
+ size,
578
+ } = this.props;
579
+ const contentStyle = { minHeight, maxHeight };
580
+ const sourceMode = this.state.mode === 'source';
581
+ let config = resolveToolbarConfig(toolbarPreset, toolbarConfig);
582
+ if (!allowSource) {
583
+ config = config.map(group => group.filter(key => key !== 'source')).filter(group => group.length > 0);
584
+ }
585
+
586
+ return (
587
+ <div className={classnames('form-group', !labelTop && 'row')}>
588
+ {label !== '' && (
589
+ <label htmlFor={id} className={classnames(!labelTop && 'col-xxs-w6 col-form-label')}>
590
+ {label}
591
+ {required && <span>&nbsp;*</span>}
592
+ </label>
593
+ )}
594
+ <div className={classnames(!labelTop && 'col-xxs-w30')}>
595
+ <div
596
+ className={classnames(
597
+ 'rbf-editor input-group',
598
+ disabled && 'rbf-editor-disabled',
599
+ (error || warning) && 'is-invalid',
600
+ )}
601
+ >
602
+ <div className="rbf-editor-main form-control border-ui h-auto">
603
+ {this.state.toolbar && !disabled && (
604
+ <Toolbar
605
+ editor={this.editor}
606
+ config={config}
607
+ icons={this.getIcons()}
608
+ t={t}
609
+ size={size === 'sm' ? 'sm' : 'sm'}
610
+ mergeVars={this.getMergeVars()}
611
+ sourceMode={sourceMode}
612
+ onCommand={this.flush}
613
+ onLink={this.onOpenLink}
614
+ onImage={this.onOpenImage}
615
+ onClean={this.onClean}
616
+ onSource={this.onToggleSource}
617
+ />
618
+ )}
619
+ {this.renderDialog()}
620
+ {this.state.sourceWarning && !sourceMode && (
621
+ <div className="alert alert-warning alert-sm rbf-editor-source-warning" role="alert">
622
+ <span>
623
+ {this.state.sourceWarning === 'unbalanced'
624
+ ? t({
625
+ id: 'ui.editor.source.unbalanced',
626
+ defaultMessage: 'The HTML was not balanced and has been corrected automatically.',
627
+ })
628
+ : t({
629
+ id: 'ui.editor.source.unsupported',
630
+ defaultMessage: 'Some tags are not supported by the editor and have been removed.',
631
+ })}
632
+ </span>
633
+ <button type="button" className="btn btn-sm btn-link" onClick={this.onRestoreSource}>
634
+ {t({ id: 'ui.editor.source.back', defaultMessage: 'Back to the source' })}
635
+ </button>
636
+ <button
637
+ type="button"
638
+ className="btn btn-sm btn-link"
639
+ onClick={() => this.setState({ sourceWarning: null, sourceBackup: null })}
640
+ >
641
+ {t({ id: 'ui.editor.source.dismiss', defaultMessage: 'Keep the correction' })}
642
+ </button>
643
+ </div>
644
+ )}
645
+ {/* Le conteneur TipTap est masqué, jamais démonté : le détruire
646
+ effacerait l'historique d'annulation à chaque aller-retour. */}
647
+ <div
648
+ className={classnames('rbf-editor-scroll', sourceMode && 'd-none')}
649
+ style={contentStyle}
650
+ ref={this.contentRef}
651
+ />
652
+ {sourceMode && (
653
+ <SourceView
654
+ t={t}
655
+ value={this.state.source}
656
+ onChange={this.onSourceChange}
657
+ style={contentStyle}
658
+ />
659
+ )}
660
+ </div>
661
+ <div className="input-group-append rbf-editor-append">
662
+ {this.renderAppendButton(
663
+ this.props.toolbarIcon,
664
+ this.onToolbar,
665
+ t({ id: 'ui.editor.toolbar.toolbar', defaultMessage: 'Show or hide the toolbar' }),
666
+ )}
667
+ {this.renderAppendButton(
668
+ this.props.clearIcon,
669
+ this.onClear,
670
+ t({ id: 'ui.editor.toolbar.clear', defaultMessage: 'Clear the content' }),
671
+ )}
672
+ </div>
673
+ </div>
674
+ {help && help !== '' && <small className="form-text text-muted">{help}</small>}
675
+ {error && <div className="invalid-feedback">{error}</div>}
676
+ {warning && <div className="invalid-feedback">{warning}</div>}
677
+ </div>
678
+ </div>
679
+ );
680
+ }
681
+ }