From b5e906454f4045eb4a1fc989e5b2e1026688c128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20C=C3=B4ng=20D=C5=A9ng?= Date: Tue, 10 Dec 2024 16:12:52 +0700 Subject: [PATCH 1/5] Handle range delete - Depth check not delete when in range has .mceNonEditable - Clean code - Handle event *BeforeExecCommand*, *BeforeSetContent*. Reference: [thopark request](https://github.com/csga5000/tinymce-prevent-delete/pull/9) --- preventdelete.js | 410 ++++++++++++++++++++++------------------------- 1 file changed, 188 insertions(+), 222 deletions(-) diff --git a/preventdelete.js b/preventdelete.js index de08067..95d48a8 100644 --- a/preventdelete.js +++ b/preventdelete.js @@ -1,222 +1,188 @@ -(function() { - Array.prototype.contains = function (e) { - return this.indexOf(e) > -1 - } - - function PreventDelete() { - var self = this - - //Returns whether val is within the range specified by min/max - function r(val, min, max) { - return val >= min && val <= max - } - //Returns whether there is any non-space characters in the specified direction relative to the position - function hasText(str, pos, left) { - //160 is  , 32 is ' ' - left = left === false ? false : true - - for (var i = left ? pos-1 : pos; left ? i > 0 : i < str.length; left ? i-- : i++) { - if ([160, 32].contains(str.charCodeAt(i))) - continue - else - return true - } - return false - } - //This just returns true if there is relevant text that would stop ctrl back/del from propogating farther than this string - function hasStopText(str, pos, left) { - var text = false - var space = false - left = left === false ? false : true - - for (var i = left ? pos-1 : pos; left ? i > 0 : i < str.length; left ? i-- : i++) { - var isSpace = [160, 32].contains(str.charCodeAt(i)) - if (!space && isSpace) - space = true - else if (!text && !isSpace) - text = true - - if (space && text) - return true - } - return false - } - - this.root_id = 'tinymce' - this.preventdelete_class = 'mceNonEditable' - - this.nextElement = function(elem) { - var $elem = $(elem) - var next_sibling = $elem.next() - while(next_sibling.length == 0){ - $elem = $elem.parent() - if($elem.attr('id') == self.root_id) - return false - - next_sibling = $elem.next() - } - - return next_sibling - } - this.prevElement = function(elem) { - var $elem = $(elem) - var prev_sibling = $elem.prev() - while(prev_sibling.length == 0){ - $elem = $elem.parent() - if($elem.attr('id') == self.root_id) - return false - - prev_sibling = $elem.prev() - } - - return prev_sibling - } - - this.keyWillDelete = function(evt) { - /* - In trying to figure out how to detect if a key was relevant, I appended all the keycodes for keys on my keyboard that would "delete" selected text, and sorted. Generated the range blow: - Deleting - 8, 9, 13, 46, 48-57, 65-90, 96-111, 186-192, 219-222 - - I did the same thign with keys that wouldn't and got these below - Not harmful - 16-19, 27, 33-40, 45, 91-93, 112-123, 144 - - You should note, since it's onkeydown it doesn't change the code if you have alt or ctrl or something pressed. It makes it fewer keycombos actually. - - I'm pretty sure in these "deleting" keys will still "delete" if shift is held - */ - - var c = evt.keyCode - - //ctrl+x or ctrl+back/del will all delete, but otherwise it probably won't - if (evt.ctrlKey) - return evt.key == 'x' || [8, 46].contains(c) - - return [8, 9, 13, 46].contains(c) || r(c, 48, 57) || r(c, 65, 90) || r(c, 96, 111) || r(c, 186, 192) || r(c, 219, 222) - - } - this.cancelKey = function(evt) { - evt.preventDefault() - evt.stopPropagation() - return false - } - this.check = function(node) { - return $(node).hasClass(self.preventdelete_class) - } - this.checkParents = function(node) { - if (!node) - return true - - return $(node).parents('.'+self.preventdelete_class).length > 0 - } - this.checkChildren = function(node) { - if (!node) - return false - - return $(node).find('.'+self.preventdelete_class).length > 0 - } - - this.logElem = function(elem) { - var e = {} - - var keys = ['innerHTML', 'nodeName', 'nodeType', 'nextSibling', 'previousSibling', 'outerHTML', 'parentElement', 'data'] - - keys.forEach( - function(key) { - e[key] = elem[key] - } - ) - - } - - tinymce.PluginManager.add('preventdelete', function(ed, link) { - ed.on('keydown', function(evt) { - - if (!self.keyWillDelete(evt)) - return true; - - var selected = tinymce.activeEditor.selection.getNode() - if (self.check(selected) || self.checkChildren(selected)){ - return self.cancelKey(evt) - } - - var range = tinymce.activeEditor.selection.getRng() - - self.logElem(range.startContainer) - - var back = evt.keyCode == 8 - var del = evt.keyCode == 46 - - var conNoEdit - - //Ensure nothing in the span between elems is noneditable - for (var c = range.startContainer; !conNoEdit && c; c = c.nextSibling) { - conNoEdit = conNoEdit || self.check(c) - - if (range.endContainer === c) - break - } - - var end = range.endContainer - if (end && range.endOffset === 0 && (self.check(end) || self.checkChildren(end))) - return self.cancelKey(evt) - - if (conNoEdit) - return self.cancelKey(evt) - - - var endData = (range.endContainer.data || "") - var zwnbsp = range.startContainer.data && range.startContainer.data.charCodeAt(0) === 65279 - - var delin = del && range.endContainer.data && (range.endOffset < endData.length) && !(zwnbsp && endData.length === 1) - var backin = back && range.startContainer.data && range.startOffset > zwnbsp; - - var noselection = range.startOffset === range.endOffset - - var ctrlDanger = (evt.ctrlKey && (back || del)) && !hasStopText(range.startContainer.data, range.startOffset, back) - - if (delin || backin) { - //Allow the delete - if (!ctrlDanger) - return true - } - - // If ctrl is a danger we need to skip this block and check the siblings which is done in the rest of this function - if (!ctrlDanger) { - if (del && noselection && (range.startOffset+1) < range.endContainer.childElementCount) { - var elem = range.endContainer.childNodes[range.startOffset+1] - return self.check(elem) ? self.cancelKey(evt) : true - } - - //The range is within this container - if (range.startOffset !== range.endOffset) { - - //If this container is non-editable, cancel the event, otherwise allow the event - return conNoEdit ? self.cancelKey(evt) : true - } - } - - //Keypress was del and will effect the next element - if (del) { - var next = self.nextElement(range.endContainer) - //No next element, so we don't need to delete anyway - if (!next) - return self.cancelKey(evt) - - if (self.check(next) || self.checkChildren(next)) - return self.cancelKey(evt) - } - //Keypress was back and will effect the previouselement - if (back) { - var prev = self.prevElement(range.startContainer) - - if (self.check(prev)) - return self.cancelKey(evt) - } - - }) - }) - } - new PreventDelete() -})() +(function () { + // Helper function to check if an element is in an array + Array.prototype.contains = function (elem) { + return this.indexOf(elem) > -1; + }; + + function PreventDelete() { + const self = this; + + // Range validation function + const isWithinRange = (value, min, max) => value >= min && value <= max; + + // Prevent delete class and root ID + this.rootId = 'tinymce'; + this.preventDeleteClass = 'mceNonEditable'; + + // Function to check if a node or its children have the 'prevent delete' class + this.hasNonEditableNode = (node) => { + if (!node) return false; + if ( + node.nodeType === 1 && + node.classList && + node.classList.contains(self.preventDeleteClass) + ) { + return true; + } + if (node.hasChildNodes()) { + for (const child of node.childNodes) { + if (self.hasNonEditableNode(child)) return true; + } + } + return false; + }; + + // Function to check if a range intersects with any non-editable nodes + this.checkRange = (range) => { + if (!range) return false; + let container = range.commonAncestorContainer; + if (container.nodeType === 3) container = container.parentNode; + + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_ELEMENT, + { + acceptNode(node) { + const nodeRange = document.createRange(); + nodeRange.selectNode(node); + if (range.intersectsNode(node)) return NodeFilter.FILTER_ACCEPT; + return NodeFilter.FILTER_SKIP; + }, + } + ); + + let node; + while ((node = walker.nextNode())) { + if (self.hasNonEditableNode(node)) return true; + } + + const startNode = + range.startContainer.nodeType === 1 + ? range.startContainer + : range.startContainer.parentElement; + const endNode = + range.endContainer.nodeType === 1 + ? range.endContainer + : range.endContainer.parentElement; + + return ( + self.hasNonEditableNode(startNode) || self.hasNonEditableNode(endNode) + ); + }; + + // Function to find the next editable element + this.nextElement = (elem) => { + let currentElem = elem; + let nextSibling = currentElem.nextElementSibling; + while (!nextSibling) { + currentElem = currentElem.parentElement; + if (currentElem.id === self.rootId) return false; + nextSibling = currentElem.nextElementSibling; + } + return nextSibling; + }; + + // Function to find the previous editable element + this.prevElement = (elem) => { + let currentElem = elem; + let prevSibling = currentElem.previousElementSibling; + while (!prevSibling) { + currentElem = currentElem.parentElement; + if (currentElem.id === self.rootId) return false; + prevSibling = currentElem.previousElementSibling; + } + return prevSibling; + }; + + // Key press validation to prevent certain deletions + /* + In trying to figure out how to detect if a key was relevant, I appended all the keycodes for keys on my keyboard that would "delete" selected text, and sorted. Generated the range blow: + Deleting + 8, 9, 13, 46, 48-57, 65-90, 96-111, 186-192, 219-222 + + I did the same thign with keys that wouldn't and got these below + Not harmful + 16-19, 27, 33-40, 45, 91-93, 112-123, 144 + + You should note, since it's onkeydown it doesn't change the code if you have alt or ctrl or something pressed. It makes it fewer keycombos actually. + + I'm pretty sure in these "deleting" keys will still "delete" if shift is held + */ + this.keyWillDelete = (evt) => { + const keyCode = evt.keyCode; + // ctrl+x or ctrl+back/del will all delete, but otherwise it probably won't + if (evt.ctrlKey) return evt.key === 'x' || [8, 46].contains(keyCode); + return ( + [8, 9, 13, 46].contains(keyCode) || + isWithinRange(keyCode, 48, 57) || + isWithinRange(keyCode, 65, 90) || + isWithinRange(keyCode, 96, 111) || + isWithinRange(keyCode, 186, 192) || + isWithinRange(keyCode, 219, 222) + ); + }; + + // Cancel the key event (e.g., prevent default delete behavior) + this.cancelKey = (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + return false; + }; + + // Function to check if a node has the 'prevent delete' class + this.checkNode = (node) => { + return ( + node && + node.nodeType === 1 && + node.nodeName.toLowerCase() !== 'body' && + node.classList && + node.classList.contains(self.preventDeleteClass) + ); + }; + + // Function to check if any parent of a node has the 'prevent delete' class + this.checkParents = (node) => { + if ( + !node || + node.nodeType !== 1 || + node.nodeName.toLowerCase() === 'body' + ) + return false; + return node.closest(`.${self.preventDeleteClass}`) !== null; + }; + + // Function to check if any child of a node has the 'prevent delete' class + this.checkChildren = (node) => { + if ( + !node || + node.nodeType !== 1 || + node.nodeName.toLowerCase() === 'body' + ) + return false; + return node.querySelector(`.${self.preventDeleteClass}`) !== null; + }; + + // Plugin logic to intercept keydown events and prevent deletion + tinymce.PluginManager.add('preventdelete', (ed) => { + ed.on('keydown', (evt) => { + if (!self.keyWillDelete(evt)) return true; + const range = tinymce.activeEditor.selection.getRng(); + if (self.checkRange(range)) return self.cancelKey(evt); + }); + ed.on('BeforeExecCommand', function (evt) { + if (['Cut', 'Delete', 'Paste'].includes(evt.command)) { + const range = tinymce.activeEditor.selection.getRng(); + if (self.checkRange(range)) return self.cancelKey(evt); + } + + return true; + }); + ed.on('BeforeSetContent', function (evt) { + const range = tinymce.activeEditor.selection.getRng(); + if (self.checkRange(range)) return self.cancelKey(evt); + }); + }); + } + + new PreventDelete(); +})(); From 620f4db3700e962a3ed5797b6de27d737e9aa9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20C=C3=B4ng=20D=C5=A9ng?= Date: Thu, 12 Dec 2024 14:26:08 +0700 Subject: [PATCH 2/5] Handle backspace, delete mceNonEditable --- preventdelete.js | 52 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/preventdelete.js b/preventdelete.js index 95d48a8..20f76af 100644 --- a/preventdelete.js +++ b/preventdelete.js @@ -152,7 +152,7 @@ }; // Function to check if any child of a node has the 'prevent delete' class - this.checkChildren = (node) => { + this.hasNonEditableInChildren = (node) => { if ( !node || node.nodeType !== 1 || @@ -162,25 +162,51 @@ return node.querySelector(`.${self.preventDeleteClass}`) !== null; }; + this.handleEvent = (evt) => { + if (!self.keyWillDelete(evt)) return true; + + const selectedNode = tinymce.activeEditor.selection.getNode(); + const range = tinymce.activeEditor.selection.getRng(); + + const prev = self.prevElement(range.startContainer); + const next = self.nextElement(range.startContainer); + const startContainer = range.startContainer; + const startTextContent = startContainer?.textContent; + const cleanedTextContent = startTextContent?.replace(/^\uFEFF/gm, ''); + const prevCheck = + self.hasNonEditableNode(prev) || self.hasNonEditableInChildren(prev); + const nextCheck = self.hasNonEditableNode(next); + + const isStartContainerValid = + self.hasNonEditableNode(startContainer) || + self.hasNonEditableInChildren(startContainer); + const isStartContentValid = + startContainer && startTextContent && cleanedTextContent?.length; + + // Prevent deletion if the range contains non-editable content + if ( + self.checkRange(range) || + self.hasNonEditableNode(selectedNode) || + self.hasNonEditableInChildren(selectedNode) || + (!isStartContainerValid && + !isStartContentValid && + (prevCheck || nextCheck)) + ) { + return self.cancelKey(evt); + } + }; + // Plugin logic to intercept keydown events and prevent deletion tinymce.PluginManager.add('preventdelete', (ed) => { - ed.on('keydown', (evt) => { - if (!self.keyWillDelete(evt)) return true; - const range = tinymce.activeEditor.selection.getRng(); - if (self.checkRange(range)) return self.cancelKey(evt); - }); - ed.on('BeforeExecCommand', function (evt) { + ed.on('keydown', (evt) => self.handleEvent(evt)); + ed.on('BeforeExecCommand', (evt) => { if (['Cut', 'Delete', 'Paste'].includes(evt.command)) { - const range = tinymce.activeEditor.selection.getRng(); - if (self.checkRange(range)) return self.cancelKey(evt); + self.handleEvent(evt); } return true; }); - ed.on('BeforeSetContent', function (evt) { - const range = tinymce.activeEditor.selection.getRng(); - if (self.checkRange(range)) return self.cancelKey(evt); - }); + ed.on('BeforeSetContent', (evt) => self.handleEvent(evt)); }); } From 7cb0e3f4b7c6cf204cde66e5aaa34a2cfd39b0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20C=C3=B4ng=20D=C5=A9ng?= Date: Thu, 12 Dec 2024 18:41:57 +0700 Subject: [PATCH 3/5] Handle Ctrl+v, Ctrl+x, Ctrl+Delete, Ctrl+Backspace, Shift+Insert, Shift+Delete, Shift+Backspace in range --- preventdelete.js | 176 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 147 insertions(+), 29 deletions(-) diff --git a/preventdelete.js b/preventdelete.js index 20f76af..363127c 100644 --- a/preventdelete.js +++ b/preventdelete.js @@ -10,6 +10,38 @@ // Range validation function const isWithinRange = (value, min, max) => value >= min && value <= max; + // Function to check if a string has any non-whitespace character near the specified position + const hasTextAround = (str, pos, left = true) => { + for ( + let i = left ? pos - 1 : pos; + left ? i > 0 : i < str.length; + left ? i-- : i++ + ) { + // 160 is  , 32 is ' ' + if ([160, 32].contains(str.charCodeAt(i))) continue; // Skip spaces + return true; // Found non-whitespace character + } + return false; + }; + + // Function to check if there's a stop condition (space and text sequence) around the position + const hasStopTextAround = (str, pos, left = true) => { + let foundSpace = false; + let foundText = false; + for ( + let i = left ? pos - 1 : pos; + left ? i > 0 : i < str.length; + left ? i-- : i++ + ) { + const isSpace = [160, 32].contains(str.charCodeAt(i)); + if (!foundSpace && isSpace) foundSpace = true; + else if (!foundText && !isSpace) foundText = true; + + if (foundSpace && foundText) return true; // Space and text found + } + return false; + }; + // Prevent delete class and root ID this.rootId = 'tinymce'; this.preventDeleteClass = 'mceNonEditable'; @@ -76,7 +108,7 @@ let nextSibling = currentElem.nextElementSibling; while (!nextSibling) { currentElem = currentElem.parentElement; - if (currentElem.id === self.rootId) return false; + if (currentElem?.id === self.rootId) return false; nextSibling = currentElem.nextElementSibling; } return nextSibling; @@ -110,8 +142,57 @@ */ this.keyWillDelete = (evt) => { const keyCode = evt.keyCode; - // ctrl+x or ctrl+back/del will all delete, but otherwise it probably won't - if (evt.ctrlKey) return evt.key === 'x' || [8, 46].contains(keyCode); + const isBackspace = evt?.keyCode === 8; + const isDelete = evt?.keyCode === 46; + + if (evt.shiftKey || evt.ctrlKey || isBackspace || isDelete) { + const selectedNode = tinymce.activeEditor.selection.getNode(); + const range = tinymce.activeEditor.selection.getRng(); + + const prevSibling = self.prevElement(range.startContainer); + const nextSibling = self.nextElement(range.startContainer); + const hasNonEditable = + self.hasNonEditableNode(selectedNode) || + self.hasNonEditableNode(range.startContainer) || + self.hasNonEditableInChildren(range.startContainer) || + ((!range.startContainer.textContent || + !range.startContainer.textContent.trim()) && + self.hasNonEditableNode(prevSibling)) || + self.hasNonEditableInChildren(prevSibling) || + self.hasNonEditableNode(nextSibling); + + // Handle Shift+Insert, Shift+Delete, Shift+Backspace in range + if ( + evt.shiftKey && + (['Insert', 'Delete', 'Backspace'].includes(evt.key) || + [45, 8, 46].includes(keyCode)) && + hasNonEditable + ) + return self.cancelKey(evt); + + // Handle Ctrl+v, Ctrl+x, Ctrl+Delete, Ctrl+Backspace in range + if ( + evt.ctrlKey && + (['v', 'x', 'Delete', 'Backspace'].includes(evt.key) || + [86, 88, 8, 46].includes(keyCode)) && + hasNonEditable + ) + return self.cancelKey(evt); + + // Handle delete when next is mceNonEditable + if (isDelete) { + const nextSibling = self.nextElement(range.endContainer); + if (!nextSibling || self.hasNonEditableNode(nextSibling)) { + return self.cancelKey(evt); + } + } + + // Handle backspace when prev is mceNonEditable + if (isBackspace && hasNonEditable) { + return self.cancelKey(evt); + } + } + return ( [8, 9, 13, 46].contains(keyCode) || isWithinRange(keyCode, 48, 57) || @@ -163,47 +244,84 @@ }; this.handleEvent = (evt) => { - if (!self.keyWillDelete(evt)) return true; + // const selectedNode = tinymce.activeEditor.selection.getNode(); + // if ( + // self.hasNonEditableNode(selectedNode) || + // self.hasNonEditableInChildren(selectedNode) + // ) { + // return self.cancelKey(evt); + // } - const selectedNode = tinymce.activeEditor.selection.getNode(); const range = tinymce.activeEditor.selection.getRng(); + const isBackspace = evt?.keyCode === 8; + const isDelete = evt?.keyCode === 46; - const prev = self.prevElement(range.startContainer); - const next = self.nextElement(range.startContainer); - const startContainer = range.startContainer; - const startTextContent = startContainer?.textContent; - const cleanedTextContent = startTextContent?.replace(/^\uFEFF/gm, ''); - const prevCheck = - self.hasNonEditableNode(prev) || self.hasNonEditableInChildren(prev); - const nextCheck = self.hasNonEditableNode(next); - - const isStartContainerValid = - self.hasNonEditableNode(startContainer) || - self.hasNonEditableInChildren(startContainer); - const isStartContentValid = - startContainer && startTextContent && cleanedTextContent?.length; - - // Prevent deletion if the range contains non-editable content if ( - self.checkRange(range) || - self.hasNonEditableNode(selectedNode) || - self.hasNonEditableInChildren(selectedNode) || - (!isStartContainerValid && - !isStartContentValid && - (prevCheck || nextCheck)) + range.endContainer && + range.endOffset === 0 && + self.hasNonEditableNode(range.endContainer) ) { return self.cancelKey(evt); } + + if (self.checkRange(range)) return self.cancelKey(evt); + + const endContainerText = range.endContainer.textContent || ''; + const isZwnbsp = + range.startContainer.textContent && + range.startContainer.textContent.charCodeAt(0) === 65279; + + const deleteWithinNode = + isDelete && + range.endOffset < endContainerText.length && + !(isZwnbsp && endContainerText.length === 1); + const backspaceWithinNode = + isBackspace && range.startOffset > (isZwnbsp ? 1 : 0); + const ctrlDanger = + evt.ctrlKey && + (isBackspace || isDelete) && + !hasTextAround( + range.startContainer.data, + range.startOffset, + isBackspace + ); + + // Allow the delete + if ((deleteWithinNode || backspaceWithinNode) && !ctrlDanger) { + return true; + } + + const noselection = range.startOffset === range.endOffset; + // If ctrl is a danger we need to skip this block and check the siblings which is done in the rest of this function + if (!ctrlDanger) { + if ( + isDelete && + noselection && + range.startOffset + 1 < range.endContainer.childElementCount + ) { + const elem = range.endContainer.childNodes[range.startOffset + 1]; + return self.check(elem) ? self.cancelKey(evt) : true; + } + + //The range is within this container + if (range.startOffset !== range.endOffset) { + //If this container is non-editable, cancel the event, otherwise allow the event + return self.checkRange(range) ? self.cancelKey(evt) : true; + } + } + + return !self.keyWillDelete(evt); }; // Plugin logic to intercept keydown events and prevent deletion tinymce.PluginManager.add('preventdelete', (ed) => { ed.on('keydown', (evt) => self.handleEvent(evt)); ed.on('BeforeExecCommand', (evt) => { - if (['Cut', 'Delete', 'Paste'].includes(evt.command)) { + if ( + ['Cut', 'Delete', 'Paste', 'mceInsertContent'].includes(evt.command) + ) { self.handleEvent(evt); } - return true; }); ed.on('BeforeSetContent', (evt) => self.handleEvent(evt)); From d4b77e3164c3d6c2bae5cfcd9ca120fca06d6b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20C=C3=B4ng=20D=C5=A9ng?= Date: Fri, 13 Dec 2024 14:01:19 +0700 Subject: [PATCH 4/5] Handle delete empty line, press ctrl+delete, shift+delete, ctrl+backspace, shift+delete --- preventdelete.js | 139 +++++++++++++---------------------------------- 1 file changed, 37 insertions(+), 102 deletions(-) diff --git a/preventdelete.js b/preventdelete.js index 363127c..bd62207 100644 --- a/preventdelete.js +++ b/preventdelete.js @@ -10,38 +10,6 @@ // Range validation function const isWithinRange = (value, min, max) => value >= min && value <= max; - // Function to check if a string has any non-whitespace character near the specified position - const hasTextAround = (str, pos, left = true) => { - for ( - let i = left ? pos - 1 : pos; - left ? i > 0 : i < str.length; - left ? i-- : i++ - ) { - // 160 is  , 32 is ' ' - if ([160, 32].contains(str.charCodeAt(i))) continue; // Skip spaces - return true; // Found non-whitespace character - } - return false; - }; - - // Function to check if there's a stop condition (space and text sequence) around the position - const hasStopTextAround = (str, pos, left = true) => { - let foundSpace = false; - let foundText = false; - for ( - let i = left ? pos - 1 : pos; - left ? i > 0 : i < str.length; - left ? i-- : i++ - ) { - const isSpace = [160, 32].contains(str.charCodeAt(i)); - if (!foundSpace && isSpace) foundSpace = true; - else if (!foundText && !isSpace) foundText = true; - - if (foundSpace && foundText) return true; // Space and text found - } - return false; - }; - // Prevent delete class and root ID this.rootId = 'tinymce'; this.preventDeleteClass = 'mceNonEditable'; @@ -49,13 +17,8 @@ // Function to check if a node or its children have the 'prevent delete' class this.hasNonEditableNode = (node) => { if (!node) return false; - if ( - node.nodeType === 1 && - node.classList && - node.classList.contains(self.preventDeleteClass) - ) { - return true; - } + if (node.nodeName.toLowerCase() === 'body') return false; + if (self.checkNode(node)) return true; if (node.hasChildNodes()) { for (const child of node.childNodes) { if (self.hasNonEditableNode(child)) return true; @@ -157,9 +120,35 @@ self.hasNonEditableInChildren(range.startContainer) || ((!range.startContainer.textContent || !range.startContainer.textContent.trim()) && - self.hasNonEditableNode(prevSibling)) || - self.hasNonEditableInChildren(prevSibling) || - self.hasNonEditableNode(nextSibling); + (self.hasNonEditableNode(prevSibling) || + self.hasNonEditableInChildren(prevSibling) || + self.hasNonEditableNode(nextSibling))); + + const noSelected = + range.startOffset === range.endOffset || + range?.startContainer.textContent === ''; + + // Handle delete empty line, press ctrl+delete, shift+delete, ctrl+backspace, shift+delete + if (noSelected) { + if ( + (evt.ctrlKey || evt.shiftKey) && + isBackspace && + range.startOffset === 0 && + (self.hasNonEditableNode(prevSibling) || + self.hasNonEditableInChildren(prevSibling)) + ) { + return self.cancelKey(evt); + } + + if ( + isDelete && + (evt.ctrlKey || evt.shiftKey) && + (self.hasNonEditableNode(nextSibling) || + self.hasNonEditableInChildren(nextSibling)) + ) { + return self.cancelKey(evt); + } + } // Handle Shift+Insert, Shift+Delete, Shift+Backspace in range if ( @@ -176,8 +165,9 @@ (['v', 'x', 'Delete', 'Backspace'].includes(evt.key) || [86, 88, 8, 46].includes(keyCode)) && hasNonEditable - ) + ) { return self.cancelKey(evt); + } // Handle delete when next is mceNonEditable if (isDelete) { @@ -193,14 +183,14 @@ } } - return ( - [8, 9, 13, 46].contains(keyCode) || + if ( isWithinRange(keyCode, 48, 57) || isWithinRange(keyCode, 65, 90) || isWithinRange(keyCode, 96, 111) || isWithinRange(keyCode, 186, 192) || isWithinRange(keyCode, 219, 222) - ); + ) + return false; }; // Cancel the key event (e.g., prevent default delete behavior) @@ -244,17 +234,7 @@ }; this.handleEvent = (evt) => { - // const selectedNode = tinymce.activeEditor.selection.getNode(); - // if ( - // self.hasNonEditableNode(selectedNode) || - // self.hasNonEditableInChildren(selectedNode) - // ) { - // return self.cancelKey(evt); - // } - const range = tinymce.activeEditor.selection.getRng(); - const isBackspace = evt?.keyCode === 8; - const isDelete = evt?.keyCode === 46; if ( range.endContainer && @@ -265,52 +245,7 @@ } if (self.checkRange(range)) return self.cancelKey(evt); - - const endContainerText = range.endContainer.textContent || ''; - const isZwnbsp = - range.startContainer.textContent && - range.startContainer.textContent.charCodeAt(0) === 65279; - - const deleteWithinNode = - isDelete && - range.endOffset < endContainerText.length && - !(isZwnbsp && endContainerText.length === 1); - const backspaceWithinNode = - isBackspace && range.startOffset > (isZwnbsp ? 1 : 0); - const ctrlDanger = - evt.ctrlKey && - (isBackspace || isDelete) && - !hasTextAround( - range.startContainer.data, - range.startOffset, - isBackspace - ); - - // Allow the delete - if ((deleteWithinNode || backspaceWithinNode) && !ctrlDanger) { - return true; - } - - const noselection = range.startOffset === range.endOffset; - // If ctrl is a danger we need to skip this block and check the siblings which is done in the rest of this function - if (!ctrlDanger) { - if ( - isDelete && - noselection && - range.startOffset + 1 < range.endContainer.childElementCount - ) { - const elem = range.endContainer.childNodes[range.startOffset + 1]; - return self.check(elem) ? self.cancelKey(evt) : true; - } - - //The range is within this container - if (range.startOffset !== range.endOffset) { - //If this container is non-editable, cancel the event, otherwise allow the event - return self.checkRange(range) ? self.cancelKey(evt) : true; - } - } - - return !self.keyWillDelete(evt); + if (self.keyWillDelete(evt)) return self.cancelKey(evt); }; // Plugin logic to intercept keydown events and prevent deletion From 87e1ca36fa658b93af6eb09161cf75e975e219cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20C=C3=B4ng=20D=C5=A9ng?= Date: Wed, 8 Jan 2025 10:21:07 +0700 Subject: [PATCH 5/5] Handle not focus, click cursor to noneditable --- preventdelete.js | 142 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 114 insertions(+), 28 deletions(-) diff --git a/preventdelete.js b/preventdelete.js index bd62207..273985c 100644 --- a/preventdelete.js +++ b/preventdelete.js @@ -68,11 +68,12 @@ // Function to find the next editable element this.nextElement = (elem) => { let currentElem = elem; - let nextSibling = currentElem.nextElementSibling; + if (!currentElem) return; + let nextSibling = currentElem.nextSibling; while (!nextSibling) { currentElem = currentElem.parentElement; - if (currentElem?.id === self.rootId) return false; - nextSibling = currentElem.nextElementSibling; + if (!currentElem || currentElem?.id === self.rootId) return false; + nextSibling = currentElem.nextSibling; } return nextSibling; }; @@ -80,11 +81,11 @@ // Function to find the previous editable element this.prevElement = (elem) => { let currentElem = elem; - let prevSibling = currentElem.previousElementSibling; + let prevSibling = currentElem.previousSibling; while (!prevSibling) { currentElem = currentElem.parentElement; if (currentElem.id === self.rootId) return false; - prevSibling = currentElem.previousElementSibling; + prevSibling = currentElem.previousSibling; } return prevSibling; }; @@ -109,24 +110,43 @@ const isDelete = evt?.keyCode === 46; if (evt.shiftKey || evt.ctrlKey || isBackspace || isDelete) { - const selectedNode = tinymce.activeEditor.selection.getNode(); - const range = tinymce.activeEditor.selection.getRng(); - - const prevSibling = self.prevElement(range.startContainer); - const nextSibling = self.nextElement(range.startContainer); - const hasNonEditable = - self.hasNonEditableNode(selectedNode) || - self.hasNonEditableNode(range.startContainer) || - self.hasNonEditableInChildren(range.startContainer) || - ((!range.startContainer.textContent || - !range.startContainer.textContent.trim()) && + const selection = tinymce?.activeEditor?.selection; + const selectedNode = selection?.getNode?.(); + const range = selection?.getRng?.(); + if (!range) return; + + const startContainer = range.startContainer; + const prevSibling = self.prevElement(startContainer); + const nextSibling = self.nextElement(startContainer); + + const isEmptyStartContainer = + !range.startContainer.textContent || + !range.startContainer.textContent.trim(); + + const conditionHasNonEditable = { + hasNonEditableNode_selectedNode: + self.hasNonEditableNode(selectedNode), + hasNonEditableNode_startContainer: self.hasNonEditableNode( + range.startContainer + ), + hasNonEditableInChildren_startContainer: + self.hasNonEditableInChildren(range.startContainer), + isBackspaceWithNonEditablePrevSibling: + isEmptyStartContainer && + isBackspace && (self.hasNonEditableNode(prevSibling) || - self.hasNonEditableInChildren(prevSibling) || - self.hasNonEditableNode(nextSibling))); + self.hasNonEditableInChildren(prevSibling)), + isDeleteWithNonEditableNextSibling: + isEmptyStartContainer && + isDelete && + self.hasNonEditableNode(nextSibling), + }; + + const hasNonEditable = Object.values(conditionHasNonEditable).some( + (condition) => condition + ); - const noSelected = - range.startOffset === range.endOffset || - range?.startContainer.textContent === ''; + const noSelected = self.isNoSelected(range); // Handle delete empty line, press ctrl+delete, shift+delete, ctrl+backspace, shift+delete if (noSelected) { @@ -164,6 +184,7 @@ evt.ctrlKey && (['v', 'x', 'Delete', 'Backspace'].includes(evt.key) || [86, 88, 8, 46].includes(keyCode)) && + !noSelected && hasNonEditable ) { return self.cancelKey(evt); @@ -172,7 +193,10 @@ // Handle delete when next is mceNonEditable if (isDelete) { const nextSibling = self.nextElement(range.endContainer); - if (!nextSibling || self.hasNonEditableNode(nextSibling)) { + if ( + !range.startContainer.textContent.trim() && + (!nextSibling || self.hasNonEditableNode(nextSibling)) + ) { return self.cancelKey(evt); } } @@ -233,8 +257,16 @@ return node.querySelector(`.${self.preventDeleteClass}`) !== null; }; + this.isNoSelected = (range) => { + return ( + range.startOffset === range.endOffset || + range?.startContainer.textContent === '' + ); + }; + this.handleEvent = (evt) => { - const range = tinymce.activeEditor.selection.getRng(); + const range = tinymce?.activeEditor?.selection?.getRng?.(); + if (!range) return; if ( range.endContainer && @@ -249,17 +281,71 @@ }; // Plugin logic to intercept keydown events and prevent deletion - tinymce.PluginManager.add('preventdelete', (ed) => { - ed.on('keydown', (evt) => self.handleEvent(evt)); - ed.on('BeforeExecCommand', (evt) => { + tinymce.PluginManager.add('preventdelete', (editor) => { + editor.on('keydown', (evt) => self.handleEvent(evt)); + editor.on('BeforeExecCommand', (evt) => { + //? Handle when focus to notediable -> select nextSibling can editable + if (evt.command === 'mceFocus') { + const selection = evt.target?.selection; + const range = selection?.getRng?.(); + const noSelected = self.isNoSelected(range); + const end = selection?.getEnd(); + + // If not range and selected notediable + if (noSelected && self.hasNonEditableNode(end)) { + let selector = end; + while ( + selector?.nextSibling && + !( + self.hasNonEditableNode(selector?.nextSibling) || + self.hasNonEditableInChildren(selector?.nextSibling) + ) + ) { + selector = selector?.nextSibling; + } + return selection.setCursorLocation(selector, 0); + } + } + if ( ['Cut', 'Delete', 'Paste', 'mceInsertContent'].includes(evt.command) ) { - self.handleEvent(evt); + return self.handleEvent(evt); } return true; }); - ed.on('BeforeSetContent', (evt) => self.handleEvent(evt)); + editor.on('BeforeSetContent', (evt) => self.handleEvent(evt)); + editor.on('click', () => { + const selection = tinymce?.activeEditor?.selection; + let selectedNode = selection?.getNode?.(); + + if (!selectedNode) return; + + const checkBeforeNonEdiable = (selector) => + selector.matches( + '[data-mce-caret="before"], [data-mce-bogus="all"]' + ) && self.hasNonEditableNode(selector.nextSibling); + + let isBeforeNonEdiable = checkBeforeNonEdiable(selectedNode); + + // Check when click child of before NonEdiable + if ( + !isBeforeNonEdiable && + checkBeforeNonEdiable(selectedNode.parentElement) + ) { + isBeforeNonEdiable = true; + selectedNode = selectedNode.parentElement; + } + + if (isBeforeNonEdiable) { + let nextElement = selectedNode.nextSibling; + while (self.hasNonEditableNode(nextElement)) { + nextElement = nextElement.nextSibling; + } + + return selection.setCursorLocation(nextElement, 0); + } + }); }); }