var create_drag_box = function(div) { var create_handle = function(cursor, style) { var handle = $(document.createElement("div")); handle.style.position = "absolute"; handle.className = "frame-box-handle " + cursor; handle.frame_drag_cursor = cursor; handle.style.pointerEvents = "all"; div.appendChild(handle); for(s in style) { handle.style[s] = style[s]; } return handle; } /* Create the corner handles after the edge handles, so they're on top. */ create_handle("n-resize", {top: "-5px", width: "100%", height: "10px"}); create_handle("s-resize", {bottom: "-5px", width: "100%", height: "10px"}); create_handle("w-resize", {left: "-5px", height: "100%", width: "10px"}); create_handle("e-resize", {right: "-5px", height: "100%", width: "10px"}); create_handle("nw-resize", {top: "-5px", left: "-5px", height: "10px", width: "10px"}); create_handle("ne-resize", {top: "-5px", right: "-5px", height: "10px", width: "10px"}); create_handle("sw-resize", {bottom: "-5px", left: "-5px", height: "10px", width: "10px"}); create_handle("se-resize", {bottom: "-5px", right: "-5px", height: "10px", width: "10px"}); } var apply_drag = function(dragging_mode, x, y, image_dimensions, box) { var move_modes = { "move": { left: +1, top: +1, bottom: +1, right: +1 }, "n-resize": { top: +1 }, "s-resize": { bottom: +1 }, "w-resize": { left: +1 }, "e-resize": { right: +1 }, "nw-resize": { top: +1, left: +1 }, "ne-resize": { top: +1, right: +1 }, "sw-resize": { bottom: +1, left: +1 }, "se-resize": { bottom: +1, right: +1 } } var mode = move_modes[dragging_mode]; var result = { left: box.left, top: box.top, width: box.width, height: box.height }; var right = result.left + result.width; var bottom = result.top + result.height; if(dragging_mode == "move") { /* In move mode, clamp the movement. In other modes, clip the size below. */ x = clamp(x, -result.left, image_dimensions.width-right); y = clamp(y, -result.top, image_dimensions.height-bottom); } /* Apply the drag. */ if(mode.top != null) result.top += y * mode.top; if(mode.left != null) result.left += x * mode.left; if(mode.right != null) right += x * mode.right; if(mode.bottom != null) bottom += y * mode.bottom; if(dragging_mode != "move") { /* Only clamp the dimensions that were modified. */ if(mode.left != null) result.left = clamp(result.left, 0, right-1); if(mode.top != null) result.top = clamp(result.top, 0, bottom-1); if(mode.bottom != null) bottom = clamp(bottom, result.top+1, image_dimensions.height); if(mode.right != null) right = clamp(right, result.left+1, image_dimensions.width); } result.width = right - result.left; result.height = bottom - result.top; return result; } /* * Given a frame, its post and an image, return the frame's rectangle scaled to * the size of the image. */ var frame_dimensions_to_image = function(frame, image, post) { var result = { top: frame.source_top, left: frame.source_left, width: frame.source_width, height: frame.source_height }; result.left *= image.width / post.width; result.top *= image.height / post.height; result.width *= image.width / post.width; result.height *= image.height / post.height; result.top = Math.round(result.top); result.left = Math.round(result.left); result.width = Math.round(result.width); result.height = Math.round(result.height); return result; } /* * Convert dimensions scaled to an image back to the source resolution. */ var frame_dimensions_from_image = function(frame, image, post) { var result = { source_top: frame.top, source_left: frame.left, source_width: frame.width, source_height: frame.height }; /* Scale the coordinates back into the source resolution. */ result.source_top /= image.height / post.height; result.source_left /= image.width / post.width; result.source_height /= image.height / post.height; result.source_width /= image.width / post.width; result.source_top = Math.round(result.source_top); result.source_left = Math.round(result.source_left); result.source_width = Math.round(result.source_width); result.source_height = Math.round(result.source_height); return result; } FrameEditor = function(container, image_container, popup_container, options) { this.container = container; this.popup_container = popup_container; this.image_container = image_container; this.options = options; this.show_corner_drag = true; this.image_frames = []; /* Event handlers which are set only while the tag editor is open: */ this.open_handlers = []; /* Set up the four parts of the corner dragger. */ var popup_parts = [".frame-editor-nw", ".frame-editor-ne", ".frame-editor-sw", ".frame-editor-se"]; this.corner_draggers = []; for(var i = 0; i < popup_parts.length; ++i) { var part = popup_parts[i]; var div = this.popup_container.down(part); var corner_dragger = new CornerDragger(div, part, { onUpdate: function() { this.update_frame_in_list(this.editing_frame); this.update_image_frame(this.editing_frame); }.bind(this) }); this.corner_draggers.push(corner_dragger); } /* Create the main frame. This sits on top of the image, receives mouse events and * holds the individual frames. */ var div = $(document.createElement("div")); div.style.position = "absolute"; div.style.left = "0"; div.style.top = "0"; div.className = "frame-editor-main-frame"; this.image_container.appendChild(div); this.main_frame = div; this.main_frame.hide(); /* Frame editor buttons: */ this.container.down(".frame-editor-add").on("click", function(e) { e.stop(); this.add_frame(); }.bindAsEventListener(this)); /* Buttons in the frame table: */ this.container.on("click", ".frame-label", function(e, element) { e.stop(); var frame_idx = element.up(".frame-row").frame_idx; this.focus(frame_idx); }.bind(this)); this.container.on("click", ".frame-delete", function(e, element) { e.stop(); var frame_idx = element.up(".frame-row").frame_idx; this.delete_frame(frame_idx); }.bind(this)); this.container.on("click", ".frame-up", function(e, element) { e.stop(); var frame_idx = element.up(".frame-row").frame_idx; this.move_frame(frame_idx, frame_idx-1); }.bind(this)); this.container.on("click", ".frame-down", function(e, element) { e.stop(); var frame_idx = element.up(".frame-row").frame_idx; this.move_frame(frame_idx, frame_idx+1); }.bind(this)); this.container.down("table").on("change", function(e) { this.form_data_changed(); }.bind(this)); } FrameEditor.prototype.move_frame = function(frame_idx, frame_idx_target) { var post = Post.posts.get(this.post_id); frame_idx_target = Math.max(frame_idx_target, 0); frame_idx_target = Math.min(frame_idx_target, post.frames_pending.length-1); if(frame_idx == frame_idx_target) return; var frame = post.frames_pending[frame_idx]; post.frames_pending.splice(frame_idx, 1); post.frames_pending.splice(frame_idx_target, 0, frame); this.repopulate_table(); /* Reset the focus. If the item that was moved was focused, focus on it in * its new position. */ var editing_frame = this.editing_frame == frame_idx? frame_idx_target:this.editing_frame; this.editing_frame = null; this.focus(editing_frame); } FrameEditor.prototype.form_data_changed = function() { var post = Post.posts.get(this.post_id); for(var i = 0; i < post.frames_pending.length; ++i) this.update_frame_from_list(i); this.update(); } FrameEditor.prototype.set_drag_to_create = function(enable) { this.drag_to_create = enable; } FrameEditor.prototype.update_show_corner_drag = function() { var shown = this.post_id != null && this.editing_frame != null && this.show_corner_drag; if(Prototype.Browser.WebKit) { /* Work around a WebKit (maybe just a Chrome) issue. Images are downloaded immediately, but * they're only decompressed the first time they're actually painted on screen. This happens * late, after all style is applied: hiding with display: none, visibility: hidden or even * opacity: 0 causes the image to not be decoded until it's displayed, which causes a huge * UI hitch the first time the user drags a box. Work around this by setting opacity very * small; it'll trick it into decoding the image, but it'll clip to 0 when rendered. */ if(shown) { this.popup_container.style.opacity = 1; this.popup_container.style.pointerEvents = ""; this.popup_container.style.position = "static"; } else { this.popup_container.style.opacity = 0.001; /* Make sure the invisible element doesn't interfere with the page; disable pointer-events * so it doesn't receive clicks, and set it to absolute so it doesn't affect the size of its * containing box. */ this.popup_container.style.pointerEvents = "none"; this.popup_container.style.position = "absolute"; this.popup_container.style.top = "0px"; this.popup_container.style.right = "0px"; } this.popup_container.show(); } else { this.popup_container.show(shown); } for(var i = 0; i < this.corner_draggers.length; ++i) this.corner_draggers[i].update(); } FrameEditor.prototype.set_show_corner_drag = function(enable) { this.show_corner_drag = enable; this.update_show_corner_drag(); } FrameEditor.prototype.set_image_dimensions = function(width, height) { var editing_frame = this.editing_frame; var post_id = this.post_id; this.close(); this.image_dimensions = {width: width, height: height}; this.main_frame.style.width = this.image_dimensions.width + "px"; this.main_frame.style.height = this.image_dimensions.height + "px"; if(post_id != null) { this.open(post_id); this.focus(editing_frame); } } /* * Like document.elementFromPoint, but returns an array of all elements at the given point. * If a top element is specified, stop if it's reached without including it in the list. * */ var elementArrayFromPoint = function(x, y, top) { var elements = []; while(1) { var element = document.elementFromPoint(x, y); if(element == this.main_frame || element == document.documentElement) break; element.original_display = element.style.display; element.style.display = "none"; elements.push(element); } /* Restore the elements we just hid. */ elements.each(function(e) { e.style.display = e.original_display; e.original_display = null; }); return elements; } FrameEditor.prototype.is_opened = function() { return this.post_id != null; } /* Open the frame editor if it isn't already, and focus on the specified frame. */ FrameEditor.prototype.open = function(post_id) { if(this.image_dimensions == null) throw "Must call set_image_dimensions before open"; if(this.post_id != null) return; this.post_id = post_id; this.editing_frame = null; this.dragging_item = null; this.container.show(); this.main_frame.show(); this.update_show_corner_drag(); var post = Post.posts.get(this.post_id); /* Tell the corner draggers which post we're working on now, so they'll start * loading the JPEG version immediately if necessary. Otherwise, we'll start * loading it the first time we focus a frame, which will hitch the editor for * a while in Chrome. */ for(var i = 0; i < this.corner_draggers.length; ++i) this.corner_draggers[i].set_post_id(this.post_id); this.open_handlers.push( document.on("keydown", function(e) { if (e.keyCode == Event.KEY_ESC) { this.discard(); } }.bindAsEventListener(this)) ) /* If we havn't done so already, make a backup of this post's frames. We'll restore * from this later if the user cancels the edit. */ this.original_frames = Object.toJSON(post.frames_pending); this.repopulate_table(); this.create_dragger(); if(post.frames_pending.length > 0) this.focus(0); this.update(); } FrameEditor.prototype.create_dragger = function() { if(this.dragger) this.dragger.destroy(); this.dragger = new DragElement(this.main_frame, { ondown: function(e) { var post = Post.posts.get(this.post_id); /* * Figure out which element(s) we're clicking on. The click may lie on a spot * where multiple frames overlap; make a list. * * Temporarily enable pointerEvents on the frames, so elementFromPoint will * resolve them. */ this.image_frames.each(function(frame) { frame.style.pointerEvents = "all"; }); var clicked_elements = elementArrayFromPoint(e.x, e.y, this.main_frame); this.image_frames.each(function(frame) { frame.style.pointerEvents = "none"; }); /* If we clicked on a handle, prefer it over frame bodies at the same spot. */ var element = null; clicked_elements.each(function(e) { /* If a handle was clicked, always prefer it. Use the first handle we find, * so we prefer the corner handles (which are always on top) to edge handles. */ if(element == null && e.hasClassName("frame-box-handle")) element = e; }.bind(this)); /* If a handle wasn't clicked, prefer the frame that's currently focused. */ if(element == null) { clicked_elements.each(function(e) { if(!e.hasClassName("frame-editor-frame-box")) e = e.up(".frame-editor-frame-box"); if(this.image_frames.indexOf(e) == this.editing_frame) element = e; }.bind(this)); } /* Otherwise, just use the first item that was found. */ if(element == null) element = clicked_elements[0]; /* If a handle was clicked on, find the frame element that contains it. */ var frame_element = element; if(!frame_element.hasClassName("frame-editor-frame-box")) frame_element = frame_element.up(".frame-editor-frame-box"); /* If we didn't click on a frame box at all, create a new one. */ if(frame_element == null) { if(!this.drag_to_create) return; this.dragging_new = true; } else this.dragging_new = false; /* If the element we actually clicked on was one of the edge handles, set the drag * mode based on which one was clicked. */ if(element.hasClassName("frame-box-handle")) this.dragging_mode = element.frame_drag_cursor else this.dragging_mode = "move"; if(frame_element && frame_element.hasClassName("frame-editor-frame-box")) { var frame_idx = this.image_frames.indexOf(frame_element); this.dragging_idx = frame_idx; var frame = post.frames_pending[this.dragging_idx]; this.dragging_anchor = frame_dimensions_to_image(frame, this.image_dimensions, post); } this.focus(this.dragging_idx); /* If we're dragging a handle, override the drag class so the pointer will * use the handle pointer instead of the drag pointer. */ this.dragger.overriden_drag_class = this.dragging_mode == "move"? null: this.dragging_mode; this.dragger.options.snap_pixels = this.dragging_new? 10:0; /* Stop propagation of the event, so any other draggers in the chain don't start. In * particular, when we're dragging inside the image, we need to stop WindowDragElementAbsolute. * Only do this if we're actually dragging, not if we aborted due to this.drag_to_create. */ e.latest_event.stopPropagation(); }.bind(this), onup: function(e) { this.dragging_idx = null; this.dragging_anchor = null; }.bind(this), ondrag: function(e) { var post = Post.posts.get(this.post_id); if(this.dragging_new) { /* Pick a dragging mode based on which way we were dragged. This is a * little funny; we should probably be able to drag freely, not be fixed * to the first direction we drag. */ if(e.aX > 0 && e.aY > 0) this.dragging_mode = "se-resize"; else if(e.aX > 0 && e.aY < 0) this.dragging_mode = "ne-resize"; else if(e.aX < 0 && e.aY > 0) this.dragging_mode = "sw-resize"; else if(e.aX < 0 && e.aY < 0) this.dragging_mode = "nw-resize"; else return; this.dragging_new = false; /* Create a new, empty frame. When we get to the regular drag path below we'll * give it its real size, based on how far we've dragged so far. */ var frame_offset = this.main_frame.cumulativeOffset(); var dims = { left: e.dragger.anchor_x - frame_offset.left, top: e.dragger.anchor_y - frame_offset.top, height: 0, width: 0 }; this.dragging_anchor = dims; var source_dims = frame_dimensions_from_image(dims, this.image_dimensions, post); this.dragging_idx = this.add_frame(source_dims); post.frames_pending[this.editing_frame] = source_dims; } if(this.dragging_idx == null) return; var dims = apply_drag(this.dragging_mode, e.aX, e.aY, this.image_dimensions, this.dragging_anchor); /* Scale the changed dimensions back to the source resolution and apply them * to the frame. */ var source_dims = frame_dimensions_from_image(dims, this.image_dimensions, post); post.frames_pending[this.editing_frame] = source_dims; this.update_frame_in_list(this.editing_frame); this.update_image_frame(this.editing_frame); }.bind(this) }); } FrameEditor.prototype.repopulate_table = function() { var post = Post.posts.get(this.post_id); /* Clear the table. */ var tbody = this.container.down(".frame-list").down("TBODY"); while(tbody.firstChild) tbody.removeChild(tbody.firstChild); /* Clear the image frames. */ this.image_frames.each(function(f) { f.parentNode.removeChild(f); }.bind(this)); this.image_frames = []; for(var i = 0; i < post.frames_pending.length; ++i) { this.add_frame_to_list(i); this.create_image_frame(); this.update_image_frame(i); } } FrameEditor.prototype.update = function() { this.update_show_corner_drag(); if(this.image_dimensions == null) return; var post = Post.posts.get(this.post_id); if(post != null) { for(var i = 0; i < post.frames_pending.length; ++i) this.update_image_frame(i); } } /* If the frame editor is open, discard changes and close it. */ FrameEditor.prototype.discard = function() { if(this.post_id == null) return; /* Save revert_to, and close the editor before reverting, to make sure closing * the editor doesn't change anything. */ var revert_to = this.original_frames; var post_id = this.post_id; this.close(); /* Revert changes. */ var post = Post.posts.get(post_id); post.frames_pending = revert_to.evalJSON(); } /* Get the frames specifier for the post's frames. */ FrameEditor.prototype.get_current_frames_spec = function() { var post = Post.posts.get(this.post_id); var frame = post.frames_pending; var frame_specs = []; post.frames_pending.each(function(frame) { var s = frame.source_left + "x" + frame.source_top + "," + frame.source_width + "x" + frame.source_height; frame_specs.push(s); }.bind(this)); return frame_specs.join(";"); } /* Return true if the frames have been changed. */ FrameEditor.prototype.changed = function() { var post = Post.posts.get(this.post_id); var spec = this.get_current_frames_spec(); return spec != post.frames_pending_string; } /* Save changes to the post, if any. If not null, call finished on completion. */ FrameEditor.prototype.save = function(finished) { if(this.post_id == null) { if(finished) finished(); return; } /* Save the current post_id, so it's preserved when the AJAX completion function * below is run. */ var post_id = this.post_id; var post = Post.posts.get(post_id); var frame = post.frames_pending; var spec = this.get_current_frames_spec(); if(spec == post.frames_pending_string) { if(finished) finished(); return; } Post.update_batch([{ id: post_id, frames_pending_string: spec }], function(posts) { if(this.post_id == post_id) { /* The registered post has been changed, and we're still displaying it. Grab the * new version, and updated original_frames so we no longer consider this post * changed. */ var post = Post.posts.get(post_id); this.original_frames = Object.toJSON(post.frames_pending); /* In the off-chance that the frames_pending that came back differs from what we * requested, update the display. */ this.update(); } if(finished) finished(); }.bind(this)); } FrameEditor.prototype.create_image_frame = function() { var div = $(document.createElement("div")); div.className = "frame-editor-frame-box"; /* Disable pointer-events on the image frame, so the handle cursors always * show up even when an image frame lies on top of it. */ div.style.pointerEvents = "none"; // div.style.opacity=0.1; this.main_frame.appendChild(div); this.image_frames.push(div); create_drag_box(div); } FrameEditor.prototype.update_image_frame = function(frame_idx) { var post = Post.posts.get(this.post_id); var frame = post.frames_pending[frame_idx]; /* If the focused frame is being modified, update the corner dragger as well. */ if(frame_idx == this.editing_frame) { for(var i = 0; i < this.corner_draggers.length; ++i) this.corner_draggers[i].update(); } var dimensions = frame_dimensions_to_image(frame, this.image_dimensions, post); var div = this.image_frames[frame_idx]; div.style.left = dimensions.left + "px"; div.style.top = dimensions.top + "px"; div.style.width = dimensions.width + "px"; div.style.height = dimensions.height + "px"; if(frame_idx == this.editing_frame) div.addClassName("focused-frame-box"); else div.removeClassName("focused-frame-box"); } /* Append the given frame to the editor list. */ FrameEditor.prototype.add_frame_to_list = function(frame_idx) { var tbody = this.container.down(".frame-list").down("TBODY"); var tr = $(document.createElement("TR")); tr.className = "frame-row frame-" + frame_idx; tr.frame_idx = frame_idx; tbody.appendChild(tr); var html = "