From 62aca4caf7a5e457f077058b8bd0112544487c8e Mon Sep 17 00:00:00 2001 From: Julian Shapiro Date: Wed, 6 Aug 2014 17:25:32 -0700 Subject: [PATCH] Refactor and Zepto Support --- jquery.blast.js | 624 +++++++++++++++++++++----------------------- jquery.blast.min.js | 5 +- 2 files changed, 306 insertions(+), 323 deletions(-) diff --git a/jquery.blast.js b/jquery.blast.js index 63377d7..6b00ffb 100644 --- a/jquery.blast.js +++ b/jquery.blast.js @@ -4,7 +4,8 @@ /*! * Blast.js: Blast text apart to make it manipulable. -* @version 0.1.1 +* @version 0.2.0 +* @dependency Works with both jQuery and Zepto. * @docs julian.com/research/blast * @license Copyright 2014 Julian Shapiro. MIT License: http://en.wikipedia.org/wiki/MIT_License */ @@ -39,13 +40,13 @@ })(); /* Shim to prevent console.log() from throwing errors on IE<=7. */ - var console = window.console || { log: function () {} }; + var console = window.console || { log: function () {}, time: function () {} }; /***************** Constants *****************/ - var pluginName = "blast", + var NAME = "blast", characterRanges = { latinPunctuation: "–—′’'“″„\"(«.…¡¿′’'”″“\")».…!?", latinLetters: "\\u0041-\\u005A\\u0061-\\u007A\\u00C0-\\u017F\\u0100-\\u01FF\\u0180-\\u027F" @@ -57,191 +58,251 @@ onlyContainsPunctuation: new RegExp("[^" + characterRanges.latinPunctuation + "]"), adjoinedPunctuation: new RegExp("^[" + characterRanges.latinPunctuation + "]+|[" + characterRanges.latinPunctuation + "]+$", "g"), skippedElements: /(script|style|select|textarea)/i, - hasPluginClass: new RegExp("(^| )" + pluginName + "( |$)", "gi") + hasPluginClass: new RegExp("(^| )" + NAME + "( |$)", "gi") }; - /************************* - Punctuation Escaping - *************************/ - - /* Escape likely false-positives for sentence-final periods. Escaping is performed by converting a character into its ASCII equivalent and wrapping it in double curly brackets. */ - function escapePeriods (text) { - /* Escape the following Latin abbreviations and English titles: e.g., i.e., Mr., Mrs., Ms., Dr., Prof., Esq., Sr., and Jr. */ - text = text.replace(Reg.abbreviations, function(match) { - return match.replace(/\./g, "{{46}}"); - }); - - /* Escape inner-word (non-space-delimited) periods, e.g. Blast.js. */ - text = text.replace(Reg.innerWordPeriod, function(match) { - return match.replace(/\./g, "{{46}}"); - }); - - return text; - } - - /* decodePunctuation() is used to decode the output of escapePeriods() and punctuation that has been manually escaped by users. */ - function decodePunctuation (text) { - return text.replace(/{{(\d{1,3})}}/g, function(fullMatch, subMatch) { - return String.fromCharCode(subMatch); - }); - } - - /*********************** - Wrapper Generation - ***********************/ - - function wrapNode (node, opts) { - var wrapper = document.createElement(opts.tag); + /**************** + $.fn.blast + ****************/ - /* At minimum, assign the element a class of "blast". */ - wrapper.className = pluginName; + $.fn[NAME] = function (options) { - if (opts.customClass) { - wrapper.className += " " + opts.customClass; + /************************* + Punctuation Escaping + *************************/ - /* generateIndexID: If an opts.customClass is provided, generate an ID consisting of customClass and a number indicating this match's iteration. */ - if (opts.generateIndexID) { - wrapper.id = opts.customClass + "-" + Element.blastedCount; - } + /* Escape likely false-positives of sentence-final periods. Escaping is performed by wrapping a character's ASCII equivalent in double curly brackets, + which is then reversed (deencodcoded) after delimiting. */ + function encodePunctuation (text) { + return text + /* Escape the following Latin abbreviations and English titles: e.g., i.e., Mr., Mrs., Ms., Dr., Prof., Esq., Sr., and Jr. */ + .replace(Reg.abbreviations, function(match) { + return match.replace(/\./g, "{{46}}"); + }) + /* Escape inner-word (non-space-delimited) periods. For example, the period inside "Blast.js". */ + .replace(Reg.innerWordPeriod, function(match) { + return match.replace(/\./g, "{{46}}"); + }); } - /* generateValueClass: Assign the element a class equal to its escaped inner text. Only applicable to the character and word delimiters (since they do not contain spaces). */ - if (opts.generateValueClass === true && (opts.delimiter === "character" || opts.delimiter === "word")) { - var valueClass, - text = node.data; - - /* For the word delimiter, remove adjoined punctuation, which is unlikely to be desired as part of the match. */ - /* But, if the text consists purely of punctuation characters (e.g. "!!!"), leave the text as it is. */ - if (opts.delimiter === "word" && Reg.onlyContainsPunctuation.test(text)) { - /* E: Remove punctuation that's adjoined to either side of the word match. */ - text = text.replace(Reg.adjoinedPunctuation, ""); - } - - valueClass = pluginName + "-" + opts.delimiter + "-" + text.toLowerCase(); - - wrapper.className += " " + valueClass; + /* Used to decode both the output of encodePunctuation() and punctuation that has been manually escaped by users. */ + function decodePunctuation (text) { + return text.replace(/{{(\d{1,3})}}/g, function(fullMatch, subMatch) { + return String.fromCharCode(subMatch); + }); } - wrapper.appendChild(node.cloneNode(false)); - - return wrapper; - } + /****************** + DOM Traversal + ******************/ - /****************** - DOM Traversal - ******************/ + function wrapNode (node, opts) { + var wrapper = document.createElement(opts.tag); - function traverseDOM (node, opts) { - var matchPosition = -1, - skipNodeBit = 0; + /* Assign the element a class of "blast". */ + wrapper.className = NAME; - /* Only proceed if the node is a text node and isn't empty. */ - if (node.nodeType === 3 && node.data.length) { - /* Perform punctuation encoding/decoding once per original whole text node (before it gets split up into bits). */ - if (Element.nodeBeginning) { - /* For the sentence delimiter, we escape likely false-positive sentence-final punctuation before we execute the RegEx. */ - /* For all other delimiters, we must decode manually-escaped punctuation so that the RegEx can match correctly. */ - node.data = (opts.delimiter === "sentence") ? escapePeriods(node.data) : decodePunctuation(node.data); + /* If a custom class was provided, assign that too. */ + if (opts.customClass) { + wrapper.className += " " + opts.customClass; - Element.nodeBeginning = false; + /* If an opts.customClass is provided, generate an ID consisting of customClass and a number indicating the match's iteration. */ + if (opts.generateIndexID) { + wrapper.id = opts.customClass + "-" + Element.blastedIndex; + } } - matchPosition = node.data.search(Element.delimiterRegex); + /* Assign the element a class equal to its escaped inner text. Only applicable to the character and word delimiters (since they do not contain spaces). */ + if (opts.generateValueClass === true && (opts.delimiter === "character" || opts.delimiter === "word")) { + var valueClass, + text = node.data; + + /* For the word delimiter, remove adjoined punctuation, which is unlikely to be desired as part of the match -- unless the text + consists solely of punctuation (e.g. "!!!"), in which case we leave the text as-is. */ + if (opts.delimiter === "word" && Reg.onlyContainsPunctuation.test(text)) { + /* E: Remove punctuation that's adjoined to either side of the word match. */ + text = text.replace(Reg.adjoinedPunctuation, ""); + } - /* If there's a RegEx match in this text node, proceed with element wrapping. */ - if (matchPosition !== -1) { - var match = node.data.match(Element.delimiterRegex), - matchText = match[0], - subMatchText = match[1] || false; + valueClass = NAME + "-" + opts.delimiter.toLowerCase() + "-" + text.toLowerCase(); - Element.blastedCount++; + wrapper.className += " " + valueClass; + } - /* RegEx queries that can return empty strings (e.g ".*") produce an empty matchText which throws the entire traversal process into an infinite loop due to the position index not incrementing. - Thus, we bump up the position index manually, resulting in a zero-width split at this location followed by the continuation of the traversal process. */ - if (matchText === "") { - matchPosition++; - /* If a RegEx submatch is produced that is not identical to the full string match, assume the submatch's index position and text. This technique allows us to avoid writing multi-part RegEx queries. */ - } else if (subMatchText && subMatchText !== matchText) { - matchPosition += matchText.indexOf(subMatchText); - matchText = subMatchText; - } + wrapper.appendChild(node.cloneNode(false)); - /* Split this text node into two separate nodes at the position of the match, returning the node that begins after the match position. */ - var middleBit = node.splitText(matchPosition); + return wrapper; + } - /* Split the newly-produced text node at the end of the match's text so that middleBit is a text node that consists solely of the matched text. - The other newly-created text node, which begins at the end of the match's text, is what will be traversed in the subsequent loop (in order to find additional matches in the same original text node). */ - middleBit.splitText(matchText.length); + function traverseDOM (node, opts) { + var matchPosition = -1, + skipNodeBit = 0; - /* Over-increment the loop counter (see below) so that we skip the extra node (middleBit) that we've just created (and already processed). */ - skipNodeBit = 1; + /* Only proceed if the node is a text node and isn't empty. */ + if (node.nodeType === 3 && node.data.length) { + /* Perform punctuation encoding/decoding once per original whole text node (before it gets split up into bits). */ + if (Element.nodeBeginning) { + /* For the sentence delimiter, we first escape likely false-positive sentence-final punctuation. For all other delimiters, + we must decode the user's manually-escaped punctuation so that the RegEx can match correctly (without being thrown off by characters in {{ASCII}}). */ + node.data = (opts.delimiter === "sentence") ? encodePunctuation(node.data) : decodePunctuation(node.data); - /* We couldn't previously decode punctuation for the sentence delimiter. We do so now. */ - if (opts.delimiter === "sentence") { - middleBit.data = decodePunctuation(middleBit.data); + Element.nodeBeginning = false; } - var wrappedNode = wrapNode(middleBit, opts, Element.blastedCount); + matchPosition = node.data.search(delimiterRegex); + + /* If there's a RegEx match in this text node, proceed with element wrapping. */ + if (matchPosition !== -1) { + var match = node.data.match(delimiterRegex), + matchText = match[0], + subMatchText = match[1] || false; + + Element.blastedIndex++; + + /* RegEx queries that can return empty strings (e.g ".*") produce an empty matchText which throws the entire traversal process into an infinite loop due to the position index not incrementing. + Thus, we bump up the position index manually, resulting in a zero-width split at this location followed by the continuation of the traversal process. */ + if (matchText === "") { + matchPosition++; + /* If a RegEx submatch is produced that is not identical to the full string match, use the submatch's index position and text. + This technique allows us to avoid writing multi-part RegEx queries for submatch finding. */ + } else if (subMatchText && subMatchText !== matchText) { + matchPosition += matchText.indexOf(subMatchText); + matchText = subMatchText; + } - /* Replace the middleBit node with its wrapped version. */ - middleBit.parentNode.replaceChild(wrappedNode, middleBit); + /* Split this text node into two separate nodes at the position of the match, returning the node that begins after the match position. */ + var middleBit = node.splitText(matchPosition); - /* Push the wrapper onto the Call.generatedElements array. */ - Call.generatedElements.push(wrappedNode); + /* Split the newly-produced text node at the end of the match's text so that middleBit is a text node that consists solely of the matched text. The other newly-created text node, which begins + at the end of the match's text, is what will be traversed in the subsequent loop (in order to find additional matches in the containing text node). */ + middleBit.splitText(matchText.length); - /* Note: We use this slow splice-then-iterate method because every match needs to be converted into an HTML element node. A text node's text cannot have HTML elements inserted into it. */ - /* Todo: To improve performance, use documentFragments to delay node manipulation so that DOM queries and updates can be batched across elements. */ - } - /* Traverse the DOM tree until we find text nodes. Skip script and style elements. Skip select and textarea elements (which contain text nodes that cannot/should not be wrapped). - Additionally, check for the existence of our plugin's class to ensure that we do not traverse pre-Blasted elements. */ - /* Note: The basic DOM traversal technique is copyright Johann Burkard . Licensed under the MIT License: http://en.wikipedia.org/wiki/MIT_License */ - } else if (node.nodeType === 1 && node.hasChildNodes() && !Reg.skippedElements.test(node.tagName) && !Reg.hasPluginClass.test(node.className)) { - /* Note: We don't cache childNodes' length since it's a live nodeList (which changes dynamically with the use of splitText() above). */ - for (var i = 0; i < node.childNodes.length; i++) { - Element.nodeBeginning = true; - i += traverseDOM(node.childNodes[i], opts); - } - } + /* Over-increment the loop counter (see below) so that we skip the extra node (middleBit) that we've just created (and already processed). */ + skipNodeBit = 1; - return skipNodeBit; - } + if (opts.delimiter === "sentence") { + /* Now that we've forcefully escaped all likely false-positive sentence-final punctuation, we must decode the punctuation back from ASCII. */ + middleBit.data = decodePunctuation(middleBit.data); + } - /******************* - Call Variables - *******************/ + /* Create the wrapped node. */ + var wrappedNode = wrapNode(middleBit, opts, Element.blastedIndex); + /* Then replace the middleBit text node with its wrapped version. */ + middleBit.parentNode.replaceChild(wrappedNode, middleBit); - /* Call-specific data dontainer. */ - var Call = { - /* Keep track of the elements generated by Blast so that they can optionally be pushed onto the jQuery call stack. */ - generatedElements: [] - }, - /* Element-specific data container. */ - Element = {}; + /* Push the wrapper onto the Element.wrappers array (for later use with stack manipulation). */ + Element.wrappers.push(wrappedNode); - /**************** - $.fn.blast - ****************/ + /* Note: We use this slow splice-then-iterate method because every match needs to be converted into an HTML element node. A text node's text cannot have HTML elements inserted into it. */ + /* TODO: To improve performance, use documentFragments to delay node manipulation so that DOM queries and updates can be batched across elements. */ + } + /* Traverse the DOM tree until we find text nodes. Skip script and style elements. Skip select and textarea elements since they contain special text nodes that users would not want wrapped. + Additionally, check for the existence of our plugin's class to ensure that we do not retraverse elements that have already been blasted. */ + /* Note: This basic DOM traversal technique is copyright Johann Burkard . Licensed under the MIT License: http://en.wikipedia.org/wiki/MIT_License */ + } else if (node.nodeType === 1 && node.hasChildNodes() && !Reg.skippedElements.test(node.tagName) && !Reg.hasPluginClass.test(node.className)) { + /* Note: We don't cache childNodes' length since it's a live nodeList (which changes dynamically with the use of splitText() above). */ + for (var i = 0; i < node.childNodes.length; i++) { + Element.nodeBeginning = true; + + i += traverseDOM(node.childNodes[i], opts); + } + } - $.fn.blast = function (options) { + return skipNodeBit; + } - /***************** - Known Issues - *****************/ + /******************* + Call Variables + *******************/ + + var opts = $.extend({}, $.fn[NAME].defaults, options), + delimiterRegex, + /* Container for variables specific to each element targeted by the Blast call. */ + Element = {}; + + /*********************** + Delimiter Creation + ***********************/ + + /* Ensure that the opts.delimiter search variable is a non-empty string. */ + if (opts.search === true && $.type(opts.delimiter) === "string" && $.trim(opts.delimiter).length) { + /* Since the search is performed as a Regex (see below), we escape the string's Regex meta-characters. */ + opts.delimiter = opts.delimiter.replace(/[-[\]{,}(.)*+?|^$\\\/]/g, "\\$&"); + + /* Note: This matches the apostrophe+s of the phrase's possessive form: {PHRASE's}. */ + /* Note: This will not match text that is part of a compound word (two words adjoined with a dash), e.g. "front" won't match inside "front-end". */ + /* Note: Based on the way the search algorithm is implemented, it is not possible to search for a string that consists solely of punctuation characters. */ + /* Note: By creating boundaries at Latin alphabet ranges instead of merely spaces, we effectively match phrases that are inlined alongside any type of non-Latin-letter, + e.g. word|, word!, ♥word♥ will all match. */ + delimiterRegex = new RegExp("(?:^|[^-" + characterRanges.latinLetters + "])(" + opts.delimiter + "('s)?)(?![-" + characterRanges.latinLetters + "])", "i"); + } else { + /* Normalize the string's case for the delimiter switch check below. */ + if ($.type(opts.delimiter) === "string") { + opts.delimiter = opts.delimiter.toLowerCase(); + } + + switch (opts.delimiter) { + case "letter": + case "char": + case "character": + /* Matches every non-space character. */ + /* Note: The character delimiter is unique in that it makes it cumbersome for some screenreaders to read Blasted text — since each letter will be read one-at-a-time. + Thus, when using the character delimiter, it is recommended that your use of Blast is temporary, e.g. to animate letters into place before thereafter reversing Blast. */ + /* Note: This is the slowest delimiter. However, its slowness is only noticeable when it's used on larger bodies of text (of over 500 characters) on <=IE8. + (Run Blast with opts.debug=true to monitor execution times.) */ + delimiterRegex = /(\S)/; + break; + + case "word": + /* Matches strings in between space characters. */ + /* Note: Matches will include any punctuation that's adjoined to the word, e.g. "Hey!" will be a full match. */ + /* Note: Remember that, with Blast, every HTML element marks the start of a brand new string. Hence, "inside" matches as three separate words. */ + delimiterRegex = /\s*(\S+)\s*/; + break; + + case "sentence": + /* Matches phrases either ending in Latin alphabet punctuation or located at the end of the text. (Linebreaks are not considered punctuation.) */ + /* Note: If you don't want punctuation to demarcate a sentence match, replace the punctuation character with {{ASCII_CODE_FOR_DESIRED_PUNCTUATION}}. ASCII codes: .={{46}}, ?={{63}}, !={{33}} */ + delimiterRegex = /(?=\S)(([.]{2,})?[^!?]+?([.…!?]+|(?=\s+$)|$)(\s*[′’'”″“")»]+)*)/; + /* RegExp explanation (Tip: Use Regex101.com to play around with this expression and see which strings it matches): + - Expanded view: /(?=\S) ( ([.]{2,})? [^!?]+? ([.…!?]+|(?=\s+$)|$) (\s*[′’'”″“")»]+)* ) + - (?=\S) --> Match must contain a non-space character. + - ([.]{2,})? --> Match may begin with a group of periods. + - [^!?]+? --> Grab everything that isn't an unequivocally-terminating punctuation character, but stop at the following condition... + - ([.…!?]+|(?=\s+$)|$) --> Match the last occurrence of sentence-final punctuation or the end of the text (optionally with left-side trailing spaces). + - (\s*[′’'”″“")»]+)* --> After the final punctuation, match any and all pairs of (optionally space-delimited) quotes and parentheses. + */ + break; + + case "element": + /* Matches text between HTML tags. */ + /* Note: Wrapping always occurs inside of elements, i.e. Bolded text here. */ + delimiterRegex = /(?=\S)([\S\s]*\S)/; + break; - /* In <=IE7, when Blast is called on the same element more than once with opts.stripHTMLTags=false, calls after the first may not target the entirety of the element and/or may inject excess spacing between inner text parts due to <=IE7's faulty node normalization. */ + /***************** + Custom Regex + *****************/ - /****************** - Call Options - ******************/ + default: + /* You can pass in /your-own-regex/. */ + if (opts.delimiter instanceof RegExp) { + delimiterRegex = opts.delimiter; + } else { + console.log(NAME + ": Unrecognized delimiter, empty search string, or invalid custom Regex. Aborting."); - var opts = $.extend({}, $.fn[pluginName].defaults, options); + /* Abort this Blast call. */ + return true; + } + } + } /********************** Element Iteration **********************/ this.each(function() { - var $this = $(this) + var $this = $(this); /* When anything except false is passed in for the options object, Blast is initiated. */ if (options !== false) { @@ -251,143 +312,57 @@ **********************/ Element = { - delimiterRegex: null, - blastedCount: 0, - nodeBeginning: false + /* The index of each wrapper element generated by blasting. */ + blastedIndex: 0, + /* Whether we're just entering this node. */ + nodeBeginning: false, + /* Keep track of the elements generated by Blast so that they can (optionally) be pushed onto the jQuery call stack. */ + wrappers: [] }; /***************** Housekeeping *****************/ - /* Unless a consecutive opts.search is being performed, reverse the current Blast call on the target element before proceeding. */ - /* Note: When traversing the DOM, Blast skips wrapper elements that it's previously generated. */ - if ($this.data(pluginName) !== undefined && ($this.data(pluginName).delimiter !== "search" || !opts.search)) { + /* Unless a consecutive opts.search is being performed, an element's existing Blast call is reversed before proceeding. */ + if ($this.data(NAME) !== undefined && ($this.data(NAME) !== "search" || !opts.search)) { /* De-Blast the previous call before continuing. */ reverse($this, opts); - if (opts.debug) console.log(pluginName + ": Removing element's existing Blast call and re-running."); + if (opts.debug) console.log(NAME + ": Removing element's existing Blast call."); } /* Store the current delimiter type so that it can be compared against on subsequent calls (see above). */ - $this.data(pluginName, { - delimiter: opts.search ? "search" : opts.delimiter - }); - - /* Reset the Call.generatedElements array for each target element. */ - Call.generatedElements = []; + $this.data(NAME, opts.search ? "search" : opts.delimiter); /**************** Preparation ****************/ - /* opts.tag is the only parameter that can cause Blast to throw an error (due to the user inputting unaccepted characters). So, we cleanse that property. */ + /* Perform optional HTML tag stripping. */ + if (opts.stripHTMLTags) { + $this.html($this.text()); + } + + /* If the browser throws an error for the provided element type (browers whitelist the letters and types of the elements they accept), fall back to using "span". */ try { - /* Note: The garbage collector will automatically remove this since we're not assigning it to a variable. */ document.createElement(opts.tag); } catch (error) { opts.tag = "span"; - if (opts.debug) console.log(pluginName + ": Invalid tag supplied. Defaulting to span."); + if (opts.debug) console.log(NAME + ": Invalid tag supplied. Defaulting to span."); } - /* Assign the target element a root class for reference purposes when reversing Blast. */ - $this.addClass(pluginName + "-root"); - - if (opts.debug) console.time("blast"); - - /*********** - Search - ***********/ - - /* Ensure that the opts.delimiter parameter for searching is a string with a non-zero length. */ - if (opts.search === true && $.type(opts.delimiter) === "string" && $.trim(opts.delimiter).length) { - /* Since the search is performed as a RegEx, we remove RegEx meta-characters from the search string. */ - opts.delimiter = opts.delimiter.replace(/[-[\]{,}(.)*+?|^$\\\/]/g, "\\$&"); - - /* Note: This includes the possessive apostrophe-s form as a match: {STRING}'s. */ - /* Note: This will not match text that is part of a compound word (two words adjoined with a dash), e.g. "front-end" won't result in a match for "front". */ - /* Note: Based on the way the algorithm is implemented, it is not possible to search for a string that consists solely of punctuation characters. */ - /* Note: By creating boundaries at Latin alphabet ranges instead of merely spaces, we effectively match phrases that are inlined alongside any type of non-Latin-letter, e.g. word|, word!, ♥word♥ will all match. */ - Element.delimiterRegex = new RegExp("(?:[^-" + characterRanges.latinLetters + "])(" + opts.delimiter + "('s)?)(?![-" + characterRanges.latinLetters + "])", "i"); - - /*************** - Delimiters - ***************/ - - } else { - /* Normalize the string's case for the delimiter switch below. */ - if ($.type(opts.delimiter) === "string") { - opts.delimiter = opts.delimiter.toLowerCase(); - } - - switch (opts.delimiter) { - case "letter": - case "char": - case "character": - /* Matches every non-space character. */ - /* Note: The character delimiter is unique in that it makes it cumbersome for some screenreaders to read Blasted text — since each letter will be read one-at-a-time. Thus, when using the character delimiter, - it is recommended that your use of Blast is temporary, e.g. to animate letters into place before reversing Blast. */ - /* Note: This is the slowest delimiter. However, its slowness is only truly noticeable when it's used on larger bodies of text (of over 500 characters) on <=IE8. Run Blast with opts.debug=true to monitor execution times. */ - Element.delimiterRegex = /(\S)/; - break; - - case "word": - /* Matches strings between space characters. */ - /* Note: Matches will include punctuation that's adjoined with the word, e.g. "Hey!" is a full match. */ - /* Note: Remember that every element marks the start of a new string. Thus, "inside" will match as three separate words. */ - Element.delimiterRegex = /\s*(\S+)\s*/; - break; - - case "sentence": - /* Matches phrases either ending in Latin alphabet punctuation or located at the end of the text. (Linebreaks are not considered punctuation.) */ - /* Note: If you don't want punctuation to demarcate a sentence match, replace the punctuation character with {{ASCII_CODE_FOR_DESIRED_PUNCTUATION}}. ASCII Codes: .={{46}}, ?={{63}}, !={{33}} */ - Element.delimiterRegex = /(?=\S)(([.]{2,})?[^!?]+?([.…!?]+|(?=\s+$)|$)(\s*[′’'”″“")»]+)*)/; - /* RegExp explanation (Tip: Use Regex101.com to play around with this expression and see which strings it matches): - - Expanded view: /(?=\S) ( ([.]{2,})? [^!?]+? ([.…!?]+|(?=\s+$)|$) (\s*[′’'”″“")»]+)* ) - - (?=\S) --> Match must contain a non-space character. - - ([.]{2,})? --> Match may begin with a group of periods. - - [^!?]+? --> Grab everything that isn't an unequivocally-terminating punctuation character, but stop at the following condition... - - ([.…!?]+|(?=\s+$)|$) --> Match the last occurrence of sentence-final punctuation or the end of the text (optionally with left-side trailing spaces). - - (\s*[′’'”″“")»]+)* --> After the final punctuation, match any and all pairs of (optionally space-delimited) quotes and parentheses. - */ - break; - - case "element": - /* Matches text between HTML tags. */ - /* Note: Wrapping always occurs inside of elements, i.e. Bolded text here. */ - Element.delimiterRegex = /(?=\S)([\S\s]*\S)/; - break; - - /***************** - Custom Regex - *****************/ - - default: - if (opts.delimiter instanceof RegExp) { - Element.delimiterRegex = opts.delimiter; - } else { - console.log(pluginName + ": Unrecognized delimiter, empty search string, or invalid custom RegEx. Aborting."); - - /* Clean up what was performed under the Housekeeping section. */ - $this.blast(false); - - /* Abort this Blast call. */ - return true; - } - } - } - - /* Perform HTML tag stripping if requested. */ - if (opts.stripHTMLTags) { - $this.html($this.text()); - } + /* For reference purposes when reversing Blast, assign the target element a root class. */ + $this.addClass(NAME + "-root"); /* Initiate the DOM traversal process. */ + if (opts.debug) console.time(NAME); traverseDOM(this, opts); + if (opts.debug) console.timeEnd(NAME); - /* When false is passed in as the options object, Blast is reversed. */ - } else if (options === false && $this.data(pluginName)) { + /* If false is passed in as the first parameter, reverse Blast. */ + } else if (options === false && $this.data(NAME) !== undefined) { reverse($this, opts); } @@ -395,86 +370,93 @@ Debugging **************/ + /* Output the full string of each wrapper element and color alternate the wrappers. This is in addition to the performance timing that has already been outputted. */ if (opts.debug) { - console.timeEnd("blast"); - $this.find(".blast") - .each(function () { - console.log(pluginName + " [" + opts.delimiter + "] " + $(this)[0].outerHTML); - }) - .filter(":even") - .css("backgroundColor", "#f12185") - .end() - .filter(":odd") - .css("backgroundColor", "#075d9a"); + .each(function() { console.log(NAME + " [" + opts.delimiter + "] " + $(this)[0].outerHTML); }) + .filter(function(index, element) { + this.style.backgroundColor = index % 2 ? "#f12185" : "#075d9a"; + }); } }); - /************* - Chain - *************/ + /************ + Reverse + ************/ - /* Either return a stack composed of our call's generatedElements or return the element(s) originally targeted by this Blast call. */ - /* Note: returnGenerated can only be disabled on a per-call basis (not a per-element basis), and thus a single check is performed to see if it was explicitly set to false in the call options object. */ - if (options !== false && opts.returnGenerated === true) { - return this.pushStack(Call.generatedElements); - } else { - return this; - } - }; + function reverse ($this, opts) { + if (opts.debug) console.time("blast reversal"); - /************ - Reverse - ************/ + var skippedDescendantRoot = false; - function reverse ($this, opts) { - if (opts.debug) console.time("blast reversal"); + $this + .removeClass(NAME + "-root") + .data(NAME, undefined) + .find("." + NAME) + .each(function () { + var $this = $(this); - var skippedDescendantRoot = false; + /* Do not reverse Blast on descendant root elements. (Before you can reverse Blast on an element, you must reverse Blast on any parent elements that have been Blasted.) */ + if (!$this.closest("." + NAME + "-root").length) { + var thisParentNode = this.parentNode; + + /* This triggers some sort of node layout, thereby solving a node normalization bug in <=IE7 for reasons unknown. If you know the specific reason, tweet me: @Shapiro. */ + if (IE <= 7) (thisParentNode.firstChild.nodeName); - $this - .removeClass(pluginName + "-root") - .removeData(pluginName) - .find("." + pluginName) - .each(function () { - var $this = $(this); + /* Strip the HTML tags off of the wrapper elements by replacing the elements with their child node's text. */ + thisParentNode.replaceChild(this.firstChild, this); - /* Do not reverse Blast on descendant root elements. (Before you can reverse Blast on an element, you must reverse Blast on any parent elements that have been Blasted.) */ - if (!$this.closest("." + pluginName + "-root").length) { - var thisParentNode = this.parentNode; - - /* This triggers some sort of node layout, thereby solving a node normalization bug in <=IE7 for reasons unknown. If you know the specific reason, tweet me: @Shapiro. */ - if (IE <= 7) (thisParentNode.firstChild.nodeName); + /* Normalize() parents to remove empty text nodes and concatenate sibling text nodes. (This cleans up the DOM after our manipulation.) */ + thisParentNode.normalize(); + } else { + skippedDescendantRoot = true; + } + }); - /* Strip the HTML tags off of the wrapper elements by replacing the elements with their child node's text. */ - thisParentNode.replaceChild(this.firstChild, this); + if (opts.debug) { + console.log(NAME + ": Reversed Blast" + ($this.attr("id") ? " on #" + $this.attr("id") + "." : ".") + (skippedDescendantRoot ? " Skipped reversal on the children of one or more descendant root elements." : "")); + console.timeEnd("blast reversal"); + } + } - /* Normalize() parents to remove empty text nodes and concatenate sibling text nodes. This cleans up the DOM after our manipulation. */ - thisParentNode.normalize(); - } else { - skippedDescendantRoot = true; - } - }); + /************* + Chain + *************/ + + /* Either return a stack composed of our call's Element.wrappers or return the element(s) originally targeted by the Blast call. */ + /* Note: returnGenerated can only be disabled on a per-call basis (not a per-element basis). */ + if (options !== false && opts.returnGenerated === true) { + /* A reimplementation of jQuery's $.pushStack() (since Zepto does not provide this function). */ + var newStack = $().add(Element.wrappers); + newStack.prevObject = this; + newStack.context = this.context; - if (opts.debug) { - console.log(pluginName + ": Reversed Blast" + ($this.attr("id") ? " on #" + $this.attr("id") + "." : ".") + (skippedDescendantRoot ? " Skipped reversal on the children of one or more descendant root elements." : "")); - console.timeEnd("blast reversal"); + return newStack; + } else { + return this; } - } -})(jQuery, window, document); + }; -/*************** - Defaults -***************/ + /*************** + Defaults + ***************/ + + $.fn.blast.defaults = { + returnGenerated: true, + delimiter: "word", + tag: "span", + search: false, + customClass: "", + generateIndexID: false, + generateValueClass: false, + stripHTMLTags: false, + debug: false + }; +})(window.jQuery || window.Zepto, window, document); + +/***************** + Known Issues +*****************/ -$.fn.blast.defaults = { - returnGenerated: true, - delimiter: "word", - tag: "span", - search: false, - customClass: "", - generateIndexID: false, - generateValueClass: false, - stripHTMLTags: false, - debug: false -}; \ No newline at end of file +/* In <=IE7, when Blast is called on the same element more than once with opts.stripHTMLTags=false, calls after the first may not target the entirety of the element and/or may + inject excess spacing between inner text parts due to <=IE7's faulty node normalization. */ \ No newline at end of file diff --git a/jquery.blast.min.js b/jquery.blast.min.js index bd885d4..2526e62 100644 --- a/jquery.blast.min.js +++ b/jquery.blast.min.js @@ -1,7 +1,8 @@ /*! * Blast.js: Blast text apart to make it manipulable. -* @version 0.1.1 +* @version 0.2.0 +* @dependency Works with both jQuery and Zepto. * @docs julian.com/research/blast * @license Copyright 2014 Julian Shapiro. MIT License: http://en.wikipedia.org/wiki/MIT_License */ -!function($,e,t,n){function a(e){return e=e.replace(g.abbreviations,function(e){return e.replace(/\./g,"{{46}}")}),e=e.replace(g.innerWordPeriod,function(e){return e.replace(/\./g,"{{46}}")})}function i(e){return e.replace(/{{(\d{1,3})}}/g,function(e,t){return String.fromCharCode(t)})}function r(e,n){var a=t.createElement(n.tag);if(a.className=c,n.customClass&&(a.className+=" "+n.customClass,n.generateIndexID&&(a.id=n.customClass+"-"+h.blastedCount)),n.generateValueClass===!0&&("character"===n.delimiter||"word"===n.delimiter)){var i,r=e.data;"word"===n.delimiter&&g.onlyContainsPunctuation.test(r)&&(r=r.replace(g.adjoinedPunctuation,"")),i=c+"-"+n.delimiter+"-"+r.toLowerCase(),a.className+=" "+i}return a.appendChild(e.cloneNode(!1)),a}function s(e,t){var n=-1,l=0;if(3===e.nodeType&&e.data.length){if(h.nodeBeginning&&(e.data="sentence"===t.delimiter?a(e.data):i(e.data),h.nodeBeginning=!1),n=e.data.search(h.delimiterRegex),-1!==n){var d=e.data.match(h.delimiterRegex),o=d[0],c=d[1]||!1;h.blastedCount++,""===o?n++:c&&c!==o&&(n+=o.indexOf(c),o=c);var u=e.splitText(n);u.splitText(o.length),l=1,"sentence"===t.delimiter&&(u.data=i(u.data));var f=r(u,t,h.blastedCount);u.parentNode.replaceChild(f,u),m.generatedElements.push(f)}}else if(1===e.nodeType&&e.hasChildNodes()&&!g.skippedElements.test(e.tagName)&&!g.hasPluginClass.test(e.className))for(var p=0;p=d&&t.firstChild.nodeName,t.replaceChild(this.firstChild,this),t.normalize()}}),t.debug&&(o.log(c+": Reversed Blast"+(e.attr("id")?" on #"+e.attr("id")+".":".")+(n?" Skipped reversal on the children of one or more descendant root elements.":"")),o.timeEnd("blast reversal"))}var d=function(){if(t.documentMode)return t.documentMode;for(var e=7;e>0;e--){var a=t.createElement("div");if(a.innerHTML="",a.getElementsByTagName("span").length)return a=null,e;a=null}return n}(),o=e.console||{log:function(){}},c="blast",u={latinPunctuation:"–—′’'“″„\"(«.…¡¿′’'”″“\")».…!?",latinLetters:"\\u0041-\\u005A\\u0061-\\u007A\\u00C0-\\u017F\\u0100-\\u01FF\\u0180-\\u027F"},g={abbreviations:new RegExp("[^"+u.latinLetters+"](e\\.g\\.)|(i\\.e\\.)|(mr\\.)|(mrs\\.)|(ms\\.)|(dr\\.)|(prof\\.)|(esq\\.)|(sr\\.)|(jr\\.)[^"+u.latinLetters+"]","ig"),innerWordPeriod:new RegExp("["+u.latinLetters+"].["+u.latinLetters+"]","ig"),onlyContainsPunctuation:new RegExp("[^"+u.latinPunctuation+"]"),adjoinedPunctuation:new RegExp("^["+u.latinPunctuation+"]+|["+u.latinPunctuation+"]+$","g"),skippedElements:/(script|style|select|textarea)/i,hasPluginClass:new RegExp("(^| )"+c+"( |$)","gi")},m={generatedElements:[]},h={};$.fn.blast=function(e){var a=$.extend({},$.fn[c].defaults,e);return this.each(function(){var i=$(this);if(e!==!1){h={delimiterRegex:null,blastedCount:0,nodeBeginning:!1},i.data(c)===n||"search"===i.data(c).delimiter&&a.search||(l(i,a),a.debug&&o.log(c+": Removing element's existing Blast call and re-running.")),i.data(c,{delimiter:a.search?"search":a.delimiter}),m.generatedElements=[];try{t.createElement(a.tag)}catch(r){a.tag="span",a.debug&&o.log(c+": Invalid tag supplied. Defaulting to span.")}if(i.addClass(c+"-root"),a.debug&&o.time("blast"),a.search===!0&&"string"===$.type(a.delimiter)&&$.trim(a.delimiter).length)a.delimiter=a.delimiter.replace(/[-[\]{,}(.)*+?|^$\\\/]/g,"\\$&"),h.delimiterRegex=new RegExp("(?:[^-"+u.latinLetters+"])("+a.delimiter+"('s)?)(?![-"+u.latinLetters+"])","i");else switch("string"===$.type(a.delimiter)&&(a.delimiter=a.delimiter.toLowerCase()),a.delimiter){case"letter":case"char":case"character":h.delimiterRegex=/(\S)/;break;case"word":h.delimiterRegex=/\s*(\S+)\s*/;break;case"sentence":h.delimiterRegex=/(?=\S)(([.]{2,})?[^!?]+?([.…!?]+|(?=\s+$)|$)(\s*[′’'”″“")»]+)*)/;break;case"element":h.delimiterRegex=/(?=\S)([\S\s]*\S)/;break;default:if(!(a.delimiter instanceof RegExp))return o.log(c+": Unrecognized delimiter, empty search string, or invalid custom RegEx. Aborting."),i.blast(!1),!0;h.delimiterRegex=a.delimiter}a.stripHTMLTags&&i.html(i.text()),s(this,a)}else e===!1&&i.data(c)&&l(i,a);a.debug&&(o.timeEnd("blast"),i.find(".blast").each(function(){o.log(c+" ["+a.delimiter+"] "+$(this)[0].outerHTML)}).filter(":even").css("backgroundColor","#f12185").end().filter(":odd").css("backgroundColor","#075d9a"))}),e!==!1&&a.returnGenerated===!0?this.pushStack(m.generatedElements):this}}(jQuery,window,document),$.fn.blast.defaults={returnGenerated:!0,delimiter:"word",tag:"span",search:!1,customClass:"",generateIndexID:!1,generateValueClass:!1,stripHTMLTags:!1,debug:!1}; \ No newline at end of file +!function($,e,t,n){var a=function(){if(t.documentMode)return t.documentMode;for(var e=7;e>0;e--){var a=t.createElement("div");if(a.innerHTML="",a.getElementsByTagName("span").length)return a=null,e;a=null}return n}(),i=e.console||{log:function(){},time:function(){}},r="blast",s={latinPunctuation:"–—′’'“″„\"(«.…¡¿′’'”″“\")».…!?",latinLetters:"\\u0041-\\u005A\\u0061-\\u007A\\u00C0-\\u017F\\u0100-\\u01FF\\u0180-\\u027F"},l={abbreviations:new RegExp("[^"+s.latinLetters+"](e\\.g\\.)|(i\\.e\\.)|(mr\\.)|(mrs\\.)|(ms\\.)|(dr\\.)|(prof\\.)|(esq\\.)|(sr\\.)|(jr\\.)[^"+s.latinLetters+"]","ig"),innerWordPeriod:new RegExp("["+s.latinLetters+"].["+s.latinLetters+"]","ig"),onlyContainsPunctuation:new RegExp("[^"+s.latinPunctuation+"]"),adjoinedPunctuation:new RegExp("^["+s.latinPunctuation+"]+|["+s.latinPunctuation+"]+$","g"),skippedElements:/(script|style|select|textarea)/i,hasPluginClass:new RegExp("(^| )"+r+"( |$)","gi")};$.fn[r]=function(e){function d(e){return e.replace(l.abbreviations,function(e){return e.replace(/\./g,"{{46}}")}).replace(l.innerWordPeriod,function(e){return e.replace(/\./g,"{{46}}")})}function o(e){return e.replace(/{{(\d{1,3})}}/g,function(e,t){return String.fromCharCode(t)})}function c(e,n){var a=t.createElement(n.tag);if(a.className=r,n.customClass&&(a.className+=" "+n.customClass,n.generateIndexID&&(a.id=n.customClass+"-"+h.blastedIndex)),n.generateValueClass===!0&&("character"===n.delimiter||"word"===n.delimiter)){var i,s=e.data;"word"===n.delimiter&&l.onlyContainsPunctuation.test(s)&&(s=s.replace(l.adjoinedPunctuation,"")),i=r+"-"+n.delimiter.toLowerCase()+"-"+s.toLowerCase(),a.className+=" "+i}return a.appendChild(e.cloneNode(!1)),a}function u(e,t){var n=-1,a=0;if(3===e.nodeType&&e.data.length){if(h.nodeBeginning&&(e.data="sentence"===t.delimiter?d(e.data):o(e.data),h.nodeBeginning=!1),n=e.data.search(p),-1!==n){var i=e.data.match(p),r=i[0],s=i[1]||!1;h.blastedIndex++,""===r?n++:s&&s!==r&&(n+=r.indexOf(s),r=s);var g=e.splitText(n);g.splitText(r.length),a=1,"sentence"===t.delimiter&&(g.data=o(g.data));var m=c(g,t,h.blastedIndex);g.parentNode.replaceChild(m,g),h.wrappers.push(m)}}else if(1===e.nodeType&&e.hasChildNodes()&&!l.skippedElements.test(e.tagName)&&!l.hasPluginClass.test(e.className))for(var f=0;f=a&&t.firstChild.nodeName,t.replaceChild(this.firstChild,this),t.normalize()}}),t.debug&&(i.log(r+": Reversed Blast"+(e.attr("id")?" on #"+e.attr("id")+".":".")+(s?" Skipped reversal on the children of one or more descendant root elements.":"")),i.timeEnd("blast reversal"))}var m=$.extend({},$.fn[r].defaults,e),p,h={};if(m.search===!0&&"string"===$.type(m.delimiter)&&$.trim(m.delimiter).length)m.delimiter=m.delimiter.replace(/[-[\]{,}(.)*+?|^$\\\/]/g,"\\$&"),p=new RegExp("(?:^|[^-"+s.latinLetters+"])("+m.delimiter+"('s)?)(?![-"+s.latinLetters+"])","i");else switch("string"===$.type(m.delimiter)&&(m.delimiter=m.delimiter.toLowerCase()),m.delimiter){case"letter":case"char":case"character":p=/(\S)/;break;case"word":p=/\s*(\S+)\s*/;break;case"sentence":p=/(?=\S)(([.]{2,})?[^!?]+?([.…!?]+|(?=\s+$)|$)(\s*[′’'”″“")»]+)*)/;break;case"element":p=/(?=\S)([\S\s]*\S)/;break;default:if(!(m.delimiter instanceof RegExp))return i.log(r+": Unrecognized delimiter, empty search string, or invalid custom Regex. Aborting."),!0;p=m.delimiter}if(this.each(function(){var a=$(this);if(e!==!1){h={blastedIndex:0,nodeBeginning:!1,wrappers:[]},a.data(r)===n||"search"===a.data(r)&&m.search||(g(a,m),m.debug&&i.log(r+": Removing element's existing Blast call.")),a.data(r,m.search?"search":m.delimiter),m.stripHTMLTags&&a.html(a.text());try{t.createElement(m.tag)}catch(s){m.tag="span",m.debug&&i.log(r+": Invalid tag supplied. Defaulting to span.")}a.addClass(r+"-root"),m.debug&&i.time(r),u(this,m),m.debug&&i.timeEnd(r)}else e===!1&&a.data(r)!==n&&g(a,m);m.debug&&a.find(".blast").each(function(){i.log(r+" ["+m.delimiter+"] "+$(this)[0].outerHTML)}).filter(function(e,t){this.style.backgroundColor=e%2?"#f12185":"#075d9a"})}),e!==!1&&m.returnGenerated===!0){var f=$().add(h.wrappers);return f.prevObject=this,f.context=this.context,f}return this},$.fn.blast.defaults={returnGenerated:!0,delimiter:"word",tag:"span",search:!1,customClass:"",generateIndexID:!1,generateValueClass:!1,stripHTMLTags:!1,debug:!1}}(window.jQuery||window.Zepto,window,document); \ No newline at end of file