"Live" Element Scroller

AttachmentSize
elementScroller.js.txt5.45 KB
elementScroller.php.txt4.8 KB

In writing and troubleshooting a web based PHP application that (among other things) installs instances of software, I realized that the user was receiving no feedback as the installation progressed. However, several logs were being produced from system calls, so I set these up to tee the output and replaced my system() calls with passthru() calls. The result was a mess of hardly usable output being spewed at the user.

I then set up the page to open a scrolling div before each log, allowing the browser to assume a closing div, effectively causing all of the log output to be stuffed into the scrolling container. This works well enough, but doesn't effectively convey the idea of activity since the scrolling div can't be made to scroll "from the bottom" using CSS or DOM properties. In asking google what might be done, this post came up, which is almost what I needed. That code assumes that the div content is being generated from javascript rather than from the initial HTTP request. In tinkering with it for a half hour or so, I came to the following solution.

The end result is a scrolling container that maintains its scroll at the lowest point even as more content is dumped into the page. However, the user can take control and scroll to any location in the div - doing so disables the auto-scrolling to the bottom-most position. If the user then scrolls all the way to the bottom again then auto-scrolling is reactivated.

elementScroller.js:

/**
 * The elementScrollerManager object maintains a list of elements to keep scrolled to their bottom. The
 * elementScroller object provides functions on a dom elements used to scroll it to them to their bottoms.
 * This premise of this code is that an element is defined to capture output from the server in a scrolling
 * container as it is produced in the normal and initial HTTP request; output is generated directly from the server
 * and into a scrolling DOM element (i.e as opposed to being retrieved fed in by and Javascript generated AJAX
 * solution) (e.g. from a long running compilation command).
*/

/****************************************\
* Element Scroller                       *
\****************************************/
var elementScroller = new Object();

/**
 * Intialize a new elementScroller
 *
 * @param {String} scrollContainerId
 * @type Void
*/
elementScroller.init = function(scrollContainerId){
  this._scrollContainerId = scrollContainerId;
  this._elementRef = null;
  this._lastUserHasScrolled = false;
  this._lastScrollPosition = null;
  this.autoScrollEnabled = true;
  elementScrollerManager.add(this);
}

/**
 * Flag this elementScroller as no longer active
 *
 * @type Void
*/
elementScroller.init.prototype.deactivate = function(){
  this.autoScrollEnabled = false;
}

/**
 * Get the height of scroller element.
 *
 * @type integer
 * @returns the height of the node
 */
elementScroller.init.prototype.getHeight = function () {
  if (this._elementRef.offsetHeight !== undefined ) {
    return this._elementRef.offsetHeight;
  } else if (this._elementRef.clientHeight !== undefined ) {
    return this._elementRef.clientHeight;
  } else if (this._elementRef.height !== undefined ) {
    return this._elementRef.height;
  } else {
    throw "Can't figure out how to work with this browser.";
  }
};

/**
 * Scroll element to the bottom of it's scrollable space.
 *
 * @type Void
*/
elementScroller.init.prototype.activeScroll = function(){

  var currentHeight = 0;
  var first_run = false;

  // if the scroll div does not yet exist, then do nothing.
  if (this._elementRef == null && !(this._elementRef = document.getElementById(this._scrollContainerId))) {
    return;
  } else if (!this._elementHeight) {
    // preserve the element height as a means of testing if the user has re-scrolled to the bottom of the element
    this._elementHeight = this.getHeight();
    // flag this as the first time scrolling is being attempted on this element
    first_run = true;
  }

  var currentScrollPosition = this._elementRef.scrollTop;

  // if this scroll is currently active, but the _deactivate dom object has been output, then disable this elementScroller
  if (this.autoScrollEnabled && document.getElementById(this._scrollContainerId + '_deactivate') != null) {
    this.deactivate();
  }

  var hasUserScrolled = (
      (this._lastScrollPosition != null && this._lastScrollPosition != currentScrollPosition)
      || this._lastUserHasScrolled
    );

  if (this._elementRef.scrollHeight > 0) currentHeight = this._elementRef.scrollHeight;
  else if(this._elementRef.offsetHeight > 0) currentHeight = this._elementRef.offsetHeight;

  if (
    first_run // always scroll down on the first run through
    || !hasUserScrolled // if the user has scrolled elsewhere, leave them there...
    || (currentHeight - currentScrollPosition) <= this._elementHeight // ...unless user scrolled all the way down again
  ) {
    this._elementRef.scrollTop = currentHeight; // move scroll down to bottom
    hasUserScrolled = false; // user is not scrolling anymore; either user was not scrolling on user has scrolled to bottom again
  }

  // record current status for use in next comparison
  this._lastScrollPosition = this._elementRef.scrollTop;
  this._lastUserHasScrolled = hasUserScrolled;
}

/****************************************\
* Element Scroller Manager               *
\****************************************/
var elementScrollerManager = new Object();
elementScrollerManager._elementScrollers = new Array(); // collection of elementScroller objects
elementScrollerManager._running = false; // whether the manager is running

/**
 * Add a new elementScroller object to the manager
 *
 * @param {elementScroller} elementScrollerObject
 *
 * @type Void
*/
elementScrollerManager.add = function(elementScrollerObject) {
  this._elementScrollers.push(elementScrollerObject);
  if (!this._running) {this.start();}
}

/**
 * Acts on all the elementScroller objects in the _elementScrollers array and then calls itself again after a brief timeout.
 *
 * @type Void
*/
elementScrollerManager._run = function() {
  for (var i=0, item; item = elementScrollerManager._elementScrollers[i]; i++) {
    if (item.autoScrollEnabled) {
      item.activeScroll();
    }
  }

  if (this._running) {
    setTimeout(function() {elementScrollerManager._run(elementScrollerManager);}, 100);
  }
}

/**
 * Start the manager running.
 *
 * @type Void
*/
elementScrollerManager.start = function() {
  this._running = true;

  // add an event to the onload chain to stop the manager once the manager once the page finishes loading.
  var oldonload = window.onload;
  if (typeof window.onload != 'function') {
    window.onload = function() {elementScrollerManager.stop();}
  } else {
    window.onload = function() {
      if (oldonload) {
        oldonload();
      }
      elementScrollerManager.stop();
    }
  }

  this._run();
}

/**
 * Stop the manager from running.
 *
 * @type Void
*/
elementScrollerManager.stop = function() {
  this._running = false;
}

And here is a quick little PHP page to test this JS out; note that this script assumes a Linux installation. If you write a test / demo case up that works well for another OS, please do let me know and I'll add some system detection logic into the script.

<?php
/* Turnning caching off for the benefit of repeat visiters */
header("Cache-Control: no-cache, must-revalidate"); // HTTP/1.1
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT"); // Date in the past
/* Turning compression off for page content so that flush works correctly. */
if (function_exists('apache_setenv')) {apache_setenv('no-gzip', 1);}
ini_set('zlib.output_compression', 0);
ini_set('implicit_flush', 1);
ob_implicit_flush(1);
?><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2//EN">
<html>
    <head>
        <script language="JavaScript" src="elementScroller.js"></script>
        <style>
            .passthru_format_display{
                height: 200px;
                width: 1000px;
                overflow: scroll;
                border: 1px solid #666;
                padding: 8px;
                margin: 0 20px 5px 5px;
                background-color: #efefef;
                white-space: pre;
            }
        </style>
    </head>
    <body>
    <?php
    $return_var = '';
    // command here is assuming a standard linux install
    $command =
        'for j in $(seq 1 3); do '. // loop three times
            'cat /proc/meminfo | tee example_$j.log; '. // output a spew of information to jump the scroll down a ways
            'for i in $(seq 1 5); do '. // loop five times, giving incremental output
                'cat /proc/meminfo | grep \'MemFree:\' | tee mylog_$j.log; '. // just show the current free memory
                'sleep 1; '. // sleep for a moment, simulating a "long running" process
            'done; '.
        'done; ';
    $options = array(
        'message_pre'=>'This is an example. See the referring post at <a href="http://emanaton.com/code/javascript/elementscroller">http://emanaton.com/code/javascript/elementscroller</a>',
        'message_post'=>'That was easy!',
        'log_files'=>array('example_1.log', 'example_2.log', 'example_3.log'),
        'div_style_class'=>'passthru_format_display',
    );

    // NOTE: for the log files to be created, the directory being executed in has to be writable by the apache user!
    passthru_format($command, $return_var, $options);
    ?>
    </body>
</html>

<?php

/**
 * Execute a passthru of the given command, outputing the results to a scrolling text box text box. On the first call
 * to this function, a style is also output.
 *
 * @param  string $command
 * @param  int $return_var see the passthru documentation: http://www.php.net/manual/en/function.passthru.php
 * @param  array $options optional, with keys for:
 *                - message_pre: Message to display before the scrolling div
 *                - message_post: Message to display after the scrolling div
 *                - log_files: array of names of log files created to provide links to
 *                - log_files_url_base:
 * @return void
 */
function passthru_format($command, &$return_var, $options = null)
{
    $message_pre = '';
    $message_post = '';
    $scroll_class = '';
    if (is_array($options)) {
        $message_pre = array_key_exists('message_pre', $options) ? $options['message_pre'] : '';
        $message_post = array_key_exists('message_post', $options) ? $options['message_post'] : '';
        $scroll_class = array_key_exists('div_style_class', $options) ? $options['div_style_class'] : 'div_style_class';
        if (array_key_exists('log_files', $options)) {
            if (is_string($options['log_files'])) {$options['log_files'] = array($options['log_files']);}
            $log_files_count = count($options['log_files']);
            if (is_array($options['log_files']) && $log_files_count > 0) {
                $message_post .= (strlen($message_post) > 0 ? '<br />' : '').'Output logged in ';
                foreach ($options['log_files'] as $count=>$log_file) {
                    $message_post .=
                        ($count > 0 ? ($count < ($log_files_count - 1) ? ', ' : ' and ') : '').
                        '<a '.'href="'.
                            (array_key_exists('log_files_url_base', $options) ? $options['log_files_url_base'].'/' : '').
                            $log_file.
                        '">'.$log_file.'</a>';
                }
                $message_post .= '.';
            }
        }
    }

    echo strlen($message_pre) > 0 ? '<p>'.$message_pre.'</p>' : '';

    $div_id = '';
    $letters = '1234567890qwertyuiopasdfghjklzxcvbnm';
    $lettersLength = strlen($letters)-1;

    for($i = 0 ; $i < 15 ; $i++) {
      $div_id .= $letters[rand(0, $lettersLength)];
    }

    echo "\n".'<script>var chatScroll_'.$div_id.' = new elementScroller.init(\''.$div_id.'\');</script>'."\n";
    echo '<div class="'.$scroll_class.'" id="'.$div_id.'">';
    flush();
    passthru($command, $return_var);
    echo '</div><input type="hidden" id="'.$div_id.'_deactivate" />';
    echo strlen($message_post) > 0 ? '<p>'.$message_post.'</p>' : '';
    flush();
}