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,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Styles de base de ProseMirror.
|
|
3
|
+
*
|
|
4
|
+
* Recopiés depuis prosemirror-view/style/prosemirror.css et
|
|
5
|
+
* prosemirror-gapcursor/style/gapcursor.css (licence MIT, stables depuis des
|
|
6
|
+
* années) plutôt qu'importés par `~prosemirror-view/...` : avec `npm link`, le
|
|
7
|
+
* `~` résoudrait dans les node_modules de la librairie et non dans ceux de
|
|
8
|
+
* l'application, ce qui couplerait la résolution SCSS des deux dépôts.
|
|
9
|
+
*/
|
|
10
|
+
.ProseMirror {
|
|
11
|
+
position: relative;
|
|
12
|
+
word-wrap: break-word;
|
|
13
|
+
white-space: pre-wrap;
|
|
14
|
+
white-space: break-spaces;
|
|
15
|
+
-webkit-font-variant-ligatures: none;
|
|
16
|
+
font-variant-ligatures: none;
|
|
17
|
+
font-feature-settings: 'liga' 0;
|
|
18
|
+
outline: none;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.ProseMirror pre {
|
|
22
|
+
white-space: pre-wrap;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.ProseMirror li {
|
|
26
|
+
position: relative;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.ProseMirror-hideselection *::selection {
|
|
30
|
+
background: transparent;
|
|
31
|
+
}
|
|
32
|
+
.ProseMirror-hideselection *::-moz-selection {
|
|
33
|
+
background: transparent;
|
|
34
|
+
}
|
|
35
|
+
.ProseMirror-hideselection {
|
|
36
|
+
caret-color: transparent;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.ProseMirror-selectednode {
|
|
40
|
+
outline: 2px solid #8cf;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
li.ProseMirror-selectednode {
|
|
44
|
+
outline: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
li.ProseMirror-selectednode:after {
|
|
48
|
+
content: '';
|
|
49
|
+
position: absolute;
|
|
50
|
+
left: -32px;
|
|
51
|
+
right: -2px;
|
|
52
|
+
top: -2px;
|
|
53
|
+
bottom: -2px;
|
|
54
|
+
border: 2px solid #8cf;
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
img.ProseMirror-separator {
|
|
59
|
+
display: inline !important;
|
|
60
|
+
border: none !important;
|
|
61
|
+
margin: 0 !important;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.ProseMirror-gapcursor {
|
|
65
|
+
display: none;
|
|
66
|
+
pointer-events: none;
|
|
67
|
+
position: absolute;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.ProseMirror-gapcursor:after {
|
|
71
|
+
content: '';
|
|
72
|
+
display: block;
|
|
73
|
+
position: absolute;
|
|
74
|
+
top: -2px;
|
|
75
|
+
width: 20px;
|
|
76
|
+
border-top: 1px solid black;
|
|
77
|
+
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@keyframes ProseMirror-cursor-blink {
|
|
81
|
+
to {
|
|
82
|
+
visibility: hidden;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.ProseMirror-focused .ProseMirror-gapcursor {
|
|
87
|
+
display: block;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Le champ lui-même.
|
|
92
|
+
*/
|
|
93
|
+
.rbf-editor {
|
|
94
|
+
.rbf-editor-main {
|
|
95
|
+
padding: 0;
|
|
96
|
+
display: flex;
|
|
97
|
+
flex-direction: column;
|
|
98
|
+
min-width: 0;
|
|
99
|
+
/* Bootstrap pose flex:1 sur .form-control dans un input-group, mais le
|
|
100
|
+
contenu ne doit pas déborder de l'input-group. */
|
|
101
|
+
flex: 1 1 auto;
|
|
102
|
+
width: 1%;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* La hauteur est bornée : sans cela les six champs d'un même formulaire
|
|
106
|
+
font exploser la page. Le contenu défile à l'intérieur. */
|
|
107
|
+
.rbf-editor-scroll {
|
|
108
|
+
overflow-y: auto;
|
|
109
|
+
overflow-x: hidden;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.rbf-editor-content {
|
|
113
|
+
padding: 0.375rem 0.75rem;
|
|
114
|
+
min-height: 100%;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Marges resserrées : dans un champ de formulaire, l'espacement par défaut
|
|
118
|
+
des paragraphes donne une impression de contenu troué. */
|
|
119
|
+
.rbf-editor-content {
|
|
120
|
+
p,
|
|
121
|
+
ul,
|
|
122
|
+
ol,
|
|
123
|
+
blockquote,
|
|
124
|
+
h1,
|
|
125
|
+
h2,
|
|
126
|
+
h3 {
|
|
127
|
+
margin-top: 0;
|
|
128
|
+
margin-bottom: 0.5rem;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
> *:last-child {
|
|
132
|
+
margin-bottom: 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
img {
|
|
136
|
+
max-width: 100%;
|
|
137
|
+
height: auto;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
blockquote {
|
|
141
|
+
padding-left: 0.75rem;
|
|
142
|
+
border-left: 3px solid theme-color('secondary-light');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
hr {
|
|
146
|
+
margin: 0.5rem 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
a {
|
|
150
|
+
text-decoration: underline;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Le texte indicatif de l'extension Placeholder. */
|
|
155
|
+
.rbf-editor-content p.is-editor-empty:first-child::before {
|
|
156
|
+
content: attr(data-placeholder);
|
|
157
|
+
float: left;
|
|
158
|
+
height: 0;
|
|
159
|
+
pointer-events: none;
|
|
160
|
+
color: theme-color('secondary-inactive');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.rbf-editor-append .btn {
|
|
164
|
+
max-height: 40px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
&.rbf-editor-disabled .rbf-editor-main {
|
|
168
|
+
background-color: $input-disabled-bg;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Variables de fusion. La variante « inconnue » est le vrai apport : une
|
|
174
|
+
* variable mal orthographiée se voit à la frappe, alors qu'elle ne se
|
|
175
|
+
* découvrait jusqu'ici qu'à l'envoi du courriel.
|
|
176
|
+
*/
|
|
177
|
+
.rbf-mergevar {
|
|
178
|
+
background: #e8f5e9;
|
|
179
|
+
color: #105a1c;
|
|
180
|
+
border-radius: 3px;
|
|
181
|
+
padding: 0 2px;
|
|
182
|
+
white-space: nowrap;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.rbf-mergevar-unknown {
|
|
186
|
+
background: #fdecea;
|
|
187
|
+
color: #b71c1c;
|
|
188
|
+
text-decoration: underline wavy;
|
|
189
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import React, { Component } from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
import classnames from 'classnames';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Insertion d'une image.
|
|
7
|
+
*
|
|
8
|
+
* Deux voies :
|
|
9
|
+
* - une adresse, toujours disponible et de loin la plus sûre pour un
|
|
10
|
+
* courriel : les images doivent être hébergées, pas embarquées ;
|
|
11
|
+
* - un fichier, seulement si l'application fournit `onUpload`. Sans endpoint
|
|
12
|
+
* d'upload, l'onglet propose un repli en data-URL plafonné, avec
|
|
13
|
+
* l'avertissement qui va avec.
|
|
14
|
+
*/
|
|
15
|
+
export default class ImageDialog extends Component {
|
|
16
|
+
static propTypes = {
|
|
17
|
+
onApply: PropTypes.func.isRequired,
|
|
18
|
+
onCancel: PropTypes.func.isRequired,
|
|
19
|
+
/** (File) => Promise<url>. Absente, l'onglet fichier passe en data-URL. */
|
|
20
|
+
onUpload: PropTypes.func,
|
|
21
|
+
/** Autorise le repli data-URL quand onUpload est absente. */
|
|
22
|
+
allowDataUrl: PropTypes.bool,
|
|
23
|
+
maxImageSize: PropTypes.number,
|
|
24
|
+
t: PropTypes.func.isRequired,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
static defaultProps = {
|
|
28
|
+
onUpload: null,
|
|
29
|
+
allowDataUrl: true,
|
|
30
|
+
maxImageSize: 524288,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
constructor(props) {
|
|
34
|
+
super(props);
|
|
35
|
+
this.state = {
|
|
36
|
+
tab: 'url',
|
|
37
|
+
src: '',
|
|
38
|
+
alt: '',
|
|
39
|
+
width: '',
|
|
40
|
+
pending: false,
|
|
41
|
+
error: null,
|
|
42
|
+
};
|
|
43
|
+
this.srcRef = React.createRef();
|
|
44
|
+
this.onApply = this.onApply.bind(this);
|
|
45
|
+
this.onFile = this.onFile.bind(this);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
componentDidMount() {
|
|
49
|
+
if (this.srcRef.current) {
|
|
50
|
+
this.srcRef.current.focus();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onApply() {
|
|
55
|
+
const src = this.state.src.trim();
|
|
56
|
+
if (src === '') {
|
|
57
|
+
this.setState({ error: this.props.t({ id: 'ui.editor.image.url', defaultMessage: 'Enter an address' }) });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.props.onApply({ src, alt: this.state.alt, width: this.state.width || null });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
onFile(ev) {
|
|
64
|
+
const file = ev.target.files && ev.target.files[0];
|
|
65
|
+
if (!file) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const { t, onUpload, maxImageSize, allowDataUrl } = this.props;
|
|
69
|
+
this.setState({ pending: true, error: null });
|
|
70
|
+
|
|
71
|
+
if (onUpload) {
|
|
72
|
+
onUpload(file).then(
|
|
73
|
+
url => this.setState({ pending: false, src: url, tab: 'url' }),
|
|
74
|
+
() => this.setState({ pending: false, error: t({ id: 'ui.editor.image.uploadError', defaultMessage: 'Upload failed' }) }),
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!allowDataUrl) {
|
|
80
|
+
this.setState({ pending: false, error: t({ id: 'ui.editor.image.uploadError', defaultMessage: 'Upload failed' }) });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (file.size > maxImageSize) {
|
|
84
|
+
this.setState({
|
|
85
|
+
pending: false,
|
|
86
|
+
error: t({
|
|
87
|
+
id: 'ui.editor.image.tooLarge',
|
|
88
|
+
defaultMessage: 'Image too large to be embedded, host it and paste its address',
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const reader = new FileReader();
|
|
94
|
+
reader.onload = () => this.setState({ pending: false, src: reader.result, tab: 'url' });
|
|
95
|
+
reader.onerror = () =>
|
|
96
|
+
this.setState({ pending: false, error: t({ id: 'ui.editor.image.uploadError', defaultMessage: 'Upload failed' }) });
|
|
97
|
+
reader.readAsDataURL(file);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
renderTabs() {
|
|
101
|
+
const { t } = this.props;
|
|
102
|
+
return (
|
|
103
|
+
<ul className="nav nav-tabs nav-tabs-sm mb-2">
|
|
104
|
+
<li className="nav-item">
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
className={classnames('nav-link btn btn-link', this.state.tab === 'url' && 'active')}
|
|
108
|
+
onClick={() => this.setState({ tab: 'url' })}
|
|
109
|
+
>
|
|
110
|
+
{t({ id: 'ui.editor.image.url', defaultMessage: 'Address' })}
|
|
111
|
+
</button>
|
|
112
|
+
</li>
|
|
113
|
+
<li className="nav-item">
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
className={classnames('nav-link btn btn-link', this.state.tab === 'file' && 'active')}
|
|
117
|
+
onClick={() => this.setState({ tab: 'file' })}
|
|
118
|
+
>
|
|
119
|
+
{t({ id: 'ui.editor.image.upload', defaultMessage: 'File' })}
|
|
120
|
+
</button>
|
|
121
|
+
</li>
|
|
122
|
+
</ul>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
render() {
|
|
127
|
+
const { t, onUpload, allowDataUrl } = this.props;
|
|
128
|
+
const showFileTab = !!onUpload || allowDataUrl;
|
|
129
|
+
return (
|
|
130
|
+
<div className="rbf-editor-dialog">
|
|
131
|
+
{showFileTab && this.renderTabs()}
|
|
132
|
+
{this.state.tab === 'file' ? (
|
|
133
|
+
<div>
|
|
134
|
+
<input type="file" accept="image/*" className="form-control-file" onChange={this.onFile} />
|
|
135
|
+
{!onUpload && (
|
|
136
|
+
<small className="form-text text-muted">
|
|
137
|
+
{t({
|
|
138
|
+
id: 'ui.editor.image.dataUrlWarning',
|
|
139
|
+
defaultMessage: 'The image will be embedded in the content and may be blocked by mail clients.',
|
|
140
|
+
})}
|
|
141
|
+
</small>
|
|
142
|
+
)}
|
|
143
|
+
{this.state.pending && (
|
|
144
|
+
<small className="form-text text-muted">
|
|
145
|
+
{t({ id: 'ui.editor.image.uploading', defaultMessage: 'Sending…' })}
|
|
146
|
+
</small>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
) : (
|
|
150
|
+
<div className="form-row align-items-end">
|
|
151
|
+
<div className="col">
|
|
152
|
+
<label className="rbf-editor-dialog-label">{t({ id: 'ui.editor.image.url', defaultMessage: 'Address' })}</label>
|
|
153
|
+
<input
|
|
154
|
+
ref={this.srcRef}
|
|
155
|
+
type="text"
|
|
156
|
+
className="form-control form-control-sm"
|
|
157
|
+
value={this.state.src}
|
|
158
|
+
placeholder="https://"
|
|
159
|
+
onChange={ev => this.setState({ src: ev.target.value, error: null })}
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
<div className="col">
|
|
163
|
+
<label className="rbf-editor-dialog-label">{t({ id: 'ui.editor.image.alt', defaultMessage: 'Alt text' })}</label>
|
|
164
|
+
<input
|
|
165
|
+
type="text"
|
|
166
|
+
className="form-control form-control-sm"
|
|
167
|
+
value={this.state.alt}
|
|
168
|
+
onChange={ev => this.setState({ alt: ev.target.value })}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
<div className="col-auto" style={{ maxWidth: '8rem' }}>
|
|
172
|
+
<label className="rbf-editor-dialog-label">{t({ id: 'ui.editor.image.width', defaultMessage: 'Width' })}</label>
|
|
173
|
+
<input
|
|
174
|
+
type="text"
|
|
175
|
+
className="form-control form-control-sm"
|
|
176
|
+
value={this.state.width}
|
|
177
|
+
placeholder="600"
|
|
178
|
+
onChange={ev => this.setState({ width: ev.target.value })}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
<div className="col-auto">
|
|
182
|
+
<button type="button" className="btn btn-sm btn-primary" onClick={this.onApply}>
|
|
183
|
+
{t({ id: 'ui.editor.image.apply', defaultMessage: 'Insert' })}
|
|
184
|
+
</button>
|
|
185
|
+
<button type="button" className="btn btn-sm btn-link" onClick={this.props.onCancel}>
|
|
186
|
+
{t({ id: 'ui.editor.dialog.cancel', defaultMessage: 'Cancel' })}
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
{this.state.error && <div className="text-danger small mt-1">{this.state.error}</div>}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import React, { Component } from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
|
|
4
|
+
const SAFE_SCHEME = /^(https?:|mailto:|tel:|#|\/)/i;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Saisie d'un lien.
|
|
8
|
+
*
|
|
9
|
+
* Panneau en ligne plutôt que modale : l'éditeur reste visible, et surtout on
|
|
10
|
+
* n'ouvre pas une modale à l'intérieur d'une modale — la plupart des
|
|
11
|
+
* formulaires appelants sont déjà dans un `ResponsiveModalOrForm`.
|
|
12
|
+
*
|
|
13
|
+
* La cible se choisit ici, lien par lien, et n'est jamais forcée globalement :
|
|
14
|
+
* un `target="_blank"` posé par défaut réécrirait tous les liens de la base au
|
|
15
|
+
* premier enregistrement.
|
|
16
|
+
*/
|
|
17
|
+
export default class LinkDialog extends Component {
|
|
18
|
+
static propTypes = {
|
|
19
|
+
url: PropTypes.string,
|
|
20
|
+
text: PropTypes.string,
|
|
21
|
+
target: PropTypes.string,
|
|
22
|
+
canRemove: PropTypes.bool,
|
|
23
|
+
/** Vrai si du texte est sélectionné : le libellé n'est alors pas demandé. */
|
|
24
|
+
hasSelection: PropTypes.bool,
|
|
25
|
+
onApply: PropTypes.func.isRequired,
|
|
26
|
+
onRemove: PropTypes.func.isRequired,
|
|
27
|
+
onCancel: PropTypes.func.isRequired,
|
|
28
|
+
t: PropTypes.func.isRequired,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
static defaultProps = {
|
|
32
|
+
url: '',
|
|
33
|
+
text: '',
|
|
34
|
+
target: '',
|
|
35
|
+
canRemove: false,
|
|
36
|
+
hasSelection: false,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
constructor(props) {
|
|
40
|
+
super(props);
|
|
41
|
+
this.state = {
|
|
42
|
+
url: props.url,
|
|
43
|
+
text: props.text,
|
|
44
|
+
blank: props.target === '_blank',
|
|
45
|
+
error: null,
|
|
46
|
+
};
|
|
47
|
+
this.urlRef = React.createRef();
|
|
48
|
+
this.onApply = this.onApply.bind(this);
|
|
49
|
+
this.onKeyDown = this.onKeyDown.bind(this);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
componentDidMount() {
|
|
53
|
+
if (this.urlRef.current) {
|
|
54
|
+
this.urlRef.current.focus();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
onKeyDown(ev) {
|
|
59
|
+
if (ev.key === 'Enter') {
|
|
60
|
+
ev.preventDefault();
|
|
61
|
+
this.onApply();
|
|
62
|
+
} else if (ev.key === 'Escape') {
|
|
63
|
+
ev.preventDefault();
|
|
64
|
+
this.props.onCancel();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
onApply() {
|
|
69
|
+
const url = this.state.url.trim();
|
|
70
|
+
if (url === '') {
|
|
71
|
+
this.setState({ error: this.props.t({ id: 'ui.editor.link.invalidUrl', defaultMessage: 'Enter an address' }) });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!SAFE_SCHEME.test(url)) {
|
|
75
|
+
/* Une adresse sans schéma est presque toujours un oubli de https://,
|
|
76
|
+
et accepter n'importe quel schéma ouvrirait la porte à javascript:. */
|
|
77
|
+
if (url.indexOf(':') !== -1) {
|
|
78
|
+
this.setState({
|
|
79
|
+
error: this.props.t({ id: 'ui.editor.link.invalidUrl', defaultMessage: 'Unsupported address' }),
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.props.onApply({ url: 'https://' + url, text: this.state.text, target: this.state.blank ? '_blank' : null });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.props.onApply({ url, text: this.state.text, target: this.state.blank ? '_blank' : null });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
render() {
|
|
90
|
+
const { t, canRemove, hasSelection } = this.props;
|
|
91
|
+
return (
|
|
92
|
+
<div className="rbf-editor-dialog" onKeyDown={this.onKeyDown}>
|
|
93
|
+
<div className="form-row align-items-end">
|
|
94
|
+
<div className="col">
|
|
95
|
+
<label className="rbf-editor-dialog-label">{t({ id: 'ui.editor.link.url', defaultMessage: 'Address' })}</label>
|
|
96
|
+
<input
|
|
97
|
+
ref={this.urlRef}
|
|
98
|
+
type="text"
|
|
99
|
+
className="form-control form-control-sm"
|
|
100
|
+
value={this.state.url}
|
|
101
|
+
placeholder="https://"
|
|
102
|
+
onChange={ev => this.setState({ url: ev.target.value, error: null })}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
{!hasSelection && (
|
|
106
|
+
<div className="col">
|
|
107
|
+
<label className="rbf-editor-dialog-label">{t({ id: 'ui.editor.link.label', defaultMessage: 'Label' })}</label>
|
|
108
|
+
<input
|
|
109
|
+
type="text"
|
|
110
|
+
className="form-control form-control-sm"
|
|
111
|
+
value={this.state.text}
|
|
112
|
+
onChange={ev => this.setState({ text: ev.target.value })}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
<div className="col-auto">
|
|
117
|
+
<div className="custom-control custom-checkbox">
|
|
118
|
+
<input
|
|
119
|
+
type="checkbox"
|
|
120
|
+
className="custom-control-input"
|
|
121
|
+
id="rbf-editor-link-blank"
|
|
122
|
+
checked={this.state.blank}
|
|
123
|
+
onChange={ev => this.setState({ blank: ev.target.checked })}
|
|
124
|
+
/>
|
|
125
|
+
<label className="custom-control-label" htmlFor="rbf-editor-link-blank">
|
|
126
|
+
{t({ id: 'ui.editor.link.target', defaultMessage: 'New window' })}
|
|
127
|
+
</label>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="col-auto">
|
|
131
|
+
<button type="button" className="btn btn-sm btn-primary" onClick={this.onApply}>
|
|
132
|
+
{t({ id: 'ui.editor.link.apply', defaultMessage: 'Apply' })}
|
|
133
|
+
</button>
|
|
134
|
+
{canRemove && (
|
|
135
|
+
<button type="button" className="btn btn-sm btn-outline-danger ml-1" onClick={this.props.onRemove}>
|
|
136
|
+
{t({ id: 'ui.editor.link.remove', defaultMessage: 'Remove' })}
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
<button type="button" className="btn btn-sm btn-link" onClick={this.props.onCancel}>
|
|
140
|
+
{t({ id: 'ui.editor.dialog.cancel', defaultMessage: 'Cancel' })}
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
{this.state.error && <div className="text-danger small mt-1">{this.state.error}</div>}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import React, { Component } from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalise la structure des variables de fusion.
|
|
6
|
+
*
|
|
7
|
+
* Accepte la forme groupée `[{ group, items: [{ code, label, help }] }]` et
|
|
8
|
+
* l'ancienne forme plate `[{ text_code, text_content }]` de `presetTexts`,
|
|
9
|
+
* repliée en un groupe unique. La prop historique reste donc fonctionnelle.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeMergeVars(mergeVars, fallbackGroupLabel) {
|
|
12
|
+
if (!Array.isArray(mergeVars) || mergeVars.length === 0) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const flat = [];
|
|
16
|
+
const groups = [];
|
|
17
|
+
for (let i = 0; i < mergeVars.length; i++) {
|
|
18
|
+
const entry = mergeVars[i];
|
|
19
|
+
if (!entry) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(entry.items)) {
|
|
23
|
+
groups.push({
|
|
24
|
+
key: entry.key || entry.group || String(i),
|
|
25
|
+
group: entry.group || '',
|
|
26
|
+
items: entry.items.filter(item => item && item.code),
|
|
27
|
+
});
|
|
28
|
+
} else if (entry.code || entry.text_code) {
|
|
29
|
+
flat.push({
|
|
30
|
+
code: entry.code || entry.text_code,
|
|
31
|
+
label: entry.label || entry.text_content || entry.code || entry.text_code,
|
|
32
|
+
help: entry.help || '',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (flat.length > 0) {
|
|
37
|
+
groups.push({ key: '__flat__', group: fallbackGroupLabel || '', items: flat });
|
|
38
|
+
}
|
|
39
|
+
return groups;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function matches(item, needle) {
|
|
43
|
+
if (needle === '') {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
const haystack = ((item.label || '') + ' ' + (item.code || '')).toLowerCase();
|
|
47
|
+
return haystack.indexOf(needle) !== -1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Menu d'insertion des variables de fusion.
|
|
52
|
+
* Le champ de filtre devient indispensable dès une trentaine de variables.
|
|
53
|
+
*/
|
|
54
|
+
export default class MergeMenu extends Component {
|
|
55
|
+
static propTypes = {
|
|
56
|
+
groups: PropTypes.array.isRequired,
|
|
57
|
+
onSelect: PropTypes.func.isRequired,
|
|
58
|
+
t: PropTypes.func.isRequired,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
constructor(props) {
|
|
62
|
+
super(props);
|
|
63
|
+
this.state = { filter: '' };
|
|
64
|
+
this.onFilter = this.onFilter.bind(this);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
onFilter(ev) {
|
|
68
|
+
this.setState({ filter: ev.target.value.toLowerCase() });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
render() {
|
|
72
|
+
const { groups, onSelect, t } = this.props;
|
|
73
|
+
const needle = this.state.filter;
|
|
74
|
+
const visible = groups
|
|
75
|
+
.map(group => ({ ...group, items: group.items.filter(item => matches(item, needle)) }))
|
|
76
|
+
.filter(group => group.items.length > 0);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="rbf-editor-mergemenu">
|
|
80
|
+
<div className="rbf-editor-mergemenu-search">
|
|
81
|
+
<input
|
|
82
|
+
type="text"
|
|
83
|
+
className="form-control form-control-sm"
|
|
84
|
+
placeholder={t({ id: 'ui.editor.mergevar.search', defaultMessage: 'Search a variable' })}
|
|
85
|
+
value={this.state.filter}
|
|
86
|
+
onChange={this.onFilter}
|
|
87
|
+
/* Le clic dans le champ ne doit pas refermer le menu. */
|
|
88
|
+
onClick={ev => ev.stopPropagation()}
|
|
89
|
+
onMouseDown={ev => ev.stopPropagation()}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="rbf-editor-mergemenu-list">
|
|
93
|
+
{visible.length === 0 && (
|
|
94
|
+
<div className="dropdown-item-text text-muted">
|
|
95
|
+
{t({ id: 'ui.editor.mergevar.empty', defaultMessage: 'No variable' })}
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
{visible.map(group => (
|
|
99
|
+
<div key={group.key}>
|
|
100
|
+
{group.group !== '' && <h6 className="dropdown-header">{group.group}</h6>}
|
|
101
|
+
{group.items.map(item => (
|
|
102
|
+
<button
|
|
103
|
+
key={item.code}
|
|
104
|
+
type="button"
|
|
105
|
+
className="dropdown-item rbf-editor-mergemenu-item"
|
|
106
|
+
title={item.help || item.code}
|
|
107
|
+
onMouseDown={ev => ev.preventDefault()}
|
|
108
|
+
onClick={() => onSelect(item)}
|
|
109
|
+
>
|
|
110
|
+
<span className="rbf-editor-mergemenu-label">{item.label}</span>
|
|
111
|
+
<small className="text-muted d-block">{item.code}</small>
|
|
112
|
+
</button>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|