JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
0a60f329096c943477f89a689b7f10887781e6a8
[crayon_mockup.git] / main.coffee
1 # Copyright 2015 Jason Woofenden
2 #
3 # This program is free software: you can redistribute it and/or modify it under
4 # the terms of the GNU General Public License as published by the Free Software
5 # Foundation, either version 3 of the License, or (at your option) any later
6 # version.
7 #
8 # This program is distributed in the hope that it will be useful, but WITHOUT
9 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10 # FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
11 # details.
12 #
13 # You should have received a copy of the GNU General Public License along with
14 # this program.  If not, see <http://www.gnu.org/licenses/>.
15
16
17 # settings
18 width = 800
19 height = 600
20 supply_height = 96
21 CLICK_FUZ = 10 # this far away from things is close enough to be "clicked on"
22 PROX_MAX = CLICK_FUZ * CLICK_FUZ
23 PROX_TOO_FAR = PROX_MAX + 1 # no need to be precice when it's too far
24
25 # constants
26 STATES = {
27         NORMAL:   { txt: 'normal' }
28         SELECTED: { txt: 'selected' }
29         DRAGGING: { txt: 'dragging' }
30         EDITING:  { txt: 'editing' }
31 }
32 TYPE_WIDGET = 1
33 TYPE_CONTROL = 2
34
35 # json (compiled to javascript and minified) is ~8% smaller than the resulting xml
36 json_to_svg = (json) ->
37         for tag, attrs of json
38                 el = document.createElementNS 'http://www.w3.org/2000/svg', tag
39                 for k, v of attrs
40                         if k is 'children'
41                                 for child in v
42                                         el.appendChild json_to_svg child
43                         else if k is 'contents'
44                                 el.appendChild document.createTextNode v
45                         else
46                                 el.setAttribute k, v
47         return el
48
49 resizer_nw = (widget) ->
50         return (dxy) ->
51                 widget.resize w: widget.width - dxy.x, h: widget.height - dxy.y
52                 widget.move x: widget.x + dxy.x, y: widget.y + dxy.y
53 resizer_n = (widget) ->
54         return (dxy) ->
55                 widget.resize w: widget.width, h: widget.height - dxy.y
56                 widget.move x: widget.x, y: widget.y + dxy.y
57 resizer_ne = (widget) ->
58         return (dxy) ->
59                 widget.resize w: widget.width + dxy.x, h: widget.height - dxy.y
60                 widget.move x: widget.x, y: widget.y + dxy.y
61 resizer_e = (widget) ->
62         return (dxy) ->
63                 widget.resize w: widget.width + dxy.x, h: widget.height
64 resizer_se = (widget) ->
65         return (dxy) ->
66                 widget.resize w: widget.width + dxy.x, h: widget.height + dxy.y
67 resizer_s = (widget) ->
68         return (dxy) ->
69                 widget.resize w: widget.width, h: widget.height + dxy.y
70 resizer_sw = (widget) ->
71         return (dxy) ->
72                 widget.resize w: widget.width - dxy.x, h: widget.height + dxy.y
73                 widget.move x: widget.x + dxy.x, y: widget.y
74 resizer_w = (widget) ->
75         return (dxy) ->
76                 widget.resize w: widget.width - dxy.x, h: widget.height
77                 widget.move x: widget.x + dxy.x, y: widget.y
78 resizers = [
79         resizer_nw
80         resizer_n
81         resizer_ne
82         resizer_e
83         resizer_se
84         resizer_s
85         resizer_sw
86         resizer_w
87 ]
88 resizer_shapes = [
89         -> return "M#{@x - 5} #{@y - 5}h6l-2 2 4 4 2 -2v6h-6l2-2-4-4-2 2z"
90         -> return "M #{@x},#{@y - 7} l 4,4 -2.5,0 0,5 2.5,0 -4,4 -4,-4 2.5,0 0,-5 -2.5,0 z"
91         -> return "M#{@x + 5} #{@y - 5}v6l-2-2-4 4 2 2h-6v-6l2 2 4-4-2-2z"
92         -> return "M #{@x - 7},#{@y} l 4,-4 0,2.5 5,0 0,-2.5 4,4 -4,4 0,-2.5 -5,0 0,2.5 z"
93 ]
94 shape_node_move = -> "M#{@x} #{@y - 9}l-2.5 4.5h2v2.404a2.156 2.156 0 0 0-1.596 1.596h-2.404v-2l-4.5 2.5 4.5 2.5v-2h2.404a2.156 2.156 0 0 0 1.596 1.596v2.404h-2l2.5 4.5 2.5-4.5h-2v-2.404a2.156 2.156 0 0 0 1.596-1.596h2.404v2l4.5-2.5-4.5-2.5v2h-2.404a2.156 2.156 0 0 0-1.596-1.596v-2.404h2l-2.5-4.5z"
95
96 next_widget_id = 0
97 # public vars: x, y, width, height, el
98 class Visible
99         # required args: svg
100         constructor: (args) ->
101                 @id = next_widget_id
102                 next_widget_id += 1
103                 @svg = args.svg
104                 @x = args.x ? 1
105                 @y = args.y ? 1
106                 @width = args.width ? 50
107                 @height = args.height ? 34
108                 @state = args.state ? STATES.NORMAL
109                 @hover = false
110         destruct: ->
111         update_class: ->
112                 css_class = "#{@css_class} #{@state.txt}"
113                 if @hover
114                         css_class += " hover"
115                 @el.setAttribute 'class', css_class
116         set_hover: (tf) ->
117                 if tf != @hover
118                         @hover = tf
119                         @update_class()
120         move: (xy) -> # just move
121                 @x = xy.x
122                 @y = xy.y
123         drag: (dxy) -> # react to mouse drag (obey constraints, etc.)
124                 @move x: @x + dxy.x, y: @y + dxy.y
125         proximity: (xy) -> # return the square of the distance to your visible bits
126                 return PROX_TOO_FAR
127         set_state: (state) ->
128                 @state = state
129
130 class Control extends Visible
131         constructor: (args) ->
132                 super args
133                 @type = TYPE_CONTROL
134                 @on_drag = args.drag
135                 @on_destruct = args.done ? null
136         destruct: ->
137                 super()
138                 if @on_destruct?
139                         @on_destruct @
140         drag: (args) -> # call this when control point is being manipulated directly
141                 @on_drag args
142         proximity: (xy) -> # return the square of the distance to your visible bits
143                 dx = xy.x - @x
144                 dy = xy.y - @y
145                 return dx * dx + dy * dy
146 class ControlPath extends Control
147         constructor: (args) ->
148                 super args
149                 @css_class = 'control_point'
150                 @make_path = args.shape
151                 @el = json_to_svg path:
152                         d: @make_path()
153                         class: 'control_point normal'
154                 @svg.appendChild @el
155         destruct: ->
156                 super()
157                 if @el?
158                         @svg.removeChild @el
159         move: (args) ->
160                 super args
161                 @el.setAttribute 'd', @make_path()
162
163 class Widget extends Visible
164         #sub-classes are expected to implement all of these:
165         constructor: (args) ->
166                 super args
167                 @controls = []
168                 @type = TYPE_WIDGET
169         destruct: ->
170                 @kill_controls()
171         clone: ->
172                 return new Widget @
173         make_controls: -> # create controls, return them
174                 return []
175         kill_controls: ->
176                 for c in @controls
177                         c.destruct()
178                 @controls = []
179                 return
180         move: (xy) -> # just move
181                 dx = xy.x - @x
182                 dy = xy.y - @y
183                 super xy
184                 for c in @controls
185                         c.move x: c.x + dx, y: c.y + dy
186         set_state: (state) ->
187                 return if @state is state
188                 if @state is STATES.EDITING
189                         @kill_controls()
190                 super state
191
192 class RectWidget extends Widget
193         constructor: (args) ->
194                 super args
195                 @css_class = 'box'
196                 @el = json_to_svg rect:
197                         x: @x + 1
198                         y: @y + 1
199                         width: @width - 2
200                         height: @height - 2
201                         class: 'box normal'
202                 @svg.appendChild @el
203         destruct: ->
204                 super()
205                 if @el?
206                         @svg.removeChild @el
207         clone: ->
208                 return new RectWidget @
209         set_state: (state) ->
210                 super state
211                 @update_class()
212         move: (args) ->
213                 super args
214                 @el.setAttribute 'x', @x + 1
215                 @el.setAttribute 'y', @y + 1
216                 @reposition_controls()
217         proximity: (xy) -> # return the square of the distance to your visible bits
218                 x = xy.x
219                 y = xy.y
220                 prox = PROX_TOO_FAR
221                 in_x = false
222                 in_y = false
223                 if x > @x - CLICK_FUZ and x < @x + @width + CLICK_FUZ
224                         in_x = true
225                         if y < @y + @height / 2
226                                 new_prox = @y - y
227                         else
228                                 new_prox = @y + @height - y
229                         new_prox *= new_prox
230                         if new_prox < prox
231                                 prox = new_prox
232                 if y > @y - CLICK_FUZ and y < @y + @height + CLICK_FUZ
233                         in_y = true
234                         if x < @x + @width / 2
235                                 new_prox = @x - x
236                         else
237                                 new_prox = @x + @width - x
238                         new_prox *= new_prox
239                         if new_prox < prox
240                                 prox = new_prox
241                 if in_x and in_y and prox > PROX_MAX
242                         prox = PROX_MAX - 1
243                 return prox
244         resize: (wh) ->
245                 dw = wh.w - @width
246                 dh = wh.h - @height
247                 @width = wh.w
248                 @el.setAttribute 'width', @width - 2
249                 @height = wh.h
250                 @el.setAttribute 'height', @height - 2
251                 @reposition_controls()
252         reposition_controls: ->
253                 if @controls.length > 1
254                         positions = @control_positions()
255                         for i in [0...positions.length]
256                                 @controls[i].move x: positions[i].x, y: positions[i].y
257         control_positions: ->
258                 gap = 7
259                 mgap = 9
260                 w2p = Math.floor(@width / 2) + 0.5
261                 h2p = Math.floor(@height / 2) + 0.5
262                 return [
263                         { x: @x - gap, y: @y - gap }
264                         { x: @x + w2p, y: @y - mgap }
265                         { x: @x + @width + gap, y: @y - gap }
266                         { x: @x + @width + mgap, y: @y + h2p }
267                         { x: @x + @width + gap, y: @y + @height + gap }
268                         { x: @x + w2p, y: @y + @height + mgap }
269                         { x: @x - gap, y: @y + @height + gap }
270                         { x: @x - mgap, y: @y + h2p }
271                 ]
272         make_controls: (args) -> # create controls, return them
273                 if @controls.length > 0
274                         if console?.log?
275                                 console.log "warning: re-adding controls"
276                         @kill_controls()
277                 positions = @control_positions()
278                 for i in [0...positions.length]
279                         @controls.push new ControlPath {
280                                 svg: @svg
281                                 x: positions[i].x
282                                 y: positions[i].y
283                                 done: args.done
284                                 drag: resizers[i] @
285                                 shape: resizer_shapes[i % resizer_shapes.length]
286                         }
287                 return @controls
288
289 class PolylineWidget extends Widget
290         constructor: (args) ->
291                 super args
292                 @css_class = 'polyline'
293                 @nodes = []
294                 for n in args.nodes
295                         @nodes.push x: n.x, y: n.y
296                 @el = json_to_svg path:
297                         d: @my_path_d()
298                         class: 'polyline normal'
299                 @svg.appendChild @el
300         destruct: ->
301                 super()
302                 if @el?
303                         @svg.removeChild @el
304         clone: ->
305                 return new PolylineWidget @
306         my_path_d: ->
307                 ret = ''
308                 for n in @nodes
309                         if ret is ''
310                                 ret += 'M'
311                         else
312                                 ret += 'L'
313                         ret += n.x + @x
314                         ret += ' '
315                         ret += n.y + @y
316                 return ret
317         set_state: (state) ->
318                 super state
319                 @update_class()
320         move: (args) ->
321                 super args
322                 @el.setAttribute 'd', @my_path_d()
323                 @reposition_controls()
324         proximity: (xy) -> # return the square of the distance to your visible bits
325                 prox = PROX_TOO_FAR
326                 for n, i in @nodes
327                         dx = @x + n.x - xy.x
328                         dy = @y + n.y - xy.y
329                         p = dx * dx + dy * dy
330                         if p < prox
331                                 prox = p
332                         if i > 0
333                                 l1x = @x + @nodes[i-1].x
334                                 l1y = @y + @nodes[i-1].y
335                                 l2x = @x + @nodes[i].x
336                                 l2y = @y + @nodes[i].y
337                                 ldx = l2x - l1x
338                                 ldy = l2y - l1y
339                                 if ldx is 0 # vertical line
340                                         if (xy.y < l1y) is (xy.y < l2y)
341                                                 continue
342                                         dx = l1x - xy.x
343                                         p = dx * dx
344                                 else if ldy is 0 # horizontal line
345                                         if (xy.x < l1x) is (xy.x < l2x)
346                                                 continue
347                                         dy = l1y - xy.y
348                                         p = dy * dy
349                                 else # slanty line
350                                         # https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
351                                         a = ldy
352                                         b = -1 * ldx
353                                         c = l2x * l1y - l2y * l1x
354                                         y_on_line = (a * (a * xy.y - b * xy.x) - b * c) / (a * a + b * b)
355                                         if (y_on_line < l1y) is (y_on_line < l2y)
356                                                 continue
357                                         p = (a * xy.x + b * xy.y + c) / Math.sqrt(a * a + b * b)
358                                         p *= p
359
360                                 if p < prox
361                                         prox = p
362                 return prox
363         resize: (wh) ->
364                 # FIXME (apply to more than just 2nd node)
365                 @nodes[1].x = wh.w
366                 @nodes[1].y = wh.h
367                 @width = wh.w
368                 @height = wh.h
369                 @el.setAttribute 'd', @my_path_d()
370                 @reposition_controls()
371                 return
372         node_dragger: (i) ->
373                 if i is 0
374                         return (dxy) =>
375                                 for i in [1...@nodes.length]
376                                         @nodes[i].x -= dxy.x
377                                         @nodes[i].y -= dxy.y
378                                 @move x: @x + dxy.x, y: @y + dxy.y
379                 return (dxy) =>
380                         @nodes[i].x += dxy.x
381                         @nodes[i].y += dxy.y
382                         @el.setAttribute 'd', @my_path_d()
383                         @reposition_controls()
384         reposition_controls: ->
385                 if @controls.length > 1
386                         positions = @control_positions()
387                         for i in [0...positions.length]
388                                 @controls[i].move x: positions[i].x, y: positions[i].y
389                 return
390         control_positions: ->
391                 ret = []
392                 for n in @nodes
393                         ret.push x: @x + n.x, y: @y + n.y
394                 return ret
395         make_controls: (args) -> # create controls, return them
396                 console.log 'make line controls'
397                 if @controls.length > 0
398                         if console?.log?
399                                 console.log "warning: re-adding controls"
400                         @kill_controls()
401                 positions = @control_positions()
402                 for i in [0...positions.length]
403                         @controls.push new ControlPath {
404                                 svg: @svg
405                                 x: positions[i].x
406                                 y: positions[i].y
407                                 done: args.done
408                                 drag: @node_dragger i
409                                 shape: shape_node_move
410                         }
411                 return @controls
412
413 # called automatically on domcontentloaded
414 init = ->
415         svg_offset = null
416         $container = $ '.crayon_mockup'
417         svg = json_to_svg svg: width: width, height: height, viewBox: "0 0 #{width} #{height}"
418         $svg = $ svg
419         $container.append $svg
420         svg.appendChild json_to_svg filter:
421                 id: 'crayon', filterUnits: 'userSpaceOnUse'
422                 x: '-5%', y: '-5%', height: '110%', width: '110%'
423                 children: [
424                         { feTurbulence: baseFrequency: '.3', numOctaves: '2', type: 'fractalNoise' }
425                         { feDisplacementMap: scale: '6', xChannelSelector: 'R', in: 'SourceGraphic' }
426                 ]
427         svg.appendChild json_to_svg style:
428                 type: 'text/css'
429                 contents: '.box.normal,.polyline.normal{filter: url(#crayon)}'
430
431         # create canvas border
432         svg.appendChild json_to_svg rect:
433                 x: 1
434                 y: supply_height + 1
435                 width: width - 2
436                 height: height - 2 - supply_height
437                 class: 'canvas_border'
438
439         supply = {}
440         supply_count = 0
441         supply_add = (type, args) ->
442                 args.x ?= 0
443                 args.y ?= 0
444                 args.x += 30 + supply_count * 90
445                 args.y += (supply_height - 50) / 2
446                 args.svg = svg
447                 w = new type args
448                 supply[w.id] = w
449                 supply_count += 1
450         supply_add RectWidget, width: 50, height: 50
451         supply_add PolylineWidget, y: 25, nodes: [{x: 0, y: 0}, {x: 50, y: 0}]
452         supply_add PolylineWidget, x: 25, nodes: [{x: 0, y: 0}, {x: 0, y: 50}]
453         supply_add PolylineWidget, x: 10, nodes: [{x: 0, y: 0}, {x: 15, y: 50}, {x: 30, y: 0}]
454         supply_add PolylineWidget, x: 0, nodes: [{x: 0, y: 50}, {x: 17, y: 0}, {x: 33, y: 50}, {x: 50, y: 0}]
455
456         # editor state
457         controls_layer = { all: {}, selected: {} }
458         widget_layer = { all: {}, selected: {}, editing: null }
459         layers = [controls_layer, widget_layer]
460         hovered = null # can be in any layer
461         dragging = false # mouse state
462         drag_layer = null
463         drag_from = x: 0, y: 0 # mouse was here at last frame of drag
464         shift_key_down = false
465
466         stop_editing = ->
467                 if widget_layer.editing
468                         widget_layer.editing.kill_controls()
469                         widget_layer.editing = null
470         deselect = (layer, s) ->
471                 return unless layer.selected[s.id]?
472                 s.set_state STATES.NORMAL
473                 delete layer.selected[s.id]
474                 if widget_layer.editing is s
475                         widget_layer.editing = null
476                 return
477         deselect_all = (layer, except = null) ->
478                 for id, s of layer.selected
479                         deselect layer, s
480                 return
481         _select = (layer, s) -> # don't call this directly, use select_only() or select_also()
482                 s.set_state STATES.SELECTED
483                 layer.selected[s.id] = s
484                 return
485         select_only = (layer, s) ->
486                 deselect_all layer, s
487                 return if layer.selected[s.id]?
488                 _select layer, s
489                 return
490         select_also = (layer, s) ->
491                 return if layer.selected[s.id]?
492                 if layer is widget_layer
493                         stop_editing()
494                 _select layer, s
495                 return
496         find_closest = (widgets, xy) ->
497                 prox = PROX_TOO_FAR
498                 closest = null
499                 for id, w of widgets
500                         new_prox = w.proximity xy
501                         if new_prox < prox
502                                 prox = new_prox
503                                 closest = w
504                 if prox > PROX_MAX
505                         return null
506                 return closest
507         svg_event_to_xy = (e) ->
508                 unless svg_offset?
509                         svg_offset = $svg.offset()
510                 return {
511                         x: Math.round(e.pageX - svg_offset.left)
512                         y: Math.round(e.pageY - svg_offset.top)
513                 }
514         closest_in_layers = (xy) ->
515                 for layer in layers
516                         s = find_closest layer.selected, xy
517                         return layer: layer, s: s if s?
518                         s = find_closest layer.all, xy
519                         return layer: layer, s: s if s?
520                 return null
521         mousedown = (e) ->
522                 hit = null
523                 closest = null
524                 layer = null
525                 mousemove e
526                 if dragging # two mousedowns in a row?! it happens
527                         return mouseup e
528                 xy = svg_event_to_xy e
529                 if xy.y < supply_height
530                         s = find_closest supply, xy
531                         if s?
532                                 hit = {
533                                         s: s.clone()
534                                         layer: widget_layer
535                                 }
536                                 widget_layer.all[hit.s.id] = hit.s
537                 else
538                         hit = closest_in_layers xy
539                 if hit?
540                         if hit.layer.selected[hit.s.id]
541                                 # already selected
542                                 # TODO start detection of a click that doesn't drag (to shrink selection)
543                         else if xy.y < supply_height
544                                 # dragging a new thing in
545                                 select_only hit.layer, hit.s
546                         else if shift_key_down
547                                 select_also hit.layer, hit.s
548                         else
549                                 select_only hit.layer, hit.s
550                         for id, s of hit.layer.selected
551                                 s.set_state STATES.DRAGGING
552                         dragging = true
553                         drag_layer = hit.layer
554                         drag_from = xy
555                 else
556                         deselect_all widget_layer
557                 return
558         mouseup = (e) ->
559                 mousemove e
560                 if dragging
561                         selected_count = 0
562                         for id, s of drag_layer.selected
563                                 if s.y < supply_height and drag_layer is widget_layer
564                                         deselect drag_layer, s
565                                         s.destruct()
566                                         delete drag_layer.all[id]
567                                 else
568                                         selected_count += 1
569                                         s.set_state STATES.SELECTED
570                         if drag_layer is widget_layer and selected_count is 1
571                                 for id, s of drag_layer.selected
572                                         s.set_state STATES.EDITING
573                                         cs = s.make_controls done: (c) ->
574                                                 deselect controls_layer, c
575                                                 delete controls_layer.all[c.id]
576                                         for c in cs
577                                                 controls_layer.all[c.id] = c
578                                         widget_layer.editing = s
579                 dragging = false
580                 return
581         mousemove = (e) ->
582                 xy = svg_event_to_xy e
583                 if dragging
584                         return if drag_from.x is xy.x and drag_from.y is xy.y
585                         rel_x = xy.x - drag_from.x
586                         rel_y = xy.y - drag_from.y
587                         drag_from = xy
588                         for id, w of drag_layer.selected
589                                 w.drag x: rel_x, y: rel_y
590                 else
591                         hit = closest_in_layers xy
592                         if hovered and hovered isnt hit?.s
593                                 hovered.set_hover false
594                         return unless hit?
595                         hovered = hit.s
596                         hovered.set_hover true
597                 return
598         $svg.mousedown (e) ->
599                 mousedown e
600                 return false
601         $svg.mouseup (e) ->
602                 mouseup e
603                 return false
604         $svg.mousemove (e) ->
605                 mousemove e
606                 return false
607         $(document).on 'keyup keydown', (e) ->
608                 shift_key_down = e.shiftKey
609                 return true
610         #($ document).keydown (e) ->
611
612 $ init