├── .gitignore ├── .npmignore ├── .travis.yml ├── Gruntfile.js ├── LICENSE ├── README.md ├── TODO.md ├── curve.js ├── curve.min.js ├── examples ├── example.html └── explore.js ├── package.json ├── server.sh ├── spec ├── ellipse-spec.coffee ├── model-spec.coffee ├── node-editor-spec.coffee ├── node-spec.coffee ├── object-editor-spec.coffee ├── path-editor-spec.coffee ├── path-parser-spec.coffee ├── path-spec.coffee ├── pen-tool-spec.coffee ├── rectangle-spec.coffee ├── selection-model-spec.coffee ├── selection-view-spec.coffee ├── shape-editor-spec.coffee ├── shape-tool-spec.coffee ├── spec-helper.coffee ├── subpath-spec.coffee ├── svg-document-spec.coffee └── transform-spec.coffee ├── src ├── curve.coffee ├── deserialize-svg.coffee ├── draggable-mixin.coffee ├── ellipse-model.coffee ├── ellipse.coffee ├── ext │ ├── svg-circle.coffee │ └── svg-draggable.coffee ├── model.coffee ├── node-editor.coffee ├── node.coffee ├── object-editor.coffee ├── object-selection.coffee ├── path-editor.coffee ├── path-model.coffee ├── path-parser.coffee ├── path.coffee ├── pen-tool.coffee ├── point.coffee ├── pointer-tool.coffee ├── rectangle-model.coffee ├── rectangle-selection.coffee ├── rectangle.coffee ├── selection-model.coffee ├── selection-view.coffee ├── serialize-svg.coffee ├── shape-editor.coffee ├── shape-tool.coffee ├── size.coffee ├── subpath.coffee ├── svg-document-model.coffee ├── svg-document.coffee ├── transform.coffee └── utils.coffee └── vendor ├── svg.export.js ├── svg.js └── svg.parser.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | lib/ 4 | test/lib 5 | node_modules/* 6 | .grunt 7 | npm-debug.log 8 | _SpecRunner.html 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | spec/ 3 | examples/ 4 | .DS_Store 5 | .npmignore 6 | .grunt 7 | .travis.yml 8 | /curve.js 9 | /curve.min.js 10 | Gruntfile.js 11 | npm-debug.log 12 | server.sh 13 | TODO.md 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.8" 4 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | grunt.initConfig({ 3 | pkg: grunt.file.readJSON('package.json'), 4 | 5 | coffee: { 6 | build: { 7 | expand: true, 8 | cwd: 'src/', 9 | src: ['**/*.coffee'], 10 | dest: 'lib/', 11 | ext: '.js' 12 | } 13 | }, 14 | 15 | browserify: { 16 | options: { 17 | browserifyOptions: { 18 | standalone: 'Curve' 19 | }, 20 | debug: true, 21 | }, 22 | production: { 23 | options: { 24 | debug: false 25 | }, 26 | src: ['lib/curve.js'], 27 | dest: 'curve.js' 28 | } 29 | }, 30 | 31 | uglify: { 32 | dist: { 33 | files: { 34 | '<%= pkg.name %>.min.js': ['<%= pkg.name %>.js'] 35 | } 36 | } 37 | }, 38 | 39 | watch: { 40 | build: { 41 | files: ['src/**/*.coffee'], 42 | tasks: ['coffee:build', 'browserify'] 43 | }, 44 | test: { 45 | files: ['src/**/*.coffee', 'spec/**/*.coffee'], 46 | tasks: ['shell:test'] 47 | } 48 | }, 49 | 50 | shell: { 51 | test: { 52 | command: 'node_modules/.bin/electron-jasmine ./spec', 53 | options: { 54 | stdout: true, 55 | stderr: true, 56 | failOnError: true 57 | } 58 | } 59 | } 60 | }); 61 | 62 | grunt.loadNpmTasks('grunt-contrib-concat'); 63 | grunt.loadNpmTasks('grunt-contrib-uglify'); 64 | grunt.loadNpmTasks('grunt-contrib-coffee'); 65 | grunt.loadNpmTasks('grunt-contrib-watch'); 66 | grunt.loadNpmTasks('grunt-browserify'); 67 | grunt.loadNpmTasks('grunt-shell'); 68 | 69 | grunt.registerTask('test', ['shell:test']); 70 | grunt.registerTask('default', ['coffee', 'browserify', 'uglify']); 71 | }; 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Ben Ogle 2 | 3 | MIT LICENSE 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the "Software"), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Curve 2 | 3 | Curve is a vector drawing library providing a layer of user interaction tools over SVG. It is used in an [Electron][electron]-based vector drawing app called [Curve.app][app]. 4 | 5 | ![shot](https://cloud.githubusercontent.com/assets/69169/9297079/56f79f34-444e-11e5-82ad-f36889ee524f.png) 6 | 7 | Built on top of [svg.js][svg]. 8 | 9 | * Will load any svg file 10 | * Will serialize (save!) the loaded svg file 11 | * Can create paths (pen tool), rectangles, and ellipses 12 | * Can select and modify paths, rectangles, and ellipses 13 | 14 | ## Running the example 15 | 16 | * `python -m SimpleHTTPServer 8080` 17 | * Load up http://localhost:8080/examples/example.html 18 | 19 | ## Usage 20 | 21 | Curve is built with [browserify][browserify] and works in the browser, and node.js and Electron applications. 22 | 23 | ### In the browser 24 | 25 | The only dependency is svg.js which is bundled in `curve.js` and `curve.min.js`. Download curve.js or curve.min.js, and include it in your page 26 | 27 | ```html 28 | 29 | 30 | 31 | 32 | Curve App 33 | 34 | 35 | 36 |
37 | 38 | 39 | ``` 40 | 41 | Then in your JS: 42 | 43 | ```js 44 | var doc = new Curve.SVGDocument("canvas") 45 | var svgString = "..." 46 | doc.deserialize(svgString) 47 | doc.initializeTools() 48 | ``` 49 | 50 | ### In a node/io.js or Electron app 51 | 52 | ```bash 53 | npm install --save curve 54 | ``` 55 | 56 | And it works similarly 57 | 58 | ```js 59 | var SVGDocument = require('curve').SVGDocument 60 | 61 | var canvas = document.createElement('div') 62 | var doc = new Curve.SVGDocument(canvas) 63 | var svgString = "..." 64 | doc.deserialize(svgString) 65 | doc.initializeTools() 66 | ``` 67 | 68 | ## Browser support 69 | 70 | Officially tested on Chrome 71 | 72 | ## Testing/Building 73 | 74 | * Requires grunt `npm install -g grunt-cli` 75 | * Install grunt modules `npm install` 76 | * Automatically compile changes `grunt watch` 77 | * Run tests with `npm test` 78 | 79 | ## License 80 | 81 | MIT License 82 | 83 | [electron]:http://electron.atom.io 84 | [app]:https://github.com/benogle/curve-app 85 | [svg]:https://github.com/wout/svg.js 86 | [browserify]:http://browserify.org/ 87 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## TODO 2 | 3 | * Undo 4 | * Zoom 5 | * Multi-select 6 | * Better handle management on nodes (break, join, pull) 7 | * Legit color picker that allows alpha 8 | * The editing of more parameters (more than just fill!) 9 | * Layer management 10 | * Like everything else a legit vector drawing app has... 11 | -------------------------------------------------------------------------------- /examples/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Curve Test 6 | 7 | 8 | 9 | 10 | 15 | 16 | 71 | 72 | 73 | 74 |
75 | 76 | 77 | 78 | 79 |
80 |
81 | 82 | 83 | -------------------------------------------------------------------------------- /examples/explore.js: -------------------------------------------------------------------------------- 1 | var doc 2 | 3 | window.main = function() { 4 | this.DOC = doc = new Curve.SVGDocument("canvas") 5 | 6 | fileToLoad = localStorage.getItem('curve-file') || Curve.Examples.cloud 7 | doc.deserialize(fileToLoad) 8 | doc.initializeTools() 9 | 10 | var size = doc.getSize() 11 | var svgNode = document.querySelector('#canvas') 12 | svgNode.style.width = size.width + 'px' 13 | svgNode.style.height = size.height + 'px' 14 | 15 | document.addEventListener('keydown', onKeyDown, false) 16 | 17 | document.querySelector('button[data-tool="pointer"]').classList.add('selected') 18 | document.addEventListener('click', function(event) { 19 | if (event.target.nodeName === 'BUTTON' && event.target.classList.contains('tool-button')) { 20 | document.querySelector('.tool-button.selected').classList.remove('selected') 21 | event.target.classList.add('selected') 22 | doc.setActiveToolType(event.target.getAttribute('data-tool')) 23 | } 24 | }) 25 | } 26 | 27 | function save() { 28 | fileToSave = doc.serialize() 29 | localStorage.setItem('curve-file', fileToSave) 30 | } 31 | 32 | function onKeyDown(event) { 33 | if(event.keyCode == 83 && event.metaKey) { // cmd + s 34 | save() 35 | event.preventDefault() 36 | } 37 | } 38 | 39 | // Draws a couple paths 40 | window._main = function() { 41 | this.DOC = doc = new Curve.SVGDocument("canvas") 42 | 43 | path1 = new Path(doc) 44 | path1.addNode(new Node([50, 50], [-10, 0], [10, 0])) 45 | path1.addNode(new Node([80, 60], [-10, -5], [10, 5])) 46 | path1.addNode(new Node([60, 80], [10, 0], [-10, 0])) 47 | path1.close() 48 | 49 | path2 = new Path(doc, { 50 | fill: 'none', 51 | stroke: '#333', 52 | 'stroke-width': 2 53 | }) 54 | path2.addNode(new Node([150, 50], [-10, 0], [10, 0])) 55 | path2.addNode(new Node([220, 100], [-10, -5], [10, 5])) 56 | path2.addNode(new Node([160, 120], [10, 0], [-10, 0])) 57 | path2.close() 58 | 59 | doc.getSelectionModel().setSelected(path1) 60 | doc.getSelectionModel().setSelectedNode(path1.nodes[2]) 61 | } 62 | 63 | Curve.Examples = { 64 | cloud: '', 65 | rects: '', 66 | heckert: '\n\n\n \n \n \n \n \n \n \n \n \n \n' 67 | }; 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "curve", 3 | "version": "0.1.1", 4 | "main": "./lib/curve", 5 | "description": "Vector drawing library", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/benogle/curve.git" 10 | }, 11 | "scripts": { 12 | "prepublish": "grunt coffee", 13 | "test": "grunt test" 14 | }, 15 | "devDependencies": { 16 | "electron-jasmine": "^0.1.8", 17 | "grunt": "^0.4.5", 18 | "grunt-browserify": "^3.8.0", 19 | "grunt-cli": "^0.1", 20 | "grunt-contrib-coffee": "^0.13.0", 21 | "grunt-contrib-concat": "^0.5.1", 22 | "grunt-contrib-uglify": "^0.9.1", 23 | "grunt-contrib-watch": "^0.6.1", 24 | "grunt-shell": "^1.1.2" 25 | }, 26 | "dependencies": { 27 | "delegato": "^1.0.0", 28 | "event-kit": "^1.2.0", 29 | "mixto": "^1.0.0", 30 | "object-assign": "^3.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | python -m SimpleHTTPServer 8080 3 | -------------------------------------------------------------------------------- /spec/ellipse-spec.coffee: -------------------------------------------------------------------------------- 1 | SVGDocument = require '../src/svg-document' 2 | 3 | Ellipse = require '../src/ellipse' 4 | Point = require '../src/point' 5 | Size = require '../src/size' 6 | 7 | describe 'Ellipse', -> 8 | [svg, ellipse] = [] 9 | 10 | beforeEach -> 11 | canvas = document.createElement('div') 12 | jasmine.attachToDOM(canvas) 13 | svg = new SVGDocument(canvas) 14 | 15 | describe "creation", -> 16 | it 'has an id', -> 17 | ellipse = new Ellipse(svg) 18 | expect(ellipse.getID()).toBe "Ellipse-#{ellipse.model.id}" 19 | 20 | it 'registers itself with the document', -> 21 | ellipse = new Ellipse(svg) 22 | expect(svg.getObjects()).toContain ellipse 23 | 24 | it 'emits an event when it is removed', -> 25 | ellipse = new Ellipse(svg) 26 | ellipse.on 'remove', removeSpy = jasmine.createSpy() 27 | ellipse.remove() 28 | expect(removeSpy).toHaveBeenCalledWith({object: ellipse}) 29 | 30 | it 'can be created with no parameters', -> 31 | ellipse = new Ellipse(svg) 32 | 33 | el = ellipse.svgEl 34 | expect(el.attr('cx')).toBe 5 35 | expect(el.attr('cy')).toBe 5 36 | expect(el.attr('rx')).toBe 5 37 | expect(el.attr('ry')).toBe 5 38 | 39 | it 'can be created with parameters', -> 40 | ellipse = new Ellipse(svg, {x: 10, y: 20, width: 200, height: 300, fill: '#ff0000'}) 41 | 42 | el = ellipse.svgEl 43 | expect(el.attr('cx')).toBe 110 44 | expect(el.attr('cy')).toBe 170 45 | expect(el.attr('rx')).toBe 100 46 | expect(el.attr('ry')).toBe 150 47 | expect(el.attr('fill')).toBe '#ff0000' 48 | 49 | expect(ellipse.get('position')).toEqual Point.create(10, 20) 50 | expect(ellipse.get('size')).toEqual Size.create(200, 300) 51 | 52 | describe "updating attributes", -> 53 | beforeEach -> 54 | ellipse = new Ellipse(svg, {x: 10, y: 20, width: 200, height: 300, fill: '#ff0000'}) 55 | 56 | it "emits an event with the object, model, old, and new values when changed", -> 57 | ellipse.on('change', changeSpy = jasmine.createSpy()) 58 | ellipse.set(fill: '#00ff00') 59 | 60 | arg = changeSpy.calls.mostRecent().args[0] 61 | expect(changeSpy).toHaveBeenCalled() 62 | expect(arg.object).toBe ellipse 63 | expect(arg.model).toBe ellipse.model 64 | expect(arg.value).toEqual fill: '#00ff00' 65 | expect(arg.oldValue).toEqual fill: '#ff0000' 66 | 67 | it "can have its fill color changed", -> 68 | el = ellipse.svgEl 69 | expect(el.attr('fill')).toBe '#ff0000' 70 | 71 | ellipse.set(fill: '#00ff00') 72 | expect(el.attr('fill')).toBe '#00ff00' 73 | expect(ellipse.get('fill')).toBe '#00ff00' 74 | -------------------------------------------------------------------------------- /spec/model-spec.coffee: -------------------------------------------------------------------------------- 1 | Model = require '../src/model' 2 | 3 | describe 'Model', -> 4 | [model] = [] 5 | 6 | beforeEach -> 7 | model = new Model(['one', 'two', 'three']) 8 | 9 | describe "::get()", -> 10 | beforeEach -> 11 | model.set(one: 1, two: 2) 12 | 13 | it "returns all the properties when used with no arg", -> 14 | expect(model.get()).toEqual one: 1, two: 2, three: null 15 | 16 | it "returns a single property when used with a prop arg", -> 17 | expect(model.get('one')).toBe 1 18 | 19 | describe "::set()", -> 20 | it "sets allowed properties and emits events", -> 21 | model.on('change', changeSpy = jasmine.createSpy()) 22 | model.on('change:one', changeOneSpy = jasmine.createSpy()) 23 | model.on('change:four', changeFourSpy = jasmine.createSpy()) 24 | 25 | model.set(one: 1, four: 4, two: 2) 26 | expect(changeFourSpy).not.toHaveBeenCalled() 27 | 28 | expect(changeOneSpy).toHaveBeenCalled() 29 | arg = changeOneSpy.calls.mostRecent().args[0] 30 | expect(arg.model).toBe model 31 | expect(arg.oldValue).toBe null 32 | expect(arg.value).toBe 1 33 | expect(arg.property).toBe 'one' 34 | 35 | expect(changeSpy).toHaveBeenCalled() 36 | expect(changeSpy.calls.count()).toBe 1 37 | arg = changeSpy.calls.mostRecent().args[0] 38 | expect(arg.model).toBe model 39 | expect(arg.oldValue).toEqual {one: null, two: null} 40 | expect(arg.value).toEqual {one: 1, two: 2} 41 | 42 | it "does not emit an event when the value has not changed", -> 43 | model.on('change', changeSpy = jasmine.createSpy()) 44 | model.on('change:one', changeOneSpy = jasmine.createSpy()) 45 | 46 | model.set(one: 1, two: 2) 47 | expect(changeSpy).toHaveBeenCalled() 48 | expect(changeSpy.calls.count()).toBe 1 49 | 50 | expect(changeOneSpy).toHaveBeenCalled() 51 | expect(changeOneSpy.calls.count()).toBe 1 52 | 53 | model.set(one: 1, two: 2) 54 | expect(changeSpy).toHaveBeenCalled() 55 | expect(changeSpy.calls.count()).toBe 1 56 | 57 | expect(changeOneSpy).toHaveBeenCalled() 58 | expect(changeOneSpy.calls.count()).toBe 1 59 | 60 | describe "::addFilter()", -> 61 | beforeEach -> 62 | model.addFilter 'one', (value) -> value * 10 63 | 64 | it "adds a filter that can transform the value", -> 65 | model.set(one: 1) 66 | expect(model.get('one')).toBe 10 67 | 68 | model.set(one: 2) 69 | expect(model.get('one')).toBe 20 70 | 71 | it "is not run when filter:false", -> 72 | model.addFilter 'one', (value) -> value * 10 73 | 74 | model.set({one: 1}, filter: false) 75 | expect(model.get('one')).toBe 1 76 | -------------------------------------------------------------------------------- /spec/node-editor-spec.coffee: -------------------------------------------------------------------------------- 1 | SVGDocument = require '../src/svg-document' 2 | 3 | Node = require '../src/node' 4 | Path = require '../src/path' 5 | Point = require '../src/point' 6 | NodeEditor = require '../src/node-editor' 7 | 8 | describe 'NodeEditor', -> 9 | [svgDocument, path, nodeEditor] = [] 10 | beforeEach -> 11 | canvas = document.createElement('div') 12 | jasmine.attachToDOM(canvas) 13 | svgDocument = new SVGDocument(canvas) 14 | 15 | path = new Path(svgDocument) 16 | path.addNode(new Node([50, 50], [-10, 0], [10, 0])) 17 | path.addNode(new Node([80, 60], [-10, -5], [10, 5])) 18 | path.addNode(new Node([60, 80], [10, 0], [-10, 0])) 19 | path.close() 20 | 21 | nodeEditor = new NodeEditor(svgDocument) 22 | 23 | describe 'dragging handles', -> 24 | beforeEach -> 25 | nodeEditor.setNode(path.getSubpaths()[0].nodes[0]) 26 | 27 | it 'dragging a handle updates the path and the node editor', -> 28 | nodeEditor._startPosition = path.getSubpaths()[0].nodes[0].getPoint() 29 | nodeEditor.onDraggingHandleOut({x: 20, y: 10}, {clientX: 70, clientY: 60}) 30 | 31 | expect(path.getSubpaths()[0].nodes[0].handleOut).toEqual new Point([20, 10]) 32 | expect(nodeEditor.handleElements.members[1].node).toHaveAttr 'cx', '70' 33 | expect(nodeEditor.handleElements.members[1].node).toHaveAttr 'cy', '60' 34 | 35 | describe 'clicking nodes', -> 36 | beforeEach -> 37 | nodeEditor.setNode(path.getSubpaths()[0].nodes[0]) 38 | 39 | it 'clicking the node editor selects the node', -> 40 | expect(svgDocument.getSelectionModel().getSelectedNode()).toBe null 41 | 42 | xyParams = jasmine.buildMouseParams(20, 30) 43 | nodeEditorElement = nodeEditor.nodeElement.node 44 | nodeEditorElement.dispatchEvent(jasmine.buildMouseEvent('mousedown', xyParams, target: nodeEditorElement)) 45 | nodeEditorElement.dispatchEvent(jasmine.buildMouseEvent('mouseup', xyParams)) 46 | 47 | expect(svgDocument.getSelectionModel().getSelectedNode()).toBe path.getNodes()[0] 48 | 49 | it 'clicking the node editor does not select the node when event.preventDefault is called', -> 50 | nodeEditor.on 'mousedown:node', mousedownSpy = jasmine.createSpy().and.callFake ({preventDefault}) -> preventDefault() 51 | expect(svgDocument.getSelectionModel().getSelectedNode()).toBe null 52 | 53 | xyParams = jasmine.buildMouseParams(20, 30) 54 | nodeEditorElement = nodeEditor.nodeElement.node 55 | nodeEditorElement.dispatchEvent(jasmine.buildMouseEvent('mousedown', xyParams, target: nodeEditorElement)) 56 | nodeEditorElement.dispatchEvent(jasmine.buildMouseEvent('mouseup', xyParams)) 57 | 58 | expect(mousedownSpy).toHaveBeenCalled() 59 | expect(svgDocument.getSelectionModel().getSelectedNode()).toBe null 60 | -------------------------------------------------------------------------------- /spec/node-spec.coffee: -------------------------------------------------------------------------------- 1 | Point = require '../src/point' 2 | Node = require '../src/node' 3 | 4 | describe 'Node', -> 5 | [node] = [] 6 | 7 | beforeEach -> 8 | node = new Node([50, 50], [-10, 0], [10, 0], true) 9 | 10 | describe 'joined handles', -> 11 | it 'updating one handle updates the other', -> 12 | node.setHandleIn([20, 30]) 13 | expect(node.handleOut).toEqual new Point(-20, -30) 14 | 15 | node.setHandleOut([15, -5]) 16 | expect(node.handleIn).toEqual new Point(-15, 5) 17 | 18 | it 'join() will set the other non-joined handle', -> 19 | node.isJoined = false 20 | node.setHandleIn([0, 0]) 21 | 22 | node.join('handleOut') 23 | expect(node.handleIn).toEqual new Point(-10, 0) 24 | 25 | it 'setting handle to null, mirrors', -> 26 | node.setHandleIn(null) 27 | expect(node.handleOut).toEqual null 28 | 29 | describe "::translate()", -> 30 | it "translates the node and the handles", -> 31 | node.translate(new Point(-25, 10)) 32 | expect(node.getPoint()).toEqual new Point(25, 60) 33 | expect(node.getHandleIn()).toEqual new Point(-10, 0) 34 | expect(node.getHandleOut()).toEqual new Point(10, 0) 35 | -------------------------------------------------------------------------------- /spec/object-editor-spec.coffee: -------------------------------------------------------------------------------- 1 | SVGDocument = require '../src/svg-document' 2 | 3 | Node = require '../src/node' 4 | Path = require '../src/path' 5 | Rectangle = require '../src/rectangle' 6 | Ellipse = require '../src/ellipse' 7 | SelectionModel = require '../src/selection-model' 8 | ObjectEditor = require '../src/object-editor' 9 | ShapeEditor = require '../src/shape-editor' 10 | 11 | describe 'ObjectEditor', -> 12 | [canvas, svgDocument, model, editor, path] = [] 13 | beforeEach -> 14 | canvas = document.createElement('div') 15 | jasmine.attachToDOM(canvas) 16 | svgDocument = new SVGDocument(canvas) 17 | model = svgDocument.getSelectionModel() 18 | 19 | editor = new ObjectEditor(svgDocument) 20 | path = new Path(svgDocument) 21 | path.addNode(new Node([50, 50], [-10, 0], [10, 0])) 22 | path.close() 23 | 24 | it 'ignores selection model when not active', -> 25 | expect(editor.isActive()).toBe false 26 | expect(editor.getActiveObject()).toBe null 27 | model.setSelected(path) 28 | expect(editor.isActive()).toBe false 29 | expect(editor.getActiveObject()).toBe null 30 | 31 | describe "when there are selected objects before the editor becomes active", -> 32 | beforeEach -> 33 | model.setSelected(path) 34 | model.setSelectedNode(path.getNodes()[0]) 35 | 36 | it 'activates the editor associated with the selected object', -> 37 | editor.activate() 38 | expect(editor.isActive()).toBe true 39 | expect(editor.getActiveObject()).toBe path 40 | expect(editor.getActiveEditor().activeNode).toBe path.getNodes()[0] 41 | 42 | describe "when the ObjectEditor is active", -> 43 | beforeEach -> 44 | editor.activate() 45 | expect(editor.isActive()).toBe true 46 | expect(editor.getActiveObject()).toBe null 47 | 48 | it 'activates the editor associated with the selected object', -> 49 | model.setSelected(path) 50 | expect(editor.isActive()).toBe true 51 | expect(editor.getActiveObject()).toBe path 52 | expect(canvas.querySelector('svg circle.node-editor-node')).toShow() 53 | 54 | model.clearSelected() 55 | expect(editor.isActive()).toBe true 56 | expect(editor.getActiveObject()).toBe null 57 | expect(canvas.querySelector('svg circle.node-editor-node')).toHide() 58 | 59 | it 'deactivates the editor associated with the selected object when the ObjectEditor is deactivated', -> 60 | model.setSelected(path) 61 | expect(editor.isActive()).toBe true 62 | expect(editor.getActiveObject()).toBe path 63 | expect(canvas.querySelector('svg circle.node-editor-node')).toShow() 64 | 65 | editor.deactivate() 66 | expect(editor.isActive()).toBe false 67 | expect(editor.getActiveObject()).toBe null 68 | expect(canvas.querySelector('svg circle.node-editor-node')).toHide() 69 | 70 | describe "when the selected object is a Rectangle", -> 71 | [object] = [] 72 | beforeEach -> 73 | object = new Rectangle(svgDocument) 74 | 75 | it "activates the ShapeEditor", -> 76 | model.setSelected(object) 77 | expect(editor.isActive()).toBe true 78 | expect(editor.getActiveObject()).toBe object 79 | expect(editor.getActiveEditor() instanceof ShapeEditor).toBe true 80 | 81 | describe "when the selected object is an Ellipse", -> 82 | [object] = [] 83 | beforeEach -> 84 | object = new Ellipse(svgDocument) 85 | 86 | it "activates the ShapeEditor", -> 87 | model.setSelected(object) 88 | expect(editor.isActive()).toBe true 89 | expect(editor.getActiveObject()).toBe object 90 | expect(editor.getActiveEditor() instanceof ShapeEditor).toBe true 91 | 92 | describe "when the selected node is changed", -> 93 | it 'activates the node editor associated with the selected node', -> 94 | model.setSelected(path) 95 | expect(editor.isActive()).toBe true 96 | expect(editor.getActiveObject()).toBe path 97 | expect(editor.getActiveEditor().activeNode).toBe null 98 | 99 | model.setSelectedNode(path.getNodes()[0]) 100 | expect(editor.getActiveEditor().activeNode).toBe path.getNodes()[0] 101 | 102 | model.setSelectedNode() 103 | expect(editor.getActiveEditor().activeNode).toBe null 104 | -------------------------------------------------------------------------------- /spec/path-editor-spec.coffee: -------------------------------------------------------------------------------- 1 | SVGDocument = require '../src/svg-document' 2 | Node = require '../src/node' 3 | Path = require '../src/path' 4 | PathEditor = require '../src/path-editor' 5 | 6 | describe 'PathEditor', -> 7 | [svgDocument, canvas, path, editor] = [] 8 | beforeEach -> 9 | canvas = document.createElement('div') 10 | jasmine.attachToDOM(canvas) 11 | svgDocument = new SVGDocument(canvas) 12 | 13 | beforeEach -> 14 | editor = new PathEditor(svgDocument) 15 | path = new Path(svgDocument) 16 | path.addNode(new Node([50, 50], [-10, 0], [10, 0])) 17 | path.close() 18 | 19 | it 'creates nodes when selecting and cleans up when selecting nothing', -> 20 | editor.activateObject(path) 21 | expect(canvas.querySelector('svg circle.node-editor-node')).toShow() 22 | expect(canvas.querySelector('svg path.object-selection')).toShow() 23 | 24 | editor.deactivate() 25 | expect(canvas.querySelector('svg circle.node-editor-node')).toHide() 26 | expect(canvas.querySelector('svg path.object-selection')).toBe null 27 | 28 | it 'renders node editor when selecting a node', -> 29 | editor.activateObject(path) 30 | editor.activateNode(path.getSubpaths()[0].nodes[0]) 31 | 32 | expect(canvas.querySelectorAll('svg circle.node-editor-handle')[0]).toShow() 33 | expect(canvas.querySelectorAll('svg circle.node-editor-handle')[1]).toShow() 34 | expect(canvas.querySelectorAll('svg circle.node-editor-handle')[0]).toHaveAttr 'cx', '40' 35 | expect(canvas.querySelectorAll('svg circle.node-editor-handle')[1]).toHaveAttr 'cx', '60' 36 | 37 | editor.deactivateNode() 38 | 39 | expect(canvas.querySelectorAll('svg circle.node-editor-handle')[0]).toHide() 40 | expect(canvas.querySelectorAll('svg circle.node-editor-handle')[1]).toHide() 41 | 42 | it 'hides handles when unselecting object', -> 43 | editor.activateObject(path) 44 | editor.activateNode(path.getSubpaths()[0].nodes[0]) 45 | 46 | expect(canvas.querySelectorAll('svg circle.node-editor-handle')[0]).toShow() 47 | expect(canvas.querySelectorAll('svg circle.node-editor-handle')[1]).toShow() 48 | 49 | editor.deactivate() 50 | 51 | expect(canvas.querySelectorAll('svg circle.node-editor-handle')[0]).toHide() 52 | expect(canvas.querySelectorAll('svg circle.node-editor-handle')[1]).toHide() 53 | 54 | it 'makes new NodeEditors when adding nodes to object', -> 55 | editor.activateObject(path) 56 | 57 | expect(canvas.querySelectorAll('svg circle.node-editor-node')).toHaveLength 1 58 | 59 | path.addNode(new Node([40, 40], [-10, 0], [10, 0])) 60 | expect(canvas.querySelectorAll('svg circle.node-editor-node')).toHaveLength 2 61 | 62 | path.addNode(new Node([10, 40], [-10, 0], [10, 0])) 63 | expect(canvas.querySelectorAll('svg circle.node-editor-node')).toHaveLength 3 64 | 65 | it 'removes NodeEditors when removing nodes from object', -> 66 | editor.activateObject(path) 67 | path.addNode(new Node([40, 40], [-10, 0], [10, 0])) 68 | path.addNode(new Node([10, 40], [-10, 0], [10, 0])) 69 | 70 | expect(editor.nodeEditors).toHaveLength 3 71 | 72 | path.removeNode(path.getNodes()[0]) 73 | expect(editor.nodeEditors).toHaveLength 2 74 | 75 | path.removeNode(path.getNodes()[0]) 76 | expect(editor.nodeEditors).toHaveLength 1 77 | 78 | it "emits an event when a node is mousedown'd", -> 79 | editor.activateObject(path) 80 | editor.activateNode(path.getNodes()[0]) 81 | 82 | editor.on('mousedown:node', nodeSpy = jasmine.createSpy()) 83 | 84 | nodeEditorElement = editor.nodeEditors[0].nodeElement.node 85 | xyParams = jasmine.buildMouseParams(20, 30) 86 | nodeEditorElement.dispatchEvent(jasmine.buildMouseEvent('mousedown', xyParams, target: nodeEditorElement)) 87 | 88 | args = nodeSpy.calls.mostRecent().args[0] 89 | expect(nodeSpy).toHaveBeenCalled() 90 | expect(args.node).toBe path.getNodes()[0] 91 | expect(args.event).toBeTruthy() 92 | 93 | nodeEditorElement.dispatchEvent(jasmine.buildMouseEvent('mouseup', xyParams)) 94 | 95 | path.addNode(new Node([10, 40], [-10, 0], [10, 0])) 96 | 97 | nodeEditorElement = editor.nodeEditors[1].nodeElement.node 98 | xyParams = jasmine.buildMouseParams(10, 40) 99 | nodeEditorElement.dispatchEvent(jasmine.buildMouseEvent('mousedown', xyParams, target: nodeEditorElement)) 100 | 101 | args = nodeSpy.calls.mostRecent().args[0] 102 | expect(nodeSpy).toHaveBeenCalled() 103 | expect(args.node).toBe path.getNodes()[1] 104 | -------------------------------------------------------------------------------- /spec/path-parser-spec.coffee: -------------------------------------------------------------------------------- 1 | PathParser = require '../src/path-parser' 2 | 3 | getXY = (obj) -> {x: obj.x, y: obj.y} 4 | 5 | describe 'PathParser.lexPath', -> 6 | [path] = [] 7 | 8 | it 'works with spaces', -> 9 | path = 'M 101.454,311.936 C 98,316 92,317 89,315Z' 10 | tokens = PathParser.lexPath(path) 11 | 12 | expect(tokens).toEqual [ 13 | { type: 'COMMAND', string: 'M' }, 14 | { type: 'NUMBER', string: '101.454' }, 15 | { type: 'NUMBER', string: '311.936' }, 16 | { type: 'COMMAND', string: 'C' }, 17 | { type: 'NUMBER', string: '98' }, 18 | { type: 'NUMBER', string: '316' }, 19 | { type: 'NUMBER', string: '92' }, 20 | { type: 'NUMBER', string: '317' }, 21 | { type: 'NUMBER', string: '89' }, 22 | { type: 'NUMBER', string: '315' }, 23 | { type: 'COMMAND', string: 'Z' } 24 | ] 25 | 26 | it 'works with commas and spaces', -> 27 | path = 'M 101.454 , 311.936 C 98 ,316 92, 317 89 , 315 Z' 28 | tokens = PathParser.lexPath(path) 29 | 30 | expect(tokens).toEqual [ 31 | { type: 'COMMAND', string: 'M' }, 32 | { type: 'NUMBER', string: '101.454' }, 33 | { type: 'NUMBER', string: '311.936' }, 34 | { type: 'COMMAND', string: 'C' }, 35 | { type: 'NUMBER', string: '98' }, 36 | { type: 'NUMBER', string: '316' }, 37 | { type: 'NUMBER', string: '92' }, 38 | { type: 'NUMBER', string: '317' }, 39 | { type: 'NUMBER', string: '89' }, 40 | { type: 'NUMBER', string: '315' }, 41 | { type: 'COMMAND', string: 'Z' } 42 | ] 43 | 44 | it 'works with no spaces', -> 45 | path = 'M101.454,311.936C98,316,92,317,89,315Z' 46 | tokens = PathParser.lexPath(path) 47 | 48 | expect(tokens).toEqual [ 49 | { type: 'COMMAND', string: 'M' }, 50 | { type: 'NUMBER', string: '101.454' }, 51 | { type: 'NUMBER', string: '311.936' }, 52 | { type: 'COMMAND', string: 'C' }, 53 | { type: 'NUMBER', string: '98' }, 54 | { type: 'NUMBER', string: '316' }, 55 | { type: 'NUMBER', string: '92' }, 56 | { type: 'NUMBER', string: '317' }, 57 | { type: 'NUMBER', string: '89' }, 58 | { type: 'NUMBER', string: '315' }, 59 | { type: 'COMMAND', string: 'Z' } 60 | ] 61 | 62 | it 'handles - as a separator', -> 63 | path = 'M-101.454-311.936C-98-316-92-317-89-315Z' 64 | tokens = PathParser.lexPath(path) 65 | 66 | expect(tokens).toEqual [ 67 | { type: 'COMMAND', string: 'M' }, 68 | { type: 'NUMBER', string: '-101.454' }, 69 | { type: 'NUMBER', string: '-311.936' }, 70 | { type: 'COMMAND', string: 'C' }, 71 | { type: 'NUMBER', string: '-98' }, 72 | { type: 'NUMBER', string: '-316' }, 73 | { type: 'NUMBER', string: '-92' }, 74 | { type: 'NUMBER', string: '-317' }, 75 | { type: 'NUMBER', string: '-89' }, 76 | { type: 'NUMBER', string: '-315' }, 77 | { type: 'COMMAND', string: 'Z' } 78 | ] 79 | 80 | describe 'PathParser.groupCommands', -> 81 | it 'groups commands properly', -> 82 | path = 'M50,50C60,50,70,55,80,60C90,65,68,103,60,80C50,80,40,50,50,50Z' 83 | groupsOfCommands = PathParser.groupCommands(PathParser.lexPath(path)) 84 | 85 | expect(groupsOfCommands[0]).toEqual type: 'M', parameters: [50, 50] 86 | expect(groupsOfCommands[1]).toEqual type: 'C', parameters: [60, 50, 70, 55, 80, 60] 87 | expect(groupsOfCommands[2]).toEqual type: 'C', parameters: [90, 65, 68, 103, 60, 80] 88 | expect(groupsOfCommands[3]).toEqual type: 'C', parameters: [50, 80, 40, 50, 50, 50] 89 | expect(groupsOfCommands[4]).toEqual type: 'Z', parameters: [] 90 | 91 | it 'groups commands properly with no close', -> 92 | path = 'M50,50C50,80,40,50,60,70' 93 | groupsOfCommands = PathParser.groupCommands(PathParser.lexPath(path)) 94 | 95 | expect(groupsOfCommands[0]).toEqual type: 'M', parameters: [50, 50] 96 | expect(groupsOfCommands[1]).toEqual type: 'C', parameters: [50, 80, 40, 50, 60, 70] 97 | 98 | describe 'PathParser.parsePath', -> 99 | [path, tokens] = [] 100 | 101 | it 'parses closed, wrapped shapes', -> 102 | path = 'M50,50C60,50,70,55,80,60C90,65,68,103,60,80C50,80,40,50,50,50Z' 103 | parsedPath = PathParser.parsePath(path) 104 | subject = parsedPath.subpaths[0] 105 | 106 | expect(parsedPath.subpaths.length).toEqual 1 107 | expect(subject.closed).toEqual true 108 | expect(subject.nodes.length).toEqual 3 109 | 110 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 50, y: 50 111 | expect(getXY(subject.nodes[0].handleIn, 'x', 'y')).toEqual x: -10, y: 0 112 | expect(getXY(subject.nodes[0].handleOut, 'x', 'y')).toEqual x: 10, y: 0 113 | expect(subject.nodes[0].isJoined).toEqual true 114 | 115 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 80, y: 60 116 | expect(getXY(subject.nodes[1].handleIn, 'x', 'y')).toEqual x: -10, y: -5 117 | expect(getXY(subject.nodes[1].handleOut, 'x', 'y')).toEqual x: 10, y: 5 118 | expect(subject.nodes[1].isJoined).toEqual true 119 | 120 | expect(getXY(subject.nodes[2].point, 'x', 'y')).toEqual x: 60, y: 80 121 | expect(getXY(subject.nodes[2].handleIn, 'x', 'y')).toEqual x: 8, y: 23 122 | expect(getXY(subject.nodes[2].handleOut, 'x', 'y')).toEqual x: -10, y: 0 123 | expect(subject.nodes[2].isJoined).toEqual false 124 | 125 | it 'parses closed, non-wrapped shapes', -> 126 | path = 'M10,10C20,10,70,55,80,60C90,65,68,103,60,80C50,80,40,50,50,50Z' 127 | parsedPath = PathParser.parsePath(path) 128 | subject = parsedPath.subpaths[0] 129 | 130 | expect(parsedPath.subpaths.length).toEqual 1 131 | expect(subject.closed).toEqual true 132 | expect(subject.nodes.length).toEqual 4 133 | 134 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 10, y: 10 135 | expect(subject.nodes[0].handleIn).toBeUndefined() 136 | expect(getXY(subject.nodes[0].handleOut, 'x', 'y')).toEqual x: 10, y: 0 137 | 138 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 80, y: 60 139 | expect(getXY(subject.nodes[1].handleIn, 'x', 'y')).toEqual x: -10, y: -5 140 | expect(getXY(subject.nodes[1].handleOut, 'x', 'y')).toEqual x: 10, y: 5 141 | 142 | expect(getXY(subject.nodes[2].point, 'x', 'y')).toEqual x: 60, y: 80 143 | expect(getXY(subject.nodes[2].handleIn, 'x', 'y')).toEqual x: 8, y: 23 144 | expect(getXY(subject.nodes[2].handleOut, 'x', 'y')).toEqual x: -10, y: 0 145 | 146 | expect(getXY(subject.nodes[3].point, 'x', 'y')).toEqual x: 50, y: 50 147 | expect(getXY(subject.nodes[3].handleIn, 'x', 'y')).toEqual x: -10, y: 0 148 | expect(subject.nodes[3].handleOut).toBeUndefined() 149 | 150 | it 'parses closed, non-wrapped shapes with multiple subpaths', -> 151 | path = 'M10,10C20,10,70,55,80,60C90,65,68,103,60,80Z M30,40L15,16Z' 152 | parsedPath = PathParser.parsePath(path) 153 | expect(parsedPath.subpaths.length).toEqual 2 154 | 155 | subject = parsedPath.subpaths[0] 156 | expect(subject.closed).toEqual true 157 | expect(subject.nodes.length).toEqual 3 158 | 159 | subject = parsedPath.subpaths[1] 160 | expect(subject.closed).toEqual true 161 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 30, y: 40 162 | expect(subject.nodes[0].handleOut).toBeUndefined() 163 | expect(subject.nodes[0].handleIn).toBeUndefined() 164 | 165 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 15, y: 16 166 | expect(subject.nodes[1].handleOut).toBeUndefined() 167 | expect(subject.nodes[1].handleIn).toBeUndefined() 168 | 169 | it 'parses non closed shapes', -> 170 | path = 'M10,10C20,10 70,55 80,60C90,65 68,103 60,80' 171 | parsedPath = PathParser.parsePath(path) 172 | subject = parsedPath.subpaths[0] 173 | 174 | expect(parsedPath.subpaths.length).toEqual 1 175 | expect(subject.closed).toEqual false 176 | expect(subject.nodes.length).toEqual 3 177 | 178 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 10, y: 10 179 | expect(subject.nodes[0].handleIn).toBeUndefined() 180 | expect(getXY(subject.nodes[0].handleOut, 'x', 'y')).toEqual x: 10, y: 0 181 | 182 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 80, y: 60 183 | expect(getXY(subject.nodes[1].handleIn, 'x', 'y')).toEqual x: -10, y: -5 184 | expect(getXY(subject.nodes[1].handleOut, 'x', 'y')).toEqual x: 10, y: 5 185 | 186 | expect(getXY(subject.nodes[2].point, 'x', 'y')).toEqual x: 60, y: 80 187 | expect(getXY(subject.nodes[2].handleIn, 'x', 'y')).toEqual x: 8, y: 23 188 | expect(subject.nodes[2].handleOut).toBeUndefined() 189 | 190 | it 'parses L, H, h, and V, v commands', -> 191 | path = 'M512,384L320,576h128v320h128V576H704L512,384z' 192 | parsedPath = PathParser.parsePath(path) 193 | expect(parsedPath.subpaths.length).toEqual 1 194 | 195 | subject = parsedPath.subpaths[0] 196 | expect(subject.closed).toEqual true 197 | expect(subject.nodes.length).toEqual 7 198 | 199 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 512, y: 384 200 | expect(subject.nodes[0].handleOut).toBeUndefined() 201 | expect(subject.nodes[0].handleIn).toBeUndefined() 202 | 203 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 320, y: 576 204 | expect(getXY(subject.nodes[2].point, 'x', 'y')).toEqual x: 320+128, y: 576 205 | expect(getXY(subject.nodes[3].point, 'x', 'y')).toEqual x: 320+128, y: 576+320 206 | expect(getXY(subject.nodes[4].point, 'x', 'y')).toEqual x: 320+256, y: 576+320 207 | expect(getXY(subject.nodes[5].point, 'x', 'y')).toEqual x: 320+256, y: 576 208 | expect(getXY(subject.nodes[6].point, 'x', 'y')).toEqual x: 704, y: 576 209 | 210 | it 'parses L, H, h, and V, v commands when multple coordinate sets are present', -> 211 | path = 'M100,100 L200,100 250,150 300,200 v 100 100 h 100 150 V 500 600 z' 212 | parsedPath = PathParser.parsePath(path) 213 | expect(parsedPath.subpaths.length).toEqual 1 214 | 215 | subject = parsedPath.subpaths[0] 216 | expect(subject.closed).toEqual true 217 | expect(subject.nodes.length).toEqual 10 218 | 219 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 100, y: 100 220 | expect(subject.nodes[0].handleOut).toBeUndefined() 221 | expect(subject.nodes[0].handleIn).toBeUndefined() 222 | 223 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 200, y: 100 224 | expect(getXY(subject.nodes[2].point, 'x', 'y')).toEqual x: 250, y: 150 225 | expect(getXY(subject.nodes[3].point, 'x', 'y')).toEqual x: 300, y: 200 226 | expect(getXY(subject.nodes[4].point, 'x', 'y')).toEqual x: 300, y: 200 + 100 227 | expect(getXY(subject.nodes[5].point, 'x', 'y')).toEqual x: 300, y: 200 + 100 + 100 228 | expect(getXY(subject.nodes[6].point, 'x', 'y')).toEqual x: 300 + 100, y: 200 + 100 + 100 229 | expect(getXY(subject.nodes[7].point, 'x', 'y')).toEqual x: 300 + 100 + 150, y: 200 + 100 + 100 230 | expect(getXY(subject.nodes[8].point, 'x', 'y')).toEqual x: 300 + 100 + 150, y: 500 231 | expect(getXY(subject.nodes[9].point, 'x', 'y')).toEqual x: 300 + 100 + 150, y: 600 232 | 233 | it 'parses multple M coordinate sets as lineto commands', -> 234 | path = 'M100,100 200,100 200,200 100,200z' 235 | parsedPath = PathParser.parsePath(path) 236 | expect(parsedPath.subpaths.length).toEqual 1 237 | 238 | subject = parsedPath.subpaths[0] 239 | expect(subject.closed).toEqual true 240 | expect(subject.nodes.length).toEqual 4 241 | 242 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 100, y: 100 243 | expect(subject.nodes[0].handleOut).toBeUndefined() 244 | expect(subject.nodes[0].handleIn).toBeUndefined() 245 | 246 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 200, y: 100 247 | expect(getXY(subject.nodes[2].point, 'x', 'y')).toEqual x: 200, y: 200 248 | expect(getXY(subject.nodes[3].point, 'x', 'y')).toEqual x: 100, y: 200 249 | 250 | it 'parses small m as an absolute move when at the beginning', -> 251 | path = 'm100,100 100,0z m50,50 50,50z' 252 | parsedPath = PathParser.parsePath(path) 253 | expect(parsedPath.subpaths.length).toEqual 2 254 | 255 | subject = parsedPath.subpaths[0] 256 | expect(subject.closed).toEqual true 257 | expect(subject.nodes.length).toEqual 2 258 | 259 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 100, y: 100 260 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 200, y: 100 261 | 262 | subject = parsedPath.subpaths[1] 263 | expect(subject.closed).toEqual true 264 | expect(subject.nodes.length).toEqual 2 265 | 266 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 250, y: 150 267 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 300, y: 200 268 | 269 | it 'parses S and s commands', -> 270 | path = 'M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80 s55-70, 85 0' 271 | parsedPath = PathParser.parsePath(path) 272 | expect(parsedPath.subpaths.length).toEqual 1 273 | 274 | subject = parsedPath.subpaths[0] 275 | expect(subject.closed).toEqual false 276 | expect(subject.nodes.length).toEqual 4 277 | 278 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 10, y: 80 279 | expect(subject.nodes[0].handleIn).toBeUndefined() 280 | expect(getXY(subject.nodes[0].handleOut, 'x', 'y')).toEqual x: 30, y: -70 281 | 282 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 95, y: 80 283 | expect(getXY(subject.nodes[1].handleIn, 'x', 'y')).toEqual x: -30, y: -70 284 | expect(getXY(subject.nodes[1].handleOut, 'x', 'y')).toEqual x: 30, y: 70 285 | 286 | expect(getXY(subject.nodes[2].point, 'x', 'y')).toEqual x: 180, y: 80 287 | expect(getXY(subject.nodes[2].handleIn, 'x', 'y')).toEqual x: -30, y: 70 288 | expect(getXY(subject.nodes[2].handleOut, 'x', 'y')).toEqual x: 30, y: -70 289 | 290 | expect(getXY(subject.nodes[3].point, 'x', 'y')).toEqual x: 265, y: 80 291 | expect(getXY(subject.nodes[3].handleIn, 'x', 'y')).toEqual x: -30, y: -70 292 | expect(subject.nodes[3].handleOut).toBeUndefined() 293 | 294 | it 'parses S and s commands when multiple coordinate sets specified', -> 295 | path = 'M10 80 C 40 10, 65 10, 100 100 s 50 50, 80-20 55-70, 85 0S300,400 350,320 500,100 600,320' 296 | parsedPath = PathParser.parsePath(path) 297 | expect(parsedPath.subpaths.length).toEqual 1 298 | 299 | subject = parsedPath.subpaths[0] 300 | expect(subject.closed).toEqual false 301 | expect(subject.nodes.length).toEqual 6 302 | 303 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 10, y: 80 304 | expect(subject.nodes[0].handleIn).toBeUndefined() 305 | expect(getXY(subject.nodes[0].handleOut, 'x', 'y')).toEqual x: 30, y: -70 306 | 307 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 100, y: 100 308 | expect(getXY(subject.nodes[1].handleIn, 'x', 'y')).toEqual x: -35, y: -90 309 | expect(getXY(subject.nodes[1].handleOut, 'x', 'y')).toEqual x: 35, y: 90 310 | 311 | expect(getXY(subject.nodes[2].point, 'x', 'y')).toEqual x: 180, y: 80 312 | expect(getXY(subject.nodes[2].handleIn, 'x', 'y')).toEqual x: -30, y: 70 313 | expect(getXY(subject.nodes[2].handleOut, 'x', 'y')).toEqual x: 30, y: -70 314 | 315 | expect(getXY(subject.nodes[3].point, 'x', 'y')).toEqual x: 265, y: 80 316 | expect(getXY(subject.nodes[3].handleIn, 'x', 'y')).toEqual x: -30, y: -70 317 | expect(getXY(subject.nodes[3].handleOut, 'x', 'y')).toEqual x: 30, y: 70 318 | 319 | expect(getXY(subject.nodes[4].point, 'x', 'y')).toEqual x: 350, y: 320 320 | expect(getXY(subject.nodes[4].handleIn, 'x', 'y')).toEqual x: -50, y: 80 321 | expect(getXY(subject.nodes[4].handleOut, 'x', 'y')).toEqual x: 50, y: -80 322 | 323 | expect(getXY(subject.nodes[5].point, 'x', 'y')).toEqual x: 600, y: 320 324 | expect(getXY(subject.nodes[5].handleIn, 'x', 'y')).toEqual x: -100, y: -220 325 | expect(subject.nodes[5].handleOut).toBeUndefined() 326 | 327 | it 'parses C and c commands when multiple coordinate sets specified', -> 328 | path = 'M10 80 C 40 10, 65 10, 100 100 45 15, 70 15, 105 105 ' 329 | parsedPath = PathParser.parsePath(path) 330 | expect(parsedPath.subpaths.length).toEqual 1 331 | 332 | subject = parsedPath.subpaths[0] 333 | expect(subject.closed).toEqual false 334 | expect(subject.nodes.length).toEqual 3 335 | 336 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 10, y: 80 337 | expect(subject.nodes[0].handleIn).toBeUndefined() 338 | expect(getXY(subject.nodes[0].handleOut, 'x', 'y')).toEqual x: 30, y: -70 339 | 340 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 100, y: 100 341 | expect(getXY(subject.nodes[1].handleIn, 'x', 'y')).toEqual x: -35, y: -90 342 | expect(getXY(subject.nodes[1].handleOut, 'x', 'y')).toEqual x: -55, y: -85 343 | 344 | expect(getXY(subject.nodes[2].point, 'x', 'y')).toEqual x: 105, y: 105 345 | expect(getXY(subject.nodes[2].handleIn, 'x', 'y')).toEqual x: -35, y: -90 346 | expect(subject.nodes[2].handleOut).toBeUndefined() 347 | 348 | it 'parses Q and T commands', -> 349 | path = 'M10 80 Q 52.5 10, 95 80 T 180 80' 350 | parsedPath = PathParser.parsePath(path) 351 | expect(parsedPath.subpaths.length).toEqual 1 352 | 353 | subject = parsedPath.subpaths[0] 354 | expect(subject.closed).toEqual false 355 | expect(subject.nodes.length).toEqual 3 356 | 357 | expect(getXY(subject.nodes[0].point, 'x', 'y')).toEqual x: 10, y: 80 358 | expect(subject.nodes[0].handleIn).toBeUndefined() 359 | expect(getXY(subject.nodes[0].handleOut, 'x', 'y')).toEqual x: 42.5, y: -70 360 | 361 | expect(getXY(subject.nodes[1].point, 'x', 'y')).toEqual x: 95, y: 80 362 | expect(getXY(subject.nodes[1].handleIn, 'x', 'y')).toEqual x: -42.5, y: -70 363 | expect(getXY(subject.nodes[1].handleOut, 'x', 'y')).toEqual x: 42.5, y: 70 364 | 365 | expect(getXY(subject.nodes[2].point, 'x', 'y')).toEqual x: 180, y: 80 366 | expect(getXY(subject.nodes[2].handleIn, 'x', 'y')).toEqual x: -42.5, y: 70 367 | expect(subject.nodes[2].handleOut).toBeUndefined() 368 | -------------------------------------------------------------------------------- /spec/path-spec.coffee: -------------------------------------------------------------------------------- 1 | SVGDocument = require '../src/svg-document' 2 | 3 | Node = require '../src/node' 4 | Path = require '../src/path' 5 | Point = require '../src/point' 6 | Subpath = require '../src/subpath' 7 | Utils = require '../src/utils' 8 | 9 | describe 'Path', -> 10 | [svg, path] = [] 11 | 12 | beforeEach -> 13 | canvas = document.createElement('div') 14 | jasmine.attachToDOM(canvas) 15 | svg = new SVGDocument(canvas) 16 | 17 | describe "creation", -> 18 | it 'has an id', -> 19 | path = new Path(svg) 20 | expect(path.getID()).toBe "Path-#{path.getModel().id}" 21 | 22 | it 'has empty path string after creation', -> 23 | path = new Path(svg) 24 | expect(path.get('path')).toEqual '' 25 | 26 | it 'registers itself with the document', -> 27 | path = new Path(svg) 28 | expect(svg.getObjects()).toContain path 29 | 30 | it "can be created with attributes", -> 31 | path = new Path(svg, {fill: '#ff0000'}) 32 | 33 | el = path.svgEl 34 | expect(el.attr('fill')).toBe '#ff0000' 35 | 36 | it 'emits an event when it is removed', -> 37 | path = new Path(svg) 38 | path.on 'remove', removeSpy = jasmine.createSpy() 39 | path.remove() 40 | expect(removeSpy).toHaveBeenCalledWith({object: path}) 41 | 42 | it 'has empty path string with empty subpath', -> 43 | path = new Path(svg) 44 | path.model._addSubpath(new Subpath({path})) 45 | expect(path.get('path')).toEqual '' 46 | 47 | it 'can be created', -> 48 | path = new Path(svg) 49 | path.addNode(new Node([50, 50], [-10, 0], [10, 0])) 50 | path.addNode(new Node([80, 60], [-10, -5], [10, 5])) 51 | path.addNode(new Node([60, 80], [10, 0], [-10, 0])) 52 | path.close() 53 | 54 | el = path.svgEl 55 | expect(el.attr('d')).toMatch(/^M50,50C60,50/) 56 | expect(el.attr('d')).toMatch(/Z$/) 57 | 58 | it 'is associated with the node', -> 59 | path = new Path(svg) 60 | path.addNode(new Node([60, 80], [10, 0], [-10, 0])) 61 | path.render() 62 | 63 | el = path.svgEl 64 | expect(Utils.getObjectFromNode(el.node)).toEqual path 65 | 66 | it 'handles null handles', -> 67 | path = new Path(svg) 68 | path.addNode(new Node([60, 80])) 69 | path.addNode(new Node([70, 90])) 70 | path.render() 71 | 72 | el = path.svgEl 73 | expect(el.attr('d')).toEqual('M60,80L70,90') 74 | 75 | describe 'creating from path string', -> 76 | it 'can be created', -> 77 | pathString = 'M50,50C60,50,70,55,80,60C90,65,68,103,60,80C50,80,40,50,50,50Z' 78 | node = svg.getSVGRoot().path(pathString) 79 | path = new Path(svg, svgEl: node) 80 | 81 | expect(path.get('path')).toEqual pathString 82 | 83 | it 'can be created with non-wrapped closed shapes', -> 84 | pathString = 'M10,10C20,10,70,55,80,60C90,65,68,103,60,80C50,80,40,50,50,50Z' 85 | node = svg.getSVGRoot().path(pathString) 86 | path = new Path(svg, svgEl: node) 87 | 88 | expect(path.get('path')).toEqual pathString 89 | 90 | it 'handles move nodes', -> 91 | pathString = 'M50,50C60,50,70,55,80,60C90,65,68,103,60,80Z M10,10C60,50,70,55,50,70C90,65,68,103,60,80Z' 92 | node = svg.getSVGRoot().path(pathString) 93 | path = new Path(svg, svgEl: node) 94 | 95 | expect(path.get('path')).toEqual pathString 96 | 97 | describe "::translate()", -> 98 | beforeEach -> 99 | path = new Path(svg) 100 | path.addNode(new Node([50, 50], [-10, 0], [10, 0])) 101 | path.addNode(new Node([80, 60], [-10, -5], [10, 5])) 102 | path.addNode(new Node([60, 80], [10, 0], [-10, 0])) 103 | path.close() 104 | 105 | it "translates all the nodes", -> 106 | path.model.translate(new Point(-10, 10)) 107 | expect(path.getNodes()[0].getPoint()).toEqual new Point(40, 60) 108 | expect(path.getNodes()[1].getPoint()).toEqual new Point(70, 70) 109 | expect(path.getNodes()[2].getPoint()).toEqual new Point(50, 90) 110 | 111 | describe 'updating via the the node attributes', -> 112 | beforeEach -> 113 | path = new Path(svg) 114 | path.addNode(new Node([60, 60], [-10, 0], [10, 0])) 115 | expect(path.get('path')).toBe 'M60,60' 116 | 117 | it 'updates the model when the path string changes', -> 118 | newPathString = 'M50,50C60,50,70,55,80,60C90,65,70,80,60,80C50,80,40,50,50,50Z' 119 | el = path.svgEl 120 | el.attr('d', newPathString) 121 | path.updateFromAttributes() 122 | 123 | nodes = path.getNodes() 124 | expect(nodes.length).toBe 3 125 | expect(nodes[0].getPoint()).toEqual new Point(50, 50) 126 | expect(nodes[1].getPoint()).toEqual new Point(80, 60) 127 | expect(nodes[2].getPoint()).toEqual new Point(60, 80) 128 | expect(el.attr('d')).toBe newPathString 129 | 130 | it 'updates the model when the transform changes', -> 131 | el = path.svgEl 132 | el.attr(transform: 'translate(10 20)') 133 | path.updateFromAttributes() 134 | 135 | transformString = path.model.get('transform') 136 | expect(transformString).toBe 'translate(10 20)' 137 | 138 | it "updates the node points when the transform changes", -> 139 | el = path.svgEl 140 | el.attr(transform: 'translate(10 20)') 141 | path.updateFromAttributes() 142 | 143 | nodes = path.getNodes() 144 | expect(nodes[0].getPoint()).toEqual new Point(70, 80) 145 | expect(nodes[0].getAbsoluteHandleIn()).toEqual new Point(60, 80) 146 | expect(nodes[0].getAbsoluteHandleOut()).toEqual new Point(80, 80) 147 | 148 | describe 'updating via the model', -> 149 | beforeEach -> 150 | path = new Path(svg) 151 | path.addNode(new Node([50, 50], [-10, 0], [10, 0])) 152 | path.addNode(new Node([80, 60], [-10, -5], [10, 5])) 153 | path.addNode(new Node([60, 80], [10, 0], [-10, 0])) 154 | 155 | it 'renders when node point is updated', -> 156 | path.getSubpaths()[0].nodes[0].setPoint([70, 70]) 157 | el = path.svgEl 158 | expect(el.attr('d')).toMatch(/^M70,70C80,70/) 159 | 160 | it 'kicks out event when changes', -> 161 | spy = jasmine.createSpy() 162 | path.on 'change', spy 163 | path.getSubpaths()[0].nodes[0].setPoint([70, 70]) 164 | expect(spy).toHaveBeenCalled() 165 | 166 | it 'kicks out event when closed', -> 167 | closespy = jasmine.createSpy() 168 | path.on 'change', closespy 169 | path.close() 170 | expect(closespy).toHaveBeenCalled() 171 | 172 | it 'node added adds node to the end', -> 173 | node = new Node([40, 60], [0, 0], [0, 0]) 174 | path.on 'change', spy = jasmine.createSpy() 175 | path.on 'insert:node', insertSpy = jasmine.createSpy() 176 | path.addNode(node) 177 | expect(path.getSubpaths()[0].nodes[3]).toEqual node 178 | expect(spy).toHaveBeenCalled() 179 | expect(insertSpy).toHaveBeenCalled() 180 | expect(insertSpy.calls.mostRecent().args[0].index).toEqual 3 181 | expect(insertSpy.calls.mostRecent().args[0].node).toEqual node 182 | expect(insertSpy.calls.mostRecent().args[0].object).toEqual path 183 | 184 | it 'node inserted inserts node in right place', -> 185 | node = new Node([40, 60], [0, 0], [0, 0]) 186 | spy = jasmine.createSpy() 187 | path.on 'change', spy 188 | path.insertNode(node, 0) 189 | expect(path.getSubpaths()[0].nodes[0]).toEqual node 190 | expect(spy).toHaveBeenCalled() 191 | 192 | it 'removes a node from the correct place', -> 193 | node = new Node([20, 30], [0, 0], [0, 0]) 194 | 195 | subpath = path.createSubpath() 196 | subpath.addNode(node) 197 | 198 | expect(path.getSubpaths()[0].nodes).toHaveLength 3 199 | expect(path.getSubpaths()[1].nodes).toHaveLength 1 200 | expect(path.getSubpaths()[1].nodes[0]).toEqual node 201 | 202 | path.on 'change', changedSpy = jasmine.createSpy() 203 | 204 | path.removeNode(node) 205 | expect(path.getSubpaths()[0].nodes).toHaveLength 3 206 | expect(path.getSubpaths()[1].nodes).toHaveLength 0 207 | 208 | expect(changedSpy).toHaveBeenCalled() 209 | 210 | describe "updating attributes", -> 211 | beforeEach -> 212 | path = new Path(svg, {x: 10, y: 20, width: 200, height: 300, fill: '#ff0000'}) 213 | 214 | it "emits an event with the object, model, old, and new values when changed", -> 215 | path.on('change', changeSpy = jasmine.createSpy()) 216 | 217 | path.set(fill: '#00ff00') 218 | 219 | arg = changeSpy.calls.mostRecent().args[0] 220 | expect(changeSpy).toHaveBeenCalled() 221 | expect(arg.object).toBe path 222 | expect(arg.model).toBe path.model 223 | expect(arg.value).toEqual fill: '#00ff00' 224 | expect(arg.oldValue).toEqual fill: '#ff0000' 225 | 226 | it "can have its fill color changed", -> 227 | el = path.svgEl 228 | expect(el.attr('fill')).toBe '#ff0000' 229 | expect(path.get('fill')).toBe '#ff0000' 230 | 231 | path.set(fill: '#00ff00') 232 | expect(el.attr('fill')).toBe '#00ff00' 233 | expect(path.get('fill')).toBe '#00ff00' 234 | -------------------------------------------------------------------------------- /spec/pen-tool-spec.coffee: -------------------------------------------------------------------------------- 1 | SVGDocument = require '../src/svg-document' 2 | 3 | PenTool = require '../src/pen-tool' 4 | Size = require '../src/size' 5 | Point = require '../src/point' 6 | Path = require '../src/path' 7 | 8 | describe 'PenTool', -> 9 | [tool, svg, canvas, selectionModel] = [] 10 | 11 | beforeEach -> 12 | canvas = document.createElement('div') 13 | jasmine.attachToDOM(canvas) 14 | svg = new SVGDocument(canvas) 15 | selectionModel = svg.getSelectionModel() 16 | tool = new PenTool(svg) 17 | 18 | describe "when activated", -> 19 | beforeEach -> 20 | tool.activate() 21 | expect(selectionModel.getSelected()).toBe null 22 | 23 | it "creates a path when clicking and dragging", -> 24 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousedown', jasmine.buildMouseParams(20, 30))) 25 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(30, 50))) 26 | selected = selectionModel.getSelected() 27 | selectedNode = selectionModel.getSelectedNode() 28 | expect(selected instanceof Path).toBe true 29 | expect(selectedNode).toBe selected.getNodes()[0] 30 | expect(selectedNode.getPoint()).toEqual new Point(20, 30) 31 | expect(selectedNode.getHandleIn()).toEqual new Point(-10, -20) 32 | expect(selectedNode.getHandleOut()).toEqual new Point(10, 20) 33 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mouseup')) 34 | 35 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousedown', jasmine.buildMouseParams(80, 90))) 36 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(50, 100))) 37 | selectedNode = selectionModel.getSelectedNode() 38 | expect(selected instanceof Path).toBe true 39 | expect(selectedNode).toBe selected.getNodes()[1] 40 | expect(selectedNode.getPoint()).toEqual new Point(80, 90) 41 | expect(selectedNode.getHandleIn()).toEqual new Point(30, -10) 42 | expect(selectedNode.getHandleOut()).toEqual new Point(-30, 10) 43 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mouseup')) 44 | 45 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousedown', jasmine.buildMouseParams(85, 95))) 46 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(55, 105))) 47 | selectedNode = selectionModel.getSelectedNode() 48 | expect(selected instanceof Path).toBe true 49 | expect(selectedNode).toBe selected.getNodes()[2] 50 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mouseup')) 51 | 52 | # remove the node, it selects the previous 53 | selected.removeNode(selected.getNodes()[2]) 54 | selectedNode = selectionModel.getSelectedNode() 55 | expect(selectedNode).toBe selected.getNodes()[1] 56 | 57 | # closes when clicking on the first node 58 | nodeEditorElement = svg.getObjectEditor().getActiveEditor().nodeEditors[0].nodeElement.node 59 | xyParams = jasmine.buildMouseParams(20, 30) 60 | nodeEditorElement.dispatchEvent(jasmine.buildMouseEvent('mousedown', xyParams, target: nodeEditorElement)) 61 | 62 | selectedNode = selectionModel.getSelectedNode() 63 | expect(selected.isClosed()).toBe true 64 | expect(selectedNode).toBe selected.getNodes()[0] 65 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mouseup')) 66 | 67 | # now it should create a new Path 68 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousedown', jasmine.buildMouseParams(200, 300))) 69 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(300, 500))) 70 | newSelected = selectionModel.getSelected() 71 | newSelectedNode = selectionModel.getSelectedNode() 72 | expect(newSelected instanceof Path).toBe true 73 | expect(newSelected).not.toBe selected 74 | expect(newSelectedNode).toBe newSelected.getNodes()[0] 75 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mouseup')) 76 | -------------------------------------------------------------------------------- /spec/rectangle-spec.coffee: -------------------------------------------------------------------------------- 1 | SVGDocument = require '../src/svg-document' 2 | 3 | Rectangle = require '../src/rectangle' 4 | Point = require '../src/point' 5 | Size = require '../src/size' 6 | 7 | describe 'Rectangle', -> 8 | [svg, rect] = [] 9 | 10 | beforeEach -> 11 | canvas = document.createElement('div') 12 | jasmine.attachToDOM(canvas) 13 | svg = new SVGDocument(canvas) 14 | 15 | describe "creation", -> 16 | it 'has an id', -> 17 | rect = new Rectangle(svg) 18 | expect(rect.getID()).toBe "Rectangle-#{rect.model.id}" 19 | 20 | it 'registers itself with the document', -> 21 | rect = new Rectangle(svg) 22 | expect(svg.getObjects()).toContain rect 23 | 24 | it 'emits an event when it is removed', -> 25 | rect = new Rectangle(svg) 26 | rect.on 'remove', removeSpy = jasmine.createSpy() 27 | rect.remove() 28 | expect(removeSpy).toHaveBeenCalledWith({object: rect}) 29 | 30 | it 'can be created with no parameters', -> 31 | rect = new Rectangle(svg) 32 | 33 | el = rect.svgEl 34 | expect(el.attr('x')).toBe 0 35 | expect(el.attr('y')).toBe 0 36 | expect(el.attr('width')).toBe 10 37 | expect(el.attr('height')).toBe 10 38 | 39 | it 'can be created with parameters', -> 40 | rect = new Rectangle(svg, {x: 10, y: 20, width: 200, height: 300, fill: '#ff0000'}) 41 | 42 | el = rect.svgEl 43 | expect(el.attr('x')).toBe 10 44 | expect(el.attr('y')).toBe 20 45 | expect(el.attr('width')).toBe 200 46 | expect(el.attr('height')).toBe 300 47 | expect(el.attr('fill')).toBe '#ff0000' 48 | 49 | expect(rect.get('position')).toEqual Point.create(10, 20) 50 | expect(rect.get('size')).toEqual Size.create(200, 300) 51 | 52 | describe "updating attributes", -> 53 | beforeEach -> 54 | rect = new Rectangle(svg, {x: 10, y: 20, width: 200, height: 300, fill: '#ff0000'}) 55 | 56 | it "emits an event with the object, model, old, and new values when changed", -> 57 | rect.on('change', changeSpy = jasmine.createSpy()) 58 | rect.set(fill: '#00ff00') 59 | 60 | arg = changeSpy.calls.mostRecent().args[0] 61 | expect(changeSpy).toHaveBeenCalled() 62 | expect(arg.object).toBe rect 63 | expect(arg.model).toBe rect.model 64 | expect(arg.value).toEqual fill: '#00ff00' 65 | expect(arg.oldValue).toEqual fill: '#ff0000' 66 | 67 | it "can have its fill color changed", -> 68 | el = rect.svgEl 69 | expect(el.attr('fill')).toBe '#ff0000' 70 | 71 | rect.set(fill: '#00ff00') 72 | expect(el.attr('fill')).toBe '#00ff00' 73 | expect(rect.get('fill')).toBe '#00ff00' 74 | -------------------------------------------------------------------------------- /spec/selection-model-spec.coffee: -------------------------------------------------------------------------------- 1 | {Emitter} = require 'event-kit' 2 | SelectionModel = require '../src/selection-model' 3 | 4 | SVGDocument = require '../src/svg-document' 5 | Node = require '../src/node' 6 | Path = require '../src/path' 7 | 8 | describe 'SelectionModel', -> 9 | [model, path, onSelected, onSelectedNode, svg] = [] 10 | beforeEach -> 11 | canvas = document.createElement('div') 12 | svg = new SVGDocument(canvas) 13 | 14 | model = new SelectionModel() 15 | path = {id: 1} 16 | 17 | model.on 'change:selected', onSelected = jasmine.createSpy() 18 | model.on 'change:selectedNode', onSelectedNode = jasmine.createSpy() 19 | 20 | it 'fires events when changing selection', -> 21 | model.setSelected(path) 22 | 23 | expect(onSelected).toHaveBeenCalled() 24 | expect(onSelected.calls.mostRecent().args[0]).toEqual object: path, old: null 25 | 26 | it 'fires events when changing selected node', -> 27 | node = {omg: 1} 28 | model.setSelectedNode(node) 29 | 30 | expect(onSelectedNode).toHaveBeenCalled() 31 | expect(onSelectedNode.calls.mostRecent().args[0]).toEqual node: node, old: null 32 | 33 | it 'sends proper old value through when unset', -> 34 | node = {omg: 1} 35 | model.setSelectedNode(node) 36 | model.setSelectedNode(null) 37 | 38 | expect(onSelectedNode).toHaveBeenCalled() 39 | expect(onSelectedNode.calls.mostRecent().args[0]).toEqual node: null, old: node 40 | 41 | it "deselects the object when it's been removed", -> 42 | emitter = new Emitter 43 | path.on = (args...) -> emitter.on(args...) 44 | 45 | model.setSelected(path) 46 | expect(model.getSelected()).toBe path 47 | 48 | emitter.emit('remove', {object: path}) 49 | expect(model.getSelected()).toBe null 50 | 51 | it "deselects the node when it's been removed", -> 52 | path = new Path(svg) 53 | path.addNode(new Node([50, 50])) 54 | path.addNode(new Node([80, 60])) 55 | path.addNode(new Node([60, 80])) 56 | 57 | node = path.getNodes()[1] 58 | model.setSelected(path) 59 | model.setSelectedNode(node) 60 | expect(model.getSelected()).toBe path 61 | expect(model.getSelectedNode()).toBe node 62 | 63 | path.removeNode(path.getNodes()[0]) 64 | expect(model.getSelected()).toBe path 65 | expect(model.getSelectedNode()).toBe node 66 | 67 | path.removeNode(node) 68 | expect(model.getSelected()).toBe path 69 | expect(model.getSelectedNode()).toBe null 70 | -------------------------------------------------------------------------------- /spec/selection-view-spec.coffee: -------------------------------------------------------------------------------- 1 | SVGDocument = require '../src/svg-document' 2 | 3 | Node = require '../src/node' 4 | Path = require '../src/path' 5 | SelectionModel = require '../src/selection-model' 6 | SelectionView = require '../src/selection-view' 7 | 8 | describe 'SelectionView', -> 9 | [path, canvas, model, svgDocument] = [] 10 | 11 | beforeEach -> 12 | canvas = document.createElement('div') 13 | jasmine.attachToDOM(canvas) 14 | svgDocument = new SVGDocument(canvas) 15 | 16 | model = svgDocument.getSelectionModel() 17 | selectionView = svgDocument.getSelectionView() 18 | path = new Path(svgDocument) 19 | path.addNode(new Node([50, 50], [-10, 0], [10, 0])) 20 | path.close() 21 | 22 | it 'creates nodes when PREselecting and cleans up when selecting nothing', -> 23 | expect(canvas.querySelectorAll('svg path.object-preselection')).toHaveLength 0 24 | 25 | model.setPreselected(path) 26 | expect(canvas.querySelectorAll('svg path.object-preselection')).toHaveLength 1 27 | 28 | model.clearPreselected() 29 | expect(canvas.querySelectorAll('svg path.object-preselection')).toHaveLength 0 30 | -------------------------------------------------------------------------------- /spec/shape-editor-spec.coffee: -------------------------------------------------------------------------------- 1 | SVGDocument = require '../src/svg-document' 2 | Node = require '../src/node' 3 | Rectangle = require '../src/rectangle' 4 | Ellipse = require '../src/ellipse' 5 | Point = require '../src/point' 6 | Size = require '../src/size' 7 | ShapeEditor = require '../src/shape-editor' 8 | 9 | describe 'ShapeEditor', -> 10 | [svgDocument, canvas, object, editor] = [] 11 | beforeEach -> 12 | canvas = document.createElement('div') 13 | jasmine.attachToDOM(canvas) 14 | svgDocument = new SVGDocument(canvas) 15 | 16 | describe "when using a Ellipse object", -> 17 | beforeEach -> 18 | editor = new ShapeEditor(svgDocument) 19 | object = new Ellipse(svgDocument, x: 20, y: 20, width: 100, height: 200) 20 | editor.activateObject(object) 21 | 22 | it 'creates nodes when selecting and cleans up when selecting nothing', -> 23 | expect(canvas.querySelector('svg rect.shape-editor-handle')).toShow() 24 | expect(canvas.querySelector('svg rect.object-selection')).toShow() 25 | 26 | editor.deactivate() 27 | expect(canvas.querySelector('svg rect.shape-editor-handle')).toHide() 28 | expect(canvas.querySelector('svg rect.object-selection')).toBe null 29 | 30 | describe "when using a Rectangle object", -> 31 | beforeEach -> 32 | editor = new ShapeEditor(svgDocument) 33 | object = new Rectangle(svgDocument, x: 20, y: 20, width: 100, height: 200) 34 | editor.activateObject(object) 35 | 36 | it 'creates nodes when selecting and cleans up when selecting nothing', -> 37 | expect(canvas.querySelector('svg rect.shape-editor-handle')).toShow() 38 | expect(canvas.querySelector('svg rect.object-selection')).toShow() 39 | 40 | editor.deactivate() 41 | expect(canvas.querySelector('svg rect.shape-editor-handle')).toHide() 42 | expect(canvas.querySelector('svg rect.object-selection')).toBe null 43 | 44 | it "updates the object and the handles when dragging the Top Left handles", -> 45 | cornerHandle = editor.cornerHandles.members[0] 46 | cornerHandleNode = cornerHandle.node 47 | xyParams = jasmine.buildMouseParams(20, 20) 48 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mousedown', xyParams, target: cornerHandleNode)) 49 | 50 | xyParams = jasmine.buildMouseParams(10, 15) 51 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mousemove', xyParams, target: cornerHandleNode)) 52 | expect(object.get('position')).toEqual new Point(10, 15) 53 | expect(object.get('size')).toEqual new Size(110, 205) 54 | expect(cornerHandle.attr('x')).toBe 10 - editor.handleSize / 2 55 | expect(cornerHandle.attr('y')).toBe 15 - editor.handleSize / 2 56 | 57 | xyParams = jasmine.buildMouseParams(200, 300) 58 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mousemove', xyParams, target: cornerHandleNode)) 59 | expect(object.get('position')).toEqual new Point(120, 220) 60 | expect(object.get('size')).toEqual new Size(80, 80) 61 | expect(cornerHandle.attr('x')).toBe 120 - editor.handleSize / 2 62 | expect(cornerHandle.attr('y')).toBe 220 - editor.handleSize / 2 63 | 64 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mouseup', xyParams)) 65 | 66 | it "updates the object and the handles when dragging the Top Right handles", -> 67 | # Top right 68 | cornerHandle = editor.cornerHandles.members[1] 69 | cornerHandleNode = cornerHandle.node 70 | xyParams = jasmine.buildMouseParams(120, 20) 71 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mousedown', xyParams, target: cornerHandleNode)) 72 | 73 | xyParams = jasmine.buildMouseParams(130, 10) 74 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mousemove', xyParams, target: cornerHandleNode)) 75 | expect(object.get('position')).toEqual new Point(20, 10) 76 | expect(object.get('size')).toEqual new Size(110, 210) 77 | expect(cornerHandle.attr('x')).toBe 130 - editor.handleSize / 2 78 | expect(cornerHandle.attr('y')).toBe 10 - editor.handleSize / 2 79 | 80 | xyParams = jasmine.buildMouseParams(0, 230) 81 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mousemove', xyParams, target: cornerHandleNode)) 82 | expect(object.get('position')).toEqual new Point(0, 220) 83 | expect(object.get('size')).toEqual new Size(20, 10) 84 | expect(cornerHandle.attr('x')).toBe 20 - editor.handleSize / 2 85 | expect(cornerHandle.attr('y')).toBe 220 - editor.handleSize / 2 86 | 87 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mouseup', xyParams)) 88 | 89 | it "constrains to 1:1 proportion when shift is held while dragging", -> 90 | # bottom right 91 | cornerHandle = editor.cornerHandles.members[2] 92 | cornerHandleNode = cornerHandle.node 93 | xyParams = jasmine.buildMouseParams(120, 220) 94 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mousedown', xyParams, target: cornerHandleNode)) 95 | 96 | xyParams = jasmine.buildMouseParams(100, 300) 97 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mousemove', xyParams, target: cornerHandleNode, shiftKey: true)) 98 | expect(object.get('position')).toEqual new Point(20, 20) 99 | expect(object.get('size')).toEqual new Size(80, 80) 100 | expect(cornerHandle.attr('x')).toBe 100 - editor.handleSize / 2 101 | expect(cornerHandle.attr('y')).toBe 100 - editor.handleSize / 2 102 | 103 | xyParams = jasmine.buildMouseParams(300, 100) 104 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mousemove', xyParams, target: cornerHandleNode, shiftKey: true)) 105 | expect(object.get('position')).toEqual new Point(20, 20) 106 | expect(object.get('size')).toEqual new Size(80, 80) 107 | expect(cornerHandle.attr('x')).toBe 100 - editor.handleSize / 2 108 | expect(cornerHandle.attr('y')).toBe 100 - editor.handleSize / 2 109 | 110 | cornerHandleNode.dispatchEvent(jasmine.buildMouseEvent('mouseup', xyParams)) 111 | -------------------------------------------------------------------------------- /spec/shape-tool-spec.coffee: -------------------------------------------------------------------------------- 1 | SVGDocument = require '../src/svg-document' 2 | 3 | ShapeTool = require '../src/shape-tool' 4 | SelectionModel = require '../src/selection-model' 5 | Size = require '../src/size' 6 | Point = require '../src/point' 7 | Rectangle = require '../src/rectangle' 8 | Ellipse = require '../src/ellipse' 9 | 10 | describe 'ShapeTool', -> 11 | [tool, svg, canvas, selectionModel] = [] 12 | 13 | beforeEach -> 14 | canvas = document.createElement('div') 15 | jasmine.attachToDOM(canvas) 16 | svg = new SVGDocument(canvas) 17 | selectionModel = svg.getSelectionModel() 18 | tool = new ShapeTool(svg) 19 | 20 | it "has a crosshair cursor when activated", -> 21 | expect(svg.getSVGRoot().node.style.cursor).toBe '' 22 | tool.activate('rectangle') 23 | expect(svg.getSVGRoot().node.style.cursor).toBe 'crosshair' 24 | tool.deactivate() 25 | expect(svg.getSVGRoot().node.style.cursor).toBe '' 26 | 27 | it "emits a `cancel` event when clicking without creating a shape", -> 28 | tool.on 'cancel', cancelSpy = jasmine.createSpy() 29 | 30 | tool.activate() 31 | svgNode = svg.getSVGRoot().node 32 | svgNode.dispatchEvent(jasmine.buildMouseEvent('mousedown', jasmine.buildMouseParams(20, 30))) 33 | svgNode.dispatchEvent(jasmine.buildMouseEvent('mouseup')) 34 | 35 | expect(cancelSpy).toHaveBeenCalled() 36 | 37 | describe "when activated with Rectangle", -> 38 | beforeEach -> 39 | tool.activate('rectangle') 40 | expect(selectionModel.getSelected()).toBe null 41 | 42 | it "does not create an object unless dragging for >= 5px", -> 43 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousedown', jasmine.buildMouseParams(20, 30))) 44 | selected = selectionModel.getSelected() 45 | expect(selected).toBe null 46 | 47 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(21, 34))) 48 | selected = selectionModel.getSelected() 49 | expect(selected).toBe null 50 | 51 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(21, 36))) 52 | selected = selectionModel.getSelected() 53 | expect(selected).not.toBe null 54 | 55 | it "creates a rectangle when dragging", -> 56 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousedown', jasmine.buildMouseParams(20, 30))) 57 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(30, 50))) 58 | selected = selectionModel.getSelected() 59 | expect(selected instanceof Rectangle).toBe true 60 | expect(selected.get('position')).toEqual new Point(20, 30) 61 | expect(selected.get('size')).toEqual new Size(10, 20) 62 | 63 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(10, 50))) 64 | expect(selected.get('position')).toEqual new Point(10, 30) 65 | expect(selected.get('size')).toEqual new Size(10, 20) 66 | 67 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(10, 10))) 68 | expect(selected.get('position')).toEqual new Point(10, 10) 69 | expect(selected.get('size')).toEqual new Size(10, 20) 70 | 71 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(30, 10))) 72 | expect(selected.get('position')).toEqual new Point(20, 10) 73 | expect(selected.get('size')).toEqual new Size(10, 20) 74 | 75 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mouseup')) 76 | 77 | # mousemove events dont change it after the mouse up 78 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(200, 150))) 79 | expect(selected.get('position')).toEqual new Point(20, 10) 80 | expect(selected.get('size')).toEqual new Size(10, 20) 81 | 82 | it "constrains the proportion to 1:1 when shift is held down", -> 83 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousedown', jasmine.buildMouseParams(0, 0))) 84 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(30, 50), shiftKey: true)) 85 | selected = selectionModel.getSelected() 86 | expect(selected.get('size')).toEqual new Size(30, 30) 87 | 88 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mouseup')) 89 | 90 | describe "when activated with Rectangle", -> 91 | beforeEach -> 92 | tool.activate('ellipse') 93 | expect(selectionModel.getSelected()).toBe null 94 | 95 | it "creates an Ellipse when dragging", -> 96 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousedown', jasmine.buildMouseParams(20, 30))) 97 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(30, 50))) 98 | selected = selectionModel.getSelected() 99 | expect(selected instanceof Ellipse).toBe true 100 | expect(selected.get('position')).toEqual new Point(20, 30) 101 | expect(selected.get('size')).toEqual new Size(10, 20) 102 | 103 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mousemove', jasmine.buildMouseParams(10, 50))) 104 | expect(selected.get('position')).toEqual new Point(10, 30) 105 | expect(selected.get('size')).toEqual new Size(10, 20) 106 | 107 | svg.getSVGRoot().node.dispatchEvent(jasmine.buildMouseEvent('mouseup')) 108 | -------------------------------------------------------------------------------- /spec/spec-helper.coffee: -------------------------------------------------------------------------------- 1 | require '../vendor/svg' 2 | require '../src/ext/svg-circle' 3 | require '../src/ext/svg-draggable' 4 | require "../vendor/svg.parser" 5 | require "../vendor/svg.export" 6 | 7 | util = require 'util' 8 | ObjectAssign = require 'object-assign' 9 | 10 | jasmine.buildMouseEvent = (type, properties...) -> 11 | properties = ObjectAssign({bubbles: true, cancelable: true}, properties...) 12 | properties.detail ?= 1 13 | event = new MouseEvent(type, properties) 14 | Object.defineProperty(event, 'which', get: -> properties.which) if properties.which? 15 | if properties.target? 16 | Object.defineProperty(event, 'target', get: -> properties.target) 17 | Object.defineProperty(event, 'srcObject', get: -> properties.target) 18 | if properties.pageX? 19 | Object.defineProperty(event, 'pageX', get: -> properties.pageX) 20 | if properties.pageY? 21 | Object.defineProperty(event, 'pageY', get: -> properties.pageY) 22 | if properties.offsetX? 23 | Object.defineProperty(event, 'offsetX', get: -> properties.offsetX) 24 | if properties.offsetY? 25 | Object.defineProperty(event, 'offsetY', get: -> properties.offsetY) 26 | event 27 | 28 | jasmine.buildMouseParams = (x, y) -> 29 | pageX: x 30 | pageY: y 31 | offsetX: x 32 | offsetY: y 33 | 34 | beforeEach -> 35 | jasmine.addMatchers 36 | toShow: -> 37 | compare: (actual) -> 38 | pass = getComputedStyle(actual)['display'] isnt 'none' 39 | {pass} 40 | 41 | toHide: -> 42 | compare: (actual) -> 43 | pass = getComputedStyle(actual)['display'] is 'none' 44 | {pass} 45 | 46 | toHaveLength: -> 47 | compare: (actual, expectedValue) -> 48 | actualValue = actual.length 49 | pass = actualValue is expectedValue 50 | notStr = if pass then ' not' else '' 51 | message = "Expected array with length #{actualValue} to#{notStr} have length #{expectedValue}" 52 | {pass, message} 53 | 54 | toHaveAttr: -> 55 | compare: (actual, attr, expectedValue) -> 56 | actualValue = actual.getAttribute(attr) 57 | pass = actualValue is expectedValue 58 | notStr = if pass then ' not' else '' 59 | message = "Expected attr '#{attr}' to#{notStr} be #{JSON.stringify(expectedValue)} but it was #{JSON.stringify(actualValue)}" 60 | {pass, message} 61 | -------------------------------------------------------------------------------- /spec/subpath-spec.coffee: -------------------------------------------------------------------------------- 1 | Node = require '../src/node' 2 | Point = require '../src/point' 3 | Subpath = require '../src/subpath' 4 | PathParser = require '../src/path-parser' 5 | 6 | describe 'Subpath', -> 7 | path = null 8 | describe 'toPathString', -> 9 | it 'outputs empty path string with NO nodes', -> 10 | subpath = new Subpath() 11 | expect(subpath.toPathString()).toEqual '' 12 | 13 | it 'outputs empty path string closed with NO nodes', -> 14 | subpath = new Subpath() 15 | subpath.close() 16 | expect(subpath.toPathString()).toEqual '' 17 | 18 | it 'outputs correct path string with nodes', -> 19 | subpath = new Subpath() 20 | subpath.addNode(new Node([50, 50], [-10, 0], [10, 0])) 21 | subpath.addNode(new Node([80, 60], [-10, -5], [10, 5])) 22 | subpath.addNode(new Node([60, 80], [10, 0], [-10, 0])) 23 | subpath.close() 24 | 25 | pathStr = subpath.toPathString() 26 | expect(pathStr).toMatch(/^M50,50C60,50/) 27 | expect(pathStr).toMatch(/Z$/) 28 | 29 | describe 'from parsed path', -> 30 | it 'uses shorthand commands rather than all beziers', -> 31 | path = 'M512,384L320,576h128v320h128V576H704L512,384z' 32 | parsedPath = PathParser.parsePath(path) 33 | path = new Subpath(parsedPath.subpaths[0]) 34 | 35 | pathString = path.toPathString() 36 | expect(pathString).toEqual 'M512,384L320,576H448V896H576V576H704Z' 37 | 38 | describe 'creating', -> 39 | it 'can be created with nodes and close', -> 40 | nodes = [ 41 | new Node([50, 50], [-10, 0], [10, 0]) 42 | new Node([80, 60], [-10, -5], [10, 5]) 43 | new Node([60, 80], [10, 0], [-10, 0]) 44 | ] 45 | path = new Subpath({closed: true, nodes}) 46 | 47 | expect(path.closed).toEqual true 48 | expect(path.nodes.length).toEqual 3 49 | 50 | describe "::translate()", -> 51 | beforeEach -> 52 | path = new Subpath() 53 | path.addNode(new Node([50, 50], [-10, 0], [10, 0])) 54 | path.addNode(new Node([80, 60], [-10, -5], [10, 5])) 55 | path.addNode(new Node([60, 80], [10, 0], [-10, 0])) 56 | path.close() 57 | 58 | it "translates all the nodes", -> 59 | path.translate(new Point(-10, 10)) 60 | expect(path.getNodes()[0].getPoint()).toEqual new Point(40, 60) 61 | expect(path.getNodes()[1].getPoint()).toEqual new Point(70, 70) 62 | expect(path.getNodes()[2].getPoint()).toEqual new Point(50, 90) 63 | 64 | describe 'updating', -> 65 | beforeEach -> 66 | path = new Subpath() 67 | path.addNode(new Node([50, 50], [-10, 0], [10, 0])) 68 | path.addNode(new Node([80, 60], [-10, -5], [10, 5])) 69 | path.addNode(new Node([60, 80], [10, 0], [-10, 0])) 70 | path.close() 71 | 72 | it 'kicks out event when changes', -> 73 | spy = jasmine.createSpy() 74 | path.on 'change', spy 75 | path.nodes[0].setPoint([70, 70]) 76 | expect(spy).toHaveBeenCalled() 77 | 78 | it 'kicks out event when closed', -> 79 | closespy = jasmine.createSpy() 80 | changespy = jasmine.createSpy() 81 | path.on 'change', changespy 82 | path.close() 83 | expect(changespy).toHaveBeenCalled() 84 | 85 | it 'adds a node to the end with addNode and emits an event', -> 86 | node = new Node([40, 60], [0, 0], [0, 0]) 87 | 88 | spy = jasmine.createSpy() 89 | path.on 'insert:node', spy 90 | 91 | path.addNode(node) 92 | 93 | expect(path.nodes[3]).toEqual node 94 | expect(spy.calls.mostRecent().args[0]).toEqual 95 | subpath: path 96 | index: 3 97 | node: node 98 | 99 | it 'removes a node with removeNode and emits an event', -> 100 | node = path.nodes[1] 101 | path.on 'remove:node', removedSpy = jasmine.createSpy() 102 | path.on 'change', changedSpy = jasmine.createSpy() 103 | path.removeNode(node) 104 | 105 | expect(changedSpy).toHaveBeenCalled() 106 | expect(removedSpy.calls.mostRecent().args[0]).toEqual 107 | subpath: path 108 | index: 1 109 | node: node 110 | 111 | expect(path.nodes.indexOf(node)).toBe -1 112 | expect(path.nodes).toHaveLength 2 113 | 114 | it 'node inserted inserts node in right place', -> 115 | node = new Node([40, 60], [0, 0], [0, 0]) 116 | 117 | spy = jasmine.createSpy() 118 | path.on 'insert:node', spy 119 | 120 | path.insertNode(node, 0) 121 | 122 | expect(path.nodes[0]).toEqual node 123 | expect(spy.calls.mostRecent().args[0]).toEqual 124 | subpath: path 125 | index: 0 126 | node: node 127 | 128 | it 'nodes can be replaced', -> 129 | path.on 'change', changeSpy = jasmine.createSpy() 130 | 131 | nodes = [new Node([20, 30], [-10, 0], [10, 0])] 132 | path.setNodes(nodes) 133 | 134 | expect(changeSpy).toHaveBeenCalled() 135 | expect(changeSpy.calls.count()).toEqual 1 136 | nodes[0].setPoint([70, 70]) 137 | expect(changeSpy.calls.count()).toEqual 2 138 | -------------------------------------------------------------------------------- /spec/svg-document-spec.coffee: -------------------------------------------------------------------------------- 1 | SVGDocument = require '../src/svg-document' 2 | Rectangle = require '../src/rectangle' 3 | Ellipse = require '../src/ellipse' 4 | Path = require '../src/path' 5 | Node = require '../src/node' 6 | Size = require '../src/size' 7 | Point = require '../src/point' 8 | 9 | describe 'Curve.SVGDocument', -> 10 | [svg, canvas] = [] 11 | beforeEach -> 12 | canvas = document.createElement('div') 13 | jasmine.attachToDOM(canvas) 14 | svg = new SVGDocument(canvas) 15 | 16 | it 'has a tool layer', -> 17 | expect(canvas.querySelector('svg>.tool-layer')).toBeDefined() 18 | 19 | describe 'when the document is empty', -> 20 | beforeEach -> 21 | rect = new Rectangle(svg, {x: 20, y: 30, width: 45, height: 55}) 22 | 23 | it "creates a new object layer with a default size", -> 24 | children = svg.getObjectLayer().node.childNodes 25 | expect(children.length).toBe 1 26 | expect(children[0].nodeName).toBe 'rect' 27 | 28 | expect(svg.getObjectLayer().width()).toBe 1024 29 | expect(svg.getObjectLayer().height()).toBe 1024 30 | 31 | it "exports with the correct xmlns", -> 32 | svgString = svg.serialize().trim() 33 | expect(svgString).toContain 'xmlns="http://www.w3.org/2000/svg"' 34 | 35 | 36 | describe 'reading svg', -> 37 | beforeEach -> 38 | 39 | it 'will deserialize an svg document', -> 40 | svg.deserialize(DOCUMENT) 41 | 42 | expect(canvas.querySelector('svg>svg')).toBeDefined() 43 | expect(canvas.querySelector('svg>svg #arrow')).toBeDefined() 44 | 45 | it 'places tool things in the tool layer', -> 46 | svg.deserialize(DOCUMENT) 47 | 48 | object = svg.getObjects()[0] 49 | svg.selectionModel.setSelected(object) 50 | svg.selectionModel.setSelectedNode(object.getSubpaths()[0].nodes[0]) 51 | 52 | expect(canvas.querySelector('.tool-layer .node-editor-node')).toBeDefined() 53 | expect(canvas.querySelector('.tool-layer .object-selection')).toBeDefined() 54 | 55 | describe "when there are ellipses and circles", -> 56 | it "parses out the ellipses and circles", -> 57 | svgString = ''' 58 | 59 | 60 | 61 | 62 | ''' 63 | 64 | svg.deserialize(svgString) 65 | expect(svg.getObjects()).toHaveLength 2 66 | 67 | ellipse = svg.getObjects()[0] 68 | expect(ellipse instanceof Ellipse).toBe true 69 | expect(ellipse.get('size')).toEqual new Size(20, 40) 70 | expect(ellipse.get('position')).toEqual new Point(90, 55) 71 | 72 | circle = svg.getObjects()[1] 73 | expect(circle instanceof Ellipse).toBe true 74 | expect(circle.get('size')).toEqual new Size(100, 100) 75 | expect(circle.get('position')).toEqual new Point(150, 125) 76 | 77 | describe 'exporting svg', -> 78 | it 'will export an svg document', -> 79 | svg.deserialize(DOCUMENT) 80 | expect(svg.serialize().trim()).toEqual DOCUMENT_WITH_XML_DOCTYPE 81 | 82 | it 'serializing and deserializing is symmetrical', -> 83 | svg.deserialize(DOCUMENT_WITH_XML_DOCTYPE) 84 | expect(svg.serialize().trim()).toEqual DOCUMENT_WITH_XML_DOCTYPE 85 | 86 | describe 'document size', -> 87 | beforeEach -> 88 | svg.deserialize(DOCUMENT) 89 | 90 | it "initially has the width and height", -> 91 | expect(svg.getSize()).toEqual new Size(1024, 1024) 92 | 93 | it "sets height and width on the document when changed", -> 94 | svg.on 'change:size', sizeChangeSpy = jasmine.createSpy() 95 | 96 | svg.setSize(Size.create(1000, 1050)) 97 | root = svg.getObjectLayer() 98 | expect(root.width()).toBe 1000 99 | expect(root.height()).toBe 1050 100 | 101 | expect(sizeChangeSpy).toHaveBeenCalled() 102 | 103 | size = sizeChangeSpy.calls.mostRecent().args[0].size 104 | expect(svg.getSize()).toEqual new Size(1000, 1050) 105 | 106 | describe "changes in the document", -> 107 | beforeEach -> 108 | svg.deserialize(DOCUMENT) 109 | 110 | it "emits a change event when anything in the document changes", -> 111 | svg.on 'change', documentChangeSpy = jasmine.createSpy() 112 | 113 | object = svg.getObjects()[0] 114 | node = object.getSubpaths()[0].nodes[0] 115 | node.setPoint(new Point(200, 250)) 116 | 117 | expect(documentChangeSpy).toHaveBeenCalled() 118 | 119 | it "emits a change event when a new object is added and then removed", -> 120 | svg.on 'change', documentChangeSpy = jasmine.createSpy() 121 | 122 | object = new Rectangle(svg, {width: 10, height: 35}) 123 | expect(documentChangeSpy).toHaveBeenCalled() 124 | expect(svg.getObjects()).toContain object 125 | 126 | documentChangeSpy.calls.reset() 127 | object.remove() 128 | expect(documentChangeSpy).toHaveBeenCalled() 129 | expect(svg.getObjects()).not.toContain object 130 | 131 | describe "changing tools", -> 132 | [pointerTool, shapeTool] = [] 133 | beforeEach -> 134 | svg.initializeTools() 135 | shapeTool = svg.toolForType('shape') 136 | pointerTool = svg.toolForType('pointer') 137 | 138 | spyOn(pointerTool, 'activate').and.callThrough() 139 | spyOn(pointerTool, 'deactivate').and.callThrough() 140 | spyOn(shapeTool, 'activate').and.callThrough() 141 | spyOn(shapeTool, 'deactivate').and.callThrough() 142 | 143 | it "can switch to different tools", -> 144 | svg.on 'change:tool', toolChangeSpy = jasmine.createSpy() 145 | 146 | expect(svg.getActiveToolType()).toBe 'pointer' 147 | 148 | svg.setActiveToolType('rectangle') 149 | expect(toolChangeSpy).toHaveBeenCalledWith(toolType: 'rectangle') 150 | expect(pointerTool.activate).not.toHaveBeenCalled() 151 | expect(pointerTool.deactivate).toHaveBeenCalled() 152 | expect(shapeTool.activate).toHaveBeenCalledWith('rectangle') 153 | expect(shapeTool.deactivate).not.toHaveBeenCalled() 154 | expect(svg.getActiveToolType()).toBe 'rectangle' 155 | 156 | shapeTool.activate.calls.reset() 157 | pointerTool.deactivate.calls.reset() 158 | toolChangeSpy.calls.reset() 159 | svg.setActiveToolType('pointer') 160 | expect(toolChangeSpy).toHaveBeenCalledWith(toolType: 'pointer') 161 | expect(pointerTool.activate).toHaveBeenCalled() 162 | expect(pointerTool.deactivate).not.toHaveBeenCalled() 163 | expect(shapeTool.activate).not.toHaveBeenCalled() 164 | expect(shapeTool.deactivate).toHaveBeenCalled() 165 | expect(svg.getActiveToolType()).toBe 'pointer' 166 | 167 | it "will not switch to non-existent tools", -> 168 | svg.setActiveToolType('junk') 169 | expect(svg.getActiveToolType()).toBe 'pointer' 170 | expect(pointerTool.deactivate).not.toHaveBeenCalled() 171 | 172 | it "will not call deactivate when attempting to switch to the same", -> 173 | svg.setActiveToolType('pointer') 174 | expect(svg.getActiveToolType()).toBe 'pointer' 175 | expect(pointerTool.deactivate).not.toHaveBeenCalled() 176 | 177 | it "will only call deactivate when attempting to switch to the same tool when it supports multiple types", -> 178 | svg.setActiveToolType('rectangle') 179 | expect(svg.getActiveToolType()).toBe 'rectangle' 180 | svg.setActiveToolType('rectangle') 181 | expect(svg.getActiveToolType()).toBe 'rectangle' 182 | expect(shapeTool.deactivate).not.toHaveBeenCalled() 183 | 184 | svg.setActiveToolType('ellipse') 185 | expect(svg.getActiveToolType()).toBe 'ellipse' 186 | expect(shapeTool.deactivate).toHaveBeenCalled() 187 | 188 | describe '::translateSelectedObjects', -> 189 | [object] = [] 190 | beforeEach -> 191 | object = new Rectangle(svg, {x: 20, y: 30}) 192 | 193 | it "does nothing when there is no selected object", -> 194 | svg.translateSelectedObjects([10, 0]) 195 | 196 | it "translates the selected object by the point specified", -> 197 | expect(object.get('position')).toEqual new Point(20, 30) 198 | 199 | svg.selectionModel.setSelected(object) 200 | svg.translateSelectedObjects([20, 0]) 201 | 202 | expect(object.get('position')).toEqual new Point(40, 30) 203 | 204 | it "translates the selected node by the point when a node is selected", -> 205 | object = new Path(svg) 206 | object.addNode(new Node([20, 30])) 207 | object.addNode(new Node([30, 30])) 208 | object.addNode(new Node([30, 40])) 209 | object.addNode(new Node([20, 40])) 210 | object.close() 211 | expect(object.getNodes()[0].getPoint()).toEqual new Point(20, 30) 212 | expect(object.getNodes()[1].getPoint()).toEqual new Point(30, 30) 213 | 214 | svg.selectionModel.setSelected(object) 215 | svg.selectionModel.setSelectedNode(object.getNodes()[1]) 216 | svg.translateSelectedObjects([20, 0]) 217 | expect(object.getNodes()[0].getPoint()).toEqual new Point(20, 30) 218 | expect(object.getNodes()[1].getPoint()).toEqual new Point(50, 30) 219 | 220 | describe '::removeSelectedObjects', -> 221 | [object] = [] 222 | beforeEach -> 223 | object = new Rectangle(svg, {x: 20, y: 30}) 224 | 225 | it "does nothing when there is no selected object", -> 226 | svg.removeSelectedObjects() 227 | 228 | it "removes the selected objects", -> 229 | expect(svg.getObjects()).toContain object 230 | svg.selectionModel.setSelected(object) 231 | svg.removeSelectedObjects() 232 | expect(svg.getObjects()).not.toContain object 233 | 234 | it "removes the selected node when a node is selected", -> 235 | object = new Path(svg) 236 | object.addNode(new Node([20, 30])) 237 | object.addNode(new Node([30, 30])) 238 | object.addNode(new Node([30, 40])) 239 | object.addNode(new Node([20, 40])) 240 | object.close() 241 | expect(object.getNodes()).toHaveLength 4 242 | 243 | svg.selectionModel.setSelected(object) 244 | svg.selectionModel.setSelectedNode(object.getNodes()[1]) 245 | svg.removeSelectedObjects() 246 | expect(object.getNodes()).toHaveLength 3 247 | 248 | DOCUMENT = ''' 249 | 250 | 251 | 252 | ''' 253 | 254 | DOCUMENT_WITH_XML_DOCTYPE = ''' 255 | 256 | 257 | Created with Curve 258 | 259 | 260 | 261 | ''' 262 | -------------------------------------------------------------------------------- /spec/transform-spec.coffee: -------------------------------------------------------------------------------- 1 | Point = require '../src/point' 2 | Transform = require '../src/transform' 3 | 4 | describe 'Curve.Transform', -> 5 | [trans, point] = [] 6 | beforeEach -> 7 | point = new Point(20, 30) 8 | trans = new Transform() 9 | 10 | describe "when there is not a translation", -> 11 | it 'does not transform the point', -> 12 | expect(trans.transformPoint(point)).toBe point 13 | 14 | describe "when the transform has a translation", -> 15 | beforeEach -> 16 | expect(trans.setTransformString('translate(-5 6)')).toBe true 17 | 18 | it 'translates the point', -> 19 | expect(trans.transformPoint(point)).toEqual new Point(15, 36) 20 | expect(trans.setTransformString(null)).toBe true 21 | expect(trans.transformPoint(point)).toEqual point 22 | expect(trans.setTransformString(null)).toBe false 23 | 24 | describe "when the transform has a matrix", -> 25 | beforeEach -> 26 | expect(trans.setTransformString('matrix(1, 0, 0, 1, -5, 6)')).toBe true 27 | 28 | it 'translates the point', -> 29 | expect(trans.transformPoint(point)).toEqual new Point(15, 36) 30 | expect(trans.setTransformString(null)).toBe true 31 | expect(trans.transformPoint(point)).toEqual point 32 | expect(trans.setTransformString(null)).toBe false 33 | -------------------------------------------------------------------------------- /src/curve.coffee: -------------------------------------------------------------------------------- 1 | require './ext/svg-circle' 2 | require './ext/svg-draggable' 3 | require "../vendor/svg.parser" 4 | require "../vendor/svg.export" 5 | 6 | module.exports = { 7 | Point: require "./point" 8 | Size: require "./size" 9 | Transform: require "./transform" 10 | Utils: require "./utils" 11 | 12 | Node: require "./node" 13 | 14 | Path: require "./path" 15 | Subpath: require "./subpath" 16 | Rectangle: require "./rectangle" 17 | 18 | NodeEditor: require "./node-editor" 19 | ObjectEditor: require "./object-editor" 20 | ObjectSelection: require "./object-selection" 21 | PathEditor: require "./path-editor" 22 | PathParser: require "./path-parser" 23 | 24 | SelectionModel: require "./selection-model" 25 | SelectionView: require "./selection-view" 26 | 27 | PenTool: require "./pen-tool" 28 | PointerTool: require "./pointer-tool" 29 | 30 | SVGDocument: require "./svg-document" 31 | } 32 | -------------------------------------------------------------------------------- /src/deserialize-svg.coffee: -------------------------------------------------------------------------------- 1 | SVG = require "../vendor/svg" 2 | require "../vendor/svg.parser" 3 | 4 | Path = require "./path" 5 | Rectangle = require "./rectangle" 6 | Ellipse = require "./ellipse" 7 | 8 | # svg.import.js 0.11 - Copyright (c) 2013 Wout Fierens - Licensed under the MIT license 9 | # 10 | # Converted to coffeescript and modified by benogle 11 | 12 | # Place the `svgString` in the svgDocument, and parse into objects Curve can 13 | # understand 14 | # 15 | # * `svgDocument` A {SVGDocument} 16 | # * `svgString` {String} with the svg markup 17 | # 18 | # Returns an array of objects selectable and editable by Curve tools. 19 | module.exports = (svgDocument, svgString) -> 20 | IMPORT_FNS = 21 | path: (el) -> [new Path(svgDocument, svgEl: el)] 22 | rect: (el) -> [new Rectangle(svgDocument, svgEl: el)] 23 | circle: (el) -> [new Ellipse(svgDocument, svgEl: el)] 24 | ellipse: (el) -> [new Ellipse(svgDocument, svgEl: el)] 25 | 26 | # create temporary div to receive svg content 27 | parentNode = document.createElement('div') 28 | store = {} 29 | 30 | # properly close svg tags and add them to the DOM 31 | parentNode.innerHTML = svgString 32 | .replace(/\n/, '') 33 | .replace(/<(\w+)([^<]+?)\/>/g, '<$1$2>') 34 | 35 | objects = [] 36 | convertNodes parentNode.childNodes, svgDocument.getSVGRoot(), 0, store, -> 37 | nodeType = this.node.nodeName 38 | objects = objects.concat(IMPORT_FNS[nodeType](this)) if IMPORT_FNS[nodeType] 39 | null 40 | 41 | parentNode = null 42 | objects 43 | 44 | # Convert nodes to svg.js elements 45 | convertNodes = (nodes, context, level, store, block) -> 46 | for i in [0...nodes.length] 47 | element = null 48 | child = nodes[i] 49 | attr = {} 50 | clips = [] 51 | 52 | # get node type 53 | type = child.nodeName.toLowerCase() 54 | 55 | # objectify attributes 56 | attr = SVG.parse.attr(child) 57 | 58 | # create elements 59 | switch type 60 | when 'path' then element = context[type]() 61 | when 'polygon' then element = context[type]() 62 | when 'polyline' then element = context[type]() 63 | 64 | when 'rect' then element = context[type](0,0) 65 | when 'circle' then element = context[type](0,0) 66 | when 'ellipse' then element = context[type](0,0) 67 | 68 | when 'line' then element = context.line(0,0,0,0) 69 | 70 | when 'text' 71 | if child.childNodes.length == 0 72 | element = context[type](child.textContent) 73 | else 74 | element = null 75 | 76 | for j in [0...child.childNodes.length] 77 | grandchild = child.childNodes[j] 78 | 79 | if grandchild.nodeName.toLowerCase() == 'tspan' 80 | if element == null 81 | # first time through call the text() function on the current context 82 | element = context[type](grandchild.textContent) 83 | else 84 | # for the remaining times create additional tspans 85 | element 86 | .tspan(grandchild.textContent) 87 | .attr(SVG.parse.attr(grandchild)) 88 | 89 | when 'image' then element = context.image(attr['xlink:href']) 90 | 91 | when 'g', 'svg' 92 | element = context[if type == 'g' then 'group' else 'nested']() 93 | convertNodes(child.childNodes, element, level + 1, store, block) 94 | 95 | when 'defs' 96 | convertNodes(child.childNodes, context.defs(), level + 1, store, block) 97 | 98 | when 'use' 99 | element = context.use() 100 | 101 | when 'clippath', 'mask' 102 | element = context[type == 'mask' ? 'mask' : 'clip']() 103 | convertNodes(child.childNodes, element, level + 1, store, block) 104 | 105 | when 'lineargradient', 'radialgradient' 106 | element = context.defs().gradient type.split('gradient')[0], (stop) -> 107 | for j in [0...child.childNodes.length] 108 | stop 109 | .at(offset: 0) 110 | .attr(SVG.parse.attr(child.childNodes[j])) 111 | .style(child.childNodes[j].getAttribute('style')) 112 | 113 | when '#comment', '#text', 'metadata', 'desc' 114 | ; # safely ignore these elements 115 | else 116 | console.log('SVG Import got unexpected type ' + type, child) 117 | 118 | if element 119 | # parse transform attribute 120 | transform = SVG.parse.transform(attr.transform) 121 | delete attr.transform 122 | 123 | # set attributes and transformations 124 | element 125 | .attr(attr) 126 | .transform(transform) 127 | 128 | # store element by id 129 | store[element.attr('id')] = element if element.attr('id') 130 | 131 | # now that we've set the attributes "rebuild" the text to correctly set the attributes 132 | element.rebuild() if type == 'text' 133 | 134 | # call block if given 135 | block.call(element) if typeof block == 'function' 136 | 137 | context 138 | -------------------------------------------------------------------------------- /src/draggable-mixin.coffee: -------------------------------------------------------------------------------- 1 | Mixin = require 'mixto' 2 | 3 | module.exports = 4 | class Draggable extends Mixin 5 | # Allows for user dragging on the screen 6 | # * `startEvent` (optional) event from a mousedown event 7 | enableDragging: (startEvent) -> 8 | return if @_draggingEnabled 9 | element = @svgEl 10 | return unless element? 11 | 12 | element.draggable(startEvent) 13 | element.dragmove = => 14 | @updateFromAttributes() 15 | element.dragend = (event) => 16 | @model.set(transform: null) 17 | @model.translate([event.x, event.y]) 18 | @_draggingEnabled = true 19 | 20 | disableDragging: -> 21 | return unless @_draggingEnabled 22 | element = @svgEl 23 | return unless element? 24 | 25 | element.fixed?() 26 | element.dragstart = null 27 | element.dragmove = null 28 | element.dragend = null 29 | @_draggingEnabled = false 30 | -------------------------------------------------------------------------------- /src/ellipse-model.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable} = require 'event-kit' 2 | Delegator = require 'delegato' 3 | 4 | Transform = require './transform' 5 | Point = require './point' 6 | Size = require './size' 7 | Model = require './model' 8 | 9 | IDS = 0 10 | 11 | module.exports = 12 | class EllipseModel extends Model 13 | constructor: -> 14 | super(['transform', 'position', 'size', 'fill']) 15 | @id = IDS++ 16 | @transform = new Transform 17 | 18 | @addFilter 'size', (value) => Size.create(value) 19 | @addFilter 'position', (value) => Point.create(value) 20 | @addFilter 'transform', (value) => 21 | if value is 'matrix(1,0,0,1,0,0)' then null else value 22 | 23 | @subscriptions = new CompositeDisposable 24 | @subscriptions.add @on 'change:transform', ({value}) => @transform.setTransformString(value) 25 | 26 | destroy: -> 27 | @subscriptions.dispose() 28 | 29 | ### 30 | Section: Public Methods 31 | ### 32 | 33 | getType: -> 'Ellipse' 34 | 35 | getID: -> "#{@getType()}-#{@id}" 36 | 37 | toString: -> "{Ellipse #{@id}: #{@get('position')} #{@get('size')}" 38 | 39 | ### 40 | Section: Position / Size Methods 41 | ### 42 | 43 | getTransform: -> @transform 44 | 45 | translate: (point) -> 46 | point = Point.create(point) 47 | @set(position: @get('position').add(point)) 48 | -------------------------------------------------------------------------------- /src/ellipse.coffee: -------------------------------------------------------------------------------- 1 | {Emitter} = require 'event-kit' 2 | ObjectAssign = require 'object-assign' 3 | Delegator = require 'delegato' 4 | 5 | Utils = require './utils' 6 | Draggable = require './draggable-mixin' 7 | 8 | EllipseModel = require './ellipse-model' 9 | 10 | DefaultAttrs = {cx: 5, cy: 5, rx: 5, ry: 5, fill: '#eee', stroke: 'none'} 11 | IDS = 0 12 | 13 | # Represents a svg element. Handles interacting with the element, and 14 | # rendering from the {EllipseModel}. 15 | module.exports = 16 | class Ellipse 17 | Draggable.includeInto(this) 18 | Delegator.includeInto(this) 19 | 20 | @delegatesMethods 'on', toProperty: 'emitter' 21 | @delegatesMethods 'get', 'set', 'getID', 'getType', 'toString', 22 | 'translate', 23 | toProperty: 'model' 24 | 25 | constructor: (@svgDocument, options={}) -> 26 | @emitter = new Emitter 27 | @model = new EllipseModel 28 | @_setupSVGObject(options) 29 | @model.on 'change', @onModelChange 30 | @svgDocument.registerObject(this) 31 | 32 | ### 33 | Section: Public Methods 34 | ### 35 | 36 | remove: -> 37 | @svgEl.remove() 38 | @emitter.emit('remove', object: this) 39 | 40 | # Call when the XML attributes change without the model knowing. Will update 41 | # the model with the new attributes. 42 | updateFromAttributes: -> 43 | r = @svgEl.attr('r') 44 | if r 45 | rx = ry = r 46 | else 47 | rx = @svgEl.attr('rx') 48 | ry = @svgEl.attr('ry') 49 | x = @svgEl.attr('cx') - rx 50 | y = @svgEl.attr('cy') - ry 51 | 52 | width = rx * 2 53 | height = ry * 2 54 | transform = @svgEl.attr('transform') 55 | fill = @svgEl.attr('fill') 56 | 57 | @model.set({position: [x, y], size: [width, height], transform, fill}) 58 | 59 | # Will render data from the model 60 | render: (svgEl=@svgEl) -> 61 | position = @model.get('position') 62 | size = @model.get('size') 63 | attrs = {x: position.x, y: position.y, width: size.width, height: size.height} 64 | svgEl.attr ObjectAssign { 65 | transform: @model.get('transform') or null 66 | fill: @model.get('fill') or null 67 | }, @_convertPositionAndSizeToCenter(attrs) 68 | 69 | cloneElement: (svgDocument=@svgDocument) -> 70 | el = svgDocument.getObjectLayer().ellipse() 71 | @render(el) 72 | el 73 | 74 | ### 75 | Section: Event Handlers 76 | ### 77 | 78 | onModelChange: (args) => 79 | @render() 80 | args.object = this 81 | @emitter.emit 'change', args 82 | 83 | ### 84 | Section: Private Methods 85 | ### 86 | 87 | _convertPositionAndSizeToCenter: (attrs) -> 88 | {x, y, width, height} = attrs 89 | return {} unless x? and y? and width? and height? 90 | 91 | rx = width / 2 92 | ry = height / 2 93 | cx = x + rx 94 | cy = y + ry 95 | 96 | {rx, ry, cx, cy} 97 | 98 | _setupSVGObject: (options) -> 99 | {@svgEl} = options 100 | unless @svgEl 101 | attrs = ObjectAssign({}, DefaultAttrs, options, @_convertPositionAndSizeToCenter(options)) 102 | delete attrs.x 103 | delete attrs.y 104 | delete attrs.width 105 | delete attrs.height 106 | @svgEl = @svgDocument.getObjectLayer().ellipse().attr(attrs) 107 | Utils.setObjectOnNode(@svgEl.node, this) 108 | @updateFromAttributes() 109 | -------------------------------------------------------------------------------- /src/ext/svg-circle.coffee: -------------------------------------------------------------------------------- 1 | SVG = require '../../vendor/svg.js' 2 | 3 | # 4 | class SVG.Circle extends SVG.Shape 5 | constructor: -> 6 | super(SVG.create('circle')) 7 | 8 | cx: (x) -> 9 | if x == null then this.attr('cx') else @attr('cx', new SVG.Number(x).divide(this.trans.scaleX)) 10 | 11 | cy: (y) -> 12 | if y == null then this.attr('cy') else @attr('cy', new SVG.Number(y).divide(this.trans.scaleY)) 13 | 14 | radius: (rad) -> 15 | @attr(r: new SVG.Number(rad)) 16 | 17 | 18 | SVG.extend SVG.Container, 19 | circle: (radius) -> 20 | return this.put(new SVG.Circle).radius(radius).move(0, 0) 21 | -------------------------------------------------------------------------------- /src/ext/svg-draggable.coffee: -------------------------------------------------------------------------------- 1 | # svg.draggable.js 0.1.0 - Copyright (c) 2014 Wout Fierens - Licensed under the MIT license 2 | # extended by Florian Loch 3 | # 4 | # Modified by benogle 5 | # * It's now using translations for moves, rather than the move() method 6 | # * I removed a bunch of features I didnt need 7 | 8 | SVG = require '../../vendor/svg' 9 | 10 | TranslateRegex = /translate\(([-0-9]+) ([-0-9]+)\)/ 11 | 12 | # * `startEvent` (optional) initial event. This could be the initial mousedown 13 | # event that selects and allows the dragging to start 14 | SVG.extend SVG.Element, draggable: (startEvent) -> 15 | element = this 16 | @fixed?() # remove draggable if already present 17 | 18 | startHandler = (event) -> 19 | onStart(element, event) 20 | attachDragEvents(dragHandler, endHandler) 21 | dragHandler = (event) -> 22 | onDrag(element, event) 23 | endHandler = (event) -> 24 | onEnd(element, event) 25 | detachDragEvents(dragHandler, endHandler) 26 | 27 | element.on 'mousedown', startHandler 28 | 29 | # Disable dragging on this event. 30 | element.fixed = -> 31 | element.off 'mousedown', startHandler 32 | detachDragEvents() 33 | startHandler = dragHandler = endHandler = null 34 | element 35 | 36 | startHandler(startEvent) if startEvent? 37 | this 38 | 39 | attachDragEvents = (dragHandler, endHandler) -> 40 | SVG.on window, 'mousemove', dragHandler 41 | SVG.on window, 'mouseup', endHandler 42 | 43 | detachDragEvents = (dragHandler, endHandler) -> 44 | SVG.off window, 'mousemove', dragHandler 45 | SVG.off window, 'mouseup', endHandler 46 | 47 | onStart = (element, event=window.event) -> 48 | parent = element.parent(SVG.Nested) or element(SVG.Doc) 49 | element.startEvent = event 50 | 51 | x = y = 0 52 | translation = TranslateRegex.exec(element.attr('transform')) 53 | if translation? 54 | x = parseInt(translation[1]) 55 | y = parseInt(translation[2]) 56 | 57 | zoom = parent.viewbox().zoom 58 | rotation = element.transform('rotation') * Math.PI / 180 59 | element.startPosition = {x, y, zoom, rotation} 60 | element.dragstart?({x: 0, y: 0, zoom}, event) 61 | 62 | # prevent selection dragging 63 | if event.preventDefault then event.preventDefault() else (event.returnValue = false) 64 | 65 | onDrag = (element, event=window.event) -> 66 | if element.startEvent 67 | rotation = element.startPosition.rotation 68 | delta = 69 | x: event.pageX - element.startEvent.pageX 70 | y: event.pageY - element.startEvent.pageY 71 | zoom: element.startPosition.zoom 72 | 73 | ### caculate new position [with rotation correction] ### 74 | x = element.startPosition.x + (delta.x * Math.cos(rotation) + delta.y * Math.sin(rotation)) / element.startPosition.zoom 75 | y = element.startPosition.y + (delta.y * Math.cos(rotation) + delta.x * Math.sin(-rotation)) / element.startPosition.zoom 76 | 77 | element.transform({x, y}) 78 | element.dragmove?(delta, event) 79 | 80 | onEnd = (element, event=window.event) -> 81 | delta = 82 | x: event.pageX - element.startEvent.pageX 83 | y: event.pageY - element.startEvent.pageY 84 | zoom: element.startPosition.zoom 85 | 86 | element.startEvent = null 87 | element.startPosition = null 88 | element.dragend?(delta, event) 89 | -------------------------------------------------------------------------------- /src/model.coffee: -------------------------------------------------------------------------------- 1 | {Emitter, CompositeDisposable} = require 'event-kit' 2 | Delegator = require 'delegato' 3 | 4 | module.exports = 5 | class Model 6 | Delegator.includeInto(this) 7 | 8 | @delegatesMethods 'on', toProperty: 'emitter' 9 | 10 | constructor: (allowedProperties) -> 11 | @emitter = new Emitter 12 | @filters = {} 13 | @properties = {} 14 | for prop in allowedProperties 15 | @properties[prop] = null 16 | return 17 | 18 | get: (property) -> 19 | if property? 20 | @properties[property] 21 | else 22 | @properties 23 | 24 | set: (properties, options) -> 25 | return unless properties 26 | 27 | eventObject = null 28 | for property, value of properties 29 | continue unless @properties.hasOwnProperty(property) 30 | if propEventObject = @_setProperty(property, value, options) 31 | eventObject ?= {model: this, oldValue: {}, value: {}} 32 | eventObject.oldValue[property] = propEventObject.oldValue 33 | eventObject.value[property] = propEventObject.value 34 | @emitter.emit('change', eventObject) if eventObject? 35 | 36 | addFilter: (property, filter) -> 37 | @filters[property] = filter 38 | 39 | _setProperty: (property, value, options) -> 40 | return null if @properties[property] is value 41 | 42 | if @filters[property]? and (not options? or options.filter isnt false) 43 | value = @filters[property](value) 44 | return null if @properties[property] is value 45 | 46 | oldValue = @properties[property] 47 | @properties[property] = value 48 | 49 | eventObject = {model: this, oldValue, value, property} 50 | @emitter.emit("change:#{property}", eventObject) 51 | eventObject 52 | -------------------------------------------------------------------------------- /src/node-editor.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable, Emitter} = require 'event-kit' 2 | Delegator = require 'delegato' 3 | Point = require './point' 4 | 5 | # A node UI in the interface allowing for user interaction (moving the node, 6 | # moving the handles). Draws the node, and draws the handles. 7 | # Managed by a PathEditor object. 8 | module.exports = 9 | class NodeEditor 10 | Delegator.includeInto(this) 11 | @delegatesMethods 'on', toProperty: 'emitter' 12 | 13 | nodeSize: 5 14 | handleSize: 3 15 | 16 | node = null 17 | nodeElement = null 18 | handleElements = null 19 | lineElement = null 20 | 21 | constructor: (@svgDocument, @pathEditor) -> 22 | @emitter = new Emitter 23 | @toolLayer = @svgDocument.getToolLayer() 24 | @_setupNodeElement() 25 | @_setupLineElement() 26 | @_setupHandleElements() 27 | @hide() 28 | 29 | hide: -> 30 | @visible = false 31 | @lineElement.hide() 32 | @nodeElement.hide() 33 | @handleElements.hide() 34 | 35 | show: (toFront) -> 36 | @visible = true 37 | @nodeElement.show() 38 | 39 | if toFront 40 | @lineElement.front() 41 | @nodeElement.front() 42 | @handleElements.front() 43 | 44 | if @enableHandles 45 | @lineElement.show() 46 | @handleElements.show() 47 | else 48 | @lineElement.hide() 49 | @handleElements.hide() 50 | 51 | setEnableHandles: (@enableHandles) -> 52 | @show() if @visible 53 | 54 | setNode: (node) -> 55 | @_unbindNode() 56 | @node = node 57 | @_bindNode(@node) 58 | @setEnableHandles(false) 59 | @render() 60 | 61 | render: => 62 | return @hide() unless @node 63 | 64 | handleIn = @node.getAbsoluteHandleIn() 65 | handleOut = @node.getAbsoluteHandleOut() 66 | point = @node.getPoint() 67 | 68 | linePath = "M#{handleIn.x},#{handleIn.y}L#{point.x},#{point.y}L#{handleOut.x},#{handleOut.y}" 69 | @lineElement.attr(d: linePath) 70 | 71 | # Note nulling out the transform; the svg lib is a dick and adds them in 72 | # when setting the center attributes. Not sure why. 73 | @handleElements.members[0].attr(cx: handleIn.x, cy: handleIn.y, transform: '') 74 | @handleElements.members[1].attr(cx: handleOut.x, cy: handleOut.y, transform: '') 75 | @nodeElement.attr(cx: point.x, cy: point.y, transform: '') 76 | 77 | @show() 78 | 79 | # make sure the handle the user is dragging is on top. could get in the 80 | # situation where the handle passed under the other, and it feels weird. 81 | @_draggingHandle.front() if @_draggingHandle 82 | 83 | onDraggingNode: (delta, event) => 84 | @node.setPoint(@_startPosition.add(delta)) 85 | onDraggingHandleIn: (delta, event) => 86 | @node.setAbsoluteHandleIn(@_startPosition.add(delta)) 87 | onDraggingHandleOut: (delta, event) => 88 | @node.setAbsoluteHandleOut(@_startPosition.add(delta)) 89 | 90 | _bindNode: (node) -> 91 | return unless node 92 | @nodeSubscriptions = new CompositeDisposable 93 | @nodeSubscriptions.add node.on('change', @render) 94 | @nodeSubscriptions.add node.getPath()?.on('change', @render) 95 | 96 | _unbindNode: -> 97 | @nodeSubscriptions?.dispose() 98 | @nodeSubscriptions = null 99 | 100 | _setupNodeElement: -> 101 | @nodeElement = @toolLayer.circle(@nodeSize) 102 | @nodeElement.node.setAttribute('class', 'node-editor-node') 103 | 104 | @nodeElement.mousedown (e) => 105 | e.stopPropagation() 106 | 107 | defaultPrevented = false 108 | preventDefault = -> defaultPrevented = true 109 | @emitter.emit('mousedown:node', {@node, preventDefault, event}) 110 | @svgDocument.getSelectionModel().setSelectedNode(@node) unless defaultPrevented 111 | false 112 | 113 | @nodeElement.draggable() 114 | @nodeElement.dragmove = @onDraggingNode 115 | @nodeElement.dragstart = => 116 | @_startPosition = @node.getPoint() 117 | @nodeElement.dragend = => 118 | @_startPosition = null 119 | 120 | @nodeElement.on 'mouseover', => 121 | @nodeElement.front() 122 | @nodeElement.attr('r': @nodeSize+2) 123 | @nodeElement.on 'mouseout', => 124 | @nodeElement.attr('r': @nodeSize) 125 | 126 | _setupLineElement: -> 127 | @lineElement = @toolLayer.path('') 128 | @lineElement.node.setAttribute('class', 'node-editor-lines') 129 | 130 | _setupHandleElements: -> 131 | self = this 132 | 133 | @handleElements = @toolLayer.set() 134 | @handleElements.add( 135 | @toolLayer.circle(@handleSize), 136 | @toolLayer.circle(@handleSize) 137 | ) 138 | @handleElements.members[0].node.setAttribute('class', 'node-editor-handle') 139 | @handleElements.members[1].node.setAttribute('class', 'node-editor-handle') 140 | 141 | @handleElements.mousedown (e) => 142 | e.stopPropagation() 143 | false 144 | 145 | onStopDraggingHandle = => 146 | @_draggingHandle = null 147 | @_startPosition = null 148 | 149 | @handleElements.members[0].draggable() 150 | @handleElements.members[0].dragmove = @onDraggingHandleIn 151 | @handleElements.members[0].dragend = onStopDraggingHandle 152 | @handleElements.members[0].dragstart = -> 153 | # Use self here as `this` is the SVG element 154 | self._draggingHandle = this 155 | self._startPosition = self.node.getAbsoluteHandleIn() 156 | 157 | @handleElements.members[1].draggable() 158 | @handleElements.members[1].dragmove = @onDraggingHandleOut 159 | @handleElements.members[1].dragend = onStopDraggingHandle 160 | @handleElements.members[1].dragstart = -> 161 | # Use self here as `this` is the SVG element 162 | self._draggingHandle = this 163 | self._startPosition = self.node.getAbsoluteHandleOut() 164 | 165 | @handleElements.on 'mouseover', -> 166 | this.front() 167 | this.attr('r': self.handleSize+2) 168 | @handleElements.on 'mouseout', -> 169 | this.attr('r': self.handleSize) 170 | -------------------------------------------------------------------------------- /src/node.coffee: -------------------------------------------------------------------------------- 1 | {Emitter} = require 'event-kit' 2 | Point = require './point' 3 | 4 | module.exports = 5 | class Node 6 | constructor: (point, handleIn, handleOut, @isJoined=false) -> 7 | @emitter = new Emitter 8 | 9 | @setPoint(point) 10 | @setHandleIn(handleIn) if handleIn 11 | @setHandleOut(handleOut) if handleOut 12 | 13 | toString: -> 14 | "(#{@getPoint()}, #{@getHandleIn()}, #{@getHandleOut()})" 15 | 16 | on: (args...) -> @emitter.on(args...) 17 | 18 | join: (referenceHandle='handleIn') -> 19 | @isJoined = true 20 | @["set#{referenceHandle.replace('h', 'H')}"](@[referenceHandle]) 21 | 22 | setPath: (@path) -> 23 | 24 | getPath: -> @path 25 | 26 | getPoint: -> 27 | @_transformPoint(@point) 28 | getHandleIn: -> @handleIn 29 | getHandleOut: -> @handleOut 30 | 31 | getAbsoluteHandleIn: -> 32 | if @handleIn 33 | @_transformPoint(@point.add(@handleIn)) 34 | else 35 | @getPoint() 36 | 37 | getAbsoluteHandleOut: -> 38 | if @handleOut 39 | @_transformPoint(@point.add(@handleOut)) 40 | else 41 | @getPoint() 42 | 43 | setAbsoluteHandleIn: (point) -> 44 | @setHandleIn(Point.create(point).subtract(@point)) 45 | 46 | setAbsoluteHandleOut: (point) -> 47 | @setHandleOut(Point.create(point).subtract(@point)) 48 | 49 | setPoint: (point) -> 50 | @set('point', Point.create(point)) 51 | setHandleIn: (point) -> 52 | point = Point.create(point) if point 53 | @set('handleIn', point) 54 | @set('handleOut', if point then new Point(0,0).subtract(point) else point) if @isJoined 55 | setHandleOut: (point) -> 56 | point = Point.create(point) if point 57 | @set('handleOut', point) 58 | @set('handleIn', if point then new Point(0,0).subtract(point) else point) if @isJoined 59 | 60 | computeIsjoined: -> 61 | @isJoined = (not @handleIn and not @handleOut) or (@handleIn and @handleOut and Math.round(@handleIn.x) == Math.round(-@handleOut.x) and Math.round(@handleIn.y) == Math.round(-@handleOut.y)) 62 | 63 | set: (attribute, value) -> 64 | old = @[attribute] 65 | @[attribute] = value 66 | 67 | event = "change:#{attribute}" 68 | eventArgs = {node: this, event, value, old} 69 | 70 | @emitter.emit event, eventArgs 71 | @emitter.emit 'change', eventArgs 72 | 73 | translate: (point) -> 74 | point = Point.create(point) 75 | @set('point', @point.add(point)) 76 | 77 | _transformPoint: (point) -> 78 | transform = @path?.getTransform() 79 | point = transform.transformPoint(point) if transform? 80 | point 81 | -------------------------------------------------------------------------------- /src/object-editor.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable} = require 'event-kit' 2 | PathEditor = require './path-editor' 3 | ShapeEditor = require './shape-editor' 4 | 5 | # Manages the editor UIs for all object types. e.g. PathEditor object for 6 | # SVG objects. 7 | # 8 | # The goal for this arch is flexibility. Any tool can make one of these and 9 | # activate it when it wants UIs for object editing. 10 | module.exports = 11 | class ObjectEditor 12 | constructor: (@svgDocument) -> 13 | @active = false 14 | @activeEditor = null 15 | @selectionModel = @svgDocument.getSelectionModel() 16 | shapeEditor = new ShapeEditor(@svgDocument) 17 | @editors = 18 | Path: new PathEditor(@svgDocument) 19 | Ellipse: shapeEditor 20 | Rectangle: shapeEditor 21 | 22 | isActive: -> 23 | @active 24 | 25 | getActiveObject: -> 26 | @activeEditor?.getActiveObject() ? null 27 | 28 | getActiveEditor: -> 29 | @activeEditor 30 | 31 | activate: -> 32 | @active = true 33 | @subscriptions = new CompositeDisposable 34 | @subscriptions.add @selectionModel.on('change:selected', ({object}) => @activateSelectedObject(object)) 35 | @subscriptions.add @selectionModel.on('change:selectedNode', ({node}) => @activateSelectedNode(node)) 36 | @activateSelectedObject(@selectionModel.getSelected()) 37 | @activateSelectedNode(@selectionModel.getSelectedNode()) 38 | 39 | deactivate: -> 40 | @subscriptions?.dispose() 41 | @_deactivateActiveEditor() 42 | @active = false 43 | 44 | activateSelectedObject: (object) => 45 | @_deactivateActiveEditor() 46 | if object? 47 | @activeEditor = @editors[object.getType()] 48 | @activeEditor?.activateObject(object) 49 | 50 | activateSelectedNode: (node) => 51 | @activeEditor?.activateNode?(node) 52 | 53 | _deactivateActiveEditor: -> 54 | @activeEditor?.deactivate() 55 | @activeEditor = null 56 | -------------------------------------------------------------------------------- /src/object-selection.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable} = require 'event-kit' 2 | 3 | # The display for a selected object. i.e. the red or blue outline around the 4 | # selected object. 5 | # 6 | # It basically cops the underlying object's attributes (path definition, etc.) 7 | module.exports = 8 | class ObjectSelection 9 | constructor: (@svgDocument, @options={}) -> 10 | @options.class ?= 'object-selection' 11 | 12 | setObject: (object) -> 13 | return if object is @object 14 | @_unbindObject() 15 | @object = object 16 | @_bindObject(@object) 17 | 18 | @trackingObject.remove() if @trackingObject 19 | @trackingObject = null 20 | if @object 21 | @trackingObject = @object.cloneElement(@svgDocument) 22 | @trackingObject.node.setAttribute('class', @options.class + ' invisible-to-hit-test') 23 | @svgDocument.getToolLayer().add(@trackingObject) 24 | @trackingObject.back() 25 | @render() 26 | return 27 | 28 | render: => 29 | @object.render(@trackingObject) 30 | 31 | _bindObject: (object) -> 32 | return unless object 33 | @selectedObjectSubscriptions = new CompositeDisposable 34 | @selectedObjectSubscriptions.add object.on('change', @render) 35 | 36 | _unbindObject: -> 37 | @selectedObjectSubscriptions?.dispose() 38 | @selectedObjectSubscriptions = null 39 | -------------------------------------------------------------------------------- /src/path-editor.coffee: -------------------------------------------------------------------------------- 1 | {Emitter, CompositeDisposable} = require 'event-kit' 2 | Delegator = require 'delegato' 3 | NodeEditor = require './node-editor' 4 | ObjectSelection = require './object-selection' 5 | 6 | # Handles the UI for free-form path editing. Manages NodeEditor objects based on 7 | # a Path's nodes. 8 | module.exports = 9 | class PathEditor 10 | Delegator.includeInto(this) 11 | @delegatesMethods 'on', toProperty: 'emitter' 12 | 13 | constructor: (@svgDocument) -> 14 | @emitter = new Emitter 15 | @path = null 16 | @node = null 17 | @nodeEditors = [] 18 | @_nodeEditorPool = [] 19 | @objectSelection = new ObjectSelection(@svgDocument) 20 | @nodeEditorSubscriptions = new CompositeDisposable() 21 | 22 | isActive: -> !!@path 23 | 24 | getActiveObject: -> @path 25 | 26 | activateObject: (object) -> 27 | @deactivate() 28 | if object? 29 | @path = object 30 | @_bindToObject(@path) 31 | @objectSelection.setObject(object) 32 | @_createNodeEditors(@path) 33 | 34 | deactivate: -> 35 | @objectSelection.setObject(null) 36 | @deactivateNode() 37 | @_unbindFromObject() 38 | @_removeNodeEditors() 39 | @path = null 40 | 41 | activateNode: (node) -> 42 | @deactivateNode() 43 | if node? 44 | @activeNode = node 45 | nodeEditor = @_findNodeEditorForNode(node) 46 | nodeEditor.setEnableHandles(true) if nodeEditor? 47 | 48 | deactivateNode: -> 49 | if @activeNode? 50 | nodeEditor = @_findNodeEditorForNode(@activeNode) 51 | nodeEditor.setEnableHandles(false) if nodeEditor? 52 | @activeNode = null 53 | 54 | onInsertNode: ({node, index}={}) => 55 | @_addNodeEditor(node) 56 | null # Force null. otherwise _insertNodeEditor returns true and tells event emitter 'once'. Ugh 57 | 58 | onRemoveNode: ({node, index}={}) => 59 | @_removeNodeEditorForNode(node) 60 | 61 | _bindToObject: (object) -> 62 | return unless object 63 | @objectSubscriptions = new CompositeDisposable 64 | @objectSubscriptions.add object.on('insert:node', @onInsertNode) 65 | @objectSubscriptions.add object.on('remove:node', @onRemoveNode) 66 | 67 | _unbindFromObject: -> 68 | @objectSubscriptions?.dispose() 69 | @objectSubscriptions = null 70 | 71 | _removeNodeEditorForNode: (node) -> 72 | nodeEditor = @_findNodeEditorForNode(node) 73 | if nodeEditor? 74 | nodeEditor.setNode(null) 75 | editorIndex = @nodeEditors.indexOf(nodeEditor) 76 | @nodeEditors.splice(editorIndex, 1) 77 | @_nodeEditorPool.push(nodeEditor) 78 | 79 | _removeNodeEditors: -> 80 | @_nodeEditorPool = @_nodeEditorPool.concat(@nodeEditors) 81 | @nodeEditors = [] 82 | for nodeEditor in @_nodeEditorPool 83 | nodeEditor.setNode(null) 84 | return 85 | 86 | _createNodeEditors: (object) -> 87 | @_removeNodeEditors() 88 | 89 | if object?.getNodes? 90 | nodes = object.getNodes() 91 | @_addNodeEditor(node) for node in nodes 92 | return 93 | 94 | _addNodeEditor: (node) -> 95 | return false unless node 96 | 97 | if @_nodeEditorPool.length 98 | nodeEditor = @_nodeEditorPool.pop() 99 | else 100 | nodeEditor = new NodeEditor(@svgDocument, this) 101 | @nodeEditorSubscriptions.add nodeEditor.on 'mousedown:node', @_forwardEvent.bind(this, 'mousedown:node') 102 | 103 | nodeEditor.setNode(node) 104 | @nodeEditors.push(nodeEditor) 105 | true 106 | 107 | _findNodeEditorForNode: (node) -> 108 | for nodeEditor in @nodeEditors 109 | return nodeEditor if nodeEditor.node == node 110 | null 111 | 112 | _forwardEvent: (eventName, args) -> 113 | return unless path = @getActiveObject() 114 | args.object = path 115 | @emitter.emit(eventName, args) 116 | -------------------------------------------------------------------------------- /src/path-model.coffee: -------------------------------------------------------------------------------- 1 | {Emitter, CompositeDisposable} = require 'event-kit' 2 | Delegator = require 'delegato' 3 | 4 | PathParser = require './path-parser' 5 | Transform = require './transform' 6 | Subpath = require './subpath' 7 | Point = require './point' 8 | Model = require './model' 9 | 10 | IDS = 0 11 | 12 | flatten = (array) -> 13 | concat = (accumulator, item) -> 14 | if Array.isArray(item) 15 | accumulator.concat(flatten(item)); 16 | else 17 | accumulator.concat(item); 18 | array.reduce(concat, []) 19 | 20 | # The PathModel contains the object representation of an SVG path string + SVG 21 | # object transformations. Basically translates something like 'M0,0C20,30...Z' 22 | # into a list of {Curve.Subpath} objects that each contains a list of 23 | # {Curve.Node} objects. This model has no idea how to render SVG or anything 24 | # about the DOM. 25 | module.exports = 26 | class PathModel extends Model 27 | 28 | constructor: -> 29 | super(['transform', 'path', 'fill']) 30 | @id = IDS++ 31 | @subpaths = [] 32 | @transform = new Transform 33 | 34 | @addFilter 'path', (value) => @_parseFromPathString(value) 35 | @addFilter 'transform', (value) => 36 | if value is 'matrix(1,0,0,1,0,0)' then null else value 37 | 38 | @subscriptions = new CompositeDisposable 39 | @subscriptions.add @on 'change:transform', ({value}) => @transform.setTransformString(value) 40 | 41 | destroy: -> 42 | @subscriptions.dispose() 43 | 44 | ### 45 | Section: Path Details 46 | ### 47 | 48 | getType: -> 'Path' 49 | 50 | getID: -> "#{@getType()}-#{@id}" 51 | 52 | toString: -> @getPathString() 53 | 54 | getSubpaths: -> @subpaths 55 | 56 | getNodes: -> 57 | nodes = (subpath.getNodes() for subpath in @subpaths) 58 | flatten(nodes) 59 | 60 | isClosed: -> 61 | # FIXME: this is pretty crappy right now. Not sure what the best thing is 62 | for subpath in @subpaths 63 | return false unless subpath.isClosed() 64 | true 65 | 66 | ### 67 | Section: Position / Size Methods 68 | ### 69 | 70 | translate: (point) -> 71 | point = Point.create(point) 72 | for subpath in @subpaths 73 | subpath.translate(point) 74 | return 75 | 76 | getTransform: -> @transform 77 | 78 | ### 79 | Section: Current Subpath stuff 80 | 81 | FIXME: the currentSubpath thing will probably leave. depends on how insert 82 | nodes works in interface. 83 | ### 84 | 85 | addNode: (node) -> 86 | @_addCurrentSubpathIfNotPresent() 87 | @currentSubpath.addNode(node) 88 | insertNode: (node, index) -> 89 | @_addCurrentSubpathIfNotPresent() 90 | @currentSubpath.insertNode(node, index) 91 | close: -> 92 | @_addCurrentSubpathIfNotPresent() 93 | @currentSubpath.close() 94 | createSubpath: (args={}) -> 95 | args.path = this 96 | @currentSubpath = @_addSubpath(new Subpath(args)) 97 | removeNode: (node) -> 98 | for subpath in @subpaths 99 | subpath.removeNode(node) 100 | return 101 | _addCurrentSubpathIfNotPresent: -> 102 | @createSubpath() unless @currentSubpath 103 | 104 | ### 105 | Section: Event Handlers 106 | ### 107 | 108 | onSubpathChange: (subpath, eventArgs) => 109 | @_updatePathString() 110 | 111 | ### 112 | Section: Private Methods 113 | ### 114 | 115 | _addSubpath: (subpath) -> 116 | @subpaths.push(subpath) 117 | @_bindSubpath(subpath) 118 | @_updatePathString() 119 | subpath 120 | 121 | _bindSubpath: (subpath) -> 122 | return unless subpath 123 | @subpathSubscriptions ?= new CompositeDisposable 124 | @subpathSubscriptions.add subpath.on('change', @onSubpathChange) 125 | @subpathSubscriptions.add subpath.on('insert:node', @_forwardEvent.bind(this, 'insert:node')) 126 | @subpathSubscriptions.add subpath.on('remove:node', @_forwardEvent.bind(this, 'remove:node')) 127 | 128 | _unbindSubpath: (subpath) -> 129 | return unless subpath 130 | subpath.removeAllListeners() # scary! 131 | 132 | _removeAllSubpaths: -> 133 | @subpathSubscriptions?.dispose() 134 | @subpathSubscriptions = null 135 | @subpaths = [] 136 | 137 | _updatePathString: -> 138 | @set({path: @_pathToString()}, filter: false) 139 | 140 | _pathToString: -> 141 | (subpath.toPathString() for subpath in @subpaths).join(' ') 142 | 143 | _parseFromPathString: (pathString) -> 144 | return unless pathString 145 | return if pathString is @pathString 146 | @_removeAllSubpaths() 147 | parsedPath = PathParser.parsePath(pathString) 148 | @createSubpath(parsedSubpath) for parsedSubpath in parsedPath.subpaths 149 | @_pathToString() 150 | 151 | _forwardEvent: (eventName, args) -> 152 | @emitter.emit(eventName, args) 153 | -------------------------------------------------------------------------------- /src/path-parser.coffee: -------------------------------------------------------------------------------- 1 | Node = require "./node" 2 | 3 | [COMMAND, NUMBER] = ['COMMAND', 'NUMBER'] 4 | 5 | parsePath = (pathString) -> 6 | #console.log 'parsing', pathString 7 | tokens = lexPath(pathString) 8 | parseTokens(groupCommands(tokens)) 9 | 10 | # Parses the result of lexPath 11 | parseTokens = (groupedCommands) -> 12 | result = subpaths: [] 13 | 14 | # Just a move command? We dont care. 15 | return result if groupedCommands.length == 1 and groupedCommands[0].type in ['M', 'm'] 16 | 17 | # svg is stateful. Each command will set currentPoint. 18 | currentPoint = null 19 | currentSubpath = null 20 | addNewSubpath = (movePoint) -> 21 | node = new Node(movePoint) 22 | currentSubpath = 23 | closed: false 24 | nodes: [node] 25 | result.subpaths.push(currentSubpath) 26 | node 27 | 28 | slicePoint = (array, index) -> 29 | [array[index], array[index + 1]] 30 | 31 | # make relative points absolute based on currentPoint 32 | makeAbsolute = (array) -> 33 | if currentPoint? 34 | (val + currentPoint[i % 2] for val, i in array) 35 | else 36 | array 37 | 38 | # Create a node and add it to the list. When the last node is the same as the 39 | # first, and the path is closed, we do not create the node. 40 | createNode = (point, commandIndex) -> 41 | currentPoint = point 42 | 43 | node = null 44 | firstNode = currentSubpath.nodes[0] 45 | 46 | nextCommand = groupedCommands[commandIndex + 1] 47 | unless nextCommand and nextCommand.type in ['z', 'Z'] and firstNode and firstNode.point.equals(currentPoint) 48 | node = new Node(currentPoint) 49 | currentSubpath.nodes.push(node) 50 | 51 | node 52 | 53 | # When a command has more than one set of coords specified, we iterate over 54 | # each set of coords. 55 | # 56 | # Relative coordinates are relative to the last set of coordinates, not the 57 | # last command. (The SVG docs http://www.w3.org/TR/SVG/paths.html are not 58 | # super clear on this.) 59 | iterateOverParameterSets = (command, setSize, isRelative, callback) -> 60 | sets = command.parameters.length / setSize 61 | for setIndex in [0...sets] 62 | minindex = setIndex * setSize + 0 63 | maxIndex = setIndex * setSize + setSize 64 | paramSet = command.parameters.slice(minindex, maxIndex) 65 | paramSet = makeAbsolute(paramSet) if isRelative 66 | callback(paramSet, setIndex) 67 | return 68 | 69 | for i in [0...groupedCommands.length] 70 | command = groupedCommands[i] 71 | switch command.type 72 | when 'M', 'm' 73 | # Move to 74 | hasMoved = false 75 | setSize = 2 76 | isRelative = command.type == 'm' 77 | iterateOverParameterSets command, setSize, isRelative, (paramSet) -> 78 | if hasMoved 79 | createNode(paramSet, i) 80 | else 81 | hasMoved = true 82 | currentPoint = paramSet 83 | addNewSubpath(currentPoint) 84 | 85 | when 'L', 'l' 86 | # Line to 87 | setSize = 2 88 | isRelative = command.type == 'l' 89 | iterateOverParameterSets command, setSize, isRelative, (paramSet) -> 90 | createNode(slicePoint(paramSet, 0), i) 91 | 92 | when 'H', 'h' 93 | # Horizontal line 94 | setSize = 1 95 | isRelative = command.type == 'h' 96 | iterateOverParameterSets command, setSize, isRelative, (paramSet) -> 97 | createNode([paramSet[0], currentPoint[1]], i) 98 | 99 | when 'V', 'v' 100 | # Vertical line 101 | setSize = 1 102 | isRelative = command.type == 'v' 103 | # command 104 | iterateOverParameterSets command, setSize, false, (paramSet) -> 105 | val = paramSet[0] 106 | val += currentPoint[1] if isRelative 107 | createNode([currentPoint[0], val], i) 108 | 109 | when 'C', 'c' 110 | # Bezier 111 | setSize = 6 112 | isRelative = command.type == 'c' 113 | iterateOverParameterSets command, setSize, isRelative, (paramSet) -> 114 | currentPoint = slicePoint(paramSet, 4) 115 | handleIn = slicePoint(paramSet, 2) 116 | handleOut = slicePoint(paramSet, 0) 117 | 118 | lastNode = currentSubpath.nodes[currentSubpath.nodes.length - 1] 119 | lastNode.setAbsoluteHandleOut(handleOut) 120 | 121 | if node = createNode(currentPoint, i) 122 | node.setAbsoluteHandleIn(handleIn) 123 | else 124 | firstNode = currentSubpath.nodes[0] 125 | firstNode.setAbsoluteHandleIn(handleIn) 126 | 127 | when 'Q', 'q' 128 | # Bezier 129 | setSize = 4 130 | isRelative = command.type == 'q' 131 | iterateOverParameterSets command, setSize, isRelative, (paramSet) -> 132 | currentPoint = slicePoint(paramSet, 2) 133 | handleIn = handleOut = slicePoint(paramSet, 0) 134 | 135 | lastNode = currentSubpath.nodes[currentSubpath.nodes.length - 1] 136 | lastNode.setAbsoluteHandleOut(handleOut) 137 | 138 | if node = createNode(currentPoint, i) 139 | node.setAbsoluteHandleIn(handleIn) 140 | else 141 | firstNode = currentSubpath.nodes[0] 142 | firstNode.setAbsoluteHandleIn(handleIn) 143 | 144 | when 'S', 's' 145 | # Shorthand cubic bezier. 146 | # Infer last node's handleOut to be a mirror of its handleIn. 147 | setSize = 4 148 | isRelative = command.type == 's' 149 | iterateOverParameterSets command, setSize, isRelative, (paramSet) -> 150 | currentPoint = slicePoint(paramSet, 2) 151 | handleIn = slicePoint(paramSet, 0) 152 | 153 | lastNode = currentSubpath.nodes[currentSubpath.nodes.length - 1] 154 | lastNode.join('handleIn') 155 | 156 | if node = createNode(currentPoint, i) 157 | node.setAbsoluteHandleIn(handleIn) 158 | else 159 | firstNode = currentSubpath.nodes[0] 160 | firstNode.setAbsoluteHandleIn(handleIn) 161 | 162 | when 'T', 't' 163 | # Shorthand quadradic bezier. 164 | # Infer node's handles based on previous node's handles 165 | setSize = 2 166 | isRelative = command.type == 'q' 167 | iterateOverParameterSets command, setSize, isRelative, (paramSet) -> 168 | currentPoint = slicePoint(paramSet, 0) 169 | 170 | lastNode = currentSubpath.nodes[currentSubpath.nodes.length - 1] 171 | lastNode.join('handleIn') 172 | 173 | # Use the handle out from the previous node. 174 | # TODO: Should check if the last node was a Q command... 175 | # https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#Bezier_Curves 176 | handleIn = lastNode.getAbsoluteHandleOut() 177 | 178 | if node = createNode(currentPoint, i) 179 | node.setAbsoluteHandleIn(handleIn) 180 | else 181 | firstNode = currentSubpath.nodes[0] 182 | firstNode.setAbsoluteHandleIn(handleIn) 183 | 184 | when 'Z', 'z' 185 | currentSubpath.closed = true 186 | 187 | for subpath in result.subpaths 188 | node.computeIsjoined() for node in subpath.nodes 189 | 190 | result 191 | 192 | # Returns a list of svg commands with their parameters. 193 | # [ 194 | # {type: 'M', parameters: [10, 30]}, 195 | # {type: 'L', parameters: [340, 300]}, 196 | # ] 197 | groupCommands = (pathTokens) -> 198 | #console.log 'grouping tokens', pathTokens 199 | commands = [] 200 | for i in [0...pathTokens.length] 201 | token = pathTokens[i] 202 | 203 | continue unless token.type == COMMAND 204 | 205 | command = 206 | type: token.string 207 | parameters: [] 208 | 209 | 210 | while nextToken = pathTokens[i+1] 211 | if nextToken.type == NUMBER 212 | command.parameters.push(parseFloat(nextToken.string)) 213 | i++ 214 | else 215 | break 216 | 217 | #console.log command.type, command 218 | commands.push(command) 219 | 220 | commands 221 | 222 | # Breaks pathString into tokens 223 | lexPath = (pathString) -> 224 | numberMatch = '-0123456789.' 225 | separatorMatch = ' ,\n\t' 226 | 227 | tokens = [] 228 | currentToken = null 229 | 230 | saveCurrentTokenWhenDifferentThan = (command) -> 231 | saveCurrentToken() if currentToken and currentToken.type != command 232 | 233 | saveCurrentToken = -> 234 | return unless currentToken 235 | currentToken.string = currentToken.string.join('') if currentToken.string.join 236 | tokens.push(currentToken) 237 | currentToken = null 238 | 239 | for ch in pathString 240 | if numberMatch.indexOf(ch) > -1 241 | saveCurrentTokenWhenDifferentThan(NUMBER) 242 | saveCurrentToken() if ch == '-' 243 | 244 | currentToken = {type: NUMBER, string: []} unless currentToken 245 | currentToken.string.push(ch) 246 | 247 | else if separatorMatch.indexOf(ch) > -1 248 | saveCurrentToken() 249 | 250 | else 251 | saveCurrentToken() 252 | tokens.push(type: COMMAND, string: ch) 253 | 254 | saveCurrentToken() 255 | tokens 256 | 257 | module.exports = {lexPath, parsePath, groupCommands, parseTokens} 258 | -------------------------------------------------------------------------------- /src/path.coffee: -------------------------------------------------------------------------------- 1 | {Emitter, CompositeDisposable} = require 'event-kit' 2 | Delegator = require 'delegato' 3 | ObjectAssign = require 'object-assign' 4 | 5 | Utils = require './utils' 6 | Point = require './point' 7 | Draggable = require './draggable-mixin' 8 | PathModel = require './path-model' 9 | 10 | DefaultAttrs = {fill: '#eee', stroke: 'none'} 11 | 12 | # Represents a svg element. Handles interacting with the element, and 13 | # rendering from the {PathModel}. 14 | module.exports = 15 | class Path 16 | Draggable.includeInto(this) 17 | Delegator.includeInto(this) 18 | 19 | @delegatesMethods 'on', toProperty: 'emitter' 20 | @delegatesMethods 'get', 'set', 'getID', 'getType', 21 | 'getNodes', 'getSubpaths', 'addNode', 'insertNode', 'removeNode', 'createSubpath', 22 | 'close', 'isClosed' 23 | 'translate' 24 | toProperty: 'model' 25 | 26 | constructor: (@svgDocument, options={}) -> 27 | @emitter = new Emitter 28 | @_draggingEnabled = false 29 | @model = new PathModel 30 | @model.on 'change', @onModelChange 31 | @model.on 'insert:node', @_forwardEvent.bind(this, 'insert:node') 32 | @model.on 'remove:node', @_forwardEvent.bind(this, 'remove:node') 33 | @_setupSVGObject(options) 34 | @svgDocument.registerObject(this) 35 | 36 | ### 37 | Section: Public Methods 38 | ### 39 | 40 | toString: -> 41 | "Path #{@id} #{@model.toString()}" 42 | 43 | getModel: -> @model 44 | 45 | getPosition: -> 46 | new Point(@svgEl.x(), @svgEl.y()) 47 | 48 | remove: -> 49 | @svgEl.remove() 50 | @emitter.emit('remove', object: this) 51 | 52 | # Call when the XML attributes change without the model knowing. Will update 53 | # the model with the new attributes. 54 | updateFromAttributes: -> 55 | path = @svgEl.attr('d') 56 | transform = @svgEl.attr('transform') 57 | fill = @svgEl.attr('fill') 58 | @model.set({transform, path, fill}) 59 | 60 | # Will render the nodes and the transform from the model. 61 | render: (svgEl=@svgEl) -> 62 | pathStr = @model.get('path') 63 | fill = @model.get('fill') 64 | svgEl.attr(d: pathStr) if pathStr 65 | svgEl.attr(fill: @model.get('fill')) if fill and fill isnt '#000000' 66 | svgEl.attr 67 | transform: @model.get('transform') or null 68 | 69 | cloneElement: (svgDocument=@svgDocument) -> 70 | el = svgDocument.getObjectLayer().path() 71 | @render(el) 72 | el 73 | 74 | ### 75 | Section: Event Handlers 76 | ### 77 | 78 | onModelChange: (args) => 79 | @render() 80 | args.object = this 81 | @emitter.emit 'change', args 82 | 83 | ### 84 | Section: Private Methods 85 | ### 86 | 87 | _forwardEvent: (eventName, args) -> 88 | args.object = this 89 | @emitter.emit(eventName, args) 90 | 91 | _setupSVGObject: (options) -> 92 | {@svgEl} = options 93 | @svgEl = @svgDocument.getObjectLayer().path().attr(ObjectAssign({}, DefaultAttrs, options)) unless @svgEl 94 | Utils.setObjectOnNode(@svgEl.node, this) 95 | @updateFromAttributes() 96 | -------------------------------------------------------------------------------- /src/pen-tool.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable} = require 'event-kit' 2 | Node = require './node' 3 | Path = require './path' 4 | {getCanvasPosition} = require './utils' 5 | 6 | module.exports = 7 | class PenTool 8 | currentObject: null 9 | currentNode: null 10 | 11 | constructor: (@svgDocument) -> 12 | @selectionModel = @svgDocument.getSelectionModel() 13 | @objectEditor = @svgDocument.getObjectEditor() 14 | 15 | getType: -> 'pen' 16 | 17 | supportsType: (type) -> type is 'pen' 18 | 19 | isActive: -> @active 20 | 21 | activate: -> 22 | @objectEditor.activate() 23 | @subscriptions = new CompositeDisposable 24 | @subscriptions.add @objectEditor.editors.Path.on('mousedown:node', @onMouseDownNode.bind(this)) 25 | svg = @svgDocument.getSVGRoot() 26 | svg.on 'mousedown', @onMouseDown 27 | svg.on 'mousemove', @onMouseMove 28 | svg.on 'mouseup', @onMouseUp 29 | @active = true 30 | 31 | deactivate: -> 32 | @objectEditor.deactivate() 33 | @subscriptions?.dispose() 34 | @_unsetCurrentObject() 35 | svg = @svgDocument.getSVGRoot() 36 | svg.off 'mousedown', @onMouseDown 37 | svg.off 'mousemove', @onMouseMove 38 | svg.off 'mouseup', @onMouseUp 39 | @active = false 40 | 41 | onMouseDownNode: (event) -> 42 | {node} = event 43 | path = @selectionModel.getSelected() 44 | if path? 45 | nodeIndex = path.getNodes().indexOf(node) 46 | path.close() if nodeIndex is 0 47 | @_unsetCurrentObject() 48 | 49 | onMouseDown: (event) => 50 | unless @currentObject 51 | @currentObject = new Path(@svgDocument) 52 | @currentObjectSubscriptions = new CompositeDisposable 53 | @currentObjectSubscriptions.add(@currentObject.on 'remove:node', @onRemovedNode.bind(this)) 54 | @selectionModel.setSelected(@currentObject) 55 | 56 | position = getCanvasPosition(@svgDocument.getSVGRoot(), event) 57 | @currentNode = new Node(position, [0, 0], [0, 0], true) 58 | @currentObject.addNode(@currentNode) 59 | @selectionModel.setSelectedNode(@currentNode) 60 | 61 | onMouseMove: (e) => 62 | if @currentNode 63 | position = getCanvasPosition(@svgDocument.getSVGRoot(), e) 64 | @currentNode.setAbsoluteHandleOut(position) 65 | 66 | onMouseUp: (e) => 67 | @_unsetCurrentNode() 68 | 69 | onRemovedNode: ({node, subpath, index}) -> 70 | @_unsetCurrentNode() if node is @currentNode 71 | if newNode = subpath.getNodes()[index - 1] 72 | @selectionModel.setSelectedNode(newNode) 73 | 74 | _unsetCurrentObject: -> 75 | @currentObjectSubscriptions?.dispose() 76 | @currentObject = null 77 | 78 | _unsetCurrentNode: -> 79 | @currentNode = null 80 | -------------------------------------------------------------------------------- /src/point.coffee: -------------------------------------------------------------------------------- 1 | module.exports = 2 | class Point 3 | @create: (x, y) -> 4 | return x if x instanceof Point 5 | if x?.x? and x?.y? 6 | new Point(x.x, x.y) 7 | else if Array.isArray(x) 8 | new Point(x[0], x[1]) 9 | else 10 | new Point(x, y) 11 | 12 | constructor: (x, y) -> 13 | @set(x, y) 14 | 15 | set: (@x, @y) -> 16 | [@x, @y] = @x if Array.isArray(@x) 17 | 18 | add: (other) -> 19 | other = Point.create(other) 20 | new Point(@x + other.x, @y + other.y) 21 | 22 | subtract: (other) -> 23 | other = Point.create(other) 24 | new Point(@x - other.x, @y - other.y) 25 | 26 | toArray: -> 27 | [@x, @y] 28 | 29 | equals: (other) -> 30 | other = Point.create(other) 31 | other.x == @x and other.y == @y 32 | 33 | toString: -> 34 | "(#{@x}, #{@y})" 35 | -------------------------------------------------------------------------------- /src/pointer-tool.coffee: -------------------------------------------------------------------------------- 1 | Utils = require './utils' 2 | 3 | module.exports = 4 | class PointerTool 5 | constructor: (@svgDocument) -> 6 | @_evrect = @svgDocument.getSVGRoot().node.createSVGRect(); 7 | @_evrect.width = @_evrect.height = 1; 8 | 9 | @selectionModel = @svgDocument.getSelectionModel() 10 | @selectionView = @svgDocument.getSelectionView() 11 | @toolLayer = @svgDocument.getToolLayer() 12 | 13 | @objectEditor = @svgDocument.getObjectEditor() 14 | 15 | getType: -> 'pointer' 16 | 17 | supportsType: (type) -> type is 'pointer' 18 | 19 | isActive: -> @active 20 | 21 | activate: -> 22 | @objectEditor.activate() 23 | svg = @svgDocument.getSVGRoot() 24 | svg.on 'mousedown', @onMouseDown 25 | svg.on 'mousemove', @onMouseMove 26 | 27 | @changeSubscriptions = @selectionModel.on('change:selected', @onChangedSelectedObject) 28 | @active = true 29 | 30 | deactivate: -> 31 | @objectEditor.deactivate() 32 | svg = @svgDocument.getSVGRoot() 33 | svg.off 'mousedown', @onMouseDown 34 | svg.off 'mousemove', @onMouseMove 35 | 36 | @selectionModel.getSelected()?.disableDragging?() 37 | 38 | @changeSubscriptions?.dispose() 39 | @changeSubscriptions = null 40 | @active = false 41 | 42 | onChangedSelectedObject: ({object, old}) => 43 | old.disableDragging() if old? 44 | object.enableDragging(@_dragStartEvent) if object? 45 | @_dragStartEvent = null 46 | 47 | onMouseDown: (event) => 48 | # obj = @_hitWithIntersectionList(event) 49 | object = @_hitWithTarget(event) 50 | @_dragStartEvent = event if object? 51 | @selectionModel.setSelected(object) 52 | @selectionModel.setSelectedNode(null) 53 | object?.enableDragging(event) # just in case it isnt enabled 54 | true 55 | 56 | onMouseMove: (e) => 57 | # @selectionModel.setPreselected(@_hitWithIntersectionList(e)) 58 | @selectionModel.setPreselected(@_hitWithTarget(e)) 59 | 60 | _hitWithTarget: (e) -> 61 | obj = null 62 | obj = Utils.getObjectFromNode(e.target) if e.target != @svgDocument.getSVGRoot().node 63 | obj 64 | 65 | # This seems slower and more complicated than _hitWithTarget 66 | _hitWithIntersectionList: (e) -> 67 | svgNode = @svgDocument.getSVGRoot().node 68 | top = svgNode.offsetTop 69 | left = svgNode.offsetLeft 70 | @_evrect.x = e.clientX - left 71 | @_evrect.y = e.clientY - top 72 | nodes = svgNode.getIntersectionList(@_evrect, null) 73 | 74 | if nodes.length 75 | for i in [nodes.length-1..0] 76 | className = nodes[i].getAttribute('class') 77 | continue if className and className.indexOf('invisible-to-hit-test') > -1 78 | return Utils.getObjectFromNode(nodes[i]) 79 | null 80 | -------------------------------------------------------------------------------- /src/rectangle-model.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable} = require 'event-kit' 2 | Delegator = require 'delegato' 3 | 4 | Transform = require './transform' 5 | Point = require './point' 6 | Size = require './size' 7 | Model = require './model' 8 | 9 | IDS = 0 10 | 11 | module.exports = 12 | class RectangleModel extends Model 13 | constructor: -> 14 | super(['transform', 'position', 'size', 'fill']) 15 | @id = IDS++ 16 | @transform = new Transform 17 | 18 | @addFilter 'size', (value) => Size.create(value) 19 | @addFilter 'position', (value) => Point.create(value) 20 | @addFilter 'transform', (value) => 21 | if value is 'matrix(1,0,0,1,0,0)' then null else value 22 | 23 | @subscriptions = new CompositeDisposable 24 | @subscriptions.add @on 'change:transform', ({value}) => @transform.setTransformString(value) 25 | 26 | destroy: -> 27 | @subscriptions.dispose() 28 | 29 | ### 30 | Section: Public Methods 31 | ### 32 | 33 | getType: -> 'Rectangle' 34 | 35 | getID: -> "#{@getType()}-#{@id}" 36 | 37 | toString: -> "{Rect #{@id}: #{@get('position')} #{@get('size')}" 38 | 39 | ### 40 | Section: Position / Size Methods 41 | ### 42 | 43 | getTransform: -> @transform 44 | 45 | translate: (point) -> 46 | point = Point.create(point) 47 | @set(position: @get('position').add(point)) 48 | -------------------------------------------------------------------------------- /src/rectangle-selection.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable} = require 'event-kit' 2 | 3 | # A rectangle display for a selected object. i.e. the red or blue outline around 4 | # the selected object. This one just displays wraps the object in a rectangle. 5 | module.exports = 6 | class RectangleSelection 7 | constructor: (@svgDocument, @options={}) -> 8 | @options.class ?= 'object-selection' 9 | 10 | setObject: (object) -> 11 | return if object is @object 12 | @_unbindObject() 13 | @object = object 14 | @_bindObject(@object) 15 | 16 | @trackingObject.remove() if @trackingObject 17 | @trackingObject = null 18 | if @object 19 | @trackingObject = @svgDocument.getToolLayer().rect() 20 | @trackingObject.node.setAttribute('class', @options.class + ' invisible-to-hit-test') 21 | @trackingObject.back() 22 | @render() 23 | return 24 | 25 | render: => 26 | position = @object.get('position') 27 | size = @object.get('size') 28 | @trackingObject.attr 29 | x: Math.round(position.x) - .5 30 | y: Math.round(position.y) - .5 31 | width: Math.round(size.width) + 1 32 | height: Math.round(size.height) + 1 33 | transform: @object.get('transform') 34 | 35 | _bindObject: (object) -> 36 | return unless object 37 | @selectedObjectSubscriptions = new CompositeDisposable 38 | @selectedObjectSubscriptions.add object.on('change', @render) 39 | 40 | _unbindObject: -> 41 | @selectedObjectSubscriptions?.dispose() 42 | @selectedObjectSubscriptions = null 43 | -------------------------------------------------------------------------------- /src/rectangle.coffee: -------------------------------------------------------------------------------- 1 | {Emitter} = require 'event-kit' 2 | ObjectAssign = require 'object-assign' 3 | Delegator = require 'delegato' 4 | 5 | Utils = require './utils' 6 | Draggable = require './draggable-mixin' 7 | 8 | RectangleModel = require './rectangle-model' 9 | 10 | DefaultAttrs = {x: 0, y: 0, width: 10, height: 10, fill: '#eee', stroke: 'none'} 11 | IDS = 0 12 | 13 | # Represents a svg element. Handles interacting with the element, and 14 | # rendering from the {RectangleModel}. 15 | module.exports = 16 | class Rectangle 17 | Draggable.includeInto(this) 18 | Delegator.includeInto(this) 19 | 20 | @delegatesMethods 'on', toProperty: 'emitter' 21 | @delegatesMethods 'get', 'set', 'getID', 'getType', 'toString', 22 | 'translate', 23 | toProperty: 'model' 24 | 25 | constructor: (@svgDocument, options={}) -> 26 | @emitter = new Emitter 27 | @model = new RectangleModel 28 | @_setupSVGObject(options) 29 | @model.on 'change', @onModelChange 30 | @svgDocument.registerObject(this) 31 | 32 | ### 33 | Section: Public Methods 34 | ### 35 | 36 | remove: -> 37 | @svgEl.remove() 38 | @emitter.emit('remove', object: this) 39 | 40 | # Call when the XML attributes change without the model knowing. Will update 41 | # the model with the new attributes. 42 | updateFromAttributes: -> 43 | x = @svgEl.attr('x') 44 | y = @svgEl.attr('y') 45 | width = @svgEl.attr('width') 46 | height = @svgEl.attr('height') 47 | transform = @svgEl.attr('transform') 48 | fill = @svgEl.attr('fill') 49 | 50 | @model.set({position: [x, y], size: [width, height], transform, fill}) 51 | 52 | # Will render the nodes and the transform from the model. 53 | render: (svgEl=@svgEl) -> 54 | position = @model.get('position') 55 | size = @model.get('size') 56 | svgEl.attr 57 | x: position.x 58 | y: position.y 59 | width: size.width 60 | height: size.height 61 | transform: @model.get('transform') or null 62 | fill: @model.get('fill') or null 63 | 64 | cloneElement: (svgDocument=@svgDocument) -> 65 | el = svgDocument.getObjectLayer().rect() 66 | @render(el) 67 | el 68 | 69 | ### 70 | Section: Event Handlers 71 | ### 72 | 73 | onModelChange: (args) => 74 | @render() 75 | args.object = this 76 | @emitter.emit 'change', args 77 | 78 | ### 79 | Section: Private Methods 80 | ### 81 | 82 | _setupSVGObject: (options) -> 83 | {@svgEl} = options 84 | @svgEl = @svgDocument.getObjectLayer().rect().attr(ObjectAssign({}, DefaultAttrs, options)) unless @svgEl 85 | Utils.setObjectOnNode(@svgEl.node, this) 86 | @updateFromAttributes() 87 | -------------------------------------------------------------------------------- /src/selection-model.coffee: -------------------------------------------------------------------------------- 1 | {Emitter, CompositeDisposable} = require 'event-kit' 2 | 3 | # Models what is selected and preselected. Preselection is shown as a red 4 | # outline when the user hovers over the object. 5 | module.exports = 6 | class SelectionModel 7 | constructor: -> 8 | @emitter = new Emitter 9 | @preselected = null 10 | @selected = null 11 | @selectedNode = null 12 | 13 | on: (args...) -> @emitter.on(args...) 14 | 15 | getPreselected: -> @preselected 16 | 17 | setPreselected: (preselected) -> 18 | return if preselected == @preselected 19 | return if preselected and preselected == @selected 20 | old = @preselected 21 | @preselected = preselected 22 | @emitter.emit 'change:preselected', object: @preselected, old: old 23 | 24 | getSelected: -> @selected 25 | 26 | setSelected: (selected) -> 27 | return if selected == @selected 28 | old = @selected 29 | @selected = selected 30 | 31 | @selectedSubscriptions?.dispose() 32 | @selectedSubscriptions = null 33 | 34 | if @selected? 35 | @selectedSubscriptions = new CompositeDisposable 36 | @selectedSubscriptions.add @selected?.on?('remove', => @setSelected(null)) 37 | @selectedSubscriptions.add @selected?.on? 'remove:node', ({node}) => 38 | @setSelectedNode(null) if node is @selectedNode 39 | 40 | @setPreselected(null) if @preselected is selected 41 | @emitter.emit 'change:selected', object: @selected, old: old 42 | 43 | getSelectedNode: -> @selectedNode 44 | 45 | setSelectedNode: (selectedNode) -> 46 | return if selectedNode == @selectedNode 47 | old = @selectedNode 48 | @selectedNode = selectedNode 49 | @emitter.emit 'change:selectedNode', node: @selectedNode, old: old 50 | 51 | clearSelected: -> 52 | @setSelected(null) 53 | clearPreselected: -> 54 | @setPreselected(null) 55 | clearSelectedNode: -> 56 | @setSelectedNode(null) 57 | -------------------------------------------------------------------------------- /src/selection-view.coffee: -------------------------------------------------------------------------------- 1 | ObjectSelection = require "./object-selection" 2 | 3 | # Handles showing / hiding the red outlines when an object is preselected. This 4 | # handles preselection only. Each ObjectEditor (e.g. PathEditor, ShapeEditor) 5 | # handles displaying its own selection rect or path. 6 | module.exports = 7 | class SelectionView 8 | constructor: (@svgDocument) -> 9 | @model = @svgDocument.getSelectionModel() 10 | @objectPreselection = new ObjectSelection(@svgDocument, class: 'object-preselection') 11 | @model.on 'change:preselected', @onChangePreselected 12 | 13 | getObjectSelection: -> 14 | @objectSelection 15 | 16 | onChangePreselected: ({object}) => 17 | @objectPreselection.setObject(object) 18 | -------------------------------------------------------------------------------- /src/serialize-svg.coffee: -------------------------------------------------------------------------------- 1 | SVG = require "../vendor/svg" 2 | SVG = require "../vendor/svg.export" 3 | 4 | module.exports = (svgElement, options) -> 5 | svgElement.exportSvg(options) 6 | -------------------------------------------------------------------------------- /src/shape-editor.coffee: -------------------------------------------------------------------------------- 1 | {CompositeDisposable} = require 'event-kit' 2 | Point = require './point' 3 | Size = require './size' 4 | RectangleSelection = require './rectangle-selection' 5 | {normalizePositionAndSize} = require './utils' 6 | 7 | # Handles the UI for free-form path editing. Manages NodeEditor objects based on 8 | # a Path's nodes. 9 | module.exports = 10 | class ShapeEditor 11 | # Fucking SVG renders borders in the center. So on pixel boundaries, a 1px 12 | # border is a fuzzy 2px. When this number is odd with a 1px border, it 13 | # produces sharp lines because it places the nodes on a .5 px boundary. Yay. 14 | handleSize: 9 15 | cornerPositionByIndex: ['topLeft', 'topRight', 'bottomRight', 'bottomLeft'] 16 | 17 | constructor: (@svgDocument) -> 18 | @object = null 19 | @toolLayer = @svgDocument.getToolLayer() 20 | @rectangleSelection = new RectangleSelection(@svgDocument) 21 | 22 | isActive: -> !!@object 23 | 24 | getActiveObject: -> @object 25 | 26 | activateObject: (object) -> 27 | @deactivate() 28 | if object? 29 | @object = object 30 | @_bindToObject(@object) 31 | @_setupCornerNodes() 32 | @rectangleSelection.setObject(object) 33 | @render() 34 | @show() 35 | 36 | deactivate: -> 37 | @rectangleSelection.setObject(null) 38 | @hide() 39 | @_unbindFromObject() 40 | @object = null 41 | 42 | hide: -> 43 | @visible = false 44 | @cornerHandles?.hide() 45 | 46 | show: (toFront) -> 47 | @visible = true 48 | @cornerHandles?.show() 49 | 50 | render: -> 51 | return unless @object? 52 | 53 | size = @object.get('size') 54 | position = @object.get('position') 55 | transform = @object.get('transform') 56 | 57 | for corner, index in @cornerHandles.members 58 | cornerPosition = @cornerPositionByIndex[index] 59 | point = @_getPointForCornerPosition(cornerPosition, position, size) 60 | 61 | corner.attr 62 | x: point.x - @handleSize / 2 63 | y: point.y - @handleSize / 2 64 | transform: transform 65 | 66 | return 67 | 68 | ### 69 | Section: Event Handlers 70 | ### 71 | 72 | onChangeObject: ({object, value}) -> 73 | @render() if value.size? or value.position? or value.transform? 74 | 75 | onStartDraggingCornerHandle: (cornerPosition) -> 76 | @_startSize = @object.get('size') 77 | @_startPosition = @object.get('position') 78 | 79 | onDraggingCornerHandle: (cornerPosition, delta, event) -> 80 | # Technique here is to anchor at the corner _opposite_ the corner the user 81 | # is dragging, find the new point at the corner we're dragging, then pass 82 | # that through the normalize function. 83 | switch cornerPosition 84 | when 'topLeft' 85 | anchor = @_getPointForCornerPosition('bottomRight', @_startPosition, @_startSize) 86 | changedPoint = @_getPointForCornerPosition('topLeft', @_startPosition, @_startSize).add(delta) 87 | when 'topRight' 88 | anchor = @_getPointForCornerPosition('bottomLeft', @_startPosition, @_startSize) 89 | changedPoint = @_getPointForCornerPosition('topRight', @_startPosition, @_startSize).add(delta) 90 | when 'bottomRight' 91 | anchor = @_getPointForCornerPosition('topLeft', @_startPosition, @_startSize) 92 | changedPoint = @_getPointForCornerPosition('bottomRight', @_startPosition, @_startSize).add(delta) 93 | when 'bottomLeft' 94 | anchor = @_getPointForCornerPosition('topRight', @_startPosition, @_startSize) 95 | changedPoint = @_getPointForCornerPosition('bottomLeft', @_startPosition, @_startSize).add(delta) 96 | 97 | @object.set(normalizePositionAndSize(anchor, changedPoint, event.shiftKey)) 98 | 99 | ### 100 | Section: Private Methods 101 | ### 102 | 103 | _bindToObject: (object) -> 104 | return unless object 105 | @objectSubscriptions = new CompositeDisposable 106 | @objectSubscriptions.add object.on('change', @onChangeObject.bind(this)) 107 | 108 | _unbindFromObject: -> 109 | @objectSubscriptions?.dispose() 110 | @objectSubscriptions = null 111 | 112 | _setupCornerNodes: -> 113 | return if @cornerHandles? 114 | @cornerHandles = @toolLayer.set() 115 | @cornerHandles.add( 116 | @toolLayer.rect(@handleSize, @handleSize), 117 | @toolLayer.rect(@handleSize, @handleSize), 118 | @toolLayer.rect(@handleSize, @handleSize), 119 | @toolLayer.rect(@handleSize, @handleSize) 120 | ) 121 | @cornerHandles.mousedown (e) => 122 | e.stopPropagation() 123 | false 124 | 125 | for corner, index in @cornerHandles.members 126 | corner.node.setAttribute('class', 'shape-editor-handle') 127 | corner.draggable() 128 | 129 | cornerPosition = @cornerPositionByIndex[index] 130 | corner.dragmove = @onDraggingCornerHandle.bind(this, cornerPosition) 131 | corner.dragstart = @onStartDraggingCornerHandle.bind(this, cornerPosition) 132 | corner.dragend = => 133 | @_startPosition = null 134 | 135 | return 136 | 137 | _getPointForCornerPosition: (cornerPosition, position, size) -> 138 | switch cornerPosition 139 | when 'topLeft' 140 | position 141 | when 'topRight' 142 | position.add([size.width, 0]) 143 | when 'bottomRight' 144 | position.add([size.width, size.height]) 145 | when 'bottomLeft' 146 | position.add([0, size.height]) 147 | -------------------------------------------------------------------------------- /src/shape-tool.coffee: -------------------------------------------------------------------------------- 1 | {Emitter} = require 'event-kit' 2 | 3 | Point = require './point' 4 | Size = require './size' 5 | Ellipse = require './ellipse' 6 | Rectangle = require './rectangle' 7 | {getCanvasPosition, normalizePositionAndSize} = require './utils' 8 | 9 | module.exports = 10 | class ShapeTool 11 | constructor: (@svgDocument) -> 12 | @emitter = new Emitter 13 | @selectionModel = @svgDocument.getSelectionModel() 14 | 15 | on: (args...) -> @emitter.on(args...) 16 | 17 | getType: -> @shapeType 18 | 19 | supportsType: (type) -> type in ['shape', 'rectangle', 'ellipse'] 20 | 21 | isActive: -> @active 22 | 23 | activate: (@shapeType) -> 24 | @shapeType ?= 'rectangle' 25 | svg = @svgDocument.getSVGRoot() 26 | svg.node.style.cursor = 'crosshair' 27 | svg.on 'mousedown', @onMouseDown 28 | svg.on 'mousemove', @onMouseMove 29 | svg.on 'mouseup', @onMouseUp 30 | @active = true 31 | 32 | deactivate: -> 33 | svg = @svgDocument.getSVGRoot() 34 | svg.node.style.cursor = null 35 | svg.off 'mousedown', @onMouseDown 36 | svg.off 'mousemove', @onMouseMove 37 | svg.off 'mouseup', @onMouseUp 38 | @active = false 39 | 40 | createShape: (params) -> 41 | if @shapeType is 'rectangle' 42 | new Rectangle(@svgDocument, params) 43 | else if @shapeType is 'ellipse' 44 | new Ellipse(@svgDocument, params) 45 | else 46 | null 47 | 48 | onMouseDown: (event) => 49 | @anchor = getCanvasPosition(@svgDocument.getSVGRoot(), event) 50 | true 51 | 52 | onMouseMove: (event) => 53 | return unless @anchor? 54 | point = getCanvasPosition(@svgDocument.getSVGRoot(), event) 55 | 56 | if not @shape and (Math.abs(point.x - @anchor.x) >= 5 or Math.abs(point.y - @anchor.y) >= 5) 57 | @shape = @createShape({x: @anchor.x, y: @anchor.y, width: 0, height: 0}) 58 | @selectionModel.setSelected(@shape) 59 | 60 | @shape.set(normalizePositionAndSize(@anchor, point, event.shiftKey)) if @shape 61 | 62 | onMouseUp: (event) => 63 | @anchor = null 64 | if @shape? 65 | @shape = null 66 | else 67 | @emitter.emit('cancel') 68 | -------------------------------------------------------------------------------- /src/size.coffee: -------------------------------------------------------------------------------- 1 | Point = require './point' 2 | 3 | # 4 | module.exports = 5 | class Size 6 | @create: (width, height) -> 7 | return width if width instanceof Size 8 | if Array.isArray(width) 9 | new Size(width[0], width[1]) 10 | else 11 | new Size(width, height) 12 | 13 | constructor: (width, height) -> 14 | @set(width, height) 15 | 16 | set: (@width, @height) -> 17 | [@width, @height] = @width if Array.isArray(@width) 18 | 19 | add: (width, height) -> 20 | if width instanceof Point or (width.x? and width.y?) 21 | point = width 22 | new Size(@width + point.x, @height + point.y) 23 | else 24 | other = Size.create(width, height) 25 | new Size(@width + other.width, @height + other.height) 26 | 27 | toArray: -> 28 | [@width, @height] 29 | 30 | equals: (other) -> 31 | other = Size.create(other) 32 | other.width == @width and other.height == @height 33 | 34 | toString: -> 35 | "(#{@width}, #{@height})" 36 | -------------------------------------------------------------------------------- /src/subpath.coffee: -------------------------------------------------------------------------------- 1 | {Emitter, CompositeDisposable} = require 'event-kit' 2 | Point = require './point' 3 | 4 | # Subpath handles a single path from move node -> close node. 5 | # 6 | # Svg paths can have many subpaths like this: 7 | # 8 | # M50,50L20,30Z M4,5L2,3Z 9 | # 10 | # Each one of these will be represented by this Subpath class. 11 | module.exports = 12 | class Subpath 13 | constructor: ({@path, @closed, nodes}={}) -> 14 | @emitter = new Emitter 15 | @nodes = [] 16 | @setNodes(nodes) 17 | @closed = !!@closed 18 | 19 | on: (args...) -> @emitter.on(args...) 20 | 21 | toString: -> 22 | "Subpath #{@toPathString()}" 23 | 24 | toPathString: -> 25 | path = '' 26 | lastPoint = null 27 | 28 | makeCurve = (fromNode, toNode) -> 29 | curve = '' 30 | if fromNode.handleOut or toNode.handleIn 31 | # use a bezier 32 | curve = [] 33 | curve = curve.concat(fromNode.getAbsoluteHandleOut().toArray()) 34 | curve = curve.concat(toNode.getAbsoluteHandleIn().toArray()) 35 | curve = curve.concat(toNode.point.toArray()) 36 | curve = "C#{curve.join(',')}" 37 | 38 | else if fromNode.point.x == toNode.point.x 39 | curve = "V#{toNode.point.y}" 40 | 41 | else if fromNode.point.y == toNode.point.y 42 | curve = "H#{toNode.point.x}" 43 | 44 | else 45 | curve = "L#{toNode.point.toArray().join(',')}" 46 | 47 | curve 48 | 49 | closePath = (firstNode, lastNode)-> 50 | return '' unless firstNode and lastNode 51 | closingPath = '' 52 | closingPath += makeCurve(lastNode, firstNode) if lastNode.handleOut or firstNode.handleIn 53 | closingPath += 'Z' 54 | 55 | for node in @nodes 56 | if path 57 | path += makeCurve(lastNode, node) 58 | else 59 | path += 'M' + node.point.toArray().join(',') 60 | 61 | lastNode = node 62 | 63 | path += closePath(@nodes[0], @nodes[@nodes.length-1]) if @closed 64 | path 65 | 66 | getNodes: -> @nodes 67 | 68 | setNodes: (nodes) -> 69 | return unless nodes and Array.isArray(nodes) 70 | 71 | @_unbindNodes() 72 | @_bindNodes(nodes) 73 | 74 | @nodes = nodes 75 | @emitter.emit('change', this) 76 | 77 | addNode: (node) -> 78 | @insertNode(node, @nodes.length) 79 | 80 | insertNode: (node, index) -> 81 | @_bindNode(node) 82 | @nodes.splice(index, 0, node) 83 | @emitter.emit('insert:node', {subpath: this, index, node}) 84 | @emitter.emit('change', this) 85 | 86 | removeNode: (node) -> 87 | index = @nodes.indexOf(node) 88 | if index > -1 89 | @nodes.splice(index, 1) 90 | @emitter.emit('remove:node', {subpath: this, index, node}) 91 | @emitter.emit('change', this) 92 | 93 | isClosed: -> @closed 94 | 95 | close: -> 96 | @closed = true 97 | @emitter.emit('change', this) 98 | 99 | translate: (point) -> 100 | point = Point.create(point) 101 | for node in @nodes 102 | node.translate(point) 103 | return 104 | 105 | onNodeChange: => 106 | @emitter.emit 'change', this 107 | 108 | _bindNode: (node) -> 109 | node.setPath(@path) 110 | @nodeChangeSubscriptions ?= new CompositeDisposable 111 | @nodeChangeSubscriptions.add node.on('change', @onNodeChange) 112 | 113 | _bindNodes: (nodes) -> 114 | for node in nodes 115 | @_bindNode(node) 116 | return 117 | 118 | _unbindNodes: -> 119 | for node in @nodes 120 | node.setPath(null) 121 | @nodeChangeSubscriptions?.dispose() 122 | @nodeChangeSubscriptions = null 123 | 124 | _findNodeIndex: (node) -> 125 | for i in [0...@nodes.length] 126 | return i if @nodes[i] == node 127 | -1 128 | -------------------------------------------------------------------------------- /src/svg-document-model.coffee: -------------------------------------------------------------------------------- 1 | {Emitter, CompositeDisposable} = require 'event-kit' 2 | 3 | Size = require "./size" 4 | Point = require "./point" 5 | 6 | module.exports = 7 | class SVGDocumentModel 8 | constructor: -> 9 | @emitter = new Emitter 10 | @reset() 11 | 12 | reset: -> 13 | @objects = [] 14 | @objectSubscriptions?.dispose() 15 | @objectSubscriptions = new CompositeDisposable 16 | @objectSubscriptionsByObject = {} 17 | 18 | on: (args...) -> @emitter.on(args...) 19 | 20 | setObjects: (objects) -> 21 | @reset() 22 | options = {silent: true} 23 | for object in objects 24 | @registerObject(object, options) 25 | return 26 | 27 | getObjects: -> @objects 28 | 29 | registerObject: (object, options) -> 30 | 31 | @objectSubscriptionsByObject[object.getID()] = subscriptions = new CompositeDisposable 32 | 33 | subscriptions.add object.on('change', @onChangedObject) 34 | subscriptions.add object.on('remove', @onRemovedObject) 35 | 36 | @objectSubscriptions.add(subscriptions) 37 | @objects.push(object) 38 | @emitter.emit('change') unless @options?.silent 39 | 40 | setSize: (w, h) -> 41 | size = Size.create(w, h) 42 | return if size.equals(@size) 43 | @size = size 44 | @emitter.emit 'change:size', {size} 45 | 46 | getSize: -> @size 47 | 48 | onChangedObject: (event) => 49 | @emitter.emit 'change', event 50 | 51 | onRemovedObject: (event) => 52 | {object} = event 53 | subscription = @objectSubscriptionsByObject[object.getID()] 54 | delete @objectSubscriptionsByObject[object.getID()] 55 | if subscription? 56 | subscription.dispose() 57 | @objectSubscriptions.remove(subscription) 58 | index = @objects.indexOf(object) 59 | @objects.splice(index, 1) if index > -1 60 | @emitter.emit 'change', event 61 | -------------------------------------------------------------------------------- /src/svg-document.coffee: -------------------------------------------------------------------------------- 1 | {Emitter} = require 'event-kit' 2 | SVG = require '../vendor/svg' 3 | 4 | SelectionModel = require "./selection-model" 5 | SelectionView = require "./selection-view" 6 | PenTool = require "./pen-tool" 7 | PointerTool = require "./pointer-tool" 8 | ShapeTool = require "./shape-tool" 9 | SerializeSVG = require "./serialize-svg" 10 | DeserializeSVG = require "./deserialize-svg" 11 | Size = require "./size" 12 | Point = require "./point" 13 | ObjectEditor = require './object-editor' 14 | SVGDocumentModel = require "./svg-document-model" 15 | 16 | module.exports = 17 | class SVGDocument 18 | constructor: (rootNode) -> 19 | @emitter = new Emitter 20 | @model = new SVGDocumentModel 21 | @svg = SVG(rootNode) 22 | 23 | @toolLayer = @svg.group() 24 | @toolLayer.node.setAttribute('class', 'tool-layer') 25 | 26 | @selectionModel = new SelectionModel() 27 | @selectionView = new SelectionView(this) 28 | @objectEditor = new ObjectEditor(this) 29 | 30 | @model.on('change:size', @onChangedSize) 31 | 32 | # delegate model events 33 | @model.on 'change', (event) => @emitter.emit('change', event) 34 | @model.on 'change:size', (event) => @emitter.emit('change:size', event) 35 | 36 | on: (args...) -> @emitter.on(args...) 37 | 38 | initializeTools: -> 39 | @tools = [ 40 | new PointerTool(this) 41 | new PenTool(this) 42 | new ShapeTool(this) 43 | ] 44 | 45 | for tool in @tools 46 | tool.on?('cancel', => @setActiveToolType('pointer')) 47 | 48 | @setActiveToolType('pointer') 49 | 50 | ### 51 | Section: File Serialization 52 | ### 53 | 54 | deserialize: (svgString) -> 55 | @model.setObjects(DeserializeSVG(this, svgString)) 56 | 57 | objectLayer = null 58 | @svg.each -> objectLayer = this if this.node.nodeName == 'svg' 59 | @objectLayer = objectLayer 60 | objectLayer = @getObjectLayer() unless objectLayer? 61 | 62 | @model.setSize(new Size(objectLayer.width(), objectLayer.height())) 63 | @toolLayer.front() 64 | return 65 | 66 | serialize: -> 67 | svgRoot = @getObjectLayer() 68 | if svgRoot 69 | SerializeSVG(svgRoot, whitespace: true) 70 | else 71 | '' 72 | 73 | ### 74 | Section: Tool Management 75 | ### 76 | 77 | toolForType: (toolType) -> 78 | for tool in @tools 79 | return tool if tool.supportsType(toolType) 80 | null 81 | 82 | getActiveTool: -> 83 | for tool in @tools 84 | return tool if tool.isActive() 85 | null 86 | 87 | getActiveToolType: -> 88 | @getActiveTool()?.getType() 89 | 90 | setActiveToolType: (toolType) -> 91 | oldActiveTool = @getActiveTool() 92 | oldActiveToolType = oldActiveTool?.getType() 93 | 94 | newTool = @toolForType(toolType) 95 | if newTool? and toolType isnt oldActiveToolType 96 | oldActiveTool?.deactivate() 97 | newTool.activate(toolType) 98 | @emitter.emit('change:tool', {toolType}) 99 | 100 | ### 101 | Section: Selections 102 | ### 103 | 104 | getSelectionModel: -> @selectionModel 105 | 106 | getSelectionView: -> @selectionView 107 | 108 | ### 109 | Section: SVG Details 110 | ### 111 | 112 | getSVGRoot: -> @svg 113 | 114 | getToolLayer: -> @toolLayer 115 | 116 | getObjectLayer: -> 117 | @objectLayer = @_createObjectLayer() unless @objectLayer? 118 | @objectLayer 119 | 120 | ### 121 | Section: Document Details 122 | ### 123 | 124 | setSize: (w, h) -> @model.setSize(w, h) 125 | 126 | getSize: -> @model.getSize() 127 | 128 | getObjects: -> @model.getObjects() 129 | 130 | getObjectEditor: -> @objectEditor 131 | 132 | ### 133 | Section: Event Handlers 134 | ### 135 | 136 | onChangedSize: ({size}) => 137 | root = @getObjectLayer() 138 | root.width(size.width) 139 | root.height(size.height) 140 | 141 | ### 142 | Section: Acting on selected elements 143 | ### 144 | 145 | translateSelectedObjects: (deltaPoint) -> 146 | # TODO: this could ask the active tool to move the selected objects 147 | return unless deltaPoint 148 | deltaPoint = Point.create(deltaPoint) 149 | 150 | if selectedNode = @selectionModel.getSelectedNode() 151 | selectedNode?.translate?(deltaPoint) 152 | else if selectedObject = @selectionModel.getSelected() 153 | selectedObject?.translate?(deltaPoint) 154 | 155 | removeSelectedObjects: -> 156 | # TODO: this could ask the active tool to remove the selected objects 157 | selectedObject = @selectionModel.getSelected() 158 | 159 | if selectedObject and (selectedNode = @selectionModel.getSelectedNode()) 160 | selectedObject.removeNode?(selectedNode) 161 | else 162 | selectedObject?.remove() 163 | 164 | registerObject: (object) -> 165 | @model.registerObject(object) 166 | 167 | _createObjectLayer: -> 168 | @objectLayer = @svg.nested() 169 | @setSize(1024, 1024) 170 | @objectLayer.back() 171 | -------------------------------------------------------------------------------- /src/transform.coffee: -------------------------------------------------------------------------------- 1 | SVG = require '../vendor/svg' 2 | Point = require "./point" 3 | 4 | # Transform class parses the string from an SVG transform attribute, and running 5 | # points through the parsed transformation. 6 | # 7 | # TODO: 8 | # 9 | # * Add support for all other transformations. Currently this only supports 10 | # translations because I didnt need anything else. 11 | # 12 | module.exports = 13 | class Transform 14 | constructor: -> 15 | @matrix = null 16 | @transformString = '' 17 | 18 | setTransformString: (transformString='') -> 19 | if @transformString is transformString 20 | false 21 | else 22 | @transformString = transformString 23 | transform = SVG.parse.transform(transformString) 24 | 25 | @matrix = null 26 | if transform 27 | @matrix = SVG.parser.draw.node.createSVGMatrix() 28 | if transform.x? 29 | @matrix = @matrix.translate(transform.x, transform.y) 30 | else if transform.a? 31 | for k, v of transform 32 | @matrix[k] = transform[k] 33 | else 34 | @matrix = null 35 | 36 | true 37 | 38 | toString: -> 39 | @transformString 40 | 41 | transformPoint: (point) -> 42 | point = Point.create(point) 43 | if @matrix 44 | svgPoint = SVG.parser.draw.node.createSVGPoint() 45 | svgPoint.x = point.x 46 | svgPoint.y = point.y 47 | svgPoint = svgPoint.matrixTransform(@matrix) 48 | Point.create(svgPoint.x, svgPoint.y) 49 | else 50 | point 51 | -------------------------------------------------------------------------------- /src/utils.coffee: -------------------------------------------------------------------------------- 1 | Point = require "./point" 2 | Size = require "./size" 3 | 4 | # Browserify loads this module twice :/ 5 | getObjectMap = -> 6 | g = (global ? window) 7 | g.NodeObjectMap ?= {} 8 | g.NodeObjectMap 9 | 10 | Utils = 11 | getObjectFromNode: (domNode) -> 12 | getObjectMap()[domNode.id] 13 | 14 | setObjectOnNode: (domNode, object) -> 15 | getObjectMap()[domNode.id] = object 16 | 17 | getCanvasPosition: (svgRoot, event) -> 18 | if event.offsetX? and event.offsetY? 19 | x = event.offsetX 20 | y = event.offsetY 21 | else 22 | x = event.pageX - svgRoot.node.offsetLeft 23 | y = event.pageY - svgRoot.node.offsetTop 24 | new Point(x, y) 25 | 26 | normalizePositionAndSize: (anchor, point, constrain) -> 27 | if constrain 28 | diffX = point.x - anchor.x 29 | diffY = point.y - anchor.y 30 | minVal = Math.min(Math.abs(diffX), Math.abs(diffY)) 31 | 32 | diffX = diffX / Math.abs(diffX) * minVal 33 | diffY = diffY / Math.abs(diffY) * minVal 34 | point = new Point(anchor.x + diffX, anchor.y + diffY) 35 | 36 | topLeft = new Point(Math.min(anchor.x, point.x), Math.min(anchor.y, point.y)) 37 | bottomRight = new Point(Math.max(anchor.x, point.x), Math.max(anchor.y, point.y)) 38 | diff = bottomRight.subtract(topLeft) 39 | {position: topLeft, size: new Size(diff.x, diff.y)} 40 | 41 | module.exports = Utils 42 | -------------------------------------------------------------------------------- /vendor/svg.export.js: -------------------------------------------------------------------------------- 1 | // svg.export.js 0.1.1 - Copyright (c) 2014 Wout Fierens - Licensed under the MIT license 2 | // NOTE: benogle edited some things: 3 | // * Added isRoot to be true on elements 4 | // * Removed exporting a generated id attribute, and identity matrix transform attr 5 | ;(function() { 6 | 7 | var isGeneratedEid = function(id) { 8 | return id.indexOf('Svgjs') == 0 9 | } 10 | 11 | // Add export method to SVG.Element 12 | SVG.extend(SVG.Element, { 13 | // Build node string 14 | exportSvg: function(options, level) { 15 | var i, il, width, height, well, clone 16 | , name = this.node.nodeName 17 | , node = '' 18 | 19 | /* ensure options */ 20 | options = options || {} 21 | 22 | if (options.exclude == null || !options.exclude.call(this)) { 23 | /* ensure defaults */ 24 | options = options || {} 25 | level = level || 0 26 | 27 | // benogle did this 28 | var isRoot = this.node.nodeName == 'svg' 29 | 30 | /* set context */ 31 | if (isRoot) { 32 | /* define doctype */ 33 | node += whitespaced('', options.whitespace, level) 34 | 35 | /* store current width and height */ 36 | width = this.attr('width') 37 | height = this.attr('height') 38 | 39 | /* set required size */ 40 | if (options.width) 41 | this.attr('width', options.width) 42 | if (options.height) 43 | this.attr('height', options.height) 44 | 45 | if (!this.attr('xmlns')) 46 | this.attr('xmlns', 'http://www.w3.org/2000/svg') 47 | } 48 | 49 | /* open node */ 50 | node += whitespaced('<' + name + this.attrToString() + '>', options.whitespace, level) 51 | 52 | /* reset size and add description */ 53 | if (isRoot) { 54 | this.attr({ 55 | width: width 56 | , height: height 57 | }) 58 | 59 | node += whitespaced('Created with Curve', options.whitespace, level + 1) 60 | /* Add defs... */ 61 | if (this.defs().first()) 62 | node += this.defs().exportSvg(options, level + 1); 63 | 64 | } 65 | 66 | /* add children */ 67 | if (this instanceof SVG.Parent) { 68 | for (i = 0, il = this.children().length; i < il; i++) { 69 | if (SVG.Absorbee && this.children()[i] instanceof SVG.Absorbee) { 70 | clone = this.children()[i].node.cloneNode(true) 71 | well = document.createElement('div') 72 | well.appendChild(clone) 73 | node += well.innerHTML 74 | } else { 75 | node += this.children()[i].exportSvg(options, level + 1) 76 | } 77 | } 78 | 79 | } else if (this instanceof SVG.Text || this instanceof SVG.Tspan) { 80 | for (i = 0, il = this.node.childNodes.length; i < il; i++) 81 | if (this.node.childNodes[i].instance instanceof SVG.TSpan) 82 | node += this.node.childNodes[i].instance.exportSvg(options, level + 1) 83 | else 84 | node += this.node.childNodes[i].nodeValue.replace(/&/g,'&') 85 | 86 | } else if (SVG.ComponentTransferEffect && this instanceof SVG.ComponentTransferEffect) { 87 | this.rgb.each(function() { 88 | node += this.exportSvg(options, level + 1) 89 | }) 90 | 91 | } 92 | 93 | /* close node */ 94 | node += whitespaced('', options.whitespace, level) 95 | } 96 | 97 | return node 98 | } 99 | // Set specific export attibutes 100 | , exportAttr: function(attr) { 101 | /* acts as getter */ 102 | if (arguments.length == 0) 103 | return this.data('svg-export-attr') 104 | 105 | /* acts as setter */ 106 | return this.data('svg-export-attr', attr) 107 | } 108 | // Convert attributes to string 109 | , attrToString: function() { 110 | var i, key, value 111 | , attr = [] 112 | , data = this.exportAttr() 113 | , exportAttrs = this.attr() 114 | 115 | /* ensure data */ 116 | if (typeof data == 'object') 117 | for (key in data) 118 | if (key != 'data-svg-export-attr') 119 | exportAttrs[key] = data[key] 120 | 121 | /* build list */ 122 | for (key in exportAttrs) { 123 | value = exportAttrs[key] 124 | 125 | // benogle did this 126 | if (key == 'transform' && value == 'matrix(1,0,0,1,0,0)') 127 | continue 128 | else if (key == 'id' && isGeneratedEid(value)) 129 | continue 130 | 131 | /* enfoce explicit xlink namespace */ 132 | if (key == 'xlink') { 133 | key = 'xmlns:xlink' 134 | } else if (key == 'href') { 135 | if (!exportAttrs['xlink:href']) 136 | key = 'xlink:href' 137 | } 138 | 139 | /* normailse value */ 140 | if (typeof value === 'string') 141 | value = value.replace(/"/g,"'") 142 | 143 | /* build value */ 144 | if (key != 'data-svg-export-attr' && key != 'href') { 145 | if (key != 'stroke' || parseFloat(exportAttrs['stroke-width']) > 0) 146 | attr.push(key + '="' + value + '"') 147 | } 148 | 149 | } 150 | 151 | return attr.length ? ' ' + attr.join(' ') : '' 152 | } 153 | 154 | }) 155 | 156 | ///////////// 157 | // helpers 158 | ///////////// 159 | 160 | // Whitespaced string 161 | function whitespaced(value, add, level) { 162 | if (add) { 163 | var whitespace = '' 164 | , space = add === true ? ' ' : add || '' 165 | 166 | /* build indentation */ 167 | for (i = level - 1; i >= 0; i--) 168 | whitespace += space 169 | 170 | /* add whitespace */ 171 | value = whitespace + value + '\n' 172 | } 173 | 174 | return value; 175 | } 176 | 177 | }).call(this); 178 | -------------------------------------------------------------------------------- /vendor/svg.parser.js: -------------------------------------------------------------------------------- 1 | // svg.parser.js 0.1.0 - Copyright (c) 2014 Wout Fierens - Licensed under the MIT license 2 | ;(function() { 3 | 4 | SVG.parse = { 5 | // Convert attributes to an object 6 | attr: function(child) { 7 | var i 8 | , attrs = child.attributes || [] 9 | , attr = {} 10 | 11 | /* gather attributes */ 12 | for (i = attrs.length - 1; i >= 0; i--) 13 | attr[attrs[i].nodeName] = attrs[i].nodeValue 14 | 15 | /* ensure stroke width where needed */ 16 | if (typeof attr.stroke != 'undefined' && typeof attr['stroke-width'] == 'undefined') 17 | attr['stroke-width'] = 1 18 | 19 | return attr 20 | } 21 | 22 | // Convert transformations to an object 23 | , transform: function(transform) { 24 | var i, t, v 25 | , trans = {} 26 | , list = (transform || '').match(/[A-Za-z]+\([^\)]+\)/g) || [] 27 | , def = SVG.defaults.trans 28 | 29 | /* gather transformations */ 30 | for (i = list.length - 1; i >= 0; i--) { 31 | /* parse transformation */ 32 | t = list[i].match(/([A-Za-z]+)\(([^\)]+)\)/) 33 | v = (t[2] || '').replace(/^\s+/,'').replace(/,/g, ' ').replace(/\s+/g, ' ').split(' ') 34 | 35 | /* objectify transformation */ 36 | switch(t[1]) { 37 | case 'matrix': 38 | trans.a = SVG.regex.isNumber.test(v[0]) ? parseFloat(v[0]) : def.a 39 | trans.b = parseFloat(v[1]) || def.b 40 | trans.c = parseFloat(v[2]) || def.c 41 | trans.d = SVG.regex.isNumber.test(v[3]) ? parseFloat(v[3]) : def.d 42 | trans.e = parseFloat(v[4]) || def.e 43 | trans.f = parseFloat(v[5]) || def.f 44 | break 45 | case 'rotate': 46 | trans.rotation = parseFloat(v[0]) || def.rotation 47 | trans.cx = parseFloat(v[1]) || def.cx 48 | trans.cy = parseFloat(v[2]) || def.cy 49 | break 50 | case 'scale': 51 | trans.scaleX = SVG.regex.isNumber.test(v[0]) ? parseFloat(v[0]) : def.scaleX 52 | trans.scaleY = SVG.regex.isNumber.test(v[1]) ? parseFloat(v[1]) : def.scaleY 53 | break 54 | case 'skewX': 55 | trans.skewX = parseFloat(v[0]) || def.skewX 56 | break 57 | case 'skewY': 58 | trans.skewY = parseFloat(v[0]) || def.skewY 59 | break 60 | case 'translate': 61 | trans.x = parseFloat(v[0]) || def.x 62 | trans.y = parseFloat(v[1]) || def.y 63 | break 64 | } 65 | } 66 | 67 | return trans 68 | } 69 | } 70 | 71 | SVG.defaults.trans = { 72 | /* translate */ 73 | x: 0 74 | , y: 0 75 | /* scale */ 76 | , scaleX: 1 77 | , scaleY: 1 78 | /* rotate */ 79 | , rotation: 0 80 | /* skew */ 81 | , skewX: 0 82 | , skewY: 0 83 | /* matrix */ 84 | , matrix: this.matrix 85 | , a: 1 86 | , b: 0 87 | , c: 0 88 | , d: 1 89 | , e: 0 90 | , f: 0 91 | } 92 | })(); 93 | --------------------------------------------------------------------------------