/*
 * Handle the thumbnail view, and navigation for the main view.
 *
 * Handle a large number (thousands) of entries cleanly.  Thumbnail nodes are created
 * as needed, and destroyed when they scroll off screen.  This gives us constant
 * startup time, loads thumbnails on demand, allows preloading thumbnails in advance
 * by creating more nodes in advance, and keeps memory usage constant.
 */
ThumbnailView = function(container, view)
{
  this.container = container;
  this.view = view;
  this.post_ids = [];
  this.post_frames = [];
  this.expanded_post_idx = null;
  this.centered_post_idx = null;
  this.centered_post_offset = 0;
  this.last_mouse_x = 0;
  this.last_mouse_y = 0;
  this.thumb_container_shown = true;
  this.allow_wrapping = true;
  this.thumb_preload_container = new PreloadContainer();
  this.unused_thumb_pool = [];

  /* The [first, end) range of posts that are currently inside .post-browser-posts. */
  this.posts_populated = [0, 0];

  document.on("DOMMouseScroll", this.document_mouse_wheel_event.bindAsEventListener(this));
  document.on("mousewheel", this.document_mouse_wheel_event.bindAsEventListener(this));

  document.on("viewer:displayed-image-loaded", this.displayed_image_loaded_event.bindAsEventListener(this));
  document.on("viewer:set-active-post", function(e) {
    var post_id_and_frame = [e.memo.post_id, e.memo.post_frame];
    this.set_active_post(post_id_and_frame, e.memo.lazy, e.memo.center_thumbs);
  }.bindAsEventListener(this));
  document.on("viewer:show-next-post", function(e) { this.show_next_post(e.memo.prev); }.bindAsEventListener(this));

  document.on("viewer:scroll", function(e) { this.scroll(e.memo.left); }.bindAsEventListener(this));
  document.on("viewer:set-thumb-bar", function(e) {
    if(e.memo.toggle)
      this.show_thumb_bar(!this.thumb_container_shown);
    else
      this.show_thumb_bar(e.memo.set);
  }.bindAsEventListener(this));
  document.on("viewer:loaded-posts", this.loaded_posts_event.bindAsEventListener(this));

  this.hashchange_post_id = this.hashchange_post_id.bind(this);
  UrlHash.observe("post-id", this.hashchange_post_id);
  UrlHash.observe("post-frame", this.hashchange_post_id);

  new DragElement(this.container, { ondrag: this.container_ondrag.bind(this) });

  Element.on(window, "resize", this.window_resize_event.bindAsEventListener(this));

  this.container.on("mousemove", this.container_mousemove_event.bindAsEventListener(this));
  this.container.on("mouseover", this.container_mouseover_event.bindAsEventListener(this));
  this.container.on("mouseout", this.container_mouseout_event.bindAsEventListener(this));
  this.container.on("click", this.container_click_event.bindAsEventListener(this));
  this.container.on("dblclick", ".post-thumb,.browser-thumb-hover-overlay",
      this.container_dblclick_event.bindAsEventListener(this));

  /* Prevent the default behavior of left-clicking on the expanded thumbnail overlay.  It's
   * handled by container_click_event. */
  this.container.down(".browser-thumb-hover-overlay").on("click", function(event) {
    if(event.isLeftClick())
      event.preventDefault();
  }.bindAsEventListener(this));

  /*
   * For Android browsers, we're set to 150 DPI, which (in theory) scales us to a consistent UI size
   * based on the screen DPI.  This means that we can determine the physical screen size from the
   * window resolution: 150x150 is 1"x1".  Set a thumbnail scale based on this.  On a 320x480 HVGA
   * phone screen the thumbnails are about 2x too big, so set thumb_scale to 0.5.
   *
   * For iOS browsers, there's no way to set the viewport based on the DPI, so it's fixed at 1x.
   * (Note that on Retina screens the browser lies: even though we request 1x, it's actually at
   * 0.5x and our screen dimensions work as if we're on the lower-res iPhone screen.  We can mostly
   * ignore this.)  CSS inches aren't implemented (the DPI is fixed at 96), so that doesn't help us.
   * Fall back on special-casing individual iOS devices.
   */
  this.config = { };
  if(navigator.userAgent.indexOf("iPad") != -1)
  {
    this.config.thumb_scale = 1.0;
  }
  else if(navigator.userAgent.indexOf("iPhone") != -1 || navigator.userAgent.indexOf("iPod") != -1)
  {
    this.config.thumb_scale = 0.5;
  }
  else if(navigator.userAgent.indexOf("Android") != -1)
  {
    /* We may be in landscape or portrait; use out the narrower dimension. */
    var width = Math.min(window.innerWidth, window.innerHeight);

    /* Scale a 320-width screen to 0.5, up to 1.0 for a 640-width screen.  Remember
     * that this width is already scaled by the DPI of the screen due to target-densityDpi,
     * so these numbers aren't actually real pixels, and this scales based on the DPI
     * and size of the screen rather than the pixel count. */
    this.config.thumb_scale = scale(width, 320, 640, 0.5, 1.0);
    debug("Unclamped thumb scale: " + this.config.thumb_scale);

    /* Clamp to [0.5,1.0]. */
    this.config.thumb_scale = Math.min(this.config.thumb_scale, 1.0);
    this.config.thumb_scale = Math.max(this.config.thumb_scale, 0.5);

    debug("startup, window size: " + window.innerWidth + "x" + window.innerHeight);
  }
  else
  {
    /* Unknown device, or not a mobile device. */
    this.config.thumb_scale = 1.0;
  }
  debug("Thumb scale: " + this.config.thumb_scale);

  this.config_changed();

  /* Send the initial viewer:thumb-bar-changed event. */
  this.thumb_container_shown = false;
  this.show_thumb_bar(true);
}

ThumbnailView.prototype.window_resize_event = function(e)
{
  if(e.stopped)
    return;
  if(this.thumb_container_shown)
    this.center_on_post_for_scroll(this.centered_post_idx);
}

/* Show the given posts.  If extending is true, post_ids are meant to extend a previous
 * search; attempt to continue where we left off. */
ThumbnailView.prototype.loaded_posts_event = function(event)
{
  var post_ids = event.memo.post_ids;

  var old_post_ids = this.post_ids;
  var old_centered_post_idx = this.centered_post_idx;
  this.remove_all_posts();

  /* Filter blacklisted posts. */
  post_ids = post_ids.reject(Post.is_blacklisted);

  this.post_ids = [];
  this.post_frames = [];

  for(var i = 0; i < post_ids.length; ++i)
  {
    var post_id = post_ids[i];
    var post = Post.posts.get(post_id);
    if(post.frames.length > 0)
    {
      for(var frame_idx = 0; frame_idx < post.frames.length; ++frame_idx)
      {
        this.post_ids.push(post_id);
        this.post_frames.push(frame_idx);
      }
    }
    else
    {
      this.post_ids.push(post_id);
      this.post_frames.push(-1);
    }
  }

  this.allow_wrapping = !event.memo.can_be_extended_further;

  /* Show the results box or "no results".  Do this before updating the results box to make sure
   * the results box isn't hidden when we update, which will make offsetLeft values inside it zero
   * and break things.  If the reason we have no posts is because we didn't do a search at all,
   * don't show no-results. */
  this.container.down(".post-browser-no-results").show(event.memo.tags != null && this.post_ids.length == 0);
  this.container.down(".post-browser-posts").show(this.post_ids.length != 0);

  if(event.memo.extending)
  {
    /*
     * We're extending a previous search with more posts.  The new post list we get may
     * not line up with the old one: the post we're focused on may no longer be in the
     * search, or may be at a different index.
     *
     * Find a nearby post in the new results.  Start searching at the post we're already
     * centered on.  If that doesn't match, move outwards from there.  Only look forward
     * a little bit, or we may match a post that was never seen and jump forward too far
     * in the results.
     */
    var post_id_search_order = sort_array_by_distance(old_post_ids.slice(0, old_centered_post_idx+3), old_centered_post_idx);
    var initial_post_id = null;
    for(var i = 0; i < post_id_search_order.length; ++i)
    {
      var post_id_to_search = post_id_search_order[i];
      var post = Post.posts.get(post_id_to_search);
      if(post != null)
      {
        initial_post_id = post.id;
        break;
      }
    }
    debug("center-on-" + initial_post_id);

    /* If we didn't find anything that matched, go back to the start. */
    if(initial_post_id == null)
    {
      this.centered_post_offset = 0;
      initial_post_id = new_post_ids[0];
    }

    var center_on_post_idx = this.post_ids.indexOf(initial_post_id);
    this.center_on_post_for_scroll(center_on_post_idx);
  }
  else
  {
    /*
     * A new search has completed.
     *
     * results_mode can be one of the following:
     *
     * "center-on-first"
     * Don't change the active post.  Center the results on the first result.  This is used
     * when performing a search by clicking on a tag, where we don't want to center on the
     * post we're on (since it'll put us at some random spot in the results when the user
     * probably wants to browse from the beginning), and we don't want to change the displayed
     * post either.
     *
     * "center-on-current"
     * Don't change the active post.  Center the results on the existing current item,
     * if possible.  This is used when we want to show a new search without disrupting the
     * shown post, such as the "child posts" link in post info, and when loading the initial
     * URL hash when we start up.
     *
     * "jump-to-first"
     * Set the active post to the first result, and center on it.  This is used after making
     * a search in the tags box.
     */
    var results_mode = event.memo.load_options.results_mode || "center-on-current";

    var initial_post_id_and_frame;
    if(results_mode == "center-on-first" || results_mode == "jump-to-first")
      initial_post_id_and_frame = [this.post_ids[0], this.post_frames[0]];
    else
      initial_post_id_and_frame = this.get_current_post_id_and_frame();

    var center_on_post_idx = this.get_post_idx(initial_post_id_and_frame);
    if(center_on_post_idx == null)
      center_on_post_idx = 0;

    this.centered_post_offset = 0;
    this.center_on_post_for_scroll(center_on_post_idx);

    /* If no post is currently displayed and we just completed a search, set the current post.
     * This happens when first initializing; we wait for the first search to complete to retrieve
     * info about the post we're starting on, instead of making a separate query. */
    if(results_mode == "jump-to-first" || this.view.wanted_post_id == null)
      this.set_active_post(initial_post_id_and_frame, false, false, true);
  }

  if(event.memo.tags == null)
  {
    /* If tags is null then no search has been done, which means we're on a URL
     * with a post ID and no search, eg. "/post/browse#12345".  Hide the thumb
     * bar, so we'll just show the post. */
    this.show_thumb_bar(false);
  }
}

ThumbnailView.prototype.container_ondrag = function(e)
{
  this.centered_post_offset -= e.dX;
  this.center_on_post_for_scroll(this.centered_post_idx);
}

ThumbnailView.prototype.container_mouseover_event = function(event)
{
  var li = $(event.target);
  if(!li.hasClassName(".post-thumb"))
    li = li.up(".post-thumb");
  if(li)
    this.expand_post(li.post_idx);
}

ThumbnailView.prototype.container_mouseout_event = function(event)
{
  /* If the mouse is leaving the hover overlay, hide it. */
  var target = $(event.target);
  if(!target.hasClassName(".browser-thumb-hover-overlay"))
    target = target.up(".browser-thumb-hover-overlay");
  if(target)
    this.expand_post(null);
}

ThumbnailView.prototype.hashchange_post_id = function()
{
  var post_id_and_frame = this.get_current_post_id_and_frame();
  if(post_id_and_frame[0] == null)
    return;

  /* If we're already displaying this post, ignore the hashchange.  Don't center on the
   * post if this is just a side-effect of clicking a post, rather than the user actually
   * changing the hash. */
  var post_id = post_id_and_frame[0];
  var post_frame = post_id_and_frame[1];
  if(post_id == this.view.displayed_post_id &&
      post_frame == this.view.displayed_post_frame)
  {
//    debug("ignored-hashchange");
    return;
  }

  var new_post_idx = this.get_post_idx(post_id_and_frame);
  this.centered_post_offset = 0;
  this.center_on_post_for_scroll(new_post_idx);
  this.set_active_post(post_id_and_frame, false, false, true);
}

/* Search for the given post ID and frame in the current search results, and return its
 * index.  If the given post isn't in post_ids, return null. */
ThumbnailView.prototype.get_post_idx = function(post_id_and_frame)
{
  var post_id = post_id_and_frame[0];
  var post_frame = post_id_and_frame[1];

  var post_idx = this.post_ids.indexOf(post_id);
  if(post_idx == -1)
    return null;
  if(post_frame == -1)
    return post_idx;

  /* A post-frame is specified.  Search for a matching post-id and post-frame.  We assume
   * here that all frames for a post are grouped together in post_ids. */
  var post_frame_idx = post_idx;
  while(post_frame_idx < this.post_ids.length && this.post_ids[post_frame_idx] == post_id)
  {
    if(this.post_frames[post_frame_idx] == post_frame)
      return post_frame_idx;
    ++post_frame_idx;
  }

  /* We found a matching post, but not a matching frame.  Return the post. */
  return post_idx;
}

/* Return the post and frame that's currently being displayed in the main view, based
 * on the URL hash.  If no post is displayed and no search results are available,
 * return [null, null]. */
ThumbnailView.prototype.get_current_post_id_and_frame = function()
{
  var post_id = UrlHash.get("post-id");
  if(post_id == null)
  {
    if(this.post_ids.length == 0)
      return [null, null];
    else
      return [this.post_ids[0], this.post_frames[0]];
  }
  post_id = parseInt(post_id);

  var post_frame = UrlHash.get("post-frame");

  // If no frame is set, attempt to resolve the post_frame we'll display, if the post data
  // is already loaded.  Otherwise, post_frame will remain null.
  if(post_frame == null)
    post_frame = this.view.get_default_post_frame(post_id);

  return [post_id, post_frame];
}

/* Track the mouse cursor when it's within the container. */
ThumbnailView.prototype.container_mousemove_event = function(e)
{
  var x = e.pointerX() - document.documentElement.scrollLeft;
  var y = e.pointerY() - document.documentElement.scrollTop;
  this.last_mouse_x = x;
  this.last_mouse_y = y;
}

ThumbnailView.prototype.document_mouse_wheel_event = function(event)
{
  event.stop();

  var val;
  if(event.wheelDelta)
  {
    val = event.wheelDelta;
  } else if (event.detail) {
    val = -event.detail;
  }

  if(this.thumb_container_shown)
    document.fire("viewer:scroll", { left: val >= 0 });
  else
    document.fire("viewer:show-next-post", { prev: val >= 0 });
}

/* Set the post that's shown in the view.  The thumbs will be centered on the post
 * if center_thumbs is true.  See BrowserView.prototype.set_post for an explanation
 * of no_hash_change. */
ThumbnailView.prototype.set_active_post = function(post_id_and_frame, lazy, center_thumbs, no_hash_change, replace_history)
{
  /* If no post is specified, do nothing.  This will happen if a search returns
   * no results. */
  if(post_id_and_frame[0] == null)
    return;

  this.view.set_post(post_id_and_frame[0], post_id_and_frame[1], lazy, no_hash_change, replace_history);

  if(center_thumbs)
  {
    var post_idx = this.get_post_idx(post_id_and_frame);
    this.centered_post_offset = 0;
    this.center_on_post_for_scroll(post_idx);
  }
}

ThumbnailView.prototype.set_active_post_idx = function(post_idx, lazy, center_thumbs, no_hash_change, replace_history)
{
  if(post_idx == null)
    return;

  var post_id = this.post_ids[post_idx];
  var post_frame = this.post_frames[post_idx];
  this.set_active_post([post_id, post_frame], lazy, center_thumbs, no_hash_change, replace_history);
}

ThumbnailView.prototype.show_next_post = function(prev)
{
  if(this.post_ids.length == 0)
    return;

  var current_idx = this.get_post_idx([this.view.wanted_post_id, this.view.wanted_post_frame]);

  /* If the displayed post isn't in the thumbnails and we're changing posts, start
   * at the beginning. */
  if(current_idx == null)
    current_idx = 0;

  var add = prev? -1:+1;
  if(this.post_frames[current_idx] != this.view.wanted_post_frame && add == +1)
  {
    /*
     * We didn't find an exact match for the frame we're displaying, which usually means
     * we viewed a post frame, and then the user changed the view to the main post, and
     * the main post isn't in the thumbnails.
     *
     * It's strange to be on the main post, to hit pgdn, and to end up on the second frame
     * because the nearest match was the first frame.  Instead, we should end up on the first
     * frame.  To do that, just don't add anything to the index.
     */
    debug("Snapped the display to the nearest frame");
    if(add == +1)
      add = 0;
  }

  var new_idx = current_idx;
  new_idx += add;

  new_idx += this.post_ids.length;
  new_idx %= this.post_ids.length;

  var wrapped = (prev && new_idx > current_idx) || (!prev && new_idx < current_idx);
  if(wrapped)
  {
    /* Only allow wrapping over the edge if we've already expanded the results. */
    if(!this.allow_wrapping)
      return;
    if(!this.thumb_container_shown && prev)
      notice("Continued from the end");
    else if(!this.thumb_container_shown && !prev)
      notice("Starting over from the beginning");
  }

  this.set_active_post_idx(new_idx, true, true, false, true);
}

/* Scroll the thumbnail view left or right.  Don't change the displayed post. */
ThumbnailView.prototype.scroll = function(left)
{
  /* There's no point in scrolling the list if it's not visible. */
  if(!this.thumb_container_shown)
    return;
  var new_idx = this.centered_post_idx;

  /* If we're not centered on the post, and we're moving towards the center,
   * don't jump past the post. */
  if(this.centered_post_offset > 0 && left)
    ;
  else if(this.centered_post_offset < 0 && !left)
    ;
  else
    new_idx += (left? -1:+1);

  // Snap to the nearest post.
  this.centered_post_offset = 0;

  /* Wrap the new index. */
  if(new_idx < 0)
  {
    /* Only allow scrolling over the left edge if we've already expanded the results. */
    if(!this.allow_wrapping)
      new_idx = 0;
    else
      new_idx = this.post_ids.length - 1;
  }
  else if(new_idx >= this.post_ids.length)
  {
    if(!this.allow_wrapping)
      new_idx = this.post_ids.length - 1;
    else
      new_idx = 0;
  }

  this.center_on_post_for_scroll(new_idx);
}

/* Hide the hovered post, if any, call center_on_post(post_idx), then hover over the correct post again. */
ThumbnailView.prototype.center_on_post_for_scroll = function(post_idx)
{
  if(this.thumb_container_shown)
    this.expand_post(null);

  this.center_on_post(post_idx);

  /*
   * Now that we've re-centered, we need to expand the correct image.  Usually, we can just
   * wait for the mouseover event to fire, since we hid the expanded thumb overlay and the
   * image underneith it is now under the mouse.  However, browsers are badly broken here.
   * Opera doesn't fire mouseover events when the element under the cursor is hidden.  FF
   * fires the mouseover on hide, but misses the mouseout when the new overlay is shown, so
   * the next time it's hidden mouseover events are lost.
   *
   * Explicitly figure out which item we're hovering over and expand it.
   */
  if(this.thumb_container_shown)
  {
    var element = document.elementFromPoint(this.last_mouse_x, this.last_mouse_y);
    element = $(element);
    if(element)
    {
      var li = element.up(".post-thumb");
      if(li)
        this.expand_post(li.post_idx);
    }
  }
}

ThumbnailView.prototype.remove_post = function(right)
{
  if(this.posts_populated[0] == this.posts_populated[1])
    return false; /* none to remove */

  var node = this.container.down(".post-browser-posts");
  if(right)
  {
    --this.posts_populated[1];
    var node_to_remove = node.lastChild;
  }
  else
  {
    ++this.posts_populated[0];
    var node_to_remove = node.firstChild;
  }

  /* Remove the thumbnail that's no longer visible, and put it in unused_thumb_pool
   * so we can reuse it later.  This won't grow out of control, since we'll always use
   * an item from the pool if available rather than creating a new one. */
  var item = node.removeChild(node_to_remove);
  this.unused_thumb_pool.push(item);
  return true;
}

ThumbnailView.prototype.remove_all_posts = function()
{
  while(this.remove_post(true))
    ;
}

/* Add the next thumbnail to the left or right side. */
ThumbnailView.prototype.add_post_to_display = function(right)
{
  var node = this.container.down(".post-browser-posts");
  if(right)
  {
    var post_idx_to_populate = this.posts_populated[1];
    if(post_idx_to_populate == this.post_ids.length)
      return false;
    ++this.posts_populated[1];

    var thumb = this.create_thumb(post_idx_to_populate);
    node.insertBefore(thumb, null);
  }
  else
  {
    if(this.posts_populated[0] == 0)
      return false;
    --this.posts_populated[0];
    var post_idx_to_populate = this.posts_populated[0];
    var thumb = this.create_thumb(post_idx_to_populate);
    node.insertBefore(thumb, node.firstChild);
  }
  return true;
}

/* Fill the container so post_idx is visible. */
ThumbnailView.prototype.populate_post = function(post_idx)
{
  if(this.is_post_idx_shown(post_idx))
    return;

  /* If post_idx is on the immediate border of what's already displayed, add it incrementally, and
   * we'll cull extra posts later.  Otherwise, clear all of the posts and populate from scratch. */
  if(post_idx == this.posts_populated[1])
  {
    this.add_post_to_display(true);
    return;
  }
  else if(post_idx == this.posts_populated[0])
  {
    this.add_post_to_display(false);
    return;
  }

  /* post_idx isn't on the boundary, so we're jumping posts rather than scrolling.
   * Clear the container and start over. */
  this.remove_all_posts();

  var node = this.container.down(".post-browser-posts");

  var thumb = this.create_thumb(post_idx);
  node.appendChild(thumb);
  this.posts_populated[0] = post_idx;
  this.posts_populated[1] = post_idx + 1;
}

ThumbnailView.prototype.is_post_idx_shown = function(post_idx)
{
  if(post_idx >= this.posts_populated[1])
    return false;
  return post_idx >= this.posts_populated[0];
}

/* Return the total width of all thumbs to the left or right of post_idx, not
 * including itself. */
ThumbnailView.prototype.get_width_adjacent_to_post = function(post_idx, right)
{
  var post = $("p" + post_idx);
  if(right)
  {
    var rightmost_node = post.parentNode.lastChild;
    if(rightmost_node == post)
      return 0;
    var right_edge = rightmost_node.offsetLeft + rightmost_node.offsetWidth;
    var center_post_right_edge = post.offsetLeft + post.offsetWidth;
    return right_edge - center_post_right_edge
  }
  else
  {
    return post.offsetLeft;
  }
}

/* Center the thumbnail strip on post_idx.  If post_id isn't in the display, do nothing.
 * Fire viewer:need-more-thumbs if we're scrolling near the edge of the list. */
ThumbnailView.prototype.center_on_post = function(post_idx)
{
  if(!this.post_ids)
  {
    debug("unexpected: center_on_post has no post_ids");
    return;
  }

  var post_id = this.post_ids[post_idx];
  if(Post.posts.get(post_id) == null)
    return;

  if(post_idx > this.post_ids.length*3/4)
  {
    /* We're coming near the end of the loaded posts, so load more.  We may be currently
     * in the middle of setting up the post; defer this, so we finish what we're doing first. */
    (function() {
      document.fire("viewer:need-more-thumbs", { view: this });
    }).defer();
  }

  this.centered_post_idx = post_idx;

  /* If we're not expanded, we can't figure out how to center it since we'll have no width.
   * Also, don't cause thumbnails to be loaded if we're hidden.  Just set centered_post_idx,
   * and we'll come back here when we're displayed. */
  if(!this.thumb_container_shown)
    return;

  /* If centered_post_offset is high enough to put the actual center post somewhere else,
   * adjust it towards zero and change centered_post_idx.  This keeps centered_post_idx
   * pointing at the item that's actually centered. */
  while(1)
  {
    var post = $("p" + this.centered_post_idx);
    if(!post)
      break;
    var pos = post.offsetWidth/2 + this.centered_post_offset;
    if(pos >= 0 && pos < post.offsetWidth)
      break;

    var next_post_idx = this.centered_post_idx + (this.centered_post_offset > 0? +1:-1);
    var next_post = $("p" + next_post_idx);
    if(next_post == null)
      break;

    var current_post_center = post.offsetLeft + post.offsetWidth/2;
    var next_post_center = next_post.offsetLeft + next_post.offsetWidth/2;
    var distance = next_post_center - current_post_center;
    this.centered_post_offset -= distance;
    this.centered_post_idx = next_post_idx;

    post_idx = this.centered_post_idx;
    break;
  }

  this.populate_post(post_idx);

  /* Make sure that we have enough posts populated around the one we're centering
   * on to fill the display.  If we have too many nodes, remove some. */
  for(var direction = 0; direction < 2; ++direction)
  {
    var right = !!direction;

    /* We need at least this.container.offsetWidth/2 in each direction.  Load a little more, to
     * reduce flicker. */
    var minimum_distance = this.container.offsetWidth/2;
    minimum_distance *= 1.25;
    var maximum_distance = minimum_distance + 500;
    while(true)
    {
      var added = false;
      var width = this.get_width_adjacent_to_post(post_idx, right);

      /* If we're offset to the right then we need more data to the left, and vice versa. */
      width += this.centered_post_offset * (right? -1:+1);
      if(width < 0)
        width = 1;

      if(width < minimum_distance)
      {
        /* We need another post.  Stop if there are no more posts to add. */
        if(!this.add_post_to_display(right))
          break;
        added = false;
      }
      else if(width > maximum_distance)
      {
        /* We have a lot of posts off-screen.  Remove one. */
        this.remove_post(right);

        /* Sanity check: we should never add and remove in the same direction.  If this
         * happens, the distance between minimum_distance and maximum_distance may be less
         * than the width of a single thumbnail. */
        if(added)
        {
          alert("error");
          break;
        }
      }
      else
      {
        break;
      }
    }
  }

  this.preload_thumbs();

  /* We always center the thumb.  Don't clamp to the edge when we're near the first or last
   * item, so we always have empty space on the sides for expanded landscape thumbnails to
   * be visible. */
  var thumb = $("p" + post_idx);
  var center_on_position = this.container.offsetWidth/2;

  var shift_pixels_right = center_on_position - thumb.offsetWidth/2 - thumb.offsetLeft;
  shift_pixels_right -= this.centered_post_offset;
  shift_pixels_right = Math.round(shift_pixels_right);

  var node = this.container.down(".post-browser-scroller");
  node.setStyle({left: shift_pixels_right + "px"});
}

/* Preload thumbs on the boundary of what's actually displayed. */
ThumbnailView.prototype.preload_thumbs = function()
{
  var post_idxs = [];
  for(var i = 0; i < 5; ++i)
  {
    var preload_post_idx = this.posts_populated[0] - i - 1;
    if(preload_post_idx >= 0)
      post_idxs.push(preload_post_idx);

    var preload_post_idx = this.posts_populated[1] + i;
    if(preload_post_idx < this.post_ids.length)
      post_idxs.push(preload_post_idx);
  }

  /* Remove any preloaded thumbs that are no longer in the preload list. */
  this.thumb_preload_container.get_all().each(function(element) {
    var post_idx = element.post_idx;
    if(post_idxs.indexOf(post_idx) != -1)
    {
      /* The post is staying loaded.  Clear the value in post_idxs, so we don't load it
       * again down below. */
      post_idxs[post_idx] = null;
      return;
    }

    /* The post is no longer being preloaded.  Remove the preload. */
    this.thumb_preload_container.cancel_preload(element);
  }.bind(this));

  /* Add new preloads. */
  for(var i = 0; i < post_idxs.length; ++i)
  {
    var post_idx = post_idxs[i];
    if(post_idx == null)
      continue;

    var post_id = this.post_ids[post_idx];
    var post = Post.posts.get(post_id);

    var post_frame = this.post_frames[post_idx];
    var url;
    if(post_frame != -1)
      url = post.frames[post_frame].preview_url;
    else
      url = post.preview_url;

    var element = this.thumb_preload_container.preload(url);
    element.post_idx = post_idx;
  }
}

ThumbnailView.prototype.expand_post = function(post_idx)
{
  /* Thumbs on click for touchpads doesn't make much sense anyway--touching the thumb causes it
   * to be loaded.  It also triggers a bug in iPhone WebKit (covering up the original target of
   * a mouseover during the event seems to cause the subsequent click event to not be delivered).
   * Just disable hover thumbnails for touchscreens.  */
  // if(Prototype.BrowserFeatures.Touchscreen)
  /* MI: Using Prototype.BrowserIsMobile instead. */
  if (Prototype.BrowserIsMobile)
    return;

  if(!this.thumb_container_shown)
    return;

  var post_id = this.post_ids[post_idx];

  var overlay = this.container.down(".browser-thumb-hover-overlay");
  overlay.hide();
  overlay.down("IMG").src = "/images/blank.gif";

  this.expanded_post_idx = post_idx;
  if(post_idx == null)
    return;

  var post = Post.posts.get(post_id);
  if(post.status == "deleted")
    return;

  var thumb = $("p" + post_idx);

  var bottom = this.container.down(".browser-bottom-bar").offsetHeight;
  overlay.style.bottom = bottom + "px";

  var post_frame = this.post_frames[post_idx];
  var image_width, image_url;
  if(post_frame != -1)
  {
    var frame = post.frames[post_frame];
    image_width = frame.preview_width;
    image_url = frame.preview_url;
  }
  else
  {
    image_width = post.actual_preview_width;
    image_url = post.preview_url;
  }

  var left = thumb.cumulativeOffset().left - image_width/2 + thumb.offsetWidth/2;
  overlay.style.left = left + "px";

  /* If the hover thumbnail overflows the right edge of the viewport, it'll extend the document and
   * allow scrolling to the right, which we don't want.  overflow: hidden doesn't fix this, since this
   * element is absolutely positioned.  Set the max-width to clip the right side of the thumbnail if
   * necessary. */
  var max_width = document.viewport.getDimensions().width - left;
  overlay.style.maxWidth = max_width + "px";
  overlay.href = "/post/browse#" + post.id + this.view.post_frame_hash(post, post_frame);
  overlay.down("IMG").src = image_url;
  overlay.show();
}

ThumbnailView.prototype.create_thumb = function(post_idx)
{
  var post_id = this.post_ids[post_idx];
  var post_frame = this.post_frames[post_idx];

  var post = Post.posts.get(post_id);

  /*
   * Reuse thumbnail blocks that are no longer in use, to avoid WebKit memory leaks: it
   * doesn't like creating and deleting lots of images (or blocks with images inside them).
   *
   * Thumbnails are hidden until they're loaded, so we don't show ugly load-borders.  This
   * also keeps us from showing old thumbnails before the new image is loaded.  Use visibility:
   * hidden, not display: none, or the size of the image won't be defined, which breaks
   * center_on_post.
   */
  if(this.unused_thumb_pool.length == 0)
  {
    var div =
      '<div class="inner">' +
        '<a class="thumb" tabindex="-1">' +
          '<img alt="" class="preview" onload="this.style.visibility = \'visible\';">' +
        '</a>' +
      '</div>';
    var item = $(document.createElement("li"));
    item.innerHTML = div;
    item.className = "post-thumb";
  }
  else
  {
    var item = this.unused_thumb_pool.pop();
  }

  item.id = "p" + post_idx;
  item.post_idx = post_idx;
  item.down("A").href = "/post/browse#" + post.id + this.view.post_frame_hash(post, post_frame);

  /* If the image is already what we want, then leave it alone.  Setting it to what it's
   * already set to won't necessarily cause onload to be fired, so it'll never be set
   * back to visible. */
  var img = item.down("IMG");
  var url;
  if(post_frame != -1)
    url = post.frames[post_frame].preview_url;
  else
    url = post.preview_url;
  if(img.src != url)
  {
    img.style.visibility = "hidden";
    img.src = url;
  }

  this.set_thumb_dimensions(item);
  return item;
}

ThumbnailView.prototype.set_thumb_dimensions = function(li)
{
  var post_idx = li.post_idx;
  var post_id = this.post_ids[post_idx];
  var post_frame = this.post_frames[post_idx];
  var post = Post.posts.get(post_id);

  var width, height;
  if(post_frame != -1)
  {
    var frame = post.frames[post_frame];
    width = frame.preview_width;
    height = frame.preview_height;
  }
  else
  {
    width = post.actual_preview_width;
    height = post.actual_preview_height;
  }

  width *= this.config.thumb_scale;
  height *= this.config.thumb_scale;

  /* This crops blocks that are too wide, but doesn't pad them if they're too
   * narrow, since that creates odd spacing.
   *
   * If the height of this block is changed, adjust .post-browser-posts-container in
   * config_changed. */
  var block_size = [Math.min(width, 200 * this.config.thumb_scale), 200 * this.config.thumb_scale];
  var crop_left = Math.round((width - block_size[0]) / 2);
  var pad_top = Math.max(0, block_size[1] - height);

  var inner = li.down(".inner");
  inner.actual_width = block_size[0];
  inner.actual_height = block_size[1];
  inner.setStyle({width: block_size[0] + "px", height: block_size[1] + "px"});

  var img = inner.down("img");
  img.width = width;
  img.height = height;
  img.setStyle({marginTop: pad_top + "px", marginLeft: -crop_left + "px"});
}

ThumbnailView.prototype.config_changed = function()
{
  /* Adjust the size of the container to fit the thumbs at the current scale.  They're the
   * height of the thumb block, plus ten pixels for padding at the top and bottom. */
  var container_height = 200*this.config.thumb_scale + 10;
  this.container.down(".post-browser-posts-container").setStyle({height: container_height + "px"});

  this.container.select("LI.post-thumb").each(this.set_thumb_dimensions.bind(this));

  this.center_on_post_for_scroll(this.centered_post_idx);
}

/* Handle clicks and doubleclicks on thumbnails.  These events are handled by
 * the container, so we don't need to put event handlers on every thumb. */
ThumbnailView.prototype.container_click_event = function(event)
{
  /* Ignore the click if it was stopped by the DragElement. */
  if(event.stopped)
    return;

  if($(event.target).up(".browser-thumb-hover-overlay"))
  {
    /* The hover overlay was clicked.  When the user clicks a thumbnail, this is
     * usually what happens, since the hover overlay covers the actual thumbnail. */
    this.set_active_post_idx(this.expanded_post_idx);
    event.preventDefault();
    return;
  }

  var li = $(event.target).up(".post-thumb");
  if(li == null)
    return;

  /* An actual thumbnail was clicked.  This can happen if we don't have the expanded
   * thumbnails for some reason. */
  event.preventDefault();
  this.set_active_post_idx(li.post_idx);
}

ThumbnailView.prototype.container_dblclick_event = function(event)
{
  if(event.button)
    return;

  event.preventDefault();
  this.show_thumb_bar(false);
}

ThumbnailView.prototype.show_thumb_bar = function(shown)
{
  if(this.thumb_container_shown == shown)
    return;
  this.thumb_container_shown = shown;
  this.container.show(shown);

  /* If the centered post was changed while we were hidden, it wasn't applied by
   * center_on_post, so do it now. */
  this.center_on_post_for_scroll(this.centered_post_idx);

  document.fire("viewer:thumb-bar-changed", {
    shown: this.thumb_container_shown,
    height: this.thumb_container_shown? this.container.offsetHeight:0
  });
}

/* Return the next or previous post, wrapping around if necessary. */
ThumbnailView.prototype.get_adjacent_post_idx_wrapped = function(post_idx, next)
{
  post_idx += next? +1:-1;
  post_idx = (post_idx + this.post_ids.length) % this.post_ids.length;
  return post_idx;
}

ThumbnailView.prototype.displayed_image_loaded_event = function(event)
{
  /* If we don't have a loaded search, then we don't have any nearby posts to preload. */
  if(this.post_ids == null)
    return;

  var post_id = event.memo.post_id;
  var post_frame = event.memo.post_frame;
  var post_idx = this.get_post_idx([post_id, post_frame]);
  if(post_idx == null)
    return;

  /*
   * The image in the post we're displaying is finished loading.
   *
   * Preload the next and previous posts.  Normally, one or the other of these will
   * already be in cache.
   *
   * Include the current post in the preloads, so if we switch from a frame back to
   * the main image, the frame itself will still be loaded.
   */
  var post_ids_to_preload = [];
  post_ids_to_preload.push([this.post_ids[post_idx], this.post_frames[post_idx]]);
  var adjacent_post_idx = this.get_adjacent_post_idx_wrapped(post_idx, true);
  if(adjacent_post_idx != null)
    post_ids_to_preload.push([this.post_ids[adjacent_post_idx], this.post_frames[adjacent_post_idx]]);
  var adjacent_post_idx = this.get_adjacent_post_idx_wrapped(post_idx, false);
  if(adjacent_post_idx != null)
    post_ids_to_preload.push([this.post_ids[adjacent_post_idx], this.post_frames[adjacent_post_idx]]);
  this.view.preload(post_ids_to_preload);
}


/* This handler handles global keypress bindings, and fires viewer: events. */
function InputHandler()
{
  TrackFocus();

  /*
   * Keypresses are aggrevating:
   *
   * Opera can only stop key events from keypress, not keydown.
   *
   * Chrome only sends keydown for non-alpha keys, not keypress.
   *
   * In Firefox, keypress's keyCode value for non-alpha keys is always 0.
   *
   * Alpha keys can always be detected with keydown.  Don't use keypress; Opera only provides
   * charCode to that event, and it's affected by the caps state, which we don't want.
   *
   * Use OnKey for alpha key bindings.  For other keys, use keypress in Opera and FF and
   * keydown in other browsers.
   */
  var keypress_event_name = window.opera || Prototype.Browser.Gecko? "keypress":"keydown";
  document.on(keypress_event_name, this.document_keypress_event.bindAsEventListener(this));
}

InputHandler.prototype.handle_keypress = function(e)
{
  var key = e.charCode;
  if(!key)
    key = e.keyCode; /* Opera */
  if(key == Event.KEY_ESC)
  {
    if(document.focusedElement && document.focusedElement.blur && !document.focusedElement.hasClassName("no-blur-on-escape"))
    {
      document.focusedElement.blur();
      return true;
    }
  }

  var target = e.target;
  if(target.tagName == "INPUT" || target.tagName == "TEXTAREA")
    return false;

  if(key == 63) // ?, f
  {
    debug("xxx");
    document.fire("viewer:show-help");
    return true;
  }

  if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey)
    return false;
  var grave_keycode = Prototype.Browser.WebKit? 192: 96;
  if(key == 32) // space
    document.fire("viewer:set-thumb-bar", { toggle: true });
  else if(key == 49) // 1
    document.fire("viewer:vote", { score: 1 });
  else if(key == 50) // 2
    document.fire("viewer:vote", { score: 2 });
  else if(key == 51) // 3
    document.fire("viewer:vote", { score: 3 });
  else if(key == grave_keycode) // `
    document.fire("viewer:vote", { score: 0 });
  else if(key == 65 || key == 97) // A, b
    document.fire("viewer:show-next-post", { prev: true });
  else if(key == 69 || key == 101) // E, e
    document.fire("viewer:edit-post");
  else if(key == 83 || key == 115) // S, s
    document.fire("viewer:show-next-post", { prev: false });
  else if(key == 70 || key == 102) // F, f
    document.fire("viewer:focus-tag-box");
  else if(key == 86 || key == 118) // V, v
    document.fire("viewer:view-large-toggle");
  else if(key == Event.KEY_PAGEUP)
    document.fire("viewer:show-next-post", { prev: true });
  else if(key == Event.KEY_PAGEDOWN)
    document.fire("viewer:show-next-post", { prev: false });
  else if(key == Event.KEY_LEFT)
    document.fire("viewer:scroll", { left: true });
  else if(key == Event.KEY_RIGHT)
    document.fire("viewer:scroll", { left: false });
  else
    return false;
  return true;
}

InputHandler.prototype.document_keypress_event = function(e)
{
  //alert(e.charCode + ", " + e.keyCode);
  if(this.handle_keypress(e))
    e.stop();
}

