├── .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 | 
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'
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 |
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 |
252 | '''
253 |
254 | DOCUMENT_WITH_XML_DOCTYPE = '''
255 |
256 |
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>$1>')
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('' + name + '>', 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 |
--------------------------------------------------------------------------------