@visns-studio/visns-components 6.34.0 → 6.34.3
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/README.md +14 -1
- package/package.json +1 -1
- package/src/components/auth/Login.jsx +2 -2
- package/src/components/auth/Reset.jsx +1 -1
- package/src/components/auth/TwoFactorAuth.jsx +1 -1
- package/src/components/auth/Verify.jsx +2 -2
- package/src/components/emailCampaigns/BlockEditor.jsx +486 -0
- package/src/components/emailCampaigns/CampaignEditor.jsx +614 -0
- package/src/components/emailCampaigns/CampaignReport.jsx +98 -0
- package/src/components/emailCampaigns/EmailCampaigns.jsx +422 -0
- package/src/components/emailCampaigns/EmailLists.jsx +505 -0
- package/src/components/emailCampaigns/emailCampaignApi.js +135 -0
- package/src/components/sms/SmsCampaigns.jsx +34 -4
- package/src/components/sms/SmsInbox.jsx +2 -5
- package/src/components/sms/SmsLineSettings.jsx +2 -5
- package/src/components/styles/EmailCampaigns.module.scss +922 -0
- package/src/components/styles/Sms.module.scss +33 -4
- package/src/components/styles/Vault.module.scss +5 -3
- package/src/components/styles/global.css +14 -2
- package/src/index.js +4 -0
package/README.md
CHANGED
|
@@ -9,7 +9,20 @@ A comprehensive React component library used by the VISNS Studio team for CRM an
|
|
|
9
9
|
|
|
10
10
|
VISNS Components is a React-based UI component library that provides a set of reusable, consistent, and customizable components for building web applications. It includes components for authentication, data grids, forms, navigation, and more, designed to work seamlessly together.
|
|
11
11
|
|
|
12
|
-
## Recent Updates (v6.34.
|
|
12
|
+
## Recent Updates (v6.34.3)
|
|
13
|
+
|
|
14
|
+
### The auth fields' values no longer sit on top of their labels
|
|
15
|
+
|
|
16
|
+
The sign-in, reset, verify and 2FA screens draw a floating label inside the
|
|
17
|
+
input, and `_authShell.scss` sizes those inputs for it — a 3.35rem box with a
|
|
18
|
+
top-heavy padding so the value clears the label. That rule is `.field input`,
|
|
19
|
+
(0,1,1), and `global.css` sizes every text input through a twenty-clause
|
|
20
|
+
`input[type]:not(...)` chain that outranks it. So the chain won: the box shrank
|
|
21
|
+
to `--field-height`, the padding went horizontal-only, and the value was drawn
|
|
22
|
+
straight through the label. The six auth inputs now carry `visns-auth-input`,
|
|
23
|
+
and both global chains (sizing and focus) exclude it, the same opt-out
|
|
24
|
+
`searchInput` and `filterInput` already have. Apps that keep a global input
|
|
25
|
+
rule of their own can exclude the same class.
|
|
13
26
|
|
|
14
27
|
### The shell layout is on `<html>`, where anything can read it
|
|
15
28
|
|
package/package.json
CHANGED
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
94
94
|
},
|
|
95
95
|
"name": "@visns-studio/visns-components",
|
|
96
|
-
"version": "6.34.
|
|
96
|
+
"version": "6.34.3",
|
|
97
97
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
98
98
|
"main": "src/index.js",
|
|
99
99
|
"files": [
|
|
@@ -524,7 +524,7 @@ const Login = ({
|
|
|
524
524
|
value={auth.email}
|
|
525
525
|
onChange={handleChange}
|
|
526
526
|
tabIndex="1"
|
|
527
|
-
className={authClass.username}
|
|
527
|
+
className={`visns-auth-input ${authClass.username}`.trim()}
|
|
528
528
|
autoComplete="username"
|
|
529
529
|
placeholder=" "
|
|
530
530
|
disabled={isBusy}
|
|
@@ -540,7 +540,7 @@ const Login = ({
|
|
|
540
540
|
value={auth.password}
|
|
541
541
|
onChange={handleChange}
|
|
542
542
|
tabIndex="2"
|
|
543
|
-
className={authClass.password}
|
|
543
|
+
className={`visns-auth-input ${authClass.password}`.trim()}
|
|
544
544
|
autoComplete="current-password"
|
|
545
545
|
placeholder=" "
|
|
546
546
|
disabled={isBusy}
|
|
@@ -455,7 +455,7 @@ const TwoFactorAuth = ({
|
|
|
455
455
|
value={verificationCode}
|
|
456
456
|
onChange={handleChange}
|
|
457
457
|
tabIndex="1"
|
|
458
|
-
className={codeClass}
|
|
458
|
+
className={`visns-auth-input ${codeClass}`.trim()}
|
|
459
459
|
placeholder=" "
|
|
460
460
|
autoComplete="one-time-code"
|
|
461
461
|
inputMode="numeric"
|
|
@@ -201,7 +201,7 @@ const Verify = ({
|
|
|
201
201
|
value={password}
|
|
202
202
|
onChange={handleInputChange}
|
|
203
203
|
tabIndex="1"
|
|
204
|
-
className={passwordClass}
|
|
204
|
+
className={`visns-auth-input ${passwordClass}`.trim()}
|
|
205
205
|
autoComplete="new-password"
|
|
206
206
|
placeholder=" "
|
|
207
207
|
autoFocus
|
|
@@ -237,7 +237,7 @@ const Verify = ({
|
|
|
237
237
|
value={passwordRepeat}
|
|
238
238
|
onChange={handleInputChange}
|
|
239
239
|
tabIndex="2"
|
|
240
|
-
className={passwordRepeatClass}
|
|
240
|
+
className={`visns-auth-input ${passwordRepeatClass}`.trim()}
|
|
241
241
|
autoComplete="new-password"
|
|
242
242
|
placeholder=" "
|
|
243
243
|
disabled={isLoading}
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
AlignCenter,
|
|
4
|
+
AlignLeft,
|
|
5
|
+
AlignRight,
|
|
6
|
+
ArrowDown,
|
|
7
|
+
ArrowUp,
|
|
8
|
+
Bold,
|
|
9
|
+
ImageUp,
|
|
10
|
+
Italic,
|
|
11
|
+
Link2,
|
|
12
|
+
List,
|
|
13
|
+
Trash2,
|
|
14
|
+
} from 'lucide-react';
|
|
15
|
+
|
|
16
|
+
import styles from '../styles/EmailCampaigns.module.scss';
|
|
17
|
+
import { api, BLOCK_TYPES, newBlock } from './emailCampaignApi';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The campaign's content, as an ordered list of blocks.
|
|
21
|
+
*
|
|
22
|
+
* Each block is a card with its own fields and the same three controls: move
|
|
23
|
+
* up, move down, remove. The server turns the blocks into the email
|
|
24
|
+
* (EmailCampaignRenderer) and cleans the rich text again on the way, so nothing
|
|
25
|
+
* here is trusted for safety; it is only an editor.
|
|
26
|
+
*
|
|
27
|
+
* Merge tags are typed as `{first_name}` and friends. The "Insert" menu puts one
|
|
28
|
+
* at the cursor so nobody has to remember the spelling.
|
|
29
|
+
*/
|
|
30
|
+
const TAG_LABELS = {
|
|
31
|
+
first_name: 'First name',
|
|
32
|
+
last_name: 'Last name',
|
|
33
|
+
name: 'Full name',
|
|
34
|
+
company: 'Company',
|
|
35
|
+
email: 'Email',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const BlockEditor = ({ blocks, onChange, routes, disabled, tags = Object.keys(TAG_LABELS) }) => {
|
|
39
|
+
const update = (index, patch) =>
|
|
40
|
+
onChange(blocks.map((block, at) => (at === index ? { ...block, ...patch } : block)));
|
|
41
|
+
|
|
42
|
+
const move = (index, by) => {
|
|
43
|
+
const target = index + by;
|
|
44
|
+
|
|
45
|
+
if (target < 0 || target >= blocks.length) return;
|
|
46
|
+
|
|
47
|
+
const next = [...blocks];
|
|
48
|
+
[next[index], next[target]] = [next[target], next[index]];
|
|
49
|
+
onChange(next);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const remove = (index) => onChange(blocks.filter((block, at) => at !== index));
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={styles.blocks}>
|
|
56
|
+
{blocks.length === 0 && (
|
|
57
|
+
<p className={styles.quiet}>No content yet. Add a heading or some text below.</p>
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
{blocks.map((block, index) => (
|
|
61
|
+
<div className={styles.block} key={index}>
|
|
62
|
+
<div className={styles.blockHead}>
|
|
63
|
+
<span className={styles.blockType}>
|
|
64
|
+
{BLOCK_TYPES.find((entry) => entry.type === block.type)?.label || block.type}
|
|
65
|
+
</span>
|
|
66
|
+
<span className={styles.blockTools}>
|
|
67
|
+
<IconButton
|
|
68
|
+
title="Move this block up"
|
|
69
|
+
disabled={disabled || index === 0}
|
|
70
|
+
onClick={() => move(index, -1)}
|
|
71
|
+
>
|
|
72
|
+
<ArrowUp size={14} aria-hidden="true" />
|
|
73
|
+
</IconButton>
|
|
74
|
+
<IconButton
|
|
75
|
+
title="Move this block down"
|
|
76
|
+
disabled={disabled || index === blocks.length - 1}
|
|
77
|
+
onClick={() => move(index, 1)}
|
|
78
|
+
>
|
|
79
|
+
<ArrowDown size={14} aria-hidden="true" />
|
|
80
|
+
</IconButton>
|
|
81
|
+
<IconButton
|
|
82
|
+
title="Remove this block"
|
|
83
|
+
danger
|
|
84
|
+
disabled={disabled}
|
|
85
|
+
onClick={() => remove(index)}
|
|
86
|
+
>
|
|
87
|
+
<Trash2 size={14} aria-hidden="true" />
|
|
88
|
+
</IconButton>
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<BlockFields
|
|
93
|
+
block={block}
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
tags={tags}
|
|
96
|
+
routes={routes}
|
|
97
|
+
onChange={(patch) => update(index, patch)}
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
))}
|
|
101
|
+
|
|
102
|
+
{!disabled && (
|
|
103
|
+
<div className={styles.addRow}>
|
|
104
|
+
<span className={styles.addLabel}>Add</span>
|
|
105
|
+
{BLOCK_TYPES.map((entry) => (
|
|
106
|
+
<button
|
|
107
|
+
key={entry.type}
|
|
108
|
+
type="button"
|
|
109
|
+
className={styles.addButton}
|
|
110
|
+
title={`Add a ${entry.label.toLowerCase()} block at the end`}
|
|
111
|
+
onClick={() => onChange([...blocks, newBlock(entry.type)])}
|
|
112
|
+
>
|
|
113
|
+
{entry.label}
|
|
114
|
+
</button>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const BlockFields = ({ block, onChange, disabled, tags, routes }) => {
|
|
123
|
+
switch (block.type) {
|
|
124
|
+
case 'heading':
|
|
125
|
+
return (
|
|
126
|
+
<div className={styles.fieldRow}>
|
|
127
|
+
<TextWithTags
|
|
128
|
+
value={block.text || ''}
|
|
129
|
+
placeholder="Heading"
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
tags={tags}
|
|
132
|
+
onChange={(text) => onChange({ text })}
|
|
133
|
+
/>
|
|
134
|
+
<select
|
|
135
|
+
className={styles.select}
|
|
136
|
+
value={block.level === 2 ? 2 : 1}
|
|
137
|
+
disabled={disabled}
|
|
138
|
+
title="Heading size"
|
|
139
|
+
onChange={(event) => onChange({ level: Number(event.target.value) })}
|
|
140
|
+
>
|
|
141
|
+
<option value={1}>Large</option>
|
|
142
|
+
<option value={2}>Small</option>
|
|
143
|
+
</select>
|
|
144
|
+
<AlignPicker value={block.align} disabled={disabled} onChange={(align) => onChange({ align })} />
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
case 'text':
|
|
149
|
+
return (
|
|
150
|
+
<RichText
|
|
151
|
+
value={block.html || ''}
|
|
152
|
+
disabled={disabled}
|
|
153
|
+
tags={tags}
|
|
154
|
+
onChange={(html) => onChange({ html })}
|
|
155
|
+
/>
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
case 'columns':
|
|
159
|
+
return (
|
|
160
|
+
<div className={styles.columnsEdit}>
|
|
161
|
+
<RichText value={block.left || ''} disabled={disabled} tags={tags} onChange={(left) => onChange({ left })} />
|
|
162
|
+
<RichText value={block.right || ''} disabled={disabled} tags={tags} onChange={(right) => onChange({ right })} />
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
case 'button':
|
|
167
|
+
return (
|
|
168
|
+
<div className={styles.fieldRow}>
|
|
169
|
+
<TextWithTags
|
|
170
|
+
value={block.label || ''}
|
|
171
|
+
placeholder="Button label"
|
|
172
|
+
disabled={disabled}
|
|
173
|
+
tags={tags}
|
|
174
|
+
onChange={(label) => onChange({ label })}
|
|
175
|
+
/>
|
|
176
|
+
<input
|
|
177
|
+
type="url"
|
|
178
|
+
className={styles.input}
|
|
179
|
+
value={block.url || ''}
|
|
180
|
+
placeholder="https://"
|
|
181
|
+
disabled={disabled}
|
|
182
|
+
title="Where the button goes"
|
|
183
|
+
onChange={(event) => onChange({ url: event.target.value })}
|
|
184
|
+
/>
|
|
185
|
+
<AlignPicker value={block.align} disabled={disabled} onChange={(align) => onChange({ align })} />
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
case 'image':
|
|
190
|
+
return <ImageFields block={block} disabled={disabled} routes={routes} onChange={onChange} />;
|
|
191
|
+
|
|
192
|
+
case 'spacer':
|
|
193
|
+
return (
|
|
194
|
+
<label className={styles.inline}>
|
|
195
|
+
Height
|
|
196
|
+
<input
|
|
197
|
+
type="range"
|
|
198
|
+
min={8}
|
|
199
|
+
max={80}
|
|
200
|
+
step={4}
|
|
201
|
+
value={block.height || 24}
|
|
202
|
+
disabled={disabled}
|
|
203
|
+
onChange={(event) => onChange({ height: Number(event.target.value) })}
|
|
204
|
+
/>
|
|
205
|
+
<span className={styles.quiet}>{block.height || 24}px</span>
|
|
206
|
+
</label>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
case 'divider':
|
|
210
|
+
return <p className={styles.quiet}>A thin line across the email.</p>;
|
|
211
|
+
|
|
212
|
+
default:
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const ImageFields = ({ block, onChange, disabled, routes }) => {
|
|
218
|
+
const [uploading, setUploading] = useState(false);
|
|
219
|
+
const [error, setError] = useState('');
|
|
220
|
+
const file = useRef(null);
|
|
221
|
+
|
|
222
|
+
const upload = async (picked) => {
|
|
223
|
+
if (!picked) return;
|
|
224
|
+
|
|
225
|
+
const body = new FormData();
|
|
226
|
+
body.append('image', picked);
|
|
227
|
+
setUploading(true);
|
|
228
|
+
setError('');
|
|
229
|
+
|
|
230
|
+
const result = await api(routes.images, 'POST', body);
|
|
231
|
+
|
|
232
|
+
setUploading(false);
|
|
233
|
+
|
|
234
|
+
if (result.ok && result.data?.url) {
|
|
235
|
+
onChange({ url: result.data.url, alt: block.alt || picked.name.replace(/\.[a-z0-9]+$/i, '') });
|
|
236
|
+
} else {
|
|
237
|
+
setError(result.message || 'The image could not be uploaded.');
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div className={styles.imageEdit}>
|
|
243
|
+
{block.url ? (
|
|
244
|
+
<img src={block.url} alt="" className={styles.imageThumb} />
|
|
245
|
+
) : (
|
|
246
|
+
<div className={styles.imageEmpty}>No image yet</div>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
<div className={styles.imageFields}>
|
|
250
|
+
<div className={styles.fieldRow}>
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
className={styles.button}
|
|
254
|
+
disabled={disabled || uploading}
|
|
255
|
+
title="Upload a JPEG, PNG, GIF or WebP from this computer"
|
|
256
|
+
onClick={() => file.current?.click()}
|
|
257
|
+
>
|
|
258
|
+
<ImageUp size={14} aria-hidden="true" />
|
|
259
|
+
{uploading ? 'Uploading…' : 'Upload image'}
|
|
260
|
+
</button>
|
|
261
|
+
<input
|
|
262
|
+
ref={file}
|
|
263
|
+
type="file"
|
|
264
|
+
accept="image/jpeg,image/png,image/gif,image/webp"
|
|
265
|
+
hidden
|
|
266
|
+
onChange={(event) => upload(event.target.files?.[0])}
|
|
267
|
+
/>
|
|
268
|
+
<input
|
|
269
|
+
type="url"
|
|
270
|
+
className={styles.input}
|
|
271
|
+
value={block.url || ''}
|
|
272
|
+
placeholder="or paste an image address (https://…)"
|
|
273
|
+
disabled={disabled}
|
|
274
|
+
onChange={(event) => onChange({ url: event.target.value })}
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
<div className={styles.fieldRow}>
|
|
278
|
+
<input
|
|
279
|
+
type="text"
|
|
280
|
+
className={styles.input}
|
|
281
|
+
value={block.alt || ''}
|
|
282
|
+
placeholder="Describe the image (shown if it does not load)"
|
|
283
|
+
disabled={disabled}
|
|
284
|
+
onChange={(event) => onChange({ alt: event.target.value })}
|
|
285
|
+
/>
|
|
286
|
+
<input
|
|
287
|
+
type="url"
|
|
288
|
+
className={styles.input}
|
|
289
|
+
value={block.href || ''}
|
|
290
|
+
placeholder="Link when clicked (optional)"
|
|
291
|
+
disabled={disabled}
|
|
292
|
+
onChange={(event) => onChange({ href: event.target.value })}
|
|
293
|
+
/>
|
|
294
|
+
</div>
|
|
295
|
+
<label className={styles.inline}>
|
|
296
|
+
Width
|
|
297
|
+
<input
|
|
298
|
+
type="range"
|
|
299
|
+
min={20}
|
|
300
|
+
max={100}
|
|
301
|
+
step={5}
|
|
302
|
+
value={block.width || 100}
|
|
303
|
+
disabled={disabled}
|
|
304
|
+
onChange={(event) => onChange({ width: Number(event.target.value) })}
|
|
305
|
+
/>
|
|
306
|
+
<span className={styles.quiet}>{block.width || 100}%</span>
|
|
307
|
+
<AlignPicker value={block.align || 'center'} disabled={disabled} onChange={(align) => onChange({ align })} />
|
|
308
|
+
</label>
|
|
309
|
+
{error && <p className={styles.fieldError}>{error}</p>}
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** A single-line field with a merge-tag menu beside it. */
|
|
316
|
+
const TextWithTags = ({ value, onChange, placeholder, disabled, tags }) => {
|
|
317
|
+
const input = useRef(null);
|
|
318
|
+
|
|
319
|
+
const insert = (tag) => {
|
|
320
|
+
const element = input.current;
|
|
321
|
+
const token = `{${tag}}`;
|
|
322
|
+
|
|
323
|
+
if (!element) {
|
|
324
|
+
onChange(`${value}${token}`);
|
|
325
|
+
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const start = element.selectionStart ?? value.length;
|
|
330
|
+
const end = element.selectionEnd ?? value.length;
|
|
331
|
+
onChange(value.slice(0, start) + token + value.slice(end));
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<span className={styles.withTags}>
|
|
336
|
+
<input
|
|
337
|
+
ref={input}
|
|
338
|
+
type="text"
|
|
339
|
+
className={styles.input}
|
|
340
|
+
value={value}
|
|
341
|
+
placeholder={placeholder}
|
|
342
|
+
disabled={disabled}
|
|
343
|
+
onChange={(event) => onChange(event.target.value)}
|
|
344
|
+
/>
|
|
345
|
+
<TagMenu tags={tags} disabled={disabled} onPick={insert} />
|
|
346
|
+
</span>
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const TagMenu = ({ tags, onPick, disabled }) => (
|
|
351
|
+
<select
|
|
352
|
+
className={styles.tagSelect}
|
|
353
|
+
value=""
|
|
354
|
+
disabled={disabled}
|
|
355
|
+
title="Insert a personal detail, filled in for each recipient"
|
|
356
|
+
aria-label="Insert a personal detail"
|
|
357
|
+
onChange={(event) => {
|
|
358
|
+
if (event.target.value) onPick(event.target.value);
|
|
359
|
+
}}
|
|
360
|
+
>
|
|
361
|
+
<option value="">Insert…</option>
|
|
362
|
+
{tags.map((tag) => (
|
|
363
|
+
<option key={tag} value={tag}>
|
|
364
|
+
{TAG_LABELS[tag] || tag}
|
|
365
|
+
</option>
|
|
366
|
+
))}
|
|
367
|
+
</select>
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* A small rich-text box: bold, italic, link, bullet list, and a merge tag.
|
|
372
|
+
*
|
|
373
|
+
* `contentEditable` with `execCommand`: deprecated on paper and supported by
|
|
374
|
+
* every browser in practice, and a whole editor library for five buttons would
|
|
375
|
+
* be a bundle every reader of every page pays for. The HTML it produces is
|
|
376
|
+
* cleaned on the server before it goes anywhere.
|
|
377
|
+
*
|
|
378
|
+
* The box is written to only when the value changes from OUTSIDE (a template
|
|
379
|
+
* loaded, a reset) — writing on every keystroke would throw the cursor to the
|
|
380
|
+
* start of the box.
|
|
381
|
+
*/
|
|
382
|
+
const RichText = ({ value, onChange, disabled, tags }) => {
|
|
383
|
+
const box = useRef(null);
|
|
384
|
+
const last = useRef(value);
|
|
385
|
+
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (box.current && value !== last.current) {
|
|
388
|
+
box.current.innerHTML = value;
|
|
389
|
+
last.current = value;
|
|
390
|
+
}
|
|
391
|
+
}, [value]);
|
|
392
|
+
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
if (box.current) box.current.innerHTML = value;
|
|
395
|
+
// First paint only; later outside changes go through the effect above.
|
|
396
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
397
|
+
}, []);
|
|
398
|
+
|
|
399
|
+
const emit = () => {
|
|
400
|
+
const html = box.current?.innerHTML ?? '';
|
|
401
|
+
last.current = html;
|
|
402
|
+
onChange(html);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const run = (command, argument = null) => {
|
|
406
|
+
box.current?.focus();
|
|
407
|
+
document.execCommand(command, false, argument);
|
|
408
|
+
emit();
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const link = () => {
|
|
412
|
+
const url = window.prompt('Link address (https://…)');
|
|
413
|
+
|
|
414
|
+
if (url && /^(https?:\/\/|mailto:)/i.test(url.trim())) {
|
|
415
|
+
run('createLink', url.trim());
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<div className={styles.rich}>
|
|
421
|
+
<div className={styles.richBar}>
|
|
422
|
+
<IconButton title="Bold" disabled={disabled} onClick={() => run('bold')}>
|
|
423
|
+
<Bold size={14} aria-hidden="true" />
|
|
424
|
+
</IconButton>
|
|
425
|
+
<IconButton title="Italic" disabled={disabled} onClick={() => run('italic')}>
|
|
426
|
+
<Italic size={14} aria-hidden="true" />
|
|
427
|
+
</IconButton>
|
|
428
|
+
<IconButton title="Link the selected words" disabled={disabled} onClick={link}>
|
|
429
|
+
<Link2 size={14} aria-hidden="true" />
|
|
430
|
+
</IconButton>
|
|
431
|
+
<IconButton title="Bulleted list" disabled={disabled} onClick={() => run('insertUnorderedList')}>
|
|
432
|
+
<List size={14} aria-hidden="true" />
|
|
433
|
+
</IconButton>
|
|
434
|
+
<TagMenu tags={tags} disabled={disabled} onPick={(tag) => run('insertText', `{${tag}}`)} />
|
|
435
|
+
</div>
|
|
436
|
+
<div
|
|
437
|
+
ref={box}
|
|
438
|
+
className={styles.richBox}
|
|
439
|
+
contentEditable={!disabled}
|
|
440
|
+
suppressContentEditableWarning
|
|
441
|
+
role="textbox"
|
|
442
|
+
aria-multiline="true"
|
|
443
|
+
onInput={emit}
|
|
444
|
+
onBlur={emit}
|
|
445
|
+
/>
|
|
446
|
+
</div>
|
|
447
|
+
);
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const AlignPicker = ({ value = 'left', onChange, disabled }) => (
|
|
451
|
+
<span className={styles.alignPicker} role="group" aria-label="Alignment">
|
|
452
|
+
{[
|
|
453
|
+
['left', AlignLeft],
|
|
454
|
+
['center', AlignCenter],
|
|
455
|
+
['right', AlignRight],
|
|
456
|
+
].map(([key, Icon]) => (
|
|
457
|
+
<IconButton
|
|
458
|
+
key={key}
|
|
459
|
+
title={`Align ${key}`}
|
|
460
|
+
pressed={value === key}
|
|
461
|
+
disabled={disabled}
|
|
462
|
+
onClick={() => onChange(key)}
|
|
463
|
+
>
|
|
464
|
+
<Icon size={14} aria-hidden="true" />
|
|
465
|
+
</IconButton>
|
|
466
|
+
))}
|
|
467
|
+
</span>
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
const IconButton = ({ title, onClick, disabled, danger, pressed, children }) => (
|
|
471
|
+
<button
|
|
472
|
+
type="button"
|
|
473
|
+
className={[styles.iconButton, danger ? styles.iconDanger : '', pressed ? styles.iconOn : '']
|
|
474
|
+
.filter(Boolean)
|
|
475
|
+
.join(' ')}
|
|
476
|
+
title={title}
|
|
477
|
+
aria-label={title}
|
|
478
|
+
aria-pressed={pressed === undefined ? undefined : pressed}
|
|
479
|
+
disabled={disabled}
|
|
480
|
+
onClick={onClick}
|
|
481
|
+
>
|
|
482
|
+
{children}
|
|
483
|
+
</button>
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
export default BlockEditor;
|