leksy-editor 2.1.4 → 2.2.1

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
@@ -108,6 +108,7 @@ const app = createApp({
108
108
  | `disabled` | To disabled editor |
109
109
  | `onFullScreen` | To open custom preview |
110
110
  | `showTabs` | Enables or disables the display of tabs. |
111
+ | `enableFindAndReplace` | To enabled find and replace feature|
111
112
 
112
113
  ### CSS Customization
113
114
 
package/constant.js CHANGED
@@ -41,6 +41,7 @@ const CLASSES = {
41
41
  RESIZE_ITEMS: '-resize-items',
42
42
  RESIZE_ITEM: '-resize-item',
43
43
  MODAL: '-modal',
44
+ DRAGGABLE_MODAL: '-draggable-modal',
44
45
  MODAL_CONTENT: '-modal-content',
45
46
  MODAL_HEADER: '-modal-header',
46
47
  MODAL_BODY: '-modal-body',
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import './style.css'
2
2
  import { CLASSES, CSS, CSS_VARIABLES, ERRORS, REGEX, SVG } from "./constant"
3
3
  import PLUGINS, { applyTextFormat } from './plugin';
4
- import { showAnchorPopover, changeAllToolbarState, changeToolbarStateByName, changeToolbarValueByName, cleanHTML, debounce, destroyImageResizer, destroyTableEditPlugin, initImageResizer, initTableEditPlugin, makeToolbarButton, makeToolbarColor, makeToolbarDropdown, makeToolbarSelect, rgbToHex, updateTableResizerPosition, destroyAnchorPopover, changeToolbarHtmlByName, showRemoteCursor, syncRemoteChangesDebounce, applyRemoteChanges, updateCursorPositionDebounce, buildTributeValues, updateHeight, getTableGrid, getCellPosition, makeUnorderedList, makeOrderedList, makeHeading, makeBlockQuote, makeStrikethrough, makeCodeBlock, makeSublist, showTooltip, makeToolbarButtonSelect, navigateToHeading, updateOutlineItems, highlightActiveOutline } from './utilities';
4
+ import { showAnchorPopover, changeAllToolbarState, changeToolbarStateByName, changeToolbarValueByName, cleanHTML, debounce, destroyImageResizer, destroyTableEditPlugin, initImageResizer, initTableEditPlugin, makeToolbarButton, makeToolbarColor, makeToolbarDropdown, makeToolbarSelect, rgbToHex, updateTableResizerPosition, destroyAnchorPopover, changeToolbarHtmlByName, showRemoteCursor, syncRemoteChangesDebounce, applyRemoteChanges, updateCursorPositionDebounce, buildTributeValues, updateHeight, getTableGrid, getCellPosition, makeUnorderedList, makeOrderedList, makeHeading, makeBlockQuote, makeStrikethrough, makeCodeBlock, makeSublist, showTooltip, makeToolbarButtonSelect, navigateToHeading, updateOutlineItems, highlightActiveOutline, findAndReplace, handleBackspaceInList } from './utilities';
5
5
  import { initTabs, findTab, renderTabs, prepareTabs } from './tab';
6
6
  import { v4 as uuidv4 } from 'uuid';
7
7
 
@@ -32,6 +32,7 @@ class LeksyEditor {
32
32
  * @property {Boolean} disabled
33
33
  * @property {Function} onFullScreen
34
34
  * @property {Boolean} showTabs
35
+ * @property {Boolean} enableFindAndReplace
35
36
  */
36
37
  /**
37
38
  *
@@ -177,6 +178,9 @@ class LeksyEditor {
177
178
  syncRemoteChangesDebounce(core)
178
179
  updateOutlineItems(core, options)
179
180
  },
181
+ onSave:() => {
182
+ if (editorRef.onSave instanceof Function) editorRef.onSave()
183
+ },
180
184
  onBlur: (html) => {
181
185
  if (editorRef.onBlur instanceof Function) editorRef.onBlur(html)
182
186
  },
@@ -292,6 +296,7 @@ class LeksyEditor {
292
296
  },
293
297
  getContents: () => core.state.tabs,
294
298
  onChange: () => { },
299
+ onSave: () => { },
295
300
  onBlur: () => { },
296
301
  onAttachment: () => { },
297
302
  manuplateImage: async () => { },
@@ -657,7 +662,7 @@ class LeksyEditor {
657
662
  const isCtrlOrCmd = event.ctrlKey || event.metaKey;
658
663
 
659
664
  // Check for Ctrl + Z (undo) or Ctrl + Y (redo)
660
- if (isCtrlOrCmd && ['z', 'y', 'b', 'i', 'u'].includes(event.key)) {
665
+ if (isCtrlOrCmd && ['z', 'y', 'b', 'i', 'u', 'f', 'h', 's'].includes(event.key)) {
661
666
  return event.preventDefault(); // Prevent the default undo/redo action
662
667
  }
663
668
  }
@@ -695,6 +700,10 @@ class LeksyEditor {
695
700
  applyTextFormat(core, 'underline')
696
701
  } else if (event.key === 'i') {
697
702
  applyTextFormat(core, 'italic')
703
+ } else if (event.key === 'f' || event.key === 'h'){
704
+ findAndReplace(core, options, event)
705
+ } else if (event.key === 's'){
706
+ core.onSave();
698
707
  }
699
708
  }
700
709
 
@@ -846,6 +855,8 @@ class LeksyEditor {
846
855
  range.setStart(textNode, textNode.length);
847
856
  range.collapse(true);
848
857
  }
858
+
859
+ handleBackspaceInList(event, core);
849
860
 
850
861
  if (!isLinkCreated) core.elements.lastCreatedLink = null
851
862
 
@@ -996,8 +1007,7 @@ const convertHtmlToText = (html) => {
996
1007
  const isHTMLEmpty = (html) => {
997
1008
  const tempElement = document.createElement('div');
998
1009
  tempElement.innerHTML = html
999
-
1000
- return !tempElement.innerText.trim() && !tempElement.querySelector('img');
1010
+ return !tempElement.innerText.trim() && !tempElement.querySelector('img , ol, ul, blockquote, h1, h2, h3');
1001
1011
  }
1002
1012
 
1003
1013
  export default LeksyEditor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leksy-editor",
3
- "version": "2.1.4",
3
+ "version": "2.2.1",
4
4
  "description": "Leksy Editor is an alternative to traditional WYSIWYG editors, designed primarily for creating mail templates, blogs, and documents without any content manipulation.",
5
5
  "main": "index.js",
6
6
  "directories": {
package/style.css CHANGED
@@ -472,6 +472,16 @@ button.leksy-editor-popover-tab.active {
472
472
  background-color: rgba(0, 0, 0, 0.5);
473
473
  }
474
474
 
475
+ .leksy-editor-modal.leksy-editor-draggable-modal {
476
+ position: fixed;
477
+ top: 50%;
478
+ left: 50%;
479
+ transform: translate(-50%, -50%);
480
+ background: inherit;
481
+ width: fit-content;
482
+ height: inherit;
483
+ }
484
+
475
485
  .leksy-editor-modal-content {
476
486
  margin: 10% auto;
477
487
  border: 1px solid #898f9f;
@@ -479,8 +489,13 @@ button.leksy-editor-popover-tab.active {
479
489
  border-radius: 12px;
480
490
  }
481
491
 
492
+ .leksy-editor-modal.leksy-editor-draggable-modal .leksy-editor-modal-content {
493
+ margin: unset;
494
+ width: fit-content;
495
+ }
496
+
482
497
  .leksy-editor-modal-header {
483
- padding: 1.5rem;
498
+ padding: 1.25rem;
484
499
  display: flex;
485
500
  align-items: center;
486
501
  justify-content: space-between;
@@ -502,7 +517,7 @@ button.leksy-editor-popover-tab.active {
502
517
 
503
518
  .leksy-editor-modal-footer {
504
519
  background-color: #f2f2f3;
505
- padding: 1.5rem;
520
+ padding: 1.25rem;
506
521
  display: flex;
507
522
  justify-content: flex-end;
508
523
  gap: 12px;
@@ -622,34 +637,64 @@ button.leksy-editor-popover-tab.active {
622
637
  }
623
638
 
624
639
  .leksy-editor-upload-img-box {
625
- height: 180px;
626
- border: 2px dashed #d1d5db;
627
- border-radius: 12px;
628
- background: #f9fafb;
629
- display: flex;
630
- align-items: center;
631
- justify-content: center;
632
- cursor: pointer;
633
- transition: border-color 0.2s ease;
640
+ height: 180px;
641
+ border: 2px dashed #d1d5db;
642
+ border-radius: 12px;
643
+ background: #f9fafb;
644
+ display: flex;
645
+ align-items: center;
646
+ justify-content: center;
647
+ cursor: pointer;
648
+ transition: border-color 0.2s ease;
634
649
  }
635
650
 
636
651
  .leksy-editor-upload-img-box:hover {
637
- border-color: gray;
652
+ border-color: gray;
638
653
  }
639
654
 
640
655
  .leksy-editor-upload-img-preview-box {
641
- height: 180px;
642
- border: 2px solid grey;
643
- margin-bottom: 8px;
644
- border-radius: 8px;
656
+ height: 180px;
657
+ border: 2px solid grey;
658
+ margin-bottom: 8px;
659
+ border-radius: 8px;
645
660
  }
646
661
 
647
662
  .leksy-editor-upload-img-preview {
648
- width: 100%;
649
- height: 100%;
650
- border-radius: 8px;
651
- object-fit: cover;
652
- object-position: top;
663
+ width: 100%;
664
+ height: 100%;
665
+ border-radius: 8px;
666
+ object-fit: cover;
667
+ object-position: top;
668
+ }
669
+
670
+ .leksy-editor-find-and-replace-modal-tab-btn {
671
+ background: transparent;
672
+ border: none;
673
+ cursor: pointer;
674
+ font-size: 16px;
675
+ padding: 0;
676
+ color: var(--primary);
677
+ }
678
+
679
+ .leksy-editor-find-and-replace-modal-tab-btn.active {
680
+ font-weight: bold;
681
+ border-bottom: 2px solid black;
682
+ }
683
+
684
+ .leksy-editor-find-and-replace-modal-label {
685
+ padding-left: 4px;
686
+ margin-bottom: 10px;
687
+ }
688
+
689
+ .leksy-editor-find-and-replace-modal-count {
690
+ position: absolute;
691
+ right: 8px;
692
+ top: 50%;
693
+ transform: translateY(-50%);
694
+ font-size: 12px;
695
+ color: #585555;
696
+ pointer-events: none;
697
+ user-select: none;
653
698
  }
654
699
 
655
700
  @keyframes leksy-editor-rotate {
package/utilities.js CHANGED
@@ -1122,6 +1122,111 @@ const openModal = ({ title, bodyNode, footerNode }, core, options) => {
1122
1122
  }
1123
1123
  }
1124
1124
 
1125
+ const openDraggableModal = ({ title, bodyNode, footerNode, onClose }, core, options) => {
1126
+ const modalContainer = document.createElement('div');
1127
+ modalContainer.className = `${options.classPrefix}${CLASSES.MODAL} ${options.classPrefix}${CLASSES.DRAGGABLE_MODAL} `
1128
+
1129
+ const modalContent = document.createElement('div');
1130
+ modalContent.className = `${options.classPrefix}${CLASSES.MODAL_CONTENT} `
1131
+
1132
+ const modalHeader = document.createElement('div');
1133
+ modalHeader.className = `${options.classPrefix}${CLASSES.MODAL_HEADER} `
1134
+ if (title instanceof HTMLElement) {
1135
+ modalHeader.appendChild(title);
1136
+ } else {
1137
+ const modalTitle = document.createElement('span');
1138
+ modalTitle.innerText = title ?? ''
1139
+ modalHeader.appendChild(modalTitle);
1140
+ }
1141
+ const modalCloseButton = document.createElement('button');
1142
+ modalCloseButton.className = 'modal-close-btn';
1143
+ modalCloseButton.innerHTML = SVG.CLOSE;
1144
+ modalCloseButton.type = 'button'
1145
+ modalCloseButton.onclick = () => {
1146
+ modalContainer.remove();
1147
+ if (typeof onClose === 'function') onClose()
1148
+ }
1149
+ modalHeader.appendChild(modalCloseButton)
1150
+
1151
+ let isDragging = false;
1152
+ let currentX;
1153
+ let currentY;
1154
+ let initialX;
1155
+ let initialY;
1156
+ let xOffset = 0;
1157
+ let yOffset = 0;
1158
+
1159
+ const dragStart = (e) => {
1160
+ if (e.target.closest('button')) return;
1161
+
1162
+ if (e.type === "touchstart") {
1163
+ initialX = e.touches[0].clientX - xOffset;
1164
+ initialY = e.touches[0].clientY - yOffset;
1165
+ } else {
1166
+ initialX = e.clientX - xOffset;
1167
+ initialY = e.clientY - yOffset;
1168
+ }
1169
+
1170
+ isDragging = true;
1171
+
1172
+ document.addEventListener('mouseup', dragEnd);
1173
+ document.addEventListener('touchend', dragEnd);
1174
+ core.elements.iframeWindow.addEventListener('mouseup', dragEnd);
1175
+ core.elements.iframeWindow.addEventListener('touchend', dragEnd);
1176
+ document.addEventListener('mousemove', drag);
1177
+ document.addEventListener('touchmove', drag);
1178
+ };
1179
+
1180
+ const dragEnd = () => {
1181
+ isDragging = false;
1182
+ document.removeEventListener('mouseup', dragEnd, false);
1183
+ document.removeEventListener('touchend', dragEnd, false);
1184
+ core.elements.iframeWindow.removeEventListener('mouseup', dragEnd, false);
1185
+ core.elements.iframeWindow.removeEventListener('touchend', dragEnd, false);
1186
+ document.removeEventListener('mousemove', drag, false);
1187
+ document.removeEventListener('touchmove', drag, false);
1188
+ };
1189
+
1190
+ const drag = (e) => {
1191
+ if (isDragging) {
1192
+ e.preventDefault();
1193
+ currentX = (e.type === "touchmove" ? e.touches[0].clientX : e.clientX) - initialX;
1194
+ currentY = (e.type === "touchmove" ? e.touches[0].clientY : e.clientY) - initialY;
1195
+
1196
+ xOffset = currentX;
1197
+ yOffset = currentY;
1198
+
1199
+ modalContainer.style.transform = `translate(-50%, -50%) translate3d(${currentX}px, ${currentY}px, 0)`;
1200
+ }
1201
+ };
1202
+
1203
+ modalHeader.addEventListener('mousedown', dragStart, false);
1204
+ modalHeader.addEventListener('touchstart', dragStart, false);
1205
+
1206
+ const modalBody = document.createElement('div');
1207
+ modalBody.className = `${options.classPrefix}${CLASSES.MODAL_BODY} `
1208
+ modalBody.appendChild(bodyNode)
1209
+
1210
+ const modalFooter = document.createElement('div');
1211
+ modalFooter.className = `${options.classPrefix}${CLASSES.MODAL_FOOTER} `
1212
+ modalFooter.appendChild(footerNode)
1213
+
1214
+ modalContent.appendChild(modalHeader);
1215
+ modalContent.appendChild(modalBody);
1216
+ modalContent.appendChild(modalFooter);
1217
+
1218
+ modalContainer.appendChild(modalContent)
1219
+ core.elements.base.appendChild(modalContainer)
1220
+
1221
+ return {
1222
+ close: () => {
1223
+ modalContainer.remove();
1224
+ if (typeof onClose === 'function') onClose()
1225
+ }
1226
+ }
1227
+ }
1228
+
1229
+
1125
1230
  const initImageResizer = (type, image, options, core) => {
1126
1231
  destroyImageResizer(options, core)
1127
1232
  changeAllToolbarState(core, 'disabled', ['link', 'align_justify', 'align_left', 'align_right', 'align_center'])
@@ -3061,14 +3166,18 @@ const indentListItem = (li, core) => {
3061
3166
  const prevLi = li.previousElementSibling;
3062
3167
  if (!prevLi) return;
3063
3168
 
3064
- const parentTag = li.parentElement.tagName;
3065
- let subList = prevLi.querySelector(parentTag);
3169
+ const parentList = li.parentElement;
3170
+ const parentTag = parentList.tagName; // UL or OL
3171
+
3172
+ let subList = prevLi.querySelector(`:scope > ${parentTag}`);
3066
3173
 
3174
+ // create sublist if it doesn't exist
3067
3175
  if (!subList) {
3068
3176
  subList = document.createElement(parentTag);
3069
3177
  prevLi.appendChild(subList);
3070
3178
  }
3071
3179
 
3180
+ // move the li into the sublist
3072
3181
  subList.appendChild(li);
3073
3182
  const selection = core.elements.iframeWindow.getSelection();
3074
3183
  const range = document.createRange();
@@ -3078,7 +3187,6 @@ const indentListItem = (li, core) => {
3078
3187
  selection.addRange(range);
3079
3188
  core.elements.editor.focus();
3080
3189
  core.updateCaretPosition();
3081
-
3082
3190
  if (parentTag === 'OL' || parentTag === 'UL') {
3083
3191
  const level = getListLevel(li);
3084
3192
  applyListStyle(subList, level);
@@ -3089,13 +3197,12 @@ const outdentListItem = (li, core) => {
3089
3197
  const parentOl = li.parentElement;
3090
3198
  const parentLi = parentOl.closest('li');
3091
3199
  if (!parentLi) return;
3092
-
3093
3200
  parentLi.after(li);
3094
3201
 
3095
3202
  const selection = core.elements.iframeWindow.getSelection();
3096
3203
  const range = document.createRange();
3097
3204
  range.selectNodeContents(li);
3098
- range.collapse(false);
3205
+ range.collapse(true);
3099
3206
  selection.removeAllRanges();
3100
3207
  selection.addRange(range);
3101
3208
  core.elements.editor.focus();
@@ -3118,26 +3225,37 @@ const makeSublist = (event, core) => {
3118
3225
  const selection = core.elements.iframeWindow.getSelection();
3119
3226
  if (!selection.rangeCount) return;
3120
3227
 
3121
- let node = selection.anchorNode;
3122
- if (node.nodeType === Node.TEXT_NODE) {
3123
- node = node.parentElement;
3228
+ const range = selection.getRangeAt(0);
3229
+
3230
+ let container = range.commonAncestorContainer;
3231
+
3232
+ if (container.nodeType === Node.TEXT_NODE) {
3233
+ container = container.parentElement;
3124
3234
  }
3235
+ // find the nearest list container
3236
+ const list = container.closest('ul, ol');
3237
+ if (!list) return;
3238
+ const allLis = list.querySelectorAll('li');
3125
3239
 
3126
- const li = node.closest('li');
3127
- if (!li) return;
3240
+ const selectedLis = [];
3128
3241
 
3129
- const clone = li.cloneNode(true);
3130
- clone.querySelectorAll('ul, ol').forEach(l => l.remove());
3131
- const hasContent = clone.textContent.replace(/\u00A0/g, '').length > 0 || clone.querySelector('img, video, audio, table, hr');
3132
- if (hasContent) return;
3242
+ allLis.forEach(li => {
3243
+ if (range.intersectsNode(li)) {
3244
+ selectedLis.push(li);
3245
+ }
3246
+ });
3247
+
3248
+ if (!selectedLis.length) return;
3133
3249
 
3134
3250
  event.preventDefault();
3135
3251
 
3136
- if (event.shiftKey) {
3137
- outdentListItem(li, core);
3138
- } else {
3139
- indentListItem(li, core);
3140
- }
3252
+ selectedLis.forEach(li => {
3253
+ if (event.shiftKey) {
3254
+ outdentListItem(li, core);
3255
+ } else {
3256
+ indentListItem(li, core);
3257
+ }
3258
+ });
3141
3259
  };
3142
3260
 
3143
3261
  const transformCase = (text, type) => {
@@ -3350,6 +3468,510 @@ const updateOutlineItems = debounce((core, options) => {
3350
3468
  highlightActiveOutline(core, options, outlineContainer);
3351
3469
  }, 300)
3352
3470
 
3471
+ const findAndReplace = (core, options, event) => {
3472
+ if (!options.enableFindAndReplace) return;
3473
+ if (core.state.findAndReplaceModalOpen) return;
3474
+
3475
+ core.state.findAndReplaceModalOpen = true
3476
+ const editor = core.elements.editor;
3477
+ const iframeWindow = core.elements.iframeWindow;
3478
+
3479
+ const headerContainer = document.createElement('div');
3480
+ headerContainer.style.display = 'flex';
3481
+ headerContainer.style.gap = '15px';
3482
+ headerContainer.style.alignItems = 'center';
3483
+
3484
+ const findTab = document.createElement('button');
3485
+ findTab.type = 'button';
3486
+ findTab.innerText = 'Find';
3487
+ findTab.className = 'leksy-editor-find-and-replace-modal-tab-btn';
3488
+
3489
+ const replaceTab = document.createElement('button');
3490
+ replaceTab.type = 'button';
3491
+ replaceTab.innerText = 'Replace';
3492
+ replaceTab.className = 'leksy-editor-find-and-replace-modal-tab-btn';
3493
+
3494
+ headerContainer.append(findTab, replaceTab);
3495
+
3496
+ const body = document.createElement('div');
3497
+
3498
+ const findLabel = document.createElement("label");
3499
+ findLabel.innerText = "Find";
3500
+ findLabel.style.paddingLeft = "4px";
3501
+
3502
+ const findInputWrapper = document.createElement('div');
3503
+ findInputWrapper.style.position = 'relative';
3504
+ findInputWrapper.style.marginBottom = '10px';
3505
+
3506
+ const findInput = document.createElement('input');
3507
+ findInput.type = 'text';
3508
+ findInput.placeholder = 'Search text';
3509
+ findInput.style.width = '100%';
3510
+ findInput.style.boxSizing = 'border-box';
3511
+ findInput.style.paddingRight = '60px';
3512
+
3513
+ const countSpan = document.createElement('span');
3514
+ countSpan.className = 'leksy-editor-find-and-replace-modal-count';
3515
+
3516
+ findInputWrapper.append(findInput, countSpan);
3517
+
3518
+ const replaceLabel = document.createElement("label");
3519
+ replaceLabel.innerText = "Replace with";
3520
+ replaceLabel.style.display = "block";
3521
+ replaceLabel.style.paddingLeft = "4px";
3522
+ const replaceInput = document.createElement('input');
3523
+ replaceInput.type = 'text';
3524
+ replaceInput.placeholder = 'Replace text';
3525
+ replaceInput.style.marginBottom = '10px';
3526
+
3527
+ const optionsContainer = document.createElement('div');
3528
+ optionsContainer.style.marginBottom = '10px';
3529
+ optionsContainer.style.display = 'flex';
3530
+ optionsContainer.style.flexDirection = 'column'
3531
+
3532
+ const matchCaseCheckboxContainer = document.createElement('div');
3533
+ const matchCaseCheckbox = document.createElement('input');
3534
+ matchCaseCheckbox.type = 'checkbox';
3535
+ matchCaseCheckbox.id = 'match-case-opt';
3536
+ matchCaseCheckbox.style.marginRight = '5px';
3537
+ matchCaseCheckbox.style.width = '24px';
3538
+
3539
+ const matchCaseLabel = document.createElement('label');
3540
+ matchCaseLabel.innerText = "Match case";
3541
+ matchCaseLabel.htmlFor = 'match-case-opt';
3542
+ matchCaseLabel.style.userSelect = 'none';
3543
+ matchCaseCheckboxContainer.append(matchCaseCheckbox, matchCaseLabel)
3544
+
3545
+ const wholeWordCheckboxContainer = document.createElement('div');
3546
+ const wholeWordCheckbox = document.createElement('input');
3547
+ wholeWordCheckbox.type = 'checkbox';
3548
+ wholeWordCheckbox.id = 'whole-word-opt';
3549
+ wholeWordCheckbox.style.marginRight = '5px';
3550
+ wholeWordCheckbox.style.width = '24px';
3551
+
3552
+ const wholeWordLabel = document.createElement('label');
3553
+ wholeWordLabel.innerText = "Whole word";
3554
+ wholeWordLabel.htmlFor = 'whole-word-opt';
3555
+ wholeWordLabel.style.userSelect = 'none';
3556
+ wholeWordCheckboxContainer.append(wholeWordCheckbox, wholeWordLabel);
3557
+
3558
+ const regexCheckboxContainer = document.createElement('div');
3559
+ const useRegexCheckbox = document.createElement('input');
3560
+ useRegexCheckbox.type = 'checkbox';
3561
+ useRegexCheckbox.id = 'use-regex-opt';
3562
+ useRegexCheckbox.style.marginRight = '5px';
3563
+ useRegexCheckbox.style.width = '24px';
3564
+
3565
+ const useRegexLabel = document.createElement('label');
3566
+ useRegexLabel.innerText = "Use regular expressions";
3567
+ useRegexLabel.htmlFor = 'use-regex-opt';
3568
+ useRegexLabel.style.userSelect = 'none';
3569
+ regexCheckboxContainer.append(useRegexCheckbox, useRegexLabel)
3570
+
3571
+ optionsContainer.append(matchCaseCheckboxContainer, regexCheckboxContainer, wholeWordCheckboxContainer);
3572
+
3573
+ const span = document.createElement('span');
3574
+ span.className = 'warning';
3575
+
3576
+ body.append(findLabel, findInputWrapper, replaceLabel, replaceInput, optionsContainer, span);
3577
+
3578
+ const footer = document.createElement('div');
3579
+ footer.style.display = 'flex';
3580
+ footer.style.gap = '8px';
3581
+ footer.style.justifyContent = 'flex-end';
3582
+ footer.style.width = '100%';
3583
+
3584
+ const prevBtn = document.createElement('button');
3585
+ prevBtn.type = 'button';
3586
+ prevBtn.className = 'submit';
3587
+ prevBtn.innerText = 'Previous';
3588
+
3589
+ const nextBtn = document.createElement('button');
3590
+ nextBtn.type = 'button';
3591
+ nextBtn.className = 'submit';
3592
+ nextBtn.innerText = 'Next';
3593
+
3594
+ const replaceBtn = document.createElement('button');
3595
+ replaceBtn.type = 'button';
3596
+ replaceBtn.className = 'submit';
3597
+ replaceBtn.innerText = 'Replace';
3598
+
3599
+ const replaceAllBtn = document.createElement('button');
3600
+ replaceAllBtn.type = 'button';
3601
+ replaceAllBtn.className = 'submit';
3602
+ replaceAllBtn.innerText = 'Replace All';
3603
+
3604
+ footer.append(prevBtn, nextBtn, replaceBtn, replaceAllBtn);
3605
+
3606
+ const toggleTab = (mode) => {
3607
+ if (mode === 'find') {
3608
+ replaceLabel.style.display = 'none';
3609
+ replaceInput.style.display = 'none';
3610
+ replaceBtn.style.display = 'none';
3611
+ replaceAllBtn.style.display = 'none';
3612
+
3613
+ findTab.classList.add('active');
3614
+ replaceTab.classList.remove('active');
3615
+ } else {
3616
+ replaceLabel.style.display = 'block';
3617
+ replaceInput.style.display = 'block';
3618
+ replaceBtn.style.display = 'flex';
3619
+ replaceAllBtn.style.display = 'flex';
3620
+
3621
+ replaceTab.classList.add('active');
3622
+ findTab.classList.remove('active');
3623
+ }
3624
+ }
3625
+
3626
+ findTab.onclick = () => toggleTab('find');
3627
+ replaceTab.onclick = () => toggleTab('replace');
3628
+
3629
+ openDraggableModal({
3630
+ title: headerContainer,
3631
+ bodyNode: body,
3632
+ footerNode: footer,
3633
+ onClose: () => {
3634
+ core.state.findAndReplaceModalOpen = false
3635
+ }
3636
+ }, core, options);
3637
+
3638
+ if (event.key === 'f')
3639
+ toggleTab('find');
3640
+ else
3641
+ toggleTab('replace');
3642
+
3643
+
3644
+ findInput.focus();
3645
+
3646
+ const find = (direction) => {
3647
+ const searchText = findInput.value;
3648
+ const matchCase = matchCaseCheckbox.checked;
3649
+ const useRegex = useRegexCheckbox.checked;
3650
+ const wholeWord = wholeWordCheckbox.checked;
3651
+
3652
+ span.innerText = "";
3653
+ countSpan.innerText = "";
3654
+
3655
+ if (!searchText) {
3656
+ span.innerText = "Please enter text to find";
3657
+ return;
3658
+ }
3659
+
3660
+ let searchPattern = searchText;
3661
+
3662
+ if (!useRegex) {
3663
+ const cleaned = wholeWord ? searchText.trim() : searchText;
3664
+ const escaped = cleaned.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3665
+
3666
+ searchPattern = wholeWord
3667
+ ? `(?<!\\w)${escaped}(?!\\w)`
3668
+ : escaped;
3669
+ }
3670
+
3671
+ const selection = iframeWindow.getSelection();
3672
+
3673
+ let regex;
3674
+ try {
3675
+ const flags = matchCase ? 'g' : 'gi';
3676
+ regex = new RegExp(searchPattern, flags);
3677
+ } catch (error) {
3678
+ span.innerText = "Invalid regular expression";
3679
+ return;
3680
+ }
3681
+
3682
+ // ✅ Collect matches (node-based + global index)
3683
+ const walker = core.elements.iframeWindow.createTreeWalker(
3684
+ editor,
3685
+ NodeFilter.SHOW_TEXT,
3686
+ null
3687
+ );
3688
+
3689
+ const matches = [];
3690
+ let node;
3691
+ let globalIndex = 0;
3692
+
3693
+ while ((node = walker.nextNode())) {
3694
+ const text = node.nodeValue;
3695
+ regex.lastIndex = 0;
3696
+
3697
+ let match;
3698
+ while ((match = regex.exec(text)) !== null) {
3699
+ matches.push({
3700
+ node,
3701
+ start: match.index,
3702
+ end: match.index + match[0].length,
3703
+ globalStart: globalIndex + match.index
3704
+ });
3705
+ }
3706
+
3707
+ globalIndex += text.length;
3708
+ }
3709
+
3710
+ if (!matches.length) {
3711
+ countSpan.innerText = "0 of 0";
3712
+ return;
3713
+ }
3714
+
3715
+ // ✅ Get current cursor global position
3716
+ let currentGlobal = 0;
3717
+
3718
+ if (selection.rangeCount > 0) {
3719
+ const range = selection.getRangeAt(0);
3720
+
3721
+ const walker2 = core.elements.iframeWindow.createTreeWalker(
3722
+ editor,
3723
+ NodeFilter.SHOW_TEXT,
3724
+ null
3725
+ );
3726
+
3727
+ let n, count = 0;
3728
+
3729
+ while ((n = walker2.nextNode())) {
3730
+ if (n === range.startContainer) {
3731
+ currentGlobal = count + range.startOffset;
3732
+ break;
3733
+ }
3734
+ count += n.nodeValue.length;
3735
+ }
3736
+ }
3737
+
3738
+ // ✅ Find next / prev
3739
+ let matchIndex = -1;
3740
+
3741
+ if (direction === 'next') {
3742
+ matchIndex = matches.findIndex(m => m.globalStart > currentGlobal);
3743
+ if (matchIndex === -1) matchIndex = 0;
3744
+ } else {
3745
+ for (let i = matches.length - 1; i >= 0; i--) {
3746
+ if (matches[i].globalStart < currentGlobal) {
3747
+ matchIndex = i;
3748
+ break;
3749
+ }
3750
+ }
3751
+ if (matchIndex === -1) matchIndex = matches.length - 1;
3752
+ }
3753
+
3754
+ const match = matches[matchIndex];
3755
+
3756
+ // ✅ Select
3757
+ const newRange = core.elements.iframeWindow.createRange();
3758
+ newRange.setStart(match.node, match.start);
3759
+ newRange.setEnd(match.node, match.end);
3760
+
3761
+ selection.removeAllRanges();
3762
+ selection.addRange(newRange);
3763
+
3764
+ match.node.parentElement?.scrollIntoView({
3765
+ block: "center",
3766
+ behavior: "smooth"
3767
+ });
3768
+
3769
+ countSpan.innerText = `${matchIndex + 1} of ${matches.length}`;
3770
+ };
3771
+
3772
+ const replace = () => {
3773
+ const searchText = findInput.value;
3774
+ const replaceText = replaceInput.value;
3775
+ const matchCase = matchCaseCheckbox.checked;
3776
+ const useRegex = useRegexCheckbox.checked;
3777
+ const wholeWord = wholeWordCheckbox.checked;
3778
+
3779
+ if (!searchText) return;
3780
+
3781
+ // 🔹 Build regex (same as find)
3782
+ let pattern = searchText;
3783
+
3784
+ if (!useRegex) {
3785
+ const cleaned = wholeWord ? searchText.trim() : searchText;
3786
+ const escaped = cleaned.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3787
+
3788
+ pattern = wholeWord
3789
+ ? `(?<!\\w)${escaped}(?!\\w)`
3790
+ : escaped;
3791
+ }
3792
+
3793
+ let regex;
3794
+ try {
3795
+ regex = new RegExp(pattern, matchCase ? '' : 'i');
3796
+ } catch {
3797
+ return;
3798
+ }
3799
+
3800
+ const selection = iframeWindow.getSelection();
3801
+
3802
+ if (!selection.rangeCount) {
3803
+ find('next');
3804
+ return;
3805
+ }
3806
+
3807
+ const range = selection.getRangeAt(0);
3808
+ const selectedText = range.toString();
3809
+
3810
+ const match = selectedText.match(regex);
3811
+
3812
+ if (match && match[0] === selectedText) {
3813
+ const newText = selectedText.replace(regex, replaceText);
3814
+
3815
+ // ✅ safe replace
3816
+ range.deleteContents();
3817
+ range.insertNode(core.elements.iframeWindow.createTextNode(newText));
3818
+
3819
+ // move cursor after replaced text
3820
+ range.setStart(range.endContainer, range.endOffset);
3821
+ selection.removeAllRanges();
3822
+ selection.addRange(range);
3823
+ }
3824
+
3825
+ find('next');
3826
+ };
3827
+
3828
+ const replaceAll = () => {
3829
+ const searchText = findInput.value;
3830
+ const replaceText = replaceInput.value;
3831
+ const matchCase = matchCaseCheckbox.checked;
3832
+ const useRegex = useRegexCheckbox.checked;
3833
+ const wholeWord = wholeWordCheckbox.checked;
3834
+
3835
+ if (!searchText) return;
3836
+
3837
+ let pattern = searchText;
3838
+
3839
+ if (!useRegex) {
3840
+ const cleaned = wholeWord ? searchText.trim() : searchText;
3841
+ const escaped = cleaned.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3842
+
3843
+ pattern = wholeWord
3844
+ ? `(?<!\\w)${escaped}(?!\\w)`
3845
+ : escaped;
3846
+ }
3847
+
3848
+ let regex;
3849
+ try {
3850
+ regex = new RegExp(pattern, matchCase ? 'g' : 'gi');
3851
+ } catch {
3852
+ return;
3853
+ }
3854
+
3855
+ // 🔹 Collect matches (same as find)
3856
+ const walker = core.elements.iframeWindow.createTreeWalker(
3857
+ editor,
3858
+ NodeFilter.SHOW_TEXT,
3859
+ null
3860
+ );
3861
+
3862
+ const matches = [];
3863
+ let node;
3864
+
3865
+ while ((node = walker.nextNode())) {
3866
+ const text = node.nodeValue;
3867
+ regex.lastIndex = 0;
3868
+
3869
+ let match;
3870
+ while ((match = regex.exec(text)) !== null) {
3871
+ matches.push({
3872
+ node,
3873
+ start: match.index,
3874
+ end: match.index + match[0].length,
3875
+ original: match[0]
3876
+ });
3877
+ }
3878
+ }
3879
+
3880
+ // 🔹 Replace in reverse (important)
3881
+ let count = 0;
3882
+
3883
+ for (let i = matches.length - 1; i >= 0; i--) {
3884
+ const m = matches[i];
3885
+ const text = m.node.nodeValue;
3886
+
3887
+ const newText = m.original.replace(
3888
+ new RegExp(pattern, matchCase ? '' : 'i'),
3889
+ replaceText
3890
+ );
3891
+
3892
+ m.node.nodeValue =
3893
+ text.slice(0, m.start) +
3894
+ newText +
3895
+ text.slice(m.end);
3896
+
3897
+ count++;
3898
+ }
3899
+
3900
+ span.innerText = `Replaced ${count} occurrences.`;
3901
+
3902
+ find('next');
3903
+ };
3904
+
3905
+ prevBtn.onclick = () => find('prev');
3906
+ nextBtn.onclick = () => find('next');
3907
+ replaceBtn.onclick = replace;
3908
+ replaceAllBtn.onclick = replaceAll;
3909
+
3910
+ const onInputKeydown = (event) => {
3911
+ if (event.key === 'Enter') {
3912
+ event.preventDefault();
3913
+ find('next');
3914
+ }
3915
+ };
3916
+ findInput.addEventListener('keydown', onInputKeydown);
3917
+ }
3918
+
3919
+ const isCaretAtStart = (range, li) => {
3920
+ const preRange = range.cloneRange();
3921
+ preRange.selectNodeContents(li);
3922
+ preRange.setEnd(range.startContainer, range.startOffset);
3923
+
3924
+ return preRange.toString().length === 0;
3925
+ };
3926
+ const handleBackspaceInList = (event, core) => {
3927
+ if (event.key !== 'Backspace') return;
3928
+
3929
+ const selection = core.elements.iframeWindow.getSelection();
3930
+ if (!selection.rangeCount) return;
3931
+ const range = selection.getRangeAt(0);
3932
+
3933
+ if (!range.collapsed) return;
3934
+
3935
+ let element = range.startContainer;
3936
+ if (element.nodeType === Node.TEXT_NODE) element = element.parentElement;
3937
+
3938
+ const li = element.closest('li');
3939
+ if (!li) return;
3940
+
3941
+ if (!core.elements.editor.contains(li)) return;
3942
+
3943
+ const nestedList = li.querySelector('ul, ol');
3944
+ if (!nestedList) return;
3945
+
3946
+ const clone = li.cloneNode(true);
3947
+ const nestedInClone = clone.querySelector('ul, ol');
3948
+ if (nestedInClone) nestedInClone.remove();
3949
+ const textContent = clone.textContent.replace(/\u200B/g, '').trim();
3950
+
3951
+ if (textContent.length > 0 && !isCaretAtStart(range, li)) return;
3952
+
3953
+ event.preventDefault();
3954
+
3955
+ const parentList = li.parentElement;
3956
+ const fragment = document.createDocumentFragment();
3957
+ const children = Array.from(nestedList.children);
3958
+
3959
+ children.forEach(child => fragment.appendChild(child));
3960
+
3961
+ if (children.length > 0) {
3962
+ parentList.insertBefore(fragment, li);
3963
+ const firstChild = children[0];
3964
+ const newRange = document.createRange();
3965
+ newRange.setStart(firstChild, 0);
3966
+ newRange.collapse(true);
3967
+ selection.removeAllRanges();
3968
+ selection.addRange(newRange);
3969
+ }
3970
+
3971
+ li.remove();
3972
+ core.updateCaretPosition();
3973
+ };
3974
+
3353
3975
  export {
3354
3976
  cleanHTML,
3355
3977
  debounce,
@@ -3398,4 +4020,6 @@ export {
3398
4020
  navigateToHeading,
3399
4021
  updateOutlineItems,
3400
4022
  highlightActiveOutline,
4023
+ findAndReplace,
4024
+ handleBackspaceInList,
3401
4025
  }