From 053f6f208092e9b986f54ca84726fde11fc44e57 Mon Sep 17 00:00:00 2001 From: Jonathan Wilkes <jon.w.wilkes@gmail.com> Date: Thu, 1 Oct 2015 20:33:40 -0400 Subject: [PATCH] first draft of a functioning "Find" bar for the Pd console --- pd/nw/console_search.js | 632 ++++++++++++++++++++++++++++++++++++++++ pd/nw/css/default.css | 19 +- pd/nw/index.html | 18 +- pd/nw/index.js | 157 +++++++++- 4 files changed, 807 insertions(+), 19 deletions(-) create mode 100644 pd/nw/console_search.js diff --git a/pd/nw/console_search.js b/pd/nw/console_search.js new file mode 100644 index 000000000..d288c231c --- /dev/null +++ b/pd/nw/console_search.js @@ -0,0 +1,632 @@ +/** + * findAndReplaceDOMText v 0.4.3 + * @author James Padolsey http://james.padolsey.com + * @license http://unlicense.org/UNLICENSE + * + * Matches the text of a DOM node against a regular expression + * and replaces each match (or node-separated portions of the match) + * in the specified element. + */ + (function (root, factory) { + if (typeof module === 'object' && module.exports) { + // Node/CommonJS + module.exports = factory(); + } else if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(factory); + } else { + // Browser globals + root.findAndReplaceDOMText = factory(); + } + }(this, function factory() { + + var PORTION_MODE_RETAIN = 'retain'; + var PORTION_MODE_FIRST = 'first'; + + var doc = document; + var toString = {}.toString; + var hasOwn = {}.hasOwnProperty; + + function isArray(a) { + return toString.call(a) == '[object Array]'; + } + + function escapeRegExp(s) { + return String(s).replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1'); + } + + function exposed() { + // Try deprecated arg signature first: + return deprecated.apply(null, arguments) || findAndReplaceDOMText.apply(null, arguments); + } + + function deprecated(regex, node, replacement, captureGroup, elFilter) { + if ((node && !node.nodeType) && arguments.length <= 2) { + return false; + } + var isReplacementFunction = typeof replacement == 'function'; + + if (isReplacementFunction) { + replacement = (function(original) { + return function(portion, match) { + return original(portion.text, match.startIndex); + }; + }(replacement)); + } + + // Awkward support for deprecated argument signature (<0.4.0) + var instance = findAndReplaceDOMText(node, { + + find: regex, + + wrap: isReplacementFunction ? null : replacement, + replace: isReplacementFunction ? replacement : '$' + (captureGroup || '&'), + + prepMatch: function(m, mi) { + + // Support captureGroup (a deprecated feature) + + if (!m[0]) throw 'findAndReplaceDOMText cannot handle zero-length matches'; + + if (captureGroup > 0) { + var cg = m[captureGroup]; + m.index += m[0].indexOf(cg); + m[0] = cg; + } + + m.endIndex = m.index + m[0].length; + m.startIndex = m.index; + m.index = mi; + + return m; + }, + filterElements: elFilter + }); + + exposed.revert = function() { + return instance.revert(); + }; + + return true; + } + + /** + * findAndReplaceDOMText + * + * Locates matches and replaces with replacementNode + * + * @param {Node} node Element or Text node to search within + * @param {RegExp} options.find The regular expression to match + * @param {String|Element} [options.wrap] A NodeName, or a Node to clone + * @param {String|Function} [options.replace='$&'] What to replace each match with + * @param {Function} [options.filterElements] A Function to be called to check whether to + * process an element. (returning true = process element, + * returning false = avoid element) + */ + function findAndReplaceDOMText(node, options) { + return new Finder(node, options); + } + + exposed.NON_PROSE_ELEMENTS = { + br:1, hr:1, + // Media / Source elements: + script:1, style:1, img:1, video:1, audio:1, canvas:1, svg:1, map:1, object:1, + // Input elements + input:1, textarea:1, select:1, option:1, optgroup: 1, button:1 + }; + + exposed.NON_CONTIGUOUS_PROSE_ELEMENTS = { + + // Elements that will not contain prose or block elements where we don't + // want prose to be matches across element borders: + + // Block Elements + address:1, article:1, aside:1, blockquote:1, dd:1, div:1, + dl:1, fieldset:1, figcaption:1, figure:1, footer:1, form:1, h1:1, h2:1, h3:1, + h4:1, h5:1, h6:1, header:1, hgroup:1, hr:1, main:1, nav:1, noscript:1, ol:1, + output:1, p:1, pre:1, section:1, ul:1, + // Other misc. elements that are not part of continuous inline prose: + br:1, li: 1, summary: 1, dt:1, details:1, rp:1, rt:1, rtc:1, + // Media / Source elements: + script:1, style:1, img:1, video:1, audio:1, canvas:1, svg:1, map:1, object:1, + // Input elements + input:1, textarea:1, select:1, option:1, optgroup: 1, button:1, + // Table related elements: + table:1, tbody:1, thead:1, th:1, tr:1, td:1, caption:1, col:1, tfoot:1, colgroup:1 + + }; + + exposed.NON_INLINE_PROSE = function(el) { + return hasOwn.call(exposed.NON_CONTIGUOUS_PROSE_ELEMENTS, el.nodeName.toLowerCase()); + }; + + // Presets accessed via `options.preset` when calling findAndReplaceDOMText(): + exposed.PRESETS = { + prose: { + forceContext: exposed.NON_INLINE_PROSE, + filterElements: function(el) { + return !hasOwn.call(exposed.NON_PROSE_ELEMENTS, el.nodeName.toLowerCase()); + } + } + }; + + exposed.Finder = Finder; + + /** + * Finder -- encapsulates logic to find and replace. + */ + function Finder(node, options) { + + var preset = options.preset && exposed.PRESETS[options.preset]; + + options.portionMode = options.portionMode || PORTION_MODE_RETAIN; + + if (preset) { + for (var i in preset) { + if (hasOwn.call(preset, i) && !hasOwn.call(options, i)) { + options[i] = preset[i]; + } + } + } + + this.node = node; + this.options = options; + + // ENable match-preparation method to be passed as option: + this.prepMatch = options.prepMatch || this.prepMatch; + + this.reverts = []; + + this.matches = this.search(); + + if (this.matches.length) { + this.processMatches(); + } + + } + + Finder.prototype = { + + /** + * Searches for all matches that comply with the instance's 'match' option + */ + search: function() { + + var match; + var matchIndex = 0; + var offset = 0; + var regex = this.options.find; + var textAggregation = this.getAggregateText(); + var matches = []; + var self = this; + + regex = typeof regex === 'string' ? RegExp(escapeRegExp(regex), 'g') : regex; + + matchAggregation(textAggregation); + + function matchAggregation(textAggregation) { + for (var i = 0, l = textAggregation.length; i < l; ++i) { + + var text = textAggregation[i]; + + if (typeof text !== 'string') { + // Deal with nested contexts: (recursive) + matchAggregation(text); + continue; + } + + if (regex.global) { + while (match = regex.exec(text)) { + matches.push(self.prepMatch(match, matchIndex++, offset)); + } + } else { + if (match = text.match(regex)) { + matches.push(self.prepMatch(match, 0, offset)); + } + } + + offset += text.length; + } + } + + return matches; + + }, + + /** + * Prepares a single match with useful meta info: + */ + prepMatch: function(match, matchIndex, characterOffset) { + + if (!match[0]) { + throw new Error('findAndReplaceDOMText cannot handle zero-length matches'); + } + + match.endIndex = characterOffset + match.index + match[0].length; + match.startIndex = characterOffset + match.index; + match.index = matchIndex; + + return match; + }, + + /** + * Gets aggregate text within subject node + */ + getAggregateText: function() { + + var elementFilter = this.options.filterElements; + var forceContext = this.options.forceContext; + + return getText(this.node); + + /** + * Gets aggregate text of a node without resorting + * to broken innerText/textContent + */ + function getText(node, txt) { + + if (node.nodeType === 3) { + return [node.data]; + } + + if (elementFilter && !elementFilter(node)) { + return []; + } + + var txt = ['']; + var i = 0; + + if (node = node.firstChild) do { + + if (node.nodeType === 3) { + txt[i] += node.data; + continue; + } + + var innerText = getText(node); + + if ( + forceContext && + node.nodeType === 1 && + (forceContext === true || forceContext(node)) + ) { + txt[++i] = innerText; + txt[++i] = ''; + } else { + if (typeof innerText[0] === 'string') { + // Bridge nested text-node data so that they're + // not considered their own contexts: + // I.e. ['some', ['thing']] -> ['something'] + txt[i] += innerText.shift(); + } + if (innerText.length) { + txt[++i] = innerText; + txt[++i] = ''; + } + } + } while (node = node.nextSibling); + + return txt; + + } + + }, + + /** + * Steps through the target node, looking for matches, and + * calling replaceFn when a match is found. + */ + processMatches: function() { + + var matches = this.matches; + var node = this.node; + var elementFilter = this.options.filterElements; + + var startPortion, + endPortion, + innerPortions = [], + curNode = node, + match = matches.shift(), + atIndex = 0, // i.e. nodeAtIndex + matchIndex = 0, + portionIndex = 0, + doAvoidNode, + nodeStack = [node]; + + out: while (true) { + + if (curNode.nodeType === 3) { + + if (!endPortion && curNode.length + atIndex >= match.endIndex) { + + // We've found the ending + endPortion = { + node: curNode, + index: portionIndex++, + text: curNode.data.substring(match.startIndex - atIndex, match.endIndex - atIndex), + indexInMatch: atIndex - match.startIndex, + indexInNode: match.startIndex - atIndex, // always zero for end-portions + endIndexInNode: match.endIndex - atIndex, + isEnd: true + }; + + } else if (startPortion) { + // Intersecting node + innerPortions.push({ + node: curNode, + index: portionIndex++, + text: curNode.data, + indexInMatch: atIndex - match.startIndex, + indexInNode: 0 // always zero for inner-portions + }); + } + + if (!startPortion && curNode.length + atIndex > match.startIndex) { + // We've found the match start + startPortion = { + node: curNode, + index: portionIndex++, + indexInMatch: 0, + indexInNode: match.startIndex - atIndex, + endIndexInNode: match.endIndex - atIndex, + text: curNode.data.substring(match.startIndex - atIndex, match.endIndex - atIndex) + }; + } + + atIndex += curNode.data.length; + + } + + doAvoidNode = curNode.nodeType === 1 && elementFilter && !elementFilter(curNode); + + if (startPortion && endPortion) { + + curNode = this.replaceMatch(match, startPortion, innerPortions, endPortion); + + // processMatches has to return the node that replaced the endNode + // and then we step back so we can continue from the end of the + // match: + + atIndex -= (endPortion.node.data.length - endPortion.endIndexInNode); + + startPortion = null; + endPortion = null; + innerPortions = []; + match = matches.shift(); + portionIndex = 0; + matchIndex++; + + if (!match) { + break; // no more matches + } + + } else if ( + !doAvoidNode && + (curNode.firstChild || curNode.nextSibling) + ) { + // Move down or forward: + if (curNode.firstChild) { + nodeStack.push(curNode); + curNode = curNode.firstChild; + } else { + curNode = curNode.nextSibling; + } + continue; + } + + // Move forward or up: + while (true) { + if (curNode.nextSibling) { + curNode = curNode.nextSibling; + break; + } + curNode = nodeStack.pop(); + if (curNode === node) { + break out; + } + } + + } + + }, + + /** + * Reverts ... TODO + */ + revert: function() { + // Reversion occurs backwards so as to avoid nodes subsequently + // replaced during the matching phase (a forward process): + for (var l = this.reverts.length; l--;) { + this.reverts[l](); + } + this.reverts = []; + }, + + prepareReplacementString: function(string, portion, match, matchIndex) { + var portionMode = this.options.portionMode; + if ( + portionMode === PORTION_MODE_FIRST && + portion.indexInMatch > 0 + ) { + return ''; + } + string = string.replace(/\$(\d+|&|`|')/g, function($0, t) { + var replacement; + switch(t) { + case '&': + replacement = match[0]; + break; + case '`': + replacement = match.input.substring(0, match.startIndex); + break; + case '\'': + replacement = match.input.substring(match.endIndex); + break; + default: + replacement = match[+t]; + } + return replacement; + }); + + if (portionMode === PORTION_MODE_FIRST) { + return string; + } + + if (portion.isEnd) { + return string.substring(portion.indexInMatch); + } + + return string.substring(portion.indexInMatch, portion.indexInMatch + portion.text.length); + }, + + getPortionReplacementNode: function(portion, match, matchIndex) { + + var replacement = this.options.replace || '$&'; + var wrapper = this.options.wrap; + + if (wrapper && wrapper.nodeType) { + // Wrapper has been provided as a stencil-node for us to clone: + var clone = doc.createElement('div'); + clone.innerHTML = wrapper.outerHTML || new XMLSerializer().serializeToString(wrapper); + wrapper = clone.firstChild; + } + + if (typeof replacement == 'function') { + replacement = replacement(portion, match, matchIndex); + if (replacement && replacement.nodeType) { + return replacement; + } + return doc.createTextNode(String(replacement)); + } + + var el = typeof wrapper == 'string' ? doc.createElement(wrapper) : wrapper; + + replacement = doc.createTextNode( + this.prepareReplacementString( + replacement, portion, match, matchIndex + ) + ); + + if (!replacement.data) { + return replacement; + } + + if (!el) { + return replacement; + } + + el.appendChild(replacement); + + return el; + }, + + replaceMatch: function(match, startPortion, innerPortions, endPortion) { + + var matchStartNode = startPortion.node; + var matchEndNode = endPortion.node; + + var preceedingTextNode; + var followingTextNode; + + if (matchStartNode === matchEndNode) { + + var node = matchStartNode; + + if (startPortion.indexInNode > 0) { + // Add `before` text node (before the match) + preceedingTextNode = doc.createTextNode(node.data.substring(0, startPortion.indexInNode)); + node.parentNode.insertBefore(preceedingTextNode, node); + } + + // Create the replacement node: + var newNode = this.getPortionReplacementNode( + endPortion, + match + ); + + node.parentNode.insertBefore(newNode, node); + + if (endPortion.endIndexInNode < node.length) { // ????? + // Add `after` text node (after the match) + followingTextNode = doc.createTextNode(node.data.substring(endPortion.endIndexInNode)); + node.parentNode.insertBefore(followingTextNode, node); + } + + node.parentNode.removeChild(node); + + this.reverts.push(function() { + if (preceedingTextNode === newNode.previousSibling) { + preceedingTextNode.parentNode.removeChild(preceedingTextNode); + } + if (followingTextNode === newNode.nextSibling) { + followingTextNode.parentNode.removeChild(followingTextNode); + } + newNode.parentNode.replaceChild(node, newNode); + }); + + return newNode; + + } else { + // Replace matchStartNode -> [innerMatchNodes...] -> matchEndNode (in that order) + + + preceedingTextNode = doc.createTextNode( + matchStartNode.data.substring(0, startPortion.indexInNode) + ); + + followingTextNode = doc.createTextNode( + matchEndNode.data.substring(endPortion.endIndexInNode) + ); + + var firstNode = this.getPortionReplacementNode( + startPortion, + match + ); + + var innerNodes = []; + + for (var i = 0, l = innerPortions.length; i < l; ++i) { + var portion = innerPortions[i]; + var innerNode = this.getPortionReplacementNode( + portion, + match + ); + portion.node.parentNode.replaceChild(innerNode, portion.node); + this.reverts.push((function(portion, innerNode) { + return function() { + innerNode.parentNode.replaceChild(portion.node, innerNode); + }; + }(portion, innerNode))); + innerNodes.push(innerNode); + } + + var lastNode = this.getPortionReplacementNode( + endPortion, + match + ); + + matchStartNode.parentNode.insertBefore(preceedingTextNode, matchStartNode); + matchStartNode.parentNode.insertBefore(firstNode, matchStartNode); + matchStartNode.parentNode.removeChild(matchStartNode); + + matchEndNode.parentNode.insertBefore(lastNode, matchEndNode); + matchEndNode.parentNode.insertBefore(followingTextNode, matchEndNode); + matchEndNode.parentNode.removeChild(matchEndNode); + + this.reverts.push(function() { + preceedingTextNode.parentNode.removeChild(preceedingTextNode); + firstNode.parentNode.replaceChild(matchStartNode, firstNode); + followingTextNode.parentNode.removeChild(followingTextNode); + lastNode.parentNode.replaceChild(matchEndNode, lastNode); + }); + + return lastNode; + } + } + + }; + + return exposed; + +})); + diff --git a/pd/nw/css/default.css b/pd/nw/css/default.css index 3bb087c62..7c704b8b5 100644 --- a/pd/nw/css/default.css +++ b/pd/nw/css/default.css @@ -1,8 +1,9 @@ /* Global CSS */ @font-face { - font-family: "DejaVu Sans Mono"; - src: url("../DejaVuSansMono.ttf"); + font-family: "Droid Sans Mono Dotted"; +/* src: url("../DejaVuSansMono.ttf"); */ + src: url("../DroidSansMonoDotted.ttf"); } .noselect { @@ -48,6 +49,20 @@ overflow-y: scroll; } +/* marks for matches to console_find */ +mark { + background: white; +} + +mark.console_find_current.console_find_highlighted, +mark.console_find_current { + background: yellow; +} + +mark.console_find_highlighted { + background: red; +} + #console_find { width: 100%; height: 1em; diff --git a/pd/nw/index.html b/pd/nw/index.html index 298ab0233..324586bd2 100644 --- a/pd/nw/index.html +++ b/pd/nw/index.html @@ -14,7 +14,6 @@ </label> </div> </div> - <script type="text/javascript" src="index.js"></script> <input style="display:none;" id="saveDialog" type="file" nwsaveas /> <div id = "console_bottom"> <div id = "printout"> @@ -29,16 +28,17 @@ id="console_find_text" name="console_find_text" defaultValue="Search in Console" - style="width:10em;" - onfocus="console_find_input_focus(this)" - onblur="console_find_input_blur(this)"/>XXXXXX + style="width:10em;"/> + </label> + <label>Highlight All + <input type="checkbox" + id="console_find_highlight" + name="console_find_highlight" + onchange="console_find_highlight_all(this);"/> </label> </div> </div> - <script> - var t = document.getElementById('console_find_text'); - t.defaultValue = "Search in Console"; - console_find_input_blur(t); - </script> + <script src="./console_search.js"></script> + <script type="text/javascript" src="index.js"></script> </body> </html> diff --git a/pd/nw/index.js b/pd/nw/index.js index 9cbd22de1..b16d7d923 100644 --- a/pd/nw/index.js +++ b/pd/nw/index.js @@ -36,6 +36,24 @@ document.getElementById("dsp_control").addEventListener("click", } ); +var find_bar = document.getElementById('console_find_text'); +find_bar.addEventListener("keydown", + function(e) { + return console_find_keydown(this, e); + }, false +); + +find_bar.addEventListener("keypress", + function(e) { + console_find_keypress(this, e); + }, false +); + +find_bar.defaultValue = "Search in Console"; + +console_find_set_default(find_bar); + + // Invoke some functions to create main menus, etc. nw.Window.get().on("close", function() { pdgui.menu_quit(); @@ -55,18 +73,138 @@ pdgui.init_socket_events(); pdgui.set_new_window_fn(nw_create_window); pdgui.set_close_window_fn(nw_close_window); -// Greyed out text for the "Find" bar -function console_find_input_focus(e) { +function console_find_check_default(e) { if (e.value === e.defaultValue) { - e.value = ''; - e.style.color = "#000"; + return true; + } else { + return false; + } +} + +function console_find_set_default(e) { + e.value = e.defaultValue; + e.setSelectionRange(0,0); + e.style.color = "#888"; +} + +function console_unwrap_tag(console_elem, tag_name) { + var b = console_elem.getElementsByTagName(tag_name), + parent_elem; + while (b.length) { + parent_elem = b[0].parentNode; + while(b[0].firstChild) { + parent_elem.insertBefore(b[0].firstChild, b[0]); + } + parent_elem.removeChild(b[0]); + parent_elem.normalize(); + } +} + +function console_find_text(elem, evt, callback) { + var console_text = document.getElementById('p1'), + wrap_tag = 'mark', + wrapper_count; + // Check the input for default text before the event happens + if (console_find_check_default(elem)) { + // if so, erase it + elem.value = ''; + // put this in css and use class here + elem.style.color = "#000"; } + window.setTimeout(function () { + console_unwrap_tag(console_text, wrap_tag); + + // Check after the event if the value is empty, and if + // so set it to default value + if (elem.value === undefined || elem.value === '') { + console_find_set_default(elem); + } else if (!console_find_check_default(elem)) { + window.findAndReplaceDOMText(console_text, { + //preset: 'prose', + find: elem.value.toLowerCase(), + wrap: wrap_tag + }); + // The searchAndReplace API is so bad you can't even know how + // many matches there were without traversing the DOM and + // counting the wrappers! + wrapper_count = console_text.getElementsByTagName(wrap_tag).length; + if (wrapper_count < 1) { + elem.style.setProperty('background', 'red'); + } else { + elem.style.setProperty('background', 'white'); + } + } + if (callback) { + callback(); + } + }, 0); +} + +// start at top and highlight the first result after a search +function console_find_callback() { + console_find_traverse.set_index(0); + console_find_traverse.next(); +} + +function console_find_keypress(elem, e) { + console_find_text(elem, e, console_find_callback); } -function console_find_input_blur(e) { - if (e.value === '' || e.value === e.defaultValue) { - e.value = e.defaultValue; - e.style.color = "#888"; +function console_find_highlight_all(elem) { + var matches = document.getElementById('p1').getElementsByTagName('mark'), + highlight_tag = 'console_find_highlighted', + state = elem.checked, + i; + for (i = 0; i < matches.length; i++) { + if (state) { + matches[i].classList.add(highlight_tag); + } else { + matches[i].classList.remove(highlight_tag); + } + } +} + +var console_find_traverse = (function() { + var count = 0, + console_text = document.getElementById('p1'), + wrap_tag = 'mark'; + return { + next: function() { + var i, last, next, + elements = console_text.getElementsByTagName(wrap_tag); + if (elements.length > 0) { + i = count % elements.length; + elements[i].classList.add('console_find_current'); + if (elements.length > 1) { + last = i === 0 ? elements.length - 1 : i - 1; + next = (i + 1) % elements.length; + elements[last].classList.remove('console_find_current'); + elements[next].classList.remove('console_find_current'); + } + // adjust the scrollbar to make sure the element is visible, + // but only if necessary. + // I don't think this is available on all browsers... + elements[i].scrollIntoViewIfNeeded(); + count++; + } + }, + set_index: function(c) { + count = c; + } + } +}()); + +function console_find_keydown(elem, evt) { + if (evt.keyCode === 13) { + console_find_traverse.next(); + evt.stopPropagation(); + evt.preventDefault(); + return false; + } else if (evt.keyCode === 27) { // escape + + } else if (evt.keyCode === 8 || // backspace or delete + evt.keyCode === 46) { + console_find_text(elem, evt, console_find_callback); } } @@ -312,6 +450,7 @@ function nw_create_pd_window_menus () { label: l('menu.find'), click: function () { var find_bar = document.getElementById('console_find'), + find_bar_text = document.getElementById('console_find_text'), text_container = document.getElementById('console_bottom'), state = find_bar.style.getPropertyValue('display'); if (state === 'none') { @@ -319,6 +458,8 @@ function nw_create_pd_window_menus () { find_bar.style.setProperty('display', 'inline'); find_bar.style.setProperty('height', '1em'); text_container.scrollTop = text_container.scrollHeight; + find_bar_text.focus(); + find_bar_text.select(); } else { text_container.style.setProperty('bottom', '0px'); find_bar.style.setProperty('display', 'none'); -- GitLab