@mbs-dev/react-editor 1.0.8 → 1.2.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/README.md +210 -56
- package/dist/Editor.js +233 -44
- package/package.json +1 -1
- package/src/Editor.tsx +315 -55
- package/src/Editor.types.ts +3 -2
- package/types/Editor.d.ts +22 -8
- package/types/Editor.types.d.ts +1 -0
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
|
|
2
1
|
# @mbs-dev/react-editor
|
|
3
2
|
|
|
4
|
-
A lightweight, typed React wrapper around **Jodit** rich
|
|
3
|
+
A lightweight, typed React wrapper around **Jodit** rich‑text editor, designed for real‑world CRUD forms (blogs, products, CMS pages, etc.).
|
|
5
4
|
|
|
6
5
|
It provides:
|
|
7
6
|
|
|
8
7
|
- A **simple React component** `<ReactEditor />`
|
|
9
8
|
- A reusable **`config()` helper** for configuration
|
|
10
|
-
-
|
|
9
|
+
- A reusable **`uploaderConfig()` helper**
|
|
10
|
+
- A new **`onDeleteImage` callback** to delete images on server when removed from editor
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
@@ -19,11 +19,12 @@ It provides:
|
|
|
19
19
|
|
|
20
20
|
## ✨ Features
|
|
21
21
|
|
|
22
|
-
- 🧩
|
|
23
|
-
- ⚙️
|
|
24
|
-
- 🖼️
|
|
25
|
-
-
|
|
26
|
-
-
|
|
22
|
+
- 🧩 Simple React API
|
|
23
|
+
- ⚙️ Powerful config builder (`config()`)
|
|
24
|
+
- 🖼️ Image upload support
|
|
25
|
+
- 🗑️ **New: Server‑side image delete support (`onDeleteImage`)**
|
|
26
|
+
- 🔥 Fully typed (TS/TSX)
|
|
27
|
+
- 📦 Built for npm libraries
|
|
27
28
|
|
|
28
29
|
---
|
|
29
30
|
|
|
@@ -38,19 +39,19 @@ npm install @mbs-dev/react-editor
|
|
|
38
39
|
## 🚀 Quick Start
|
|
39
40
|
|
|
40
41
|
```tsx
|
|
41
|
-
import React, { useMemo, useState } from
|
|
42
|
-
import ReactEditor, { config } from
|
|
42
|
+
import React, { useMemo, useState } from "react";
|
|
43
|
+
import ReactEditor, { config } from "@mbs-dev/react-editor";
|
|
43
44
|
|
|
44
45
|
const MyPage = () => {
|
|
45
|
-
const [content, setContent] = useState(
|
|
46
|
+
const [content, setContent] = useState("");
|
|
46
47
|
|
|
47
|
-
const editorConfig = useMemo(() => config(
|
|
48
|
+
const editorConfig = useMemo(() => config({}), []);
|
|
48
49
|
|
|
49
50
|
return (
|
|
50
51
|
<ReactEditor
|
|
51
52
|
config={editorConfig}
|
|
52
53
|
value={content}
|
|
53
|
-
onChange={
|
|
54
|
+
onChange={setContent}
|
|
54
55
|
/>
|
|
55
56
|
);
|
|
56
57
|
};
|
|
@@ -58,82 +59,235 @@ const MyPage = () => {
|
|
|
58
59
|
|
|
59
60
|
---
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
# 🔧 Configuration (Full API)
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
## `config(params: ConfigParams)`
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
```ts
|
|
67
|
+
type ConfigParams = {
|
|
68
|
+
includeUploader?: boolean;
|
|
69
|
+
apiUrl?: string;
|
|
70
|
+
imageUrl?: string;
|
|
71
|
+
onDeleteImage?: (imageUrl: string) => void | Promise<void>;
|
|
72
|
+
};
|
|
73
|
+
```
|
|
66
74
|
|
|
67
|
-
|
|
75
|
+
### Example
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
const editorConfig = useMemo(
|
|
79
|
+
() =>
|
|
80
|
+
config({
|
|
81
|
+
includeUploader: true,
|
|
82
|
+
apiUrl: `${apiUrl}/upload`,
|
|
83
|
+
imageUrl: blogPostImgUrl,
|
|
84
|
+
onDeleteImage: handleDeleteImage,
|
|
85
|
+
}),
|
|
86
|
+
[]
|
|
87
|
+
);
|
|
88
|
+
```
|
|
68
89
|
|
|
69
90
|
---
|
|
70
91
|
|
|
71
|
-
|
|
92
|
+
# 🖼️ Image Uploading
|
|
93
|
+
|
|
94
|
+
The backend must return:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"success": true,
|
|
99
|
+
"data": {
|
|
100
|
+
"files": ["uploaded.webp"],
|
|
101
|
+
"messages": []
|
|
102
|
+
},
|
|
103
|
+
"msg": "Upload successful"
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Images get inserted automatically:
|
|
108
|
+
|
|
109
|
+
```html
|
|
110
|
+
<img src="https://domain.com/uploads/images/filename.webp" />
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
72
114
|
|
|
73
|
-
|
|
115
|
+
# 🗑️ NEW — Image Delete (Server Sync)
|
|
74
116
|
|
|
75
|
-
|
|
117
|
+
The editor detects removed `<img>` tags and calls your function:
|
|
76
118
|
|
|
77
|
-
|
|
119
|
+
### **TSX Implementation**
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
const handleDeleteImage = async (imageSrc: string) => {
|
|
123
|
+
const filename = imageSrc.split("/").pop();
|
|
124
|
+
if (!filename) return;
|
|
125
|
+
|
|
126
|
+
await axios.delete(`${apiUrl}/delete/${filename}`);
|
|
127
|
+
};
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
# 🧩 Full TSX Example (Add Blog)
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
const AddBlog = () => {
|
|
136
|
+
const [description, setDescription] = useState("");
|
|
137
|
+
|
|
138
|
+
const handleDeleteImage = async (imageSrc: string) => {
|
|
139
|
+
const filename = imageSrc.split("/").pop();
|
|
140
|
+
if (!filename) return;
|
|
141
|
+
await axios.delete(`${apiUrl}/delete/${filename}`);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const editorConfig = useMemo(
|
|
145
|
+
() =>
|
|
146
|
+
config({
|
|
147
|
+
includeUploader: true,
|
|
148
|
+
apiUrl: `${apiUrl}/upload`,
|
|
149
|
+
imageUrl: blogPostImgUrl,
|
|
150
|
+
onDeleteImage: handleDeleteImage,
|
|
151
|
+
}),
|
|
152
|
+
[]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<ReactEditor
|
|
157
|
+
config={editorConfig}
|
|
158
|
+
value={description}
|
|
159
|
+
onChange={setDescription}
|
|
160
|
+
/>
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
# 🧩 Symfony API Platform Full Example
|
|
168
|
+
|
|
169
|
+
## **1. Upload base directory**
|
|
170
|
+
|
|
171
|
+
`config/services.yaml`
|
|
78
172
|
|
|
79
173
|
```yaml
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
controller: App\Controller\ImageUploadController
|
|
83
|
-
methods: [POST]
|
|
174
|
+
parameters:
|
|
175
|
+
blog_post_images: '%kernel.project_dir%/public/uploads/images_dir'
|
|
84
176
|
```
|
|
85
177
|
|
|
86
|
-
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## **2. Symfony Upload & Delete Service**
|
|
87
181
|
|
|
88
182
|
```php
|
|
89
|
-
|
|
183
|
+
<?php
|
|
90
184
|
|
|
91
|
-
|
|
185
|
+
final class UploaderService
|
|
92
186
|
{
|
|
93
|
-
|
|
94
|
-
|
|
187
|
+
public function __construct(
|
|
188
|
+
private readonly EntityManagerInterface $entityManager,
|
|
189
|
+
private readonly ParameterBagInterface $params
|
|
190
|
+
) {}
|
|
191
|
+
|
|
192
|
+
public function uploadBlogPostImages(array $uploadedFiles): array
|
|
193
|
+
{
|
|
194
|
+
if ($uploadedFiles === []) {
|
|
195
|
+
throw new BadRequestHttpException('Les fichiers sont requis.');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
$uploadedFileNames = [];
|
|
199
|
+
|
|
200
|
+
foreach ($uploadedFiles as $file) {
|
|
201
|
+
if (!$file instanceof UploadedFile) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
$image = new BlogPostImages();
|
|
206
|
+
$image->setImageFile($file);
|
|
207
|
+
|
|
208
|
+
$this->entityManager->persist($image);
|
|
209
|
+
$uploadedFileNames[] = $image->getImage();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if ($uploadedFileNames === []) {
|
|
213
|
+
throw new BadRequestHttpException('Aucun fichier valide n’a été fourni.');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
$this->entityManager->flush();
|
|
217
|
+
|
|
218
|
+
return $uploadedFileNames;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
public function deleteBlogPostImage(string $filename): void
|
|
222
|
+
{
|
|
223
|
+
$cleanName = basename($filename);
|
|
224
|
+
|
|
225
|
+
$imageEntity = $this->entityManager
|
|
226
|
+
->getRepository(BlogPostImages::class)
|
|
227
|
+
->findOneBy(['image' => $cleanName]);
|
|
228
|
+
|
|
229
|
+
if (!$imageEntity) {
|
|
230
|
+
throw new NotFoundHttpException("L'image demandée n'existe pas.");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
$fileDir = str_replace('\\', '/', $this->params->get('blog_post_images'));
|
|
234
|
+
$filePath = rtrim($fileDir, '/') . '/' . $cleanName;
|
|
235
|
+
|
|
236
|
+
if (is_file($filePath)) {
|
|
237
|
+
unlink($filePath);
|
|
238
|
+
}
|
|
95
239
|
|
|
96
|
-
|
|
97
|
-
$
|
|
98
|
-
$file->move($this->getParameter('kernel.project_dir').'/public/uploads/images', $newName);
|
|
99
|
-
$storedFiles[] = $newName;
|
|
240
|
+
$this->entityManager->remove($imageEntity);
|
|
241
|
+
$this->entityManager->flush();
|
|
100
242
|
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## **3. Delete endpoint**
|
|
249
|
+
|
|
250
|
+
```php
|
|
251
|
+
public function deleteImage(string $filename): JsonResponse
|
|
252
|
+
{
|
|
253
|
+
$this->uploaderService->deleteBlogPostImage($filename);
|
|
101
254
|
|
|
102
255
|
return new JsonResponse([
|
|
103
256
|
'success' => true,
|
|
104
|
-
'
|
|
105
|
-
'files' => $storedFiles,
|
|
106
|
-
'messages' => [],
|
|
107
|
-
],
|
|
108
|
-
'msg' => 'Upload successful'
|
|
257
|
+
'message' => "L'image a été supprimée avec succès.",
|
|
109
258
|
]);
|
|
110
259
|
}
|
|
111
260
|
```
|
|
112
261
|
|
|
113
|
-
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## **4. React Integration**
|
|
114
265
|
|
|
115
266
|
```ts
|
|
116
|
-
export const
|
|
117
|
-
|
|
267
|
+
export const blogPostImgUrl = `${apiUrl}/uploads/post`;
|
|
268
|
+
```
|
|
118
269
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
[]
|
|
126
|
-
);
|
|
270
|
+
```tsx
|
|
271
|
+
const handleDeleteImage = async (src: string) => {
|
|
272
|
+
const filename = src.split("/").pop();
|
|
273
|
+
if (filename)
|
|
274
|
+
await axios.delete(`${apiUrl}/post/delete/${filename}`);
|
|
275
|
+
};
|
|
127
276
|
```
|
|
128
277
|
|
|
129
|
-
|
|
278
|
+
---
|
|
130
279
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
280
|
+
# 📘 API Summary
|
|
281
|
+
|
|
282
|
+
| Feature | Supported |
|
|
283
|
+
|-------------------|-----------|
|
|
284
|
+
| Image upload | ✅ |
|
|
285
|
+
| Image delete sync | ✅ |
|
|
286
|
+
| TypeScript ready | ✅ |
|
|
287
|
+
| Config builder | ✅ |
|
|
134
288
|
|
|
135
289
|
---
|
|
136
290
|
|
|
137
|
-
|
|
291
|
+
# 📝 License
|
|
138
292
|
|
|
139
|
-
MIT —
|
|
293
|
+
MIT — free for commercial and private use.
|
package/dist/Editor.js
CHANGED
|
@@ -46,6 +46,31 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
|
46
46
|
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
47
47
|
}
|
|
48
48
|
};
|
|
49
|
+
var __read = (this && this.__read) || function (o, n) {
|
|
50
|
+
var m = typeof Symbol === "function" && o[Symbol.iterator];
|
|
51
|
+
if (!m) return o;
|
|
52
|
+
var i = m.call(o), r, ar = [], e;
|
|
53
|
+
try {
|
|
54
|
+
while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
|
|
55
|
+
}
|
|
56
|
+
catch (error) { e = { error: error }; }
|
|
57
|
+
finally {
|
|
58
|
+
try {
|
|
59
|
+
if (r && !r.done && (m = i["return"])) m.call(i);
|
|
60
|
+
}
|
|
61
|
+
finally { if (e) throw e.error; }
|
|
62
|
+
}
|
|
63
|
+
return ar;
|
|
64
|
+
};
|
|
65
|
+
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
|
|
66
|
+
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
|
|
67
|
+
if (ar || !(i in from)) {
|
|
68
|
+
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
|
|
69
|
+
ar[i] = from[i];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return to.concat(ar || Array.prototype.slice.call(from));
|
|
73
|
+
};
|
|
49
74
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
50
75
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
51
76
|
};
|
|
@@ -57,8 +82,147 @@ var ReactEditor = function (_a) {
|
|
|
57
82
|
var onChange = _a.onChange, onBlur = _a.onBlur, value = _a.value, config = _a.config;
|
|
58
83
|
return (react_1.default.createElement(jodit_react_1.default, { value: value, config: config, onBlur: onBlur, onChange: onChange }));
|
|
59
84
|
};
|
|
60
|
-
var
|
|
61
|
-
|
|
85
|
+
var isRecord = function (v) {
|
|
86
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
87
|
+
};
|
|
88
|
+
var toStringSafe = function (v) { return (typeof v === 'string' ? v : ''); };
|
|
89
|
+
var isAbsoluteUrl = function (url) {
|
|
90
|
+
return /^https?:\/\//i.test(url) || /^\/\//.test(url);
|
|
91
|
+
};
|
|
92
|
+
var normalizeJoinUrl = function (base, path) {
|
|
93
|
+
if (!base)
|
|
94
|
+
return path;
|
|
95
|
+
if (!path)
|
|
96
|
+
return base;
|
|
97
|
+
if (isAbsoluteUrl(path))
|
|
98
|
+
return path;
|
|
99
|
+
return "".concat(base.replace(/\/+$/, ''), "/").concat(path.replace(/^\/+/, ''));
|
|
100
|
+
};
|
|
101
|
+
var getExtensionLower = function (filenameOrUrl) {
|
|
102
|
+
var clean = filenameOrUrl.split('?')[0].split('#')[0];
|
|
103
|
+
var lastDot = clean.lastIndexOf('.');
|
|
104
|
+
if (lastDot === -1)
|
|
105
|
+
return '';
|
|
106
|
+
return clean.slice(lastDot + 1).toLowerCase();
|
|
107
|
+
};
|
|
108
|
+
var escapeHtml = function (s) {
|
|
109
|
+
return s
|
|
110
|
+
.replace(/&/g, '&')
|
|
111
|
+
.replace(/</g, '<')
|
|
112
|
+
.replace(/>/g, '>')
|
|
113
|
+
.replace(/"/g, '"')
|
|
114
|
+
.replace(/'/g, ''');
|
|
115
|
+
};
|
|
116
|
+
var DEFAULT_IMAGE_EXTENSIONS = [
|
|
117
|
+
'jpg',
|
|
118
|
+
'jpeg',
|
|
119
|
+
'png',
|
|
120
|
+
'gif',
|
|
121
|
+
'webp',
|
|
122
|
+
'avif',
|
|
123
|
+
'svg',
|
|
124
|
+
];
|
|
125
|
+
var DEFAULT_FILE_LINK_EXTENSIONS = [
|
|
126
|
+
'pdf',
|
|
127
|
+
'doc',
|
|
128
|
+
'docx',
|
|
129
|
+
'xls',
|
|
130
|
+
'xlsx',
|
|
131
|
+
'ppt',
|
|
132
|
+
'pptx',
|
|
133
|
+
'csv',
|
|
134
|
+
'txt',
|
|
135
|
+
'7z',
|
|
136
|
+
];
|
|
137
|
+
var asJoditLike = function (v) {
|
|
138
|
+
if (!isRecord(v))
|
|
139
|
+
return null;
|
|
140
|
+
return v;
|
|
141
|
+
};
|
|
142
|
+
var extractUploadedFiles = function (resp) {
|
|
143
|
+
if (!isRecord(resp))
|
|
144
|
+
return [];
|
|
145
|
+
var r = resp;
|
|
146
|
+
var data = isRecord(r.data) ? r.data : undefined;
|
|
147
|
+
var files1 = data === null || data === void 0 ? void 0 : data.files;
|
|
148
|
+
if (Array.isArray(files1))
|
|
149
|
+
return files1.map(toStringSafe).filter(Boolean);
|
|
150
|
+
if (typeof files1 === 'string')
|
|
151
|
+
return [files1];
|
|
152
|
+
var files2 = r.files;
|
|
153
|
+
if (Array.isArray(files2))
|
|
154
|
+
return files2.map(toStringSafe).filter(Boolean);
|
|
155
|
+
if (typeof files2 === 'string')
|
|
156
|
+
return [files2];
|
|
157
|
+
if (isRecord(r.data) && isRecord(r.data.data)) {
|
|
158
|
+
var d2 = r.data.data;
|
|
159
|
+
var f = d2.files;
|
|
160
|
+
if (Array.isArray(f))
|
|
161
|
+
return f.map(toStringSafe).filter(Boolean);
|
|
162
|
+
if (typeof f === 'string')
|
|
163
|
+
return [f];
|
|
164
|
+
}
|
|
165
|
+
return [];
|
|
166
|
+
};
|
|
167
|
+
var extractMessages = function (resp) {
|
|
168
|
+
if (!isRecord(resp))
|
|
169
|
+
return [];
|
|
170
|
+
var r = resp;
|
|
171
|
+
var data = isRecord(r.data) ? r.data : undefined;
|
|
172
|
+
var messages = data === null || data === void 0 ? void 0 : data.messages;
|
|
173
|
+
if (Array.isArray(messages))
|
|
174
|
+
return messages.map(toStringSafe).filter(Boolean);
|
|
175
|
+
if (typeof messages === 'string')
|
|
176
|
+
return [messages];
|
|
177
|
+
var msg1 = data === null || data === void 0 ? void 0 : data.msg;
|
|
178
|
+
if (typeof msg1 === 'string' && msg1.trim())
|
|
179
|
+
return [msg1];
|
|
180
|
+
var msg2 = r.msg;
|
|
181
|
+
if (typeof msg2 === 'string' && msg2.trim())
|
|
182
|
+
return [msg2];
|
|
183
|
+
return [];
|
|
184
|
+
};
|
|
185
|
+
var isSuccessResponse = function (resp) {
|
|
186
|
+
if (!isRecord(resp))
|
|
187
|
+
return false;
|
|
188
|
+
var r = resp;
|
|
189
|
+
if (r.success === true)
|
|
190
|
+
return true;
|
|
191
|
+
if (isRecord(r.data) && r.data.success === true)
|
|
192
|
+
return true;
|
|
193
|
+
if (isRecord(r.data) && r.data.error === 0)
|
|
194
|
+
return true;
|
|
195
|
+
if (r.error === 0)
|
|
196
|
+
return true;
|
|
197
|
+
var files = extractUploadedFiles(resp);
|
|
198
|
+
return files.length > 0;
|
|
199
|
+
};
|
|
200
|
+
var extractAssetsFromHtml = function (html, fileLinkExtensions) {
|
|
201
|
+
if (typeof document === 'undefined') {
|
|
202
|
+
return { images: new Set(), files: new Set() };
|
|
203
|
+
}
|
|
204
|
+
var container = document.createElement('div');
|
|
205
|
+
container.innerHTML = html || '';
|
|
206
|
+
var images = new Set();
|
|
207
|
+
var files = new Set();
|
|
208
|
+
Array.from(container.getElementsByTagName('img')).forEach(function (img) {
|
|
209
|
+
var src = img.getAttribute('src') || '';
|
|
210
|
+
if (src)
|
|
211
|
+
images.add(src);
|
|
212
|
+
});
|
|
213
|
+
Array.from(container.getElementsByTagName('a')).forEach(function (a) {
|
|
214
|
+
var href = a.getAttribute('href') || '';
|
|
215
|
+
if (!href)
|
|
216
|
+
return;
|
|
217
|
+
var ext = getExtensionLower(href);
|
|
218
|
+
if (ext && fileLinkExtensions.includes(ext))
|
|
219
|
+
files.add(href);
|
|
220
|
+
});
|
|
221
|
+
return { images: images, files: files };
|
|
222
|
+
};
|
|
223
|
+
var uploaderConfig = function (apiUrl, assetBaseUrl) { return ({
|
|
224
|
+
insertImageAsBase64URI: false,
|
|
225
|
+
imagesExtensions: __spreadArray([], __read(DEFAULT_IMAGE_EXTENSIONS), false),
|
|
62
226
|
filesVariableName: function (t) {
|
|
63
227
|
return 'files[' + t + ']';
|
|
64
228
|
},
|
|
@@ -69,45 +233,69 @@ var uploaderConfig = function (apiUrl, imageUrl) { return ({
|
|
|
69
233
|
prepareData: function (formdata) {
|
|
70
234
|
return formdata;
|
|
71
235
|
},
|
|
72
|
-
isSuccess: function (
|
|
73
|
-
|
|
74
|
-
var fn = this.jodit;
|
|
75
|
-
if (((_a = e === null || e === void 0 ? void 0 : e.data) === null || _a === void 0 ? void 0 : _a.files) && e.data.files.length) {
|
|
76
|
-
var tagName_1 = 'img';
|
|
77
|
-
e.data.files.forEach(function (filename) {
|
|
78
|
-
var elm = fn.createInside.element(tagName_1);
|
|
79
|
-
var src = imageUrl ? "".concat(imageUrl, "/").concat(filename) : filename;
|
|
80
|
-
elm.setAttribute('src', src);
|
|
81
|
-
fn.s.insertImage(elm, null, fn.o.imageDefaultWidth);
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
return !!(e === null || e === void 0 ? void 0 : e.success);
|
|
236
|
+
isSuccess: function (resp) {
|
|
237
|
+
return isSuccessResponse(resp);
|
|
85
238
|
},
|
|
86
|
-
getMessage: function (
|
|
87
|
-
var
|
|
88
|
-
return
|
|
89
|
-
? e.data.messages.join('')
|
|
90
|
-
: '';
|
|
239
|
+
getMessage: function (resp) {
|
|
240
|
+
var msgs = extractMessages(resp);
|
|
241
|
+
return msgs.length ? msgs.join(' ') : '';
|
|
91
242
|
},
|
|
92
243
|
process: function (resp) {
|
|
93
|
-
var
|
|
94
|
-
files.
|
|
244
|
+
var rawFiles = extractUploadedFiles(resp);
|
|
245
|
+
var files = rawFiles.map(function (filenameOrUrl) {
|
|
246
|
+
if (!assetBaseUrl)
|
|
247
|
+
return filenameOrUrl;
|
|
248
|
+
return normalizeJoinUrl(assetBaseUrl, filenameOrUrl);
|
|
249
|
+
});
|
|
250
|
+
var messages = extractMessages(resp);
|
|
95
251
|
return {
|
|
96
|
-
files:
|
|
97
|
-
|
|
98
|
-
|
|
252
|
+
files: files,
|
|
253
|
+
path: '',
|
|
254
|
+
baseurl: '',
|
|
255
|
+
messages: messages,
|
|
256
|
+
msg: messages.join(' '),
|
|
99
257
|
};
|
|
100
258
|
},
|
|
259
|
+
defaultHandlerSuccess: function (data) {
|
|
260
|
+
var jodit = asJoditLike(this.jodit);
|
|
261
|
+
if (!jodit)
|
|
262
|
+
return;
|
|
263
|
+
var selection = jodit.s;
|
|
264
|
+
var creator = jodit.createInside;
|
|
265
|
+
var files = isRecord(data) && Array.isArray(data.files)
|
|
266
|
+
? data.files.map(toStringSafe).filter(Boolean)
|
|
267
|
+
: [];
|
|
268
|
+
files.forEach(function (url) {
|
|
269
|
+
var _a, _b, _c, _d;
|
|
270
|
+
var ext = getExtensionLower(url);
|
|
271
|
+
var isImage = DEFAULT_IMAGE_EXTENSIONS.includes(ext);
|
|
272
|
+
if (isImage) {
|
|
273
|
+
if ((creator === null || creator === void 0 ? void 0 : creator.element) && (selection === null || selection === void 0 ? void 0 : selection.insertImage)) {
|
|
274
|
+
var img = creator.element('img');
|
|
275
|
+
img.setAttribute('src', url);
|
|
276
|
+
selection.insertImage(img, null, (_a = jodit.o) === null || _a === void 0 ? void 0 : _a.imageDefaultWidth);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
(_b = selection === null || selection === void 0 ? void 0 : selection.insertHTML) === null || _b === void 0 ? void 0 : _b.call(selection, "<img src=\"".concat(escapeHtml(url), "\" alt=\"\" />"));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
var label = escapeHtml((_c = url.split('/').pop()) !== null && _c !== void 0 ? _c : url);
|
|
283
|
+
var linkHtml = "<a href=\"".concat(escapeHtml(url), "\" target=\"_blank\" rel=\"noopener noreferrer\">").concat(label, "</a>");
|
|
284
|
+
(_d = selection === null || selection === void 0 ? void 0 : selection.insertHTML) === null || _d === void 0 ? void 0 : _d.call(selection, linkHtml);
|
|
285
|
+
});
|
|
286
|
+
},
|
|
101
287
|
error: function (e) {
|
|
102
|
-
|
|
288
|
+
var _a, _b, _c;
|
|
289
|
+
(_c = (_b = (_a = this.j) === null || _a === void 0 ? void 0 : _a.e) === null || _b === void 0 ? void 0 : _b.fire) === null || _c === void 0 ? void 0 : _c.call(_b, 'errorMessage', e.message, 'error', 4000);
|
|
103
290
|
},
|
|
104
291
|
defaultHandlerError: function (e) {
|
|
105
|
-
|
|
292
|
+
var _a, _b, _c;
|
|
293
|
+
(_c = (_b = (_a = this.j) === null || _a === void 0 ? void 0 : _a.e) === null || _b === void 0 ? void 0 : _b.fire) === null || _c === void 0 ? void 0 : _c.call(_b, 'errorMessage', (e === null || e === void 0 ? void 0 : e.message) || 'Upload error');
|
|
106
294
|
},
|
|
107
295
|
}); };
|
|
108
296
|
exports.uploaderConfig = uploaderConfig;
|
|
109
297
|
var config = function (_a) {
|
|
110
|
-
var _b = _a === void 0 ? {} : _a, includeUploader = _b.includeUploader, apiUrl = _b.apiUrl, imageUrl = _b.imageUrl, onDeleteImage = _b.onDeleteImage;
|
|
298
|
+
var _b = _a === void 0 ? {} : _a, includeUploader = _b.includeUploader, apiUrl = _b.apiUrl, imageUrl = _b.imageUrl, onDeleteImage = _b.onDeleteImage, onDeleteFile = _b.onDeleteFile, _c = _b.fileLinkExtensions, fileLinkExtensions = _c === void 0 ? DEFAULT_FILE_LINK_EXTENSIONS : _c;
|
|
111
299
|
var base = {
|
|
112
300
|
readonly: false,
|
|
113
301
|
placeholder: 'Start typing...',
|
|
@@ -132,6 +320,7 @@ var config = function (_a) {
|
|
|
132
320
|
'ol',
|
|
133
321
|
'|',
|
|
134
322
|
'image',
|
|
323
|
+
'file',
|
|
135
324
|
'|',
|
|
136
325
|
'video',
|
|
137
326
|
'|',
|
|
@@ -156,33 +345,33 @@ var config = function (_a) {
|
|
|
156
345
|
if (includeUploader) {
|
|
157
346
|
base.uploader = (0, exports.uploaderConfig)(apiUrl, imageUrl);
|
|
158
347
|
}
|
|
159
|
-
|
|
348
|
+
var hasDelete = Boolean(onDeleteImage || onDeleteFile);
|
|
349
|
+
if (hasDelete) {
|
|
160
350
|
base.events = __assign(__assign({}, (base.events || {})), { afterInit: function (editor) {
|
|
161
351
|
var _this = this;
|
|
162
|
-
var extractImageSrcs = function (html) {
|
|
163
|
-
var container = document.createElement('div');
|
|
164
|
-
container.innerHTML = html || '';
|
|
165
|
-
var imgs = Array.from(container.getElementsByTagName('img'));
|
|
166
|
-
return new Set(imgs
|
|
167
|
-
.map(function (img) { return img.getAttribute('src') || ''; })
|
|
168
|
-
.filter(function (src) { return !!src; }));
|
|
169
|
-
};
|
|
170
352
|
var prevValue = editor.value || '';
|
|
171
|
-
var
|
|
353
|
+
var prevAssets = extractAssetsFromHtml(prevValue, fileLinkExtensions);
|
|
172
354
|
editor.events.on('change', function () { return __awaiter(_this, void 0, void 0, function () {
|
|
173
|
-
var currentValue,
|
|
355
|
+
var currentValue, currentAssets;
|
|
174
356
|
return __generator(this, function (_a) {
|
|
175
357
|
currentValue = editor.value || '';
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (!
|
|
358
|
+
currentAssets = extractAssetsFromHtml(currentValue, fileLinkExtensions);
|
|
359
|
+
prevAssets.images.forEach(function (src) {
|
|
360
|
+
if (!currentAssets.images.has(src)) {
|
|
179
361
|
if (!imageUrl || src.startsWith(imageUrl)) {
|
|
180
|
-
void onDeleteImage(src);
|
|
362
|
+
onDeleteImage === null || onDeleteImage === void 0 ? void 0 : onDeleteImage(src);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
prevAssets.files.forEach(function (href) {
|
|
367
|
+
if (!currentAssets.files.has(href)) {
|
|
368
|
+
if (!imageUrl || href.startsWith(imageUrl)) {
|
|
369
|
+
onDeleteFile === null || onDeleteFile === void 0 ? void 0 : onDeleteFile(href);
|
|
181
370
|
}
|
|
182
371
|
}
|
|
183
372
|
});
|
|
184
373
|
prevValue = currentValue;
|
|
185
|
-
|
|
374
|
+
prevAssets = currentAssets;
|
|
186
375
|
return [2];
|
|
187
376
|
});
|
|
188
377
|
}); });
|
package/package.json
CHANGED
package/src/Editor.tsx
CHANGED
|
@@ -2,6 +2,9 @@ import React from 'react';
|
|
|
2
2
|
import JoditEditor from 'jodit-react';
|
|
3
3
|
import { EditorProps } from './Editor.types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Keep the component unchanged
|
|
7
|
+
*/
|
|
5
8
|
const ReactEditor: React.FC<EditorProps> = ({ onChange, onBlur, value, config }) => {
|
|
6
9
|
return (
|
|
7
10
|
<JoditEditor
|
|
@@ -13,61 +16,303 @@ const ReactEditor: React.FC<EditorProps> = ({ onChange, onBlur, value, config })
|
|
|
13
16
|
);
|
|
14
17
|
};
|
|
15
18
|
|
|
19
|
+
type UnknownRecord = Record<string, unknown>;
|
|
20
|
+
const isRecord = (v: unknown): v is UnknownRecord =>
|
|
21
|
+
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
22
|
+
|
|
23
|
+
const toStringSafe = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
24
|
+
|
|
25
|
+
const isAbsoluteUrl = (url: string): boolean =>
|
|
26
|
+
/^https?:\/\//i.test(url) || /^\/\//.test(url);
|
|
27
|
+
|
|
28
|
+
const normalizeJoinUrl = (base: string, path: string): string => {
|
|
29
|
+
if (!base) return path;
|
|
30
|
+
if (!path) return base;
|
|
31
|
+
if (isAbsoluteUrl(path)) return path;
|
|
32
|
+
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const getExtensionLower = (filenameOrUrl: string): string => {
|
|
36
|
+
const clean = filenameOrUrl.split('?')[0].split('#')[0];
|
|
37
|
+
const lastDot = clean.lastIndexOf('.');
|
|
38
|
+
if (lastDot === -1) return '';
|
|
39
|
+
return clean.slice(lastDot + 1).toLowerCase();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const escapeHtml = (s: string): string =>
|
|
43
|
+
s
|
|
44
|
+
.replace(/&/g, '&')
|
|
45
|
+
.replace(/</g, '<')
|
|
46
|
+
.replace(/>/g, '>')
|
|
47
|
+
.replace(/"/g, '"')
|
|
48
|
+
.replace(/'/g, ''');
|
|
49
|
+
|
|
50
|
+
const DEFAULT_IMAGE_EXTENSIONS: readonly string[] = [
|
|
51
|
+
'jpg',
|
|
52
|
+
'jpeg',
|
|
53
|
+
'png',
|
|
54
|
+
'gif',
|
|
55
|
+
'webp',
|
|
56
|
+
'avif',
|
|
57
|
+
'svg',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const DEFAULT_FILE_LINK_EXTENSIONS: readonly string[] = [
|
|
61
|
+
'pdf',
|
|
62
|
+
'doc',
|
|
63
|
+
'docx',
|
|
64
|
+
'xls',
|
|
65
|
+
'xlsx',
|
|
66
|
+
'ppt',
|
|
67
|
+
'pptx',
|
|
68
|
+
'csv',
|
|
69
|
+
'txt',
|
|
70
|
+
'7z',
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
type JoditSelectionLike = {
|
|
74
|
+
insertImage?: (img: HTMLImageElement, arg2?: unknown, width?: unknown) => void;
|
|
75
|
+
insertHTML?: (html: string) => void;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type JoditCreateInsideLike = {
|
|
79
|
+
element: (tag: string) => HTMLElement;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type JoditLike = {
|
|
83
|
+
createInside?: JoditCreateInsideLike;
|
|
84
|
+
s?: JoditSelectionLike;
|
|
85
|
+
o?: {
|
|
86
|
+
imageDefaultWidth?: unknown;
|
|
87
|
+
};
|
|
88
|
+
events?: {
|
|
89
|
+
on?: (eventName: string, cb: () => void) => void;
|
|
90
|
+
};
|
|
91
|
+
value?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type UploaderThis = {
|
|
95
|
+
jodit?: unknown;
|
|
96
|
+
j?: {
|
|
97
|
+
e?: {
|
|
98
|
+
fire?: (...args: unknown[]) => void;
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const asJoditLike = (v: unknown): JoditLike | null => {
|
|
104
|
+
if (!isRecord(v)) return null;
|
|
105
|
+
return v as unknown as JoditLike;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type UploadResponseShape = {
|
|
109
|
+
success?: unknown;
|
|
110
|
+
msg?: unknown;
|
|
111
|
+
data?: {
|
|
112
|
+
files?: unknown;
|
|
113
|
+
messages?: unknown;
|
|
114
|
+
error?: unknown;
|
|
115
|
+
msg?: unknown;
|
|
116
|
+
};
|
|
117
|
+
error?: unknown;
|
|
118
|
+
files?: unknown;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const extractUploadedFiles = (resp: unknown): string[] => {
|
|
122
|
+
if (!isRecord(resp)) return [];
|
|
123
|
+
const r = resp as UploadResponseShape;
|
|
124
|
+
|
|
125
|
+
// Common: axios => { data: <json> }, but here we already receive <json>
|
|
126
|
+
const data = isRecord(r.data) ? r.data : undefined;
|
|
127
|
+
|
|
128
|
+
const files1 = data?.files;
|
|
129
|
+
if (Array.isArray(files1)) return files1.map(toStringSafe).filter(Boolean);
|
|
130
|
+
if (typeof files1 === 'string') return [files1];
|
|
131
|
+
|
|
132
|
+
const files2 = r.files;
|
|
133
|
+
if (Array.isArray(files2)) return files2.map(toStringSafe).filter(Boolean);
|
|
134
|
+
if (typeof files2 === 'string') return [files2];
|
|
135
|
+
|
|
136
|
+
// Sometimes: resp = { data: { data: { files: [...] } } }
|
|
137
|
+
if (isRecord(r.data) && isRecord((r.data as UnknownRecord).data)) {
|
|
138
|
+
const d2 = (r.data as UnknownRecord).data as UnknownRecord;
|
|
139
|
+
const f = d2.files;
|
|
140
|
+
if (Array.isArray(f)) return f.map(toStringSafe).filter(Boolean);
|
|
141
|
+
if (typeof f === 'string') return [f];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return [];
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const extractMessages = (resp: unknown): string[] => {
|
|
148
|
+
if (!isRecord(resp)) return [];
|
|
149
|
+
const r = resp as UploadResponseShape;
|
|
150
|
+
|
|
151
|
+
const data = isRecord(r.data) ? r.data : undefined;
|
|
152
|
+
|
|
153
|
+
const messages = data?.messages;
|
|
154
|
+
if (Array.isArray(messages)) return messages.map(toStringSafe).filter(Boolean);
|
|
155
|
+
if (typeof messages === 'string') return [messages];
|
|
156
|
+
|
|
157
|
+
const msg1 = data?.msg;
|
|
158
|
+
if (typeof msg1 === 'string' && msg1.trim()) return [msg1];
|
|
159
|
+
|
|
160
|
+
const msg2 = r.msg;
|
|
161
|
+
if (typeof msg2 === 'string' && msg2.trim()) return [msg2];
|
|
162
|
+
|
|
163
|
+
return [];
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const isSuccessResponse = (resp: unknown): boolean => {
|
|
167
|
+
if (!isRecord(resp)) return false;
|
|
168
|
+
const r = resp as UploadResponseShape;
|
|
169
|
+
|
|
170
|
+
// success flag (if present)
|
|
171
|
+
if (r.success === true) return true;
|
|
172
|
+
if (isRecord(r.data) && (r.data as UnknownRecord).success === true) return true;
|
|
173
|
+
|
|
174
|
+
// Symfony-style: data.error === 0
|
|
175
|
+
if (isRecord(r.data) && (r.data as UnknownRecord).error === 0) return true;
|
|
176
|
+
if (r.error === 0) return true;
|
|
177
|
+
|
|
178
|
+
// fallback: if files exist, treat as success
|
|
179
|
+
const files = extractUploadedFiles(resp);
|
|
180
|
+
return files.length > 0;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const extractAssetsFromHtml = (
|
|
184
|
+
html: string,
|
|
185
|
+
fileLinkExtensions: readonly string[]
|
|
186
|
+
): { images: Set<string>; files: Set<string> } => {
|
|
187
|
+
if (typeof document === 'undefined') {
|
|
188
|
+
return { images: new Set<string>(), files: new Set<string>() };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const container = document.createElement('div');
|
|
192
|
+
container.innerHTML = html || '';
|
|
193
|
+
|
|
194
|
+
const images = new Set<string>();
|
|
195
|
+
const files = new Set<string>();
|
|
196
|
+
|
|
197
|
+
Array.from(container.getElementsByTagName('img')).forEach((img) => {
|
|
198
|
+
const src = img.getAttribute('src') || '';
|
|
199
|
+
if (src) images.add(src);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
Array.from(container.getElementsByTagName('a')).forEach((a) => {
|
|
203
|
+
const href = a.getAttribute('href') || '';
|
|
204
|
+
if (!href) return;
|
|
205
|
+
|
|
206
|
+
const ext = getExtensionLower(href);
|
|
207
|
+
if (ext && fileLinkExtensions.includes(ext)) files.add(href);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return { images, files };
|
|
211
|
+
};
|
|
212
|
+
|
|
16
213
|
/**
|
|
17
214
|
* Uploader configuration for Jodit
|
|
18
|
-
* Handles image upload + insertion in the editor
|
|
215
|
+
* Handles image + file upload + insertion in the editor
|
|
19
216
|
*/
|
|
20
|
-
export const uploaderConfig = (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
217
|
+
export const uploaderConfig = (apiUrl?: string, assetBaseUrl?: string) => ({
|
|
218
|
+
insertImageAsBase64URI: false,
|
|
219
|
+
|
|
220
|
+
imagesExtensions: [...DEFAULT_IMAGE_EXTENSIONS],
|
|
221
|
+
|
|
25
222
|
filesVariableName(t: number): string {
|
|
26
223
|
return 'files[' + t + ']';
|
|
27
224
|
},
|
|
225
|
+
|
|
28
226
|
url: apiUrl,
|
|
29
227
|
withCredentials: false,
|
|
30
228
|
format: 'json',
|
|
31
229
|
method: 'POST',
|
|
230
|
+
|
|
32
231
|
prepareData(formdata: FormData): FormData {
|
|
33
232
|
return formdata;
|
|
34
233
|
},
|
|
35
|
-
isSuccess(this: any, e: any): boolean {
|
|
36
|
-
const fn = this.jodit;
|
|
37
|
-
|
|
38
|
-
if (e?.data?.files && e.data.files.length) {
|
|
39
|
-
const tagName = 'img';
|
|
40
|
-
|
|
41
|
-
e.data.files.forEach((filename: string) => {
|
|
42
|
-
const elm = fn.createInside.element(tagName);
|
|
43
|
-
const src = imageUrl ? `${imageUrl}/${filename}` : filename;
|
|
44
|
-
elm.setAttribute('src', src);
|
|
45
|
-
fn.s.insertImage(elm as HTMLImageElement, null, fn.o.imageDefaultWidth);
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
234
|
|
|
49
|
-
|
|
235
|
+
/**
|
|
236
|
+
* ✅ Robust success detection (supports: success=true OR data.error=0 OR files exist)
|
|
237
|
+
*/
|
|
238
|
+
isSuccess(this: unknown, resp: unknown): boolean {
|
|
239
|
+
return isSuccessResponse(resp);
|
|
50
240
|
},
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
241
|
+
|
|
242
|
+
getMessage(resp: unknown): string {
|
|
243
|
+
const msgs = extractMessages(resp);
|
|
244
|
+
return msgs.length ? msgs.join(' ') : '';
|
|
55
245
|
},
|
|
56
|
-
process(resp: any): { files: any[]; error: string; msg: string } {
|
|
57
|
-
const files: any[] = [];
|
|
58
|
-
files.unshift(resp?.data);
|
|
59
246
|
|
|
247
|
+
/**
|
|
248
|
+
* ✅ Normalize backend response to the shape Jodit expects.
|
|
249
|
+
* Convert to absolute URLs (so editor can display immediately).
|
|
250
|
+
*/
|
|
251
|
+
process(resp: unknown): { files: string[]; path: string; baseurl: string; messages?: string[]; msg?: string } {
|
|
252
|
+
const rawFiles = extractUploadedFiles(resp);
|
|
253
|
+
|
|
254
|
+
const files = rawFiles.map((filenameOrUrl) => {
|
|
255
|
+
if (!assetBaseUrl) return filenameOrUrl;
|
|
256
|
+
return normalizeJoinUrl(assetBaseUrl, filenameOrUrl);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const messages = extractMessages(resp);
|
|
60
260
|
return {
|
|
61
|
-
files
|
|
62
|
-
|
|
63
|
-
|
|
261
|
+
files,
|
|
262
|
+
path: '',
|
|
263
|
+
baseurl: '',
|
|
264
|
+
messages,
|
|
265
|
+
msg: messages.join(' '),
|
|
64
266
|
};
|
|
65
267
|
},
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* ✅ Insert uploaded assets:
|
|
271
|
+
* - images => <img>
|
|
272
|
+
* - other files => <a href="...">filename</a>
|
|
273
|
+
*/
|
|
274
|
+
defaultHandlerSuccess(this: UploaderThis, data: unknown): void {
|
|
275
|
+
const jodit = asJoditLike(this.jodit);
|
|
276
|
+
if (!jodit) return;
|
|
277
|
+
|
|
278
|
+
const selection = jodit.s;
|
|
279
|
+
const creator = jodit.createInside;
|
|
280
|
+
|
|
281
|
+
const files = isRecord(data) && Array.isArray((data as UnknownRecord).files)
|
|
282
|
+
? ((data as UnknownRecord).files as unknown[]).map(toStringSafe).filter(Boolean)
|
|
283
|
+
: [];
|
|
284
|
+
|
|
285
|
+
files.forEach((url) => {
|
|
286
|
+
const ext = getExtensionLower(url);
|
|
287
|
+
const isImage = DEFAULT_IMAGE_EXTENSIONS.includes(ext);
|
|
288
|
+
|
|
289
|
+
if (isImage) {
|
|
290
|
+
// Prefer insertImage if available
|
|
291
|
+
if (creator?.element && selection?.insertImage) {
|
|
292
|
+
const img = creator.element('img') as HTMLImageElement;
|
|
293
|
+
img.setAttribute('src', url);
|
|
294
|
+
selection.insertImage(img, null, jodit.o?.imageDefaultWidth);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Fallback
|
|
299
|
+
selection?.insertHTML?.(`<img src="${escapeHtml(url)}" alt="" />`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// File link insertion
|
|
304
|
+
const label = escapeHtml(url.split('/').pop() ?? url);
|
|
305
|
+
const linkHtml = `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
306
|
+
selection?.insertHTML?.(linkHtml);
|
|
307
|
+
});
|
|
308
|
+
},
|
|
309
|
+
|
|
66
310
|
error(this: any, e: Error): void {
|
|
67
|
-
this.j
|
|
311
|
+
this.j?.e?.fire?.('errorMessage', e.message, 'error', 4000);
|
|
68
312
|
},
|
|
313
|
+
|
|
69
314
|
defaultHandlerError(this: any, e: any): void {
|
|
70
|
-
this.j
|
|
315
|
+
this.j?.e?.fire?.('errorMessage', e?.message || 'Upload error');
|
|
71
316
|
},
|
|
72
317
|
});
|
|
73
318
|
|
|
@@ -75,11 +320,22 @@ type ConfigParams = {
|
|
|
75
320
|
includeUploader?: boolean;
|
|
76
321
|
apiUrl?: string;
|
|
77
322
|
imageUrl?: string;
|
|
323
|
+
|
|
78
324
|
/**
|
|
79
325
|
* Called when an image is really removed from the editor content.
|
|
80
326
|
* You receive the image src URL and can call your API to delete it on server.
|
|
81
327
|
*/
|
|
82
328
|
onDeleteImage?: (imageUrl: string) => void | Promise<void>;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* ✅ Called when a tracked file link (<a href="...">) is removed.
|
|
332
|
+
*/
|
|
333
|
+
onDeleteFile?: (fileHref: string) => void | Promise<void>;
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* ✅ Which file extensions to track for delete-sync when removed from content.
|
|
337
|
+
*/
|
|
338
|
+
fileLinkExtensions?: readonly string[];
|
|
83
339
|
};
|
|
84
340
|
|
|
85
341
|
/**
|
|
@@ -90,6 +346,8 @@ export const config = ({
|
|
|
90
346
|
apiUrl,
|
|
91
347
|
imageUrl,
|
|
92
348
|
onDeleteImage,
|
|
349
|
+
onDeleteFile,
|
|
350
|
+
fileLinkExtensions = DEFAULT_FILE_LINK_EXTENSIONS,
|
|
93
351
|
}: ConfigParams = {}) => {
|
|
94
352
|
const base: any = {
|
|
95
353
|
readonly: false,
|
|
@@ -116,6 +374,7 @@ export const config = ({
|
|
|
116
374
|
'ol',
|
|
117
375
|
'|',
|
|
118
376
|
'image',
|
|
377
|
+
'file', // ✅ added (file uploads)
|
|
119
378
|
'|',
|
|
120
379
|
'video',
|
|
121
380
|
'|',
|
|
@@ -139,48 +398,49 @@ export const config = ({
|
|
|
139
398
|
};
|
|
140
399
|
|
|
141
400
|
if (includeUploader) {
|
|
401
|
+
// ✅ Use imageUrl as base for BOTH images + files (no new required props)
|
|
142
402
|
base.uploader = uploaderConfig(apiUrl, imageUrl);
|
|
143
403
|
}
|
|
144
404
|
|
|
145
|
-
|
|
405
|
+
const hasDelete = Boolean(onDeleteImage || onDeleteFile);
|
|
406
|
+
|
|
407
|
+
if (hasDelete) {
|
|
146
408
|
base.events = {
|
|
147
409
|
...(base.events || {}),
|
|
410
|
+
|
|
148
411
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
412
|
+
* Detect removed assets by diffing previous HTML vs current HTML.
|
|
413
|
+
* - removed <img src="..."> => onDeleteImage
|
|
414
|
+
* - removed <a href="..."> (matching fileLinkExtensions) => onDeleteFile
|
|
151
415
|
*/
|
|
152
416
|
afterInit(editor: any) {
|
|
153
|
-
const extractImageSrcs = (html: string): Set<string> => {
|
|
154
|
-
const container = document.createElement('div');
|
|
155
|
-
container.innerHTML = html || '';
|
|
156
|
-
const imgs = Array.from(container.getElementsByTagName('img')) as HTMLImageElement[];
|
|
157
|
-
|
|
158
|
-
return new Set(
|
|
159
|
-
imgs
|
|
160
|
-
.map((img) => img.getAttribute('src') || '')
|
|
161
|
-
.filter((src) => !!src)
|
|
162
|
-
);
|
|
163
|
-
};
|
|
164
|
-
|
|
165
417
|
let prevValue: string = editor.value || '';
|
|
166
|
-
let
|
|
418
|
+
let prevAssets = extractAssetsFromHtml(prevValue, fileLinkExtensions);
|
|
167
419
|
|
|
168
420
|
editor.events.on('change', async () => {
|
|
169
421
|
const currentValue: string = editor.value || '';
|
|
170
|
-
const
|
|
422
|
+
const currentAssets = extractAssetsFromHtml(currentValue, fileLinkExtensions);
|
|
171
423
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
if (!
|
|
175
|
-
// If imageUrl is defined, you can filter to only your own assets
|
|
424
|
+
// Deleted images
|
|
425
|
+
prevAssets.images.forEach((src) => {
|
|
426
|
+
if (!currentAssets.images.has(src)) {
|
|
176
427
|
if (!imageUrl || src.startsWith(imageUrl)) {
|
|
177
|
-
|
|
428
|
+
onDeleteImage?.(src);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Deleted files
|
|
434
|
+
prevAssets.files.forEach((href) => {
|
|
435
|
+
if (!currentAssets.files.has(href)) {
|
|
436
|
+
if (!imageUrl || href.startsWith(imageUrl)) {
|
|
437
|
+
onDeleteFile?.(href);
|
|
178
438
|
}
|
|
179
439
|
}
|
|
180
440
|
});
|
|
181
441
|
|
|
182
442
|
prevValue = currentValue;
|
|
183
|
-
|
|
443
|
+
prevAssets = currentAssets;
|
|
184
444
|
});
|
|
185
445
|
},
|
|
186
446
|
};
|
package/src/Editor.types.ts
CHANGED
|
@@ -5,8 +5,9 @@ export interface EditorProps {
|
|
|
5
5
|
useUploadImage?: boolean;
|
|
6
6
|
apiUrl?: string;
|
|
7
7
|
imageUrl?: string;
|
|
8
|
-
config?: any
|
|
8
|
+
config?: any;
|
|
9
9
|
// ref?: any;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export type OnDeleteImage = (src: string) => void | Promise<void>;
|
|
12
|
+
export type OnDeleteImage = (src: string) => void | Promise<void>;
|
|
13
|
+
export type OnDeleteFile = (href: string) => void | Promise<void>;
|
package/types/Editor.d.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { EditorProps } from './Editor.types';
|
|
3
3
|
declare const ReactEditor: React.FC<EditorProps>;
|
|
4
|
-
|
|
4
|
+
type UploaderThis = {
|
|
5
|
+
jodit?: unknown;
|
|
6
|
+
j?: {
|
|
7
|
+
e?: {
|
|
8
|
+
fire?: (...args: unknown[]) => void;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export declare const uploaderConfig: (apiUrl?: string, assetBaseUrl?: string) => {
|
|
13
|
+
insertImageAsBase64URI: boolean;
|
|
5
14
|
imagesExtensions: string[];
|
|
6
15
|
filesVariableName(t: number): string;
|
|
7
16
|
url: string | undefined;
|
|
@@ -9,13 +18,16 @@ export declare const uploaderConfig: (apiUrl?: string, imageUrl?: string) => {
|
|
|
9
18
|
format: string;
|
|
10
19
|
method: string;
|
|
11
20
|
prepareData(formdata: FormData): FormData;
|
|
12
|
-
isSuccess(this:
|
|
13
|
-
getMessage(
|
|
14
|
-
process(resp:
|
|
15
|
-
files:
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
isSuccess(this: unknown, resp: unknown): boolean;
|
|
22
|
+
getMessage(resp: unknown): string;
|
|
23
|
+
process(resp: unknown): {
|
|
24
|
+
files: string[];
|
|
25
|
+
path: string;
|
|
26
|
+
baseurl: string;
|
|
27
|
+
messages?: string[] | undefined;
|
|
28
|
+
msg?: string | undefined;
|
|
18
29
|
};
|
|
30
|
+
defaultHandlerSuccess(this: UploaderThis, data: unknown): void;
|
|
19
31
|
error(this: any, e: Error): void;
|
|
20
32
|
defaultHandlerError(this: any, e: any): void;
|
|
21
33
|
};
|
|
@@ -24,6 +36,8 @@ type ConfigParams = {
|
|
|
24
36
|
apiUrl?: string;
|
|
25
37
|
imageUrl?: string;
|
|
26
38
|
onDeleteImage?: (imageUrl: string) => void | Promise<void>;
|
|
39
|
+
onDeleteFile?: (fileHref: string) => void | Promise<void>;
|
|
40
|
+
fileLinkExtensions?: readonly string[];
|
|
27
41
|
};
|
|
28
|
-
export declare const config: ({ includeUploader, apiUrl, imageUrl, onDeleteImage, }?: ConfigParams) => any;
|
|
42
|
+
export declare const config: ({ includeUploader, apiUrl, imageUrl, onDeleteImage, onDeleteFile, fileLinkExtensions, }?: ConfigParams) => any;
|
|
29
43
|
export default ReactEditor;
|
package/types/Editor.types.d.ts
CHANGED