@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 CHANGED
@@ -1,13 +1,13 @@
1
-
2
1
  # @mbs-dev/react-editor
3
2
 
4
- A lightweight, typed React wrapper around **Jodit** rich-text editor, designed for real-world CRUD forms (blogs, products, CMS pages, etc.).
3
+ A lightweight, typed React wrapper around **Jodit** richtext editor, designed for realworld 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
- - An optional **`uploaderConfig()` helper** to handle image uploads and automatic insertion into the editor
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
- - 🧩 **Simple React API**: controlled `value` + `onChange`
23
- - ⚙️ **Helper config**: `config()` to quickly build configuration
24
- - 🖼️ **Image upload integration**: optional `uploaderConfig()` for file uploading + auto-insert `<img />`
25
- - 🧪 **TypeScript-ready**: ships with types & TSX-friendly usage
26
- - 🧱 **Library-friendly**: minimal surface area, built to be reused across apps
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 'react';
42
- import ReactEditor, { config } from '@mbs-dev/react-editor';
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(false), []);
48
+ const editorConfig = useMemo(() => config({}), []);
48
49
 
49
50
  return (
50
51
  <ReactEditor
51
52
  config={editorConfig}
52
53
  value={content}
53
- onChange={(value) => setContent(value)}
54
+ onChange={setContent}
54
55
  />
55
56
  );
56
57
  };
@@ -58,82 +59,235 @@ const MyPage = () => {
58
59
 
59
60
  ---
60
61
 
61
- ## 🔧 Configuration Helpers
62
+ # 🔧 Configuration (Full API)
62
63
 
63
- ### `config(includeUploader?: boolean, apiUrl?: string, imageUrl?: string)`
64
+ ## `config(params: ConfigParams)`
64
65
 
65
- ### `uploaderConfig(apiUrl?: string, imageUrl?: string)`
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
- Both helpers simplify setting up configuration and upload support.
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
- ## 🧩 Symfony API Platform Example (Recommended Integration)
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
- If your backend is **Symfony + API Platform**, here is the **most common real‑world setup**:
115
+ # 🗑️ NEW Image Delete (Server Sync)
74
116
 
75
- ### **📌 1. Expose your upload endpoint**
117
+ The editor detects removed `<img>` tags and calls your function:
76
118
 
77
- In your Symfony API (`config/routes.yaml`):
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
- upload_image:
81
- path: /upload
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
- ### **📌 2. Store uploaded images in `/public/uploads/images`**
178
+ ---
179
+
180
+ ## **2. Symfony Upload & Delete Service**
87
181
 
88
182
  ```php
89
- // App/Controller/ImageUploadController.php
183
+ <?php
90
184
 
91
- public function __invoke(Request $request): JsonResponse
185
+ final class UploaderService
92
186
  {
93
- $files = $request->files->get('files');
94
- $storedFiles = [];
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
- foreach ($files as $file) {
97
- $newName = uniqid().'.'.$file->guessExtension();
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
- 'data' => [
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
- ### **📌 3. Front-end React configuration**
262
+ ---
263
+
264
+ ## **4. React Integration**
114
265
 
115
266
  ```ts
116
- export const uploadUrl = `${apiUrl}/upload`;
117
- export const blogPostImgUrl = `${uploadUrl}/images`;
267
+ export const blogPostImgUrl = `${apiUrl}/uploads/post`;
268
+ ```
118
269
 
119
- const editorConfig = useMemo(
120
- () => config(
121
- true, // enable uploader
122
- `${apiUrl}/upload`, // Symfony upload endpoint
123
- blogPostImgUrl // URL to access public images
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
- This will automatically insert images as:
278
+ ---
130
279
 
131
- ```html
132
- <img src="https://your-domain.com/uploads/images/filename.webp" />
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
- ## 📝 License
291
+ # 📝 License
138
292
 
139
- MIT — feel free to use it in commercial and private applications.
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 uploaderConfig = function (apiUrl, imageUrl) { return ({
61
- imagesExtensions: ['jpg', 'png', 'jpeg', 'gif', 'webp'],
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, '&amp;')
111
+ .replace(/</g, '&lt;')
112
+ .replace(/>/g, '&gt;')
113
+ .replace(/"/g, '&quot;')
114
+ .replace(/'/g, '&#039;');
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 (e) {
73
- var _a;
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 (e) {
87
- var _a;
88
- return ((_a = e === null || e === void 0 ? void 0 : e.data) === null || _a === void 0 ? void 0 : _a.messages) && Array.isArray(e.data.messages)
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 files = [];
94
- files.unshift(resp === null || resp === void 0 ? void 0 : resp.data);
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: resp === null || resp === void 0 ? void 0 : resp.data,
97
- error: resp === null || resp === void 0 ? void 0 : resp.msg,
98
- msg: resp === null || resp === void 0 ? void 0 : resp.msg,
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
- this.j.e.fire('errorMessage', e.message, 'error', 4000);
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
- this.j.e.fire('errorMessage', (e === null || e === void 0 ? void 0 : e.message) || 'Upload error');
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
- if (onDeleteImage) {
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 prevSrcs = extractImageSrcs(prevValue);
353
+ var prevAssets = extractAssetsFromHtml(prevValue, fileLinkExtensions);
172
354
  editor.events.on('change', function () { return __awaiter(_this, void 0, void 0, function () {
173
- var currentValue, currentSrcs;
355
+ var currentValue, currentAssets;
174
356
  return __generator(this, function (_a) {
175
357
  currentValue = editor.value || '';
176
- currentSrcs = extractImageSrcs(currentValue);
177
- prevSrcs.forEach(function (src) {
178
- if (!currentSrcs.has(src)) {
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
- prevSrcs = currentSrcs;
374
+ prevAssets = currentAssets;
186
375
  return [2];
187
376
  });
188
377
  }); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mbs-dev/react-editor",
3
- "version": "1.0.8",
3
+ "version": "1.2.0",
4
4
  "description": "react editor",
5
5
  "main": "dist/index.js",
6
6
  "types": "types/index.d.ts",
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, '&amp;')
45
+ .replace(/</g, '&lt;')
46
+ .replace(/>/g, '&gt;')
47
+ .replace(/"/g, '&quot;')
48
+ .replace(/'/g, '&#039;');
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
- apiUrl?: string,
22
- imageUrl?: string
23
- ) => ({
24
- imagesExtensions: ['jpg', 'png', 'jpeg', 'gif', 'webp'],
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
- return !!e?.success;
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
- getMessage(e: any): string {
52
- return e?.data?.messages && Array.isArray(e.data.messages)
53
- ? e.data.messages.join('')
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: resp?.data,
62
- error: resp?.msg,
63
- msg: resp?.msg,
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.e.fire('errorMessage', e.message, 'error', 4000);
311
+ this.j?.e?.fire?.('errorMessage', e.message, 'error', 4000);
68
312
  },
313
+
69
314
  defaultHandlerError(this: any, e: any): void {
70
- this.j.e.fire('errorMessage', e?.message || 'Upload error');
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
- if (onDeleteImage) {
405
+ const hasDelete = Boolean(onDeleteImage || onDeleteFile);
406
+
407
+ if (hasDelete) {
146
408
  base.events = {
147
409
  ...(base.events || {}),
410
+
148
411
  /**
149
- * We use the value diff to detect removed <img> src.
150
- * This avoids false calls during upload (where DOM nodes can be replaced).
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 prevSrcs: Set<string> = extractImageSrcs(prevValue);
418
+ let prevAssets = extractAssetsFromHtml(prevValue, fileLinkExtensions);
167
419
 
168
420
  editor.events.on('change', async () => {
169
421
  const currentValue: string = editor.value || '';
170
- const currentSrcs = extractImageSrcs(currentValue);
422
+ const currentAssets = extractAssetsFromHtml(currentValue, fileLinkExtensions);
171
423
 
172
- // src present before, not present now -> deleted
173
- prevSrcs.forEach((src) => {
174
- if (!currentSrcs.has(src)) {
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
- void onDeleteImage(src);
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
- prevSrcs = currentSrcs;
443
+ prevAssets = currentAssets;
184
444
  });
185
445
  },
186
446
  };
@@ -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
- export declare const uploaderConfig: (apiUrl?: string, imageUrl?: string) => {
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: any, e: any): boolean;
13
- getMessage(e: any): string;
14
- process(resp: any): {
15
- files: any[];
16
- error: string;
17
- msg: string;
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;
@@ -8,3 +8,4 @@ export interface EditorProps {
8
8
  config?: any;
9
9
  }
10
10
  export type OnDeleteImage = (src: string) => void | Promise<void>;
11
+ export type OnDeleteFile = (href: string) => void | Promise<void>;