suneditor 3.1.2 → 3.1.4

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.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/dist/suneditor.min.css +1 -1
  3. package/dist/suneditor.min.js +1 -1
  4. package/package.json +9 -6
  5. package/src/assets/suneditor.css +28 -2
  6. package/src/core/event/handlers/handler_ww_dragDrop.js +20 -0
  7. package/src/core/logic/dom/html.js +7 -15
  8. package/src/core/logic/panel/menu.js +4 -0
  9. package/src/core/logic/shell/ui.js +13 -2
  10. package/src/core/schema/options.js +16 -16
  11. package/src/core/section/constructor.js +29 -22
  12. package/src/core/section/documentType.js +55 -29
  13. package/src/modules/contract/Browser.js +15 -11
  14. package/src/modules/contract/Controller.js +3 -0
  15. package/src/modules/contract/Figure.js +26 -4
  16. package/src/modules/contract/Modal.js +14 -1
  17. package/src/modules/ui/ModalAnchorEditor.js +8 -3
  18. package/src/plugins/browser/audioGallery.js +6 -0
  19. package/src/plugins/browser/fileBrowser.js +6 -0
  20. package/src/plugins/browser/fileGallery.js +6 -0
  21. package/src/plugins/browser/imageGallery.js +6 -0
  22. package/src/plugins/browser/videoGallery.js +6 -0
  23. package/src/plugins/dropdown/table/services/table.style.js +21 -12
  24. package/src/plugins/modal/embed.js +41 -1
  25. package/src/plugins/modal/image/index.js +2 -1
  26. package/types/core/schema/options.d.ts +29 -37
  27. package/types/core/section/documentType.d.ts +17 -1
  28. package/types/modules/contract/Browser.d.ts +7 -7
  29. package/types/modules/ui/ModalAnchorEditor.d.ts +6 -1
  30. package/types/plugins/browser/audioGallery.d.ts +16 -0
  31. package/types/plugins/browser/fileBrowser.d.ts +16 -0
  32. package/types/plugins/browser/fileGallery.d.ts +16 -0
  33. package/types/plugins/browser/imageGallery.d.ts +16 -0
  34. package/types/plugins/browser/videoGallery.d.ts +16 -0
  35. package/types/plugins/modal/embed.d.ts +34 -0
@@ -162,28 +162,19 @@ class DocumentType {
162
162
 
163
163
  // page break
164
164
  let pageBreakHeight = 0;
165
- let lastBreakPosition = 0;
166
- let additionalPages = 0;
165
+ const breakPoints = [];
167
166
  if (pageBreaks.length > 0) {
168
167
  pageBreakHeight = pageBreaks[0].offsetHeight;
169
168
  for (let i = 0; i < pageBreaks.length; i++) {
170
- const breakPosition = pageBreaks[i].offsetTop;
171
- const sectionHeight = breakPosition - lastBreakPosition;
172
- if (sectionHeight % A4_PAGE_HEIGHT !== 0) additionalPages++;
173
- lastBreakPosition = breakPosition;
169
+ breakPoints.push({ top: pageBreaks[i].offsetTop, end: pageBreaks[i].offsetTop + pageBreakHeight / 2 });
174
170
  }
175
-
176
- const lastSectionHeight = mirrorHeight - lastBreakPosition;
177
- if (lastSectionHeight > 0 && lastSectionHeight % A4_PAGE_HEIGHT !== 0) additionalPages++;
178
171
  }
179
172
 
180
173
  const scrollTop = !this.#isScrollable(this.#fc) ? 0 : this._getWWScrollTop();
181
- const totalPages = Math.ceil(mirrorHeight / A4_PAGE_HEIGHT) + additionalPages;
182
- const wwWidth = this.#wwFrame.offsetWidth + 1;
183
- const pages = [];
174
+ const pages = [{ number: 0, top: 0 }];
184
175
 
185
- for (let i = 0; i < pageBreaks.length; i++) {
186
- pages.push({ number: i, top: pageBreaks[i].offsetTop + pageBreakHeight / 2 });
176
+ for (let i = 0; i < breakPoints.length; i++) {
177
+ pages.push({ number: 0, top: breakPoints[i].top + pageBreakHeight / 2, isBreak: true });
187
178
  }
188
179
 
189
180
  this.#mirrorCache = 0;
@@ -191,14 +182,27 @@ class DocumentType {
191
182
  const mChr = this.#mirror.children;
192
183
  this._initializeCache(mChr);
193
184
 
194
- pages.push({ number: 0, top: 0 });
185
+ // Calculate page positions per section (between page breaks)
186
+ const sectionStarts = [0, ...breakPoints.map((b) => b.end)];
187
+ const sectionEnds = [...breakPoints.map((b) => b.top), mirrorHeight];
188
+
189
+ for (let s = 0; s < sectionStarts.length; s++) {
190
+ const sStart = sectionStarts[s];
191
+ const sEnd = sectionEnds[s];
192
+ let t = sStart;
193
+ let isFirst = s === 0;
195
194
 
196
- for (let i = 1, t = 0; i < totalPages; i++) {
197
- t += A4_PAGE_HEIGHT + (i === 1 ? this.#paddingTop + this.#paddingBottom : this.#paddingTop);
198
- if (!pages.some((p) => Math.abs(p.top - t) < 3)) {
199
- const top = this._calcPageBreakTop(t, chr, mChr);
200
- if (top === null) break;
201
- pages.push({ number: i, top });
195
+ while (true) {
196
+ t += A4_PAGE_HEIGHT + (isFirst ? this.#paddingTop + this.#paddingBottom : this.#paddingTop);
197
+ isFirst = false;
198
+
199
+ if (t >= sEnd) break;
200
+
201
+ if (!pages.some((p) => Math.abs(p.top - t) < 3)) {
202
+ const top = this._calcPageBreakTop(t, chr, mChr, pages);
203
+ if (top === null) break;
204
+ pages.push({ number: 0, top });
205
+ }
202
206
  }
203
207
  }
204
208
 
@@ -214,8 +218,8 @@ class DocumentType {
214
218
  this.#page.innerHTML = '';
215
219
  this.#pages = [];
216
220
 
217
- for (let i = 0, t; i < totalPages; i++) {
218
- if (!pages[i]) continue;
221
+ const wwWidth = this.#wwFrame.offsetWidth + 1;
222
+ for (let i = 0, t; i < pages.length; i++) {
219
223
  t = pages[i].top;
220
224
  if (mirrorHeight < t) break;
221
225
 
@@ -249,10 +253,11 @@ class DocumentType {
249
253
  * @param {number} t - The initial top position value to be adjusted.
250
254
  * @param {HTMLCollection} chr - The elements array in the current (main) page.
251
255
  * @param {HTMLCollection} mChr - The elements array in the mirrored page.
256
+ * @param {Array.<{number: number, top: number, isBreak?: boolean}>} [pages] - The pages array containing page break info.
252
257
  * @returns {number|null} The adjusted top value.
253
258
  */
254
- _calcPageBreakTop(t, chr, mChr) {
255
- const { ci } = this._getElementAtPosition(t, mChr);
259
+ _calcPageBreakTop(t, chr, mChr, pages) {
260
+ const { ci } = this._getElementAtPosition(t, mChr, pages);
256
261
  const mel = /** @type {HTMLElement} */ (mChr[ci]);
257
262
  const el = /** @type {HTMLElement} */ (chr[ci]);
258
263
  if (!mel || !el) return null;
@@ -290,22 +295,39 @@ class DocumentType {
290
295
  * @description Retrieves the element at a given position.
291
296
  * @param {number} pageTop - The vertical position to check.
292
297
  * @param {HTMLCollection} mChr - List of mirrored elements.
298
+ * @param {Array.<{number: number, top: number, isBreak?: boolean}>} [pages] - The pages array containing page break info for skipping break elements.
293
299
  * @returns {{ci: number, cm: number, ch: number}} The closest element and its related data.
294
300
  * - ci: The index of the closest element.
295
301
  * - cm: The distance between the top of the closest element and the given position.
296
302
  * - ch: The height of the closest element.
297
303
  */
298
- _getElementAtPosition(pageTop, mChr) {
304
+ _getElementAtPosition(pageTop, mChr, pages) {
299
305
  let start = this.#mirrorCache;
300
306
  let end = mChr.length - 1;
301
307
 
308
+ // Reset cache if target position is before cached position (crossing section boundaries)
309
+ if (start > 0) {
310
+ const cachedPos = this.#positionCache.get(start);
311
+ if (cachedPos && pageTop < cachedPos.top) {
312
+ start = 0;
313
+ }
314
+ }
315
+
302
316
  while (start <= end) {
303
317
  const mid = Math.floor((start + end) / 2);
304
318
  const { top, height, bottom } = this.#positionCache.get(mid);
305
319
 
306
320
  if (pageTop >= top && pageTop <= bottom) {
307
- this.#mirrorCache = mid;
308
- return { ci: mid, cm: pageTop - bottom, ch: height };
321
+ let ci = mid;
322
+ // Skip page break elements use adjacent content element
323
+ if (pages && dom.utils.hasClass(mChr[ci], 'se-page-break')) {
324
+ ci = ci + 1 < mChr.length ? ci + 1 : Math.max(0, ci - 1);
325
+ const adjPos = this.#positionCache.get(ci);
326
+ this.#mirrorCache = ci;
327
+ return { ci, cm: pageTop - adjPos.bottom, ch: adjPos.height };
328
+ }
329
+ this.#mirrorCache = ci;
330
+ return { ci, cm: pageTop - bottom, ch: height };
309
331
  }
310
332
 
311
333
  if (pageTop < top) {
@@ -315,7 +337,11 @@ class DocumentType {
315
337
  }
316
338
  }
317
339
 
318
- const closestIndex = mChr[start] ? start : end;
340
+ let closestIndex = mChr[start] ? start : end;
341
+ // Skip page break elements for closest match
342
+ if (pages && dom.utils.hasClass(mChr[closestIndex], 'se-page-break')) {
343
+ closestIndex = closestIndex + 1 < mChr.length ? closestIndex + 1 : Math.max(0, closestIndex - 1);
344
+ }
319
345
  this.#mirrorCache = closestIndex;
320
346
  const iElement = this.#positionCache.get(closestIndex);
321
347
  return { ci: closestIndex, cm: pageTop - iElement.bottom, ch: iElement.height };
@@ -40,7 +40,7 @@ import ApiManager from '../manager/ApiManager';
40
40
  * ]
41
41
  * }
42
42
  * ```
43
- * @property {Object<string, string>} [searchUrlHeader] - File server search http header. Optional. Can be overridden in browser.
43
+ * @property {Object<string, string>} [searchHeaders] - File server search http header. Optional. Can be overridden in browser.
44
44
  * @property {string} [listClass] - Class name of list div. Required. Can be overridden in browser.
45
45
  * @property {(item: BrowserFile) => string} [drawItemHandler] - Function that returns HTML string for rendering each file item. Required. Can be overridden in browser.
46
46
  * ```js
@@ -114,9 +114,9 @@ class Browser {
114
114
  this.listClass = params.listClass || 'se-preview-list';
115
115
  this.directData = params.data;
116
116
  this.url = params.url;
117
- this.urlHeader = params.headers;
117
+ this.headers = params.headers;
118
118
  this.searchUrl = params.searchUrl;
119
- this.searchUrlHeader = params.searchUrlHeader;
119
+ this.searchHeaders = params.searchHeaders;
120
120
  this.drawItemHandler = (params.drawItemHandler || DrawItems).bind({ thumbnail: params.thumbnail, props: params.props || [] });
121
121
  this.selectorHandler = params.selectorHandler;
122
122
  this.columnSize = params.columnSize || 4;
@@ -151,6 +151,7 @@ class Browser {
151
151
  // init
152
152
  browserFrame.appendChild(dom.utils.createElement('DIV', { class: 'se-browser-back' }));
153
153
  browserFrame.appendChild(content);
154
+ browserFrame.setAttribute('popover', 'manual');
154
155
  this.#$.contextProvider.carrierWrapper.appendChild(browserFrame);
155
156
 
156
157
  this.#$.eventManager.addEvent(this.tagArea, 'click', this.#OnClickTag.bind(this));
@@ -175,7 +176,7 @@ class Browser {
175
176
  * @param {string} [params.listClass] - Class name of list div. If not, use `this.listClass`.
176
177
  * @param {string} [params.title] - File browser window title. If not, use `this.title`.
177
178
  * @param {string} [params.url] - File server url. If not, use `this.url`.
178
- * @param {Object<string, string>} [params.urlHeader] - File server http header. If not, use `this.urlHeader`.
179
+ * @param {Object<string, string>} [params.headers] - File server http header. If not, use `this.headers`.
179
180
  * @example
180
181
  * // Open with default settings (configured at construction):
181
182
  * this.browser.open();
@@ -184,7 +185,7 @@ class Browser {
184
185
  * this.browser.open({
185
186
  * title: 'Select a video',
186
187
  * url: '/api/videos',
187
- * urlHeader: { Authorization: 'Bearer token' },
188
+ * headers: { Authorization: 'Bearer token' },
188
189
  * });
189
190
  */
190
191
  open(params = {}) {
@@ -197,13 +198,14 @@ class Browser {
197
198
 
198
199
  this.titleArea.textContent = params.title || this.title;
199
200
  this.area.style.display = 'block';
201
+ this.area.showPopover?.();
200
202
  this.#$.ui.opendBrowser = this;
201
203
  this.closeArrow = this.#$.options.get('_rtl') ? this.#$.icons.menu_arrow_left : this.#$.icons.menu_arrow_right;
202
204
 
203
205
  if (this.directData) {
204
206
  this.#drowItems(this.directData);
205
207
  } else {
206
- this.#drawFileList(params.url || this.url, params.urlHeader || this.urlHeader, false);
208
+ this.#drawFileList(params.url || this.url, params.headers || this.headers, false);
207
209
  }
208
210
 
209
211
  this.body.style.maxHeight = dom.utils.getClientSize().h - (this.#$.offset.getGlobal(this.body).top - _w.scrollY) - 20 + 'px';
@@ -217,6 +219,7 @@ class Browser {
217
219
  this.#removeGlobalEvent();
218
220
  this.apiManager.cancel();
219
221
 
222
+ this.area.hidePopover?.();
220
223
  this.area.style.display = 'none';
221
224
  this.selectedTags = [];
222
225
  this.items = [];
@@ -243,7 +246,8 @@ class Browser {
243
246
  search(keyword) {
244
247
  if (this.searchUrl) {
245
248
  this.keyword = keyword;
246
- this.#drawFileList(this.searchUrl + '?keyword=' + keyword, this.searchUrlHeader, false);
249
+ const sep = this.searchUrl.includes('?') ? '&' : '?';
250
+ this.#drawFileList(this.searchUrl + sep + 'keyword=' + _w.encodeURIComponent(keyword), this.searchHeaders, false);
247
251
  } else {
248
252
  this.keyword = keyword.toLowerCase();
249
253
  this.#drawListItem(this.#allItems.length > 0 ? this.#allItems : this.items, false);
@@ -299,11 +303,11 @@ class Browser {
299
303
  /**
300
304
  * @description Fetches the file list from the server.
301
305
  * @param {string} url - The file server URL.
302
- * @param {Object<string, string>} urlHeader - The HTTP headers for the request.
306
+ * @param {Object<string, string>} headers - The HTTP headers for the request.
303
307
  * @param {boolean} pageLoading - Indicates if this is a paginated request.
304
308
  */
305
- #drawFileList(url, urlHeader, pageLoading) {
306
- this.apiManager.call({ method: 'GET', url, headers: urlHeader, callBack: this.#CallBackGet.bind(this), errorCallBack: this.#CallBackError.bind(this) });
309
+ #drawFileList(url, headers, pageLoading) {
310
+ this.apiManager.call({ method: 'GET', url, headers, callBack: this.#CallBackGet.bind(this), errorCallBack: this.#CallBackError.bind(this) });
307
311
  if (!pageLoading) {
308
312
  this.sideOpenBtn.style.display = 'none';
309
313
  this.showBrowserLoading();
@@ -598,7 +602,7 @@ class Browser {
598
602
  this.selectedTags = [];
599
603
 
600
604
  if (typeof data === 'string') {
601
- this.#drawFileList(data, this.urlHeader, true);
605
+ this.#drawFileList(data, this.headers, true);
602
606
  } else {
603
607
  this.#drawListItem(data, true);
604
608
  }
@@ -110,6 +110,7 @@ class Controller {
110
110
  }
111
111
 
112
112
  // add element
113
+ this.form.setAttribute('popover', 'manual');
113
114
  this.#$.contextProvider.carrierWrapper.appendChild(element);
114
115
 
115
116
  // init
@@ -394,6 +395,7 @@ class Controller {
394
395
  * @description Hide controller at editor area (link button, image resize button..)
395
396
  */
396
397
  #controllerOff() {
398
+ this.form.hidePopover?.();
397
399
  this.form.style.display = 'none';
398
400
  this.#$.ui.opendControllers = this.#$.ui.opendControllers.filter((v) => v.form !== this.form);
399
401
  if (this.#$.ui.currentControllerName !== this.kind && this.#$.ui.opendControllers.length > 0) return;
@@ -464,6 +466,7 @@ class Controller {
464
466
 
465
467
  controller.style.zIndex = this.toTop ? INDEX_0 : this.#reserveIndex ? INDEX_S_1 : INDEX_1;
466
468
  controller.style.visibility = '';
469
+ controller.showPopover?.();
467
470
  return true;
468
471
  }
469
472
 
@@ -282,10 +282,18 @@ class Figure {
282
282
  const cover = dom.query.getParentElement(element, 'FIGURE', 2);
283
283
  const inlineCover = dom.query.getParentElement(element, 'SPAN', 2);
284
284
  const anyCover = cover || inlineCover;
285
- const target = dom.query.getParentElement(element, (current) => current.parentElement === anyCover, 0) || /** @type {HTMLElement} */ (element);
285
+ let target = dom.query.getParentElement(element, (current) => current.parentElement === anyCover, 0) || element;
286
+
287
+ // When image is wrapped by anchor, target becomes <a> instead of <img>
288
+ if (dom.check.isAnchor(target)) {
289
+ const imgEl = target.querySelector(':scope > img');
290
+ if (imgEl) {
291
+ target = imgEl;
292
+ }
293
+ }
286
294
 
287
295
  return {
288
- target,
296
+ target: /** @type {HTMLElement} */ (target),
289
297
  container: dom.query.getParentElement(target, Figure.is, 3) || cover,
290
298
  cover: cover,
291
299
  inlineCover: dom.utils.hasClass(inlineCover, 'se-inline-component') ? /** @type {HTMLElement} */ (inlineCover) : null,
@@ -734,12 +742,23 @@ class Figure {
734
742
  const { container, inlineCover, target } = Figure.GetContainer(targetNode);
735
743
  const { w, h } = this.getSize(target);
736
744
 
745
+ // Check if target is wrapped by an anchor
746
+ const anchorEl = dom.check.isAnchor(target.parentNode) ? target.parentNode : null;
747
+
737
748
  const newTarget = /** @type {HTMLElement} */ (target.cloneNode(false));
738
749
  newTarget.style.width = '';
739
750
  newTarget.style.height = '';
740
751
  newTarget.removeAttribute('width');
741
752
  newTarget.removeAttribute('height');
742
753
 
754
+ // Preserve anchor wrapper if exists
755
+ let elementToInsert = newTarget;
756
+ if (anchorEl) {
757
+ const newAnchor = /** @type {HTMLElement} */ (anchorEl.cloneNode(false));
758
+ newAnchor.appendChild(newTarget);
759
+ elementToInsert = newAnchor;
760
+ }
761
+
743
762
  switch (formatStyle) {
744
763
  case 'inline': {
745
764
  if (inlineCover) break;
@@ -748,7 +767,7 @@ class Figure {
748
767
  const next = container.nextElementSibling;
749
768
  const parent = container.parentElement;
750
769
 
751
- const figure = Figure.CreateInlineContainer(newTarget);
770
+ const figure = Figure.CreateInlineContainer(elementToInsert);
752
771
  dom.utils.addClass(
753
772
  figure.container,
754
773
  container.className
@@ -777,7 +796,7 @@ class Figure {
777
796
  dom.utils.removeItem(s.previousElementSibling);
778
797
  }
779
798
 
780
- const figure = Figure.CreateContainer(newTarget);
799
+ const figure = Figure.CreateContainer(elementToInsert);
781
800
  dom.utils.addClass(
782
801
  figure.container,
783
802
  container.className
@@ -1534,6 +1553,7 @@ class Figure {
1534
1553
  this.setAlign(this._element, value);
1535
1554
  this.selectMenu_align.close();
1536
1555
  this.#$.component.select(this._element, this.kind);
1556
+ this.#$.history.push(false);
1537
1557
  }
1538
1558
 
1539
1559
  /**
@@ -1542,6 +1562,7 @@ class Figure {
1542
1562
  #SetMenuAs(value) {
1543
1563
  this.convertAsFormat(this._element, value);
1544
1564
  this.selectMenu_as.close();
1565
+ this.#$.history.push(false);
1545
1566
  }
1546
1567
 
1547
1568
  /**
@@ -1564,6 +1585,7 @@ class Figure {
1564
1585
 
1565
1586
  this.selectMenu_resize.close();
1566
1587
  this.#$.component.select(this._element, this.kind);
1588
+ this.#$.history.push(false);
1567
1589
  }
1568
1590
 
1569
1591
  #OffFigureContainer() {
@@ -10,6 +10,9 @@ const DIRECTION_CURSOR_MAP = { w: 'ns-resize', h: 'ew-resize', c: 'nwse-resize',
10
10
  class Modal {
11
11
  #$;
12
12
 
13
+ /** @type {Node} */
14
+ #targetElement;
15
+
13
16
  /** @type {HTMLElement} */
14
17
  #modalArea;
15
18
  /** @type {HTMLElement} */
@@ -162,6 +165,7 @@ class Modal {
162
165
 
163
166
  dom.utils.addClass(this.#modalArea, 'se-backdrop-show');
164
167
  dom.utils.addClass(this.form, 'se-modal-show');
168
+ this.#modalArea.showPopover?.();
165
169
 
166
170
  if (this.#resizeBody) {
167
171
  const offset = this.#saveOffset();
@@ -174,6 +178,8 @@ class Modal {
174
178
  }
175
179
 
176
180
  if (this.focusElement) this.focusElement.focus();
181
+
182
+ this.#targetElement = this.#$.component.currentTarget;
177
183
  }
178
184
 
179
185
  /**
@@ -197,13 +203,20 @@ class Modal {
197
203
  this.#bindClose &&= this.#$.eventManager.removeGlobalEvent(this.#bindClose);
198
204
 
199
205
  // close
206
+ this.#modalArea.hidePopover?.();
200
207
  dom.utils.removeClass(this.#modalArea, 'se-backdrop-show');
201
208
  dom.utils.removeClass(this.form, 'se-modal-show');
202
209
 
203
210
  this.inst.modalInit?.();
204
211
  this.inst.modalOff?.(this.isUpdate);
205
212
 
206
- if (!this.isUpdate) this.#$.focusManager.focus();
213
+ if (!this.isUpdate) {
214
+ this.#$.focusManager.focus();
215
+ } else if (this.#targetElement) {
216
+ this.inst.componentSelect?.(this.#targetElement);
217
+ }
218
+
219
+ this.#targetElement = null;
207
220
  }
208
221
 
209
222
  /**
@@ -209,6 +209,8 @@ class ModalAnchorEditor {
209
209
  /**
210
210
  * @description Creates an anchor (`<a>`) element with the specified attributes.
211
211
  * @param {boolean} notText - If `true`, the anchor will not contain text content.
212
+ * @param {?string} [urlOverride] - Fallback URL used when the modal's `linkValue` is empty
213
+ * - (e.g., when called outside the modal lifecycle such as from `retainFormat`)
212
214
  * @returns {HTMLElement|null} - The newly created anchor element, or `null` if the URL is empty.
213
215
  * @example
214
216
  * // In a link plugin — create anchor with text content:
@@ -221,11 +223,14 @@ class ModalAnchorEditor {
221
223
  * if (anchor) {
222
224
  * anchor.appendChild(imgElement);
223
225
  * }
226
+ *
227
+ * // Preserve an existing parent anchor's href when rebuilding outside the modal:
228
+ * const anchor = this.anchor.create(true, parentAnchor.href);
224
229
  */
225
- create(notText) {
226
- if (this.linkValue.length === 0) return null;
230
+ create(notText, urlOverride) {
231
+ const url = this.linkValue || urlOverride || '';
232
+ if (url.length === 0) return null;
227
233
 
228
- const url = this.linkValue;
229
234
  const displayText = this.displayInput.value.length === 0 ? url : this.displayInput.value;
230
235
 
231
236
  const oA = /** @type {HTMLAnchorElement} */ (this.currentTarget || dom.utils.createElement('A'));
@@ -19,6 +19,10 @@ import { Browser } from '../../modules/contract';
19
19
  * }
20
20
  * ```
21
21
  * @property {Object<string, string>} [headers] - Server request headers
22
+ * @property {string} [searchUrl] - Server-side search URL. When set, the keyword is sent to this URL
23
+ * as `?keyword=<value>` and the server response replaces the list. When not set, search filters the
24
+ * already-loaded items locally.
25
+ * @property {Object<string, string>} [searchHeaders] - Server-side search request headers
22
26
  * @property {string|((item: SunEditor.Module.Browser.File) => string)} [thumbnail] - Default thumbnail
23
27
  */
24
28
 
@@ -51,6 +55,8 @@ class AudioGallery extends PluginBrowser {
51
55
  data: pluginOptions.data,
52
56
  url: pluginOptions.url,
53
57
  headers: pluginOptions.headers,
58
+ searchUrl: pluginOptions.searchUrl,
59
+ searchHeaders: pluginOptions.searchHeaders,
54
60
  selectorHandler: this.#SetItem.bind(this),
55
61
  columnSize: 4,
56
62
  className: 'se-audio-gallery',
@@ -26,6 +26,10 @@ import { Browser } from '../../modules/contract';
26
26
  * }
27
27
  * ```
28
28
  * @property {Object<string, string>} [headers] - Server request headers
29
+ * @property {string} [searchUrl] - Server-side search URL. When set, the keyword is sent to this URL
30
+ * as `?keyword=<value>` and the server response replaces the list. When not set, search filters the
31
+ * already-loaded items locally.
32
+ * @property {Object<string, string>} [searchHeaders] - Server-side search request headers
29
33
  * @property {string|((item: SunEditor.Module.Browser.File) => string)} [thumbnail] - Default thumbnail URL or a function that returns a thumbnail URL per item.
30
34
  * @property {number} [expand=1] - Initial folder expand depth. `1` expands the first level, `Infinity` expands all. Default: `1`.
31
35
  * @property {Array<string>} [props] - Additional tag names
@@ -64,6 +68,8 @@ class FileBrowser extends PluginBrowser {
64
68
  data: pluginOptions.data,
65
69
  url: pluginOptions.url,
66
70
  headers: pluginOptions.headers,
71
+ searchUrl: pluginOptions.searchUrl,
72
+ searchHeaders: pluginOptions.searchHeaders,
67
73
  selectorHandler: this.#SetItem.bind(this),
68
74
  columnSize: 4,
69
75
  className: 'se-file-browser',
@@ -20,6 +20,10 @@ import { Browser } from '../../modules/contract';
20
20
  * }
21
21
  * ```
22
22
  * @property {Object<string, string>} [headers] - Server request headers
23
+ * @property {string} [searchUrl] - Server-side search URL. When set, the keyword is sent to this URL
24
+ * as `?keyword=<value>` and the server response replaces the list. When not set, search filters the
25
+ * already-loaded items locally.
26
+ * @property {Object<string, string>} [searchHeaders] - Server-side search request headers
23
27
  * @property {string|((item: SunEditor.Module.Browser.File) => string)} [thumbnail] - Default thumbnail
24
28
  */
25
29
 
@@ -52,6 +56,8 @@ class FileGallery extends PluginBrowser {
52
56
  data: pluginOptions.data,
53
57
  url: pluginOptions.url,
54
58
  headers: pluginOptions.headers,
59
+ searchUrl: pluginOptions.searchUrl,
60
+ searchHeaders: pluginOptions.searchHeaders,
55
61
  selectorHandler: this.#SetItem.bind(this),
56
62
  columnSize: 4,
57
63
  className: 'se-file-gallery',
@@ -20,6 +20,10 @@ import { Browser } from '../../modules/contract';
20
20
  * }
21
21
  * ```
22
22
  * @property {Object<string, string>} [headers] - Server request headers
23
+ * @property {string} [searchUrl] - Server-side search URL. When set, the keyword is sent to this URL
24
+ * as `?keyword=<value>` and the server response replaces the list. When not set, search filters the
25
+ * already-loaded items locally.
26
+ * @property {Object<string, string>} [searchHeaders] - Server-side search request headers
23
27
  */
24
28
 
25
29
  /**
@@ -50,6 +54,8 @@ class ImageGallery extends PluginBrowser {
50
54
  data: pluginOptions.data,
51
55
  url: pluginOptions.url,
52
56
  headers: pluginOptions.headers,
57
+ searchUrl: pluginOptions.searchUrl,
58
+ searchHeaders: pluginOptions.searchHeaders,
53
59
  selectorHandler: this.#SetItem.bind(this),
54
60
  columnSize: 4,
55
61
  className: 'se-image-gallery',
@@ -20,6 +20,10 @@ import { Browser } from '../../modules/contract';
20
20
  * }
21
21
  * ```
22
22
  * @property {Object<string, string>} [headers] - Server request headers
23
+ * @property {string} [searchUrl] - Server-side search URL. When set, the keyword is sent to this URL
24
+ * as `?keyword=<value>` and the server response replaces the list. When not set, search filters the
25
+ * already-loaded items locally.
26
+ * @property {Object<string, string>} [searchHeaders] - Server-side search request headers
23
27
  * @property {string|((item: SunEditor.Module.Browser.File) => string)} [thumbnail] - Default thumbnail
24
28
  */
25
29
 
@@ -52,6 +56,8 @@ class VideoGallery extends PluginBrowser {
52
56
  data: pluginOptions.data,
53
57
  url: pluginOptions.url,
54
58
  headers: pluginOptions.headers,
59
+ searchUrl: pluginOptions.searchUrl,
60
+ searchHeaders: pluginOptions.searchHeaders,
55
61
  selectorHandler: this.#SetItem.bind(this),
56
62
  columnSize: 4,
57
63
  className: 'se-video-gallery',
@@ -598,16 +598,25 @@ export class TableStyleService {
598
598
  this._propsCache = [];
599
599
 
600
600
  for (let i = 0, t, isBreak; (t = targets[i]); i++) {
601
- // eslint-disable-next-line no-shadow
602
- const { cssText, border, backgroundColor, color, textAlign, verticalAlign, fontWeight, textDecoration, fontStyle } = t.style;
603
- this._propsCache.push([t, cssText]);
601
+ const {
602
+ cssText: t_cssText,
603
+ border: t_border,
604
+ backgroundColor: t_backgroundColor,
605
+ color: t_color,
606
+ textAlign: t_textAlign,
607
+ verticalAlign: t_verticalAlign,
608
+ fontWeight: t_fontWeight,
609
+ textDecoration: t_textDecoration,
610
+ fontStyle: t_fontStyle,
611
+ } = t.style;
612
+ this._propsCache.push([t, t_cssText]);
604
613
  if (isBreak) continue;
605
614
 
606
- const { c, s, w } = this.#getBorderStyle(border);
615
+ const { c, s, w } = this.#getBorderStyle(t_border);
607
616
 
608
617
  // use getComputedStyle to normalize any CSS color format to rgb
609
- let hexBackColor = backgroundColor;
610
- let hexColor = color;
618
+ let hexBackColor = t_backgroundColor;
619
+ let hexColor = t_color;
611
620
  if (hexBackColor || hexColor) {
612
621
  const computed = _w.getComputedStyle(t);
613
622
  if (hexBackColor) hexBackColor = computed.backgroundColor;
@@ -619,12 +628,12 @@ export class TableStyleService {
619
628
  if (b_width && cellBorder.w !== w) b_width = '';
620
629
  if (backColor !== converter.rgb2hex(hexBackColor)) backColor = '';
621
630
  if (fontColor !== converter.rgb2hex(hexColor)) fontColor = '';
622
- if (align !== (isTable ? this.#state.figureElement?.style.float : textAlign)) align = '';
623
- if (align_v && align_v !== verticalAlign) align_v = '';
624
- if (bold && bold !== /.+/.test(fontWeight)) bold = false;
625
- if (underline && underline !== /underline/i.test(textDecoration)) underline = false;
626
- if (strike && strike !== /line-through/i.test(textDecoration)) strike = false;
627
- if (italic && italic !== /italic/i.test(fontStyle)) italic = false;
631
+ if (align !== (isTable ? this.#state.figureElement?.style.float : t_textAlign)) align = '';
632
+ if (align_v && align_v !== t_verticalAlign) align_v = '';
633
+ if (bold && bold !== /.+/.test(t_fontWeight)) bold = false;
634
+ if (underline && underline !== /underline/i.test(t_textDecoration)) underline = false;
635
+ if (strike && strike !== /line-through/i.test(t_textDecoration)) strike = false;
636
+ if (italic && italic !== /italic/i.test(t_fontStyle)) italic = false;
628
637
  if (!b_color || !b_style || !b_width || !backColor || !fontColor) {
629
638
  isBreak = true;
630
639
  }
@@ -39,6 +39,17 @@ const { _w, NO_EVENT } = env;
39
39
  * { query_vimeo: 'autoplay=1' }
40
40
  * ```
41
41
  * @property {Array<RegExp>} [urlPatterns] - Additional URL patterns to recognize as embeddable content.
42
+ * @property {Array<RegExp|string>} [scriptSrcWhitelist] - Allowed `<script src=...>` patterns for raw embed HTML
43
+ * - (e.g. Twitter blockquote + `widgets.js`). Each entry is a `RegExp` (tested against the full src) or a `string` (matched via `startsWith`).
44
+ * - Defaults to `[]` — all script tags are rejected.** Inline scripts (no `src`) are always rejected.
45
+ * ```js
46
+ * {
47
+ * scriptSrcWhitelist: [
48
+ * /^https:\/\/platform\.x\.com\/widgets\.js$/,
49
+ * /^https:\/\/www\.instagram\.com\/embed\.js$/,
50
+ * ]
51
+ * }
52
+ * ```
42
53
  * @property {Object<string, {pattern: RegExp, action: (url: string) => string, tag: string}>} [embedQuery] - Custom embed service definitions.
43
54
  * Each key is a service name, with `pattern` to match the URL, `action` to transform it into an embed URL, and `tag` for the output element.
44
55
  * ```js
@@ -125,6 +136,18 @@ class Embed extends PluginModal {
125
136
  return false;
126
137
  }
127
138
 
139
+ /**
140
+ * @description Validate a `<script src>` against the configured whitelist for raw embed HTML.
141
+ * Inline scripts (no `src`) are always rejected.
142
+ * @param {string} src
143
+ * @param {Array<RegExp|string>} whitelist
144
+ * @returns {boolean}
145
+ */
146
+ static #isAllowedScriptSrc(src, whitelist) {
147
+ if (!src) return false;
148
+ return whitelist.some((p) => (p instanceof RegExp ? p.test(src) : src.startsWith(p)));
149
+ }
150
+
128
151
  /** @type {Array<RegExp>} */
129
152
  static #urlPatterns = null;
130
153
 
@@ -168,6 +191,7 @@ class Embed extends PluginModal {
168
191
  iframeTagAttributes: pluginOptions.iframeTagAttributes || null,
169
192
  query_youtube: pluginOptions.query_youtube || '',
170
193
  query_vimeo: pluginOptions.query_vimeo || '',
194
+ scriptSrcWhitelist: Array.isArray(pluginOptions.scriptSrcWhitelist) ? pluginOptions.scriptSrcWhitelist : [],
171
195
  insertBehavior: pluginOptions.insertBehavior,
172
196
  };
173
197
 
@@ -446,6 +470,19 @@ class Embed extends PluginModal {
446
470
  if (/^<iframe\s|^<blockquote\s/i.test(src)) {
447
471
  const embedDOM = new DOMParser().parseFromString(src, 'text/html').body.children;
448
472
  if (embedDOM.length === 0) return false;
473
+
474
+ // Validate every iframe in the raw HTML against the URL whitelist.
475
+ for (let i = 0; i < embedDOM.length; i++) {
476
+ const node = /** @type {Element} */ (embedDOM[i]);
477
+ if (/^iframe$/i.test(node.nodeName) && !Embed.#checkContentType(node.getAttribute('src') || '')) return false;
478
+ const nested = node.querySelectorAll?.('iframe');
479
+ if (nested) {
480
+ for (let j = 0; j < nested.length; j++) {
481
+ if (!Embed.#checkContentType(nested[j].getAttribute('src') || '')) return false;
482
+ }
483
+ }
484
+ }
485
+
449
486
  embedInfo = { children: embedDOM, ...this.#getInfo(), process: null };
450
487
  } else {
451
488
  const processUrl = this.findProcessUrl(src);
@@ -604,9 +641,12 @@ class Embed extends PluginModal {
604
641
  container = figure.container;
605
642
 
606
643
  const childNodes = Array.from(children);
644
+ const scriptWhitelist = this.pluginOptions.scriptSrcWhitelist;
607
645
  for (const chd of childNodes) {
608
646
  if (/^script$/i.test(chd.nodeName)) {
609
- scriptTag = dom.utils.createElement('script', { src: /** @type {Element} */ (chd).getAttribute('src'), async: 'true' }, null);
647
+ const scriptSrc = /** @type {Element} */ (chd).getAttribute('src') || '';
648
+ if (!Embed.#isAllowedScriptSrc(scriptSrc, scriptWhitelist)) continue;
649
+ scriptTag = dom.utils.createElement('script', { src: scriptSrc, async: 'true' }, null);
610
650
  continue;
611
651
  }
612
652
  cover.appendChild(chd);