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