diff --git a/README.md b/README.md index 4ede3df..26bacb9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ svg-pan-zoom library Simple pan/zoom solution for SVGs in HTML. It adds events listeners for mouse scroll, double-click and pan, plus it optionally offers: * JavaScript API for control of pan and zoom behavior -* onPan and onZoom event handlers +* Pan, Zoom, Render and other events * On-screen zoom controls It works cross-browser and supports both inline SVGs and SVGs in HTML 'object' or 'embed' elements. @@ -106,10 +106,6 @@ svgPanZoom('#demo-tiger', { , contain: false , center: true , refreshRate: 'auto' -, beforeZoom: function(){} -, onZoom: function(){} -, beforePan: function(){} -, onPan: function(){} , customEventsHandler: {} , eventsListenerElement: null }); @@ -130,34 +126,9 @@ If any arguments are specified, they must have the following value types: * 'contain' must be true or false. Default is false. * 'center' must be true or false. Default is true. * 'refreshRate' must be a number or 'auto' -* 'beforeZoom' must be a callback function to be called before zoom changes. -* 'onZoom' must be a callback function to be called when zoom changes. -* 'beforePan' must be a callback function to be called before pan changes. -* 'onPan' must be a callback function to be called when pan changes. * 'customEventsHandler' must be an object with `init` and `destroy` arguments as functions. * 'eventsListenerElement' must be an SVGElement or null. -`beforeZoom` will be called with 2 float attributes: oldZoom and newZoom. -If `beforeZoom` will return `false` then zooming will be halted. - -`onZoom` callbacks will be called with one float attribute representing new zoom scale. - -`beforePan` will be called with 2 attributes: -* `oldPan` -* `newPan` - -Each of this objects has two attributes (x and y) representing current pan (on X and Y axes). - -If `beforePan` will return `false` or an object `{x: true, y: true}` then panning will be halted. -If you want to prevent panning only on one axis then return an object of type `{x: true, y: false}`. -You can alter panning on X and Y axes by providing alternative values through return `{x: 10, y: 20}`. - -> *Caution!* If you alter panning by returning custom values `{x: 10, y: 20}` it will update only current pan step. If panning is done by mouse/touch you have to take in account that next pan step (after the one that you altered) will be performed with values that do not consider altered values (as they even did not existed). - -`onPan` callback will be called with one attribute: `newPan`. - -> *Caution!* Calling zoom or pan API methods form inside of `beforeZoom`, `onZoom`, `beforePan` and `onPan` callbacks may lead to infinite loop. - `panEnabled` and `zoomEnabled` are related only to user interaction. If any of this options are disabled - you still can zoom and pan via API. `fit` takes preceeding to `contain`. So if you set `fit: true` then `contain`'s value doesn't matter. @@ -296,8 +267,6 @@ Keep content visible/Limit pan You may want to keep SVG content visible by not allowing panning over SVG borders. -To do so you may prevent or alter panning from `beforePan` callback. For more details take a look at `demo/limit-pan.html` example. - Public API ---------- @@ -308,8 +277,6 @@ When you call `svgPanZoom` method it returns an object with following methods: * pan * panBy * getPan -* setBeforePan -* setOnPan * enableZoom * disableZoom * isZoomEnabled @@ -325,8 +292,6 @@ When you call `svgPanZoom` method it returns an object with following methods: * setZoomScaleSensitivity * setMinZoom * setMaxZoom -* setBeforeZoom -* setOnZoom * zoom * zoomBy * zoomAtPoint diff --git a/src/shadow-viewport.js b/src/shadow-viewport.js index 9e26b91..1e2a039 100644 --- a/src/shadow-viewport.js +++ b/src/shadow-viewport.js @@ -216,6 +216,28 @@ ShadowViewport.prototype.getCTM = function() { * @param {SVGMatrix} newCTM */ ShadowViewport.prototype.setCTM = function(newCTM) { + if (this.isZoomDifferent(newCTM) || this.isPanDifferent(newCTM)) { + // Before panzoom + this.options.trigger('before:panzoom', { + zoom: this.computeRelativeZoom(newCTM.a) + , x: newCTM.e + , y: newCTM.f + }) + + // Update + this.updateCache(newCTM) + this.updateCTMOnNextFrame() + + // After panzoom + this.options.trigger('panzoom', { + zoom: this.getRelativeZoom() + , x: this.activeState.x + , y: this.activeState.y + }) + } +} + +ShadowViewport.prototype.setCTM_ = function(newCTM) { var willZoom = this.isZoomDifferent(newCTM) , willPan = this.isPanDifferent(newCTM) @@ -323,11 +345,15 @@ ShadowViewport.prototype.updateCTMOnNextFrame = function() { * Update viewport CTM with cached CTM */ ShadowViewport.prototype.updateCTM = function() { + this.options.trigger('before:render') + // Updates SVG element SvgUtils.setCTM(this.viewport, this.getCTM(), this.defs) // Free the lock this.pendingUpdate = false + + this.options.trigger('render') } module.exports = function(viewport, options){ diff --git a/src/svg-pan-zoom.js b/src/svg-pan-zoom.js index 5fc3f6b..e59f6a4 100644 --- a/src/svg-pan-zoom.js +++ b/src/svg-pan-zoom.js @@ -23,12 +23,9 @@ var optionsDefaults = { , contain: false // enable or disable viewport contain the svg (default false) , center: true // enable or disable viewport centering in SVG (default true) , refreshRate: 'auto' // Maximum number of frames per second (altering SVG's viewport) -, beforeZoom: null -, onZoom: null -, beforePan: null -, onPan: null , customEventsHandler: null , eventsListenerElement: null +, plugins: null } SvgPanZoom.prototype.init = function(svg, options) { @@ -43,6 +40,12 @@ SvgPanZoom.prototype.init = function(svg, options) { // Set options this.options = Utils.extend(Utils.extend({}, optionsDefaults), options) + // Init events + this.initEvents() + + // Init plugins + this.initPlugins() + // Set default state this.state = 'none' @@ -60,28 +63,9 @@ SvgPanZoom.prototype.init = function(svg, options) { , contain: this.options.contain , center: this.options.center , refreshRate: this.options.refreshRate - // Put callbacks into functions as they can change through time - , beforeZoom: function(oldScale, newScale) { - if (that.viewport && that.options.beforeZoom) {return that.options.beforeZoom(oldScale, newScale)} - } - , onZoom: function(scale) { - if (that.viewport && that.options.onZoom) {return that.options.onZoom(scale)} - } - , beforePan: function(oldPoint, newPoint) { - if (that.viewport && that.options.beforePan) {return that.options.beforePan(oldPoint, newPoint)} - } - , onPan: function(point) { - if (that.viewport && that.options.onPan) {return that.options.onPan(point)} - } + , trigger: Utils.proxy(this.trigger, this) }) - // Wrap callbacks into public API context - var publicInstance = this.getPublicInstance() - publicInstance.setBeforeZoom(this.options.beforeZoom) - publicInstance.setOnZoom(this.options.onZoom) - publicInstance.setBeforePan(this.options.beforePan) - publicInstance.setOnPan(this.options.onPan) - if (this.options.controlIconsEnabled) { ControlIcons.enable(this) } @@ -91,6 +75,152 @@ SvgPanZoom.prototype.init = function(svg, options) { this.setupHandlers() } +/** + * Init events and middlewares + */ +SvgPanZoom.prototype.initEvents = function() { + this.events = {} + this.middlewares = {} +} + +/** + * Add an event listener + * + * @param {String} name Events name + * @param {Function} fn Event callback + * @param {Object} [ctx] Callback context + * @param {String} [pluginName] Plugin name + */ +SvgPanZoom.prototype.on = function(name, fn, ctx, pluginName) { + // Create events list if it doesn't exist + if (!(name in this.events)) { + this.events[name] = [] + } + + this.events[name].push({ + fn: fn + , ctx: ctx + , pluginName: pluginName + }) +} + +/** + * Remove event listener + * Specifying only the name will remove all event listeners + * Users and plugins can only remove events defined by them + * + * @param {String} name Event name + * @param {Function} [fn] Event callback + * @param {Oblect} [ctx] Callback context + * @param {String} [pluginName] Plugin name + */ +SvgPanZoom.prototype.off = function(name, fn, ctx, pluginName) { + var i, sameFn, sameCtx, samePlugin + + if (name in this.events) { + for (i = this.events[name].length - 1; i >= 0; i--) { + sameFn = fn == null || fn === this.events[name][i].fn + sameCtx = ctx == null || ctx === this.events[name][i].ctx + samePlugin = pluginName == null || pluginName === this.events[name][i].pluginName + + if (sameFn && sameCtx && samePlugin) { + this.events[name].splice(i, 1) + } + } + } +} + +/** + * Trigger an event. All the arguments except first will be passed to event listeners + * + * @param {String} name Event name. Plugins should namespace their events as pluginName:eventName + */ +SvgPanZoom.prototype.trigger = function(name) { + // console.log('event:', name) + var arg = Array.prototype.slice.call(arguments, 1) + + if (name in this.events) { + for (var i = 0; i < this.events[name].length; i++) { + this.events[name][i].fn.apply(this.events[name][i].ctx, arg) + } + } +} + +SvgPanZoom.prototype.use = function(name, fn, ctx, pluginName) { + +} + +SvgPanZoom.prototype.unuse = function(name, fn, ctx, pluginName) { + +} + +SvgPanZoom.prototype.through = function(name, evt, cb) { + +} + +/** + * Init plugins + */ +SvgPanZoom.prototype.initPlugins = function() { + this.plugins = [] + + if (Utils.isArray(this.options.plugins)) { + for (var i = 0; i < this.options.plugins.length; i++) { + this.addPlugin(this.options.plugins[i]) + } + } else { + // Load all plugins + for (var key in this.pluginsStore) { + this.addPlugin(key) + } + } +} + +/** + * Add plugin + * + * @param {String} name Plugin name + */ +SvgPanZoom.prototype.addPlugin = function(name) { + if (name in this.pluginsStore) { + var pluginApi = this.getPluginApi(name) + + this.plugins.push({ + name: name + , plugin: this.pluginsStore[name](pluginApi) + , api: pluginApi + }) + } else { + throw new Error('Following plugin is not available: ' + name) + } +} + +/** + * Remove all plugins with a given name + * + * @param {String} name Plugin name + */ +SvgPanZoom.prototype.removePlugin = function(name) { + var i, event + + this.trigger('before:plugin:remove', name) + + // Remove all events of this plugin + for (event in this.events) { + this.off(event, null, null, name) + } + + // TODO Remove middlewares + + for (i = this.plugins.length - 1; i >= 0; i--) { + if (this.plugins[i].name === name) { + this.plugins.splice(i, 1) + } + } + + this.trigger('plugin:remove', name) +} + /** * Register event handlers */ @@ -143,7 +273,7 @@ SvgPanZoom.prototype.setupHandlers = function() { this.options.customEventsHandler.init({ svgElement: this.svg , eventsListenerElement: this.options.eventsListenerElement - , instance: this.getPublicInstance() + , instance: this.getPublicApi() }) // Custom event handler may halt builtin listeners @@ -512,7 +642,7 @@ SvgPanZoom.prototype.center = function() { , offsetX = (this.width - (viewBox.width + viewBox.x * 2) * this.getZoom()) * 0.5 , offsetY = (this.height - (viewBox.height + viewBox.y * 2) * this.getZoom()) * 0.5 - this.getPublicInstance().pan({x: offsetX, y: offsetY}) + this.getPublicApi().pan({x: offsetX, y: offsetY}) } /** @@ -569,8 +699,8 @@ SvgPanZoom.prototype.resize = function() { // Reposition control icons by re-enabling them if (this.options.controlIconsEnabled) { - this.getPublicInstance().disableControlIcons() - this.getPublicInstance().enableControlIcons() + this.getPublicApi().disableControlIcons() + this.getPublicApi().enableControlIcons() } } @@ -580,18 +710,17 @@ SvgPanZoom.prototype.resize = function() { SvgPanZoom.prototype.destroy = function() { var that = this - // Free callbacks - this.beforeZoom = null - this.onZoom = null - this.beforePan = null - this.onPan = null + // Remove all plugins + while (this.plugins.length) { + this.removePlugin(this.plugins[0].name) + } // Destroy custom event handlers if (this.options.customEventsHandler != null) { // jshint ignore:line this.options.customEventsHandler.destroy({ svgElement: this.svg , eventsListenerElement: this.options.eventsListenerElement - , instance: this.getPublicInstance() + , instance: this.getPublicApi() }) } @@ -605,11 +734,16 @@ SvgPanZoom.prototype.destroy = function() { this.disableMouseWheelZoom() // Remove control icons - this.getPublicInstance().disableControlIcons() + this.getPublicApi().disableControlIcons() // Reset zoom and pan this.reset() + // Remove all events + this.events = {} + + // TODO unhook middlewares + // Remove instance from instancesStore instancesStore = instancesStore.filter(function(instance){ return instance.svg !== that.svg @@ -618,25 +752,25 @@ SvgPanZoom.prototype.destroy = function() { // Delete options and its contents delete this.options - // Destroy public instance and rewrite getPublicInstance - delete this.publicInstance + // Destroy public instance and rewrite getPublicApi + delete this.publicApi delete this.pi - this.getPublicInstance = function(){ + this.getPublicApi = function(){ return null } } /** - * Returns a public instance object + * Returns a public API object * - * @return {Object} Public instance object + * @return {Object} Public API object */ -SvgPanZoom.prototype.getPublicInstance = function() { +SvgPanZoom.prototype.getPublicApi = function() { var that = this // Create cache - if (!this.publicInstance) { - this.publicInstance = this.pi = { + if (!this.publicApi) { + this.publicApi = this.pi = { // Pan enablePan: function() {that.options.panEnabled = true; return that.pi} , disablePan: function() {that.options.panEnabled = false; return that.pi} @@ -644,9 +778,6 @@ SvgPanZoom.prototype.getPublicInstance = function() { , pan: function(point) {that.pan(point); return that.pi} , panBy: function(point) {that.panBy(point); return that.pi} , getPan: function() {return that.getPan()} - // Pan event - , setBeforePan: function(fn) {that.options.beforePan = fn === null ? null : Utils.proxy(fn, that.publicInstance); return that.pi} - , setOnPan: function(fn) {that.options.onPan = fn === null ? null : Utils.proxy(fn, that.publicInstance); return that.pi} // Zoom and Control Icons , enableZoom: function() {that.options.zoomEnabled = true; return that.pi} , disableZoom: function() {that.options.zoomEnabled = false; return that.pi} @@ -678,9 +809,6 @@ SvgPanZoom.prototype.getPublicInstance = function() { , setZoomScaleSensitivity: function(scale) {that.options.zoomScaleSensitivity = scale; return that.pi} , setMinZoom: function(zoom) {that.options.minZoom = zoom; return that.pi} , setMaxZoom: function(zoom) {that.options.maxZoom = zoom; return that.pi} - // Zoom event - , setBeforeZoom: function(fn) {that.options.beforeZoom = fn === null ? null : Utils.proxy(fn, that.publicInstance); return that.pi} - , setOnZoom: function(fn) {that.options.onZoom = fn === null ? null : Utils.proxy(fn, that.publicInstance); return that.pi} // Zooming , zoom: function(scale) {that.publicZoom(scale, true); return that.pi} , zoomBy: function(scale) {that.publicZoom(scale, false); return that.pi} @@ -708,14 +836,51 @@ SvgPanZoom.prototype.getPublicInstance = function() { , viewBox: that.viewport.getViewBox() } } + // Plugins + , addPlugin: function(name) {that.addPlugin(name); return that.pi} + , removePlugin: function(name) {that.removePlugin(name); return that.pi} // Destroy , destroy: function() {that.destroy(); return that.pi} + // Events handling + , on: function(name, fn, ctx) { + if (typeof ctx === 'undefined') ctx = that.pi // Automatically inject public context + that.on(name, fn, ctx, '__user') + return that.pi + } + , off: function(name, fn, ctx) {that.off(name, fn, ctx, '__user'); return that.pi} + , use: function(name, fn, ctx) {that.use(name, fn, ctx, '__user'); return that.pi} + , unuse: function(name, fn, ctx) {that.unuse(name, fn, ctx, '__user'); return that.pi} } } - return this.publicInstance + return this.publicApi +} + +SvgPanZoom.prototype.getPluginApi = function(pluginName) { + var publicApi = this.getPublicApi() + , that = this + + // Same API as for public use but with slight differences + var pluginApi = Object.create(publicApi) + pluginApi.on = function(name, fn, ctx) { + if (typeof ctx === 'undefined') ctx = publicApi // Automatically inject plugin context + that.on(name, fn, ctx, pluginName) + return pluginApi + } + pluginApi.off = function(name, fn, ctx) {that.off(name, fn, ctx, pluginName); return pluginApi} + pluginApi.use = function(name, fn, ctx) {that.use(name, fn, ctx, pluginName); return pluginApi} + pluginApi.unuse = function(name, fn, ctx) {that.unuse(name, fn, ctx, pluginName); return pluginApi} + + return pluginApi } +/** + * Keeps all plugins + * + * @type {Object} + */ +SvgPanZoom.prototype.pluginsStore = {} + /** * Stores pairs of instances of SvgPanZoom and SVG * Each pair is represented by an object {svg: SVGSVGElement, instance: SvgPanZoom} @@ -733,7 +898,7 @@ var svgPanZoom = function(elementOrSelector, options){ // Look for existent instance for(var i = instancesStore.length - 1; i >= 0; i--) { if (instancesStore[i].svg === svg) { - return instancesStore[i].instance.getPublicInstance() + return instancesStore[i].instance.getPublicApi() } } @@ -744,7 +909,37 @@ var svgPanZoom = function(elementOrSelector, options){ }) // Return just pushed instance - return instancesStore[instancesStore.length - 1].instance.getPublicInstance() + return instancesStore[instancesStore.length - 1].instance.getPublicApi() + } +} + +/** + * Register a plugin + * + * @param {String} name Plugin name + * @param {Function} fn Plugin function that returns plugin instance + */ +svgPanZoom.register = function(name, fn) { + if (name.indexOf('__') === 0) { + throw new Error('Plugin name can\'t start with __') + } else { + SvgPanZoom.prototype.pluginsStore[name] = fn + } +} + +/** + * Deregister a plugin + * + * @param {String} name Plugin name + */ +svgPanZoom.deregister = function(name) { + if (name in SvgPanZoom.prototype.pluginsStore) { + // Go through each instance and remove this pugin + for (var i in instancesStore) { + instancesStore[i].instance.removePlugin(name) + } + + delete SvgPanZoom.prototype.pluginsStore[name] } }