suneditor 3.0.2 → 3.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suneditor",
3
- "version": "3.0.2",
3
+ "version": "3.0.4",
4
4
  "description": "Vanilla JavaScript based WYSIWYG web editor",
5
5
  "author": "Yi JiHong",
6
6
  "license": "MIT",
@@ -916,13 +916,13 @@
916
916
  .sun-editor .se-toolbar.se-toolbar-bottom {
917
917
  top: auto;
918
918
  bottom: 0;
919
- outline-offset: -1px;
920
919
  }
921
920
 
922
921
  /* Bottom toolbar: more layer opens above buttons */
923
922
  .sun-editor .se-toolbar.se-toolbar-bottom .se-btn-tray {
924
923
  display: flex;
925
924
  flex-wrap: wrap;
925
+ padding: 4px 3px;
926
926
  }
927
927
 
928
928
  .sun-editor .se-toolbar.se-toolbar-bottom .se-btn-tray > .se-toolbar-more-layer {
@@ -934,6 +934,7 @@
934
934
  .sun-editor .se-toolbar.se-toolbar-bottom .se-toolbar-more-layer .se-more-layer {
935
935
  border-top: none;
936
936
  border-bottom: 1px solid var(--se-main-divider-color);
937
+ margin-bottom: 4px;
937
938
  }
938
939
 
939
940
  /* JS fallback sticky for bottom */
@@ -1065,11 +1066,11 @@
1065
1066
  }
1066
1067
 
1067
1068
  .sun-editor .se-btn-select.se-btn-tool-font {
1068
- width: 100px;
1069
+ width: 128px;
1069
1070
  }
1070
1071
 
1071
1072
  .sun-editor .se-btn-select.se-btn-tool-format {
1072
- width: 86px;
1073
+ width: 96px;
1073
1074
  }
1074
1075
 
1075
1076
  .sun-editor .se-btn-select.se-btn-tool-font-size .se-txt {
@@ -1879,7 +1880,27 @@
1879
1880
  background: transparent;
1880
1881
  }
1881
1882
 
1883
+ /** status bar - wordCounter */
1884
+ .sun-editor .se-status-bar .se-word-counter-wrapper {
1885
+ position: relative;
1886
+ width: auto;
1887
+ height: auto;
1888
+ margin: 0;
1889
+ padding: 0;
1890
+ color: var(--se-statusbar-font-color);
1891
+ font-size: var(--se-statusbar-font-size);
1892
+ background: transparent;
1893
+ }
1894
+
1895
+ .sun-editor .se-status-bar .se-word-counter-wrapper .se-word-label {
1896
+ margin-right: 4px;
1897
+ }
1898
+
1882
1899
  /** status bar - charCounter */
1900
+ .sun-editor .se-status-bar .se-char-counter-wrapper.se-with-word-counter {
1901
+ margin-left: 0.6em;
1902
+ }
1903
+
1883
1904
  .sun-editor .se-status-bar .se-char-counter-wrapper {
1884
1905
  flex: none;
1885
1906
  position: relative;
@@ -1889,7 +1910,7 @@
1889
1910
  margin: 0;
1890
1911
  padding: 0;
1891
1912
  color: var(--se-statusbar-font-color);
1892
- font-size: var(--se-main-font-size);
1913
+ font-size: var(--se-statusbar-font-size);
1893
1914
  background: transparent;
1894
1915
  }
1895
1916
 
@@ -3883,10 +3904,20 @@
3883
3904
  }
3884
3905
 
3885
3906
  /* statusbar */
3886
- .sun-editor.se-rtl .se-status-bar .se-navigation {
3907
+ .sun-editor.se-rtl .se-status-bar {
3887
3908
  direction: rtl;
3888
3909
  }
3889
3910
 
3911
+ .sun-editor.se-rtl .se-status-bar .se-word-counter-wrapper .se-word-label {
3912
+ margin-right: 0;
3913
+ margin-left: 4px;
3914
+ }
3915
+
3916
+ .sun-editor.se-rtl .se-status-bar .se-char-counter-wrapper.se-with-word-counter {
3917
+ margin-left: 0;
3918
+ margin-right: 0.6em;
3919
+ }
3920
+
3890
3921
  /* button--- */
3891
3922
  /* button - select text */
3892
3923
  .sun-editor.se-rtl .se-btn-select .se-txt {
@@ -331,7 +331,7 @@ export default class OptionProvider {
331
331
  }
332
332
 
333
333
  #GetResetDiffKey(key) {
334
- if (/^statusbar|^charCounter/.test(key)) return 'statusbar-changed';
334
+ if (/^statusbar|^charCounter|^wordCounter/.test(key)) return 'statusbar-changed';
335
335
  return key;
336
336
  }
337
337
 
@@ -204,6 +204,8 @@ class Editor {
204
204
 
205
205
  // char counter
206
206
  if (e.has('charCounter')) e.get('charCounter').textContent = String(this.$.char.getLength());
207
+ // word counter
208
+ if (e.has('wordCounter')) e.get('wordCounter').textContent = String(this.$.char.getWordCount());
207
209
 
208
210
  // document type init
209
211
  if (this.$.options.get('type') === 'document') {
@@ -138,6 +138,21 @@ class EventOrchestrator extends KernelInjector {
138
138
  }, 250);
139
139
  }
140
140
 
141
+ /**
142
+ * @description Toggle toolbar-balloon with delay (debounced for selectionchange).
143
+ */
144
+ #toggleToolbarBalloonDelay() {
145
+ if (this.#balloonDelay) {
146
+ _w.clearTimeout(this.#balloonDelay);
147
+ }
148
+
149
+ this.#balloonDelay = _w.setTimeout(() => {
150
+ _w.clearTimeout(this.#balloonDelay);
151
+ this.#balloonDelay = null;
152
+ this._toggleToolbarBalloon();
153
+ }, 250);
154
+ }
155
+
141
156
  /**
142
157
  * @internal
143
158
  * @description Show or hide the toolbar-balloon.
@@ -654,6 +669,9 @@ class EventOrchestrator extends KernelInjector {
654
669
  if (this.#store.mode.isInline || this.#store.mode.isBalloonAlways) this.#toolbar.show();
655
670
  if (this.#store.mode.isSubBalloonAlways) this.$.subToolbar.show();
656
671
 
672
+ // sticky
673
+ this.#toolbar._resetSticky();
674
+
657
675
  // user event
658
676
  this.#eventManager.triggerEvent('onFocus', { frameContext, event });
659
677
  // plugin event
@@ -909,17 +927,23 @@ class EventOrchestrator extends KernelInjector {
909
927
  }
910
928
 
911
929
  #OnResize_viewport() {
912
- if (isMobile && this.#options.get('toolbar_sticky') > -1) {
930
+ if (isMobile && this.#options.get('_toolbar_sticky') > -1) {
913
931
  this.#toolbar._resetSticky();
914
932
  this.#menu.__restoreMenuPosition();
915
933
  }
916
934
 
917
935
  this.#scrollContainer();
936
+
937
+ const prevHeight = this.#store.get('currentViewportHeight');
918
938
  this.__setViewportSize();
939
+
940
+ if (isMobile && prevHeight > 0 && prevHeight - _w.visualViewport.height > 100 && this.#store.get('hasFocus')) {
941
+ this.$.selection.scrollTo(this.$.selection.getRange(), { behavior: 'auto', block: 'nearest', inline: 'nearest' });
942
+ }
919
943
  }
920
944
 
921
945
  #OnScroll_window() {
922
- if (this.#options.get('toolbar_sticky') > -1) {
946
+ if (this.#options.get('_toolbar_sticky') > -1) {
923
947
  this.#toolbar._resetSticky();
924
948
  }
925
949
 
@@ -938,7 +962,7 @@ class EventOrchestrator extends KernelInjector {
938
962
  }
939
963
 
940
964
  #OnMobileScroll_viewport() {
941
- if (this.#options.get('toolbar_sticky') > -1) {
965
+ if (this.#options.get('_toolbar_sticky') > -1) {
942
966
  this.#toolbar._resetSticky();
943
967
  this.#menu.__restoreMenuPosition();
944
968
  }
@@ -959,6 +983,11 @@ class EventOrchestrator extends KernelInjector {
959
983
  this.$.selection.init();
960
984
  this.applyTagEffect();
961
985
 
986
+ // balloon toolbar - touch devices
987
+ if (isTouchDevice && (this.#store.mode.isBalloon || this.#store.mode.isSubBalloon)) {
988
+ this.#toggleToolbarBalloonDelay();
989
+ }
990
+
962
991
  // document type
963
992
  if (root.has('documentType_use_header')) {
964
993
  const el = dom.query.getParentElement(this.$.selection.selectionNode, this.$.format.isLine.bind(this.$.format));
@@ -158,14 +158,14 @@ export function makePorts(inst, { _styleNodes }) {
158
158
 
159
159
  // === enter event specific ===
160
160
  /**
161
- * @description Scrolls the editor view to the caret position after pressing `Enter`. (Ignored on mobile devices)
161
+ * @description Scrolls the editor view to the caret position after pressing `Enter`.
162
162
  * @param {Range} range Range object
163
163
  */
164
164
  enterScrollTo(range) {
165
165
  ui._iframeAutoHeight(frameContext);
166
166
 
167
167
  // scroll to
168
- if (isMobile && inst.scrollparents.length > 0) return;
168
+ // if (isMobile && inst.scrollparents.length > 0) return;
169
169
  selection.scrollTo(range, { behavior: 'auto', block: 'nearest', inline: 'nearest' });
170
170
  },
171
171
  /**
@@ -92,15 +92,38 @@ class Char {
92
92
  }
93
93
 
94
94
  /**
95
- * @description Set the char count to charCounter element textContent.
95
+ * @description Get the number of words in the content.
96
+ * - If [content] is `undefined`, get the current editor's word count.
97
+ * @param {string} [content] Content to count. (default: wysiwyg textContent)
98
+ * @returns {number}
99
+ * const currentWords = editor.$.char.getWordCount();
100
+ * const textWords = editor.$.char.getWordCount('Hello World');
101
+ */
102
+ getWordCount(content) {
103
+ if (typeof content !== 'string') {
104
+ content = this.#frameContext.get('wysiwyg').innerText;
105
+ }
106
+
107
+ const trimmed = content.trim();
108
+ if (!trimmed) return 0;
109
+
110
+ return trimmed.split(/\s+/).length;
111
+ }
112
+
113
+ /**
114
+ * @description Set the char count and word count to counter element textContent.
96
115
  * @param {?SunEditor.FrameContext} [fc] Frame context
97
116
  */
98
117
  display(fc) {
99
- const charCounter = (fc || this.#frameContext).get('charCounter');
100
- if (charCounter) {
118
+ const ctx = fc || this.#frameContext;
119
+ const charCounter = ctx.get('charCounter');
120
+ const wordCounter = ctx.get('wordCounter');
121
+
122
+ if (charCounter || wordCounter) {
101
123
  // Defer count update — DOM content may still be mutating from the current input/paste action
102
124
  _w.setTimeout(() => {
103
- charCounter.textContent = String(this.getLength());
125
+ if (charCounter) charCounter.textContent = String(this.getLength());
126
+ if (wordCounter) wordCounter.textContent = String(this.getWordCount());
104
127
  }, 0);
105
128
  }
106
129
  }
@@ -2134,6 +2134,14 @@ function _isSafeURL(url) {
2134
2134
  return _SAFE_URL_PROTOCOL.test(normalized) || !_RE_COLON.test(normalized);
2135
2135
  }
2136
2136
 
2137
+ /**
2138
+ * @description Checks whether an HTML attribute string is safe from XSS injection.
2139
+ * - Non-URL attributes (anything other than `href` or `src`) are always safe.
2140
+ * - For URL attributes, extracts the URL value and delegates to {@link _isSafeURL}
2141
+ * to verify the protocol against the allowed whitelist.
2142
+ * @param {string} attr A single attribute string (e.g. `href="https://example.com"`, `class="foo"`)
2143
+ * @returns {boolean} `true` if the attribute is safe, `false` if it contains a dangerous URL protocol.
2144
+ */
2137
2145
  function _isSafeAttribute(attr) {
2138
2146
  if (!_URL_ATTR_PATTERN.test(attr)) return true;
2139
2147
 
@@ -503,7 +503,7 @@ class Offset {
503
503
  const th = this.#context.get('toolbar_main').offsetHeight;
504
504
  const containerToolbar = this.#options.get('toolbar_container');
505
505
  const headLess = this.#store.mode.isBalloon || this.#store.mode.isInline || containerToolbar;
506
- const toolbarH = (containerToolbar && globalTop - wScrollY - th > 0) || (!this.#$.toolbar.isSticky && headLess) ? 0 : th + (this.#$.toolbar.isSticky ? this.#options.get('toolbar_sticky') : 0);
506
+ const toolbarH = (containerToolbar && globalTop - wScrollY - th > 0) || (!this.#$.toolbar.isSticky && headLess) ? 0 : th + (this.#$.toolbar.isSticky ? this.#options.get('_toolbar_sticky') : 0);
507
507
  const statusBarH = this.#frameContext.get('statusbar')?.offsetHeight || 0;
508
508
 
509
509
  // check margin
@@ -425,7 +425,7 @@ class Selection_ {
425
425
  const isBottom = this.#store.mode.isBottom;
426
426
  const realToolbarHeight = this.#context.get('toolbar_main').offsetHeight;
427
427
  const toolbarHeight = this.#$.toolbar.isSticky ? realToolbarHeight : 0;
428
- const positionToolbarHeight = this.#$.toolbar.isSticky ? toolbarHeight + this.#options.get('toolbar_sticky') : toolbarHeight;
428
+ const positionToolbarHeight = this.#$.toolbar.isSticky ? toolbarHeight + this.#options.get('_toolbar_sticky') : toolbarHeight;
429
429
  const statusbarHeight = this.#frameContext.get('statusbar')?.offsetHeight || 0;
430
430
 
431
431
  if (this.#hasScrollParents) {
@@ -154,7 +154,7 @@ class Finder {
154
154
  */
155
155
  #updateStickyTop() {
156
156
  if (!this.#isOpen || !this.#panel) return;
157
- const stickyTop = this.#$.options.get('toolbar_sticky');
157
+ const stickyTop = this.#$.options.get('_toolbar_sticky');
158
158
  if (this.#store.mode.isBottom) {
159
159
  this.#panel.style.top = 'auto';
160
160
  this.#panel.style.bottom = stickyTop >= 0 ? stickyTop + this.#$.context.get('toolbar_main').offsetHeight + 'px' : '0px';
@@ -28,6 +28,7 @@ class Toolbar {
28
28
 
29
29
  #useCSSSticky = false;
30
30
  #_isStickyFlag = false;
31
+ #_cssStickyShifted = false;
31
32
 
32
33
  /**
33
34
  * @constructor
@@ -88,7 +89,7 @@ class Toolbar {
88
89
 
89
90
  // CSS sticky: non-balloon, non-inline, non-container, sticky enabled
90
91
  const isStickyPosible = !this.isSub && !balloon && !inline;
91
- const stickyTop = this.#options.get('toolbar_sticky');
92
+ const stickyTop = this.#options.get('_toolbar_sticky');
92
93
 
93
94
  this.#useCSSSticky = isStickyPosible && stickyTop >= 0 && !this.#options.get('toolbar_container') && typeof CSS !== 'undefined' && CSS.supports('position', 'sticky');
94
95
 
@@ -119,14 +120,15 @@ class Toolbar {
119
120
  */
120
121
  get isSticky() {
121
122
  if (this.isSub) return false;
122
- const stickyTop = this.#options.get('toolbar_sticky');
123
+ const stickyTop = this.#options.get('_toolbar_sticky');
123
124
  if (stickyTop < 0) return false;
124
125
 
125
126
  if (this.#useCSSSticky) {
126
127
  const toolbar = this.#context.get(this.keyName.main);
127
128
  if (!toolbar || toolbar.offsetWidth === 0 || toolbar.style.display === 'none') return false;
128
129
  if (this.isBottomMode) {
129
- return toolbar.getBoundingClientRect().bottom >= _w.innerHeight - stickyTop - 1;
130
+ const viewportHeight = this.#isViewPortSize ? _w.visualViewport.height : _w.innerHeight;
131
+ return toolbar.getBoundingClientRect().bottom >= viewportHeight - stickyTop - 1;
130
132
  }
131
133
  return toolbar.getBoundingClientRect().top <= stickyTop + 1;
132
134
  }
@@ -254,13 +256,16 @@ class Toolbar {
254
256
  * @description Reset the sticky toolbar position based on the editor state.
255
257
  */
256
258
  _resetSticky() {
257
- if (this.#useCSSSticky) return;
259
+ if (this.#useCSSSticky) {
260
+ this.#resetCSSStickyOffset();
261
+ return;
262
+ }
258
263
 
259
264
  const wrapper = this.#frameContext.get('wrapper');
260
265
  if (!wrapper) return;
261
266
 
262
267
  const toolbar = this.#context.get(this.keyName.main);
263
- const stickyTop = this.#options.get('toolbar_sticky');
268
+ const stickyTop = this.#options.get('_toolbar_sticky');
264
269
  if (this.#frameContext.get('isFullScreen') || toolbar.offsetWidth === 0 || stickyTop < 0) return;
265
270
 
266
271
  const currentScrollY = this.#isViewPortSize ? _w.visualViewport.pageTop : _w.scrollY;
@@ -485,11 +490,11 @@ class Toolbar {
485
490
  }
486
491
 
487
492
  if (this.isBottomMode) {
488
- const toolbarBottomPosition = this.#options.get('toolbar_sticky') + this.#getViewportTop();
493
+ const toolbarBottomPosition = this.#options.get('_toolbar_sticky') + this.#getViewportTop();
489
494
  toolbar.style.bottom = `${toolbarBottomPosition}px`;
490
495
  toolbar.style.top = 'auto';
491
496
  } else {
492
- const toolbarTopPosition = this.#options.get('toolbar_sticky') + inlineOffset + this.#getViewportTop();
497
+ const toolbarTopPosition = this.#options.get('_toolbar_sticky') + inlineOffset + this.#getViewportTop();
493
498
  toolbar.style.top = `${toolbarTopPosition}px`;
494
499
  }
495
500
  toolbar.style.width = this.isInlineMode ? this.inlineToolbarAttr.width : toolbar.offsetWidth + 'px';
@@ -508,6 +513,43 @@ class Toolbar {
508
513
  return 0;
509
514
  }
510
515
 
516
+ /**
517
+ * @description Adjust CSS sticky toolbar position when the mobile virtual keyboard changes the visual viewport.
518
+ */
519
+ #resetCSSStickyOffset() {
520
+ if (!this.#isViewPortSize) return;
521
+ // When the editor is inside a scrollable container (e.g., modal),
522
+ // position:sticky is relative to that container, not the viewport.
523
+ if (this.#kernel._eventOrchestrator.scrollparents.length > 0) return;
524
+
525
+ const viewportOffset = Math.round(_w.visualViewport.offsetTop);
526
+ if (viewportOffset === 0 && !this.#_cssStickyShifted) return;
527
+
528
+ const toolbar = this.#context.get(this.keyName.main);
529
+ const stickyOffset = this.#options.get('_toolbar_sticky_offset');
530
+
531
+ if (viewportOffset > 0) {
532
+ this.#_cssStickyShifted = true;
533
+ if (this.isBottomMode) {
534
+ const viewportBottom = Math.round(_w.innerHeight - _w.visualViewport.height - viewportOffset);
535
+ toolbar.style.bottom = stickyOffset + viewportBottom + 'px';
536
+ toolbar.style.top = 'auto';
537
+ } else {
538
+ toolbar.style.top = stickyOffset + viewportOffset + 'px';
539
+ }
540
+ } else {
541
+ // restore original CSS sticky value
542
+ this.#_cssStickyShifted = false;
543
+ const stickyTop = this.#options.get('_toolbar_sticky');
544
+ if (this.isBottomMode) {
545
+ toolbar.style.bottom = stickyTop + 'px';
546
+ toolbar.style.top = 'auto';
547
+ } else {
548
+ toolbar.style.top = stickyTop > 0 ? stickyTop + 'px' : '';
549
+ }
550
+ }
551
+ }
552
+
511
553
  /**
512
554
  * @description Disable `sticky` toolbar mode.
513
555
  */
@@ -411,7 +411,7 @@ class Viewer {
411
411
  this.#toolbarParent = null;
412
412
  }
413
413
 
414
- if (this.#options.get('toolbar_sticky') > -1) {
414
+ if (this.#options.get('_toolbar_sticky') > -1) {
415
415
  dom.utils.removeClass(toolbar, 'se-toolbar-sticky');
416
416
  }
417
417
 
@@ -38,6 +38,8 @@ import { get as getNumber } from '../../helper/numbers';
38
38
  * @property {HTMLElement} navigation - Navigation element (e.g., for outline or bookmarks).
39
39
  * @property {HTMLElement} charWrapper - Wrapper for the character counter element.
40
40
  * @property {HTMLElement} charCounter - Element showing the character counter.
41
+ * @property {HTMLElement} wordWrapper - Wrapper for the word counter element.
42
+ * @property {HTMLElement} wordCounter - Element showing the word counter.
41
43
  * @property {Window} [_ww] - The window object of the WYSIWYG frame (iframe window).
42
44
  * @property {Document} [_wd] - The document object of the WYSIWYG frame (iframe document).
43
45
  *
@@ -171,4 +173,8 @@ export function UpdateStatusbarContext(statusbar, mapper) {
171
173
  navigation ? mapper.set('navigation', navigation) : mapper.delete('navigation');
172
174
  charWrapper ? mapper.set('charWrapper', charWrapper) : mapper.delete('charWrapper');
173
175
  charCounter ? mapper.set('charCounter', charCounter) : mapper.delete('charCounter');
176
+ const wordWrapper = statusbar ? statusbar.querySelector('.se-word-counter-wrapper') : null;
177
+ const wordCounter = statusbar ? statusbar.querySelector('.se-word-counter-wrapper .se-word-counter') : null;
178
+ wordWrapper ? mapper.set('wordWrapper', wordWrapper) : mapper.delete('wordWrapper');
179
+ wordCounter ? mapper.set('wordCounter', wordCounter) : mapper.delete('wordCounter');
174
180
  }
@@ -150,6 +150,13 @@ export const DEFAULTS = {
150
150
  * - `char`: Characters length.
151
151
  * - `byte`: Binary data size of characters.
152
152
  * - `byte-html`: Binary data size of the full HTML string.
153
+ *
154
+ * === Word Counter ===
155
+ * @property {boolean} [wordCounter=false] - Shows the number of words in the editor.
156
+ * @property {?string} [wordCounter_label=null] - Text to be displayed in the `wordCounter` area of the bottom bar.
157
+ * ```js
158
+ * { wordCounter_label: 'Words :' }
159
+ * ```
153
160
  */
154
161
 
155
162
  /** ================================================================================================================================ */
@@ -460,7 +467,22 @@ export const DEFAULTS = {
460
467
  * @property {boolean} [tabDisable=false] - Disables tab key input.
461
468
  * @property {string} [toolbar_width="auto"] - Toolbar width.
462
469
  * @property {?HTMLElement} [toolbar_container] - Container element for the toolbar.
463
- * @property {number} [toolbar_sticky=0] - Enables sticky toolbar with optional offset.
470
+ * @property {number|{top: number, offset: number}} [toolbar_sticky=0] - Enables sticky toolbar.
471
+ * - `number`: Sets the sticky top position (px). Use `-1` to disable sticky.
472
+ * - `{top, offset}`: `top` is the sticky position when the page header is visible.
473
+ * - `offset` is the sticky position when a virtual keyboard shifts the viewport (e.g., on tablets, touch devices).
474
+ * - When the virtual keyboard is active, `offset` replaces `top` so the toolbar doesn't leave a gap
475
+ * - for a page header that has scrolled out of view. Default `offset` is `0`.
476
+ * ```js
477
+ * // Basic usage — sticky at top with 0px offset
478
+ * toolbar_sticky: 0
479
+ *
480
+ * // Account for a 92px fixed/sticky site header
481
+ * toolbar_sticky: 92
482
+ *
483
+ * // 92px header on desktop, but 0px when virtual keyboard pushes the viewport
484
+ * toolbar_sticky: { top: 92, offset: 0 }
485
+ * ```
464
486
  * @property {boolean} [toolbar_hide=false] - Hides toolbar initially.
465
487
  * @property {Object} [subToolbar={}] - Sub-toolbar configuration. A secondary toolbar that appears on text selection.
466
488
  * @property {SunEditor.UI.ButtonList} [subToolbar.buttonList] - List of Sub-toolbar buttons, grouped by sub-arrays.
@@ -666,6 +688,8 @@ export const OPTION_FRAME_FIXED_FLAG = {
666
688
  charCounter_max: true,
667
689
  charCounter_label: true,
668
690
  charCounter_type: true,
691
+ wordCounter: true,
692
+ wordCounter_label: true,
669
693
  };
670
694
 
671
695
  /**
@@ -754,7 +778,7 @@ export const OPTION_FIXED_FLAG = {
754
778
  };
755
779
 
756
780
  /**
757
- * @typedef {'formatClosureBrLine' | 'formatBrLine' | 'formatLine' | 'formatClosureBlock' | 'formatBlock' | 'toolbar_width' | 'toolbar_container' | 'toolbar_sticky' | 'strictMode' | 'lineAttrReset'} TransformedOptionKeys
781
+ * @typedef {'formatClosureBrLine' | 'formatBrLine' | 'formatLine' | 'formatClosureBlock' | 'formatBlock' | 'toolbar_width' | 'toolbar_container' | '_toolbar_sticky' | '_toolbar_sticky_offset' | 'strictMode' | 'lineAttrReset'} TransformedOptionKeys
758
782
  */
759
783
 
760
784
  /**
@@ -776,7 +800,8 @@ export const OPTION_FIXED_FLAG = {
776
800
  * @property {{ reg: RegExp, str: string }} formatBlock
777
801
  * @property {string} toolbar_width
778
802
  * @property {HTMLElement|null} toolbar_container
779
- * @property {number} toolbar_sticky
803
+ * @property {number} _toolbar_sticky
804
+ * @property {number} _toolbar_sticky_offset
780
805
  * @property {StrictModeOptions} strictMode
781
806
  * @property {string[]} lineAttrReset
782
807
  */
@@ -616,7 +616,20 @@ export function InitOptions(options, editorTargets, plugins) {
616
616
  /** Toolbar */
617
617
  o.set('toolbar_width', options.toolbar_width ? (numbers.is(options.toolbar_width) ? options.toolbar_width + 'px' : options.toolbar_width) : 'auto');
618
618
  o.set('toolbar_container', options.toolbar_container && !/inline/i.test(o.get('mode')) ? (typeof options.toolbar_container === 'string' ? _d.querySelector(options.toolbar_container) : options.toolbar_container) : null);
619
- o.set('toolbar_sticky', /balloon/i.test(o.get('mode')) ? -1 : options.toolbar_sticky === undefined ? 0 : numbers.is(options.toolbar_sticky) ? numbers.get(options.toolbar_sticky, 0) : -1);
619
+
620
+ const _stickyOpt = options.toolbar_sticky;
621
+ const _isBalloon = /balloon/i.test(o.get('mode'));
622
+ if (_isBalloon) {
623
+ o.set('_toolbar_sticky', -1);
624
+ o.set('_toolbar_sticky_offset', 0);
625
+ } else if (_stickyOpt !== null && typeof _stickyOpt === 'object') {
626
+ o.set('_toolbar_sticky', numbers.get(_stickyOpt.top, 0));
627
+ o.set('_toolbar_sticky_offset', numbers.get(_stickyOpt.offset, 0));
628
+ } else {
629
+ o.set('_toolbar_sticky', _stickyOpt === undefined ? 0 : numbers.is(_stickyOpt) ? _stickyOpt : -1);
630
+ o.set('_toolbar_sticky_offset', 0);
631
+ }
632
+
620
633
  o.set('toolbar_hide', !!options.toolbar_hide);
621
634
 
622
635
  /** subToolbar */
@@ -759,12 +772,14 @@ export function InitOptions(options, editorTargets, plugins) {
759
772
  * @description Create a context object for the editor frame.
760
773
  * @param {SunEditor.FrameOptions} targetOptions - `editor.frameOptions`
761
774
  * @param {HTMLElement} statusbar - statusbar element
762
- * @returns {{statusbar: HTMLElement, navigation: HTMLElement, charWrapper: HTMLElement, charCounter: HTMLElement}}
775
+ * @returns {{statusbar: HTMLElement, navigation: HTMLElement, charWrapper: HTMLElement, charCounter: HTMLElement, wordWrapper: HTMLElement, wordCounter: HTMLElement}}
763
776
  */
764
777
  export function CreateStatusbar(targetOptions, statusbar) {
765
778
  let navigation = null;
766
779
  let charWrapper = null;
767
780
  let charCounter = null;
781
+ let wordWrapper = null;
782
+ let wordCounter = null;
768
783
 
769
784
  if (targetOptions.get('statusbar')) {
770
785
  statusbar ||= dom.utils.createElement('DIV', { class: 'se-status-bar sun-editor-common' });
@@ -773,10 +788,31 @@ export function CreateStatusbar(targetOptions, statusbar) {
773
788
  navigation = statusbar.querySelector('.se-navigation') || dom.utils.createElement('DIV', { class: 'se-navigation sun-editor-common' });
774
789
  statusbar.appendChild(navigation);
775
790
 
776
- /** char counter */
791
+ /** word counter (left) */
792
+ if (targetOptions.get('wordCounter')) {
793
+ wordWrapper = statusbar.querySelector('.se-word-counter-wrapper') || dom.utils.createElement('DIV', { class: 'se-word-counter-wrapper' });
794
+
795
+ if (targetOptions.get('wordCounter_label')) {
796
+ const wordLabel = wordWrapper.querySelector('.se-word-label') || dom.utils.createElement('SPAN', { class: 'se-word-label' });
797
+ wordLabel.textContent = targetOptions.get('wordCounter_label');
798
+ wordWrapper.appendChild(wordLabel);
799
+ }
800
+
801
+ wordCounter = wordWrapper.querySelector('.se-word-counter') || dom.utils.createElement('SPAN', { class: 'se-word-counter' });
802
+ wordCounter.textContent = '0';
803
+ wordWrapper.appendChild(wordCounter);
804
+
805
+ statusbar.appendChild(wordWrapper);
806
+ }
807
+
808
+ /** char counter (right) */
777
809
  if (targetOptions.get('charCounter')) {
778
810
  charWrapper = statusbar.querySelector('.se-char-counter-wrapper') || dom.utils.createElement('DIV', { class: 'se-char-counter-wrapper' });
779
811
 
812
+ if (targetOptions.get('wordCounter') && charWrapper.className.indexOf('se-with-word-counter') === -1) {
813
+ charWrapper.className += ' se-with-word-counter';
814
+ }
815
+
780
816
  if (targetOptions.get('charCounter_label')) {
781
817
  const charLabel = charWrapper.querySelector('.se-char-label') || dom.utils.createElement('SPAN', { class: 'se-char-label' });
782
818
  charLabel.textContent = targetOptions.get('charCounter_label');
@@ -802,6 +838,8 @@ export function CreateStatusbar(targetOptions, statusbar) {
802
838
  navigation: /** @type {HTMLElement} */ (navigation),
803
839
  charWrapper: /** @type {HTMLElement} */ (charWrapper),
804
840
  charCounter: /** @type {HTMLElement} */ (charCounter),
841
+ wordWrapper: /** @type {HTMLElement} */ (wordWrapper),
842
+ wordCounter: /** @type {HTMLElement} */ (wordCounter),
805
843
  };
806
844
  }
807
845
 
@@ -839,6 +877,8 @@ function InitFrameOptions(o, origin) {
839
877
  const charCounter_max = barContainer || o.charCounter_max === undefined ? origin.charCounter_max : o.charCounter_max;
840
878
  const charCounter_label = barContainer || o.charCounter_label === undefined ? origin.charCounter_label : o.charCounter_label;
841
879
  const charCounter_type = barContainer || o.charCounter_type === undefined ? origin.charCounter_type : o.charCounter_type;
880
+ const wordCounter = barContainer || o.wordCounter === undefined ? origin.wordCounter : o.wordCounter;
881
+ const wordCounter_label = barContainer || o.wordCounter_label === undefined ? origin.wordCounter_label : o.wordCounter_label;
842
882
 
843
883
  // value
844
884
  fo.set('value', value);
@@ -868,6 +908,9 @@ function InitFrameOptions(o, origin) {
868
908
  fo.set('charCounter_max', numbers.is(charCounter_max) && charCounter_max > -1 ? charCounter_max * 1 : null);
869
909
  fo.set('charCounter_label', typeof charCounter_label === 'string' ? charCounter_label.trim() : null);
870
910
  fo.set('charCounter_type', typeof charCounter_type === 'string' ? charCounter_type : 'char');
911
+ // status bar - word count
912
+ fo.set('wordCounter', typeof wordCounter === 'boolean' ? wordCounter : false);
913
+ fo.set('wordCounter_label', typeof wordCounter_label === 'string' ? wordCounter_label.trim() : null);
871
914
 
872
915
  return fo;
873
916
  }
@@ -180,6 +180,7 @@ class ApiManager {
180
180
  * @returns
181
181
  */
182
182
  #normalizeUrl(url) {
183
+ if (!url) return '';
183
184
  return url.replace(/([^:])\/+/g, '$1/').replace(/\/(\?|#|$)/, '$1');
184
185
  }
185
186
 
@@ -166,7 +166,7 @@ export function makePorts(
166
166
  formatAttrsTempCache: (attrs: any) => any;
167
167
  setOnShortcutKey: (v: any) => any;
168
168
  /**
169
- * @description Scrolls the editor view to the caret position after pressing `Enter`. (Ignored on mobile devices)
169
+ * @description Scrolls the editor view to the caret position after pressing `Enter`.
170
170
  * @param {Range} range Range object
171
171
  */
172
172
  enterScrollTo(range: Range): void;
@@ -36,7 +36,16 @@ declare class Char {
36
36
  */
37
37
  getByteLength(text: string): number;
38
38
  /**
39
- * @description Set the char count to charCounter element textContent.
39
+ * @description Get the number of words in the content.
40
+ * - If [content] is `undefined`, get the current editor's word count.
41
+ * @param {string} [content] Content to count. (default: wysiwyg textContent)
42
+ * @returns {number}
43
+ * const currentWords = editor.$.char.getWordCount();
44
+ * const textWords = editor.$.char.getWordCount('Hello World');
45
+ */
46
+ getWordCount(content?: string): number;
47
+ /**
48
+ * @description Set the char count and word count to counter element textContent.
40
49
  * @param {?SunEditor.FrameContext} [fc] Frame context
41
50
  */
42
51
  display(fc?: SunEditor.FrameContext | null): void;