UNPKG

smart-textarea

Version:

A simplistic textarea in browser that supports undo, redo, find, and replace.

714 lines (651 loc) 27.9 kB
import './utils.js' import './style.css' import SimpleUndo from 'simple-undo' import tippy from 'tippy.js' import './smart-textarea-icons.css' // Source: https://stackoverflow.com/questions/273789/is-there-a-version-of-javascripts-string-indexof-that-allows-for-regular-expr RegExp.escape = function (s) { return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); }; // Source: https://stackoverflow.com/questions/273789/is-there-a-version-of-javascripts-string-indexof-that-allows-for-regular-expr String.prototype.regexIndexOf = function (regex, startpos) { var indexOf = this.substring(startpos || 0).search(regex); return (indexOf >= 0) ? (indexOf + (startpos || 0)) : indexOf; } // Source: https://stackoverflow.com/questions/273789/is-there-a-version-of-javascripts-string-indexof-that-allows-for-regular-expr String.prototype.regexLastIndexOf = function (regex, startpos) { regex = (regex.global) ? regex : new RegExp(regex.source, "g" + (regex.ignoreCase ? "i" : "") + (regex.multiLine ? "m" : "")); if (typeof (startpos) == "undefined") { startpos = this.length; } else if (startpos < 0) { startpos = 0; } var stringToWorkWith = this.substring(0, startpos + 1); var lastIndexOf = -1; var nextStop = 0; while ((result = regex.exec(stringToWorkWith)) != null) { lastIndexOf = result.index; regex.lastIndex = ++nextStop; } return lastIndexOf; } String.prototype.regexFindNext = function (regex, startIndex) { // add global flag to regex so that lastIndex of regex.exec works regex = (regex.global) ? regex : new RegExp(regex.source, "g" + (regex.ignoreCase ? "i" : "") + (regex.multiLine ? "m" : "")); regex.lastIndex = startIndex || 0; var result = regex.exec(this); if (result === null) { var pos = -1; } else { var pos = result.index; var matchLength = result[0].length; } return { pos: pos, matchLength: matchLength }; } String.prototype.regexFindPrevious = function (regex, startIndex) { // add global flag to regex so that lastIndex of regex.exec works regex = (regex.global) ? regex : new RegExp(regex.source, "g" + (regex.ignoreCase ? "i" : "") + (regex.multiLine ? "m" : "")); if (typeof (startIndex) == "undefined") { startIndex = this.length; } else if (startIndex < 0) { startIndex = 0; } var stringToWorkWith = this.substring(0, startIndex + 1); var lastIndexOf = -1; var nextStop = 0; var matchLength; var result; while ((result = regex.exec(stringToWorkWith)) != null) { lastIndexOf = result.index; matchLength = result[0].length; regex.lastIndex = ++nextStop; } return { pos: lastIndexOf, matchLength: matchLength }; } String.prototype.replaceFrom = function (search, replace, startIndex) { if (startIndex >= 0) { return this.substring(0, startIndex) + this.substring(startIndex).replace(search, replace); } else { return this.replace(search, replace); } } // Based on: https://stackoverflow.com/a/7781395/6798201 class SmartTextarea { constructor(textarea, options) { if (textarea === undefined) { throw new Error("No target textarea specified!"); } else { this.textarea = textarea; this.textarea.classList.add("FARTextarea"); } const defaultOptions = { isCaseSensitive: false, isWholeWord: false, isRegex: false, maxHistoryLength: 100, } const mergedOptions = { ...defaultOptions, ...options }; Object.keys(mergedOptions).forEach((key) => { this[key] = mergedOptions[key]; }); this.findMode = false; // find next search result when ENTER is pressed this.findAndReplaceMode = false; // find and replace next search result when ENTER is pressed // api source: https://github.com/mattjmattj/simple-this.undo const that = this; this.history = new SimpleUndo({ maxLength: this.maxHistoryLength, provider: (done) => { done(that.getContent.bind(that)()); } }); this.history.initialize(this.getContent()); this._surroundTextareaWithDiv(); this._createFARPanel(); this._initializeFARComponentNames(); // add btn-hover style to turned on buttons if (this.isCaseSensitive){ this.caseSensitiveBtn.classList.add("btn-hover"); } if (this.isWholeWord){ this.wholeWordBtn.classList.add("btn-hover"); } if (this.isRegex){ this.useRegexBtn.classList.add("btn-hover"); } this._initializeTermNotFoundTooltip(); this._setUpTextarea(); this._positionFARPanel(); this._setUpFARInputs(); this._setUpFARButtons(); this.constructor._setUpButtonHoverStyle(); } _initializeFARComponentNames() { const componentNameList = [ "expandBtn", "findField", "termSearch", "caseSensitiveBtn", "wholeWordBtn", "useRegexBtn", "findPreviousBtn", "findNextBtn", "closeFARPanelBtn", "replaceField", "termReplace", "findAndReplaceBtn", "replaceAllBtn" ]; const that = this; componentNameList.forEach(function (name) { console.log('TCL: SmartTextarea -> _initializeFARComponentNames -> name', name); that[name] = that.FARPanel.querySelector(`.${name}`); }); hide(this.replaceField); } _surroundTextareaWithDiv() { const smartTextarea = document.createElement("div"); smartTextarea.classList.add("smartTextarea"); this.textarea.insertAdjacentElement("beforebegin", smartTextarea); smartTextarea.appendChild(this.textarea); } // create search and replace panel _createFARPanel() { this.textarea.insertAdjacentHTML("afterend", `<div class="FARPanel"> <span class="expandBtn btn"><i class="icon-right-triangle"></i></span> <div class="findField"> <div> <input type="text" class="termSearch" placeholder="Find" /> <span class="caseSensitiveBtn btn" title="Match Case"><i class="icon-caseSensitivity"></i></span> <span class="wholeWordBtn btn" title="Match Whole Word"><i class="icon-wholeWord"></i></span> <span class="useRegexBtn btn" title="Use Regular Expression"><i class="icon-useRegex"></i></span> </div> <span class="findPreviousBtn btn" title="Find Previous"><i class="icon-arrow-left"></i></span> <span class="findNextBtn btn" title="Find Next"><i class="icon-arrow-right"></i></span> <span class="closeFARPanelBtn btn" title="Close"><i class="icon-close"></i></span> </div> <div class="replaceField"> <input type="text" class="termReplace" placeholder="Replace" title="Replace" /> <span class="findAndReplaceBtn btn" title="Find & Replace"><i class="icon-findAndReplace"></i></span> <span class="replaceAllBtn btn" title="Replace All"><i class="icon-replaceAll"></i></span> </div> </div>` ); this.FARPanel = this.textarea.nextElementSibling; console.log('TCL: SmartTextarea -> _createFARPanel -> FARPanel', this.FARPanel); } _setUpTextarea() { this.textarea.addEventListener("keydown", (e) => { var evtobj = window.event ? event : e // detect ctrl+z (this.undo) if (evtobj.keyCode == 90 && evtobj.ctrlKey) { this.undo(); }; // detect ctrl+y (this.redo) if (evtobj.keyCode == 89 && evtobj.ctrlKey) { this.redo(); }; // detect ENTER if (evtobj.keyCode === 13) { if (this.findMode) { e.preventDefault(); this.findNext(); this._detectCursorMove(this.textarea).then(() => { console.log("cursor moved!"); this.findMode = false; }); } else if (this.findAndReplaceMode) { e.preventDefault(); this.findAndReplace(); this._detectCursorMove(this.textarea).then(() => { this.findAndReplaceMode = false; }); } } this._toggleFARPanel(e); }); this.closeFARPanelBtn.addEventListener("click", (e) => { toggleShowHide(this.FARPanel, "table"); this.textarea.focus(); }); } // position find and replace panel _positionFARPanel() { if (this.textarea.clientWidth >= 800 && this.textarea.clientHeight >= 300) { this.FARPanel.style.top = 0; this.FARPanel.style.right = 0; this.FARPanel.style.margin = 0; } } // set up search and replace input boxes _setUpFARInputs() { // disable default this.undo in search and replace inputs and textarea this.termSearch.addEventListener("keydown", this.constructor._disableUndo); this.termSearch.addEventListener("input", () => { // turn off tooltip alert this._hideTermNotFoundTooltip(); }); this.termReplace.addEventListener("keydown", this.constructor._disableUndo); this.termSearch.addEventListener("keyup", this.constructor._disableUndo); this.termReplace.addEventListener("keyup", this.constructor._disableUndo); this.textarea.addEventListener("keyup", this.constructor._disableUndo); this.textarea.addEventListener("keydown", this.constructor._disableUndo); // store history for custom this.undo in textarea this.previousContent = this.getContent(); this.textarea.addEventListener("input", this.updateHistory.bind(this)); // set up inputs this.termSearch.addEventListener("keydown", (e) => { this._toggleFARPanel(e); var evtobj = window.event ? event : e // detect ENTER if (evtobj.keyCode === 13) { e.preventDefault(); console.log('TCL: SmartTextarea -> _setUpFARInputs -> this.findNext', this.findNext); console.log('TCL: SmartTextarea -> _setUpFARInputs -> this', this); this.findNext(); this.findMode = true; this._detectCursorMove(this.textarea).then(() => { console.log("cursor moved!"); this.findMode = false; }); } }); this.termReplace.addEventListener("keydown", (e) => { this._toggleFARPanel(e); var evtobj = window.event ? event : e // detect ENTER if (evtobj.keyCode === 13) { e.preventDefault(); this.findAndReplace(); this.findAndReplaceMode = true; this._detectCursorMove(this.textarea).then(() => { console.log("cursor moved!"); this.findAndReplaceMode = false; }); } }); } // set up buttons in FARPanel _setUpFARButtons() { // show/hide replaceField this.expandBtn.addEventListener("click", () => { const icon = this.expandBtn.firstElementChild; if (icon.classList.contains("icon-right-triangle")){ icon.classList.remove("icon-right-triangle"); icon.classList.add("icon-down-triangle"); } else{ icon.classList.remove("icon-down-triangle"); icon.classList.add("icon-right-triangle"); } toggleShowHide(this.replaceField, "table-row"); }); this.caseSensitiveBtn.addEventListener("click", (e) => { this.isCaseSensitive = !this.isCaseSensitive; this.caseSensitiveBtn.classList.toggle("btn-hover"); }); this.wholeWordBtn.addEventListener("click", (e) => { this.isWholeWord = !this.isWholeWord; this.wholeWordBtn.classList.toggle("btn-hover"); }); this.useRegexBtn.addEventListener("click", (e) => { this.isRegex = !this.isRegex; this.useRegexBtn.classList.toggle("btn-hover"); }); // use bind to assign the right object to "this" this.findPreviousBtn.addEventListener("click", this.findPrevious.bind(this)); this.findNextBtn.addEventListener("click", this.findNext.bind(this)); this.findAndReplaceBtn.addEventListener("click", this.findAndReplace.bind(this)); this.replaceAllBtn.addEventListener("click", this.replaceAll.bind(this)); } // Detect mouse hovering on buttons and switch styles static _setUpButtonHoverStyle() { document.addEventListener("mouseover", this.toggleBtnHighlight); document.addEventListener("mouseout", this.toggleBtnHighlight); } _detectCursorMove(input) { // clear previous interval if (this.detectCursorMoveTimeId) { clearInterval(this.detectCursorMoveTimeId); } return new Promise((resolve, reject) => { let lastCursorPosition = this.constructor.getCursorPos(input); const timeId = setInterval(() => { console.log(`timeId ${timeId} detecting cursor move...`); if (input !== document.activeElement) { // input not on focus clearInterval(timeId); resolve("input out of focus!"); } else { let currentCursorPosition = this.constructor.getCursorPos(input); if (!this.constructor.isSameCursorPosition(currentCursorPosition, lastCursorPosition)) { console.log('TCL: timeId -> lastCursorPosition', lastCursorPosition); console.log('TCL: timeId -> currentCursorPosition', currentCursorPosition); console.log('TCL: timeId -> timeId', timeId); clearInterval(timeId); resolve("cursor moved!"); } lastCursorPosition = currentCursorPosition; } }, 100); this.detectCursorMoveTimeId = timeId; }); } _toggleFARPanel(e) { var evtobj = window.event ? event : e // detect ctrl+f (find) if (evtobj.keyCode == 70 && evtobj.ctrlKey) { console.log('TCL: toggleFARPanel -> ctrl+f is pressed!'); e.preventDefault(); this.search(); }; // detect esc (Escape) if (evtobj.keyCode == 27) { this.textarea.focus(); toggleShowHide(this.FARPanel, "table"); } } search() { const selectedText = window.getSelection().toString(); if (this.termSearch.value === selectedText) { console.log('TCL: SmartTextareaBase -> search -> selectedText', selectedText); toggleShowHide(this.FARPanel, "table", () => {}, // show callback () => { // hide callback this.textarea.focus(); }); if (selectedText === ""){ this.termSearch.focus(); } } else { if (selectedText !== "") { show(this.FARPanel, "table"); this.termSearch.value = selectedText; this.findMode = true; this.textarea.focus(); } else { toggleShowHide(this.FARPanel, "table", () => {this.termSearch.focus();}, () => {this.textarea.focus();}); } } } _initializeTermNotFoundTooltip() { // term not found tooltip this.notFoundTooltip = tippy(this.termSearch, { trigger: "manual", animation: "perspective", }); console.log('TCL: SmartTextarea -> _initializeTermNotFoundTooltip -> this.notFoundTooltip', this.notFoundTooltip); } _showTermNotFoundTooltip() { this.notFoundTooltip.setContent(this.termSearch.value + " not found!"); this.notFoundTooltip.show(); } _hideTermNotFoundTooltip() { this.notFoundTooltip.hide(); } updateHistory() { const content = this.getContent(); const difference = this.constructor._getDifference(this.previousContent, content); console.log('TCL: difference', difference); const lastVersionIndex = this.history.count(); if ((difference.length === 1 && /\W/.test(difference)) || difference.length > 1 || lastVersionIndex === 0) { console.log("Saving latest history version..."); this.history.save(); } else { // update last history version this.history.stack[lastVersionIndex] = content; } this.previousContent = content; } static _disableUndo(e) { var evtobj = window.event ? event : e // disable ctrl+z (this.undo) if (evtobj.keyCode == 90 && evtobj.ctrlKey) { // console.log("preventing this.undo..."); e.preventDefault(); }; }; findNext() { this.find(true); } findPrevious() { console.log('TCL: SmartTextarea -> findNext -> this', this); this.find(false); } find(lookForNext = true) { console.log('TCL: this.find -> find'); const textarea = this.textarea; // collect variables var txt = textarea.value; var searchRegex = this.termSearch.value; searchRegex = this.processRegexPattern(searchRegex); console.log('TCL: this.find -> strSearchTerm', searchRegex); // find next index of searchterm, starting from current cursor position var cursorPosEnd = this.constructor.getCursorPosEnd(textarea); console.log('TCL: this.find -> cursorPos', cursorPosEnd); if (lookForNext) { // next match const result = txt.regexFindNext(searchRegex, cursorPosEnd); var termPos = result.pos; var searchTermLength = result.matchLength; } else { // previous match var cursorPosStart = this.constructor.getCursorPosStart(textarea) - 1; if (cursorPosStart < 0) { var termPos = -1; } else { const result = txt.regexFindPrevious(searchRegex, cursorPosStart); var termPos = result.pos; var searchTermLength = result.matchLength; } } // if found, select it if (termPos != -1) { this.constructor.setSelectionRange(textarea, termPos, termPos + searchTermLength); } else { // not found from cursor pos if (lookForNext) { // so start from beginning const result = txt.regexFindNext(searchRegex, 0); termPos = result.pos; searchTermLength = result.matchLength; } else { // so start from end const result = txt.regexFindPrevious(searchRegex, txt.length); var termPos = result.pos; var searchTermLength = result.matchLength; } if (termPos != -1) { this.constructor.setSelectionRange(textarea, termPos, termPos + searchTermLength); if (searchTermLength === undefined) { this.find(lookForNext); } } else { this._showTermNotFoundTooltip(); } } }; findAndReplace() { const textarea = this.textarea; // collect variables var origTxt = textarea.value; // needed for text replacement var txt = textarea.value; var searchRegex = this.termSearch.value; var replaceRegex = this.termReplace.value; searchRegex = this.processRegexPattern(searchRegex); // find next index of searchterm, starting from current cursor position var cursorPos = this.constructor.getCursorPosEnd(textarea); const result = txt.regexFindNext(searchRegex, cursorPos); var termPos = result.pos; var searchTermLength = result.matchLength; console.log('TCL: this.findAndReplace -> searchTermLength', searchTermLength); console.log('TCL: this.findAndReplace -> termPos', termPos); var newText = ''; var replaceTerm = () => { newText = origTxt.replaceFrom(searchRegex, replaceRegex, termPos); console.log('TCL: this.findAndReplace -> strReplaceWith', replaceRegex); console.log('TCL: this.findAndReplace -> strSearchTerm', searchRegex); let replaceTermLength = searchTermLength + (newText.length - origTxt.length); console.log('TCL: replaceTerm -> replaceTermLength', replaceTermLength); textarea.value = newText; this.constructor.setSelectionRange(textarea, termPos, termPos + replaceTermLength); this.history.save(); } // if found, replace it, then select it if (termPos != -1) { replaceTerm(); } else { // not found from cursor pos, so start from beginning const result = txt.regexFindNext(searchRegex, 0); termPos = result.pos; searchTermLength = result.matchLength; if (termPos != -1) { replaceTerm(); } else { this._showTermNotFoundTooltip(); } } }; replaceAll() { const textarea = this.textarea; // collect variables var txt = textarea.value; var strSearchTerm = this.termSearch.value; strSearchTerm = this.processRegexPattern(strSearchTerm); // find all occurances of search string var matches = []; var pos = txt.regexIndexOf(strSearchTerm); while (pos > -1) { matches.push(pos); pos = txt.regexIndexOf(strSearchTerm, pos + 1); } for (var match in matches) { this.findAndReplace(); } }; processRegexPattern(regexStr) { let regex = regexStr; // escape special characters if search term is NOT a regular expression if (this.isRegex === false) { regex = RegExp.escape(regexStr); } // match whole word or not if (this.isWholeWord) { regex = `\\b${regex}\\b`; } // make text lowercase if search is supposed to be case insensitive if (this.isCaseSensitive === false) { regex = new RegExp(regex, "i"); } else { regex = new RegExp(regex); } return regex; } // this.undo changes in textarea undo() { this.history.undo(this.setContent.bind(this)); } // this.redo changes in textarea redo() { this.history.redo(this.setContent.bind(this)); } getContent() { return this.constructor._copyString(this.textarea.value); } setContent(newContent) { this.textarea.value = newContent; } /************************* Util methods ***********************/ static toggleBtnHighlight(e) { const hoveredButton = e.target.closest(".btn"); if (hoveredButton) { hoveredButton.classList.toggle("btn-hover"); } } static getCursorPosEnd(input) { return this.getCursorPos(input).end; } static getCursorPosStart(input) { return this.getCursorPos(input).start; } // source: https://stackoverflow.com/a/7745998/6798201 static getCursorPos(input) { input.focus(); if ("selectionStart" in input && document.activeElement == input) { return { start: input.selectionStart, end: input.selectionEnd }; } else if (input.createTextRange) { var sel = document.selection.createRange(); if (sel.parentElement() === input) { var rng = input.createTextRange(); rng.moveToBookmark(sel.getBookmark()); for (var len = 0; rng.compareEndPoints("EndToStart", rng) > 0; rng.moveEnd("character", -1)) { len++; } rng.setEndPoint("StartToStart", input.createTextRange()); for (var pos = { start: 0, end: len }; rng.compareEndPoints("EndToStart", rng) > 0; rng.moveEnd("character", -1)) { pos.start++; pos.end++; } return pos; } } return -1; } static isSameCursorPosition(posA, posB) { return (posA.start === posB.start && posA.end === posB.end); } // Set selection of text in a textarea // and scroll selection into middle of the screen // Based on: https://stackoverflow.com/a/53082182/6798201 static setSelectionRange(textarea, selectionStart, selectionEnd) { const fullText = textarea.value; textarea.value = fullText.substring(0, selectionEnd); const scrollHeight = textarea.scrollHeight textarea.value = fullText; let scrollTop = scrollHeight; console.log('TCL: setSelectionRange -> scrollTop', scrollTop); const textareaHeight = textarea.clientHeight; if (scrollTop > textareaHeight) { scrollTop -= textareaHeight / 2; } else { scrollTop = 0; } console.log('TCL: setSelectionRange -> scrollTop', scrollTop); textarea.scrollTop = scrollTop; textarea.setSelectionRange(selectionStart, selectionEnd); } // source: https://stackoverflow.com/a/31733628/6798201 static _copyString(str) { return (' ' + str).slice(1) } // source: https://stackoverflow.com/a/29574724/6798201 //assuming "b" contains a subsequence containing //all of the letters in "a" in the same order static _getDifference(a, b) { var i = 0; var j = 0; var result = ""; while (j < b.length) { if (a[i] != b[j] || i == a.length) result += b[j]; else i++; j++; } return result; } } // export the class for further composition // browser global if (typeof window !== 'undefined') { window.SmartTextarea = SmartTextarea; } export {SmartTextarea};