AngularJS lazy rendering (not lazy loading views) - javascript

Let's say I have a lot (3000+) of items I want to render (in a ng-repeat) in a div with a fixed height and overflow: auto, so I'd get N visible items and a scrollbar for the rest of them.
I'm guessing doing a simple ng-repeat with so many items will probably take a lot of time. Is there a way I can make AngularJS render only those visible N items?
Edit:
An infinite scroll is not what I want. I want the user to be able to scroll to any point of the list, so I literally want a text editor-like behavior. Said with other words: I'd like the scroll to contain the "height" of all the items, but place in the DOM just a few ones.

This answer provides an approach for lazy-rendering only items currently in-view, as defined by the edit to the original question. I want the user to be able to scroll to any point of the list, so I literally want a text editor-like behavior. Said with other words: I'd like the scroll to contain the "height" of all the items, but place in the DOM just a few ones.
Install the angular-inview plugin before trying this.
In order to get your scrollheight you'd need something holding the space for your array items. So I'd start with an array of 3000 simple items (or combine with infinite scroll to whatever extent you want.)
var $app = angular.module('app', ['infinite-scroll']);
$app.controller('listingController', function($scope, $window) {
$scope.listOfItems = new Array($window._bootstrappedData.length);
$scope.loadItem = function($index,$inview) {
if($inview) {
$scope.listOfItems[$index] = $window._bootstrappedData[$index];
}
}
});
Since we're talking about flexible heights, I would create a placeholder for what your content looks like pre-render.
<div ng-controller="listingController">
<ul>
<li ng-repeat="item in listOfItems track by $index" in-view="loadItem($index,$inview)" style="min-height:100px"><div ng-if="item">{{item.name}}</div></li>
</ul>
</div>
Using ng-if will prevent rendering logic from being run unnecessarily. When you scroll an item into view, it'll automatically display. If you want to wait a second to see if the user is still scrolling you could set a timeout in the loadItem method that cancels if the same index gets pushed out of view within a reasonable time period.
Note: If you truly wanted to avoid putting anything in the DOM, you could set your scrollable area to a specific multiple of your "placeholder" height. Then you could create a directive that uses that height to determine the indexes of the items that should be displayed. As soon as you display new items, you'd need to add their heights to the total and make sure you position them at the right spot and make sure your directive knows how to interpret those heights into evaluating the next set of displayed elements. But I think that's way too radical and unnecessary.

Expanding on Grundy's point of using .slice().
I use ngInfiniteScroll when I need to lazy-render/lazy-load data.
I would keep those 3000 records out of your scope to prevent weighing down your digest performance unnecessarily and then append them to your scope data as you need them. Here's an example.
var $app = angular.module('app', ['infinite-scroll']);
$app.controller('listingController', function($scope, $window) {
/*
* Outside of this controller you should bootstrap your data to a non-digested array.
* If you're loading the data via Ajax, save your data similarly.
* For example:
* <script>
* window._bootstrappedData = [{id:1,name:'foo'},{id:2,name:'bar'},...];
* </script>
*/
var currentPage, pageLength;
$scope.listOfItems = [];
currentPage = 0;
pageLength = 100;
$scope.nextPage = function() {
// make sure we don't keep trying to slice data that doesn't exist.
if (currentPage * pageLength >= $window._bootstrappedData.length) {
return false;
}
// append the next data set to your array
$scope.listOfItems.push($window._bootstrappedData.slice(currentPage * pageLength, (currentPage + 1) * pageLength));
currentPage++;
};
/*
* Kickstart this data with our first page.
*/
return $scope.nextPage();
});
And your template:
<div ng-controller="listingController" infinite-scroll="nextPage()" infinite-scroll-distance="3">
<ul>
<li ng-repeat="item in listOfItems">{{item.name}}</li>
</ul>
</div>

Related

Next/Prev buttons to step through div contents

First of all a disclaimer, I'm not a dev. I'm halfway through The Odin Project and have covered some HTML and CSS, but, have not yet started on JS. In order to help with my learning I've created my own blog. My aim is for each blog post to have its own stylesheet (so with each new post I learn a little more about CSS).
Anyway, I plan to write a post about the benefits of using an eReader, specifically the Kindle. I've styled the page to look like a Kindle Oasis, and I'd like the reader to be able to step through the article contents via the Kindle's next/prev buttons, but, as I'm not a dev, this is where I'm stuck. Via Stack overflow I've managed to add some JS that will display page 1, 2 and 3 via dedicated buttons for each dive element, but, what I really need is to step through x number of pages via the prev/next buttons.
Here's what I have so far: https://codepen.io/dbssticky/pen/yLVoORO. Any help would be much appreciated. What I should do of course is finish The Odin Project and come up with a solution on my own, but, I'd really like to get this Kindle article published sooner rather than later. Hence my rather cheeky request for assistance.
Here's the JS I'm currently using:
function swapContent(id) {
const main = document.getElementById("main_place");
const div = document.getElementById(id);
const clone = div.cloneNode(true);
while (main.firstChild) main.firstChild.remove();
main.appendChild(clone);
}
You have the right idea and it just needs a few adjustments to get the previous/next functionality.
Currently your div IDs are following the format operation1, operation2, and so on. Since you want the previous/next functionality you'll need to change your 'swapping' function, which currently takes the full ID, to use the numeric portion only.
Add a new function which appends the number to 'operation' instead of using the whole thing:
function goToPage(pageNumber){
const main = document.getElementById("main_place");
const div = document.getElementById("operation" + pageNumber);
const clone = div.cloneNode(true);
while (main.firstChild) main.firstChild.remove();
main.appendChild(clone);
}
And then change your Page 1/2/3 buttons to use goToPage(1), goToPage(2) and so on.
Now for the previous/next functionality you'll need a way to track which page you're on, so that you can figure out which page to load.
Add a variable at the top (outside functions)
var currentPage = 0;
Then add a line in your goToPage function to track the page you're on.
currentPage = pageNumber;
Now that you're tracking you can add a previous and next function.
function goNextPage(){
goToPage(currentPage-1);
}
function goPreviousPage(){
goToPage(currentPage+1);
}
Then call it from the previous and next buttons.
<button onClick="goNextPage()" class="next-button"></button>
<button onClick="goPreviousPage()" class="previous-button"></button>
Here's a codepen: https://codepen.io/srirachapen/pen/WNZOXQZ
It's barebones and you may have to handle things like non existent div IDs.
HTML
<button class="next-button" onclick="nextContent()"></button>
<button class="previous-button" onclick="prevContent()"></button>
JS
var pageid = 0;
var maxpage = 3;
function nextContent() {
if(pageid == maxpage) return
pageid++
swapContent(`operation${pageid}`)
}
function prevContent() {
if(pageid == 1) return
pageid--
swapContent(`operation${pageid}`)
}
you can try this to switch between pages. But you may need to edit the "swapContent" method more sensibly.
Track the Current Page
Whatever solution you use to render pages & links (manual hardcoded links & content vs externally-stored & auto-generated), one thing is unavoidable: You need to track the current page!
var currentPage = 0
Then, any time there's a page change event, you update that variable.
With the current page being tracked, you can now perform operations relative to it (e.g. +1 or -1)
I'd suggest making a goToPage(page) function that does high-level paging logic, and keep your swapContent() function specifically for the literal act of swapping div content. In the future, you may find you'd want to use swapContent() for non-page content, like showing a "Welcome" or "Help" screen.
Example:
function goToPage(page) {
// Update `currentPage`
currentPage = page
// ... other logic, like a tracking event or anything else you want you occur when pages change
// Do the actual content swap, which could be your existing swapContent()
swapContent('operation'+page)
}
You'd invoke the function like so:
goToPage(3) // Jump to a specific page
goToPage(currentPage + 1) // Go to the next page
goToPage(currentPage - 1) // Go to the prev page
You can make separate helper functions like "goToNextPage()" if you desire, but for sure you start with a fundamental page-change function first.

Add an ellipsis to middle of long string in React16

I am trying to add an ellipsis to the mid-point in a string with the following complications:
I don't know how long the string is going to be
I only know the max-width and min-width of the parent element
The string may or may not fit into it's parent and not require ellipses
I have a plunk here to illustrate it. The script only assumes one instance, but you should get the idea:
(function(){
// variables
var parent = document.querySelectorAll(".wrapper")[0],
parentWidth = parent.clientWidth,x = 0, elem, hellip
txtStr = document.querySelector("#shorten"),
strWidth = txtStr.clientWidth,
strTxt = txtStr.innerText,
ending = document.createElement("span"),
endTxt = strTxt.slice(Math.max(strTxt.length - (strTxt.length / 4))) || endTxt;
txtStr.style.overflow = "hidden"
txtStr.style.textOverflow = "ellipsis"
ending.appendChild(document.createTextNode(endTxt))
ending.classList.add("ellipsis")
document.querySelectorAll(".wrapper")[0].appendChild(ending)
var ell = function(a, b){
if (a <= b){
ending.classList.add("visible")
}
else {
ending.classList.remove("visible")
}
}
ell(parentWidth, strWidth) // We need to display any changes immediately
window.onresize = function(){ // if the window is resized, we also need to display changes
hellip = document.querySelectorAll(".ellipsis")[0].clientWidth
parentWidth = parent.clientWidth
// the use of 'calc()' is because the length of string in px is never known
txtStr.style.width = "calc(100% - " + hellip + "px"
ell(parentWidth, strWidth)
}
})();
It's a bit clunky, but demonstrates the idea.
The issue I am having in React 16, is that the string is not rendered at the point I need to measure it to create the bit of text at the end. Therefore, when the new node is created it has no dimensions and cannot be measured as it doesn't exist in the DOM.
The functionality works - sort of as the screen resizes, but that's beside the point. I need to get it to do the do at render time.
The actual app is proprietary, and I cannot share any of my code from that in this forum.
EDIT: Another thing to bare in mind (teaching to suck eggs, here) is that in the example, the script is loaded only after the DOM is rendered, so all of the information required is already there and measurable.
Thank you to all that looked at this, but I managed to figure out the problem.
Once I worked out the finesse of the lifecycle, it was actually still quite tricky. The issue being measuring the original string of text. Looking back now, it seems insignificant.
Essentially, I pass a few elements into the component as props: an id, any required padding, the length of the ending text required for context and the text (children).
Once they are in, I need to wait until it is mounted until I can do anything as it all depends on the DOM being rendered before anything can be measured. Therefore, componentDidMount() and componentDidUpdate() are the stages I was interested in. componentWillUnmount() is used to remove the associated event listener which in this instance is a resize event.
Once mounted, I can get the bits required for measuring: the element and importantly, its parent.
getElements(){
return {
parent: this.ellipsis.offsetParent,
string: this.props.children
}
}
Then, I need to make sure that I can actually measure the element so implement some inline styles to allow for that:
prepareParentForMeasure(){
if(this.getElements().parent != null){
this.getElements().parent.style.opacity = 0.001
this.getElements().parent.style.overflow = 'visible'
this.getElements().parent.style.width = 'auto'
}
}
As soon as I have those measurements, I removed the styles.
At this point, the script will partially work if I carry on down the same path. However, adding an additional element to work as a guide is the kicker.
The returned element is split into three elements (span tags), each with a different purpose. There is the main bit of text, or this.props.children, if you like. This is always available and is never altered. The next is the tail of the text, the 'n' number of characters at the end of the string that are used to contextually display the end of the string - this is given a class of 'ellipsis', although the ellipsis is actually added to the original and first element. The third is essentially exactly the same as the first, but is hidden and uninteractable, although it does have dimensions. This is because the first two - when rendered - have different widths and cannot be relied upon as both contribute to the width of the element, whereas the third doesn't.
<Fragment>
<span className='text'>{this.props.children}</span>
<span className='ellipsis'>{this.tail()}</span>
<span className='text guide' ref={node => this.ellipsis = node}>
{this.props.children}</span>
</Fragment>
These are in a fragment so as to not require a surrounding element.
So, I have the width of the surrounding parent and I have the width of the text element (in the third span). which means that if I find that the text string is wider than the surrounding wrapper, I add a class to the ellipsis span of 'visible', and one to the 'text' element of 'trimmed', I get an ellipsis in the middle of the string and I use the resize event to make sure that if someone does do that, all measurements are re-done and stuff is recalculated and rendered accordingly.

How do I Stop Angular from Clearing the Text Selection?

I'm trying to render and refresh every few seconds a list of complicated objects using Angular. An issue I've found is that when the refresh happens, even if a particular HTML subcomponent hasn't changed, the HTML is updated and if you'd selected some of the text (e.g. you were trying to copy it) the selection goes away.
I know there is a general issue with changing the html that contains a selection, but I'm wondering if Angular has some solution to the problem that I'm just not aware of. Basically what I'm looking for is for only the HTML that actually changed being updated. I could do that if I was writing view code manually in jQuery, but every other part of doing it manually is awful
JS:
angular.module('items', [])
.factory('itemList', ['$http', function($http) {
var items = [];
var refresh = function() {
// imagine that this makes an HTTP call to get the new list
// of items
items.length = 0;
for (var i = 0; i < 10; i++) {
items.push("item " + Math.random(1, 10))
}
}
refresh();
return {
items: items,
refresh: refresh
};
}]);
var app = angular.module('app', [
'items'
]);
app.controller('ItemListController',
['$scope', 'itemList', '$interval',
function($scope, itemList, $interval) {
this.items = itemList.items;
$interval(itemList.refresh, 2000)
}
]);
HTML:
<body ng-app="app">
<div ng-controller="ItemListController as controller">
<h3>Items</h3>
<div ng-model="active">
<div ng-repeat="item in controller.items">
<div class="header">Header</div>
<div>{{item}}</div>
<hr/>
</div>
</div>
</div>
</body>
As you're wholesale replacing itemList on each refresh, angular has no option but to re-create all the elements in the ng-repeat, which is fine if you don't mind losing the selection and the refresh isn't too large and expensive. To prevent this though, you could try writing a merge in that factory that diffs the previous against the new and adds/removes items without replacing the whole reference. Then only if the item you selected no longer exists would you lose the selection.
Also, if the list is long and the differences from refresh to refresh are small, then this will probably be more efficient.
$interval refreshes entire DOM So selection is get refreshed and new value will appear.
if you want to select then cancel the interval for particular time $interval.cancel(milliseconds) then restart the timer with selected range.
$scope.$watch("refresh", function(){
$interval.cancel(5000);
p = $interval(itemsList.refresh(), 2000);
})
I figured it out. Thor's answer was on the right track. The issue was that I wasn't using "track by FOO" on my ng-repeat, so it had no way of knowing that the elements in my actual case with complex objects were the same and thus it redrew them

NG Style Failing to automatically bind to page

I'm trying to make a page with two sections, which can be slid back and forth horizontally to take up different relative widths on the page. The idea was to track the percentile width of the (to-be) draggable bar separating the two panes/sections, and use ng_style to automatically update the widths of the two "panes" in relation to where that bar is dragged.
The following is in a Rails app, with integrated Angular. The Angular's loading just fine -- no errors, and the rest is all working -- and the ng_style is being loaded from the Angular controller when the page first loads up -- but it's not changing when I attempt to drag the spacer in between the two "panes", as it's supposed to.
Here's a simplified version of my HAML (sorta like Jade. Just indented HTML):
#full-page.fluid{ ng_controller: "ExerciseCtrl", ng_mousemove: 'updateSidebarWidth($event)', ng_mouseup: 'untrackMouseMove()', ng_cloak: true }
.spacer
.fixed-section-container
.exercise-show
%div
.sidebar-section{ ng_style: '{{ sidebarWidthStyle }}' }
.sidebar_header
.sidebar
%div{ markdown: #exercise.body }
.sidebar-toggle{ ng_mousedown: 'trackMouseMove()' }
.work_area{ ng_style: '{{ workAreaWidthStyle }}' }
And here are the relevant lines in my (Coffeescript) Angular Controller.
$scope.sidebarWidth = 35
$scope.trackingMouse = false
$scope.trackMouseMove = -> $scope.trackingMouse = true
$scope.untrackMouseMove = -> $scope.trackingMouse = false
$scope.updateSidebarWidth = (event) ->
if $scope.trackingMouse
pageWidth = $('.emelyn-layout.middle.fluid').width()
x_percent = (event.pageX * 100) / pageWidth
x_percent = Math.max( Math.min(100, x_percent), 0 )
$scope.sidebarWidth = x_percent
$scope.workAreaWidthStyle = { width: "#{99 - $scope.sidebarWidth}%", marginLeft: "#{$scope.sidebarWidth + 1}%" }
$scope.sideBarWidthStyle = { width: "#{$scope.sidebarWidth}%" }
In other words, I have this .sidebar-section on the left, then a .sidebar-toggle (just a vertical bar), which should be draggable (which causes the width styles to change, once I get this working), and then the .work-area, which is resized, along with the .sidebar, on drag of the toggle.
The issue is that, although the ng_styles are loaded on page load, and although the ng_style values visibly change when I inspect the page and drag the toggle bar, the ng_style changes aren't propagating to the style attributes of the given elements.
In other words, when I inspect the element, I see something like this:
<div class="work_area" ng_style="{ 'width':'48.142857142857146', 'marginLeft':'51.857142857142854' }">
I took a look at this post and tried wrapping updates to the styles in a $scope.$watch, but that didn't change any of the above behavior at all, and it seems wrong to me -- the docs on ng_style suggest to me that the way I'm using it should be effectively correct -- as in, Angular handles the binding for you, without your needing to explicitly tell it when to update the DOM jQuery style.
Any ideas what I'm doing wrong and how to fix it? (Or what might be a better or easier way of doing the above?)
Thanks, and please let me know if you'd like to see any other files or anything,
Sasha
ngStyle works a little differently than what you have. It is evaluated as is, no need for {{}} to point to a $scoped variable.
#full-page.fluid{ ng_controller: "ExerciseCtrl", ng_mousemove: 'updateSidebarWidth($event)', ng_mouseup: 'untrackMouseMove()', ng_cloak: true }
.spacer
.fixed-section-container
.exercise-show
%div
.sidebar-section{ ng_style: 'sidebarWidthStyle' }
.sidebar_header
.sidebar
%div{ markdown: #exercise.body }
.sidebar-toggle{ ng_mousedown: 'trackMouseMove()' }
.work_area{ ng_style: 'workAreaWidthStyle' }
When you use {{}} it evaluates that to a string and then runs it through ngStyle. ngStyle ends up watching a string and not a variable.

AngularJS on top of server generated content

I'm looking for a way to integrate something like ng-repeat with static content. That is, to send static divs and to have them bound to JS array (or rather, to have an array constructed from content and then bound to it).
I realize that I could send static content, then remove and regenerate the dynamic bits. I'd like not to write the same divs twice though.
The goal is not only to cater for search engines and people without js, but to strike a healthy balance between static websites and single page applications.
I'm not sure this is exactly what you meant, but it was interesting enough to try.
Basically what this directive does is create an item for each of its children by collecting the properties that were bound with ng-bind. And after it's done that it leaves just the first child as a template for ng-repeat.
Directive:
var app = angular.module('myApp', []);
app.directive('unrepeat', function($parse) {
return {
compile : function (element, attrs) {
/* get name of array and item from unrepeat-attribute */
var arrays = $parse(attrs.unrepeat)();
angular.forEach(arrays, function(v,i){
this[i] = [];
/* get items from divs */
angular.forEach(element.children(), function(el){
var item = {}
/* find the bound properties, and put text values on item */
$(el).find('[ng-bind^="'+v+'."]').each(function(){
var prop = $(this).attr('ng-bind').split('.');
/* ignoring for the moment complex properties like item.prop.subprop */
item[prop[1]] = $(this).text();
});
this[i].push(item);
});
});
/* remove all children except first */
$(element).children(':gt(0)').remove()
/* add array to scope in postLink, when we have a scope to add it to*/
return function postLink(scope) {
angular.forEach(arrays, function(v,i){
scope[i] = this[i];
});
}
}
};
});
Usage example:
<div ng-app="myApp" >
<div unrepeat="{list:'item'}" >
<div ng-repeat="item in list">
<span ng-bind="item.name">foo</span>
<span ng-bind="item.value">bar</span>
</div>
<div ng-repeat="item in list">
<span ng-bind="item.name">spam</span>
<span ng-bind="item.value">eggs</span>
</div>
<div ng-repeat="item in list">
<span ng-bind="item.name">cookies</span>
<span ng-bind="item.value">milk</span>
</div>
</div>
<button ng-click="list.push({name:'piep', value:'bla'})">Add</button>
</div>
Presumable those repeated divs are created in a loop by PHP or some other backend application, hence why I put ng-repeat in all of them.
http://jsfiddle.net/LvjyZ/
(Note that there is some superfluous use of $(), because I didn't load jQuery and Angular in the right order, and the .find on angular's jqLite lacks some features.)
You really have only one choice for this:
Render differently for search engines on the server, using something like the approach described here
The problem is you would need to basically rewrite all the directives to support loading their data from DOM, and then loading their templates somehow without having them show up in the DOM as well.
As an alternative, you could investigate using React instead of Angular, which (at least according to their website) could be used to render things directly on the web server without using a heavy setup like phantomjs.

Categories

Resources