@mbs-dev/react-editor 1.0.7 → 1.1.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
@@ -1,28 +1,50 @@
1
1
  "use strict";
2
- var __read = (this && this.__read) || function (o, n) {
3
- var m = typeof Symbol === "function" && o[Symbol.iterator];
4
- if (!m) return o;
5
- var i = m.call(o), r, ar = [], e;
6
- try {
7
- while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
8
- }
9
- catch (error) { e = { error: error }; }
10
- finally {
11
- try {
12
- if (r && !r.done && (m = i["return"])) m.call(i);
2
+ var __assign = (this && this.__assign) || function () {
3
+ __assign = Object.assign || function(t) {
4
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
5
+ s = arguments[i];
6
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
7
+ t[p] = s[p];
13
8
  }
14
- finally { if (e) throw e.error; }
15
- }
16
- return ar;
9
+ return t;
10
+ };
11
+ return __assign.apply(this, arguments);
17
12
  };
18
- var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
19
- if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
20
- if (ar || !(i in from)) {
21
- if (!ar) ar = Array.prototype.slice.call(from, 0, i);
22
- ar[i] = from[i];
23
- }
13
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
14
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
15
+ return new (P || (P = Promise))(function (resolve, reject) {
16
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
17
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
18
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
19
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
20
+ });
21
+ };
22
+ var __generator = (this && this.__generator) || function (thisArg, body) {
23
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
24
+ return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
25
+ function verb(n) { return function (v) { return step([n, v]); }; }
26
+ function step(op) {
27
+ if (f) throw new TypeError("Generator is already executing.");
28
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
29
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
30
+ if (y = 0, t) op = [op[0] & 2, t.value];
31
+ switch (op[0]) {
32
+ case 0: case 1: t = op; break;
33
+ case 4: _.label++; return { value: op[1], done: false };
34
+ case 5: _.label++; y = op[1]; op = [0]; continue;
35
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
36
+ default:
37
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
38
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
39
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
40
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
41
+ if (t[2]) _.ops.pop();
42
+ _.trys.pop(); continue;
43
+ }
44
+ op = body.call(thisArg, _);
45
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
46
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
24
47
  }
25
- return to.concat(ar || Array.prototype.slice.call(from));
26
48
  };
27
49
  var __importDefault = (this && this.__importDefault) || function (mod) {
28
50
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -48,42 +70,45 @@ var uploaderConfig = function (apiUrl, imageUrl) { return ({
48
70
  return formdata;
49
71
  },
50
72
  isSuccess: function (e) {
73
+ var _a;
51
74
  var fn = this.jodit;
52
- if (e.data.files && e.data.files.length) {
75
+ if (((_a = e === null || e === void 0 ? void 0 : e.data) === null || _a === void 0 ? void 0 : _a.files) && e.data.files.length) {
53
76
  var tagName_1 = 'img';
54
77
  e.data.files.forEach(function (filename) {
55
78
  var elm = fn.createInside.element(tagName_1);
56
- elm.setAttribute('src', "".concat(imageUrl, "/").concat(filename));
79
+ var src = imageUrl ? "".concat(imageUrl, "/").concat(filename) : filename;
80
+ elm.setAttribute('src', src);
57
81
  fn.s.insertImage(elm, null, fn.o.imageDefaultWidth);
58
82
  });
59
83
  }
60
- return e.success;
84
+ return !!(e === null || e === void 0 ? void 0 : e.success);
61
85
  },
62
86
  getMessage: function (e) {
63
- return e.data.messages && Array.isArray(e.data.messages)
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)
64
89
  ? e.data.messages.join('')
65
90
  : '';
66
91
  },
67
92
  process: function (resp) {
68
93
  var files = [];
69
- files.unshift(resp.data);
94
+ files.unshift(resp === null || resp === void 0 ? void 0 : resp.data);
70
95
  return {
71
- files: resp.data,
72
- error: resp.msg,
73
- msg: resp.msg,
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,
74
99
  };
75
100
  },
76
101
  error: function (e) {
77
102
  this.j.e.fire('errorMessage', e.message, 'error', 4000);
78
103
  },
79
104
  defaultHandlerError: function (e) {
80
- this.j.e.fire('errorMessage', e.message);
105
+ this.j.e.fire('errorMessage', (e === null || e === void 0 ? void 0 : e.message) || 'Upload error');
81
106
  },
82
107
  }); };
83
108
  exports.uploaderConfig = uploaderConfig;
84
109
  var config = function (_a) {
85
110
  var _b = _a === void 0 ? {} : _a, includeUploader = _b.includeUploader, apiUrl = _b.apiUrl, imageUrl = _b.imageUrl, onDeleteImage = _b.onDeleteImage;
86
- return ({
111
+ var base = {
87
112
  readonly: false,
88
113
  placeholder: 'Start typing...',
89
114
  toolbarAdaptive: false,
@@ -127,40 +152,43 @@ var config = function (_a) {
127
152
  'table',
128
153
  'fullsize',
129
154
  ],
130
- uploader: includeUploader ? (0, exports.uploaderConfig)(apiUrl, imageUrl) : undefined,
131
- events: onDeleteImage
132
- ? {
133
- afterInit: function (editor) {
134
- var root = editor.editor;
135
- var observer = new MutationObserver(function (mutations) {
136
- mutations.forEach(function (mutation) {
137
- mutation.removedNodes.forEach(function (node) {
138
- if (node.nodeType !== Node.ELEMENT_NODE)
139
- return;
140
- var el = node;
141
- var imgs = __spreadArray(__spreadArray([], __read((el.matches('img') ? [el] : [])), false), __read(Array.from(el.querySelectorAll('img'))), false);
142
- imgs.forEach(function (img) {
143
- var src = img.getAttribute('src');
144
- if (!src)
145
- return;
146
- if (imageUrl && !src.startsWith(imageUrl))
147
- return;
148
- onDeleteImage(src);
149
- });
150
- });
155
+ };
156
+ if (includeUploader) {
157
+ base.uploader = (0, exports.uploaderConfig)(apiUrl, imageUrl);
158
+ }
159
+ if (onDeleteImage) {
160
+ base.events = __assign(__assign({}, (base.events || {})), { afterInit: function (editor) {
161
+ 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
+ var prevValue = editor.value || '';
171
+ var prevSrcs = extractImageSrcs(prevValue);
172
+ editor.events.on('change', function () { return __awaiter(_this, void 0, void 0, function () {
173
+ var currentValue, currentSrcs;
174
+ return __generator(this, function (_a) {
175
+ currentValue = editor.value || '';
176
+ currentSrcs = extractImageSrcs(currentValue);
177
+ prevSrcs.forEach(function (src) {
178
+ if (!currentSrcs.has(src)) {
179
+ if (!imageUrl || src.startsWith(imageUrl)) {
180
+ void onDeleteImage(src);
181
+ }
182
+ }
151
183
  });
184
+ prevValue = currentValue;
185
+ prevSrcs = currentSrcs;
186
+ return [2];
152
187
  });
153
- observer.observe(root, {
154
- childList: true,
155
- subtree: true,
156
- });
157
- editor.events.on('beforeDestruct', function () {
158
- observer.disconnect();
159
- });
160
- },
161
- }
162
- : undefined,
163
- });
188
+ }); });
189
+ } });
190
+ }
191
+ return base;
164
192
  };
165
193
  exports.config = config;
166
194
  exports.default = ReactEditor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mbs-dev/react-editor",
3
- "version": "1.0.7",
3
+ "version": "1.1.0",
4
4
  "description": "react editor",
5
5
  "main": "dist/index.js",
6
6
  "types": "types/index.d.ts",
package/src/Editor.tsx CHANGED
@@ -1,7 +1,6 @@
1
- // Editor.tsx
2
1
  import React from 'react';
3
2
  import JoditEditor from 'jodit-react';
4
- import { EditorProps, OnDeleteImage } from './Editor.types';
3
+ import { EditorProps } from './Editor.types';
5
4
 
6
5
  const ReactEditor: React.FC<EditorProps> = ({ onChange, onBlur, value, config }) => {
7
6
  return (
@@ -14,7 +13,14 @@ const ReactEditor: React.FC<EditorProps> = ({ onChange, onBlur, value, config })
14
13
  );
15
14
  };
16
15
 
17
- export const uploaderConfig = (apiUrl?: string, imageUrl?: string) => ({
16
+ /**
17
+ * Uploader configuration for Jodit
18
+ * Handles image upload + insertion in the editor
19
+ */
20
+ export const uploaderConfig = (
21
+ apiUrl?: string,
22
+ imageUrl?: string
23
+ ) => ({
18
24
  imagesExtensions: ['jpg', 'png', 'jpeg', 'gif', 'webp'],
19
25
  filesVariableName(t: number): string {
20
26
  return 'files[' + t + ']';
@@ -29,38 +35,39 @@ export const uploaderConfig = (apiUrl?: string, imageUrl?: string) => ({
29
35
  isSuccess(this: any, e: any): boolean {
30
36
  const fn = this.jodit;
31
37
 
32
- if (e.data.files && e.data.files.length) {
38
+ if (e?.data?.files && e.data.files.length) {
33
39
  const tagName = 'img';
34
40
 
35
41
  e.data.files.forEach((filename: string) => {
36
42
  const elm = fn.createInside.element(tagName);
37
- elm.setAttribute('src', `${imageUrl}/${filename}`);
43
+ const src = imageUrl ? `${imageUrl}/${filename}` : filename;
44
+ elm.setAttribute('src', src);
38
45
  fn.s.insertImage(elm as HTMLImageElement, null, fn.o.imageDefaultWidth);
39
46
  });
40
47
  }
41
48
 
42
- return e.success;
49
+ return !!e?.success;
43
50
  },
44
51
  getMessage(e: any): string {
45
- return e.data.messages && Array.isArray(e.data.messages)
52
+ return e?.data?.messages && Array.isArray(e.data.messages)
46
53
  ? e.data.messages.join('')
47
54
  : '';
48
55
  },
49
56
  process(resp: any): { files: any[]; error: string; msg: string } {
50
57
  const files: any[] = [];
51
- files.unshift(resp.data);
58
+ files.unshift(resp?.data);
52
59
 
53
60
  return {
54
- files: resp.data,
55
- error: resp.msg,
56
- msg: resp.msg,
61
+ files: resp?.data,
62
+ error: resp?.msg,
63
+ msg: resp?.msg,
57
64
  };
58
65
  },
59
66
  error(this: any, e: Error): void {
60
67
  this.j.e.fire('errorMessage', e.message, 'error', 4000);
61
68
  },
62
69
  defaultHandlerError(this: any, e: any): void {
63
- this.j.e.fire('errorMessage', e.message);
70
+ this.j.e.fire('errorMessage', e?.message || 'Upload error');
64
71
  },
65
72
  });
66
73
 
@@ -68,105 +75,118 @@ type ConfigParams = {
68
75
  includeUploader?: boolean;
69
76
  apiUrl?: string;
70
77
  imageUrl?: string;
71
- onDeleteImage?: OnDeleteImage;
78
+ /**
79
+ * Called when an image is really removed from the editor content.
80
+ * You receive the image src URL and can call your API to delete it on server.
81
+ */
82
+ onDeleteImage?: (imageUrl: string) => void | Promise<void>;
72
83
  };
73
84
 
85
+ /**
86
+ * Build Jodit config for ReactEditor
87
+ */
74
88
  export const config = ({
75
89
  includeUploader,
76
90
  apiUrl,
77
91
  imageUrl,
78
92
  onDeleteImage,
79
- }: ConfigParams = {}) => ({
80
- readonly: false,
81
- placeholder: 'Start typing...',
82
- toolbarAdaptive: false,
83
- useSearch: false,
84
- language: 'en',
85
- allowResizeX: false,
86
- allowResizeY: false,
87
- height: 400,
88
- enableDragAndDropFileToEditor: true,
89
- showCharsCounter: true,
90
- showWordsCounter: true,
91
- showXPathInStatusbar: false,
92
-
93
- buttons: [
94
- 'source',
95
- '|',
96
- 'bold',
97
- 'italic',
98
- 'underline',
99
- '|',
100
- 'ul',
101
- 'ol',
102
- '|',
103
- 'image',
104
- '|',
105
- 'video',
106
- '|',
107
- 'link',
108
- '|',
109
- 'undo',
110
- 'redo',
111
- '|',
112
- 'hr',
113
- '|',
114
- 'eraser',
115
- '|',
116
- 'font',
117
- 'fontsize',
118
- 'paragraph',
119
- 'brush',
120
- '|',
121
- 'table',
122
- 'fullsize',
123
- ],
124
-
125
- uploader: includeUploader ? uploaderConfig(apiUrl, imageUrl) : undefined,
126
-
127
- // 👇 This part ensures delete callback runs when image is removed
128
- events: onDeleteImage
129
- ? {
93
+ }: ConfigParams = {}) => {
94
+ const base: any = {
95
+ readonly: false,
96
+ placeholder: 'Start typing...',
97
+ toolbarAdaptive: false,
98
+ useSearch: false,
99
+ language: 'en',
100
+ allowResizeX: false,
101
+ allowResizeY: false,
102
+ height: 400,
103
+ enableDragAndDropFileToEditor: true,
104
+ showCharsCounter: true,
105
+ showWordsCounter: true,
106
+ showXPathInStatusbar: false,
107
+
108
+ buttons: [
109
+ 'source',
110
+ '|',
111
+ 'bold',
112
+ 'italic',
113
+ 'underline',
114
+ '|',
115
+ 'ul',
116
+ 'ol',
117
+ '|',
118
+ 'image',
119
+ '|',
120
+ 'video',
121
+ '|',
122
+ 'link',
123
+ '|',
124
+ 'undo',
125
+ 'redo',
126
+ '|',
127
+ 'hr',
128
+ '|',
129
+ 'eraser',
130
+ '|',
131
+ 'font',
132
+ 'fontsize',
133
+ 'paragraph',
134
+ 'brush',
135
+ '|',
136
+ 'table',
137
+ 'fullsize',
138
+ ],
139
+ };
140
+
141
+ if (includeUploader) {
142
+ base.uploader = uploaderConfig(apiUrl, imageUrl);
143
+ }
144
+
145
+ if (onDeleteImage) {
146
+ base.events = {
147
+ ...(base.events || {}),
148
+ /**
149
+ * We use the value diff to detect removed <img> src.
150
+ * This avoids false calls during upload (where DOM nodes can be replaced).
151
+ */
130
152
  afterInit(editor: any) {
131
- const root: HTMLElement = editor.editor;
132
-
133
- const observer = new MutationObserver((mutations) => {
134
- mutations.forEach((mutation) => {
135
- mutation.removedNodes.forEach((node) => {
136
- if (node.nodeType !== Node.ELEMENT_NODE) return;
137
-
138
- const el = node as HTMLElement;
139
-
140
- const imgs: HTMLImageElement[] = [
141
- ...(el.matches('img') ? [el as HTMLImageElement] : []),
142
- ...Array.from(el.querySelectorAll('img')),
143
- ];
144
-
145
- imgs.forEach((img) => {
146
- const src = img.getAttribute('src');
147
- if (!src) return;
148
-
149
- // Optionally restrict to your image base URL
150
- if (imageUrl && !src.startsWith(imageUrl)) return;
151
-
152
- onDeleteImage(src);
153
- });
154
- });
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
+ let prevValue: string = editor.value || '';
166
+ let prevSrcs: Set<string> = extractImageSrcs(prevValue);
167
+
168
+ editor.events.on('change', async () => {
169
+ const currentValue: string = editor.value || '';
170
+ const currentSrcs = extractImageSrcs(currentValue);
171
+
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
176
+ if (!imageUrl || src.startsWith(imageUrl)) {
177
+ void onDeleteImage(src);
178
+ }
179
+ }
155
180
  });
156
- });
157
181
 
158
- observer.observe(root, {
159
- childList: true,
160
- subtree: true,
161
- });
162
-
163
- // Cleanup when editor is destroyed
164
- editor.events.on('beforeDestruct', () => {
165
- observer.disconnect();
182
+ prevValue = currentValue;
183
+ prevSrcs = currentSrcs;
166
184
  });
167
185
  },
168
- }
169
- : undefined,
170
- });
186
+ };
187
+ }
188
+
189
+ return base;
190
+ };
171
191
 
172
192
  export default ReactEditor;
package/types/Editor.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { EditorProps, OnDeleteImage } from './Editor.types';
2
+ import { EditorProps } from './Editor.types';
3
3
  declare const ReactEditor: React.FC<EditorProps>;
4
4
  export declare const uploaderConfig: (apiUrl?: string, imageUrl?: string) => {
5
5
  imagesExtensions: string[];
@@ -23,42 +23,7 @@ type ConfigParams = {
23
23
  includeUploader?: boolean;
24
24
  apiUrl?: string;
25
25
  imageUrl?: string;
26
- onDeleteImage?: OnDeleteImage;
27
- };
28
- export declare const config: ({ includeUploader, apiUrl, imageUrl, onDeleteImage, }?: ConfigParams) => {
29
- readonly: boolean;
30
- placeholder: string;
31
- toolbarAdaptive: boolean;
32
- useSearch: boolean;
33
- language: string;
34
- allowResizeX: boolean;
35
- allowResizeY: boolean;
36
- height: number;
37
- enableDragAndDropFileToEditor: boolean;
38
- showCharsCounter: boolean;
39
- showWordsCounter: boolean;
40
- showXPathInStatusbar: boolean;
41
- buttons: string[];
42
- uploader: {
43
- imagesExtensions: string[];
44
- filesVariableName(t: number): string;
45
- url: string | undefined;
46
- withCredentials: boolean;
47
- format: string;
48
- method: string;
49
- prepareData(formdata: FormData): FormData;
50
- isSuccess(this: any, e: any): boolean;
51
- getMessage(e: any): string;
52
- process(resp: any): {
53
- files: any[];
54
- error: string;
55
- msg: string;
56
- };
57
- error(this: any, e: Error): void;
58
- defaultHandlerError(this: any, e: any): void;
59
- } | undefined;
60
- events: {
61
- afterInit(editor: any): void;
62
- } | undefined;
26
+ onDeleteImage?: (imageUrl: string) => void | Promise<void>;
63
27
  };
28
+ export declare const config: ({ includeUploader, apiUrl, imageUrl, onDeleteImage, }?: ConfigParams) => any;
64
29
  export default ReactEditor;