ux-datagrid-findInList.js |
|
uxDatagrid v.0.4.0-alpha (c) 2014, WebUX https://github.com/webux/ux-angularjs-datagrid License: MIT. |
(function(exports, global){
var finder, // there can only be one at a time.
cmdKey;
|
if the cmdKey is pressed on a mac. |
angular.module("ux").factory("findInList", [ "$window", "$compile", function($window, $compile) {
return function(inst) {
var result = {}, term = "", input, lowerCaseTerm, lastFiltered, searchIndex = 0, matchCount, searchIntv, scrollToItemActive = false, itemTexts = {}, findInListTemplate = '<div data-ux-datagrid-find-in-list="datagrid" class="findInList"></div>', templateTexts = {}, spanClass = "uxDatagridFindInListHighlight", workingScope = inst.scope.$new();
function onKeyDown(event) {
inst.flow.info("onKeyDown %s", event.keyCode);
detectCmdKey(event);
|
we only want to do this if the grid has focus. |
if (inst.element[0].contains(document.activeElement)) {
if (event.keyCode == 114 || (event.ctrlKey || cmdKey) && event.keyCode == 70) {
|
Block CTRL + F event |
event.preventDefault();
addFinder();
}
}
}
function onKeyUp(event) {
inst.flow.info("onKeyUp %s", event.keyCode);
if (cmdKey) {
detectCmdKey(event);
}
}
function detectCmdKey(event) {
inst.flow.info("detectCmdKey");
if (isCmdKey(event)) {
cmdKey = event.type === "keydown";
}
}
function isCmdKey(event) {
inst.flow.info("isCmdKey");
|
if mac we need to check the command key based on each browser because the keycode is different. |
if ($window.navigator.platform === "MacIntel") {
|
chrome/safari left cmd or right cmd key |
if ($window.navigator.userAgent.match(/(Chrome|Safari)/i) && event.keyCode === 91 || event.keyCode === 93) {
return true;
}
if (window.navigator.userAgent.match(/Firefox/i) && event.keyCode === 224) {
return true;
}
if (window.navigator.userAgent.match(/Opera/i) && event.keyCode === 17) {
return true;
}
}
return false;
}
function cloneTextNodes() {
var ary = [];
ux.each(this, cloneTextNodeProps, ary);
return ary;
}
function cloneTextNodeProps(textNode, index, list, ary) {
ary[index] = textNode.slice(0);
ary[index].text = textNode.text;
}
function onGetTemplate(template, index, list, res) {
var el, textNodes;
if (!res[template.name]) {
textNodes = [];
textNodes.clone = cloneTextNodes;
el = angular.element(template.template);
exports.each(el[0].childNodes, findTextNodes, {
textNodes: textNodes
});
res[template.name] = textNodes;
}
}
function findTextNodes(el, index, childNodes, data) {
var indexes = data.indexes ? data.indexes.slice(0) : [];
indexes.push(index);
if (el.childNodes.length) {
exports.each(el.childNodes, findTextNodes, {
indexes: indexes,
textNodes: data.textNodes
});
} else if (el.nodeValue) {
indexes.text = el.nodeValue;
data.textNodes.push(indexes);
}
}
function addFinder() {
inst.flow.info("addFinder");
if (!finder) {
finder = angular.element(findInListTemplate);
inst.element[0].parentNode.insertBefore(finder[0], inst.element[0]);
$compile(finder)(inst.scope);
finder.css({
top: parseInt(inst.element.css("top"), 10) - finder[0].offsetHeight
});
input = finder[0].getElementsByClassName("findInListInput")[0];
if (input.select) {
input.select();
}
input.focus();
finder.scope().close = removeFinder;
inst.safeDigest(finder.scope());
input.addEventListener("keyup", onInputKeyUp);
matchCount = finder[0].getElementsByClassName("findInListMatchCount")[0];
itemTexts = {};
ux.each(inst.templateModel.getTemplates(), onGetTemplate, templateTexts);
throttleDoSearch();
} else {
removeFinder();
addFinder();
}
}
function removeFinder() {
inst.flow.info("removeFinder");
if (finder) {
var f = finder;
itemTexts = null;
clearHighlights();
finder = null;
|
null this first to prevent recursive loop possibility. |
input.removeEventListener("keyup", onInputKeyUp);
f.scope().$destroy();
f.remove();
}
}
function onInputKeyUp(event) {
inst.flow.info("onInputKeyUp");
var newTerm = input.value;
|
TODO: iOS has different key for enter that needs to be added here. |
if (event.keyCode === 13) {
|
enter |
(event.shiftKey ? result.up : result.down)();
}
if (event.keyCode === 27) {
|
esc |
result.close();
}
if (newTerm === term) {
return;
}
throttleDoSearch();
}
function throttleDoSearch() {
clearTimeout(searchIntv);
searchIntv = setTimeout(doSearch, 250);
}
function doSearch() {
var searchList = inst.getData(), filtered = [];
term = input.value;
|
TODO: need to ignore keys that are navigation or special characters. TODO: this needs to make sure to keep the indexes of the data in the original list to scroll to them. |
clearHighlights();
searchIndex = 0;
if (term.length) {
scrollToItemActive = true;
filtered.absCount = 0;
lowerCaseTerm = term.toLowerCase();
ux.each(searchList, onFilter, filtered);
if (filtered.length) {
highlightMatches(filtered);
}
lastFiltered = filtered;
updateMatchCount();
scrollToItemActive = false;
}
}
function updateMatchCount() {
if (lastFiltered && lastFiltered.length) {
matchCount.innerText = searchIndex + 1 + " of " + lastFiltered.length;
} else {
matchCount.innerText = "0 of 0";
}
}
function evalTemplateText(item) {
var tpl = inst.templateModel.getTemplate(item), matches, uncompiledText = templateTexts[tpl.name].clone();
workingScope[tpl.item] = item;
|
TODO: this can be optimized. Perhaps cache results for a time. |
exports.each(uncompiledText, replaceCompiled, workingScope);
return uncompiledText;
}
function replaceCompiled(textNode, index, list, workingScope) {
var matches = textNode.text.match(/{{(.*?)}}/g);
textNode.workingScope = workingScope;
exports.each(matches, evalMatches, textNode);
delete textNode.workingScope;
}
function evalMatches(value, index, list, textNode) {
var evalStr = textNode.workingScope.$eval(value.substr(2, value.length - 4));
textNode.text = textNode.text.replace(value, evalStr);
list[index] = evalStr;
}
function onFilter(item, index, list, filtered) {
var matches = [];
|
cache itemTexts on an open/close basis. |
if (!itemTexts[index]) {
itemTexts[index] = evalTemplateText(item);
}
exports.each(itemTexts[index], getMatches, matches, filtered);
if (matches.length) {
matches.rowIndex = index;
filtered.push(matches);
}
}
function getMatches(itemText, index, list, itemTextMatches, filtered) {
var value = itemText.text.toLowerCase(), matchIndex = value.indexOf(lowerCaseTerm, 0), i = 0;
if (itemTextMatches.absIndex === undefined) {
|
only set it if it has not been. we don't want to overwrite it. |
itemTextMatches.absIndex = filtered.absCount;
}
while (matchIndex !== -1) {
itemTextMatches.push({
matchIndex: matchIndex,
itemText: itemText,
absIndex: filtered.absCount
});
matchIndex = value.indexOf(lowerCaseTerm, matchIndex + 1);
filtered.absCount += 1;
i += 1;
}
}
function highlightMatches(filtered) {
var selected;
if (filtered.length) {
ux.each(filtered, highlight);
selected = filtered.selected;
if (scrollToItemActive && (selected.rowIndex < inst.values.activeRange.min + 2 || selected.rowIndex > inst.values.activeRange.max - 2)) {
inst.scrollModel.scrollIntoView(selected.rowIndex, true);
}
}
}
function highlight(match, index, list) {
|
we need to find all of the matches. And make a selected range. Then highlight those. |
if (match.rowIndex >= inst.values.activeRange.min && match.rowIndex <= inst.values.activeRange.max) {
var row = inst.getRowElm(match.rowIndex);
clearHighlightsForRow(match, true);
exports.each(match, highlightRowMatches, row[0]);
}
if (match.absIndex <= searchIndex && searchIndex - match.absIndex < match.length) {
list.selected = match;
list.selectedMatchIndex = searchIndex - match.absIndex;
}
}
function highlightRowMatches(match, index, list, row) {
var node = getNodeFromMatch(row, match);
|
TODO: this needs to check all matches. now we need to highlight the text range inside of the node |
highlightTextRange(node, match, index);
}
function getNodeFromMatch(el, match) {
var depth = 0, len = match.itemText.length, child = el;
while (depth < len) {
child = child.childNodes[match.itemText[depth]];
depth += 1;
}
return child;
}
function highlightTextRange(el, match, index) {
|
TODO: needs to be injected. |
if (el) {
var range = document.createRange(), selectionContents, span = document.createElement("span"), i = 0, len = 0, startIndex = match.matchIndex, endIndex = match.matchIndex + term.length, siblings = el.parentNode.childNodes;
while (i < siblings.length - 1) {
el = siblings[i];
if (el.className && el.className.indexOf(spanClass) !== -1) {
|
these are already matches that have been turned into spans. |
len = el.innerText.length;
} else if (match.matchIndex && el.nodeType === 3) {
|
text node. |
len = el.nodeValue ? el.nodeValue.length : 0;
if (len < startIndex) {
len = 0;
}
} else {
len = 0;
}
startIndex -= len;
endIndex -= len;
i += 1;
}
el = siblings[siblings.length - 1];
if (el.nodeType === 3 && el.nodeValue.length >= endIndex) {
try {
range.setStart(el, startIndex);
range.setEnd(el, endIndex);
} catch (e) {
throw new Error("OOPS! Something went wrong with the highlighting!");
}
selectionContents = range.extractContents();
span.appendChild(selectionContents);
span.className = spanClass + (match.absIndex === searchIndex ? " selectedHighlight" : "");
range.insertNode(span);
}
}
}
function clearHighlights() {
if (lastFiltered) {
|
we want to digest every row that had one. |
ux.each(lastFiltered, clearHighlightsForRow);
lastFiltered = [];
}
}
function clearHighlightsForRow(match, force) {
|
TODO: We need to clear all. Not just the ones that are in view. or when out of view we need to clear them... maybe before a render we clear them. |
if (force || match.rowIndex >= inst.values.activeRange.min + inst.options.cushion && match.rowIndex <= inst.values.activeRange.max - inst.options.cushion) {
var row = inst.getRowElm(match.rowIndex)[0];
exports.each(match, clearHighlightsForRowMatches, row);
}
}
function clearHighlightsForRowMatches(match, index, list, row) {
var node = getNodeFromMatch(row, match), parent, children, i = 0, len, child, str, sib;
if (node) {
parent = node.parentNode;
children = parent.childNodes;
len = children.length;
while (i < len) {
child = children[i];
isSpan = child.className && child.className.indexOf(spanClass) !== -1;
if (isSpan || child.nodeType === 3 && child.previousSibling) {
str = isSpan ? child.innerText : child.nodeValue;
sib = child.previousSibling;
parent.removeChild(child);
sib.nodeValue += str;
children = parent.childNodes;
len = children.length;
i -= 1;
}
i += 1;
}
}
}
function setup() {
inst.flow.info("setup");
|
listen for key events to open the find. |
$window.addEventListener("keydown", onKeyDown);
$window.addEventListener("keyup", onKeyUp);
|
make datagrid focusable so we can have focus in it to find. |
inst.element.attr("tabindex", 999999);
}
function updateSearchIndexHighlight() {
beforeRender();
afterRender();
}
function beforeRender() {
var filtered = lastFiltered;
clearHighlights();
lastFiltered = filtered;
}
function afterRender() {
if (finder && lastFiltered) {
highlightMatches(lastFiltered);
updateMatchCount();
}
}
result.up = function up() {
scrollToItemActive = true;
if (searchIndex > 0) {
searchIndex -= 1;
} else {
searchIndex = lastFiltered.length - 1;
}
updateSearchIndexHighlight();
scrollToItemActive = false;
};
result.down = function down() {
scrollToItemActive = true;
if (searchIndex < lastFiltered.length - 1) {
searchIndex += 1;
} else {
searchIndex = 0;
}
updateSearchIndexHighlight();
scrollToItemActive = false;
};
result.destroy = function() {
if (finder) {
removeFinder();
}
$window.removeEventListener("keydown", onKeyDown);
$window.removeEventListener("keyup", onKeyUp);
input = null;
lastFiltered = null;
matchCount = null;
workingScope.$destroy();
workingScope = null;
result = null;
};
setup();
result.open = addFinder;
result.close = function() {
removeFinder();
inst.element[0].focus();
};
inst.findInList = result;
inst.unwatchers.push(inst.scope.$on(exports.datagrid.events.ON_BEFORE_UPDATE_WATCHERS, beforeRender));
inst.unwatchers.push(inst.scope.$on(exports.datagrid.events.ON_AFTER_UPDATE_WATCHERS, afterRender));
return inst;
};
} ]);
angular.module("ux").directive("uxDatagridFindInList", function() {
var directiveTerm = "";
function countFind(el, index, list, result) {
if (el === document.activeElement) {
result.count += 1;
}
}
return {
restrict: "A",
scope: true,
template: '<input type="text" ng-model="term" class="findInListInput">' + '<span class="findInListMatchCount"></span>' + '<input type="button" class="findInListButton findInListDown" value="▼" ng-click="down()">' + '<input type="button" class="findInListButton findInListUp" value="▲" ng-click="up()">' + '<input type="button" class="findInListButton findInListClose" value="X" ng-click="close()">',
link: function(scope, element, attr) {
var children = element.children();
scope.term = directiveTerm;
|
we want this to wait and run after the element is there. |
children.bind("blur", function(event) {
setTimeout(function() {
var result = {
count: 0
};
ux.each(children, countFind, result);
if (!result.count) {
scope.close();
}
});
});
scope.up = function() {
scope.datagrid.findInList.up();
};
scope.down = function() {
scope.datagrid.findInList.down();
};
scope.$watch(function() {
directiveTerm = scope.term;
});
}
};
});
}(this.ux = this.ux || {}, function() {return this;}()));
|