Skip to content

Instantly share code, notes, and snippets.

@johan
Created June 14, 2012 18:25
Show Gist options
  • Select an option

  • Save johan/2931957 to your computer and use it in GitHub Desktop.

Select an option

Save johan/2931957 to your computer and use it in GitHub Desktop.
A Backbone picture zoom widget
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Backbone zoom widget</title>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
<script src="http://ajax.cdnjs.com/ajax/libs/json2/20110223/json2.js"></script>
<script src="http://ajax.cdnjs.com/ajax/libs/underscore.js/1.1.6/underscore-min.js"></script>
<script src="http://ajax.cdnjs.com/ajax/libs/backbone.js/0.9.2/backbone-min.js"></script>
<script src="zoom-widget.js"></script>
<link rel="stylesheet" href="zoom-widget.css">
</head>
<body>
<div class="right-pane">
<div class="zoom-widget" id="page-1">
<div class="spill-zone">
<div class="zoomer"><img></div>
<div class="page-frame"></div>
<div class="page-edge"></div>
</div>
<button class="zoom details zoom-out">-</button>
<button class="zoom details zoom-in">+</button>
<div class="debug details">image offset:
<span class="offset_x">0</span>,
<span class="offset_y">0</span>; image_scale:
<span class="image_scale">1.00</span>
</div>
</div>
</div>
</body>
</html>
body, html {
margin: 0;
padding: 0;
height: 500px;
}
/* the portion of screen in which zooming and positioning goes on
* - used for :hover selectors to show editor details selectively
*/
.right-pane {
background: #ccc;
float: right;
width: 40%;
height: 100%;
min-width: 460px; // 24 + read_embed_460.w + 24 px
}
/* parent div that holds all elements of the image zooming / plaement editor */
.zoom-widget {
margin: 1em auto 0;
width: 460px;
overflow: hidden;
}
/* basing the widget on the read_embed_460 size
* - this overflow is what crops large images to our page size
* - some of these become visible while we're editing the page
*/
.zoomer, .page-frame, .page-edge, .spill-zone {
width: 412px;
height: 274px;
overflow: hidden;
}
/* surrounding 22px zone that shows if there's more image we can drag around */
.spill-zone {
padding: 22px;
position: relative;
}
/* used to paint that zone in a darker shade while we drag and zoom the image */
.page-frame {
cursor: move;
border: 22px solid transparent;
-webkit-transition: border-color 0.2s linear;
-moz-transition: border-color 0.2s linear;
-ms-transition: border-color 0.2s linear;
-o-transition: border-color 0.2s linear;
transition: border-color 0.2s linear;
}
/* visual cue to see clearly where the page edge is while dragging or zooming */
.page-edge {
cursor: move;
margin: 21px;
border: 1px solid #000;
}
.page-frame, .page-edge {
top: 0;
left: 0;
position: absolute;
}
/* the image we're dragging around in the frame */
.zoomer img {
outline: 1px dashed #000;
position: relative;
top: 0;
left: 0;
min-width: 100%;
min-height: 100%;
}
/* .details marks page elements we don't want to see unless we're editing now */
.right-pane .details {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
-ms-transition: opacity 0.2s linear;
-o-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.right-pane:hover .details {
opacity: 1;
}
.right-pane:hover .page-frame {
border-color: rgba(0,0,0,0.2);
}
.right-pane:hover .page-edge {
border-color: #000;
}
button.zoom {
float: right;
cursor: pointer;
}
/* show extending parts of the picture in the spill zone, while we're editing */
.right-pane:hover .zoomer {
overflow: visible;
}
.debug {
visibility: hidden;
text-align: right;
margin: 3px;
}
var url = 'http://farm8.staticflickr.com/7097/7357430266_54bf1e7069_b.jpg'
, zoom
, Zoomer = Backbone.View.extend(
{ events:
{ 'click button.zoom-in': 'zoomIn'
, 'click button.zoom-out': 'zoomOut'
, 'mousewheel .spill-zone': 'zoom'
, 'mousedown .spill-zone': 'dragStart'
}
// used solely for decoding what image_scale===1 means, for some given image
, page_w: 960
, page_h: 640
, $bg: null // this widget's zoomable image
, initialize: function(img_opts) {
_.bindAll( this, 'load', 'place' // fetching the image
, 'input', 'output' // data conversion, to/from site format
, 'zoom', 'zoomIn', 'zoomOut', 'zoomBy', 'legalCoords'
, 'dragStart', 'dragMove', 'dragEnd'
);
this.$bg = this.$('.zoomer img');
this.$z_in = this.$('button.zoom-in');
this.$z_out = this.$('button.zoom-out');
// undefined, if first time we load a picture, otherwise set in 'input':
this.height = // original image height
this.width = // original image width
this.dx = // hor. offset of image tag
this.dy = // vert offset of image tag
this.zoom_w = undefined; // width, zoomed
if ('image_scale' in img_opts) this.input(img_opts);
// this.load (really this.place) sets these, once we have all details
this.min_w =
this.max_w = undefined;
this.load(img_opts.url);
}
, input: function(data) {
// this code corresponds loosely to app/helpers/pages_helper.rb's ie8
// fallback, where we're back-computing what a scale-from-center of s
// does to the top / left coordinates
var img_w = this.width = data.width
, img_h = this.height = data.height
, delta = (data.image_scale - 1) / 2 // coord. translation factor
// a page is page_w * page_h, which becomes boxed.width * boxed.height
// at the minimum zoom level needed to attain our full-frame fill-crop
, boxed = constrain(img_w, img_h, this.page_w, this.page_h)
;
this.dx = Math.round(data.offset_x - delta * boxed.width);
this.dy = Math.round(data.offset_y - delta * boxed.height);
this.zoom_w = Math.round(data.image_scale * boxed.width);
}
, output: function() {
var boxed = constrain(this.width, this.height, this.page_w, this.page_h)
, base = boxed.width / this.width // baseline image width at pg scale
, scale = this.zoom_w / this.width / base
, delta = (this.zoom_w / boxed.width - 1) / 2
;
return { offset_x: Math.round(this.dx + boxed.width * (scale-1) / 2)
, offset_y: Math.round(this.dy + boxed.height * (scale-1) / 2)
, image_scale: scale
};
}
, load: function(url) {
var img = this.$bg.get(0);
img.src = url;
if (img.naturalWidth)
this.place();
else
this.$bg.load(this.place);
}
, place: function(dx, dy) {
if (!_.isNumber(dx) || !_.isNumber(dy)) dx = dy = 0;
var self = this
, $bg = this.$bg
, $box = $bg.parent()
, box_w = $box.width(), mid_x = box_w >> 1
, box_h = $box.height(), mid_y = box_h >> 1
, img = $bg.get(0)
, img_h = Math.round(this.zoom_w * this.height / this.width)
, boxed, w, h
;
if (!_.isNumber(this.zoom_w)) { // fill-crop to mid part of image
this.width = w = img.naturalWidth;
this.height = h = img.naturalHeight;
boxed = constrain(this.width, this.height, box_w, box_h);
this.zoom_w = boxed.width;
this.dx = parseInt(boxed['margin-left'] || '0', 10);
this.dy = parseInt(boxed['margin-top'] || '0', 10);
}
$bg.css(this.legalCoords({ width: this.zoom_w
, left: this.dx + dx
, top: this.dy + dy
}));
$.each(this.output(), function debug(k, val) {
self.$('.debug .' + k).text(val.toFixed(k === 'image_scale' ? 2 : 0));
});
// bounds of the zoom widget; zooming outside of this range not possible
if (!this.min_w) {
boxed = boxed || constrain(this.width, this.height, box_w, box_h);
this.min_w = boxed.width;
this.max_w = Infinity; // FIXME: would be nicer MAX_ZOOM_FACTOR based
// console.log( 'min:', this.min_w
// , 'max:', this.max_w);
}
// show visually when you can't zoom any deeper / further out
this.$z_in.prop( 'disabled', this.zoom_w >= this.max_w);
this.$z_out.prop('disabled', this.zoom_w <= this.min_w);
}
, legalCoords: function(data) {
var $box = this.$bg.parent()
, img_h = Math.round(this.zoom_w * this.height / this.width)
;
data.left = confine(data.left, [$box.width() - this.zoom_w, 0]);
data.top = confine(data.top, [$box.height() - img_h, 0]);
return data;
}
, dragStart: function(e) {
var drag = '.zoom-drag-'+ this.cid; // scope our event listeners
$(window).bind('mouseup' + drag, this.dragEnd)
.bind('mouseleave' + drag, this.dragEnd)
.bind('mousemove' + drag, this.dragMove);
this.x = e.clientX;
this.y = e.clientY;
e.preventDefault();
}
, dragEnd: function(e) {
$(window).unbind('.zoom-drag-'+ this.cid);
e.preventDefault();
var pos = this.legalCoords({ left: this.dx + e.clientX - this.x
, top: this.dy + e.clientY - this.y });
this.dx = pos.left;
this.dy = pos.top;
this.place();
}
, dragMove: function(e) {
this.place(e.clientX - this.x, e.clientY - this.y);
}
, zoom: function(e) {
var orig = e.originalEvent
, dy = orig.wheelDeltaY ? orig.wheelDeltaY / 120 :
orig.wheelDelta ? orig.wheelDelta / 120 :
orig.detail ? orig.detail / 3 : 0
, zoom = dy<0 ? 'zoomOut' : 'zoomIn'
;
if (zoom) {
e.preventDefault();
this[zoom](e);
}
}
, zoomIn: function(e) {
if (this.zoomBy(e, +1)) this.place();
}
, zoomOut: function(e) {
if (this.zoomBy(e, -1)) this.place();
}
// picks amount of zoom and tries to zoom, relative to the viewport's center
// (returns the amount of change to the image width -- so 0 means no change)
, zoomBy: function(e, sign) {
// for more precision - hold shift, for fast zooming - hold alt
var shift = e.shiftKey ? 1 : 10
, alt = e.metaKey ? 100 : 1
, amt = sign * alt * shift
// do the actual zoom (within bounds)
, old_w = this.zoom_w
, old_h = this.zoom_w * this.height / this.width
, new_w = this.zoom_w = confine(old_w + amt, [this.min_w, this.max_w])
, new_h = this.zoom_w * this.height / this.width
, delta = new_w - old_w
// what coordinate should the current centerpoint be in the new image?
, $box = this.$bg.parent()
, box_w = $box.width()
, box_h = $box.height()
, x_dom = [-new_w + box_w / 2, box_w / 2]
, y_dom = [-new_h + box_h / 2, box_h / 2]
// FIXME: the centering math here needs rethinking somehow
, pct_x = pct(confine(this.dx - box_w / 2, x_dom), x_dom)
, pct_y = pct(confine(this.dy - box_h / 2, y_dom), y_dom)
;
function pct(n, domain) {
var total = domain[1] - domain[0];
return (n - domain[0]) / total;
}
//console.log(this.dx, this.dy, pct_x.toFixed(4), pct_y.toFixed(4));
this.dx += delta * pct_x;
this.dy += delta * pct_y;
return delta;
}
})
;
$(document).ready(function() {
zoom = new Zoomer({ el: $('#page-1').get(0)
, url: url
//, image_scale: 1.8
//, offset_x: -255
//, offset_y: -354
//, width: 785
//, height: 594
});
});
function constrain(img_w, img_h, to_w, to_h) {
var box_w = (to_w || 800)
, box_h = (to_h || 600)
// aspect ratios
, box_a = box_w / box_h
, img_a = img_w / img_h
// only one of these is used, depending on the image and box aspect ratios:
, new_w = Math.floor(box_h * img_a)
, new_h = Math.floor(box_w / img_a)
;
return img_a < box_a ?
{ width: box_w, height: new_h, 'margin-top': (box_h - new_h) >> 1 }
: { width: new_w, height: box_h, 'margin-left': (box_w - new_w) >> 1 };
}
function confine(n, domain) {
return Math.max(domain[0], Math.min(n, domain[1]));
}
@lennylmiller
Copy link

Nice job.

I got it working standalone, then put it in a project that uses Twitter Bootstrap, and it doesn't work. Does this ring any bells?

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