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,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> *</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
|
+
}
|