/*
* svgcanvas.js
*
* Licensed under the Apache License, Version 2
*
* Copyright(c) 2010 Alexis Deveria
* Copyright(c) 2010 Pavol Rusnak
* Copyright(c) 2010 Jeff Schiller
*
*/
if(!window.console) {
window.console = {};
window.console.log = function(str) {};
window.console.dir = function(str) {};
}
if(window.opera) {
window.console.log = function(str) {opera.postError(str);};
window.console.dir = function(str) {};
}
(function() {
// This fixes $(...).attr() to work as expected with SVG elements.
// Does not currently use *AttributeNS() since we rarely need that.
// See http://api.jquery.com/attr/ for basic documentation of .attr()
// Additional functionality:
// - When getting attributes, a string that's a number is return as type number.
// - If an array is supplied as first parameter, multiple values are returned
// as an object with values for each given attributes
var proxied = jQuery.fn.attr, svgns = "http://www.w3.org/2000/svg";
jQuery.fn.attr = function(key, value) {
var len = this.length;
if(!len) return this;
for(var i=0; i').text(str).html();
},
// Function: Utils.fromXml
// Converts XML entities in a string to single characters.
// Example: "&" becomes "&"
//
// Parameters:
// str - The string to be converted
//
// Returns:
// The converted string
"fromXml": function(str) {
return $('
').html(str).text();
},
// This code was written by Tyler Akins and has been placed in the
// public domain. It would be nice if you left this header intact.
// Base64 code from Tyler Akins -- http://rumkin.com
// schiller: Removed string concatenation in favour of Array.join() optimization,
// also precalculate the size of the array needed.
// Function: Utils.encode64
// Converts a string to base64
"encode64" : function(input) {
// base64 strings are 4/3 larger than the original string
// input = Utils.encodeUTF8(input); // convert non-ASCII characters
input = Utils.convertToXMLReferences(input);
if(window.btoa) return window.btoa(input); // Use native if available
var output = new Array( Math.floor( (input.length + 2) / 3 ) * 4 );
var chr1, chr2, chr3;
var enc1, enc2, enc3, enc4;
var i = 0, p = 0;
do {
chr1 = input.charCodeAt(i++);
chr2 = input.charCodeAt(i++);
chr3 = input.charCodeAt(i++);
enc1 = chr1 >> 2;
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
enc4 = chr3 & 63;
if (isNaN(chr2)) {
enc3 = enc4 = 64;
} else if (isNaN(chr3)) {
enc4 = 64;
}
output[p++] = _keyStr.charAt(enc1);
output[p++] = _keyStr.charAt(enc2);
output[p++] = _keyStr.charAt(enc3);
output[p++] = _keyStr.charAt(enc4);
} while (i < input.length);
return output.join('');
},
// Function: Utils.decode64
// Converts a string from base64
"decode64" : function(input) {
if(window.atob) return window.atob(input);
var output = "";
var chr1, chr2, chr3 = "";
var enc1, enc2, enc3, enc4 = "";
var i = 0;
// remove all characters that are not A-Z, a-z, 0-9, +, /, or =
input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");
do {
enc1 = _keyStr.indexOf(input.charAt(i++));
enc2 = _keyStr.indexOf(input.charAt(i++));
enc3 = _keyStr.indexOf(input.charAt(i++));
enc4 = _keyStr.indexOf(input.charAt(i++));
chr1 = (enc1 << 2) | (enc2 >> 4);
chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
chr3 = ((enc3 & 3) << 6) | enc4;
output = output + String.fromCharCode(chr1);
if (enc3 != 64) {
output = output + String.fromCharCode(chr2);
}
if (enc4 != 64) {
output = output + String.fromCharCode(chr3);
}
chr1 = chr2 = chr3 = "";
enc1 = enc2 = enc3 = enc4 = "";
} while (i < input.length);
return unescape(output);
},
// Currently not being used, so commented out for now
// based on http://phpjs.org/functions/utf8_encode:577
// codedread:does not seem to work with webkit-based browsers on OSX
// "encodeUTF8": function(input) {
// //return unescape(encodeURIComponent(input)); //may or may not work
// var output = '';
// for (var n = 0; n < input.length; n++){
// var c = input.charCodeAt(n);
// if (c < 128) {
// output += input[n];
// }
// else if (c > 127) {
// if (c < 2048){
// output += String.fromCharCode((c >> 6) | 192);
// }
// else {
// output += String.fromCharCode((c >> 12) | 224) + String.fromCharCode((c >> 6) & 63 | 128);
// }
// output += String.fromCharCode((c & 63) | 128);
// }
// }
// return output;
// },
// Function: Utils.convertToXMLReferences
// Converts a string to use XML references
"convertToXMLReferences": function(input) {
var output = '';
for (var n = 0; n < input.length; n++){
var c = input.charCodeAt(n);
if (c < 128) {
output += input[n];
}
else if(c > 127) {
output += ("" + c + ";");
}
}
return output;
},
// Function: rectsIntersect
// Check if two rectangles (BBoxes objects) intersect each other
//
// Paramaters:
// r1 - The first BBox-like object
// r2 - The second BBox-like object
//
// Returns:
// Boolean that's true if rectangles intersect
"rectsIntersect": function(r1, r2) {
return r2.x < (r1.x+r1.width) &&
(r2.x+r2.width) > r1.x &&
r2.y < (r1.y+r1.height) &&
(r2.y+r2.height) > r1.y;
},
// Function: snapToAngle
// Returns a 45 degree angle coordinate associated with the two given
// coordinates
//
// Parameters:
// x1 - First coordinate's x value
// x2 - Second coordinate's x value
// y1 - First coordinate's y value
// y2 - Second coordinate's y value
//
// Returns:
// Object with the following values:
// x - The angle-snapped x value
// y - The angle-snapped y value
// snapangle - The angle at which to snap
"snapToAngle": function(x1,y1,x2,y2) {
var snap = Math.PI/4; // 45 degrees
var dx = x2 - x1;
var dy = y2 - y1;
var angle = Math.atan2(dy,dx);
var dist = Math.sqrt(dx * dx + dy * dy);
var snapangle= Math.round(angle/snap)*snap;
var x = x1 + dist*Math.cos(snapangle);
var y = y1 + dist*Math.sin(snapangle);
//console.log(x1,y1,x2,y2,x,y,angle)
return {x:x, y:y, a:snapangle};
},
// Function: text2xml
// Cross-browser compatible method of converting a string to an XML tree
// found this function here: http://groups.google.com/group/jquery-dev/browse_thread/thread/c6d11387c580a77f
"text2xml": function(sXML) {
if(sXML.indexOf('' +
'' +
'' +
''+
''+
''+
''+
''+
''+
''+
''+
'').documentElement, true);
container.appendChild(svgroot);
// The actual element that represents the final output SVG element
var svgcontent = svgdoc.createElementNS(svgns, "svg");
$(svgcontent).attr({
id: 'svgcontent',
width: dimensions[0],
height: dimensions[1],
x: dimensions[0],
y: dimensions[1],
overflow: curConfig.show_outside_canvas?'visible':'hidden',
xmlns: svgns,
"xmlns:se": se_ns,
"xmlns:xlink": xlinkns
}).appendTo(svgroot);
// Set nonce if randomize_ids = true
if (randomize_ids) svgcontent.setAttributeNS(se_ns, 'se:nonce', nonce);
// map namespace URIs to prefixes
var nsMap = {};
nsMap[xlinkns] = 'xlink';
nsMap[xmlns] = 'xml';
nsMap[xmlnsns] = 'xmlns';
nsMap[se_ns] = 'se';
nsMap[htmlns] = 'xhtml';
nsMap[mathns] = 'mathml';
// map prefixes to namespace URIs
var nsRevMap = {};
$.each(nsMap, function(key,value){
nsRevMap[value] = key;
});
// Produce a Namespace-aware version of svgWhitelist
var svgWhiteListNS = {};
$.each(svgWhiteList, function(elt,atts){
var attNS = {};
$.each(atts, function(i, att){
if (att.indexOf(':') != -1) {
var v = att.split(':');
attNS[v[1]] = nsRevMap[v[0]];
} else {
attNS[att] = att == 'xmlns' ? xmlnsns : null;
}
});
svgWhiteListNS[elt] = attNS;
});
// Animation element to change the opacity of any newly created element
var opac_ani = document.createElementNS(svgns, 'animate');
$(opac_ani).attr({
attributeName: 'opacity',
begin: 'indefinite',
dur: 1,
fill: 'freeze'
}).appendTo(svgroot);
// Group: Unit conversion functions
// Set the scope for these functions
var convertToNum, convertToUnit, setUnitAttr;
(function() {
var w_attrs = ['x', 'x1', 'cx', 'rx', 'width'];
var h_attrs = ['y', 'y1', 'cy', 'ry', 'height'];
var unit_attrs = $.merge(['r','radius'], w_attrs);
$.merge(unit_attrs, h_attrs);
// Function: convertToNum
// Converts given values to numbers. Attributes must be supplied in
// case a percentage is given
//
// Parameters:
// attr - String with the name of the attribute associated with the value
// val - String with the attribute value to convert
convertToNum = function(attr, val) {
// Return a number if that's what it already is
if(!isNaN(val)) return val-0;
if(val.substr(-1) === '%') {
// Deal with percentage, depends on attribute
var num = val.substr(0, val.length-1)/100;
var res = getResolution();
if($.inArray(attr, w_attrs) !== -1) {
return num * res.w;
} else if($.inArray(attr, h_attrs) !== -1) {
return num * res.h;
} else {
return num * Math.sqrt((res.w*res.w) + (res.h*res.h))/Math.sqrt(2);
}
} else {
var unit = val.substr(-2);
var num = val.substr(0, val.length-2);
// Note that this multiplication turns the string into a number
return num * unit_types[unit];
}
};
// Function: setUnitAttr
// Sets an element's attribute based on the unit in its current value.
//
// Parameters:
// elem - DOM element to be changed
// attr - String with the name of the attribute associated with the value
// val - String with the attribute value to convert
setUnitAttr = function(elem, attr, val) {
if(!isNaN(val)) {
// New value is a number, so check currently used unit
var old_val = elem.getAttribute(attr);
if(old_val !== null && isNaN(old_val)) {
// Old value was a number, so get unit, then convert
var unit;
if(old_val.substr(-1) === '%') {
var res = getResolution();
unit = '%';
val *= 100;
if($.inArray(attr, w_attrs) !== -1) {
val = val / res.w;
} else if($.inArray(attr, h_attrs) !== -1) {
val = val / res.h;
} else {
return val / Math.sqrt((res.w*res.w) + (res.h*res.h))/Math.sqrt(2);
}
} else {
unit = old_val.substr(-2);
val = val / unit_types[unit];
}
val += unit;
}
}
elem.setAttribute(attr, val);
}
// Function: isValidUnit
// Check if an attribute's value is in a valid format
//
// Parameters:
// attr - String with the name of the attribute associated with the value
// val - String with the attribute value to check
canvas.isValidUnit = function(attr, val) {
var valid = false;
if($.inArray(attr, unit_attrs) != -1) {
// True if it's just a number
if(!isNaN(val)) {
valid = true;
} else {
// Not a number, check if it has a valid unit
val = val.toLowerCase();
$.each(unit_types, function(unit) {
if(valid) return;
var re = new RegExp('^-?[\\d\\.]+' + unit + '$');
if(re.test(val)) valid = true;
});
}
} else if (attr == "id") {
// if we're trying to change the id, make sure it's not already present in the doc
// and the id value is valid.
var result = false;
// because getElem() can throw an exception in the case of an invalid id
// (according to http://www.w3.org/TR/xml-id/ IDs must be a NCName)
// we wrap it in an exception and only return true if the ID was valid and
// not already present
try {
var elem = getElem(val);
result = (elem == null);
} catch(e) {}
return result;
} else valid = true;
return valid;
}
})();
// Group: Undo/Redo history management
this.undoCmd = {};
// Function: ChangeElementCommand
// History command to make a change to an element.
// Usually an attribute change, but can also be textcontent.
//
// Parameters:
// elem - The DOM element that was changed
// attrs - An object with the attributes to be changed and the values they had *before* the change
// text - An optional string visible to user related to this change
var ChangeElementCommand = this.undoCmd.changeElement = function(elem, attrs, text) {
this.elem = elem;
this.text = text ? ("Change " + elem.tagName + " " + text) : ("Change " + elem.tagName);
this.newValues = {};
this.oldValues = attrs;
for (var attr in attrs) {
if (attr == "#text") this.newValues[attr] = elem.textContent;
else if (attr == "#href") this.newValues[attr] = getHref(elem);
else this.newValues[attr] = elem.getAttribute(attr);
}
// Function: ChangeElementCommand.apply
// Performs the stored change action
this.apply = function() {
var bChangedTransform = false;
for(var attr in this.newValues ) {
if (this.newValues[attr]) {
if (attr == "#text") this.elem.textContent = this.newValues[attr];
else if (attr == "#href") setHref(this.elem, this.newValues[attr])
else this.elem.setAttribute(attr, this.newValues[attr]);
}
else {
if (attr == "#text") this.elem.textContent = "";
else {
this.elem.setAttribute(attr, "");
this.elem.removeAttribute(attr);
}
}
if (attr == "transform") { bChangedTransform = true; }
else if (attr == "stdDeviation") { canvas.setBlurOffsets(this.elem.parentNode, this.newValues[attr]); }
}
// relocate rotational transform, if necessary
if(!bChangedTransform) {
var angle = getRotationAngle(elem);
if (angle) {
var bbox = elem.getBBox();
var cx = bbox.x + bbox.width/2,
cy = bbox.y + bbox.height/2;
var rotate = ["rotate(", angle, " ", cx, ",", cy, ")"].join('');
if (rotate != elem.getAttribute("transform")) {
elem.setAttribute("transform", rotate);
}
}
}
// if we are changing layer names, re-identify all layers
if (this.elem.tagName == "title" && this.elem.parentNode.parentNode == svgcontent) {
identifyLayers();
}
return true;
};
// Function: ChangeElementCommand.unapply
// Reverses the stored change action
this.unapply = function() {
var bChangedTransform = false;
for(var attr in this.oldValues ) {
if (this.oldValues[attr]) {
if (attr == "#text") this.elem.textContent = this.oldValues[attr];
else if (attr == "#href") setHref(this.elem, this.oldValues[attr]);
else this.elem.setAttribute(attr, this.oldValues[attr]);
if (attr == "stdDeviation") canvas.setBlurOffsets(this.elem.parentNode, this.oldValues[attr]);
}
else {
if (attr == "#text") this.elem.textContent = "";
else this.elem.removeAttribute(attr);
}
if (attr == "transform") { bChangedTransform = true; }
}
// relocate rotational transform, if necessary
if(!bChangedTransform) {
var angle = getRotationAngle(elem);
if (angle) {
var bbox = elem.getBBox();
var cx = bbox.x + bbox.width/2,
cy = bbox.y + bbox.height/2;
var rotate = ["rotate(", angle, " ", cx, ",", cy, ")"].join('');
if (rotate != elem.getAttribute("transform")) {
elem.setAttribute("transform", rotate);
}
}
}
// if we are changing layer names, re-identify all layers
if (this.elem.tagName == "title" && this.elem.parentNode.parentNode == svgcontent) {
identifyLayers();
}
// Remove transformlist to prevent confusion that causes bugs like 575.
if (svgTransformLists[this.elem.id]) {
delete svgTransformLists[this.elem.id];
}
return true;
};
// Function: ChangeElementCommand.elements
// Returns array with element associated with this command
this.elements = function() { return [this.elem]; }
}
// Function: InsertElementCommand
// History command for an element that was added to the DOM
//
// Parameters:
// elem - The newly added DOM element
// text - An optional string visible to user related to this change
var InsertElementCommand = this.undoCmd.insertElement = function(elem, text) {
this.elem = elem;
this.text = text || ("Create " + elem.tagName);
this.parent = elem.parentNode;
// Function: InsertElementCommand.apply
// Re-Inserts the new element
this.apply = function() {
this.elem = this.parent.insertBefore(this.elem, this.elem.nextSibling);
if (this.parent == svgcontent) {
identifyLayers();
}
};
// Function: InsertElementCommand.unapply
// Removes the element
this.unapply = function() {
this.parent = this.elem.parentNode;
this.elem = this.elem.parentNode.removeChild(this.elem);
if (this.parent == svgcontent) {
identifyLayers();
}
};
// Function: InsertElementCommand.elements
// Returns array with element associated with this command
this.elements = function() { return [this.elem]; };
}
// Function: RemoveElementCommand
// History command for an element removed from the DOM
//
// Parameters:
// elem - The removed DOM element
// parent - The DOM element's parent
// text - An optional string visible to user related to this change
var RemoveElementCommand = this.undoCmd.removeElement = function(elem, parent, text) {
this.elem = elem;
this.text = text || ("Delete " + elem.tagName);
this.parent = parent;
// Function: RemoveElementCommand.apply
// Re-removes the new element
this.apply = function() {
if (svgTransformLists[this.elem.id]) {
delete svgTransformLists[this.elem.id];
}
this.parent = this.elem.parentNode;
this.elem = this.parent.removeChild(this.elem);
if (this.parent == svgcontent) {
identifyLayers();
}
};
// Function: RemoveElementCommand.unapply
// Re-adds the new element
this.unapply = function() {
if (svgTransformLists[this.elem.id]) {
delete svgTransformLists[this.elem.id];
}
this.elem = this.parent.insertBefore(this.elem, this.elem.nextSibling);
if (this.parent == svgcontent) {
identifyLayers();
}
};
// Function: RemoveElementCommand.elements
// Returns array with element associated with this command
this.elements = function() { return [this.elem]; };
// special hack for webkit: remove this element's entry in the svgTransformLists map
if (svgTransformLists[elem.id]) {
delete svgTransformLists[elem.id];
}
}
// Function: MoveElementCommand
// History command for an element that had its DOM position changed
//
// Parameters:
// elem - The DOM element that was moved
// oldNextSibling - The element's next sibling before it was moved
// oldParent - The element's parent before it was moved
// text - An optional string visible to user related to this change
var MoveElementCommand = this.undoCmd.moveElement = function(elem, oldNextSibling, oldParent, text) {
this.elem = elem;
this.text = text ? ("Move " + elem.tagName + " to " + text) : ("Move " + elem.tagName);
this.oldNextSibling = oldNextSibling;
this.oldParent = oldParent;
this.newNextSibling = elem.nextSibling;
this.newParent = elem.parentNode;
// Function: MoveElementCommand.unapply
// Re-positions the element
this.apply = function() {
this.elem = this.newParent.insertBefore(this.elem, this.newNextSibling);
if (this.newParent == svgcontent) {
identifyLayers();
}
};
// Function: MoveElementCommand.unapply
// Positions the element back to its original location
this.unapply = function() {
this.elem = this.oldParent.insertBefore(this.elem, this.oldNextSibling);
if (this.oldParent == svgcontent) {
identifyLayers();
}
};
// Function: MoveElementCommand.elements
// Returns array with element associated with this command
this.elements = function() { return [this.elem]; };
}
// TODO: create a 'typing' command object that tracks changes in text
// if a new Typing command is created and the top command on the stack is also a Typing
// and they both affect the same element, then collapse the two commands into one
// Function: BatchCommand
// History command that can contain/execute multiple other commands
//
// Parameters:
// text - An optional string visible to user related to this change
var BatchCommand = this.undoCmd.batch = function(text) {
this.text = text || "Batch Command";
this.stack = [];
// Function: BatchCommand.apply
// Runs "apply" on all subcommands
this.apply = function() {
var len = this.stack.length;
for (var i = 0; i < len; ++i) {
this.stack[i].apply();
}
};
// Function: BatchCommand.unapply
// Runs "unapply" on all subcommands
this.unapply = function() {
for (var i = this.stack.length-1; i >= 0; i--) {
this.stack[i].unapply();
}
};
// Function: BatchCommand.elements
// Iterate through all our subcommands and returns all the elements we are changing
this.elements = function() {
var elems = [];
var cmd = this.stack.length;
while (cmd--) {
var thisElems = this.stack[cmd].elements();
var elem = thisElems.length;
while (elem--) {
if (elems.indexOf(thisElems[elem]) == -1) elems.push(thisElems[elem]);
}
}
return elems;
};
// Function: BatchCommand.addSubCommand
// Adds a given command to the history stack
//
// Parameters:
// cmd - The undo command object to add
this.addSubCommand = function(cmd) { this.stack.push(cmd); };
// Function: BatchCommand.isEmpty
// Returns a boolean indicating whether or not the batch command is empty
this.isEmpty = function() { return this.stack.length == 0; };
}
// Set scope for these undo functions
var resetUndoStack, addCommandToHistory;
// Undo/redo stack related functions
(function(c) {
var undoStackPointer = 0,
undoStack = [];
// Function: resetUndoStack
// Resets the undo stack, effectively clearing the undo/redo history
resetUndoStack = function() {
undoStack = [];
undoStackPointer = 0;
};
c.undoMgr = {
// Function: undoMgr.getUndoStackSize
// Returns:
// Integer with the current size of the undo history stack
getUndoStackSize: function() { return undoStackPointer; },
// Function: undoMgr.getRedoStackSize
// Returns:
// Integer with the current size of the redo history stack
getRedoStackSize: function() { return undoStack.length - undoStackPointer; },
// Function: undoMgr.getNextUndoCommandText
// Returns:
// String associated with the next undo command
getNextUndoCommandText: function() {
if (undoStackPointer > 0)
return undoStack[undoStackPointer-1].text;
return "";
},
// Function: undoMgr.getNextRedoCommandText
// Returns:
// String associated with the next redo command
getNextRedoCommandText: function() {
if (undoStackPointer < undoStack.length)
return undoStack[undoStackPointer].text;
return "";
},
// Function: undoMgr.undo
// Performs an undo step
undo: function() {
if (undoStackPointer > 0) {
c.clearSelection();
var cmd = undoStack[--undoStackPointer];
cmd.unapply();
pathActions.clear();
call("changed", cmd.elements());
}
},
// Function: undoMgr.redo
// Performs a redo step
redo: function() {
if (undoStackPointer < undoStack.length && undoStack.length > 0) {
c.clearSelection();
var cmd = undoStack[undoStackPointer++];
cmd.apply();
pathActions.clear();
call("changed", cmd.elements());
}
}
};
// Function: addCommandToHistory
// Adds a command object to the undo history stack
//
// Parameters:
// cmd - The command object to add
addCommandToHistory = c.undoCmd.add = function(cmd) {
// FIXME: we MUST compress consecutive text changes to the same element
// (right now each keystroke is saved as a separate command that includes the
// entire text contents of the text element)
// TODO: consider limiting the history that we store here (need to do some slicing)
// if our stack pointer is not at the end, then we have to remove
// all commands after the pointer and insert the new command
if (undoStackPointer < undoStack.length && undoStack.length > 0) {
undoStack = undoStack.splice(0, undoStackPointer);
}
undoStack.push(cmd);
undoStackPointer = undoStack.length;
};
}(canvas));
(function(c) {
// New functions for refactoring of Undo/Redo
// this is the stack that stores the original values, the elements and
// the attribute name for begin/finish
var undoChangeStackPointer = -1;
var undoableChangeStack = [];
// Function: beginUndoableChange
// This function tells the canvas to remember the old values of the
// attrName attribute for each element sent in. The elements and values
// are stored on a stack, so the next call to finishUndoableChange() will
// pop the elements and old values off the stack, gets the current values
// from the DOM and uses all of these to construct the undo-able command.
//
// Parameters:
// attrName - The name of the attribute being changed
// elems - Array of DOM elements being changed
c.beginUndoableChange = function(attrName, elems) {
var p = ++undoChangeStackPointer;
var i = elems.length;
var oldValues = new Array(i), elements = new Array(i);
while (i--) {
var elem = elems[i];
if (elem == null) continue;
elements[i] = elem;
oldValues[i] = elem.getAttribute(attrName);
}
undoableChangeStack[p] = {'attrName': attrName,
'oldValues': oldValues,
'elements': elements};
};
// Function: finishUndoableChange
// This function returns a BatchCommand object which summarizes the
// change since beginUndoableChange was called. The command can then
// be added to the command history
//
// Returns:
// Batch command object with resulting changes
c.finishUndoableChange = function() {
var p = undoChangeStackPointer--;
var changeset = undoableChangeStack[p];
var i = changeset['elements'].length;
var attrName = changeset['attrName'];
var batchCmd = new BatchCommand("Change " + attrName);
while (i--) {
var elem = changeset['elements'][i];
if (elem == null) continue;
var changes = {};
changes[attrName] = changeset['oldValues'][i];
if (changes[attrName] != elem.getAttribute(attrName)) {
batchCmd.addSubCommand(new ChangeElementCommand(elem, changes, attrName));
}
}
undoableChangeStack[p] = null;
return batchCmd;
};
}(canvas));
// Put SelectorManager in this scope
var SelectorManager;
(function() {
// Interface: Selector
// Private class for DOM element selection boxes
//
// Parameters:
// id - integer to internally indentify the selector
// elem - DOM element associated with this selector
function Selector(id, elem) {
// this is the selector's unique number
this.id = id;
// this holds a reference to the element for which this selector is being used
this.selectedElement = elem;
// this is a flag used internally to track whether the selector is being used or not
this.locked = true;
// Function: Selector.reset
// Used to reset the id and element that the selector is attached to
//
// Parameters:
// e - DOM element associated with this selector
this.reset = function(e) {
this.locked = true;
this.selectedElement = e;
this.resize();
this.selectorGroup.setAttribute("display", "inline");
};
// this holds a reference to the element that holds all visual elements of the selector
this.selectorGroup = addSvgElementFromJson({ "element": "g",
"attr": {"id": ("selectorGroup"+this.id)}
});
// this holds a reference to the path rect
this.selectorRect = this.selectorGroup.appendChild( addSvgElementFromJson({
"element": "path",
"attr": {
"id": ("selectedBox"+this.id),
"fill": "none",
"stroke": "#22C",
"stroke-width": "1",
"stroke-dasharray": "5,5",
// need to specify this so that the rect is not selectable
"style": "pointer-events:none"
}
}) );
// this holds a reference to the grip elements for this selector
this.selectorGrips = { "nw":null,
"n":null,
"ne":null,
"e":null,
"se":null,
"s":null,
"sw":null,
"w":null
};
this.rotateGripConnector = this.selectorGroup.appendChild( addSvgElementFromJson({
"element": "line",
"attr": {
"id": ("selectorGrip_rotateconnector_" + this.id),
"stroke": "#22C",
"stroke-width": "1"
}
}) );
this.rotateGrip = this.selectorGroup.appendChild( addSvgElementFromJson({
"element": "circle",
"attr": {
"id": ("selectorGrip_rotate_" + this.id),
"fill": "lime",
"r": 4,
"stroke": "#22C",
"stroke-width": 2,
"style": "cursor:url(" + curConfig.imgPath + "rotate.png) 12 12, auto;"
}
}) );
// add the corner grips
for (var dir in this.selectorGrips) {
this.selectorGrips[dir] = this.selectorGroup.appendChild(
addSvgElementFromJson({
"element": "circle",
"attr": {
"id": ("selectorGrip_resize_" + dir + "_" + this.id),
"fill": "#22C",
"r": 4,
"style": ("cursor:" + dir + "-resize"),
// This expands the mouse-able area of the grips making them
// easier to grab with the mouse.
// This works in Opera and WebKit, but does not work in Firefox
// see https://bugzilla.mozilla.org/show_bug.cgi?id=500174
"stroke-width": 2,
"pointer-events":"all",
"display":"none"
}
}) );
}
// Function: Selector.showGrips
// Show the resize grips of this selector
//
// Parameters:
// show - boolean indicating whether grips should be shown or not
this.showGrips = function(show) {
// TODO: use suspendRedraw() here
var bShow = show ? "inline" : "none";
this.rotateGrip.setAttribute("display", bShow);
this.rotateGripConnector.setAttribute("display", bShow);
var elem = this.selectedElement;
for (var dir in this.selectorGrips) {
this.selectorGrips[dir].setAttribute("display", bShow);
}
if(elem) this.updateGripCursors(getRotationAngle(elem));
};
// Function: Selector.updateGripCursors
// Updates cursors for corner grips on rotation so arrows point the right way
//
// Parameters:
// angle - Float indicating current rotation angle in degrees
this.updateGripCursors = function(angle) {
var dir_arr = [];
var steps = Math.round(angle / 45);
if(steps < 0) steps += 8;
for (var dir in this.selectorGrips) {
dir_arr.push(dir);
}
while(steps > 0) {
dir_arr.push(dir_arr.shift());
steps--;
}
var i = 0;
for (var dir in this.selectorGrips) {
this.selectorGrips[dir].setAttribute('style', ("cursor:" + dir_arr[i] + "-resize"));
i++;
};
};
// Function: Selector.resize
// Updates the selector to match the element's size
this.resize = function() {
var selectedBox = this.selectorRect,
selectedGrips = this.selectorGrips,
selected = this.selectedElement,
sw = selected.getAttribute("stroke-width");
var offset = 1/current_zoom;
if (selected.getAttribute("stroke") != "none" && !isNaN(sw)) {
offset += (sw/2);
}
if (selected.tagName == "text") {
offset += 2/current_zoom;
}
var bbox = getBBox(selected);
if(selected.tagName == 'g' && !$(selected).data('gsvg')) {
// The bbox for a group does not include stroke vals, so we
// get the bbox based on its children.
var stroked_bbox = getStrokedBBox(selected.childNodes);
if(stroked_bbox) {
$.each(bbox, function(key, val) {
bbox[key] = stroked_bbox[key];
});
}
}
// loop and transform our bounding box until we reach our first rotation
var m = getMatrix(selected);
// This should probably be handled somewhere else, but for now
// it keeps the selection box correctly positioned when zoomed
m.e *= current_zoom;
m.f *= current_zoom;
// apply the transforms
var l=bbox.x-offset, t=bbox.y-offset, w=bbox.width+(offset*2), h=bbox.height+(offset*2),
bbox = {x:l, y:t, width:w, height:h};
// we need to handle temporary transforms too
// if skewed, get its transformed box, then find its axis-aligned bbox
//*
var nbox = transformBox(l*current_zoom, t*current_zoom, w*current_zoom, h*current_zoom, m),
nbax = nbox.aabox.x,
nbay = nbox.aabox.y,
nbaw = nbox.aabox.width,
nbah = nbox.aabox.height;
// now if the shape is rotated, un-rotate it
var cx = nbax + nbaw/2,
cy = nbay + nbah/2;
var angle = getRotationAngle(selected);
if (angle) {
var rot = svgroot.createSVGTransform();
rot.setRotate(-angle,cx,cy);
var rotm = rot.matrix;
nbox.tl = transformPoint(nbox.tl.x,nbox.tl.y,rotm);
nbox.tr = transformPoint(nbox.tr.x,nbox.tr.y,rotm);
nbox.bl = transformPoint(nbox.bl.x,nbox.bl.y,rotm);
nbox.br = transformPoint(nbox.br.x,nbox.br.y,rotm);
// calculate the axis-aligned bbox
var minx = nbox.tl.x,
miny = nbox.tl.y,
maxx = nbox.tl.x,
maxy = nbox.tl.y;
minx = Math.min(minx, Math.min(nbox.tr.x, Math.min(nbox.bl.x, nbox.br.x) ) );
miny = Math.min(miny, Math.min(nbox.tr.y, Math.min(nbox.bl.y, nbox.br.y) ) );
maxx = Math.max(maxx, Math.max(nbox.tr.x, Math.max(nbox.bl.x, nbox.br.x) ) );
maxy = Math.max(maxy, Math.max(nbox.tr.y, Math.max(nbox.bl.y, nbox.br.y) ) );
nbax = minx;
nbay = miny;
nbaw = (maxx-minx);
nbah = (maxy-miny);
}
var sr_handle = svgroot.suspendRedraw(100);
var dstr = "M" + nbax + "," + nbay
+ " L" + (nbax+nbaw) + "," + nbay
+ " " + (nbax+nbaw) + "," + (nbay+nbah)
+ " " + nbax + "," + (nbay+nbah) + "z";
assignAttributes(selectedBox, {'d': dstr});
var gripCoords = {
nw: [nbax, nbay],
ne: [nbax+nbaw, nbay],
sw: [nbax, nbay+nbah],
se: [nbax+nbaw, nbay+nbah],
n: [nbax + (nbaw)/2, nbay],
w: [nbax, nbay + (nbah)/2],
e: [nbax + nbaw, nbay + (nbah)/2],
s: [nbax + (nbaw)/2, nbay + nbah]
};
if(selected == selectedElements[0]) {
for(var dir in gripCoords) {
var coords = gripCoords[dir];
assignAttributes(selectedGrips[dir], {
cx: coords[0], cy: coords[1]
});
};
}
if (angle) {
this.selectorGroup.setAttribute("transform", "rotate(" + [angle,cx,cy].join(",") + ")");
}
else {
this.selectorGroup.setAttribute("transform", "");
}
// we want to go 20 pixels in the negative transformed y direction, ignoring scale
assignAttributes(this.rotateGripConnector, { x1: nbax + (nbaw)/2,
y1: nbay,
x2: nbax + (nbaw)/2,
y2: nbay- 20});
assignAttributes(this.rotateGrip, { cx: nbax + (nbaw)/2,
cy: nbay - 20 });
svgroot.unsuspendRedraw(sr_handle);
};
// now initialize the selector
this.reset(elem);
};
// Interface: SelectorManager
// Public class to manage all selector objects (selection boxes)
SelectorManager = function() {
// this will hold the element that contains all selector rects/grips
this.selectorParentGroup = null;
// this is a special rect that is used for multi-select
this.rubberBandBox = null;
// this will hold objects of type Selector (see above)
this.selectors = [];
// this holds a map of SVG elements to their Selector object
this.selectorMap = {};
// local reference to this object
var mgr = this;
// Function: SelectorManager.initGroup
// Resets the parent selector group element
this.initGroup = function() {
// remove old selector parent group if it existed
if (mgr.selectorParentGroup && mgr.selectorParentGroup.parentNode) {
mgr.selectorParentGroup.parentNode.removeChild(mgr.selectorParentGroup);
}
// create parent selector group and add it to svgroot
mgr.selectorParentGroup = svgdoc.createElementNS(svgns, "g");
mgr.selectorParentGroup.setAttribute("id", "selectorParentGroup");
svgroot.appendChild(mgr.selectorParentGroup);
mgr.selectorMap = {};
mgr.selectors = [];
mgr.rubberBandBox = null;
if($("#canvasBackground").length) return;
var canvasbg = svgdoc.createElementNS(svgns, "svg");
var dims = curConfig.dimensions;
assignAttributes(canvasbg, {
'id':'canvasBackground',
'width': dims[0],
'height': dims[1],
'x': 0,
'y': 0,
'overflow': 'visible',
'style': 'pointer-events:none'
});
var rect = svgdoc.createElementNS(svgns, "rect");
assignAttributes(rect, {
'width': '100%',
'height': '100%',
'x': 0,
'y': 0,
'stroke-width': 1,
'stroke': '#000',
'fill': '#FFF',
'style': 'pointer-events:none'
});
// Both Firefox and WebKit are too slow with this filter region (especially at higher
// zoom levels) and Opera has at least one bug
// if (!window.opera) rect.setAttribute('filter', 'url(#canvashadow)');
canvasbg.appendChild(rect);
svgroot.insertBefore(canvasbg, svgcontent);
};
// Function: SelectorManager.requestSelector
// Returns the selector based on the given element
//
// Parameters:
// elem - DOM element to get the selector for
this.requestSelector = function(elem) {
if (elem == null) return null;
var N = this.selectors.length;
// if we've already acquired one for this element, return it
if (typeof(this.selectorMap[elem.id]) == "object") {
this.selectorMap[elem.id].locked = true;
return this.selectorMap[elem.id];
}
for (var i = 0; i < N; ++i) {
if (this.selectors[i] && !this.selectors[i].locked) {
this.selectors[i].locked = true;
this.selectors[i].reset(elem);
this.selectorMap[elem.id] = this.selectors[i];
return this.selectors[i];
}
}
// if we reached here, no available selectors were found, we create one
this.selectors[N] = new Selector(N, elem);
this.selectorParentGroup.appendChild(this.selectors[N].selectorGroup);
this.selectorMap[elem.id] = this.selectors[N];
return this.selectors[N];
};
// Function: SelectorManager.releaseSelector
// Removes the selector of the given element (hides selection box)
//
// Parameters:
// elem - DOM element to remove the selector for
this.releaseSelector = function(elem) {
if (elem == null) return;
var N = this.selectors.length,
sel = this.selectorMap[elem.id];
for (var i = 0; i < N; ++i) {
if (this.selectors[i] && this.selectors[i] == sel) {
if (sel.locked == false) {
console.log("WARNING! selector was released but was already unlocked");
}
delete this.selectorMap[elem.id];
sel.locked = false;
sel.selectedElement = null;
sel.showGrips(false);
// remove from DOM and store reference in JS but only if it exists in the DOM
try {
sel.selectorGroup.setAttribute("display", "none");
} catch(e) { }
break;
}
}
};
// Function: SelectorManager.getRubberBandBox
// Returns the rubberBandBox DOM element. This is the rectangle drawn by the user for selecting/zooming
this.getRubberBandBox = function() {
if (this.rubberBandBox == null) {
this.rubberBandBox = this.selectorParentGroup.appendChild(
addSvgElementFromJson({ "element": "rect",
"attr": {
"id": "selectorRubberBand",
"fill": "#22C",
"fill-opacity": 0.15,
"stroke": "#22C",
"stroke-width": 0.5,
"display": "none",
"style": "pointer-events:none"
}
}));
}
return this.rubberBandBox;
};
this.initGroup();
};
}());
// **************************************************************************************
// SVGTransformList implementation for Webkit
// These methods do not currently raise any exceptions.
// These methods also do not check that transforms are being inserted or handle if
// a transform is already in the list, etc. This is basically implementing as much
// of SVGTransformList that we need to get the job done.
//
// interface SVGEditTransformList {
// attribute unsigned long numberOfItems;
// void clear ( )
// SVGTransform initialize ( in SVGTransform newItem )
// SVGTransform getItem ( in unsigned long index )
// SVGTransform insertItemBefore ( in SVGTransform newItem, in unsigned long index )
// SVGTransform replaceItem ( in SVGTransform newItem, in unsigned long index )
// SVGTransform removeItem ( in unsigned long index )
// SVGTransform appendItem ( in SVGTransform newItem )
// NOT IMPLEMENTED: SVGTransform createSVGTransformFromMatrix ( in SVGMatrix matrix );
// NOT IMPLEMENTED: SVGTransform consolidate ( );
// }
// **************************************************************************************
var svgTransformLists = {};
var SVGEditTransformList = function(elem) {
function transformToString(xform) {
var m = xform.matrix,
text = "";
switch(xform.type) {
case 1: // MATRIX
text = "matrix(" + [m.a,m.b,m.c,m.d,m.e,m.f].join(",") + ")";
break;
case 2: // TRANSLATE
text = "translate(" + m.e + "," + m.f + ")";
break;
case 3: // SCALE
if (m.a == m.d) text = "scale(" + m.a + ")";
else text = "scale(" + m.a + "," + m.d + ")";
break;
case 4: // ROTATE
var cx = 0, cy = 0;
// this prevents divide by zero
if (xform.angle != 0) {
var K = 1 - m.a;
cy = ( K * m.f + m.b*m.e ) / ( K*K + m.b*m.b );
cx = ( m.e - m.b * cy ) / K;
}
text = "rotate(" + xform.angle + " " + cx + "," + cy + ")";
break;
}
return text;
};
this._elem = elem || null;
this._xforms = [];
// TODO: how do we capture the undo-ability in the changed transform list?
this._update = function() {
var tstr = "";
var concatMatrix = svgroot.createSVGMatrix();
for (var i = 0; i < this.numberOfItems; ++i) {
var xform = this._list.getItem(i);
tstr += transformToString(xform) + " ";
}
this._elem.setAttribute("transform", tstr);
};
this._list = this;
this._init = function() {
// Transform attribute parser
var str = this._elem.getAttribute("transform");
if(!str) return;
// TODO: Add skew support in future
var re = /\s*((scale|matrix|rotate|translate)\s*\(.*?\))\s*,?\s*/;
var arr = [];
var m = true;
while(m) {
m = str.match(re);
str = str.replace(re,'');
if(m && m[1]) {
var x = m[1];
var bits = x.split(/\s*\(/);
var name = bits[0];
var val_bits = bits[1].match(/\s*(.*?)\s*\)/);
var val_arr = val_bits[1].split(/[, ]+/);
var letters = 'abcdef'.split('');
var mtx = svgroot.createSVGMatrix();
$.each(val_arr, function(i, item) {
val_arr[i] = parseFloat(item);
if(name == 'matrix') {
mtx[letters[i]] = val_arr[i];
}
});
var xform = svgroot.createSVGTransform();
var fname = 'set' + name.charAt(0).toUpperCase() + name.slice(1);
var values = name=='matrix'?[mtx]:val_arr;
if(name == 'scale' && values.length == 1) {
values.push(values[0]);
} else if(name == 'translate' && values.length == 1) {
values.push(0);
}
xform[fname].apply(xform, values);
this._list.appendItem(xform);
}
}
}
this.numberOfItems = 0;
this.clear = function() {
this.numberOfItems = 0;
this._xforms = [];
};
this.initialize = function(newItem) {
this.numberOfItems = 1;
this._xforms = [newItem];
};
this.getItem = function(index) {
if (index < this.numberOfItems && index >= 0) {
return this._xforms[index];
}
return null;
};
this.insertItemBefore = function(newItem, index) {
var retValue = null;
if (index >= 0) {
if (index < this.numberOfItems) {
var newxforms = new Array(this.numberOfItems + 1);
// TODO: use array copying and slicing
for ( var i = 0; i < index; ++i) {
newxforms[i] = this._xforms[i];
}
newxforms[i] = newItem;
for ( var j = i+1; i < this.numberOfItems; ++j, ++i) {
newxforms[j] = this._xforms[i];
}
this.numberOfItems++;
this._xforms = newxforms;
retValue = newItem;
this._list._update();
}
else {
retValue = this._list.appendItem(newItem);
}
}
return retValue;
};
this.replaceItem = function(newItem, index) {
var retValue = null;
if (index < this.numberOfItems && index >= 0) {
this._xforms[index] = newItem;
retValue = newItem;
this._list._update();
}
return retValue;
};
this.removeItem = function(index) {
var retValue = null;
if (index < this.numberOfItems && index >= 0) {
var retValue = this._xforms[index];
var newxforms = new Array(this.numberOfItems - 1);
for (var i = 0; i < index; ++i) {
newxforms[i] = this._xforms[i];
}
for (var j = i; j < this.numberOfItems-1; ++j, ++i) {
newxforms[j] = this._xforms[i+1];
}
this.numberOfItems--;
this._xforms = newxforms;
this._list._update();
}
return retValue;
};
this.appendItem = function(newItem) {
this._xforms.push(newItem);
this.numberOfItems++;
this._list._update();
return newItem;
};
};
// **************************************************************************************
// Group: Helper functions
// Function: walkTree
// Walks the tree and executes the callback on each element in a top-down fashion
//
// Parameters:
// elem - DOM element to traverse
// cbFn - Callback function to run on each element
function walkTree(elem, cbFn){
if (elem && elem.nodeType == 1) {
cbFn(elem);
var i = elem.childNodes.length;
while (i--) {
walkTree(elem.childNodes.item(i), cbFn);
}
}
};
// Function: walkTreePost
// Walks the tree and executes the callback on each element in a depth-first fashion
//
// Parameters:
// elem - DOM element to traverse
// cbFn - Callback function to run on each element
function walkTreePost(elem, cbFn) {
if (elem && elem.nodeType == 1) {
var i = elem.childNodes.length;
while (i--) {
walkTree(elem.childNodes.item(i), cbFn);
}
cbFn(elem);
}
};
// Function: assignAttributes
// Assigns multiple attributes to an element.
//
// Parameters:
// node - DOM element to apply new attribute values to
// attrs - Object with attribute keys/values
// suspendLength - Optional integer of milliseconds to suspend redraw
// unitCheck - Boolean to indicate the need to use setUnitAttr
var assignAttributes = this.assignAttributes = function(node, attrs, suspendLength, unitCheck) {
if(!suspendLength) suspendLength = 0;
// Opera has a problem with suspendRedraw() apparently
var handle = null;
if (!window.opera) svgroot.suspendRedraw(suspendLength);
for (var i in attrs) {
var ns = (i.substr(0,4) == "xml:" ? xmlns :
i.substr(0,6) == "xlink:" ? xlinkns : null);
if(ns || !unitCheck) {
node.setAttributeNS(ns, i, attrs[i]);
} else {
setUnitAttr(node, i, attrs[i]);
}
}
if (!window.opera) svgroot.unsuspendRedraw(handle);
};
// Function: cleanupElement
// Remove unneeded (default) attributes, makes resulting SVG smaller
//
// Parameters:
// element - DOM element to clean up
var cleanupElement = this.cleanupElement = function(element) {
var handle = svgroot.suspendRedraw(60);
var defaults = {
'fill-opacity':1,
'stop-opacity':1,
'opacity':1,
'stroke':'none',
'stroke-dasharray':'none',
'stroke-linejoin':'miter',
'stroke-linecap':'butt',
'stroke-opacity':1,
'stroke-width':1,
'rx':0,
'ry':0
}
for(var attr in defaults) {
var val = defaults[attr];
if(element.getAttribute(attr) == val) {
element.removeAttribute(attr);
}
}
svgroot.unsuspendRedraw(handle);
};
// Function: addSvgElementFromJson
// Create a new SVG element based on the given object keys/values and add it to the current layer
// The element will be ran through cleanupElement before being returned
//
// Parameters:
// data - Object with the following keys/values:
// * element - DOM element to create
// * attr - Object with attributes/values to assign to the new element
// * curStyles - Boolean indicating that current style attributes should be applied first
//
// Returns: The new element
var addSvgElementFromJson = this.addSvgElementFromJson = function(data) {
var shape = getElem(data.attr.id);
// if shape is a path but we need to create a rect/ellipse, then remove the path
if (shape && data.element != shape.tagName) {
current_layer.removeChild(shape);
shape = null;
}
if (!shape) {
shape = svgdoc.createElementNS(svgns, data.element);
if (current_layer) {
current_layer.appendChild(shape);
}
}
if(data.curStyles) {
assignAttributes(shape, {
"fill": cur_shape.fill,
"stroke": cur_shape.stroke,
"stroke-width": cur_shape.stroke_width,
"stroke-dasharray": cur_shape.stroke_dasharray,
"stroke-linejoin": cur_shape.stroke_linejoin,
"stroke-linecap": cur_shape.stroke_linecap,
"stroke-opacity": cur_shape.stroke_opacity,
"fill-opacity": cur_shape.fill_opacity,
"opacity": cur_shape.opacity / 2,
"style": "pointer-events:inherit"
}, 100);
}
assignAttributes(shape, data.attr, 100);
cleanupElement(shape);
return shape;
};
(function() {
// TODO: make this string optional and set by the client
var comment = svgdoc.createComment(" Created with SVG-edit - http://svg-edit.googlecode.com/ ");
svgcontent.appendChild(comment);
// TODO For Issue 208: this is a start on a thumbnail
// var svgthumb = svgdoc.createElementNS(svgns, "use");
// svgthumb.setAttribute('width', '100');
// svgthumb.setAttribute('height', '100');
// setHref(svgthumb, '#svgcontent');
// svgroot.appendChild(svgthumb);
})();
// z-ordered array of tuples containing layer names and elements
// the first layer is the one at the bottom of the rendering
var all_layers = [],
// Object to contain image data for raster images that were found encodable
encodableImages = {},
// String with image URL of last loadable image
last_good_img_url = curConfig.imgPath + 'logo.png',
// pointer to the current layer
current_layer = null,
// Object with save options
save_options = {round_digits: 5},
// Boolean indicating whether or not a draw action has been started
started = false,
// Integer with internal ID number for the latest element
obj_num = 1,
// String with an element's initial transform attribute value
start_transform = null,
// String indicating the current editor mode
current_mode = "select",
// String with the current direction in which an element is being resized
current_resize_mode = "none",
// Object containing data for the currently selected styles
all_properties = {
shape: {
fill: "#" + curConfig.initFill.color,
fill_paint: null,
fill_opacity: curConfig.initFill.opacity,
stroke: "#" + curConfig.initStroke.color,
stroke_paint: null,
stroke_opacity: curConfig.initStroke.opacity,
stroke_width: curConfig.initStroke.width,
stroke_dasharray: 'none',
stroke_linejoin: 'miter',
stroke_linecap: 'butt',
opacity: curConfig.initOpacity
}
};
all_properties.text = $.extend(true, {}, all_properties.shape);
$.extend(all_properties.text, {
fill: "#000000",
stroke_width: 0,
font_size: 24,
font_family: 'serif'
});
// Current shape style properties
var cur_shape = all_properties.shape,
// Current text style properties
cur_text = all_properties.text,
// Current general properties
cur_properties = cur_shape,
// Float displaying the current zoom level (1 = 100%, .5 = 50%, etc)
current_zoom = 1,
// Array with all the currently selected elements
// default size of 1 until it needs to grow bigger
selectedElements = new Array(1),
// Array with selected elements' Bounding box object
selectedBBoxes = new Array(1),
// The DOM element that was just selected
justSelected = null,
// this object manages selectors for us
selectorManager = this.selectorManager = new SelectorManager(),
// DOM element for selection rectangle drawn by the user
rubberBox = null,
// Array of current BBoxes (still needed?)
curBBoxes = [],
// Object to contain all included extensions
extensions = {},
// Canvas point for the most recent right click
lastClickPoint = null;
// Clipboard for cut, copy&pasted elements
canvas.clipBoard = [];
// Should this return an array by default, so extension results aren't overwritten?
var runExtensions = this.runExtensions = function(action, vars, returnArray) {
var result = false;
if(returnArray) result = [];
$.each(extensions, function(name, opts) {
if(action in opts) {
if(returnArray) {
result.push(opts[action](vars))
} else {
result = opts[action](vars);
}
}
});
return result;
}
// Function: addExtension
// Add an extension to the editor
//
// Parameters:
// name - String with the ID of the extension
// ext_func - Function supplied by the extension with its data
this.addExtension = function(name, ext_func) {
if(!(name in extensions)) {
// Provide private vars/funcs here. Is there a better way to do this?
if($.isFunction(ext_func)) {
var ext = ext_func($.extend(canvas.getPrivateMethods(), {
svgroot: svgroot,
svgcontent: svgcontent,
nonce: nonce,
selectorManager: selectorManager
}));
} else {
var ext = ext_func;
}
extensions[name] = ext;
call("extension_added", ext);
} else {
console.log('Cannot add extension "' + name + '", an extension by that name already exists"');
}
};
// Function: shortFloat
// Rounds a given value to a float with number of digits defined in save_options
//
// Parameters:
// val - The value as a String, Number or Array of two numbers to be rounded
//
// Returns:
// If a string/number was given, returns a Float. If an array, return a string
// with comma-seperated floats
var shortFloat = function(val) {
var digits = save_options.round_digits;
if(!isNaN(val)) {
return Number(Number(val).toFixed(digits));
} else if($.isArray(val)) {
return shortFloat(val[0]) + ',' + shortFloat(val[1]);
}
}
// This method rounds the incoming value to the nearest value based on the current_zoom
var round = this.round = function(val) {
return parseInt(val*current_zoom)/current_zoom;
};
// This method sends back an array or a NodeList full of elements that
// intersect the multi-select rubber-band-box on the current_layer only.
//
// Since the only browser that supports the SVG DOM getIntersectionList is Opera,
// we need to provide an implementation here. We brute-force it for now.
//
// Reference:
// Firefox does not implement getIntersectionList(), see https://bugzilla.mozilla.org/show_bug.cgi?id=501421
// Webkit does not implement getIntersectionList(), see https://bugs.webkit.org/show_bug.cgi?id=11274
var getIntersectionList = this.getIntersectionList = function(rect) {
if (rubberBox == null) { return null; }
if(!curBBoxes.length) {
// Cache all bboxes
curBBoxes = getVisibleElements(current_layer, true);
}
var resultList = null;
try {
resultList = current_layer.getIntersectionList(rect, null);
} catch(e) { }
if (resultList == null || typeof(resultList.item) != "function") {
resultList = [];
var rubberBBox = rubberBox.getBBox();
$.each(rubberBBox, function(key, val) {
rubberBBox[key] = val / current_zoom;
});
var i = curBBoxes.length;
while (i--) {
if(!rubberBBox.width || !rubberBBox.width) continue;
if (Utils.rectsIntersect(rubberBBox, curBBoxes[i].bbox)) {
resultList.push(curBBoxes[i].elem);
}
}
}
// addToSelection expects an array, but it's ok to pass a NodeList
// because using square-bracket notation is allowed:
// http://www.w3.org/TR/DOM-Level-2-Core/ecma-script-binding.html
return resultList;
};
// Function: getStrokedBBox
// Get the bounding box for one or more stroked and/or transformed elements
//
// Parameters:
// elems - Array with DOM elements to check
//
// Returns:
// A single bounding box object
var getStrokedBBox = this.getStrokedBBox = function(elems) {
if(!elems) elems = getVisibleElements();
if(!elems.length) return false;
// Make sure the expected BBox is returned if the element is a group
var getCheckedBBox = function(elem) {
try {
// TODO: Fix issue with rotated groups. Currently they work
// fine in FF, but not in other browsers (same problem mentioned
// in Issue 339 comment #2).
var bb = getBBox(elem);
var angle = getRotationAngle(elem);
if ((angle && angle % 90) || hasMatrixTransform(getTransformList(elem))) {
// Accurate way to get BBox of rotated element in Firefox:
// Put element in group and get its BBox
var good_bb = false;
// Get the BBox from the raw path for these elements
var elemNames = ['ellipse','path','line','polyline','polygon'];
if($.inArray(elem.tagName, elemNames) != -1) {
bb = good_bb = canvas.convertToPath(elem, true);
} else if(elem.tagName == 'rect') {
// Look for radius
var rx = elem.getAttribute('rx');
var ry = elem.getAttribute('ry');
if(rx || ry) {
bb = good_bb = canvas.convertToPath(elem, true);
}
}
if(!good_bb) {
var g = document.createElementNS(svgns, "g");
var parent = elem.parentNode;
parent.replaceChild(g, elem);
g.appendChild(elem);
bb = g.getBBox();
parent.insertBefore(elem,g);
parent.removeChild(g);
}
// Old method: Works by giving the rotated BBox,
// this is (unfortunately) what Opera and Safari do
// natively when getting the BBox of the parent group
// var angle = angle * Math.PI / 180.0;
// var rminx = Number.MAX_VALUE, rminy = Number.MAX_VALUE,
// rmaxx = Number.MIN_VALUE, rmaxy = Number.MIN_VALUE;
// var cx = round(bb.x + bb.width/2),
// cy = round(bb.y + bb.height/2);
// var pts = [ [bb.x - cx, bb.y - cy],
// [bb.x + bb.width - cx, bb.y - cy],
// [bb.x + bb.width - cx, bb.y + bb.height - cy],
// [bb.x - cx, bb.y + bb.height - cy] ];
// var j = 4;
// while (j--) {
// var x = pts[j][0],
// y = pts[j][1],
// r = Math.sqrt( x*x + y*y );
// var theta = Math.atan2(y,x) + angle;
// x = round(r * Math.cos(theta) + cx);
// y = round(r * Math.sin(theta) + cy);
//
// // now set the bbox for the shape after it's been rotated
// if (x < rminx) rminx = x;
// if (y < rminy) rminy = y;
// if (x > rmaxx) rmaxx = x;
// if (y > rmaxy) rmaxy = y;
// }
//
// bb.x = rminx;
// bb.y = rminy;
// bb.width = rmaxx - rminx;
// bb.height = rmaxy - rminy;
}
return bb;
} catch(e) {
console.log(elem, e);
return null;
}
}
var full_bb;
$.each(elems, function() {
if(full_bb) return;
if(!this.parentNode) return;
full_bb = getCheckedBBox(this);
});
// This shouldn't ever happen...
if(full_bb == null) return null;
// full_bb doesn't include the stoke, so this does no good!
// if(elems.length == 1) return full_bb;
var max_x = full_bb.x + full_bb.width;
var max_y = full_bb.y + full_bb.height;
var min_x = full_bb.x;
var min_y = full_bb.y;
// FIXME: same re-creation problem with this function as getCheckedBBox() above
var getOffset = function(elem) {
var sw = elem.getAttribute("stroke-width");
var offset = 0;
if (elem.getAttribute("stroke") != "none" && !isNaN(sw)) {
offset += sw/2;
}
return offset;
}
var bboxes = [];
$.each(elems, function(i, elem) {
var cur_bb = getCheckedBBox(elem);
if(cur_bb) {
var offset = getOffset(elem);
min_x = Math.min(min_x, cur_bb.x - offset);
min_y = Math.min(min_y, cur_bb.y - offset);
bboxes.push(cur_bb);
}
});
full_bb.x = min_x;
full_bb.y = min_y;
$.each(elems, function(i, elem) {
var cur_bb = bboxes[i];
// ensure that elem is really an element node
if (cur_bb && elem.nodeType == 1) {
var offset = getOffset(elem);
max_x = Math.max(max_x, cur_bb.x + cur_bb.width + offset);
max_y = Math.max(max_y, cur_bb.y + cur_bb.height + offset);
}
});
full_bb.width = max_x - min_x;
full_bb.height = max_y - min_y;
return full_bb;
}
// Function: getVisibleElements
// Get all elements that have a BBox (excludes , , etc).
// Note that 0-opacity, off-screen etc elements are still considered "visible"
// for this function
//
// Parameters:
// parent - The parent DOM element to search within
// includeBBox - Boolean to indicate that an object should return with the element and its bbox
//
// Returns:
// An array with all "visible" elements, or if includeBBox is true, an array with
// objects that include:
// * elem - The element
// * bbox - The element's BBox as retrieved from getStrokedBBox
var getVisibleElements = this.getVisibleElements = function(parent, includeBBox) {
if(!parent) parent = $(svgcontent).children(); // Prevent layers from being included
var contentElems = [];
$(parent).children().each(function(i, elem) {
try {
var box = elem.getBBox();
if (box) {
var item = includeBBox?{'elem':elem, 'bbox':getStrokedBBox([elem])}:elem;
contentElems.push(item);
}
} catch(e) {}
});
return contentElems.reverse();
}
// Function: groupSvgElem
// Wrap an SVG element into a group element, mark the group as 'gsvg'
//
// Parameters:
// elem - SVG element to wrap
var groupSvgElem = this.groupSvgElem = function(elem) {
var g = document.createElementNS(svgns, "g");
elem.parentNode.replaceChild(g, elem);
$(g).append(elem).data('gsvg', elem)[0].id = getNextId();
}
// Function: copyElem
// Create a clone of an element, updating its ID and its children's IDs when needed
//
// Parameters:
// el - DOM element to clone
//
// Returns: The cloned element
var copyElem = function(el) {
// manually create a copy of the element
var new_el = document.createElementNS(el.namespaceURI, el.nodeName);
$.each(el.attributes, function(i, attr) {
if (attr.localName != '-moz-math-font-style') {
new_el.setAttributeNS(attr.namespaceURI, attr.nodeName, attr.nodeValue);
}
});
// set the copied element's new id
new_el.removeAttribute("id");
new_el.id = getNextId();
// manually increment obj_num because our cloned elements are not in the DOM yet
obj_num++;
// Opera's "d" value needs to be reset for Opera/Win/non-EN
// Also needed for webkit (else does not keep curved segments on clone)
if((isWebkit) && el.nodeName == 'path') {
var fixed_d = pathActions.convertPath(el);
new_el.setAttribute('d', fixed_d);
}
// now create copies of all children
$.each(el.childNodes, function(i, child) {
switch(child.nodeType) {
case 1: // element node
new_el.appendChild(copyElem(child));
break;
case 3: // text node
new_el.textContent = child.nodeValue;
break;
default:
break;
}
});
if($(el).data('gsvg')) {
$(new_el).data('gsvg', new_el.firstChild);
} else if($(el).data('symbol')) {
var ref = $(el).data('symbol');
$(new_el).data('ref', ref).data('symbol', ref);
}
else if(new_el.tagName == 'image') {
preventClickDefault(new_el);
}
return new_el;
};
// Function: getElem
// Get a DOM element by ID within the SVG root element.
//
// Parameters:
// id - String with the element's new ID
function getElem(id) {
// if(svgroot.getElementById) {
// // getElementById lookup
// return svgroot.getElementById(id);
// } else
if(svgroot.querySelector) {
// querySelector lookup
return svgroot.querySelector('#'+id);
} else if(svgdoc.evaluate) {
// xpath lookup
return svgdoc.evaluate('svg:svg[@id="svgroot"]//svg:*[@id="'+id+'"]', container, function() { return "http://www.w3.org/2000/svg"; }, 9, null).singleNodeValue;
} else {
// jQuery lookup: twice as slow as xpath in FF
return $(svgroot).find('[id=' + id + ']')[0];
}
// getElementById lookup: includes icons, not good
// return svgdoc.getElementById(id);
}
// Set scope for these functions
var getId, getNextId;
(function(c) {
// Object to contain editor event names and callback functions
var events = {};
// Prefix string for element IDs
var idprefix = "svg_";
// Function: getId
// Returns the last created DOM element ID string
getId = c.getId = function() {
if (events["getid"]) return call("getid", obj_num);
if (randomize_ids) {
return idprefix + nonce +'_' + obj_num;
} else {
return idprefix + obj_num;
}
};
// Function: getNextId
// Creates and returns a unique ID string for a DOM element
getNextId = c.getNextId = function() {
// ensure the ID does not exist
var id = getId();
while (getElem(id)) {
obj_num++;
id = getId();
}
return id;
};
// Run the callback function associated with the given event
//
// Parameters:
// event - String with the event name
// arg - Argument to pass through to the callback function
call = c.call = function(event, arg) {
if (events[event]) {
return events[event](this,arg);
}
};
// Function: bind
// Attaches a callback function to an event
//
// Parameters:
// event - String indicating the name of the event
// f - The callback function to bind to the event
//
// Return:
// The previous event
c.bind = function(event, f) {
var old = events[event];
events[event] = f;
return old;
};
// Function: setIdPrefix
// Changes the ID prefix to the given value
//
// Parameters:
// p - String with the new prefix
c.setIdPrefix = function(p) {
idprefix = p;
};
}(canvas));
// Function: sanitizeSvg
// Sanitizes the input node and its children
// It only keeps what is allowed from our whitelist defined above
//
// Parameters:
// node - The DOM element to be checked, will also check its children
var sanitizeSvg = this.sanitizeSvg = function(node) {
// we only care about element nodes
// automatically return for all comment, etc nodes
// for text, we do a whitespace trim
if (node.nodeType == 3) {
node.nodeValue = node.nodeValue.replace(/^\s+|\s+$/g, "");
// Remove empty text nodes
if(!node.nodeValue.length) node.parentNode.removeChild(node);
}
if (node.nodeType != 1) return;
var doc = node.ownerDocument;
var parent = node.parentNode;
// can parent ever be null here? I think the root node's parent is the document...
if (!doc || !parent) return;
var allowedAttrs = svgWhiteList[node.nodeName];
var allowedAttrsNS = svgWhiteListNS[node.nodeName];
// if this element is allowed
if (allowedAttrs != undefined) {
var se_attrs = [];
var i = node.attributes.length;
while (i--) {
// if the attribute is not in our whitelist, then remove it
// could use jQuery's inArray(), but I don't know if that's any better
var attr = node.attributes.item(i);
var attrName = attr.nodeName;
var attrLocalName = attr.localName;
var attrNsURI = attr.namespaceURI;
// Check that an attribute with the correct localName in the correct namespace is on
// our whitelist or is a namespace declaration for one of our allowed namespaces
if (!(allowedAttrsNS.hasOwnProperty(attrLocalName) && attrNsURI == allowedAttrsNS[attrLocalName] && attrNsURI != xmlnsns) &&
!(attrNsURI == xmlnsns && nsMap[attr.nodeValue]) )
{
// Bypassing the whitelist to allow se: prefixes. Is there
// a more appropriate way to do this?
if(attrName.indexOf('se:') == 0) {
se_attrs.push([attrName, attr.nodeValue]);
}
node.removeAttributeNS(attrNsURI, attrLocalName);
}
// special handling for path d attribute
if (node.nodeName == 'path' && attrName == 'd') {
// Convert to absolute
node.setAttribute('d',pathActions.convertPath(node));
pathActions.fixEnd(node);
}
// for the style attribute, rewrite it in terms of XML presentational attributes
if (attrName == "style") {
var props = attr.nodeValue.split(";"),
p = props.length;
while(p--) {
var nv = props[p].split(":");
// now check that this attribute is supported
if (allowedAttrs.indexOf(nv[0]) != -1) {
node.setAttribute(nv[0],nv[1]);
}
}
node.removeAttribute('style');
}
}
$.each(se_attrs, function(i, attr) {
node.setAttributeNS(se_ns, attr[0], attr[1]);
});
// for some elements that have a xlink:href, ensure the URI refers to a local element
// (but not for links)
var href = getHref(node);
if(href &&
$.inArray(node.nodeName, ["filter", "linearGradient", "pattern",
"radialGradient", "textPath", "use"]) != -1)
{
// TODO: we simply check if the first character is a #, is this bullet-proof?
if (href[0] != "#") {
// remove the attribute (but keep the element)
setHref(node, "");
node.removeAttributeNS(xlinkns, "href");
}
}
// Safari crashes on a