Skip to content

Instantly share code, notes, and snippets.

@furf
Last active July 21, 2016 06:36
Show Gist options
  • Save furf/4331090 to your computer and use it in GitHub Desktop.
Save furf/4331090 to your computer and use it in GitHub Desktop.
A minimal recursive tree directive for Angular.js. Demo: http://jsfiddle.net/furf/EJGHX/
var app = angular.module('app', []);
app.directive('yaTree', function () {
return {
restrict: 'A',
transclude: 'element',
priority: 1000,
terminal: true,
compile: function (tElement, tAttrs, transclude) {
var repeatExpr, childExpr, rootExpr, childrenExpr;
repeatExpr = tAttrs.yaTree.match(/^(.*) in ((?:.*\.)?(.*)) at (.*)$/);
childExpr = repeatExpr[1];
rootExpr = repeatExpr[2];
childrenExpr = repeatExpr[3];
branchExpr = repeatExpr[4];
return function link (scope, element, attrs) {
var rootElement = element[0].parentNode,
cache = [];
// Reverse lookup object to avoid re-rendering elements
function lookup (child) {
var i = cache.length;
while (i--) {
if (cache[i].scope[childExpr] === child) {
return cache.splice(i, 1)[0];
}
}
}
scope.$watch(rootExpr, function (root) {
var currentCache = [];
// Recurse the data structure
(function walk (children, parentNode, parentScope, depth) {
var i = 0,
n = children.length,
last = n - 1,
cursor,
child,
cached,
childScope,
grandchildren;
// Iterate the children at the current level
for (; i < n; ++i) {
// We will compare the cached element to the element in
// at the destination index. If it does not match, then
// the cached element is being moved into this position.
cursor = parentNode.childNodes[i];
child = children[i];
// See if this child has been previously rendered
// using a reverse lookup by object reference
cached = lookup(child);
// If the parentScope no longer matches, we've moved.
// We'll have to transclude again so that scopes
// and controllers are properly inherited
if (cached && cached.parentScope !== parentScope) {
cache.push(cached);
cached = null;
}
// If it has not, render a new element and prepare its scope
// We also cache a reference to its branch node which will
// be used as the parentNode in the next level of recursion
if (!cached) {
transclude(parentScope.$new(), function (clone, childScope) {
childScope[childExpr] = child;
cached = {
scope: childScope,
parentScope: parentScope,
element: clone[0],
branch: clone.find(branchExpr)[0]
};
// This had to happen during transclusion so inherited
// controllers, among other things, work properly
parentNode.insertBefore(cached.element, cursor);
});
} else if (cached.element !== cursor) {
parentNode.insertBefore(cached.element, cursor);
}
// Lets's set some scope values
childScope = cached.scope;
// Store the current depth on the scope in case you want
// to use it (for good or evil, no judgment).
childScope.$depth = depth;
// Emulate some ng-repeat values
childScope.$index = i;
childScope.$first = (i === 0);
childScope.$last = (i === last);
childScope.$middle = !(childScope.$first || childScope.$last);
// Push the object onto the new cache which will replace
// the old cache at the end of the walk.
currentCache.push(cached);
// If the child has children of its own, recurse 'em.
grandchildren = child[childrenExpr];
if (grandchildren && grandchildren.length) {
walk(grandchildren, cached.branch, childScope, depth + 1);
}
}
})(root, rootElement, scope, 0);
// Cleanup objects which have been removed.
// Remove DOM elements and destroy scopes to prevent memory leaks.
i = cache.length;
while (i--) {
cached = cache[i];
if (cached.scope) {
cached.scope.$destroy();
}
if (cached.element) {
cached.element.parentNode.removeChild(cached.element);
}
}
// Replace previous cache.
cache = currentCache;
}, true);
};
}
};
});
app.controller('TreeController', function ($scope, $timeout) {
$scope.json = '';
$scope.data = {
children: [{
title: 'hello, world',
children: [{
title: 'test 1',
children: []
},
{
title: 'test 2',
children: []
}]
}]
};
$scope.getJson = function () {
$scope.json = angular.toJson($scope.data);
};
$scope.toggleMinimized = function (child) {
child.minimized = !child.minimized;
};
$scope.addChild = function (child) {
child.children.push({
title: '',
children: []
});
};
$scope.remove = function (child) {
function walk(target) {
var children = target.children,
i;
if (children) {
i = children.length;
while (i--) {
if (children[i] === child) {
return children.splice(i, 1);
} else {
walk(children[i])
}
}
}
}
walk($scope.data);
}
$scope.update = function (event, ui) {
var root = event.target,
item = ui.item,
parent = item.parent(),
target = (parent[0] === root) ? $scope.data : parent.scope().child,
child = item.scope().child,
index = item.index();
target.children || (target.children = []);
function walk(target, child) {
var children = target.children,
i;
if (children) {
i = children.length;
while (i--) {
if (children[i] === child) {
return children.splice(i, 1);
} else {
walk(children[i], child);
}
}
}
}
walk($scope.data, child);
target.children.splice(index, 0, child);
};
});
[ng-cloak] {
display: none;
}
* {
box-sizing: border-box
}
body {
font: 12px/16px helvetica, arial, sans-serif;
background: #eee;
}
ol {
margin:4px 0;
border: 0 none transparent;
padding: 0;
background:#eee;
}
li {
list-style:none;
margin: 1px;
border: 1px solid #ddd;
padding: 8px 8px 4px 8px;
background: #fff;
}
.ui-sortable li {
cursor: move;
}
.live input, .shadow input {
margin: 0;
border: 1px solid #ddd;
padding: 1px;
font: inherit;
font-weight: bold;
height:2em;
}
.ui-state-highlight {
border: 1px solid #fc0;
background: #ffe;
}
.live, .shadow {
float: left;
width: 45%;
margin-right: 5%;
}
.shadow {
opacity: 0.7;
}
.debug {
clear: both;
}
.debug textarea {
width: 100%;
height: 40em;
font: 10px/13px monospace;
}
textarea, input {
outline: none;
}
.pregnant {
border: 1px solid #ddd;
}
.bg0 { background:#fcc; }
.bg1 { background:#ffc; }
.bg2 { background:#cfc; }
.bg3 { background:#cff; }
.bg4 { background:#ccf; }
.bg5 { background:#fcf; }
.minimized > ol > li { display:none; }
.minimized > ol { border: 0 none transparent; }
.toggle { border: 0 none transparent; background:transparent; width:2em; color:#aaa; }
button { cursor: pointer }
<script type='text/javascript' src='//code.jquery.com/jquery-1.9.1.js'></script>
<script type="text/javascript" src="http://code.jquery.com/ui/1.9.2/jquery-ui.js"></script>
<link rel="stylesheet" type="text/css" href="http://code.jquery.com/ui/1.9.2/themes/base/jquery-ui.css">
<script type='text/javascript' src="http://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.2/jquery.ui.touch-punch.min.js"></script>
<script type='text/javascript' src="https://raw.github.com/mjsarfatti/nestedSortable/master/jquery.mjs.nestedSortable.js"></script>
<script type='text/javascript' src="http://cdnjs.cloudflare.com/ajax/libs/angular.js/1.1.1/angular.min.js"></script>
<div ng-app="app" ng-cloak>
<div ng-controller="TreeController">
<div>
<button ng-click="addChild(data)">+ New</button>
</div>
<div class="live">
<ol>
<li ya-tree="child in data.children at ol" ng-class="{minimized:child.minimized}">
<div>
<button class="toggle" ng-click="toggleMinimized(child)" ng-switch on="child.minimized"><span ng-switch-when="true">&#x25B6;</span><span ng-switch-default>&#x25BC;</span></button>
<input ng-model="child.title" />
<button ng-click="addChild(child)">+</button>
<button ng-click="remove(child)">x</button>
</div>
<ol ng-class="{pregnant:child.children.length}"></ol>
</li>
</ol>
</div>
<div class="shadow">
<ol>
<li ya-tree="child in data.children at ol" class="bg{{$depth%6}}" ng-class="{minimized:child.minimized}">
<div>
<input disabled value="{{child.title}}" /> <em>({{$depth}})</em>
</div>
<ol ng-class="{pregnant:child.children.length}"></ol>
</li>
</ol>
</div>
<div><button ng-click="getJson()">Json</button></div>
<div>{{json}}</div>
</div>
</div>
@tamtakoe
Copy link

I saw example http://jsfiddle.net/furf/R2eMe/ It is cool! But I can't to do synchronization with model. Help me.

@JustMaier
Copy link

Cool approach, just saw a later version that synchronizes with the model. very cool stuff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment